From 64f0345a4750a8db1cb7a19b1fe864a33b536bc8 Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Sat, 29 Aug 2026 23:51:55 +0200 Subject: [PATCH] feat(ui): display LLM retry and failure error in modal with syntax highlighting --- src/server/chat/agent-loop-failure.test.ts | 2 +- src/server/chat/agent-loop.ts | 2 +- src/server/ws/protocol.ts | 8 +- src/shared/protocol.ts | 2 + web/src/components/plan/MessageList.tsx | 94 +++++++++++++++++++++- web/src/stores/session/llm-retry.test.ts | 9 ++- web/src/stores/session/messageHandler.ts | 7 +- web/src/stores/session/types.ts | 2 +- 8 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/server/chat/agent-loop-failure.test.ts b/src/server/chat/agent-loop-failure.test.ts index 18218086..bfb6ad6d 100644 --- a/src/server/chat/agent-loop-failure.test.ts +++ b/src/server/chat/agent-loop-failure.test.ts @@ -278,7 +278,7 @@ describe('agent loop LLM failure handling', () => { const retryMsg = onMessage.mock.calls.map((c: any[]) => c[0]).find((m: any) => m?.type === 'chat.llm_retry') expect(retryMsg).toBeDefined() - expect(retryMsg.payload).toEqual({ attempt: 2, retryInMs: 0 }) + expect(retryMsg.payload).toEqual({ attempt: 2, retryInMs: 0, error: 'boom' }) }) it('gives up after the retry window and relays chat.llm_retry_failed', async () => { diff --git a/src/server/chat/agent-loop.ts b/src/server/chat/agent-loop.ts index 34396db0..f1e86671 100644 --- a/src/server/chat/agent-loop.ts +++ b/src/server/chat/agent-loop.ts @@ -447,7 +447,7 @@ export async function runTopLevelAgentLoop( return { failed: { error: attemptResult.error } } } if (!config.subAgentMetadata) { - config.onMessage?.(createChatLLMRetryMessage(decision.attempt, decision.delayMs)) + config.onMessage?.(createChatLLMRetryMessage(decision.attempt, decision.delayMs, attemptResult.error)) } const waitResult = await sleepThroughRetryBackoff(decision.delayMs, sessionId, signal) if (waitResult === 'aborted') throw new Error('Aborted') diff --git a/src/server/ws/protocol.ts b/src/server/ws/protocol.ts index ab6068ba..326e87ab 100644 --- a/src/server/ws/protocol.ts +++ b/src/server/ws/protocol.ts @@ -295,8 +295,12 @@ export function createChatErrorMessage(error: string, recoverable: boolean): Ser return createServerMessage('chat.error', { error, recoverable }) } -export function createChatLLMRetryMessage(attempt: number, retryInMs: number): ServerMessage { - return createServerMessage('chat.llm_retry', { attempt, retryInMs }) +export function createChatLLMRetryMessage( + attempt: number, + retryInMs: number, + error?: string, +): ServerMessage { + return createServerMessage('chat.llm_retry', { attempt, retryInMs, ...(error !== undefined ? { error } : {}) }) } export function createChatLLMRetryFailedMessage( diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 43646170..fdf666a4 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -342,6 +342,8 @@ export interface ChatLLMRetryPayload { attempt: number /** Delay in ms until the next retry attempt (drives the UI countdown). */ retryInMs: number + /** Optional error message that triggered the retry. */ + error?: string } export interface ChatLLMRetryFailedPayload { diff --git a/web/src/components/plan/MessageList.tsx b/web/src/components/plan/MessageList.tsx index dc1e6e1a..2f048932 100644 --- a/web/src/components/plan/MessageList.tsx +++ b/web/src/components/plan/MessageList.tsx @@ -10,6 +10,8 @@ import { useDisplaySettings } from '../../stores/settings' import { ChatFeedItems } from './ChatFeedItems' import { CloseButton } from '../shared/CloseButton' import { ChevronUpIcon } from '../shared/icons' +import { Modal } from '../shared/Modal' +import { CodeHighlight } from '../shared/CodeHighlight' import { useClickOutside } from '../../hooks/useClickOutside' import { useSessionScope, useScopedPaneState } from '../../stores/session/session-scope' import type { DisplayItem } from './groupMessages.js' @@ -18,17 +20,59 @@ import type { LLMRetryState } from '../../stores/session/types' const EMPTY_CRITERIA: MetadataEntry[] = [] +function formatErrorMessage(error: string): { code: string; language: string; prefix?: string } { + // If the whole string is JSON + const trimmed = error.trim() + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + const parsed = JSON.parse(trimmed) + return { code: JSON.stringify(parsed, null, 2), language: 'json' } + } catch { + // ignore + } + } + + // If there's a prefix like "HTTP 400: {" or "LLMError: HTTP 400: {" + const jsonStart = error.indexOf('{') + const jsonArrayStart = error.indexOf('[') + const firstJsonIdx = + jsonStart !== -1 && jsonArrayStart !== -1 + ? Math.min(jsonStart, jsonArrayStart) + : jsonStart !== -1 + ? jsonStart + : jsonArrayStart + + if (firstJsonIdx > 0) { + const prefix = error.slice(0, firstJsonIdx).trim() + const potentialJson = error.slice(firstJsonIdx).trim() + try { + const parsed = JSON.parse(potentialJson) + return { prefix, code: JSON.stringify(parsed, null, 2), language: 'json' } + } catch { + // ignore + } + } + + return { code: error, language: 'text' } +} + /** Live countdown pill shown while an LLM call is backing off before its next retry. */ function LLMRetryIndicator({ retry, onRetryNow, + onShowError, }: { retry: Extract onRetryNow: () => void + onShowError: (error: string) => void }) { - const [receivedAt] = useState(Date.now()) + const [receivedAt, setReceivedAt] = useState(Date.now()) const [now, setNow] = useState(Date.now()) + useEffect(() => { + setReceivedAt(Date.now()) + }, [retry.attempt, retry.retryInMs]) + useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 250) return () => clearInterval(timer) @@ -49,6 +93,14 @@ function LLMRetryIndicator({ > Retry now + {retry.error && ( + + )} ) } @@ -145,6 +197,14 @@ export const MessageList = memo(function MessageList({ const [popupBlocked, setPopupBlocked] = useState(false) const [isScrollable, setIsScrollable] = useState(false) const [scrolledPastTop, setScrolledPastTop] = useState(false) + const [activeErrorModal, setActiveErrorModal] = useState(null) + + // Keep active error content updated if a new error comes in for the retry while modal is open + useEffect(() => { + if (activeErrorModal && llmRetry?.status === 'retrying' && llmRetry.error) { + setActiveErrorModal(llmRetry.error) + } + }, [activeErrorModal, llmRetry]) const getViewport = useViewport(scrollContainerRef) @@ -243,9 +303,9 @@ export const MessageList = memo(function MessageList({ {llmRetry?.status === 'retrying' && isRunning && (
sessionId && retryLLMNow(sessionId)} + onShowError={(err) => setActiveErrorModal(err)} />
)} @@ -253,8 +313,14 @@ export const MessageList = memo(function MessageList({ {(llmRetry?.status === 'failed' && !isRunning) || blockedWorkflowStep ? (
{llmRetry?.status === 'failed' && ( -
- The LLM call failed: {llmRetry.error} +
+ The LLM call failed. +
)} {isWorkflowBlock && ( @@ -363,6 +429,26 @@ export const MessageList = memo(function MessageList({
)} + + {activeErrorModal && ( + setActiveErrorModal(null)} title="LLM Error" size="lg"> +
+ {formatErrorMessage(activeErrorModal).prefix && ( +
+ {formatErrorMessage(activeErrorModal).prefix} +
+ )} +
+ +
+
+
+ )}
) }) diff --git a/web/src/stores/session/llm-retry.test.ts b/web/src/stores/session/llm-retry.test.ts index bb554f4b..5d9cf912 100644 --- a/web/src/stores/session/llm-retry.test.ts +++ b/web/src/stores/session/llm-retry.test.ts @@ -99,10 +99,15 @@ describe('LLM retry UI state', () => { useSessionStore.getState().handleServerMessage({ type: 'chat.llm_retry', sessionId: 'session-1', - payload: { attempt: 2, retryInMs: 4000 }, + payload: { attempt: 2, retryInMs: 4000, error: 'Request failed 400' }, }) - expect(useSessionStore.getState().llmRetry).toEqual({ status: 'retrying', attempt: 2, retryInMs: 4000 }) + expect(useSessionStore.getState().llmRetry).toEqual({ + status: 'retrying', + attempt: 2, + retryInMs: 4000, + error: 'Request failed 400', + }) expect(useSessionStore.getState().error).toBeNull() }) diff --git a/web/src/stores/session/messageHandler.ts b/web/src/stores/session/messageHandler.ts index d4b3db7c..ca4ef100 100644 --- a/web/src/stores/session/messageHandler.ts +++ b/web/src/stores/session/messageHandler.ts @@ -727,7 +727,12 @@ export function handleServerMessage( applyChat(set, get, sessionId, (pane) => ({ ...pane, error: null, - llmRetry: { status: 'retrying', attempt: payload.attempt, retryInMs: payload.retryInMs }, + llmRetry: { + status: 'retrying', + attempt: payload.attempt, + retryInMs: payload.retryInMs, + ...(payload.error !== undefined ? { error: payload.error } : {}), + }, })) break } diff --git a/web/src/stores/session/types.ts b/web/src/stores/session/types.ts index 350263e5..30ef9e4e 100644 --- a/web/src/stores/session/types.ts +++ b/web/src/stores/session/types.ts @@ -32,7 +32,7 @@ export interface PendingQuestion { /** Live status of an LLM failure: backing off before a retry, or the window exhausted. */ export type LLMRetryState = - { status: 'retrying'; attempt: number; retryInMs: number } | { status: 'failed'; error: string } + { status: 'retrying'; attempt: number; retryInMs: number; error?: string } | { status: 'failed'; error: string } export interface StreamingBuffer { messageId: string | null