Repository navigation
Session prompt loop can create duplicate same-parent assistant replies when message IDs are out of order #30809
Description
Summary
We observed a real OpenCode session where one user/system-reminder message persisted multiple terminal assistant siblings under the same parentID, followed by several empty assistant rows. This looks like the same bug family as #29478 and #28202, with an additional concrete trigger involving background task result injection and prompt loop exit logic.
The core issue appears to be that SessionPrompt.runLoop() treats a finished assistant as completing the latest user turn based on message ID ordering:
lastUser.id < lastAssistant.idThat is fragile when callers supply message IDs externally or when message creation order and ID sort order diverge. In that case, a finished assistant from an earlier parent can make the loop exit even though the newest user message has not received an assistant. Later prompt loops can then create more assistant rows for the same parent or produce empty assistant turns.
Environment
- Installed runtime:
opencode-ai@1.15.13 - Binary:
/opt/openclaw/runtime/global/lib/node_modules/opencode-ai/bin/opencode.exe - Session ID:
ses_16c325575ffe4biv2cgOA1BaNX - Local timezone: CST; logs below are UTC
Evidence from the affected session
The parent message was a background-task-ready system reminder:
- Parent user/system-reminder:
msg_e93d70e5d001h32lZrq1GbK3Sp - Content starts with:
<system-reminder>\n[BACKGROUND TASK RESULT READY]...
Multiple assistant messages were persisted with that same parent:
msg_e93d76ac20013nSaa3NwBEoQy4, text length 608, around CST02:13:48msg_e93d77d46001tu2egv3mn3oaPC, text length 564, around CST02:13:53msg_e93d7b5e7001CiRGa0ndJu6OXT, text length 376, around CST02:14:08
Then several empty assistant rows appeared under the same parent, including:
msg_e93d81c2b001OclZKy4KXHDnhRmsg_e93d81e2a001bWEOcJ2dDaFE69
Runtime logs showed overlapping/near-overlapping prompt loops entering the processor for the same session:
2026-06-04T18:14:34 service=session.prompt step=14 loop -> msg_e93d81c2b001OclZKy4KXHDnhR -> session.processor process
2026-06-04T18:14:34 service=session.prompt step=6 loop -> msg_e93d81e2a001bWEOcJ2dDaFE69 -> session.processor process
The DB query used to inspect the rows was:
sqlite3 "/root/.local/share/opencode/opencode.db" "SELECT m.id, datetime(m.time_created/1000,'unixepoch','+8 hours') AS cst, json_extract(m.data,'$.role') role, json_extract(m.data,'$.parentID') parent, json_extract(m.data,'$.tokens.output') out_tok, group_concat(json_extract(p.data,'$.type') || ':' || length(coalesce(json_extract(p.data,'$.text'),'')), '|') parts FROM message m LEFT JOIN part p ON p.message_id=m.id WHERE m.session_id='ses_16c325575ffe4biv2cgOA1BaNX' AND m.time_created BETWEEN 1780596800000 AND 1780596880000 GROUP BY m.id ORDER BY m.time_created;"Related issues
- Web can persist duplicate final answers when client message IDs sort after assistant IDs #29478: duplicate final answers when client message IDs sort after assistant IDs
- [Bug] Plugin async prompts can overlap with Web prompt_async and create same-parent assistant siblings #28202: async/plugin prompts overlap and create same-parent assistant siblings
- Title: User messages are duplicated —
createUserMessage()runs outside the concurrency gate #24773:createUserMessage()runs outside the concurrency gate - Dead loop: infinite empty assistant messages (tokens=0) after tool call with empty text #28507: empty assistant turns after model/tool transitions
- [BUG] Queued SessionPrompt.loop() callers can hang forever on cancel/dispose #14565 / prompt_async does not wake idle sessions (noReply: false returns 204 but no assistant turn) #21524: related queued loop /
prompt_asyncstate-machine behavior
Source-path notes
The path we focused on is:
packages/opencode/src/tool/task.ts:TaskTool.injectBackgroundResultcalls parent-sessionops.prompt(...)packages/opencode/src/session/prompt.ts:SessionPrompt.prompt()creates the user message before callingloop()packages/opencode/src/session/run-state.ts/packages/opencode/src/effect/runner.ts: while a run is active, laterensureRunning()callers share the current run result rather than running the later work immediatelypackages/opencode/src/session/prompt.ts:runLoop()exits when the latest user ID sorts before the latest finished assistant ID, even if the assistant is parented to an earlier user
Minimal reproduction covered by a regression test
The regression test we added locally creates:
- An old user message and a finished assistant with
finish: "stop". - A newer user message supplied with a custom message ID that sorts before the old assistant ID.
- A
prompt.loop()call.
Before the fix, the loop can exit immediately because lastUser.id < lastAssistant.id is false/true in the wrong semantic direction depending on the supplied ID, even though the old assistant belongs to a different parent. After the fix, the loop creates a new assistant for the latest user.
The core patch is to bind completion to the parent relationship instead of ID sort order:
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
- lastUser.id < lastAssistant.id
+ lastAssistant.parentID === lastUser.id
) {
break
}Local validation
On branch fix/session-parent-loop-exit:
bun test --timeout 30000 test/session/prompt.test.ts -t "loop replies to latest user"
# 1 pass
bun test --timeout 30000 test/effect/runner.test.ts
# 26 passlsp_diagnostics showed no errors in:
packages/opencode/src/session/prompt.tspackages/opencode/test/session/prompt.test.ts
The full test/session/prompt.test.ts run currently has one unrelated failure in cancel finalizes interrupted bash tool output through normal truncation where tool.state.metadata.truncated was false instead of true; the new regression test itself passes.
Expected behavior
Once a user/system-reminder message has a terminal assistant response, OpenCode should not create multiple terminal assistant siblings for that same parent because overlapping prompt loops or custom message IDs confused the loop exit condition.
The loop should consider a user turn complete only when the finished assistant is parented to that user message.
Activity
github-actions commentedon Jun 4, 2026
This issue might be a duplicate of existing issues. Please check:
SessionPrompt.runcompares message IDs instead of parent/transcript relationships, breaking custommessageIDs accepted by the APIrunLoopfails to exit when client-generatedmessageIDhas clock skew or ordering divergence, also involving<system-reminder>wrappingIf these cover the same underlying problem, it may be worth consolidating. If your case has a meaningfully different trigger or scope, please clarify the distinction.
github-actions commentedon Aug 4, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.
Futuri-Risk commentedon Aug 15, 2026
I reproduced this failure family in a real session without custom message IDs or clock skew.
OpenCode's own ascending ID generator wraps its effective millisecond timestamp every
2^36ms (about 795.36 days): it multipliesDate.now()by0x1000, then stores six bytes. On OpenCode 1.18.4, the boundary occurred at2026-08-14T11:19:55.136Z.A session's last completed turn had
msg_fff...IDs; its next user messages hadmsg_002...IDs. The messages were persisted, butMessageV2.latest()selected the lexicographically greatest old user, andrunLoop()exited on that old completed user/assistant pair. No assistant child was created for the chronologically latest user, so the visible symptom was a silent non-response.A read-only audit found 209 of 244 Discord-mapped historical sessions would have ignored a newly generated message under 1.18.4 at the time of testing. This is related to #18807, but the trigger here is the deterministic internal timestamp wrap rather than two clocks.
The released v1.18.18 source appears to contain both necessary fixes:
latest()comparestime.created, using ID only as a tie-breaker.lastAssistant.parentID === lastUser.id.A useful regression test would generate messages immediately before and after a
2^36ms boundary and assert that the post-wrap user is selected and receives an assistant child.