fix(desktop): wait for session discovery without discarding queued prompts (#106986)

* fix(desktop): wait for session list before auto-draining queued prompts

Restored composer-queue entries were retrying as soon as the gateway
socket opened, burning the drain budget against reaped runtimes and
toasting "Queued message not sent" on every launch.

* test(desktop): cover composer-queue drain while sessions are still loading

* docs(desktop): clarify queue-drain discovery gates

* test(desktop): preserve hidden-session queues after failed drains

---------

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
This commit is contained in:
Austin Pickett
2026-09-09 20:42:49 -04:00
committed by GitHub
parent 145c713f16
commit fad7cfca42
5 changed files with 176 additions and 4 deletions
@@ -10,6 +10,7 @@ import {
MAX_AUTO_DRAIN_ATTEMPTS,
parkQueuedPrompts
} from '@/store/composer-queue'
import { setSessionsLoading } from '@/store/session'
import type { QueueEditState } from '../composer-utils'
import type { ChatBarProps } from '../types'
@@ -58,6 +59,7 @@ describe('useComposerQueue park integration', () => {
window.localStorage.clear()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
setSessionsLoading(false)
})
afterEach(() => {
@@ -65,6 +67,7 @@ describe('useComposerQueue park integration', () => {
vi.restoreAllMocks()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
setSessionsLoading(true)
})
it('reschedules rejected foreground drains to a bounded stop and keeps manual recovery', async () => {
@@ -253,4 +256,36 @@ describe('useComposerQueue park integration', () => {
expect(isQueueParked(SESSION_KEY)).toBe(false)
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
})
it('does not auto-drain restored queues while the session list is still loading', async () => {
setSessionsLoading(true)
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'wait for session list' })
const { onSubmit } = renderQueueHook()
await act(async () => {
await Promise.resolve()
})
expect(onSubmit).not.toHaveBeenCalled()
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
})
it('auto-drains a restored queue once the session list finishes loading', async () => {
setSessionsLoading(true)
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'send after load' })
const { hook, onSubmit } = renderQueueHook()
await act(async () => {
await Promise.resolve()
})
expect(onSubmit).not.toHaveBeenCalled()
setSessionsLoading(false)
hook.rerender({ busy: false })
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(0)
})
})
@@ -22,6 +22,7 @@ import {
updateQueuedPrompt
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { $sessionsLoading } from '@/store/session'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { useComposerScope } from '../scope'
@@ -80,6 +81,7 @@ export function useComposerQueue({
// is fine; the auto-drain effect below reads it as a gate.
const parkedSessions = useStore($parkedQueueSessions)
const queueParked = Boolean(activeQueueSessionKey && parkedSessions[activeQueueSessionKey])
const sessionsLoading = useStore($sessionsLoading)
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
queueEditRef.current = queueEdit
@@ -402,10 +404,16 @@ export function useComposerQueue({
// strand them. A park (explicit Stop/Esc) is the one gate: those entries wait
// for the user. To cancel queued turns, the user deletes them from the panel.
useEffect(() => {
// Match the background drainer: preserve the retry budget while session
// discovery runs at boot, on a gateway/profile switch, or over an empty list.
if (sessionsLoading) {
return
}
if (shouldAutoDrain({ isBusy: busy, parked: queueParked, queueLength: queuedPrompts.length })) {
return autoDrainNext()
}
}, [autoDrainNext, busy, drainRetryTick, queueParked, queuedPrompts.length])
}, [autoDrainNext, busy, drainRetryTick, queueParked, queuedPrompts.length, sessionsLoading])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
@@ -10,7 +10,7 @@ import {
getQueuedPrompts,
parkQueuedPrompts
} from '@/store/composer-queue'
import { $sessions, setSessions } from '@/store/session'
import { $sessions, setSessions, setSessionsLoading } from '@/store/session'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
@@ -62,6 +62,9 @@ describe('useBackgroundQueueDrain', () => {
beforeEach(() => {
vi.useRealTimers()
clearAllSessionStates()
// Production drain waits for the sidebar list. Tests that assert drain
// behavior are post-load unless they opt into the loading gate.
setSessionsLoading(false)
})
afterEach(() => {
@@ -71,6 +74,7 @@ describe('useBackgroundQueueDrain', () => {
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
$sessions.set([])
setSessionsLoading(true)
clearAllSessionStates()
})
@@ -222,4 +226,52 @@ describe('useBackgroundQueueDrain', () => {
expect(submitText).toHaveBeenCalledTimes(2)
expect(getQueuedPrompts('stored-session-a')).toHaveLength(0)
})
it('does not drain restored queues while the session list is still loading', async () => {
vi.useFakeTimers()
setSessionsLoading(true)
const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'wait for session list', attachments: [] })
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
// Four 750ms retries is what used to burn the drain budget and toast on boot.
await act(async () => {
await vi.advanceTimersByTimeAsync(750 * 4)
await Promise.resolve()
})
expect(submitText).not.toHaveBeenCalled()
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
})
it('drains a restored background queue once the session list finishes loading', async () => {
setSessionsLoading(true)
const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'send after load', attachments: [] })
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(submitText).not.toHaveBeenCalled()
setSessionsLoading(false)
await waitFor(() => {
expect(submitText).toHaveBeenCalledWith('send after load', {
attachments: [],
fromQueue: true,
sessionId: 'rt-session-a',
storedSessionId: 'stored-session-a'
})
})
await waitFor(() => expect(getQueuedPrompts('stored-session-a')).toHaveLength(0))
})
})
@@ -13,7 +13,7 @@ import {
shouldAutoDrain
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { $sessions, idsShareLineage } from '@/store/session'
import { $sessions, $sessionsLoading, idsShareLineage } from '@/store/session'
import { $workingSessionIds } from '@/store/session-states'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
@@ -46,6 +46,7 @@ export function useBackgroundQueueDrain({
const { t } = useI18n()
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const parkedQueueSessions = useStore($parkedQueueSessions)
const sessionsLoading = useStore($sessionsLoading)
const workingSessionIds = useStore($workingSessionIds)
const submitTextRef = useRef(submitText)
const drainingSessionIdsRef = useRef(new Set<string>())
@@ -151,7 +152,10 @@ export function useBackgroundQueueDrain({
)
useEffect(() => {
if (!enabled) {
// Preserve the retry budget while session discovery runs at boot, on a
// gateway/profile switch, or during a refresh over an empty list.
// Once discovery settles, submitText can resume by stored id.
if (!enabled || sessionsLoading) {
return
}
@@ -194,6 +198,7 @@ export function useBackgroundQueueDrain({
queuedPromptsBySession,
retryTick,
selectedStoredSessionId,
sessionsLoading,
workingSessionIds
])
}
@@ -0,0 +1,72 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
enqueueQueuedPrompt,
getQueuedPrompts,
MAX_AUTO_DRAIN_ATTEMPTS
} from '@/store/composer-queue'
import { clearNotifications } from '@/store/notifications'
import { $sessions, forgetSessionOwnerHintsForSession, setSessionOwnerHint, setSessionsLoading } from '@/store/session'
import { clearAllSessionStates } from '@/store/session-states'
import { useBackgroundQueueDrain } from './use-background-queue-drain'
afterEach(() => {
cleanup()
vi.useRealTimers()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
$sessions.set([])
setSessionsLoading(true)
forgetSessionOwnerHintsForSession('hidden-bot-chat')
clearNotifications()
clearAllSessionStates()
})
it.each(['rejection', 'transport error'])(
'preserves a known hidden session queue after transient %s',
async failure => {
vi.useFakeTimers()
$sessions.set([])
setSessionsLoading(false)
clearAllSessionStates()
setSessionOwnerHint('hidden-bot-chat', { connectionId: 'remote-a', profile: 'bot' })
const first = enqueueQueuedPrompt('hidden-bot-chat', { text: 'retry this later', attachments: [] })!
const second = enqueueQueuedPrompt('hidden-bot-chat', { text: 'not attempted yet', attachments: [] })!
const submitText = vi.fn<(text: string) => Promise<boolean>>()
if (failure === 'transport error') {
submitText.mockRejectedValue(new Error('WebSocket temporarily disconnected'))
} else {
submitText.mockResolvedValue(false)
}
const runtimeMap = { current: new Map([['hidden-bot-chat', 'live-runtime']]) }
renderHook(() =>
useBackgroundQueueDrain({
enabled: true,
runtimeIdByStoredSessionIdRef: runtimeMap,
selectedStoredSessionId: 'foreground',
submitText
})
)
await act(async () => {
await Promise.resolve()
})
for (let attempt = 1; attempt < MAX_AUTO_DRAIN_ATTEMPTS; attempt++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
}
expect(submitText).toHaveBeenCalledTimes(MAX_AUTO_DRAIN_ATTEMPTS)
expect(submitText.mock.calls.every(call => call[0] === first.text)).toBe(true)
expect(getQueuedPrompts('hidden-bot-chat').map(entry => entry.id)).toEqual([first.id, second.id])
}
)