Skip to content

fix(ask): a prompt typed into an Ask-this-session turn takes it over on every host - #1703

Merged
backnotprop merged 3 commits into
mainfrom
fix/session-ask-takeover
Oct 5, 2026
Merged

backnotprop merged 3 commits into
mainfrom
fix/session-ask-takeover

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Target: 0.28.3.

Bug

When the person typed into the session while it was answering a Plannotator "Ask this session" question, two things went wrong on all three hosts:

  • The reply to the person's prompt kept streaming into the Ask AI panel as if it were the answer.
  • A reviewer's Stop (or "Interrupt and ask now") aborted the turn, which now also carried the person's own work.

The rule (same on every host)

Once another message enters the turn that is answering the question (the person typing, another extension's steer):

  1. Streaming stops at once, and what was already sent stays.

  2. The question settles with a new bridge error code, taken_over. The provider maps it to session_taken_over with a note.

  3. If the answer had already finished before the other message entered, the question settles as done instead. "Finished" means the turn's last model response stopped with no tool call and some answer text was shown:

    • Claude: end_turn. A thinking-only final response with no text settles as taken_over, not as an empty done.
    • Pi: stop.
    • OpenCode: finish: "stop" and no session.tool.input.started in that step. Some OpenAI-compatible providers report "stop" on a step that called tools.

    This keeps a follow-up, such as Plannotator's own decision on Pi, from turning a complete answer into a cut one.

  4. Plannotator never aborts that turn again. Stop only closes the question, and interrupt() refuses with SESSION_ASK_TAKEN_OVER_INTERRUPT_TEXT for as long as the taken-over run lasts.

Note wording

  • Default (SESSION_ASK_TAKEN_OVER_TEXT): "Another message entered this session while it was answering, so the rest of the reply went to that message."
  • When the Claude Code mod knows the person typed it (composer, bridge), it uses SESSION_ASK_TAKEN_OVER_BY_PERSON_TEXT: "You typed into this session while it was answering, so the rest of the reply went to your prompt."

Per host, before → after

Claude Code mod

Before: prompt.submit ignored e.turnId and only recorded the prompt text. The turn kept streaming until turn.complete, and cancel or interrupt called $.turn.abort.

After:

  • Which prompts take over: the prompt must carry the question turn's turnId and come from an allowlisted origin: composer, bridge, slack-ping, channel.
    • peer is deliberately excluded (owner's call): a peer session's reply streaming into the panel is better than losing the reviewer's answer.
    • Never a take-over: peer, task-notification, peer-send-message (notifications framed for the agent), scheduled-trigger, observer*, coordinator, projects-relay, auto-continuation, unclassified (engine idle notices and delivery receipts), and any origin added later.
  • When streaming stops: the moment the prompt reaches the mod's prompt.submit hook, before next(e) runs the hooks beneath it, so a slow hook cannot let the next step stream. The take-over is confirmed when next(e) resolves with the prompt. If a hook beneath drops the prompt, the held output is released and streaming resumes.
  • What happens to the step in flight: its output is held and released when the question settles. That request was sent before the person's prompt, so its output still answers the question.
  • When it settles:
    • At the turn's next turn.step, which carries the person's prompt: done if the previous response stopped with end_turn and some text was shown, otherwise taken_over.
    • At turn.complete, if no step followed: done with the turn's answer. The person's prompt then runs as its own turn, which is not claimed.
  • Another plugin's prompt: it arrives at turn.start wrapped in "The plugin sent a message:", so the text guard never matched it. takeForeign now also compares with that frame removed, so another plugin's turn is never claimed.

Pi

Before: a message_start with role user was ignored. Streaming ran until agent_end, and Stop called ctx.abort() on the person's steered work.

After:

  • A take-over is either a user message, or a custom message other than ours that is delivered right after a turn_start (a steer or follow-up).
  • A non-triggering, display-only custom message, such as plannotator-handoff, is appended by Pi at turn_end, outside that window, so it is not a take-over.
  • The taken-over flag holds until agent_settled, or until the session is idle on older Pi, so an error retry of the person's run cannot be interrupted either.

OpenCode 2

Before: session.inbox.delivered for another id was ignored. Deltas streamed until the run ended, and session.interrupt stopped everything.

After:

  • Only another user row takes over. The delivered event carries only the row id, so each row's kind is read from session.inbox.enqueued (item.type). Synthetic, compaction, move, and unseen rows never take over.
  • It must also arrive after ours was delivered and the model began answering. A command's session-URL notice promoted in the same batch as the question is therefore not a take-over.

UI

useAIChat treats session_taken_over as response.notice, not an error, so the partial answer stays visible. SessionAskNote renders the note under it in the document chat panel and the review AI tab.

Back-compat with older CLI servers

The plugin and the binary update separately. An older server reads taken_over as failed, and its UI would then replace the partial answer with the error. To handle that:

  • The pull server now advertises features: ["taken_over"] (SESSION_BRIDGE_POLL_FEATURES) on every poll answer.
  • When a pull host (the mod's bridge.ts, or runPullSessionBridgeClient for OpenCode) talks to a server without that feature, it settles a take-over as done. The answer is the partial text plus the note as its last paragraph (takenOverFallback).
  • Pi always runs in-process with the vendored server, so it is unaffected.

Tests

  • Claude Code mod (turns.test.ts, bridge.test.ts):
    • person types mid-answer
    • streaming holds at hook entry, and a Stop while the prompt is pending aborts nothing
    • a dropped prompt releases the hold
    • an end_turn before the next step settles as done
    • the turn completes first
    • note wording per origin
    • a thinking-only end_turn settles as taken_over, not an empty done
    • excluded origins (peer, task-notification, peer-send-message, scheduled-trigger, observer, observer-activity, coordinator, projects-relay, auto-continuation, unclassified, an unknown kind) never take over
    • another plugin's framed turn is never claimed
    • text constants match the provider's
    • end to end against the real pull server, plus the older-server fallback
  • Pi:
    • person steers: Stop and interrupt leave the run alone
    • another extension's steer is a take-over
    • a display-only custom message is not
    • a follow-up after a finished answer settles as done
    • a turn that called tools is not a finished answer
    • the run stays protected across an error retry until agent_settled
    • through the provider
  • OpenCode:
    • a user row is a take-over; Stop does not interrupt, and interrupt() rejects
    • rows promoted in the same batch are not a take-over
    • synthetic, compaction, move and unseen rows are not a take-over
    • a user row after finish: "stop" settles as done
    • a step that called tools with finish: "stop" is not a finished answer
    • text constants match the provider's
  • packages/ai:
    • the provider maps taken_over
    • the pull bridge carries it, and poll answers carry features
    • the pull client falls back against an older server
  • packages/ui (DOM): the note is kept under the streamed text.

Commands run

  • bun test packages/ai apps/hook apps/pi-extension apps/opencode-plugin: 1312 pass, 0 fail.
  • DOM_TESTS=1 bun test on the two changed UI test files: 9 pass.
  • bun run typecheck, including the mod tsconfig: clean.
  • CLAUDE_CONFIG_DIR=$(mktemp -d) sh scripts/test-claude-code-mod.sh: 14 pass, 0 fail.
  • claude plugin validate apps/hook: passed.

🤖 Generated with Claude Code

backnotprop and others added 3 commits October 5, 2026 00:15
…on every host

When the person typed into the session while it answered a Plannotator
"Ask this session" question, the reply to THEIR prompt kept streaming into
the Ask AI panel as the answer, and a reviewer's Stop (or "Interrupt and ask
now") aborted the turn that now carried the person's own work.

One rule, per host: once a prompt Plannotator did not send enters the turn
answering the question, stop streaming at once, keep what was sent, settle
the question with the new bridge error code `taken_over` (provider:
`session_taken_over` + "You typed into this session while it was answering,
so the rest of the reply went to your prompt."), and never abort that turn
from Plannotator again.

- Claude Code mod: prompt.submit's turnId names the question's turn and the
  origin is not task-notification. Output of the step in flight (requested
  before their prompt) is held and released when the question settles: at the
  turn's next step as taken_over, or at turn.complete as done when no step
  followed. Another plugin's framed prompt ("The <name> plugin sent a
  message:") now matches the foreign guard, so it is never claimed.
- Pi: a user message, or a custom message delivered right after a
  turn_start (steer / follow-up), in the run answering our question. A
  display-only, non-triggering custom message (appended at turn_end) is not.
- OpenCode 2: session.inbox.delivered for another row after ours was
  delivered and the model began answering; a notice promoted in the same
  batch is not a take-over.
- UI: session_taken_over is a note under the partial answer
  (response.notice / SessionAskNote), not an error replacing it.
- Pull bridge accepts taken_over; hosts send the text with it so an older
  server (unknown code reads as failed) still says why.

Co-Authored-By: Claude <noreply@anthropic.com>
…eutral note, finished answers stay done, older-server fallback

- OpenCode 2: a delivery takes the run over only when the row is a USER row,
  read per inboxID from session.inbox.enqueued (item.type); synthetic,
  compaction, move and unseen rows never do.
- Claude Code mod: an allowlist of origins the model must now answer
  (composer, bridge, slack-ping, channel, peer); task-notification,
  peer-send-message, scheduled-trigger, observer*, coordinator,
  projects-relay, auto-continuation, unclassified and future kinds never take
  over. Streaming holds the moment the prompt reaches our prompt.submit hook,
  before next(e); confirmed when it entered, released when a hook beneath
  dropped it. A Stop while it is on its way closes only the question.
- Note wording: neutral "Another message entered this session…" by default;
  "You typed…" only where the mod knows the person typed (composer, bridge).
  Interrupt refusal text is neutral and shared.
- A message arriving after the answer finished (last response stopped with no
  tool call: Claude end_turn, Pi stop, OpenCode finish stop) settles done,
  e.g. Plannotator's own decision follow-up on Pi.
- Pi: the taken-over flag holds until agent_settled (idle on older Pi), so an
  error retry of the person's run cannot be interrupted.
- Older CLI servers: the pull server advertises `features: ["taken_over"]` on
  every poll answer; a pull host (mod bridge.ts, runPullSessionBridgeClient)
  talking to a server without it settles a take-over as done with the partial
  answer plus the note (takenOverFallback), so an older UI never replaces the
  partial answer with an error.

Co-Authored-By: Claude <noreply@anthropic.com>
…peer is not a take-over

- OpenCode: a step that started a tool call (session.tool.input.started) is
  never a finished answer, even when its finish reads "stop" (some
  OpenAI-compatible providers report that on tool-calling steps).
- Claude Code mod: the end_turn path settles done only when some answer text
  was shown; a thinking-only final response settles as taken_over, as on Pi
  and OpenCode.
- Claude Code mod: `peer` leaves the take-over allowlist (owner's call: a
  lost answer is worse than a peer's reply streaming into the panel).

Co-Authored-By: Claude <noreply@anthropic.com>
@backnotprop
backnotprop merged commit ed04a1e into main Oct 5, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant