| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/tool" |
| 15 | ) |
| 16 | |
| 17 | // fatTool returns a fixed-size blob, standing in for a real read_file / bash |
| 18 | // whose output dominates the recent (verbatim-kept) tail of the session. |
| 19 | type fatTool struct{ blob string } |
| 20 | |
| 21 | func (fatTool) Name() string { return "fat_read" } |
| 22 | func (fatTool) Description() string { return "read a large file" } |
| 23 | func (fatTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{}}`) } |
| 24 | func (fatTool) ReadOnly() bool { return true } |
| 25 | func (f fatTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 26 | return f.blob, nil |
| 27 | } |
| 28 | |
| 29 | // loopMock emits exactly one tool call per user turn (a tool call when the last |
| 30 | // message is the user's, a final answer when it is the tool result), so each Run |
| 31 | // does one tool round — the next request then runs ContextManager.Prepare. finalText overrides |
| 32 | // the per-turn closing answer so a test can grow the session with assistant text |
| 33 | // (which pruning never touches) instead of tool output. |
| 34 | type loopMock struct { |
| 35 | t *testing.T |
| 36 | rounds int |
| 37 | finalText string |
| 38 | } |
| 39 | |
| 40 | func lastRole(msgs []json.RawMessage) string { |
| 41 | if len(msgs) == 0 { |
| 42 | return "" |
| 43 | } |
| 44 | var m struct { |
| 45 | Role string `json:"role"` |
| 46 | } |
| 47 | _ = json.Unmarshal(msgs[len(msgs)-1], &m) |
| 48 | return m.Role |
| 49 | } |
| 50 | |
| 51 | func (m *loopMock) handler(w http.ResponseWriter, r *http.Request) { |
| 52 | body, _ := io.ReadAll(r.Body) |
| 53 | if isSummarizeRequest(body) { |
| 54 | writeSSE(w, m.t, |
| 55 | streamChunk(deltaText("- goal: keep going\n- pending: continue the task")), |
| 56 | finishChunk("stop"), |
| 57 | usageChunk(80, 30, 0, 80)) |
| 58 | return |
| 59 | } |
| 60 | |
| 61 | msgs := decodeMessages(body) |
| 62 | promptTok := charsOf(msgs) / 4 |
| 63 | |
| 64 | if lastRole(msgs) == "tool" { |
| 65 | text := m.finalText |
| 66 | if text == "" { |
| 67 | text = "Done with this step." |
| 68 | } |
| 69 | writeSSE(w, m.t, |
| 70 | streamChunk(deltaText(text)), |
| 71 | finishChunk("stop"), |
| 72 | usageChunk(promptTok, 20, 0, promptTok)) |
| 73 | return |
| 74 | } |
| 75 | |
| 76 | m.rounds++ |
| 77 | writeSSE(w, m.t, |
| 78 | streamChunk(deltaToolCall(m.rounds, "fat_read", "{}")), |
| 79 | finishChunk("tool_calls"), |
| 80 | usageChunk(promptTok, 20, 0, promptTok)) |
| 81 | } |
| 82 | |
| 83 | // compactionsPerTurn drives `turns` user messages through a fresh agent wired to |
| 84 | // loopMock and reports, per turn, how many compactions started and whether an |
| 85 | // durable blocked receipt was seen. |
| 86 | func compactionsPerTurn(t *testing.T, windowTok int, blob, finalText string, turns int) (perTurn []int, paused bool, prunes int) { |
| 87 | t.Helper() |
| 88 | mock := &loopMock{t: t, finalText: finalText} |
| 89 | srv := httptest.NewServer(http.HandlerFunc(mock.handler)) |
| 90 | defer srv.Close() |
| 91 | |
| 92 | reg := tool.NewRegistry() |
| 93 | reg.Add(fatTool{blob: blob}) |
| 94 | |
| 95 | a, _ := newAgent(t, srv.URL, reg, windowTok, 4) |
| 96 | started := 0 |
| 97 | a.svc.sink = event.FuncSink(func(e event.Event) { |
| 98 | switch e.Kind { |
| 99 | case event.CompactionStarted: |
| 100 | started++ |
| 101 | case event.Notice: |
| 102 | if strings.Contains(e.Text, "Automatic context cleanup paused") { |
| 103 | paused = true |
| 104 | } |
| 105 | if strings.Contains(e.Text, "pruned") { |
| 106 | prunes++ |
| 107 | } |
| 108 | case event.ContextMaintenanceEvent: |
| 109 | if e.Maintenance != nil && e.Maintenance.Status == "blocked" { |
| 110 | paused = true |
| 111 | } |
| 112 | if e.Maintenance != nil && e.Maintenance.Status == "applied" && e.Maintenance.Action == "prune" { |
| 113 | prunes++ |
| 114 | } |
| 115 | } |
| 116 | }) |
| 117 | |
| 118 | perTurn = make([]int, turns) |
| 119 | for i := range turns { |
| 120 | before := started |
| 121 | if err := a.Run(context.Background(), fmt.Sprintf("turn %d: keep going, continue the work", i)); err != nil { |
| 122 | t.Fatalf("Run %d: %v", i, err) |
| 123 | } |
| 124 | perTurn[i] = started - before |
| 125 | } |
| 126 | return perTurn, paused, prunes |
| 127 | } |
| 128 | |
| 129 | func consecutiveCompactingTurns(perTurn []int) int { |
| 130 | worst, run := 0, 0 |
| 131 | for _, n := range perTurn { |
| 132 | if n > 0 { |
| 133 | run++ |
| 134 | if run > worst { |
| 135 | worst = run |
| 136 | } |
| 137 | } else { |
| 138 | run = 0 |
| 139 | } |
| 140 | } |
| 141 | return worst |
| 142 | } |
| 143 | |
| 144 | // TestCompactionStopsWhenProtectedContentExceedsWindow covers the user report |
| 145 | // where a single tool result alone exhausts a tiny window. Automatic maintenance |
| 146 | // no longer prunes mid-session tool bodies: it attempts one summary, records a |
| 147 | // generation-scoped block when the candidate cannot land, and must not loop. |
| 148 | func TestCompactionPausesWhenWindowTooSmall(t *testing.T) { |
| 149 | mock := &loopMock{t: t} |
| 150 | srv := httptest.NewServer(http.HandlerFunc(mock.handler)) |
| 151 | defer srv.Close() |
| 152 | reg := tool.NewRegistry() |
| 153 | reg.Add(fatTool{blob: strings.Repeat("LARGE FILE CONTENTS. ", 350)}) |
| 154 | a, _ := newAgent(t, srv.URL, reg, 1600, 4) |
| 155 | started := 0 |
| 156 | blocked := 0 |
| 157 | a.svc.sink = event.FuncSink(func(e event.Event) { |
| 158 | if e.Kind == event.CompactionStarted { |
| 159 | started++ |
| 160 | } |
| 161 | if e.Kind == event.ContextMaintenanceEvent && e.Maintenance != nil && |
| 162 | (e.Maintenance.Status == "blocked" || e.Maintenance.Status == "failed") { |
| 163 | blocked++ |
| 164 | } |
| 165 | }) |
| 166 | // First turn may fail with a typed overflow/blocked error once protected |
| 167 | // content cannot form a safe checkpoint. It must not start many summaries. |
| 168 | _ = a.Run(context.Background(), "turn 0: keep going") |
| 169 | _ = a.Run(context.Background(), "turn 1: keep going") |
| 170 | if started > 2 { |
| 171 | t.Fatalf("summary transactions started = %d, want ≤2 (no multi-span / retry loop)", started) |
| 172 | } |
| 173 | if blocked == 0 && a.currentProjectionVersion() == 0 { |
| 174 | // Either a durable block or a successful install is fine; looping is not. |
| 175 | t.Logf("started=%d blocked=%d version=%d", started, blocked, a.currentProjectionVersion()) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // TestCompactionHealthyWindowNeverLoops is the companion: when growth comes from |
| 180 | // assistant text (which pruning never touches), compaction still fires as the |
| 181 | // session grows but reclaims enough headroom that it never fires on consecutive |
| 182 | // turns and never trips the stuck guard. |
| 183 | func TestCompactionHealthyWindowNeverLoops(t *testing.T) { |
| 184 | perTurn, paused, _ := compactionsPerTurn(t, 40000, "small tool output", strings.Repeat("analysis paragraph. ", 600), 20) |
| 185 | |
| 186 | total := 0 |
| 187 | for _, n := range perTurn { |
| 188 | total += n |
| 189 | } |
| 190 | t.Logf("compactions per turn: %v (total %d), paused=%v", perTurn, total, paused) |
| 191 | |
| 192 | if paused { |
| 193 | t.Errorf("a healthy window should never pause auto-compaction") |
| 194 | } |
| 195 | if total == 0 { |
| 196 | t.Errorf("expected compaction to fire at least once over a long session") |
| 197 | } |
| 198 | if c := consecutiveCompactingTurns(perTurn); c > 1 { |
| 199 | t.Errorf("compaction fired on %d consecutive turns; a healthy compaction should leave breathing room", c) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // Tool-heavy growth is reclaimed by durable prune projections before paying |
| 204 | // for a summary. |
| 205 | func TestSummaryKeepsToolHeavySessionBounded(t *testing.T) { |
| 206 | perTurn, paused, prunes := compactionsPerTurn(t, 40000, strings.Repeat("file line. ", 1100), "", 20) |
| 207 | |
| 208 | total := 0 |
| 209 | for _, n := range perTurn { |
| 210 | total += n |
| 211 | } |
| 212 | t.Logf("compactions per turn: %v (total %d), paused=%v, prunes=%d", perTurn, total, paused, prunes) |
| 213 | |
| 214 | if total > 3 { |
| 215 | t.Errorf("summary fired %d times; prune should reclaim most tool-heavy growth", total) |
| 216 | } |
| 217 | if paused { |
| 218 | t.Errorf("auto-compaction paused; successful summary should have prevented the stuck loop") |
| 219 | } |
| 220 | if prunes == 0 { |
| 221 | t.Error("expected at least one durable prune projection") |
| 222 | } |
| 223 | if c := consecutiveCompactingTurns(perTurn); c > 1 { |
| 224 | t.Errorf("compaction fired on %d consecutive turns; content-driven summary should reclaim headroom", c) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // Keep the old name as an alias so external references still resolve during the |
| 229 | // rename window; the body asserts the new no-prune contract. |
| 230 | func TestPruneKeepsToolHeavySessionBounded(t *testing.T) { |
| 231 | TestSummaryKeepsToolHeavySessionBounded(t) |
| 232 | } |
| 233 |