diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index f03e0bdce3..f0570b58ec 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -737,6 +737,13 @@ func ChatCompletionsChunkToResponsesEvents( copyCall.ID = generateItemID() } copyCall.Type = "function" + // Arguments are accumulated by the shared block below so the + // emitted delta and the stored value stay in sync. Some upstreams + // (e.g. GLM/Zhipu) pack id+name+arguments into the first tool_call + // chunk; without this reset the first chunk's arguments would be + // counted twice (once from this copy, once from the += below), + // producing a doubled, invalid JSON like {"a":1}{"a":1}. + copyCall.Function.Arguments = "" state.ToolCalls[idx] = ©Call stored = ©Call itemID := generateItemID() diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go index b71d1ba78c..baa4e4b2a5 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go @@ -179,6 +179,41 @@ func TestStream_ToolCallLifecycleComplete(t *testing.T) { require.True(t, sawItemDone, "function_call output_item.done missing") } +// TestStream_ToolCallArgumentsInFirstChunkNotDoubled guards the GLM/Zhipu shape +// where a single tool_call delta chunk carries id+name+arguments together. +// Earlier code copied the whole tool_call (including arguments) into state and +// then accumulated the same chunk's arguments again, producing a doubled, +// invalid JSON like {"cmd":"ls"}{"cmd":"ls"} that breaks Codex tool parsing +// ("trailing characters"). +func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) { + events := collectStreamEvents(t, []string{ + `{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`, + `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{\"cmd\":\"ls\"}"}}]}}]}`, + `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + }) + + var argsDelta strings.Builder + var sawArgsDone, sawItemDone bool + for _, e := range events { + switch e.Type { + case "response.function_call_arguments.delta": + _, _ = argsDelta.WriteString(e.Delta) + case "response.function_call_arguments.done": + sawArgsDone = true + require.Equal(t, `{"cmd":"ls"}`, e.Arguments) + case "response.output_item.done": + if e.Item != nil && e.Item.Type == "function_call" { + sawItemDone = true + require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments) + } + } + } + require.True(t, sawArgsDone, "function_call_arguments.done missing") + require.True(t, sawItemDone, "function_call output_item.done missing") + // Accumulated deltas must equal the final arguments exactly (no duplication). + require.Equal(t, `{"cmd":"ls"}`, argsDelta.String()) +} + // TestStream_SSEWireComplete drives the full stream through SSE encoding and // asserts the function_call events carry complete fields on the wire. func TestStream_SSEWireComplete(t *testing.T) {