From 517c8396a122a8f8128997a043a336997412a282 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 30 Dec 2025 16:28:03 +0100 Subject: [PATCH] ui: surface agent errors in UI (Claude/Codex/Gemini) --- src/claude/claudeLocalLauncher.ts | 3 +- src/claude/claudeRemoteLauncher.ts | 3 +- src/codex/runCodex.ts | 61 ++++++++++++++++++++++++++++-- src/gemini/runGemini.ts | 3 +- src/utils/formatErrorForUi.ts | 14 +++++++ 5 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 src/utils/formatErrorForUi.ts diff --git a/src/claude/claudeLocalLauncher.ts b/src/claude/claudeLocalLauncher.ts index 0a7772ed..5650d8c2 100644 --- a/src/claude/claudeLocalLauncher.ts +++ b/src/claude/claudeLocalLauncher.ts @@ -3,6 +3,7 @@ import { claudeLocal } from "./claudeLocal"; import { Session } from "./session"; import { Future } from "@/utils/future"; import { createSessionScanner } from "./utils/sessionScanner"; +import { formatErrorForUi } from "@/utils/formatErrorForUi"; export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { @@ -123,7 +124,7 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | } catch (e) { logger.debug('[local]: launch error', e); if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + session.client.sendSessionEvent({ type: 'message', message: `Claude process error: ${formatErrorForUi(e)}` }); continue; } else { break; diff --git a/src/claude/claudeRemoteLauncher.ts b/src/claude/claudeRemoteLauncher.ts index b1888804..a3d11ae7 100644 --- a/src/claude/claudeRemoteLauncher.ts +++ b/src/claude/claudeRemoteLauncher.ts @@ -15,6 +15,7 @@ import { EnhancedMode } from "./loop"; import { RawJSONLines } from "@/claude/types"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { getToolName } from "./utils/getToolName"; +import { formatErrorForUi } from "@/utils/formatErrorForUi"; interface PermissionsField { date: number; @@ -402,7 +403,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | } catch (e) { logger.debug('[remote]: launch error', e); if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + session.client.sendSessionEvent({ type: 'message', message: `Claude process error: ${formatErrorForUi(e)}` }); continue; } } finally { diff --git a/src/codex/runCodex.ts b/src/codex/runCodex.ts index d789bb19..97fc6d67 100644 --- a/src/codex/runCodex.ts +++ b/src/codex/runCodex.ts @@ -22,12 +22,13 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { MessageBuffer } from "@/ui/ink/messageBuffer"; import { CodexDisplay } from "@/ui/ink/CodexDisplay"; import { trimIdent } from "@/utils/trimIdent"; -import type { CodexSessionConfig } from './types'; +import type { CodexSessionConfig, CodexToolResponse } from './types'; import { CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; import { notifyDaemonSessionStarted } from "@/daemon/controlClient"; import { registerKillSessionHandler } from "@/claude/registerKillSessionHandler"; import { delay } from "@/utils/time"; import { stopCaffeinate } from "@/utils/caffeinate"; +import { formatErrorForUi } from "@/utils/formatErrorForUi"; type ReadyEventOptions = { pending: unknown; @@ -391,12 +392,46 @@ export async function runCodex(opts: { session.sendCodexMessage(message); }); client.setPermissionHandler(permissionHandler); + + function extractCodexToolErrorText(response: CodexToolResponse): string | null { + if (!response?.isError) { + return null; + } + const text = (response.content || []) + .map((c) => (c && typeof c.text === 'string' ? c.text : '')) + .filter(Boolean) + .join('\n') + .trim(); + return text || 'Codex error'; + } + + function forwardCodexStatusToUi(messageText: string): void { + messageBuffer.addMessage(messageText, 'status'); + session.sendSessionEvent({ type: 'message', message: messageText }); + } + + function forwardCodexErrorToUi(errorText: string): void { + forwardCodexStatusToUi(`Codex error: ${errorText}`); + } + client.setHandler((msg) => { logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`); // Add messages to the ink UI buffer based on message type if (msg.type === 'agent_message') { messageBuffer.addMessage(msg.message, 'assistant'); + } else if (msg.type === 'error') { + const errorText = typeof msg.message === 'string' && msg.message.trim() ? msg.message.trim() : 'Codex error'; + forwardCodexErrorToUi(errorText); + } else if (msg.type === 'stream_error') { + const status = typeof msg.message === 'string' && msg.message.trim() ? msg.message.trim() : 'Codex stream error'; + forwardCodexStatusToUi(`Codex stream error: ${status}`); + } else if (msg.type === 'mcp_startup_update') { + if (msg.status?.state === 'failed') { + const errorText = typeof msg.status?.error === 'string' ? msg.status.error : 'MCP server failed to start'; + const serverName = typeof msg.server === 'string' ? msg.server : 'unknown'; + forwardCodexStatusToUi(`MCP server "${serverName}" failed to start: ${errorText}`); + } } else if (msg.type === 'agent_reasoning_delta') { // Skip reasoning deltas in the UI to reduce noise } else if (msg.type === 'agent_reasoning') { @@ -671,10 +706,18 @@ export async function runCodex(opts: { (startConfig.config as any).experimental_resume = resumeFile; } - await client.startSession( + const startResponse = await client.startSession( startConfig, { signal: abortController.signal } ); + const startError = extractCodexToolErrorText(startResponse); + if (startError) { + forwardCodexErrorToUi(startError); + client.clearSession(); + wasCreated = false; + currentModeHash = null; + continue; + } wasCreated = true; first = false; } else { @@ -683,6 +726,14 @@ export async function runCodex(opts: { { signal: abortController.signal } ); logger.debug('[Codex] continueSession response:', response); + const continueError = extractCodexToolErrorText(response); + if (continueError) { + forwardCodexErrorToUi(continueError); + client.clearSession(); + wasCreated = false; + currentModeHash = null; + continue; + } } } catch (error) { logger.warn('Error in codex session:', error); @@ -697,8 +748,10 @@ export async function runCodex(opts: { currentModeHash = null; logger.debug('[Codex] Marked session as not created after abort for proper resume'); } else { - messageBuffer.addMessage('Process exited unexpectedly', 'status'); - session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + const details = formatErrorForUi(error); + const messageText = `Codex process error: ${details}`; + messageBuffer.addMessage(messageText, 'status'); + session.sendSessionEvent({ type: 'message', message: messageText }); // For unexpected exits, try to store session for potential recovery if (client.hasActiveSession()) { storedSessionIdForResume = client.storeSessionForResume(); diff --git a/src/gemini/runGemini.ts b/src/gemini/runGemini.ts index b89554bf..de8ab049 100644 --- a/src/gemini/runGemini.ts +++ b/src/gemini/runGemini.ts @@ -27,6 +27,7 @@ import { MessageBuffer } from '@/ui/ink/messageBuffer'; import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; import { stopCaffeinate } from '@/utils/caffeinate'; +import { formatErrorForUi } from '@/utils/formatErrorForUi'; import { createGeminiBackend } from '@/agent/acp/gemini'; import type { AgentBackend, AgentMessage } from '@/agent/AgentBackend'; @@ -1039,7 +1040,7 @@ export async function runGemini(opts: { errorMsg = errorDetails || errorMessage || errObj.message; } } else if (error instanceof Error) { - errorMsg = error.message; + errorMsg = formatErrorForUi(error); } messageBuffer.addMessage(errorMsg, 'status'); diff --git a/src/utils/formatErrorForUi.ts b/src/utils/formatErrorForUi.ts new file mode 100644 index 00000000..ee11128f --- /dev/null +++ b/src/utils/formatErrorForUi.ts @@ -0,0 +1,14 @@ +/** + * Convert an unknown thrown value into a user-visible string. + * + * Intended for UI surfaces (TUI/mobile) where giant stacks can be noisy; we keep a generous cap. + */ +export function formatErrorForUi(error: unknown, opts?: { maxChars?: number }): string { + const maxChars = Math.max(1000, opts?.maxChars ?? 50_000); + const msg = error instanceof Error + ? (error.stack || error.message || String(error)) + : String(error); + + return msg.length > maxChars ? `${msg.slice(0, maxChars)}\n…[truncated]` : msg; +} +