Repository navigation
fix(cli): end an opencode turn on its terminal provider error #989
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| /** | ||
| * opencode keeps a turn open forever after a terminal provider error: it logs | ||
| * `message="stream error"` and retries, but never answers the ACP prompt. On | ||
| * 2026-10-22 OpenCode Zen's free quota ran out for every Box (they share one | ||
| * egress IP) and each message hung 90 s, then failed with the generic idle | ||
| * bubble. The client now ends the turn on the first such stderr line, cancels | ||
| * it (opencode would keep retrying), and the bubble names the real cause. | ||
| */ | ||
|
|
||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| vi.mock('../../src/agents/acp/idleTimeout', () => ({ | ||
| createIdleTimeout: () => ({ | ||
| promise: new Promise<never>(() => {}), | ||
| bump: () => {}, | ||
| suspend: () => {}, | ||
| clear: () => {}, | ||
| }), | ||
| })); | ||
|
|
||
| import { AcpClient, agentErrorOf, type AcpClientOptions } from '../../src/agents/acp/client'; | ||
| import { OPENCODE_FATAL_TURN_STDERR_RE, getAcpAdapter } from '../../src/agents/acp/adapters'; | ||
| import { AGENT_FREE_LIMIT_MESSAGE, failureBubble } from '../../src/agents/acp/failure-messages'; | ||
|
|
||
| // Verbatim from a Box's `opencode acp --print-logs --log-level ERROR` (2026-10-22). | ||
| const FREE_LIMIT_LINE = | ||
| 'timestamp=2026-10-22T21:38:33.582Z level=ERROR run=fc660dd6 message="stream error" providerID=opencode modelID=big-pickle session.id=ses_e9a72d54bffeSlhGZ7xjQunKvi small=false agent=build mode=primary error.error="AI_APICallError: Free usage limit reached. Subscribe to OpenCode Go to keep going: https://opencode.ai/go"'; | ||
| const RATE_LIMIT_LINE = | ||
| 'timestamp=2026-10-22T21:38:33.582Z level=ERROR run=fc660dd6 message="stream error" providerID=openrouter modelID=x session.id=ses_1 error.error="AI_APICallError: Rate limit exceeded, retry in 10s"'; | ||
| const SERVER_ERROR_LINE = | ||
| 'timestamp=2026-10-22T21:38:33.582Z level=ERROR run=fc660dd6 message="stream error" providerID=anthropic modelID=x session.id=ses_1 error.error="AI_APICallError: Internal server error"'; | ||
|
|
||
| interface ClientInternals { | ||
| connection: { | ||
| prompt: (p: unknown) => Promise<unknown>; | ||
| cancel: (p: unknown) => Promise<void>; | ||
| } | null; | ||
| sessionId: string | null; | ||
| fatalTurnStderr: RegExp | null; | ||
| onAdapterStderrLine(line: string): void; | ||
| } | ||
|
|
||
| function clientWithOpenTurn() { | ||
| const client = new AcpClient({ | ||
| adapter: { command: 'opencode', args: [] }, | ||
| cwd: '/tmp', | ||
| onSessionUpdate: () => {}, | ||
| } as unknown as AcpClientOptions); | ||
| const internals = client as unknown as ClientInternals; | ||
| let answer: (v: unknown) => void = () => {}; | ||
| const cancel = vi.fn(async () => answer({ stopReason: 'cancelled' })); | ||
| internals.connection = { | ||
| // Never answers on its own — exactly what opencode does after the error. | ||
| prompt: () => new Promise((resolve) => (answer = resolve)), | ||
| cancel, | ||
| }; | ||
| internals.sessionId = 'ses_1'; | ||
| internals.fatalTurnStderr = OPENCODE_FATAL_TURN_STDERR_RE; | ||
| return { client, internals, cancel }; | ||
| } | ||
|
|
||
| describe('opencode fatal turn errors', () => { | ||
| it('the pattern catches terminal errors only', () => { | ||
| expect(OPENCODE_FATAL_TURN_STDERR_RE.test(FREE_LIMIT_LINE)).toBe(true); | ||
| expect( | ||
| OPENCODE_FATAL_TURN_STDERR_RE.test( | ||
| FREE_LIMIT_LINE.replace( | ||
| /error\.error="[^"]+"/, | ||
| 'error.error="AI_APICallError: Invalid API key"', | ||
| ), | ||
| ), | ||
| ).toBe(true); | ||
| // opencode retries these on its own; ending the turn would cut a recovery. | ||
| expect(OPENCODE_FATAL_TURN_STDERR_RE.test(RATE_LIMIT_LINE)).toBe(false); | ||
| expect(OPENCODE_FATAL_TURN_STDERR_RE.test(SERVER_ERROR_LINE)).toBe(false); | ||
| }); | ||
|
|
||
| it('pulls the agent error out of the log line', () => { | ||
| expect(agentErrorOf(FREE_LIMIT_LINE)).toBe( | ||
| 'AI_APICallError: Free usage limit reached. Subscribe to OpenCode Go to keep going: https://opencode.ai/go', | ||
| ); | ||
| expect(agentErrorOf('plain text')).toBe('plain text'); | ||
| }); | ||
|
|
||
| it('ends the turn on the first fatal line and cancels it', async () => { | ||
| const { client, internals, cancel } = clientWithOpenTurn(); | ||
| const turn = client.prompt('hola'); | ||
| await new Promise((r) => setImmediate(r)); | ||
| internals.onAdapterStderrLine(FREE_LIMIT_LINE); | ||
| await expect(turn).rejects.toThrow( | ||
| /^AGENT_TURN_FAILED: AI_APICallError: Free usage limit reached/, | ||
| ); | ||
| expect(cancel).toHaveBeenCalledWith({ sessionId: 'ses_1' }); | ||
| }); | ||
|
|
||
| it('a retryable error leaves the turn running', async () => { | ||
| const { client, internals, cancel } = clientWithOpenTurn(); | ||
| let settled = false; | ||
| const turn = client.prompt('hola').finally(() => (settled = true)); | ||
| await new Promise((r) => setImmediate(r)); | ||
| internals.onAdapterStderrLine(RATE_LIMIT_LINE); | ||
| await new Promise((r) => setImmediate(r)); | ||
| expect(settled).toBe(false); | ||
| expect(cancel).not.toHaveBeenCalled(); | ||
| internals.onAdapterStderrLine(FREE_LIMIT_LINE); | ||
| await expect(turn).rejects.toThrow(/AGENT_TURN_FAILED/); | ||
| }); | ||
|
|
||
| it('a fatal line outside a turn is only logged', () => { | ||
| const { internals } = clientWithOpenTurn(); | ||
| expect(() => internals.onAdapterStderrLine(FREE_LIMIT_LINE)).not.toThrow(); | ||
| }); | ||
|
|
||
| it('the bubble names the free limit and the ways forward', () => { | ||
| const bubble = failureBubble({ | ||
| detail: `AGENT_TURN_FAILED: ${agentErrorOf(FREE_LIMIT_LINE)}`, | ||
| recentStderr: FREE_LIMIT_LINE, | ||
| hadText: false, | ||
| agent: 'opencode', | ||
| env: {}, | ||
| }); | ||
| expect(bubble).toBe(AGENT_FREE_LIMIT_MESSAGE); | ||
| expect(bubble).toContain('Profile › Agents'); | ||
| }); | ||
|
|
||
| it('a partial reply still gets the free-limit bubble', () => { | ||
| expect( | ||
| failureBubble({ | ||
| detail: 'AGENT_TURN_FAILED: Free usage limit reached.', | ||
| recentStderr: '', | ||
| hadText: true, | ||
| agent: 'opencode', | ||
| env: {}, | ||
| }), | ||
| ).toBe(AGENT_FREE_LIMIT_MESSAGE); | ||
| }); | ||
|
|
||
| it('opencode is launched with its errors on stderr and the fatal pattern', () => { | ||
| const spec = getAcpAdapter('opencode'); | ||
| expect(spec?.args).toEqual(['acp', '--print-logs', '--log-level', 'ERROR']); | ||
| expect(spec?.fatalTurnStderr).toBe(OPENCODE_FATAL_TURN_STDERR_RE); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -197,6 +197,14 @@ export const FATAL_STARTUP_RE = | |
| */ | ||
| const NEWSESSION_TIMEOUT_MS = 120_000; | ||
|
|
||
| /** | ||
| * The agent's own error out of a logfmt stderr line (`error.error="…"`, what | ||
| * opencode prints), or the whole line when it carries none. | ||
| */ | ||
| export function agentErrorOf(line: string): string { | ||
| return /error\.error="([^"]+)"/.exec(line)?.[1] ?? line; | ||
| } | ||
|
|
||
| /** | ||
| * How long the AGENT waits for ONE MCP server to finish starting, handed to it | ||
| * as `MCP_TIMEOUT`. | ||
|
|
@@ -469,6 +477,12 @@ export class AcpClient { | |
| * engages; with it, the crash rejects immediately → retry respawns once the | ||
| * install settles. Broader than {@link startupFailureReject} (newSession-only). */ | ||
| private startAbortReject: ((err: Error) => void) | null = null; | ||
| /** The adapter's {@link AdapterSpec.fatalTurnStderr}, set at spawn. */ | ||
| private fatalTurnStderr: RegExp | null = null; | ||
| /** Set while a `session/prompt` is in flight; the stderr watcher calls it on | ||
| * a {@link fatalTurnStderr} line to end the turn now (opencode keeps a turn | ||
| * open forever after a terminal provider error — see the adapter spec). */ | ||
| private turnFailureReject: ((err: Error) => void) | null = null; | ||
|
|
||
| constructor(private readonly opts: AcpClientOptions) {} | ||
|
|
||
|
|
@@ -602,6 +616,7 @@ export class AcpClient { | |
| 'acpClient', | ||
| `provider routing ${formatProviderRouting(describeProviderRouting(childEnv, { cwd }), adapter.command)}`, | ||
| ); | ||
| this.fatalTurnStderr = adapter.fatalTurnStderr ?? null; | ||
| const child = spawn(adapter.command, adapter.args, { | ||
| cwd, | ||
| env: childEnv, | ||
|
|
@@ -633,24 +648,7 @@ export class AcpClient { | |
| child.stderr?.on('data', (chunk: string) => { | ||
| for (const line of chunk.split(/\r?\n/)) { | ||
| const trimmed = line.trim(); | ||
| if (trimmed) { | ||
| // Bump to info — adapter stderr is the primary signal when | ||
| // the agent's auth / install / config is wrong and the | ||
| // protocol just stalls. Trace-level suppression hid the | ||
| // root cause on every smoke test up to v2.27.6. | ||
| log.info('acpAdapter', trimmed); | ||
| this.opts.onStderr?.(trimmed); | ||
| this.recentStderr.push(trimmed); | ||
| if (this.recentStderr.length > 12) this.recentStderr.shift(); | ||
| // Fail `newSession` fast when the adapter prints a fatal startup | ||
| // error (auth / tier ineligibility). gemini --acp logs these to | ||
| // stderr but never rejects the RPC, so the only other signal is the | ||
| // timeout backstop — surfacing it here makes the error immediate. | ||
| if (this.startupFailureReject && FATAL_STARTUP_RE.test(trimmed)) { | ||
| this.startupFailureReject(new Error(`AGENT_STARTUP_FAILED: ${trimmed}`)); | ||
| this.startupFailureReject = null; | ||
| } | ||
| } | ||
| if (trimmed) this.onAdapterStderrLine(trimmed); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a provider-error log line is split across stderr Useful? React with 👍 / 👎. |
||
| } | ||
| }); | ||
|
|
||
|
|
@@ -988,11 +986,18 @@ export class AcpClient { | |
| PROMPT_ACTIVE_IDLE_TIMEOUT_MS, | ||
| ); | ||
| this.promptIdle = idle; | ||
| let fatalTripped = false; | ||
| const fatal = new Promise<never>((_, reject) => { | ||
| this.turnFailureReject = (err) => { | ||
| fatalTripped = true; | ||
| reject(err); | ||
| }; | ||
| }); | ||
| // Fresh tool-call ledger per turn so a stale entry from a prior | ||
| // turn can't keep this turn's watchdog suspended forever. | ||
| this.pendingToolCalls.clear(); | ||
| try { | ||
| const result = await Promise.race([send, idle.promise]); | ||
| const result = await Promise.race([send, idle.promise, fatal]); | ||
| // Root-cause fix (fleet-1 handoff-robustness round 3, empirically | ||
| // confirmed on a live box): the ACP SDK's stdio dispatch resolves a | ||
| // `session/prompt` RPC response in ONE promise hop | ||
|
|
@@ -1035,18 +1040,47 @@ export class AcpClient { | |
| // cleanup cancel does, and the cancel kills IT instead — opencode even | ||
| // folds a prompt into a still-running turn (codeagent-u7mm: a queued | ||
| // message failed as "empty reply"). | ||
| if (idleTripped) await this.cancelAbandonedTurn(sessionId, send); | ||
| // A fatal stderr error leaves the same open turn behind — and opencode | ||
| // keeps retrying it against the provider until it is cancelled. | ||
| if (idleTripped || fatalTripped) await this.cancelAbandonedTurn(sessionId, send); | ||
| throw err; | ||
| } finally { | ||
| // Permanently disarm so a late bump/timer can't reject after | ||
| // we've moved on, and drop the handle for the next prompt. | ||
| idle.clear(); | ||
| this.promptIdle = null; | ||
| this.turnFailureReject = null; | ||
| } | ||
| } | ||
|
|
||
| /** One non-empty adapter stderr line: logged, kept in the ring, and checked | ||
| * for the fatal startup / fatal turn errors that end a stuck RPC early. */ | ||
| private onAdapterStderrLine(trimmed: string): void { | ||
| // Bump to info — adapter stderr is the primary signal when | ||
| // the agent's auth / install / config is wrong and the | ||
| // protocol just stalls. Trace-level suppression hid the | ||
| // root cause on every smoke test up to v2.27.6. | ||
| log.info('acpAdapter', trimmed); | ||
| this.opts.onStderr?.(trimmed); | ||
| this.recentStderr.push(trimmed); | ||
| if (this.recentStderr.length > 12) this.recentStderr.shift(); | ||
| // Fail `newSession` fast when the adapter prints a fatal startup | ||
| // error (auth / tier ineligibility). gemini --acp logs these to | ||
| // stderr but never rejects the RPC, so the only other signal is the | ||
| // timeout backstop — surfacing it here makes the error immediate. | ||
| if (this.startupFailureReject && FATAL_STARTUP_RE.test(trimmed)) { | ||
| this.startupFailureReject(new Error(`AGENT_STARTUP_FAILED: ${trimmed}`)); | ||
| this.startupFailureReject = null; | ||
| } | ||
| // Same for a turn: an error the agent logs but never reports over ACP. | ||
| if (this.turnFailureReject && this.fatalTurnStderr?.test(trimmed)) { | ||
| this.turnFailureReject(new Error(`AGENT_TURN_FAILED: ${agentErrorOf(trimmed)}`)); | ||
| this.turnFailureReject = null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Cancel a turn the idle watchdog abandoned and wait, bounded, for the | ||
| * Cancel a turn the idle watchdog (or a fatal stderr error) abandoned and wait, bounded, for the | ||
| * adapter to answer its `session/prompt` — the proof the turn is over before | ||
| * the next prompt is sent. Best-effort: a dead adapter can't hold the queue | ||
| * longer than {@link ABANDONED_TURN_SETTLE_MS}. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new fixture bypasses required constructor fields using
as unknown as AcpClientOptions: its adapter omitsrequiresAgentBinaryandwaitForBinary, and the options omitonRequestPermission. It then uses another double assertion to replace private internals. This violates the repository's explicit non-negotiable typing rule and prevents TypeScript from validating the fixture against the actual client contract. Supply complete typed options and mock the connection boundary instead of silencing these mismatches.AGENTS.md reference: AGENTS.md:L12-L12
Useful? React with 👍 / 👎.