fix(opencode): surface upstream errors and retries (#1433)

* fix(opencode): report why a prompt failed instead of pointing at logs

The provider's own explanation already reaches this process: the ACP
transport rejects session/prompt with the JSON-RPC error message
verbatim. The launcher caught it, logged it, and handed the user a fixed
"OpenCode prompt failed. Check logs for details." — a remote user is by
definition not at the machine holding those logs, so a rate-limited
session simply stopped with no stated reason.

Only the message is used; that channel carries no response headers,
cookies or body. It is unbounded though (a 20KB provider body produced a
20,210-character message), so it is capped at the same 200 characters
the compaction bridge already applies to provider text, and the JSON-RPC
"Internal error: " wrapper is stripped. A failure with nothing readable
to say still renders the sentence it always did.

* feat(opencode): surface upstream retries from the agent event stream

A provider rate limit leaves OpenCode retrying indefinitely, and it
announces that on exactly one channel: its own server event stream.
Measured against a provider stubbed to answer 429 — 40 minutes, 85
retries, zero ACP notifications, zero stderr bytes, session/prompt never
settling. HAPI showed a session that looked like it was thinking and
said nothing else.

Subscribes to that stream once the ACP session id is known and reports
retries as the same api_error system message Claude sessions already
use, so the web timeline folds a run of them into one block whose
attempt count climbs. The reason rides along in the payload, and the
presentation now appends it to "Retrying..." when an agent supplies one;
sessions that supply nothing render exactly as before.

Not every attempt is announced. The backoff tops out at 30 seconds and
OpenCode does not give up, so a session held against a daily quota would
otherwise persist two messages a minute for as long as it is left
running. The first few attempts are reported, then only attempt numbers
that are powers of two, which needs no clock to decide.

The subscription must be scoped with ?directory=: without it the
endpoint delivers heartbeats and no session events at all, with neither
an error nor a 404 to notice. Its session.error event is deliberately
not read — it carries the Authorization header, cookies and the full
response body verbatim, and the same failure already reaches the user
stripped to a message through the ACP prompt error.

Delegated turns are not covered: OpenCode's subagent tool runs them in a
child session with its own id, and this follows only the one it was
opened for.

No countdown is rendered, though the payload offers one: a timeline
block outlives the turn it describes. Turn state is left alone; a
retrying session really is busy.

* fix(opencode): only relay prompt failures OpenCode itself reported

AcpStdioTransport flattens a JSON-RPC error response to
new Error(response.error.message), so a rejected session/prompt is
indistinguishable by shape from an error the transport built locally.
Its process-close error appends up to 4KB of raw subprocess stderr to
the message, which the formatter would then have published to the hub
and every connected client.

Requiring the "Internal error: " wrapper OpenCode puts on every service
failure turns this into an allowlist: an unrecognised rejection renders
the sentence this path rendered before rather than whatever it happened
to contain. Retry text is collapsed to one line before it is judged,
since a whitespace-only message is truthy and embedded newlines break
the one-line contract the helper states.
This commit is contained in:
Junmo Kim
2026-08-10 10:48:48 +08:00
committed by GitHub
parent ffd0c752ca
commit 495fa53465
8 changed files with 1861 additions and 6 deletions
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import {
OPENCODE_PROVIDER_TEXT_MAX_LENGTH,
formatOpencodePromptError,
truncateOpencodeProviderText
} from './opencodeErrorText';
/** The wording this path rendered before the reason was wired through. */
const FALLBACK = 'OpenCode prompt failed. Check logs for details.';
describe('truncateOpencodeProviderText', () => {
it('leaves text that already fits untouched', () => {
const text = 'Rate limit exceeded: free-models-per-day.';
expect(truncateOpencodeProviderText(text)).toBe(text);
});
it('caps text no provider bounds, marking that it was cut', () => {
// Measured: a provider answering a rate limit with a 20KB body
// produces a 20,210-character message, forwarded verbatim.
const capped = truncateOpencodeProviderText('x'.repeat(20_210));
expect(capped).toHaveLength(OPENCODE_PROVIDER_TEXT_MAX_LENGTH);
expect(capped.endsWith('…')).toBe(true);
});
});
describe('formatOpencodePromptError', () => {
it('states the provider reason the ACP transport already delivered', () => {
const error = new Error('Internal error: Rate limit exceeded: free-models-per-day. Add credits to unlock 1000 free models.');
expect(formatOpencodePromptError(error)).toBe(
'OpenCode prompt failed: Rate limit exceeded: free-models-per-day. Add credits to unlock 1000 free models.'
);
});
it('does not relay a rejection that arrives without the JSON-RPC wrapper', () => {
// The wrapper is the only evidence the text came back over the wire
// from OpenCode rather than being built locally by the transport.
expect(formatOpencodePromptError(new Error('Session not found'))).toBe(FALLBACK);
});
it('does not mistake a message that merely mentions an internal error for the wrapper', () => {
// Anchored: only a message that starts with it is agent-authored.
expect(formatOpencodePromptError(new Error('The provider reported an Internal error: upstream')))
.toBe(FALLBACK);
});
it('reads a rejection thrown as a bare string', () => {
expect(formatOpencodePromptError('Internal error: Overloaded'))
.toBe('OpenCode prompt failed: Overloaded');
});
it('reads a rejection thrown as a plain JSON-RPC error object', () => {
expect(formatOpencodePromptError({
code: -32603,
message: 'Internal error: Overloaded',
data: { service: 'session', errorName: 'APIError' }
})).toBe('OpenCode prompt failed: Overloaded');
});
it('collapses embedded newlines so one failure stays one timeline line', () => {
expect(formatOpencodePromptError(new Error('Internal error: Rate limit exceeded.\n\n Try again later.')))
.toBe('OpenCode prompt failed: Rate limit exceeded. Try again later.');
});
it('caps a message no provider bounds', () => {
const formatted = formatOpencodePromptError(new Error(`Internal error: ${'y'.repeat(20_210)}`));
expect(formatted).toBe(`OpenCode prompt failed: ${'y'.repeat(OPENCODE_PROVIDER_TEXT_MAX_LENGTH - 1)}…`);
});
it('keeps the original wording when there is no message', () => {
expect(formatOpencodePromptError(new Error(''))).toBe(FALLBACK);
expect(formatOpencodePromptError(new Error(' '))).toBe(FALLBACK);
});
it('keeps the original wording when the wrapper is all there was', () => {
// A bare "Internal error:" says nothing the fallback does not.
expect(formatOpencodePromptError(new Error('Internal error: '))).toBe(FALLBACK);
});
it('does not relay a rejection OpenCode did not author', () => {
// AcpStdioTransport builds this one itself on process close and
// appends up to 4KB of raw subprocess stderr to the message. Relaying
// it would publish local stderr, credentials included, to the remote
// timeline.
const closeError = new Error(
'ACP process exited (code=1, signal=null). stderr: AWS_SECRET_ACCESS_KEY=wJalrXUt shutting down'
);
const formatted = formatOpencodePromptError(closeError);
expect(formatted).toBe(FALLBACK);
expect(formatted).not.toContain('AWS_SECRET_ACCESS_KEY');
expect(formatted).not.toContain('stderr');
});
it('does not relay the transport\'s own timeout', () => {
expect(formatOpencodePromptError(new Error("ACP request 'session/prompt' timed out after 1000ms")))
.toBe(FALLBACK);
});
it('keeps the original wording for a rejection that is not an error at all', () => {
expect(formatOpencodePromptError(null)).toBe(FALLBACK);
expect(formatOpencodePromptError(undefined)).toBe(FALLBACK);
expect(formatOpencodePromptError(42)).toBe(FALLBACK);
expect(formatOpencodePromptError({ code: -32603 })).toBe(FALLBACK);
});
});