Repository navigation
fix(coding-agent): settle extension command turns before returning - #298
Merged
Merged
Conversation
`pi.sendUserMessage` and `pi.sendMessage({ triggerTurn })` are fire-and-forget
bindings: the command handler returns while the turn it started is still
running. `prompt()` reported the command as complete at that point.
Print and JSON mode dispose the runtime as soon as `prompt()` resolves, so a
command that hands work to the agent — `/plan <task>` is the reachable one —
tore the session down mid-turn. Every extension then failed with "This extension
ctx is stale after session replacement or reload", the task never ran, and no
session file was written. Interactive mode hid it because the UI keeps the
session alive.
Track the turns each command handler starts and settle them before `prompt()`
returns. Acceptance stays immediate: `preflightResult(true)` fires once the
handler returns, before the wait, because RPC answers the client from it and a
turn can open a dialog that needs that client. Open handlers are kept on a
stack and every one is charged, so two overlapping commands cannot orphan a
tracker and let a turn escape the wait.
Covered by a suite regression for a deferred turn, a triggered custom message,
acceptance ordering, overlapping handlers, nested commands, the no-turn control,
and a rejected deferred turn that must not fail the command.
liuedcson
requested review from
MelodyVAR,
Uking-xxx and
ZouR-Ma
as code owners
October 23, 2026 06:12
liuedcson
added a commit
that referenced
this pull request
Oct 23, 2026
Brings the seven commits main gained since dff413f: - feat(coding-agent): make a feedback report investigable (#297) - fix(coding-agent): settle extension command turns before returning (#298) - fix(coding-agent): keep the tool block stable across plan mode (#296) - fix(cli): save model selection as default on Enter (#285) - feat(cli): add -C/--cwd flag (#289) - fix(coding-agent): preserve task-state prompt cache prefixes (#293) Two conflicts, both in the plan extension, where this branch had added behaviour on top of files main rewrote: - `plan-mode-tools.ts`: main's comment for `exitPlanMode` ("restore any tools plan mode added") with this branch's optional `submittedPlanHash` parameter, which records the proposal handed to an external reviewer. - `step-plan.ts`: main's `version: 2` entry and `carriesLegacyTools` migration path, plus this branch's `submittedPlanHash` / `planHashAtEnter` on the same entry, and both sides of the restore step (legacy tool baseline and the persisted hashes). Main's `resolveToCwd` guard and `planModeAddedTools` bookkeeping are kept as-is: plan mode now appends tools instead of replacing the list, which is what keeps the request's tool block stable. The host plan-mode test asserted the old refusal wording; the guard now says "Only the plan file may be written", so it matches on that.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
pi.sendUserMessageandpi.sendMessage({ triggerTurn: true })are fire-and-forget bindings: an extension command handler returns while the agent turn it started is still running.prompt()treated the command as fully handled and returned at that point.Print and JSON mode dispose the runtime as soon as
prompt()resolves, so a command that hands work to the agent tore the session down mid-turn. Every extension then failed with:…the task never ran, and no session file was written.
Reachable today through the most common headless entry point:
step -p "/plan 把 target.txt 改成 hacked"Exit status was 0, stdout was empty, and nothing happened. The TUI hid the bug because the UI keeps the session alive.
Change
AgentSessiontracks the turns each command handler starts and settles them beforeprompt()returns.preflightResult(true)fires as soon as the handler returns, before the wait. RPC answers the client from that callback, and the deferred turn can open a dialog (plan review) that needs the client — delaying the ack would deadlock it.Verification
New suite regression
command-deferred-turn.test.ts, 7 cases:mainsendUserMessageturn is awaitedsendMessage({ triggerTurn })turn is awaitedtsgo --noEmitclean;biome check --error-on-warningsclean (the earlierpreflightResult: () => (accepted = true)trippednoAssignInExpressionsand would have failed CI — fixed).check:ts-imports,check:layer-direction,check:public-boundarypass.vitest --runin this worktree: 6 failures, all pre-existingtest/completion-check-cli.test.tscases that also fail on an unmodifiedorigin/mainworktree.Docs
packages/coding-agent/docs/sdk.md: a slash command is accepted when its handler returns, whileprompt()still resolves only after the turn settles.packages/coding-agent/docs/extensions.md:sendUserMessageis fire-and-forget, but a turn it starts inside a command handler is awaited beforeprompt()resolves.Not addressed
createReplacedSessionContext()bindssendMessage/sendUserMessagedirectly, bypassing the tracker. AwithSessioncallback that does not await its send can still outlive the caller; the documented examples all await. Left as-is rather than charging the outgoing session's tracker.