Files
goclaw/internal/agent/loop_history_edge_test.go
Thanh_Dang 68ad5953d7 fix(pipeline): defer non-tool messages until all tool results are emitted (#1270)
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
2026-06-24 10:49:31 +07:00

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")
}
}