You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(opencode): stop treating an empty fault-state response as a finished turn
A provider that finishes with `finish: "error"` having produced nothing was being recorded as a normal
terminal state. The reason went onto the assistant message, no error object was ever built, `process()`
returned "continue", and the next pass through the prompt loop found a `finish` that was not "tool-calls"
with no tool calls beside it and simply broke — the `exiting loop` log, then idle. No error, no retry, and
from the outside an agent that stops mid-task for no stated reason. Bedrock does this intermittently:
eight times in one 48-hour stretch on `openai.gpt-5.6-sol`.
The empty case is now raised as a failure from the `step-finish` handler, before the snapshot and before
any part is written, so five spent attempts leave nothing behind in the timeline. That single failure is
the whole fix: `Effect.retry(SessionRetry.policy(…))` already wraps the stream drain with `llm.stream()`
inside it, so it buys the bounded five attempts, the backoff, the `retry` status, and — once those are
spent — `halt` storing a visible error and returning "stop". retry.ts, prompt.ts and the AI SDK adapter
are untouched. The neighbouring `provider-error` branch has always converted a stream event into a throw
exactly like this.
`ProviderError.ResponseStreamError` carries it because message-v2.ts already maps that to a retryable
`APIError`, and `APIError` is one of the eight members of the closed `AssistantErrorSchema` union. A
purpose-made error type would have to be added to that union, to core, and to the generated SDKs — which
this fork never regenerates — and any client on an older build would then fail to decode the message it
was stored on. The other candidate, `APICallError`, requires a fabricated `url`.
Raising it in the processor rather than the adapter is deliberate: two runtimes reach this state, the AI
SDK adapter passing the reason through and native Gemini's MALFORMED_FUNCTION_CALL, and a guard in the
adapter would silently miss Gemini. Both arrive here as one `step-finish`.
A turn that produced output must never be replayed — the retry re-sends the original request, so text
would be duplicated and tools would run twice. Those are reported instead: `"error"` after any output sets
a non-retryable `APIError` on the message and publishes it, which stops the loop with something on screen,
mirroring what prompt.ts already does for a content filter. Reasoning counts as output for this purpose,
since the reader has already seen it. `"unknown"` is replayed only when empty; on a turn that said
something it is an ordinary completion, and several providers return one.
Tests were confirmed to fail without the guards, not merely to pass with them: five of the six new
processor tests fail when both are removed, and the sixth asserts the guards do *not* fire on a normal
finish. The retry side gets the one genuinely new assertion — that this error classifies as retryable;
the five-attempt bound does not vary by error and is already covered.
Not reproducible on demand, so the tests are the verification. In production the signal is that
`exiting loop` should now be preceded by a `retry` status or a stored `APIError`; a message with
`finish="error"`, `error=null` and a straight drop to idle would mean the condition missed a case.
Upstreamable, and worth dropping once upstream lands an equivalent — anomalyco#31430 and anomalyco#41469,
where PR anomalyco#40531 covered only the `unknown` case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0 commit comments