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
+65 -40
View File
@@ -1196,57 +1196,82 @@ func mergeReplayReasoningMetadata(last *modeladapter.Message, current modeladapt
}
}
// replayToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。
// 窗口内除了 tool 结果消息,还允许出现同轮穿插的纯文本 assistant 消息:
// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本,
// 落盘顺序为 tool_call → assistant_text → tool_result,若只收集紧邻的 tool 消息,
// 会把有结果回放的调用误判为悬空。
func replayToolResponseWindowEnd(messages []modeladapter.Message, index int) int {
end := index + 1
for end < len(messages) {
candidate := messages[end]
switch {
case strings.TrimSpace(candidate.Role) == "tool":
end++
case strings.TrimSpace(candidate.Role) == "assistant" && len(candidate.ToolCalls) == 0:
end++
default:
return end
}
}
return end
}
func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []modeladapter.Message {
if len(messages) == 0 {
return nil
}
trimmed := make([]modeladapter.Message, 0, len(messages))
for index := 0; index < len(messages); index++ {
message := cloneReplayModelMessage(messages[index])
survivingToolCallIDs := make(map[string]struct{})
for index, message := range messages {
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
continue
}
responded := make(map[string]struct{}, len(message.ToolCalls))
for scan := index + 1; scan < replayToolResponseWindowEnd(messages, index); scan++ {
if strings.TrimSpace(messages[scan].Role) != "tool" {
continue
}
if toolCallID := strings.TrimSpace(messages[scan].ToolCallID); toolCallID != "" {
responded[toolCallID] = struct{}{}
}
}
for _, toolCall := range message.ToolCalls {
if toolCallID := strings.TrimSpace(toolCall.ID); toolCallID != "" {
if _, ok := responded[toolCallID]; ok {
survivingToolCallIDs[toolCallID] = struct{}{}
}
}
}
}
trimmed := make([]modeladapter.Message, 0, len(messages))
for _, item := range messages {
message := cloneReplayModelMessage(item)
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
nextToolCalls := make([]modeladapter.ToolCallDescriptor, 0, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
if _, ok := survivingToolCallIDs[strings.TrimSpace(toolCall.ID)]; !ok {
continue
}
toolCall.Index = len(nextToolCalls)
nextToolCalls = append(nextToolCalls, toolCall)
}
if len(nextToolCalls) == 0 {
if strings.TrimSpace(message.Content) == "" && len(message.ContentParts) == 0 && !hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
continue
}
message.ToolCalls = nil
} else {
message.ToolCalls = nextToolCalls
}
trimmed = append(trimmed, message)
continue
}
end := index + 1
responded := make(map[string]struct{}, len(message.ToolCalls))
for end < len(messages) && strings.TrimSpace(messages[end].Role) == "tool" {
toolCallID := strings.TrimSpace(messages[end].ToolCallID)
if toolCallID != "" {
responded[toolCallID] = struct{}{}
}
end++
}
nextToolCalls := make([]modeladapter.ToolCallDescriptor, 0, len(message.ToolCalls))
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
toolCallID := strings.TrimSpace(toolCall.ID)
if _, ok := responded[toolCallID]; !ok {
if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok {
continue
}
item := toolCall
item.Index = len(nextToolCalls)
nextToolCalls = append(nextToolCalls, item)
allowedToolCallIDs[toolCallID] = struct{}{}
}
if len(nextToolCalls) > 0 {
message.ToolCalls = nextToolCalls
trimmed = append(trimmed, message)
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
toolMessage := cloneReplayModelMessage(messages[toolIndex])
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
continue
}
trimmed = append(trimmed, toolMessage)
}
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
message.ToolCalls = nil
trimmed = append(trimmed, message)
}
index = end - 1
trimmed = append(trimmed, message)
}
return trimmed
}
@@ -0,0 +1,89 @@
package forwarder
import (
"testing"
modeladapter "cursor/internal/backend/agent/model"
)
func replayToolCall(id string, name string) modeladapter.ToolCallDescriptor {
return modeladapter.ToolCallDescriptor{
ID: id,
Type: "function",
Function: modeladapter.ToolCallFunctionShape{
Name: name,
Arguments: "{}",
},
}
}
// 模型在同一条响应里先输出 function_call 再输出说明文本时,
// 历史回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result]。
// trimReplayDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果。
func TestTrimReplayDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) {
messages := []modeladapter.Message{
{Role: "user", Content: "query"},
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")}},
{Role: "assistant", Content: "我先快速定位上下文"},
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
{Role: "user", Content: "next"},
}
trimmed := trimReplayDanglingAssistantToolCalls(messages)
if len(trimmed) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
t.Fatalf("expected assistant tool call call_1 to survive, got %+v", trimmed[1].ToolCalls)
}
if trimmed[3].Role != "tool" || trimmed[3].ToolCallID != "call_1" {
t.Fatalf("expected tool result call_1 to survive, got %+v", trimmed[3])
}
}
// 没有任何结果回放的调用仍应被剥离;被剥离调用对应的 tool 结果(若存在)也不得保留。
func TestTrimReplayDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) {
messages := []modeladapter.Message{
{Role: "user", Content: "query"},
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{
replayToolCall("call_1", "Grep"),
replayToolCall("call_2", "Read"),
}},
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
{Role: "tool", Name: "Read", ToolCallID: "call_3", Content: "orphan result"},
{Role: "user", Content: "next"},
}
trimmed := trimReplayDanglingAssistantToolCalls(messages)
if len(trimmed) != 4 {
t.Fatalf("expected 4 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
t.Fatalf("expected only call_1 to survive, got %+v", trimmed[1].ToolCalls)
}
if trimmed[2].ToolCallID != "call_1" {
t.Fatalf("expected only call_1 result to survive, got %+v", trimmed[2])
}
}
// 调用全部悬空但消息携带可回放 reasoning 时,保留为无调用的 assistant 消息。
func TestTrimReplayDanglingAssistantToolCallsKeepsReasoningOnlyShell(t *testing.T) {
messages := []modeladapter.Message{
{Role: "user", Content: "query"},
{
Role: "assistant",
ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")},
ReasoningSignature: "sig",
ReasoningSignatureSource: modeladapter.ReasoningSignatureSourceOpenAIResponses,
},
{Role: "user", Content: "next"},
}
trimmed := trimReplayDanglingAssistantToolCalls(messages)
if len(trimmed) != 3 {
t.Fatalf("expected 3 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 0 {
t.Fatalf("expected tool calls to be trimmed, got %+v", trimmed[1].ToolCalls)
}
}