Summary
Downshift has two related lifecycle paths that can enqueue continuation after work should have stopped:
- A handoff steering message can be delivered after the premium model has already produced a terminal final response.
- Aborting handoff generation with Escape still causes Downshift to treat the handoff as complete, switch to economy, and enqueue the continuation message.
Both cases reopen a run after the user or model has already ended it.
Verified lifecycle
Terminal response before queued handoff
Current Downshift behavior:
src/downshift.ts evaluates the threshold from the context event.
- While an agent run is active,
ctx.isIdle() is false, so maybeDownshift() receives "steer" delivery.
requestHandoff() sets handoff: "requested", sets continueAfterHandoff: true, and calls sendUserMessage(..., { deliverAs: "steer" }).
- After the handoff completes,
completeHandoffAndSwitch() switches to economy and queues the CONTINUE_MARKER prompt as a follow-up.
Pi v0.80.6 behavior:
- The
context hook runs before each LLM call.
- Steering messages are drained after the current assistant turn and its tool calls finish.
- A steering message therefore becomes the next turn even when the current assistant response was a final answer with no tool calls.
The resulting ordering can be:
context threshold check
-> queue HANDOFF_MARKER as steer
-> premium model returns final answer
-> Pi delivers queued HANDOFF_MARKER
-> premium model writes handoff note
-> Downshift switches to economy
-> Downshift queues CONTINUE_MARKER
-> economy model continues an already completed task
Abort during handoff generation
Pi represents an aborted assistant response with stopReason: "aborted", emits turn_end, then emits agent_end without polling steering or follow-up queues.
Downshift currently does not inspect the handoff assistant message result. handleAgentEnd() considers any handoff: "active" state completed and calls completeHandoffAndSwitch().
Because a steered handoff sets continueAfterHandoff: true, aborting handoff generation can produce this ordering:
HANDOFF_MARKER turn starts on premium
-> user presses Escape
-> assistant message ends with stopReason: "aborted"
-> Pi emits agent_end
-> Downshift marks handoff done
-> Downshift switches to economy
-> Downshift queues CONTINUE_MARKER as follow-up
-> a new economy run starts despite the user's abort
The abort is therefore incorrectly treated as a successful handoff.
Reproduction
Terminal-response case
- Enable Downshift with
handoffBeforeDownshift: true.
- Configure a threshold that is reached immediately before an LLM call.
- Run a task where that premium-model call returns a final assistant response without tool calls.
- Observe the session after the final response.
Aborted-handoff case
- Enable Downshift with
handoffBeforeDownshift: true.
- Trigger a threshold crossing during active work so the handoff is delivered as
steer.
- Wait until the premium model begins writing the handoff note.
- Press Escape to abort the handoff generation.
- Observe that Downshift switches to economy and sends the continuation prompt.
Current behavior
Terminal response
The final response is followed by a handoff turn and an economy continuation turn, even though the premium model already completed the task.
Aborted handoff
The aborted handoff is marked done. Downshift switches to economy and sends CONTINUE_MARKER, overriding the user's explicit request to stop.
These paths add unnecessary tokens, create misleading conversation history, and may duplicate completed work or restart intentionally aborted work.
Expected behavior
A terminal assistant response must remain terminal, and a user abort must remain an abort.
After a terminal response
- Do not append or deliver a
HANDOFF_MARKER instruction after the final response.
- Do not generate a handoff note for completed work.
- Do not enqueue the
CONTINUE_MARKER prompt.
- Transition to economy at a safe boundary, or record a deterministic deferred transition, without reopening the completed task.
After an aborted handoff
- Do not treat an assistant message with
stopReason: "aborted" as a completed handoff.
- Do not enqueue
CONTINUE_MARKER.
- Do not automatically start another agent run.
- Clear
continueAfterHandoff and leave no stale pending handoff state.
- Preserve the user's abort as the final action.
- Use a deterministic model/position policy after the abort. Prefer remaining on premium or pausing rather than silently continuing on economy.
For genuinely continuing tool-driven work, the existing premium handoff followed by economy continuation should remain available.
Proposed direction
Move decisions that require knowledge of the assistant result out of the pre-call context hook and validate handoff completion explicitly.
A likely approach is:
- Use
context only to record that the threshold has been reached for the active call.
- Decide at
turn_end whether the assistant turn is continuing, terminal, errored, or aborted.
- If more agent work is required, queue the handoff before the next LLM call.
- If the original turn is terminal, switch or defer the model transition without generating a handoff or continuation.
- During a handoff turn, mark the handoff complete only when its assistant message finished successfully.
- On
stopReason: "aborted" or "error", clear continuation intent and enter a stable non-continuing state.
- Clear pending transition state on pause, tree navigation, reload, failed model activation, and other interruption paths.
The exact implementation may differ, but it must not:
- use
ctx.isIdle() inside context as a proxy for whether the current LLM call will continue,
- equate
agent_end with successful handoff completion, or
- override an explicit user abort by enqueueing follow-up work.
Non-goals
- Do not remove handoff notes for active multi-turn work.
- Do not change explicit
/downshift now semantics unless required for consistent abort handling.
- Do not change threshold comparison semantics.
- Do not infer task completion from assistant prose.
- Do not add prompt classification or general model-routing behavior.
Acceptance criteria
Terminal turns
Aborted handoffs
Continuing and safety paths
Test coverage
Add core and adapter-level regression coverage for these event sequences.
Terminal turn
context(threshold reached)
turn_end(final assistant message, no tool calls)
agent_end
Assert:
- no handoff user message is sent after the final response,
- no continuation user message is sent,
- no pending handoff remains,
- the final model/position state follows the defined terminal-transition behavior.
Continuing turn
context(threshold reached)
turn_end(assistant tool call + tool result)
next LLM call
Assert:
- exactly one steering handoff is delivered before the next work call,
- the handoff completes on premium,
- the model switches once to economy,
- exactly one continuation prompt is queued.
Aborted handoff
before_agent_start(HANDOFF_MARKER)
turn_end(assistant stopReason: aborted)
agent_end
Assert:
- the handoff is not treated as complete,
- no economy continuation message is sent,
- no new run starts,
continueAfterHandoff is false,
- the handoff state is stable and non-pending,
- the selected post-abort model/position policy is applied exactly once.
Errored handoff
Repeat the aborted-handoff assertions with stopReason: "error".
Safety paths
Cover repeated lifecycle events, failed economy activation, reload, session restore, and session-tree navigation while a threshold transition is pending.
Summary
Downshift has two related lifecycle paths that can enqueue continuation after work should have stopped:
Both cases reopen a run after the user or model has already ended it.
Verified lifecycle
Terminal response before queued handoff
Current Downshift behavior:
src/downshift.tsevaluates the threshold from thecontextevent.ctx.isIdle()is false, somaybeDownshift()receives"steer"delivery.requestHandoff()setshandoff: "requested", setscontinueAfterHandoff: true, and callssendUserMessage(..., { deliverAs: "steer" }).completeHandoffAndSwitch()switches to economy and queues theCONTINUE_MARKERprompt as a follow-up.Pi
v0.80.6behavior:contexthook runs before each LLM call.The resulting ordering can be:
Abort during handoff generation
Pi represents an aborted assistant response with
stopReason: "aborted", emitsturn_end, then emitsagent_endwithout polling steering or follow-up queues.Downshift currently does not inspect the handoff assistant message result.
handleAgentEnd()considers anyhandoff: "active"state completed and callscompleteHandoffAndSwitch().Because a steered handoff sets
continueAfterHandoff: true, aborting handoff generation can produce this ordering:The abort is therefore incorrectly treated as a successful handoff.
Reproduction
Terminal-response case
handoffBeforeDownshift: true.Aborted-handoff case
handoffBeforeDownshift: true.steer.Current behavior
Terminal response
The final response is followed by a handoff turn and an economy continuation turn, even though the premium model already completed the task.
Aborted handoff
The aborted handoff is marked
done. Downshift switches to economy and sendsCONTINUE_MARKER, overriding the user's explicit request to stop.These paths add unnecessary tokens, create misleading conversation history, and may duplicate completed work or restart intentionally aborted work.
Expected behavior
A terminal assistant response must remain terminal, and a user abort must remain an abort.
After a terminal response
HANDOFF_MARKERinstruction after the final response.CONTINUE_MARKERprompt.After an aborted handoff
stopReason: "aborted"as a completed handoff.CONTINUE_MARKER.continueAfterHandoffand leave no stale pending handoff state.For genuinely continuing tool-driven work, the existing premium handoff followed by economy continuation should remain available.
Proposed direction
Move decisions that require knowledge of the assistant result out of the pre-call
contexthook and validate handoff completion explicitly.A likely approach is:
contextonly to record that the threshold has been reached for the active call.turn_endwhether the assistant turn is continuing, terminal, errored, or aborted.stopReason: "aborted"or"error", clear continuation intent and enter a stable non-continuing state.The exact implementation may differ, but it must not:
ctx.isIdle()insidecontextas a proxy for whether the current LLM call will continue,agent_endwith successful handoff completion, orNon-goals
/downshift nowsemantics unless required for consistent abort handling.Acceptance criteria
Terminal turns
HANDOFF_MARKERmessage after that response.CONTINUE_MARKERmessage is queued for a task that already ended with a terminal assistant response.Aborted handoffs
CONTINUE_MARKER.continueAfterHandoffis cleared after abort.requestedoractivehandoff state remains after abort.Continuing and safety paths
Test coverage
Add core and adapter-level regression coverage for these event sequences.
Terminal turn
Assert:
Continuing turn
Assert:
Aborted handoff
Assert:
continueAfterHandoffis false,Errored handoff
Repeat the aborted-handoff assertions with
stopReason: "error".Safety paths
Cover repeated lifecycle events, failed economy activation, reload, session restore, and session-tree navigation while a threshold transition is pending.