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>
Showing 16 changed files with 948 additions and 42 deletions.
| @@ -205,6 +205,24 @@ eval numbers didn't drop. | ||
| 205 | 205 | touched and why, decisions made, things discovered the hard way ("the config |
| 206 | 206 | is loaded from X, not Y"), and the last few turns verbatim. Do it at 70%, not |
| 207 | 207 | 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. | |
| 208 | 226 | - **Stale reads:** if a file was read 20 turns ago and edited since, its old |
| 209 | 227 | contents in history are actively harmful. Simplest fix that works: replace |
| 210 | 228 | superseded `read_file` results with a stub pointing at the current version. |
| @@ -53,8 +53,33 @@ which case the replacement is written back — leaving the dead one there is wha | ||
| 53 | 53 | would break your Claude Code login. |
| 54 | 54 | |
| 55 | 55 | 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. | |
| 58 | 83 | |
| 59 | 84 | ## The TUI |
| 60 | 85 | |
| @@ -123,6 +148,15 @@ fresh input tokens from cache reads, because the gap between the two is what | ||
| 123 | 148 | [METRICS.md](METRICS.md) is about. The prompt box grows with what you type, up |
| 124 | 149 | to six lines. |
| 125 | 150 | |
| 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 | + | |
| 126 | 160 | | Key | Does | |
| 127 | 161 | | --- | --- | |
| 128 | 162 | | `enter` | send | |
| @@ -136,8 +170,8 @@ to six lines. | ||
| 136 | 170 | | `esc` | interrupt the turn | |
| 137 | 171 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 138 | 172 | |
| 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. | |
| 141 | 175 | |
| 142 | 176 | ### The mouse |
| 143 | 177 | |
| @@ -338,15 +372,29 @@ honoured): | ||
| 338 | 372 | "model": "claude-opus-5", |
| 339 | 373 | "effort": "high", |
| 340 | 374 | "approvals": "ask", |
| 341 | - "theme": "ember" | |
| 375 | + "theme": "ember", | |
| 376 | + "auto_compact": "on" | |
| 342 | 377 | } |
| 343 | 378 | ``` |
| 344 | 379 | |
| 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 | + | |
| 345 | 391 | Nothing in that file is required, and none of it wins an argument with a flag: |
| 346 | 392 | precedence is **flag, then config, then the built-in default**, so |
| 347 | 393 | `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a |
| 348 | 394 | default is honoured, but `-yolo` can only turn approvals off, never on — a file |
| 349 | 395 | 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. | |
| 350 | 398 | A value this build does not recognise is dropped rather than forwarded to the |
| 351 | 399 | API, and effort is checked against `llm.EffortLevels` rather than a second copy |
| 352 | 400 | of the same five strings. |
| @@ -491,7 +539,7 @@ internal/llm Messages API: types, HTTP, SSE, pricing | ||
| 491 | 539 | internal/agent turn loop, conversation state, compaction, sessions |
| 492 | 540 | internal/tools the tool registry and the tools |
| 493 | 541 | 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 | |
| 495 | 543 | internal/approval allow / deny / ask, with shell analysis |
| 496 | 544 | internal/metrics JSONL event log |
| 497 | 545 | internal/ui line-based terminal rendering |
| @@ -48,6 +48,8 @@ func run() error { | ||
| 48 | 48 | maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt") |
| 49 | 49 | contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact") |
| 50 | 50 | 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") | |
| 51 | 53 | noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints") |
| 52 | 54 | noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming") |
| 53 | 55 | logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root") |
| @@ -126,6 +128,9 @@ func run() error { | ||
| 126 | 128 | // unattended, so -yolo can turn it on but a config can only be the default |
| 127 | 129 | // for a session where the flag said nothing. |
| 128 | 130 | 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() | |
| 129 | 134 | |
| 130 | 135 | session := time.Now().UTC().Format("20060102T150405Z") |
| 131 | 136 | log, err := metrics.Open(filepath.Join(root, *logPath), session) |
| @@ -225,6 +230,8 @@ func run() error { | ||
| 225 | 230 | Effort: effortSetting, |
| 226 | 231 | ContextWindow: *contextWindow, |
| 227 | 232 | CompactAt: *compactAt, |
| 233 | + CompactFloor: *compactFloor, | |
| 234 | + NoAutoCompact: autoCompactOff, | |
| 228 | 235 | NoCache: *noCache, |
| 229 | 236 | Stream: !*noStream, |
| 230 | 237 | Skills: found, |
| @@ -3,6 +3,7 @@ package agent | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "fmt" |
| 6 | + "strconv" | |
| 6 | 7 | "strings" |
| 7 | 8 | |
| 8 | 9 | "harness/internal/llm" |
| @@ -26,14 +27,30 @@ const DefaultContextWindow = 1_000_000 | ||
| 26 | 27 | // DefaultCompactAt is the fraction of the window at which to summarize. |
| 27 | 28 | // Compaction itself needs headroom — it is a model call with the history in it |
| 28 | 29 | // — 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. | |
| 30 | 32 | const DefaultCompactAt = 0.7 |
| 31 | 33 | |
| 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 | + | |
| 32 | 42 | // keepRecentMessages is how much of the tail survives compaction verbatim. The |
| 33 | 43 | // model is usually mid-thought, and a summary of the last two minutes of work |
| 34 | 44 | // is worse than the work itself. |
| 35 | 45 | const keepRecentMessages = 6 |
| 36 | 46 | |
| 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 | + | |
| 37 | 54 | 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. |
| 38 | 55 | |
| 39 | 56 | Preserve, in this order: |
| @@ -46,23 +63,126 @@ Preserve, in this order: | ||
| 46 | 63 | Be specific. Names, paths and line numbers are worth more than prose. Omit |
| 47 | 64 | pleasantries and anything already visible in the current state of the files.` |
| 48 | 65 | |
| 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 | |
| 53 | 72 | if window <= 0 { |
| 54 | 73 | window = DefaultContextWindow |
| 55 | 74 | } |
| 56 | - at := a.opts.CompactAt | |
| 75 | + at = a.opts.CompactAt | |
| 57 | 76 | if at <= 0 { |
| 58 | 77 | at = DefaultCompactAt |
| 59 | 78 | } |
| 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) { | |
| 61 | 176 | return false, nil |
| 62 | 177 | } |
| 178 | + return a.compact(ctx, a.lastInputTokens, keepUrgentMessages) | |
| 179 | + } | |
| 63 | 180 | |
| 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) { | |
| 64 | 184 | history := a.state.History() |
| 65 | - split := compactionSplit(history) | |
| 185 | + split := compactionSplit(history, keep) | |
| 66 | 186 | if split < 0 { |
| 67 | 187 | return false, nil // nothing safe to fold away yet |
| 68 | 188 | } |
| @@ -81,20 +201,29 @@ func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, er | ||
| 81 | 201 | |
| 82 | 202 | a.state.Replace(append([]llm.Message{folded}, history[split:]...)) |
| 83 | 203 | 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 | + | |
| 84 | 212 | a.emit(Event{Kind: EventCompact, Text: summary}) |
| 85 | 213 | return true, nil |
| 86 | 214 | } |
| 87 | 215 | |
| 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 { | |
| 94 | 223 | return -1 |
| 95 | 224 | } |
| 96 | 225 | want := len(history) / 2 |
| 97 | - if max := len(history) - keepRecentMessages; want > max { | |
| 226 | + if max := len(history) - keep; want > max { | |
| 98 | 227 | want = max |
| 99 | 228 | } |
| 100 | 229 | for i := want; i < len(history); i++ { |
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | 6 | "encoding/json" |
| 7 | + "errors" | |
| 7 | 8 | "os" |
| 8 | 9 | "path/filepath" |
| 9 | 10 | "strings" |
| @@ -70,36 +71,46 @@ func TestBreakpointsDoNotLeakIntoHistory(t *testing.T) { | ||
| 70 | 71 | } |
| 71 | 72 | } |
| 72 | 73 | |
| 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 | |
| 82 | 82 | } |
| 83 | - return out | |
| 83 | + out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}}) | |
| 84 | 84 | } |
| 85 | + return out | |
| 86 | + } | |
| 87 | + | |
| 88 | + func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) { | |
| 85 | 89 | tests := []struct { |
| 86 | 90 | name string |
| 87 | 91 | roles string |
| 92 | + keep int | |
| 88 | 93 | want int |
| 89 | 94 | }{ |
| 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}, | |
| 92 | 97 | // The tail can end up one message shorter than the target when the |
| 93 | 98 | // 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}, | |
| 95 | 106 | } |
| 96 | 107 | for _, tc := range tests { |
| 97 | 108 | t.Run(tc.name, func(t *testing.T) { |
| 98 | - got := compactionSplit(mk(tc.roles)) | |
| 109 | + got := compactionSplit(mkHistory(tc.roles), tc.keep) | |
| 99 | 110 | if got != tc.want { |
| 100 | 111 | t.Fatalf("compactionSplit = %d, want %d", got, tc.want) |
| 101 | 112 | } |
| 102 | - if got >= 0 && mk(tc.roles)[got].Role != llm.RoleAssistant { | |
| 113 | + if got >= 0 && mkHistory(tc.roles)[got].Role != llm.RoleAssistant { | |
| 103 | 114 | t.Error("the kept tail must start with an assistant message, or its tool_results reference nothing") |
| 104 | 115 | } |
| 105 | 116 | }) |
| @@ -293,3 +304,317 @@ func TestReadPathParsing(t *testing.T) { | ||
| 293 | 304 | t.Errorf("readPath = %q, want empty on malformed input", got) |
| 294 | 305 | } |
| 295 | 306 | } |
| 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 | + } | |
| @@ -67,10 +67,17 @@ type Options struct { | ||
| 67 | 67 | System string |
| 68 | 68 | // Effort maps to output_config.effort. Empty means the API default (high). |
| 69 | 69 | 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. | |
| 72 | 74 | ContextWindow int |
| 73 | 75 | 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 | |
| 74 | 81 | // NoCache disables prompt cache breakpoints, for measuring what they buy. |
| 75 | 82 | NoCache bool |
| 76 | 83 | // Stream uses the SSE endpoint so output appears as it is produced. |
| @@ -95,6 +102,10 @@ const ( | ||
| 95 | 102 | EventToolResult EventKind = "tool_result" |
| 96 | 103 | EventUsage EventKind = "usage" |
| 97 | 104 | 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" | |
| 98 | 109 | // EventTextDelta is emitted instead of EventText while streaming. |
| 99 | 110 | EventTextDelta EventKind = "text_delta" |
| 100 | 111 | ) |
| @@ -182,6 +193,12 @@ func (a *Agent) emit(ev Event) { | ||
| 182 | 193 | // Run sends one user message and drives the tool loop until the model stops |
| 183 | 194 | // asking for tools. It returns the final assistant text. |
| 184 | 195 | 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 | + } | |
| 185 | 202 | a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}}) |
| 186 | 203 | |
| 187 | 204 | for turn := 1; turn <= a.opts.MaxTurns; turn++ { |
| @@ -32,6 +32,14 @@ const ( | ||
| 32 | 32 | ApprovalsAcceptAll = "accept-all" |
| 33 | 33 | ) |
| 34 | 34 | |
| 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 | + | |
| 35 | 43 | // Themes are the TUI palettes, in the order the wizard offers them. The names |
| 36 | 44 | // live here rather than in the tui package so that reading a config never |
| 37 | 45 | // depends on a terminal front end being linked in — the eval runner has none. |
| @@ -51,8 +59,16 @@ type Config struct { | ||
| 51 | 59 | Approvals string `json:"approvals,omitempty"` |
| 52 | 60 | // Theme is one of Themes. |
| 53 | 61 | 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"` | |
| 54 | 66 | } |
| 55 | 67 | |
| 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 | + | |
| 56 | 72 | // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or |
| 57 | 73 | // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset. |
| 58 | 74 | func Path() (string, error) { |
| @@ -107,6 +123,9 @@ func sanitize(c Config) Config { | ||
| 107 | 123 | if !ValidTheme(c.Theme) { |
| 108 | 124 | c.Theme = "" |
| 109 | 125 | } |
| 126 | + if c.AutoCompact != AutoCompactOn && c.AutoCompact != AutoCompactOff { | |
| 127 | + c.AutoCompact = "" | |
| 128 | + } | |
| 110 | 129 | return c |
| 111 | 130 | } |
| 112 | 131 | |
| @@ -152,3 +152,44 @@ func TestFlagsOverrideTheConfig(t *testing.T) { | ||
| 152 | 152 | t.Errorf("two empties produced %q", got) |
| 153 | 153 | } |
| 154 | 154 | } |
| 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 | + } | |
| @@ -37,6 +37,7 @@ type ( | ||
| 37 | 37 | } |
| 38 | 38 | evUsage struct{ usage llm.Usage } |
| 39 | 39 | evCompact struct{} |
| 40 | + evNotice struct{ text string } | |
| 40 | 41 | |
| 41 | 42 | // approvalMsg asks the user to decide. reply is buffered, so answering |
| 42 | 43 | // never blocks the UI even if the agent's context was cancelled first. |
| @@ -74,6 +75,8 @@ func (b *bridge) Observe(ev agent.Event) { | ||
| 74 | 75 | b.p.Send(evUsage{usage: ev.Usage}) |
| 75 | 76 | case agent.EventCompact: |
| 76 | 77 | b.p.Send(evCompact{}) |
| 78 | + case agent.EventNotice: | |
| 79 | + b.p.Send(evNotice{text: ev.Text}) | |
| 77 | 80 | } |
| 78 | 81 | } |
| 79 | 82 | |
| @@ -26,6 +26,7 @@ var builtins = []command{ | ||
| 26 | 26 | {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"}, |
| 27 | 27 | {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"}, |
| 28 | 28 | {name: "setup", desc: "the full-screen wizard for the saved defaults"}, |
| 29 | + {name: "compact", desc: "fold the conversation into a summary now"}, | |
| 29 | 30 | {name: "clear", desc: "empty the transcript"}, |
| 30 | 31 | {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"}, |
| 31 | 32 | {name: "allowed", desc: "what is permanently allowed this session"}, |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -133,6 +133,10 @@ type model struct { | ||
| 133 | 133 | modelIdx int |
| 134 | 134 | modelsLoading bool |
| 135 | 135 | |
| 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 | + | |
| 136 | 140 | // pending approvals. The agent blocks on one at a time, but a queue costs |
| 137 | 141 | // nothing and means a second request can never be dropped. |
| 138 | 142 | pending []approvalMsg |
| @@ -386,7 +390,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 386 | 390 | return m, m.onModels(msg) |
| 387 | 391 | |
| 388 | 392 | 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 { | |
| 390 | 394 | var cmd tea.Cmd |
| 391 | 395 | m.spin, cmd = m.spin.Update(msg) |
| 392 | 396 | return m, cmd |
| @@ -425,6 +429,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 425 | 429 | m.tr.addNotice("compacted the conversation") |
| 426 | 430 | return m, m.sync() |
| 427 | 431 | |
| 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 | + | |
| 428 | 439 | case approvalMsg: |
| 429 | 440 | m.pending = append(m.pending, msg) |
| 430 | 441 | m.phase = phaseApproving |
| @@ -503,10 +514,15 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 503 | 514 | } |
| 504 | 515 | m.pending = nil |
| 505 | 516 | |
| 517 | + var full *agent.ContextFullError | |
| 506 | 518 | switch { |
| 507 | 519 | case msg.err == nil: |
| 508 | 520 | case errors.Is(msg.err, context.Canceled): |
| 509 | 521 | 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") | |
| 510 | 526 | default: |
| 511 | 527 | m.tr.addError(msg.err.Error()) |
| 512 | 528 | } |
| @@ -543,7 +559,7 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 543 | 559 | case "ctrl+c": |
| 544 | 560 | // While a turn runs, ctrl-c interrupts it and leaves the session |
| 545 | 561 | // intact. Idle, it exits. |
| 546 | - if m.phase == phaseRunning { | |
| 562 | + if m.phase == phaseRunning || m.compacting { | |
| 547 | 563 | return m, m.interrupt() |
| 548 | 564 | } |
| 549 | 565 | m.quitting = true |
| @@ -551,7 +567,7 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 551 | 567 | |
| 552 | 568 | case "esc": |
| 553 | 569 | m.clearSelection() |
| 554 | - if m.phase == phaseRunning { | |
| 570 | + if m.phase == phaseRunning || m.compacting { | |
| 555 | 571 | return m, m.interrupt() |
| 556 | 572 | } |
| 557 | 573 | if m.running != nil { |
| @@ -587,6 +603,11 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 587 | 603 | if m.phase == phaseRunning { |
| 588 | 604 | return m, nil // the agent is busy; queueing input would be a lie |
| 589 | 605 | } |
| 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 | + } | |
| 590 | 611 | if m.running != nil { |
| 591 | 612 | // A local command is still running and its output is the next |
| 592 | 613 | // 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) { | ||
| 671 | 692 | case line == "/effort" || strings.HasPrefix(line, "/effort "): |
| 672 | 693 | return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort"))) |
| 673 | 694 | |
| 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 | + | |
| 674 | 705 | case line == "/clear": |
| 675 | 706 | m.clearSelection() |
| 676 | 707 | m.tr.clear() |
| @@ -912,6 +943,10 @@ func (m *model) activity() string { | ||
| 912 | 943 | return spread(m.localActivity(), "", m.width) |
| 913 | 944 | } |
| 914 | 945 | |
| 946 | + if m.compacting { | |
| 947 | + return spread(m.compactActivity(), "", m.width) | |
| 948 | + } | |
| 949 | + | |
| 915 | 950 | var left string |
| 916 | 951 | switch m.phase { |
| 917 | 952 | case phaseRunning: |
| @@ -12,6 +12,13 @@ | ||
| 12 | 12 | // tool_result comes back it streams a closing sentence and stops. That covers |
| 13 | 13 | // streaming, tool rendering, approve, deny, and interrupt. |
| 14 | 14 | // |
| 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 | + // | |
| 15 | 22 | // Flags: -addr to move it, -fail to make the command exit non-zero, -slow to |
| 16 | 23 | // stretch the stream out for watching the spinner and ctrl-c. |
| 17 | 24 | package main |
| @@ -42,6 +49,23 @@ func main() { | ||
| 42 | 49 | |
| 43 | 50 | func handle(w http.ResponseWriter, r *http.Request) { |
| 44 | 51 | 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 | + | |
| 45 | 69 | // A tool_result in the history means the harness ran (or refused) our |
| 46 | 70 | // command and fed the outcome back, so the next turn is the last one. |
| 47 | 71 | final := strings.Contains(string(body), "tool_result") |
| @@ -49,11 +73,15 @@ func handle(w http.ResponseWriter, r *http.Request) { | ||
| 49 | 73 | w.Header().Set("Content-Type", "text/event-stream") |
| 50 | 74 | w.WriteHeader(http.StatusOK) |
| 51 | 75 | |
| 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)) | |
| 52 | 80 | send(w, "message_start", map[string]any{ |
| 53 | 81 | "type": "message_start", |
| 54 | 82 | "message": map[string]any{ |
| 55 | 83 | "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}, | |
| 57 | 85 | }, |
| 58 | 86 | }) |
| 59 | 87 | |
| @@ -97,6 +125,15 @@ func handle(w http.ResponseWriter, r *http.Request) { | ||
| 97 | 125 | send(w, "message_stop", map[string]any{"type": "message_stop"}) |
| 98 | 126 | } |
| 99 | 127 | |
| 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 | + | |
| 100 | 137 | func send(w http.ResponseWriter, event string, data any) { |
| 101 | 138 | b, _ := json.Marshal(data) |
| 102 | 139 | fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b) |
| @@ -437,3 +437,33 @@ func TestEffortPillNamesTheCommandThatChangesIt(t *testing.T) { | ||
| 437 | 437 | t.Errorf("the effort pill points at /setup, which changes the saved default: %q", got) |
| 438 | 438 | } |
| 439 | 439 | } |
| 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 | + } | |
| @@ -67,6 +67,9 @@ func (r *Renderer) Handle(ev agent.Event) { | ||
| 67 | 67 | case agent.EventCompact: |
| 68 | 68 | r.endLine() |
| 69 | 69 | 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) | |
| 70 | 73 | } |
| 71 | 74 | } |
| 72 | 75 | |