mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user