Skip to content

Commit c7e48fb

Browse files
authored
fix(anthropic): reorder interleaved text before tool_use (#640)
* fix(anthropic): reorder interleaved text before tool_use Closes #620 * test(anthropic): rename wire body var for review clarity
1 parent 341d486 commit c7e48fb

2 files changed

Lines changed: 43 additions & 5 deletions

File tree

src/adapters/anthropic.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -403,29 +403,33 @@ function messagesToAnthropicFormat(
403403
}
404404
case "assistant": {
405405
const aMsg = msg as OcxAssistantMessage;
406-
const content: unknown[] = [];
406+
const preface: unknown[] = [];
407+
const toolUses: unknown[] = [];
407408
const toolUseIds: string[] = [];
408409
for (const part of aMsg.content) {
409410
if (part.type === "text") {
410411
const text = (part as OcxTextContent).text;
411-
if (text) content.push({ type: "text", text });
412+
if (text) preface.push({ type: "text", text });
412413
} else if (part.type === "thinking") {
413414
const t = part as OcxThinkingContent;
414415
// Redacted blocks replay verbatim FIRST (they preceded the visible thinking block
415416
// in the original stream order preserved by the bridge envelope).
416417
for (const data of t.redacted ?? []) {
417-
content.push({ type: "redacted_thinking", data });
418+
preface.push({ type: "redacted_thinking", data });
418419
}
419420
if (isLikelyRealAnthropicThinkingSignature(t.signature)) {
420-
content.push({ type: "thinking", thinking: t.thinking, signature: t.signature });
421+
preface.push({ type: "thinking", thinking: t.thinking, signature: t.signature });
421422
}
422423
} else if (part.type === "toolCall") {
423424
const tc = part as OcxToolCall;
424425
const flatName = namespacedToolName(tc.namespace, tc.name);
425426
toolUseIds.push(tc.id);
426-
content.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments });
427+
toolUses.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments });
427428
}
428429
}
430+
// Anthropic treats text/thinking after tool_use as ending the tool turn, which makes
431+
// earlier tool_use ids look unpaired (#620 / common multi-step history shape).
432+
const content = [...preface, ...toolUses];
429433
if (content.length === 0) break;
430434
messages.push({ role: "assistant", content });
431435
if (toolUseIds.length > 0) {

tests/adapter-usage.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,40 @@ describe("anthropic tool result history repair", () => {
490490
});
491491
});
492492

493+
test("reorders interleaved text after tool_use so Anthropic pairing stays valid (#620)", async () => {
494+
const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" });
495+
const request = await adapter.buildRequest({
496+
modelId: "claude-sonnet",
497+
context: {
498+
messages: [
499+
{ role: "user", content: "start", timestamp: 0 },
500+
{
501+
role: "assistant",
502+
content: [
503+
{ type: "text", text: "before" },
504+
{ type: "toolCall", id: "call_a", name: "first_tool", arguments: {} },
505+
{ type: "text", text: "between steps" },
506+
{ type: "toolCall", id: "call_b", name: "second_tool", arguments: {} },
507+
],
508+
model: "claude-sonnet",
509+
timestamp: 0,
510+
},
511+
{ role: "toolResult", toolCallId: "call_a", toolName: "first_tool", content: "one", isError: false, timestamp: 0 },
512+
{ role: "toolResult", toolCallId: "call_b", toolName: "second_tool", content: "two", isError: false, timestamp: 0 },
513+
],
514+
},
515+
stream: true,
516+
options: {},
517+
});
518+
const wire = JSON.parse(request.body) as { messages: Array<{ role: string; content: any }> };
519+
const assistant = wire.messages.find(m => m.role === "assistant" && Array.isArray(m.content) && m.content.some((c: { type?: string }) => c.type === "tool_use"));
520+
expect(assistant).toBeDefined();
521+
const types = (assistant!.content as { type: string }[]).map(c => c.type);
522+
expect(types).toEqual(["text", "text", "tool_use", "tool_use"]);
523+
expect(wire.messages[2].role).toBe("user");
524+
expect(wire.messages[2].content.map((c: { tool_use_id?: string }) => c.tool_use_id)).toEqual(["call_a", "call_b"]);
525+
});
526+
493527
test("preserves orphan tool results as text instead of invalid Anthropic tool_result blocks", async () => {
494528
const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" });
495529
const request = await adapter.buildRequest({

0 commit comments

Comments
 (0)