feat(coding-agent): implemented ordered compaction fallback and settings

- Replaced legacy `compaction.strategy` and `remoteEnabled` settings with `compaction.methodOrder` across session maintenance, schema, and tests.
- Added automatic fallback mechanism to try subsequent compaction methods upon failure or unsupported model capabilities.
- Added mouse drag-and-drop reordering support and click handlers to multi-select settings submenus.
- Updated documentation and test suites to reflect ordered compaction strategy preferences and fallback chains.
This commit is contained in:
can1357
2026-08-20 02:59:08 +02:00
parent 6d2bae2c41
commit 8c35861d96
38 changed files with 837 additions and 425 deletions
+13 -14
View File
@@ -109,45 +109,45 @@ The automatic paths are intentionally different:
- Trigger: current-model assistant error is detected as context overflow and the error is not older than the latest compaction.
- The failing assistant error message is removed from active agent state before retry.
- Context promotion is tried first; if a configured larger model is available, the agent switches model and retries without compacting.
- If promotion is unavailable and compaction is enabled, context-full compaction runs with `reason: "overflow"` and `willRetry: true`; handoff strategy is not used for overflow because the handoff request would reuse the overflowing input.
- If promotion is unavailable and compaction is enabled, automatic maintenance walks `compaction.methodOrder` with `reason: "overflow"` and `willRetry: true`; handoff is skipped because its request would reuse the overflowing input.
- On success, `agent.continue()` is scheduled to retry the turn.
- **Incomplete-output recovery**
- Trigger: same-model assistant message ends with `stopReason === "length"` and the message is not older than the latest compaction.
- The incomplete assistant message is removed from active agent state before recovery.
- Context promotion is tried first.
- If promotion is unavailable and compaction is enabled, auto maintenance runs with `reason: "incomplete"` and `willRetry: true`.
- Unlike overflow, `compaction.strategy: "handoff"` is allowed for incomplete-output recovery because the input context is still usable.
- On context-full success, `agent.continue()` is scheduled to retry the turn.
- If promotion is unavailable and compaction is enabled, auto maintenance walks `compaction.methodOrder` with `reason: "incomplete"` and `willRetry: true`.
- Unlike overflow, a reachable `handoff` preference may run because the input context is still usable.
- On soft-compaction success, `agent.continue()` is scheduled to retry the turn.
- **Threshold maintenance**
- Trigger: successful, non-error assistant message whose adjusted context tokens exceed `resolveThresholdTokens(...)`.
- Mid-turn maintenance also checks safe tool-loop boundaries before the next provider request when `compaction.midTurnEnabled !== false`.
- Tool-output pruning can reduce the measured token count before threshold comparison.
- Context promotion is tried before post-turn compaction.
- If promotion is unavailable, auto maintenance runs with `reason: "threshold"` and `willRetry: false`.
- With `compaction.strategy: "handoff"`, post-turn threshold maintenance normally schedules a post-prompt auto-handoff task instead of writing a compaction entry; pre-prompt and mid-turn checks run inline to avoid racing the next turn. Mid-turn checks suppress handoff session resets and fall back to context-full compaction.
- If promotion is unavailable, auto maintenance walks `compaction.methodOrder` with `reason: "threshold"` and `willRetry: false`.
- When `handoff` is the next runnable method, post-turn threshold maintenance normally schedules a post-prompt auto-handoff task instead of writing a compaction entry; pre-prompt and mid-turn checks run inline to avoid racing the next turn. Mid-turn checks skip handoff and advance to the next preference.
- On success, if `compaction.autoContinue !== false`, post-turn maintenance schedules an agent-authored developer auto-continue prompt from `prompts/system/auto-continue.md`; mid-turn maintenance never schedules a separate continuation because the core loop already owns the next provider request.
- **Idle maintenance**
- Trigger: `runIdleCompaction()` when not streaming or already compacting.
- Uses `reason: "idle"` and does not auto-continue afterward.
### Shake strategy
### Shake method
`compaction.strategy: "shake"` performs an inline, local reduction instead of calling a summarization model. It replaces eligible tool results and large fenced/XML blocks with recoverable `artifact://` references, using a protected recent-token window and minimum-savings threshold. Automatic shake emits the normal auto-compaction events with `action: "shake"`.
Including `shake` in `compaction.methodOrder` performs an inline, local reduction instead of calling a summarization model. It replaces eligible tool results and large fenced/XML blocks with recoverable `artifact://` references, using a protected recent-token window and minimum-savings threshold. Automatic shake emits the normal auto-compaction events with `action: "shake"`.
Threshold, incomplete-output, and overflow recovery fall through to context-full summarization when shake cannot reclaim enough context to get below the recovery band; this prevents repeated no-op shake loops. Idle shake does not use that fallback because the idle timer rechecks usage before running again. Manual `/shake` is a separate, more aggressive command that can target all eligible history.
Threshold, incomplete-output, and overflow recovery advance to the next configured method when shake cannot reclaim enough context to get below the recovery band; this prevents repeated no-op shake loops. Idle shake does not use that fallback because the idle timer rechecks usage before running again. Manual `/shake` is a separate, more aggressive command that can target all eligible history.
### Snapcompact strategy
### Snapcompact method
`compaction.strategy: "snapcompact"` replaces the LLM summarization call with a local, deterministic archival pass (`compact` from `@oh-my-pi/snapcompact`):
Including `snapcompact` in `compaction.methodOrder` replaces the LLM summarization call with a local, deterministic archival pass (`compact` from `@oh-my-pi/snapcompact`):
- The discarded history is serialized, whitespace-collapsed, and printed onto model-aware PNG frames (frame width fixed per shape; frame height hugs the rows actually printed) using bundled public-domain pixel fonts. The shape — and frame size — resolve from the **model id** when the model line was measured: Claude reads X.org `8x13` glyphs on an 11px advance (extra letter-spacing, black ink — `11on16-bw`; high-res lines — Opus 4.7+, Fable, Mythos — get 1932px frames under Anthropic's 4,784 visual-token cap, older lines stay at 1568px), Gemini reads `8x13` glyphs on a 22px pitch (extra leading, black ink — `8on22-bw` at 2048px, since Gemini 3.x bills a fixed 1,120-token budget per image at any pixel size), GPT/Codex read the same `8on22-bw` shape at 1568px (patch billing is area-proportional, so larger frames cannot improve chars per token), and Kimi/GLM read `8x13` glyphs on a 16px pitch (`8on16-bw` at 1568px — kimi's processor downscales past 1792px). A Claude routed through Vertex or OpenRouter keeps its Claude shape. Unmeasured models fall back to their wire API family (Anthropic-family/unknown → `11on16-bw`, Google → `8on22-bw`, OpenAI-compatible → `8on22-bw`); billing (per-family patch/budget formulas, OpenAI's `detail: "original"` hint) always follows the API carrying the request, computed for the resolved frame size. The `snapcompact.shape` setting (default `auto`) forces one of the research-eval variants instead: square grids (`8x8r`/`8x8u`/`6x6u`/`5x8` × sentence-hue/black ink) or the per-model eval winners (`6x12-dim`, `8x13-bw`, `8on16-bw`, `8on22-bw`, `11on16-bw`, and the two-column word-wrapped `doc-8on16-bw`/`-sent`/`-sent-dim`, where `dim` prints stopwords in gray). A forced variant keeps its geometry but is re-priced for the target provider's image billing. The same setting governs inline system-prompt/tool-result imaging (`snapcompact.systemPrompt`, `snapcompact.toolResults`).
- Serialization keeps the archive conversation-dense: tool results are truncated head+tail (default 2,000 chars at a 0.6 head ratio), tool-call argument values are capped per value (500) and per call (2,000), and tool output is printed in dim gray ink so conversation reads louder than tool noise. All budgets and the dimming are configurable via `SerializeOptions` (`toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, `dimToolResults`).
- The snapcompact archive persists under `CompactionEntry.preserveData.snapcompact` as bounded source text plus rendered frames. On each context rebuild it is reconstructed into ordered compaction blocks: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. The entry's `summary` is just the short resume lead-in plus the usual file-operation list.
- Later compactions re-render from that bounded source text (`Archive.text`), not by carrying old PNGs forward blindly. `maxFrames` now defaults to `MAX_FRAMES_DEFAULT` (80) and acts only as an upper limit; when the imaged middle is large it foveates internally (HQ/LQ/HQ), while both chronological edges stay verbatim text.
- No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (`model.input` includes `"image"`); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual `/compact` honors the strategy unless custom instructions are given (those imply a directed LLM summary).
- No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (`model.input` includes `"image"`); otherwise automatic maintenance skips it and advances to the next configured method. Manual `/compact` honors the method order unless custom instructions are given (those imply a directed LLM summary).
- Rationale: the shape table comes from the snapcompact 200k-token evals in `packages/snapcompact`, where bitmap frames preserved QA recall at lower billed-token cost than raw text for vision-capable models.
### Display transcript
@@ -418,13 +418,12 @@ Post-navigation event exposing new/old leaf and optional summary entry.
From `settings-schema.ts`:
- `compaction.enabled` = `true`
- `compaction.strategy` = `"snapcompact"` (`"context-full"`, `"handoff"`, `"shake"`, and `"off"` are also supported)
- `compaction.methodOrder` = `["remote", "snapcompact", "handoff", "shake", "soft"]`. `remote` uses provider-native OpenAI-compatible server compaction when available; unavailable or failed methods advance to the next preference.
- `compaction.reserveTokens` is unset by default. The compaction layer normally applies a `16384`-token floor and at least 15% of the context window; on small windows where that default would be impractical, budget checks use the 15% proportional reserve. An explicit configured reserve is honored.
- `compaction.keepRecentTokens` = `20000`
- `compaction.autoContinue` = `true`
- `compaction.midTurnEnabled` = `true`
- `compaction.handoffSaveToDisk` = `false`
- `compaction.remoteEnabled` = `true`
- `compaction.remoteEndpoint` = `undefined`
- `compaction.remoteStreamingV2Enabled` = `true`
- `compaction.v2RetainedMessageBudget` = `64000`
+2 -2
View File
@@ -166,9 +166,9 @@ Auto-triggered handoffs can additionally write a timestamped `handoff-*.md` arti
### Automatic handoff
Manual `/handoff` works regardless of the context-maintenance strategy. To use this pipeline for automatic maintenance, set `compaction.strategy: handoff` (the strategy default is `snapcompact`). Normal threshold-triggered handoffs defer to a post-prompt task; an `incomplete` output recovery may hand off inline. Input `overflow` always falls back to in-place context-full maintenance because the handoff request would carry the same oversized input.
Manual `/handoff` works regardless of the context-maintenance method order. To use this pipeline automatically, include `handoff` in `compaction.methodOrder` (the default order is `remote`, `snapcompact`, `handoff`, `shake`, `soft`). Normal threshold-triggered handoffs defer to a post-prompt task; an `incomplete` output recovery may hand off inline. Input `overflow` skips handoff because the request would carry the same oversized input.
If auto generation returns no document, maintenance falls back to context-full compaction. An abort or a `session_before_switch` hook cancellation does not trigger that fallback. `compaction.handoffSaveToDisk` defaults to `false`; when enabled, only auto-triggered handoffs write the extra markdown artifact.
If auto generation returns no document, maintenance advances to the next configured method. An abort or a `session_before_switch` hook cancellation does not trigger that fallback. `compaction.handoffSaveToDisk` defaults to `false`; when enabled, only auto-triggered handoffs write the extra markdown artifact.
## Controller/UI behavior
+3 -6
View File
@@ -611,27 +611,24 @@ contextPromotion:
compaction:
enabled: true
strategy: snapcompact # context-full, handoff, shake, snapcompact, off
methodOrder: [remote, snapcompact, handoff, shake, soft]
midTurnEnabled: true # check thresholds between tool-loop provider requests
thresholdPercent: -1 # -1 = default reserve-based behavior
thresholdTokens: -1 # fixed token limit when > 0
remoteEnabled: true
memory:
backend: off # off, local, hindsight, mnemopi
```
| Key | Type | Default | Notes |
| ----------------------------- | ------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------------- | ------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contextPromotion.enabled` | boolean | `false` | Promote to the active model's explicit `contextPromotionTarget` on context overflow. |
| `compaction.enabled` | boolean | `true` | Automatic conversation compaction. |
| `compaction.midTurnEnabled` | boolean | `true` | Check thresholds at safe mid-turn tool-loop boundaries before the next provider request. |
| `compaction.strategy` | enum | `snapcompact` | `context-full`, `handoff`, `shake`, `snapcompact`, `off`. |
| `compaction.methodOrder` | array | `remote, snapcompact, handoff, shake, soft` | Ordered fallbacks. `remote` uses provider-native OpenAI-compatible server compaction; unavailable or failed methods advance. |
| `compaction.thresholdPercent` | number | `-1` | Percent-of-context trigger; `-1` = reserve-based default. |
| `compaction.thresholdTokens` | number | `-1` | Fixed token trigger when `> 0`. |
| `compaction.reserveTokens` | number | _(unset)_ | Absolute reserve floor. When unset, the effective reserve is the larger of `16384` and 15% of the context window; if that default would leave no practical small-window budget, it falls back to the 15% reserve. |
| `compaction.keepRecentTokens` | number | `20000` | Recent tokens always preserved. |
| `compaction.remoteEnabled` | boolean | `true` | Allow remote compaction service. |
| `compaction.autoContinue` | boolean | `true` | Continue automatically after compaction. |
| `memory.backend` | enum | `off` | `off`, `local`, `hindsight`, `mnemopi`. Each backend has its own `hindsight.*` / `mnemopi.*` / `memories.*` tuning keys. |
| `autolearn.enabled` | boolean | `false` | Experimental: after the agent stops, nudge it to capture lessons to memory and create/enhance isolated managed skills under `~/.omp/agent/managed-skills`. Enables the `manage_skill` tool (and `learn` when a memory backend is active). |
+1
View File
@@ -9,6 +9,7 @@
- Added `extendedContext` setting (`/settings` → Context → General, default on). When off, models with a premium long-context price tier (OpenAI GPT-5.6 Sol/Terra/Luna bill 2x input / 1.5x output above 272K input tokens, on both the API and subscription Codex) are capped at the standard-pricing threshold — they appear as 272K again and compaction fires before a request crosses into premium billing. Toggling mid-session re-clamps or restores the active model's window immediately. Anthropic Claude 4.6+ serves its full 1M window at standard pricing, so no Anthropic model is affected.
### Changed
- Replaced the single `compaction.strategy` / `compaction.remoteEnabled` policy with ordered `compaction.methodOrder` preferences. The default now tries OpenAI-compatible server compaction, snapcompact, handoff, shake, then soft compaction; unavailable or failed methods advance through that list.
- `/settings` rows can now carry a risk note: a warning glyph on the row plus a warning-colored line above the description. `External Thinking` (`externalThinking`, `--external-thinking`) is the first user — providers have flagged the request shape it produces as abuse, up to account-level enforcement, so both the settings entry and `--help` now say so.
- The todo HUD header now draws a summed progress bar — `Todos ━━──────── 44/355` — counting closed/total tasks across every stage, instead of the stage-position counter (`Todos · 4/14`). Bar glyphs follow the symbol preset (`progress.filled`/`progress.empty`: heavy/light lines for unicode and nerd, `=`/`-` for ascii). Stages hidden past the collapsed cap are now announced with a trailing `… n more stages` row, since the header no longer implies them.
@@ -3,6 +3,11 @@ import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact";
import { DEFAULT_RELAY_URL } from "../collab/protocol";
import { DEFAULT_LIVE_VOICE, LIVE_VOICE_OPTIONS, LIVE_VOICE_VALUES } from "../live/voices";
import {
COMPACTION_METHOD_CHOICES,
type CompactionMethod,
DEFAULT_COMPACTION_METHOD_ORDER,
} from "../session/compaction-methods";
import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS, STT_MODEL_VALUES } from "../stt/models";
import { STT_SUBMIT_TRIGGER_OPTIONS, STT_SUBMIT_TRIGGER_VALUES } from "../stt/submit-trigger";
import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking";
@@ -2187,39 +2192,17 @@ export const SETTINGS_SCHEMA = {
},
},
"compaction.strategy": {
type: "enum",
values: ["context-full", "handoff", "shake", "snapcompact", "off"] as const,
default: "snapcompact",
"compaction.methodOrder": {
type: "array",
default: [...DEFAULT_COMPACTION_METHOD_ORDER],
ui: {
tab: "context",
group: "Compaction",
label: "Compaction Strategy",
label: "Compaction Method Order",
description:
"Choose in-place context-full maintenance, auto-handoff, surgical shake (drop heavy content), snapcompact (archive history as dense images), or disable auto maintenance (off)",
options: [
{
value: "context-full",
label: "Context-full",
description: "Summarize in-place and keep the current session",
},
{ value: "handoff", label: "Handoff", description: "Generate handoff and continue in a new session" },
{
value: "shake",
label: "Shake",
description: "Drop heavy content (tool results + large blocks) in place; recover via artifact",
},
{
value: "snapcompact",
label: "Snapcompact",
description: "Archive history onto dense bitmap images the model reads back; no LLM call",
},
{
value: "off",
label: "Off",
description: "Disable automatic context maintenance (same behavior as Auto-compact off)",
},
],
"Preferred fallback order for automatic context maintenance; unavailable or failed methods advance to the next choice",
options: COMPACTION_METHOD_CHOICES,
ordered: true,
},
},
@@ -2280,17 +2263,6 @@ export const SETTINGS_SCHEMA = {
},
},
"compaction.remoteEnabled": {
type: "boolean",
default: true,
ui: {
tab: "context",
group: "Compaction",
label: "Remote Compaction",
description: "Use remote compaction endpoints when available instead of local summarization",
},
},
"compaction.remoteStreamingV2Enabled": {
type: "boolean",
default: true,
@@ -5737,7 +5709,7 @@ export type Personality = SettingValue<"personality">;
export interface CompactionSettings {
enabled: boolean;
strategy: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
methodOrder: CompactionMethod[];
thresholdPercent: number;
thresholdTokens: number;
reserveTokens: number | undefined;
@@ -5745,7 +5717,6 @@ export interface CompactionSettings {
midTurnEnabled: boolean;
handoffSaveToDisk: boolean;
autoContinue: boolean;
remoteEnabled: boolean;
remoteEndpoint: string | undefined;
remoteStreamingV2Enabled: boolean;
v2RetainedMessageBudget: number;
+49 -7
View File
@@ -38,6 +38,7 @@ import type { ModelRole } from "../config/model-roles";
import { loadCapability } from "../discovery";
import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
import { AgentStorage } from "../session/agent-storage";
import { type CompactionMethod, DEFAULT_COMPACTION_METHOD_ORDER } from "../session/compaction-methods";
import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-providers";
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
import { INSPECT_IMAGE_MODES } from "../utils/inspect-image-mode";
@@ -1622,15 +1623,56 @@ export class Settings {
raw["edit.mode"] = "hashline";
}
// compaction.strategy: removed local-model shake-summary mode; plain shake
// keeps the same mechanical artifact-backed reduction without background CPU.
const compactionObj = raw.compaction as Record<string, unknown> | undefined;
if (compactionObj?.strategy === "shake-summary") {
compactionObj.strategy = "shake";
// compaction.strategy / compaction.remoteEnabled → compaction.methodOrder.
// The old single strategy could not express a capability-dependent fallback
// chain. Preserve explicit legacy intent while new installs use the
// server → snapcompact → handoff → shake → soft default.
const compactionObj = isRecord(raw.compaction) ? raw.compaction : undefined;
const configuredMethodOrder = compactionObj?.methodOrder ?? raw["compaction.methodOrder"];
const legacyStrategy = compactionObj?.strategy ?? raw["compaction.strategy"];
const legacyRemoteEnabled = compactionObj?.remoteEnabled ?? raw["compaction.remoteEnabled"];
if (!Array.isArray(configuredMethodOrder)) {
const remoteEnabled = legacyRemoteEnabled !== false;
const strategy = legacyStrategy === "shake-summary" ? "shake" : legacyStrategy;
let methodOrder: CompactionMethod[] | undefined;
switch (strategy) {
case "context-full":
methodOrder = remoteEnabled ? ["remote", "soft"] : ["soft"];
break;
case "handoff":
methodOrder = remoteEnabled ? ["handoff", "remote", "soft"] : ["handoff", "soft"];
break;
case "shake":
methodOrder = remoteEnabled ? ["shake", "remote", "soft"] : ["shake", "soft"];
break;
case "snapcompact":
methodOrder = remoteEnabled ? ["snapcompact", "remote", "soft"] : ["snapcompact", "soft"];
break;
case "off":
methodOrder = [];
break;
default:
if (legacyRemoteEnabled === false) {
methodOrder = DEFAULT_COMPACTION_METHOD_ORDER.filter(method => method !== "remote");
}
if (raw["compaction.strategy"] === "shake-summary") {
raw["compaction.strategy"] = "shake";
}
if (methodOrder) {
const root = compactionObj ?? {};
root.methodOrder = methodOrder;
raw.compaction = root;
}
} else if (!compactionObj || compactionObj.methodOrder === undefined) {
const root = compactionObj ?? {};
root.methodOrder = configuredMethodOrder;
raw.compaction = root;
}
if (compactionObj) {
delete compactionObj.strategy;
delete compactionObj.remoteEnabled;
}
delete raw["compaction.strategy"];
delete raw["compaction.remoteEnabled"];
delete raw["compaction.methodOrder"];
// snapcompact.systemPrompt: boolean -> scoped enum.
const snapcompactObj = raw.snapcompact as Record<string, unknown> | undefined;
@@ -116,11 +116,11 @@ export type CustomToolSessionEvent =
| {
reason: "auto_compaction_start";
trigger: "threshold" | "overflow" | "idle" | "incomplete";
action: "context-full" | "handoff" | "shake" | "snapcompact";
action: "context-full" | "remote" | "handoff" | "shake" | "snapcompact";
}
| {
reason: "auto_compaction_end";
action: "context-full" | "handoff" | "shake" | "snapcompact";
action: "context-full" | "remote" | "handoff" | "shake" | "snapcompact";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
@@ -384,9 +384,9 @@ export interface CompactOptions {
onComplete?: (result: CompactionResult) => void;
onError?: (error: Error) => void;
/**
* Force a one-off compaction mode for this invocation, overriding the
* configured `compaction.strategy` / `remoteEnabled` (the `/compact`
* subcommands: `soft` | `remote` | `snapcompact`). Omitted = configured behavior.
* Force a one-off compaction mode for this invocation, replacing the
* configured `compaction.methodOrder` (`/compact soft`, `remote`, or
* `snapcompact`). Omitted = configured preference order.
*/
mode?: CompactMode;
/**
@@ -224,13 +224,13 @@ export interface TurnEndEvent {
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle" | "incomplete";
action: "context-full" | "handoff" | "shake" | "snapcompact";
action: "context-full" | "remote" | "handoff" | "shake" | "snapcompact";
}
/** Fired when auto-compaction ends */
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
action: "context-full" | "handoff" | "shake" | "snapcompact";
action: "context-full" | "remote" | "handoff" | "shake" | "snapcompact";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
@@ -231,7 +231,8 @@ class MultiSelectSubmenu extends Container {
#value: string[];
#cursor = 0;
#selectListLineOffset = 0;
#pressedItemId: string | undefined;
#dropItemId: string | undefined;
constructor(
private readonly title: string,
private readonly description: string,
@@ -277,8 +278,8 @@ class MultiSelectSubmenu extends Container {
this.addChild(new Spacer(1));
const hint = this.ordered
? " Enter/Space to toggle · ←/→ move · 1-9 place at position · Esc to go back"
: " Enter/Space to toggle · Esc to go back";
? " Click to toggle · drag selected items to reorder · ←/→ move · 1-9 place · Esc to go back"
: " Click/Enter/Space to toggle · Esc to go back";
this.addChild(new Text(theme.fg("dim", hint), 0, 0));
}
@@ -304,6 +305,16 @@ class MultiSelectSubmenu extends Container {
this.#apply(next);
}
/** Move a selected item before another selected item, retaining every other preference. */
#moveBefore(id: string, beforeId: string): void {
if (id === beforeId) return;
const next = this.#value.filter(value => value !== id);
const target = next.indexOf(beforeId);
if (target === -1) return;
next.splice(target, 0, id);
this.#apply(next);
}
/** Splice the option into the 1-based `position` of the selection (adding it if unselected). */
#placeAt(id: string, position: number): void {
const next = this.#value.filter(v => v !== id);
@@ -325,7 +336,46 @@ class MultiSelectSubmenu extends Container {
}
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
const itemIndex = this.#selectList.hitTest(line - this.#selectListLineOffset);
if (event.wheel !== null) {
routeSelectListMouse(this.#selectList, event, line - this.#selectListLineOffset);
return;
}
if (event.motion) {
this.#selectList.setHoverIndex(itemIndex ?? null);
const target = itemIndex === undefined ? undefined : this.options[itemIndex]?.value;
if (
this.ordered &&
this.#pressedItemId !== undefined &&
target !== undefined &&
target !== this.#pressedItemId &&
this.#value.includes(target)
) {
this.#dropItemId = target;
}
return;
}
if (event.leftClick && itemIndex !== undefined) {
const item = this.options[itemIndex];
if (!item) return;
this.#cursor = itemIndex;
this.#selectList.setSelectedIndex(itemIndex);
this.#pressedItemId = item.value;
this.#dropItemId = item.value;
return;
}
if (!event.release) return;
const pressedItemId = this.#pressedItemId;
const dropItemId = this.#dropItemId;
this.#pressedItemId = undefined;
this.#dropItemId = undefined;
if (!pressedItemId) return;
if (this.ordered && dropItemId !== undefined && dropItemId !== pressedItemId) {
this.#moveBefore(pressedItemId, dropItemId);
return;
}
this.#toggle(pressedItemId);
}
handleInput(data: string): void {
@@ -1850,7 +1850,9 @@ export class EventController {
? "Idle "
: "";
const actionLabel =
event.action === "handoff"
event.action === "remote"
? "Auto server compaction"
: event.action === "handoff"
? "Auto-handoff"
: event.action === "shake"
? "Auto-shake"
@@ -1878,12 +1880,15 @@ export class EventController {
this.ctx.statusContainer.disposeChildren();
}
const isHandoffAction = event.action === "handoff";
const isRemoteAction = event.action === "remote";
const isShakeAction = event.action === "shake";
const isSnapcompactAction = event.action === "snapcompact";
if (event.aborted) {
this.ctx.showStatus(
isHandoffAction
? "Auto-handoff cancelled"
: isRemoteAction
? "Auto server compaction cancelled"
: isShakeAction
? "Auto-shake cancelled"
: isSnapcompactAction
@@ -1941,6 +1946,8 @@ export class EventController {
// to compact yet. Not a failure — suppress the warning.
} else if (isSnapcompactAction) {
this.ctx.showWarning("Auto-snapcompact maintenance failed; continuing without maintenance");
} else if (isRemoteAction) {
this.ctx.showWarning("Auto server compaction failed; continuing without maintenance");
} else {
this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance");
}
@@ -18,11 +18,11 @@ export type AgentSessionEvent =
| {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle" | "incomplete";
action: "context-full" | "handoff" | "shake" | "snapcompact";
action: "context-full" | "remote" | "handoff" | "shake" | "snapcompact";
}
| {
type: "auto_compaction_end";
action: "context-full" | "handoff" | "shake" | "snapcompact";
action: "context-full" | "remote" | "handoff" | "shake" | "snapcompact";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
@@ -10,18 +10,17 @@
* Adding a mode is a single entry here: the command surface (autocomplete +
* ACP hint), the parser, and the engine override all read this table.
*/
import type { CompactionMethod } from "./compaction-methods";
/** Subcommand selecting a one-off compaction mode for manual `/compact`. */
export type CompactMode = "soft" | "remote" | "snapcompact";
/**
* Per-invocation overrides merged over the configured `compaction.*` settings.
* Narrowed to the two knobs the modes actually flip; the result stays
* assignable to the full `CompactionSettings`.
* Per-invocation ordered methods merged over the configured
* `compaction.methodOrder` for this run.
*/
export interface CompactionOverride {
strategy?: "context-full" | "snapcompact";
remoteEnabled?: boolean;
methodOrder?: CompactionMethod[];
}
export interface CompactModeDef {
@@ -36,30 +35,23 @@ export interface CompactModeDef {
* images without a directed summary).
*/
readonly rejectsFocus?: boolean;
/**
* When true, the mode explicitly demands a remote path; the engine warns and
* falls back to a local summary if neither a remote endpoint nor a
* provider-native compaction path is available.
*/
readonly requiresRemote?: boolean;
}
export const COMPACT_MODES: readonly CompactModeDef[] = [
{
name: "soft",
description: "Summarize locally with the active model (skip remote endpoints)",
overrides: { strategy: "context-full", remoteEnabled: false },
description: "Summarize locally with the active model (skip server compaction)",
overrides: { methodOrder: ["soft"] },
},
{
name: "remote",
description: "Summarize via the remote endpoint / provider-native compaction",
overrides: { strategy: "context-full", remoteEnabled: true },
requiresRemote: true,
description: "Summarize via OpenAI-compatible server compaction, then fall back to a local summary",
overrides: { methodOrder: ["remote", "soft"] },
},
{
name: "snapcompact",
description: "Archive history onto dense bitmap images the model reads back (no LLM call)",
overrides: { strategy: "snapcompact" },
overrides: { methodOrder: ["snapcompact"] },
rejectsFocus: true,
},
];
@@ -0,0 +1,69 @@
/** Ordered automatic context-maintenance methods and their settings metadata. */
/** Choices presented by the ordered compaction-method setting. */
export const COMPACTION_METHOD_CHOICES = [
{
value: "remote",
label: "OpenAI server compaction",
description: "Use provider-native OpenAI-compatible server compaction when the active route supports it",
},
{
value: "snapcompact",
label: "Snapcompact",
description: "Archive history onto dense bitmap images the active vision model reads back; no LLM call",
},
{
value: "handoff",
label: "Handoff",
description: "Generate a handoff document and continue in a new session",
},
{
value: "soft",
label: "Soft compaction",
description: "Summarize in place with a compaction model without using server compaction",
},
{
value: "shake",
label: "Shake",
description: "Drop recoverable heavy content in place without an LLM call",
},
] as const;
/** One selectable automatic context-maintenance method. */
export type CompactionMethod = (typeof COMPACTION_METHOD_CHOICES)[number]["value"];
/** Default fallback order: server-native first, portable summary last. */
export const DEFAULT_COMPACTION_METHOD_ORDER: CompactionMethod[] = [
"remote",
"snapcompact",
"handoff",
"shake",
"soft",
];
const COMPACTION_METHODS: Record<CompactionMethod, true> = {
remote: true,
snapcompact: true,
handoff: true,
soft: true,
shake: true,
};
/** Whether an unknown configuration value names a supported compaction method. */
export function isCompactionMethod(value: unknown): value is CompactionMethod {
return typeof value === "string" && Object.hasOwn(COMPACTION_METHODS, value);
}
/**
* Filter malformed entries and preserve first occurrence order from a configured
* compaction-method preference list.
*/
export function resolveCompactionMethodOrder(value: unknown): CompactionMethod[] {
if (!Array.isArray(value)) return [];
const methods: CompactionMethod[] = [];
for (const method of value) {
if (isCompactionMethod(method) && !methods.includes(method)) methods.push(method);
}
return methods;
}
@@ -83,6 +83,7 @@ import {
} from "../thinking";
import type { AgentSessionEvent } from "./agent-session-events";
import type { ClientBridge } from "./client-bridge";
import { resolveCompactionMethodOrder } from "./compaction-methods";
import type { CustomMessage, CustomMessagePayload } from "./messages";
import { isAdvisorCard, isTerminalTextAssistantAnswer } from "./queued-messages";
import {
@@ -1324,8 +1325,9 @@ export class SessionAdvisors {
const incomingTokens = agent.tokenizer.countMessage(incoming);
const compactionSettings = this.#host.settings.getGroup("compaction");
if (compactionSettings.strategy === "off") return false;
if (!compactionSettings.enabled) return false;
if (!compactionSettings.enabled || resolveCompactionMethodOrder(compactionSettings.methodOrder).length === 0) {
return false;
}
const advisorModel = agent.state.model;
const contextWindow = advisorModel.contextWindow ?? 0;
@@ -16,13 +16,13 @@ import {
CompactionCancelledError,
type CompactionPreparation,
type CompactionResult,
type CompactionSettings,
calculateContextTokens,
collectShakeRegions,
compact,
compactionContextTokens,
createCompactionSummaryMessage,
DEFAULT_SHAKE_CONFIG,
type CompactionSettings as EngineCompactionSettings,
effectiveReserveTokens,
isTranscriptUsageAnchor,
NativeCompactionError,
@@ -34,7 +34,6 @@ import {
type ShakeRegion,
type SummaryOptions,
shouldCompact,
shouldUseOpenAiRemoteCompaction,
shouldUseProviderNativeCompaction,
} from "@oh-my-pi/pi-agent-core/compaction";
import {
@@ -52,8 +51,7 @@ import { logger } from "@oh-my-pi/pi-utils";
import * as snapcompact from "@oh-my-pi/snapcompact";
import type { ModelRegistry } from "../config/model-registry";
import { MODEL_ROLE_IDS } from "../config/model-roles";
import type { Settings } from "../config/settings";
import { getDefault } from "../config/settings";
import type { CompactionSettings as ConfiguredCompactionSettings, Settings } from "../config/settings";
import type { ExtensionRunner, SessionBeforeCompactResult } from "../extensibility/extensions";
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
import type { GoalModeState } from "../goals/state";
@@ -66,6 +64,11 @@ import type { ConfiguredThinkingLevel } from "../thinking";
import type { AgentSessionEvent } from "./agent-session-events";
import type { ContextUsageBreakdown, HandoffResult, SessionHandoffOptions } from "./agent-session-types";
import { findCompactMode } from "./compact-modes";
import {
type CompactionMethod,
DEFAULT_COMPACTION_METHOD_ORDER,
resolveCompactionMethodOrder,
} from "./compaction-methods";
import { convertToLlm, stripImagesFromMessage } from "./messages";
import { isTerminalTextAssistantAnswer } from "./queued-messages";
import {
@@ -105,6 +108,43 @@ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
automaticContinuationBlocked: true,
};
const STRATEGY_BY_COMPACTION_METHOD: Record<CompactionMethod, "context-full" | "handoff" | "shake" | "snapcompact"> = {
remote: "context-full",
snapcompact: "snapcompact",
handoff: "handoff",
soft: "context-full",
shake: "shake",
};
/**
* Convert the selected preference into the engine's compact operation flags.
* The engine intentionally remains usable by SDK consumers that do not expose
* the coding agent's preference list.
*/
function resolveMethodSettings(
settings: ConfiguredCompactionSettings,
method: CompactionMethod,
): EngineCompactionSettings {
return {
...settings,
strategy: STRATEGY_BY_COMPACTION_METHOD[method],
remoteEnabled: method === "remote",
};
}
/** Whether server compaction has either a configured endpoint or an active native route. */
function canUseRemoteCompaction(model: Model | null | undefined, settings: EngineCompactionSettings): boolean {
return (
(typeof settings.remoteEndpoint === "string" && settings.remoteEndpoint.length > 0) ||
(model !== null && model !== undefined && shouldUseProviderNativeCompaction(model, settings))
);
}
/** Whether a configured preference list contains at least one automatic method. */
function hasConfiguredCompactionMethod(settings: ConfiguredCompactionSettings): boolean {
return resolveCompactionMethodOrder(settings.methodOrder).length > 0;
}
/**
* User-facing notice for a compaction dead end: maintenance freed too little
* to retry safely. `remedies` names the recovery actions left on the emitting
@@ -578,8 +618,14 @@ export class SessionMaintenance {
* @param customInstructions Optional instructions for the compaction summary
* @param options Optional callbacks for completion/error handling
*/
async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
if (this.#compactionAbortController) {
async compact(
customInstructions?: string,
options?: CompactOptions,
methodOffset = 0,
retryController?: AbortController,
): Promise<CompactionResult> {
const ownsCompactionController = retryController === undefined;
if (this.#compactionAbortController && this.#compactionAbortController !== retryController) {
throw new Error("Compaction already in progress");
}
// Resolve the `/compact <mode>` subcommand up front so input validation
@@ -595,46 +641,81 @@ export class SessionMaintenance {
if (compactMode?.rejectsFocus && (customInstructions || options?.internalGuidance)) {
throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
}
const compactionAbortController = new AbortController();
let methods: CompactionMethod[] = [];
let selectedMethodIndex = -1;
let compactionCommitted = false;
let methodAttempted = false;
const compactionAbortController = retryController ?? new AbortController();
if (ownsCompactionController) {
this.#compactionAbortController = compactionAbortController;
}
try {
if (ownsCompactionController) {
this.#host.disconnectFromAgent();
await this.#host.abort({ goalReason: "internal", preserveCompaction: true });
if (!this.#model) {
}
const activeModel = this.#model;
if (!activeModel) {
throw new Error("No model selected");
}
const compactionSettings = this.#host.settings.getGroup("compaction");
// The `/compact <mode>` override (resolved above) replaces the configured
// strategy/remote flags for this one invocation. Merged before
// prepareCompaction so the remote gating (preparation.settings.
// remoteEnabled/endpoint) and the snapcompact decision below both see it.
const effectiveSettings = compactMode
? { ...compactionSettings, ...compactMode.overrides }
: compactionSettings;
// /compact remote demands provider-native compaction. When no remote
// endpoint is configured (one would override per-model gating in
// compact()), drop fallback candidates that aren't remote-capable so the
// engine never silently runs a local summary on a configured-but-non-
// remote compactionModel. If filtering empties the chain, warn and fall
// back to the full chain so the operation still completes.
methods = resolveCompactionMethodOrder(compactMode?.overrides.methodOrder ?? compactionSettings.methodOrder);
const explicitSnapcompact = compactMode?.name === "snapcompact";
let selectedMethod: CompactionMethod | undefined;
for (let index = methodOffset; index < methods.length; index++) {
const method = methods[index];
if (method === "remote") {
if (canUseRemoteCompaction(activeModel, resolveMethodSettings(compactionSettings, method))) {
selectedMethod = method;
selectedMethodIndex = index;
break;
}
continue;
}
if (method === "snapcompact") {
if (
explicitSnapcompact ||
(!customInstructions && !options?.internalGuidance && activeModel.input.includes("image"))
) {
selectedMethod = method;
selectedMethodIndex = index;
break;
}
continue;
}
if (method === "soft") {
selectedMethod = method;
selectedMethodIndex = index;
break;
}
}
if (!selectedMethod) {
throw new Error("No configured compaction method can run manually.");
}
const effectiveSettings = resolveMethodSettings(compactionSettings, selectedMethod);
const availableModels = this.#host.modelRegistry.getAvailable();
const requireProviderRemote = Boolean(compactMode?.requiresRemote && !effectiveSettings.remoteEndpoint);
let compactionCandidates = this.#getCompactionModelCandidates(
const requireProviderRemote = selectedMethod === "remote" && !effectiveSettings.remoteEndpoint;
const compactionCandidates = this.#getCompactionModelCandidates(
availableModels,
requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined,
requireProviderRemote
? candidate =>
candidate.provider === activeModel.provider &&
shouldUseProviderNativeCompaction(candidate, effectiveSettings)
: undefined,
);
if (requireProviderRemote && compactionCandidates.length === 0) {
this.#host.emitNotice(
"warning",
`remote compaction is unavailable for ${this.#model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`,
`remote compaction is unavailable for ${activeModel.id}; trying the next preferred method`,
"compaction",
);
compactionCandidates = this.#getCompactionModelCandidates(availableModels);
return await this.compact(customInstructions, options, selectedMethodIndex + 1, compactionAbortController);
}
const pathEntries = this.#host.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, effectiveSettings, this.#model, this.#tokenizer);
const preparation = prepareCompaction(pathEntries, effectiveSettings, activeModel, this.#tokenizer);
if (!preparation) {
// Check why we can't compact
const lastEntry = pathEntries[pathEntries.length - 1];
@@ -668,23 +749,12 @@ export class SessionMaintenance {
}
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
if (compactionPrep.kind !== "fromHook") methodAttempted = true;
// Strategy honored on manual /compact too. Custom instructions (public
// user focus OR internal plan-mode guidance) imply a directed LLM
// summary; a text-only model cannot read snapcompact frames.
const wantsSnapcompact =
compactionPrep.kind !== "fromHook" &&
effectiveSettings.strategy === "snapcompact" &&
!customInstructions &&
!options?.internalGuidance;
// `/compact snapcompact` is an explicit no-LLM archive request: honor
// its contract by failing locally rather than silently shipping the
// transcript to a provider. The default-configured snapcompact
// strategy, in contrast, falls back to LLM compaction (mirroring the
// auto-compaction path) so a routine /compact still completes on a
// text-only model (issue #5064).
const explicitSnapcompact = compactMode?.name === "snapcompact";
let snapcompactReady = wantsSnapcompact;
// Focus instructions require an LLM summary, so the preference resolver
// only selects snapcompact for an undirected manual compaction.
const wantsSnapcompact = compactionPrep.kind !== "fromHook" && selectedMethod === "snapcompact";
const snapcompactReady = wantsSnapcompact;
const snapcompactShapeSetting = this.#host.settings.get("snapcompact.shape");
let snapcompactShape: snapcompact.Shape | undefined;
// Claude refuses inputs that reproduce its own reasoning as text
@@ -693,20 +763,12 @@ export class SessionMaintenance {
// Anthropic-dialect targets (issue #6093).
const snapcompactIncludeThinking = preferredDialect(this.#model.id) !== "anthropic";
if (wantsSnapcompact && !this.#model.input.includes("image")) {
if (explicitSnapcompact) {
this.#host.emitNotice(
"warning",
`snapcompact needs a vision-capable model (${this.#model.id} is text-only)`,
"compaction",
);
throw new Error(`snapcompact cannot run locally: ${this.#model.id} is text-only.`);
}
this.#host.emitNotice(
"warning",
`snapcompact needs a vision-capable model (${this.#model.id} is text-only); falling back to LLM compaction`,
"compaction",
);
snapcompactReady = false;
} else if (snapcompactReady) {
const text = snapcompact.serializeConversation(
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
@@ -723,7 +785,7 @@ export class SessionMaintenance {
const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
this.#host.emitNotice(
"warning",
`snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%). No LLM fallback was attempted.`,
`snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%).`,
"compaction",
);
throw new Error(
@@ -741,8 +803,8 @@ export class SessionMaintenance {
// Snapcompact runs locally first. The frame cap is sized from the live
// model window via #computeSnapcompactMaxFrames so the post-render context
// fits without the warning loop (issue #3247). Zero-frame budget now fails
// the snapcompact request locally rather than falling back to an LLM call.
// fits without the warning loop (issue #3247). A local blocker rejects
// this method, allowing the configured preference order to continue.
let snapcompactResult: snapcompact.CompactionResult | undefined;
if (snapcompactReady) {
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
@@ -752,7 +814,7 @@ export class SessionMaintenance {
});
this.#host.emitNotice(
"warning",
"snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
"snapcompact: kept history alone exceeds the context budget.",
"compaction",
);
throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
@@ -777,7 +839,7 @@ export class SessionMaintenance {
});
this.#host.emitNotice(
"warning",
"snapcompact produced too much standing image payload. No LLM fallback was attempted.",
"snapcompact produced too much standing image payload.",
"compaction",
);
throw new Error(
@@ -795,7 +857,7 @@ export class SessionMaintenance {
});
this.#host.emitNotice(
"warning",
"snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
"snapcompact could not bring the context under the limit.",
"compaction",
);
throw new Error("snapcompact could not bring the context under the limit locally.");
@@ -878,6 +940,7 @@ export class SessionMaintenance {
fromExtension,
preserveData,
);
compactionCommitted = true;
const newEntries = this.#host.sessionManager.getEntries();
const sessionContext = this.#host.buildDisplaySessionContext();
this.#host.agent.replaceMessages(sessionContext.messages);
@@ -919,15 +982,30 @@ export class SessionMaintenance {
return compactionResult;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
if (
methodAttempted &&
!compactionCommitted &&
!compactionAbortController.signal.aborted &&
!(error instanceof CompactionCancelledError) &&
selectedMethodIndex >= 0 &&
selectedMethodIndex + 1 < methods.length
) {
this.#host.emitNotice(
"warning",
`${methods[selectedMethodIndex]} compaction failed; trying the next preferred method`,
"compaction",
);
return await this.compact(customInstructions, options, selectedMethodIndex + 1, compactionAbortController);
}
options?.onError?.(err);
throw error;
} finally {
if (ownsCompactionController) {
if (this.#compactionAbortController === compactionAbortController) {
this.#compactionAbortController = undefined;
}
this.#host.reconnectToAgent();
// Compaction disconnected before `await abort()`, so abort's finally drain
// (and any steer/follow-up that arrived mid-compaction — async IRC, an
// `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected
// (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent
@@ -936,6 +1014,7 @@ export class SessionMaintenance {
this.#host.drainStrandedQueuedMessages();
}
}
}
/**
* Ask the active memory backend for an extra-context block to splice into
@@ -1072,8 +1151,8 @@ export class SessionMaintenance {
* before its next model call. Before compacting, the just-finished turn is
* synchronously persisted if async message hooks have not reached the normal
* append path yet. Mid-run handoff is suppressed because resetting the session
* while the loop owns `activeMessages` would race the next request; handoff
* strategy falls back to in-place context-full compaction here.
* while the loop owns `activeMessages` would race the next request; the handoff
* preference is skipped in favor of the next in-place method.
*/
async maintainContextMidRun(
activeMessages: AgentMessage[],
@@ -1096,7 +1175,7 @@ export class SessionMaintenance {
const compactionSettings = this.#host.settings.getGroup("compaction");
if (
!compactionSettings.enabled ||
compactionSettings.strategy === "off" ||
!hasConfiguredCompactionMethod(compactionSettings) ||
compactionSettings.midTurnEnabled === false
) {
return;
@@ -1176,7 +1255,7 @@ export class SessionMaintenance {
logger.debug("Mid-run compaction ran between provider calls", {
contextTokens,
contextWindow,
strategy: compactionSettings.strategy,
methods: resolveCompactionMethodOrder(compactionSettings.methodOrder),
goalActive: this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active",
messagesBefore,
messagesAfter: activeMessages.length,
@@ -1197,11 +1276,11 @@ export class SessionMaintenance {
*
* @param assistantMessage The assistant message to check
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
* @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a
* deferred post-prompt task instead of running inline. Callers running inside the
* `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
* on the pre-prompt path (where the next agent turn is about to start) set it to false
* to avoid racing the deferred handoff against the new turn.
* @param allowDefer If true, a threshold-driven handoff preference may schedule
* itself as a deferred post-prompt task instead of running inline. Callers running
* inside the `agent_end` handler set this to true so `session.prompt()` resolves
* cleanly; callers on the pre-prompt path (where the next agent turn is about to
* start) set it to false to avoid racing the deferred handoff against the new turn.
* @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt.
* @returns whether compaction/recovery scheduled a handoff, retry, auto-continue, or
* queued-message drain that already owns the next turn. Callers MUST skip
@@ -1251,7 +1330,7 @@ export class SessionMaintenance {
// No promotion target available fall through to compaction
const compactionSettings = this.#host.settings.getGroup("compaction");
if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
if (compactionSettings.enabled && hasConfiguredCompactionMethod(compactionSettings)) {
return await this.#host.runRecoveryCompactionWithRollback("overflow", assistantMessage, allowDefer, {
autoContinue,
});
@@ -1305,8 +1384,8 @@ export class SessionMaintenance {
// (and Codex) maps to stopReason === "length". The model burned its
// `max_output_tokens` budget on reasoning/text and emitted no actionable
// deliverable. Same recovery class as overflow: promotion if available,
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
// allow the handoff strategy to actually run.
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so a
// reachable handoff preference may run.
if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
// Same active-context vs persisted-history split as the overflow path
// above: clear the dead turn from agent state so it cannot be replayed,
@@ -1324,10 +1403,10 @@ export class SessionMaintenance {
}
const incompleteCompactionSettings = this.#host.settings.getGroup("compaction");
if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
if (incompleteCompactionSettings.enabled && hasConfiguredCompactionMethod(incompleteCompactionSettings)) {
logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
model: `${assistantMessage.provider}/${assistantMessage.model}`,
strategy: incompleteCompactionSettings.strategy,
methods: resolveCompactionMethodOrder(incompleteCompactionSettings.methodOrder),
});
return await this.#host.runRecoveryCompactionWithRollback("incomplete", assistantMessage, allowDefer, {
autoContinue,
@@ -1348,7 +1427,8 @@ export class SessionMaintenance {
const supersedeResult = await this.#pruneStaleToolResults();
const compactionSettings = this.#host.settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
if (!compactionSettings.enabled || !hasConfiguredCompactionMethod(compactionSettings))
return COMPACTION_CHECK_NONE;
// Case 4: Threshold - turn succeeded but context is getting large
// Skip if this was an error (non-overflow errors don't have usage data)
@@ -1395,7 +1475,7 @@ export class SessionMaintenance {
stopReason: assistantMessage.stopReason,
sameModel: sameModel === true,
contextWindow,
strategy: compactionSettings.strategy,
methods: resolveCompactionMethodOrder(compactionSettings.methodOrder),
thresholdTokens,
assistantUsageContextTokens,
storedContextTokens,
@@ -1713,7 +1793,7 @@ export class SessionMaintenance {
* ~402k frame-token projection always overflows any sub-1M-token window
* (issue #3247).
*/
#computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
#computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: EngineCompactionSettings): number {
const ctxWindow = this.#model?.contextWindow ?? 0;
if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
const reserve = effectiveReserveTokens(ctxWindow, settings);
@@ -1918,7 +1998,7 @@ export class SessionMaintenance {
// a threshold-derived frame budget.
const frameRescue = await this.#rescueSnapcompactFrameOverflow(
this.#host.sessionManager.getBranch(),
this.#host.settings.getGroup("compaction"),
resolveMethodSettings(this.#host.settings.getGroup("compaction"), "snapcompact"),
signal,
);
if (frameRescue !== undefined && options.hasProgress()) return true;
@@ -1991,7 +2071,7 @@ export class SessionMaintenance {
* Returns 0 when not even one frame fits that budget — the rebuild could
* never create headroom, so the caller must not append it.
*/
#computeSnapcompactRescueMaxFrames(settings: CompactionSettings, keptTailTokens: number): number {
#computeSnapcompactRescueMaxFrames(settings: EngineCompactionSettings, keptTailTokens: number): number {
const ctxWindow = this.#model?.contextWindow ?? 0;
if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
const thresholdTokens = resolveThresholdTokens(ctxWindow, settings);
@@ -2035,12 +2115,12 @@ export class SessionMaintenance {
*/
async #rescueSnapcompactFrameOverflow(
branchEntries: SessionEntry[],
settings: CompactionSettings,
settings: EngineCompactionSettings,
signal: AbortSignal,
): Promise<snapcompact.CompactionResult | undefined> {
if (signal.aborted) return undefined;
// Re-rendering frames needs a vision-capable model, same gate as the
// snapcompact strategy path.
// snapcompact method.
if (!this.#model?.input.includes("image")) return undefined;
const staleEntry = getLatestCompactionEntry(branchEntries);
if (!staleEntry) return undefined;
@@ -2154,14 +2234,11 @@ export class SessionMaintenance {
/**
* Internal: Run auto-compaction with events.
*
* @param allowDefer If true (default), threshold-driven handoff strategy is allowed to
* schedule itself as a deferred post-prompt task and return a deferred-handoff result
* immediately. The caller MUST treat that as "compaction will happen async — do not
* also schedule `agent.continue()` for this turn", otherwise the deferred handoff
* races a fresh streaming turn (the symptom: "Auto-handoff" loader + assistant
* message still streaming). Callers on a path that is about to start a new agent
* turn (e.g. the pre-prompt check in `#promptWithMessage`) pass `false` to force
* inline execution so the handoff completes before the new turn begins.
* @param allowDefer If true (default), a threshold-driven handoff preference
* may schedule itself as a deferred post-prompt task and return a
* deferred-handoff result immediately. The caller MUST avoid separately
* scheduling `agent.continue()` then; pre-prompt callers pass `false` to
* complete the handoff before the next agent turn begins.
* @returns whether auto-compaction scheduled a follow-up turn.
*/
async runAutoCompaction(
@@ -2178,11 +2255,16 @@ export class SessionMaintenance {
terminalTextAnswer?: boolean;
/** Mid-turn: splice history then return; do not await UI/extension fan-out. */
detachPostCommit?: boolean;
/** Index to resume from after an earlier preferred method failed. */
methodIndex?: number;
/** A preceding shake already rewrote history before this fallback attempt. */
fallbackFromShake?: boolean;
} = {},
): Promise<CompactionCheckResult> {
const compactionSettings = this.#host.settings.getGroup("compaction");
if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
const methods = resolveCompactionMethodOrder(compactionSettings.methodOrder);
if (methods.length === 0) return COMPACTION_CHECK_NONE;
const generation = this.#host.promptGeneration();
const terminalTextAnswer =
options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#host.findLastAssistantMessage());
@@ -2190,11 +2272,32 @@ export class SessionMaintenance {
const shouldAutoContinue =
!suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
const suppressHandoff = options.suppressHandoff === true;
let fallbackFromShake = false;
// Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
// reclaims nothing we fall through to the summary-compaction body below so
// the oversized input still gets resolved.
if (compactionSettings.strategy === "shake") {
const startIndex = options.methodIndex ?? 0;
let methodIndex = -1;
let method: CompactionMethod | undefined;
for (let index = startIndex; index < methods.length; index++) {
const candidate = methods[index];
const available =
candidate === "remote"
? canUseRemoteCompaction(this.#model, resolveMethodSettings(compactionSettings, candidate))
: candidate === "snapcompact"
? this.#model?.input.includes("image") === true
: candidate === "handoff"
? reason !== "overflow" && !suppressHandoff
: true;
if (!available) continue;
method = candidate;
methodIndex = index;
break;
}
if (!method) return COMPACTION_CHECK_NONE;
const effectiveSettings = resolveMethodSettings(compactionSettings, method);
const fallbackFromShake = options.fallbackFromShake === true;
// Shake runs inline (cheap, no remote LLM). If it cannot recover enough
// context, resume from the next configured method instead of hardcoding a
// context-full summary.
if (method === "shake") {
const outcome = await this.#runAutoShake(
reason,
willRetry,
@@ -2206,19 +2309,22 @@ export class SessionMaintenance {
options.detachPostCommit === true,
);
if (outcome !== "fallback") return outcome;
fallbackFromShake = true;
return await this.runAutoCompaction(reason, willRetry, deferred, allowDefer, {
...options,
methodIndex: methodIndex + 1,
fallbackFromShake: true,
});
}
// "overflow" and "incomplete" force inline execution because they are recovery
// paths the caller wants resolved before scheduling the next turn. "idle" is
// triggered by the idle loop and does its own scheduling.
if (
!suppressHandoff &&
method === "handoff" &&
!deferred &&
allowDefer &&
reason !== "overflow" &&
reason !== "incomplete" &&
reason !== "idle" &&
compactionSettings.strategy === "handoff"
reason !== "idle"
) {
this.#host.schedulePostPromptTask(
async signal => {
@@ -2226,6 +2332,7 @@ export class SessionMaintenance {
if (signal.aborted) return;
await this.runAutoCompaction(reason, willRetry, true, true, {
...options,
methodIndex,
terminalTextAnswer,
});
},
@@ -2237,29 +2344,21 @@ export class SessionMaintenance {
};
}
// "overflow" forces context-full because the input itself is broken — a handoff
// LLM call would hit the same overflow. "incomplete" is an output-side problem,
// so a handoff request on the existing context is still viable.
let action: "context-full" | "handoff" | "snapcompact" =
compactionSettings.strategy === "snapcompact"
const action: "context-full" | "handoff" | "snapcompact" | "remote" =
method === "remote"
? "remote"
: method === "snapcompact"
? "snapcompact"
: compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
: method === "handoff"
? "handoff"
: "context-full";
if (action === "snapcompact" && this.#model && !this.#model.input.includes("image")) {
this.#host.emitNotice(
"warning",
`snapcompact needs a vision-capable active model (${this.#model.id} is text-only); using context-full auto-compaction instead.`,
"compaction",
);
action = "context-full";
}
// Abort any older auto-compaction before installing this run's controller.
this.#autoCompactionAbortController?.abort();
const autoCompactionAbortController = new AbortController();
this.#autoCompactionAbortController = autoCompactionAbortController;
const autoCompactionSignal = autoCompactionAbortController.signal;
let compactionCommitted = false;
try {
// Emit start AFTER the controller is installed so isCompacting is already true
// for any listener — and for input routed during this emit's event-loop yield:
@@ -2292,10 +2391,24 @@ export class SessionMaintenance {
);
return COMPACTION_CHECK_NONE;
}
logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
logger.warn("Auto-handoff returned no document; trying next preferred compaction method", {
reason,
});
action = "context-full";
await this.#emitLifecycleEvent(
{
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
errorMessage: "Auto-handoff returned no document; trying the next preferred compaction method.",
},
options.detachPostCommit === true,
);
return await this.runAutoCompaction(reason, willRetry, deferred, allowDefer, {
...options,
methodIndex: methodIndex + 1,
});
}
if (handoffResult) {
await this.#emitLifecycleEvent(
@@ -2357,12 +2470,7 @@ export class SessionMaintenance {
const pathEntries = this.#host.sessionManager.getBranch();
let pathEntriesForCompaction = pathEntries;
let preparation = prepareCompaction(
pathEntriesForCompaction,
compactionSettings,
this.#model,
this.#tokenizer,
);
let preparation = prepareCompaction(pathEntriesForCompaction, effectiveSettings, this.#model, this.#tokenizer);
if (!preparation) {
// prepareCompaction found nothing to summarize because the kept region
// is a single oversized recent turn — findCutPoint never cuts inside a
@@ -2378,8 +2486,8 @@ export class SessionMaintenance {
// a compaction entry anchors the stale billed usage so the
// auto-continue re-check cannot re-trip and loop the warning — issue
// #4786). `skipElide` when we already fell through from a shake
// strategy pass (it tried and found nothing); skip entirely on the
// idle timer (it re-checks usage on its own cadence).
// method (it tried and found nothing); skip entirely on the idle timer
// (it re-checks usage on its own cadence).
let rescueRewroteHistory = false;
// A snapcompact CompactionEntry is invisible to both rescue tiers
// below (they only inspect message entries) and to prepareCompaction
@@ -2396,7 +2504,7 @@ export class SessionMaintenance {
if (reason !== "idle") {
frameRescueResult = await this.#rescueSnapcompactFrameOverflow(
pathEntriesForCompaction,
compactionSettings,
effectiveSettings,
autoCompactionSignal,
);
if (frameRescueResult) {
@@ -2414,7 +2522,7 @@ export class SessionMaintenance {
pathEntriesForCompaction = this.#host.sessionManager.getBranch();
preparation = prepareCompaction(
pathEntriesForCompaction,
compactionSettings,
effectiveSettings,
this.#model,
this.#tokenizer,
);
@@ -2539,12 +2647,11 @@ export class SessionMaintenance {
// + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
// per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
// live window so we don't run snapcompact just to overflow every threshold
// tick. Any local blocker (unsupported snapcompact glyphs, kept-history too large,
// post-render overflow) downgrades auto maintenance to a context-full LLM
// summary instead of wedging the session (#3659) — auto runs the default
// strategy on the user's behalf, so a fallback that lets the session keep
// running is the right behavior. Manual `/compact snapcompact` keeps the
// local-only contract (#3599): the user explicitly picked it.
// tick. Any local blocker (unsupported snapcompact glyphs, kept-history too
// large, post-render overflow) advances automatic maintenance to the next
// configured preference instead of wedging the session (#3659). Manual
// `/compact snapcompact` remains local-only because its one-method override
// leaves no fallback.
let snapcompactResult: snapcompact.CompactionResult | undefined;
let snapcompactBlocker: string | undefined;
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
@@ -2570,15 +2677,15 @@ export class SessionMaintenance {
model: this.#model?.id,
unrenderableRatio: renderScan.unrenderableRatio,
});
snapcompactBlocker = `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%); using context-full auto-compaction instead.`;
snapcompactBlocker = `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%); trying the next preferred compaction method.`;
} else {
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
if (maxFrames < 1) {
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
model: this.#model?.id,
});
snapcompactBlocker =
"snapcompact: kept history alone exceeds the context budget; using context-full auto-compaction instead.";
"snapcompact: kept history alone exceeds the context budget; trying the next preferred compaction method.";
} else {
snapcompactResult = await snapcompact.compact(preparation, {
convertToLlm,
@@ -2595,14 +2702,14 @@ export class SessionMaintenance {
budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
});
snapcompactBlocker =
"snapcompact produced too much standing image payload; using context-full auto-compaction instead.";
"snapcompact produced too much standing image payload; trying the next preferred compaction method.";
snapcompactResult = undefined;
}
if (snapcompactResult) {
const ctxWindow = this.#model?.contextWindow ?? 0;
const budget =
ctxWindow > 0
? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
: Number.POSITIVE_INFINITY;
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
if (projected > budget) {
@@ -2612,7 +2719,7 @@ export class SessionMaintenance {
budget,
});
snapcompactBlocker =
"snapcompact could not bring the context under the limit; using context-full auto-compaction instead.";
"snapcompact could not bring the context under the limit; trying the next preferred compaction method.";
snapcompactResult = undefined;
}
}
@@ -2620,7 +2727,21 @@ export class SessionMaintenance {
}
if (snapcompactBlocker) {
this.#host.emitNotice("warning", snapcompactBlocker, "compaction");
action = "context-full";
await this.#emitLifecycleEvent(
{
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
errorMessage: snapcompactBlocker,
},
options.detachPostCommit === true,
);
return await this.runAutoCompaction(reason, willRetry, deferred, allowDefer, {
...options,
methodIndex: methodIndex + 1,
});
}
}
@@ -2639,7 +2760,14 @@ export class SessionMaintenance {
details = snapcompactResult.details;
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
} else {
const candidates = this.#getCompactionModelCandidates(availableModels);
const candidates = this.#getCompactionModelCandidates(
availableModels,
method === "remote" && !effectiveSettings.remoteEndpoint
? candidate =>
candidate.provider === this.#model?.provider &&
shouldUseProviderNativeCompaction(candidate, effectiveSettings)
: undefined,
);
const retrySettings = this.#host.settings.getGroup("retry");
const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
let compactResult: CompactionResult | undefined;
@@ -2839,6 +2967,7 @@ export class SessionMaintenance {
fromExtension,
preserveData,
);
compactionCommitted = true;
const newEntries = this.#host.sessionManager.getEntries();
const sessionContext = this.#host.buildDisplaySessionContext();
this.#host.agent.replaceMessages(sessionContext.messages);
@@ -3013,6 +3142,17 @@ export class SessionMaintenance {
return COMPACTION_CHECK_NONE;
}
const errorMessage = error instanceof Error ? error.message : "compaction failed";
const contextErrorMessage =
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: reason === "incomplete"
? `Incomplete response recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`;
if (!compactionCommitted && methodIndex + 1 < methods.length) {
logger.warn("Automatic compaction method failed; trying next preference", {
method,
error: errorMessage,
});
await this.#emitLifecycleEvent(
{
type: "auto_compaction_end",
@@ -3020,12 +3160,23 @@ export class SessionMaintenance {
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: reason === "incomplete"
? `Incomplete response recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
errorMessage: `${contextErrorMessage}; trying the next preferred compaction method.`,
},
options.detachPostCommit === true,
);
return await this.runAutoCompaction(reason, willRetry, deferred, allowDefer, {
...options,
methodIndex: methodIndex + 1,
});
}
await this.#emitLifecycleEvent(
{
type: "auto_compaction_end",
action,
result: undefined,
aborted: false,
willRetry: false,
errorMessage: contextErrorMessage,
},
options.detachPostCommit === true,
);
@@ -3038,14 +3189,13 @@ export class SessionMaintenance {
}
/**
* Run a shake-strategy auto-maintenance pass. Emits the
* Run a shake-method auto-maintenance pass. Emits the
* `auto_compaction_start`/`auto_compaction_end` pair with a shake `action`,
* runs {@link shake} inline against the protect-window config, and schedules
* continuation exactly like the context-full tail.
*
* Returns `"fallback"` only for an overflow recovery where shake reclaimed
* nothing (or threw) — the caller then runs the summary-compaction body so
* the oversized input still gets resolved. Returns `"handled"` otherwise.
* Returns `"fallback"` when the caller should advance to the next configured
* method; returns a check result when shake handled the maintenance itself.
*/
async #runAutoShake(
reason: "overflow" | "threshold" | "idle" | "incomplete",
@@ -3085,9 +3235,9 @@ export class SessionMaintenance {
// new to drop on the second pass, so the loop spins until the user kills it.
// Same hazard for "incomplete" (the retry would re-hit the length cap) and
// for the existing "overflow + nothing reclaimed" case. In every recovery
// reason we hand off to the summarization-driven context-full path so the
// situation actually resolves; "idle" is exempt because its 60s+ timer
// re-checks usage before re-firing and cannot dead-loop on its own.
// reason we advance to the next preferred method so the situation actually
// resolves; "idle" is exempt because its 60s+ timer re-checks usage before
// re-firing and cannot dead-loop on its own.
//
// #2275: the post-shake check MUST stay provider-anchored when caller
// usage and local estimates diverge. The local estimator undercounts
@@ -3097,7 +3247,7 @@ export class SessionMaintenance {
// hysteresis (80% recovery band) so we don't oscillate at the boundary.
// Threshold callers pass the provider-billed trigger after accounting for
// any supersede/drop-useless pruning that already rewrote the next prompt;
// without that pre-shake savings, shake can fall through to context-full
// without that pre-shake savings, shake can advance to the next preference
// even though the post-prune history is already inside the recovery band.
const contextWindow = this.#model?.contextWindow ?? 0;
const compactionSettings = this.#host.settings.getGroup("compaction");
@@ -3116,8 +3266,8 @@ export class SessionMaintenance {
const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
if (shouldFallBack) {
const errorMessage = reclaimed
? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.`
: "Auto-shake found nothing eligible to drop; falling back to context-full compaction.";
? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; trying the next preferred compaction method.`
: "Auto-shake found nothing eligible to drop; trying the next preferred compaction method.";
await this.#emitLifecycleEvent(
{
type: "auto_compaction_end",
@@ -3220,14 +3370,16 @@ export class SessionMaintenance {
*/
setAutoCompactionEnabled(enabled: boolean): void {
this.#host.settings.set("compaction.enabled", enabled);
if (enabled && this.#host.settings.get("compaction.strategy") === "off") {
const defaultStrategy = getDefault("compaction.strategy");
this.#host.settings.set("compaction.strategy", defaultStrategy === "off" ? "context-full" : defaultStrategy);
if (enabled && resolveCompactionMethodOrder(this.#host.settings.get("compaction.methodOrder")).length === 0) {
this.#host.settings.set("compaction.methodOrder", [...DEFAULT_COMPACTION_METHOD_ORDER]);
}
}
/** Whether auto-compaction is enabled */
/** Whether automatic maintenance has an enabled method to run. */
get autoCompactionEnabled(): boolean {
return this.#host.settings.get("compaction.enabled") && this.#host.settings.get("compaction.strategy") !== "off";
return (
this.#host.settings.get("compaction.enabled") &&
resolveCompactionMethodOrder(this.#host.settings.get("compaction.methodOrder")).length > 0
);
}
}
@@ -66,7 +66,7 @@ describe("AgentSession advisor context maintenance", () => {
const settings = Settings.isolated({
"advisor.syncBacklog": "1",
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"contextPromotion.enabled": contextPromotionEnabled,
});
const agent = new Agent({
@@ -153,7 +153,7 @@ describe("AgentSession advisor context maintenance", () => {
const settings = Settings.isolated({
"advisor.syncBacklog": "1",
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"contextPromotion.enabled": false,
});
settings.setModelRole("advisor", `${nativeModel.provider}/${nativeModel.id}`);
@@ -349,7 +349,7 @@ describe("AgentSession advisor context maintenance", () => {
const settings = Settings.isolated({
"advisor.syncBacklog": "1",
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"contextPromotion.enabled": false,
});
const agent = new Agent({
@@ -859,7 +859,7 @@ describe("AgentSession auto-compaction progress guard", () => {
});
it("does not restore a length stop after handoff recovery commits", async () => {
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("contextPromotion.enabled", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
@@ -1163,7 +1163,7 @@ describe("AgentSession auto-compaction progress guard", () => {
// Pin the threshold so the recovery band is exact: floor(76384 * 0.8) = 61107.
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.methodOrder", ["soft"]);
session.settings.set("compaction.dropUseless", true);
session.settings.set("compaction.supersedeReads", true);
session.settings.set("compaction.keepRecentTokens", 10000);
@@ -639,7 +639,7 @@ describe("AgentSession auto-compaction queue resume", () => {
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.methodOrder", ["soft"]);
session.settings.set("compaction.dropUseless", true);
session.settings.set("compaction.supersedeReads", true);
session.settings.set("compaction.keepRecentTokens", 10000);
@@ -488,7 +488,7 @@ describe("AgentSession context promotion", () => {
}
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.strategy": "snapcompact",
"compaction.methodOrder": ["snapcompact", "soft"],
"compaction.keepRecentTokens": 1,
"compaction.thresholdPercent": -1,
"contextPromotion.enabled": false,
@@ -173,7 +173,7 @@ describe("AgentSession eager prelude re-injection after compaction", () => {
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"task.eager": "always",
"todo.enabled": false,
"todo.eager": "default",
@@ -108,7 +108,7 @@ describe("AgentSession mid-run threshold compaction", () => {
const modelRegistry = sharedModelRegistry;
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.autoContinue": true,
"compaction.midTurnEnabled": true,
"compaction.thresholdTokens": 1000,
@@ -221,7 +221,7 @@ describe("AgentSession mid-run threshold compaction", () => {
});
it("falls back to in-place compaction for mid-run handoff strategy", async () => {
const { session, observedContexts } = await createHarness({ "compaction.strategy": "handoff" });
const { session, observedContexts } = await createHarness({ "compaction.methodOrder": ["handoff", "soft"] });
const handoffSpy = vi.spyOn(session, "handoff").mockImplementation(async () => {
throw new Error("mid-run compaction must not reset the session through handoff");
});
@@ -497,11 +497,11 @@ describe("AgentSession mid-run threshold compaction", () => {
});
it.each([
["auto_compaction_end", "context-full"],
["session_compact", "context-full"],
["auto_compaction_end", "shake"],
["session_compact", "shake"],
] as const)("hung %s handlers do not pin the mid-run %s loop", async (handlerType, strategy) => {
["auto_compaction_end", "context-full", ["soft"]],
["session_compact", "context-full", ["soft"]],
["auto_compaction_end", "shake", ["shake", "soft"]],
["session_compact", "shake", ["shake", "soft"]],
] as const)("hung %s handlers do not pin the mid-run %s loop", async (handlerType, action, methodOrder) => {
const releaseHandler = Promise.withResolvers<void>();
const handlerEntered = Promise.withResolvers<void>();
const nextProviderCall = Promise.withResolvers<void>();
@@ -516,7 +516,7 @@ describe("AgentSession mid-run threshold compaction", () => {
}),
} as unknown as ExtensionRunner;
const { session, observedContexts } = await createHarness(
{ "compaction.strategy": strategy },
{ "compaction.methodOrder": methodOrder },
{
extensionRunner,
onProviderCall: index => {
@@ -525,7 +525,7 @@ describe("AgentSession mid-run threshold compaction", () => {
},
);
const shakeSpy =
strategy === "shake"
action === "shake"
? vi
.spyOn(session, "shake")
.mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 })
@@ -491,7 +491,7 @@ describe("AgentSession handoff", () => {
});
it("obfuscates the previous compaction summary but preserves opaque replay data", async () => {
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.methodOrder", ["soft"]);
const placeholder = obfuscator.obfuscate(HANDOFF_SECRET);
const entries = sessionManager.getBranch();
const lastEntryId = entries[entries.length - 1]?.id;
@@ -534,7 +534,7 @@ describe("AgentSession handoff", () => {
});
it("obfuscates migrated snapcompact archive text but preserves opaque replay data", async () => {
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.methodOrder", ["soft"]);
const placeholder = obfuscator.obfuscate(HANDOFF_SECRET);
const entries = sessionManager.getBranch();
const lastEntryId = entries[entries.length - 1]?.id;
@@ -618,8 +618,8 @@ describe("AgentSession handoff", () => {
expect(compactSpy).not.toHaveBeenCalled();
});
it("downgrades auto snapcompact to context-full when local preflight rejects the transcript", async () => {
session.settings.set("compaction.strategy", "snapcompact");
it("advances from auto snapcompact to soft compaction when local preflight rejects the transcript", async () => {
session.settings.set("compaction.methodOrder", ["snapcompact", "soft"]);
const entries = sessionManager.getBranch();
const lastEntryId = entries[entries.length - 1]?.id;
if (!lastEntryId) throw new Error("Expected a seeded entry id");
@@ -652,11 +652,9 @@ describe("AgentSession handoff", () => {
const endEvent = events.find(
(event): event is Extract<AgentSessionEvent, { type: "auto_compaction_end" }> =>
event.type === "auto_compaction_end",
event.type === "auto_compaction_end" && event.action === "context-full",
);
expect(compactSpy).toHaveBeenCalled();
// The start event fires before the in-try preflight downgrades action, so it
// still reports "snapcompact"; the end event reflects the downgraded action.
expect(events).toContainEqual({ type: "auto_compaction_start", reason: "idle", action: "snapcompact" });
expect(endEvent).toMatchObject({
type: "auto_compaction_end",
@@ -669,7 +667,7 @@ describe("AgentSession handoff", () => {
event.source === "compaction" &&
event.message.startsWith("snapcompact disabled: unsupported characters for selected snapcompact font"),
);
expect(downgradeNotice?.message).toContain("using context-full auto-compaction instead.");
expect(downgradeNotice?.message).toContain("trying the next preferred compaction method.");
});
it("strips hook-supplied snapcompact data when persisting context-full compaction", async () => {
@@ -722,7 +720,7 @@ describe("AgentSession handoff", () => {
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
}),
modelRegistry,
extensionRunner,
@@ -795,7 +793,7 @@ describe("AgentSession handoff", () => {
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
}),
modelRegistry,
extensionRunner,
@@ -819,7 +817,7 @@ describe("AgentSession handoff", () => {
});
it("runs context maintenance before sending an oversized pending prompt", async () => {
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.methodOrder", ["soft"]);
session.settings.set("compaction.thresholdTokens", 50);
session.settings.set("compaction.keepRecentTokens", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -849,7 +847,7 @@ describe("AgentSession handoff", () => {
});
it("falls back after one auto-compaction timeout instead of retrying the same model", async () => {
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.methodOrder", ["soft"]);
session.settings.set("compaction.thresholdTokens", 50);
session.settings.set("compaction.keepRecentTokens", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -969,7 +967,7 @@ describe("AgentSession handoff", () => {
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdTokens": 8_000,
"contextPromotion.enabled": false,
}),
@@ -1054,7 +1052,7 @@ describe("AgentSession handoff", () => {
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdTokens": 8_000,
"contextPromotion.enabled": false,
}),
@@ -1136,7 +1134,7 @@ describe("AgentSession handoff", () => {
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdTokens": 8_000,
"compaction.keepRecentTokens": 1,
"contextPromotion.enabled": false,
@@ -1222,7 +1220,7 @@ describe("AgentSession handoff", () => {
settings: Settings.isolated({
"compaction.enabled": false,
"compaction.autoContinue": false,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdTokens": 9_500,
"contextPromotion.enabled": false,
}),
@@ -1251,7 +1249,7 @@ describe("AgentSession handoff", () => {
expect(mock.calls).toHaveLength(2);
});
it("does not run auto maintenance after final yield", async () => {
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -1352,7 +1350,7 @@ describe("AgentSession handoff", () => {
});
it("does not run auto maintenance when strategy is off", async () => {
session.settings.set("compaction.strategy", "off");
session.settings.set("compaction.methodOrder", []);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -1389,18 +1387,24 @@ describe("AgentSession handoff", () => {
expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0);
});
it("restores default strategy when enabling auto-compaction from off strategy", () => {
it("restores default methods when enabling auto-compaction from an empty order", () => {
session.settings.set("compaction.enabled", true);
session.settings.set("compaction.strategy", "off");
session.settings.set("compaction.methodOrder", []);
expect(session.autoCompactionEnabled).toBe(false);
session.setAutoCompactionEnabled(true);
expect(session.settings.get("compaction.strategy")).toBe("snapcompact");
expect(session.settings.get("compaction.methodOrder")).toEqual([
"remote",
"snapcompact",
"handoff",
"shake",
"soft",
]);
expect(session.autoCompactionEnabled).toBe(true);
});
it("falls back to context-full maintenance for overflow when strategy is handoff", async () => {
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("contextPromotion.enabled", false);
const model = session.model;
@@ -1444,7 +1448,7 @@ describe("AgentSession handoff", () => {
});
it("uses handoff strategy for threshold-triggered auto maintenance", async () => {
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -1580,7 +1584,7 @@ describe("AgentSession handoff", () => {
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
"compaction.strategy": "handoff",
"compaction.methodOrder": ["handoff", "soft"],
"compaction.thresholdPercent": 1,
"contextPromotion.enabled": false,
}),
@@ -1611,7 +1615,7 @@ describe("AgentSession handoff", () => {
// schedules a deferred handoff and returns. The handler used to fall through to
// #checkTodoCompletion, which scheduled agent.continue() — both fired concurrently,
// rendering as "Auto-handoff" loader + an assistant message still streaming.
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
session.settings.set("todo.enabled", true);
@@ -1663,7 +1667,7 @@ describe("AgentSession handoff", () => {
// Reproduces /exit / Ctrl+C-double-tap hanging when a deferred handoff is awaiting
// the LLM call: dispose() now aborts the handoff controller before draining post-prompt
// tasks, so Promise.allSettled() in #cancelPostPromptTasks can resolve.
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -1723,8 +1727,8 @@ describe("AgentSession handoff", () => {
resolveHandoff("handoff");
});
it("falls back to context-full when handoff strategy returns no document", async () => {
session.settings.set("compaction.strategy", "handoff");
it("advances to soft compaction when handoff returns no document", async () => {
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -1755,24 +1759,30 @@ describe("AgentSession handoff", () => {
session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] });
await waitFor(() => events.filter(event => event.type === "auto_compaction_end").length === 1);
await waitFor(() =>
events.some(event => event.type === "auto_compaction_end" && event.action === "context-full"),
);
expect(handoffSpy).toHaveBeenCalledTimes(1);
const endEvents = events.filter(event => event.type === "auto_compaction_end");
expect(endEvents).toHaveLength(1);
expect(endEvents).toHaveLength(2);
expect(endEvents[0]).toMatchObject({
type: "auto_compaction_end",
action: "handoff",
aborted: false,
willRetry: false,
errorMessage: "Auto-handoff returned no document; trying the next preferred compaction method.",
});
expect(endEvents[1]).toMatchObject({
type: "auto_compaction_end",
action: "context-full",
aborted: false,
willRetry: false,
});
expect(endEvents[0]).not.toMatchObject({
errorMessage: "Auto-handoff failed: no handoff document was generated",
});
});
it("treats a vetoed auto-handoff switch as cancelled instead of falling back", async () => {
session.settings.set("compaction.strategy", "handoff");
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -87,7 +87,7 @@ describe("AgentSession manual snapcompact text-only fallback", () => {
if (!sessionManager.getBranch()[0]?.id) throw new Error("Expected seeded branch entry");
const settings = Settings.isolated({
"compaction.strategy": "snapcompact",
"compaction.methodOrder": ["snapcompact", "soft"],
"compaction.keepRecentTokens": 1,
});
session = new AgentSession({ agent, sessionManager, settings, modelRegistry });
@@ -113,15 +113,12 @@ describe("AgentSession manual snapcompact text-only fallback", () => {
const result = await harness.session.compact();
expect(result.summary).toBe("llm summary");
// LLM fallback ran; the active text-only model is tried first.
// The preference resolver skips snapcompact and tries the active model for soft compaction.
expect(compactSpy).toHaveBeenCalled();
const [, firstCandidate] = compactSpy.mock.calls[0]!;
expect(`${firstCandidate.provider}/${firstCandidate.id}`).toBe(
`${harness.activeModel.provider}/${harness.activeModel.id}`,
);
expect(harness.notices).toContain(
`snapcompact needs a vision-capable model (${harness.activeModel.id} is text-only); falling back to LLM compaction`,
);
expect(harness.sessionManager.getBranch().find(entry => entry.type === "compaction")).toMatchObject({
type: "compaction",
summary: "llm summary",
@@ -101,7 +101,7 @@ describe("AgentSession mid-turn compaction dead-end", () => {
streamFn: mock.stream,
});
const settings = Settings.isolated({
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdTokens": 100_000,
"compaction.midTurnEnabled": true,
"compaction.autoContinue": false,
@@ -87,7 +87,7 @@ describe("AgentSession plan-mode compaction hook contract (issue #4359)", () =>
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`));
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
// Aggressive keep-recent budget so the small seeded conversation still
// yields a non-empty messagesToSummarize window (prepareCompaction
// otherwise short-circuits with "Nothing to compact").
@@ -140,7 +140,7 @@ describe("AgentSession approved-plan reference re-injection after compaction (is
fixtureDir.removeSync();
});
async function createHarness(strategy: "context-full" | "snapcompact" = "context-full"): Promise<Harness> {
async function createHarness(method: "soft" | "snapcompact" = "soft"): Promise<Harness> {
const observedCalls: ObservedPromptCall[] = [];
const waiters: Array<{
predicate: (call: ObservedPromptCall) => boolean;
@@ -158,7 +158,7 @@ describe("AgentSession approved-plan reference re-injection after compaction (is
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": true,
"compaction.strategy": strategy,
"compaction.methodOrder": method === "snapcompact" ? ["snapcompact", "soft"] : ["soft"],
"task.eager": "default",
"todo.enabled": false,
"todo.eager": "default",
@@ -3934,7 +3934,7 @@ describe("AgentSession retry fallback", () => {
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdPercent": 80,
"compaction.thresholdTokens": -1,
"contextPromotion.enabled": true,
@@ -4050,7 +4050,7 @@ describe("AgentSession retry fallback", () => {
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdPercent": 80,
"compaction.thresholdTokens": -1,
"contextPromotion.enabled": true,
@@ -7,6 +7,7 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { CompactionMethod } from "@oh-my-pi/pi-coding-agent/session/compaction-methods";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
const UNRENDERABLE_SNAPCOMPACT_TEXT = "\uE000\uE001\uE002\uE003\uE004\uE005\uE006\uE007\uE008\uE009";
@@ -22,6 +23,8 @@ interface Harness {
interface HarnessOptions {
activeModel: { provider: GeneratedProvider; id: string };
seedMessages?: Message[];
/** Null leaves compaction.methodOrder at its schema default. */
methodOrder?: readonly CompactionMethod[] | null;
}
async function createHarness(modelRegistry: ModelRegistry, options: HarnessOptions): Promise<Harness> {
@@ -36,8 +39,9 @@ async function createHarness(modelRegistry: ModelRegistry, options: HarnessOptio
const firstKeptEntryId = sessionManager.getBranch()[0]?.id;
if (!firstKeptEntryId) throw new Error("Expected seeded branch entry");
const methodOrder = options.methodOrder ?? ["snapcompact", "soft"];
const settings = Settings.isolated({
"compaction.strategy": "snapcompact",
...(options.methodOrder === null ? {} : { "compaction.methodOrder": [...methodOrder] }),
// Force a 1-token recent window so the post-turn cut always splits off the
// last turn and summarizes the seeded unrenderable history. With the default
// 20k window the cut keeps both tiny messages, leaving nothing for
@@ -58,12 +62,15 @@ async function createHarness(modelRegistry: ModelRegistry, options: HarnessOptio
tokensBefore: 123,
details: {},
});
const end = Promise.withResolvers<{ action: string; errorMessage?: string }>();
const notices: string[] = [];
session.subscribe(event => {
if (event.type === "notice" && event.source === "compaction") notices.push(event.message);
if (event.type === "auto_compaction_end") {
if (
event.type === "auto_compaction_end" &&
!event.aborted &&
(event.result !== undefined || event.skipped === true)
) {
end.resolve({ action: event.action, errorMessage: event.errorMessage });
}
});
@@ -111,6 +118,7 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => {
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("aimlapi", "test-key");
authStorage.setRuntimeApiKey("openai", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
@@ -124,7 +132,7 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => {
authStorage.close();
});
it("downgrades to context-full when the active model cannot read snapcompact frames", async () => {
it("uses soft compaction when snapcompact is unavailable for the active model", async () => {
const harness = await createHarness(modelRegistry, {
activeModel: { provider: "aimlapi", id: "alibaba/qwen3-coder-480b-a35b-instruct" },
});
@@ -134,15 +142,55 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => {
const result = await harness.awaitCompactionEnd();
expect(result).toEqual({ action: "context-full", errorMessage: undefined });
expect(compactionModule.compact).toHaveBeenCalled();
expect(harness.notices).toContain(
"snapcompact needs a vision-capable active model (alibaba/qwen3-coder-480b-a35b-instruct is text-only); using context-full auto-compaction instead.",
);
expect(harness.sessionManager.getBranch().find(entry => entry.type === "compaction")).toMatchObject({
type: "compaction",
summary: "compacted",
});
});
it("uses snapcompact for a non-OpenAI vision model under the default preference order", async () => {
const harness = await createHarness(modelRegistry, {
activeModel: { provider: "aimlapi", id: "claude-sonnet-4-5-20250929" },
methodOrder: null,
});
session = harness.session;
harness.triggerThreshold();
const result = await harness.awaitCompactionEnd();
expect(result).toEqual({ action: "snapcompact", errorMessage: undefined });
expect(compactionModule.compact).not.toHaveBeenCalled();
expect(harness.sessionManager.getBranch().some(entry => entry.type === "compaction")).toBe(true);
});
it("uses OpenAI server compaction before local fallback methods by default", async () => {
const harness = await createHarness(modelRegistry, {
activeModel: { provider: "openai", id: "gpt-5" },
methodOrder: null,
});
session = harness.session;
harness.triggerThreshold();
const result = await harness.awaitCompactionEnd();
expect(result).toEqual({ action: "remote", errorMessage: undefined });
expect(compactionModule.compact).toHaveBeenCalledTimes(1);
});
it("falls through from a failed OpenAI server compaction to snapcompact", async () => {
const harness = await createHarness(modelRegistry, {
activeModel: { provider: "openai", id: "gpt-5" },
methodOrder: null,
});
session = harness.session;
vi.spyOn(compactionModule, "compact").mockRejectedValue(new Error("server compaction unavailable"));
harness.triggerThreshold();
const result = await harness.awaitCompactionEnd();
expect(result).toEqual({ action: "snapcompact", errorMessage: undefined });
expect(compactionModule.compact).toHaveBeenCalledTimes(1);
});
it("downgrades to context-full when unsupported glyphs make snapcompact unsafe", async () => {
const harness = await createHarness(modelRegistry, {
activeModel: { provider: "aimlapi", id: "claude-sonnet-4-5-20250929" },
@@ -164,8 +212,7 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => {
const unsupportedGlyphNotice = harness.notices.find(message =>
message.startsWith("snapcompact disabled: unsupported characters for selected snapcompact font"),
);
expect(unsupportedGlyphNotice).toBeDefined();
expect(unsupportedGlyphNotice).toContain("using context-full auto-compaction instead.");
expect(unsupportedGlyphNotice).toContain("trying the next preferred compaction method.");
expect(harness.sessionManager.getBranch().find(entry => entry.type === "compaction")).toMatchObject({
type: "compaction",
summary: "compacted",
@@ -91,7 +91,7 @@ describe("AgentSession snapcompact frame-budget sizing", () => {
agent,
sessionManager,
settings: Settings.isolated({
"compaction.strategy": "snapcompact",
"compaction.methodOrder": ["snapcompact", "soft"],
"compaction.autoContinue": false,
// Force a small kept-recent window so the seeded conversation
// definitely splits into discard + kept and prepareCompaction()
@@ -180,7 +180,7 @@ describe("AgentSession snapcompact frame dead-end rescue", () => {
sessionManager,
settings: Settings.isolated({
"compaction.autoContinue": true,
"compaction.strategy": "snapcompact",
"compaction.methodOrder": ["snapcompact", "soft"],
// Fixed trigger so the rescue's threshold-derived frame budget is
// deterministic: band 0.8 × 60k = 48k minus base/edge reserves
// yields well under 16 frames — the rebuild must shrink.
@@ -2,20 +2,14 @@ import { describe, expect, it } from "bun:test";
import { findCompactMode, parseCompactArgs } from "@oh-my-pi/pi-coding-agent/session/compact-modes";
describe("compact mode registry", () => {
it("maps each mode to the settings overrides the engine relies on", () => {
// These override values are load-bearing: the engine merges them over the
// configured compaction.* settings, so a regression here silently changes
// what `/compact <mode>` does.
expect(findCompactMode("soft")?.overrides).toEqual({ strategy: "context-full", remoteEnabled: false });
expect(findCompactMode("remote")?.overrides).toEqual({ strategy: "context-full", remoteEnabled: true });
expect(findCompactMode("snapcompact")?.overrides).toEqual({ strategy: "snapcompact" });
it("maps each mode to the method order the engine executes", () => {
expect(findCompactMode("soft")?.overrides).toEqual({ methodOrder: ["soft"] });
expect(findCompactMode("remote")?.overrides).toEqual({ methodOrder: ["remote", "soft"] });
expect(findCompactMode("snapcompact")?.overrides).toEqual({ methodOrder: ["snapcompact"] });
});
it("flags remote as remote-requiring and snapcompact as focus-rejecting", () => {
expect(findCompactMode("remote")?.requiresRemote).toBe(true);
it("flags snapcompact as focus-rejecting", () => {
expect(findCompactMode("snapcompact")?.rejectsFocus).toBe(true);
// soft is a plain local summary: neither flag.
expect(findCompactMode("soft")?.requiresRemote).toBeUndefined();
expect(findCompactMode("soft")?.rejectsFocus).toBeUndefined();
});
@@ -46,7 +46,7 @@ describe("compaction prefers the current session model over modelRoles.default",
throw new Error("Expected bundled test models to exist");
}
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" });
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.methodOrder": ["soft"] });
settings.setModelRole("default", `${defaultRoleModel.provider}/${defaultRoleModel.id}`);
const promptCacheKey = "inherited-parent-cache";
@@ -110,7 +110,7 @@ describe("compaction prefers the current session model over modelRoles.default",
throw new Error("Expected bundled test models to exist");
}
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" });
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.methodOrder": ["soft"] });
settings.setModelRole("smol", `${fallbackModel.provider}/${fallbackModel.id}`);
const agent = new Agent({
@@ -202,7 +202,7 @@ describe("compaction prefers the current session model over modelRoles.default",
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }),
settings: Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.methodOrder": ["soft"] }),
modelRegistry,
});
session.subscribe(() => {});
@@ -84,7 +84,7 @@ describe("Context usage consolidation", () => {
settings: Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": false,
"compaction.strategy": "context-full",
"compaction.methodOrder": ["soft"],
"compaction.thresholdTokens": 8000,
}),
modelRegistry,
@@ -39,7 +39,10 @@ describe("issue #986 compaction auth fallback", () => {
throw new Error("Expected bundled test models to exist");
}
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" });
const settings = Settings.isolated({
"compaction.keepRecentTokens": 1,
"compaction.methodOrder": ["remote", "soft"],
});
if (options?.fallbackModelRole) {
settings.setModelRole(options.fallbackModelRole, `${fallbackModel.provider}/${fallbackModel.id}`);
}
@@ -79,11 +82,13 @@ describe("issue #986 compaction auth fallback", () => {
session.agent.appendMessage(assistant);
session.sessionManager.appendMessage(assistant);
}
return { currentModel, fallbackModel };
}
async function createAutoNativeFallbackSession(options?: { sameProviderNativeEnabled?: boolean }) {
async function createAutoNativeFallbackSession(options?: {
sameProviderNativeEnabled?: boolean;
includeSoftFallback?: boolean;
}) {
const currentModel = getBundledModel("openai", "gpt-5");
const sameProviderBase = getBundledModel("openai", "gpt-5-mini");
const sameProviderModel =
@@ -98,7 +103,7 @@ describe("issue #986 compaction auth fallback", () => {
const settings = Settings.isolated({
"compaction.autoContinue": false,
"compaction.keepRecentTokens": 1,
"compaction.strategy": "context-full",
"compaction.methodOrder": options?.includeSoftFallback ? ["remote", "soft"] : ["remote"],
"contextPromotion.enabled": false,
});
settings.setModelRole("smol", `${sameProviderModel.provider}/${sameProviderModel.id}`);
@@ -342,19 +347,25 @@ describe("issue #986 compaction auth fallback", () => {
expect(sameProviderModel.remoteCompaction?.enabled).toBe(false);
});
it("preserves cross-provider auto-compaction fallback for auth-classified native failures", async () => {
it("falls through to cross-provider soft compaction after native authentication failures", async () => {
const { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } =
await createAutoNativeFallbackSession();
await createAutoNativeFallbackSession({ includeSoftFallback: true });
const attemptedModels: string[] = [];
vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => {
attemptedModels.push(`${model.provider}/${model.id}`);
if (preparation.settings.remoteEnabled === true) {
if (model.provider === currentModel.provider || model.provider === sameProviderModel.provider) {
throw new compactionModule.NativeCompactionError(
Object.assign(new Error("native compaction authentication failed"), { status: 401 }),
);
}
throw new Error(`Unexpected remote compaction model ${model.provider}/${model.id}`);
}
if (model.provider === currentModel.provider || model.provider === sameProviderModel.provider) {
throw new AIError.ProviderHttpError("local compaction authentication failed", 401);
}
if (model.provider !== crossProviderModel.provider || model.id !== crossProviderModel.id) {
throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`);
throw new Error(`Unexpected soft compaction model ${model.provider}/${model.id}`);
}
return {
summary: "authenticated fallback summary",
@@ -367,6 +378,8 @@ describe("issue #986 compaction auth fallback", () => {
await triggerAutoCompaction();
expect(attemptedModels).toEqual([
`${currentModel.provider}/${currentModel.id}`,
`${sameProviderModel.provider}/${sameProviderModel.id}`,
`${currentModel.provider}/${currentModel.id}`,
`${sameProviderModel.provider}/${sameProviderModel.id}`,
`${crossProviderModel.provider}/${crossProviderModel.id}`,
@@ -402,7 +415,7 @@ describe("issue #986 compaction auth fallback", () => {
expect(attemptedModels).not.toContain(`${crossProviderModel.provider}/${crossProviderModel.id}`);
});
it("falls back across providers when native compaction receives auth_unavailable", async () => {
it("falls back across providers when server compaction receives auth_unavailable", async () => {
const { currentModel, fallbackModel } = await createSession({ fallbackModelRole: "smol" });
const originalCompact = compactionModule.compact;
const fetchMock = vi.fn(async () =>
@@ -415,6 +428,7 @@ describe("issue #986 compaction auth fallback", () => {
.spyOn(compactionModule, "compact")
.mockImplementation(async (preparation, model, apiKey, customInstructions, signal, options) => {
if (model.provider === currentModel.provider && model.id === currentModel.id) {
if (preparation.settings.remoteEnabled === true) {
return originalCompact(
{
...preparation,
@@ -427,6 +441,8 @@ describe("issue #986 compaction auth fallback", () => {
{ ...options, fetch: fetchMock },
);
}
throw new AIError.ProviderHttpError("local compaction authentication failed", 401);
}
if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) {
throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`);
}
@@ -447,9 +463,9 @@ describe("issue #986 compaction auth fallback", () => {
const result = await session.compact();
expect(result.summary).toBe("fallback summary");
expect(fetchMock).toHaveBeenCalled();
expect(compactSpy).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([
`${currentModel.provider}/${currentModel.id}`,
`${currentModel.provider}/${currentModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
@@ -516,8 +532,9 @@ describe("issue #986 compaction auth fallback", () => {
const result = await session.compact();
expect(result.summary).toBe("fallback summary");
expect(compactSpy).toHaveBeenCalledTimes(2);
expect(compactSpy).toHaveBeenCalledTimes(3);
expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([
`${currentModel.provider}/${currentModel.id}`,
`${currentModel.provider}/${currentModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
@@ -57,6 +57,23 @@ function createSelector(): SettingsSelectorComponent {
const [firstChoice, secondChoice] = SEARCH_PROVIDER_CHOICES;
function optionRow(component: SettingsSelectorComponent, label: string): number {
const lines = Bun.stripANSI(component.render(120).join("\n")).split("\n");
const row = lines.findIndex(line => line.includes(label));
if (row === -1) throw new Error(`Missing settings option: ${label}`);
return row + 1;
}
function sendMouse(component: SettingsSelectorComponent, button: number, row: number, suffix: "M" | "m"): void {
component.handleInput(`\x1b[<${button};3;${row}${suffix}`);
}
function clickOption(component: SettingsSelectorComponent, label: string): void {
const row = optionRow(component, label);
sendMouse(component, 0, row, "M");
sendMouse(component, 0, row, "m");
}
describe("multiselect settings (array-of-enum)", () => {
it("edits providers.webSearchOrder via the ordered toggle list", () => {
const comp = createSelector();
@@ -165,4 +182,33 @@ describe("multiselect settings (array-of-enum)", () => {
comp.handleInput(" ");
expect(settings.get("providers.webSearchExclude")).toEqual([]);
});
it("toggles list members on mouse click", () => {
const comp = createSelector();
for (const ch of "web search provider order") comp.handleInput(ch);
comp.handleInput("\n");
clickOption(comp, firstChoice!.label);
expect(settings.get("providers.webSearchOrder")).toEqual([firstChoice!.value]);
clickOption(comp, firstChoice!.label);
expect(settings.get("providers.webSearchOrder")).toEqual([]);
});
it("reorders selected list members by drag and drop", () => {
const comp = createSelector();
for (const ch of "web search provider order") comp.handleInput(ch);
comp.handleInput("\n");
clickOption(comp, firstChoice!.label);
clickOption(comp, secondChoice!.label);
expect(settings.get("providers.webSearchOrder")).toEqual([firstChoice!.value, secondChoice!.value]);
const sourceRow = optionRow(comp, secondChoice!.label);
const targetRow = optionRow(comp, firstChoice!.label);
sendMouse(comp, 0, sourceRow, "M");
sendMouse(comp, 32, targetRow, "M");
sendMouse(comp, 0, targetRow, "m");
expect(settings.get("providers.webSearchOrder")).toEqual([secondChoice!.value, firstChoice!.value]);
});
});
@@ -951,6 +951,25 @@ describe("Settings", () => {
});
});
describe("compaction method migration", () => {
it("defaults to server, snapcompact, handoff, shake, then soft compaction", () => {
expect(Settings.isolated().get("compaction.methodOrder")).toEqual([
"remote",
"snapcompact",
"handoff",
"shake",
"soft",
]);
});
it("migrates a local-only legacy strategy to soft compaction", async () => {
await writeSettings({ compaction: { strategy: "context-full", remoteEnabled: false } });
const settings = await Settings.init({ cwd: projectDir, agentDir });
expect(settings.get("compaction.methodOrder")).toEqual(["soft"]);
});
});
describe("migrations", () => {
it("consolidates legacy Exa suite toggles onto exa.enabled", async () => {
await writeSettings({
+11 -11
View File
@@ -344,7 +344,7 @@ describe("AgentSession shake", () => {
describe("auto-shake strategy", () => {
it("dispatches the elide path and emits a shake action for threshold maintenance", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -384,7 +384,7 @@ describe("AgentSession shake", () => {
});
it("keeps a successful overflow shake recovery committed before retrying", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("contextPromotion.enabled", false);
seedHeavyToolResult("X ".repeat(20000));
branchToolResults()[0].useless = true;
@@ -440,7 +440,7 @@ describe("AgentSession shake", () => {
});
it("keeps a no-op incomplete shake retry committed before rollback can restore the length tail", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("contextPromotion.enabled", false);
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
vi.spyOn(session.agent, "continue").mockResolvedValue();
@@ -500,7 +500,7 @@ describe("AgentSession shake", () => {
// Defect 1 parity for the shake strategy: the controller backing isCompacting
// must be installed before auto_compaction_start is emitted, so a message
// typed as the loader appears is queued safely rather than mis-routed.
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -542,8 +542,8 @@ describe("AgentSession shake", () => {
expect(capturedIsCompacting).toBe(true);
});
it("falls back to context-full when shake cannot drop context below the threshold (regression #2119)", async () => {
session.settings.set("compaction.strategy", "shake");
it("advances to soft compaction when shake cannot drop context below the threshold (regression #2119)", async () => {
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("compaction.thresholdPercent", 1);
session.settings.set("contextPromotion.enabled", false);
@@ -590,7 +590,7 @@ describe("AgentSession shake", () => {
e => e.type === "auto_compaction_end" && (e as { action?: string }).action === "shake",
) as { errorMessage?: string; skipped?: boolean } | undefined;
expect(shakeEnd).toBeDefined();
expect(shakeEnd?.errorMessage).toMatch(/falling back to context-full/i);
expect(shakeEnd?.errorMessage).toMatch(/trying the next preferred compaction method/i);
// Fallback enters the context-full path so the situation actually resolves.
const fullStart = events.find(
@@ -600,7 +600,7 @@ describe("AgentSession shake", () => {
});
it("falls back when provider-reported usage stays above the threshold even though the local estimate is below it (regression #2275)", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("compaction.thresholdTokens", 5_000);
session.settings.set("contextPromotion.enabled", false);
@@ -642,7 +642,7 @@ describe("AgentSession shake", () => {
e => e.type === "auto_compaction_end" && (e as { action?: string }).action === "shake",
) as { errorMessage?: string; skipped?: boolean } | undefined;
expect(shakeEnd).toBeDefined();
expect(shakeEnd?.errorMessage).toMatch(/falling back to context-full/i);
expect(shakeEnd?.errorMessage).toMatch(/trying the next preferred compaction method/i);
const fullStart = events.find(
e => e.type === "auto_compaction_start" && (e as { action?: string }).action === "context-full",
@@ -651,7 +651,7 @@ describe("AgentSession shake", () => {
});
it("counts pre-shake prune savings when deciding whether to fall back to context-full", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.dropUseless", true);
@@ -715,7 +715,7 @@ describe("AgentSession shake", () => {
});
it("falls back after pre-prompt shake when the floored stored conversation remains over threshold", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.methodOrder", ["shake", "soft"]);
session.settings.set("compaction.thresholdTokens", 8_000);
session.settings.set("compaction.keepRecentTokens", 1);
session.settings.set("contextPromotion.enabled", false);