diff --git a/go/internal/adapter/anthropic/anthropic.go b/go/internal/adapter/anthropic/anthropic.go index 1c5b32086d..9bd07c585b 100644 --- a/go/internal/adapter/anthropic/anthropic.go +++ b/go/internal/adapter/anthropic/anthropic.go @@ -314,22 +314,23 @@ func assistantContent(raw json.RawMessage, names toolNameTransforms) ([]any, []s } return nil, nil, nil } - out := make([]any, 0, len(parts)) + preface := make([]any, 0, len(parts)) + toolUses := make([]any, 0) toolIDs := make([]string, 0) for _, rawPart := range parts { part, _ := rawPart.(map[string]any) switch part["type"] { case "text", "output_text": if text := firstString(part, "text", "output_text"); text != "" { - out = append(out, map[string]any{"type": "text", "text": text}) + preface = append(preface, map[string]any{"type": "text", "text": text}) } case "thinking", "reasoning": for _, data := range stringItems(part["redacted"]) { - out = append(out, map[string]any{"type": "redacted_thinking", "data": data}) + preface = append(preface, map[string]any{"type": "redacted_thinking", "data": data}) } if thinking := firstString(part, "thinking", "reasoning", "text"); thinking != "" { if signature, _ := part["signature"].(string); isLikelyRealAnthropicThinkingSignature(signature) { - out = append(out, map[string]any{"type": "thinking", "thinking": thinking, "signature": signature}) + preface = append(preface, map[string]any{"type": "thinking", "thinking": thinking, "signature": signature}) } } case "toolCall", "tool_call": @@ -339,10 +340,13 @@ func assistantContent(raw json.RawMessage, names toolNameTransforms) ([]any, []s if input == nil { input = map[string]any{} } - out = append(out, map[string]any{"type": "tool_use", "id": id, "name": names.toWire(name), "input": input}) + toolUses = append(toolUses, map[string]any{"type": "tool_use", "id": id, "name": names.toWire(name), "input": input}) toolIDs = append(toolIDs, id) } } + // 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). + out := append(preface, toolUses...) return out, toolIDs, nil } diff --git a/go/internal/adapter/anthropic/anthropic_test.go b/go/internal/adapter/anthropic/anthropic_test.go index 02d3357d93..15b17f2bcc 100644 --- a/go/internal/adapter/anthropic/anthropic_test.go +++ b/go/internal/adapter/anthropic/anthropic_test.go @@ -60,6 +60,51 @@ func TestBuildRequestAnthropicMessagesShape(t *testing.T) { } } +func TestBuildRequestReordersInterleavedTextBeforeToolUse(t *testing.T) { + adapter := &Adapter{BaseURL: "https://provider.test/v1", APIKey: "secret"} + req := &types.NormalizedRequest{ + ModelID: "claude-test", Stream: true, + Context: types.RequestContext{ + Messages: []types.Message{ + {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"start"}]`)}, + {Role: "assistant", Content: json.RawMessage(`[ + {"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":{}} + ]`)}, + {Role: "toolResult", ToolCallID: "call_a", ToolName: "first_tool", Content: json.RawMessage(`"one"`)}, + {Role: "toolResult", ToolCallID: "call_b", ToolName: "second_tool", Content: json.RawMessage(`"two"`)}, + }, + }, + } + httpReq, err := adapter.BuildRequest(context.Background(), req) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.NewDecoder(httpReq.Body).Decode(&body); err != nil { + t.Fatal(err) + } + messages := body["messages"].([]any) + assistant := messages[1].(map[string]any)["content"].([]any) + typesSeen := make([]string, 0, len(assistant)) + for _, block := range assistant { + typesSeen = append(typesSeen, block.(map[string]any)["type"].(string)) + } + if got, want := strings.Join(typesSeen, ","), "text,text,tool_use,tool_use"; got != want { + t.Fatalf("assistant block types = %q, want %q", got, want) + } + user := messages[2].(map[string]any)["content"].([]any) + ids := []string{ + user[0].(map[string]any)["tool_use_id"].(string), + user[1].(map[string]any)["tool_use_id"].(string), + } + if ids[0] != "call_a" || ids[1] != "call_b" { + t.Fatalf("tool_result ids = %#v", ids) + } +} + func TestParseStreamAndUnary(t *testing.T) { stream := strings.Join([]string{ `event: message_start`, `data: {"type":"message_start","message":{"usage":{"input_tokens":3,"cache_read_input_tokens":2}}}`, "", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 714fedac0e..d54918f220 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 ef6ac810f9..b76c2dfe56 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({