diff --git a/internal/backend/agent/model/openai.go b/internal/backend/agent/model/openai.go index 908a6ae..4dd6631 100644 --- a/internal/backend/agent/model/openai.go +++ b/internal/backend/agent/model/openai.go @@ -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{}{} + } } } } diff --git a/internal/backend/agent/model/openai_responses_orphan_output_test.go b/internal/backend/agent/model/openai_responses_orphan_output_test.go new file mode 100644 index 0000000..8712f2f --- /dev/null +++ b/internal/backend/agent/model/openai_responses_orphan_output_test.go @@ -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) + } +} diff --git a/internal/backend/agent/model/router.go b/internal/backend/agent/model/router.go index 787c22e..7b9b991 100644 --- a/internal/backend/agent/model/router.go +++ b/internal/backend/agent/model/router.go @@ -339,57 +339,82 @@ func mergeProviderReasoningMetadata(last *Message, current Message) { } } +// providerToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。 +// 窗口内除 tool 结果消息外,还允许出现同轮穿插的纯文本 assistant 消息: +// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本, +// 回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result],若只收集紧邻的 +// tool 消息,会把有结果回放的调用误判为悬空。 +func providerToolResponseWindowEnd(messages []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 trimDanglingAssistantToolCalls(input []Message) []Message { if len(input) == 0 { return nil } - trimmed := make([]Message, 0, len(input)) - for index := 0; index < len(input); index++ { - message := cloneProviderMessage(input[index]) + survivingToolCallIDs := make(map[string]struct{}) + for index, message := range input { if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 { + continue + } + responded := make(map[string]struct{}, len(message.ToolCalls)) + for scan := index + 1; scan < providerToolResponseWindowEnd(input, index); scan++ { + if strings.TrimSpace(input[scan].Role) != "tool" { + continue + } + if toolCallID := strings.TrimSpace(input[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([]Message, 0, len(input)) + for _, item := range input { + message := cloneProviderMessage(item) + if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 { + nextToolCalls := make([]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 && strings.TrimSpace(message.ReasoningContent) == "" { + 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(input) && strings.TrimSpace(input[end].Role) == "tool" { - toolCallID := strings.TrimSpace(input[end].ToolCallID) - if toolCallID != "" { - responded[toolCallID] = struct{}{} - } - end++ - } - - nextToolCalls := make([]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 := cloneProviderMessage(input[toolIndex]) - if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok { - continue - } - trimmed = append(trimmed, toolMessage) - } - } else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" { - message.ToolCalls = nil - trimmed = append(trimmed, message) - } - - index = end - 1 + trimmed = append(trimmed, message) } return trimmed } diff --git a/internal/backend/agent/model/router_dangling_tool_call_test.go b/internal/backend/agent/model/router_dangling_tool_call_test.go new file mode 100644 index 0000000..fa19ada --- /dev/null +++ b/internal/backend/agent/model/router_dangling_tool_call_test.go @@ -0,0 +1,77 @@ +package modeladapter + +import "testing" + +func routerToolCall(id string, name string) ToolCallDescriptor { + return ToolCallDescriptor{ + ID: id, + Type: "function", + Function: ToolCallFunctionShape{ + Name: name, + Arguments: `{"pattern":"ref"}`, + }, + } +} + +// 模型在同一条响应里先输出 function_call 再输出说明文本时, +// sanitize 后的消息序列为 assistant[tool_call] -> assistant[text] -> tool[result]。 +// trimDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果, +// 否则会产生孤儿 function_call_output(Responses API 400)。 +func TestTrimDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) { + input := []Message{ + {Role: "user", Content: "query"}, + { + Role: "assistant", + ToolCalls: []ToolCallDescriptor{ + routerToolCall("call_1", "Grep"), + routerToolCall("call_2", "Read"), + }, + }, + {Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"}, + {Role: "assistant", Content: "我先快速定位上下文"}, + {Role: "tool", Name: "Read", ToolCallID: "call_2", Content: "read result"}, + {Role: "user", Content: "next"}, + } + + trimmed := sanitizeProviderMessages(input) + if len(trimmed) != 6 { + t.Fatalf("expected 6 messages, got %d: %+v", len(trimmed), trimmed) + } + if len(trimmed[1].ToolCalls) != 2 { + t.Fatalf("expected both tool calls to survive, got %+v", trimmed[1].ToolCalls) + } + if trimmed[3].Role != "assistant" || trimmed[3].Content == "" { + t.Fatalf("expected interleaved assistant text to survive, got %+v", trimmed[3]) + } + if trimmed[4].ToolCallID != "call_2" { + t.Fatalf("expected call_2 result to survive, got %+v", trimmed[4]) + } +} + +// 完全没有结果回放的调用仍应被剥离,且孤儿 tool 结果不得保留。 +func TestTrimDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) { + input := []Message{ + {Role: "user", Content: "query"}, + { + Role: "assistant", + ToolCalls: []ToolCallDescriptor{ + routerToolCall("call_1", "Grep"), + routerToolCall("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 := sanitizeProviderMessages(input) + 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]) + } +} diff --git a/internal/backend/agent/model/tool_image_test.go b/internal/backend/agent/model/tool_image_test.go index 36ab108..be15e7e 100644 --- a/internal/backend/agent/model/tool_image_test.go +++ b/internal/backend/agent/model/tool_image_test.go @@ -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) diff --git a/internal/backend/forwarder/projector.go b/internal/backend/forwarder/projector.go index 7f454b7..6b049e8 100644 --- a/internal/backend/forwarder/projector.go +++ b/internal/backend/forwarder/projector.go @@ -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 } diff --git a/internal/backend/forwarder/projector_replay_trim_test.go b/internal/backend/forwarder/projector_replay_trim_test.go new file mode 100644 index 0000000..c0d799d --- /dev/null +++ b/internal/backend/forwarder/projector_replay_trim_test.go @@ -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) + } +}