Compare commits

...

5 Commits

Author SHA1 Message Date
Aiden Cline
f4577c78bf wip 2026-04-15 14:46:04 -05:00
Aiden Cline
3dea9e91d1 Merge branch 'dev' into anthropic-fixes 2026-04-15 14:31:29 -05:00
Aiden Cline
f0a12b549c fix: msg tool issue 2026-04-15 14:27:46 -05:00
Aiden Cline
c4d804fb48 test: add regression test for tool_use ids were found without tool_result blocks immediately after 2026-04-15 11:34:27 -05:00
opencode-agent[bot]
1f279cd2c8 chore: update nix node_modules hashes 2026-04-15 03:47:58 +00:00
3 changed files with 394 additions and 2 deletions

View File

@@ -75,7 +75,7 @@ export namespace ProviderTransform {
if (model.api.id.includes("claude")) {
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
return msgs.map((msg) => {
msgs = msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
return {
...msg,
@@ -101,6 +101,31 @@ export namespace ProviderTransform {
return msg
})
}
if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
// Anthropic rejects assistant turns where tool_use blocks are followed by non-tool
// content, e.g. [tool_use, tool_use, text], with:
// `tool_use` ids were found without `tool_result` blocks immediately after...
//
// Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive
// assistant messages are later merged by the provider/SDK, so preserving the
// original [tool_use...] then [text] order still produces the invalid payload.
//
// The root cause appears to be somewhere upstream where the stream is originally
// processed. We were unable to locate an exact narrower reproduction elsewhere,
// so we keep this transform in place for the time being.
msgs = msgs.flatMap((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]
const parts = msg.content
const first = parts.findIndex((part) => part.type === "tool-call")
if (first === -1) return [msg]
if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]
return [
{ ...msg, content: parts.filter((part) => part.type !== "tool-call") },
{ ...msg, content: parts.filter((part) => part.type === "tool-call") },
]
})
}
if (
model.providerID === "mistral" ||
model.api.id.toLowerCase().includes("mistral") ||

View File

@@ -1271,6 +1271,110 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[0].content).toBe("")
expect(result[1].content).toHaveLength(1)
})
test("splits anthropic assistant messages when text trails tool calls", () => {
const msgs = [
{
role: "user",
content: [{ type: "text", text: "Check my home directory for PDFs" }],
},
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
{ type: "text", text: "I checked your home directory and looked for PDF files." },
],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
{
type: "tool-result",
toolCallId: "toolu_2",
toolName: "glob",
output: { type: "text", value: "No files found" },
},
],
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
expect(result).toHaveLength(4)
expect(result[1]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
})
expect(result[2]).toMatchObject({
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
],
})
})
test("leaves valid anthropic assistant tool ordering unchanged", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "text", text: "I checked your home directory and looked for PDF files." },
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
],
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
expect(result).toHaveLength(1)
expect(result[0].content).toMatchObject([
{ type: "text", text: "I checked your home directory and looked for PDF files." },
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
])
})
test("splits vertex anthropic assistant messages when text trails tool calls", () => {
const model = {
...anthropicModel,
providerID: "google-vertex-anthropic",
api: {
id: "claude-sonnet-4@20250514",
url: "https://us-central1-aiplatform.googleapis.com",
npm: "@ai-sdk/google-vertex/anthropic",
},
}
const msgs = [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
{ type: "text", text: "I checked your home directory and looked for PDF files." },
],
},
] as any[]
const result = ProviderTransform.message(msgs, model, {}) as any[]
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
})
expect(result[1]).toMatchObject({
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
],
})
})
})
describe("ProviderTransform.message - strip openai metadata when store=false", () => {

View File

@@ -13,7 +13,7 @@ import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent"
import type { MessageV2 } from "../../src/session/message-v2"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
@@ -909,6 +909,269 @@ describe("session.llm.stream", () => {
})
})
test("sends anthropic tool_use blocks with tool_result immediately after them", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("anthropic", "claude-opus-4-6")
const model = source.model
const chunks = [
{
type: "message_start",
message: {
id: "msg-tool-order",
model: model.id,
usage: {
input_tokens: 3,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "ok" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
usage: {
input_tokens: 3,
output_tokens: 2,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
},
{ type: "message_stop" },
]
const request = waitRequest("/messages", createEventResponse(chunks))
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic"],
provider: {
anthropic: {
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
npm: "@ai-sdk/anthropic",
api: "https://api.anthropic.com/v1",
models: {
[model.id]: model,
},
options: {
apiKey: "test-anthropic-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-anthropic-tools")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-anthropic-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" },
} satisfies MessageV2.User
const input = [
{
info: {
id: "msg_user",
sessionID,
role: "user",
time: { created: 1 },
agent: "gentleman",
model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
},
parts: [
{
id: "p_user",
sessionID,
messageID: "msg_user",
type: "text",
text: "Can you check whether there are any PDF files in my home directory?",
},
],
},
{
info: {
id: "msg_call",
sessionID,
parentID: "msg_user",
role: "assistant",
mode: "gentleman",
agent: "gentleman",
variant: "max",
path: { cwd: "/root", root: "/" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: "claude-opus-4-6",
providerID: "anthropic",
time: { created: 2, completed: 3 },
finish: "tool-calls",
},
parts: [
{
id: "p_step",
sessionID,
messageID: "msg_call",
type: "step-start",
},
{
id: "p_read",
sessionID,
messageID: "msg_call",
type: "tool",
tool: "read",
callID: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
state: {
status: "completed",
input: { filePath: "/root" },
output: "<path>/root</path>",
metadata: {},
title: "root",
time: { start: 10, end: 11 },
},
},
{
id: "p_glob",
sessionID,
messageID: "msg_call",
type: "tool",
tool: "glob",
callID: "toolu_01APxrADs7VozN8uWzw9WwHr",
state: {
status: "completed",
input: { pattern: "**/*.pdf", path: "/root" },
output: "No files found",
metadata: {},
title: "root",
time: { start: 12, end: 13 },
},
},
{
id: "p_text",
sessionID,
messageID: "msg_call",
type: "text",
text: "I checked your home directory and looked for PDF files.",
time: { start: 14, end: 15 },
},
],
},
] as any[]
await drain({
user,
sessionID,
model: resolved,
agent,
system: [],
messages: await MessageV2.toModelMessages(input as any, resolved),
tools: {
read: tool({
description: "Stub read tool",
inputSchema: z.object({
filePath: z.string(),
}),
execute: async () => ({ output: "stub" }),
}),
glob: tool({
description: "Stub glob tool",
inputSchema: z.object({
pattern: z.string(),
path: z.string().optional(),
}),
execute: async () => ({ output: "stub" }),
}),
},
})
const capture = await request
const body = capture.body
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
expect(body.messages).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
},
{
role: "assistant",
content: [
{
type: "text",
text: "I checked your home directory and looked for PDF files.",
},
{
type: "tool_use",
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
name: "read",
input: { filePath: "/root" },
},
{
type: "tool_use",
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
name: "glob",
input: { pattern: "**/*.pdf", path: "/root" },
cache_control: {
type: "ephemeral",
},
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
content: "<path>/root</path>",
},
{
type: "tool_result",
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
content: "No files found",
cache_control: {
type: "ephemeral",
},
},
],
},
])
},
})
})
test("sends Google API payload for Gemini models", async () => {
const server = state.server
if (!server) {