diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 714fedac0..d54918f22 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -403,29 +403,33 @@ function messagesToAnthropicFormat( } case "assistant": { const aMsg = msg as OcxAssistantMessage; - const content: unknown[] = []; + const preface: unknown[] = []; + const toolUses: unknown[] = []; const toolUseIds: string[] = []; for (const part of aMsg.content) { if (part.type === "text") { const text = (part as OcxTextContent).text; - if (text) content.push({ type: "text", text }); + if (text) preface.push({ type: "text", text }); } else if (part.type === "thinking") { const t = part as OcxThinkingContent; // Redacted blocks replay verbatim FIRST (they preceded the visible thinking block // in the original stream order preserved by the bridge envelope). for (const data of t.redacted ?? []) { - content.push({ type: "redacted_thinking", data }); + preface.push({ type: "redacted_thinking", data }); } if (isLikelyRealAnthropicThinkingSignature(t.signature)) { - content.push({ type: "thinking", thinking: t.thinking, signature: t.signature }); + preface.push({ type: "thinking", thinking: t.thinking, signature: t.signature }); } } else if (part.type === "toolCall") { const tc = part as OcxToolCall; const flatName = namespacedToolName(tc.namespace, tc.name); toolUseIds.push(tc.id); - content.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments }); + toolUses.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments }); } } + // Anthropic treats text/thinking after tool_use as ending the tool turn, which makes + // earlier tool_use ids look unpaired (#620 / common multi-step history shape). + const content = [...preface, ...toolUses]; if (content.length === 0) break; messages.push({ role: "assistant", content }); if (toolUseIds.length > 0) { diff --git a/tests/adapter-usage.test.ts b/tests/adapter-usage.test.ts index ef6ac810f..b76c2dfe5 100644 --- a/tests/adapter-usage.test.ts +++ b/tests/adapter-usage.test.ts @@ -490,6 +490,40 @@ describe("anthropic tool result history repair", () => { }); }); + test("reorders interleaved text after tool_use so Anthropic pairing stays valid (#620)", async () => { + const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" }); + const request = await adapter.buildRequest({ + modelId: "claude-sonnet", + context: { + messages: [ + { role: "user", content: "start", timestamp: 0 }, + { + role: "assistant", + content: [ + { type: "text", text: "before" }, + { type: "toolCall", id: "call_a", name: "first_tool", arguments: {} }, + { type: "text", text: "between steps" }, + { type: "toolCall", id: "call_b", name: "second_tool", arguments: {} }, + ], + model: "claude-sonnet", + timestamp: 0, + }, + { role: "toolResult", toolCallId: "call_a", toolName: "first_tool", content: "one", isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: "call_b", toolName: "second_tool", content: "two", isError: false, timestamp: 0 }, + ], + }, + stream: true, + options: {}, + }); + const wire = JSON.parse(request.body) as { messages: Array<{ role: string; content: any }> }; + const assistant = wire.messages.find(m => m.role === "assistant" && Array.isArray(m.content) && m.content.some((c: { type?: string }) => c.type === "tool_use")); + expect(assistant).toBeDefined(); + const types = (assistant!.content as { type: string }[]).map(c => c.type); + expect(types).toEqual(["text", "text", "tool_use", "tool_use"]); + expect(wire.messages[2].role).toBe("user"); + expect(wire.messages[2].content.map((c: { tool_use_id?: string }) => c.tool_use_id)).toEqual(["call_a", "call_b"]); + }); + test("preserves orphan tool results as text instead of invalid Anthropic tool_result blocks", async () => { const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" }); const request = await adapter.buildRequest({