返回 DeepSeek-Reasonix
prune_test.go
根目录 / internal / agent / prune_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 func pruneFixture(toolContent string) *Session {
18 return &Session{Messages: []provider.Message{
19 {Role: provider.RoleSystem, Content: "sys"},
20 {Role: provider.RoleUser, Content: "task"},
21 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
22 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: toolContent},
23 {Role: provider.RoleAssistant, Content: "step done"},
24 {Role: provider.RoleUser, Content: "next"},
25 {Role: provider.RoleAssistant, Content: "ok"},
26 }}
27 }
28
29 func TestPruneStaleToolResults(t *testing.T) {
30 big := strings.Repeat("x", 5000)
31 sess := pruneFixture(big)
32 dir := t.TempDir()
33 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard)
34
35 st, err := a.PruneStaleToolResults()
36 if err != nil {
37 t.Fatalf("prune: %v", err)
38 }
39 if st.Results != 1 {
40 t.Fatalf("Results = %d, want 1", st.Results)
41 }
42 if st.SavedChars < 4000 {
43 t.Errorf("SavedChars = %d, want > 4000", st.SavedChars)
44 }
45 msgs := sess.Snapshot()
46 if len(msgs) != 7 {
47 t.Fatalf("message count changed: %d", len(msgs))
48 }
49 pruned := msgs[3]
50 if !strings.HasPrefix(pruned.Content, prunedMarker) {
51 t.Errorf("tool content not elided: %.60q", pruned.Content)
52 }
53 if pruned.ToolCallID != "1" || pruned.Name != "read_file" || pruned.Role != provider.RoleTool {
54 t.Errorf("tool pairing fields changed: %+v", pruned)
55 }
56 if len(msgs[2].ToolCalls) != 1 || msgs[2].ToolCalls[0].ID != "1" {
57 t.Errorf("assistant tool_calls touched: %+v", msgs[2])
58 }
59 if got := sess.RewriteVersion(); got != 1 {
60 t.Errorf("RewriteVersion = %d, want 1", got)
61 }
62 if st.Archive == "" {
63 t.Fatal("no archive written")
64 }
65 raw, err := os.ReadFile(st.Archive)
66 if err != nil {
67 t.Fatalf("read archive: %v", err)
68 }
69 if !strings.Contains(string(raw), big[:64]) {
70 t.Error("archive does not contain the original tool output")
71 }
72 if filepath.Dir(st.Archive) != dir {
73 t.Errorf("archive outside dir: %s", st.Archive)
74 }
75
76 st2, err := a.PruneStaleToolResults()
77 if err != nil {
78 t.Fatalf("second prune: %v", err)
79 }
80 if st2.Results != 0 {
81 t.Errorf("second pass pruned %d, want 0 (idempotent)", st2.Results)
82 }
83 if got := sess.RewriteVersion(); got != 1 {
84 t.Errorf("no-op pass bumped RewriteVersion to %d", got)
85 }
86 }
87
88 func TestPruneNeverRewritesLocalInterruptedDisplay(t *testing.T) {
89 m := provider.Message{
90 Role: provider.RoleTool, LocalOnly: true,
91 ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
92 Content: strings.Repeat("partial output", 1024),
93 }
94 if shouldMaintainToolResult(m, toolResultSnip) || shouldMaintainToolResult(m, toolResultPrune) {
95 t.Fatal("local interrupted display must remain verbatim across tool-result maintenance")
96 }
97 }
98
99 func TestSnipStaleToolResults(t *testing.T) {
100 var lines []string
101 for i := 0; i < 1000; i++ {
102 lines = append(lines, "line")
103 }
104 big := strings.Join(lines, "\n")
105 sess := pruneFixture(big)
106 dir := t.TempDir()
107 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard)
108
109 st, err := a.SnipStaleToolResults()
110 if err != nil {
111 t.Fatalf("snip: %v", err)
112 }
113 if st.Results != 1 {
114 t.Fatalf("Results = %d, want 1", st.Results)
115 }
116 snipped := sess.Snapshot()[3].Content
117 if !strings.HasPrefix(snipped, snippedMarker) {
118 t.Fatalf("tool content not snipped: %.80q", snipped)
119 }
120 if !strings.Contains(snipped, "[... ") || !strings.Contains(snipped, "lines omitted") {
121 t.Fatalf("snipped content missing omission marker: %.120q", snipped)
122 }
123 if st.SavedChars <= 0 {
124 t.Fatalf("SavedChars = %d, want positive", st.SavedChars)
125 }
126 if st.Archive == "" {
127 t.Fatal("no archive written")
128 }
129
130 st2, err := a.SnipStaleToolResults()
131 if err != nil {
132 t.Fatalf("second snip: %v", err)
133 }
134 if st2.Results != 0 {
135 t.Fatalf("second pass snipped %d, want 0", st2.Results)
136 }
137 }
138
139 func TestSnipCanUpgradeToPrune(t *testing.T) {
140 big := strings.Join([]string{strings.Repeat("a\n", 800), strings.Repeat("b\n", 800)}, "")
141 sess := pruneFixture(big)
142 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
143
144 snipStats, err := a.SnipStaleToolResults()
145 if err != nil || snipStats.Results != 1 {
146 t.Fatalf("snip st=%+v err=%v, want one result", snipStats, err)
147 }
148 if pruneStats, err := a.PruneStaleToolResults(); err != nil || pruneStats.Results != 1 {
149 t.Fatalf("prune st=%+v err=%v, want one upgraded result", pruneStats, err)
150 }
151 if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, prunedMarker) {
152 t.Fatalf("snipped result was not upgraded to prune: %.80q", got)
153 } else if !strings.Contains(got, snipStats.Archive) {
154 t.Fatalf("pruned marker did not preserve original archive path %q: %.120q", snipStats.Archive, got)
155 }
156 }
157
158 func TestPruneNoopWithoutWindow(t *testing.T) {
159 sess := pruneFixture(strings.Repeat("x", 5000))
160 a := New(nil, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
161 st, err := a.PruneStaleToolResults()
162 if err != nil || st.Results != 0 {
163 t.Fatalf("st=%+v err=%v, want no-op", st, err)
164 }
165 }
166
167 func TestPruneSkipsSmallResults(t *testing.T) {
168 sess := pruneFixture(strings.Repeat("x", 200))
169 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2}, event.Discard)
170 st, err := a.PruneStaleToolResults()
171 if err != nil || st.Results != 0 {
172 t.Fatalf("st=%+v err=%v, want small result kept", st, err)
173 }
174 if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, "xxx") {
175 t.Errorf("small tool result was rewritten: %.40q", got)
176 }
177 }
178
179 func TestMaybeCompactPruneAvoidsFold(t *testing.T) {
180 prov := &fakeProvider{reply: "summary"}
181 sess := pruneFixture(strings.Repeat("x", 5000))
182 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
183
184 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 850})
185
186 if prov.got != nil {
187 t.Fatal("summarizer was called although pruning cleared the trigger")
188 }
189 if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, prunedMarker) {
190 t.Errorf("tool result not pruned: %.60q", got)
191 }
192 }
193
194 func TestMaybeCompactSnipsAtSnipRatioWithoutFold(t *testing.T) {
195 prov := &fakeProvider{reply: "summary"}
196 sess := pruneFixture(strings.Repeat("line\n", 1000))
197 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
198
199 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 650})
200
201 if prov.got != nil {
202 t.Fatal("summarizer was called at snip ratio")
203 }
204 if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, snippedMarker) {
205 t.Errorf("tool result not snipped at snip ratio: %.80q", got)
206 }
207 }
208
209 func TestMaybeCompactPruneFallsThroughWhenStillOverThreshold(t *testing.T) {
210 prov := &fakeProvider{reply: "summary"}
211 sess := &Session{Messages: []provider.Message{
212 {Role: provider.RoleSystem, Content: "sys"},
213 {Role: provider.RoleUser, Content: "task"},
214 {Role: provider.RoleAssistant, Content: strings.Repeat("foldable assistant work\n", 500)},
215 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
216 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("x", 1200)},
217 {Role: provider.RoleAssistant, Content: "step done"},
218 {Role: provider.RoleUser, Content: "next"},
219 {Role: provider.RoleAssistant, Content: "ok"},
220 }}
221 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 10000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
222
223 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 8900})
224
225 if prov.got == nil {
226 t.Fatal("summarizer was not called although pruning still left prompt above compact threshold")
227 }
228 foundSummary := false
229 for _, m := range sess.Snapshot() {
230 if strings.Contains(m.Content, summaryTagOpen) {
231 foundSummary = true
232 break
233 }
234 }
235 if !foundSummary {
236 t.Fatal("summary compaction did not update the session")
237 }
238 }
239
240 func TestMaybeCompactForceRatioStillFolds(t *testing.T) {
241 prov := &fakeProvider{reply: "summary"}
242 // A big assistant turn in the foldable region (after the pinned task, before the
243 // recent tail) survives pruning — only tool results prune — so the forced fold
244 // has real content to compact while the task turn stays pinned verbatim.
245 sess := &Session{Messages: []provider.Message{
246 {Role: provider.RoleSystem, Content: "sys"},
247 {Role: provider.RoleUser, Content: "task"},
248 {Role: provider.RoleAssistant, Content: strings.Repeat("y", 5000)},
249 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
250 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("x", 5000)},
251 {Role: provider.RoleAssistant, Content: "step done"},
252 {Role: provider.RoleUser, Content: "next"},
253 {Role: provider.RoleAssistant, Content: "ok"},
254 }}
255 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
256
257 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 950})
258
259 if prov.got == nil {
260 t.Fatal("force ratio crossed but summarizer never called")
261 }
262 if got := sess.Snapshot()[1].Content; got != "task" {
263 t.Errorf("first user turn not pinned verbatim: %.40q", got)
264 }
265 found := false
266 for _, m := range sess.Snapshot() {
267 if strings.Contains(m.Content, summaryTagOpen) {
268 found = true
269 }
270 }
271 if !found {
272 t.Error("no compaction summary in session after forced fold")
273 }
274 }
275
276 func TestPruneSkipsRecentTail(t *testing.T) {
277 old := strings.Repeat("old\n", 1000)
278 recent := strings.Repeat("recent\n", 1000)
279 sess := &Session{Messages: []provider.Message{
280 {Role: provider.RoleSystem, Content: "sys"},
281 {Role: provider.RoleUser, Content: "task"},
282 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "old", Name: "read_file", Arguments: "{}"}}},
283 {Role: provider.RoleTool, ToolCallID: "old", Name: "read_file", Content: old},
284 {Role: provider.RoleUser, Content: "next"},
285 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "recent", Name: "read_file", Arguments: "{}"}}},
286 {Role: provider.RoleTool, ToolCallID: "recent", Name: "read_file", Content: recent},
287 }}
288 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 3, ArchiveDir: t.TempDir()}, event.Discard)
289
290 st, err := a.PruneStaleToolResults()
291 if err != nil {
292 t.Fatalf("prune: %v", err)
293 }
294 if st.Results != 1 {
295 t.Fatalf("Results = %d, want only the stale result pruned", st.Results)
296 }
297 msgs := sess.Snapshot()
298 if !strings.HasPrefix(msgs[3].Content, prunedMarker) {
299 t.Fatalf("old result was not pruned: %.80q", msgs[3].Content)
300 }
301 if msgs[6].Content != recent {
302 t.Fatalf("recent tail tool result was rewritten")
303 }
304 }
305
306 func TestPruneHonorsKeepErrors(t *testing.T) {
307 // KeepErrors must carry error/blocked tool results through pruning verbatim;
308 // eliding here rewrites Content to the [elided ...] marker, so compact()'s
309 // KeepErrors predicate sees only the placeholder and the failure is lost on
310 // the next fold.
311 for _, prefix := range []string{"error:", "blocked:"} {
312 content := prefix + strings.Repeat(" detail", 200)
313 sess := pruneFixture(content)
314 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, KeepPolicy: KeepErrors}, event.Discard)
315
316 st, err := a.PruneStaleToolResults()
317 if err != nil {
318 t.Fatalf("prune (%s): %v", prefix, err)
319 }
320 if st.Results != 0 {
321 t.Errorf("%s: Results = %d, want 0 (KeepErrors preserves error tool results)", prefix, st.Results)
322 }
323 if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, prefix) {
324 t.Errorf("%s: error tool result was elided: %.60q", prefix, got)
325 }
326 }
327 }
328
329 func TestPruneElidesErrorsWithoutKeepPolicy(t *testing.T) {
330 // Without KeepErrors, a large error tool result prunes like any other — a
331 // regression guard for the policy-gated skip.
332 content := "error: build failed\n" + strings.Repeat("x", 5000)
333 sess := pruneFixture(content)
334 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2}, event.Discard)
335
336 st, err := a.PruneStaleToolResults()
337 if err != nil {
338 t.Fatalf("prune: %v", err)
339 }
340 if st.Results != 1 {
341 t.Errorf("Results = %d, want 1 (no keep policy)", st.Results)
342 }
343 if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, prunedMarker) {
344 t.Errorf("error tool result not elided without keep policy: %.60q", got)
345 }
346 }
347
348 // hintingTool is a read-only tool that advertises a distinctive snip geometry,
349 // so a test can prove the maintainer honored the tool's own SnipHint rather
350 // than a name-keyed table or a generic default.
351 type hintingTool struct {
352 name string
353 hint tool.SnipHint
354 }
355
356 func (h hintingTool) Name() string { return h.name }
357 func (hintingTool) Description() string { return "" }
358 func (hintingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
359 func (hintingTool) ReadOnly() bool { return true }
360 func (hintingTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
361 func (h hintingTool) SnipHint() tool.SnipHint { return h.hint }
362
363 func snipFixtureFor(toolName, content string) *Session {
364 return &Session{Messages: []provider.Message{
365 {Role: provider.RoleSystem, Content: "sys"},
366 {Role: provider.RoleUser, Content: "task"},
367 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: toolName, Arguments: "{}"}}},
368 {Role: provider.RoleTool, ToolCallID: "1", Name: toolName, Content: content},
369 {Role: provider.RoleAssistant, Content: "step done"},
370 {Role: provider.RoleUser, Content: "next"},
371 {Role: provider.RoleAssistant, Content: "ok"},
372 }}
373 }
374
375 func TestSnipUsesRegisteredToolHint(t *testing.T) {
376 // 600 numbered lines; a SnipHinter keeping head=3, tail=2 must yield exactly
377 // those boundary lines, which no default geometry would produce.
378 var lines []string
379 for i := 0; i < 600; i++ {
380 lines = append(lines, fmt.Sprintf("L%d", i))
381 }
382 content := strings.Join(lines, "\n")
383 sess := snipFixtureFor("custom_reader", content)
384
385 reg := tool.NewRegistry()
386 reg.Add(hintingTool{name: "custom_reader", hint: tool.SnipHint{Head: 3, Tail: 2, HeadChars: 100, TailChars: 100}})
387 a := New(nil, reg, sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
388
389 if st, err := a.SnipStaleToolResults(); err != nil || st.Results != 1 {
390 t.Fatalf("snip st=%+v err=%v, want one result", st, err)
391 }
392 got := sess.Snapshot()[3].Content
393 if !strings.Contains(got, "showing first 3 lines and last 2 lines") {
394 t.Fatalf("snip did not honor the tool's SnipHint geometry: %.120q", got)
395 }
396 if !strings.Contains(got, "\nL0\nL1\nL2\n") {
397 t.Errorf("kept head is not the first 3 lines: %.160q", got)
398 }
399 if !strings.Contains(got, "\nL598\nL599") {
400 t.Errorf("kept tail is not the last 2 lines: %.160q", got)
401 }
402 }
403
404 func TestSnipFallsBackByReadOnlyTier(t *testing.T) {
405 var lines []string
406 for i := 0; i < 600; i++ {
407 lines = append(lines, fmt.Sprintf("L%d", i))
408 }
409 content := strings.Join(lines, "\n")
410
411 // A side-effecting tool with no SnipHint takes the even-split default
412 // (head==tail), while a read-only one keeps a longer head than tail.
413 cases := []struct {
414 name string
415 readOnly bool
416 evenEnds bool
417 }{
418 {"side_effecting", false, true},
419 {"read_only", true, false},
420 }
421 for _, tc := range cases {
422 sess := snipFixtureFor(tc.name, content)
423 reg := tool.NewRegistry()
424 reg.Add(fakeTool{name: tc.name, readOnly: tc.readOnly})
425 a := New(nil, reg, sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
426
427 if st, err := a.SnipStaleToolResults(); err != nil || st.Results != 1 {
428 t.Fatalf("%s: snip st=%+v err=%v, want one result", tc.name, st, err)
429 }
430 got := sess.Snapshot()[3].Content
431 want := "showing first 40 lines and last 40 lines"
432 if !tc.evenEnds {
433 want = "showing first 80 lines and last 12 lines"
434 }
435 if !strings.Contains(got, want) {
436 t.Errorf("%s: fallback geometry wrong, want %q in: %.120q", tc.name, want, got)
437 }
438 }
439 }
440
440 lines GO