From 0952ce34105b496d953d59d0b7de33e7e36550fe Mon Sep 17 00:00:00 2001 From: zoutao Date: Thu, 17 Sep 2026 16:47:53 +0800 Subject: [PATCH] fix(apicompat): include streamed arguments in function_call_arguments.done Gemini/Antigravity delivers tool arguments as one complete object, so this converter synthesizes the Responses tool-call stream itself and therefore owns its self-consistency. The done event was built without Arguments, leaving it "" while the preceding delta carried the entire JSON. Clients that reconcile the done payload against the accumulated deltas (OpenAI Responses SDK); Qoder's reconcileToolCall) reject that mismatch as inconsistent_tool_call and abort the turn non-retryably, so every tool call through the gateway failed. Emit state.CurrentArgs, which closeCurrentResponsesItem already uses for the item, restoring sum(deltas) == done.arguments == item.arguments. Regression test asserts the delta sum equals the done payload and fails closed when Arguments is dropped. --- .../anthropic_to_responses_response.go | 8 ++- .../anthropic_to_responses_stream_test.go | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index 43542dfaaf..f270050d43 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -431,13 +431,19 @@ func anthToResHandleContentBlockStop(evt *AnthropicStreamEvent, state *Anthropic return events case "function_call": - // Emit function_call_arguments.done + output item done + // Emit function_call_arguments.done + output item done. + // arguments must repeat exactly what the deltas already streamed for this + // item: clients reconcile the done event against the accumulated + // function_call_arguments.delta payloads and reject the call as + // inconsistent_tool_call when the two disagree. Omitting the field left it + // empty while the deltas carried the whole JSON. events := []ResponsesStreamEvent{ makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{ OutputIndex: state.OutputIndex, ItemID: state.CurrentItemID, CallID: state.CurrentCallID, Name: state.CurrentName, + Arguments: state.CurrentArgs, }), } events = append(events, closeCurrentResponsesItem(state)...) diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_stream_test.go b/backend/internal/pkg/apicompat/anthropic_to_responses_stream_test.go index e21f4175ef..2a2dfe285c 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_stream_test.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_stream_test.go @@ -394,3 +394,63 @@ func TestAnthropicEventToResponses_ItemLifecycleIsBalanced(t *testing.T) { }) } } + +// TestAnthropicEventToResponses_ToolCallArgumentsDoneMatchesDeltas pins that +// response.function_call_arguments.done repeats exactly the arguments the deltas +// already streamed for that item. +// +// Why: clients reconcile the done event against the accumulated +// function_call_arguments.delta payloads and reject the whole call as +// inconsistent_tool_call when the two disagree — Qoder surfaces that as a bare +// "something went wrong" and never runs the tool. The field used to be left +// unset, so done carried "" while the deltas and the item carried the full JSON. +// TestAnthropicEventToResponses_ToolCallCompletedCarriesArguments did not catch +// it because it only looked at output_item.done and response.completed. +func TestAnthropicEventToResponses_ToolCallArgumentsDoneMatchesDeltas(t *testing.T) { + state := NewAnthropicEventToResponsesState() + state.Model = "claude-sonnet-4-5" + + var events []ResponsesStreamEvent + feed := func(evt *AnthropicStreamEvent) { + events = append(events, AnthropicEventToResponsesEvents(evt, state)...) + } + + idx := 0 + feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}}) + feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{ + Type: "tool_use", ID: "toolu_1", Name: "grep_search", + }}) + // Anthropic streams arguments as JSON fragments; their sum is the contract + // clients diff the done event against. + feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{ + Type: "input_json_delta", PartialJSON: `{"path":`, + }}) + feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{ + Type: "input_json_delta", PartialJSON: `".","pattern":"TODO"}`, + }}) + feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx}) + + var streamed, done string + var sawDone bool + for _, e := range events { + switch e.Type { + case "response.function_call_arguments.delta": + streamed += e.Delta + case "response.function_call_arguments.done": + sawDone = true + done = e.Arguments + } + } + + const want = `{"path":".","pattern":"TODO"}` + if streamed != want { + t.Fatalf("sum of deltas = %q, want %q", streamed, want) + } + if !sawDone { + t.Fatalf("response.function_call_arguments.done was not emitted; got %d events", len(events)) + } + if done != streamed { + t.Errorf("function_call_arguments.done arguments = %q, want %q (must equal the streamed deltas)", + done, streamed) + } +}