package agent import ( "context" "errors" "os" "path/filepath" "reasonix/internal/event" "strings" "testing" "reasonix/internal/provider" "reasonix/internal/tool" ) // fakeProvider returns a fixed reply and records the messages it was asked to // complete, so tests can drive summarization without a network call. type fakeProvider struct { reply string promptTokens int got []provider.Message streamErr error // when set, Stream emits a ChunkError instead of the reply hang bool // when true, Stream returns a channel that never sends or closes } func (f *fakeProvider) Name() string { return "fake" } func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { f.got = req.Messages if f.hang { return make(chan provider.Chunk), nil } ch := make(chan provider.Chunk, 3) if f.streamErr != nil { ch <- provider.Chunk{Type: provider.ChunkError, Err: f.streamErr} close(ch) return ch, nil } ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply} if f.promptTokens > 0 { ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: f.promptTokens, TotalTokens: f.promptTokens}} } ch <- provider.Chunk{Type: provider.ChunkDone} close(ch) return ch, nil } func TestTailStart(t *testing.T) { // 10-char content → with tokPerChar 1.0, each non-empty message costs 10 // "tokens"; tool-call messages carry name+args instead. msg := func(role provider.Role, n int) provider.Message { return provider.Message{Role: role, Content: strings.Repeat("x", n)} } u := func(n int) provider.Message { return msg(provider.RoleUser, n) } as := func(n int) provider.Message { return msg(provider.RoleAssistant, n) } ac := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "f", Arguments: "{}"}}} to := func(n int) provider.Message { return provider.Message{Role: provider.RoleTool, ToolCallID: "1", Name: "f", Content: strings.Repeat("x", n)} } sys := provider.Message{Role: provider.RoleSystem} cases := []struct { name string msgs []provider.Message head int budget int minKeep int wantStr int }{ // Budget 25 fits the two newest 10-char messages (20) but not a third (30); // the tail stops at the third-from-last. {"budget-bounds-tail", []provider.Message{u(10), as(10), u(10), as(10), u(10)}, 0, 25, 2, 3}, // A single huge recent message can't blow the budget below minKeep: the last // two are kept regardless. {"min-keep-floor", []provider.Message{u(10), as(10), u(10), as(10), to(9999)}, 0, 25, 2, 3}, // The boundary lands on an orphan tool result and must move back onto its // assistant so the tail begins with the tool_calls. {"align-off-tool", []provider.Message{sys, u(10), ac, to(10), ac, to(10)}, 1, 0, 1, 4}, // A generous budget keeps everything down to the first compactable message // after the head. {"budget-keeps-all", []provider.Message{sys, u(10), as(10), u(10)}, 1, 100000, 2, 2}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { start := tailStart(tc.msgs, tc.head, tc.budget, 1.0, tc.minKeep) if start != tc.wantStr { t.Errorf("start = %d, want %d", start, tc.wantStr) } if tc.msgs[start].Role == provider.RoleTool { t.Errorf("recent tail begins with orphan tool message at %d", start) } }) } } func TestTailStartSmallSession(t *testing.T) { sys := provider.Message{Role: provider.RoleSystem} usr := provider.Message{Role: provider.RoleUser, Content: "hi"} for i, msgs := range [][]provider.Message{ {sys, usr}, // system + one message: nothing fits the tail; must not index msgs[len] {sys}, {usr}, {}, } { head := 0 if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem { head = 1 } start := tailStart(msgs, head, 16384, 0.25, 2) if start < head || start > len(msgs) { t.Errorf("case %d: start=%d out of bounds [%d,%d]", i, start, head, len(msgs)) } } } func TestPinnedPrefixLen(t *testing.T) { sys := provider.Message{Role: provider.RoleSystem} small := provider.Message{Role: provider.RoleUser, Content: "do X with token T"} big := provider.Message{Role: provider.RoleUser, Content: strings.Repeat("x", 100000)} sum := provider.Message{Role: provider.RoleUser, Content: summaryTagOpen + "\ndigest\n" + summaryTagClose} as := provider.Message{Role: provider.RoleAssistant, Content: "a"} newA := func(win int) *Agent { return New(&fakeProvider{}, tool.NewRegistry(), &Session{}, Options{ContextWindow: win}, event.Discard) } cases := []struct { name string win int msgs []provider.Message want int }{ {"pins-system-and-small-task", 0, []provider.Message{sys, small, as, as}, 2}, {"also-pins-prior-summaries", 0, []provider.Message{sys, small, sum, sum, as}, 4}, {"large-first-turn-stays-foldable", 0, []provider.Message{sys, big, as, as}, 1}, {"tiny-window-wont-pin", 10, []provider.Message{sys, small, as, as}, 1}, {"summary-is-not-the-task-turn", 0, []provider.Message{sys, sum, as}, 2}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := newA(tc.win).pinnedPrefixLen(tc.msgs); got != tc.want { t.Errorf("pinnedPrefixLen = %d, want %d", got, tc.want) } }) } } func TestCompactKeepsMidSessionUserTurns(t *testing.T) { big := strings.Repeat("work output ", 100) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "first task"}, {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: big}, {Role: provider.RoleUser, Content: "by the way, always use pnpm not npm"}, {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: big}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) } // Both the pinned first turn and the mid-session fact survive verbatim — not as // summary text — while the assistant/tool work between them is folded. var pinnedFirst, keptMid bool for _, m := range sess.Snapshot() { if isCompactionSummary(m) { continue } if m.Role == provider.RoleUser && m.Content == "first task" { pinnedFirst = true } if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") { keptMid = true } } if !pinnedFirst || !keptMid { t.Fatalf("user turns not kept verbatim (first=%v mid=%v): %+v", pinnedFirst, keptMid, sess.Snapshot()) } if strings.Contains(strings.Join(snapshotContents(sess), " "), big) { t.Errorf("assistant/tool work was not folded") } } func snapshotContents(s *Session) []string { msgs := s.Snapshot() out := make([]string, len(msgs)) for i, m := range msgs { out[i] = m.Content } return out } func TestRunCompactsAfterFinalAnswer(t *testing.T) { // A turn that ends with a final answer (no trailing tool batch) must still // compact when the context is over the trigger; otherwise a large context // carries into the next turn un-folded and overflows the model window. big := strings.Repeat("old work ", 200) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleAssistant, Content: big}, }} a := New(&fakeProvider{reply: "done", promptTokens: 95}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) if err := a.Run(context.Background(), "what's the status?"); err != nil { t.Fatalf("run: %v", err) } if got := sess.RewriteVersion(); got != 1 { t.Fatalf("final-answer turn over the trigger did not compact: rewrite version = %d, want 1", got) } } func TestCompactKeepsPriorDigests(t *testing.T) { // A prior digest anywhere in the folded region is kept verbatim, not // re-summarized — so a fact it already captured is not lost to re-fold drift. priorDigest := summaryTagOpen + "\n## Standing facts\n- db is orion_prod_42\n" + summaryTagClose big := strings.Repeat("work output ", 200) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: big}, // breaks leading-summary contiguity {Role: provider.RoleUser, Content: priorDigest}, {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(&fakeProvider{reply: "new digest"}, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) } // The fake summarizer returns "new digest" (no fact); the prior fact survives // only because the prior digest was kept verbatim rather than re-folded. var kept bool for _, m := range sess.Snapshot() { if strings.Contains(m.Content, "orion_prod_42") { kept = true } } if !kept { t.Fatalf("prior digest re-summarized away: %+v", sess.Snapshot()) } } func TestCompactReplacesHistory(t *testing.T) { prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"} bigStep := strings.Repeat("important implementation detail ", 80) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task " + bigStep}, {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}}, {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: "file contents"}, {Role: provider.RoleAssistant, Content: "did a step"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} dir := t.TempDir() a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: dir}, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) } if got := sess.RewriteVersion(); got != 1 { t.Fatalf("rewrite version = %d, want 1", got) } // system + pinned first user turn + summary + last 2 verbatim. if got := len(sess.Messages); got != 5 { t.Fatalf("len = %d, want 5: %+v", got, sess.Messages) } if sess.Messages[0].Role != provider.RoleSystem { t.Errorf("message 0 = %s, want system", sess.Messages[0].Role) } if task := sess.Messages[1]; task.Role != provider.RoleUser || !strings.HasPrefix(task.Content, "task ") { t.Errorf("first user turn not pinned verbatim: %+v", task) } summary := sess.Messages[2] if summary.Role != provider.RoleUser || !strings.Contains(summary.Content, "Summary of earlier") || !strings.Contains(summary.Content, "do X") { t.Errorf("summary message = %+v", summary) } if sess.Messages[3].Content != "next" || sess.Messages[4].Content != "ok" { t.Errorf("recent tail not preserved: %+v", sess.Messages[3:]) } // The 3 dropped originals were archived, one JSON object per line (the task // turn is pinned, not folded, so it is not among them). entries, err := os.ReadDir(dir) if err != nil || len(entries) != 1 { t.Fatalf("archive dir: entries=%d err=%v", len(entries), err) } data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) if err != nil { t.Fatalf("read archive: %v", err) } if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines != 3 { t.Errorf("archived %d lines, want 3:\n%s", lines, data) } if !strings.HasSuffix(entries[0].Name(), ".jsonl") { t.Errorf("archive name = %q, want .jsonl", entries[0].Name()) } } func TestCompactKeepsErrorMessages(t *testing.T) { prov := &fakeProvider{reply: "- normal work summarized"} sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"bad"}`}}}, {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command failed"}, {Role: provider.RoleUser, Content: "continue"}, {Role: provider.RoleAssistant, Content: "continued"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepErrors}, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) } if len(sess.Messages) < 6 { t.Fatalf("session unexpectedly short after compact: %+v", sess.Messages) } if sess.Messages[2].Role != provider.RoleAssistant || len(sess.Messages[2].ToolCalls) != 1 { t.Fatalf("kept error lost its assistant tool call: %+v", sess.Messages) } if sess.Messages[3].Role != provider.RoleTool || sess.Messages[3].Content != "error: command failed" { t.Fatalf("error tool result not kept verbatim: %+v", sess.Messages) } if strings.Contains(prov.got[1].Content, "error: command failed") { t.Fatalf("kept error was still folded into summary input:\n%s", prov.got[1].Content) } } func TestKeepIndexesKeepsSiblingToolResultsForKeptError(t *testing.T) { region := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{ {ID: "err", Name: "bash", Arguments: `{"cmd":"bad"}`}, {ID: "ok", Name: "read_file", Arguments: `{"path":"main.go"}`}, }}, {Role: provider.RoleTool, ToolCallID: "err", Name: "bash", Content: "error: command failed"}, {Role: provider.RoleTool, ToolCallID: "ok", Name: "read_file", Content: "package main"}, } keep := keepIndexes(region, KeepErrors) for i, kept := range keep { if !kept { t.Fatalf("keep[%d] = false, want all sibling tool-call messages kept: %v", i, keep) } } } func TestKeepIndexesScopesPolicyAfterLatestSummary(t *testing.T) { priorSummary := provider.Message{Role: provider.RoleUser, Content: summaryTagOpen + "\nprior digest\n" + summaryTagClose} region := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "old", Name: "bash", Arguments: `{}`}}}, {Role: provider.RoleTool, ToolCallID: "old", Name: "bash", Content: "error: old failure"}, priorSummary, {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "new", Name: "bash", Arguments: `{}`}}}, {Role: provider.RoleTool, ToolCallID: "new", Name: "bash", Content: "error: new failure"}, } keep := keepIndexes(region, KeepErrors) want := []bool{false, false, false, true, true} for i := range want { if keep[i] != want[i] { t.Fatalf("keep = %v, want %v", keep, want) } } } func TestCompactKeepsUserMarkedMessages(t *testing.T) { prov := &fakeProvider{reply: "- unmarked work summarized"} marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 200) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleUser, Content: marked}, {Role: provider.RoleAssistant, Content: "worked"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepUserMarked}, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) } var kept bool for _, m := range sess.Messages { if m.Content == marked { kept = true break } } if !kept { t.Fatalf("marked message not kept verbatim: %+v", sess.Messages) } if strings.Contains(prov.got[1].Content, "exact requirement") { t.Fatalf("marked message was still folded into summary input:\n%s", prov.got[1].Content) } } func TestKeepUserMarkedRequiresUserPrefixMarker(t *testing.T) { region := []provider.Message{ {Role: provider.RoleAssistant, Content: "[keep] assistant output"}, {Role: provider.RoleUser, Content: "ordinary prose mentioning [keep] later"}, {Role: provider.RoleUser, Content: " exact requirement"}, } keep := keepIndexes(region, KeepUserMarked) want := []bool{false, false, true} for i := range want { if keep[i] != want[i] { t.Fatalf("keep = %v, want %v", keep, want) } } } // TestCompactFallsBackToMechanicalFoldWhenSummaryFails: when the summarizer is // unreachable, /compact must still free context (fold mechanically) and surface a // card, not hang or abort leaving a full window. func TestCompactFallsBackToMechanicalFoldWhenSummaryFails(t *testing.T) { prov := &fakeProvider{streamErr: errors.New("provider down")} sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: "step one"}, {Role: provider.RoleUser, Content: "more"}, {Role: provider.RoleAssistant, Content: "step two"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} var got []event.Event sink := event.FuncSink(func(e event.Event) { got = append(got, e) }) a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, sink) before := len(sess.Messages) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact should fall back, not error: %v", err) } if len(sess.Messages) >= before { t.Fatalf("session not compacted on summarizer failure: %d -> %d", before, len(sess.Messages)) } var done *event.Compaction for i := range got { if got[i].Kind == event.CompactionDone { done = &got[i].Compaction } } if done == nil || !strings.Contains(done.Summary, "summary was unavailable") { t.Fatalf("CompactionDone = %+v, want a mechanical-fold summary", done) } } // TestSummarizeRespectsContextCancel: a stalled stream (open but never closing) // must unblock on context cancellation instead of pinning compaction forever. func TestSummarizeRespectsContextCancel(t *testing.T) { a := New(&fakeProvider{hang: true}, tool.NewRegistry(), &Session{}, Options{}, event.Discard) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := a.summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err == nil { t.Fatal("summarize must return when ctx is cancelled, not hang") } } // TestCompactEmitsEvents covers the card-driving signals: a CompactionStarted // (before the summarizer runs) then a CompactionDone carrying the trigger, // message count, and summary — in that order. func TestCompactEmitsEvents(t *testing.T) { prov := &fakeProvider{reply: "- goal: do X"} sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: "step one"}, {Role: provider.RoleUser, Content: "more"}, {Role: provider.RoleAssistant, Content: "step two"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} var got []event.Event sink := event.FuncSink(func(e event.Event) { got = append(got, e) }) a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, sink) if err := a.compact(context.Background(), "auto", "", true); err != nil { t.Fatalf("compact: %v", err) } startedAt, doneAt := -1, -1 for i, e := range got { switch e.Kind { case event.CompactionStarted: startedAt = i if e.Compaction.Trigger != "auto" { t.Errorf("started trigger = %q, want auto", e.Compaction.Trigger) } case event.CompactionDone: doneAt = i c := e.Compaction if c.Trigger != "auto" || c.Messages == 0 || !strings.Contains(c.Summary, "do X") { t.Errorf("done event = %+v", c) } } } if startedAt < 0 { t.Fatal("no CompactionStarted event emitted") } if doneAt < 0 { t.Fatal("no CompactionDone event emitted") } if startedAt > doneAt { t.Errorf("CompactionStarted (%d) must precede CompactionDone (%d)", startedAt, doneAt) } } // TestCompactInjectsFocusAndPreCompactHook checks that /compact text and // a PreCompact hook's output both reach the summarizer's system prompt. func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) { prov := &fakeProvider{reply: "- ok"} sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: "step one"}, {Role: provider.RoleUser, Content: "more"}, {Role: provider.RoleAssistant, Content: "step two"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"}}, event.Discard) if err := a.compact(context.Background(), "manual", "focus on the auth refactor", true); err != nil { t.Fatalf("compact: %v", err) } if len(prov.got) == 0 || prov.got[0].Role != provider.RoleSystem { t.Fatalf("summarizer wasn't asked with a system prompt: %+v", prov.got) } sys := prov.got[0].Content if !strings.Contains(sys, "focus on the auth refactor") { t.Errorf("summary system prompt missing the /compact focus text: %q", sys) } if !strings.Contains(sys, "KEEP-THE-MIGRATION-PLAN") { t.Errorf("summary system prompt missing the PreCompact hook output: %q", sys) } } func TestCompactRewriteVersionFeedsCacheDiagnostics(t *testing.T) { prov := &fakeProvider{reply: "- summary"} sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "a"}, {Role: provider.RoleAssistant, Content: "b"}, {Role: provider.RoleUser, Content: "c"}, {Role: provider.RoleAssistant, Content: "d"}, {Role: provider.RoleUser, Content: "e"}, {Role: provider.RoleAssistant, Content: "f"}, }} a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard) before := CaptureShape("sys", nil, sess.RewriteVersion()) if err := a.compact(context.Background(), "auto", "", true); err != nil { t.Fatalf("compact: %v", err) } after := CaptureShape("sys", nil, sess.RewriteVersion()) diag := CompareShape(before, after, &provider.Usage{CacheMissTokens: 10}) if !diag.PrefixChanged { t.Fatalf("diagnostics should report prefix change: %+v", diag) } if len(diag.PrefixChangeReasons) != 1 || diag.PrefixChangeReasons[0] != "log_rewrite" { t.Fatalf("change reasons = %v, want [log_rewrite]", diag.PrefixChangeReasons) } } func TestCompactFoldsSingleLargeMessage(t *testing.T) { prov := &fakeProvider{reply: "- captured the large file contents"} sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("large output line\n", 500)}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) if err := a.compact(context.Background(), "auto", "", false); err != nil { t.Fatalf("compact: %v", err) } if got := len(sess.Messages); got != 4 { t.Fatalf("len = %d, want 4: %+v", got, sess.Messages) } if !strings.Contains(sess.Messages[1].Content, "large file contents") { t.Fatalf("single large message was not summarized: %+v", sess.Messages) } if len(prov.got) == 0 || !strings.Contains(prov.got[1].Content, "large output line") { t.Fatalf("summarizer did not receive the large message: %+v", prov.got) } } func TestCompactSkipsSingleSmallMessage(t *testing.T) { prov := &fakeProvider{reply: "- should not be called"} sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "tiny"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) if err := a.compact(context.Background(), "auto", "", false); err != nil { t.Fatalf("compact: %v", err) } if got := len(sess.Messages); got != 4 { t.Fatalf("small single message should not compact, len = %d", got) } if len(prov.got) != 0 { t.Fatalf("summarizer was called for tiny region: %+v", prov.got) } } func TestMaybeCompactThreshold(t *testing.T) { // A large early user message gives the fold real value; with a 100-token window // the soft (50%), trigger (80%), and force (90%) thresholds are easy to hit. newSess := func() *Session { return &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: strings.Repeat("a ", 500)}, {Role: provider.RoleAssistant, Content: "b"}, {Role: provider.RoleUser, Content: "c"}, {Role: provider.RoleAssistant, Content: "d"}, {Role: provider.RoleUser, Content: "e"}, {Role: provider.RoleAssistant, Content: "f"}, }} } // Below 50% of the window: untouched. sess := newSess() a := New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 49}) if len(sess.Messages) != 7 { t.Errorf("below threshold should not compact, len = %d", len(sess.Messages)) } // At/above 50% only emits a soft notice; it does not rewrite the cache prefix. sess = newSess() prov := &fakeProvider{reply: "s"} var notices []event.Event a = New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.FuncSink(func(e event.Event) { if e.Kind == event.Notice { notices = append(notices, e) } })) a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 50}) if len(sess.Messages) != 7 { t.Errorf("soft threshold should not compact, len = %d", len(sess.Messages)) } if len(prov.got) != 0 { t.Fatalf("soft threshold called summarizer: %+v", prov.got) } if len(notices) != 1 || notices[0].Text != "Context is getting large; preserving cache until cleanup is needed." || !strings.Contains(notices[0].Detail, "context reached 50%") { t.Fatalf("soft threshold notice = %+v", notices) } a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 60}) if len(notices) != 1 { t.Fatalf("soft threshold notice should only emit once, got %d", len(notices)) } // At/above 80%: compacts when the fold is economically worthwhile. The // token-budgeted tail keeps the small recent messages, so the large early // message is the only foldable region — folding it installs a summary at // index 1 (the count is unchanged because one message becomes one summary). sess = newSess() a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80}) if !strings.Contains(sess.Messages[1].Content, "Summary of earlier") { t.Errorf("compact threshold should fold the large early message, got: %+v", sess.Messages[1]) } // No context window: compaction disabled. sess = newSess() a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 1 << 30}) if len(sess.Messages) != 7 { t.Errorf("no window should disable compaction, len = %d", len(sess.Messages)) } } func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) { sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "small old request"}, {Role: provider.RoleAssistant, Content: "small old answer"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} prov := &fakeProvider{reply: "forced summary"} a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 90}) // The first user turn is pinned (index 1) and the token-budgeted tail keeps // next, ok, so only "small old answer" folds — force bypasses the economics // skip and installs a summary at index 2, leaving the count at 5. if got := len(sess.Messages); got != 5 { t.Fatalf("len = %d, want 5 after forced single-message fold: %+v", got, sess.Messages) } if sess.Messages[1].Content != "small old request" { t.Fatalf("first user turn not pinned verbatim: %+v", sess.Messages[1]) } if !strings.Contains(sess.Messages[2].Content, "forced summary") { t.Fatalf("forced compact did not install summary: %+v", sess.Messages) } if len(prov.got) == 0 { t.Fatalf("summarizer was not called at force ceiling") } } func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) { sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "small old request"}, {Role: provider.RoleAssistant, Content: "small old answer"}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} prov := &fakeProvider{reply: "should not summarize"} a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80}) if got := len(sess.Messages); got != 5 { t.Fatalf("low-value region should not compact before force ceiling, len = %d", got) } if len(prov.got) != 0 { t.Fatalf("summarizer was called for low-value non-forced region: %+v", prov.got) } } func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) { sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: strings.Repeat("large prompt chunk ", 500)}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80}) if got := len(sess.Messages); got != 4 { t.Fatalf("len = %d, want 4: %+v", got, sess.Messages) } if !strings.Contains(sess.Messages[1].Content, "single large summary") { t.Fatalf("single large message was not compacted at threshold: %+v", sess.Messages) } } func TestRenderTranscriptRedactsToolCallArgs(t *testing.T) { msgs := []provider.Message{ {Role: provider.RoleUser, Content: "Find me popular GitHub MCP projects"}, { Role: provider.RoleAssistant, Content: "I'll research that.", ToolCalls: []provider.ToolCall{ {Name: "research", Arguments: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`}, }, }, {Role: provider.RoleTool, Name: "research", Content: "Found 5 projects."}, } out := renderTranscript(msgs) if strings.Contains(out, "Search for recently popular") { t.Fatalf("renderTranscript leaked tool-call arguments into transcript:\n%s", out) } if !strings.Contains(out, "[assistant calls research]") { t.Fatalf("renderTranscript missing tool-call label:\n%s", out) } if !strings.Contains(out, "task") { t.Fatalf("renderTranscript missing key names:\n%s", out) } } func TestSummarizeToolArgs(t *testing.T) { tests := []struct { name string args string want string wantNot string }{ { name: "redacts long task prompt", args: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`, want: "task", }, { name: "empty args", args: "", want: "no arguments", }, { name: "invalid json", args: "not json", want: "bytes", }, { name: "multiple keys sorted", args: `{"prompt":"do something","model":"gpt-4"}`, want: "model, prompt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := summarizeToolArgs(tt.args) if !strings.Contains(got, tt.want) { t.Errorf("summarizeToolArgs(%q) = %q, want contains %q", tt.args, got, tt.want) } if tt.wantNot != "" && strings.Contains(got, tt.wantNot) { t.Errorf("summarizeToolArgs(%q) = %q, should NOT contain %q", tt.args, got, tt.wantNot) } }) } }