Repository navigation
Bug: Agent loop self-replies when message IDs are non-monotonic (2.8% of sessions) #28986
Description
Bug Description
The agent loop continues generating responses after the model has stopped (finish reason: "stop", no tool calls), causing the AI to reply to its own previous response. This creates a visible self-conversation where the AI answers questions it asked itself.
Root Cause
The agent loop exit condition in SessionPrompt.run uses lexicographic message ID comparison:
// From the compiled binary (SessionPrompt.run loop):
if (L?.finish && !["tool-calls"].includes(L.finish) && !yA && sA.id < L.id) {
break; // exit loop
}Where sA = latest user message, L = latest assistant message.
The sA.id < L.id check assumes message IDs are monotonically increasing with creation time. This assumption is wrong. When the ID generator produces IDs within milliseconds of each other, the ordering can be inverted — the user message ID ends up higher than the assistant message ID, even though the user message was created first.
Evidence
Example from a real session
| Message | ID | Created (ms) | Role |
|---|---|---|---|
| User question | msg_e55166ef1001... |
1779543994009 | user |
| Assistant response | msg_e55166ed1001... |
1779543994065 | assistant |
The user message ID prefix e55166ef is lexicographically higher than the assistant ID prefix e55166ed, despite being created 56ms earlier. So sA.id < L.id evaluates to false, and the loop does not exit.
The model stopped with finish: "stop" and zero tool calls, but the agent loop continued anyway, generating a second response that answered its own question from the first response.
Scale (measured from local database)
- 16,069 user→assistant message pairs created within 1 second of each other
- 453 pairs (2.8%) have inverted IDs
- 143 orphan continuations (self-replies) detected across 36 sessions
- Each orphan continuation wastes tokens and produces confusing output
Self-reply pattern
What the user sees:
AI: No. It's just a cron job that fires every 15 minutes... Want me to disable it?
AI: Yeah, exactly right. The heartbeat is just a cron job with extra steps... Let me disable it.
AI: Heartbeat disabled. Script's still there if we ever need it.
Three consecutive AI responses to a single user question, with the AI treating its own text as new context and continuing.
Suggested Fix
Replace the ID-based comparison with timestamp-based:
// Current (broken when IDs are non-monotonic):
if (L?.finish && !["tool-calls"].includes(L.finish) && !yA && sA.id < L.id)
// Fixed:
if (L?.finish && !["tool-calls"].includes(L.finish) && !yA && sA.info.time.created < L.info.time.created)Alternatively, if the model finished with "stop" and there are no pending tool calls, the loop should simply exit — the additional ID guard may be unnecessary, or could use the assistant's parentID field to verify it's responding to the correct user message.
Environment
- opencode version: 1.15.6
- Model: glm-5.1 (via zai-coding-plan provider)
- Platform: Linux x86_64
- Storage: SQLite (local)
Activity
github-actions commentedon May 23, 2026
This issue might be a duplicate of an existing issue. Please check:
runLoopexit condition uses lexicographic message ID comparison (lastUser.id < lastAssistant.id), which fails when IDs are non-monotonic. runLoop fails to exit when client-generated messageID has clock skew ahead of server, causing infinite continuation with <system-reminder> wrap #28618 documents the same bug triggered by client/server clock skew, with an identical suggested fix (usetime_createdtimestamps instead of ID ordering). The two reports describe the same underlying defect from different angles (local millisecond-resolution collision vs. cross-host clock skew).If you believe this is a distinct scenario worth tracking separately, please add a comment clarifying the difference. Otherwise, consider adding your evidence and reproduction data to #28618.
github-actions commentedon Jul 23, 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.
thib3113 commentedon Aug 1, 2026
I've been hitting this too, and honestly way more often than 2.8% — it felt like it happened all the time, so I decided to dig into my local database and measure it properly.
What I did
I have ~30k messages across ~38 days in
~/.local/share/opencode/opencode.db. I joined each assistant message to its parent user message (via theparentIDfield in themessage.dataJSON) where the pair was created within 1 second of each other, then compared the IDs lexicographically.Query — inversion rate
What I found
So 27.5% here, almost 10x the reported rate. But it gets more interesting when you break it down.
Most of those inversions (824 of them) are on tool-call messages. Those don't actually matter: the loop keeps going for tool-calls anyway, so the ID order is never consulted. The inversion only matters when
finish=stop, because that's the branch where the loop should exit — and that's where it fails.stopmessagesstopmessagesSo every single inverted
stopproduced at least one extra assistant message. Here's the query I used to find those:Query — self-reply detection
The provider angle
I route a lot of traffic through a LiteLLM proxy, so I also split the inversion rate by provider:
Litellm is clearly the worst, but it's not the only one — every provider shows inversions, so this isn't a proxy-only issue.
A concrete example of what it looks like
In one session the model replied to a question, ended with an offer to create a resource and asked "Do you want me to?" — then the loop didn't exit, the model ran again, interpreted its own question as the user confirming, and started creating the resource. The reasoning in the follow-up literally said "the user agrees" when no user had typed anything since the previous message.
The pattern in the data is always the same:
stopmessage created ~39ms later, but with an ID that sorts before the user'sIn short: the 2.8% figure seems to only cover the pairs that inverted. In my dataset 27.5% of pairs invert, and every inverted
stop— 6.4% of allstopmessages — turns into an unwanted extra model run.Fixed by 1.18.16