9.7 KiB
/handoff generation pipeline
This document describes how the coding-agent implements /handoff today: trigger path, generation prompt, completion capture, session switch, and context reinjection.
Scope
Covers:
- Interactive
/handoffcommand dispatch AgentSession.handoff()lifecycle and state transitions- How handoff output is captured from assistant output
- 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../src/modes/controllers/command-controller.ts../src/session/agent-session.ts../src/session/session-manager.ts../src/extensibility/slash-commands.ts
Trigger path
/handoffis declared in builtin slash command metadata (slash-commands.ts) with optional inline hint:[focus instructions].- In interactive input handling (
InputController), submit text matching/handoffor/handoff ...is intercepted before normal prompt submission. - The editor is cleared and
handleHandoffCommand(customInstructions?)is called. CommandController.handleHandoffCommandperforms a preflight guard using current entries:- Counts
type === "message"entries. - If
< 2, it warns:Nothing to hand off (no messages yet)and returns.
- Counts
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 - Renders the fixed prompt template
prompts/system/handoff-document.mdwith optionaladditionalFocus - Appends
Additional focus: ...if custom instructions are provided
Prompt is sent as an agent-authored developer message via:
await this.#promptAgentWithIdleRetry([
{
role: "developer",
content: [{ type: "text", text: handoffPrompt }],
attribution: "agent",
timestamp: Date.now(),
},
]);
Because handoff bypasses prompt(...), normal slash/prompt-template expansion is not applied to this internal instruction payload.
2) Capture completion
Before sending prompt, handoff() subscribes to session events and waits for agent_end.
On agent_end, it extracts handoff text from agent state by scanning backward for the most recent assistant message, then concatenating all content blocks where type === "text" with \n.
Important extraction assumptions:
- Only text blocks are used; non-text content is ignored.
- It assumes the latest assistant message corresponds to handoff generation.
- It does not parse markdown sections or validate format compliance.
- If assistant output has no text blocks, handoff is treated as missing.
3) Cancellation checks
Cancellation throws Error("Handoff cancelled"); a completed generation with no extracted text returns undefined.
- no captured handoff text → returns
undefined - aborted handoff signal → throws
Error("Handoff cancelled")
It always clears #handoffAbortController in finally.
4) New session creation
If text was captured and not aborted:
- Flush current session writer (
sessionManager.flush()) - Cancel async jobs (
#asyncJobManager?.cancelAll()) - Start a brand-new session (
sessionManager.newSession()) - Reset in-memory agent state (
agent.reset()) - Rebind
agent.sessionIdto new session id - Clear queued context arrays (
#steeringMessages,#followUpMessages,#pendingNextTurnMessages) and any scheduled hidden next-turn generation - Reset todo reminder counter
newSession()creates a fresh header and empty entry list (leaf reset tonull). In the handoff path, noparentSessionis passed.
5) Handoff-context injection
The generated handoff document is wrapped and appended to the new session as a custom_message entry:
<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:
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true);
Semantics:
customType:"handoff"display:true(visible in TUI rebuild)- Entry type:
custom_message(participates in LLM context)
6) Rebuild active agent context
After injection:
buildDisplaySessionContext()resolves message list for current leafagent.replaceMessages(sessionContext.messages)makes the injected handoff message active context- Todo phases are synchronized from the new branch
- 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
During generation, normal message persistence remains active. The assistant handoff response is persisted as a regular message entry on message_end.
Result: the original session contains the visible generated handoff as part of historical transcript.
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.
Controller/UI behavior
CommandController.handleHandoffCommand behavior:
- 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 isAbortError:showError("Handoff cancelled") - otherwise:
showError("Handoff failed: <message>")
- if message is
- Requests render at end
Cancellation semantics (current behavior)
Session-level cancellation primitive
AgentSession exposes:
abortHandoff()→ aborts#handoffAbortControllerisGeneratingHandoff→ true while controller exists
When this abort path is used, the handoff completion waiter resolves as cancelled, agent.abort() is called, and handoff() throws Error("Handoff cancelled"); command controller maps it to cancellation UI.
Interactive /handoff path
The editor does not install a dedicated Escape handler that calls abortHandoff() for /handoff, but InputController treats session.isGeneratingHandoff as a busy state.
Practical impact:
- There is session-level cancellation support, but no handoff-specific keybinding hook in the
/handoffcommand path. - User interruption may still occur through broader agent abort paths, but that is not the same explicit cancellation channel used by
abortHandoff().
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()/ prompt pipeline (model/API validation errors, runtime exceptions, etc.) - UI shows
Handoff failed: ...
- any other thrown error from
Additional nuance: if generation completes but no text is extracted, 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< 2message 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:
- Interactive slash command intercepted
- Preflight message-count guard
#handoffAbortControllercreated (isGeneratingHandoff = true)- Internal developer handoff prompt submitted (visible in chat as normal assistant generation)
- On
agent_end, last assistant text extracted - If missing text → return
undefined; if aborted → cancellation error path - If present:
- flush old session
- cancel async jobs
- create new empty session
- reset runtime queues/counters
- append
custom_message(handoff) - optionally save an auto-triggered handoff document under the session artifacts directory when
compaction.handoffSaveToDiskis enabled
- Controller rebuilds chat UI and announces success
#handoffAbortControllercleared (isGeneratingHandoff = false)
Known assumptions and limitations
- Handoff extraction is heuristic: "last assistant text blocks"; no structural validation.
- No hard check that generated markdown follows requested section format.
- Missing extracted text is reported as cancellation in controller UX.
/handoffinteractive flow currently lacks a dedicated Escape→abortHandoff()binding, though the session reports handoff as a busy state.- New session lineage metadata (
parentSession) is not set by this path. - Auto-triggered handoffs can write a timestamped
handoff-*.mdartifact whencompaction.handoffSaveToDiskis enabled; write failure is logged and does not fail the handoff.