返回 DeepSeek-Reasonix
compress_tool_test.go
根目录 / internal / agent / compress_tool_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "reflect"
8 "strings"
9 "sync/atomic"
10 "testing"
11
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 func TestCompressContextBeforePreservesCanonicalAndTail(t *testing.T) {
18 large := strings.Repeat("old tool output ", 160)
19 local := provider.Message{Role: provider.RoleTool, LocalOnly: true, Content: "private interrupted output"}
20 sess := &Session{Messages: []provider.Message{
21 {Role: provider.RoleSystem, Content: "system stays"},
22 {Role: provider.RoleUser, Content: "old request alpha"},
23 {Role: provider.RoleAssistant, Content: strings.Repeat("analysis ", 160)},
24 local,
25 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "read-1", Name: "read_file", Arguments: `{"path":"a"}`}}},
26 {Role: provider.RoleTool, ToolCallID: "read-1", Name: "read_file", Content: large},
27 {Role: provider.RoleUser, Content: "unique boundary request"},
28 {Role: provider.RoleAssistant, Content: "tail stays byte-for-byte"},
29 }}
30 before := sess.Snapshot()
31 prov := &fakeProvider{reply: "old work summarized"}
32 a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: t.TempDir()}, event.Discard)
33
34 got, err := a.CompressContext(context.Background(), tool.CompressRequest{
35 Direction: "before", Anchor: "unique boundary", Focus: "keep file decisions",
36 })
37 if err != nil {
38 t.Fatalf("CompressContext: %v", err)
39 }
40 if got.Status != "ok" || got.Direction != "before" || got.Messages != 4 || got.Mode != CompactionModeSummarized {
41 t.Fatalf("result = %+v", got)
42 }
43 if got.ProjectionTokens >= got.SourceTokens {
44 t.Fatalf("projection did not shrink: %+v", got)
45 }
46 if !reflect.DeepEqual(sess.Snapshot(), before) {
47 t.Fatal("compress changed the canonical transcript")
48 }
49 visible := a.modelVisibleMessages()
50 if visible[0].Role != provider.RoleSystem || visible[0].Content != "system stays" {
51 t.Fatalf("system message changed: %+v", visible)
52 }
53 if !hasCompactionSummary(visible) || !strings.Contains(joinContents(visible), "unique boundary request") || !strings.Contains(joinContents(visible), "tail stays byte-for-byte") {
54 t.Fatalf("projection lost retained tail: %+v", visible)
55 }
56 if strings.Contains(joinContents(visible), large) || strings.Contains(joinContents(visible), local.Content) {
57 t.Fatalf("projection retained folded/local-only content: %+v", visible)
58 }
59 if len(prov.got) < 2 || strings.Contains(prov.got[1].Content, local.Content) {
60 t.Fatalf("LocalOnly content reached summarizer: %+v", prov.got)
61 }
62 state := a.sess.compactionState
63 if state.Generation != 1 || state.Projection.ViewInputHash == "" || state.Projection.ViewOutputHash == "" {
64 t.Fatalf("range compression did not install complete v3 lineage: %+v", state)
65 }
66 if state.LastReceipt == nil || state.LastReceipt.Status != "applied" || state.LastReceipt.Action != "summary" ||
67 state.LastReceipt.Trigger != CompactionTriggerTool {
68 t.Fatalf("range compression receipt = %+v", state.LastReceipt)
69 }
70 // New summary checkpoints do not create archives; full originals stay in canonical.
71 if state.LastReceipt.Archive != "" {
72 t.Fatalf("summary checkpoint should not create archive, got %q", state.LastReceipt.Archive)
73 }
74 }
75
76 func TestCompressContextAfterExcludesActiveTurnAndAppendsToolResult(t *testing.T) {
77 const activeCreatedAt = int64(99)
78 currentCall := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "compress-1", Name: "compress", Arguments: `{}`}}}
79 sess := &Session{Messages: []provider.Message{
80 {Role: provider.RoleSystem, Content: "sys"},
81 {Role: provider.RoleUser, Content: "start folding at alpha"},
82 {Role: provider.RoleAssistant, Content: strings.Repeat("completed work ", 180)},
83 {Role: provider.RoleUser, Content: "another completed turn"},
84 {Role: provider.RoleAssistant, Content: strings.Repeat("more completed work ", 180)},
85 {Role: provider.RoleUser, Content: "active request", CreatedAt: activeCreatedAt},
86 currentCall,
87 }}
88 before := sess.Snapshot()
89 telemetry := ""
90 sink := event.FuncSink(func(e event.Event) {
91 if e.Kind == event.Notice && e.Text == "compaction telemetry" {
92 telemetry = e.Detail
93 }
94 })
95 a := New(&fakeProvider{reply: "completed turns summarized"}, tool.NewRegistry(), sess, Options{}, sink)
96 a.activeTurnCreatedAt.Store(activeCreatedAt)
97
98 got, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "after", Anchor: "folding at alpha"})
99 if err != nil {
100 t.Fatalf("CompressContext: %v", err)
101 }
102 if got.Status != "ok" || got.Messages != 4 {
103 t.Fatalf("result = %+v", got)
104 }
105 if !strings.Contains(telemetry, "summary_input="+SummaryInputNonPrefix) {
106 t.Fatalf("telemetry = %q, want non-prefix summary input", telemetry)
107 }
108 if !reflect.DeepEqual(sess.Snapshot(), before) {
109 t.Fatal("compress changed the active canonical turn")
110 }
111 visible := a.modelVisibleMessages()
112 if !strings.Contains(joinContents(visible), "active request") || len(visible[len(visible)-1].ToolCalls) != 1 || visible[len(visible)-1].ToolCalls[0].ID != "compress-1" {
113 t.Fatalf("active turn was not retained: %+v", visible)
114 }
115
116 toolResult := provider.Message{Role: provider.RoleTool, ToolCallID: "compress-1", Name: "compress", Content: `{"status":"ok"}`}
117 sess.Add(toolResult)
118 visible = a.modelVisibleMessages()
119 if last := visible[len(visible)-1]; last.ToolCallID != "compress-1" || last.Content != toolResult.Content {
120 t.Fatalf("post-projection tool result missing: %+v", visible)
121 }
122 }
123
124 func TestCompressContextAnchorErrorsDoNotChangeState(t *testing.T) {
125 sess := &Session{Messages: []provider.Message{
126 {Role: provider.RoleSystem, Content: "sys"},
127 {Role: provider.RoleUser, Content: "shared phrase first"},
128 {Role: provider.RoleAssistant, Content: "answer"},
129 {Role: provider.RoleUser, Content: "shared phrase second"},
130 }}
131 a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{}, event.Discard)
132 before := sess.Snapshot()
133
134 for _, tc := range []struct {
135 anchor string
136 want string
137 }{
138 {anchor: "missing", want: "did not match"},
139 {anchor: "shared phrase", want: "longer unique excerpt"},
140 } {
141 _, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "before", Anchor: tc.anchor})
142 if err == nil || !strings.Contains(err.Error(), tc.want) {
143 t.Fatalf("anchor %q error = %v, want %q", tc.anchor, err, tc.want)
144 }
145 }
146 if !reflect.DeepEqual(sess.Snapshot(), before) || len(a.sess.compactionState.Projection.Messages) != 0 {
147 t.Fatal("failed anchor lookup changed state")
148 }
149 }
150
151 func TestCompressContextConsecutiveCallsMergeSummary(t *testing.T) {
152 sess := &Session{Messages: []provider.Message{
153 {Role: provider.RoleSystem, Content: "sys"},
154 {Role: provider.RoleUser, Content: "turn alpha"},
155 {Role: provider.RoleAssistant, Content: strings.Repeat("alpha work ", 180)},
156 {Role: provider.RoleUser, Content: "turn beta unique"},
157 {Role: provider.RoleAssistant, Content: strings.Repeat("beta work ", 180)},
158 {Role: provider.RoleUser, Content: "turn gamma unique"},
159 {Role: provider.RoleAssistant, Content: "gamma tail"},
160 }}
161 before := sess.Snapshot()
162 a := New(&fakeProvider{reply: "rolling summary"}, tool.NewRegistry(), sess, Options{StrictAlternatingRoles: true}, event.Discard)
163
164 for _, anchor := range []string{"beta unique", "gamma unique"} {
165 got, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "before", Anchor: anchor})
166 if err != nil || got.Status != "ok" {
167 t.Fatalf("compress before %q = %+v, %v", anchor, got, err)
168 }
169 }
170 visible := a.modelVisibleMessages()
171 summaries := 0
172 for _, msg := range visible {
173 if isCompactionSummary(msg) {
174 summaries++
175 }
176 }
177 if summaries != 1 {
178 t.Fatalf("summary count = %d, want 1: %+v", summaries, visible)
179 }
180 if !strings.Contains(joinContents(visible), "turn gamma unique") || !strings.Contains(joinContents(visible), "gamma tail") {
181 t.Fatalf("unselected tail changed: %+v", visible)
182 }
183 if len(visible) < 3 || !isCompactionSummary(visible[1]) || visible[2].Content != "turn gamma unique" {
184 t.Fatalf("projection lost logical user-turn boundary: %+v", visible)
185 }
186 providerView := a.providerProjectionMessages(visible)
187 for i := 1; i < len(providerView); i++ {
188 if providerView[i-1].Role == provider.RoleUser && providerView[i].Role == provider.RoleUser {
189 t.Fatalf("strict provider view has adjacent user roles: %+v", providerView)
190 }
191 }
192 if !reflect.DeepEqual(sess.Snapshot(), before) {
193 t.Fatal("consecutive compression changed canonical transcript")
194 }
195 }
196
197 func TestCompressionVisibleMessagesSplitsLegacyStrictSummary(t *testing.T) {
198 legacy := coalesceProjectionUserRuns([]provider.Message{
199 formatSummaryMessage("prior facts"),
200 {Role: provider.RoleUser, Content: "legacy retained anchor", Images: []string{"data:image/png;base64,AA=="}},
201 })
202 if len(legacy) != 1 {
203 t.Fatalf("legacy setup did not coalesce: %+v", legacy)
204 }
205 visible := compressionVisibleMessages(legacy)
206 if len(visible) != 2 || !isCompactionSummary(visible[0]) || !compressAnchorCandidate(visible[1]) {
207 t.Fatalf("legacy strict summary was not split: %+v", visible)
208 }
209 if visible[1].Content != "legacy retained anchor" || len(visible[1].Images) != 1 || len(visible[0].Images) != 0 {
210 t.Fatalf("legacy retained user payload changed: %+v", visible)
211 }
212 }
213
214 func TestCompressContextNoSavingsIsNoop(t *testing.T) {
215 sess := &Session{Messages: []provider.Message{
216 {Role: provider.RoleSystem, Content: "sys"},
217 {Role: provider.RoleUser, Content: "tiny"},
218 {Role: provider.RoleUser, Content: "keep boundary"},
219 }}
220 a := New(&fakeProvider{reply: strings.Repeat("long summary ", 30)}, tool.NewRegistry(), sess, Options{}, event.Discard)
221
222 got, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "before", Anchor: "keep boundary"})
223 if err != nil {
224 t.Fatalf("CompressContext: %v", err)
225 }
226 if got.Status != "noop" || !strings.Contains(got.Reason, "not be smaller") {
227 t.Fatalf("result = %+v", got)
228 }
229 if len(a.sess.compactionState.Projection.Messages) != 0 {
230 t.Fatal("noop installed a projection")
231 }
232 if reasons := sess.DrainContentRewriteReasons(); len(reasons) != 0 {
233 t.Fatalf("noop reported cache rewrite reasons: %v", reasons)
234 }
235 }
236
237 func TestCompressContextFailureDoesNotArchiveUncommittedRange(t *testing.T) {
238 archiveDir := t.TempDir()
239 sess := &Session{Messages: []provider.Message{
240 {Role: provider.RoleSystem, Content: "sys"},
241 {Role: provider.RoleUser, Content: "old unique"},
242 {Role: provider.RoleAssistant, Content: strings.Repeat("work ", 200)},
243 {Role: provider.RoleUser, Content: "keep unique"},
244 }}
245 a := New(&fakeProvider{streamErr: errors.New("summary unavailable")}, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard)
246
247 if _, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "before", Anchor: "keep unique"}); err == nil {
248 t.Fatal("CompressContext succeeded with a failed summarizer")
249 }
250 entries, err := os.ReadDir(archiveDir)
251 if err != nil {
252 t.Fatal(err)
253 }
254 if len(entries) != 0 {
255 t.Fatalf("failed range compression left %d archive files", len(entries))
256 }
257 if a.sess.compactionState.Generation != 0 || a.sess.compactionState.LastReceipt != nil {
258 t.Fatalf("failed range compression changed sidecar state: %+v", a.sess.compactionState)
259 }
260 }
261
262 type staleCompressProvider struct {
263 started chan struct{}
264 release chan struct{}
265 }
266
267 type singleflightCompressProvider struct {
268 calls atomic.Int32
269 started chan struct{}
270 release chan struct{}
271 }
272
273 func (p *singleflightCompressProvider) Name() string { return "singleflight-compress" }
274
275 func (p *singleflightCompressProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
276 n := p.calls.Add(1)
277 ch := make(chan provider.Chunk, 2)
278 if n == 1 {
279 close(p.started)
280 go func() {
281 <-p.release
282 ch <- provider.Chunk{Type: provider.ChunkText, Text: "summary"}
283 ch <- provider.Chunk{Type: provider.ChunkDone}
284 close(ch)
285 }()
286 return ch, nil
287 }
288 ch <- provider.Chunk{Type: provider.ChunkText, Text: "duplicate summary"}
289 ch <- provider.Chunk{Type: provider.ChunkDone}
290 close(ch)
291 return ch, nil
292 }
293
294 func (p *staleCompressProvider) Name() string { return "stale-compress" }
295
296 func (p *staleCompressProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
297 ch := make(chan provider.Chunk, 2)
298 close(p.started)
299 go func() {
300 <-p.release
301 ch <- provider.Chunk{Type: provider.ChunkText, Text: "summary"}
302 ch <- provider.Chunk{Type: provider.ChunkDone}
303 close(ch)
304 }()
305 return ch, nil
306 }
307
308 func TestCompressContextRejectsStaleTranscript(t *testing.T) {
309 prov := &staleCompressProvider{started: make(chan struct{}), release: make(chan struct{})}
310 sess := &Session{Messages: []provider.Message{
311 {Role: provider.RoleSystem, Content: "sys"},
312 {Role: provider.RoleUser, Content: "old unique"},
313 {Role: provider.RoleAssistant, Content: strings.Repeat("work ", 200)},
314 {Role: provider.RoleUser, Content: "keep unique"},
315 }}
316 a := New(prov, tool.NewRegistry(), sess, Options{}, event.Discard)
317 errCh := make(chan error, 1)
318 go func() {
319 _, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "before", Anchor: "keep unique"})
320 errCh <- err
321 }()
322 <-prov.started
323 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "concurrent append"})
324 close(prov.release)
325 if err := <-errCh; !errors.Is(err, errCompressStaleContext) {
326 t.Fatalf("error = %v, want stale context", err)
327 }
328 if len(a.sess.compactionState.Projection.Messages) != 0 {
329 t.Fatal("stale compression installed a projection")
330 }
331 if reasons := sess.DrainContentRewriteReasons(); len(reasons) != 0 {
332 t.Fatalf("stale compression reported cache rewrite reasons: %v", reasons)
333 }
334 }
335
336 func TestRangeCompressionSharesSummarySingleflight(t *testing.T) {
337 prov := &singleflightCompressProvider{started: make(chan struct{}), release: make(chan struct{})}
338 sess := &Session{Messages: []provider.Message{
339 {Role: provider.RoleSystem, Content: "sys"},
340 {Role: provider.RoleUser, Content: "old unique"},
341 {Role: provider.RoleAssistant, Content: strings.Repeat("work ", 200)},
342 {Role: provider.RoleUser, Content: "keep unique"},
343 {Role: provider.RoleAssistant, Content: "tail"},
344 }}
345 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
346 autoErr := make(chan error, 1)
347 go func() {
348 _, err := a.compactToProjection(context.Background(), CompactionTriggerPressure, "", true, false)
349 autoErr <- err
350 }()
351 <-prov.started
352
353 snap := a.snapshotExplicitCompression()
354 anchor := -1
355 for i, msg := range snap.visible {
356 if strings.Contains(UserMessageText(msg), "keep unique") {
357 anchor = i
358 break
359 }
360 }
361 if anchor < 0 {
362 t.Fatal("range anchor missing from snapshot")
363 }
364 rangeErr := make(chan error, 1)
365 go func() {
366 _, err := a.compressVisibleRange(context.Background(), snap, CompactionTriggerTool, "before", anchor, "keep unique", "")
367 rangeErr <- err
368 }()
369 close(prov.release)
370
371 if err := <-autoErr; err != nil {
372 t.Fatalf("automatic compression: %v", err)
373 }
374 if err := <-rangeErr; !errors.Is(err, errCompressStaleContext) {
375 t.Fatalf("queued range compression error = %v, want stale context", err)
376 }
377 if got := prov.calls.Load(); got != 1 {
378 t.Fatalf("summary provider calls = %d, want one shared transaction", got)
379 }
380 }
381
381 lines GO