From 92069e5ef3396aca4e6dbac5511c3296c70d4cfb Mon Sep 17 00:00:00 2001 From: user Date: Sat, 29 Aug 2026 10:18:21 +0800 Subject: [PATCH] fix(flow-chat): silently re-queue messages when the session turns busy When startTurn finds the state machine non-IDLE it throws "Session is still busy finishing the previous turn..."; sendMessage surfaces this as a "Thinking process error" toast and drainPendingQueue marks the queued item failed, even though the situation is transient (the session was revived busy after the drain gate confirmed IDLE). The thrown error is now tagged isSessionBusy; sendMessage drops the optimistic turn and re-throws without toasting, and drainPendingQueue silently re-queues the item so auto-drain reapplies once the session returns to IDLE. Includes a regression test for the silent re-queue path. Test: pnpm vitest run src/flow_chat/services/flow-chat-manager/MessageModule.test.ts (27 passed, 0 failed) + pnpm run type-check (0 errors) AI: This change was assisted by AI and lightly tested. --- .../flow-chat-manager/MessageModule.test.ts | 72 +++++++++++++++++++ .../flow-chat-manager/MessageModule.ts | 30 ++++++++ .../local/LocalSessionDriver.ts | 11 ++- 3 files changed, 112 insertions(+), 1 deletion(-) 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({