Skip to content

Commit 517afe4

Browse files
committed
fix(provider): substitute space for empty text between signed reasoning blocks
Anthropic adaptive thinking (Opus 4.6+) emits empty text parts between thinking blocks. Dropping them invalidates thinking block signatures ('thinking blocks cannot be modified'), but sending them as '' fails validation ('text content blocks must be non-empty'). Substitute a single space so the part survives all filters and Anthropic accepts it. Gated on reasoning having an Anthropic signature (metadata.anthropic. signature != null) so other providers' reasoning — which don't position-validate — continue to have empty text filtered normally. Closes #16748
1 parent 266e965 commit 517afe4

2 files changed

Lines changed: 86 additions & 2 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -825,13 +825,21 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
825825
role: "assistant",
826826
parts: [],
827827
}
828+
// Substitute a space for empty text between signed Anthropic reasoning
829+
// blocks to keep thinking block positions (and signatures) valid without
830+
// triggering Anthropic's empty-content rejection or the AI SDK's filter.
831+
const hasSignedReasoning = msg.parts.some(
832+
(p) => p.type === "reasoning" && (p.metadata as any)?.anthropic?.signature != null,
833+
)
828834
for (const part of msg.parts) {
829-
if (part.type === "text")
835+
if (part.type === "text") {
836+
const text = part.text === "" && hasSignedReasoning ? " " : part.text
830837
assistantMessage.parts.push({
831838
type: "text",
832-
text: part.text,
839+
text,
833840
...(differentModel ? {} : { providerMetadata: part.metadata }),
834841
})
842+
}
835843
if (part.type === "step-start")
836844
assistantMessage.parts.push({
837845
type: "step-start",

packages/opencode/test/session/message-v2.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,82 @@ describe("session.message-v2.toModelMessage", () => {
947947
},
948948
])
949949
})
950+
951+
test("substitutes space for empty text between signed reasoning blocks", async () => {
952+
// Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
953+
const assistantID = "m-assistant"
954+
const input: MessageV2.WithParts[] = [
955+
{
956+
info: assistantInfo(assistantID, "m-parent"),
957+
parts: [
958+
{ ...basePart(assistantID, "p1"), type: "step-start" },
959+
{
960+
...basePart(assistantID, "p2"),
961+
type: "reasoning",
962+
text: "thinking-one",
963+
metadata: { anthropic: { signature: "sig1" } },
964+
},
965+
{ ...basePart(assistantID, "p3"), type: "text", text: "" },
966+
{ ...basePart(assistantID, "p4"), type: "step-start" },
967+
{
968+
...basePart(assistantID, "p5"),
969+
type: "reasoning",
970+
text: "thinking-two",
971+
metadata: { anthropic: { signature: "sig2" } },
972+
},
973+
{ ...basePart(assistantID, "p6"), type: "text", text: "the answer" },
974+
] as MessageV2.Part[],
975+
},
976+
]
977+
978+
const result = await MessageV2.toModelMessages(input, model)
979+
980+
// step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later
981+
expect(result).toHaveLength(2)
982+
expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
983+
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
984+
})
985+
986+
test("leaves empty text alone when reasoning has no Anthropic signature", async () => {
987+
// Non-Anthropic providers' reasoning doesn't position-validate, so empty text
988+
// should be filtered normally rather than substituted.
989+
const assistantID = "m-assistant-unsigned"
990+
const input: MessageV2.WithParts[] = [
991+
{
992+
info: assistantInfo(assistantID, "m-parent"),
993+
parts: [
994+
{ ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" },
995+
{ ...basePart(assistantID, "p2"), type: "text", text: "" },
996+
{ ...basePart(assistantID, "p3"), type: "text", text: "answer" },
997+
] as MessageV2.Part[],
998+
},
999+
]
1000+
1001+
const result = await MessageV2.toModelMessages(input, model)
1002+
1003+
expect(result).toHaveLength(1)
1004+
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
1005+
expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
1006+
})
1007+
1008+
test("leaves empty text alone in assistant messages without reasoning", async () => {
1009+
const assistantID = "m-assistant-no-reasoning"
1010+
const input: MessageV2.WithParts[] = [
1011+
{
1012+
info: assistantInfo(assistantID, "m-parent"),
1013+
parts: [
1014+
{ ...basePart(assistantID, "p1"), type: "text", text: "" },
1015+
{ ...basePart(assistantID, "p2"), type: "text", text: "hello" },
1016+
] as MessageV2.Part[],
1017+
},
1018+
]
1019+
1020+
const result = await MessageV2.toModelMessages(input, model)
1021+
1022+
expect(result).toHaveLength(1)
1023+
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
1024+
expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
1025+
})
9501026
})
9511027

9521028
describe("session.message-v2.fromError", () => {

0 commit comments

Comments
 (0)