Skip to content

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

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.

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