diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 63ae843..3c6e0a0 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -60,6 +60,7 @@ let _loadSessionsInFlight: Promise | null = null; let _lastLoadSessionsAt = 0; const _historyLoadInFlight = new Map>(); const _lastHistoryLoadAtBySession = new Map(); +const _forceNextHistoryLoadBySession = new Set(); const _foregroundHistoryLoadSeen = new Set(); const SESSION_LOAD_MIN_INTERVAL_MS = 1_200; const HISTORY_LOAD_MIN_INTERVAL_MS = 800; @@ -81,6 +82,10 @@ function clearHistoryPoll(): void { } } +function forceNextHistoryLoad(sessionKey: string): void { + _forceNextHistoryLoadBySession.add(sessionKey); +} + function pruneChatEventDedupe(now: number): void { for (const [key, ts] of _chatEventDedupe.entries()) { if (now - ts > CHAT_EVENT_DEDUPE_TTL_MS) { @@ -1523,14 +1528,20 @@ export const useChatStore = create((set, get) => ({ const { currentSessionKey } = get(); const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(currentSessionKey); const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad); + const forceLoad = _forceNextHistoryLoadBySession.delete(currentSessionKey); const existingLoad = _historyLoadInFlight.get(currentSessionKey); if (existingLoad) { await existingLoad; - return; + if (!forceLoad) { + return; + } + if (get().currentSessionKey !== currentSessionKey) { + return; + } } const lastLoadAt = _lastHistoryLoadAtBySession.get(currentSessionKey) || 0; - if (quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) { + if (!forceLoad && quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) { return; } @@ -2087,6 +2098,7 @@ export const useChatStore = create((set, get) => ({ // briefly in the UI until loadHistory replaces the message list — and if the // quiet-mode reload is debounced away, the token can stay visible permanently. if (isInternalMessage(normalizedFinalMessage)) { + const sessionKeyForReload = get().currentSessionKey; set({ streamingText: '', streamingMessage: null, @@ -2097,6 +2109,7 @@ export const useChatStore = create((set, get) => ({ pendingToolImages: [], }); clearHistoryPoll(); + forceNextHistoryLoad(sessionKeyForReload); void get().loadHistory(true); break; } diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index dc315d0..8bc3231 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -93,6 +93,66 @@ describe('useChatStore startup history retry', () => { setTimeoutSpy.mockRestore(); }); + it('forces the internal final-message reload through the quiet history cooldown', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:main' }], + messages: [], + sessionLabels: {}, + sessionLastActivity: {}, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock + .mockResolvedValueOnce({ + messages: [{ role: 'user', content: 'hello', id: 'u1', timestamp: 1000 }], + }) + .mockResolvedValueOnce({ + messages: [ + { role: 'user', content: 'hello', id: 'u1', timestamp: 1000 }, + { role: 'assistant', content: 'Real answer', id: 'a2', timestamp: 1001 }, + ], + }); + + await useChatStore.getState().loadHistory(true); + useChatStore.setState({ + sending: true, + activeRunId: 'run-internal', + streamingText: 'NO_REPLY', + streamingMessage: { role: 'assistant', content: 'NO_REPLY' }, + }); + + useChatStore.getState().handleChatEvent({ + state: 'final', + runId: 'run-internal', + sessionKey: 'agent:main:main', + message: { role: 'assistant', content: 'NO_REPLY', id: 'a1' }, + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(gatewayRpcMock).toHaveBeenCalledTimes(2); + expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ + 'hello', + 'Real answer', + ]); + expect(useChatStore.getState().sending).toBe(false); + expect(useChatStore.getState().activeRunId).toBeNull(); + }); + it('keeps non-startup foreground loading safety timeout at 15 seconds', async () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); const { useChatStore } = await import('@/stores/chat');