Skip to content

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

Closed

Description

@dkindlund

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

  1. 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).
  2. Resume it and send any prompt.
  3. 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:

  1. Order by time.created (falling back to id) in the loop test and in MessageV2.latest(), instead of comparing id strings.
  2. 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

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

  1. github-actions commented on Jul 25, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    Your 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.id condition in packages/opencode/src/session/prompt.ts. It may be worth linking or consolidating with the above issues.

  2. dkindlund commented on Jul 25, 2026

    @dkindlund
    ContributorAuthor

    Opened #38798 with a fix: order by time.created in latest() 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.

  3. josephtingiris commented on Jul 25, 2026

    @josephtingiris

    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?

  4. dkindlund commented on Jul 30, 2026

    @dkindlund
    ContributorAuthor

    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.id root 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/anthropic provider 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 on main and passes with the change independent of model or load.)

  5. helgeu commented on Aug 14, 2026

    @helgeu

    Same bug, native trigger: opencode's own id prefix rolled f/e/d → 0

    I 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):

    id prefix messages time span
    d… 4,886 Apr 20 → May 6
    e… 22,891 May 7 → Jun 25
    f… 26,098 Jun 25 → Aug 14
    0… ~1,180 Aug 13 → now

    The old ids grew through the d…e…f… range. Then new ids restarted at 0…, which sorts before every d/e/f id. So the moment I resume any pre-Aug-13 session and it appends one new 0… message, the session stops being id-sortable. runLoop then treats an old assistant message as the last message, never exits, and the provider returns the 400:

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

    The TUI also renders the session out of order (old block below the new block). time_created stays perfectly monotonic the whole time, so the data is fine — only the id-string ordering breaks. This matches your analysis of runLoop / 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_created order), renumbers each session's messages into time_created order (remapping part.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

    opencode-fix-message-ids            # detect + list broken sessions (read-only)
    opencode-fix-message-ids --all      # fix all
    opencode-fix-message-ids -s <id>    # fix one
    

    Environment: opencode 1.18.13, macOS arm64, github-copilot claude-opus-4.8.

  6. josephtingiris commented on Aug 14, 2026

    @josephtingiris

    i fixed many 400s in #41663 ... might be worth a shot

  7. opencode-agent commented on Aug 17, 2026

    @opencode-agent
    Contributor

    This was fixed in version 1.18.15. Please upgrade to 1.18.15 or newer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions