From f9908af1a02f5ba00b869d689f4924a697dca771 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 20:33:17 -0500 Subject: [PATCH] fix(desktop): persist inline assistant errors across hydrate/resume - Detect provider failure text arriving via message.complete (HTTP 4xx, "API call failed after N retries", Provider/Gateway error: ...) and persist as an inline assistant error instead of regular completion text, blocking the hydrate that was wiping it. - preserveLocalAssistantErrors: merge by id so same-id hydrated messages keep their local error, and preserve the optimistic user+error pair as a unit (with tail-user dedupe). - Hook all hydrate/resume writers (use-session-actions resume + fallback, hydrateFromStoredSession, syncSessionStateToView) into the merge so stale snapshots can't clobber a failed turn. - Add error to chatMessagesEquivalent so the resume diff actually sees error-only changes and paints them. - editMessage on a failed turn now submits a plain resend (no truncate_before_user_ordinal) and retries plainly on the "no longer in session history" race. Style polish on touched files: - Inline error: text-only treatment (no card). - User stop / edit-composer send: shared Tabler IconPlayerStopFilled glyph + shared icon-button class slot for parity. --- apps/desktop/src/app/desktop-controller.tsx | 4 +- .../app/session/hooks/use-message-stream.ts | 124 ++++++-- .../app/session/hooks/use-prompt-actions.ts | 76 ++++- .../app/session/hooks/use-session-actions.ts | 21 +- .../session/hooks/use-session-state-cache.ts | 47 ++- .../src/components/assistant-ui/thread.tsx | 277 ++++++++++++------ apps/desktop/src/lib/chat-messages.test.ts | 171 ++++++++++- apps/desktop/src/lib/chat-messages.ts | 72 +++++ 8 files changed, 627 insertions(+), 165 deletions(-) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 1838b5bd6e8..c6f9143ed0e 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -9,7 +9,7 @@ import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' import { getSessionMessages, listSessions } from '../hermes' -import { toChatMessages } from '../lib/chat-messages' +import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' import { $pinnedSessionIds, $sessionsLimit, @@ -280,7 +280,7 @@ export function DesktopController() { runtimeSessionId, state => ({ ...state, - messages: toChatMessages(latest.messages) + messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages) }), storedSessionId ) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 6f2c31e88a5..3410ae9deb0 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -61,6 +61,21 @@ interface QueuedStreamDeltas { const STREAM_DELTA_FLUSH_MS = 16 +// Gateway/provider failures sometimes arrive as message.complete text instead +// of an explicit error event. Treat matches as inline assistant errors so they +// persist like real error events and don't get erased by hydrate fallback. +const COMPLETION_ERROR_PATTERNS = [ + /^API call failed after \d+ retries:/i, + /^HTTP\s+\d{3}\b/i, + /^(Provider|Gateway)\s+error:/i +] + +function completionErrorText(finalText: string): string | null { + const text = finalText.trim() + + return text && COMPLETION_ERROR_PATTERNS.some(re => re.test(text)) ? text : null +} + const SUBAGENT_EVENT_TYPES = new Set([ 'subagent.spawn_requested', 'subagent.start', @@ -377,7 +392,12 @@ export function useMessageStream({ ) => { if (!nativeSubagentSessionsRef.current.has(sessionId)) { for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) { - upsertSubagent(sessionId, subagentPayload, true, phase === 'complete' ? 'delegate.complete' : 'delegate.running') + upsertSubagent( + sessionId, + subagentPayload, + true, + phase === 'complete' ? 'delegate.complete' : 'delegate.running' + ) } } @@ -406,6 +426,7 @@ export function useMessageStream({ const streamId = state.streamId const finalText = renderMediaTags(text).trim() + const completionError = completionErrorText(finalText) const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() const dedupeReference = normalize(finalText) @@ -427,10 +448,26 @@ export function useMessageStream({ return finalText ? [...kept, assistantTextPart(finalText)] : kept } - const completeMessage = (message: ChatMessage): ChatMessage => ({ - ...message, - parts: replaceTextPart(message.parts), - pending: false + const completeMessage = (message: ChatMessage): ChatMessage => + completionError + ? { + ...message, + error: completionError, + parts: message.parts.filter(part => part.type !== 'text'), + pending: false + } + : { + ...message, + parts: replaceTextPart(message.parts), + pending: false + } + + const newAssistantFromCompletion = (): ChatMessage => ({ + id: `assistant-${Date.now()}`, + role: 'assistant', + parts: completionError ? [] : [assistantTextPart(finalText)], + branchGroupId: state.pendingBranchGroup ?? undefined, + ...(completionError && { error: completionError }) }) const prev = state.messages @@ -453,30 +490,18 @@ export function useMessageStream({ messageIndex === index ? completeMessage(message) : message ) } else if (finalText) { - nextMessages = [ - ...prev, - { - id: `assistant-${Date.now()}`, - role: 'assistant', - parts: [assistantTextPart(finalText)], - branchGroupId: state.pendingBranchGroup ?? undefined - } - ] + nextMessages = [...prev, newAssistantFromCompletion()] } } else if (finalText) { - nextMessages = [ - ...prev, - { - id: `assistant-${Date.now()}`, - role: 'assistant', - parts: [assistantTextPart(finalText)], - branchGroupId: state.pendingBranchGroup ?? undefined - } - ] + nextMessages = [...prev, newAssistantFromCompletion()] } } - shouldHydrate = !state.sawAssistantPayload || !finalText + const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) + const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) + const unresolvedUserTail = lastVisible?.role === 'user' + shouldHydrate = + !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) return { ...state, @@ -504,6 +529,50 @@ export function useMessageStream({ [activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState] ) + const failAssistantMessage = useCallback( + (sessionId: string, errorMessage: string) => { + updateSessionState(sessionId, state => { + const streamId = state.streamId ?? `assistant-error-${Date.now()}` + const groupId = state.pendingBranchGroup ?? undefined + const prev = state.messages + const error = errorMessage.trim() || 'Hermes reported an error' + + const nextMessages = prev.some(m => m.id === streamId) + ? prev.map(message => + message.id === streamId + ? { + ...message, + error, + pending: false + } + : message + ) + : [ + ...prev, + { + id: streamId, + role: 'assistant' as const, + parts: [], + error, + pending: false, + branchGroupId: groupId + } + ] + + return { + ...state, + messages: nextMessages, + streamId: null, + pendingBranchGroup: null, + sawAssistantPayload: true, + awaitingResponse: false, + busy: false + } + }) + }, + [updateSessionState] + ) + const handleGatewayEvent = useCallback( (event: RpcEvent) => { const payload = event.payload as GatewayEventPayload | undefined @@ -738,11 +807,7 @@ export function useMessageStream({ if (sessionId) { flushQueuedDeltas(sessionId) - updateSessionState(sessionId, state => ({ - ...state, - awaitingResponse: false, - busy: false - })) + failAssistantMessage(sessionId, errorMessage) } if (isActiveEvent) { @@ -755,6 +820,7 @@ export function useMessageStream({ appendReasoningDelta, activeSessionIdRef, completeAssistantMessage, + failAssistantMessage, flushQueuedDeltas, queryClient, refreshHermesConfig, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 86b5bb96963..62d685fc701 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -23,8 +23,8 @@ import { $composerAttachments, addComposerAttachment, clearComposerAttachments, - terminalContextBlocksFromDraft, - type ComposerAttachment + type ComposerAttachment, + terminalContextBlocksFromDraft } from '@/store/composer' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' @@ -54,6 +54,12 @@ function isProviderSetupError(error: unknown) { return isProviderSetupErrorMessage(message) } +function inlineErrorMessage(error: unknown, fallback: string): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback + + return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() +} + interface PromptActionsOptions { activeSessionId: string | null activeSessionIdRef: MutableRefObject @@ -203,10 +209,12 @@ export function usePromptActions({ const visibleText = rawText.trim() const usingComposerAttachments = !options?.attachments const attachments = options?.attachments ?? $composerAttachments.get() + const contextRefs = attachments .map(a => a.refText) .filter(Boolean) .join('\n') + const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') const hasImage = attachments.some(a => a.kind === 'image') const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r)) @@ -314,12 +322,32 @@ export function usePromptActions({ }) await requestGateway('prompt.submit', { session_id: sessionId, text }) - if (usingComposerAttachments) clearComposerAttachments() + if (usingComposerAttachments) { + clearComposerAttachments() + } return true } catch (err) { + const message = inlineErrorMessage(err, 'Prompt failed') + releaseBusy() - updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false })) + updateSessionState(sessionId, state => ({ + ...state, + messages: [ + ...state.messages, + { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + parts: [], + error: message || 'Prompt failed', + branchGroupId: state.pendingBranchGroup ?? undefined + } + ], + busy: false, + awaitingResponse: false, + pendingBranchGroup: null, + sawAssistantPayload: true + })) if (isProviderSetupError(err)) { requestDesktopOnboarding('Add a provider credential before sending your first message.') @@ -328,6 +356,7 @@ export function usePromptActions({ } notifyError(err, 'Prompt failed') + return false } }, @@ -684,10 +713,16 @@ export function usePromptActions({ return } - const truncate_before_user_ordinal = visibleUserOrdinal(messages, sourceIndex) + // Failed turn: optimistic user msg never reached the gateway, so truncating + // by ordinal would 422. Submit as a plain resend instead. + const nextMessage = messages[sourceIndex + 1] + const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error) const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] } clearNotifications() + busyRef.current = true + setBusy(true) + setAwaitingResponse(true) updateSessionState(sessionId, state => ({ ...state, busy: true, @@ -698,14 +733,39 @@ export function usePromptActions({ messages: [...state.messages.slice(0, sourceIndex), editedMessage] })) + const submit = (truncateOrdinal?: number) => + requestGateway('prompt.submit', { + session_id: sessionId, + text, + ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) + }) + + const isStaleTargetError = (err: unknown) => + /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) + try { - await requestGateway('prompt.submit', { session_id: sessionId, text, truncate_before_user_ordinal }) + await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)) } catch (err) { + let surfaced = err + + if (!isFailedTurn && isStaleTargetError(err)) { + try { + await submit() + + return + } catch (retryErr) { + surfaced = retryErr + } + } + + busyRef.current = false + setBusy(false) + setAwaitingResponse(false) updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false })) - notifyError(err, 'Edit failed') + notifyError(surfaced, 'Edit failed') } }, - [activeSessionId, activeSessionIdRef, requestGateway, updateSessionState] + [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState] ) const handleThreadMessagesChange = useCallback( diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 6c82c66fc09..da13409ae0c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -3,7 +3,7 @@ import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' import { deleteSession, getSessionMessages } from '@/hermes' -import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages' +import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' import { clearComposerAttachments, clearComposerDraft } from '@/store/composer' @@ -92,6 +92,7 @@ function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean { a.id !== b.id || a.role !== b.role || a.pending !== b.pending || + a.error !== b.error || a.hidden !== b.hidden || a.branchGroupId !== b.branchGroupId ) { @@ -194,7 +195,9 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) } -function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial> | null { +function applyRuntimeInfo( + info: SessionCreateResponse['info'] | undefined +): Partial> | null { if (!info) { return null } @@ -454,7 +457,7 @@ export function useSessionActions({ const storedMessages = await getSessionMessages(storedSessionId) if (isCurrentResume()) { - localSnapshot = toChatMessages(storedMessages.messages) + localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get()) if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { setMessages(localSnapshot) @@ -474,7 +477,11 @@ export function useSessionActions({ } const currentMessages = $messages.get() - const resumedMessages = reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages) + + const resumedMessages = preserveLocalAssistantErrors( + reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages), + currentMessages + ) // Avoid a second visible transcript rebuild on resume/switch. // `getSessionMessages()` is the stable stored transcript snapshot and // paints first; `session.resume` can return a slightly different @@ -484,13 +491,15 @@ export function useSessionActions({ // exists; use gateway messages only as a fallback when no local // snapshot was available. - const messagesForView = + const preferredMessages = localSnapshot.length > 0 ? localSnapshot : chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages + const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages) + setActiveSessionId(resumed.session_id) activeSessionIdRef.current = resumed.session_id const runtimeInfo = applyRuntimeInfo(resumed.info) @@ -521,7 +530,7 @@ export function useSessionActions({ return } - setMessages(toChatMessages(fallback.messages)) + setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get())) notifyError(err, 'Resume failed') } finally { if (isCurrentResume()) { diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 8c6bdfd7f2f..398c3c9325d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -2,8 +2,9 @@ import { useStore } from '@nanostores/react' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import type { ChatMessage } from '@/lib/chat-messages' +import { preserveLocalAssistantErrors } from '@/lib/chat-messages' import { createClientSessionState } from '@/lib/chat-runtime' -import { $busy, setSessionWorking } from '@/store/session' +import { $busy, $messages, setSessionWorking } from '@/store/session' import type { ClientSessionState } from '../../types' @@ -78,6 +79,20 @@ export function useSessionStateCache({ return created }, []) + const flushPendingViewState = useCallback(() => { + const pending = pendingViewStateRef.current + pendingViewStateRef.current = null + + if (!pending || pending.sessionId !== activeSessionIdRef.current) { + return + } + + setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get())) + setBusy(pending.state.busy) + busyRef.current = pending.state.busy + setAwaitingResponse(pending.state.awaitingResponse) + }, [busyRef, setAwaitingResponse, setBusy, setMessages]) + const syncSessionStateToView = useCallback( (sessionId: string, state: ClientSessionState) => { pendingViewStateRef.current = { sessionId, state } @@ -87,41 +102,17 @@ export function useSessionStateCache({ } if (typeof window === 'undefined') { - const pending = pendingViewStateRef.current - - if (!pending || pending.sessionId !== activeSessionIdRef.current) { - pendingViewStateRef.current = null - - return - } - - pendingViewStateRef.current = null - setMessages(pending.state.messages) - setBusy(pending.state.busy) - busyRef.current = pending.state.busy - setAwaitingResponse(pending.state.awaitingResponse) + flushPendingViewState() return } viewSyncRafRef.current = window.requestAnimationFrame(() => { viewSyncRafRef.current = null - const pending = pendingViewStateRef.current - - if (!pending || pending.sessionId !== activeSessionIdRef.current) { - pendingViewStateRef.current = null - - return - } - - pendingViewStateRef.current = null - setMessages(pending.state.messages) - setBusy(pending.state.busy) - busyRef.current = pending.state.busy - setAwaitingResponse(pending.state.awaitingResponse) + flushPendingViewState() }) }, - [busyRef, setAwaitingResponse, setBusy, setMessages] + [flushPendingViewState] ) useEffect( diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 7c2bbcf68ba..f93cd542c8c 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -13,10 +13,12 @@ import { useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' +import { IconPlayerStopFilled } from '@tabler/icons-react' import { type ClipboardEvent, type ComponentProps, type FC, + type FocusEvent, type FormEvent, type KeyboardEvent, type DragEvent as ReactDragEvent, @@ -31,9 +33,9 @@ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance' import { + type ComposerInsertMode, focusComposerInput, markActiveComposer, - type ComposerInsertMode, onComposerFocusRequest, onComposerInsertRequest } from '@/app/chat/composer/focus' @@ -148,7 +150,17 @@ export const Thread: FC<{ onCancel?: () => Promise | void sessionId?: string | null sessionKey?: string | null -}> = ({ clampToComposer = false, cwd = null, gateway = null, intro, loading, onBranchInNewChat, onCancel, sessionId = null, sessionKey }) => { +}> = ({ + clampToComposer = false, + cwd = null, + gateway = null, + intro, + loading, + onBranchInNewChat, + onCancel, + sessionId = null, + sessionKey +}) => { const introHero = useAuiState(s => Boolean(intro) && s.thread.isEmpty) const messageComponents = useMemo( @@ -195,7 +207,11 @@ export const Thread: FC<{ {loading === 'response' && } {clampToComposer && ( -