Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions tests/adapter-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading