返回 DeepSeek-Reasonix
prune_test.go
根目录 / internal / agent / prune_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 // Automatic prune/snip projections are gone. These APIs remain as no-ops so
17 // older call sites do not panic, but they never rewrite the model view.
18 func TestPruneAndSnipAreNoOps(t *testing.T) {
19 sess := &Session{Messages: []provider.Message{
20 {Role: provider.RoleSystem, Content: "sys"},
21 {Role: provider.RoleUser, Content: "task"},
22 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "t1", Name: "read_file", Arguments: "{}"}}},
23 {Role: provider.RoleTool, ToolCallID: "t1", Name: "read_file", Content: strings.Repeat("x", 8000)},
24 {Role: provider.RoleUser, Content: "next"},
25 }}
26 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 100_000, RecentKeep: 2}, event.Discard)
27 st, err := a.PruneStaleToolResults()
28 if err != nil {
29 t.Fatal(err)
30 }
31 if st.Results != 0 {
32 t.Fatalf("prune results = %d, want 0", st.Results)
33 }
34 st, err = a.SnipStaleToolResults()
35 if err != nil {
36 t.Fatal(err)
37 }
38 if st.Results != 0 {
39 t.Fatalf("snip results = %d, want 0", st.Results)
40 }
41 if got := a.currentProjectionVersion(); got != 0 {
42 t.Fatalf("projection version = %d, want 0", got)
43 }
44 for _, m := range sess.Snapshot() {
45 if m.Role == provider.RoleTool && m.Content != strings.Repeat("x", 8000) {
46 t.Fatal("canonical tool result was rewritten")
47 }
48 }
49 }
50
51 func TestBelowThresholdSamplingKeepsBoundedToolContentWithoutProjection(t *testing.T) {
52 full := strings.Repeat("完整结果", 10_000)
53 bounded := "legacy bounded result"
54 sess := &Session{Messages: []provider.Message{
55 {Role: provider.RoleSystem, Content: "sys"},
56 {Role: provider.RoleUser, Content: "read"},
57 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}},
58 {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: bounded, RawContent: full},
59 }}
60 a := New(&countingProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{ContextWindow: 1_000_000}, event.Discard)
61 req, err := a.buildSamplingRequest(context.Background(), CompactionTriggerPressure)
62 if err != nil {
63 t.Fatal(err)
64 }
65 if got := req.req.Messages[3].Content; got != bounded {
66 t.Fatalf("provider tool result = %q, want bounded content", got)
67 }
68 if a.currentProjectionVersion() != 0 {
69 t.Fatalf("below-threshold request installed projection version %d", a.currentProjectionVersion())
70 }
71 stored := sess.Snapshot()[3]
72 if stored.Content != bounded || stored.RawContent != full {
73 t.Fatal("request projection mutated compatibility storage")
74 }
75 }
76
77 func TestPruneSurvivesSummaryFailureAndRestart(t *testing.T) {
78 path := filepath.Join(t.TempDir(), "session.jsonl")
79 bigTool := strings.Repeat("🧪", 12_000)
80 bigWork := strings.Repeat("assistant work ", 12_000)
81 sess := &Session{Messages: []provider.Message{
82 {Role: provider.RoleSystem, Content: "sys"},
83 {Role: provider.RoleUser, Content: "task"},
84 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}},
85 {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: bigTool},
86 {Role: provider.RoleAssistant, Content: bigWork},
87 {Role: provider.RoleUser, Content: "tail"},
88 }}
89 a := New(&fakeProvider{streamErr: errors.New("summary down")}, tool.NewRegistry(), sess, Options{
90 ContextWindow: 60_000, CompactRatio: 0.50, SessionPath: path,
91 }, event.Discard)
92 if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil {
93 t.Fatalf("pressure below hard ceiling should continue from prune: %v", err)
94 }
95 if a.currentProjectionVersion() != 1 || countToolResultsContaining(a.modelVisibleMessages(), toolPruneMarker) != 1 {
96 t.Fatalf("prune did not survive summary failure: version=%d view=%+v", a.currentProjectionVersion(), a.modelVisibleMessages())
97 }
98
99 restarted := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 60_000, CompactRatio: 0.50, SessionPath: path}, event.Discard)
100 if restarted.currentProjectionVersion() != 1 || countToolResultsContaining(restarted.modelVisibleMessages(), toolPruneMarker) != 1 {
101 t.Fatalf("restart lost prune projection: version=%d view=%+v", restarted.currentProjectionVersion(), restarted.modelVisibleMessages())
102 }
103 }
104
105 func TestPruneSidecarWriteFailureRollsBackWithoutAppliedReceipt(t *testing.T) {
106 path := filepath.Join(t.TempDir(), "session.jsonl")
107 bigTool := strings.Repeat("界", toolPruneThresholdRunes+1)
108 sess := &Session{Messages: []provider.Message{
109 {Role: provider.RoleSystem, Content: "sys"},
110 {Role: provider.RoleUser, Content: "read"},
111 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}},
112 {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: bigTool},
113 }}
114 appliedEvents := 0
115 sink := event.FuncSink(func(e event.Event) {
116 if e.Kind == event.ContextMaintenanceEvent && e.Maintenance != nil && e.Maintenance.Status == "applied" {
117 appliedEvents++
118 }
119 })
120 a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 20_000, SessionPath: path}, sink)
121 // A directory at the sidecar destination makes atomic publication fail
122 // without relying on platform-specific permission behavior.
123 if err := os.Mkdir(ContextStatePath(path), 0o755); err != nil {
124 t.Fatal(err)
125 }
126
127 a.sess.compactionRunMu.Lock()
128 advanced, err := a.pruneToolResultsToProjectionLocked(CompactionTriggerPressure)
129 a.sess.compactionRunMu.Unlock()
130 if err == nil {
131 t.Fatal("prune unexpectedly succeeded with an unwritable sidecar destination")
132 }
133 if advanced {
134 t.Fatal("failed prune reported projection progress")
135 }
136 if got := a.currentProjectionVersion(); got != 0 {
137 t.Fatalf("projection version = %d, want rollback to 0", got)
138 }
139 if a.sess.compactionState.LastReceipt != nil {
140 t.Fatalf("failed prune published in-memory receipt: %+v", a.sess.compactionState.LastReceipt)
141 }
142 if appliedEvents != 0 {
143 t.Fatalf("applied maintenance events = %d, want 0", appliedEvents)
144 }
145 if got := sess.Snapshot()[3].Content; got != bigTool {
146 t.Fatal("failed prune modified canonical tool content")
147 }
148 }
149
150 // At the compact_ratio trigger, maintenance persists a prune projection first.
151 // If that projection clears pressure, no paid summary request is made.
152 func TestMaintenancePrunesBeforeSummaryAndStopsWhenPressureClears(t *testing.T) {
153 big := strings.Repeat("x", 10_000)
154 msgs := []provider.Message{
155 {Role: provider.RoleSystem, Content: "sys"},
156 {Role: provider.RoleUser, Content: "task"},
157 }
158 for i := range 8 {
159 id := "t" + string(rune('a'+i))
160 msgs = append(msgs,
161 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}},
162 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big},
163 )
164 }
165 msgs = append(msgs,
166 provider.Message{Role: provider.RoleUser, Content: "tail"},
167 provider.Message{Role: provider.RoleAssistant, Content: "ok"},
168 )
169 prov := &countingProvider{reply: "digest"}
170 a := New(prov, tool.NewRegistry(), &Session{Messages: msgs}, Options{
171 ContextWindow: 30_000, CompactRatio: 0.5, RecentKeep: 2,
172 }, event.Discard)
173 if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil {
174 t.Fatal(err)
175 }
176 if a.currentProjectionVersion() != 1 {
177 t.Fatalf("projection version = %d, want 1", a.currentProjectionVersion())
178 }
179 if len(prov.got) != 0 {
180 t.Fatalf("summarizer calls = %d, want 0", len(prov.got))
181 }
182 visible := a.modelVisibleMessages()
183 if got := countToolResultsContaining(visible, toolPruneMarker); got != 8 {
184 t.Fatalf("pruned tool results = %d, want 8", got)
185 }
186 if got := a.sess.compactionState.LastReceipt.Action; got != "prune" {
187 t.Fatalf("receipt action = %q, want prune", got)
188 }
189 for _, msg := range a.sess.conversation.Snapshot() {
190 if msg.Role == provider.RoleTool && msg.Content != big {
191 t.Fatal("canonical tool result was modified")
192 }
193 }
194 }
195
196 func countToolResultsContaining(msgs []provider.Message, marker string) int {
197 n := 0
198 for _, msg := range msgs {
199 if msg.Role == provider.RoleTool && strings.Contains(msg.Content, marker) {
200 n++
201 }
202 }
203 return n
204 }
205
206 func TestSnipStrategyStillBuildsBoundedCompatibilityContent(t *testing.T) {
207 a := &Agent{svc: agentServices{tools: tool.NewRegistry()}}
208 s := a.snipStrategyFor("read_file")
209 if s.head <= 0 || s.tail <= 0 {
210 t.Fatalf("snip strategy for read_file = %+v", s)
211 }
212 body, notice := truncateToolOutputFor(strings.Repeat("x", maxToolOutputBytes+100), "read_file", "call-1")
213 if notice == "" || !strings.Contains(body, "call_id=call-1") {
214 t.Fatalf("first-visible truncation missing marker: notice=%q body=%.200q", notice, body)
215 }
216 if len(body) > maxToolOutputBytes+200 {
217 t.Fatalf("bounded body still oversized: %d", len(body))
218 }
219 }
220
221 func TestModelInputMessagesKeepsBoundedToolContent(t *testing.T) {
222 msgs := []provider.Message{
223 {Role: provider.RoleUser, Content: "display user", RawContent: "raw user"},
224 {Role: provider.RoleTool, Content: "bounded", RawContent: "complete tool result", ToolCallID: "call-1"},
225 }
226
227 got := modelInputMessages(msgs)
228 if got[0].Content != "display user" {
229 t.Fatalf("user content = %q, want display form", got[0].Content)
230 }
231 if got[1].Content != "bounded" {
232 t.Fatalf("tool content = %q, want bounded Content", got[1].Content)
233 }
234 if got[1].RawContent != "" {
235 t.Fatalf("provider-bound RawContent = %q, want empty", got[1].RawContent)
236 }
237 if msgs[1].Content != "bounded" || msgs[1].RawContent != "complete tool result" {
238 t.Fatalf("stored message was mutated: %+v", msgs[1])
239 }
240 }
241
242 func TestPruneToolResultUsesUnicodeCodePoints(t *testing.T) {
243 head := strings.Repeat("界", toolPruneHeadRunes)
244 middle := strings.Repeat("🧪", toolPruneThresholdRunes-toolPruneHeadRunes-toolPruneTailRunes+1)
245 tail := strings.Repeat("尾", toolPruneTailRunes)
246 original := head + middle + tail
247
248 got, changed := pruneToolResultContent(original)
249 if !changed {
250 t.Fatal("oversized tool result was not pruned")
251 }
252 want := head + toolPruneMarker + tail
253 if got != want {
254 t.Fatalf("pruned content mismatch: got runes=%d want runes=%d", len([]rune(got)), len([]rune(want)))
255 }
256 if _, changed := pruneToolResultContent(strings.Repeat("🧪", toolPruneThresholdRunes)); changed {
257 t.Fatal("tool result at threshold must remain intact")
258 }
259 }
260
261 // Pruning must allocate for the retained head/marker/tail only. Converting the
262 // complete input to []rune makes a large ASCII tool result consume roughly
263 // four additional bytes per source byte exactly when maintenance is trying to
264 // recover from context pressure.
265 func TestPruneToolResultAllocationIsBoundedByRetainedContent(t *testing.T) {
266 const inputBytes = 256 << 10
267 large := strings.Repeat("x", inputBytes)
268 result := testing.Benchmark(func(b *testing.B) {
269 b.ReportAllocs()
270 for range b.N {
271 got, changed := pruneToolResultContent(large)
272 if !changed || len(got) == 0 {
273 b.Fatal("large tool result was not pruned")
274 }
275 }
276 })
277 if got := result.AllocedBytesPerOp(); got > 64<<10 {
278 t.Fatalf("prune allocated %d bytes/op for a %d-byte input; want allocation bounded by retained content", got, inputBytes)
279 }
280 }
281
282 func TestPrunePreservesToolEnvelopeMetadata(t *testing.T) {
283 exit := 7
284 original := provider.Message{
285 Role: provider.RoleTool, Name: "bash", ToolCallID: "call-7",
286 Content: strings.Repeat("🧪", toolPruneThresholdRunes+1),
287 Images: []string{"data:image/png;base64,AA=="}, CreatedAt: 77,
288 ToolExecution: &provider.ToolExecution{State: tool.ShellStateFailed, ExitCode: &exit},
289 }
290 sess := &Session{Messages: []provider.Message{
291 {Role: provider.RoleSystem, Content: "sys"},
292 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-7", Name: "bash", Arguments: `{}`}}},
293 original,
294 }}
295 a := New(nil, tool.NewRegistry(), sess, Options{}, event.Discard)
296 a.sess.compactionRunMu.Lock()
297 advanced, err := a.pruneToolResultsToProjectionLocked(CompactionTriggerPressure)
298 a.sess.compactionRunMu.Unlock()
299 if err != nil || !advanced {
300 t.Fatalf("prune advanced=%v err=%v", advanced, err)
301 }
302 got := a.sess.compactionState.Projection.Messages[2]
303 if got.Role != original.Role || got.Name != original.Name || got.ToolCallID != original.ToolCallID || got.CreatedAt != original.CreatedAt {
304 t.Fatalf("tool envelope changed: got=%+v want=%+v", got, original)
305 }
306 if len(got.Images) != 1 || got.Images[0] != original.Images[0] || got.ToolExecution != original.ToolExecution {
307 t.Fatalf("tool metadata changed: got=%+v want=%+v", got, original)
308 }
309 if !strings.Contains(got.Content, toolPruneMarker) {
310 t.Fatalf("tool body was not pruned: runes=%d", len([]rune(got.Content)))
311 }
312 }
313
313 lines GO