8b1364d4c2
- Replaced event-driven handoff with one-shot `generateHandoff(...)` via `completeSimple`. - Added cancellable `/handoff` command handling with a loader and Escape-to-abort flow. - Removed legacy `compaction/handoff.ts` exports and added `generateHandoff(messages, model, apiKey, options)`. - Fixed pre-cancelled handoff behavior to return `Handoff cancelled` and propagate abort signals. - Updated handoff tests/mocks to assert `generateHandoff` invocation details and `AgentSession.handoff()` options.
255 lines
11 KiB
Markdown
255 lines
11 KiB
Markdown
# `/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-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 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: <message>")`
|
|
- 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.
|