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 }