Repository navigation
Run loop can never exit when message ids are not time-sortable (imported sessions loop until the provider 400s) #38791
Description
Summary
SessionPrompt.runLoop decides a turn is finished by comparing message ids as plain strings. That is only correct because opencode's own ids embed a timestamp. Any session whose ids do not sort chronologically — e.g. one produced by a third-party importer — can put the loop in a state where it never exits: opencode re-requests indefinitely, each request ending on an assistant message, which providers reject.
On Anthropic the user-visible result is a generic Unexpected server error, with the real cause (This model does not support assistant message prefill) buried.
Where
packages/opencode/src/session/prompt.ts, in runLoop:
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id // <-- lexicographic id comparison
) {
yield* Effect.logInfo("exiting loop", { "session.id": sessionID })
break
}MessageV2.latest() selects the "last" user/assistant the same way.
The invariant this relies on comes from packages/schema/src/identifier.ts: ids are a 12-hex-char prefix encoding timestamp_ms * 0x1000 + counter, plus 14 random chars — so they sort chronologically as strings. Nothing validates that invariant at the import boundary, and it is not documented in the SessionV1 schema.
Reproduction
- Import a session whose message ids are random rather than time-sortable (I hit this with baton, which emitted
msg_<uuid>; a hand-written JSON with random ids reproduces it too). - Resume it and send any prompt.
- The first request succeeds. opencode then immediately issues a second request with the assistant's own reply appended, and the provider returns 400.
Captured with a forwarding proxy in front of the provider:
req 1: 1035312B -> 200 OK response: "stop_reason":"end_turn" + "message_stop"
req 2: 1035378B -> 400 "This model does not support assistant message prefill.
The conversation must end with a user message."
The first response is a clean, complete turn — end_turn, message_stop — and opencode continues anyway.
Failure is probabilistic in session length, because ~2.6% of random hex ids sort above a freshly generated native id:
| messages | P(at least one imported id outranks the live ones) | observed |
|---|---|---|
| 3 | ~8% | worked |
| 6 | ~15% | worked once, failed once |
| 423 | ~100% | failed every attempt |
Ruled out by testing, in case they look like suspects: plugins (reproduced with --pure), missing step-start/step-finish parts (synthesised them, no change), unpaired tool_use, unsigned thinking blocks, and empty message content. It reproduces with a structurally valid history — I replayed the exact captured payload standalone and it returned 200.
Suggested fixes
Either would resolve it; the first is the smaller change:
- Order by
time.created(falling back to id) in the loop test and inMessageV2.latest(), instead of comparing id strings. - Break on a terminal finish reason regardless of ordering — if
lastAssistant.finish === "stop"and there are no pending tool calls, the turn is over; the id comparison is then only a tiebreaker.
Worth considering alongside: a continuation request that would end on an assistant message is invalid for providers that disallow prefill, so guarding that at the request-building layer would turn this class of bug into a clear error rather than a loop.
Related
- V2: errored-message replay can 400 with Anthropic thinking + tool use #38620 — same seam (replay 400 with thinking + tool use) but a different trigger (
message.error !== undefined); does not cover id ordering. - Interrupted tool calls permanently corrupt session history (orphaned tool_use without tool_result) #21326, fix(provider): Anthropic normalize splits tool-call from tool-result causing dangling tool_use error #25774 — orphaned/dangling
tool_use; also this area, different cause.
Environment
opencode 1.18.4 · macOS arm64 (container, linux/arm64) · Anthropic-compatible endpoint (LiteLLM), claude-opus-4-8, thinking: adaptive.
Note on the provider side
Not an opencode bug, but it made diagnosis much harder: when the upstream rejected the request, the gateway's fallback chain retried on models that do not support thinking: adaptive, so both the original and fallback errors were collapsed into one opaque message. Surfacing the provider's error.message verbatim in the session log would have saved a lot of time here.
Activity
github-actions commentedon Jul 25, 2026
This issue might be a duplicate of existing issues. Please check:
runLooplexicographic message ID comparison causes duplicate/infinite LLM calls when assistant ID sorts before user IDYour report is a useful variant (third-party importer with non-time-sortable IDs, leading to an infinite loop and provider 400) and the probability table and proxy captures are valuable evidence. All of these trace to the same
lastUser.id < lastAssistant.idcondition inpackages/opencode/src/session/prompt.ts. It may be worth linking or consolidating with the above issues.dkindlund commentedon Jul 25, 2026
Opened #38798 with a fix: order by
time.createdinlatest()and in the run loop's exit test, keeping the id as the tiebreaker so behaviour is unchanged for natively minted ids.Includes a regression test that fails on the current code (2 pass / 2 fail) and passes with the change (4 pass / 0 fail) — the failing assertion is the loop's precondition itself.
josephtingiris commentedon Jul 25, 2026
Good timing but I'm doubtful that using time created is a complete fix for all api types and models. You're using npm @ai-sdk/antrophic for a provider to litellm? How long was it tested? How many compaction events and cleanup prunes happened during the stress testing? Any other model(s) than opus 4.8? What context size(s) and at what t/s rate(s)? How many slots? How many steps?
dkindlund commentedon Jul 30, 2026
On the testing questions: the change is deterministic ordering logic in
latest()and the loop-exit precondition — it never inspects the provider, model, context size, or token rate, so those axes don't affect its correctness, and the id tiebreaker preserves current behavior for natively-minted ids so native sessions can't regress. The things that do matter — the ordering itself and compaction — are covered by unit tests: the original failing→passing regression (the loop precondition), plus a new compaction-reorder test that exercises a reordered array with rewritten ids.For a deterministic invariant like "the newest assistant must resolve after the newest user," a targeted unit test is a stronger proof than a stress run (a stress run only samples the timestamp distributions it happens to hit; the unit tests pin the exact adversarial orderings). Happy to add more orderings if there's a specific one you'd want covered.
Worth noting this isn't import-specific. The four issues flagged as the same
lastUser.id < lastAssistant.idroot cause — #38787, #28618 (client/server clock skew), #29478 (web-client-generated ids), #30809 — include native, non-import repros (#28618, #29478). So the fix addresses a recurring, multiply-reported class, not just third-party imports.(Setup, since you asked: the
@ai-sdk/anthropicprovider against a LiteLLM gateway. The loop reproduced deterministically on an imported session whose message ids weren't time-sortable — which is exactly what the regression test encodes, so it fails onmainand passes with the change independent of model or load.)feat(bin): add opencode-fix-message-ids session repair tool
helgeu commentedon Aug 14, 2026
Same bug, native trigger: opencode's own id prefix rolled
f/e/d → 0I hit this exact loop, but not from an imported session. My sessions are all native opencode sessions. The id prefix changed on its own.
What I saw in my local DB (
opencode-stable.db):d…e…f…0…The old ids grew through the
d…e…f…range. Then new ids restarted at0…, which sorts before everyd/e/fid. So the moment I resume any pre-Aug-13 session and it appends one new0…message, the session stops being id-sortable.runLoopthen treats an old assistant message as the last message, never exits, and the provider returns the 400:The TUI also renders the session out of order (old block below the new block).
time_createdstays perfectly monotonic the whole time, so the data is fine — only the id-string ordering breaks. This matches your analysis ofrunLoop/MessageV2.latest()comparing ids as plain strings; it is just a different way to break the "ids sort chronologically" invariant (an opencode version/id-scheme change, not a third-party importer). It affects native sessions on upgrade, so the blast radius is larger than imported sessions alone.Fix #1 in the issue (order by
time.created, id as tiebreak) resolves my case too.Workaround while PR #38798 lands
I wrote a small script that repairs affected sessions in the DB — the data-side mirror of "order by
time.created". It detects broken sessions (id order ≠time_createdorder), renumbers each session's messages intotime_createdorder (remappingpart.message_id,parentID,session.revert), and drops empty errored assistant turns. It makes a full backup first and runs in one transaction with integrity checks.Script: https://github.com/helgeu/setup/blob/5bfc1ee/nix/bin/opencode-fix-message-ids
Environment: opencode 1.18.13, macOS arm64, github-copilot
claude-opus-4.8.josephtingiris commentedon Aug 14, 2026
i fixed many 400s in #41663 ... might be worth a shot
opencode-agent commentedon Aug 17, 2026
This was fixed in version 1.18.15. Please upgrade to 1.18.15 or newer.