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 c07222c689..3ee52ec8eb 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -1102,8 +1102,13 @@ export function useMessageStream({ if (looksLikeProviderSetup) { requestDesktopOnboarding(errorMessage) - } else if (isActiveEvent) { + } else { + // Toast globally, not just when the failing thread is focused: a + // turn-ending error (e.g. out of funds) blocks every thread, so the + // inline error alone is too easy to miss. The stable id collapses the + // same error from multiple blocked threads into one toast. notify({ + id: `gateway-error:${errorMessage}`, kind: 'error', title: 'Hermes error', message: errorMessage diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx index e2a9735827..681334aa2d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx @@ -2,12 +2,14 @@ import { act, cleanup, render } from '@testing-library/react' import type { MutableRefObject } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChatMessage } from '@/lib/chat-messages' import { $currentFastMode, $currentModel, $currentProvider, $currentReasoningEffort, $currentServiceTier, + $messages, $turnStartedAt, setCurrentFastMode, setCurrentModel, @@ -213,3 +215,113 @@ describe('useSessionStateCache — per-session turn timer', () => { expect($currentFastMode.get()).toBe(false) }) }) + +function userMessage(id: string, text: string): ChatMessage { + return { id, role: 'user', parts: [{ type: 'text', text }] } +} + +function assistantText(id: string, text: string): ChatMessage { + return { id, role: 'assistant', parts: [{ type: 'text', text }] } +} + +function assistantError(id: string, error: string): ChatMessage { + return { id, role: 'assistant', parts: [], error, pending: false } +} + +interface ViewHarnessProps { + activeSessionId: string | null + onReady: (cache: Cache) => void +} + +function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) { + const busyRef: MutableRefObject = { current: false } + const cache = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId: null, + setAwaitingResponse: () => undefined, + setBusy: () => undefined, + // Wire the published view back into the real $messages atom the flush + // reads from, so the round-trip matches production. + setMessages: messages => $messages.set(messages) + }) + + onReady(cache) + + return null +} + +describe('useSessionStateCache — cross-thread error isolation', () => { + afterEach(() => { + cleanup() + $messages.set([]) + }) + + it('does not leak a failed turn into another thread on switch', () => { + $messages.set([]) + let cache!: Cache + const { rerender } = render( (cache = c)} />) + + // Thread A ends its turn with an out-of-funds error and is on screen. + act(() => { + cache.updateSessionState( + 'thread-A', + state => ({ + ...state, + busy: false, + messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')] + }), + 'stored-A' + ) + }) + + expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true) + + // Switch to thread B (which completed cleanly). Its cached state syncs to + // the view while $messages still holds thread A's transcript. + rerender( (cache = c)} />) + act(() => { + cache.updateSessionState( + 'thread-B', + state => ({ + ...state, + busy: false, + messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')] + }), + 'stored-B' + ) + }) + + expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b']) + expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false) + }) + + it('still preserves a same-session local error a heartbeat dropped', () => { + $messages.set([]) + let cache!: Cache + render( (cache = c)} />) + + // First paint establishes thread A as the on-screen session. + act(() => { + cache.updateSessionState( + 'thread-A', + state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }), + 'stored-A' + ) + }) + + // A local error lands in the view (e.g. failAssistantMessage wrote it). + $messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')]) + + // A later same-session heartbeat carries cached state that lost the error. + act(() => { + cache.updateSessionState('thread-A', state => ({ + ...state, + busy: false, + messages: [userMessage('user-a', 'do the thing')] + })) + }) + + expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true) + }) +}) 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 a08eb1f16c..1445dd17a7 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 @@ -79,6 +79,9 @@ export function useSessionStateCache({ const runtimeIdByStoredSessionIdRef = useRef(new Map()) const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null) const viewSyncRafRef = useRef(null) + // Runtime id whose transcript currently occupies `$messages` — lets the + // flush below tell a same-session refresh from a thread switch. + const viewSessionIdRef = useRef(null) useEffect(() => { activeSessionIdRef.current = activeSessionId @@ -142,12 +145,22 @@ export function useSessionStateCache({ // jerks the scroll position while the user is reading. Skip the publish when // the merged result is content-identical to what's already on screen. const currentMessages = $messages.get() - const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages) + // On a thread switch `$messages` still holds the *previous* thread, so + // preserving its local errors would graft that thread's failed turn (e.g. + // an out-of-funds error) onto this one — then cascade it everywhere as the + // polluted view becomes the next switch's baseline. Only carry errors + // across a same-session refresh; our cached state already keeps its own. + const nextMessages = + viewSessionIdRef.current === pending.sessionId + ? preserveLocalAssistantErrors(pending.state.messages, currentMessages) + : pending.state.messages if (!sameMessageList(nextMessages, currentMessages)) { setMessages(nextMessages) } + viewSessionIdRef.current = pending.sessionId + syncRuntimeMetadataToView(pending.state) setBusy(pending.state.busy) setMutableRef(busyRef, pending.state.busy)