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 in MessageV2.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
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.
Summary
SessionPrompt.runLoopdecides 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, inrunLoop: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 encodingtimestamp_ms * 0x1000 + counter, plus 14 random chars — so they sort chronologically as strings. Nothing validates that invariant at theimportboundary, and it is not documented in theSessionV1schema.Reproduction
msg_<uuid>; a hand-written JSON with random ids reproduces it too).Captured with a forwarding proxy in front of the provider:
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:
Ruled out by testing, in case they look like suspects: plugins (reproduced with
--pure), missingstep-start/step-finishparts (synthesised them, no change), unpairedtool_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:
time.created(falling back to id) in the loop test and inMessageV2.latest(), instead of comparing id strings.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
message.error !== undefined); does not cover id ordering.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'serror.messageverbatim in the session log would have saved a lot of time here.