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):
- Start a session in
opencode web.
- Send user message U1 that triggers a tool call.
- 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.
- 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:
- 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.
- 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:
- Start a session through
opencode web.
- 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.
- Let both runs proceed to terminal
finish: "stop".
- 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
Description
In
opencode webonv1.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 hadfinish: "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-
parentIDsibling 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.
parentID: MessageIDas a single required reference (packages/opencode/src/session/message-v2.ts:460).lastUserit observes (packages/opencode/src/session/prompt.ts:1248-1305).tasktool 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):
opencode web.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 fromloop()atpackages/opencode/src/session/prompt.ts:1488.SynchronizedRef.modifyEffectatpackages/opencode/src/effect/runner.ts:115-138.sessionIDalone (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 viaEffect.forkIn(scope, { startImmediately: true }). When two such requests arrive within the lock-acquisition window, both fibers are scheduled before either acquires theSynchronizedRef. The lock serializes execution of the tworunLoops, but eachrunLoopindependently resolveslastUserand emits its own assistant message under the sameparentID.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 singlerunLoop, each iteration re-resolveslastUserfrom 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 existingrunLoopwrites its post-tool assistant message under the new user, abandoning the original turn's tool branch. A concurrentrunLoopdispatched by U2 then writes a second assistant under the samelastUser, producing the same-parentIDsibling pair.Two invariants seem to need enforcement, possibly together:
runLoopshould bind to the user message that initiated the turn for the duration of its tool continuations, not re-resolvelastUserper iteration.runLoopentries.This is the assistant-message analogue of the user-message race fixed in #24773.
Plugins
oh-my-opencode/oh-my-openagentBelieved 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.4Steps to reproduce
I do not yet have a minimal clean-room reproduction, but the trigger shape — independent of the specific plugin — is:
opencode web.sessionIDwhile 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.finish: "stop".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:
Sanitized session/message shape
Sanitized plugin-side timing shape
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):
parentIDparentIDResolved sibling race in the user-message path (this report is the assistant-message analogue):
createUserMessage()runs outside the concurrency gate #24773 —createUserMessage()ran outside the concurrency gate, allowing duplicate user messagesSame family, different root cause (kept for context, not duplicates):
Plugin-side tracking:
Operating System
WSL
Terminal
opencode web