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:
SSU-WEI HUANG
2026-08-04 11:19:02 +08:00
committed by GitHub
co-authored by Cursor
parent 3556c7d7f1
commit 807fa72aaa
11 changed files with 481 additions and 38 deletions
+107 -4
View File
@@ -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') },
+44 -8
View File
@@ -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);