# `/handoff` generation pipeline This document describes how the coding-agent implements `/handoff`: trigger path, oneshot generation, session switch, context reinjection, persistence, and UI behavior. ## Scope Covers: - Interactive `/handoff` command dispatch - `AgentSession.handoff()` lifecycle and state transitions - `generateHandoff(...)` request shape - How old/new sessions persist handoff data differently - UI behavior for success, cancel, and failure Does not cover: - Generic tree navigation/branch internals - Non-handoff session commands (`/new`, `/fork`, `/resume`) ## Implementation files - [`../src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts) - [`../src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.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) - [`../src/extensibility/slash-commands.ts`](../packages/coding-agent/src/extensibility/slash-commands.ts) ## Trigger path 1. `/handoff` is declared in builtin slash command metadata (`slash-commands.ts`) with optional inline hint: `[focus instructions]`. 2. In interactive input handling (`InputController`), submit text matching `/handoff` or `/handoff ...` is intercepted before normal prompt submission. 3. The editor is cleared and `handleHandoffCommand(customInstructions?)` is called. 4. `CommandController.handleHandoffCommand` performs a preflight guard using current entries: - Counts `type === "message"` entries. - If `< 2`, it warns: `Nothing to hand off (no messages yet)` and returns. The same minimum-content guard exists again inside `AgentSession.handoff()` and throws if violated. This duplicates safety at both UI and session layers. ## End-to-end lifecycle ### 1) Start handoff generation `AgentSession.handoff(customInstructions?)`: - Reads current branch entries (`sessionManager.getBranch()`). - Validates minimum message count (`>= 2`). - Creates `#handoffAbortController` and links any caller-provided abort signal to it. - Resolves the current model API key through `ModelRegistry`. - Calls `generateHandoff(...)` with: - live agent messages (`agent.state.messages`), - the current model and API key, - the base system prompt (`#baseSystemPrompt`), - the live tool array (`agent.state.tools`), - optional focus instructions, - coding-agent message conversion (`convertToLlm`), - provider metadata and `initiatorOverride: "agent"`. `generateHandoff(...)` lives in `packages/agent/src/compaction/compaction.ts` next to summarization. It renders `packages/agent/src/compaction/prompts/handoff-document.md` via `renderHandoffPrompt(...)` with optional `additionalFocus`. ### 2) Generate and capture output `generateHandoff(...)` converts the existing `AgentMessage[]` history to real LLM `Message[]` history, then appends one trailing agent-attributed `user` message containing the rendered handoff prompt. The request uses `completeSimple(...)` directly: ```ts await completeSimple( model, { systemPrompt, messages: requestMessages, tools, }, { apiKey, signal, reasoning: Effort.High, toolChoice: "none", initiatorOverride, metadata, }, ); ``` Important generation properties: - The request preserves the live provider cache prefix by reusing the same system prompt, tool definitions, and real message history shape as the active agent. - The handoff instruction is a trailing `user` message, not a developer message, so the cached prefix remains aligned with the prior turn. - `toolChoice: "none"` prevents intentional tool dispatch. - The returned assistant content is filtered to text blocks and joined with `\n`; stray tool-call blocks are ignored if a provider does not honor `toolChoice: "none"`. - `stopReason === "error"` throws a generation error. No agent-loop events are used for capture. The handoff path no longer waits for `agent_end` and no longer scans the latest assistant message. ### 3) Cancellation checks Cancellation throws `Error("Handoff cancelled")`; a completed generation with no text returns `undefined`. - caller signal aborts `#handoffAbortController` - `completeSimple(...)` receives the abort signal - aborted handoff signal or provider `AbortError` is normalized to `Error("Handoff cancelled")` - empty generated text returns `undefined` `AgentSession.handoff()` always clears `#handoffAbortController` in `finally`. ### 4) New session creation If text was generated and not aborted: 1. Flush current session writer (`sessionManager.flush()`). 2. Cancel session-owned async jobs. 3. Start a brand-new session with `parentSession` pointing at the previous session file when one exists. 4. Reset in-memory agent state (`agent.reset()`). 5. Rebind `agent.sessionId` to the new session id. 6. Rekey/reset hindsight state for the new session. 7. Clear queued context arrays (`#steeringMessages`, `#followUpMessages`, `#pendingNextTurnMessages`) and any scheduled hidden next-turn generation. 8. Reset todo reminder counter. ### 5) Handoff-context injection The generated handoff document is wrapped by coding-agent session glue and appended to the new session as a `custom_message` entry: ```text ...handoff text... The above is a handoff document from a previous session. Use this context to continue the work seamlessly. ``` Insertion call: ```ts this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent"); ``` Semantics: - `customType`: `"handoff"` - `display`: `true` (visible in TUI rebuild) - attribution: `"agent"` - Entry type: `custom_message` (participates in LLM context) ### 6) Rebuild active agent context After injection: 1. `buildDisplaySessionContext()` resolves message list for current leaf. 2. `agent.replaceMessages(sessionContext.messages)` makes the injected handoff message active context. 3. Todo phases are synchronized from the new branch. 4. Method returns `{ 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 session artifacts directory when `compaction.handoffSaveToDisk` is enabled. Manual `/handoff` does not write that artifact. ## Controller/UI behavior `CommandController.handleHandoffCommand` behavior: - Shows a status loader: `Generating handoff… (esc to cancel)`. - Calls `await session.handoff(customInstructions)`. - If result is `undefined`: `showError("Handoff cancelled")`. - On success: - `rebuildChatFromMessages()` (loads new session context, including injected handoff) - invalidates status line and editor top border - reloads todos - appends success chat line: `New session started with handoff context` - On exception: - if message is `"Handoff cancelled"` or error name is `AbortError`: `showError("Handoff cancelled")` - otherwise: `showError("Handoff failed: ")` - Stops the loader, restores the previous Escape handler, 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. ## Cancellation semantics ### Session-level cancellation primitive `AgentSession` exposes: - `abortHandoff()` → aborts `#handoffAbortController` - `isGeneratingHandoff` → true while controller exists When this abort path is used, the abort signal is passed to `completeSimple(...)`; `handoff()` normalizes the cancellation to `Error("Handoff cancelled")`, and command controller maps it to cancellation UI. ### Interactive `/handoff` path The command controller installs a temporary Escape handler for `/handoff` while the loader is visible. Pressing Escape calls `session.abortHandoff()`, which aborts the `completeSimple(...)` request through `#handoffAbortController`. ## Aborted vs failed handoff Current UI classification: - **Aborted/cancelled** - `abortHandoff()` path triggers `"Handoff cancelled"`, or - thrown `AbortError` - UI shows `Handoff cancelled` - **Failed** - any other thrown error from `handoff()` / `generateHandoff()` / provider request path - UI shows `Handoff failed: ...` Additional nuance: if generation completes but no text is returned, `handoff()` returns `undefined` and controller currently reports **cancelled**, not **failed**. ## 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. ## State transition summary High-level state flow: 1. Interactive slash command intercepted. 2. Preflight message-count guard. 3. `#handoffAbortController` created (`isGeneratingHandoff = true`). 4. `generateHandoff(...)` issues one `completeSimple(...)` request with live system prompt, tools, message history, and trailing handoff prompt. 5. Assistant response text blocks are joined; tool-call blocks are discarded. 6. If missing text → return `undefined`; if aborted → cancellation error path. 7. If present: - flush old session - cancel async jobs - create new empty session with previous session as parent - reset runtime queues/counters - append `custom_message(handoff)` - optionally save an auto-triggered handoff document under the session artifacts directory when `compaction.handoffSaveToDisk` is enabled 8. Controller rebuilds chat UI and announces success. 9. `#handoffAbortController` cleared (`isGeneratingHandoff = false`). ## Known assumptions and limitations - No structural validation checks that generated markdown follows the requested section format. - Missing generated text is reported as cancellation in controller UX. - Manual handoff has no streaming visibility; a cancellable loader is shown until the UI updates after generation completes. - Auto-triggered handoffs can write a timestamped `handoff-*.md` artifact when `compaction.handoffSaveToDisk` is enabled; write failure is logged and does not fail the handoff.