Skip to content

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

@ririnto

Description

@ririnto

Description

In opencode web on v1.15.4, I observed a session where a real Web prompt and overlapping async prompt traffic produced multiple terminal assistant siblings under a single user message.

This was not only a visual duplicate in the Web UI. The persisted session data showed one real user message with four terminal assistant messages sharing that same user message as parentID. Each assistant message had finish: "stop", and there was no tool-call continuation explaining the fan-out.

I also observed a more severe variant (described below) where the trigger does not appear to require any plugin involvement: a plain Web user input racing with an in-flight tool continuation reproduces the same-parentID sibling pattern.

This looks like a session/turn-boundary invariant issue: overlapping prompt traffic on the same session should not create multiple independent terminal assistant siblings under one user message, and an in-flight tool continuation should not silently rebind to a newer user message.

Not a "team mode" artifact

To pre-empt the natural question: this is not legitimate team-mode fan-out.

  • OpenCode core has no multi-agent fan-out under a single user message. The Assistant message schema declares parentID: MessageID as a single required reference (packages/opencode/src/session/message-v2.ts:460).
  • The prompt loop creates exactly one assistant message per lastUser it observes (packages/opencode/src/session/prompt.ts:1248-1305).
  • "Team mode" is a plugin-side concept; in the core, subagents are dispatched via the task tool which routes through the same single-loop pipeline rather than as same-parent siblings.

Multiple terminal finish: "stop" assistant messages under a single user message is therefore not a design outcome of any core feature.

Severest observed variant

A more severe variant I observed (with the plugin still installed, but with a trigger that does not appear to require it):

  1. Start a session in opencode web.
  2. Send user message U1 that triggers a tool call.
  3. While the tool round-trip is in flight, send a second user message U2 from the same Web UI, timed to coincide with the tool output being delivered back to the model.
  4. Observe that two terminal assistant messages are created, both with parentID = U2 (not U1). There is no tool-call continuation between them, and U1's tool call appears to be effectively orphaned — its tool result is not followed by a model response under U1's branch.

The plugin was present, so I cannot fully rule it out as a contributing factor, but the trigger is plain Web user input racing with an in-flight tool continuation — a path that does not on inspection require plugin involvement. A clean reproduction without the plugin would confirm this, and I have not yet performed one.

Suggested invariant location

The per-session concurrency guard already exists:

  • state.ensureRunning(sessionID, …) is called from loop() at packages/opencode/src/session/prompt.ts:1488.
  • Implementation uses SynchronizedRef.modifyEffect at packages/opencode/src/effect/runner.ts:115-138.
  • The runner map is keyed by sessionID alone (packages/opencode/src/effect/run-state.ts:34-67), so all prompt entrypoints share the same lock for a given session.

A candidate bypass surface is the async prompt handler at packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:295-315, which forks the prompt effect via Effect.forkIn(scope, { startImmediately: true }). When two such requests arrive within the lock-acquisition window, both fibers are scheduled before either acquires the SynchronizedRef. The lock serializes execution of the two runLoops, but each runLoop independently resolves lastUser and emits its own assistant message under the same parentID.

The severer variant above tightens the picture further. The observed parentID = U2 (the newer user message, not the one that initiated the tool call) points to a more specific defect than a simple lock window. Inside a single runLoop, each iteration re-resolves lastUser from current session state rather than binding to the user message that opened the turn. When a new user message lands between the model call and the tool-result follow-up, the existing runLoop writes its post-tool assistant message under the new user, abandoning the original turn's tool branch. A concurrent runLoop dispatched by U2 then writes a second assistant under the same lastUser, producing the same-parentID sibling pair.

Two invariants seem to need enforcement, possibly together:

  1. A runLoop should bind to the user message that initiated the turn for the duration of its tool continuations, not re-resolve lastUser per iteration.
  2. The per-session guard should serialize the entire turn (initial model call + tool round-trips + final assistant message), not just individual runLoop entries.

This is the assistant-message analogue of the user-message race fixed in #24773.

Plugins

oh-my-opencode / oh-my-openagent

Believed plugin version: 4.2.1, but not confirmed from the provided plugin log. The plugin version is not load-bearing for this report: the invariant being violated belongs to OpenCode core, regardless of which client drives the overlapping prompt traffic. The severest variant above did not require any plugin-originated prompt path to reach the same sibling pattern.

Team mode was enabled.

OpenCode version

v1.15.4

Steps to reproduce

I do not yet have a minimal clean-room reproduction, but the trigger shape — independent of the specific plugin — is:

  1. Start a session through opencode web.
  2. From any source (Web UI, plugin, SDK, second HTTP client), issue a prompt against the same sessionID while another prompt is in flight, such that two prompt fibers arrive within the runner's lock-acquisition window, or issue a fresh user prompt while an existing turn is in the middle of a tool round-trip.
  3. Let both runs proceed to terminal finish: "stop".
  4. Inspect the persisted session messages.

The plugin in my original session reached this shape via its own background-wake / continuation paths. The severer variant reaches it via plain Web user input racing with a tool continuation.

Expected behavior

A real Web prompt should produce one assistant branch for that prompt.

If additional prompt activity overlaps with an in-flight turn on the same session, OpenCode should preserve the session/turn boundary: additional output should be serialized behind a fresh user message, rejected, deferred, or represented as an explicit separate turn. It should not appear as multiple independent terminal assistant siblings under the same parentID, and an in-flight tool continuation should not silently rebind to a newer user message.

Actual behavior

One real Web user message had four terminal assistant siblings sharing the same parentID.

The assistant siblings were terminal finish: "stop" responses, with no tool-call continuation explaining the fan-out.

The duplicate behavior persisted across later turns until the session drained and became quiet again.

In the severer variant, two terminal assistant siblings were produced under the newer user message U2, with U1's tool branch left without a model follow-up.

Evidence

I am keeping raw prompt content out of the issue because it contains private session text.

Local evidence inspected:

session-ses_1c52c37ddffezrIcFQ9H63Cd7K.jsonl
oh-my-opencode.log
Sanitized session/message shape
session:
  ses_1c52c37ddffezrIcFQ9H63Cd7K

real user message:
  id: msg_e3b47bdbe0016eiqJcvSD3F2QI
  created: 1779111017610

assistant siblings:
  id: msg_e3b47cbfc001wtgzIu0Foguq0x
  parentID: msg_e3b47bdbe0016eiqJcvSD3F2QI
  created: 1779111021564
  completed: 1779111032041
  finish: "stop"

  id: msg_e3b47d0a7001Q29TDT1pRlS953
  parentID: msg_e3b47bdbe0016eiqJcvSD3F2QI
  created: 1779111022759
  completed: 1779111032201
  finish: "stop"

  id: msg_e3b47f4f3001r0BTyrS1GzX1Rs
  parentID: msg_e3b47bdbe0016eiqJcvSD3F2QI
  created: 1779111032051
  completed: 1779111037500
  finish: "stop"

  id: msg_e3b47f593001VINaxS1e9CQJRH
  parentID: msg_e3b47bdbe0016eiqJcvSD3F2QI
  created: 1779111032211
  completed: 1779111036746
  finish: "stop"
Sanitized plugin-side timing shape
todo-continuation-enforcer session.idle
todo-continuation-enforcer Agent check
todo-continuation-enforcer Countdown started, incompleteCount: 1

prompt-async-gate promptAsync dispatching
source: background-agent-parent-wake

prompt-async-gate promptAsync dispatched
source: background-agent-parent-wake

background-agent Sent deferred parent wake

todo-continuation-enforcer Ignoring user message in grace period
chat-message Skipping synthetic/internal-only message
context-injector Latest user message is synthetic/internal, skipping injection

The plugin log confirmed OpenCode 1.15.4. The numeric plugin version was not confirmed from that log.

Screenshot and/or share link

No public share link yet.

I can provide sanitized session JSONL / plugin log excerpts if useful.

Related

Likely duplicates (same root cause — Web prompt overlap producing duplicate assistant siblings or runaway loop continuation):

Resolved sibling race in the user-message path (this report is the assistant-message analogue):

Same family, different root cause (kept for context, not duplicates):

Plugin-side tracking:

Operating System

WSL

Terminal

opencode web

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions