Skip to content

Commit a1577be

Browse files
rekram1-nodeopencode-agent[bot]
authored andcommitted
fix: ensure tool_use is always followed by tool_result (anomalyco#22646)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
1 parent 1b1267f commit a1577be

3 files changed

Lines changed: 394 additions & 2 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export namespace ProviderTransform {
7575

7676
if (model.api.id.includes("claude")) {
7777
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
78-
return msgs.map((msg) => {
78+
msgs = msgs.map((msg) => {
7979
if (msg.role === "assistant" && Array.isArray(msg.content)) {
8080
return {
8181
...msg,
@@ -101,6 +101,31 @@ export namespace ProviderTransform {
101101
return msg
102102
})
103103
}
104+
if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
105+
// Anthropic rejects assistant turns where tool_use blocks are followed by non-tool
106+
// content, e.g. [tool_use, tool_use, text], with:
107+
// `tool_use` ids were found without `tool_result` blocks immediately after...
108+
//
109+
// Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive
110+
// assistant messages are later merged by the provider/SDK, so preserving the
111+
// original [tool_use...] then [text] order still produces the invalid payload.
112+
//
113+
// The root cause appears to be somewhere upstream where the stream is originally
114+
// processed. We were unable to locate an exact narrower reproduction elsewhere,
115+
// so we keep this transform in place for the time being.
116+
msgs = msgs.flatMap((msg) => {
117+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]
118+
119+
const parts = msg.content
120+
const first = parts.findIndex((part) => part.type === "tool-call")
121+
if (first === -1) return [msg]
122+
if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]
123+
return [
124+
{ ...msg, content: parts.filter((part) => part.type !== "tool-call") },
125+
{ ...msg, content: parts.filter((part) => part.type === "tool-call") },
126+
]
127+
})
128+
}
104129
if (
105130
model.providerID === "mistral" ||
106131
model.api.id.toLowerCase().includes("mistral") ||

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,110 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
12191219
expect(result[0].content).toBe("")
12201220
expect(result[1].content).toHaveLength(1)
12211221
})
1222+
1223+
test("splits anthropic assistant messages when text trails tool calls", () => {
1224+
const msgs = [
1225+
{
1226+
role: "user",
1227+
content: [{ type: "text", text: "Check my home directory for PDFs" }],
1228+
},
1229+
{
1230+
role: "assistant",
1231+
content: [
1232+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1233+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1234+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1235+
],
1236+
},
1237+
{
1238+
role: "tool",
1239+
content: [
1240+
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
1241+
{
1242+
type: "tool-result",
1243+
toolCallId: "toolu_2",
1244+
toolName: "glob",
1245+
output: { type: "text", value: "No files found" },
1246+
},
1247+
],
1248+
},
1249+
] as any[]
1250+
1251+
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
1252+
1253+
expect(result).toHaveLength(4)
1254+
expect(result[1]).toMatchObject({
1255+
role: "assistant",
1256+
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
1257+
})
1258+
expect(result[2]).toMatchObject({
1259+
role: "assistant",
1260+
content: [
1261+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1262+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1263+
],
1264+
})
1265+
})
1266+
1267+
test("leaves valid anthropic assistant tool ordering unchanged", () => {
1268+
const msgs = [
1269+
{
1270+
role: "assistant",
1271+
content: [
1272+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1273+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1274+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1275+
],
1276+
},
1277+
] as any[]
1278+
1279+
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
1280+
1281+
expect(result).toHaveLength(1)
1282+
expect(result[0].content).toMatchObject([
1283+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1284+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1285+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1286+
])
1287+
})
1288+
1289+
test("splits vertex anthropic assistant messages when text trails tool calls", () => {
1290+
const model = {
1291+
...anthropicModel,
1292+
providerID: "google-vertex-anthropic",
1293+
api: {
1294+
id: "claude-sonnet-4@20250514",
1295+
url: "https://us-central1-aiplatform.googleapis.com",
1296+
npm: "@ai-sdk/google-vertex/anthropic",
1297+
},
1298+
}
1299+
1300+
const msgs = [
1301+
{
1302+
role: "assistant",
1303+
content: [
1304+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1305+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1306+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1307+
],
1308+
},
1309+
] as any[]
1310+
1311+
const result = ProviderTransform.message(msgs, model, {}) as any[]
1312+
1313+
expect(result).toHaveLength(2)
1314+
expect(result[0]).toMatchObject({
1315+
role: "assistant",
1316+
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
1317+
})
1318+
expect(result[1]).toMatchObject({
1319+
role: "assistant",
1320+
content: [
1321+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1322+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1323+
],
1324+
})
1325+
})
12221326
})
12231327

12241328
describe("ProviderTransform.message - strip openai metadata when store=false", () => {

0 commit comments

Comments
 (0)