返回 DeepSeek-Reasonix
compact_loop_e2e_test.go
根目录 / internal / agent / compact_loop_e2e_test.go
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
233 lines GO