返回 DeepSeek-Reasonix
projection_test.go
根目录 / internal / agent / projection_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "testing"
12
13 "reasonix/internal/event"
14 "reasonix/internal/provider"
15 "reasonix/internal/tool"
16 )
17
18 func TestCompactionStateAtomicSaveLoad(t *testing.T) {
19 dir := t.TempDir()
20 path := filepath.Join(dir, "sess.jsonl")
21 st := CompactionState{
22 SchemaVersion: compactionStateSchemaCurrent,
23 TranscriptVersion: 3,
24 Projection: ContextProjection{
25 Messages: []provider.Message{
26 {Role: provider.RoleSystem, Content: "sys"},
27 {Role: provider.RoleUser, Content: "summary-body"},
28 },
29 TranscriptVersion: 3,
30 ProjectionVersion: 1,
31 CoveredCount: 10,
32 SummaryHash: summaryContentHash("summary-body"),
33 SourceTokens: 1000,
34 ProjectionTokens: 200,
35 },
36 PromptCacheKey: "ws|sess|model",
37 LastCacheState: CacheStateCold,
38 Generation: 7,
39 LastReceipt: &ContextMaintenanceReceipt{
40 Status: "applied", Action: "summary", ProjectionVersion: 1,
41 InputHash: "in", OutputHash: "out", SavedTokens: 800,
42 },
43 }
44 if err := SaveCompactionState(path, st); err != nil {
45 t.Fatalf("save: %v", err)
46 }
47 got, ok, err := LoadCompactionState(path)
48 if err != nil || !ok {
49 t.Fatalf("load: ok=%v err=%v", ok, err)
50 }
51 if got.SchemaVersion != compactionStateSchemaCurrent || got.TranscriptVersion != 3 {
52 t.Fatalf("loaded state = %+v", got)
53 }
54 if len(got.Projection.Messages) != 2 || got.Projection.CoveredCount != 10 {
55 t.Fatalf("projection = %+v", got.Projection)
56 }
57 if got.Generation != 7 || got.LastReceipt == nil || got.LastReceipt.OutputHash != "out" || got.LastReceipt.ProjectionVersion != 1 {
58 t.Fatalf("v3 maintenance receipt not round-tripped: %+v", got)
59 }
60 }
61
62 func TestLoadCompactionStateAcceptsLegacyV1(t *testing.T) {
63 path := filepath.Join(t.TempDir(), "legacy.jsonl")
64 legacy := CompactionState{
65 SchemaVersion: compactionStateSchemaV1,
66 TranscriptVersion: 2,
67 Projection: ContextProjection{
68 Messages: []provider.Message{{Role: provider.RoleUser, Content: "legacy summary"}},
69 TranscriptVersion: 2,
70 ProjectionVersion: 1,
71 CoveredCount: 3,
72 },
73 LastTrigger: CompactionTriggerManual,
74 }
75 raw, err := json.Marshal(legacy)
76 if err != nil {
77 t.Fatal(err)
78 }
79 if err := os.WriteFile(ContextStatePath(path), raw, 0o600); err != nil {
80 t.Fatal(err)
81 }
82
83 got, ok, err := LoadCompactionState(path)
84 if err != nil || !ok {
85 t.Fatalf("load legacy V1: ok=%v err=%v", ok, err)
86 }
87 if got.SchemaVersion != compactionStateSchemaV1 || got.Projection.Messages[0].Content != "legacy summary" {
88 t.Fatalf("legacy state changed: %+v", got)
89 }
90 }
91
92 func TestSaveCompactionStateCreatesPreviousReaderBoundary(t *testing.T) {
93 path := filepath.Join(t.TempDir(), "current.jsonl")
94 if err := SaveCompactionState(path, CompactionState{
95 Projection: ContextProjection{
96 Messages: []provider.Message{{Role: provider.RoleUser, Content: "logical summary"}, {Role: provider.RoleUser, Content: "retained anchor"}},
97 CoveredCount: 2,
98 },
99 }); err != nil {
100 t.Fatal(err)
101 }
102 raw, err := os.ReadFile(ContextStatePath(path))
103 if err != nil {
104 t.Fatal(err)
105 }
106 var header struct {
107 SchemaVersion int `json:"schema_version"`
108 }
109 if err := json.Unmarshal(raw, &header); err != nil {
110 t.Fatal(err)
111 }
112 if header.SchemaVersion != compactionStateSchemaCurrent {
113 t.Fatalf("written schema = %d, want %d", header.SchemaVersion, compactionStateSchemaCurrent)
114 }
115 if previousCompactionReaderAccepts(raw) {
116 t.Fatal("V1-only reader would accept a sidecar with V2 logical message invariants")
117 }
118 if _, ok, err := LoadCompactionState(path); err != nil || !ok {
119 t.Fatalf("current reader rejected V2 sidecar: ok=%v err=%v", ok, err)
120 }
121 }
122
123 func previousCompactionReaderAccepts(raw []byte) bool {
124 var header struct {
125 SchemaVersion int `json:"schema_version"`
126 }
127 if json.Unmarshal(raw, &header) != nil {
128 return false
129 }
130 return header.SchemaVersion == 0 || header.SchemaVersion == compactionStateSchemaV1
131 }
132
133 func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) {
134 fp := &fakeProvider{reply: "GOAL: ship projection\nFACTS: keep path /tmp/x"}
135 sess := NewSession("sys")
136 // Build enough history that a fold is economical.
137 for i := range 12 {
138 sess.Add(provider.Message{Role: provider.RoleUser, Content: "user turn " + strings.Repeat("x", 80) + " " + string(rune('A'+i%26))})
139 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "assistant work " + strings.Repeat("y", 200)})
140 sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c" + string(rune('0'+i%10)), Name: "read", Arguments: `{"path":"f"}`}}})
141 sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c" + string(rune('0'+i%10)), Name: "read", Content: strings.Repeat("tool-out-", 40)})
142 }
143 before := append([]provider.Message(nil), sess.Messages...)
144 dir := t.TempDir()
145 sessionPath := filepath.Join(dir, "s.jsonl")
146 a := New(fp, nil, sess, Options{
147 ContextWindow: 50_000,
148 CompactRatio: 0.85,
149 RecentKeep: 2,
150 SessionPath: sessionPath,
151 ModelRef: "test/model",
152 }, event.Discard)
153
154 if err := a.CompactNow(context.Background(), ""); err != nil {
155 t.Fatalf("CompactNow: %v", err)
156 }
157 after := sess.Snapshot()
158 if len(after) != len(before) {
159 t.Fatalf("canonical length changed: before=%d after=%d", len(before), len(after))
160 }
161 for i := range before {
162 if before[i].Content != after[i].Content || before[i].Role != after[i].Role {
163 t.Fatalf("canonical message %d changed", i)
164 }
165 }
166 if len(a.sess.compactionState.Projection.Messages) == 0 {
167 t.Fatal("expected projection messages")
168 }
169 // Projection must be shorter than canonical.
170 if estimateMessagesTokens(a.sess.compactionState.Projection.Messages) >= estimateMessagesTokens(before) {
171 t.Fatalf("projection did not shrink: proj=%d src=%d",
172 estimateMessagesTokens(a.sess.compactionState.Projection.Messages),
173 estimateMessagesTokens(before))
174 }
175 // Sidecar must exist and reload with an applied summary receipt (v3 does not
176 // persist the legacy last_mode field).
177 st, ok, err := LoadCompactionState(sessionPath)
178 if err != nil || !ok {
179 t.Fatalf("reload sidecar: ok=%v err=%v", ok, err)
180 }
181 if st.LastReceipt == nil || st.LastReceipt.Status != "applied" || st.LastReceipt.Action != "summary" {
182 t.Fatalf("last receipt = %+v, want applied summary", st.LastReceipt)
183 }
184 if st.Projection.ProjectionVersion == 0 {
185 t.Fatal("reloaded projection version is zero")
186 }
187 // Model-visible must use projection.
188 visible := a.modelVisibleMessages()
189 if len(visible) == len(before) {
190 t.Fatal("model-visible still full canonical")
191 }
192 // Summarizer must have been invoked with fold region (no tools schema).
193 if len(fp.got) == 0 {
194 t.Fatal("summarizer was not called")
195 }
196 }
197
198 func TestCompactFailureDoesNotWriteMechanicalMarker(t *testing.T) {
199 fp := &fakeProvider{streamErr: errors.New("boom")}
200 sess := NewSession("sys")
201 for range 10 {
202 sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 100)})
203 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 200)})
204 }
205 before := append([]provider.Message(nil), sess.Messages...)
206 a := New(fp, nil, sess, Options{ContextWindow: 2000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
207 err := a.CompactNow(context.Background(), "")
208 if err == nil {
209 t.Fatal("expected compaction error")
210 }
211 after := sess.Snapshot()
212 if len(after) != len(before) {
213 t.Fatalf("canonical changed on failure: %d → %d", len(before), len(after))
214 }
215 for _, m := range after {
216 if strings.Contains(m.Content, "summary was unavailable") || strings.Contains(m.Content, "folded here to free context") {
217 t.Fatalf("mechanical marker written into history: %q", m.Content)
218 }
219 }
220 if len(a.sess.compactionState.Projection.Messages) != 0 {
221 t.Fatal("failed compaction installed a projection")
222 }
223 }
224
225 func TestFixedEarlyUserTurnsStableAcrossCompactions(t *testing.T) {
226 fp := &fakeProvider{reply: "digest-1"}
227 sess := NewSession("sys")
228 // 30 distinct user turns so a "latest N" strategy would reshuffle. The
229 // first four are large enough that usage-calibrated eligibility would reject
230 // them at 1 token/char, but the fixed fallback estimate accepts them.
231 for i := range 30 {
232 user := "unique-user-fact-" + strings.Repeat(string(rune('a'+i%26)), 20) + "-" + strings.Repeat("0", i%10+1)
233 if i < 4 {
234 user = "fixed-early-" + string(rune('a'+i)) + strings.Repeat("x", 1200)
235 }
236 sess.Add(provider.Message{Role: provider.RoleUser, Content: user})
237 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("work-", 50) + string(rune('A'+i%26))})
238 }
239 dir := t.TempDir()
240 a := New(fp, nil, sess, Options{ContextWindow: 4000, RecentKeep: 2, ArchiveDir: dir, SessionPath: filepath.Join(dir, "s.jsonl")}, event.Discard)
241 if err := a.CompactNow(context.Background(), ""); err != nil {
242 t.Fatalf("compact1: %v", err)
243 }
244 firstPrefix := earlyUserPrefix(a.sess.compactionState.Projection.Messages)
245 // Grow the session and compact again.
246 for i := range 8 {
247 sess.Add(provider.Message{Role: provider.RoleUser, Content: "later-fact-" + strings.Repeat("z", 30) + string(rune('0'+i))})
248 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("more-", 60)})
249 }
250 // Simulate a projected request reporting a very different calibration from
251 // the pre-projection canonical estimate. This remains useful for tail sizing,
252 // but must not change which early turns define the stable prefix.
253 a.sess.output.lastUsage.Store(&provider.Usage{PromptTokens: charsOfMessages(sess.Messages)})
254 a.setPromptTokenCalibration(charsOfMessages(sess.Messages), requestCalibrationShapeOf(provider.Request{Messages: sess.Messages}))
255 if got := a.tokPerChar(); got < 0.9 || got > 1.1 {
256 t.Fatalf("test did not install the intended dynamic calibration: %f", got)
257 }
258 fp.reply = "digest-2"
259 if err := a.CompactNow(context.Background(), ""); err != nil {
260 t.Fatalf("compact2: %v", err)
261 }
262 secondPrefix := earlyUserPrefix(a.sess.compactionState.Projection.Messages)
263 if firstPrefix != secondPrefix {
264 t.Fatalf("early user prefix drifted across compactions:\n1: %q\n2: %q", firstPrefix, secondPrefix)
265 }
266 // Exactly one summary in the projection (A1 rolling merge).
267 summaries := 0
268 for _, m := range a.sess.compactionState.Projection.Messages {
269 if isCompactionSummary(m) {
270 summaries++
271 }
272 }
273 if summaries != 1 {
274 t.Fatalf("summaries in projection = %d, want 1", summaries)
275 }
276 }
277
278 func earlyUserPrefix(msgs []provider.Message) string {
279 var b strings.Builder
280 for _, m := range msgs {
281 if m.Role == provider.RoleSystem {
282 continue
283 }
284 if isCompactionSummary(m) {
285 break
286 }
287 if m.Role == provider.RoleUser {
288 b.WriteString(m.Content)
289 b.WriteByte('\n')
290 }
291 }
292 return b.String()
293 }
294
295 func TestLocalOnlyExcludedFromCompactionRequest(t *testing.T) {
296 fp := &fakeProvider{reply: "ok"}
297 sess := NewSession("sys")
298 for range 8 {
299 sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)})
300 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 120)})
301 }
302 sess.Add(provider.Message{
303 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
304 Content: "secret local only", LocalOnly: true,
305 })
306 a := New(fp, nil, sess, Options{ContextWindow: 2000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
307 if err := a.CompactNow(context.Background(), ""); err != nil {
308 t.Fatalf("compact: %v", err)
309 }
310 for _, m := range fp.got {
311 if strings.Contains(m.Content, "secret local only") {
312 t.Fatal("LocalOnly content reached summarizer")
313 }
314 }
315 for _, m := range a.sess.compactionState.Projection.Messages {
316 if m.LocalOnly || strings.Contains(m.Content, "secret local only") {
317 t.Fatal("LocalOnly content entered projection")
318 }
319 }
320 }
321
322 // Checkpoint installation no longer writes archive copies — the canonical
323 // transcript is the lossless store. ArchiveDir misconfiguration must not block
324 // a successful summary install.
325 func TestArchiveDirIgnoredOnCheckpointInstall(t *testing.T) {
326 fp := &fakeProvider{reply: "digest"}
327 sess := NewSession("sys")
328 big := strings.Repeat("assistant work detail ", 200)
329 for range 6 {
330 sess.Add(provider.Message{Role: provider.RoleUser, Content: "turn"})
331 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big})
332 }
333 sess.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
334 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
335 // Point archive at a file path so MkdirAll/Create would fail if archives were written.
336 badArchive := filepath.Join(t.TempDir(), "not-a-dir")
337 if err := writeFile(badArchive, []byte("x")); err != nil {
338 t.Fatal(err)
339 }
340 a := New(fp, nil, sess, Options{
341 ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: badArchive,
342 }, event.Discard)
343 if err := a.CompactNow(context.Background(), ""); err != nil {
344 t.Fatalf("CompactNow with unusable ArchiveDir: %v", err)
345 }
346 if len(a.sess.compactionState.Projection.Messages) == 0 {
347 t.Fatal("expected projection despite unusable ArchiveDir")
348 }
349 if a.sess.compactionState.LastReceipt != nil && a.sess.compactionState.LastReceipt.Archive != "" {
350 t.Fatalf("checkpoint must not create archives, got %q", a.sess.compactionState.LastReceipt.Archive)
351 }
352 }
353
354 func writeFile(path string, b []byte) error {
355 return os.WriteFile(path, b, 0o644)
356 }
357
358 func visibleContext(a *Agent) []provider.Message {
359 if a == nil {
360 return nil
361 }
362 if msgs := a.sess.compactionState.Projection.Messages; len(msgs) > 0 {
363 canonical, _ := a.sess.conversation.snapshotMessagesVersion()
364 return modelVisibleFromProjection(a.sess.compactionState.Projection, canonical)
365 }
366 if a.sess.conversation != nil {
367 return a.sess.conversation.Snapshot()
368 }
369 return nil
370 }
371
372 func hasCompactionSummary(msgs []provider.Message) bool {
373 return slices.ContainsFunc(msgs, isCompactionSummary)
374 }
375
376 func joinContents(msgs []provider.Message) string {
377 var b strings.Builder
378 for _, m := range msgs {
379 b.WriteString(m.Content)
380 b.WriteByte('\n')
381 }
382 return b.String()
383 }
384
385 func TestCompactReplacesHistory(t *testing.T) {
386 prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"}
387 bigStep := strings.Repeat("important implementation detail ", 200)
388 sess := &Session{Messages: []provider.Message{
389 {Role: provider.RoleSystem, Content: "sys"},
390 {Role: provider.RoleUser, Content: "task"},
391 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
392 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: bigStep},
393 {Role: provider.RoleAssistant, Content: bigStep},
394 {Role: provider.RoleUser, Content: "next"},
395 {Role: provider.RoleAssistant, Content: "ok"},
396 }}
397 dir := t.TempDir()
398 a := New(prov, tool.NewRegistry(), sess, Options{
399 ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: dir,
400 }, event.Discard)
401 beforeLen := len(sess.Messages)
402
403 if err := a.compact(context.Background(), "manual", "", true); err != nil {
404 t.Fatalf("compact: %v", err)
405 }
406 // Canonical transcript is never rewritten by projection compaction.
407 if got := sess.RewriteVersion(); got != 0 {
408 t.Fatalf("rewrite version = %d, want 0 (canonical intact)", got)
409 }
410 if len(sess.Messages) != beforeLen {
411 t.Fatalf("canonical len changed: %d -> %d", beforeLen, len(sess.Messages))
412 }
413
414 proj := visibleContext(a)
415 if !hasCompactionSummary(proj) {
416 t.Fatalf("projection missing summary: %+v", proj)
417 }
418 if proj[0].Role != provider.RoleSystem {
419 t.Errorf("message 0 = %s, want system", proj[0].Role)
420 }
421 // Tail preserved in projection.
422 if proj[len(proj)-2].Content != "next" || proj[len(proj)-1].Content != "ok" {
423 t.Errorf("recent tail not preserved: %+v", proj[len(proj)-2:])
424 }
425 var foundSummary bool
426 for _, m := range proj {
427 if strings.Contains(m.Content, "do X") {
428 foundSummary = true
429 }
430 }
431 if !foundSummary {
432 t.Errorf("summary missing do X: %+v", proj)
433 }
434
435 // No new archive files: canonical is the lossless store.
436 entries, err := os.ReadDir(dir)
437 if err != nil {
438 t.Fatalf("archive dir: %v", err)
439 }
440 if len(entries) != 0 {
441 t.Fatalf("archive dir entries = %d, want 0 (no new archives)", len(entries))
442 }
443 }
444
445 func TestManualCompactReportsSummarizerFailure(t *testing.T) {
446 // Manual compaction must not rewrite history or degrade to a mechanical
447 // fold when the summarizer fails. The error is returned so the caller,
448 // who is present, can retry or report it.
449 prov := &fakeProvider{streamErr: errors.New("provider down")}
450 sess := &Session{Messages: []provider.Message{
451 {Role: provider.RoleSystem, Content: "sys"},
452 {Role: provider.RoleUser, Content: "task"},
453 {Role: provider.RoleAssistant, Content: "step one"},
454 {Role: provider.RoleUser, Content: "more"},
455 {Role: provider.RoleAssistant, Content: "step two"},
456 {Role: provider.RoleUser, Content: "next"},
457 {Role: provider.RoleAssistant, Content: "ok"},
458 }}
459 var got []event.Event
460 sink := event.FuncSink(func(e event.Event) { got = append(got, e) })
461 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, sink)
462
463 before := append([]provider.Message(nil), sess.Messages...)
464 if err := a.compact(context.Background(), "manual", "", true); err == nil {
465 t.Fatal("compact should error when summarizer fails")
466 }
467 if len(sess.Messages) != len(before) {
468 t.Fatalf("canonical changed on summarizer failure: %d -> %d", len(before), len(sess.Messages))
469 }
470 for _, m := range sess.Messages {
471 if strings.Contains(m.Content, "summary was unavailable") {
472 t.Fatalf("mechanical marker written: %q", m.Content)
473 }
474 }
475 if len(a.sess.compactionState.Projection.Messages) != 0 {
476 t.Fatal("failed compact installed a projection")
477 }
478 // CompactionDone with empty summary resolves the UI placeholder.
479 var done *event.Compaction
480 for i := range got {
481 if got[i].Kind == event.CompactionDone {
482 done = &got[i].Compaction
483 }
484 }
485 if done == nil {
486 t.Fatal("expected CompactionDone on abort")
487 }
488 }
489
490 func TestCompactRewriteVersionFeedsCacheDiagnostics(t *testing.T) {
491 // Projection checkpoints do not rewrite the canonical transcript, so they
492 // must not bump LogRewriteVersion or queue compact_* content-rewrite reasons.
493 // The provider-visible change is the projection sidecar (version + summary).
494 prov := &fakeProvider{reply: "- summary"}
495 big := strings.Repeat("work detail ", 200)
496 sess := &Session{Messages: []provider.Message{
497 {Role: provider.RoleSystem, Content: "sys"},
498 {Role: provider.RoleUser, Content: "task"},
499 {Role: provider.RoleAssistant, Content: big},
500 {Role: provider.RoleUser, Content: "more"},
501 {Role: provider.RoleAssistant, Content: big},
502 {Role: provider.RoleUser, Content: "e"},
503 {Role: provider.RoleAssistant, Content: "f"},
504 }}
505 a := New(prov, tool.NewRegistry(), sess, Options{
506 ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2,
507 }, event.Discard)
508 beforeVersion := sess.RewriteVersion()
509
510 if err := a.compact(context.Background(), "auto", "", true); err != nil {
511 t.Fatalf("compact: %v", err)
512 }
513 if sess.RewriteVersion() != beforeVersion {
514 t.Fatalf("canonical rewrite version changed: %d -> %d", beforeVersion, sess.RewriteVersion())
515 }
516 if !hasCompactionSummary(visibleContext(a)) {
517 t.Fatal("expected projection summary")
518 }
519 if got := a.currentProjectionVersion(); got != 1 {
520 t.Fatalf("projection version = %d, want 1", got)
521 }
522 if reasons := sess.DrainContentRewriteReasons(); len(reasons) != 0 {
523 t.Fatalf("projection compact queued canonical rewrite reasons %v; want none", reasons)
524 }
525 }
526
527 func TestCompactSummarizesMidSessionUserTurns(t *testing.T) {
528 // Small window so the recent-tail budget cannot swallow the mid-session
529 // user turn under the fixed retained-tail budget.
530 const window = 8_000
531 // ~1500 tokens of work after the mid-fact pushes it out of the ~800-token tail.
532 big := strings.Repeat("work output line with detail. ", 250)
533 midFact := "by the way, always use pnpm not npm"
534 sess := &Session{Messages: []provider.Message{
535 {Role: provider.RoleSystem, Content: "sys"},
536 {Role: provider.RoleUser, Content: "first task"},
537 {Role: provider.RoleAssistant, Content: big},
538 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: big},
539 {Role: provider.RoleUser, Content: midFact},
540 {Role: provider.RoleAssistant, Content: big},
541 {Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: big},
542 {Role: provider.RoleUser, Content: "next"},
543 {Role: provider.RoleAssistant, Content: "ok"},
544 }}
545 // The summarizer is given a reply that drops the fact entirely: a mid-session
546 // user turn must survive on its own, never on the digest having captured it.
547 a := New(&fakeProvider{reply: "Standing facts: none"}, tool.NewRegistry(), sess,
548 Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
549
550 if err := a.compact(context.Background(), "manual", "", true); err != nil {
551 t.Fatalf("compact: %v", err)
552 }
553
554 // Canonical retains every user turn.
555 var pinnedFirst, keptMidCanonical bool
556 for _, m := range sess.Snapshot() {
557 if m.Role == provider.RoleUser && m.Content == "first task" {
558 pinnedFirst = true
559 }
560 if m.Role == provider.RoleUser && strings.Contains(m.Content, midFact) {
561 keptMidCanonical = true
562 }
563 }
564 if !pinnedFirst || !keptMidCanonical {
565 t.Fatalf("canonical lost user turns (first=%v mid=%v)", pinnedFirst, keptMidCanonical)
566 }
567 proj := visibleContext(a)
568 var projFirst, projMidVerbatim bool
569 for _, m := range proj {
570 if isCompactionSummary(m) {
571 continue
572 }
573 if m.Role == provider.RoleUser && m.Content == "first task" {
574 projFirst = true
575 }
576 if m.Role == provider.RoleUser && m.Content == midFact {
577 projMidVerbatim = true
578 }
579 }
580 if projFirst || projMidVerbatim {
581 t.Fatalf("old user turns were retained verbatim (first=%v mid=%v): %+v", projFirst, projMidVerbatim, proj)
582 }
583 if strings.Contains(joinContents(proj), big) {
584 t.Errorf("assistant/tool work was not folded out of projection")
585 }
586 }
587
588 func TestCompactKeepsPriorDigests(t *testing.T) {
589 // A1 rolling merge: prior digests enter the fold and the summarizer must
590 // carry durable facts forward into a single latest summary. The fake
591 // provider echoes the prior fact so we can assert the fold input included it.
592 priorDigest := summaryTagOpen + "\n## Standing facts\n- db is orion_prod_42\n" + summaryTagClose
593 big := strings.Repeat("work output ", 200)
594 sess := &Session{Messages: []provider.Message{
595 {Role: provider.RoleSystem, Content: "sys"},
596 {Role: provider.RoleUser, Content: "task"},
597 {Role: provider.RoleAssistant, Content: big}, // breaks leading-summary contiguity
598 {Role: provider.RoleUser, Content: priorDigest},
599 {Role: provider.RoleAssistant, Content: big},
600 {Role: provider.RoleUser, Content: "next"},
601 {Role: provider.RoleAssistant, Content: "ok"},
602 }}
603 prov := &fakeProvider{reply: "Standing facts: db is orion_prod_42"}
604 a := New(prov, tool.NewRegistry(), sess,
605 Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
606
607 if err := a.compact(context.Background(), "manual", "", true); err != nil {
608 t.Fatalf("compact: %v", err)
609 }
610
611 // Canonical retains the prior digest; projection has exactly one summary.
612 var priorInCanonical bool
613 for _, m := range sess.Snapshot() {
614 if strings.Contains(m.Content, "orion_prod_42") {
615 priorInCanonical = true
616 }
617 }
618 if !priorInCanonical {
619 t.Fatal("canonical lost prior digest")
620 }
621 proj := visibleContext(a)
622 summaries := 0
623 for _, m := range proj {
624 if isCompactionSummary(m) {
625 summaries++
626 }
627 }
628 if summaries != 1 {
629 t.Fatalf("projection summaries = %d, want 1 (rolling merge)", summaries)
630 }
631 if !strings.Contains(joinContents(proj), "orion_prod_42") {
632 t.Fatalf("rolling summary lost prior fact: %+v", proj)
633 }
634 // Prior digest body was part of the fold sent to the summarizer.
635 if !strings.Contains(joinContents(prov.got), "orion_prod_42") {
636 t.Fatalf("prior digest not folded into summarizer input: %+v", prov.got)
637 }
638 }
639
640 func TestCompactSummarizesErrorMessagesDespiteDeprecatedKeep(t *testing.T) {
641 prov := &fakeProvider{reply: "- normal work summarized"}
642 big := strings.Repeat("normal work output ", 200)
643 sess := &Session{Messages: []provider.Message{
644 {Role: provider.RoleSystem, Content: "sys"},
645 {Role: provider.RoleUser, Content: "task"},
646 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"bad"}`}}},
647 {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command failed"},
648 {Role: provider.RoleAssistant, Content: big},
649 {Role: provider.RoleUser, Content: "continue"},
650 {Role: provider.RoleAssistant, Content: big},
651 {Role: provider.RoleUser, Content: "next"},
652 {Role: provider.RoleAssistant, Content: "ok"},
653 }}
654 a := New(prov, tool.NewRegistry(), sess, Options{
655 ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, KeepPolicy: KeepErrors,
656 }, event.Discard)
657
658 if err := a.compact(context.Background(), "manual", "", true); err != nil {
659 t.Fatalf("compact: %v", err)
660 }
661 // Canonical unchanged.
662 if sess.Messages[3].Content != "error: command failed" {
663 t.Fatalf("canonical error tool result changed: %+v", sess.Messages[3])
664 }
665 proj := visibleContext(a)
666 var keptErr bool
667 for _, m := range proj {
668 if m.Role == provider.RoleTool && m.Content == "error: command failed" {
669 keptErr = true
670 }
671 }
672 if keptErr {
673 t.Fatalf("error tool result was kept verbatim in projection: %+v", proj)
674 }
675 if !strings.Contains(joinContents(prov.got), "error: command failed") {
676 t.Fatalf("error did not reach summary input:\n%s", joinContents(prov.got))
677 }
678 }
679
680 func TestCompactSummarizesUserMarkedMessagesDespiteDeprecatedKeep(t *testing.T) {
681 prov := &fakeProvider{reply: "- unmarked work summarized"}
682 // Marked text is no longer protected; surrounding work keeps the fixture
683 // large enough that the summary candidate reduces the request.
684 marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 40)
685 big := strings.Repeat("unmarked work output ", 300)
686 sess := &Session{Messages: []provider.Message{
687 {Role: provider.RoleSystem, Content: "sys"},
688 {Role: provider.RoleUser, Content: "task"},
689 {Role: provider.RoleUser, Content: marked},
690 {Role: provider.RoleAssistant, Content: big},
691 {Role: provider.RoleUser, Content: "more"},
692 {Role: provider.RoleAssistant, Content: big},
693 {Role: provider.RoleUser, Content: "next"},
694 {Role: provider.RoleAssistant, Content: "ok"},
695 }}
696 a := New(prov, tool.NewRegistry(), sess, Options{
697 ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, KeepPolicy: KeepUserMarked,
698 }, event.Discard)
699
700 if err := a.compact(context.Background(), "manual", "", true); err != nil {
701 t.Fatalf("compact: %v", err)
702 }
703 var keptCanonical, keptProj bool
704 for _, m := range sess.Messages {
705 if m.Content == marked {
706 keptCanonical = true
707 break
708 }
709 }
710 for _, m := range visibleContext(a) {
711 if m.Content == marked {
712 keptProj = true
713 break
714 }
715 }
716 if !keptCanonical {
717 t.Fatalf("marked message missing from canonical: %+v", sess.Messages)
718 }
719 if keptProj {
720 t.Fatalf("marked message was kept verbatim in projection: %+v", visibleContext(a))
721 }
722 if !strings.Contains(joinContents(prov.got), "exact requirement") {
723 t.Fatalf("marked message did not reach summary input:\n%s", joinContents(prov.got))
724 }
725 }
726
727 func TestRunCompactsAfterFinalAnswer(t *testing.T) {
728 // Maintenance runs on Prepare before sampling (ObserveUsage is a no-op).
729 // A turn whose estimated prompt already crosses compact_ratio must install
730 // the summary checkpoint on the sampling path so the final-answer request
731 // rides the reduced view.
732 const window = 10_000
733 // ~2×4K tokens of foldable work so estimatedPromptTokens ≥ fold (8500).
734 big := strings.Repeat("old work detail line with substance. ", 800)
735 sess := &Session{Messages: []provider.Message{
736 {Role: provider.RoleSystem, Content: "sys"},
737 {Role: provider.RoleUser, Content: "task"},
738 {Role: provider.RoleAssistant, Content: big},
739 {Role: provider.RoleAssistant, Content: big},
740 }}
741 // fakeProvider replies "done" for the main sample; compact also uses the same
742 // provider for the summary call (also returns "done", which is fine as a digest).
743 a := New(&fakeProvider{reply: "done"}, tool.NewRegistry(), sess,
744 Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
745
746 if before := a.estimatedPromptTokens(a.modelVisibleMessages()); before < a.compactTrigger() {
747 t.Fatalf("fixture est=%d below fold trigger %d", before, a.compactTrigger())
748 }
749 if err := a.Run(context.Background(), "what's the status?"); err != nil {
750 t.Fatalf("run: %v", err)
751 }
752 if !hasCompactionSummary(visibleContext(a)) {
753 t.Fatalf("turn over the trigger did not install projection summary")
754 }
755 // Canonical rewrite version stays 0; projection carries the fold.
756 if got := sess.RewriteVersion(); got != 0 {
757 t.Fatalf("canonical rewrite version = %d, want 0", got)
758 }
759 }
760
761 func TestCompactFoldsSingleLargeMessage(t *testing.T) {
762 prov := &fakeProvider{reply: "- captured the large file contents"}
763 sess := &Session{Messages: []provider.Message{
764 {Role: provider.RoleSystem, Content: "sys"},
765 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("large output line\n", 500)},
766 {Role: provider.RoleUser, Content: "next"},
767 {Role: provider.RoleAssistant, Content: "ok"},
768 }}
769 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
770 before := len(sess.Messages)
771
772 if err := a.compact(context.Background(), "auto", "", false); err != nil {
773 t.Fatalf("compact: %v", err)
774 }
775 if len(sess.Messages) != before {
776 t.Fatalf("canonical changed: %d -> %d", before, len(sess.Messages))
777 }
778 proj := visibleContext(a)
779 if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "large file contents") {
780 t.Fatalf("single large message was not summarized into projection: %+v", proj)
781 }
782 if len(prov.got) == 0 || !strings.Contains(prov.got[1].Content, "large output line") {
783 t.Fatalf("summarizer did not receive the large message: %+v", prov.got)
784 }
785 }
786
786 lines GO