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 + <files> 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.
This commit is contained in:
+5
-3
@@ -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-<ISO timestamp>.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 `<read-files>`/`<modified-files>` 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`
|
||||
|
||||
@@ -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` (`<sid>:side:<snowflake>`), 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 `<files>` 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-context>
|
||||
...handoff text...
|
||||
</handoff-context>
|
||||
|
||||
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: <message>")`
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, unknown> | undefined,
|
||||
activeModel: Model,
|
||||
settings: CompactionSettings,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = 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: {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -336,7 +336,6 @@ export interface HandoffResult {
|
||||
export interface SessionHandoffOptions {
|
||||
autoTriggered?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onSwitchCancelled?: () => void;
|
||||
}
|
||||
|
||||
/** Result from cycleModel(). */
|
||||
|
||||
@@ -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<HandoffResult | undefined> {
|
||||
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--) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<handoff-context>\n${document}\n</handoff-context>\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<Message[]>;
|
||||
prepareSimpleStreamOptions(options: SimpleStreamOptions, provider?: string): SimpleStreamOptions;
|
||||
effectiveServiceTier(model: Model | undefined): ServiceTier | undefined;
|
||||
flushPendingBash(): Promise<void>;
|
||||
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<void>;
|
||||
clearPendingNextTurnMessages(): void;
|
||||
resetTodoCycle(): void;
|
||||
buildDisplaySessionContext(): SessionContext;
|
||||
resetAdvisorSessionState(): void;
|
||||
drainAndDetachAdvisorRecorders(): Promise<void>;
|
||||
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<HandoffResult | undefined> {
|
||||
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<HandoffResult | undefined> {
|
||||
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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
|
||||
@@ -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<void>();
|
||||
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",
|
||||
|
||||
@@ -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<void>();
|
||||
const messageEndEntered = Promise.withResolvers<void>();
|
||||
|
||||
@@ -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<string>();
|
||||
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(
|
||||
`<handoff-context>\n${handoffText}\n</handoff-context>\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({
|
||||
|
||||
@@ -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: <T>(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<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,6 +73,8 @@ function createCtx(overrides?: {
|
||||
contextTokens: 0,
|
||||
contextWindow: 0,
|
||||
autoCompactEnabled: false,
|
||||
compactionSpeculation: "idle",
|
||||
speculationBlinkOn: true,
|
||||
subagentCount: 0,
|
||||
activeMs: 0,
|
||||
activeRepo: null,
|
||||
|
||||
@@ -60,6 +60,8 @@ function createPathContext(): SegmentContext {
|
||||
contextTokens: 0,
|
||||
contextWindow: 0,
|
||||
autoCompactEnabled: false,
|
||||
compactionSpeculation: "idle",
|
||||
speculationBlinkOn: true,
|
||||
subagentCount: 0,
|
||||
activeMs: 0,
|
||||
activeRepo: null,
|
||||
|
||||
@@ -62,6 +62,8 @@ function createCtx(activeMs: number): SegmentContext {
|
||||
contextTokens: 0,
|
||||
contextWindow: 0,
|
||||
autoCompactEnabled: false,
|
||||
compactionSpeculation: "idle",
|
||||
speculationBlinkOn: true,
|
||||
subagentCount: 0,
|
||||
activeMs,
|
||||
activeRepo: null,
|
||||
|
||||
Reference in New Issue
Block a user