mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-20 13:07:00 +08:00
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:
@@ -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_call,Responses 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)
|
||||
|
||||
Reference in New Issue
Block a user