| 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 |