/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
/handoffcommand dispatch AgentSession.handoff()lifecycle and state transitionsgenerateHandoff(...)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../src/modes/controllers/command-controller.ts../src/session/agent-session.tspackages/agent/src/compaction/compaction.ts../src/session/session-manager.ts../src/slash-commands/builtin-registry.ts
Trigger path
/handoffis declared in builtin slash command metadata (slash-commands/builtin-registry.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). - Refuses if a response is still streaming (the TUI
/handoffand RPChandoffcommand guard onisStreamingbefore calling this; the auto-handoff path runs only after the turn settles). Resetting the agent mid-stream would let the live turn keep emitting into the torn-down session. - Creates
#handoffAbortControllerand links any caller-provided abort signal to it. - Resolves the current model API key through
ModelRegistry. - Builds the handoff request through the same pipeline a live turn uses — the cache-preserving side-request path shared with
runEphemeralTurn(/btw,/omfg):- Renders the handoff prompt (
renderHandoffPrompt(...)with optionaladditionalFocus, after obfuscating any focus instructions) and appends it as a trailing agent-attributedusermessage to a snapshot ofagent.state.messages. - Converts the snapshot with
convertMessagesToLlm(...)(applies the sessiontransformContext— extension context + steering wrap — thenconvertToLlm+ obfuscation), exactly as the loop does. - Builds the provider
Contextwithagent.buildSideRequestContext(llmMessages, #baseSystemPrompt)— normalized tools andtransformProviderContext(obfuscation + inline snapcompact) matching the loop. The base system prompt is pinned here, not a per-turnbefore_agent_starthook override, so the new session does not inherit prompt-specific hook state. - Builds stream options with
prepareSimpleStreamOptions(...): a stablepromptCacheKey(= the live session id) so the oneshot reads the cache the turn populated, a unique sidesessionId(<sid>:side:<snowflake>) so OpenAI/Codex append-only state never mixes with the live turn,serviceTier/payload hooks mirrored from the session, andpreferWebsockets: false.
- Renders the handoff prompt (
- Calls
generateHandoffFromContext(context, model, { streamOptions, telemetry, thinkingLevel }).
2) Generate and capture output
generateHandoffFromContext(...) lives in packages/agent/src/compaction/compaction.ts next to summarization. It is the handoff request contract: it issues one instrumentedCompleteSimple(...) (the OTEL-instrumented completeSimple oneshot wrapper) against the caller-built Context, forcing toolChoice: "none" and reasoning: resolveCompactionEffort(model, thinkingLevel) over whatever the caller’s streamOptions carried:
await instrumentedCompleteSimple(
model,
context, // system prompt + normalized tools + transformed history + trailing handoff prompt
{
...streamOptions, // apiKey, signal, sessionId, promptCacheKey, serviceTier, hooks
reasoning: resolveCompactionEffort(model, options.thinkingLevel),
toolChoice: "none",
},
{ telemetry, oneshotKind: "handoff" },
);
(generateHandoff(messages, …) remains exported for downstream callers and now builds a basic Context from systemPrompt/tools/convertToLlm and delegates to generateHandoffFromContext. AgentSession no longer uses it because it cannot apply the host’s transform pipeline or cache routing.)
Important generation properties:
- The request shares the live provider cache prefix because the
Contextis built by the identical transform + normalization pipeline the loop uses, and routed with the samepromptCacheKeythe turn used. - The handoff instruction is a trailing
usermessage, not a developer message, so the cached prefix remains aligned with the prior turn (the trailing message is the only divergence point). 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 honortoolChoice: "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
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.
- caller signal aborts
#handoffAbortControllerand forwards its reason completeSimple(...)receives the abort signal- direct
abortHandoff()or an unreasoned caller signal is normalized toError("Handoff cancelled") - harness abort reasons and provider failures (including provider
AbortErrors) surface verbatim
AgentSession.handoff() always clears #handoffAbortController in finally.
4) New session creation
If text was generated and not aborted:
- Flush current session writer (
sessionManager.flush()). - Cancel session-owned async jobs.
- Start a brand-new session with
parentSessionpointing at the previous session file when one exists. - Reset in-memory agent state (
agent.reset()). - Rebind
agent.sessionIdto the new session id. - Rekey/reset Hindsight and Mnemopi memory session tracking for the new session.
- Clear the queued next-turn context array (
#pendingNextTurnMessages) and the scheduled hidden next-turn generation (#scheduledHiddenNextTurnGeneration). The agent’s steering and follow-up queues are already cleared byagent.reset()in step 4. - 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:
<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,
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:
buildDisplaySessionContext()resolves message list for current leaf.agent.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
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:
- Refuses with a warning when
session.isStreaming(matches/forkand/move) — the user must finish or abort the response before handing off. - 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":showError("Handoff cancelled") - otherwise: logs the error and calls
showError("Handoff failed: <message>")
- if message is
- 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.
Cancellation semantics
Session-level cancellation primitive
AgentSession exposes:
abortHandoff()→ aborts#handoffAbortControllerisGeneratingHandoff→ true while controller exists
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.
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.
Aborted vs failed handoff
Current UI classification:
- Aborted/cancelled
- direct
abortHandoff()(interactive Esc) triggers"Handoff cancelled" - an unreasoned caller signal also triggers
"Handoff cancelled" - UI shows
Handoff cancelled
- direct
- Failed
- any other thrown error from
handoff()/generateHandoff()/ provider request path - UI shows
Handoff failed: ... - a harness abort reason, an empty manual generation, or any thrown provider/session-transition error
- UI logs the error and shows
Handoff failed: ...
- any other thrown error from
Additional nuance: if generation completes but no text is returned, handoff() returns undefined and controller currently reports cancelled, not 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.
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).generateHandoff(...)issues oneinstrumentedCompleteSimple(...)request with live system prompt, tools, message history, current thinking level, and trailing handoff prompt.- Assistant response text blocks are joined; tool-call blocks are discarded.
- If missing text → return
undefined; if aborted → cancellation error path. - 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.handoffSaveToDiskis enabled
- Controller rebuilds chat UI and announces success.
#handoffAbortControllercleared (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-*.mdartifact whencompaction.handoffSaveToDiskis enabled; write failure is logged and does not fail the handoff.