From 2f54e760c1cd4a4b5be29c45f9632bbd5f4cd8ee Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 03:45:44 +0200 Subject: [PATCH] feat(coding-agent): added async compaction and in-place handoff - Added compaction.asyncEnabled (Async Compaction, default on): when context enters the pre-threshold band [threshold - lead, threshold) with lead = clamp(threshold * 0.125, 8192, 32000), maintenance speculatively summarizes in the background off a branch snapshot (first configured LLM-backed method: remote, handoff, or soft) using a side session id isolated from the live turn. Crossing the threshold splices the armed result in instantly instead of blocking on a summarization round-trip. Armed results are invalidated by branch changes, reset boundaries, model switches that strand provider-native replay payloads, and context growth past keepRecentTokens (which re-speculates); extensions registering session_before_compact keep exact blocking semantics (speculation disabled). - Reworked handoff to commit in place: /handoff and the auto handoff method now write the generated document as a regular compaction entry on the current session (summary = document + tag, cut from prepareCompaction) instead of starting a new session. SessionHandoff shrank to a document generator; session_before_switch/session_switch no longer fire with reason "handoff"; mid-turn maintenance no longer suppresses the handoff preference; overflow recovery can apply an armed handoff result. - Extracted the shared auto-compaction commit tail (#commitAutoCompactionResult / #commitCompactionEntry) used by the blocking production path, the armed speculative apply, manual compaction, and manual handoff. - Status line pulses the auto-compact icon while a speculation runs and holds it in accent once a result is armed. - Exported remotePreserveReusable from pi-agent-core/compaction for apply-time validation of speculative remote results. --- docs/compaction.md | 8 +- docs/handoff-generation-pipeline.md | 164 +-- packages/agent/CHANGELOG.md | 1 + packages/agent/src/compaction/compaction.ts | 2 +- packages/coding-agent/CHANGELOG.md | 2 + .../src/config/settings-schema.ts | 13 + .../src/extensibility/shared-events.ts | 4 +- .../modes/components/status-line/component.ts | 33 + .../modes/components/status-line/segments.ts | 21 +- .../src/modes/components/status-line/types.ts | 4 + .../modes/controllers/command-controller.ts | 11 +- .../src/session/agent-session-types.ts | 1 - .../coding-agent/src/session/agent-session.ts | 49 +- .../src/session/compaction-methods.ts | 2 +- .../src/session/session-handoff.ts | 165 +-- .../src/session/session-maintenance.ts | 991 +++++++++++++----- .../coding-agent/test/advisor-toggle.test.ts | 60 +- ...ion-auto-compaction-progress-guard.test.ts | 41 +- ...ent-session-goal-midrun-compaction.test.ts | 14 - .../test/agent-session-handoff.test.ts | 421 +------- .../test/compaction-speculation.test.ts | 244 +++++ .../test/status-line-loop.test.ts | 2 + .../test/status-line-model.test.ts | 2 + .../test/status-line-overflow.test.ts | 2 + .../test/status-line-path.test.ts | 2 + .../test/status-line-time-spent.test.ts | 2 + 26 files changed, 1218 insertions(+), 1043 deletions(-) create mode 100644 packages/coding-agent/test/compaction-speculation.test.ts diff --git a/docs/compaction.md b/docs/compaction.md index 5cc307a17..03a046a00 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -126,7 +126,7 @@ The automatic paths are intentionally different: - 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 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. + - When `handoff` is the next runnable method, post-turn threshold maintenance normally schedules a post-prompt task that generates the handoff document and commits it as a compaction entry; pre-prompt and mid-turn checks run all methods inline to avoid racing the next turn. - 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** @@ -259,7 +259,7 @@ Remote summarization modes: `packages/agent/src/compaction/compaction.ts` also exports `generateHandoff(...)`. Handoff generation uses the same `completeSimple(...)` oneshot style as summarization, but it preserves the live agent cache prefix by sending the active system prompt, tool array, and real LLM message history, then appending one agent-attributed `user` message containing the handoff prompt. It forces `toolChoice: "none"` and returns joined text blocks directly. -Handoff does not write a `CompactionEntry`. `AgentSession.handoff()` owns the session transition: it starts a new session, injects the generated document as a visible `custom_message` with `customType: "handoff"`, and rebuilds agent messages from that new session. +Handoff commits a regular `CompactionEntry` on the current session: `SessionMaintenance.handoff()` (manual `/handoff`) and the auto-maintenance `handoff` method both generate the document via `SessionHandoff.generateDocument()` and store it as the compaction summary with `firstKeptEntryId` from `prepareCompaction`, so recent history is kept and the session id, transcript, and provider cache key are unchanged. When `compaction.handoffSaveToDisk` is enabled, an **automatically triggered** handoff also writes `handoff-.md` in the persisted session's artifact directory. Manual handoffs are not written by this setting, and non-persisted sessions have no artifact directory. @@ -295,7 +295,7 @@ Legacy ``/`` tags from summaries written by earlier After summary generation (or hook-provided summary), agent session: -1. Appends `CompactionEntry` with `appendCompaction(...)` for context-full maintenance; handoff strategy creates a new session and injects a handoff `custom_message` instead. +1. Appends `CompactionEntry` with `appendCompaction(...)`; the handoff method commits the generated document as the entry's summary on the same session. 2. Rebuilds display context from the active leaf via `buildDisplaySessionContext()`. 3. Replaces live agent messages with rebuilt context. 4. Synchronizes active todo phases from the rebuilt branch and closes provider sessions whose history was rewritten. @@ -419,11 +419,13 @@ From `settings-schema.ts`: - `compaction.enabled` = `true` - `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.asyncEnabled` = `true`. Async (speculative) compaction: when context enters the pre-threshold band `[threshold − lead, threshold)` (lead = `clamp(threshold × 0.125, 8192, 32000)`), maintenance starts a background summarization for the first configured LLM-backed method (`remote`, `handoff`, or `soft`) off a branch snapshot, isolated from the live turn by a side session id. The armed result is committed instantly when the threshold is actually crossed, hiding summarization latency; post-snapshot turns are appended after the summary unchanged. Armed results are discarded when the branch prefix changes (new compaction, reset boundary, `/tree` navigation), when a provider-native replay payload is no longer readable by the active model, or when context grows past `keepRecentTokens` since compute (a fresh speculation replaces it). Speculation is skipped while an extension registers `session_before_compact`. The status line pulses the auto-compact icon while a speculation runs and holds it in accent when a result is armed. - `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` +- The `handoff` method generates a handoff document through the live-cache side-request pipeline and commits it as a compaction entry on the current session (no new session is created); `/handoff` does the same manually. - `compaction.remoteEndpoint` = `undefined` - `compaction.remoteStreamingV2Enabled` = `true` - `compaction.v2RetainedMessageBudget` = `64000` diff --git a/docs/handoff-generation-pipeline.md b/docs/handoff-generation-pipeline.md index 4d28ecd4d..d943c86e8 100644 --- a/docs/handoff-generation-pipeline.md +++ b/docs/handoff-generation-pipeline.md @@ -1,21 +1,21 @@ # `/handoff` generation pipeline -This document describes how the coding-agent implements `/handoff`: trigger path, oneshot generation, session switch, context reinjection, persistence, and UI behavior. +This document describes how the coding-agent implements `/handoff`: trigger path, oneshot generation, in-session compaction commit, persistence, and UI behavior. ## Scope Covers: - Interactive `/handoff` command dispatch -- `AgentSession.handoff()` lifecycle and state transitions -- `generateHandoffFromContext(...)` request shape and compatibility retry -- How old/new sessions persist handoff data differently +- `AgentSession.handoff()` → `SessionMaintenance.handoff()` lifecycle +- `SessionHandoff.generateDocument(...)` and `generateHandoffFromContext(...)` request shape and compatibility retry +- How the handoff document is committed as a compaction entry - UI behavior for success, cancel, and failure Does not cover: - Generic tree navigation/branch internals -- Non-handoff session commands (`/new`, `/fork`, `/resume`) +- Session commands (`/new`, `/fork`, `/resume`) ## Implementation files @@ -23,6 +23,7 @@ Does not cover: - [`src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts) - [`src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts) - [`src/session/session-handoff.ts`](../packages/coding-agent/src/session/session-handoff.ts) +- [`src/session/session-maintenance.ts`](../packages/coding-agent/src/session/session-maintenance.ts) - [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) - [`packages/agent/src/compaction/compaction.ts`](../packages/agent/src/compaction/compaction.ts) - [`src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts) @@ -34,27 +35,30 @@ Does not cover: 3. `CommandController.handleHandoffCommand` refuses while the current response is streaming, then counts `type === "message"` entries. 4. If the count is `< 2`, it warns `Nothing to hand off (no messages yet)` and returns. -The same minimum-content guard exists inside `SessionHandoff.handoff()` and throws if violated. RPC separately refuses a handoff while streaming. Direct SDK callers must avoid invoking the session method during an active response. +The same minimum-content guard exists inside `SessionMaintenance.handoff()` and throws if violated. RPC separately refuses a handoff while streaming. Direct SDK callers must avoid invoking the session method during an active response. ## End-to-end lifecycle -### 1) Start handoff generation +### 1) Prepare the commit -`AgentSession.handoff()` delegates to `SessionHandoff.handoff(customInstructions?, options?)`: +`AgentSession.handoff()` delegates to `SessionMaintenance.handoff(customInstructions?, options?)`: + +- Throws `Compaction already in progress` while manual or automatic maintenance is active, and cancels any background speculative compaction. +- Reads the current branch, validates at least two message entries, and runs `prepareCompaction(...)` with the handoff method settings to compute `firstKeptEntryId` and `tokensBefore`; an empty preparation (e.g. right after a compaction) throws `Nothing to hand off (already compacted)`. + +### 2) Generate the document + +`SessionHandoff.generateDocument(customInstructions?, options?)` owns generation and the abort controller (`isGeneratingHandoff`): -- Rejects session transitions while vibe mode is active. -- Reads the current branch and validates at least two message entries. -- Creates `#handoffAbortController` and links any caller-provided abort signal to it. - Requires a selected model and an API key/resolver for that model. - Builds the handoff request through the **same side-request pipeline a live turn uses**, shared with ephemeral turns: 1. Renders the handoff prompt (`renderHandoffPrompt(...)` with optional focus, after secret obfuscation) and appends it as an agent-attributed `user` message to a snapshot of `agent.state.messages`. 2. Converts the snapshot with `convertMessagesToLlm(...)` (session `transformContext`, LLM conversion, and obfuscation). - 3. Builds provider `Context` with `agent.buildSideRequestContext(llmMessages, baseSystemPrompt)` — normalized tools and provider-context transforms matching the loop. The base system prompt is pinned, so the fresh session does not inherit a per-turn `before_agent_start` override. + 3. Builds provider `Context` with `agent.buildSideRequestContext(llmMessages, baseSystemPrompt)` — normalized tools and provider-context transforms matching the loop. The base system prompt is pinned, so the committed summary does not inherit a per-turn `before_agent_start` override. 4. Builds simple-stream options with the live provider cache key, a unique side `sessionId` (`:side:`), service tier/payload hooks, `preferWebsockets: false`, `initiatorOverride: "agent"`, and the abort signal. - Obfuscates the final provider context and calls `generateHandoffFromContext(...)` through the host side-stream transport. -- Deobfuscates the returned handoff text before persistence or display. - -### 2) Generate and capture output +- Deobfuscates the returned handoff text. +- For auto-triggered generations with `compaction.handoffSaveToDisk`, writes a timestamped `handoff-*.md` artifact under the session's artifacts directory. `generateHandoffFromContext(...)` lives in `packages/agent/src/compaction/compaction.ts` next to summarization. It issues an OTEL-instrumented `completeSimple`-equivalent oneshot against the caller-built `Context`, overriding the supplied stream options with clamped compaction reasoning and `toolChoice: "none"`. @@ -83,92 +87,34 @@ Capture is direct from the oneshot response; no agent-loop events or latest-assi ### 3) Cancellation checks -An explicit user cancellation throws `Error("Handoff cancelled")`. Harness-initiated aborts preserve a supplied reason, or surface `Handoff aborted by session` when none is supplied. A manual handoff whose generation is empty/whitespace-only throws `Handoff generation produced no content`; auto-handoff returns `undefined` so maintenance can fall back to context-full compaction. +An explicit user cancellation throws `Error("Handoff cancelled")`. Harness-initiated aborts preserve a supplied reason, or surface `Handoff aborted by session` when none is supplied. A manual handoff whose generation is empty/whitespace-only throws `Handoff generation produced no content`; auto-handoff returns `undefined` so maintenance can advance to the next configured method. -- caller signal aborts `#handoffAbortController` and forwards its reason +- caller signal aborts the handoff controller and forwards its reason - `completeSimple(...)` receives the abort signal - direct `abortHandoff()` or an unreasoned caller signal is normalized to `Error("Handoff cancelled")` - harness abort reasons and provider failures (including provider `AbortError`s) surface verbatim -`AgentSession.handoff()` always clears `#handoffAbortController` in `finally`. +`SessionHandoff.generateDocument()` always clears the abort controller in `finally`. -### 4) New session creation +### 4) Commit as a compaction entry -If text was generated and not aborted: +If text was generated and not aborted, `SessionMaintenance.handoff()` commits the document on the **current** session: -1. Emit `session_before_switch` with reason `handoff`; an extension may cancel the switch, in which case no new session is created. -2. Flush pending bash output and the current session writer. -3. Drain/detach advisor recorders while they still point at the old session. -4. Begin a bash session transition and cancel session-owned async jobs. -5. Start a brand-new session with `parentSession` pointing at the previous session file when one exists. -6. Clear advisor cost, session-scoped tool/checkpoint state, and stale provider-session state. -7. Preserve steering and follow-up queues across `agent.reset()` so messages arriving during handoff survive into the new session. -8. Rebind the agent session id, rekey/reset memory tracking, clear queued next-turn context, and reset the todo cycle. +1. Wraps the document as a compaction summary: `upsertFileOperations(document, readFiles, modifiedFiles, …)` appends the cumulative `` tag from the preparation's file operations; `{ readFiles, modifiedFiles }` becomes the entry `details`. +2. Appends a regular `CompactionEntry` (`appendCompaction(summary, undefined, firstKeptEntryId, tokensBefore, details, false, undefined)`). +3. Rebuilds the display context, replaces live agent messages, re-anchors stats (`rebaseAfterCompaction`), resets the plan reference, advisor runtimes (`"handoff"`), and todo phases, and closes provider sessions whose history was rewritten. +4. Emits the `session_compact` extension hook with the saved entry. +5. Returns `{ document, savedPath? }`. -### 5) Handoff-context injection - -The generated handoff document is wrapped by coding-agent session glue and appended to the new session as a `custom_message` entry: - -```text - -...handoff text... - - -The above is a handoff document from a previous session. Use this context to continue the work seamlessly. -``` - -Insertion call: - -```ts -this.sessionManager.appendCustomMessageEntry( - "handoff", - handoffContent, - true, - undefined, - "agent", -); -``` - -Semantics: - -- `customType`: `"handoff"` -- `display`: `true` (visible in TUI rebuild) -- attribution: `"agent"` -- Entry type: `custom_message` (participates in LLM context) - -### 6) Rebuild active agent context - -After injection: - -1. `buildDisplaySessionContext()` resolves messages for the new leaf. -2. `agent.replaceMessages(sessionContext.messages)` activates the injected handoff context. -3. Advisor runtime state and todo phases reset for the new branch. -4. Emit `session_switch` with reason `handoff` and the previous session file. -5. Return `{ document: handoffText, savedPath? }`. - -At this point, the active LLM context in the new session contains the injected handoff message, not the old transcript. - -## Persistence model: old session vs new session - -### Old session - -Handoff generation is a oneshot request, not a visible agent turn. The generated handoff text is not appended to the old session as an assistant message. - -Result: the original session keeps its prior transcript unchanged except for data already persisted before handoff began. - -### New session - -After session reset, handoff is persisted as `custom_message` with `customType: "handoff"`. - -`buildSessionContext()` converts this entry into a runtime custom/user-context message via `createCustomMessage(...)`, so it is included in future prompts from the new session. - -Auto-triggered handoffs can additionally write a timestamped `handoff-*.md` artifact under the **new** session's artifacts directory when `compaction.handoffSaveToDisk` is enabled. Manual `/handoff` does not write that artifact. The injected custom message is forced on disk before the method returns. +The session id, session file, transcript scrollback, and provider prompt-cache key are all unchanged. Recent history from `firstKeptEntryId` onward is kept verbatim, exactly like every other compaction method; only the summarized prefix is replaced by the document. ### Automatic handoff -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. +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 document generation to a post-prompt task; pre-prompt, mid-turn, and `incomplete` recovery run inline. Input `overflow` skips handoff generation because the request would carry the same oversized input — but an already-armed speculative handoff result can still be applied during overflow recovery. -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. +Async compaction (`compaction.asyncEnabled`) may also generate the handoff document speculatively in the pre-threshold band and commit it instantly when the threshold is crossed; see `docs/compaction.md`. + +If auto generation returns no document, maintenance advances to the next configured method. `compaction.handoffSaveToDisk` defaults to `false`; when enabled, only auto-triggered handoffs write the extra markdown artifact. ## Controller/UI behavior @@ -179,17 +125,17 @@ If auto generation returns no document, maintenance advances to the next configu - Calls `await session.handoff(customInstructions)`. - If result is `undefined`: `showError("Handoff cancelled")`. - On success: - - clears transient session UI and renders the new session messages, including the injected handoff + - clears transient session UI and re-renders the session, which now shows the handoff compaction divider - invalidates status line and editor border - reloads todos - - appends `New session started with handoff context` + - appends `Context handed off and compacted in place` - shows `savedPath` when the result includes one (manual `/handoff` normally has none) - On exception: - if message is `"Handoff cancelled"`: `showError("Handoff cancelled")` - otherwise: logs the error and calls `showError("Handoff failed: ")` - Stops the loader, clears the status container, and requests render at end. -Manual `/handoff` no longer streams the generated document into chat. A cancellable loader remains visible while the oneshot request runs, and the chat is rebuilt after generation completes. +Manual `/handoff` does not stream the generated document into chat. A cancellable loader remains visible while the oneshot request runs, and the chat is rebuilt after the commit completes. ## Cancellation semantics @@ -197,14 +143,14 @@ Manual `/handoff` no longer streams the generated document into chat. A cancella `AgentSession` exposes: -- `abortHandoff()` → aborts `#handoffAbortController` -- `isGeneratingHandoff` → true while controller exists +- `abortHandoff()` → aborts the generation controller +- `isGeneratingHandoff` → true while generation is in flight -Direct `abortHandoff()` passes an unreasoned abort signal to `completeSimple(...)`; `handoff()` normalizes it to `Error("Handoff cancelled")`, and command controller maps it to cancellation UI. `AgentSession.abort(...)` instead aborts the handoff first with its harness reason (or `Handoff aborted by session`), so subsequent compaction cancellation cannot mask that failure as a user cancellation. +Direct `abortHandoff()` passes an unreasoned abort signal to `completeSimple(...)`; generation normalizes it to `Error("Handoff cancelled")`, and command controller maps it to cancellation UI. `AgentSession.abort(...)` instead aborts the handoff first with its harness reason (or `Handoff aborted by session`), so subsequent compaction cancellation cannot mask that failure as a user cancellation. ### Interactive `/handoff` path -`InputController`'s global `editor.onEscape` handler dispatches on live session state instead of swapping handlers: while `isGeneratingHandoff` is true, pressing Escape calls `session.abortHandoff()`, which aborts the `completeSimple(...)` request through `#handoffAbortController`. +`InputController`'s global `editor.onEscape` handler dispatches on live session state instead of swapping handlers: while `isGeneratingHandoff` is true, pressing Escape calls `session.abortHandoff()`, which aborts the `completeSimple(...)` request. ## Aborted vs failed handoff @@ -215,19 +161,17 @@ Current UI classification: - an unreasoned caller signal also triggers `"Handoff cancelled"` - UI shows `Handoff cancelled` - **Failed** - - a harness abort reason, an empty manual generation, or any thrown provider/session-transition error + - a harness abort reason, an empty manual generation, or any thrown provider error - UI logs the error and shows `Handoff failed: ...` -An extension-cancelled `session_before_switch` returns `undefined`, which the interactive controller reports as **cancelled**. Empty generation is not an extension cancellation: manual handoff throws; auto-handoff returns `undefined` only for its context-full fallback. +Empty generation on the manual path throws; auto-handoff returns `undefined` only for its next-method fallback. ## Short-session and minimum-content guardrails Two guards prevent low-signal handoffs: - UI layer (`handleHandoffCommand`): warns and returns early for `< 2` message entries -- Session layer (`handoff()`): throws the same condition as an error - -This avoids creating a new session with empty/near-empty handoff context. +- Session layer (`SessionMaintenance.handoff()`): throws the same condition as an error ## State transition summary @@ -235,23 +179,17 @@ High-level state flow: 1. Interactive slash command dispatched by the builtin registry. 2. Streaming and message-count preflight guards. -3. `#handoffAbortController` created (`isGeneratingHandoff = true`). -4. `generateHandoffFromContext(...)` sends one cache-aligned side request, with a one-time `"auto"` tool-choice compatibility retry when required. +3. `prepareCompaction(...)` computes the cut (`firstKeptEntryId`, `tokensBefore`). +4. Generation controller created (`isGeneratingHandoff = true`); `generateHandoffFromContext(...)` sends one cache-aligned side request, with a one-time `"auto"` tool-choice compatibility retry when required. 5. Assistant text blocks are joined; tool-call blocks are discarded; secret placeholders are restored locally. -6. If missing text or an extension cancels the switch → return `undefined`; if aborted → cancellation error. -7. If present: - - flush bash/session persistence and detach advisor recorders - - cancel async jobs and create a new child session - - reset runtime/tool/checkpoint/memory state while preserving steering/follow-up queues - - append and persist `custom_message(handoff)` - - optionally save an auto-triggered handoff artifact - - rebuild agent context, advisors, and todos, then emit `session_switch` +6. If missing text → manual throws / auto returns `undefined`; if aborted → cancellation error. +7. If present: append the `CompactionEntry`, rebuild the agent context, reset plan/advisor/todo runtime state, close rewritten provider sessions, emit `session_compact`. 8. Controller rebuilds chat UI and announces success. -9. `#handoffAbortController` clears in `finally`; failed pre-commit transitions reattach advisor recorder feeds. +9. The generation controller clears in `finally`. ## Known assumptions and limitations - No structural validation checks that generated markdown follows the requested section format. -- Missing text and extension-cancelled switches are reported as cancellation in the interactive controller. - Manual handoff has no streaming visibility; a cancellable loader is shown until the UI updates. -- Auto-triggered artifact write failure is logged and does not fail the already-created handoff session. +- Auto-triggered artifact write failure is logged and does not fail the handoff. +- Sessions created by older versions may still contain `custom_message` entries with `customType: "handoff"` from the previous new-session pipeline; they render and participate in context unchanged. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 16db6b0a8..4ecc5a489 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Added `Tokenizer.checkTokenBudget(text, budget)`: a cheap-first budget probe. Byte length is a hard upper bound on token count, so text whose raw bytes already fit answers "fits" without tokenizing at all; only text that busts the bound pays for an exact count (and that count is returned, so a proportional clamp gets the denominator it needs). Since the bound overshoots ~4x on prose, the common "comfortably under budget" answer is free. Compaction's summary-window fit check and OpenAI remote-compaction trimming now route through it. - Added provider-anchored transcript accounting (`findTranscriptUsageAnchor`, `isTranscriptUsageAnchor`, `estimateTranscriptTokens`). Every settled assistant turn carries `usage` covering the exact prompt it was sent, so transcript sizing charges that report for the prefix and tokenizes only the tail appended after it — counting proportional to one turn instead of the whole history, every turn. The four hand-rolled copies of the anchor trust rules (session stats ×3, shake) now share one predicate, and the deliberately provider-independent compaction floor counts every message locally via `tokenizer.countMessages`. +- Exported `remotePreserveReusable(preserveData, activeModel, settings)` — whether a prior remote compaction's provider-native replay payload is still readable by the active model — so hosts can validate speculatively produced compaction results before committing them. ### Changed diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 221005b1a..0dfe716c8 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -1268,7 +1268,7 @@ export interface CompactionPreparation { * let the active model replay it, so keying reuse on "any candidate shares the * provider" left a provider-switched session permanently context-less (#6343). */ -function remotePreserveReusable( +export function remotePreserveReusable( preserveData: Record | undefined, activeModel: Model, settings: CompactionSettings, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 13f66cf8b..40bd81e02 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `tokenizer` to custom model and `modelOverrides` configuration. It overrides the catalog-resolved local tokenizer family for a model when a proxy serves a known model id with a different tokenizer. - 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. - Added click-to-toggle and drag-to-reorder controls for list-valued `/settings` editors. +- Added `compaction.asyncEnabled` (Async Compaction, default on): when context enters the band just below the compaction threshold, maintenance speculatively summarizes in the background off a branch snapshot (first configured LLM-backed method — remote, handoff, or soft — isolated from the live turn by a side session id) and holds the armed result; crossing the threshold then splices it in instantly instead of blocking on a summarization round-trip. Armed results are invalidated by branch changes, reset boundaries, model switches that strand provider-native replay payloads, and context growth past `keepRecentTokens` (which re-speculates). The status line pulses the auto-compact icon while a speculation runs and holds it in accent once a result is armed. ### Changed @@ -17,6 +18,7 @@ - The todo HUD header now draws a summed progress bar counting closed/total tasks across every stage. Once all tasks close, the bar smoothly collapses before the row disappears. - Token counting is now scoped to the model being billed rather than to a process-global tokenizer: session maintenance, stats, advisors, `/context`, snapcompact inline imaging, and `compress` each count through the owning agent's `Tokenizer` (`agent.tokenizer`). Message counting is `Tokenizer.countMessage`/`countMessages` (replacing the free `estimateTokens(message, tokenizer)` helper; the legacy shim keeps a compat `estimateTokens` export for legacy pi extensions). `estimateToolSchemaTokens`, `estimateSkillsTokens`, `computeNonMessageTokens`, and `computeNonMessageBreakdown` take an explicit tokenizer; standalone prompt inspection intentionally keeps the default estimate because it has no resolved catalog model. - The advisor runtime's `maintainContext` hook now receives the pending update as a message instead of a pre-computed token count — sizing it needs the advisor model's tokenizer, which the host owns. +- Handoff no longer starts a new session: `/handoff` and the auto-maintenance `handoff` method now commit the generated document as a regular compaction entry on the current session (document becomes the summary, recent history is kept per `compaction.keepRecentTokens`, session id/transcript/cache key unchanged). The `session_before_switch`/`session_switch` extension events no longer fire with reason `"handoff"`, mid-turn maintenance no longer skips the handoff preference, and overflow recovery can now apply a pre-armed handoff result. ### Fixed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index dd0a70d7d..cbb39d5a7 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2274,6 +2274,18 @@ export const SETTINGS_SCHEMA = { }, }, + "compaction.asyncEnabled": { + type: "boolean", + default: true, + ui: { + tab: "context", + group: "Compaction", + label: "Async Compaction", + description: + "Speculatively summarize in the background as context nears the compaction threshold, then splice the ready result in when the threshold is crossed", + }, + }, + // No default: an unset reserve tells the compaction layer the user never // chose one, so small-window recovery may swap in the proportional reserve // (see resolveBudgetReserveTokens). A materialized 16384 here would make @@ -5715,6 +5727,7 @@ export interface CompactionSettings { reserveTokens: number | undefined; keepRecentTokens: number; midTurnEnabled: boolean; + asyncEnabled: boolean; handoffSaveToDisk: boolean; autoContinue: boolean; remoteEndpoint: string | undefined; diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts index d3a401ce5..b4d35b0f3 100644 --- a/packages/coding-agent/src/extensibility/shared-events.ts +++ b/packages/coding-agent/src/extensibility/shared-events.ts @@ -33,7 +33,7 @@ export interface SessionStartEvent { export interface SessionBeforeSwitchEvent { type: "session_before_switch"; /** Reason for the switch */ - reason: "new" | "resume" | "fork" | "handoff"; + reason: "new" | "resume" | "fork"; /** Session file we're switching to (only for "resume") */ targetSessionFile?: string; } @@ -42,7 +42,7 @@ export interface SessionBeforeSwitchEvent { export interface SessionSwitchEvent { type: "session_switch"; /** Reason for the switch */ - reason: "new" | "resume" | "fork" | "handoff"; + reason: "new" | "resume" | "fork"; /** Session file we came from */ previousSessionFile: string | undefined; } diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index ea28220dc..dbf61a565 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -324,6 +324,9 @@ export class StatusLineComponent implements Component { #onBranchChange: (() => void) | null = null; #disposed = false; #autoCompactEnabled: boolean = true; + /** Pulse timer for the running-speculation indicator; live only while speculation runs. */ + #speculationBlinkTimer: NodeJS.Timeout | undefined; + #speculationBlinkOn = true; #hookStatuses: Map = new Map(); #subagentCount: number = 0; /** @@ -694,12 +697,38 @@ export class StatusLineComponent implements Component { this.#branchResolveActive = undefined; this.#resetJjRequests(); this.#onBranchChange = null; + this.#stopSpeculationBlink(); this.#clearUsageStartTimer(); this.#onCodexResetFireworks = undefined; this.#codexResetSnapshots.clear(); this.#retireGitWatcher(); } + /** + * Drive the context segment's pulse while a background speculative + * compaction runs: a slow toggle that invalidates and repaints through the + * same host callback async git/PR resolves use. Stops (and resets phase) + * the first render after speculation leaves the running state. + */ + #syncSpeculationBlink(state: "idle" | "running" | "armed"): void { + if (state === "running" && !this.#disposed) { + this.#speculationBlinkTimer ??= setInterval(() => { + this.#speculationBlinkOn = !this.#speculationBlinkOn; + this.invalidate(); + this.#onBranchChange?.(); + }, 600); + return; + } + this.#stopSpeculationBlink(); + } + + #stopSpeculationBlink(): void { + if (!this.#speculationBlinkTimer) return; + clearInterval(this.#speculationBlinkTimer); + this.#speculationBlinkTimer = undefined; + this.#speculationBlinkOn = true; + } + #clearUsageStartTimer(): void { if (!this.#usageStartTimer) return; clearTimeout(this.#usageStartTimer); @@ -1599,6 +1628,8 @@ export class StatusLineComponent implements Component { this.#getGitStatus(activeRepoCache.effectiveGitCwd)) : null; const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null; + const compactionSpeculation = this.session.compactionSpeculation ?? "idle"; + this.#syncSpeculationBlink(compactionSpeculation); return { session: this.session, focusedAgentId: this.#focusedAgentId, @@ -1621,6 +1652,8 @@ export class StatusLineComponent implements Component { contextTokens, contextWindow, autoCompactEnabled: this.#autoCompactEnabled, + compactionSpeculation, + speculationBlinkOn: this.#speculationBlinkOn, subagentCount: this.#subagentCount, activeMs: this.getActiveMs(), git: { diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index aec3f3298..a01117113 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -458,11 +458,24 @@ const contextPctSegment: StatusLineSegment = { const pct = ctx.contextPercent; const window = ctx.contextWindow; - const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : ""; - const text = `${formatContextUsage(pct, window, ctx.contextTokens)}${autoIcon}`; - const color = getContextUsageThemeColor(getContextUsageLevel(pct ?? 0, window)); - const content = withIcon(theme.icon.context, theme.fg(color, text)); + // Async-compaction indicator: pulse the auto icon while a background + // speculation runs, hold it in accent once a result is armed. + let autoIcon = ""; + if (ctx.autoCompactEnabled && theme.icon.auto) { + const speculation = ctx.compactionSpeculation; + const iconColor = + speculation === "running" + ? ctx.speculationBlinkOn + ? "accent" + : "muted" + : speculation === "armed" + ? "accent" + : color; + autoIcon = ` ${theme.fg(iconColor, theme.icon.auto)}`; + } + const text = theme.fg(color, formatContextUsage(pct, window, ctx.contextTokens)); + const content = withIcon(theme.icon.context, `${text}${autoIcon}`); return { content, visible: true }; }, diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index e0f778160..c32a39389 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -97,6 +97,10 @@ export interface SegmentContext { contextTokens: number; contextWindow: number; autoCompactEnabled: boolean; + /** Background speculative-compaction state (async compaction). */ + compactionSpeculation: "idle" | "running" | "armed"; + /** Blink phase for the running-speculation pulse; toggled by the component's timer. */ + speculationBlinkOn: boolean; subagentCount: number; /** * Active processing time accumulated this session, in ms — the union of diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 861a8e9e3..b571b7077 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1412,7 +1412,8 @@ export class CommandController { this.ctx.ui.requestRender(); try { - // Handoff generation runs as a oneshot request; the new session is shown after it completes. + // Handoff generation runs as a oneshot request; the document is then + // committed as a compaction entry on this session. const result = await this.ctx.session.handoff(customInstructions); if (!result) { @@ -1420,7 +1421,7 @@ export class CommandController { return; } - // Rebuild chat from the new session (which now contains the handoff document). + // Rebuild chat from the session, which now shows the handoff compaction divider. this.ctx.clearTransientSessionUi(); await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); @@ -1429,7 +1430,11 @@ export class CommandController { this.ctx.present([ new Spacer(1), - new Text(`${theme.fg("accent", `${theme.status.success} New session started with handoff context`)}`, 1, 1), + new Text( + `${theme.fg("accent", `${theme.status.success} Context handed off and compacted in place`)}`, + 1, + 1, + ), ]); if (result.savedPath) { this.ctx.showStatus(`Handoff document saved to: ${result.savedPath}`); diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 2d3ffb019..cfe3f91f9 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -336,7 +336,6 @@ export interface HandoffResult { export interface SessionHandoffOptions { autoTriggered?: boolean; signal?: AbortSignal; - onSwitchCancelled?: () => void; } /** Result from cycleModel(). */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index d91b06995..c762593b8 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1567,7 +1567,8 @@ export class AgentSession { getContextUsage: options => this.getContextUsage(options), shake: (mode, options) => this.shake(mode, options), dropImages: () => this.dropImages(), - runHandoff: (customInstructions, options) => this.handoff(customInstructions, options), + generateHandoffDocument: (customInstructions, options) => + this.#handoff.generateDocument(customInstructions, options), removeAssistantMessageFromActiveContext: message => this.#recovery.removeAssistantMessageFromActiveContext(message), dropPersistedAssistantTurn: message => this.#recovery.dropPersistedAssistantTurn(message), @@ -1585,15 +1586,12 @@ export class AgentSession { sessionManager: this.sessionManager, settings: this.settings, modelRegistry: this.#modelRegistry, - extensionRunner: this.#extensionRunner, sideStreamFn: this.#sideStreamFn, obfuscator: this.#obfuscator, model: () => this.model, thinkingLevel: () => this.thinkingLevel, sessionId: () => this.sessionId, - sessionFile: () => this.sessionFile, baseSystemPrompt: () => this.#tools.baseSystemPrompt, - assertVibeSessionTransitionAllowed: action => this.#assertVibeSessionTransitionAllowed(action), setSkipPostTurnMaintenance: timestamp => { this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp = timestamp; }, @@ -1602,32 +1600,6 @@ export class AgentSession { convertMessagesToLlm: (messages, signal) => this.convertMessagesToLlm(messages, signal), prepareSimpleStreamOptions: (options, provider) => this.prepareSimpleStreamOptions(options, provider), effectiveServiceTier: model => this.#models.effectiveServiceTier(model), - flushPendingBash: () => this.#bash.flushPending(), - beginBashSessionTransition: () => this.#bash.beginSessionTransition(), - markBashSessionTransition: transition => this.#bash.markSessionTransition(transition), - finishBashSessionTransition: (transition, success) => this.#bash.finishSessionTransition(transition, success), - cancelOwnAsyncJobs: () => this.#cancelOwnAsyncJobs(), - clearCheckpointRuntimeState: () => this.#clearCheckpointRuntimeState(), - clearSessionScopedToolState: () => this.#clearSessionScopedToolState(), - clearFreshProviderSessionId: () => { - this.#freshProviderSessionId = undefined; - }, - syncAgentSessionId: () => this.#syncAgentSessionId(), - rekeyMemoryForCurrentSessionId: () => { - this.#memory.rekeyForCurrentSessionId(); - }, - resetMemoryContextForNewTranscript: () => this.#memory.resetContextForNewTranscript(), - clearPendingNextTurnMessages: () => { - this.#pendingNextTurnMessages = []; - this.#scheduledHiddenNextTurnGeneration = undefined; - }, - resetTodoCycle: () => this.#todo.resetCycle(), - buildDisplaySessionContext: () => this.buildDisplaySessionContext(), - resetAdvisorSessionState: () => this.#advisors.resetSessionState(), - drainAndDetachAdvisorRecorders: () => this.#advisors.drainAndDetachRecorders(), - reattachAdvisorRecorderFeeds: () => this.#advisors.reattachRecorderFeeds(), - clearAdvisorCost: () => this.#advisors.clearCost(), - syncTodoPhasesFromBranch: () => this.#todo.syncFromBranch(), }; this.#handoff = new SessionHandoff(handoffHost); @@ -4089,6 +4061,7 @@ export class AgentSession { await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile); this.#movedFromEmptySessionFile = undefined; this.#closeAllProviderSessions("dispose"); + this.#maintenance.cancelSpeculation(); this.setHindsightSessionState(undefined); hindsightState?.dispose(); this.#disconnectFromAgent(); @@ -4711,6 +4684,10 @@ export class AgentSession { return this.#maintenance.isCompacting; } + /** Background speculative-compaction state, for UI indicators. */ + get compactionSpeculation(): "idle" | "running" | "armed" { + return this.#maintenance.speculationState; + } /** Strip image content from the current branch and persist the rewrite. */ dropImages(): Promise<{ removed: number }> { return this.#maintenance.dropImages(); @@ -7121,14 +7098,16 @@ export class AgentSession { } /** - * Generate a handoff document with a oneshot LLM call, then start a new session with it. + * Generate a handoff document with a oneshot LLM call and commit it as a + * compaction entry on the current session (the document becomes the summary; + * recent history is kept). * * @param customInstructions Optional focus for the handoff document * @param options Handoff execution options * @returns The handoff document text, or undefined if cancelled/failed */ handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise { - return this.#handoff.handoff(customInstructions, options); + return this.#maintenance.handoff(customInstructions, options); } #isTerminalYieldToolResult(event: { toolName: string; isError?: boolean; result?: { details?: unknown } }): boolean { @@ -9316,9 +9295,9 @@ export class AgentSession { } /** * Get text content of the most recent visible handoff message. - * Fresh handoff sessions store the handoff context as a custom message, not - * an assistant message, so callers that copy the "last" message can use this - * as a fallback before the new session has an assistant response. + * Sessions created by older versions injected the handoff document as a + * custom message at the top of a fresh session; callers that copy the + * "last" message use this as a fallback while no assistant response exists. */ getLastVisibleHandoffText(): string | undefined { for (let i = this.messages.length - 1; i >= 0; i--) { diff --git a/packages/coding-agent/src/session/compaction-methods.ts b/packages/coding-agent/src/session/compaction-methods.ts index ff0727f25..d220c9b4c 100644 --- a/packages/coding-agent/src/session/compaction-methods.ts +++ b/packages/coding-agent/src/session/compaction-methods.ts @@ -15,7 +15,7 @@ export const COMPACTION_METHOD_CHOICES = [ { value: "handoff", label: "Handoff", - description: "Generate a handoff document and continue in a new session", + description: "Generate a handoff document and continue from it as the compaction summary", }, { value: "soft", diff --git a/packages/coding-agent/src/session/session-handoff.ts b/packages/coding-agent/src/session/session-handoff.ts index 85e15879e..d021f916e 100644 --- a/packages/coding-agent/src/session/session-handoff.ts +++ b/packages/coding-agent/src/session/session-handoff.ts @@ -1,4 +1,4 @@ -/** Handoff generation and session transition orchestration. */ +/** Handoff document generation. Committing the document as a compaction entry is owned by SessionMaintenance. */ import * as path from "node:path"; import { @@ -13,19 +13,11 @@ import type { Message, Model, ServiceTier, SimpleStreamOptions } from "@oh-my-pi import { logger, Snowflake } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import type { Settings } from "../config/settings"; -import type { ExtensionRunner, SessionBeforeSwitchResult } from "../extensibility/extensions"; -import { copyLocalArtifacts, resolveLocalUrlToPath } from "../internal-urls"; import { obfuscateProviderContext } from "../secrets/message-transform"; import type { SecretObfuscator } from "../secrets/obfuscator"; import type { HandoffResult, SessionHandoffOptions } from "./agent-session-types"; -import type { BashSessionTransition } from "./bash-runner"; -import type { SessionContext } from "./session-context"; import type { SessionManager } from "./session-manager"; -function createHandoffContext(document: string): string { - return `\n${document}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`; -} - function createHandoffFileName(date = new Date()): string { const fileTimestamp = date.toISOString().replace(/[:.]/g, "-"); return `handoff-${fileTimestamp}.md`; @@ -48,43 +40,21 @@ export interface SessionHandoffHost { sessionManager: SessionManager; settings: Settings; modelRegistry: ModelRegistry; - extensionRunner: ExtensionRunner | undefined; sideStreamFn: StreamFn; obfuscator: SecretObfuscator | undefined; model(): Model | undefined; thinkingLevel(): ThinkingLevel | undefined; sessionId(): string; - sessionFile(): string | undefined; baseSystemPrompt(): string[]; - assertVibeSessionTransitionAllowed(action: string): void; setSkipPostTurnMaintenance(timestamp: number | undefined): void; obfuscateTextForProvider(text: string | undefined): string | undefined; deobfuscateFromProvider(text: string): string; convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise; prepareSimpleStreamOptions(options: SimpleStreamOptions, provider?: string): SimpleStreamOptions; effectiveServiceTier(model: Model | undefined): ServiceTier | undefined; - flushPendingBash(): Promise; - beginBashSessionTransition(): BashSessionTransition; - markBashSessionTransition(transition: BashSessionTransition): void; - finishBashSessionTransition(transition: BashSessionTransition, success: boolean): void; - cancelOwnAsyncJobs(): void; - clearCheckpointRuntimeState(): void; - clearSessionScopedToolState(): void; - clearFreshProviderSessionId(): void; - syncAgentSessionId(): void; - rekeyMemoryForCurrentSessionId(): void; - resetMemoryContextForNewTranscript(): Promise; - clearPendingNextTurnMessages(): void; - resetTodoCycle(): void; - buildDisplaySessionContext(): SessionContext; - resetAdvisorSessionState(): void; - drainAndDetachAdvisorRecorders(): Promise; - reattachAdvisorRecorderFeeds(): void; - clearAdvisorCost(): void; - syncTodoPhasesFromBranch(): void; } -/** Generates handoff documents and owns the handoff session transition. */ +/** Generates handoff documents with a cache-friendly oneshot LLM call. */ export class SessionHandoff { #handoffAbortController: AbortController | undefined; readonly #host: SessionHandoffHost; @@ -107,21 +77,22 @@ export class SessionHandoff { } /** - * Generate a handoff document with a oneshot LLM call, then start a new session with it. + * Generate a handoff document with a oneshot LLM call. + * + * The request is built through the same pipeline a live turn uses so the + * oneshot reads the provider prompt cache the main turn populated. The + * caller (SessionMaintenance) commits the returned document as a compaction + * entry; this method rewrites no history. * * @param customInstructions Optional focus for the handoff document * @param options Handoff execution options - * @returns The handoff document text, or undefined if cancelled/failed + * @returns The handoff document text, or undefined when an auto-triggered + * generation produced no content (manual generation throws instead) */ - async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise { - this.#host.assertVibeSessionTransitionAllowed("handoff to a new session"); - const entries = this.#host.sessionManager.getBranch(); - const messageCount = entries.filter(e => e.type === "message").length; - - if (messageCount < 2) { - throw new Error("Nothing to hand off (no messages yet)"); - } - + async generateDocument( + customInstructions?: string, + options?: SessionHandoffOptions, + ): Promise { this.#host.setSkipPostTurnMaintenance(undefined); this.#handoffAbortController = new AbortController(); @@ -140,8 +111,6 @@ export class SessionHandoff { } } - let advisorRecordersDetached = false; - let sessionTransitioned = false; try { throwIfHandoffAborted(handoffSignal); @@ -180,8 +149,8 @@ export class SessionHandoff { ]; const handoffLlmMessages = await this.#host.convertMessagesToLlm(handoffSnapshot, handoffSignal); // Base system prompt, not a per-turn `before_agent_start` hook override — - // the handoff seeds a fresh session and must not carry prompt-specific - // hook state. Matches the prompt the old handoff path sent. + // the document seeds the post-compaction context and must not carry + // prompt-specific hook state. const handoffContext = await this.#host.agent.buildSideRequestContext( handoffLlmMessages, this.#host.baseSystemPrompt(), @@ -229,94 +198,14 @@ export class SessionHandoff { autoTriggered: options?.autoTriggered ?? false, }); // Auto-handoff is best-effort: returning undefined lets maintenance fall - // back to context-full compaction. A user-initiated handoff must surface - // the failure instead of a silent, misleading "cancelled". + // back to the next compaction method. A user-initiated handoff must + // surface the failure instead of a silent, misleading "cancelled". if (options?.autoTriggered) { return undefined; } throw new Error("Handoff generation produced no content"); } - // Start a new session - const previousSessionFile = this.#host.sessionFile(); - if (this.#host.extensionRunner?.hasHandlers("session_before_switch")) { - const result = (await this.#host.extensionRunner.emit({ - type: "session_before_switch", - reason: "handoff", - })) as SessionBeforeSwitchResult | undefined; - - if (result?.cancel) { - options?.onSwitchCancelled?.(); - return undefined; - } - } - await this.#host.flushPendingBash(); - await this.#host.sessionManager.flush(); - advisorRecordersDetached = true; - // Stop and settle in-flight advisors while the old-session feeds can still - // observe message_end, then mute before opening the replacement session. - await this.#host.drainAndDetachAdvisorRecorders(); - // Snapshot the outgoing session's local:// root BEFORE newSession() mints a - // fresh session id (and therefore a fresh, empty local root). The handoff - // document routinely references plans/scratch files under local://, so those - // artifacts must follow the session switch or every reference dangles. - const localProtocolOptions = { - getArtifactsDir: () => this.#host.sessionManager.getArtifactsDir(), - getSessionId: () => this.#host.sessionManager.getSessionId(), - }; - const previousLocalRoot = resolveLocalUrlToPath("local://", localProtocolOptions); - const bashTransition = this.#host.beginBashSessionTransition(); - this.#host.cancelOwnAsyncJobs(); - try { - await this.#host.sessionManager.newSession( - previousSessionFile ? { parentSession: previousSessionFile } : undefined, - ); - this.#host.markBashSessionTransition(bashTransition); - // The handoff opens a fresh conversation, so the spend of the one it - // summarizes stays with it. Clearing here, at the commit point, keeps the - // status line honest even if a later step throws. - this.#host.clearAdvisorCost(); - sessionTransitioned = true; - } finally { - this.#host.finishBashSessionTransition(bashTransition, sessionTransitioned); - } - - this.#host.clearSessionScopedToolState(); - - this.#host.clearCheckpointRuntimeState(); - // agent.reset() clears the core steering/follow-up queues. Preserve any queued - // steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a - // pre-loader TUI steer) so they survive into the post-handoff session instead of - // being silently dropped. Capture is synchronous immediately before reset and - // restore is synchronous immediately after — no await gap — so a steer arriving - // later (during ensureOnDisk/Bun.write below) appends to the restored queue - // rather than being clobbered. - const preservedSteering = this.#host.agent.peekSteeringQueue().slice(); - const preservedFollowUp = this.#host.agent.peekFollowUpQueue().slice(); - this.#host.agent.reset(); - this.#host.agent.replaceQueues(preservedSteering, preservedFollowUp); - this.#host.clearFreshProviderSessionId(); - this.#host.syncAgentSessionId(); - this.#host.rekeyMemoryForCurrentSessionId(); - await this.#host.resetMemoryContextForNewTranscript(); - this.#host.clearPendingNextTurnMessages(); - this.#host.resetTodoCycle(); - - // Carry local:// artifacts into the replacement session (best-effort: the - // switch is already committed, so a copy failure must not fail the handoff). - try { - const newLocalRoot = resolveLocalUrlToPath("local://", localProtocolOptions); - await copyLocalArtifacts(previousLocalRoot, newLocalRoot); - } catch (error) { - logger.warn("Failed to copy local artifacts into handoff session", { - error: error instanceof Error ? error.message : String(error), - }); - } - - // Inject the handoff document as a custom message - const handoffContent = createHandoffContext(handoffText); - this.#host.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent"); - await this.#host.sessionManager.ensureOnDisk(); let savedPath: string | undefined; if (options?.autoTriggered && this.#host.settings.get("compaction.handoffSaveToDisk")) { const artifactsDir = this.#host.sessionManager.getArtifactsDir(); @@ -336,20 +225,6 @@ export class SessionHandoff { } } - // Rebuild agent messages from session - const sessionContext = this.#host.buildDisplaySessionContext(); - this.#host.agent.replaceMessages(sessionContext.messages); - this.#host.resetAdvisorSessionState(); - advisorRecordersDetached = false; - this.#host.syncTodoPhasesFromBranch(); - if (this.#host.extensionRunner) { - await this.#host.extensionRunner.emit({ - type: "session_switch", - reason: "handoff", - previousSessionFile, - }); - } - return { document: handoffText, savedPath }; } catch (error) { // Only a genuine cancellation (user Esc or an unreasoned source-signal @@ -358,10 +233,6 @@ export class SessionHandoff { throwIfHandoffAborted(handoffSignal); throw error; } finally { - if (advisorRecordersDetached) { - if (sessionTransitioned) this.#host.resetAdvisorSessionState(); - else this.#host.reattachAdvisorRecorderFeeds(); - } sourceSignal?.removeEventListener("abort", onSourceAbort); this.#handoffAbortController = undefined; } diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 59247d6ab..4907c9c2a 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -14,12 +14,14 @@ import { AUTO_HANDOFF_THRESHOLD_FOCUS, applyShakeRegions, CompactionCancelledError, + type CompactionDetails, type CompactionPreparation, type CompactionResult, calculateContextTokens, collectShakeRegions, compact, compactionContextTokens, + computeFileLists, createCompactionSummaryMessage, DEFAULT_SHAKE_CONFIG, type CompactionSettings as EngineCompactionSettings, @@ -28,6 +30,7 @@ import { NativeCompactionError, prepareCompaction, RESCUE_SHAKE_CONFIG, + remotePreserveReusable, resolveBudgetReserveTokens, resolveThresholdTokens, type ShakeConfig, @@ -35,6 +38,7 @@ import { type SummaryOptions, shouldCompact, shouldUseProviderNativeCompaction, + upsertFileOperations, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, @@ -47,7 +51,7 @@ import type { AssistantMessage, CodexCompactionContext, Message, Model, Provider import * as AIError from "@oh-my-pi/pi-ai/error"; import { preferredDialect } from "@oh-my-pi/pi-catalog/identity"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, Snowflake } 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"; @@ -203,6 +207,47 @@ const PRUNE_IDLE_FLUSH_MS = 90 * 60_000; */ const COMPACTION_RECOVERY_BAND = 0.8; +/** + * Speculative-compaction lead: how far below the compaction threshold the + * background summarizer starts. Derived from the threshold instead of a second + * user-facing knob so the band scales with the window — a fixed percentage gap + * would be 200k tokens on a 1M model and useless on a 32k one. The floor keeps + * tiny windows from speculating every turn; the cap bounds how much history the + * armed summary misses (the kept tail grows by at most ~lead tokens between + * compute and apply). + */ +const SPECULATION_LEAD_FRACTION = 0.125; +const SPECULATION_LEAD_MIN_TOKENS = 8_192; +const SPECULATION_LEAD_MAX_TOKENS = 32_000; + +/** Tokens the threshold band spans: speculation fires inside `[threshold − lead, threshold)`. */ +function resolveSpeculationLeadTokens(thresholdTokens: number): number { + return Math.min( + SPECULATION_LEAD_MAX_TOKENS, + Math.max(SPECULATION_LEAD_MIN_TOKENS, Math.floor(thresholdTokens * SPECULATION_LEAD_FRACTION)), + ); +} + +/** A speculation-produced compaction result, ready to commit at threshold. */ +interface ArmedSpeculation { + result: CompactionResult; + action: "context-full" | "handoff" | "remote"; + method: CompactionMethod; + codexCompaction?: CodexCompactionContext; + /** Last branch entry covered by the speculated summary's source snapshot. */ + snapshotLeafId: string; + /** Context size when speculation started; drives refresh-on-growth. */ + contextTokensAtStart: number; +} + +/** One background speculative-compaction run and (once resolved) its armed result. */ +interface SpeculationRun { + controller: AbortController; + promise: Promise; + contextTokensAtStart: number; + armed?: ArmedSpeculation; +} + function mergeLlmCompactionPreserveData( hookPreserveData: Record | undefined, resultPreserveData: Record | undefined, @@ -211,6 +256,18 @@ function mergeLlmCompactionPreserveData( return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined); } +/** Wrap a handoff document as a compaction summary: append the cumulative file-operations tag and derive entry details. */ +function handoffSummaryFromDocument( + document: string, + preparation: CompactionPreparation, +): { summary: string; details: CompactionDetails } { + const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps); + return { + summary: upsertFileOperations(document, readFiles, modifiedFiles, preparation.fileOps.read), + details: { readFiles, modifiedFiles }, + }; +} + /** Capabilities borrowed from the owning AgentSession. */ export interface SessionMaintenanceHost { agent: Agent; @@ -283,7 +340,10 @@ export interface SessionMaintenanceHost { getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined; shake(mode: ShakeMode, options?: { config?: ShakeConfig; signal?: AbortSignal }): Promise; dropImages(): Promise<{ removed: number }>; - runHandoff(customInstructions?: string, options?: SessionHandoffOptions): Promise; + generateHandoffDocument( + customInstructions?: string, + options?: SessionHandoffOptions, + ): Promise; removeAssistantMessageFromActiveContext(message: AssistantMessage): void; dropPersistedAssistantTurn(message: AssistantMessage): Promise; runRecoveryCompactionWithRollback( @@ -323,6 +383,8 @@ export class SessionMaintenance { * persisted turn, but a new agent loop still gets its own live-array guard. */ #midTurnDeadEndPendingPrePrompt = false; + /** In-flight or armed background speculative compaction, if any. */ + #speculation: SpeculationRun | undefined; #skipPostTurnMaintenanceAssistantTimestamp: number | undefined; readonly #host: SessionMaintenanceHost; @@ -347,6 +409,21 @@ export class SessionMaintenance { return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined; } + /** Background speculative-compaction state, for UI indicators. */ + get speculationState(): "idle" | "running" | "armed" { + const run = this.#speculation; + if (!run) return "idle"; + return run.armed ? "armed" : "running"; + } + + /** Abort and discard any in-flight or armed speculative compaction. */ + cancelSpeculation(): void { + const run = this.#speculation; + if (!run) return; + this.#speculation = undefined; + run.controller.abort(); + } + /** Assistant timestamp whose post-turn maintenance must be skipped once. */ get skipPostTurnMaintenanceAssistantTimestamp(): number | undefined { return this.#skipPostTurnMaintenanceAssistantTimestamp; @@ -649,6 +726,9 @@ export class SessionMaintenance { if (ownsCompactionController) { this.#compactionAbortController = compactionAbortController; } + // A manual pass supersedes any background speculation; running both would + // double-bill the summarizer and race the commit. + this.cancelSpeculation(); try { if (ownsCompactionController) { @@ -931,7 +1011,8 @@ export class SessionMaintenance { throw new CompactionCancelledError(); } - this.#host.sessionManager.appendCompaction( + compactionCommitted = true; + await this.#commitCompactionEntry({ summary, shortSummary, firstKeptEntryId, @@ -939,36 +1020,9 @@ export class SessionMaintenance { details, fromExtension, preserveData, - ); - compactionCommitted = true; - const newEntries = this.#host.sessionManager.getEntries(); - const sessionContext = this.#host.buildDisplaySessionContext(); - this.#host.agent.replaceMessages(sessionContext.messages); - this.#host.rebaseAfterCompaction(); - // Compaction discarded the conversation history that carried the approved - // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads - // the plan from disk and re-injects it on the next turn (issue #1246). - this.#host.resetPlanReference(); - this.#host.resetAdvisorRuntimes("compact"); - this.#host.syncTodoPhasesFromBranch(); - if (codexCompaction) { - this.#host.resetCodexProviderAfterCompaction(codexCompaction); - } else { - this.#host.closeCodexProviderSessionsForHistoryRewrite(); - } - - // Get the saved compaction entry for the hook - const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as - | CompactionEntry - | undefined; - - if (this.#host.extensionRunner && savedCompactionEntry) { - await this.#host.extensionRunner.emit({ - type: "session_compact", - compactionEntry: savedCompactionEntry, - fromExtension, - }); - } + codexCompaction, + advisorResetReason: "compact", + }); const compactionResult: CompactionResult = { summary, @@ -1062,6 +1116,317 @@ export class SessionMaintenance { await this.runAutoCompaction("idle", false, true); } + /** + * Manual handoff: generate a handoff document and commit it as a compaction + * entry on the current session — the document becomes the summary and recent + * history is kept per `compaction.keepRecentTokens`. Unlike `/compact`, the + * live agent is not aborted; generation reads a snapshot of the live + * messages through the cache-friendly side-request pipeline. + */ + async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise { + if (this.isCompacting) throw new Error("Compaction already in progress"); + this.cancelSpeculation(); + const model = this.#model; + if (!model) throw new Error("No model selected for handoff"); + const entries = this.#host.sessionManager.getBranch(); + const messageCount = entries.filter(e => e.type === "message").length; + if (messageCount < 2) throw new Error("Nothing to hand off (no messages yet)"); + const compactionSettings = this.#host.settings.getGroup("compaction"); + const preparation = prepareCompaction( + entries, + resolveMethodSettings(compactionSettings, "handoff"), + model, + this.#tokenizer, + ); + if (!preparation) throw new Error("Nothing to hand off (already compacted)"); + const result = await this.#host.generateHandoffDocument(customInstructions, options); + if (!result) return undefined; + const { summary, details } = handoffSummaryFromDocument(result.document, preparation); + await this.#commitCompactionEntry({ + summary, + shortSummary: undefined, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details, + fromExtension: false, + preserveData: undefined, + codexCompaction: undefined, + advisorResetReason: "handoff", + }); + return result; + } + + /** + * Start a background speculative compaction when context has entered the + * pre-threshold band `[threshold − lead, threshold)`. The produced summary + * is held (armed) and committed instantly by the next real maintenance + * pass, hiding summarization latency. Only LLM-backed methods + * (remote/handoff/soft) are speculated — shake and snapcompact are local + * and effectively instant. Never rewrites history itself; stale results are + * discarded by apply-time branch validation in {@link #claimArmedSpeculation}. + */ + maybeStartSpeculativeCompaction(contextTokens: number, contextWindow: number): void { + if (contextWindow <= 0 || this.#host.isDisposed()) return; + const settings = this.#host.settings.getGroup("compaction"); + if (!settings.enabled || settings.asyncEnabled === false || !hasConfiguredCompactionMethod(settings)) return; + if (this.isCompacting || this.#host.isGeneratingHandoff()) return; + // Extensions that intercept compaction (cancel/replace) keep exact + // blocking semantics; a speculated result would bypass their veto. + if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) return; + const thresholdTokens = resolveThresholdTokens(contextWindow, settings); + if (contextTokens >= thresholdTokens) return; // real maintenance owns it now + if (thresholdTokens - contextTokens > resolveSpeculationLeadTokens(thresholdTokens)) return; + const current = this.#speculation; + if (current) { + if (!current.armed) return; // one run at a time + // Refresh-on-growth: the armed summary's kept tail grows with every + // turn; once the growth exceeds the keep-recent budget, a fresh cut + // reclaims materially more context at apply time. + const growth = contextTokens - current.armed.contextTokensAtStart; + const refreshBudget = Math.max(settings.keepRecentTokens, SPECULATION_LEAD_MIN_TOKENS); + if (growth <= refreshBudget && this.#armedSpeculationValid(current.armed)) return; + this.cancelSpeculation(); + } + const model = this.#model; + if (!model) return; + const method = this.#resolveSpeculationMethod(model, settings); + if (!method) return; + const controller = new AbortController(); + const run: SpeculationRun = { controller, promise: Promise.resolve(), contextTokensAtStart: contextTokens }; + this.#speculation = run; + run.promise = this.#runSpeculation(run, method, contextTokens).catch(error => { + logger.debug("Speculative compaction failed", { + method, + error: error instanceof Error ? error.message : String(error), + }); + if (this.#speculation === run) this.#speculation = undefined; + }); + } + + /** First configured method a threshold pass would run, or undefined when it is local (nothing to speculate). */ + #resolveSpeculationMethod( + model: Model, + settings: ConfiguredCompactionSettings, + ): "remote" | "handoff" | "soft" | undefined { + for (const candidate of resolveCompactionMethodOrder(settings.methodOrder)) { + const available = + candidate === "remote" + ? canUseRemoteCompaction(model, resolveMethodSettings(settings, candidate)) + : candidate === "snapcompact" + ? model.input.includes("image") + : true; + if (!available) continue; + return candidate === "remote" || candidate === "handoff" || candidate === "soft" ? candidate : undefined; + } + return undefined; + } + + /** Produce and arm one speculative compaction result off a branch snapshot. */ + async #runSpeculation( + run: SpeculationRun, + method: "remote" | "handoff" | "soft", + contextTokens: number, + ): Promise { + const clear = () => { + if (this.#speculation === run) this.#speculation = undefined; + }; + const model = this.#model; + if (!model) return clear(); + const settings = this.#host.settings.getGroup("compaction"); + const effectiveSettings = resolveMethodSettings(settings, method); + const branch = this.#host.sessionManager.getBranch(); + const snapshotLeafId = branch[branch.length - 1]?.id; + if (!snapshotLeafId) return clear(); + const preparation = prepareCompaction(branch, effectiveSettings, model, this.#tokenizer); + if (!preparation) return clear(); + const signal = run.controller.signal; + let armed: ArmedSpeculation; + if (method === "handoff") { + const generated = await this.#host.generateHandoffDocument(AUTO_HANDOFF_THRESHOLD_FOCUS, { + autoTriggered: true, + signal, + }); + if (!generated) return clear(); + const { summary, details } = handoffSummaryFromDocument(generated.document, preparation); + armed = { + result: { + summary, + shortSummary: undefined, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details, + }, + action: "handoff", + method, + snapshotLeafId, + contextTokensAtStart: contextTokens, + }; + } else { + const compactionPrep = await this.#prepareCompactionFromHooks(preparation, undefined); + // No hookCompaction is passed above, so "fromHook" is unreachable; + // the guard just narrows the union. + if (compactionPrep.kind === "fromHook") return clear(); + const candidates = this.#getCompactionModelCandidates( + this.#host.modelRegistry.getAvailable(), + method === "remote" && !effectiveSettings.remoteEndpoint + ? candidate => + candidate.provider === model.provider && + shouldUseProviderNativeCompaction(candidate, effectiveSettings) + : undefined, + ); + if (candidates.length === 0) return clear(); + const codexCompaction = createCodexCompactionContext({ + trigger: "auto", + reason: "context_limit", + phase: "standalone_turn", + }); + const result = await this.#compactWithFallbackModel( + preparation, + undefined, + signal, + { + promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt), + extraContext: compactionPrep.hookContext, + remoteInstructions: this.#host.baseSystemPrompt().join("\n\n"), + codexCompaction, + // Isolate from the live turn: remote compaction transports key + // sticky provider sessions by sessionId, and a speculation + // overlapping the live stream must never interleave with it. + sessionId: `${this.#host.sessionId()}:spec:${Snowflake.next()}`, + preferWebsockets: false, + }, + candidates, + ); + armed = { + result: { + ...result, + preserveData: mergeLlmCompactionPreserveData(compactionPrep.preserveData, result.preserveData), + }, + action: method === "remote" ? "remote" : "context-full", + method, + codexCompaction, + snapshotLeafId, + contextTokensAtStart: contextTokens, + }; + } + if (signal.aborted || this.#speculation !== run) return; + run.armed = armed; + logger.debug("Speculative compaction armed", { + method, + snapshotLeafId, + tokensBefore: armed.result.tokensBefore, + }); + } + + /** + * An armed result is committable only when the branch prefix it summarized + * is still intact: its snapshot leaf is on the active path with no later + * compaction or reset boundary, and any provider-native replay payload is + * still readable by the active model. + */ + #armedSpeculationValid(armed: ArmedSpeculation): boolean { + const model = this.#model; + if (!model) return false; + const settings = this.#host.settings.getGroup("compaction"); + if ( + armed.result.preserveData && + !remotePreserveReusable(armed.result.preserveData, model, resolveMethodSettings(settings, armed.method)) + ) { + return false; + } + const branch = this.#host.sessionManager.getBranch(); + const leafIdx = branch.findIndex(entry => entry.id === armed.snapshotLeafId); + if (leafIdx < 0) return false; + for (let i = leafIdx + 1; i < branch.length; i++) { + const type = branch[i].type; + if (type === "compaction" || type === "reset_boundary") return false; + } + return true; + } + + /** + * Consume the speculation slot for a real maintenance pass. An in-flight run + * is aborted (the real pass supersedes it); an armed result is returned only + * when still valid for the current branch, model, and settings. + */ + #claimArmedSpeculation(): ArmedSpeculation | undefined { + const run = this.#speculation; + if (!run) return undefined; + this.#speculation = undefined; + if (!run.armed) { + run.controller.abort(); + return undefined; + } + const settings = this.#host.settings.getGroup("compaction"); + if (settings.asyncEnabled === false) return undefined; + if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) return undefined; + return this.#armedSpeculationValid(run.armed) ? run.armed : undefined; + } + + /** + * Append a compaction entry and run the shared post-commit sequence: + * rebuild the display context, swap live agent messages, re-anchor stats, + * reset plan/advisor/todo runtime state derived from the replaced history, + * reset provider sessions, and emit the `session_compact` extension hook. + */ + async #commitCompactionEntry(args: { + summary: string; + shortSummary: string | undefined; + firstKeptEntryId: string; + tokensBefore: number; + details: unknown; + fromExtension: boolean; + preserveData: Record | undefined; + codexCompaction: CodexCompactionContext | undefined; + advisorResetReason: string; + detachExtensionEmit?: boolean; + }): Promise { + const entryId = this.#host.sessionManager.appendCompaction( + args.summary, + args.shortSummary, + args.firstKeptEntryId, + args.tokensBefore, + args.details, + args.fromExtension, + args.preserveData, + ); + const newEntries = this.#host.sessionManager.getEntries(); + const sessionContext = this.#host.buildDisplaySessionContext(); + this.#host.agent.replaceMessages(sessionContext.messages); + this.#host.rebaseAfterCompaction(); + // Compaction discarded the conversation history that carried the approved + // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads + // the plan from disk and re-injects it on the next turn (issue #1246). + this.#host.resetPlanReference(); + this.#host.resetAdvisorRuntimes(args.advisorResetReason); + this.#host.syncTodoPhasesFromBranch(); + if (args.codexCompaction) { + this.#host.resetCodexProviderAfterCompaction(args.codexCompaction); + } else { + this.#host.closeCodexProviderSessionsForHistoryRewrite(); + } + const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.id === entryId) as + | CompactionEntry + | undefined; + if (this.#host.extensionRunner && savedCompactionEntry) { + const compactEmit = this.#host.extensionRunner.emit({ + type: "session_compact", + compactionEntry: savedCompactionEntry, + fromExtension: args.fromExtension, + }); + if (args.detachExtensionEmit) { + void compactEmit.catch(error => { + logger.warn("Detached session_compact emit failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } else { + await compactEmit; + } + } + return savedCompactionEntry; + } + /** * Local token estimate of the stored conversation (plus any pending messages), * independent of provider-reported usage. A `before_provider_request` hook @@ -1106,7 +1471,10 @@ export class SessionMaintenance { const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow); const pendingMidTurnDeadEnd = this.#midTurnDeadEndPendingPrePrompt; this.#midTurnDeadEndPendingPrePrompt = false; - if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return; + if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) { + this.maybeStartSpeculativeCompaction(contextTokens, contextWindow); + return; + } if ( pendingMidTurnDeadEnd && prepareCompaction(this.#host.sessionManager.getBranch(), compactionSettings, model, this.#tokenizer) === @@ -1150,9 +1518,7 @@ export class SessionMaintenance { * are already paired in `activeMessages`, the live array the agent loop reads * 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; the handoff - * preference is skipped in favor of the next in-place method. + * append path yet. */ async maintainContextMidRun( activeMessages: AgentMessage[], @@ -1194,7 +1560,10 @@ export class SessionMaintenance { const billedContextTokens = calculateContextTokens(lastAssistant.usage); const storedContextTokens = this.#estimateStoredContextTokens(); const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens); - if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return; + if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) { + this.maybeStartSpeculativeCompaction(contextTokens, contextWindow); + return; + } if (!(await this.#host.persistTurnMessagesForMidRunCompaction(context))) return; if (this.#midTurnCompactionDeadEnds.has(activeMessages)) { @@ -1237,7 +1606,6 @@ export class SessionMaintenance { const result = await this.runAutoCompaction("threshold", false, false, false, { autoContinue: false, suppressContinuation: true, - suppressHandoff: true, triggerContextTokens: contextTokens, phase: "mid_turn", detachPostCommit: true, @@ -1501,6 +1869,8 @@ export class SessionMaintenance { contextWindow, model: `${assistantMessage.provider}/${assistantMessage.model}`, }); + } else { + this.maybeStartSpeculativeCompaction(contextTokens, contextWindow); } return COMPACTION_CHECK_NONE; } @@ -2250,7 +2620,6 @@ export class SessionMaintenance { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean; - suppressHandoff?: boolean; phase?: CodexCompactionContext["phase"]; terminalTextAnswer?: boolean; /** Mid-turn: splice history then return; do not await UI/extension fan-out. */ @@ -2271,7 +2640,6 @@ export class SessionMaintenance { const suppressContinuation = options.suppressContinuation === true; const shouldAutoContinue = !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false; - const suppressHandoff = options.suppressHandoff === true; const startIndex = options.methodIndex ?? 0; let methodIndex = -1; let method: CompactionMethod | undefined; @@ -2283,7 +2651,7 @@ export class SessionMaintenance { : candidate === "snapcompact" ? this.#model?.input.includes("image") === true : candidate === "handoff" - ? reason !== "overflow" && !suppressHandoff + ? reason !== "overflow" : true; if (!available) continue; method = candidate; @@ -2292,12 +2660,18 @@ export class SessionMaintenance { } if (!method) return COMPACTION_CHECK_NONE; + // A speculative pass may have already produced this compaction's summary + // in the background. Claiming consumes the slot either way: an in-flight + // run is aborted (this real pass supersedes it) and an armed result is + // returned only when still valid for the current branch/model/settings. + const armedSpec = this.#claimArmedSpeculation(); + 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") { + if (method === "shake" && !armedSpec) { const outcome = await this.#runAutoShake( reason, willRetry, @@ -2320,6 +2694,7 @@ export class SessionMaintenance { // triggered by the idle loop and does its own scheduling. if ( method === "handoff" && + !armedSpec && !deferred && allowDefer && reason !== "overflow" && @@ -2345,13 +2720,14 @@ export class SessionMaintenance { } const action: "context-full" | "handoff" | "snapcompact" | "remote" = - method === "remote" + armedSpec?.action ?? + (method === "remote" ? "remote" : method === "snapcompact" ? "snapcompact" : method === "handoff" ? "handoff" - : "context-full"; + : "context-full"); // Abort any older auto-compaction before installing this run's controller. this.#autoCompactionAbortController?.abort(); const autoCompactionAbortController = new AbortController(); @@ -2366,74 +2742,40 @@ export class SessionMaintenance { // queue, not the core steering queue (which handoff's agent.reset() would wipe). const startEvent = { type: "auto_compaction_start" as const, reason, action }; await this.#emitLifecycleEvent(startEvent, false); - if (action === "handoff") { - let handoffSwitchCancelled = false; - const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; - const handoffResult = await this.#host.runHandoff(handoffFocus, { - autoTriggered: true, - signal: autoCompactionSignal, - onSwitchCancelled: () => { - handoffSwitchCancelled = true; + if (armedSpec) { + // A background speculation already produced this compaction's + // summary; splice it in instead of paying for a blocking + // summarization. tokensBefore reflects the live trigger size when + // known — the armed value measured the smaller prefix at compute + // time. + logger.debug("Applying armed speculative compaction", { + method: armedSpec.method, + action, + reason, + }); + return await this.#commitAutoCompactionResult({ + summary: armedSpec.result.summary, + shortSummary: armedSpec.result.shortSummary, + firstKeptEntryId: armedSpec.result.firstKeptEntryId, + tokensBefore: options.triggerContextTokens ?? armedSpec.result.tokensBefore, + details: armedSpec.result.details, + preserveData: armedSpec.result.preserveData, + fromExtension: false, + codexCompaction: armedSpec.codexCompaction, + action, + reason, + willRetry, + generation, + shouldAutoContinue, + terminalTextAnswer, + suppressContinuation, + fallbackFromShake, + detachPostCommit: options.detachPostCommit === true, + autoCompactionSignal, + onCommitted: () => { + compactionCommitted = true; }, }); - if (!handoffResult) { - const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled; - if (aborted) { - await this.#emitLifecycleEvent( - { - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }, - options.detachPostCommit === true, - ); - return COMPACTION_CHECK_NONE; - } - logger.warn("Auto-handoff returned no document; trying next preferred compaction method", { - reason, - }); - 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( - { - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - }, - options.detachPostCommit === true, - ); - const continuationScheduled = - !autoCompactionSignal.aborted && - this.#host.scheduleCompactionContinuation({ - generation, - autoContinue: reason !== "idle" && shouldAutoContinue, - terminalTextAnswer, - suppressContinuation, - }); - return { - ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE), - historyRewritten: true, - }; - } } if (!this.#model) { @@ -2637,6 +2979,51 @@ export class SessionMaintenance { const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); + // Handoff runs as a summary source: generate the document off the live + // context (cache-friendly side request), then commit it like any other + // compaction summary. A failed generation advances to the next + // configured preference. + let handoffDocument: HandoffResult | undefined; + if (action === "handoff" && compactionPrep.kind !== "fromHook") { + handoffDocument = await this.#host.generateHandoffDocument(AUTO_HANDOFF_THRESHOLD_FOCUS, { + autoTriggered: true, + signal: autoCompactionSignal, + }); + if (autoCompactionSignal.aborted) { + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); + return COMPACTION_CHECK_NONE; + } + if (!handoffDocument) { + logger.warn("Auto-handoff returned no document; trying next preferred compaction method", { + reason, + }); + 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, + }); + } + } + let summary: string; let shortSummary: string | undefined; let firstKeptEntryId: string; @@ -2752,6 +3139,14 @@ export class SessionMaintenance { tokensBefore = compactionPrep.tokensBefore; details = compactionPrep.details; preserveData = compactionPrep.preserveData; + } else if (handoffDocument) { + const handoffSummary = handoffSummaryFromDocument(handoffDocument.document, preparation); + summary = handoffSummary.summary; + shortSummary = undefined; + firstKeptEntryId = preparation.firstKeptEntryId; + tokensBefore = preparation.tokensBefore; + details = handoffSummary.details; + preserveData = compactionPrep.preserveData; } else if (snapcompactResult) { summary = snapcompactResult.summary; shortSummary = snapcompactResult.shortSummary; @@ -2944,189 +3339,29 @@ export class SessionMaintenance { preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, compactResult.preserveData); } - if (autoCompactionSignal.aborted) { - await this.#emitLifecycleEvent( - { - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }, - options.detachPostCommit === true, - ); - return COMPACTION_CHECK_NONE; - } - - this.#host.sessionManager.appendCompaction( + return await this.#commitAutoCompactionResult({ summary, shortSummary, firstKeptEntryId, tokensBefore, details, - fromExtension, preserveData, - ); - compactionCommitted = true; - const newEntries = this.#host.sessionManager.getEntries(); - const sessionContext = this.#host.buildDisplaySessionContext(); - this.#host.agent.replaceMessages(sessionContext.messages); - this.#host.rebaseAfterCompaction(); - // Compaction discarded the conversation history that carried the approved - // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads - // the plan from disk and re-injects it on the next turn (issue #1246). - this.#host.resetPlanReference(); - this.#host.resetAdvisorRuntimes("auto-compaction"); - this.#host.syncTodoPhasesFromBranch(); - if (codexCompaction) { - this.#host.resetCodexProviderAfterCompaction(codexCompaction); - } else { - this.#host.closeCodexProviderSessionsForHistoryRewrite(); - } - - // Get the saved compaction entry for the hook - const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as - | CompactionEntry - | undefined; - - if (this.#host.extensionRunner && savedCompactionEntry) { - const compactEmit = this.#host.extensionRunner.emit({ - type: "session_compact", - compactionEntry: savedCompactionEntry, - fromExtension, - }); - if (options.detachPostCommit) { - void compactEmit.catch(error => { - logger.warn("Detached session_compact emit failed", { - error: error instanceof Error ? error.message : String(error), - }); - }); - } else { - await compactEmit; - } - } - - const result: CompactionResult = { - summary, - shortSummary, - firstKeptEntryId, - tokensBefore, - details, - preserveData: snapcompact.stripPreservedArchive(preserveData), - }; - // Post-maintenance progress guard — evaluated BEFORE emitting - // auto_compaction_end so the TUI rebuild triggered by that event - // already reflects any rescue rewrite (elide / image-drop) and the - // dead-end warning stamped on the compaction entry. Snapcompact can - // project over budget and fall back to a context-full summary; the - // summarizer keeps `keepRecentTokens` of recent history verbatim and - // findCutPoint can only cut at turn boundaries (never tool results), - // so a single oversized recent turn (e.g. a huge tool result) leaves - // the rewritten context still above threshold. Scheduling the - // continuation regardless means the next agent_end re-enters - // checkCompaction over the same oversized tail and re-fires forever. - // The retry and the threshold auto-continue use different progress - // tests (a recoverable overflow only has to fit; the auto-continue - // thrash needs the stricter recovery band), so each branch evaluates - // its own below. - let continuationScheduled = false; - // A non-idle pass that wanted to continue (retry or auto-continue) but freed - // too little for that path to proceed is a dead-end: warn once so the user - // understands why maintenance paused instead of silently looping. - let noProgressDeadEnd = false; - let retryFits = false; - let hasHeadroom = false; - - if (willRetry) { - const messages = this.#host.agent.state.messages; - const lastMsg = messages[messages.length - 1]; - if (lastMsg?.role === "assistant") { - const lastAssistant = lastMsg as AssistantMessage; - // Drop the prior turn before retry when it carries no actionable deliverable: - // - "error": failure was kept in history but must not re-enter the next turn's prompt. - // - reason === "incomplete" && stopReason === "length": truncated output (typically - // reasoning-only) — re-running it produces the same dead-end. - const shouldDrop = - lastAssistant.stopReason === "error" || - (reason === "incomplete" && lastAssistant.stopReason === "length"); - if (shouldDrop) { - this.#host.agent.replaceMessages(messages.slice(0, -1)); - this.#host.rebaseAfterCompaction(); - } - } - - // Retry only needs the rebuilt prompt to fit the window again — measured - // AFTER the drop above so the just-failed turn (which the retry prompt - // won't include) is excluded. Reusing the auto-continue recovery band - // here turned recoverable overflows into manual dead-ends (#3412 review), - // so use the looser fit budget. - retryFits = this.#compactionCreatedRetryFit(); - if (!retryFits) { - retryFits = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { - skipElide: fallbackFromShake, - hasProgress: () => this.#compactionCreatedRetryFit(), - }); - } - if (!retryFits) { - noProgressDeadEnd = true; - } - } else if (reason !== "idle") { - // Mirror the shake recovery-band check: only auto-continue when compaction - // landed residual context under `COMPACTION_RECOVERY_BAND × threshold`. - // Re-firing on a history that still sits just over the line is the - // snapcompact thrash, so require genuine headroom, not a bare fit. Even - // when auto-continue is disabled, a no-headroom threshold pass must still - // block later automatic continuations (todo reminders/session_stop hooks) - // from re-entering the same oversized context. - hasHeadroom = this.#compactionCreatedHeadroom(); - if (!hasHeadroom) { - hasHeadroom = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { - skipElide: fallbackFromShake, - hasProgress: () => this.#compactionCreatedHeadroom(), - }); - } - if (!hasHeadroom) { - noProgressDeadEnd = true; - } - } - - const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined; - if (deadEndWarning) { - // Stamp the divider: the compaction bar badges the dead-end and - // carries the full warning in its ctrl+o detail, so the pause - // stays explained even after the notice row scrolls away. Stamp - // the branch's LATEST compaction entry — a frame rescue may have - // superseded `savedCompactionEntry` with a rebuilt one, and the - // collapsed transcript badges only the active entry. - const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()) ?? savedCompactionEntry; - if (stampEntry) { - stampEntry.warning = deadEndWarning; - await this.#host.sessionManager.rewriteEntries(); - } - } - - await this.#emitLifecycleEvent( - { type: "auto_compaction_end", action, result, aborted: false, willRetry }, - options.detachPostCommit === true, - ); - - if (retryFits) { - this.#host.scheduleAgentContinue({ delayMs: 100, generation }); - continuationScheduled = true; - } else { - continuationScheduled = this.#host.scheduleCompactionContinuation({ - generation, - autoContinue: hasHeadroom && shouldAutoContinue, - terminalTextAnswer, - suppressContinuation, - }); - } - - if (deadEndWarning) { - this.#host.emitNotice("warning", deadEndWarning, "compaction"); - } - if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION; - return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; + fromExtension, + codexCompaction, + action, + reason, + willRetry, + generation, + shouldAutoContinue, + terminalTextAnswer, + suppressContinuation, + fallbackFromShake, + detachPostCommit: options.detachPostCommit === true, + autoCompactionSignal, + onCommitted: () => { + compactionCommitted = true; + }, + }); } catch (error) { if (autoCompactionSignal.aborted) { await this.#emitLifecycleEvent( @@ -3188,6 +3423,186 @@ export class SessionMaintenance { return COMPACTION_CHECK_NONE; } + /** + * Shared auto-maintenance commit tail: append the compaction entry, splice + * the rebuilt context into the live agent, run the post-commit progress + * checks (retry fit / recovery band with the tiered dead-end rescue), emit + * `auto_compaction_end`, and schedule the follow-up turn. Used by both the + * blocking production path and the armed speculative-apply path. + */ + async #commitAutoCompactionResult(args: { + summary: string; + shortSummary: string | undefined; + firstKeptEntryId: string; + tokensBefore: number; + details: unknown; + preserveData: Record | undefined; + fromExtension: boolean; + codexCompaction: CodexCompactionContext | undefined; + action: "context-full" | "handoff" | "snapcompact" | "remote"; + reason: "overflow" | "threshold" | "idle" | "incomplete"; + willRetry: boolean; + generation: number; + shouldAutoContinue: boolean; + terminalTextAnswer: boolean; + suppressContinuation: boolean; + fallbackFromShake: boolean; + detachPostCommit: boolean; + autoCompactionSignal: AbortSignal; + onCommitted: () => void; + }): Promise { + const { action, reason, willRetry, detachPostCommit, autoCompactionSignal } = args; + if (autoCompactionSignal.aborted) { + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + detachPostCommit, + ); + return COMPACTION_CHECK_NONE; + } + + args.onCommitted(); + const savedCompactionEntry = await this.#commitCompactionEntry({ + summary: args.summary, + shortSummary: args.shortSummary, + firstKeptEntryId: args.firstKeptEntryId, + tokensBefore: args.tokensBefore, + details: args.details, + fromExtension: args.fromExtension, + preserveData: args.preserveData, + codexCompaction: args.codexCompaction, + advisorResetReason: "auto-compaction", + detachExtensionEmit: detachPostCommit, + }); + + const result: CompactionResult = { + summary: args.summary, + shortSummary: args.shortSummary, + firstKeptEntryId: args.firstKeptEntryId, + tokensBefore: args.tokensBefore, + details: args.details, + preserveData: snapcompact.stripPreservedArchive(args.preserveData), + }; + // Post-maintenance progress guard — evaluated BEFORE emitting + // auto_compaction_end so the TUI rebuild triggered by that event + // already reflects any rescue rewrite (elide / image-drop) and the + // dead-end warning stamped on the compaction entry. Snapcompact can + // project over budget and fall back to a context-full summary; the + // summarizer keeps `keepRecentTokens` of recent history verbatim and + // findCutPoint can only cut at turn boundaries (never tool results), + // so a single oversized recent turn (e.g. a huge tool result) leaves + // the rewritten context still above threshold. Scheduling the + // continuation regardless means the next agent_end re-enters + // checkCompaction over the same oversized tail and re-fires forever. + // The retry and the threshold auto-continue use different progress + // tests (a recoverable overflow only has to fit; the auto-continue + // thrash needs the stricter recovery band), so each branch evaluates + // its own below. + let continuationScheduled = false; + // A non-idle pass that wanted to continue (retry or auto-continue) but freed + // too little for that path to proceed is a dead-end: warn once so the user + // understands why maintenance paused instead of silently looping. + let noProgressDeadEnd = false; + let retryFits = false; + let hasHeadroom = false; + + if (willRetry) { + const messages = this.#host.agent.state.messages; + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant") { + const lastAssistant = lastMsg as AssistantMessage; + // Drop the prior turn before retry when it carries no actionable deliverable: + // - "error": failure was kept in history but must not re-enter the next turn's prompt. + // - reason === "incomplete" && stopReason === "length": truncated output (typically + // reasoning-only) — re-running it produces the same dead-end. + const shouldDrop = + lastAssistant.stopReason === "error" || + (reason === "incomplete" && lastAssistant.stopReason === "length"); + if (shouldDrop) { + this.#host.agent.replaceMessages(messages.slice(0, -1)); + this.#host.rebaseAfterCompaction(); + } + } + + // Retry only needs the rebuilt prompt to fit the window again — measured + // AFTER the drop above so the just-failed turn (which the retry prompt + // won't include) is excluded. Reusing the auto-continue recovery band + // here turned recoverable overflows into manual dead-ends (#3412 review), + // so use the looser fit budget. + retryFits = this.#compactionCreatedRetryFit(); + if (!retryFits) { + retryFits = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { + skipElide: args.fallbackFromShake, + hasProgress: () => this.#compactionCreatedRetryFit(), + }); + } + if (!retryFits) { + noProgressDeadEnd = true; + } + } else if (reason !== "idle") { + // Mirror the shake recovery-band check: only auto-continue when compaction + // landed residual context under `COMPACTION_RECOVERY_BAND × threshold`. + // Re-firing on a history that still sits just over the line is the + // snapcompact thrash, so require genuine headroom, not a bare fit. Even + // when auto-continue is disabled, a no-headroom threshold pass must still + // block later automatic continuations (todo reminders/session_stop hooks) + // from re-entering the same oversized context. + hasHeadroom = this.#compactionCreatedHeadroom(); + if (!hasHeadroom) { + hasHeadroom = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { + skipElide: args.fallbackFromShake, + hasProgress: () => this.#compactionCreatedHeadroom(), + }); + } + if (!hasHeadroom) { + noProgressDeadEnd = true; + } + } + + const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined; + if (deadEndWarning) { + // Stamp the divider: the compaction bar badges the dead-end and + // carries the full warning in its ctrl+o detail, so the pause + // stays explained even after the notice row scrolls away. Stamp + // the branch's LATEST compaction entry — a frame rescue may have + // superseded `savedCompactionEntry` with a rebuilt one, and the + // collapsed transcript badges only the active entry. + const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()) ?? savedCompactionEntry; + if (stampEntry) { + stampEntry.warning = deadEndWarning; + await this.#host.sessionManager.rewriteEntries(); + } + } + + await this.#emitLifecycleEvent( + { type: "auto_compaction_end", action, result, aborted: false, willRetry }, + detachPostCommit, + ); + + if (retryFits) { + this.#host.scheduleAgentContinue({ delayMs: 100, generation: args.generation }); + continuationScheduled = true; + } else { + continuationScheduled = this.#host.scheduleCompactionContinuation({ + generation: args.generation, + autoContinue: hasHeadroom && args.shouldAutoContinue, + terminalTextAnswer: args.terminalTextAnswer, + suppressContinuation: args.suppressContinuation, + }); + } + + if (deadEndWarning) { + this.#host.emitNotice("warning", deadEndWarning, "compaction"); + } + if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION; + return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; + } + /** * Run a shake-method auto-maintenance pass. Emits the * `auto_compaction_start`/`auto_compaction_end` pair with a shake `action`, diff --git a/packages/coding-agent/test/advisor-toggle.test.ts b/packages/coding-agent/test/advisor-toggle.test.ts index 7193551ad..91f4bab5e 100644 --- a/packages/coding-agent/test/advisor-toggle.test.ts +++ b/packages/coding-agent/test/advisor-toggle.test.ts @@ -623,57 +623,21 @@ describe("AgentSession advisor toggle", () => { await session.dispose(); expect((await loadAdvisorTranscriptCosts(previousSessionFile)).get("")).toBeCloseTo(0.75, 8); }); - it("clears advisor cost when a handoff opens the replacement session", async () => { + it("resets advisor runtimes after an in-place handoff compaction", async () => { vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("## Goal\nContinue from here"); - try { - const advisor = enableAdvisor(); - prepareHandoffConversation(advisor); - const previousSessionFile = session.sessionFile; - const newSession = sessionManager.newSession.bind(sessionManager); - vi.spyOn(sessionManager, "newSession").mockImplementation(async options => { - const result = await newSession(options); - // The outgoing advisor finalizes after the replacement file is selected. - appendAdvisorCost(advisor, 9, 3); - return result; - }); + const advisor = enableAdvisor(); + prepareHandoffConversation(advisor); + session.settings.set("compaction.keepRecentTokens", 1); + const sessionFile = session.sessionFile; - await session.handoff(); + const result = await session.handoff(); - // The handoff hands the work over to a fresh conversation, so the spend of - // the one it summarizes must not follow it. - expect(session.sessionFile).not.toBe(previousSessionFile); - expect(session.getAdvisorCost()).toBe(0); - const replacementSessionFile = session.sessionFile; - if (!replacementSessionFile) throw new Error("Expected the replacement session to be persisted"); - appendAdvisorCost(advisor, 0.25, 4); - expect(session.getAdvisorCost()).toBeCloseTo(0.25, 8); - await session.dispose(); - expect((await loadAdvisorTranscriptCosts(replacementSessionFile)).get("")).toBeCloseTo(0.25, 8); - } finally { - vi.restoreAllMocks(); - } - }); - it("restores advisor recording when a handoff fails before replacing the session", async () => { - vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("## Goal\nContinue from here"); - try { - const advisor = enableAdvisor(); - prepareHandoffConversation(advisor); - const previousSessionFile = session.sessionFile; - if (!previousSessionFile) throw new Error("Expected the previous session to be persisted"); - const failure = new Error("replacement session failed"); - vi.spyOn(sessionManager, "newSession").mockRejectedValue(failure); - - await expect(session.handoff()).rejects.toThrow(failure); - - expect(session.sessionFile).toBe(previousSessionFile); - expect(session.getAdvisorCost()).toBeCloseTo(0.5, 8); - appendAdvisorCost(advisor, 0.25, 3); - expect(session.getAdvisorCost()).toBeCloseTo(0.75, 8); - await session.dispose(); - expect((await loadAdvisorTranscriptCosts(previousSessionFile)).get("")).toBeCloseTo(0.75, 8); - } finally { - vi.restoreAllMocks(); - } + expect(result?.document).toContain("Continue from here"); + expect(session.sessionFile).toBe(sessionFile); + const compaction = sessionManager.getBranch().at(-1); + expect(compaction).toMatchObject({ type: "compaction" }); + if (compaction?.type !== "compaction") throw new Error("Expected handoff compaction entry"); + expect(compaction.summary).toContain("Continue from here"); }); it("clears advisor cost when a branch skips conversation restore", async () => { const extensionRunner = { diff --git a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts index 709847306..a226cc052 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts @@ -50,6 +50,7 @@ describe("AgentSession auto-compaction progress guard", () => { let sessionManager: SessionManager; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; + let compactHookEnabled = true; const NOTICE_SOURCE = "compaction"; const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress"; @@ -61,6 +62,7 @@ describe("AgentSession auto-compaction progress guard", () => { }); beforeEach(() => { + compactHookEnabled = true; sessionManager = SessionManager.inMemory(); // The progress-guard tests exercise AgentSession's post-compaction state @@ -68,7 +70,7 @@ describe("AgentSession auto-compaction progress guard", () => { // while returning the same short-circuit result without compiling a // temporary extension for every test. const extensionRunner = { - hasHandlers: (type: string) => type === "session_before_compact", + hasHandlers: (type: string) => compactHookEnabled && type === "session_before_compact", emit: async (event: { type: string; preparation?: CompactionPreparation }) => { if (event.type !== "session_before_compact" || !event.preparation) return undefined; return { @@ -858,12 +860,34 @@ describe("AgentSession auto-compaction progress guard", () => { ); }); - it("does not restore a length stop after handoff recovery commits", async () => { + it("drops a length stop and retries after handoff recovery commits", async () => { session.settings.set("compaction.methodOrder", ["handoff", "soft"]); + session.settings.set("compaction.enabled", true); + session.settings.set("compaction.keepRecentTokens", 1); + compactHookEnabled = false; + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "completed seed" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "stop", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }); 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(); - const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue({ document: "handoff document" }); + const generateHandoffSpy = vi + .spyOn(compactionModule, "generateHandoffFromContext") + .mockResolvedValue("handoff document"); const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); session.subscribe(event => { @@ -887,15 +911,20 @@ describe("AgentSession auto-compaction progress guard", () => { }, timestamp: Date.now(), }; + sessionManager.appendMessage(assistantMsg); session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); await compactionDone; await session.waitForIdle(); - expect(promptSpy).toHaveBeenCalledTimes(1); - expect(handoffSpy).toHaveBeenCalledTimes(1); - expect(continueSpy).not.toHaveBeenCalled(); + expect(promptSpy).not.toHaveBeenCalled(); + expect(generateHandoffSpy).toHaveBeenCalledTimes(1); + expect(continueSpy).toHaveBeenCalledTimes(1); + expect(sessionManager.getBranch().at(-1)).toMatchObject({ + type: "compaction", + summary: "handoff document", + }); expect(sessionManager.getBranch()).not.toContainEqual( expect.objectContaining({ type: "message", diff --git a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts index e5cde2f73..2145f4df0 100644 --- a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts @@ -220,20 +220,6 @@ describe("AgentSession mid-run threshold compaction", () => { expect(observedContexts[1].join("\n")).toContain("ACTIVE-GOAL-MID-RUN-COMPACTED"); }); - it("falls back to in-place compaction for mid-run handoff strategy", async () => { - 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"); - }); - const compactSpy = mockCompaction("HANDOFF-MID-RUN-COMPACTED-IN-PLACE"); - - await session.prompt("work on the release"); - - expect(handoffSpy).not.toHaveBeenCalled(); - expect(compactSpy).toHaveBeenCalledTimes(1); - expect(observedContexts[1].join("\n")).toContain("HANDOFF-MID-RUN-COMPACTED-IN-PLACE"); - }); - it("does not wait for message persistence below the mid-run threshold", async () => { const releaseMessageEnd = Promise.withResolvers(); const messageEndEntered = Promise.withResolvers(); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 88d23fbd8..214a73337 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -105,6 +105,8 @@ describe("AgentSession handoff", () => { settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, + "compaction.asyncEnabled": false, + "compaction.keepRecentTokens": 1, }), modelRegistry, obfuscator, @@ -148,8 +150,10 @@ describe("AgentSession handoff", () => { vi.restoreAllMocks(); }); - it("does not run auto-compaction after handoff turn completes", async () => { + it("commits a handoff document as an in-place compaction", async () => { const handoffText = "## Goal\nContinue from here"; + const previousSessionFile = session.sessionFile; + const previousSessionId = session.sessionId; const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue(handoffText); @@ -159,143 +163,15 @@ describe("AgentSession handoff", () => { expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(result?.document).toBe(handoffText); - + expect(session.sessionFile).toBe(previousSessionFile); + expect(session.sessionId).toBe(previousSessionId); + const compaction = sessionManager.getBranch().at(-1); + expect(compaction).toMatchObject({ type: "compaction" }); + if (compaction?.type !== "compaction") throw new Error("Expected handoff compaction entry"); + expect(compaction.summary).toContain(handoffText); + expect(session.agent.state.messages.some(message => message.role === "compactionSummary")).toBe(true); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0); expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0); - expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); - }); - - it("clears staged preview state when handoff creates the replacement session", async () => { - vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("## Goal\nContinue from here"); - session.toolChoiceQueue.registerPendingInvoker("old-session-preview", "ast_edit", async () => ({ - content: [{ type: "text", text: "applied old preview" }], - })); - expect(session.peekPendingInvoker()).toBeDefined(); - expect(session.nextToolChoiceDirective()).toBeDefined(); - - await session.handoff(); - - expect(session.peekPendingInvoker()).toBeUndefined(); - expect(session.nextToolChoiceDirective()).toBeUndefined(); - }); - - it("carries local:// artifacts into the handed-off session", async () => { - // Handoff is a continuity operation: the generated document references - // plans/scratch files the old session wrote under its local:// root. The - // fresh session mints a new local root, so the artifacts must be copied - // forward or every reference the handoff document carries dangles. - vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("## Goal\nContinue from here"); - const localOptions = { - getArtifactsDir: () => sessionManager.getArtifactsDir(), - getSessionId: () => sessionManager.getSessionId(), - }; - const oldLocalRoot = resolveLocalUrlToPath("local://", localOptions); - const oldPlanPath = resolveLocalUrlToPath("local://my-plan.md", localOptions); - const oldNestedPath = resolveLocalUrlToPath("local://research/notes.txt", localOptions); - await fs.mkdir(path.dirname(oldNestedPath), { recursive: true }); - await Bun.write(oldPlanPath, "# Plan\n\nbody\n"); - await Bun.write(oldNestedPath, "scratch notes"); - - await session.handoff(); - - const newLocalRoot = resolveLocalUrlToPath("local://", localOptions); - expect(newLocalRoot).not.toBe(oldLocalRoot); - expect(await Bun.file(resolveLocalUrlToPath("local://my-plan.md", localOptions)).text()).toBe("# Plan\n\nbody\n"); - expect(await Bun.file(resolveLocalUrlToPath("local://research/notes.txt", localOptions)).text()).toBe( - "scratch notes", - ); - // The source session's artifacts remain untouched on disk. - expect(await Bun.file(oldPlanPath).text()).toBe("# Plan\n\nbody\n"); - }); - - it("emits handoff lifecycle hooks on the outgoing and replacement sessions", async () => { - // dispose() is terminal: it closes the manager and releases its in-memory - // transcript. Reopen the persisted session file for the replacement - // session, as production revival paths do. - await session.dispose(); - const sessionFile = sessionManager.getSessionFile(); - if (!sessionFile) throw new Error("Expected a persisted session file"); - sessionManager = await SessionManager.open(sessionFile, tempDir.path()); - const extensionsResult = await loadExtensions([], tempDir.path()); - const extensionRunner = new ExtensionRunner( - extensionsResult.extensions, - extensionsResult.runtime, - tempDir.path(), - sessionManager, - modelRegistry, - ); - const observedEvents: Array<{ - type: "session_before_switch" | "session_switch"; - reason: string; - previousSessionFile: string | undefined; - activeSessionFile: string | undefined; - messageCount: number; - handoffEntryCount: number; - }> = []; - vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch"); - const emit = extensionRunner.emit.bind(extensionRunner); - vi.spyOn(extensionRunner, "emit").mockImplementation(event => { - if (event.type === "session_before_switch" || event.type === "session_switch") { - observedEvents.push({ - type: event.type, - reason: event.reason, - previousSessionFile: event.type === "session_switch" ? event.previousSessionFile : undefined, - activeSessionFile: session.sessionFile, - messageCount: sessionManager.getBranch().filter(entry => entry.type === "message").length, - handoffEntryCount: sessionManager - .getBranch() - .filter(entry => entry.type === "custom_message" && entry.customType === "handoff").length, - }); - } - return emit(event); - }); - - session = new AgentSession({ - agent: new Agent({ - initialState: { - model, - systemPrompt: ["Test"], - tools: [], - messages: [], - }, - }), - sessionManager, - settings: Settings.isolated({ - "compaction.enabled": true, - "compaction.autoContinue": false, - }), - modelRegistry, - extensionRunner, - obfuscator, - }); - const previousSessionFile = session.sessionFile; - const generateHandoffSpy = vi - .spyOn(compactionModule, "generateHandoffFromContext") - .mockResolvedValue("## Goal\nContinue from here"); - - await session.handoff(); - - const nextSessionFile = session.sessionFile; - expect(generateHandoffSpy).toHaveBeenCalledTimes(1); - expect(nextSessionFile).not.toBe(previousSessionFile); - expect(observedEvents).toEqual([ - { - type: "session_before_switch", - reason: "handoff", - previousSessionFile: undefined, - activeSessionFile: previousSessionFile, - messageCount: 2, - handoffEntryCount: 0, - }, - { - type: "session_switch", - reason: "handoff", - previousSessionFile, - activeSessionFile: nextSessionFile, - messageCount: 0, - handoffEntryCount: 1, - }, - ]); }); it("runs handoff generation through the configured side stream function", async () => { @@ -345,6 +221,8 @@ describe("AgentSession handoff", () => { settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, + "compaction.asyncEnabled": false, + "compaction.keepRecentTokens": 1, }), modelRegistry, obfuscator, @@ -371,101 +249,6 @@ describe("AgentSession handoff", () => { expect(capturedSideSessionId).toStartWith(`${preHandoffSessionId}:side:`); }); - it("preserves queued steering and follow-up messages across the handoff reset", async () => { - // Defect 2: handoff() calls agent.reset(), which clears the core steering/follow-up - // queues. Steers/follow-ups already queued (the mis-routed first compaction message, - // or RPC/SDK steer()/followUp() issued during the handoff) must survive into the new - // session instead of being silently dropped. - vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("## Goal\nContinue"); - - const textOf = (message: AgentMessage): string => { - if (!("content" in message)) return ""; - const content = message.content; - if (typeof content === "string") return content; - const textBlock = content.find(block => block.type === "text"); - return textBlock?.type === "text" ? textBlock.text : ""; - }; - - const userMsg: AgentMessage = { - role: "user", - content: [{ type: "text", text: "keep-steer" }], - attribution: "user", - timestamp: Date.now(), - }; - // A hidden, user-attributed companion (e.g. an ultrathink notice). It is - // display:false, so isUserQueuedMessage(...) is false for it: preservation must - // keep it adjacent to its prompt rather than filter it out or reorder it. - const companionMsg: AgentMessage = { - role: "custom", - customType: "ultrathink-notice", - content: [{ type: "text", text: "companion" }], - attribution: "user", - display: false, - timestamp: Date.now(), - }; - const followUpMsg: AgentMessage = { - role: "user", - content: [{ type: "text", text: "keep-followup" }], - attribution: "user", - timestamp: Date.now(), - }; - session.agent.steer(userMsg); - session.agent.steer(companionMsg); - session.agent.followUp(followUpMsg); - expect(session.agent.hasQueuedMessages()).toBe(true); - - await session.handoff(); - - expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["keep-steer", "companion"]); - expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["keep-followup"]); - }); - - it("preserves steering and follow-up messages enqueued while the handoff is in flight", async () => { - // Defect 2 in-flight window: the queue snapshot must be captured immediately before - // agent.reset() (after generateHandoff resolves), NOT at handoff entry. A steer or - // follow-up issued WHILE the handoff document is still generating must survive the - // reset — proving capture happens late rather than at the start of handoff(). - const { promise: handoffDoc, resolve: releaseHandoff } = Promise.withResolvers(); - let generateHandoffCalled = false; - vi.spyOn(compactionModule, "generateHandoffFromContext").mockImplementation(async () => { - generateHandoffCalled = true; - return handoffDoc; - }); - - const textOf = (message: AgentMessage): string => { - if (!("content" in message)) return ""; - const content = message.content; - if (typeof content === "string") return content; - const textBlock = content.find(block => block.type === "text"); - return textBlock?.type === "text" ? textBlock.text : ""; - }; - - const handoffPromise = session.handoff(); - // Block until we are genuinely mid-handoff (document generation in flight). - await waitFor(() => generateHandoffCalled); - - // Enqueue AFTER generation started but BEFORE it resolves — the window where the old - // session is still live and agent.reset() has not yet fired. - session.agent.steer({ - role: "user", - content: [{ type: "text", text: "inflight-steer" }], - attribution: "user", - timestamp: Date.now(), - }); - session.agent.followUp({ - role: "user", - content: [{ type: "text", text: "inflight-followup" }], - attribution: "user", - timestamp: Date.now(), - }); - - releaseHandoff("## Goal\nContinue"); - await handoffPromise; - - expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["inflight-steer"]); - expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["inflight-followup"]); - }); - it("obfuscates custom instructions before generating a handoff", async () => { const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const generateHandoffSpy = vi @@ -967,6 +750,7 @@ describe("AgentSession handoff", () => { settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, + "compaction.asyncEnabled": false, "compaction.methodOrder": ["soft"], "compaction.thresholdTokens": 8_000, "contextPromotion.enabled": false, @@ -1302,51 +1086,25 @@ describe("AgentSession handoff", () => { expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0); }); - it("persists handoff session immediately with previous session as parent", async () => { - const previousSessionFile = session.sessionFile; - if (!previousSessionFile) { - throw new Error("Expected previous session file"); - } + it("persists the handoff compaction in the current session", async () => { + const sessionFile = session.sessionFile; + if (!sessionFile) throw new Error("Expected current session file"); const handoffText = "## Goal\nContinue from here"; vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue(handoffText); const result = await session.handoff(); - const handoffSessionFile = session.sessionFile; - if (!handoffSessionFile) { - throw new Error("Expected handoff session file"); - } - - type PersistedEntry = { - type?: string; - parentSession?: string; - customType?: string; - display?: boolean; - }; - const handoffEntries = (await Bun.file(handoffSessionFile).text()) - .trim() - .split("\n") - .map(line => JSON.parse(line) as PersistedEntry); + const entries = sessionManager.getBranch(); + const compaction = entries.at(-1); expect(result?.document).toBe(handoffText); - expect(session.getLastAssistantText()).toBeUndefined(); - expect(session.hasCopyCandidateAssistantMessage()).toBe(false); - expect(session.getLastVisibleHandoffText()).toBe( - `\n${handoffText}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`, - ); - expect(handoffSessionFile).not.toBe(previousSessionFile); - expect(handoffEntries.find(entry => entry.type === "session")).toMatchObject({ - type: "session", - parentSession: previousSessionFile, - }); - expect( - handoffEntries.some( - entry => entry.type === "custom_message" && entry.customType === "handoff" && entry.display, - ), - ).toBe(true); - - const previousSessionText = await Bun.file(previousSessionFile).text(); - expect(previousSessionText).toContain('"text":"seed"'); + expect(session.sessionFile).toBe(sessionFile); + expect(compaction).toMatchObject({ type: "compaction" }); + if (compaction?.type !== "compaction") throw new Error("Expected handoff compaction entry"); + expect(compaction.summary).toContain(handoffText); + expect(session.agent.state.messages.some(message => message.role === "compactionSummary")).toBe(true); + const persistedSessionText = await Bun.file(sessionFile).text(); + expect(persistedSessionText).toContain(JSON.stringify(handoffText)); }); it("does not run auto maintenance when strategy is off", async () => { @@ -1475,26 +1233,24 @@ describe("AgentSession handoff", () => { timestamp: Date.now(), }; - const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue({ document: "handoff document" }); + const generateHandoffSpy = vi + .spyOn(compactionModule, "generateHandoffFromContext") + .mockResolvedValue("handoff document"); session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await waitFor( () => - handoffSpy.mock.calls.length === 1 && + generateHandoffSpy.mock.calls.length === 1 && events.filter(event => event.type === "auto_compaction_end").length === 1, ); - expect(handoffSpy).toHaveBeenCalledTimes(1); - expect(handoffSpy).toHaveBeenCalledWith(expect.stringContaining("Threshold-triggered maintenance"), { - autoTriggered: true, - signal: expect.anything(), - onSwitchCancelled: expect.any(Function), - }); + expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(1); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); expect(endEvents[0]).toMatchObject({ type: "auto_compaction_end", aborted: false, willRetry: false }); + expect(sessionManager.getBranch().at(-1)).toMatchObject({ type: "compaction", summary: "handoff document" }); }); it("completes threshold-triggered auto-handoff while the original prompt is still unwinding", async () => { @@ -1607,7 +1363,7 @@ describe("AgentSession handoff", () => { expect(endEvents).toHaveLength(1); expect(endEvents[0]).toMatchObject({ type: "auto_compaction_end", action: "handoff", aborted: false }); expect(endEvents[0]).not.toMatchObject({ errorMessage: expect.any(String) }); - expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); + expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(1); }); it("does not start agent.continue when threshold-handoff defers and todos are incomplete", async () => { @@ -1629,9 +1385,9 @@ describe("AgentSession handoff", () => { throw new Error("Expected model to be set"); } - const handoffSpy = vi - .spyOn(session, "handoff") - .mockResolvedValue({ document: "## Goal\nContinue", savedPath: undefined }); + const generateHandoffSpy = vi + .spyOn(compactionModule, "generateHandoffFromContext") + .mockResolvedValue("## Goal\nContinue"); const continueSpy = vi.spyOn(session.agent, "continue"); const assistantMessage: AssistantMessage = { @@ -1654,12 +1410,11 @@ describe("AgentSession handoff", () => { session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); - await waitFor(() => handoffSpy.mock.calls.length === 1); + await waitFor(() => generateHandoffSpy.mock.calls.length === 1); await session.waitForIdle(); - expect(handoffSpy).toHaveBeenCalledTimes(1); - // The bug surfaced as agent.continue() racing the deferred handoff. With the fix, - // the agent_end handler short-circuits after the deferred-handoff signal. + expect(generateHandoffSpy).toHaveBeenCalledTimes(1); + expect(sessionManager.getBranch().at(-1)).toMatchObject({ type: "compaction", summary: "## Goal\nContinue" }); expect(continueSpy).not.toHaveBeenCalled(); }); @@ -1755,7 +1510,7 @@ describe("AgentSession handoff", () => { timestamp: Date.now(), }; - const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue(undefined); + const generateHandoffSpy = vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue(""); session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); @@ -1763,7 +1518,7 @@ describe("AgentSession handoff", () => { events.some(event => event.type === "auto_compaction_end" && event.action === "context-full"), ); - expect(handoffSpy).toHaveBeenCalledTimes(1); + expect(generateHandoffSpy).toHaveBeenCalledTimes(1); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(2); expect(endEvents[0]).toMatchObject({ @@ -1781,94 +1536,6 @@ describe("AgentSession handoff", () => { }); }); - it("treats a vetoed auto-handoff switch as cancelled instead of falling back", async () => { - session.settings.set("compaction.methodOrder", ["handoff", "soft"]); - session.settings.set("compaction.thresholdPercent", 1); - session.settings.set("contextPromotion.enabled", false); - - const model = session.model; - if (!model) { - throw new Error("Expected model to be set"); - } - - // See "emits handoff lifecycle hooks": reopen the persisted transcript - // after the terminal dispose before wiring the replacement session. - await session.dispose(); - const sessionFile = sessionManager.getSessionFile(); - if (!sessionFile) throw new Error("Expected a persisted session file"); - sessionManager = await SessionManager.open(sessionFile, tempDir.path()); - const extensionsResult = await loadExtensions([], tempDir.path()); - const extensionRunner = new ExtensionRunner( - extensionsResult.extensions, - extensionsResult.runtime, - tempDir.path(), - sessionManager, - modelRegistry, - ); - vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch"); - const emitSpy = vi.spyOn(extensionRunner, "emit").mockImplementation((async () => ({ - cancel: true, - })) as ExtensionRunner["emit"]); - - session = new AgentSession({ - agent: new Agent({ - initialState: { - model, - systemPrompt: ["Test"], - tools: [], - messages: [], - }, - }), - sessionManager, - settings: session.settings, - modelRegistry, - extensionRunner, - obfuscator, - }); - session.subscribe(event => { - events.push(event); - }); - const previousSessionFile = session.sessionFile; - const generateHandoffSpy = vi - .spyOn(compactionModule, "generateHandoffFromContext") - .mockResolvedValue("## Goal\nContinue from here"); - const assistantMessage: AssistantMessage = { - role: "assistant", - content: [{ type: "text", text: "maintenance trigger" }], - api: model.api, - provider: model.provider, - model: model.id, - stopReason: "stop", - usage: { - input: 10_000, - output: 1_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 11_000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: Date.now(), - }; - - 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); - - expect(generateHandoffSpy).toHaveBeenCalledTimes(1); - expect(emitSpy).toHaveBeenCalledWith({ type: "session_before_switch", reason: "handoff" }); - expect(emitSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "session_switch" })); - expect(session.sessionFile).toBe(previousSessionFile); - expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); - const endEvents = events.filter(event => event.type === "auto_compaction_end"); - expect(endEvents).toHaveLength(1); - expect(endEvents[0]).toMatchObject({ - type: "auto_compaction_end", - action: "handoff", - aborted: true, - willRetry: false, - }); - }); - it("resets to the base system prompt before generating a handoff", async () => { const model = session.model; if (!model) { @@ -1907,7 +1574,7 @@ describe("AgentSession handoff", () => { session = new AgentSession({ agent, sessionManager, - settings: Settings.isolated({ "compaction.enabled": false }), + settings: Settings.isolated({ "compaction.enabled": false, "compaction.keepRecentTokens": 1 }), modelRegistry, extensionRunner, }); @@ -1962,7 +1629,7 @@ describe("AgentSession handoff", () => { session = new AgentSession({ agent, sessionManager, - settings: Settings.isolated({ "compaction.enabled": false }), + settings: Settings.isolated({ "compaction.enabled": false, "compaction.keepRecentTokens": 1 }), modelRegistry, }); sessionManager.appendMessage({ diff --git a/packages/coding-agent/test/compaction-speculation.test.ts b/packages/coding-agent/test/compaction-speculation.test.ts new file mode 100644 index 000000000..9d26dc5ac --- /dev/null +++ b/packages/coding-agent/test/compaction-speculation.test.ts @@ -0,0 +1,244 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; +import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; +import type { AssistantMessage, Model, UserMessage } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionMaintenance, type SessionMaintenanceHost } from "@oh-my-pi/pi-coding-agent/session/session-maintenance"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; + +const CONTEXT_WINDOW = 100_000; +const THRESHOLD = 50_000; +const SPECULATION_BAND_START = THRESHOLD - 8_192; + +function userMessage(text: string): UserMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() }; +} + +function assistantMessage(text: string, model: Model): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: "stop", + usage: { + input: 10_000, + output: 100, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 10_100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; +} + +describe("async speculative compaction", () => { + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; + let model: Model; + let sessionManager: SessionManager; + let maintenance: SessionMaintenance; + let events: string[]; + + function appendSummarizableConversation(): void { + const text = "conversation ".repeat(8_000); + sessionManager.appendMessage(userMessage(text)); + sessionManager.appendMessage(assistantMessage("response ".repeat(8_000), model)); + sessionManager.appendMessage(userMessage(text)); + sessionManager.appendMessage(assistantMessage("final response", model)); + } + + function createMaintenance(asyncEnabled = true): SessionMaintenance { + const agent = new Agent({ + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, + }); + const settings = Settings.isolated({ + "compaction.enabled": true, + "compaction.asyncEnabled": asyncEnabled, + "compaction.methodOrder": ["soft"], + "compaction.thresholdPercent": 50, + "compaction.keepRecentTokens": 1, + "compaction.autoContinue": false, + }); + const host = { + agent, + sessionManager, + settings, + modelRegistry, + extensionRunner: undefined, + sideStreamFn: async () => { + throw new Error("The compact seam should be used instead of the side stream"); + }, + providerSessionState: new Map(), + preferWebsockets: undefined, + model: () => model, + thinkingLevel: () => undefined, + isDisposed: () => false, + isStreaming: () => false, + isGeneratingHandoff: () => false, + promptGeneration: () => 0, + sessionId: () => sessionManager.getSessionId(), + messages: () => agent.state.messages, + baseSystemPrompt: () => ["Test"], + goalModeState: () => undefined, + planReferencePath: () => "", + nonMessageTokenSource: () => ({}), + memoryBackendSession: () => undefined, + emitSessionEvent: async (event: { type: string }) => { + events.push(event.type); + }, + emitNotice: () => {}, + schedulePostPromptTask: () => {}, + scheduleAgentContinue: () => {}, + scheduleCompactionContinuation: () => false, + persistTurnMessagesForMidRunCompaction: async () => false, + findLastAssistantMessage: () => undefined, + disconnectFromAgent: () => {}, + reconnectToAgent: () => {}, + drainStrandedQueuedMessages: () => {}, + buildDisplaySessionContext: () => ({ messages: [] }), + convertToLlmForSideRequest: (messages: AgentMessage[]) => messages as never, + obfuscateTextForProvider: (text: string | undefined) => text, + obfuscatePreparationForProvider: (preparation: T) => preparation, + closeCodexProviderSessionsForHistoryRewrite: () => {}, + resetCodexProviderAfterCompaction: () => {}, + resetPlanReference: () => {}, + syncTodoPhasesFromBranch: () => {}, + resetAdvisorRuntimes: () => {}, + rebaseAfterCompaction: () => {}, + recordAnchoredHistoryRewrite: () => {}, + getContextBreakdown: () => undefined, + getContextUsage: () => undefined, + shake: async () => ({ modified: false, tokensRemoved: 0 }), + dropImages: async () => ({ removed: 0 }), + generateHandoffDocument: async () => undefined, + removeAssistantMessageFromActiveContext: () => {}, + dropPersistedAssistantTurn: async () => undefined, + runRecoveryCompactionWithRollback: async () => ({ deferredHandoff: false, continuationScheduled: false }), + parseRetryAfterMsFromError: () => undefined, + setModelTemporary: async () => {}, + abort: async () => {}, + abortHandoff: () => {}, + } as unknown as SessionMaintenanceHost; + return new SessionMaintenance(host); + } + + async function waitForState(state: "idle" | "running" | "armed"): Promise { + for (let microtask = 0; microtask < 100 && maintenance.speculationState !== state; microtask++) { + await Promise.resolve(); + } + if (maintenance.speculationState !== state) { + throw new Error(`Speculation did not become ${state}`); + } + } + + beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage); + const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!bundled) throw new Error("Expected built-in model"); + model = { ...bundled, contextWindow: CONTEXT_WINDOW }; + }); + + beforeEach(() => { + sessionManager = SessionManager.inMemory(); + events = []; + appendSummarizableConversation(); + maintenance = createMaintenance(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + authStorage.close(); + }); + + it("does not call the summarizer below the speculative band, then arms inside it", async () => { + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ + summary: "speculative summary", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + })); + + maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START - 1, CONTEXT_WINDOW); + expect(maintenance.speculationState).toBe("idle"); + expect(compactSpy).not.toHaveBeenCalled(); + + maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW); + expect(maintenance.speculationState).toBe("running"); + await waitForState("armed"); + expect(compactSpy).toHaveBeenCalledTimes(1); + }); + + it("commits an armed summary at threshold without paying for another summarizer call", async () => { + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ + summary: "armed summary", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + })); + maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW); + await waitForState("armed"); + + await maintenance.runAutoCompaction("threshold", false, false, false, { triggerContextTokens: THRESHOLD }); + + const entry = sessionManager.getEntries().findLast(item => item.type === "compaction"); + expect(entry?.type === "compaction" ? entry.summary : undefined).toBe("armed summary"); + expect(compactSpy).toHaveBeenCalledTimes(1); + expect(events).toEqual(expect.arrayContaining(["auto_compaction_start", "auto_compaction_end"])); + }); + + it("discards an armed summary after a reset boundary and re-summarizes the new branch", async () => { + let invocation = 0; + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ + summary: `summary ${++invocation}`, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + })); + maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW); + await waitForState("armed"); + sessionManager.appendResetBoundary(); + appendSummarizableConversation(); + + await maintenance.runAutoCompaction("threshold", false, false, false, { triggerContextTokens: THRESHOLD }); + + expect(compactSpy).toHaveBeenCalledTimes(2); + const entry = sessionManager.getEntries().findLast(item => item.type === "compaction"); + expect(entry?.type === "compaction" ? entry.summary : undefined).toBe("summary 2"); + }); + + it("does not start speculative work when async compaction is disabled", () => { + maintenance = createMaintenance(false); + const compactSpy = vi.spyOn(compactionModule, "compact"); + + maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW); + + expect(maintenance.speculationState).toBe("idle"); + expect(compactSpy).not.toHaveBeenCalled(); + }); + + it("clears an armed speculation when manual compaction starts", async () => { + vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ + summary: "manual summary", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + })); + maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW); + await waitForState("armed"); + + await maintenance.compact(); + + expect(maintenance.speculationState).toBe("idle"); + }); +}); diff --git a/packages/coding-agent/test/status-line-loop.test.ts b/packages/coding-agent/test/status-line-loop.test.ts index 0f30953c7..570664298 100644 --- a/packages/coding-agent/test/status-line-loop.test.ts +++ b/packages/coding-agent/test/status-line-loop.test.ts @@ -40,6 +40,8 @@ function createContext(loopMode: SegmentContext["loopMode"]): SegmentContext { contextTokens: 0, contextWindow: 0, autoCompactEnabled: false, + compactionSpeculation: "idle", + speculationBlinkOn: true, subagentCount: 0, activeMs: 0, activeRepo: null, diff --git a/packages/coding-agent/test/status-line-model.test.ts b/packages/coding-agent/test/status-line-model.test.ts index 3c8b18131..97b658ba6 100644 --- a/packages/coding-agent/test/status-line-model.test.ts +++ b/packages/coding-agent/test/status-line-model.test.ts @@ -47,6 +47,8 @@ function createModelContext(advisorActive: boolean): SegmentContext { contextTokens: 0, contextWindow: 0, autoCompactEnabled: false, + compactionSpeculation: "idle", + speculationBlinkOn: true, subagentCount: 0, activeMs: 0, activeRepo: null, diff --git a/packages/coding-agent/test/status-line-overflow.test.ts b/packages/coding-agent/test/status-line-overflow.test.ts index 18c2779e0..269b050ac 100644 --- a/packages/coding-agent/test/status-line-overflow.test.ts +++ b/packages/coding-agent/test/status-line-overflow.test.ts @@ -73,6 +73,8 @@ function createCtx(overrides?: { contextTokens: 0, contextWindow: 0, autoCompactEnabled: false, + compactionSpeculation: "idle", + speculationBlinkOn: true, subagentCount: 0, activeMs: 0, activeRepo: null, diff --git a/packages/coding-agent/test/status-line-path.test.ts b/packages/coding-agent/test/status-line-path.test.ts index 2660b4c1f..03f9e050d 100644 --- a/packages/coding-agent/test/status-line-path.test.ts +++ b/packages/coding-agent/test/status-line-path.test.ts @@ -60,6 +60,8 @@ function createPathContext(): SegmentContext { contextTokens: 0, contextWindow: 0, autoCompactEnabled: false, + compactionSpeculation: "idle", + speculationBlinkOn: true, subagentCount: 0, activeMs: 0, activeRepo: null, diff --git a/packages/coding-agent/test/status-line-time-spent.test.ts b/packages/coding-agent/test/status-line-time-spent.test.ts index e5dd3df92..9005753c1 100644 --- a/packages/coding-agent/test/status-line-time-spent.test.ts +++ b/packages/coding-agent/test/status-line-time-spent.test.ts @@ -62,6 +62,8 @@ function createCtx(activeMs: number): SegmentContext { contextTokens: 0, contextWindow: 0, autoCompactEnabled: false, + compactionSpeculation: "idle", + speculationBlinkOn: true, subagentCount: 0, activeMs, activeRepo: null,