Skip to content

Commit d2e78ad

Browse files
committed
fix(provider): substitute space for empty text between reasoning blocks
When Anthropic adaptive thinking (Opus 4.6+) emits an empty text part between two reasoning blocks, the empty part is filtered out by multiple layers (normalizeMessages, AI SDK convertToLanguageModelPrompt), and Anthropic itself rejects empty text blocks. Dropping or preserving the empty text both lead to failures: - Dropping it shifts thinking block positions, invalidating signatures: 'thinking blocks in the latest assistant message cannot be modified' - Preserving it as '' fails validation: 'text content blocks must be non-empty' Substitute a single space when building assistant UIMessages with reasoning blocks present. The space preserves the structural arrangement (reasoning block positions) without triggering either rejection. Signatures are on the reasoning block content, not the text between them, so a non-empty placeholder keeps the arrangement valid. Closes #16748
1 parent 266e965 commit d2e78ad

2 files changed

Lines changed: 103 additions & 2 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -825,13 +825,22 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
825825
role: "assistant",
826826
parts: [],
827827
}
828+
// Empty text parts adjacent to signed reasoning blocks must be preserved
829+
// positionally to keep thinking block signatures valid. But Anthropic
830+
// rejects text content blocks that are empty, and the AI SDK strips them
831+
// too. Substitute a single space — the signatures are on the reasoning
832+
// block content, not the text between them, so a non-empty placeholder
833+
// keeps the structural arrangement without invalidating signatures.
834+
const hasReasoning = msg.parts.some((p) => p.type === "reasoning")
828835
for (const part of msg.parts) {
829-
if (part.type === "text")
836+
if (part.type === "text") {
837+
const text = part.text === "" && hasReasoning ? " " : part.text
830838
assistantMessage.parts.push({
831839
type: "text",
832-
text: part.text,
840+
text,
833841
...(differentModel ? {} : { providerMetadata: part.metadata }),
834842
})
843+
}
835844
if (part.type === "step-start")
836845
assistantMessage.parts.push({
837846
type: "step-start",

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,98 @@ describe("session.message-v2.toModelMessage", () => {
947947
},
948948
])
949949
})
950+
951+
test("substitutes space for empty text parts when reasoning blocks present", async () => {
952+
// An empty text part between two signed reasoning blocks must retain its
953+
// positional slot to keep thinking block signatures valid. But Anthropic
954+
// rejects text content blocks with empty text, and the AI SDK's
955+
// convertToLanguageModelPrompt also strips them. Substitute a space so the
956+
// part survives both filters and Anthropic accepts it. This test reproduces
957+
// the exact pattern from the bug: [step-start, reasoning(sig), text(""),
958+
// step-start, reasoning(sig), text(full)].
959+
const assistantID = "m-assistant"
960+
const input: MessageV2.WithParts[] = [
961+
{
962+
info: assistantInfo(assistantID, "m-parent"),
963+
parts: [
964+
{
965+
...basePart(assistantID, "p1"),
966+
type: "step-start",
967+
},
968+
{
969+
...basePart(assistantID, "p2"),
970+
type: "reasoning",
971+
text: "thinking-one",
972+
metadata: { anthropic: { signature: "sig1" } },
973+
},
974+
{
975+
...basePart(assistantID, "p3"),
976+
type: "text",
977+
text: "",
978+
},
979+
{
980+
...basePart(assistantID, "p4"),
981+
type: "step-start",
982+
},
983+
{
984+
...basePart(assistantID, "p5"),
985+
type: "reasoning",
986+
text: "thinking-two",
987+
metadata: { anthropic: { signature: "sig2" } },
988+
},
989+
{
990+
...basePart(assistantID, "p6"),
991+
type: "text",
992+
text: "the answer",
993+
},
994+
] as MessageV2.Part[],
995+
},
996+
]
997+
998+
const result = await MessageV2.toModelMessages(input, model)
999+
1000+
// step-start splits the message into two assistant messages
1001+
// The SDK's groupIntoBlocks later merges them when sending to Anthropic
1002+
expect(result).toHaveLength(2)
1003+
1004+
// First message: [reasoning(sig1), text(" ")] — space preserves the slot
1005+
const firstText = (result[0].content as any[]).find((p) => p.type === "text")
1006+
expect(firstText.text).toBe(" ")
1007+
1008+
// Second message: [reasoning(sig2), text("the answer")] — original text unchanged
1009+
const secondText = (result[1].content as any[]).find((p) => p.type === "text")
1010+
expect(secondText.text).toBe("the answer")
1011+
})
1012+
1013+
test("does not alter text parts in assistant messages without reasoning", async () => {
1014+
// The empty-to-space substitution only applies when reasoning is present.
1015+
// Assistant messages without reasoning should have empty text filtered
1016+
// later by normalizeMessages, not substituted here.
1017+
const assistantID = "m-assistant-no-reasoning"
1018+
const input: MessageV2.WithParts[] = [
1019+
{
1020+
info: assistantInfo(assistantID, "m-parent"),
1021+
parts: [
1022+
{
1023+
...basePart(assistantID, "p1"),
1024+
type: "text",
1025+
text: "",
1026+
},
1027+
{
1028+
...basePart(assistantID, "p2"),
1029+
type: "text",
1030+
text: "hello",
1031+
},
1032+
] as MessageV2.Part[],
1033+
},
1034+
]
1035+
1036+
const result = await MessageV2.toModelMessages(input, model)
1037+
1038+
expect(result).toHaveLength(1)
1039+
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
1040+
expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
1041+
})
9501042
})
9511043

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

0 commit comments

Comments
 (0)