From 179aa98b090ba86ff8ee88e08c58edeef1d54b4a Mon Sep 17 00:00:00 2001 From: RHODIZ IT Date: Mon, 14 Sep 2026 13:25:27 -0500 Subject: [PATCH] fix(claude): forward done-only tool arguments Co-authored-by: RHODIZ IT --- src/claude/outbound.ts | 18 +++++++++++ .../claude-outbound.test.ts | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index d4e7758ee0..f281b632b7 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -212,6 +212,8 @@ interface OpenBlock { argsBuf?: string; argsBufBytes?: number; webSearchArgsEmitted?: boolean; + /** True once ordinary function-call arguments were emitted to Anthropic SSE. */ + toolArgsEmitted?: boolean; callId?: string; /** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */ reasoningPartKey?: string; @@ -488,6 +490,7 @@ export function responsesSseToAnthropicSse( argsBuf: "", argsBufBytes: 0, webSearchArgsEmitted: false, + toolArgsEmitted: false, }; break; } @@ -518,6 +521,14 @@ export function responsesSseToAnthropicSse( type: "content_block_delta", index: open.index, delta: { type: "input_json_delta", partial_json: data.delta }, }); + open.toolArgsEmitted = true; + break; + } + case "response.function_call_arguments.done": { + if (!open || open.kind !== "tool_use" || open.bufferWebSearchArgs || open.toolArgsEmitted) break; + if (typeof data.arguments !== "string" || data.arguments.length === 0) break; + emit("content_block_delta", { type: "content_block_delta", index: open.index, delta: { type: "input_json_delta", partial_json: data.arguments } }); + open.toolArgsEmitted = true; break; } case "response.output_item.done": { @@ -565,6 +576,13 @@ export function responsesSseToAnthropicSse( delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) }, }); open.webSearchArgsEmitted = true; + } else if (!open.bufferWebSearchArgs && !open.toolArgsEmitted + && typeof item.arguments === "string" && item.arguments.length > 0) { + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "input_json_delta", partial_json: item.arguments }, + }); + open.toolArgsEmitted = true; } closeOpenBlock(); } diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 72f7a22bdf..375fac9155 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -197,6 +197,21 @@ describe("claude outbound SSE", () => { expect(unspacedBudget.snapshot().currentBytes).toBe(spacedBudget.snapshot().currentBytes); }); + test("done-only function arguments reach Claude tool input", async () => { + const upstream = [ + sse("response.created", { response: { id: "resp_done", status: "in_progress" } }), + sse("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: "", status: "in_progress" } }), + sse("response.function_call_arguments.done", { item_id: "fc_done", output_index: 0, arguments: "{\"command\":\"printf RHODIZ_TOOL_OK\"}" }), + sse("response.output_item.done", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: "{\"command\":\"printf RHODIZ_TOOL_OK\"}" } }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 10, output_tokens: 5 } } }), + ].join(""); + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "claude-ocx-test")); + const argDeltas = events.filter(e => e.name === "content_block_delta" && e.data.delta?.type === "input_json_delta"); + expect(argDeltas).toHaveLength(1); + expect(argDeltas[0].data.delta.partial_json).toBe("{\"command\":\"printf RHODIZ_TOOL_OK\"}"); + expect(events.find(e => e.name === "content_block_start")?.data.content_block).toMatchObject({ type: "tool_use", name: "Bash", input: {} }); + }); + test("text + thinking + tool call + completed w/ usage -> exact Anthropic sequence", async () => { const upstream = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), @@ -255,6 +270,21 @@ describe("claude outbound SSE", () => { expect(startIndexes).toEqual([0, 1, 2]); }); + test("done-only function-call arguments reach Claude tool input", async () => { + const args = JSON.stringify({ command: "printf RHODIZ_TOOL_OK" }); + const upstream = [ + sse("response.created", { response: { id: "resp_done_args", status: "in_progress" } }), + sse("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: "", status: "in_progress" } }), + sse("response.function_call_arguments.done", { item_id: "fc_done", output_index: 0, arguments: args }), + sse("response.output_item.done", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: args, status: "completed" } }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }), + ].join(""); + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "claude-ocx-test")); + const deltas = events.filter(e => e.name === "content_block_delta" && e.data.delta?.type === "input_json_delta"); + expect(deltas).toHaveLength(1); + expect(deltas[0].data.delta.partial_json).toBe(args); + }); + test("multi-part reasoning summaries keep the JSON path's part separator", async () => { const upstream = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }),