From 02a0aa67330f5cb808544534e38714a178a134e3 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 18 Jun 2026 10:17:57 +0800 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Cursor --- cli/src/agent/messageConverter.test.ts | 12 +++++++++ .../cursor/cursorAcpRemoteLauncher.test.ts | 5 ++++ cli/src/cursor/cursorAcpRemoteLauncher.ts | 27 ++++++++++++++----- .../cursor/cursorLegacyRemoteLauncher.test.ts | 27 +++++++++++-------- cli/src/cursor/cursorLegacyRemoteLauncher.ts | 23 ++++++++++++---- cli/src/cursor/cursorLocalLauncher.ts | 6 ++++- web/src/chat/normalize.test.ts | 21 +++++++++++++++ web/src/chat/normalizeAgent.ts | 15 +++++++++++ web/src/chat/presentation.test.ts | 12 +++++++++ web/src/chat/presentation.ts | 3 +++ web/src/chat/reducerEvents.ts | 12 +++++++++ web/src/chat/reducerTimeline.ts | 9 +++++++ web/src/chat/types.ts | 1 + 13 files changed, 149 insertions(+), 24 deletions(-) diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index 0aeb31f3..86ca29cc 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -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', diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index 164d2065..1aeca09b 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -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 }; 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); }); diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index a3fe2545..4382974e 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -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: diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts index 32d81162..9b18c6c6 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts @@ -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); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index b8f80e85..7e222886 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -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()); } diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts index 2e26a24b..b08ce2aa 100644 --- a/cli/src/cursor/cursorLocalLauncher.ts +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -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); diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 3a3db02e..6b9b0a4e 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -178,6 +178,27 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('normalizes agent error payloads as error events', () => { + const normalized = normalizeDecryptedMessage(makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'error', + message: 'Cursor Agent failed: authentication required' + } + } + })) + + expect(normalized).toMatchObject({ + role: 'event', + content: { + type: 'error', + message: 'Cursor Agent failed: authentication required' + } + }) + }) + it('treats non-sidechain string user output as sidechain', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 39de43c6..3f2bf881 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -577,6 +577,21 @@ export function normalizeAgentRecord( } } + if (data.type === 'error' && typeof data.message === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'error', + message: data.message + }, + isSidechain: false, + meta + } + } + if (data.type === 'message' && typeof data.message === 'string') { const review = parseCodexReviewMessage(data.message) if (review) { diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index 6918fde4..af4497fa 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from 'vitest' import { getEventPresentation, formatMessageTimestamp, formatResetTime } from './presentation' +describe('getEventPresentation — agent errors', () => { + it('formats error events with warning icon and message text', () => { + const result = getEventPresentation({ + type: 'error', + message: 'Cursor Agent failed: authentication required' + }) + + expect(result.icon).toBe('⚠️') + expect(result.text).toBe('Cursor Agent failed: authentication required') + }) +}) + describe('getEventPresentation — limit-warning', () => { it('formats five_hour warning', () => { const result = getEventPresentation({ diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 676c6bcc..168693dc 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -182,6 +182,9 @@ export function getEventPresentation(event: AgentEvent): EventPresentation { const suffix = typeLabel ? ` (${typeLabel})` : '' return { icon: '⏳', text: endsAt ? `Usage limit reached${suffix} until ${formatUnixTimestamp(endsAt)}` : `Usage limit reached${suffix}` } } + if (event.type === 'error') { + return { icon: '⚠️', text: typeof event.message === 'string' ? event.message : 'Error' } + } if (event.type === 'message') { return { icon: null, text: typeof event.message === 'string' ? event.message : 'Message' } } diff --git a/web/src/chat/reducerEvents.ts b/web/src/chat/reducerEvents.ts index f2302cab..23ac761b 100644 --- a/web/src/chat/reducerEvents.ts +++ b/web/src/chat/reducerEvents.ts @@ -79,6 +79,18 @@ export function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] { continue } + if (event.type === 'error' && typeof event.message === 'string') { + const message = event.message.trim() + const key = `error:${message}` + if (key === prevEventKey) { + continue + } + result.push(block) + prevEventKey = key + prevTitleChangedTo = null + continue + } + let key: string try { key = `event:${JSON.stringify(event)}` diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index d13cd34a..6b95d193 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -180,6 +180,15 @@ function normalizeTraceMessage( meta: source.meta } + if (data.type === 'error' && typeof data.message === 'string') { + return [{ + ...base, + id: traceId, + role: 'event', + content: { type: 'error', message: data.message } + } as TracedMessage] + } + if (data.type === 'message' && typeof data.message === 'string') { return [{ ...base, diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 4641e913..f42d0540 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -16,6 +16,7 @@ export type UsageData = { export type AgentEvent = | { type: 'switch'; mode: 'local' | 'remote' } | { type: 'message'; message: string } + | { type: 'error'; message: string } | { type: 'title-changed'; title: string } | { type: 'limit-reached'; endsAt: number; limitType: string } | { type: 'limit-warning'; /** 0–1 ratio (e.g. 0.9 = 90%), integer-precision via CLI pipe format */ utilization: number; endsAt: number; limitType: string }