diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index ad86866b91..6977ca6519 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -1143,3 +1143,75 @@ describe('MessageModule device surface switch', () => { expect(mockNotificationError).toHaveBeenCalled(); }); }); + +describe('MessageModule queued-drain silent re-queue on session busy', () => { + beforeEach(() => { + vi.clearAllMocks(); + interruptedTurnRecoveryGate.resetForTests(); + resetRuntimeStatuses(); + mockPendingList.mockReturnValue([]); + mockGetCurrentState.mockReturnValue('idle'); + }); + + it('re-queues a drained message silently when the session is revived non-IDLE', async () => { + // The drain gate passes on IDLE, but the state machine is revived busy by + // the time startTurn runs: transition(START) returns false and the driver + // tags the thrown error with isSessionBusy. + mockGetCurrentState + .mockReturnValueOnce('idle') + .mockReturnValue('processing'); + mockTransition.mockResolvedValue(false); + const pendingItem = { + id: 'pending-resurrect', + sessionId: 'session-resurrect', + content: 'run it', + status: 'queued', + retryCount: 0, + }; + mockPendingList.mockReturnValue([pendingItem]); + const session: any = { + sessionId: 'session-resurrect', + sessionKind: 'normal', + mode: 'agentic', + titleStatus: 'generated', + dialogTurns: [], + config: { modelName: 'auto' }, + maxContextTokens: 32_000, + }; + const context: any = { + flowChatStore: { + getSurfaceGeneration: () => 0, + getState: () => ({ sessions: new Map([[session.sessionId, session]]) }), + addDialogTurn: vi.fn((_s: string, turn: any) => session.dialogTurns.push(turn)), + deleteDialogTurn: vi.fn(), + updateSessionLastSubmittedMode: vi.fn(), + updateSessionMode: vi.fn(), + }, + processingManager: { + registerStatus: vi.fn(), + clearSessionStatus: vi.fn(), + }, + userCancelledSessionIds: new Set(), + pendingHistoryLoads: new Map(), + contentBuffers: new Map(), + activeTextItems: new Map(), + }; + + await drainPendingQueue(context, session.sessionId); + + // Silent re-queue, not failed, and no error surfaced. + expect(mockPendingSetStatus).toHaveBeenCalledWith( + session.sessionId, + pendingItem.id, + 'queued', + ); + expect(mockPendingSetStatus).not.toHaveBeenCalledWith( + session.sessionId, + pendingItem.id, + 'failed', + ); + expect(mockNotificationError).not.toHaveBeenCalled(); + // The message must stay queued for the next idle drain; never removed. + expect(mockPendingRemove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 1f42704860..1d64eeb66f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -410,6 +410,24 @@ export async function sendMessage( return; } + if ((error as any)?.isSessionBusy === true) { + // The session was revived busy after the drain gate confirmed IDLE. + // Do NOT surface a "Thinking process error" toast or mark the queued + // message failed; drop the optimistic turn and re-throw so the drain + // path re-queues the message until the next IDLE. + if (turnTracker.createdLocalTurnId && !options?.preserveTurnOnStartError) { + const state = context.flowChatStore.getState(); + const currentSession = state.sessions.get(sessionId); + if (currentSession) { + context.flowChatStore.deleteDialogTurn(sessionId, turnTracker.createdLocalTurnId); + } + } + if (latestSendBySession.get(sendCoordinationKey) === sendAttempt) { + latestSendBySession.delete(sendCoordinationKey); + } + throw error; + } + log.error('Failed to send message', { sessionId: sessionId, error }); const errorMessage = error instanceof Error ? error.message : 'Failed to send message'; @@ -607,6 +625,18 @@ export async function drainPendingQueue( // reset of the retry counter, and FIFO order is preserved). pendingQueueManager.remove(sessionId, next.id); } catch (error) { + // If the session was revived busy after the drain gate confirmed IDLE, + // re-queue the message and wait silently instead of marking it failed + // (which surfaces a "Thinking process error" toast and forces the user to + // manually retry). Auto-drain re-applies once the session returns to IDLE. + if ((error as any)?.isSessionBusy === true) { + log.debug('Pending queue item re-queued: session revived busy', { + sessionId, + itemId: next.id, + }); + pendingQueueManager.setStatus(sessionId, next.id, 'queued'); + return; + } log.error('Failed to drain pending queue item', { sessionId, itemId: next.id, error }); // Mark in place. The auto-drain listener skips `failed` items so the user // can edit / send-now / delete without entering a tight retry loop. diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 9ec277a6f9..6f3daf0691 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -369,7 +369,16 @@ export const localSessionDriver: SessionDriver = { surfaceScope.assertCurrent('start session state machine'); if (!startOk) { const currentState = stateMachineManager.getCurrentState(sessionId); - throw new Error(`Session is still busy finishing the previous turn (current state: ${currentState})`); + // The machine is not IDLE (e.g. it was revived after the pending queue + // drain gate confirmed IDLE). Throwing "still busy" surfaces a + // "Thinking process error" toast and marks the queued message failed. + // Tag the error instead so the caller re-queues the message silently + // and drains it once the session returns to IDLE. + const error = new Error( + `Session is still busy finishing the previous turn (current state: ${currentState})`, + ); + (error as any).isSessionBusy = true; + throw error; } context.processingManager.registerStatus({