Skip to content

fix(opencode): prevent same-parent assistant siblings during concurrent prompts (#28202)#28488

Open
ririnto wants to merge 2 commits into
anomalyco:devfrom
ririnto:fix/session-prompt-race-28202
Open

fix(opencode): prevent same-parent assistant siblings during concurrent prompts (#28202)#28488
ririnto wants to merge 2 commits into
anomalyco:devfrom
ririnto:fix/session-prompt-race-28202

Conversation

@ririnto
Copy link
Copy Markdown

@ririnto ririnto commented May 20, 2026

Issue for this PR

Closes #28202

Type of change

  • Bug fix

What does this PR do?

runLoop re-resolved lastUser from MessageV2.latest(msgs) on every iteration. When a second user prompt landed during U1's tool round-trip, U1's post-tool continuation rebound to U2's parentID, orphaning U1's tool branch and producing same-parent assistant siblings.

The fix restructures runLoop into an outer per-turn loop with initialUser pinned as a const and an inner per-iteration loop, with a nextUser carrier for U(n)→U(n+1) transitions and a done sentinel so compaction.prune cleanup stays reachable. The terminal-exit branch additionally requires lastAssistantMsg.parentID === initialUser.id so the first iteration of a newly transitioned turn cannot exit on the previous turn's terminal assistant before calling the model. Multi-turn within a single runLoop covers the second prompt without spinning up a concurrent runLoop that would write under the same parent.

Helper lastAssistant is renamed to finalAssistant to remove a shadow against the destructured assistant: latestAssistantInfo, which had made the early return yield* lastAssistant(sessionID) paths inside the inner loop resolve to the message object rather than the function.

CLI was not affected because its transport in cli/cmd/run/stream.transport.ts enforces a client-side single-turn queue via state.wait + the session-idle event; only the Web path can fire concurrent prompts at promptAsync.

How did you verify your code works?

Three regression tests in test/session/prompt.test.ts:

  1. Tool round-trip race — U1 emits a tool call; U2 is dispatched while the tool result is gated; gate releases. Asserts exactly 2 assistants under U1 (tool + continuation) and exactly 1 under U2.
  2. U1→U2→U3 sequential chain — exactly one terminal assistant per parent.
  3. U2 with its own tool round-trip after U1 completes — U2's tool branch stays under U2.

Determinism via Deferred-gated llm.hold, llm.wait, pollWithTimeout, and Effect.forkChild (no fixed sleeps).

Manual verification of the red→green chain: checking out the test commit alone (before the fix) reproduces all 3 failures; applying the fix turns them green. bun run typecheck clean; bun test test/session/prompt.test.ts 56/56 pass.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

ririnto added 2 commits May 21, 2026 01:40
…gressions (anomalyco#28202)

Three bun:test cases covering the assistant-sibling race described in anomalyco#28202:

1. 'does not fan out assistant siblings when a new prompt arrives during a
   tool round-trip' — demonstrates the original bug: U2 arrives mid tool
   round-trip and U1's tool branch is silently orphaned or U2 produces no
   response under its parentID.
2. 'U1->U2->U3 chain produces exactly one terminal assistant per parent' —
   forward-looking coverage that overlapping but serially-resolved prompts
   each receive exactly one terminal assistant under the correct parentID.
3. 'U2 with tool round-trip lands under U2, not U1' — after a U1->U2 turn
   transition, U2's own tool continuation must stay bound to U2.

Determinism uses Deferred-gated llm.hold, llm.wait, pollWithTimeout, and
Effect.forkChild — no fixed sleeps. Tests at this commit demonstrate the
bug; the next commit fixes runLoop and turns them green.
…Loop (anomalyco#28202)

runLoop now runs as nested loops: an outer per-turn loop and an inner
per-iteration loop. Each turn captures initialUser as a const at outer-
block entry so all iteration sites in the turn (parentID, model resolution,
agent lookup, format, summary, system reminders, compaction) reference a
single pinned user message. A nextUser carrier propagates U(n)->U(n+1)
transitions between turns; a done sentinel exits the outer loop so the
trailing compaction.prune cleanup remains reachable.

The terminal-exit branch additionally requires lastAssistantMsg.parentID
=== initialUser.id. Without this, the very first iteration of a newly
transitioned turn observes the prior turn's terminal assistant via
MessageV2.latest() and exits before ever calling the model for the new
user, leaving U(n+1) without a response.

Function lastAssistant (the helper that returns the most recent assistant)
is renamed to finalAssistant to remove a shadow against the destructured
'assistant: lastAssistantMsg' inside the inner loop — previously an early
'return yield* lastAssistant(sessionID)' inside the inner block resolved
to the destructured message object and was unreachable as a callable.

Resolves the same-parentID assistant fan-out and the orphaned tool branch
described in issue anomalyco#28202. Three regression tests added in the previous
commit now pass.
@ririnto
Copy link
Copy Markdown
Author

ririnto commented May 20, 2026

Simply, this PR solves the issue. When a new prompt arrives at the same time as a tool call output, two assistant messages get created under the same parent.

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.

[Bug] Plugin async prompts can overlap with Web prompt_async and create same-parent assistant siblings

1 participant