Skip to content

Commit ed9eb70

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 16ddf5f commit ed9eb70

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
@@ -854,13 +854,21 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
854854
role: "assistant",
855855
parts: [],
856856
}
857+
// Substitute a space for empty text between signed Anthropic reasoning
858+
// blocks to keep thinking block positions (and signatures) valid without
859+
// triggering Anthropic's empty-content rejection or the AI SDK's filter.
860+
const hasSignedReasoning = msg.parts.some(
861+
(p) => p.type === "reasoning" && (p.metadata as any)?.anthropic?.signature != null,
862+
)
857863
for (const part of msg.parts) {
858-
if (part.type === "text")
864+
if (part.type === "text") {
865+
const text = part.text === "" && hasSignedReasoning ? " " : part.text
859866
assistantMessage.parts.push({
860867
type: "text",
861-
text: part.text,
868+
text,
862869
...(differentModel ? {} : { providerMetadata: part.metadata }),
863870
})
871+
}
864872
if (part.type === "step-start")
865873
assistantMessage.parts.push({
866874
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
@@ -1090,6 +1090,82 @@ describe("session.message-v2.toModelMessage", () => {
10901090
},
10911091
])
10921092
})
1093+
1094+
test("substitutes space for empty text between signed reasoning blocks", async () => {
1095+
// Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
1096+
const assistantID = "m-assistant"
1097+
const input: MessageV2.WithParts[] = [
1098+
{
1099+
info: assistantInfo(assistantID, "m-parent"),
1100+
parts: [
1101+
{ ...basePart(assistantID, "p1"), type: "step-start" },
1102+
{
1103+
...basePart(assistantID, "p2"),
1104+
type: "reasoning",
1105+
text: "thinking-one",
1106+
metadata: { anthropic: { signature: "sig1" } },
1107+
},
1108+
{ ...basePart(assistantID, "p3"), type: "text", text: "" },
1109+
{ ...basePart(assistantID, "p4"), type: "step-start" },
1110+
{
1111+
...basePart(assistantID, "p5"),
1112+
type: "reasoning",
1113+
text: "thinking-two",
1114+
metadata: { anthropic: { signature: "sig2" } },
1115+
},
1116+
{ ...basePart(assistantID, "p6"), type: "text", text: "the answer" },
1117+
] as MessageV2.Part[],
1118+
},
1119+
]
1120+
1121+
const result = await MessageV2.toModelMessages(input, model)
1122+
1123+
// step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later
1124+
expect(result).toHaveLength(2)
1125+
expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
1126+
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
1127+
})
1128+
1129+
test("leaves empty text alone when reasoning has no Anthropic signature", async () => {
1130+
// Non-Anthropic providers' reasoning doesn't position-validate, so empty text
1131+
// should be filtered normally rather than substituted.
1132+
const assistantID = "m-assistant-unsigned"
1133+
const input: MessageV2.WithParts[] = [
1134+
{
1135+
info: assistantInfo(assistantID, "m-parent"),
1136+
parts: [
1137+
{ ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" },
1138+
{ ...basePart(assistantID, "p2"), type: "text", text: "" },
1139+
{ ...basePart(assistantID, "p3"), type: "text", text: "answer" },
1140+
] as MessageV2.Part[],
1141+
},
1142+
]
1143+
1144+
const result = await MessageV2.toModelMessages(input, model)
1145+
1146+
expect(result).toHaveLength(1)
1147+
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
1148+
expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
1149+
})
1150+
1151+
test("leaves empty text alone in assistant messages without reasoning", async () => {
1152+
const assistantID = "m-assistant-no-reasoning"
1153+
const input: MessageV2.WithParts[] = [
1154+
{
1155+
info: assistantInfo(assistantID, "m-parent"),
1156+
parts: [
1157+
{ ...basePart(assistantID, "p1"), type: "text", text: "" },
1158+
{ ...basePart(assistantID, "p2"), type: "text", text: "hello" },
1159+
] as MessageV2.Part[],
1160+
},
1161+
]
1162+
1163+
const result = await MessageV2.toModelMessages(input, model)
1164+
1165+
expect(result).toHaveLength(1)
1166+
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
1167+
expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
1168+
})
10931169
})
10941170

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

0 commit comments

Comments
 (0)