Skip to content

Commit 3d85c39

Browse files
committed
fix: replace empty text in reasoning messages to preserve thinking block positions
normalizeMessages removes empty text parts, which shifts thinking block positions and invalidates signatures. Simple preservation does not work because the AI SDK has a second filter and the API rejects empty text. In assistant messages with signed reasoning, replace empty text with a placeholder instead of removing it. This preserves array positions through all filtering layers.
1 parent 150ab07 commit 3d85c39

2 files changed

Lines changed: 118 additions & 7 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,28 @@ function normalizeMessages(
6060
return msg
6161
}
6262
if (!Array.isArray(msg.content)) return msg
63-
const filtered = msg.content.filter((part) => {
64-
if (part.type === "text" || part.type === "reasoning") {
65-
return part.text !== ""
63+
const hasReasoning = msg.role === "assistant" && msg.content.some((p) => p.type === "reasoning" && p.providerOptions !== undefined)
64+
const filtered = msg.content
65+
.filter((part) => {
66+
if (part.type === "reasoning") {
67+
return part.text !== "" || part.providerOptions !== undefined
68+
}
69+
if (part.type === "text" && !hasReasoning) {
70+
return part.text !== ""
71+
}
72+
return true
73+
})
74+
.map((part) => {
75+
if (hasReasoning && part.type === "text" && part.text === "") {
76+
return { ...part, text: "..." } as typeof part
77+
}
78+
return part
79+
})
80+
if (filtered.length === 0) return undefined
81+
if (hasReasoning && filtered.length > 0 && filtered[filtered.length - 1].type === "reasoning") {
82+
filtered.push({ type: "text", text: "..." } as (typeof filtered)[number])
6683
}
67-
return true
68-
})
69-
if (filtered.length === 0) return undefined
70-
return { ...msg, content: filtered }
84+
return { ...msg, content: filtered }
7185
})
7286
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
7387
}

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,103 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
12121212
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
12131213
})
12141214

1215+
test("replaces empty text with placeholder in assistant messages with reasoning", () => {
1216+
const msgs = [
1217+
{
1218+
role: "assistant",
1219+
content: [
1220+
{ type: "reasoning", text: "thinking...", providerOptions: { anthropic: { signature: "sig_abc" } } },
1221+
{ type: "text", text: "" },
1222+
{ type: "reasoning", text: "more thinking", providerOptions: { anthropic: { signature: "sig_xyz" } } },
1223+
{ type: "text", text: "Answer" },
1224+
],
1225+
},
1226+
] as any[]
1227+
1228+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1229+
1230+
expect(result).toHaveLength(1)
1231+
expect(result[0].content).toHaveLength(4)
1232+
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "thinking...", providerOptions: { anthropic: { signature: "sig_abc" } } })
1233+
expect(result[0].content[1]).toEqual({ type: "text", text: "..." })
1234+
expect(result[0].content[2]).toEqual({ type: "reasoning", text: "more thinking", providerOptions: { anthropic: { signature: "sig_xyz" } } })
1235+
expect(result[0].content[3]).toEqual({ type: "text", text: "Answer" })
1236+
})
1237+
1238+
test("replaces empty text and appends fallback when only reasoning remains", () => {
1239+
const msgs = [
1240+
{
1241+
role: "assistant",
1242+
content: [
1243+
{ type: "reasoning", text: "thinking...", providerOptions: { anthropic: { signature: "sig_abc" } } },
1244+
{ type: "text", text: "" },
1245+
],
1246+
},
1247+
] as any[]
1248+
1249+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1250+
1251+
expect(result).toHaveLength(1)
1252+
expect(result[0].content).toHaveLength(2)
1253+
expect((result[0].content as any[])[0].type).toBe("reasoning")
1254+
expect(result[0].content[1]).toEqual({ type: "text", text: "..." })
1255+
})
1256+
1257+
test("appends fallback text when assistant has only reasoning with signature", () => {
1258+
const msgs = [
1259+
{
1260+
role: "assistant",
1261+
content: [
1262+
{ type: "reasoning", text: "deep thought", providerOptions: { anthropic: { signature: "sig_xyz" } } },
1263+
],
1264+
},
1265+
] as any[]
1266+
1267+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1268+
1269+
expect(result).toHaveLength(1)
1270+
expect(result[0].content).toHaveLength(2)
1271+
expect((result[0].content as any[])[0].type).toBe("reasoning")
1272+
expect(result[0].content[1]).toEqual({ type: "text", text: "..." })
1273+
})
1274+
1275+
test("does not replace text in assistant messages without reasoning", () => {
1276+
const msgs = [
1277+
{
1278+
role: "assistant",
1279+
content: [
1280+
{ type: "text", text: "" },
1281+
{ type: "text", text: "Hello" },
1282+
{ type: "text", text: "" },
1283+
],
1284+
},
1285+
] as any[]
1286+
1287+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1288+
1289+
expect(result).toHaveLength(1)
1290+
expect(result[0].content).toHaveLength(1)
1291+
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
1292+
})
1293+
1294+
test("does not replace empty text in user messages with reasoning", () => {
1295+
const msgs = [
1296+
{
1297+
role: "user",
1298+
content: [
1299+
{ type: "reasoning", text: "user reasoning", providerOptions: { anthropic: { signature: "sig_abc" } } },
1300+
{ type: "text", text: "" },
1301+
],
1302+
},
1303+
] as any[]
1304+
1305+
const result = ProviderTransform.message(msgs, anthropicModel, {})
1306+
1307+
expect(result).toHaveLength(1)
1308+
expect(result[0].content).toHaveLength(1)
1309+
expect((result[0].content as any[])[0].type).toBe("reasoning")
1310+
})
1311+
12151312
test("filters empty content for bedrock provider", () => {
12161313
const bedrockModel = {
12171314
...anthropicModel,

0 commit comments

Comments
 (0)