/ internal / agent / spill_test.go
spill_test.go
  1  package agent
  2  
  3  import (
  4  	"os"
  5  	"path/filepath"
  6  	"strings"
  7  	"testing"
  8  )
  9  
 10  // TestSpillToDisk_RejectsEmptyShannonDir is the regression for the bug
 11  // where filepath.Join("", "tmp") = "tmp" — a relative path — caused spill
 12  // files to land in the process cwd (e.g. internal/agent/tmp/ during unit
 13  // tests). The guard now refuses to write when shannonDir is empty.
 14  func TestSpillToDisk_RejectsEmptyShannonDir(t *testing.T) {
 15  	_, err := spillToDisk("", "sess1", "call1", "content")
 16  	if err == nil {
 17  		t.Fatal("expected spillToDisk to reject empty shannonDir")
 18  	}
 19  	if !strings.Contains(err.Error(), "shannonDir") {
 20  		t.Errorf("expected error to mention shannonDir, got: %v", err)
 21  	}
 22  }
 23  
 24  func TestSpillToDisk_SmallResult(t *testing.T) {
 25  	// Results under threshold should not be spilled (caller checks threshold).
 26  	// This test verifies spillToDisk works even for small content.
 27  	dir := t.TempDir()
 28  	content := "small output"
 29  	preview, err := spillToDisk(dir, "sess1", "call1", content)
 30  	if err != nil {
 31  		t.Fatal(err)
 32  	}
 33  	if !strings.Contains(preview, "Output saved to disk") {
 34  		t.Fatal("expected spill preview header")
 35  	}
 36  	if !strings.Contains(preview, "small output") {
 37  		t.Fatal("expected full content in preview for small output")
 38  	}
 39  }
 40  
 41  func TestSpillToDisk_LargeResult(t *testing.T) {
 42  	dir := t.TempDir()
 43  	// Build a 60K rune string.
 44  	content := strings.Repeat("x", 60000)
 45  	preview, err := spillToDisk(dir, "sess1", "call2", content)
 46  	if err != nil {
 47  		t.Fatal(err)
 48  	}
 49  
 50  	// Preview should contain the file path and char count.
 51  	if !strings.Contains(preview, "60000 chars") {
 52  		t.Fatalf("expected char count in preview, got: %s", preview[:200])
 53  	}
 54  
 55  	// Preview should be truncated to ~2000 chars of content + header.
 56  	previewContent := preview[strings.Index(preview, "Preview (first 2000 chars):\n")+len("Preview (first 2000 chars):\n"):]
 57  	if len([]rune(previewContent)) != spillPreviewChars {
 58  		t.Fatalf("expected preview content of %d runes, got %d", spillPreviewChars, len([]rune(previewContent)))
 59  	}
 60  
 61  	// Full content should be on disk.
 62  	path := filepath.Join(dir, "tmp", "tool_result_sess1_call2.txt")
 63  	data, err := os.ReadFile(path)
 64  	if err != nil {
 65  		t.Fatalf("spill file not readable: %v", err)
 66  	}
 67  	if len(data) != 60000 {
 68  		t.Fatalf("expected 60000 bytes on disk, got %d", len(data))
 69  	}
 70  }
 71  
 72  func TestSpillToDisk_RuneSafe(t *testing.T) {
 73  	dir := t.TempDir()
 74  	// Multi-byte chars: each is 3 bytes in UTF-8.
 75  	content := strings.Repeat("あ", 60000)
 76  	preview, err := spillToDisk(dir, "sess1", "call3", content)
 77  	if err != nil {
 78  		t.Fatal(err)
 79  	}
 80  	// Preview content should be exactly 2000 runes, not bytes.
 81  	idx := strings.Index(preview, "Preview (first 2000 chars):\n")
 82  	previewContent := preview[idx+len("Preview (first 2000 chars):\n"):]
 83  	if len([]rune(previewContent)) != spillPreviewChars {
 84  		t.Fatalf("expected %d runes in preview, got %d", spillPreviewChars, len([]rune(previewContent)))
 85  	}
 86  }
 87  
 88  func TestCleanupSpills(t *testing.T) {
 89  	dir := t.TempDir()
 90  	// Create spill files for two sessions.
 91  	spillToDisk(dir, "sess1", "a", "data-a")
 92  	spillToDisk(dir, "sess1", "b", "data-b")
 93  	spillToDisk(dir, "sess2", "c", "data-c")
 94  
 95  	// Cleanup sess1 only.
 96  	cleanupSpills(dir, "sess1")
 97  
 98  	// sess1 files should be gone.
 99  	matches, _ := filepath.Glob(filepath.Join(dir, "tmp", "tool_result_sess1_*.txt"))
100  	if len(matches) != 0 {
101  		t.Fatalf("expected 0 sess1 files after cleanup, got %d", len(matches))
102  	}
103  
104  	// sess2 file should remain.
105  	matches, _ = filepath.Glob(filepath.Join(dir, "tmp", "tool_result_sess2_*.txt"))
106  	if len(matches) != 1 {
107  		t.Fatalf("expected 1 sess2 file, got %d", len(matches))
108  	}
109  }
110  
111  // TestSpillFiles_SurviveBetweenRuns verifies that spill files created in one
112  // run are still readable before cleanup (session close). This is the regression
113  // test for the bug where SpillCleanupFunc was deferred per-Run, deleting files
114  // that subsequent turns could still reference.
115  func TestSpillFiles_SurviveBetweenRuns(t *testing.T) {
116  	dir := t.TempDir()
117  
118  	// Turn 1: spill a large result
119  	_, err := spillToDisk(dir, "sess1", "call1", strings.Repeat("x", 60000))
120  	if err != nil {
121  		t.Fatal(err)
122  	}
123  
124  	// Between turns: file must still exist
125  	path1 := filepath.Join(dir, "tmp", "tool_result_sess1_call1.txt")
126  	if _, err := os.Stat(path1); err != nil {
127  		t.Fatalf("spill file should survive between turns: %v", err)
128  	}
129  
130  	// Turn 2: spill another result
131  	_, err = spillToDisk(dir, "sess1", "call2", strings.Repeat("y", 60000))
132  	if err != nil {
133  		t.Fatal(err)
134  	}
135  
136  	// Both files should exist (cleanup hasn't run yet)
137  	path2 := filepath.Join(dir, "tmp", "tool_result_sess1_call2.txt")
138  	for _, p := range []string{path1, path2} {
139  		if _, err := os.Stat(p); err != nil {
140  			t.Fatalf("spill file should exist before session close: %v", err)
141  		}
142  	}
143  
144  	// Session close: cleanup
145  	cleanupSpills(dir, "sess1")
146  
147  	// Now both should be gone
148  	for _, p := range []string{path1, path2} {
149  		if _, err := os.Stat(p); !os.IsNotExist(err) {
150  			t.Fatalf("spill file should be gone after session close: %s", p)
151  		}
152  	}
153  }
154  
155  func TestSpillThresholdIntegration(t *testing.T) {
156  	// Verify that content under spillThreshold would not trigger spill
157  	// (the check is in loop.go, but we verify the constant here).
158  	under := strings.Repeat("x", spillThreshold)
159  	if len([]rune(under)) > spillThreshold {
160  		t.Fatal("threshold constant mismatch")
161  	}
162  	over := strings.Repeat("x", spillThreshold+1)
163  	if len([]rune(over)) <= spillThreshold {
164  		t.Fatal("threshold constant mismatch")
165  	}
166  }