mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
Multi-tool responses could interleave synthetic user messages (loop warnings, nudges) between tool results, breaking OpenAI-compatible providers that require contiguous tool_result grouping. Fix both sequential and parallel tool execution paths in tool_stage.go to defer non-tool messages until after all tool results are appended. Fix sanitizeHistory lookahead in loop_history_sanitize.go to reorder interleaved non-tool messages in persisted session history. Closes #1177
383 lines
13 KiB
Go
383 lines
13 KiB
Go
package agent
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// --- sanitizeHistory: all-tool-message history ---
|
|
// After aggressive truncation, history could be ALL tool messages.
|
|
// Should return nil (no useful messages), not crash.
|
|
|
|
func TestSanitizeHistory_AllToolMessages(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "tool", Content: "result1", ToolCallID: "tc1"},
|
|
{Role: "tool", Content: "result2", ToolCallID: "tc2"},
|
|
{Role: "tool", Content: "result3", ToolCallID: "tc3"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
if result != nil {
|
|
t.Fatalf("all-tool history should return nil, got %d messages", len(result))
|
|
}
|
|
if dropped != 3 {
|
|
t.Fatalf("expected 3 dropped, got %d", dropped)
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: single user message (no tools) ---
|
|
|
|
func TestSanitizeHistory_SingleUserMessage(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "hello"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(result))
|
|
}
|
|
if dropped != 0 {
|
|
t.Fatalf("expected 0 dropped, got %d", dropped)
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: assistant with tool_calls but ALL results missing ---
|
|
// Every tool_call should get a synthesized "[Tool result missing]" placeholder.
|
|
|
|
func TestSanitizeHistory_AllToolResultsMissing(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "do something"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "read_file", Arguments: map[string]any{"path": "a.go"}},
|
|
{ID: "tc2", Name: "read_file", Arguments: map[string]any{"path": "b.go"}},
|
|
{ID: "tc3", Name: "read_file", Arguments: map[string]any{"path": "c.go"}},
|
|
}},
|
|
// No tool results at all — all missing
|
|
{Role: "user", Content: "next message"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
// Should have: user + assistant + 3 synthesized tool results + user
|
|
if len(result) != 6 {
|
|
t.Fatalf("expected 6 messages (user + assistant + 3 synth + user), got %d", len(result))
|
|
}
|
|
if dropped != 3 {
|
|
t.Fatalf("expected 3 synthesized (counted as dropped), got %d", dropped)
|
|
}
|
|
|
|
// Verify synthesized messages
|
|
for i := 2; i <= 4; i++ {
|
|
if result[i].Role != "tool" {
|
|
t.Fatalf("message %d: expected role 'tool', got %q", i, result[i].Role)
|
|
}
|
|
if !strings.Contains(result[i].Content, "missing") {
|
|
t.Fatalf("message %d: expected 'missing' placeholder, got %q", i, result[i].Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: interleaved tool results from wrong assistant ---
|
|
// tool_result IDs that don't match the preceding assistant's tool_calls
|
|
// should be dropped.
|
|
|
|
func TestSanitizeHistory_ToolResultsFromWrongAssistant(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "first"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc_a", Name: "read_file", Arguments: map[string]any{}},
|
|
}},
|
|
// Tool result with wrong ID (from a different assistant turn)
|
|
{Role: "tool", Content: "wrong result", ToolCallID: "tc_z"},
|
|
// Correct result
|
|
// (missing — will be synthesized)
|
|
{Role: "user", Content: "next"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
// tc_z dropped, tc_a synthesized
|
|
if dropped != 2 {
|
|
t.Fatalf("expected 2 dropped (1 mismatched + 1 synthesized), got %d", dropped)
|
|
}
|
|
|
|
// Should have: user + assistant + synth(tc_a) + user
|
|
if len(result) != 4 {
|
|
t.Fatalf("expected 4 messages, got %d", len(result))
|
|
}
|
|
if result[2].ToolCallID != "tc_a" {
|
|
t.Fatalf("synthesized result should have tc_a, got %q", result[2].ToolCallID)
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: multiple tool_calls with partial results ---
|
|
// 3 tool_calls but only 1 result provided → 2 synthesized
|
|
|
|
func TestSanitizeHistory_PartialToolResults(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "go"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "read_file", Arguments: map[string]any{"path": "a"}},
|
|
{ID: "tc2", Name: "write_file", Arguments: map[string]any{"path": "b"}},
|
|
{ID: "tc3", Name: "exec", Arguments: map[string]any{"cmd": "ls"}},
|
|
}},
|
|
{Role: "tool", Content: "content of a", ToolCallID: "tc1"},
|
|
// tc2 and tc3 missing
|
|
{Role: "user", Content: "done"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
// user + assistant + tc1_result + tc2_synth + tc3_synth + user
|
|
if len(result) != 6 {
|
|
t.Fatalf("expected 6 messages, got %d", len(result))
|
|
}
|
|
if dropped != 2 {
|
|
t.Fatalf("expected 2 synthesized, got %d", dropped)
|
|
}
|
|
// Verify order: real result first, then synthesized
|
|
if result[2].ToolCallID != "tc1" || strings.Contains(result[2].Content, "missing") {
|
|
t.Fatal("tc1 should be the real result, not synthesized")
|
|
}
|
|
if result[3].ToolCallID != "tc2" || !strings.Contains(result[3].Content, "missing") {
|
|
t.Fatal("tc2 should be synthesized")
|
|
}
|
|
if result[4].ToolCallID != "tc3" || !strings.Contains(result[4].Content, "missing") {
|
|
t.Fatal("tc3 should be synthesized")
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: tool message between two user messages (orphaned mid-history) ---
|
|
|
|
func TestSanitizeHistory_OrphanedToolBetweenUsers(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "first"},
|
|
{Role: "assistant", Content: "response"},
|
|
{Role: "tool", Content: "orphaned", ToolCallID: "tc_orphan"}, // no preceding tool_calls
|
|
{Role: "user", Content: "second"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
if dropped != 1 {
|
|
t.Fatalf("expected 1 dropped orphan, got %d", dropped)
|
|
}
|
|
// user + assistant + user (orphan dropped)
|
|
if len(result) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: dedup with 2 identical IDs across 2 turns ---
|
|
// Turn 1: ID "dup" → kept as-is (first occurrence)
|
|
// Turn 2: ID "dup" → rewritten to "dup_dedup_0", tool result matched via idQueue
|
|
|
|
func TestSanitizeHistory_DedupAcrossTwoTurns(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
// Turn 1
|
|
{Role: "user", Content: "1"},
|
|
{Role: "assistant", ToolCalls: []providers.ToolCall{{ID: "dup", Name: "read_file", Arguments: map[string]any{}}}},
|
|
{Role: "tool", Content: "r1", ToolCallID: "dup"},
|
|
// Turn 2 — same ID
|
|
{Role: "user", Content: "2"},
|
|
{Role: "assistant", ToolCalls: []providers.ToolCall{{ID: "dup", Name: "read_file", Arguments: map[string]any{}}}},
|
|
{Role: "tool", Content: "r2", ToolCallID: "dup"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
// All 6 messages should be preserved (dedup rewrites turn 2's ID)
|
|
if len(result) != 6 {
|
|
t.Fatalf("expected 6 messages preserved via dedup, got %d", len(result))
|
|
}
|
|
// Dedup rewrites count as changes (dropped > 0 triggers DB persistence).
|
|
if dropped != 1 {
|
|
t.Fatalf("expected 1 dropped (dedup counts as change), got %d", dropped)
|
|
}
|
|
|
|
// Turn 1 assistant should keep original ID
|
|
if result[1].ToolCalls[0].ID != "dup" {
|
|
t.Fatalf("turn 1 ID should be 'dup', got %q", result[1].ToolCalls[0].ID)
|
|
}
|
|
// Turn 2 assistant should have rewritten ID
|
|
if result[4].ToolCalls[0].ID == "dup" {
|
|
t.Fatal("turn 2 ID should be rewritten, still 'dup'")
|
|
}
|
|
// Turn 2 tool result should match the rewritten ID
|
|
if result[5].ToolCallID != result[4].ToolCalls[0].ID {
|
|
t.Fatalf("turn 2 tool result ID %q should match assistant ID %q",
|
|
result[5].ToolCallID, result[4].ToolCalls[0].ID)
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: performance with large history ---
|
|
// Verify sanitization doesn't degrade catastrophically with many messages.
|
|
|
|
func TestSanitizeHistory_LargeHistory_Performance(t *testing.T) {
|
|
// Build 1000-message history with proper tool pairing
|
|
msgs := make([]providers.Message, 0, 1000)
|
|
for i := range 250 {
|
|
tcID := fmt.Sprintf("tc_%05d", i)
|
|
msgs = append(msgs,
|
|
providers.Message{Role: "user", Content: "question " + tcID},
|
|
providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{
|
|
{ID: tcID, Name: "read_file", Arguments: map[string]any{"path": "file.go"}},
|
|
}},
|
|
providers.Message{Role: "tool", Content: "result", ToolCallID: tcID},
|
|
providers.Message{Role: "assistant", Content: "answer"},
|
|
)
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
if dropped != 0 {
|
|
t.Fatalf("well-formed history should have 0 drops, got %d", dropped)
|
|
}
|
|
if len(result) != 1000 {
|
|
t.Fatalf("expected 1000 messages, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: empty ToolCallID in tool result ---
|
|
|
|
func TestSanitizeHistory_EmptyToolCallID(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "go"},
|
|
{Role: "assistant", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "read_file", Arguments: map[string]any{}},
|
|
}},
|
|
{Role: "tool", Content: "result", ToolCallID: ""}, // empty ID — won't match
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
// Empty ID tool result should be dropped (mismatched)
|
|
// tc1 should get a synthesized result
|
|
if dropped != 2 {
|
|
t.Fatalf("expected 2 dropped (1 mismatched + 1 synthesized), got %d", dropped)
|
|
}
|
|
// user + assistant + synth(tc1)
|
|
if len(result) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: interleaved user warning between tool results (#1177) ---
|
|
// When a synthetic user message (loop warning) appears between tool results
|
|
// for the same assistant, it should be deferred after all tool results.
|
|
|
|
func TestSanitizeHistory_InterleavedUserWarningBetweenToolResults(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "do work"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "read_file", Arguments: map[string]any{}},
|
|
{ID: "tc2", Name: "read_file", Arguments: map[string]any{}},
|
|
{ID: "tc3", Name: "read_file", Arguments: map[string]any{}},
|
|
}},
|
|
{Role: "tool", Content: "result1", ToolCallID: "tc1"},
|
|
{Role: "user", Content: "loop warning: read-only streak"}, // interleaved!
|
|
{Role: "tool", Content: "result2", ToolCallID: "tc2"},
|
|
{Role: "tool", Content: "result3", ToolCallID: "tc3"},
|
|
{Role: "user", Content: "next question"},
|
|
}
|
|
|
|
result, dropped := sanitizeHistory(msgs)
|
|
|
|
// All tool results should be contiguous, warning deferred after them.
|
|
// Expected order: user, assistant, tool(tc1), tool(tc2), tool(tc3), user(warning), user(next)
|
|
// The two consecutive user messages will be merged by role-alternation fix.
|
|
if dropped < 0 {
|
|
t.Fatalf("unexpected negative dropped: %d", dropped)
|
|
}
|
|
|
|
// Verify tool results are contiguous (no non-tool message between them)
|
|
toolStart := -1
|
|
toolEnd := -1
|
|
for i, m := range result {
|
|
if m.Role == "tool" {
|
|
if toolStart == -1 {
|
|
toolStart = i
|
|
}
|
|
toolEnd = i
|
|
}
|
|
}
|
|
if toolStart >= 0 {
|
|
for i := toolStart; i <= toolEnd; i++ {
|
|
if result[i].Role != "tool" {
|
|
t.Fatalf("non-tool message at index %d between tool results (role=%s, content=%q)",
|
|
i, result[i].Role, result[i].Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify all 3 tool call IDs are present
|
|
toolIDs := make(map[string]bool)
|
|
for _, m := range result {
|
|
if m.Role == "tool" {
|
|
toolIDs[m.ToolCallID] = true
|
|
}
|
|
}
|
|
for _, id := range []string{"tc1", "tc2", "tc3"} {
|
|
if !toolIDs[id] {
|
|
t.Fatalf("missing tool result for %s", id)
|
|
}
|
|
}
|
|
|
|
// Verify warning appears after all tool results
|
|
warningIdx := slices.IndexFunc(result, func(m providers.Message) bool {
|
|
return m.Role == "user" && strings.Contains(m.Content, "loop warning")
|
|
})
|
|
if warningIdx >= 0 && warningIdx <= toolEnd {
|
|
t.Fatalf("warning (index %d) should appear after last tool result (index %d)", warningIdx, toolEnd)
|
|
}
|
|
}
|
|
|
|
// --- sanitizeHistory: multiple warnings interleaved between tool results (#1177) ---
|
|
|
|
func TestSanitizeHistory_MultipleWarningsBetweenToolResults(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "go"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "exec", Arguments: map[string]any{}},
|
|
{ID: "tc2", Name: "exec", Arguments: map[string]any{}},
|
|
}},
|
|
{Role: "tool", Content: "r1", ToolCallID: "tc1"},
|
|
{Role: "user", Content: "warning1"},
|
|
{Role: "user", Content: "warning2"},
|
|
{Role: "tool", Content: "r2", ToolCallID: "tc2"},
|
|
}
|
|
|
|
result, _ := sanitizeHistory(msgs)
|
|
|
|
// Tool results must be contiguous
|
|
toolStart := -1
|
|
toolEnd := -1
|
|
for i, m := range result {
|
|
if m.Role == "tool" {
|
|
if toolStart == -1 {
|
|
toolStart = i
|
|
}
|
|
toolEnd = i
|
|
}
|
|
}
|
|
if toolStart >= 0 {
|
|
for i := toolStart; i <= toolEnd; i++ {
|
|
if result[i].Role != "tool" {
|
|
t.Fatalf("non-tool message at index %d between tool results", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Both tool results present
|
|
if !slices.ContainsFunc(result, func(m providers.Message) bool { return m.ToolCallID == "tc1" }) {
|
|
t.Fatal("missing tc1")
|
|
}
|
|
if !slices.ContainsFunc(result, func(m providers.Message) bool { return m.ToolCallID == "tc2" }) {
|
|
t.Fatal("missing tc2")
|
|
}
|
|
}
|