Skip to content

fix(session): order messages by time so the run loop can terminate - #38798

Open
dkindlund wants to merge 2 commits into
anomalyco:devfrom
dkindlund:fix/latest-message-ordering
Open

fix(session): order messages by time so the run loop can terminate#38798
dkindlund wants to merge 2 commits into
anomalyco:devfrom
dkindlund:fix/latest-message-ordering

Conversation

@dkindlund

@dkindlund dkindlund commented Jul 25, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38791

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

latest() and the run loop's exit test find the newest message by comparing ids as plain strings. That works because Identifier.ascending() puts a timestamp in the first 12 chars, so ids happen to sort chronologically. Nothing enforces that for sessions coming in through opencode import.

I hit this importing Claude Code sessions with a converter that emitted msg_<uuid>. If any imported id sorts above the ids of the live conversation, latest() hands back that old message as the newest one. lastUser.id < lastAssistant.id is then false forever, the loop never breaks, and it keeps re-requesting. Each retry ends on an assistant message, which Anthropic rejects with "does not support assistant message prefill" — the user just sees "Unexpected server error".

It looks flaky rather than broken because it depends on whether any random id happens to sort high: about 2.6% of them do, so a 3-message session usually survives and a 400-message one never does. That's what put me onto ids in the first place — the same small session passed once and failed once.

The fix is to compare time.created and fall back to the id when timestamps match. Nothing changes for normal sessions, since native ids already sort in timestamp order — it only differs when the ids were never chronological to begin with. I applied it in latest() (user, assistant, finished, and the tasks filter, which assumed the same thing) and in the loop's exit test.

I did not touch the finish === "stop" handling. Breaking out purely on the finish reason would also fix my case, but the comment right above says some providers return stop with tool calls still pending, so that seemed like a behaviour change rather than a bug fix.

How did you verify your code works?

Added packages/opencode/test/message-latest-ordering.test.ts and checked it fails first: 2 pass / 2 fail on dev, 4 pass / 0 fail with the change. The failing assertion is the loop's own precondition — latest() returned the 1000ms assistant instead of the 3000ms one.

Before writing the test I confirmed the mechanism against a real provider by putting a proxy in front of it. The successful request came back stop_reason: "end_turn" with message_stop, and opencode still issued another request one second later with the assistant reply appended. I also ruled out plugins (same failure under --pure), missing step-start/step-finish parts, unpaired tool_use, and unsigned thinking blocks — and replaying the captured payload on its own returns 200, so the history itself was valid.

Screenshots / recordings

N/A, not a UI change.

Checklist

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

`latest()` and the run loop's exit test both compare message ids as plain
strings. That holds for ids from Identifier.ascending(), which embed a
timestamp, but nothing enforces it for sessions that arrive via
`opencode import`. When an imported id sorts above the live conversation,
`latest()` returns the wrong newest message, the exit test never passes, and
the loop re-requests forever. Each continuation ends on an assistant message,
so providers that disallow prefill reject it:

    This model does not support assistant message prefill.
    The conversation must end with a user message.

The user sees a generic "Unexpected server error"; the response that triggered
the retry was a clean `stop_reason: "end_turn"` with `message_stop`.

Failure is probabilistic in session length — roughly 2.6% of random hex ids
sort above a freshly minted native id, so ~8% of 3-message sessions and
essentially 100% of 400-message ones are affected. That matches what I saw in
practice, including a small session that failed only intermittently.

Order by `time.created` and keep the id as the tiebreaker, in `latest()` (user,
assistant, finished, and the `tasks` filter, which made the same assumption)
and in the loop's exit test. Behaviour is unchanged for natively minted ids,
where time and id order already agree.

Adds a regression test that fails on the current code and passes with the fix.

Refs anomalyco#38791
@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. needs:issue labels Jul 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@josephtingiris

Copy link
Copy Markdown

lexicographic ID comparison is fundamentally fragile

It assumes.

  1. IDs are always ULIDs with embedded timestamps
  2. No two messages are created in the same millisecond
  3. Compaction doesn't rewrite messages with new IDs
  4. Imported sessions use time-sortable IDs

None of these are guaranteed.

…rdering

filterCompacted emits messages out of chronological array order and mints fresh
ids for rewritten messages. Add a regression test asserting latest() resolves
the newest user/assistant/finished by time (id as same-ms tiebreaker), never by
array position -- so a pre-compaction retained-tail assistant is not mistaken
for the most recent turn.

Co-Authored-By: Claude <noreply@anthropic.com>
@dkindlund

Copy link
Copy Markdown
Author

Thanks — useful framing. Mapping the four assumptions to what this PR actually changes:

1 & 4 (ULID / imported ids): agreed, that's the root cause — and it's exactly what this removes. latest() and the loop-exit now key on time.created, with the id only as a same-millisecond tiebreaker, so an imported id that sorts high no longer masquerades as the newest message.

2 (same millisecond): already covered for natively-minted ids — Identifier.create() packs timestamp * 0x1000 + counter (a monotonic per-ms counter), so two messages in the same ms still sort in creation order via the id tiebreaker. For imported sessions time.created is authoritative. The only unrecoverable case is an importer that collapses every message to a single timestamp and emits non-monotonic ids — at that point the ordering information is gone in the data itself; no consumer-side comparison can reconstruct it (an importer bug). I deliberately did not use array position as the tiebreaker: filterCompacted reorders the array (compaction-user, summary, …retained tail…, continue-user), so position isn't chronological — which is the whole reason latest() scans for the max instead of trusting order.

3 (compaction rewriting ids): compaction mints fresh ids via the same ascending() (so their embedded time is current), and latest() already derives by max time/id specifically to survive that reorder. I've added a regression test — survives compaction: array is reordered and messages are rewritten with new ids — that builds a compaction-reordered history with rewritten ids and asserts the newest user/assistant/finished still resolve by time, not position.

On the broader point: you're right that comparing message order to decide loop termination is a heuristic, not a proof. This PR makes the heuristic correct for every case the data can actually support. A fully ordering-independent exit (track that this iteration produced a finished assistant with no pending tool calls, rather than comparing last-user vs last-assistant) is a larger change — happy to open it as a follow-up so it can be reviewed on its own merits. This PR stays the minimal, behavior-preserving fix for the reported infinite loop, with the failing→passing regression test as the precondition.

@josephtingiris

josephtingiris commented Jul 30, 2026

Copy link
Copy Markdown

yw. appreciate the effort too. this patch helped validate my concerns, but it doesn't fix the issues i was having with opencode's message ordering. not implying this causes issues. for me, it behaves nearly identical to an unmodified version. root causes predate this.

i just tested this pr on one of my rigs and here are my results ...

tl;dr the 'exiting loop' strikes back like the imperial march. repeatedly. every test abruptly ended the runLoop prematurely.

a bit more. it's like flying blind around here. there's virtually no telemetry in this patch (or the core) to track message state. knowing really is half the battle. model context windows are tiny (a 1M window really is still very tiny) and they rarely see the big picture without extremely (painstakingly) well written prompts that might as well be code because they're so detailed. imho of course.

i used the same two initial prompts and same model (gemma4:26b) for all tests. tests setup is identical to other issues as well.

  • my first run improperly hit 'exiting loop' at step=6
  • my second improperly hit 'exiting loop' at step=7
  • my third improperly hit 'exiting loop' at step=6
  • my fourth improperly hit 'exiting loop' at (apparent) step=9 (or is it 10?)

sequences obviously garbled. raw logs are attached. from a tui perspective, all tests get cut off mid-stream and results are not return (but are given) and maybe my analysis is wrong, i.e.

opencode-pr38798-alice4

opencode-pr38798-alice1.log
opencode-pr38798-alice2.log
opencode-pr38798-alice3.log
opencode-pr38798-alice4.log

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.

Run loop can never exit when message ids are not time-sortable (imported sessions loop until the provider 400s)

2 participants