Skip to content

Commit bcbf808

Browse files
committed
fix: replace empty text in reasoning messages to preserve 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 e919db8 commit bcbf808

File tree

2 files changed

+117
-6
lines changed

2 files changed

+117
-6
lines changed

packages/opencode/src/provider/transform.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,27 @@ export namespace ProviderTransform {
6161
return msg
6262
}
6363
if (!Array.isArray(msg.content)) return msg
64-
const filtered = msg.content.filter((part) => {
65-
if (part.type === "text" || part.type === "reasoning") {
66-
return part.text !== ""
67-
}
68-
return true
69-
})
64+
const hasReasoning = msg.role === "assistant" && msg.content.some((p) => p.type === "reasoning" && p.providerOptions !== undefined)
65+
const filtered = msg.content
66+
.filter((part) => {
67+
if (part.type === "reasoning") {
68+
return part.text !== "" || part.providerOptions !== undefined
69+
}
70+
if (part.type === "text" && !hasReasoning) {
71+
return part.text !== ""
72+
}
73+
return true
74+
})
75+
.map((part) => {
76+
if (hasReasoning && part.type === "text" && part.text === "") {
77+
return { ...part, text: "..." } as typeof part
78+
}
79+
return part
80+
})
7081
if (filtered.length === 0) return undefined
82+
if (hasReasoning && filtered.length > 0 && filtered[filtered.length - 1].type === "reasoning") {
83+
filtered.push({ type: "text", text: "..." } as (typeof filtered)[number])
84+
}
7185
return { ...msg, content: filtered }
7286
})
7387
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")

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

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

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

0 commit comments

Comments
 (0)