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.
This commit is contained in:
Brooklyn Nicholson
2026-05-16 20:33:17 -05:00
parent d67a438fec
commit f9908af1a0
8 changed files with 627 additions and 165 deletions
@@ -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,
@@ -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<string | null>
@@ -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(
@@ -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<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
function applyRuntimeInfo(
info: SessionCreateResponse['info'] | undefined
): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | 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()) {
@@ -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(