From f23a4b7bb3b8a4cf533b9d69697146ebe3ef91c9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 13 Jun 2026 00:23:51 -0500 Subject: [PATCH] fix(desktop): keep queued drains quiet on transient "session busy" A queued drain firing on the settle edge can race a not-yet-wound-down turn and get a transient 4009 "session busy". Previously that appended a red "session busy" error bubble (and toast) per attempt. For fromQueue submits, swallow the busy error: release busy, keep the entry queued, and let the composer's bounded auto-drain retry on the next idle. --- .../session/hooks/use-prompt-actions.test.tsx | 39 +++++++++++++++++++ .../app/session/hooks/use-prompt-actions.ts | 10 ++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 545bff0d45f..5966ea24679 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -364,6 +364,45 @@ describe('usePromptActions submit / queue drain semantics', () => { }) }) + it('a fromQueue drain that hits "session busy" stays quiet (no error bubble) and a retry sends it', async () => { + // The drain can fire on the settle edge before the gateway has fully wound + // the turn down → transient 4009. It must not append a red "session busy" + // bubble; the entry stays queued and the next idle retry succeeds. + let attempt = 0 + const seeds: Record[] = [] + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.submit') { + attempt += 1 + + if (attempt === 1) { + throw new Error('4009: session busy') + } + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + onSeedState={s => seeds.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + const first = await handle!.submitText('queued during a turn', { fromQueue: true }) + expect(first).toBe(false) + // No assistant-error message was appended for the transient busy. + expect(seeds.some(s => Array.isArray(s.messages) && (s.messages as { error?: string }[]).some(m => m.error))).toBe( + false + ) + + const second = await handle!.submitText('queued during a turn', { fromQueue: true }) + expect(second).toBe(true) + }) + it('a normal (non-queue) submit still respects the busyRef guard', async () => { const busyRef = { current: true } const requestGateway = vi.fn(async () => ({}) as never) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index a481728362d..5c7df471a8b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -714,9 +714,17 @@ export function usePromptActions({ return true } catch (err) { + releaseBusy() + + // A queued drain that raced a not-yet-settled turn gets a transient + // "session busy" (4009). Don't surface an error bubble/toast — the entry + // stays queued and the composer's bounded auto-drain retries when idle. + if (options?.fromQueue && isSessionBusyError(err)) { + return false + } + const message = inlineErrorMessage(err, copy.promptFailed) - releaseBusy() updateSessionState(sessionId, state => ({ ...state, messages: [