fix(apicompat): avoid doubling tool_call arguments from single-chunk upstreams

When converting a Chat Completions stream into Responses events, the first
tool_call delta chunk was copied wholesale into stream state (including
function.arguments), then the same chunk's arguments were accumulated again by
the shared `+=` block. For OpenAI this is harmless because its first tool_call
chunk carries empty arguments, but upstreams that pack id+name+arguments into a
single chunk (e.g. GLM/Zhipu) end up with doubled arguments such as
{"cmd":"ls"}{"cmd":"ls"}. Codex then fails to parse the tool call with
"trailing characters", breaking every tool invocation.

Reset the copied arguments so the shared accumulator counts them exactly once,
keeping the emitted delta and the final done/arguments consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
visa2
2026-06-26 00:58:38 +08:00
co-authored by Claude Opus 4.8
parent ce6af41357
commit 29122e3051
2 changed files with 42 additions and 0 deletions
@@ -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] = &copyCall
stored = &copyCall
itemID := generateItemID()
@@ -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) {