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
+24
View File
@@ -1956,6 +1956,7 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
instructionParts := make([]string, 0, 2)
items := make([]map[string]any, 0, len(messages))
responsesCallIDs := make(map[string]string)
emittedCallIDs := make(map[string]struct{})
activeAssistantReasoningKey := ""
for _, message := range messages {
role := strings.TrimSpace(message.Role)
@@ -1968,6 +1969,26 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
}
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
if strings.TrimSpace(callID) != "" {
if _, ok := emittedCallIDs[callID]; !ok {
// 历史损坏时可能出现没有配对 function_call 的工具结果
// (例如旧版本回放逻辑剥离了 assistant 调用但保留了结果)。
// Responses API 会直接拒绝这种 input,这里补一个占位 function_call
// 让旧会话可以继续;无法补齐时丢弃该结果。
if name := strings.TrimSpace(message.Name); name != "" {
items = append(items, map[string]any{
"type": "function_call",
"call_id": callID,
"name": name,
"arguments": "{}",
"status": "completed",
})
emittedCallIDs[callID] = struct{}{}
} else {
continue
}
}
}
var output any = openAIResponsesMessageText(message)
if hasImageContentParts(message.ContentParts) {
content, err := openAIResponsesMessageContent(message, false)
@@ -2034,6 +2055,9 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
toolItem["status"] = "completed"
}
items = append(items, toolItem)
if strings.TrimSpace(callID) != "" {
emittedCallIDs[strings.TrimSpace(callID)] = struct{}{}
}
}
}
}
@@ -0,0 +1,96 @@
package modeladapter
import "testing"
// 历史损坏时(旧版本回放逻辑剥离了 assistant 调用但保留了结果),
// function_call_output 会缺少配对的 function_callResponses API 会拒绝。
// 这里验证 adapter 会为孤儿结果补一个占位 function_call,让旧会话可以继续。
func TestNormalizeOpenAIResponsesInputSynthesizesCallForOrphanToolOutput(t *testing.T) {
messages := []Message{
{Role: "user", Content: "query"},
{Role: "assistant", Content: "我先快速定位上下文"},
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
{Role: "user", Content: "next"},
}
_, items, err := normalizeOpenAIResponsesInput(messages)
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
}
var callIndexes []int
var outputIndexes []int
for index, item := range items {
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
callIndexes = append(callIndexes, index)
}
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
outputIndexes = append(outputIndexes, index)
}
}
if len(callIndexes) != 1 {
t.Fatalf("expected 1 synthesized function_call, got %d: %+v", len(callIndexes), items)
}
if len(outputIndexes) != 1 || outputIndexes[0] != callIndexes[0]+1 {
t.Fatalf("expected function_call_output right after synthesized function_call, calls=%v outputs=%v", callIndexes, outputIndexes)
}
if got := items[callIndexes[0]]["name"]; got != "Grep" {
t.Fatalf("expected synthesized function_call name Grep, got %v", got)
}
}
// 连工具名都没有的孤儿结果只能丢弃,避免上游 400。
func TestNormalizeOpenAIResponsesInputDropsNamelessOrphanToolOutput(t *testing.T) {
messages := []Message{
{Role: "user", Content: "query"},
{Role: "tool", ToolCallID: "call_unknown", Content: "orphan result"},
{Role: "user", Content: "next"},
}
_, items, err := normalizeOpenAIResponsesInput(messages)
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
}
for _, item := range items {
if item["type"] == "function_call_output" {
t.Fatalf("expected orphan function_call_output to be dropped, got %+v", item)
}
}
}
// 正常配对的调用与结果不应受防御逻辑影响。
func TestNormalizeOpenAIResponsesInputKeepsPairedCallAndOutput(t *testing.T) {
messages := []Message{
{Role: "user", Content: "query"},
{
Role: "assistant",
ToolCalls: []ToolCallDescriptor{{
ID: "call_xrN6",
Type: "function",
Function: ToolCallFunctionShape{
Name: "Grep",
Arguments: `{"pattern":"ref"}`,
},
}},
},
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
{Role: "user", Content: "next"},
}
_, items, err := normalizeOpenAIResponsesInput(messages)
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
}
var calls, outputs int
for _, item := range items {
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
calls++
}
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
outputs++
}
}
if calls != 1 || outputs != 1 {
t.Fatalf("expected exactly one paired function_call/output, got calls=%d outputs=%d", calls, outputs)
}
}
@@ -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)