fix: keep tool call replay when assistant text interleaves call and result

trimReplayDanglingAssistantToolCalls only collected tool results that
immediately followed the assistant tool-call message. Models such as
gpt-5.3-codex-spark may emit the function_call item before the
explanation text within one response, so history replay order becomes
assistant[tool_call] -> assistant[text] -> tool[result]. The call was
misjudged as dangling and stripped while the tool result survived,
producing a function_call_output without a matching function_call that
the Responses API rejects with 400.

- widen the response collection window to skip interleaved plain
  assistant text messages, and drop orphan tool results in the same pass
- synthesize a placeholder function_call (or drop the output when the
  tool name is unknown) in normalizeOpenAIResponsesInput so conversations
  already persisted with corrupted history can resume
This commit is contained in:
上玄
2026-08-14 16:51:45 +08:00
parent a3ec2a0dfc
commit 5d04b5b08b
5 changed files with 279 additions and 43 deletions
@@ -31,12 +31,14 @@ func TestToolImageProviderEncodings(t *testing.T) {
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput() error = %v", err)
}
if len(items) != 1 || items[0]["type"] != "function_call_output" {
// 孤儿 tool 结果(无前置 assistant 调用)会补一个占位 function_call
// 保证每个 function_call_output 都有配对调用。
if len(items) != 2 || items[0]["type"] != "function_call" || items[0]["call_id"] != "call-1" || items[1]["type"] != "function_call_output" {
t.Fatalf("openai responses items = %#v", items)
}
content, ok := items[0]["output"].([]map[string]any)
content, ok := items[1]["output"].([]map[string]any)
if !ok || len(content) != 2 {
t.Fatalf("openai responses output = %#v", items[0]["output"])
t.Fatalf("openai responses output = %#v", items[1]["output"])
}
if content[0]["type"] != "input_text" || content[1]["type"] != "input_image" {
t.Fatalf("openai responses content = %#v", content)