Skip to content

Commit 348a849

Browse files
fix: ensure tool_use is always followed by tool_result (#22646)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
1 parent 8ba4799 commit 348a849

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
@@ -1271,6 +1271,110 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
12711271
expect(result[0].content).toBe("")
12721272
expect(result[1].content).toHaveLength(1)
12731273
})
1274+
1275+
test("splits anthropic assistant messages when text trails tool calls", () => {
1276+
const msgs = [
1277+
{
1278+
role: "user",
1279+
content: [{ type: "text", text: "Check my home directory for PDFs" }],
1280+
},
1281+
{
1282+
role: "assistant",
1283+
content: [
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+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1287+
],
1288+
},
1289+
{
1290+
role: "tool",
1291+
content: [
1292+
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
1293+
{
1294+
type: "tool-result",
1295+
toolCallId: "toolu_2",
1296+
toolName: "glob",
1297+
output: { type: "text", value: "No files found" },
1298+
},
1299+
],
1300+
},
1301+
] as any[]
1302+
1303+
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
1304+
1305+
expect(result).toHaveLength(4)
1306+
expect(result[1]).toMatchObject({
1307+
role: "assistant",
1308+
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
1309+
})
1310+
expect(result[2]).toMatchObject({
1311+
role: "assistant",
1312+
content: [
1313+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1314+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1315+
],
1316+
})
1317+
})
1318+
1319+
test("leaves valid anthropic assistant tool ordering unchanged", () => {
1320+
const msgs = [
1321+
{
1322+
role: "assistant",
1323+
content: [
1324+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1325+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1326+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1327+
],
1328+
},
1329+
] as any[]
1330+
1331+
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
1332+
1333+
expect(result).toHaveLength(1)
1334+
expect(result[0].content).toMatchObject([
1335+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1336+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1337+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1338+
])
1339+
})
1340+
1341+
test("splits vertex anthropic assistant messages when text trails tool calls", () => {
1342+
const model = {
1343+
...anthropicModel,
1344+
providerID: "google-vertex-anthropic",
1345+
api: {
1346+
id: "claude-sonnet-4@20250514",
1347+
url: "https://us-central1-aiplatform.googleapis.com",
1348+
npm: "@ai-sdk/google-vertex/anthropic",
1349+
},
1350+
}
1351+
1352+
const msgs = [
1353+
{
1354+
role: "assistant",
1355+
content: [
1356+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1357+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1358+
{ type: "text", text: "I checked your home directory and looked for PDF files." },
1359+
],
1360+
},
1361+
] as any[]
1362+
1363+
const result = ProviderTransform.message(msgs, model, {}) as any[]
1364+
1365+
expect(result).toHaveLength(2)
1366+
expect(result[0]).toMatchObject({
1367+
role: "assistant",
1368+
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
1369+
})
1370+
expect(result[1]).toMatchObject({
1371+
role: "assistant",
1372+
content: [
1373+
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
1374+
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
1375+
],
1376+
})
1377+
})
12741378
})
12751379

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

0 commit comments

Comments
 (0)