opencode implements its `format` option as a forced tool call. Thinking models
refuse a forced tool choice outright, so every turn against one died on a
provider 400 mentioning `tool_choice`, and the user got the provider's raw words
back.
The turn is retried once with `format` omitted entirely — not set to undefined,
absent, which `opencode-body.test.ts` pins because the two are different
requests on the wire. The model is memoized process-wide so later turns skip the
doomed attempt, and one warning row per model explains it in the timeline rather
than once per turn. The retry is guarded on the turn having done no work
(`state.openedTools.size === 0`) and not having been aborted; only
`nextBlockIndex` carries into the fresh reduce state.
Dropping `format` would drop the contract with it, so
`structuredOutputInstruction()` puts it in the system prompt instead. It is
derived from `EDIT_OUTPUT_JSON_SCHEMA` rather than restating it, because a
restated schema is a second source of truth that drifts. It rides the opencode
prompt only; claude and codex are unchanged. The system prompt is now sent on
resumed turns too, which costs the preamble on every resume and is the price of
the contract surviving one.
If the retry is rejected as well, the session is poisoned: `sessionId` and
`checkpointId` are withheld so nothing resumes it, and the message says what to
do rather than quoting the provider.
Two adjacent bugs found on the way in:
- `splitStructured` took the earliest opener and hoped. A prose ```css fence
ahead of the real payload won, the extractor returned the wrong span, and the
raw JSON was released into the transcript. It now enumerates every
wrapper, fence and bare-brace candidate, caps at 32, and picks the first whose
contents validate against `EditStructuredOutputSchema` — falling back to the
old semantics while nothing validates yet, so a mid-stream partial still holds
back.
- `session.prompt`'s HTTP-level `{ error }` was never read, so an HTTP rejection
could pass as a clean turn with no edits. `toMessageError` normalizes it, and
`isForcedToolChoiceRejection` classifies structurally — status 400 or absent,
plus `tool_choice`/`toolChoice` in the message or the response body — rather
than by matching provider prose.
The server half is the consequence of withholding `sessionId`. `resolveResume`
becomes a bounded walk up the `parentJobId` chain: three hops, a cycle guard, and
an agent-kind check on every hop rather than only the first. A mid-thread turn
whose adapter withheld its session falls through to the last good one instead of
silently starting a fresh conversation.