Vectrum
Sign In

agent: a floor under compaction, /compact, and a way to turn it off

Compaction has folded the older half of the conversation at 70% since it
was built, and the threshold was chosen to leave headroom: the fold is
itself a model call with the history in it, and it is the one thing that
reliably breaks prompt caching. That argument is still right, and the
70% default has not moved.

What it does not cover is a history the split refuses to touch. The cut
keeps the last six messages, so a conversation of four enormous tool
results is too short to cut six off the end of — compactionSplit returns
-1, nothing is folded, and the window goes on filling with the only
mechanism for emptying it declining every turn. Below 70% that restraint
is correct. At 97% it is not: a verbatim tail is worth nothing if the
next request has no room to be sent. So the split now takes the tail
length as a parameter, and past -compact-floor it is tried again keeping
one message instead of six. For a conversation of any real length this
changes nothing, because the cut lands on the midpoint either way; its
only job is to make a short history of huge messages foldable at all.
When even that finds nothing, the loop says so through a new
EventNotice, which beats letting the next request come back as an API
error about a number the user cannot see.

/compact is the same fold on demand. It refuses while a turn is running,
because it rewrites the history that turn is building on, and it takes
the smaller tail past the floor for the same reason the floor does.
Afterwards the context gauge goes blank rather than keep reporting what
the model read before the conversation was replaced; the next turn
measures the window again and it returns.

auto_compact: "off" in the config, or -no-auto-compact for one run, is
for anyone who would rather lose the turn than the transcript. The
window then stops the turn instead of folding it, and the check happens
before the prompt joins the history — a turn that never ran must not
leave its prompt behind, or compacting and asking again would send it
twice. The flag only turns the fold off, never on, which is the
direction -yolo already runs in: a file should not quietly grant more
than the command line did. An absent key means on, so a config written
before this existed does not start stopping the agent.

Two things only running it would have shown. A successful fold left
lastInputTokens describing the history it had just thrown away, so with
auto-compaction off the stop kept firing on a number that had stopped
being true and /compact could never clear it — the count is now reset
with the history it described. And the mock API reported a fixed 9.2k
per turn, which no amount of talking would move; it now scales with the
request, so the gauge climbs the way a real conversation makes it climb
and a threshold can be reached by typing rather than by shrinking the
window until it is already crossed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 00e7e95commit74cbf71

Showing 16 changed files with 948 additions and 42 deletions.

+18−0PLAN.mdView File
@@ -205,6 +205,24 @@ eval numbers didn't drop.
205205 touched and why, decisions made, things discovered the hard way ("the config
206206 is loaded from X, not Y"), and the last few turns verbatim. Do it at 70%, not
207207 95% — compaction itself needs headroom.
208+ - **The 97% floor:** the 70% rule keeps the last six messages, so it declines a
209+ history too short to cut six off — a few enormous tool results that have
210+ nevertheless filled the window. That restraint is right until the window is
211+ nearly full, at which point a verbatim tail buys nothing if the next request
212+ cannot be sent. Past `-compact-floor` (0.97), compact anyway with a
213+ one-message tail — the smallest that is still a valid conversation; if even
214+ that finds nothing to fold, say so rather than let the next request fail as an
215+ opaque API error. This is a backstop, not a second opinion about when to
216+ compact: 70% is still where it normally happens.
217+ - **`/compact`:** the same fold on demand, for starting a long task with a
218+ clean slate. It waits for the current turn, since it rewrites the history
219+ that turn is building on, and past the floor it takes the smaller tail too —
220+ with auto-compaction off it is the only move left.
221+ - **Turning it off:** `auto_compact: "off"` (or `-no-auto-compact`) for anyone
222+ who would rather lose the turn than the transcript. The floor then stops the
223+ turn instead of folding, refusing the prompt before it joins the history so
224+ that compacting and retrying does not send it twice. `/compact` is unaffected:
225+ the setting is about the automatic fold, not the manual one.
208226 - **Stale reads:** if a file was read 20 turns ago and edited since, its old
209227 contents in history are actively harmful. Simplest fix that works: replace
210228 superseded `read_file` results with a stub pointing at the current version.
+54−6README.mdView File
@@ -53,8 +53,33 @@ which case the replacement is written back — leaving the dead one there is wha
5353 would break your Claude Code login.
5454
5555 Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
56- `-resume latest`, `-no-stream`, `-no-cache`, `-v`, `-yolo`, `-no-tui`, `-no-skills`,
57- `-no-claude-code-auth`.
56+ `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`,
57+ `-no-tui`, `-no-skills`, `-no-claude-code-auth`.
58+
59+ ### Flags and thresholds
60+
61+ `-compact-at` (default 0.7) is where the conversation is normally folded into a
62+ summary, keeping the last six messages verbatim. Early and rare is deliberate:
63+ compaction is itself a model call with the history in it, and it is the single
64+ biggest thing that breaks prompt caching.
65+
66+ `-compact-floor` (default 0.97) is the fraction past which compaction stops
67+ being optional. The normal threshold declines a history it cannot split safely —
68+ a handful of enormous tool results is too few messages to cut six off the end of
69+ — and below the floor that restraint is right. Past it, a verbatim tail is worth
70+ nothing if the next request has no room to be sent, so the floor folds anyway
71+ and settles for a one-message tail. `/compact` follows the same rule past the
72+ floor, since with auto-compaction off it is the only move left. When even that
73+ finds nothing to fold, the harness says so, rather than letting the next
74+ request fail as a bare API error.
75+
76+ `-context-window` (default 1,000,000) is what both fractions are measured
77+ against; lower it for a model with less room.
78+
79+ `-no-auto-compact` turns the automatic fold off for one run, the same as
80+ `"auto_compact": "off"` in the config. The floor then stops the turn instead of
81+ folding: the session stays intact, nothing is summarized behind your back, and
82+ `/compact` is what makes room. In `-p` mode the stop is a non-zero exit.
5883
5984 ## The TUI
6085
@@ -123,6 +148,15 @@ fresh input tokens from cache reads, because the gap between the two is what
123148 [METRICS.md](METRICS.md) is about. The prompt box grows with what you type, up
124149 to six lines.
125150
151+ The conversation folds itself into a summary at 70% of the window, and
152+ `/compact` does it on demand — before a long task, say, or when the gauge is
153+ climbing faster than the work is. `/compact` waits for the current turn to
154+ finish, because it rewrites the history that turn is building on. Afterwards
155+ the gauge goes blank rather than keep reporting a number the compaction just
156+ made untrue; the next turn measures the window again and it comes back. At 97%
157+ compaction stops being optional, and it can be turned off altogether: see
158+ [the flags](#flags-and-thresholds) below.
159+
126160 | Key | Does |
127161 | --- | --- |
128162 | `enter` | send |
@@ -136,8 +170,8 @@ to six lines.
136170 | `esc` | interrupt the turn |
137171 | `ctrl+c` | interrupt the turn; again when idle to exit |
138172
139- Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`,
140- `/mouse`, `/setup`, `/skills`, and one per discovered skill.
173+ Commands: `/allowed`, `/clear`, `/compact`, `/effort`, `/exit`, `/help`,
174+ `/model`, `/mouse`, `/setup`, `/skills`, and one per discovered skill.
141175
142176 ### The mouse
143177
@@ -338,15 +372,29 @@ honoured):
338372 "model": "claude-opus-5",
339373 "effort": "high",
340374 "approvals": "ask",
341- "theme": "ember"
375+ "theme": "ember",
376+ "auto_compact": "on"
342377 }
343378 ```
344379
380+ `auto_compact` is the one key the wizard does not ask about — set it by hand, or
381+ pass `-no-auto-compact` for a single run. Set to `"off"`, the conversation is
382+ never folded on its own: the window becomes a wall rather than a trigger, and at
383+ `-compact-floor` the turn stops before sending a request that would not fit,
384+ naming `/compact` and `/clear` as the ways on. The prompt is refused *before* it
385+ joins the history, so compacting and asking again sends it once rather than
386+ twice. `/compact` keeps working throughout — the setting governs the automatic
387+ fold, not the manual one. Anything other than `"on"` or `"off"` is dropped, and
388+ a missing key means on, so a config written before this existed does not
389+ suddenly start stopping the agent.
390+
345391 Nothing in that file is required, and none of it wins an argument with a flag:
346392 precedence is **flag, then config, then the built-in default**, so
347393 `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a
348394 default is honoured, but `-yolo` can only turn approvals off, never on — a file
349395 should not be able to quietly grant the agent more than the command line did.
396+ `-no-auto-compact` runs the same way round: the flag can only turn the automatic
397+ fold off, and a config that never mentions it leaves it on.
350398 A value this build does not recognise is dropped rather than forwarded to the
351399 API, and effort is checked against `llm.EffortLevels` rather than a second copy
352400 of the same five strings.
@@ -491,7 +539,7 @@ internal/llm Messages API: types, HTTP, SSE, pricing
491539 internal/agent turn loop, conversation state, compaction, sessions
492540 internal/tools the tool registry and the tools
493541 internal/skills discovery and parsing of SKILL.md
494- internal/config saved defaults: model, effort, approvals, theme
542+ internal/config saved defaults: model, effort, approvals, theme, compaction
495543 internal/approval allow / deny / ask, with shell analysis
496544 internal/metrics JSONL event log
497545 internal/ui line-based terminal rendering
+7−0cmd/harness/main.goView File
@@ -48,6 +48,8 @@ func run() error {
4848 maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt")
4949 contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact")
5050 compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact")
51+ compactFloor := flag.Float64("compact-floor", agent.DefaultCompactFloor, "fraction past which compaction is forced, keeping only a short tail")
52+ noAutoCompact := flag.Bool("no-auto-compact", false, "never fold the conversation on its own; stop the turn when the window fills instead")
5153 noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints")
5254 noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming")
5355 logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
@@ -126,6 +128,9 @@ func run() error {
126128 // unattended, so -yolo can turn it on but a config can only be the default
127129 // for a session where the flag said nothing.
128130 acceptAll := *yolo || cfg.Approvals == config.ApprovalsAcceptAll
131+ // Same direction: the flag can only turn the automatic fold off, and a
132+ // config that never mentioned it leaves it on.
133+ autoCompactOff := *noAutoCompact || !cfg.AutoCompactEnabled()
129134
130135 session := time.Now().UTC().Format("20060102T150405Z")
131136 log, err := metrics.Open(filepath.Join(root, *logPath), session)
@@ -225,6 +230,8 @@ func run() error {
225230 Effort: effortSetting,
226231 ContextWindow: *contextWindow,
227232 CompactAt: *compactAt,
233+ CompactFloor: *compactFloor,
234+ NoAutoCompact: autoCompactOff,
228235 NoCache: *noCache,
229236 Stream: !*noStream,
230237 Skills: found,
+144−15internal/agent/compact.goView File
@@ -3,6 +3,7 @@ package agent
33 import (
44 "context"
55 "fmt"
6+ "strconv"
67 "strings"
78
89 "harness/internal/llm"
@@ -26,14 +27,30 @@ const DefaultContextWindow = 1_000_000
2627 // DefaultCompactAt is the fraction of the window at which to summarize.
2728 // Compaction itself needs headroom — it is a model call with the history in it
2829 // — so waiting until the window is nearly full means the compaction request is
29- // the one that fails.
30+ // the one that fails. It is also the single biggest thing that breaks prompt
31+ // caching, which is the other argument for doing it early and rarely.
3032 const DefaultCompactAt = 0.7
3133
34+ // DefaultCompactFloor is the fraction at which compaction stops being optional.
35+ // The normal threshold refuses to cut a history it cannot split safely — a
36+ // handful of enormous tool results is too few messages for keepRecentMessages —
37+ // and below the floor that restraint is right. Once the window is this full it
38+ // is not: a tail kept verbatim is worth nothing if the next request has no room
39+ // to be sent. The floor folds with keepUrgentMessages instead.
40+ const DefaultCompactFloor = 0.97
41+
3242 // keepRecentMessages is how much of the tail survives compaction verbatim. The
3343 // model is usually mid-thought, and a summary of the last two minutes of work
3444 // is worse than the work itself.
3545 const keepRecentMessages = 6
3646
47+ // keepUrgentMessages is the tail the floor settles for. For any conversation of
48+ // real length this changes nothing — the split lands on the midpoint either way
49+ // — so its only job is to let a short history of enormous messages be cut at
50+ // all. One is the smallest tail that is still a valid conversation: the folded
51+ // summary takes the user slot, and one assistant message follows it.
52+ const keepUrgentMessages = 1
53+
3754 const compactPrompt = `Summarize the conversation above for a coding agent that will keep working on the same task with this summary in place of the transcript.
3855
3956 Preserve, in this order:
@@ -46,23 +63,126 @@ Preserve, in this order:
4663 Be specific. Names, paths and line numbers are worth more than prose. Omit
4764 pleasantries and anything already visible in the current state of the files.`
4865
49- // maybeCompact summarizes the older half of the conversation when the last
50- // request came close to filling the window. It reports whether it compacted.
51- func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, error) {
52- window := a.opts.ContextWindow
66+ // thresholds resolves the two fractions against their defaults. The floor is
67+ // never allowed below CompactAt: a -compact-at set higher than the floor would
68+ // otherwise make every ordinary compaction take the aggressive keep and throw
69+ // away the recent tail the model is still working from.
70+ func (a *Agent) thresholds() (window int, at, floor float64) {
71+ window = a.opts.ContextWindow
5372 if window <= 0 {
5473 window = DefaultContextWindow
5574 }
56- at := a.opts.CompactAt
75+ at = a.opts.CompactAt
5776 if at <= 0 {
5877 at = DefaultCompactAt
5978 }
60- if float64(lastInputTokens) < at*float64(window) {
79+ floor = a.opts.CompactFloor
80+ if floor <= 0 {
81+ floor = DefaultCompactFloor
82+ }
83+ if floor < at {
84+ floor = at
85+ }
86+ return window, at, floor
87+ }
88+
89+ // ContextFullError ends a turn that cannot be sent: the window is full and
90+ // auto-compaction is off, so nothing is going to make room on its own. It is a
91+ // typed error because a front end should offer the ways out rather than print
92+ // a failure and leave the user to guess at them.
93+ type ContextFullError struct {
94+ Used, Window int
95+ Floor float64
96+ }
97+
98+ func (e *ContextFullError) Error() string {
99+ return fmt.Sprintf("the context window is %d%% full (%s of %s tokens) and auto-compaction is off",
100+ e.Used*100/e.Window, thousands(e.Used), thousands(e.Window))
101+ }
102+
103+ // thousands groups an integer for reading: 970000 becomes 970,000.
104+ func thousands(n int) string {
105+ s := strconv.Itoa(n)
106+ for i := len(s) - 3; i > 0; i -= 3 {
107+ s = s[:i] + "," + s[i:]
108+ }
109+ return s
110+ }
111+
112+ // contextFull is the hard stop that replaces compaction when auto-compaction is
113+ // off. Below the floor it is nil and the turn proceeds as normal. It takes the
114+ // token count rather than reading a.lastInputTokens so that it and maybeCompact
115+ // cannot end up judging different numbers.
116+ func (a *Agent) contextFull(lastInputTokens int) error {
117+ if !a.opts.NoAutoCompact {
118+ return nil
119+ }
120+ window, _, floor := a.thresholds()
121+ if float64(lastInputTokens) < floor*float64(window) {
122+ return nil
123+ }
124+ return &ContextFullError{Used: lastInputTokens, Window: window, Floor: floor}
125+ }
126+
127+ // maybeCompact summarizes the older half of the conversation when the last
128+ // request came close to filling the window. It reports whether it compacted.
129+ func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, error) {
130+ // Off, the window is a wall rather than a trigger: stop while the session
131+ // is still intact and let the user decide between /compact and /clear.
132+ if a.opts.NoAutoCompact {
133+ return false, a.contextFull(lastInputTokens)
134+ }
135+
136+ window, at, floor := a.thresholds()
137+ used := float64(lastInputTokens)
138+ urgent := used >= floor*float64(window)
139+ if !urgent && used < at*float64(window) {
140+ return false, nil
141+ }
142+
143+ did, err := a.compact(ctx, lastInputTokens, keepRecentMessages)
144+ if err != nil || did || !urgent {
145+ return did, err
146+ }
147+
148+ // Past the floor and the normal keep declined: fold with the smaller tail
149+ // rather than let the next request run into the wall.
150+ did, err = a.compact(ctx, lastInputTokens, keepUrgentMessages)
151+ if err != nil || did {
152+ return did, err
153+ }
154+ a.emit(Event{Kind: EventNotice, Text: fmt.Sprintf(
155+ "the context window is %d%% full and there is nothing left that can be folded away — "+
156+ "the next request may not fit. /clear starts over, or narrow what the agent is holding open.",
157+ int(floor*100))})
158+ return false, nil
159+ }
160+
161+ // Compact folds the conversation on demand, which is what /compact asks for. It
162+ // keeps the same tail an automatic compaction keeps, and reports false rather
163+ // than spending a model call when there is nothing worth folding.
164+ //
165+ // Past the floor it settles for the smaller tail, for the same reason the floor
166+ // does — and because with auto-compaction off this is the only thing between a
167+ // full window and /clear. Declining there would be the harness refusing the one
168+ // move the user has left.
169+ func (a *Agent) Compact(ctx context.Context) (bool, error) {
170+ did, err := a.compact(ctx, a.lastInputTokens, keepRecentMessages)
171+ if err != nil || did {
172+ return did, err
173+ }
174+ window, _, floor := a.thresholds()
175+ if float64(a.lastInputTokens) < floor*float64(window) {
61176 return false, nil
62177 }
178+ return a.compact(ctx, a.lastInputTokens, keepUrgentMessages)
179+ }
63180
181+ // compact folds everything before the split into one summary message, keeping
182+ // the last keep messages verbatim. It reports whether it found anything to fold.
183+ func (a *Agent) compact(ctx context.Context, lastInputTokens, keep int) (bool, error) {
64184 history := a.state.History()
65- split := compactionSplit(history)
185+ split := compactionSplit(history, keep)
66186 if split < 0 {
67187 return false, nil // nothing safe to fold away yet
68188 }
@@ -81,20 +201,29 @@ func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, er
81201
82202 a.state.Replace(append([]llm.Message{folded}, history[split:]...))
83203 a.log.Log(metricsCompaction(lastInputTokens, len(history), a.state.Len()))
204+
205+ // The recorded input size described the history that has just been thrown
206+ // away, so it is no longer a fact about anything. Zero means "unknown",
207+ // which is the truth until the next request measures the window again —
208+ // and leaving the old number would keep the full-window stop firing on a
209+ // conversation that is no longer full, with no way out but /clear.
210+ a.lastInputTokens = 0
211+
84212 a.emit(Event{Kind: EventCompact, Text: summary})
85213 return true, nil
86214 }
87215
88- // compactionSplit picks where to cut. The kept tail must begin with an
89- // assistant message: the folded summary takes the user slot before it, and a
90- // tail starting with a tool_result user message would reference a tool_use that
91- // no longer exists, which the API rejects.
92- func compactionSplit(history []llm.Message) int {
93- if len(history) <= keepRecentMessages+2 {
216+ // compactionSplit picks where to cut, leaving at least keep messages in the
217+ // tail. The kept tail must begin with an assistant message: the folded summary
218+ // takes the user slot before it, and a tail starting with a tool_result user
219+ // message would reference a tool_use that no longer exists, which the API
220+ // rejects.
221+ func compactionSplit(history []llm.Message, keep int) int {
222+ if len(history) <= keep+2 {
94223 return -1
95224 }
96225 want := len(history) / 2
97- if max := len(history) - keepRecentMessages; want > max {
226+ if max := len(history) - keep; want > max {
98227 want = max
99228 }
100229 for i := want; i < len(history); i++ {
+340−15internal/agent/compact_test.goView File
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "errors"
78 "os"
89 "path/filepath"
910 "strings"
@@ -70,36 +71,46 @@ func TestBreakpointsDoNotLeakIntoHistory(t *testing.T) {
7071 }
7172 }
7273
73- func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) {
74- mk := func(roles string) []llm.Message {
75- var out []llm.Message
76- for _, r := range roles {
77- role := llm.RoleUser
78- if r == 'a' {
79- role = llm.RoleAssistant
80- }
81- out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}})
74+ // mkHistory turns "uaua" into alternating user/assistant messages, so a test
75+ // can say what shape of conversation it means in one string.
76+ func mkHistory(roles string) []llm.Message {
77+ var out []llm.Message
78+ for _, r := range roles {
79+ role := llm.RoleUser
80+ if r == 'a' {
81+ role = llm.RoleAssistant
8282 }
83- return out
83+ out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}})
8484 }
85+ return out
86+ }
87+
88+ func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) {
8589 tests := []struct {
8690 name string
8791 roles string
92+ keep int
8893 want int
8994 }{
90- {name: "too short to compact", roles: "uaua", want: -1},
91- {name: "cuts at the first assistant past the midpoint", roles: "uauauauauaua", want: 7},
95+ {name: "too short to compact", roles: "uaua", keep: keepRecentMessages, want: -1},
96+ {name: "cuts at the first assistant past the midpoint", roles: "uauauauauaua", keep: keepRecentMessages, want: 7},
9297 // The tail can end up one message shorter than the target when the
9398 // boundary lands on a user message: correctness beats the constant.
94- {name: "skips forward past a user message", roles: "uauauauaua", want: 5},
99+ {name: "skips forward past a user message", roles: "uauauauaua", keep: keepRecentMessages, want: 5},
100+ // The smaller keep is what the floor uses: it folds conversations the
101+ // normal split refuses to touch, because at 97% doing nothing is worse
102+ // than keeping a short tail.
103+ {name: "a short history the normal keep declines", roles: "uauaua", keep: keepUrgentMessages, want: 3},
104+ {name: "urgently, four messages can still be cut", roles: "uaua", keep: keepUrgentMessages, want: 3},
105+ {name: "nothing is foldable even urgently", roles: "ua", keep: keepUrgentMessages, want: -1},
95106 }
96107 for _, tc := range tests {
97108 t.Run(tc.name, func(t *testing.T) {
98- got := compactionSplit(mk(tc.roles))
109+ got := compactionSplit(mkHistory(tc.roles), tc.keep)
99110 if got != tc.want {
100111 t.Fatalf("compactionSplit = %d, want %d", got, tc.want)
101112 }
102- if got >= 0 && mk(tc.roles)[got].Role != llm.RoleAssistant {
113+ if got >= 0 && mkHistory(tc.roles)[got].Role != llm.RoleAssistant {
103114 t.Error("the kept tail must start with an assistant message, or its tool_results reference nothing")
104115 }
105116 })
@@ -293,3 +304,317 @@ func TestReadPathParsing(t *testing.T) {
293304 t.Errorf("readPath = %q, want empty on malformed input", got)
294305 }
295306 }
307+
308+ // TestCompactionFloor covers the case the 70% rule cannot reach: a history too
309+ // short for the normal split — a handful of enormous tool results — that has
310+ // nevertheless filled the window. Below the floor the normal split declines and
311+ // nothing happens; at the floor the smaller keep folds it anyway.
312+ func TestCompactionFloor(t *testing.T) {
313+ tests := []struct {
314+ name string
315+ inputTokens int
316+ want bool
317+ }{
318+ {name: "past CompactAt but under the floor, the normal split declines", inputTokens: 800, want: false},
319+ {name: "at the floor the smaller keep folds it", inputTokens: 980, want: true},
320+ }
321+ for _, tc := range tests {
322+ t.Run(tc.name, func(t *testing.T) {
323+ f := newFakeAPI(t)
324+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
325+ a.opts.ContextWindow = 1000
326+ // Six messages: more than keepUrgentMessages+2, fewer than
327+ // keepRecentMessages+2, so the two keeps disagree about it.
328+ a.state.Replace(mkHistory("uauaua"))
329+
330+ got, err := a.maybeCompact(context.Background(), tc.inputTokens)
331+ if err != nil {
332+ t.Fatalf("maybeCompact: %v", err)
333+ }
334+ if got != tc.want {
335+ t.Fatalf("maybeCompact = %v, want %v", got, tc.want)
336+ }
337+ if !tc.want && len(f.requests) != 0 {
338+ t.Errorf("declining to compact should cost no model call, got %d", len(f.requests))
339+ }
340+ })
341+ }
342+ }
343+
344+ // TestCompactionFloorWarnsWhenNothingIsFoldable is the case where the window is
345+ // nearly full and there is genuinely nothing to fold. Saying so beats letting
346+ // the next request hit the wall and surface as a bare API error.
347+ func TestCompactionFloorWarnsWhenNothingIsFoldable(t *testing.T) {
348+ f := newFakeAPI(t)
349+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
350+ a.opts.ContextWindow = 1000
351+ a.state.Replace(mkHistory("ua")) // nothing either keep will cut
352+
353+ var notices []string
354+ a.Observe = func(ev Event) {
355+ if ev.Kind == EventNotice {
356+ notices = append(notices, ev.Text)
357+ }
358+ }
359+ got, err := a.maybeCompact(context.Background(), 980)
360+ if err != nil {
361+ t.Fatalf("maybeCompact: %v", err)
362+ }
363+ if got {
364+ t.Fatal("there was nothing to fold; maybeCompact should report false")
365+ }
366+ if len(notices) != 1 {
367+ t.Fatalf("%d notices, want 1 warning that the window is nearly full", len(notices))
368+ }
369+ if !strings.Contains(notices[0], "97%") {
370+ t.Errorf("the warning should name the threshold it crossed: %q", notices[0])
371+ }
372+ if len(f.requests) != 0 {
373+ t.Errorf("nothing to fold should cost no model call, got %d", len(f.requests))
374+ }
375+ }
376+
377+ // TestCompactionFloorNeverSitsBelowCompactAt: -compact-at above the floor would
378+ // otherwise make every ordinary compaction take the aggressive keep and throw
379+ // away the recent tail the model is still working from.
380+ func TestCompactionFloorNeverSitsBelowCompactAt(t *testing.T) {
381+ f := newFakeAPI(t)
382+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
383+ a.opts.ContextWindow = 1000
384+ a.opts.CompactAt = 0.99
385+ a.opts.CompactFloor = 0.5
386+ a.state.Replace(mkHistory("uauaua"))
387+
388+ if got, err := a.maybeCompact(context.Background(), 600); err != nil || got {
389+ t.Fatalf("maybeCompact = %v, %v; 600/1000 is under CompactAt, so nothing should happen", got, err)
390+ }
391+ }
392+
393+ // TestCompactFoldsOnDemand is /compact: no threshold involved, and the recent
394+ // tail is kept exactly as an automatic compaction would keep it.
395+ func TestCompactFoldsOnDemand(t *testing.T) {
396+ f := newFakeAPI(t)
397+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
398+ a.state.Replace(mkHistory("uauauauauaua")) // 12 messages
399+
400+ var compacted int
401+ a.Observe = func(ev Event) {
402+ if ev.Kind == EventCompact {
403+ compacted++
404+ }
405+ }
406+ got, err := a.Compact(context.Background())
407+ if err != nil {
408+ t.Fatalf("Compact: %v", err)
409+ }
410+ if !got {
411+ t.Fatal("Compact should report that it folded the conversation")
412+ }
413+ if compacted != 1 {
414+ t.Errorf("%d compaction events, want 1", compacted)
415+ }
416+
417+ history := a.State().History()
418+ if n := len(history); n != 6 {
419+ t.Fatalf("history has %d messages, want 6: the summary plus the kept tail", n)
420+ }
421+ if !strings.Contains(history[0].Content[0].Text, "[Earlier conversation, compacted]") {
422+ t.Errorf("history should open with the folded summary, got %q", history[0].Content[0].Text)
423+ }
424+ if history[1].Role != llm.RoleAssistant {
425+ t.Errorf("the kept tail starts with %q, want assistant", history[1].Role)
426+ }
427+ }
428+
429+ // TestCompactOnDemandDeclinesAShortConversation: /compact on a conversation
430+ // with nothing worth folding should say so rather than spend a model call
431+ // summarizing three messages.
432+ func TestCompactOnDemandDeclinesAShortConversation(t *testing.T) {
433+ f := newFakeAPI(t)
434+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
435+ a.state.Replace(mkHistory("uaua"))
436+
437+ got, err := a.Compact(context.Background())
438+ if err != nil {
439+ t.Fatalf("Compact: %v", err)
440+ }
441+ if got {
442+ t.Error("Compact should report false when there is nothing to fold")
443+ }
444+ if len(f.requests) != 0 {
445+ t.Errorf("declining should cost no model call, got %d", len(f.requests))
446+ }
447+ }
448+
449+ // With auto-compaction off the harness must not fold anything on its own, even
450+ // well past the ordinary threshold.
451+ func TestAutoCompactOffNeverFolds(t *testing.T) {
452+ f := newFakeAPI(t)
453+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
454+ a.opts.ContextWindow = 1000
455+ a.opts.NoAutoCompact = true
456+ a.state.Replace(mkHistory("uauauauauaua"))
457+
458+ did, err := a.maybeCompact(context.Background(), 900) // 90%: past CompactAt
459+ if err != nil {
460+ t.Fatalf("maybeCompact: %v", err)
461+ }
462+ if did {
463+ t.Error("auto-compaction is off; nothing should have been folded")
464+ }
465+ if len(f.requests) != 0 {
466+ t.Errorf("no model call should have been made, got %d", len(f.requests))
467+ }
468+ if n := a.state.Len(); n != 12 {
469+ t.Errorf("history has %d messages, want the original 12", n)
470+ }
471+ }
472+
473+ // Off, the floor stops the turn instead of folding: sending a request that
474+ // cannot fit only trades a clear message for an opaque API error.
475+ func TestAutoCompactOffStopsAtTheFloor(t *testing.T) {
476+ f := newFakeAPI(t)
477+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
478+ a.opts.ContextWindow = 1000
479+ a.opts.NoAutoCompact = true
480+ a.state.Replace(mkHistory("uauauauauaua"))
481+
482+ _, err := a.maybeCompact(context.Background(), 980)
483+ var full *ContextFullError
484+ if !errors.As(err, &full) {
485+ t.Fatalf("maybeCompact error = %v, want a *ContextFullError", err)
486+ }
487+ if full.Used != 980 || full.Window != 1000 {
488+ t.Errorf("error carries %d of %d, want 980 of 1000", full.Used, full.Window)
489+ }
490+ if !strings.Contains(full.Error(), "98%") {
491+ t.Errorf("the message should name how full the window is: %q", full.Error())
492+ }
493+ }
494+
495+ // The prompt must not join the history on a turn that never runs, or compacting
496+ // and retrying would send it twice.
497+ func TestAutoCompactOffRefusesBeforeTheHistoryGrows(t *testing.T) {
498+ f := newFakeAPI(t)
499+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
500+ a.opts.ContextWindow = 1000
501+ a.opts.NoAutoCompact = true
502+ a.state.Replace(mkHistory("uaua"))
503+ a.lastInputTokens = 980
504+
505+ _, err := a.Run(context.Background(), "carry on")
506+ var full *ContextFullError
507+ if !errors.As(err, &full) {
508+ t.Fatalf("Run error = %v, want a *ContextFullError", err)
509+ }
510+ if n := a.state.Len(); n != 4 {
511+ t.Errorf("history has %d messages, want the original 4: the refused prompt must not be kept", n)
512+ }
513+ if len(f.requests) != 0 {
514+ t.Errorf("a refused turn should reach the API not at all, got %d requests", len(f.requests))
515+ }
516+ }
517+
518+ // /compact is the way out of a full window, so the setting that stops the
519+ // automatic fold must not disable the manual one too.
520+ func TestCompactWorksWithAutoCompactOff(t *testing.T) {
521+ f := newFakeAPI(t)
522+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
523+ a.opts.ContextWindow = 1000
524+ a.opts.NoAutoCompact = true
525+ a.lastInputTokens = 980
526+ a.state.Replace(mkHistory("uauauauauaua"))
527+
528+ did, err := a.Compact(context.Background())
529+ if err != nil {
530+ t.Fatalf("Compact: %v", err)
531+ }
532+ if !did {
533+ t.Error("/compact must still fold when auto-compaction is off")
534+ }
535+ }
536+
537+ // Under the floor with auto-compaction off, an ordinary turn is untouched.
538+ func TestAutoCompactOffLetsAnOrdinaryTurnThrough(t *testing.T) {
539+ f := newFakeAPI(t, msg("end_turn", textBlock("fine")))
540+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
541+ a.opts.ContextWindow = 1000
542+ a.opts.NoAutoCompact = true
543+ a.lastInputTokens = 500
544+
545+ if _, err := a.Run(context.Background(), "carry on"); err != nil {
546+ t.Fatalf("Run: %v", err)
547+ }
548+ }
549+
550+ // The dead end found by running it: auto-compaction off, four enormous
551+ // messages, window full. The turn is refused, and if /compact also declined
552+ // there would be nothing left but /clear — so past the floor the manual fold
553+ // takes the same smaller tail the automatic one would have taken.
554+ func TestCompactFoldsAShortHistoryWhenTheWindowIsFull(t *testing.T) {
555+ f := newFakeAPI(t)
556+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
557+ a.opts.ContextWindow = 1000
558+ a.opts.NoAutoCompact = true
559+ a.lastInputTokens = 980
560+ a.state.Replace(mkHistory("uaua"))
561+
562+ did, err := a.Compact(context.Background())
563+ if err != nil {
564+ t.Fatalf("Compact: %v", err)
565+ }
566+ if !did {
567+ t.Fatal("/compact must fold when it is the only way out of a full window")
568+ }
569+ history := a.State().History()
570+ if len(history) >= 4 {
571+ t.Errorf("history is still %d messages; nothing was folded away", len(history))
572+ }
573+ if history[len(history)-1].Role != llm.RoleAssistant {
574+ t.Error("the kept tail must still start and end on a valid boundary")
575+ }
576+ }
577+
578+ // Below the floor the same short conversation is left alone: folding three
579+ // messages costs a model call and buys nothing.
580+ func TestCompactStillDeclinesAShortHistoryWithRoomLeft(t *testing.T) {
581+ f := newFakeAPI(t)
582+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
583+ a.opts.ContextWindow = 1000
584+ a.lastInputTokens = 100
585+ a.state.Replace(mkHistory("uaua"))
586+
587+ did, err := a.Compact(context.Background())
588+ if err != nil {
589+ t.Fatalf("Compact: %v", err)
590+ }
591+ if did {
592+ t.Error("with the window nearly empty there is nothing worth folding")
593+ }
594+ if len(f.requests) != 0 {
595+ t.Errorf("declining should cost no model call, got %d", len(f.requests))
596+ }
597+ }
598+
599+ // The wedge found by running it: /compact folded the history, but the recorded
600+ // input size still described the conversation as it was before, so the next
601+ // turn was refused on a number that had stopped being true — and no amount of
602+ // compacting could ever clear it.
603+ func TestCompactingUnwedgesAFullWindow(t *testing.T) {
604+ f := newFakeAPI(t, msg("end_turn", textBlock("working again")))
605+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
606+ a.opts.ContextWindow = 1000
607+ a.opts.NoAutoCompact = true
608+ a.lastInputTokens = 980
609+ a.state.Replace(mkHistory("uauauauauaua"))
610+
611+ if _, err := a.Run(context.Background(), "refused"); err == nil {
612+ t.Fatal("the turn should be refused while the window is full")
613+ }
614+ if did, err := a.Compact(context.Background()); err != nil || !did {
615+ t.Fatalf("Compact = %v, %v; want it to fold", did, err)
616+ }
617+ if _, err := a.Run(context.Background(), "and now?"); err != nil {
618+ t.Fatalf("the turn after a compaction must go through, got %v", err)
619+ }
620+ }
+19−2internal/agent/loop.goView File
@@ -67,10 +67,17 @@ type Options struct {
6767 System string
6868 // Effort maps to output_config.effort. Empty means the API default (high).
6969 Effort string
70- // ContextWindow and CompactAt control when the conversation gets folded
71- // into a summary. Zero means the defaults.
70+ // ContextWindow, CompactAt and CompactFloor control when the conversation
71+ // gets folded into a summary: CompactAt is the ordinary threshold,
72+ // CompactFloor the fraction past which compaction stops being optional.
73+ // Zero means the defaults.
7274 ContextWindow int
7375 CompactAt float64
76+ CompactFloor float64
77+ // NoAutoCompact turns the automatic fold off entirely. The window then
78+ // becomes a hard stop at CompactFloor rather than a trigger, and /compact
79+ // is the only thing that makes room.
80+ NoAutoCompact bool
7481 // NoCache disables prompt cache breakpoints, for measuring what they buy.
7582 NoCache bool
7683 // Stream uses the SSE endpoint so output appears as it is produced.
@@ -95,6 +102,10 @@ const (
95102 EventToolResult EventKind = "tool_result"
96103 EventUsage EventKind = "usage"
97104 EventCompact EventKind = "compact"
105+ // EventNotice is the loop telling the user something about the run itself
106+ // rather than about the task — so far, only that the window is full and
107+ // compaction can do nothing more about it.
108+ EventNotice EventKind = "notice"
98109 // EventTextDelta is emitted instead of EventText while streaming.
99110 EventTextDelta EventKind = "text_delta"
100111 )
@@ -182,6 +193,12 @@ func (a *Agent) emit(ev Event) {
182193 // Run sends one user message and drives the tool loop until the model stops
183194 // asking for tools. It returns the final assistant text.
184195 func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
196+ // Checked before the prompt joins the history, so a turn refused for want
197+ // of room leaves the conversation exactly as it was: compacting and asking
198+ // again then sends the prompt once, not twice.
199+ if err := a.contextFull(a.lastInputTokens); err != nil {
200+ return "", err
201+ }
185202 a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}})
186203
187204 for turn := 1; turn <= a.opts.MaxTurns; turn++ {
+19−0internal/config/config.goView File
@@ -32,6 +32,14 @@ const (
3232 ApprovalsAcceptAll = "accept-all"
3333 )
3434
35+ // Auto-compaction settings, as written to the file. Spelled out rather than
36+ // stored as a bool for the same reason the approval modes are: an empty value
37+ // has to mean "no preference", and a bool cannot tell that apart from "off".
38+ const (
39+ AutoCompactOn = "on"
40+ AutoCompactOff = "off"
41+ )
42+
3543 // Themes are the TUI palettes, in the order the wizard offers them. The names
3644 // live here rather than in the tui package so that reading a config never
3745 // depends on a terminal front end being linked in — the eval runner has none.
@@ -51,8 +59,16 @@ type Config struct {
5159 Approvals string `json:"approvals,omitempty"`
5260 // Theme is one of Themes.
5361 Theme string `json:"theme,omitempty"`
62+ // AutoCompact is AutoCompactOn or AutoCompactOff. Empty means on: a
63+ // config written before this setting existed must not quietly start
64+ // stopping the agent at a full window.
65+ AutoCompact string `json:"auto_compact,omitempty"`
5466 }
5567
68+ // AutoCompactEnabled reports whether the conversation should fold itself when
69+ // the window fills. Only an explicit "off" turns it off.
70+ func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff }
71+
5672 // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or
5773 // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset.
5874 func Path() (string, error) {
@@ -107,6 +123,9 @@ func sanitize(c Config) Config {
107123 if !ValidTheme(c.Theme) {
108124 c.Theme = ""
109125 }
126+ if c.AutoCompact != AutoCompactOn && c.AutoCompact != AutoCompactOff {
127+ c.AutoCompact = ""
128+ }
110129 return c
111130 }
112131
+41−0internal/config/config_test.goView File
@@ -152,3 +152,44 @@ func TestFlagsOverrideTheConfig(t *testing.T) {
152152 t.Errorf("two empties produced %q", got)
153153 }
154154 }
155+
156+ func TestAutoCompactRoundTripsAndSanitizes(t *testing.T) {
157+ isolate(t)
158+ for _, v := range []string{AutoCompactOn, AutoCompactOff} {
159+ if err := Save(Config{AutoCompact: v}); err != nil {
160+ t.Fatal(err)
161+ }
162+ got, _, err := Load()
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ if got.AutoCompact != v {
167+ t.Errorf("%q did not survive a round trip: got %q", v, got.AutoCompact)
168+ }
169+ }
170+
171+ if err := Save(Config{AutoCompact: "sometimes"}); err != nil {
172+ t.Fatal(err)
173+ }
174+ got, _, err := Load()
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if got.AutoCompact != "" {
179+ t.Errorf("an unrecognised value survived: %q", got.AutoCompact)
180+ }
181+ }
182+
183+ // Empty means "no preference", and no preference has to mean on: a config
184+ // written before this setting existed must not silently stop the agent at 97%.
185+ func TestAutoCompactDefaultsToOn(t *testing.T) {
186+ if !(Config{}).AutoCompactEnabled() {
187+ t.Error("an empty config should leave auto-compaction on")
188+ }
189+ if !(Config{AutoCompact: AutoCompactOn}).AutoCompactEnabled() {
190+ t.Error("an explicit on should be on")
191+ }
192+ if (Config{AutoCompact: AutoCompactOff}).AutoCompactEnabled() {
193+ t.Error("an explicit off should be off")
194+ }
195+ }
+3−0internal/tui/bridge.goView File
@@ -37,6 +37,7 @@ type (
3737 }
3838 evUsage struct{ usage llm.Usage }
3939 evCompact struct{}
40+ evNotice struct{ text string }
4041
4142 // approvalMsg asks the user to decide. reply is buffered, so answering
4243 // never blocks the UI even if the agent's context was cancelled first.
@@ -74,6 +75,8 @@ func (b *bridge) Observe(ev agent.Event) {
7475 b.p.Send(evUsage{usage: ev.Usage})
7576 case agent.EventCompact:
7677 b.p.Send(evCompact{})
78+ case agent.EventNotice:
79+ b.p.Send(evNotice{text: ev.Text})
7780 }
7881 }
7982
+1−0internal/tui/commands.goView File
@@ -26,6 +26,7 @@ var builtins = []command{
2626 {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"},
2727 {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"},
2828 {name: "setup", desc: "the full-screen wizard for the saved defaults"},
29+ {name: "compact", desc: "fold the conversation into a summary now"},
2930 {name: "clear", desc: "empty the transcript"},
3031 {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"},
3132 {name: "allowed", desc: "what is permanently allowed this session"},
+80−0internal/tui/compact.goaddedView File
@@ -0,0 +1,80 @@
1+ package tui
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ tea "github.com/charmbracelet/bubbletea"
9+ )
10+
11+ // compactDone carries a finished /compact back to the update loop. did is
12+ // false when the agent found nothing worth folding, which is not an error and
13+ // must not be reported as one.
14+ type compactDone struct {
15+ did bool
16+ err error
17+ }
18+
19+ // startCompact answers /compact. It is a model call with the whole older half
20+ // of the conversation in it, so it gets the spinner and the interrupt key that
21+ // a turn gets — the difference is that no prompt goes with it and no turn
22+ // counter moves.
23+ func (m *model) startCompact() tea.Cmd {
24+ ctx, cancel := context.WithCancel(m.parent)
25+ m.cancel = cancel
26+ m.compacting = true
27+ m.started = time.Now()
28+ m.closeMenu()
29+
30+ a := m.opts.Agent
31+ run := func() tea.Msg {
32+ did, err := a.Compact(ctx)
33+ cancel()
34+ if did && err == nil {
35+ // The folded history is what -resume should pick up; leaving the
36+ // pre-compaction transcript on disk would undo the work on the
37+ // next start.
38+ if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil {
39+ err = fmt.Errorf("saving session: %w", saveErr)
40+ }
41+ }
42+ return compactDone{did: did, err: err}
43+ }
44+ return tea.Batch(m.sync(), m.spin.Tick, run)
45+ }
46+
47+ // finishCompact lands the result. The agent has already announced a successful
48+ // fold through EventCompact, so the only things left to say are the two cases
49+ // that produce no such event.
50+ func (m *model) finishCompact(msg compactDone) tea.Cmd {
51+ m.compacting = false
52+ m.interrupting = false
53+ m.cancel = nil
54+
55+ switch {
56+ case msg.err != nil:
57+ m.tr.addError("compaction failed: " + msg.err.Error())
58+ case !msg.did:
59+ m.tr.addNotice("nothing to compact yet — the conversation is still short enough to keep whole")
60+ default:
61+ // The context pill reports what the model read last turn. After a
62+ // manual compaction nothing re-measures it until the next turn, so
63+ // leaving it would park the pre-compaction figure on screen for as
64+ // long as the session stays idle. Hiding it says "unknown", which is
65+ // the truth; automatic compaction needs no equivalent because the
66+ // request that corrects it goes out immediately.
67+ m.ctxTokens = 0
68+ }
69+ return m.sync()
70+ }
71+
72+ // compactActivity is the line above the prompt while a compaction runs.
73+ func (m *model) compactActivity() string {
74+ detail := fmt.Sprintf("%ds · esc to interrupt", int(time.Since(m.started).Seconds()))
75+ verb := "Compacting"
76+ if m.interrupting {
77+ verb = "Interrupting"
78+ }
79+ return m.spin.View() + " " + styCoral.Render(verb+"…") + styDim.Render(" ("+detail+")")
80+ }
+113−0internal/tui/compact_test.goaddedView File
@@ -0,0 +1,113 @@
1+ package tui
2+
3+ import (
4+ "errors"
5+ "strings"
6+ "testing"
7+
8+ "harness/internal/agent"
9+ )
10+
11+ // /compact rewrites the history the in-flight request is built from, so it has
12+ // to wait for the turn to end — the same reasoning that makes /setup refuse.
13+ func TestCompactRefusesMidTurn(t *testing.T) {
14+ m := newTestModel(t)
15+ m.phase = phaseRunning
16+ m.submit("/compact")
17+
18+ if m.compacting {
19+ t.Error("/compact should not have started while a turn is running")
20+ }
21+ if out := m.tr.render(); !strings.Contains(out, "interrupt first") {
22+ t.Errorf("expected a refusal naming the way out, got %q", out)
23+ }
24+ }
25+
26+ func TestCompactStartsWhenIdle(t *testing.T) {
27+ m := newTestModel(t)
28+ m.submit("/compact")
29+
30+ if !m.compacting {
31+ t.Fatal("/compact should put the model into its compacting state")
32+ }
33+ if !strings.Contains(plain(m.activity()), "Compacting") {
34+ t.Errorf("the activity line should say what is happening, got %q", plain(m.activity()))
35+ }
36+ }
37+
38+ // The context pill reports the last turn's input size. After a manual
39+ // compaction no request follows to correct it, so leaving it alone would park
40+ // a pre-compaction number on screen indefinitely.
41+ func TestCompactClearsTheContextPill(t *testing.T) {
42+ m := newTestModel(t)
43+ m.compacting = true
44+ m.ctxTokens = 970_000
45+
46+ m.Update(compactDone{did: true})
47+
48+ if m.compacting {
49+ t.Error("compactDone should end the compacting state")
50+ }
51+ if m.ctxTokens != 0 {
52+ t.Errorf("ctxTokens = %d, want 0: the pre-compaction figure is no longer true", m.ctxTokens)
53+ }
54+ if _, ok := m.contextPill(); ok {
55+ t.Error("the pill should be hidden until the next turn measures the window again")
56+ }
57+ }
58+
59+ func TestCompactReportsNothingToFold(t *testing.T) {
60+ m := newTestModel(t)
61+ m.compacting = true
62+ m.ctxTokens = 1234
63+
64+ m.Update(compactDone{did: false})
65+
66+ if out := m.tr.render(); !strings.Contains(out, "nothing to compact") {
67+ t.Errorf("expected a notice that nothing was folded, got %q", out)
68+ }
69+ if m.ctxTokens != 1234 {
70+ t.Error("nothing was folded, so the context pill still reports the truth")
71+ }
72+ }
73+
74+ func TestCompactReportsFailure(t *testing.T) {
75+ m := newTestModel(t)
76+ m.compacting = true
77+
78+ m.Update(compactDone{err: errors.New("the endpoint said no")})
79+
80+ out := m.tr.render()
81+ if !strings.Contains(out, "the endpoint said no") {
82+ t.Errorf("a failed compaction should say why, got %q", out)
83+ }
84+ if m.compacting {
85+ t.Error("a failed compaction still ends the compacting state")
86+ }
87+ }
88+
89+ // A notice from the agent loop — the window is full and nothing can be folded
90+ // — has to reach the transcript, or the next request fails with no explanation.
91+ func TestAgentNoticeReachesTheTranscript(t *testing.T) {
92+ m := newTestModel(t)
93+ m.Update(evNotice{text: "the context window is 97% full"})
94+
95+ if out := m.tr.render(); !strings.Contains(out, "97% full") {
96+ t.Errorf("expected the notice in the transcript, got %q", out)
97+ }
98+ }
99+
100+ // A turn stopped for want of room has two ways out, and neither is obvious
101+ // from the agent's message on its own — the agent package does not know what a
102+ // slash command is.
103+ func TestContextFullErrorOffersTheWayOut(t *testing.T) {
104+ m := newTestModel(t)
105+ m.Update(turnDone{err: &agent.ContextFullError{Used: 970_000, Window: 1_000_000, Floor: 0.97}})
106+
107+ out := m.tr.render()
108+ for _, want := range []string{"97% full", "/compact", "/clear"} {
109+ if !strings.Contains(out, want) {
110+ t.Errorf("the transcript should mention %q:\n%s", want, out)
111+ }
112+ }
113+ }
+38−3internal/tui/model.goView File
@@ -133,6 +133,10 @@ type model struct {
133133 modelIdx int
134134 modelsLoading bool
135135
136+ // compacting is a /compact in flight: a model call with no turn behind it,
137+ // which still wants the spinner and the interrupt key.
138+ compacting bool
139+
136140 // pending approvals. The agent blocks on one at a time, but a queue costs
137141 // nothing and means a second request can never be dropped.
138142 pending []approvalMsg
@@ -386,7 +390,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
386390 return m, m.onModels(msg)
387391
388392 case spinner.TickMsg:
389- if m.phase == phaseRunning || m.modelsLoading || m.running != nil {
393+ if m.phase == phaseRunning || m.modelsLoading || m.compacting || m.running != nil {
390394 var cmd tea.Cmd
391395 m.spin, cmd = m.spin.Update(msg)
392396 return m, cmd
@@ -425,6 +429,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
425429 m.tr.addNotice("compacted the conversation")
426430 return m, m.sync()
427431
432+ case evNotice:
433+ m.tr.addNotice(msg.text)
434+ return m, m.sync()
435+
436+ case compactDone:
437+ return m, m.finishCompact(msg)
438+
428439 case approvalMsg:
429440 m.pending = append(m.pending, msg)
430441 m.phase = phaseApproving
@@ -503,10 +514,15 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
503514 }
504515 m.pending = nil
505516
517+ var full *agent.ContextFullError
506518 switch {
507519 case msg.err == nil:
508520 case errors.Is(msg.err, context.Canceled):
509521 m.tr.addNotice("interrupted — the session is intact; say what to do differently")
522+ case errors.As(msg.err, &full):
523+ // The agent package has no idea what a slash command is, so the ways
524+ // out are named here rather than in the error it raised.
525+ m.tr.addError(full.Error() + " — /compact folds it into a summary, /clear starts over")
510526 default:
511527 m.tr.addError(msg.err.Error())
512528 }
@@ -543,7 +559,7 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
543559 case "ctrl+c":
544560 // While a turn runs, ctrl-c interrupts it and leaves the session
545561 // intact. Idle, it exits.
546- if m.phase == phaseRunning {
562+ if m.phase == phaseRunning || m.compacting {
547563 return m, m.interrupt()
548564 }
549565 m.quitting = true
@@ -551,7 +567,7 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
551567
552568 case "esc":
553569 m.clearSelection()
554- if m.phase == phaseRunning {
570+ if m.phase == phaseRunning || m.compacting {
555571 return m, m.interrupt()
556572 }
557573 if m.running != nil {
@@ -587,6 +603,11 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
587603 if m.phase == phaseRunning {
588604 return m, nil // the agent is busy; queueing input would be a lie
589605 }
606+ if m.compacting {
607+ // The compaction is about to replace the history a new prompt
608+ // would be appended to. Holding the text in the box keeps it.
609+ return m, nil
610+ }
590611 if m.running != nil {
591612 // A local command is still running and its output is the next
592613 // thing to reach the model. Sending now would put the message
@@ -671,6 +692,16 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
671692 case line == "/effort" || strings.HasPrefix(line, "/effort "):
672693 return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort")))
673694
695+ case line == "/compact":
696+ if m.phase == phaseRunning {
697+ m.tr.addError("/compact would rewrite the history the turn is building on — interrupt first")
698+ return m, m.sync()
699+ }
700+ if m.compacting {
701+ return m, nil
702+ }
703+ return m, m.startCompact()
704+
674705 case line == "/clear":
675706 m.clearSelection()
676707 m.tr.clear()
@@ -912,6 +943,10 @@ func (m *model) activity() string {
912943 return spread(m.localActivity(), "", m.width)
913944 }
914945
946+ if m.compacting {
947+ return spread(m.compactActivity(), "", m.width)
948+ }
949+
915950 var left string
916951 switch m.phase {
917952 case phaseRunning:
+38−1internal/tui/testdata/mockapi/main.goView File
@@ -12,6 +12,13 @@
1212 // tool_result comes back it streams a closing sentence and stops. That covers
1313 // streaming, tool rendering, approve, deny, and interrupt.
1414 //
15+ // A compaction request is answered separately, as plain JSON rather than SSE,
16+ // because the summarization call does not stream. Reported usage grows with the
17+ // size of the request, so the gauge climbs as you talk; pair it with a small
18+ // -context-window to reach a threshold in a handful of turns:
19+ //
20+ // ./harness -base-url http://127.0.0.1:8899 -api-key mock -context-window 30000
21+ //
1522 // Flags: -addr to move it, -fail to make the command exit non-zero, -slow to
1623 // stretch the stream out for watching the spinner and ctrl-c.
1724 package main
@@ -42,6 +49,23 @@ func main() {
4249
4350 func handle(w http.ResponseWriter, r *http.Request) {
4451 body, _ := io.ReadAll(r.Body)
52+
53+ // Compaction is the one request the harness does not stream, so it cannot
54+ // be answered with the SSE script below.
55+ if strings.Contains(string(body), "Summarize the conversation above") {
56+ w.Header().Set("Content-Type", "application/json")
57+ json.NewEncoder(w).Encode(map[string]any{
58+ "id": "msg_mock_summary", "type": "message", "role": "assistant",
59+ "model": "claude-opus-5", "stop_reason": "end_turn",
60+ "content": []map[string]any{{
61+ "type": "text",
62+ "text": "Ran git status and go build; the tree is clean and it compiles.",
63+ }},
64+ "usage": map[string]any{"input_tokens": 900, "output_tokens": 40},
65+ })
66+ return
67+ }
68+
4569 // A tool_result in the history means the harness ran (or refused) our
4670 // command and fed the outcome back, so the next turn is the last one.
4771 final := strings.Contains(string(body), "tool_result")
@@ -49,11 +73,15 @@ func handle(w http.ResponseWriter, r *http.Request) {
4973 w.Header().Set("Content-Type", "text/event-stream")
5074 w.WriteHeader(http.StatusOK)
5175
76+ // Usage grows with the request, so the context gauge climbs as a real
77+ // conversation would and the compaction thresholds can be reached by
78+ // talking rather than only by shrinking -context-window.
79+ read, fresh := estimateTokens(len(body))
5280 send(w, "message_start", map[string]any{
5381 "type": "message_start",
5482 "message": map[string]any{
5583 "id": "msg_mock", "model": "claude-opus-5",
56- "usage": map[string]any{"input_tokens": 1200, "cache_read_input_tokens": 8000},
84+ "usage": map[string]any{"input_tokens": fresh, "cache_read_input_tokens": read},
5785 },
5886 })
5987
@@ -97,6 +125,15 @@ func handle(w http.ResponseWriter, r *http.Request) {
97125 send(w, "message_stop", map[string]any{"type": "message_stop"})
98126 }
99127
128+ // estimateTokens turns a request body size into a plausible cached/fresh split
129+ // at roughly four bytes per token. The exact numbers do not matter; that they
130+ // rise with the conversation does.
131+ func estimateTokens(bodyLen int) (cached, fresh int) {
132+ total := 1200 + bodyLen/4
133+ cached = total * 4 / 5
134+ return cached, total - cached
135+ }
136+
100137 func send(w http.ResponseWriter, event string, data any) {
101138 b, _ := json.Marshal(data)
102139 fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b)
+30−0internal/tui/wizard_test.goView File
@@ -437,3 +437,33 @@ func TestEffortPillNamesTheCommandThatChangesIt(t *testing.T) {
437437 t.Errorf("the effort pill points at /setup, which changes the saved default: %q", got)
438438 }
439439 }
440+
441+ // The wizard saves the whole config, but only asks about four of its fields.
442+ // A setting it never mentions has to survive being carried through it, or
443+ // running /setup would quietly turn auto-compaction back on.
444+ func TestWizardKeepsSettingsItDoesNotAskAbout(t *testing.T) {
445+ cur := config.Config{
446+ Model: "claude-opus-5",
447+ Effort: "high",
448+ Approvals: config.ApprovalsAsk,
449+ Theme: "ember",
450+ AutoCompact: config.AutoCompactOff,
451+ }
452+ w := newWizard(nil, cur)
453+ w.reduced = true
454+ for w.step != stepDone && w.result == nil {
455+ before := w.step
456+ w.advance()
457+ if w.step == before {
458+ t.Fatalf("the wizard stopped advancing at step %d", w.step)
459+ }
460+ }
461+ w.finish()
462+
463+ if w.result == nil {
464+ t.Fatal("the wizard produced no config")
465+ }
466+ if w.result.AutoCompact != config.AutoCompactOff {
467+ t.Errorf("auto_compact = %q, want it carried through untouched", w.result.AutoCompact)
468+ }
469+ }
+3−0internal/ui/render.goView File
@@ -67,6 +67,9 @@ func (r *Renderer) Handle(ev agent.Event) {
6767 case agent.EventCompact:
6868 r.endLine()
6969 fmt.Fprintf(r.W, "%s[compacted the conversation]%s\n", color.dim, color.reset)
70+ case agent.EventNotice:
71+ r.endLine()
72+ fmt.Fprintf(r.W, "%s[%s]%s\n", color.dim, strings.TrimSpace(ev.Text), color.reset)
7073 }
7174 }
7275