| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/event" |
| 9 | "reasonix/internal/provider" |
| 10 | "reasonix/internal/tool" |
| 11 | ) |
| 12 | |
| 13 | // prepareForObservedUsage preserves the old synthetic-usage test ergonomics |
| 14 | // while production has only one mutating entry point: ContextManager.Prepare. |
| 15 | func prepareForObservedUsage(a *Agent, ctx context.Context, usage *provider.Usage) { |
| 16 | if a == nil || usage == nil || usage.LatestPromptTokens() <= 0 { |
| 17 | return |
| 18 | } |
| 19 | view := a.modelVisibleMessages() |
| 20 | a.setPromptTokenCalibration(usage.LatestPromptTokens(), a.requestCalibrationShape(provider.Request{Messages: view})) |
| 21 | _, _ = a.contextManager().Prepare(ctx, ContextPreparePolicy{ |
| 22 | Trigger: CompactionTriggerPressure, ObservedInputTokens: usage.LatestPromptTokens(), |
| 23 | }) |
| 24 | } |
| 25 | |
| 26 | // fakeProvider returns a fixed reply and records the messages it was asked to |
| 27 | // complete, so tests can drive summarization without a network call. |
| 28 | type fakeProvider struct { |
| 29 | reply string |
| 30 | reasoningReply string // set (with empty reply) to emit ChunkReasoning: thinking-model shape |
| 31 | reasoningTool bool // with reasoningReply: also open a tool call, the shape that stays rejected |
| 32 | promptTokens int |
| 33 | got []provider.Message |
| 34 | streamErr error // when set, Stream emits a ChunkError instead of the reply |
| 35 | hang bool // when true, Stream returns a channel that never sends or closes |
| 36 | } |
| 37 | |
| 38 | func (f *fakeProvider) Name() string { return "fake" } |
| 39 | |
| 40 | func (f *fakeProvider) ContextBudgetPolicy() provider.ContextBudgetPolicy { |
| 41 | return provider.ContextBudgetPolicy{WindowMode: provider.ContextWindowIndependent} |
| 42 | } |
| 43 | |
| 44 | func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { |
| 45 | f.got = req.Messages |
| 46 | if f.hang { |
| 47 | return make(chan provider.Chunk), nil |
| 48 | } |
| 49 | ch := make(chan provider.Chunk, 3) |
| 50 | if f.streamErr != nil { |
| 51 | ch <- provider.Chunk{Type: provider.ChunkError, Err: f.streamErr} |
| 52 | close(ch) |
| 53 | return ch, nil |
| 54 | } |
| 55 | // Default stays byte-identical to the historical shape: always emit |
| 56 | // ChunkText (even empty). Only an explicit reasoningReply with no reply |
| 57 | // switches to the thinking-model reasoning-only shape. |
| 58 | if f.reply == "" && f.reasoningReply != "" { |
| 59 | ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: f.reasoningReply} |
| 60 | if f.reasoningTool { |
| 61 | ch <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file"}} |
| 62 | } |
| 63 | } else { |
| 64 | ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply} |
| 65 | } |
| 66 | if f.promptTokens > 0 { |
| 67 | ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: f.promptTokens, TotalTokens: f.promptTokens}} |
| 68 | } |
| 69 | ch <- provider.Chunk{Type: provider.ChunkDone} |
| 70 | close(ch) |
| 71 | return ch, nil |
| 72 | } |
| 73 | |
| 74 | // visibleContext returns the model-visible projection when present, else the |
| 75 | // canonical transcript. Compaction tests assert against this view. |
| 76 | |
| 77 | func TestTailStart(t *testing.T) { |
| 78 | // 10-char content → with tokPerChar 1.0, each non-empty message costs 10 |
| 79 | // "tokens"; tool-call messages carry name+args instead. |
| 80 | msg := func(role provider.Role, n int) provider.Message { |
| 81 | return provider.Message{Role: role, Content: strings.Repeat("x", n)} |
| 82 | } |
| 83 | u := func(n int) provider.Message { return msg(provider.RoleUser, n) } |
| 84 | as := func(n int) provider.Message { return msg(provider.RoleAssistant, n) } |
| 85 | ac := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "f", Arguments: "{}"}}} |
| 86 | to := func(n int) provider.Message { |
| 87 | return provider.Message{Role: provider.RoleTool, ToolCallID: "1", Name: "f", Content: strings.Repeat("x", n)} |
| 88 | } |
| 89 | |
| 90 | sys := provider.Message{Role: provider.RoleSystem} |
| 91 | cases := []struct { |
| 92 | name string |
| 93 | msgs []provider.Message |
| 94 | head int |
| 95 | budget int |
| 96 | minKeep int |
| 97 | wantStr int |
| 98 | }{ |
| 99 | // Budget 25 fits the two newest 10-char messages (20) but not a third (30); |
| 100 | // the tail stops at the third-from-last. |
| 101 | {"budget-bounds-tail", []provider.Message{u(10), as(10), u(10), as(10), u(10)}, 0, 25, 2, 3}, |
| 102 | // A single huge recent message can't blow the budget below minKeep: the last |
| 103 | // two are kept regardless. |
| 104 | {"min-keep-floor", []provider.Message{u(10), as(10), u(10), as(10), to(9999)}, 0, 25, 2, 3}, |
| 105 | // The boundary lands on an orphan tool result and must move back onto its |
| 106 | // assistant so the tail begins with the tool_calls. |
| 107 | {"align-off-tool", []provider.Message{sys, u(10), ac, to(10), ac, to(10)}, 1, 0, 1, 4}, |
| 108 | // A generous budget keeps everything down to the first compactable message |
| 109 | // after the head. |
| 110 | {"budget-keeps-all", []provider.Message{sys, u(10), as(10), u(10)}, 1, 100000, 2, 2}, |
| 111 | } |
| 112 | |
| 113 | for _, tc := range cases { |
| 114 | t.Run(tc.name, func(t *testing.T) { |
| 115 | start := tailStart(tc.msgs, tc.head, tc.budget, 1.0, tc.minKeep) |
| 116 | if start != tc.wantStr { |
| 117 | t.Errorf("start = %d, want %d", start, tc.wantStr) |
| 118 | } |
| 119 | if tc.msgs[start].Role == provider.RoleTool { |
| 120 | t.Errorf("recent tail begins with orphan tool message at %d", start) |
| 121 | } |
| 122 | }) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestTailStartSmallSession(t *testing.T) { |
| 127 | sys := provider.Message{Role: provider.RoleSystem} |
| 128 | usr := provider.Message{Role: provider.RoleUser, Content: "hi"} |
| 129 | for i, msgs := range [][]provider.Message{ |
| 130 | {sys, usr}, // system + one message: nothing fits the tail; must not index msgs[len] |
| 131 | {sys}, |
| 132 | {usr}, |
| 133 | {}, |
| 134 | } { |
| 135 | head := 0 |
| 136 | if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem { |
| 137 | head = 1 |
| 138 | } |
| 139 | start := tailStart(msgs, head, 16384, 0.25, 2) |
| 140 | if start < head || start > len(msgs) { |
| 141 | t.Errorf("case %d: start=%d out of bounds [%d,%d]", i, start, head, len(msgs)) |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestPinnedPrefixLen(t *testing.T) { |
| 147 | sys := provider.Message{Role: provider.RoleSystem} |
| 148 | small := provider.Message{Role: provider.RoleUser, Content: "do X with token T"} |
| 149 | big := provider.Message{Role: provider.RoleUser, Content: strings.Repeat("x", 100000)} |
| 150 | sum := provider.Message{Role: provider.RoleUser, Content: summaryTagOpen + "\ndigest\n" + summaryTagClose} |
| 151 | as := provider.Message{Role: provider.RoleAssistant, Content: "a"} |
| 152 | |
| 153 | newA := func(win int) *Agent { |
| 154 | return New(&fakeProvider{}, tool.NewRegistry(), &Session{}, Options{ContextWindow: win}, event.Discard) |
| 155 | } |
| 156 | cases := []struct { |
| 157 | name string |
| 158 | win int |
| 159 | msgs []provider.Message |
| 160 | want int |
| 161 | }{ |
| 162 | {"pins-only-system-before-small-task", 0, []provider.Message{sys, small, as, as}, 1}, |
| 163 | {"summaries-are-not-pinned-A1-merge", 0, []provider.Message{sys, small, sum, sum, as}, 1}, |
| 164 | {"large-first-turn-stays-foldable", 0, []provider.Message{sys, big, as, as}, 1}, |
| 165 | {"tiny-window-wont-pin", 10, []provider.Message{sys, small, as, as}, 1}, |
| 166 | {"summary-is-not-the-task-turn", 0, []provider.Message{sys, sum, as}, 1}, |
| 167 | } |
| 168 | for _, tc := range cases { |
| 169 | t.Run(tc.name, func(t *testing.T) { |
| 170 | if got := newA(tc.win).pinnedPrefixLen(tc.msgs); got != tc.want { |
| 171 | t.Errorf("pinnedPrefixLen = %d, want %d", got, tc.want) |
| 172 | } |
| 173 | }) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // TestSummarizeRespectsContextCancel: a stalled stream (open but never closing) |
| 178 | // must unblock on context cancellation instead of pinning compaction forever. |
| 179 | func TestSummarizeRespectsContextCancel(t *testing.T) { |
| 180 | a := New(&fakeProvider{hang: true}, tool.NewRegistry(), &Session{}, Options{}, event.Discard) |
| 181 | ctx, cancel := context.WithCancel(context.Background()) |
| 182 | cancel() |
| 183 | if _, _, err := a.summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err == nil { |
| 184 | t.Fatal("summarize must return when ctx is cancelled, not hang") |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // TestCompactEmitsEvents covers the card-driving signals: a CompactionStarted |
| 189 | // (before the summarizer runs) then a CompactionDone carrying the trigger, |
| 190 | // message count, and summary — in that order. |
| 191 | func TestCompactEmitsEvents(t *testing.T) { |
| 192 | prov := &fakeProvider{reply: "- goal: do X"} |
| 193 | sess := &Session{Messages: []provider.Message{ |
| 194 | {Role: provider.RoleSystem, Content: "sys"}, |
| 195 | {Role: provider.RoleUser, Content: "task"}, |
| 196 | {Role: provider.RoleAssistant, Content: strings.Repeat("step one work ", 200)}, |
| 197 | {Role: provider.RoleUser, Content: "more"}, |
| 198 | {Role: provider.RoleAssistant, Content: strings.Repeat("step two work ", 200)}, |
| 199 | {Role: provider.RoleUser, Content: "next"}, |
| 200 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 201 | }} |
| 202 | var got []event.Event |
| 203 | sink := event.FuncSink(func(e event.Event) { got = append(got, e) }) |
| 204 | a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 50_000, RecentKeep: 2}, sink) |
| 205 | |
| 206 | if err := a.compact(context.Background(), "auto", "", true); err != nil { |
| 207 | t.Fatalf("compact: %v", err) |
| 208 | } |
| 209 | |
| 210 | startedAt, doneAt := -1, -1 |
| 211 | for i, e := range got { |
| 212 | switch e.Kind { |
| 213 | case event.CompactionStarted: |
| 214 | startedAt = i |
| 215 | if e.Compaction.Trigger != "auto" { |
| 216 | t.Errorf("started trigger = %q, want auto", e.Compaction.Trigger) |
| 217 | } |
| 218 | case event.CompactionDone: |
| 219 | doneAt = i |
| 220 | c := e.Compaction |
| 221 | if c.Trigger != "auto" || c.Messages == 0 || !strings.Contains(c.Summary, "do X") { |
| 222 | t.Errorf("done event = %+v", c) |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | if startedAt < 0 { |
| 227 | t.Fatal("no CompactionStarted event emitted") |
| 228 | } |
| 229 | if doneAt < 0 { |
| 230 | t.Fatal("no CompactionDone event emitted") |
| 231 | } |
| 232 | if startedAt > doneAt { |
| 233 | t.Errorf("CompactionStarted (%d) must precede CompactionDone (%d)", startedAt, doneAt) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | // TestCompactInjectsFocusAndPreCompactHook checks that /compact <focus> text and |
| 238 | // a PreCompact hook's output both reach the summarizer's system prompt. |
| 239 | func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) { |
| 240 | prov := &fakeProvider{reply: "- ok"} |
| 241 | big := strings.Repeat("step work detail ", 200) |
| 242 | sess := &Session{Messages: []provider.Message{ |
| 243 | {Role: provider.RoleSystem, Content: "sys"}, |
| 244 | {Role: provider.RoleUser, Content: "task"}, |
| 245 | {Role: provider.RoleAssistant, Content: big}, |
| 246 | {Role: provider.RoleUser, Content: "more"}, |
| 247 | {Role: provider.RoleAssistant, Content: big}, |
| 248 | {Role: provider.RoleUser, Content: "next"}, |
| 249 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 250 | }} |
| 251 | a := New(prov, tool.NewRegistry(), sess, Options{ |
| 252 | ContextWindow: 50_000, RecentKeep: 2, |
| 253 | Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"}, |
| 254 | }, event.Discard) |
| 255 | |
| 256 | if err := a.compact(context.Background(), "manual", "focus on the auth refactor", true); err != nil { |
| 257 | t.Fatalf("compact: %v", err) |
| 258 | } |
| 259 | if len(prov.got) == 0 || prov.got[0].Role != provider.RoleSystem { |
| 260 | t.Fatalf("summarizer wasn't asked with a system prompt: %+v", prov.got) |
| 261 | } |
| 262 | instruction := prov.got[len(prov.got)-1].Content |
| 263 | if !strings.Contains(instruction, "focus on the auth refactor") { |
| 264 | t.Errorf("final summary instruction missing the /compact focus text: %q", instruction) |
| 265 | } |
| 266 | if !strings.Contains(instruction, "KEEP-THE-MIGRATION-PLAN") { |
| 267 | t.Errorf("final summary instruction missing the PreCompact hook output: %q", instruction) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | func TestCompactSkipsSingleSmallMessage(t *testing.T) { |
| 272 | prov := &fakeProvider{reply: "- should not be called"} |
| 273 | sess := &Session{Messages: []provider.Message{ |
| 274 | {Role: provider.RoleSystem, Content: "sys"}, |
| 275 | {Role: provider.RoleUser, Content: "tiny"}, |
| 276 | {Role: provider.RoleUser, Content: "next"}, |
| 277 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 278 | }} |
| 279 | a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) |
| 280 | |
| 281 | if err := a.compact(context.Background(), "auto", "", false); err != nil { |
| 282 | t.Fatalf("compact: %v", err) |
| 283 | } |
| 284 | if got := len(sess.Messages); got != 4 { |
| 285 | t.Fatalf("small single message should not compact, len = %d", got) |
| 286 | } |
| 287 | if len(prov.got) != 0 { |
| 288 | t.Fatalf("summarizer was called for tiny region: %+v", prov.got) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestMaybeCompactThreshold(t *testing.T) { |
| 293 | // compact_ratio is the sole trigger. Use a realistic window so hardInputCeiling |
| 294 | // (window−protocolReserve) stays above the fold trigger; tiny synthetic |
| 295 | // windows collapse hard to 1 and force every observation. |
| 296 | const window = 10_000 |
| 297 | const ratio = 0.8 // fold trigger = 8000 |
| 298 | newSess := func() *Session { |
| 299 | return &Session{Messages: []provider.Message{ |
| 300 | {Role: provider.RoleSystem, Content: "sys"}, |
| 301 | {Role: provider.RoleUser, Content: "task"}, |
| 302 | {Role: provider.RoleAssistant, Content: strings.Repeat("a ", 5000)}, |
| 303 | {Role: provider.RoleUser, Content: "c"}, |
| 304 | {Role: provider.RoleAssistant, Content: strings.Repeat("b ", 5000)}, |
| 305 | {Role: provider.RoleUser, Content: "e"}, |
| 306 | {Role: provider.RoleAssistant, Content: "f"}, |
| 307 | }} |
| 308 | } |
| 309 | opts := Options{ContextWindow: window, CompactRatio: ratio, RecentKeep: 2} |
| 310 | |
| 311 | // Below compact_ratio: untouched, no summarizer call. |
| 312 | sess := newSess() |
| 313 | prov := &fakeProvider{reply: "s"} |
| 314 | a := New(prov, tool.NewRegistry(), sess, opts, event.Discard) |
| 315 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 7000}) |
| 316 | if len(sess.Messages) != 7 { |
| 317 | t.Errorf("below threshold should not compact, len = %d", len(sess.Messages)) |
| 318 | } |
| 319 | if len(prov.got) != 0 { |
| 320 | t.Fatalf("below threshold called summarizer: %+v", prov.got) |
| 321 | } |
| 322 | |
| 323 | // 60% is below the sole 80% trigger: still no maintenance. |
| 324 | sess = newSess() |
| 325 | prov = &fakeProvider{reply: "s"} |
| 326 | a = New(prov, tool.NewRegistry(), sess, opts, event.Discard) |
| 327 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 6000}) |
| 328 | if a.currentProjectionVersion() != 0 || len(prov.got) != 0 { |
| 329 | t.Fatalf("60%% should not maintain: version=%d calls=%d", a.currentProjectionVersion(), len(prov.got)) |
| 330 | } |
| 331 | |
| 332 | // At/above compact_ratio: one summary projection; canonical stays full. |
| 333 | sess = newSess() |
| 334 | a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, opts, event.Discard) |
| 335 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500}) |
| 336 | if !hasCompactionSummary(visibleContext(a)) { |
| 337 | t.Errorf("compact threshold should install a summary projection, got: %+v", visibleContext(a)) |
| 338 | } |
| 339 | if len(sess.Messages) != 7 { |
| 340 | t.Errorf("canonical should stay full after projection compact, len=%d", len(sess.Messages)) |
| 341 | } |
| 342 | |
| 343 | // No context window: compaction disabled. |
| 344 | sess = newSess() |
| 345 | a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard) |
| 346 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 1 << 30}) |
| 347 | if len(sess.Messages) != 7 { |
| 348 | t.Errorf("no window should disable compaction, len = %d", len(sess.Messages)) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) { |
| 353 | // Physical hard ceiling (window−reserve) forces a summary even when the fold |
| 354 | // is below the minFoldTokens economics floor — but the fold must still be |
| 355 | // large enough that the candidate lands under the compact_ratio trigger. |
| 356 | const window = 10_000 |
| 357 | big := strings.Repeat("old analysis detail ", 400) |
| 358 | sess := &Session{Messages: []provider.Message{ |
| 359 | {Role: provider.RoleSystem, Content: "sys"}, |
| 360 | {Role: provider.RoleUser, Content: "task"}, |
| 361 | {Role: provider.RoleAssistant, Content: big}, |
| 362 | {Role: provider.RoleUser, Content: "next"}, |
| 363 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 364 | }} |
| 365 | prov := &fakeProvider{reply: "forced summary"} |
| 366 | a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) |
| 367 | |
| 368 | // hard = 10000-256 = 9744; observe just above it. |
| 369 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 9800}) |
| 370 | if got := len(sess.Messages); got != 5 { |
| 371 | t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages) |
| 372 | } |
| 373 | if sess.Messages[1].Content != "task" { |
| 374 | t.Fatalf("first user turn not pinned verbatim in canonical: %+v", sess.Messages[1]) |
| 375 | } |
| 376 | proj := visibleContext(a) |
| 377 | if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "forced summary") { |
| 378 | t.Fatalf("forced compact did not install summary projection: %+v", proj) |
| 379 | } |
| 380 | if len(prov.got) == 0 { |
| 381 | t.Fatalf("summarizer was not called at force ceiling") |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) { |
| 386 | // Above compact_ratio but below the physical hard ceiling: low-value folds |
| 387 | // are rejected by foldEconomics without calling the summarizer. |
| 388 | const window = 10_000 |
| 389 | sess := &Session{Messages: []provider.Message{ |
| 390 | {Role: provider.RoleSystem, Content: "sys"}, |
| 391 | {Role: provider.RoleUser, Content: "small old request"}, |
| 392 | {Role: provider.RoleAssistant, Content: "small old answer"}, |
| 393 | {Role: provider.RoleUser, Content: "next"}, |
| 394 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 395 | }} |
| 396 | prov := &fakeProvider{reply: "should not summarize"} |
| 397 | a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) |
| 398 | |
| 399 | // fold trigger = 8500; hard = 9744. Observe between them. |
| 400 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8600}) |
| 401 | if got := len(sess.Messages); got != 5 { |
| 402 | t.Fatalf("low-value region should not compact before force ceiling, len = %d", got) |
| 403 | } |
| 404 | if len(prov.got) != 0 { |
| 405 | t.Fatalf("summarizer was called for low-value non-forced region: %+v", prov.got) |
| 406 | } |
| 407 | if a.currentProjectionVersion() != 0 { |
| 408 | t.Fatalf("low-value region installed projection version %d", a.currentProjectionVersion()) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) { |
| 413 | const window = 10_000 |
| 414 | // Large assistant work (not the first user turn) so it is foldable, not pinned. |
| 415 | sess := &Session{Messages: []provider.Message{ |
| 416 | {Role: provider.RoleSystem, Content: "sys"}, |
| 417 | {Role: provider.RoleUser, Content: "task"}, |
| 418 | {Role: provider.RoleAssistant, Content: strings.Repeat("large prompt chunk ", 500)}, |
| 419 | {Role: provider.RoleUser, Content: "next"}, |
| 420 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 421 | }} |
| 422 | a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{ |
| 423 | ContextWindow: window, CompactRatio: 0.8, RecentKeep: 2, |
| 424 | }, event.Discard) |
| 425 | |
| 426 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500}) |
| 427 | if got := len(sess.Messages); got != 5 { |
| 428 | t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages) |
| 429 | } |
| 430 | proj := visibleContext(a) |
| 431 | if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "single large summary") { |
| 432 | t.Fatalf("single large message was not compacted into projection: %+v", proj) |
| 433 | } |
| 434 | } |
| 435 | |
| 436 | func TestRenderTranscriptRedactsToolCallArgs(t *testing.T) { |
| 437 | msgs := []provider.Message{ |
| 438 | {Role: provider.RoleUser, Content: "Find me popular GitHub MCP projects"}, |
| 439 | { |
| 440 | Role: provider.RoleAssistant, |
| 441 | Content: "I'll research that.", |
| 442 | ToolCalls: []provider.ToolCall{ |
| 443 | {Name: "research", Arguments: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`}, |
| 444 | }, |
| 445 | }, |
| 446 | {Role: provider.RoleTool, Name: "research", Content: "Found 5 projects."}, |
| 447 | } |
| 448 | |
| 449 | out := renderTranscript(msgs) |
| 450 | |
| 451 | if strings.Contains(out, "Search for recently popular") { |
| 452 | t.Fatalf("renderTranscript leaked tool-call arguments into transcript:\n%s", out) |
| 453 | } |
| 454 | if !strings.Contains(out, "[assistant calls research]") { |
| 455 | t.Fatalf("renderTranscript missing tool-call label:\n%s", out) |
| 456 | } |
| 457 | if !strings.Contains(out, "task") { |
| 458 | t.Fatalf("renderTranscript missing key names:\n%s", out) |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | // Display-only output stays verbatim in the canonical transcript by construction |
| 463 | // (compaction only writes a projection); this pins the other half: it must never |
| 464 | // reach the summarizer or the model-visible projection. |
| 465 | func TestInterruptedDisplayStaysOutOfCompactionPromptAndProjection(t *testing.T) { |
| 466 | local := provider.Message{ |
| 467 | Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, |
| 468 | LocalOnly: true, Content: "partial visible answer", ReasoningContent: "private partial reasoning", |
| 469 | InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true}, |
| 470 | } |
| 471 | a := &Agent{} |
| 472 | kept, fold, retention := a.partitionFoldForProjection([]provider.Message{local}) |
| 473 | if len(kept) != 0 || len(fold) != 0 { |
| 474 | t.Fatalf("compaction partition kept=%+v fold=%+v, want display-only output in neither", kept, fold) |
| 475 | } |
| 476 | if retention.Kept != 0 || retention.Dropped != 0 { |
| 477 | t.Fatalf("retention = %+v, want display-only output counted as neither kept nor dropped", retention) |
| 478 | } |
| 479 | if transcript := renderTranscript([]provider.Message{local}); transcript != "" { |
| 480 | t.Fatalf("local interrupted output leaked into compaction prompt: %q", transcript) |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | func TestCompactKeepsActiveTurnVerbatim(t *testing.T) { |
| 485 | const currentCreatedAt int64 = 123456 |
| 486 | call := provider.Message{ |
| 487 | Role: provider.RoleAssistant, |
| 488 | ToolCalls: []provider.ToolCall{{ |
| 489 | ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`, |
| 490 | }}, |
| 491 | } |
| 492 | result := provider.Message{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"} |
| 493 | sess := &Session{Messages: []provider.Message{ |
| 494 | {Role: provider.RoleSystem, Content: "sys"}, |
| 495 | {Role: provider.RoleUser, Content: strings.Repeat("old request ", 200)}, |
| 496 | {Role: provider.RoleAssistant, Content: strings.Repeat("old answer ", 200)}, |
| 497 | {Role: provider.RoleUser, Content: "update a.txt", CreatedAt: currentCreatedAt}, |
| 498 | call, |
| 499 | result, |
| 500 | }} |
| 501 | a := New(&fakeProvider{reply: "old work summary"}, tool.NewRegistry(), sess, Options{ |
| 502 | ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 1, |
| 503 | }, event.Discard) |
| 504 | a.activeTurnCreatedAt.Store(currentCreatedAt) |
| 505 | |
| 506 | if err := a.compact(context.Background(), "auto", "", true); err != nil { |
| 507 | t.Fatalf("compact: %v", err) |
| 508 | } |
| 509 | start := a.activeTurnStart(sess.Messages) |
| 510 | if start < 0 || len(sess.Messages)-start != 3 { |
| 511 | t.Fatalf("active turn boundary = %d in %+v, want three-message verbatim tail", start, sess.Messages) |
| 512 | } |
| 513 | if sess.Messages[start].Content != "update a.txt" || sess.Messages[start+1].ToolCalls[0].Arguments != call.ToolCalls[0].Arguments || sess.Messages[start+2].Content != result.Content { |
| 514 | t.Fatalf("active turn changed during compaction: %+v", sess.Messages[start:]) |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | func TestSummarizeToolArgs(t *testing.T) { |
| 519 | tests := []struct { |
| 520 | name string |
| 521 | args string |
| 522 | want string |
| 523 | wantNot string |
| 524 | }{ |
| 525 | { |
| 526 | name: "redacts long task prompt", |
| 527 | args: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`, |
| 528 | want: "task", |
| 529 | }, |
| 530 | { |
| 531 | name: "empty args", |
| 532 | args: "", |
| 533 | want: "no arguments", |
| 534 | }, |
| 535 | { |
| 536 | name: "invalid json", |
| 537 | args: "not json", |
| 538 | want: "bytes", |
| 539 | }, |
| 540 | { |
| 541 | name: "multiple keys sorted", |
| 542 | args: `{"prompt":"do something","model":"gpt-4"}`, |
| 543 | want: "model, prompt", |
| 544 | }, |
| 545 | } |
| 546 | for _, tt := range tests { |
| 547 | t.Run(tt.name, func(t *testing.T) { |
| 548 | got := summarizeToolArgs(tt.args) |
| 549 | if !strings.Contains(got, tt.want) { |
| 550 | t.Errorf("summarizeToolArgs(%q) = %q, want contains %q", tt.args, got, tt.want) |
| 551 | } |
| 552 | if tt.wantNot != "" && strings.Contains(got, tt.wantNot) { |
| 553 | t.Errorf("summarizeToolArgs(%q) = %q, should NOT contain %q", tt.args, got, tt.wantNot) |
| 554 | } |
| 555 | }) |
| 556 | } |
| 557 | } |
| 558 | |
| 559 | // TestMaybeCompactClearsStuckLatchAnywhereBelowTrigger pins the documented |
| 560 | // contract that any turn under the compact trigger is "breathing room" that |
| 561 | // clears the stuck latch. The snip band ([snip, high)) is the regression: it |
| 562 | // returned before the reset ran, so a compaction that healthily settled the |
| 563 | // prompt at, say, 70% of the window left a stale consecutive-run count behind |
| 564 | // and the next compaction latched the session as "window too small" — silently |
| 565 | // disabling auto-compaction for the rest of the run. |
| 566 | func TestMaybeCompactClearsStuckLatchAnywhereBelowTrigger(t *testing.T) { |
| 567 | // contextWindow 20000 => soft 10000, snip 12000, high (trigger) 16000. |
| 568 | for _, tc := range []struct { |
| 569 | name string |
| 570 | prompt int |
| 571 | }{ |
| 572 | {"below soft", 8000}, |
| 573 | {"soft band", 11000}, |
| 574 | {"snip band", 14000}, |
| 575 | } { |
| 576 | t.Run(tc.name, func(t *testing.T) { |
| 577 | sess := NewSession("sys") |
| 578 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"}) |
| 579 | a := New(&fakeProvider{reply: "- summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 20000}, event.Discard) |
| 580 | a.sess.compaction.consecutive = 1 |
| 581 | a.sess.compaction.stuck = true |
| 582 | |
| 583 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: tc.prompt}) |
| 584 | |
| 585 | if a.sess.compaction.consecutive != 0 || a.sess.compaction.stuck { |
| 586 | t.Fatalf("prompt %d sits under the trigger; want the latch cleared, got consecutiveCompacts=%d compactStuck=%v", |
| 587 | tc.prompt, a.sess.compaction.consecutive, a.sess.compaction.stuck) |
| 588 | } |
| 589 | }) |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | // TestMaybeCompactDefersWhenOnlyActiveTurnRemains proves current-turn |
| 594 | // protection wins over a synthetic pressure observation. |
| 595 | func TestMaybeCompactDefersWhenOnlyActiveTurnRemains(t *testing.T) { |
| 596 | sess := NewSession("sys") |
| 597 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"}) |
| 598 | a := New(&fakeProvider{reply: "- summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 20000}, event.Discard) |
| 599 | |
| 600 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 17000}) |
| 601 | if a.sess.compaction.stuck { |
| 602 | t.Fatalf("active turn should be deferred, not durably blocked: consecutiveCompacts=%d", a.sess.compaction.consecutive) |
| 603 | } |
| 604 | version := a.currentProjectionVersion() |
| 605 | prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 17000}) |
| 606 | if got := a.currentProjectionVersion(); got != version { |
| 607 | t.Fatalf("blocked fingerprint retried: projection version %d -> %d", version, got) |
| 608 | } |
| 609 | } |
| 610 | |
| 611 | func TestCompactTriggerIgnoresConfiguredOutputBudget(t *testing.T) { |
| 612 | a := &Agent{agentConfig: agentConfig{contextWindow: 100_000, maxOutputTokens: 20_000, compactRatio: 0.85}} |
| 613 | if got := a.compactTrigger(); got != 85_000 { |
| 614 | t.Fatalf("trigger = %d, want 85000 (output budget must not change it)", got) |
| 615 | } |
| 616 | if got := a.hardInputCeiling(); got != 100_000-protocolReserveTokens { |
| 617 | t.Fatalf("hard ceiling = %d, want window minus protocol reserve only", got) |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | func TestCompactRollsOldDigestsIntoNew(t *testing.T) { |
| 622 | // A1 rolling merge: prior digests enter the fold region and are merged into |
| 623 | // one new provider-visible summary. The canonical transcript stays intact. |
| 624 | oldDigest := summaryTagOpen + "\n" + strings.Repeat("old standing fact ", 60) + "\n" + summaryTagClose // >1500 chars → not pinnable |
| 625 | newestDigest := summaryTagOpen + "\nnewest digest\n" + summaryTagClose |
| 626 | big := strings.Repeat("work output ", 200) |
| 627 | sess := &Session{Messages: []provider.Message{ |
| 628 | {Role: provider.RoleSystem, Content: "sys"}, |
| 629 | {Role: provider.RoleUser, Content: "task"}, |
| 630 | {Role: provider.RoleUser, Content: oldDigest}, // old digest, large → folds |
| 631 | {Role: provider.RoleUser, Content: newestDigest}, |
| 632 | {Role: provider.RoleAssistant, Content: big}, |
| 633 | {Role: provider.RoleUser, Content: "next"}, |
| 634 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 635 | }} |
| 636 | a := New(&fakeProvider{reply: "merged digest"}, tool.NewRegistry(), sess, |
| 637 | Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) |
| 638 | |
| 639 | if err := a.compact(context.Background(), "manual", "", true); err != nil { |
| 640 | t.Fatalf("compact: %v", err) |
| 641 | } |
| 642 | canonical := sess.Snapshot() |
| 643 | var oldDigestRetained bool |
| 644 | for _, m := range canonical { |
| 645 | if m.Content == oldDigest { |
| 646 | oldDigestRetained = true |
| 647 | } |
| 648 | } |
| 649 | if !oldDigestRetained { |
| 650 | t.Fatalf("canonical transcript lost old digest: %+v", canonical) |
| 651 | } |
| 652 | |
| 653 | projection := visibleContext(a) |
| 654 | var summaryCount int |
| 655 | var generatedSummaryPresent bool |
| 656 | for _, m := range projection { |
| 657 | if isCompactionSummary(m) { |
| 658 | summaryCount++ |
| 659 | if strings.Contains(m.Content, "merged digest") { |
| 660 | generatedSummaryPresent = true |
| 661 | } |
| 662 | } |
| 663 | if m.Content == oldDigest { |
| 664 | t.Fatalf("old digest survived verbatim in projection: %+v", projection) |
| 665 | } |
| 666 | } |
| 667 | if summaryCount != 1 { |
| 668 | t.Fatalf("projection summaries = %d, want exactly 1: %+v", summaryCount, projection) |
| 669 | } |
| 670 | if !generatedSummaryPresent { |
| 671 | t.Fatalf("generated rolling summary missing from projection: %+v", projection) |
| 672 | } |
| 673 | } |
| 674 |