diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index e157bc821d..8a17a42819 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -3,6 +3,7 @@ import { logger } from '@/ui/logger'; import { killProcessByChildProcess } from '@/utils/process'; import { GEMINI_MODEL_PRESETS } from '@hapi/protocol'; import { registerActiveAcpTransport, unregisterActiveAcpTransport } from './agentCliGuard'; +import { matchesAcpHttp2Cancel, matchesAcpRetryBackoff } from './acpStderrErrors'; interface JsonRpcRequest { jsonrpc: '2.0'; @@ -391,6 +392,24 @@ export class AcpStdioTransport { return; } + if (matchesAcpRetryBackoff(text)) { + this.stderrErrorHandler({ + type: 'unknown', + message: 'The ACP agent is retrying after an upstream failure. The turn may be stalled.', + raw: text + }); + return; + } + + if (matchesAcpHttp2Cancel(text)) { + this.stderrErrorHandler({ + type: 'unknown', + message: 'Upstream request was cancelled. The agent may be retrying or stalled.', + raw: text + }); + return; + } + // Only report as unknown if it looks like an actual error if (lowerText.includes('error') || lowerText.includes('failed') || lowerText.includes('exception')) { this.stderrErrorHandler({ diff --git a/cli/src/agent/backends/acp/acpStderrErrors.test.ts b/cli/src/agent/backends/acp/acpStderrErrors.test.ts new file mode 100644 index 0000000000..dc272ce988 --- /dev/null +++ b/cli/src/agent/backends/acp/acpStderrErrors.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + isAcpStallStderrError, + matchesAcpHttp2Cancel, + matchesAcpRetryBackoff +} from './acpStderrErrors'; +import type { AcpStderrError } from './AcpStdioTransport'; + +function makeError(partial: Partial & Pick): AcpStderrError { + return { + raw: partial.raw ?? partial.message, + ...partial + }; +} + +describe('acpStderrErrors', () => { + it('detects quota and rate-limit stderr classes as stall errors', () => { + expect(isAcpStallStderrError(makeError({ + type: 'quota_exceeded', + message: 'API quota exceeded.' + }))).toBe(true); + expect(isAcpStallStderrError(makeError({ + type: 'rate_limit', + message: 'Rate limit exceeded.' + }))).toBe(true); + }); + + it('detects OpenCode retry backoff text as a stall error', () => { + expect(matchesAcpRetryBackoff('provider unavailable, retrying in 30s')).toBe(true); + expect(isAcpStallStderrError(makeError({ + type: 'unknown', + message: 'provider unavailable, retrying in 30s' + }))).toBe(true); + }); + + it('detects HTTP/2 cancel errors as stall errors', () => { + const message = 'Error: T: [canceled] http/2 stream closed with error code CANCEL (0x8)'; + expect(matchesAcpHttp2Cancel(message)).toBe(true); + expect(isAcpStallStderrError(makeError({ + type: 'unknown', + message + }))).toBe(true); + }); + + it('does not treat unrelated stderr as stall errors', () => { + expect(isAcpStallStderrError(makeError({ + type: 'authentication', + message: 'Authentication failed.' + }))).toBe(false); + }); +}); diff --git a/cli/src/agent/backends/acp/acpStderrErrors.ts b/cli/src/agent/backends/acp/acpStderrErrors.ts new file mode 100644 index 0000000000..b9f8c45b45 --- /dev/null +++ b/cli/src/agent/backends/acp/acpStderrErrors.ts @@ -0,0 +1,19 @@ +import type { AcpStderrError } from './AcpStdioTransport'; + +export function matchesAcpRetryBackoff(text: string): boolean { + return text.toLowerCase().includes('retrying in'); +} + +export function matchesAcpHttp2Cancel(text: string): boolean { + const lower = text.toLowerCase(); + return lower.includes('http/2') && (lower.includes('cancel') || lower.includes('0x8')); +} + +export function isAcpStallStderrError(error: AcpStderrError): boolean { + if (error.type === 'rate_limit' || error.type === 'quota_exceeded') { + return true; + } + + const text = `${error.message}\n${error.raw}`; + return matchesAcpRetryBackoff(text) || matchesAcpHttp2Cancel(text); +} diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index 86ca29cc28..baeaaf1b94 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -50,6 +50,18 @@ describe('convertAgentMessage', () => { }); }); + it('converts error messages into codex error payloads', () => { + const converted = convertAgentMessage({ + type: 'error', + message: 'API quota exceeded.' + }); + + expect(converted).toEqual({ + type: 'error', + message: 'API quota exceeded.' + }); + }); + it('converts agent errors into error wire payloads', () => { const converted = convertAgentMessage({ type: 'error', diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index 173fcbf737..97174de968 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -10,7 +10,11 @@ const harness = vi.hoisted(() => ({ events: [] as string[], setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise), setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise), - thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> } + thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> }, + stderrHandler: null as null | ((error: { type: string; message: string; raw: string }) => void), + hangPrompt: false, + resolvePrompt: null as null | (() => void), + cancelPrompt: vi.fn(async () => {}) })); vi.mock('./utils/opencodeBackend', () => ({ @@ -39,12 +43,20 @@ vi.mock('./utils/opencodeBackend', () => ({ harness.promptContents.push(content); harness.events.push('prompt:start'); harness.promptCount++; - await new Promise((resolve) => setImmediate(resolve)); + if (harness.hangPrompt) { + await new Promise((resolve) => { + harness.resolvePrompt = resolve; + }); + } else { + await new Promise((resolve) => setImmediate(resolve)); + } harness.events.push('prompt:end'); }), - cancelPrompt: vi.fn(async () => {}), + cancelPrompt: harness.cancelPrompt, respondToPermission: vi.fn(async () => {}), - onStderrError: vi.fn(), + onStderrError: vi.fn((handler: (error: { type: string; message: string; raw: string }) => void) => { + harness.stderrHandler = handler; + }), onPermissionRequest: vi.fn(), disconnect: vi.fn(async () => {}), getSessionModelsMetadata: vi.fn(() => undefined), @@ -137,6 +149,8 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }> } }; + const agentMessages: unknown[] = []; + const session = { path: '/tmp/hapi-opencode-test', logPath: '/tmp/hapi-opencode-test/test.log', @@ -156,14 +170,16 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }> onSessionFound(id: string) { session.sessionId = id; }, - sendAgentMessage(_message: unknown) {}, + sendAgentMessage(message: unknown) { + agentMessages.push(message); + }, sendSessionEvent(event: { type: string; [key: string]: unknown }) { client.sendSessionEvent(event); }, sendUserMessage(_text: string) {} }; - return { session, sessionEvents, rpcHandlers, setModelReasoningEffort, pushKeepAlive }; + return { session, sessionEvents, rpcHandlers, setModelReasoningEffort, pushKeepAlive, agentMessages }; } describe('opencodeRemoteLauncher inline model switch', () => { @@ -176,6 +192,10 @@ describe('opencodeRemoteLauncher inline model switch', () => { harness.setModelImpl = null; harness.setConfigOptionImpl = null; harness.thoughtLevelOption = null; + harness.stderrHandler = null; + harness.hangPrompt = false; + harness.resolvePrompt = null; + harness.cancelPrompt.mockClear(); }); it('calls setModel with opencode flavor between turns when the queued model differs', async () => { @@ -497,6 +517,85 @@ describe('opencodeRemoteLauncher inline model switch', () => { }); }); + it('clears thinking and cancels prompt when a stall stderr error arrives during prompt', async () => { + harness.hangPrompt = true; + const { session, agentMessages } = createSessionStub([ + { message: 'first', mode: createMode() } + ]); + + const launchPromise = opencodeRemoteLauncher(session as never); + await vi.waitFor(() => expect(harness.events).toContain('prompt:start')); + expect(session.thinking).toBe(true); + expect(harness.stderrHandler).toBeTypeOf('function'); + + harness.stderrHandler!({ + type: 'quota_exceeded', + message: 'API quota exceeded. Please check your billing or wait for quota reset.', + raw: 'quota exceeded for provider' + }); + + expect(session.thinking).toBe(false); + expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1'); + expect(agentMessages).toContainEqual({ + type: 'error', + message: 'API quota exceeded. Please check your billing or wait for quota reset.' + }); + + harness.resolvePrompt!(); + await launchPromise; + }); + + it('routes HTTP/2 cancel stderr through the error agent message pipeline', async () => { + harness.hangPrompt = true; + const { session, agentMessages } = createSessionStub([ + { message: 'first', mode: createMode() } + ]); + + const launchPromise = opencodeRemoteLauncher(session as never); + await vi.waitFor(() => expect(harness.stderrHandler).toBeTypeOf('function')); + + const message = 'Error: T: [canceled] http/2 stream closed with error code CANCEL (0x8)'; + harness.stderrHandler!({ + type: 'unknown', + message, + raw: message + }); + + expect(session.thinking).toBe(false); + expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1'); + expect(agentMessages).toContainEqual({ type: 'error', message }); + + harness.resolvePrompt!(); + await launchPromise; + }); + + it('surfaces non-stall stderr as error without clearing thinking or canceling prompt', async () => { + harness.hangPrompt = true; + const { session, agentMessages } = createSessionStub([ + { message: 'first', mode: createMode() } + ]); + + const launchPromise = opencodeRemoteLauncher(session as never); + await vi.waitFor(() => expect(harness.events).toContain('prompt:start')); + expect(session.thinking).toBe(true); + + harness.stderrHandler!({ + type: 'authentication', + message: 'Authentication failed. Please check your credentials.', + raw: 'status 401 unauthenticated' + }); + + expect(session.thinking).toBe(true); + expect(harness.cancelPrompt).not.toHaveBeenCalled(); + expect(agentMessages).toContainEqual({ + type: 'error', + message: 'Authentication failed. Please check your credentials.' + }); + + harness.resolvePrompt!(); + await launchPromise; + }); + it('serializes setModel after the previous prompt resolves', async () => { const { session } = createSessionStub([ { message: 'first', mode: createMode('ollama/a') }, diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index b911fbca07..81cf32ace6 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -1,6 +1,8 @@ import React from 'react'; import { logger } from '@/ui/logger'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; +import type { AcpStderrError } from '@/agent/backends/acp/AcpStdioTransport'; +import { isAcpStallStderrError } from '@/agent/backends/acp/acpStderrErrors'; import { convertAgentMessage } from '@/agent/messageConverter'; import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; @@ -31,6 +33,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { private defaultBackendEffort: string | null = null; private setModelSupported: boolean | undefined = undefined; private setEffortSupported: boolean | undefined = undefined; + private activeAcpSessionId: string | null = null; constructor( session: OpencodeSession, @@ -64,9 +67,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.backend = backend; backend.onStderrError((error) => { - logger.debug('[opencode-remote] stderr error', error); - session.sendSessionEvent({ type: 'message', message: error.message }); - messageBuffer.addMessage(error.message, 'status'); + this.handleAcpStderrError(error); }); await backend.initialize(); @@ -99,6 +100,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { }); } session.onSessionFound(acpSessionId); + this.activeAcpSessionId = acpSessionId; // Seed currentBackendModel from the ACP session metadata so the first // batch — whose model the hub mirrors from the just-discovered session — @@ -289,11 +291,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { }); } catch (error) { logger.warn('[opencode-remote] prompt failed', error); - session.sendSessionEvent({ - type: 'message', - message: 'OpenCode prompt failed. Check logs for details.' - }); - messageBuffer.addMessage('OpenCode prompt failed', 'status'); + this.surfaceAgentError('OpenCode prompt failed. Check logs for details.'); } finally { session.onThinkingChange(false); await this.permissionHandler?.cancelAll('Prompt finished'); @@ -323,6 +321,34 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { } } + private handleAcpStderrError(error: AcpStderrError): void { + logger.debug('[opencode-remote] stderr error', error); + this.surfaceAgentError(error.message); + if (isAcpStallStderrError(error)) { + void this.clearStalledPrompt(); + } + } + + private surfaceAgentError(message: string): void { + this.session.sendAgentMessage({ type: 'error', message }); + this.messageBuffer.addMessage(message, 'status'); + } + + private async clearStalledPrompt(): Promise { + const backend = this.backend; + const sessionId = this.activeAcpSessionId; + if (!backend || !sessionId) { + return; + } + + this.session.onThinkingChange(false); + try { + await backend.cancelPrompt(sessionId); + } catch (error) { + logger.debug('[opencode-remote] cancelPrompt after stderr failed', error); + } + } + private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void { batch.mode.modelReasoningEffort = effort; this.session.setModelReasoningEffort(effort); diff --git a/web/src/chat/normalizeAgent.error.test.ts b/web/src/chat/normalizeAgent.error.test.ts new file mode 100644 index 0000000000..2bd2ab2edd --- /dev/null +++ b/web/src/chat/normalizeAgent.error.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { normalizeAgentRecord } from './normalizeAgent' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' + +describe('normalizeAgentRecord — agent error payloads', () => { + it('normalizes codex error payloads into error-styled agent events', () => { + const normalized = normalizeAgentRecord( + 'msg-1', + null, + 1_700_000_000_000, + { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data: { + type: 'error', + message: 'API quota exceeded.' + } + } + ) + + expect(normalized).toEqual({ + id: 'msg-1', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + isSidechain: false, + content: { + type: 'error', + message: 'API quota exceeded.' + } + }) + }) +}) diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index af4497fa62..1ec6e28720 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -108,6 +108,18 @@ describe('getEventPresentation — token-count', () => { }) }) +describe('getEventPresentation — agent error', () => { + it('formats agent error events with a warning icon', () => { + const result = getEventPresentation({ + type: 'error', + message: 'Error: T: [canceled] http/2 stream closed with error code CANCEL (0x8)' + }) + + expect(result.icon).toBe('⚠️') + expect(result.text).toContain('http/2 stream closed') + }) +}) + describe('getEventPresentation — thread goals', () => { it('formats goal status updates', () => { const result = getEventPresentation({ diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 168693dc5a..1f346fc6a4 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -141,6 +141,12 @@ export type EventPresentation = { } export function getEventPresentation(event: AgentEvent): EventPresentation { + if (event.type === 'error') { + const message = typeof (event as { message?: unknown }).message === 'string' + ? (event as { message: string }).message + : 'Agent error' + return { icon: '⚠️', text: message } + } if (event.type === 'api-error') { const { retryAttempt, maxRetries } = event as { retryAttempt: number; maxRetries: number } if (maxRetries > 0 && retryAttempt >= maxRetries) { diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index f42d0540a6..a530fc3bde 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -22,6 +22,7 @@ export type AgentEvent = | { type: 'limit-warning'; /** 0–1 ratio (e.g. 0.9 = 90%), integer-precision via CLI pipe format */ utilization: number; endsAt: number; limitType: string } | { type: 'ready' } | { type: 'api-error'; retryAttempt: number; maxRetries: number; error: unknown } + | { type: 'error'; message: string } | { type: 'turn-duration'; durationMs: number; targetMessageId?: string } | { type: 'microcompact'; trigger: string; preTokens: number; tokensSaved: number } | { type: 'compact'; trigger: string; preTokens: number }