Skip to content

Commit bb79d93

Browse files
edevilomer-koren
authored andcommitted
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 anomalyco#16748
1 parent 45665f7 commit bb79d93

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
@@ -859,13 +859,21 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
859859
role: "assistant",
860860
parts: [],
861861
}
862+
// Substitute a space for empty text between signed Anthropic reasoning
863+
// blocks to keep thinking block positions (and signatures) valid without
864+
// triggering Anthropic's empty-content rejection or the AI SDK's filter.
865+
const hasSignedReasoning = msg.parts.some(
866+
(p) => p.type === "reasoning" && (p.metadata as any)?.anthropic?.signature != null,
867+
)
862868
for (const part of msg.parts) {
863-
if (part.type === "text")
869+
if (part.type === "text") {
870+
const text = part.text === "" && hasSignedReasoning ? " " : part.text
864871
assistantMessage.parts.push({
865872
type: "text",
866-
text: part.text,
873+
text,
867874
...(differentProvider ? {} : { providerMetadata: part.metadata }),
868875
})
876+
}
869877
if (part.type === "step-start")
870878
assistantMessage.parts.push({
871879
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
@@ -1302,6 +1302,82 @@ describe("session.message-v2.toModelMessage", () => {
13021302
},
13031303
])
13041304
})
1305+
1306+
test("substitutes space for empty text between signed reasoning blocks", async () => {
1307+
// Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
1308+
const assistantID = "m-assistant"
1309+
const input: MessageV2.WithParts[] = [
1310+
{
1311+
info: assistantInfo(assistantID, "m-parent"),
1312+
parts: [
1313+
{ ...basePart(assistantID, "p1"), type: "step-start" },
1314+
{
1315+
...basePart(assistantID, "p2"),
1316+
type: "reasoning",
1317+
text: "thinking-one",
1318+
metadata: { anthropic: { signature: "sig1" } },
1319+
},
1320+
{ ...basePart(assistantID, "p3"), type: "text", text: "" },
1321+
{ ...basePart(assistantID, "p4"), type: "step-start" },
1322+
{
1323+
...basePart(assistantID, "p5"),
1324+
type: "reasoning",
1325+
text: "thinking-two",
1326+
metadata: { anthropic: { signature: "sig2" } },
1327+
},
1328+
{ ...basePart(assistantID, "p6"), type: "text", text: "the answer" },
1329+
] as MessageV2.Part[],
1330+
},
1331+
]
1332+
1333+
const result = await MessageV2.toModelMessages(input, model)
1334+
1335+
// step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later
1336+
expect(result).toHaveLength(2)
1337+
expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
1338+
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
1339+
})
1340+
1341+
test("leaves empty text alone when reasoning has no Anthropic signature", async () => {
1342+
// Non-Anthropic providers' reasoning doesn't position-validate, so empty text
1343+
// should be filtered normally rather than substituted.
1344+
const assistantID = "m-assistant-unsigned"
1345+
const input: MessageV2.WithParts[] = [
1346+
{
1347+
info: assistantInfo(assistantID, "m-parent"),
1348+
parts: [
1349+
{ ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" },
1350+
{ ...basePart(assistantID, "p2"), type: "text", text: "" },
1351+
{ ...basePart(assistantID, "p3"), type: "text", text: "answer" },
1352+
] as MessageV2.Part[],
1353+
},
1354+
]
1355+
1356+
const result = await MessageV2.toModelMessages(input, model)
1357+
1358+
expect(result).toHaveLength(1)
1359+
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
1360+
expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
1361+
})
1362+
1363+
test("leaves empty text alone in assistant messages without reasoning", async () => {
1364+
const assistantID = "m-assistant-no-reasoning"
1365+
const input: MessageV2.WithParts[] = [
1366+
{
1367+
info: assistantInfo(assistantID, "m-parent"),
1368+
parts: [
1369+
{ ...basePart(assistantID, "p1"), type: "text", text: "" },
1370+
{ ...basePart(assistantID, "p2"), type: "text", text: "hello" },
1371+
] as MessageV2.Part[],
1372+
},
1373+
]
1374+
1375+
const result = await MessageV2.toModelMessages(input, model)
1376+
1377+
expect(result).toHaveLength(1)
1378+
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
1379+
expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
1380+
})
13051381
})
13061382

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

0 commit comments

Comments
 (0)