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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user