fix(desktop): stop a failed turn leaking into every other thread
A turn that ends in an error (e.g. an out-of-funds state) was being re-rendered in unrelated threads. On a warm thread switch the on-screen `$messages` still belongs to the previously viewed thread, and `flushPendingViewState` fed it into `preserveLocalAssistantErrors`, which grafted the prior thread's failed turn onto the newly opened one. Because the polluted view then became the next switch's baseline, the error cascaded into every thread the user visited. Only carry local errors across a view flush when the on-screen baseline is the same session being flushed; the cached state we publish already retains that session's own errors. Also surface the turn error as a global toast even when the failing turn ran in a background thread, since the error blocks all subsequent interactions until the user acts.
This commit is contained in:
parent
f9c8d95e43
commit
435c706e8e
@ -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
|
||||
|
||||
@ -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<boolean> = { 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(<ViewHarness activeSessionId="thread-A" onReady={c => (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(<ViewHarness activeSessionId="thread-B" onReady={c => (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(<ViewHarness activeSessionId="thread-A" onReady={c => (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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -79,6 +79,9 @@ export function useSessionStateCache({
|
||||
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
||||
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
|
||||
const viewSyncRafRef = useRef<number | null>(null)
|
||||
// Runtime id whose transcript currently occupies `$messages` — lets the
|
||||
// flush below tell a same-session refresh from a thread switch.
|
||||
const viewSessionIdRef = useRef<string | null>(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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user