mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(opencode): surface stall errors and clear thinking spinner (#869)
* test: reproduce issue #865 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): surface stall errors and clear thinking spinner (closes #865) Route quota/rate-limit/HTTP-2 cancel stderr through error-styled agent messages, cancel the in-flight prompt, and clear thinking so the web UI does not stay stuck while OpenCode retries upstream. Co-authored-by: Cursor <cursoragent@cursor.com> * test(opencode): non-stall stderr surfaces error without canceling prompt Co-authored-by: Cursor <cursoragent@cursor.com> * fix(acp): use agent-neutral retry stderr message in shared transport AcpStdioTransport is shared by Cursor, Gemini, Kimi, and OpenCode. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): surface stall errors and clear thinking spinner * fix(acp): parse split stderr stall records Buffer stderr through newline-delimited records so split retry and HTTP/2 cancel signatures still clear stalled OpenCode turns, and retain one web error presentation branch. Verified: targeted ACP and presentation tests plus CLI/web typechecks. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(acp): emit stalled stderr tails immediately Classify buffered retry and HTTP/2 cancellation tails as soon as their signatures are complete, without waiting for the ACP process to close. Verified: targeted ACP transport test and CLI typecheck. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(acp): flush newline-free quota errors * fix(acp): surface newline-free stderr errors * fix(acp): scope stall cancellation and bound stderr * fix(opencode): bind stall cancellation to prompt RPC * fix(acp): preserve partial stderr until classification * fix(acp): report complete cancellation records --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1104,6 +1104,8 @@
|
||||
|
||||
"@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.26.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-+LQAPkyT/ioEnQ4TJpVMMZs7MY7BeomhvuEETyBqsovDOSPqRfXUHrDT4K3zF3CAA0ushA5PFTJultVKcfHivA=="],
|
||||
|
||||
"@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.25.3", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-K1zhYTj8eAPt5W7q92U4ywdUs0122faulU2kywHOuzmwO95nI4zQ0CXlCNkJlGpTv6AYj7GiQdU21sR1NJSYFg=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
@@ -75,6 +75,7 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
private initializeResult: AcpInitializeResult | null = null;
|
||||
private setModeSupported: boolean | undefined = undefined;
|
||||
private isProcessingMessage = false;
|
||||
private promptRequestInFlight = false;
|
||||
private responseCompleteResolvers: Array<() => void> = [];
|
||||
private lastSessionUpdateAt = 0;
|
||||
private latestUsageUpdate: AcpUsageUpdate | null = null;
|
||||
@@ -521,10 +522,16 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
try {
|
||||
// No timeout for prompt requests - they can run for extended periods
|
||||
// during complex tasks, tool-heavy operations, or slow model responses
|
||||
const response = await this.transport.sendRequest('session/prompt', {
|
||||
sessionId,
|
||||
prompt: content
|
||||
}, { timeoutMs: Infinity });
|
||||
this.promptRequestInFlight = true;
|
||||
let response: unknown;
|
||||
try {
|
||||
response = await this.transport.sendRequest('session/prompt', {
|
||||
sessionId,
|
||||
prompt: content
|
||||
}, { timeoutMs: Infinity });
|
||||
} finally {
|
||||
this.promptRequestInFlight = false;
|
||||
}
|
||||
|
||||
stopReason = isObject(response) ? asString(response.stopReason) : null;
|
||||
promptUsage = this.extractPromptUsage(response);
|
||||
@@ -695,6 +702,10 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
return this.isProcessingMessage;
|
||||
}
|
||||
|
||||
isPromptRequestInFlight(): boolean {
|
||||
return this.promptRequestInFlight;
|
||||
}
|
||||
|
||||
getLastSessionUpdateAt(): number {
|
||||
return this.lastSessionUpdateAt;
|
||||
}
|
||||
|
||||
@@ -422,6 +422,113 @@ describe('AcpStdioTransport closed stdin writes', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['status 401', 'authentication'],
|
||||
['status 404', 'model_not_found'],
|
||||
['Cannot use this model: stale-id', 'model_not_found'],
|
||||
['unexpected error', 'unknown']
|
||||
])('reports newline-free %s stderr immediately', (chunk, type) => {
|
||||
const transport = new AcpStdioTransport({ command: 'agent' });
|
||||
const seen: Array<{ type: string }> = [];
|
||||
transport.onStderrError((error) => seen.push(error));
|
||||
const proc = (transport as unknown as { process: {
|
||||
stderr: { on: ReturnType<typeof vi.fn> };
|
||||
} }).process;
|
||||
const handlers = (proc.stderr.on as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter((call) => call[0] === 'data')
|
||||
.map((call) => call[1] as (value: string) => void);
|
||||
|
||||
for (const handler of handlers) handler(chunk);
|
||||
|
||||
expect(seen.map((error) => error.type)).toEqual([type]);
|
||||
});
|
||||
|
||||
test('reports a completed non-HTTP/2 cancellation record', () => {
|
||||
const transport = new AcpStdioTransport({ command: 'agent' });
|
||||
const seen: Array<{ type: string; message: string; raw: string }> = [];
|
||||
transport.onStderrError((error) => seen.push(error));
|
||||
const proc = (transport as unknown as { process: {
|
||||
stderr: { on: ReturnType<typeof vi.fn> };
|
||||
} }).process;
|
||||
const handlers = (proc.stderr.on as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter((call) => call[0] === 'data')
|
||||
.map((call) => call[1] as (value: string) => void);
|
||||
|
||||
for (const handler of handlers) handler('Error: request canceled by provider\n');
|
||||
|
||||
expect(seen).toEqual([{
|
||||
type: 'unknown',
|
||||
message: 'Error: request canceled by provider',
|
||||
raw: 'Error: request canceled by provider'
|
||||
}]);
|
||||
});
|
||||
|
||||
test('parses stall signatures split across stderr chunks without waiting for close', () => {
|
||||
const transport = new AcpStdioTransport({ command: 'opencode' });
|
||||
const seen: Array<{ type: string; message: string; raw: string }> = [];
|
||||
transport.onStderrError((error) => {
|
||||
seen.push(error);
|
||||
});
|
||||
const proc = (transport as unknown as { process: {
|
||||
stderr: { on: ReturnType<typeof vi.fn> };
|
||||
} }).process;
|
||||
const stderrHandlers = (proc.stderr.on as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter((call) => call[0] === 'data')
|
||||
.map((call) => call[1] as (chunk: string) => void);
|
||||
|
||||
for (const handler of stderrHandlers) {
|
||||
handler('provider unavailable, retry');
|
||||
handler('ing in 30 seconds\n');
|
||||
handler('Error: T: [canceled] ht');
|
||||
handler('tp/2 stream closed with error code CANCEL (0x8)');
|
||||
}
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
type: 'unknown',
|
||||
message: 'The ACP agent is retrying after an upstream failure. The turn may be stalled.',
|
||||
raw: 'provider unavailable, retrying in 30 seconds'
|
||||
},
|
||||
{
|
||||
type: 'unknown',
|
||||
message: 'Upstream request was cancelled. The agent may be retrying or stalled.',
|
||||
raw: 'Error: T: [canceled] http/2 stream closed with error code CANCEL (0x8)'
|
||||
}
|
||||
]);
|
||||
|
||||
for (const handler of spawnState.closeHandlers) {
|
||||
handler(1, null);
|
||||
}
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
type: 'unknown',
|
||||
message: 'The ACP agent is retrying after an upstream failure. The turn may be stalled.',
|
||||
raw: 'provider unavailable, retrying in 30 seconds'
|
||||
},
|
||||
{
|
||||
type: 'unknown',
|
||||
message: 'Upstream request was cancelled. The agent may be retrying or stalled.',
|
||||
raw: 'Error: T: [canceled] http/2 stream closed with error code CANCEL (0x8)'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('bounds newline-free unclassified stderr tails', () => {
|
||||
const transport = new AcpStdioTransport({ command: 'agent' });
|
||||
const proc = (transport as unknown as { process: {
|
||||
stderr: { on: ReturnType<typeof vi.fn> };
|
||||
} }).process;
|
||||
const handlers = (proc.stderr.on as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter((call) => call[0] === 'data')
|
||||
.map((call) => call[1] as (value: string) => void);
|
||||
|
||||
for (const handler of handlers) handler('x'.repeat(20_000));
|
||||
|
||||
const buffer = (transport as unknown as { stderrParseBuffer: string }).stderrParseBuffer;
|
||||
expect(buffer.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
|
||||
test('rejects pending requests when stdin.write throws', async () => {
|
||||
spawnState.stdinWrite.mockImplementation(() => {
|
||||
throw new Error('WritableIterable is closed');
|
||||
|
||||
@@ -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';
|
||||
@@ -61,6 +62,8 @@ export class AcpStdioTransport {
|
||||
private stderrErrorHandler: ((error: AcpStderrError) => void) | null = null;
|
||||
private buffer = '';
|
||||
private recentStderr = '';
|
||||
private stderrParseBuffer = '';
|
||||
private stderrPartialErrorReported = false;
|
||||
private emittedModelRejection = false;
|
||||
private nextId = 1;
|
||||
private protocolError: Error | null = null;
|
||||
@@ -118,15 +121,9 @@ export class AcpStdioTransport {
|
||||
}
|
||||
const text = raw.trim();
|
||||
logger.debug(`[ACP][stderr] ${text}`);
|
||||
this.parseStderrError(text);
|
||||
// If this chunk alone missed a split keyword/id, retry against the window.
|
||||
if (
|
||||
text
|
||||
&& !/Cannot use this model:\s*\S/i.test(text)
|
||||
&& /Cannot use this model:\s*\S/i.test(this.recentStderr)
|
||||
) {
|
||||
this.parseStderrError(this.stderrForCloseError() ?? this.recentStderr);
|
||||
}
|
||||
this.parseStderrRecords(raw);
|
||||
this.flushActionableStderrTail();
|
||||
this.stderrParseBuffer = this.stderrParseBuffer.slice(-AcpStdioTransport.RECENT_STDERR_WINDOW);
|
||||
});
|
||||
|
||||
// Block new stdin writes as soon as the process exits, but defer markClosed
|
||||
@@ -143,6 +140,7 @@ export class AcpStdioTransport {
|
||||
// classify the failure — Node may fire 'exit' before the last stderr 'data'.
|
||||
this.process.on('close', (code, signal) => {
|
||||
this.releaseAgentCliGuard();
|
||||
this.flushStderrParseBuffer();
|
||||
const stderr = this.stderrForCloseError();
|
||||
let message = `ACP process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`;
|
||||
if (stderr) {
|
||||
@@ -429,9 +427,41 @@ export class AcpStdioTransport {
|
||||
: source;
|
||||
}
|
||||
|
||||
private parseStderrError(text: string): void {
|
||||
private parseStderrRecords(raw: string): void {
|
||||
const lines = (this.stderrParseBuffer + raw).split(/\r\n|[\r\n]/);
|
||||
this.stderrParseBuffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const text = line.trim();
|
||||
if (text) {
|
||||
this.parseStderrError(text, true);
|
||||
this.stderrPartialErrorReported = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private flushActionableStderrTail(): void {
|
||||
const pending = this.stderrParseBuffer.trim();
|
||||
if (pending && this.parseStderrError(pending) === 'reported-complete') {
|
||||
this.stderrParseBuffer = '';
|
||||
this.stderrPartialErrorReported = false;
|
||||
}
|
||||
}
|
||||
|
||||
private flushStderrParseBuffer(): void {
|
||||
const text = this.stderrParseBuffer.trim();
|
||||
this.stderrParseBuffer = '';
|
||||
if (text) {
|
||||
this.parseStderrError(text, true);
|
||||
}
|
||||
this.stderrPartialErrorReported = false;
|
||||
}
|
||||
|
||||
private parseStderrError(
|
||||
text: string,
|
||||
completeRecord = false
|
||||
): 'none' | 'reported-partial' | 'reported-complete' {
|
||||
if (!this.stderrErrorHandler) {
|
||||
return;
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
@@ -444,7 +474,7 @@ export class AcpStdioTransport {
|
||||
const modelRejection = text.match(/Cannot use this model:\s*\S[\s\S]*/i);
|
||||
if (modelRejection) {
|
||||
if (this.emittedModelRejection) {
|
||||
return;
|
||||
return 'reported-complete';
|
||||
}
|
||||
const message = modelRejection[0].trim();
|
||||
this.emittedModelRejection = true;
|
||||
@@ -453,7 +483,7 @@ export class AcpStdioTransport {
|
||||
message,
|
||||
raw: message
|
||||
});
|
||||
return;
|
||||
return 'reported-complete';
|
||||
}
|
||||
|
||||
// Rate limit errors (429)
|
||||
@@ -463,7 +493,7 @@ export class AcpStdioTransport {
|
||||
message: 'Rate limit exceeded. Please wait before sending more requests.',
|
||||
raw: text
|
||||
});
|
||||
return;
|
||||
return 'reported-complete';
|
||||
}
|
||||
|
||||
// Model not found errors (404)
|
||||
@@ -473,7 +503,7 @@ export class AcpStdioTransport {
|
||||
message: `Model not found. Available models: ${GEMINI_MODEL_PRESETS.join(', ')}`,
|
||||
raw: text
|
||||
});
|
||||
return;
|
||||
return 'reported-complete';
|
||||
}
|
||||
|
||||
// Authentication errors (401/403)
|
||||
@@ -485,7 +515,7 @@ export class AcpStdioTransport {
|
||||
message: 'Authentication failed. Please check your credentials or run "gemini auth login".',
|
||||
raw: text
|
||||
});
|
||||
return;
|
||||
return 'reported-complete';
|
||||
}
|
||||
|
||||
// Quota exceeded
|
||||
@@ -495,16 +525,44 @@ export class AcpStdioTransport {
|
||||
message: 'API quota exceeded. Please check your billing or wait for quota reset.',
|
||||
raw: text
|
||||
});
|
||||
return;
|
||||
return 'reported-complete';
|
||||
}
|
||||
|
||||
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 'reported-complete';
|
||||
}
|
||||
|
||||
if (matchesAcpHttp2Cancel(text)) {
|
||||
this.stderrErrorHandler({
|
||||
type: 'unknown',
|
||||
message: 'Upstream request was cancelled. The agent may be retrying or stalled.',
|
||||
raw: text
|
||||
});
|
||||
return 'reported-complete';
|
||||
}
|
||||
|
||||
// Keep cancellation errors buffered until a later chunk can classify them.
|
||||
if (lowerText.includes('canceled') && !completeRecord) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// Only report as unknown if it looks like an actual error
|
||||
if (lowerText.includes('error') || lowerText.includes('failed') || lowerText.includes('exception')) {
|
||||
this.stderrErrorHandler({
|
||||
type: 'unknown',
|
||||
message: text,
|
||||
raw: text
|
||||
});
|
||||
if (!this.stderrPartialErrorReported) {
|
||||
this.stderrPartialErrorReported = true;
|
||||
this.stderrErrorHandler({
|
||||
type: 'unknown',
|
||||
message: text,
|
||||
raw: text
|
||||
});
|
||||
}
|
||||
return 'reported-partial';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -116,6 +116,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',
|
||||
|
||||
@@ -14,6 +14,10 @@ const harness = vi.hoisted(() => ({
|
||||
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 }> },
|
||||
stderrHandler: null as null | ((error: { type: string; message: string; raw: string }) => void),
|
||||
hangPrompt: false,
|
||||
resolvePrompt: null as null | (() => void),
|
||||
cancelPrompt: vi.fn(async (_sessionId: string) => {}),
|
||||
// Lets a test take full manual control of when a given prompt() call
|
||||
// resolves, instead of the fixed-one-tick setImmediate delay below —
|
||||
// needed to deterministically test ordering against /compact without
|
||||
@@ -93,20 +97,30 @@ vi.mock('./utils/opencodeBackend', () => ({
|
||||
harness.promptContents.push(content);
|
||||
harness.events.push('prompt:start');
|
||||
harness.promptCount++;
|
||||
if (harness.promptImpl) {
|
||||
if (harness.hangPrompt) {
|
||||
await new Promise<void>((resolve) => {
|
||||
harness.resolvePrompt = resolve;
|
||||
});
|
||||
} else if (harness.promptImpl) {
|
||||
await harness.promptImpl();
|
||||
} else {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
harness.events.push('prompt:end');
|
||||
}),
|
||||
cancelPrompt: vi.fn(async () => {
|
||||
isPromptRequestInFlight: vi.fn(() =>
|
||||
harness.events.lastIndexOf('prompt:start') > harness.events.lastIndexOf('prompt:end')
|
||||
),
|
||||
cancelPrompt: vi.fn(async (sessionId: string) => {
|
||||
await harness.cancelPrompt(sessionId);
|
||||
if (harness.cancelPromptImpl) {
|
||||
await harness.cancelPromptImpl();
|
||||
}
|
||||
}),
|
||||
respondToPermission: vi.fn(async () => {}),
|
||||
onStderrError: vi.fn(),
|
||||
onStderrError: vi.fn((handler: (error: { type: string; message: string; raw: string }) => void) => {
|
||||
harness.stderrHandler = handler;
|
||||
}),
|
||||
setSessionInfoUpdateListener: vi.fn(),
|
||||
refreshSessionInfo: vi.fn(async (sessionId: string, cwd: string) => {
|
||||
harness.refreshSessionInfoCalls.push({ sessionId, cwd });
|
||||
@@ -319,7 +333,7 @@ function createSessionStub(
|
||||
sendUserMessage(_text: string) {}
|
||||
};
|
||||
|
||||
return { session, sessionEvents, sentAgentMessages, rpcHandlers, setModelReasoningEffort, pushKeepAlive, emitMessagesConsumedCalls, thinkingChangeCalls };
|
||||
return { session, sessionEvents, sentAgentMessages, agentMessages: sentAgentMessages, rpcHandlers, setModelReasoningEffort, pushKeepAlive, emitMessagesConsumedCalls, thinkingChangeCalls };
|
||||
}
|
||||
|
||||
function createCompactMode(model?: string): OpencodeMode {
|
||||
@@ -350,6 +364,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();
|
||||
compactHarness.calls = [];
|
||||
compactHarness.operationEvents = [];
|
||||
compactHarness.result = { ok: true };
|
||||
@@ -1932,6 +1950,91 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a stall stderr error only once per 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'
|
||||
});
|
||||
harness.stderrHandler!({
|
||||
type: 'quota_exceeded',
|
||||
message: 'Retrying after quota error.',
|
||||
raw: 'retrying in 30 seconds'
|
||||
});
|
||||
|
||||
expect(session.thinking).toBe(false);
|
||||
expect(harness.cancelPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1');
|
||||
expect(agentMessages).toEqual([{
|
||||
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') },
|
||||
|
||||
@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto';
|
||||
import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
|
||||
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';
|
||||
@@ -121,6 +123,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
private defaultBackendEffort: string | null = null;
|
||||
private setModelSupported: boolean | undefined = undefined;
|
||||
private setEffortSupported: boolean | undefined = undefined;
|
||||
private activeAcpSessionId: string | null = null;
|
||||
private stallErrorReportedForPrompt = false;
|
||||
|
||||
constructor(
|
||||
session: OpencodeSession,
|
||||
@@ -170,9 +174,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
registerAcpSessionTitleSync(backend, session.client);
|
||||
|
||||
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();
|
||||
@@ -205,6 +207,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 —
|
||||
@@ -571,6 +574,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
text: messageText
|
||||
}];
|
||||
|
||||
this.stallErrorReportedForPrompt = false;
|
||||
session.onThinkingChange(true);
|
||||
|
||||
try {
|
||||
@@ -580,11 +584,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
void backend.refreshSessionInfo(acpSessionId, session.path);
|
||||
} 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');
|
||||
@@ -656,6 +656,42 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
// replacement.
|
||||
}
|
||||
|
||||
private handleAcpStderrError(error: AcpStderrError): void {
|
||||
logger.debug('[opencode-remote] stderr error', error);
|
||||
const isStall = isAcpStallStderrError(error)
|
||||
&& this.backend?.isPromptRequestInFlight() === true;
|
||||
if (isStall && this.stallErrorReportedForPrompt) {
|
||||
return;
|
||||
}
|
||||
if (isStall) {
|
||||
this.stallErrorReportedForPrompt = true;
|
||||
}
|
||||
this.surfaceAgentError(error.message);
|
||||
if (isStall) {
|
||||
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);
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -147,6 +147,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({
|
||||
|
||||
Reference in New Issue
Block a user