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