Files
sub2api/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go
T
visa2andClaude Opus 4.8 29122e3051 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>
2026-06-26 00:58:38 +08:00

239 lines
9.9 KiB
Go

package apicompat
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func collectStreamEvents(t *testing.T, chunks []string) []ResponsesStreamEvent {
t.Helper()
state := NewChatCompletionsToResponsesStreamState("deepseek-v4-pro")
var events []ResponsesStreamEvent
for _, payload := range chunks {
var chunk ChatCompletionsChunk
require.NoError(t, json.Unmarshal([]byte(payload), &chunk))
events = append(events, ChatCompletionsChunkToResponsesEvents(&chunk, state)...)
}
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
return events
}
// TestStream_ReasoningOpensItemBeforeDelta guards the bug where a strict client
// (Codex) drops reasoning deltas that reference an item not yet opened.
func TestStream_ReasoningOpensItemBeforeDelta(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
`{"choices":[{"index":0,"delta":{"reasoning_content":"think"}}]}`,
`{"choices":[{"index":0,"delta":{"content":"hello"}}]}`,
`{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
})
open := map[int]string{} // output_index -> item type
for _, e := range events {
switch e.Type {
case "response.output_item.added":
require.NotNil(t, e.Item)
open[e.OutputIndex] = e.Item.Type
case "response.reasoning_summary_text.delta":
require.Equalf(t, "reasoning", open[e.OutputIndex], "reasoning delta before its item was opened")
case "response.output_text.delta":
require.Equalf(t, "message", open[e.OutputIndex], "text delta before its item was opened")
}
}
}
func TestStream_ReasoningOnlySynthesizesVisibleText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
`{"choices":[{"index":0,"delta":{"reasoning_content":"thinking before final"}}]}`,
`{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
})
open := map[int]string{}
var sawTextDelta, sawTextDone, sawMessageDone bool
for _, e := range events {
switch e.Type {
case "response.output_item.added":
require.NotNil(t, e.Item)
open[e.OutputIndex] = e.Item.Type
case "response.output_text.delta":
sawTextDelta = true
require.Equalf(t, "message", open[e.OutputIndex], "fallback text delta before its item was opened")
require.Equal(t, "thinking before final", e.Delta)
case "response.output_text.done":
sawTextDone = true
require.Equal(t, "thinking before final", e.Text)
case "response.output_item.done":
if e.Item != nil && e.Item.Type == "message" {
sawMessageDone = true
require.Equal(t, "thinking before final", e.Item.Content[0].Text)
}
case "response.completed":
require.NotNil(t, e.Response)
require.Equal(t, "incomplete", e.Response.Status)
require.NotNil(t, e.Response.IncompleteDetails)
require.Equal(t, "max_output_tokens", e.Response.IncompleteDetails.Reason)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "reasoning", e.Response.Output[0].Type)
require.Equal(t, "message", e.Response.Output[1].Type)
require.Equal(t, "thinking before final", e.Response.Output[1].Content[0].Text)
}
}
require.True(t, sawTextDelta, "reasoning-only stream must produce visible text delta")
require.True(t, sawTextDone, "reasoning-only stream must close visible text part")
require.True(t, sawMessageDone, "reasoning-only stream must close synthesized message item")
}
func TestStream_ReasoningOnlyBlankDoesNotSynthesizeVisibleText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"reasoning_content":" "}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
})
for _, e := range events {
require.NotEqual(t, "response.output_text.delta", e.Type)
if e.Type == "response.completed" {
require.NotNil(t, e.Response)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "reasoning", e.Response.Output[0].Type)
require.Equal(t, "message", e.Response.Output[1].Type)
require.Equal(t, "", e.Response.Output[1].Content[0].Text)
}
}
}
func TestStream_ReasoningThenContentDoesNotDuplicateFallbackText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"reasoning_content":"private plan"}}]}`,
`{"choices":[{"index":0,"delta":{"content":"final answer"}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
})
var textDeltas []string
for _, e := range events {
switch e.Type {
case "response.output_text.delta":
textDeltas = append(textDeltas, e.Delta)
case "response.completed":
require.NotNil(t, e.Response)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "private plan", e.Response.Output[0].Summary[0].Text)
require.Equal(t, "final answer", e.Response.Output[1].Content[0].Text)
}
}
require.Equal(t, []string{"final answer"}, textDeltas)
}
func TestStream_ReasoningThenToolCallDoesNotSynthesizeVisibleText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"reasoning_content":"call a tool"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
})
for _, e := range events {
require.NotEqual(t, "response.output_text.delta", e.Type)
if e.Type == "response.completed" {
require.NotNil(t, e.Response)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "reasoning", e.Response.Output[0].Type)
require.Equal(t, "function_call", e.Response.Output[1].Type)
}
}
}
// TestStream_ToolCallLifecycleComplete guards that a tool call is fully closed
// (function_call_arguments.done + output_item.done with full arguments), which
// codex needs to execute the call.
func TestStream_ToolCallLifecycleComplete(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":""}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
})
var sawAdded, sawArgsDone, sawItemDone bool
for _, e := range events {
switch e.Type {
case "response.output_item.added":
if e.Item != nil && e.Item.Type == "function_call" {
sawAdded = true
}
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.Equal(t, "call_a", e.Item.CallID)
}
}
}
require.True(t, sawAdded, "function_call output_item.added missing")
require.True(t, sawArgsDone, "function_call_arguments.done missing")
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) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
})
var addedLine string
for _, e := range events {
sse, err := ResponsesEventToSSE(e)
require.NoError(t, err)
if e.Type == "response.output_item.added" && e.Item != nil && e.Item.Type == "function_call" {
addedLine = sse
}
}
require.NotEmpty(t, addedLine)
// The function_call added event must carry arguments:"" on the wire.
require.True(t, strings.Contains(addedLine, `"arguments":""`), "added line missing arguments: %s", addedLine)
require.Contains(t, addedLine, `"call_id":"call_a"`)
}