Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(),
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down