fix(cursor): surface agent errors with warning styling in web UI (#871)

* test: reproduce issue #864

Assert Cursor error paths emit agent error payloads and web UI renders
them as warning-styled events instead of neutral session messages.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): surface agent errors with warning styling in web UI (closes #864)

Route Cursor stderr, init, prompt, and legacy exit failures through
sendAgentMessage({ type: 'error' }) and teach the web chat layer to
render error events with a warning icon instead of neutral info text.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-06-18 10:17:57 +08:00
committed by GitHub
co-authored by Cursor
parent bfd0f4a376
commit 02a0aa6733
13 changed files with 149 additions and 24 deletions
+12
View File
@@ -50,6 +50,18 @@ describe('convertAgentMessage', () => {
});
});
it('converts agent errors into error wire payloads', () => {
const converted = convertAgentMessage({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});
expect(converted).toEqual({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});
});
it('converts usage messages into token_count payloads', () => {
const converted = convertAgentMessage({
type: 'usage',
@@ -207,11 +207,16 @@ describe('cursorAcpRemoteLauncher', () => {
it('throws on initialize failure without invoking legacy launcher', async () => {
harness.initializeError = new Error('agent acp not found');
const session = makeSession(null);
const client = session.client as unknown as { sendAgentMessage: ReturnType<typeof vi.fn> };
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
/Cursor ACP mode is required for new Cursor remote sessions/
);
expect(client.sendAgentMessage).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('agent acp not found')
});
expect(legacyLauncher).not.toHaveBeenCalled();
expect(harness.newSessionCalled).toBe(false);
});
+20 -7
View File
@@ -64,7 +64,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
backend.onStderrError((error) => {
logger.debug('[cursor-acp] stderr error', error);
session.sendSessionEvent({ type: 'message', message: error.message });
const converted = convertAgentMessage({ type: 'error', message: error.message });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(error.message, 'status');
});
@@ -72,7 +75,13 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
await backend.initialize();
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new Error(`${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`);
const fullMsg = `${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`;
const converted = convertAgentMessage({ type: 'error', message: fullMsg });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(fullMsg, 'status');
throw new Error(fullMsg);
}
await backend.authenticateIfAvailable('cursor_login');
@@ -204,11 +213,12 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
} catch (error) {
logger.warn('[cursor-acp] prompt failed', error);
const errMsg = error instanceof Error ? error.message : String(error);
session.sendSessionEvent({
type: 'message',
message: `Cursor Agent failed: ${errMsg}`
});
messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status');
const message = `Cursor Agent failed: ${errMsg}`;
const converted = convertAgentMessage({ type: 'error', message });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(message, 'status');
} finally {
session.onThinkingChange(false);
await this.permissionAdapter?.cancelAll('Prompt finished');
@@ -271,6 +281,9 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
case 'plan':
this.messageBuffer.addMessage('Plan updated', 'status');
break;
case 'error':
this.messageBuffer.addMessage(message.message, 'status');
break;
case 'turn_complete':
break;
default:
@@ -12,7 +12,12 @@ vi.mock('@/ui/logger', () => ({
}));
vi.mock('@/agent/messageConverter', () => ({
convertAgentMessage: () => null
convertAgentMessage: (message: { type: string; message?: string }) => {
if (message.type === 'error' && typeof message.message === 'string') {
return { type: 'error', message: message.message };
}
return null;
}
}));
vi.mock('@/ui/ink/OpencodeDisplay', () => ({
@@ -145,9 +150,9 @@ describe('cursorLegacyRemoteLauncher', () => {
await cursorLegacyRemoteLauncher(session);
expect(spawnMock).toHaveBeenCalledTimes(2);
const messages = client.sendSessionEvent.mock.calls
const messages = client.sendAgentMessage.mock.calls
.map((c) => c[0])
.filter((e: any) => e.type === 'message');
.filter((e: any) => e.type === 'error');
expect(messages).toHaveLength(1);
expect(messages[0].message).toContain('Cursor authentication expired');
expect(messages[0].message).toContain("'agent login'");
@@ -184,9 +189,9 @@ describe('cursorLegacyRemoteLauncher', () => {
const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher');
await cursorLegacyRemoteLauncher(session);
const messages = client.sendSessionEvent.mock.calls
const messages = client.sendAgentMessage.mock.calls
.map((c) => c[0])
.filter((e: any) => e.type === 'message');
.filter((e: any) => e.type === 'error');
expect(messages).toHaveLength(1);
expect(messages[0].message).toContain('rate limit');
expect(messages[0].message).toContain('queued and will retry');
@@ -209,9 +214,9 @@ describe('cursorLegacyRemoteLauncher', () => {
await cursorLegacyRemoteLauncher(session);
expect(spawnMock).toHaveBeenCalledTimes(1);
const messageEvents = client.sendSessionEvent.mock.calls
const messageEvents = client.sendAgentMessage.mock.calls
.map((c) => c[0])
.filter((e: any) => e.type === 'message');
.filter((e: any) => e.type === 'error');
expect(messageEvents).toHaveLength(1);
expect(messageEvents[0].message).toContain('Agent exited (134)');
expect(messageEvents[0].message).toContain('Segmentation fault');
@@ -242,9 +247,9 @@ describe('cursorLegacyRemoteLauncher', () => {
await cursorLegacyRemoteLauncher(session);
expect(spawnMock).toHaveBeenCalledTimes(1);
const messageEvents = client.sendSessionEvent.mock.calls
const messageEvents = client.sendAgentMessage.mock.calls
.map((c) => c[0])
.filter((e: any) => e.type === 'message');
.filter((e: any) => e.type === 'error');
expect(messageEvents).toHaveLength(1);
expect(messageEvents[0].message).toContain('Agent exited (143)');
expect(messageEvents[0].message).not.toContain('queued and will retry');
@@ -307,9 +312,9 @@ describe('cursorLegacyRemoteLauncher', () => {
expect(spawnMock).toHaveBeenCalledTimes(5);
const messageEvents = client.sendSessionEvent.mock.calls
const messageEvents = client.sendAgentMessage.mock.calls
.map((c) => c[0])
.filter((e: any) => e.type === 'message');
.filter((e: any) => e.type === 'error');
// 4 transient retry banners + 1 drop banner = 5
expect(messageEvents).toHaveLength(5);
const banners = messageEvents.map((e: any) => e.message);
+18 -5
View File
@@ -220,15 +220,22 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
this.consecutiveTransientFailures = 0;
const errMsg = `Agent exited (${exitCode}): ${truncateStderrForDisplay(stderr)}`;
logger.warn(`[cursor-remote] ${errMsg}`);
session.sendSessionEvent({ type: 'message', message: errMsg });
const converted = convertAgentMessage({ type: 'error', message: errMsg });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(errMsg, 'status');
}
} catch (error) {
this.consecutiveTransientFailures = 0;
logger.warn('[cursor-remote] Agent run failed', error);
const errMsg = error instanceof Error ? error.message : String(error);
session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` });
messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status');
const message = `Cursor Agent failed: ${errMsg}`;
const converted = convertAgentMessage({ type: 'error', message });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(message, 'status');
} finally {
session.onThinkingChange(false);
if (session.queue.size() === 0 && !this.shouldExit) {
@@ -317,7 +324,10 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
`[cursor-remote] transient agent failures hit cap (${MAX_CONSECUTIVE_TRANSIENT_FAILURES}); dropping message`,
{ exitCode, stderr: stderr.slice(0, STDERR_DISPLAY_LIMIT) }
);
session.sendSessionEvent({ type: 'message', message: dropMsg });
const converted = convertAgentMessage({ type: 'error', message: dropMsg });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(dropMsg, 'status');
this.consecutiveTransientFailures = 0;
return;
@@ -343,7 +353,10 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
session.queue.unshift(message, mode);
}
const friendly = friendlyTransientMessage(exitCode, stderr);
session.sendSessionEvent({ type: 'message', message: friendly });
const converted = convertAgentMessage({ type: 'error', message: friendly });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(friendly, 'status');
await this.transientBackoff(getTransientBackoffMs());
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { logger } from '@/ui/logger';
import { cursorLocal } from './cursorLocal';
import { CursorSession } from './session';
import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
import { convertAgentMessage } from '@/agent/messageConverter';
function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean } {
if (mode === 'plan') {
@@ -46,7 +47,10 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit
});
},
sendFailureMessage: (message) => {
session.sendSessionEvent({ type: 'message', message });
const converted = convertAgentMessage({ type: 'error', message });
if (converted) {
session.sendAgentMessage(converted);
}
},
recordLocalLaunchFailure: (message, exitReason) => {
session.recordLocalLaunchFailure(message, exitReason);