Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions cli/src/agent/backends/acp/AcpStdioTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
51 changes: 51 additions & 0 deletions cli/src/agent/backends/acp/acpStderrErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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<AcpStderrError> & Pick<AcpStderrError, 'type' | 'message'>): 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);
});
});
19 changes: 19 additions & 0 deletions cli/src/agent/backends/acp/acpStderrErrors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 12 additions & 0 deletions cli/src/agent/messageConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
111 changes: 105 additions & 6 deletions cli/src/opencode/opencodeRemoteLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ const harness = vi.hoisted(() => ({
events: [] as string[],
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>),
setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise<void>),
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', () => ({
Expand Down Expand Up @@ -39,12 +43,20 @@ vi.mock('./utils/opencodeBackend', () => ({
harness.promptContents.push(content);
harness.events.push('prompt:start');
harness.promptCount++;
await new Promise<void>((resolve) => setImmediate(resolve));
if (harness.hangPrompt) {
await new Promise<void>((resolve) => {
harness.resolvePrompt = resolve;
});
} else {
await new Promise<void>((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),
Expand Down Expand Up @@ -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',
Expand All @@ -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', () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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') },
Expand Down
42 changes: 34 additions & 8 deletions cli/src/opencode/opencodeRemoteLauncher.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<void> {
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);
Expand Down
Loading
Loading