Skip to content

Commit b678059

Browse files
committed
fix(provider): preserve assistant message content when reasoning blocks present
normalizeMessages() unconditionally filtered empty text parts from all message roles, including assistant. When Anthropic adaptive thinking (Opus 4.6+) emits an empty text part between two reasoning blocks, removing it shifts thinking block positions and invalidates the cryptographic signatures, causing the API to reject with: 'thinking blocks in the latest assistant message cannot be modified' But Anthropic also rejects empty text content blocks with 'text content blocks must be non-empty', and the AI SDK's convertToLanguageModelPrompt strips them, so the fix requires two layers: 1. transform.ts: normalizeMessages() skips the empty-text filter for assistant messages that contain reasoning blocks. Messages without reasoning continue to be filtered normally. This is a defensive guard that handles raw ModelMessage[] scenarios. 2. message-v2.ts: when building UIMessage parts, empty text parts in assistant messages that contain reasoning blocks are replaced with a single space. The space preserves the structural arrangement (reasoning block positions) without triggering Anthropic's empty content rejection or the SDK's filter. 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 b678059

4 files changed

Lines changed: 169 additions & 12 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ function normalizeMessages(
5151
_options: Record<string, unknown>,
5252
): ModelMessage[] {
5353
// Anthropic rejects messages with empty content - filter out empty string messages
54-
// and remove empty text/reasoning parts from array content
54+
// and remove empty text/reasoning parts from array content.
55+
// Assistant messages with reasoning blocks are excluded from filtering because
56+
// thinking block signatures encode positional context — removing an empty text
57+
// part between reasoning blocks invalidates the signatures.
5558
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
5659
msgs = msgs
5760
.map((msg) => {
@@ -60,6 +63,7 @@ function normalizeMessages(
6063
return msg
6164
}
6265
if (!Array.isArray(msg.content)) return msg
66+
if (msg.role === "assistant" && msg.content.some((part) => part.type === "reasoning")) return msg
6367
const filtered = msg.content.filter((part) => {
6468
if (part.type === "text" || part.type === "reasoning") {
6569
return part.text !== ""

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -825,13 +825,23 @@ 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+
// See transform.ts normalizeMessages for the matching defensive guard.
835+
const hasReasoning = msg.parts.some((p) => p.type === "reasoning")
828836
for (const part of msg.parts) {
829-
if (part.type === "text")
837+
if (part.type === "text") {
838+
const text = part.text === "" && hasReasoning ? " " : part.text
830839
assistantMessage.parts.push({
831840
type: "text",
832-
text: part.text,
841+
text,
833842
...(differentModel ? {} : { providerMetadata: part.metadata }),
834843
})
844+
}
835845
if (part.type === "step-start")
836846
assistantMessage.parts.push({
837847
type: "step-start",

packages/opencode/test/provider/transform.test.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
11481148
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
11491149
})
11501150

1151-
test("filters out empty reasoning parts from array content", () => {
1151+
test("preserves assistant message verbatim when reasoning blocks present", () => {
11521152
const msgs = [
11531153
{
11541154
role: "assistant",
@@ -1162,12 +1162,16 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
11621162

11631163
const result = ProviderTransform.message(msgs, anthropicModel, {})
11641164

1165+
// Assistant messages with reasoning blocks are preserved verbatim
1166+
// because thinking block signatures encode positional context
11651167
expect(result).toHaveLength(1)
1166-
expect(result[0].content).toHaveLength(1)
1167-
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
1168+
expect(result[0].content).toHaveLength(3)
1169+
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "" })
1170+
expect(result[0].content[1]).toEqual({ type: "text", text: "Answer" })
1171+
expect(result[0].content[2]).toEqual({ type: "reasoning", text: "" })
11681172
})
11691173

1170-
test("removes entire message when all parts are empty", () => {
1174+
test("preserves assistant message with all-empty parts when reasoning present", () => {
11711175
const msgs = [
11721176
{ role: "user", content: "Hello" },
11731177
{
@@ -1182,9 +1186,54 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
11821186

11831187
const result = ProviderTransform.message(msgs, anthropicModel, {})
11841188

1185-
expect(result).toHaveLength(2)
1189+
// Preserved because reasoning blocks are present
1190+
expect(result).toHaveLength(3)
11861191
expect(result[0].content).toBe("Hello")
1187-
expect(result[1].content).toBe("World")
1192+
expect(result[1].content).toHaveLength(2)
1193+
expect(result[2].content).toBe("World")
1194+
})
1195+
1196+
test("preserves empty text between reasoning blocks in assistant messages", () => {
1197+
// Anthropic adaptive thinking (Opus 4.6+) emits:
1198+
// reasoning(sig1) → text("") → reasoning(sig2) → text("answer") → tool_use
1199+
// The empty text is positionally significant — thinking block signatures
1200+
// encode block arrangement. Removing it invalidates signatures.
1201+
const msgs = [
1202+
{
1203+
role: "assistant",
1204+
content: [
1205+
{
1206+
type: "reasoning",
1207+
text: "analyzing...",
1208+
providerOptions: { anthropic: { signature: "sig1" } },
1209+
},
1210+
{ type: "text", text: "" },
1211+
{
1212+
type: "reasoning",
1213+
text: "checking auth...",
1214+
providerOptions: { anthropic: { signature: "sig2" } },
1215+
},
1216+
{ type: "text", text: "Let me check." },
1217+
{
1218+
type: "tool-call",
1219+
toolCallId: "toolu_01ABC",
1220+
toolName: "grep",
1221+
input: { pattern: "endpoint" },
1222+
},
1223+
],
1224+
},
1225+
] as any[]
1226+
1227+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1228+
1229+
expect(result).toHaveLength(1)
1230+
const parts = result[0].content as any[]
1231+
expect(parts).toHaveLength(5)
1232+
expect(parts[0].type).toBe("reasoning")
1233+
expect(parts[1]).toEqual({ type: "text", text: "" })
1234+
expect(parts[2].type).toBe("reasoning")
1235+
expect(parts[3].text).toBe("Let me check.")
1236+
expect(parts[4].type).toBe("tool-call")
11881237
})
11891238

11901239
test("keeps non-text/reasoning parts even if text parts are empty", () => {
@@ -1210,7 +1259,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
12101259
})
12111260
})
12121261

1213-
test("keeps messages with valid text alongside empty parts", () => {
1262+
test("preserves all parts in assistant message with reasoning alongside empty text", () => {
12141263
const msgs = [
12151264
{
12161265
role: "assistant",
@@ -1224,10 +1273,12 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
12241273

12251274
const result = ProviderTransform.message(msgs, anthropicModel, {})
12261275

1276+
// All 3 parts preserved — reasoning present means no filtering
12271277
expect(result).toHaveLength(1)
1228-
expect(result[0].content).toHaveLength(2)
1278+
expect(result[0].content).toHaveLength(3)
12291279
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." })
1230-
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
1280+
expect(result[0].content[1]).toEqual({ type: "text", text: "" })
1281+
expect(result[0].content[2]).toEqual({ type: "text", text: "Result" })
12311282
})
12321283

12331284
test("filters empty content for bedrock provider", () => {

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)