Skip to content

Commit ecbe9e9

Browse files
committed
fix(provider): drop empty content messages after interleaved reasoning filter
The interleaved reasoning filter strips reasoning parts from assistant messages and moves them to providerOptions. When an assistant message contains only reasoning parts, this leaves content: [], which Bedrock's ConverseAPI rejects with a validation error. The session is permanently broken after this. Fixes #17705
1 parent c529529 commit ecbe9e9

File tree

2 files changed

+163
-1
lines changed

2 files changed

+163
-1
lines changed

packages/opencode/src/provider/transform.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ export namespace ProviderTransform {
143143
// Filter out reasoning parts from content
144144
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
145145

146+
if (filteredContent.length === 0) return undefined
147+
146148
// Include reasoning_content | reasoning_details directly on the message for all assistant messages
147149
if (reasoningText) {
148150
return {
@@ -165,7 +167,7 @@ export namespace ProviderTransform {
165167
}
166168

167169
return msg
168-
})
170+
}).filter((msg): msg is ModelMessage => msg !== undefined)
169171
}
170172

171173
return msgs
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { ProviderTransform } from "../../src/provider/transform"
3+
4+
// Model with interleaved reasoning (object form with field property)
5+
const interleavedBedrockModel = {
6+
id: "amazon-bedrock/zai.glm-4.7",
7+
providerID: "amazon-bedrock",
8+
api: {
9+
id: "zai.glm-4.7",
10+
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
11+
npm: "@ai-sdk/amazon-bedrock",
12+
},
13+
name: "GLM 4.7",
14+
capabilities: {
15+
temperature: true,
16+
reasoning: true,
17+
attachment: false,
18+
toolcall: true,
19+
input: { text: true, audio: false, image: false, video: false, pdf: false },
20+
output: { text: true, audio: false, image: false, video: false, pdf: false },
21+
interleaved: { field: "reasoning_content" },
22+
},
23+
cost: { input: 0.001, output: 0.002, cache: { read: 0, write: 0 } },
24+
limit: { context: 128000, output: 8192 },
25+
status: "active",
26+
options: {},
27+
headers: {},
28+
release_date: "2025-01-01",
29+
} as any
30+
31+
const interleavedOpenAICompatModel = {
32+
...interleavedBedrockModel,
33+
id: "custom/kimi-k2.5",
34+
providerID: "custom",
35+
api: {
36+
id: "kimi-k2.5",
37+
url: "https://custom-endpoint.example.com",
38+
npm: "@ai-sdk/openai-compatible",
39+
},
40+
name: "Kimi K2.5",
41+
} as any
42+
43+
describe("interleaved reasoning - empty content safety net", () => {
44+
test("drops assistant message when only reasoning parts are stripped by interleaved filter", () => {
45+
const msgs = [
46+
{ role: "user", content: "hello" },
47+
{
48+
role: "assistant",
49+
content: [{ type: "reasoning", text: "Let me think about this..." }],
50+
},
51+
{ role: "user", content: "go on" },
52+
] as any[]
53+
54+
const result = ProviderTransform.message(msgs, interleavedBedrockModel, {})
55+
56+
expect(result).toHaveLength(2)
57+
expect(result[0]).toEqual({ role: "user", content: "hello" })
58+
expect(result[1]).toEqual({ role: "user", content: "go on" })
59+
})
60+
61+
test("drops assistant message when multiple reasoning-only parts are stripped", () => {
62+
const msgs = [
63+
{ role: "user", content: "analyze this code" },
64+
{
65+
role: "assistant",
66+
content: [
67+
{ type: "reasoning", text: "First, let me look at the structure..." },
68+
{ type: "reasoning", text: "I see several patterns here..." },
69+
],
70+
},
71+
{ role: "user", content: "what did you find?" },
72+
] as any[]
73+
74+
const result = ProviderTransform.message(msgs, interleavedBedrockModel, {})
75+
76+
expect(result).toHaveLength(2)
77+
expect(result[0].content).toBe("analyze this code")
78+
expect(result[1].content).toBe("what did you find?")
79+
})
80+
81+
test("drops reasoning-only assistant message for openai-compatible provider", () => {
82+
const msgs = [
83+
{ role: "user", content: "hello" },
84+
{
85+
role: "assistant",
86+
content: [{ type: "reasoning", text: "Thinking..." }],
87+
},
88+
{ role: "user", content: "continue" },
89+
] as any[]
90+
91+
const result = ProviderTransform.message(msgs, interleavedOpenAICompatModel, {})
92+
93+
expect(result).toHaveLength(2)
94+
expect(result.every((m) => m.role === "user")).toBe(true)
95+
})
96+
97+
test("keeps assistant message when reasoning + text content remains after filter", () => {
98+
const msgs = [
99+
{
100+
role: "assistant",
101+
content: [
102+
{ type: "reasoning", text: "Let me think..." },
103+
{ type: "text", text: "Here is my answer." },
104+
],
105+
},
106+
] as any[]
107+
108+
const result = ProviderTransform.message(msgs, interleavedBedrockModel, {})
109+
110+
expect(result).toHaveLength(1)
111+
expect(result[0].content).toEqual([{ type: "text", text: "Here is my answer." }])
112+
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think...")
113+
})
114+
115+
test("keeps assistant message when reasoning + tool-call content remains after filter", () => {
116+
const msgs = [
117+
{
118+
role: "assistant",
119+
content: [
120+
{ type: "reasoning", text: "I should read this file..." },
121+
{ type: "tool-call", toolCallId: "tc_1", toolName: "read", input: { path: "/foo" } },
122+
],
123+
},
124+
] as any[]
125+
126+
const result = ProviderTransform.message(msgs, interleavedBedrockModel, {})
127+
128+
expect(result).toHaveLength(1)
129+
expect(result[0].content).toHaveLength(1)
130+
expect(result[0].content[0]).toEqual({
131+
type: "tool-call",
132+
toolCallId: "tc_1",
133+
toolName: "read",
134+
input: { path: "/foo" },
135+
})
136+
})
137+
138+
test("does not affect non-interleaved models", () => {
139+
const nonInterleavedModel = {
140+
...interleavedBedrockModel,
141+
capabilities: {
142+
...interleavedBedrockModel.capabilities,
143+
interleaved: true,
144+
},
145+
} as any
146+
147+
const msgs = [
148+
{
149+
role: "assistant",
150+
content: [{ type: "reasoning", text: "Let me think..." }],
151+
},
152+
] as any[]
153+
154+
const result = ProviderTransform.message(msgs, nonInterleavedModel, {})
155+
156+
expect(result).toHaveLength(1)
157+
expect(result[0].content).toHaveLength(1)
158+
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Let me think..." })
159+
})
160+
})

0 commit comments

Comments
 (0)