diff --git a/src/stores/chat.ts b/src/stores/chat.ts index f326cf1c..18e32962 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -8,6 +8,14 @@ import { hostApiFetch } from '@/lib/host-api'; import { useGatewayStore } from './gateway'; import { useAgentsStore } from './agents'; import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils'; +import { + CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS, + classifyHistoryStartupRetryError, + getHistoryLoadingSafetyTimeout, + getStartupHistoryTimeoutOverride, + shouldRetryStartupHistoryLoad, + sleep, +} from './chat/history-startup-retry'; import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, @@ -52,6 +60,7 @@ let _loadSessionsInFlight: Promise | null = null; let _lastLoadSessionsAt = 0; const _historyLoadInFlight = new Map>(); const _lastHistoryLoadAtBySession = new Map(); +const _foregroundHistoryLoadSeen = new Set(); const SESSION_LOAD_MIN_INTERVAL_MS = 1_200; const HISTORY_LOAD_MIN_INTERVAL_MS = 800; const HISTORY_POLL_SILENCE_WINDOW_MS = 2_500; @@ -1304,6 +1313,8 @@ export const useChatStore = create((set, get) => ({ loadHistory: async (quiet = false) => { const { currentSessionKey } = get(); + const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(currentSessionKey); + const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad); const existingLoad = _historyLoadInFlight.get(currentSessionKey); if (existingLoad) { await existingLoad; @@ -1323,7 +1334,7 @@ export const useChatStore = create((set, get) => ({ const loadingSafetyTimer = quiet ? null : setTimeout(() => { loadingTimedOut = true; set({ loading: false }); - }, 15_000); + }, getHistoryLoadingSafetyTimeout(isInitialForegroundLoad)); const loadPromise = (async () => { const isCurrentSession = () => get().currentSessionKey === currentSessionKey; @@ -1367,7 +1378,7 @@ export const useChatStore = create((set, get) => ({ // Guard: if the user switched sessions while this async load was in // flight, discard the result to prevent overwriting the new session's // messages with stale data from the old session. - if (!isCurrentSession()) return; + if (!isCurrentSession()) return false; // Before filtering: attach images/files from tool_result messages to the next assistant message const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); @@ -1466,13 +1477,53 @@ export const useChatStore = create((set, get) => ({ set({ sending: false, activeRunId: null, pendingFinal: false }); } } + return true; }; try { - const data = await useGatewayStore.getState().rpc>( - 'chat.history', - { sessionKey: currentSessionKey, limit: 200 }, - ); + let data: Record | null = null; + let lastError: unknown = null; + + for (let attempt = 0; attempt <= CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length; attempt += 1) { + if (!isCurrentSession()) { + break; + } + + try { + data = await useGatewayStore.getState().rpc>( + 'chat.history', + { sessionKey: currentSessionKey, limit: 200 }, + historyTimeoutOverride, + ); + lastError = null; + break; + } catch (error) { + lastError = error; + } + + if (!isCurrentSession()) { + break; + } + + const errorKind = classifyHistoryStartupRetryError(lastError); + const shouldRetry = isInitialForegroundLoad + && attempt < CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length + && shouldRetryStartupHistoryLoad(useGatewayStore.getState().status, errorKind); + + if (!shouldRetry) { + break; + } + + console.warn('[chat.history] startup retry scheduled', { + sessionKey: currentSessionKey, + attempt: attempt + 1, + gatewayState: useGatewayStore.getState().status.state, + errorKind, + error: String(lastError), + }); + await sleep(CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS[attempt]!); + } + if (data) { let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : []; const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null; @@ -1480,20 +1531,40 @@ export const useChatStore = create((set, get) => ({ rawMessages = await loadCronFallbackMessages(currentSessionKey, 200); } - applyLoadedMessages(rawMessages, thinkingLevel); + const applied = applyLoadedMessages(rawMessages, thinkingLevel); + if (applied && isInitialForegroundLoad) { + _foregroundHistoryLoadSeen.add(currentSessionKey); + } } else { + if (isCurrentSession() && isInitialForegroundLoad && classifyHistoryStartupRetryError(lastError)) { + console.warn('[chat.history] startup retry exhausted', { + sessionKey: currentSessionKey, + gatewayState: useGatewayStore.getState().status.state, + error: String(lastError), + }); + } + const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200); if (fallbackMessages.length > 0) { - applyLoadedMessages(fallbackMessages, null); + const applied = applyLoadedMessages(fallbackMessages, null); + if (applied && isInitialForegroundLoad) { + _foregroundHistoryLoadSeen.add(currentSessionKey); + } } else { - applyLoadFailure('Failed to load chat history'); + applyLoadFailure( + (lastError instanceof Error ? lastError.message : String(lastError)) + || 'Failed to load chat history', + ); } } } catch (err) { console.warn('Failed to load chat history:', err); const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200); if (fallbackMessages.length > 0) { - applyLoadedMessages(fallbackMessages, null); + const applied = applyLoadedMessages(fallbackMessages, null); + if (applied && isInitialForegroundLoad) { + _foregroundHistoryLoadSeen.add(currentSessionKey); + } } else { applyLoadFailure(String(err)); } diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index d962cd53..6e58ca27 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -1,5 +1,6 @@ import { invokeIpc } from '@/lib/api-client'; import { hostApiFetch } from '@/lib/host-api'; +import { useGatewayStore } from '@/stores/gateway'; import { clearHistoryPoll, enrichWithCachedImages, @@ -12,9 +13,18 @@ import { toMs, } from './helpers'; import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils'; +import { + CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS, + classifyHistoryStartupRetryError, + getStartupHistoryTimeoutOverride, + shouldRetryStartupHistoryLoad, + sleep, +} from './history-startup-retry'; import type { RawMessage } from './types'; import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; +const foregroundHistoryLoadSeen = new Set(); + async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise { if (!isCronSessionKey(sessionKey)) return []; try { @@ -35,6 +45,8 @@ export function createHistoryActions( return { loadHistory: async (quiet = false) => { const { currentSessionKey } = get(); + const isInitialForegroundLoad = !quiet && !foregroundHistoryLoadSeen.has(currentSessionKey); + const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad); if (!quiet) set({ loading: true, error: null }); const isCurrentSession = () => get().currentSessionKey === currentSessionKey; @@ -75,7 +87,7 @@ export function createHistoryActions( }; const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => { - if (!isCurrentSession()) return; + if (!isCurrentSession()) return false; // Before filtering: attach images/files from tool_result messages to the next assistant message const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role) && !isInternalMessage(msg)); @@ -173,36 +185,103 @@ export function createHistoryActions( set({ sending: false, activeRunId: null, pendingFinal: false }); } } + return true; }; try { - const result = await invokeIpc( - 'gateway:rpc', - 'chat.history', - { sessionKey: currentSessionKey, limit: 200 } - ) as { success: boolean; result?: Record; error?: string }; + let result: { success: boolean; result?: Record; error?: string } | null = null; + let lastError: unknown = null; + + for (let attempt = 0; attempt <= CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length; attempt += 1) { + if (!isCurrentSession()) { + break; + } + + try { + result = await invokeIpc( + 'gateway:rpc', + 'chat.history', + { sessionKey: currentSessionKey, limit: 200 }, + ...(historyTimeoutOverride != null ? [historyTimeoutOverride] as const : []), + ) as { success: boolean; result?: Record; error?: string }; + + if (result.success) { + lastError = null; + break; + } + + lastError = new Error(result.error || 'Failed to load chat history'); + } catch (error) { + lastError = error; + } + + if (!isCurrentSession()) { + break; + } + + const errorKind = classifyHistoryStartupRetryError(lastError); + const shouldRetry = result?.success !== true + && isInitialForegroundLoad + && attempt < CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length + && shouldRetryStartupHistoryLoad(useGatewayStore.getState().status, errorKind); + + if (!shouldRetry) { + break; + } - if (result.success && result.result) { + console.warn('[chat.history] startup retry scheduled', { + sessionKey: currentSessionKey, + attempt: attempt + 1, + gatewayState: useGatewayStore.getState().status.state, + errorKind, + error: String(lastError), + }); + await sleep(CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS[attempt]!); + } + + if (result?.success && result.result) { const data = result.result; let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : []; const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null; if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) { rawMessages = await loadCronFallbackMessages(currentSessionKey, 200); } - applyLoadedMessages(rawMessages, thinkingLevel); - } else { - const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200); - if (fallbackMessages.length > 0) { - applyLoadedMessages(fallbackMessages, null); - } else { - applyLoadFailure(result.error || 'Failed to load chat history'); + const applied = applyLoadedMessages(rawMessages, thinkingLevel); + if (applied && isInitialForegroundLoad) { + foregroundHistoryLoadSeen.add(currentSessionKey); } + return; + } + + if (isCurrentSession() && isInitialForegroundLoad && classifyHistoryStartupRetryError(lastError)) { + console.warn('[chat.history] startup retry exhausted', { + sessionKey: currentSessionKey, + gatewayState: useGatewayStore.getState().status.state, + error: String(lastError), + }); + } + + const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200); + if (fallbackMessages.length > 0) { + const applied = applyLoadedMessages(fallbackMessages, null); + if (applied && isInitialForegroundLoad) { + foregroundHistoryLoadSeen.add(currentSessionKey); + } + } else { + applyLoadFailure( + result?.error + || (lastError instanceof Error ? lastError.message : String(lastError)) + || 'Failed to load chat history', + ); } } catch (err) { console.warn('Failed to load chat history:', err); const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200); if (fallbackMessages.length > 0) { - applyLoadedMessages(fallbackMessages, null); + const applied = applyLoadedMessages(fallbackMessages, null); + if (applied && isInitialForegroundLoad) { + foregroundHistoryLoadSeen.add(currentSessionKey); + } } else { applyLoadFailure(String(err)); } diff --git a/src/stores/chat/history-startup-retry.ts b/src/stores/chat/history-startup-retry.ts new file mode 100644 index 00000000..55e9b6d2 --- /dev/null +++ b/src/stores/chat/history-startup-retry.ts @@ -0,0 +1,79 @@ +import type { GatewayStatus } from '@/types/gateway'; + +export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000; +export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [600] as const; +export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 15_000; +export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS = + CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS; +export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000; +export const CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS = + CHAT_HISTORY_RPC_TIMEOUT_MS * (CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length + 1) + + CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0) + + 2_000; + +export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable'; + +export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null { + const message = String(error).toLowerCase(); + + if ( + message.includes('rpc timeout: chat.history') + || message.includes('gateway rpc timeout: chat.history') + || message.includes('gateway ws timeout: chat.history') + || message.includes('request timed out') + ) { + return 'timeout'; + } + + if ( + message.includes('gateway not connected') + || message.includes('gateway socket is not connected') + || message.includes('gateway is unavailable') + || message.includes('service channel unavailable') + || message.includes('websocket closed before handshake') + || message.includes('connect handshake timeout') + || message.includes('gateway ws connect timeout') + || message.includes('gateway connection closed') + ) { + return 'gateway_unavailable'; + } + + return null; +} + +export function shouldRetryStartupHistoryLoad( + gatewayStatus: GatewayStatus | undefined, + errorKind: HistoryRetryErrorKind | null, +): boolean { + if (!gatewayStatus || !errorKind) return false; + + if (gatewayStatus.state === 'starting') { + return true; + } + + if (gatewayStatus.state !== 'running') { + return false; + } + + if (gatewayStatus.connectedAt == null) { + return true; + } + + return Date.now() - gatewayStatus.connectedAt <= CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS; +} + +export async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function getStartupHistoryTimeoutOverride( + isInitialForegroundLoad: boolean, +): number | undefined { + return isInitialForegroundLoad ? CHAT_HISTORY_RPC_TIMEOUT_MS : undefined; +} + +export function getHistoryLoadingSafetyTimeout(isInitialForegroundLoad: boolean): number { + return isInitialForegroundLoad + ? CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS + : CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS; +} diff --git a/tests/e2e/chat-history-startup-retry.spec.ts b/tests/e2e/chat-history-startup-retry.spec.ts new file mode 100644 index 00000000..5d5c5e69 --- /dev/null +++ b/tests/e2e/chat-history-startup-retry.spec.ts @@ -0,0 +1,102 @@ +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +test.describe('ClawX startup chat history recovery', () => { + test('retries an initial chat.history timeout and eventually renders history', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345, connectedAt: Date.now() }, + gatewayRpc: {}, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345, connectedAt: Date.now() }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'main' }] }, + }, + }, + }, + }); + + await app.evaluate(async ({ app: _app }) => { + const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); + let chatHistoryCallCount = 0; + + ipcMain.removeHandler('gateway:rpc'); + ipcMain.handle('gateway:rpc', async (_event: unknown, method: string, payload: unknown) => { + const stableStringify = (value: unknown): string => { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; + }; + + const key = stableStringify([method, payload ?? null]); + if (key === stableStringify(['sessions.list', {}])) { + return { + success: true, + result: { + sessions: [{ key: 'agent:main:main', displayName: 'main' }], + }, + }; + } + if (key === stableStringify(['chat.history', { sessionKey: 'agent:main:main', limit: 200 }])) { + chatHistoryCallCount += 1; + if (chatHistoryCallCount === 1) { + return { + success: false, + error: 'RPC timeout: chat.history', + }; + } + return { + success: true, + result: { + messages: [ + { role: 'user', content: 'hello', timestamp: 1000 }, + { role: 'assistant', content: 'history restored after retry', timestamp: 1001 }, + ], + }, + }; + } + return { success: true, result: {} }; + }); + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) { + throw error; + } + } + + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByText('history restored after retry')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('RPC timeout: chat.history')).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts index 916bf1c5..00c9f62a 100644 --- a/tests/unit/chat-history-actions.test.ts +++ b/tests/unit/chat-history-actions.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const invokeIpcMock = vi.fn(); const hostApiFetchMock = vi.fn(); +const gatewayStoreGetStateMock = vi.fn(); const clearHistoryPoll = vi.fn(); const enrichWithCachedImages = vi.fn((messages) => messages); const enrichWithToolResultFiles = vi.fn((messages) => messages); @@ -30,6 +31,12 @@ vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), })); +vi.mock('@/stores/gateway', () => ({ + useGatewayStore: { + getState: () => gatewayStoreGetStateMock(), + }, +})); + vi.mock('@/stores/chat/helpers', () => ({ clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args), enrichWithCachedImages: (...args: unknown[]) => enrichWithCachedImages(...args), @@ -83,8 +90,13 @@ function makeHarness(initial?: Partial) { describe('chat history actions', () => { beforeEach(() => { vi.resetAllMocks(); + vi.resetModules(); + vi.useRealTimers(); invokeIpcMock.mockResolvedValue({ success: true, result: { messages: [] } }); hostApiFetchMock.mockResolvedValue({ messages: [] }); + gatewayStoreGetStateMock.mockReturnValue({ + status: { state: 'running', port: 18789, connectedAt: Date.now() }, + }); }); it('uses cron session fallback when gateway history is empty', async () => { @@ -156,10 +168,158 @@ describe('chat history actions', () => { await actions.loadHistory(); expect(h.read().messages.map((message) => message.content)).toEqual(['still here']); - expect(h.read().error).toBe('Error: Gateway unavailable'); + expect(h.read().error).toBe('Gateway unavailable'); expect(h.read().loading).toBe(false); }); + it('retries the first foreground startup history load after a timeout and then succeeds', async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + }); + const actions = createHistoryActions(h.set as never, h.get as never); + gatewayStoreGetStateMock.mockReturnValue({ + status: { state: 'running', port: 18789, connectedAt: Date.now() - 40_000 }, + }); + + invokeIpcMock + .mockResolvedValueOnce({ success: false, error: 'RPC timeout: chat.history' }) + .mockResolvedValueOnce({ + success: true, + result: { + messages: [ + { role: 'assistant', content: 'restored after retry', timestamp: 1000 }, + ], + }, + }); + + const loadPromise = actions.loadHistory(); + await vi.runAllTimersAsync(); + await loadPromise; + + expect(invokeIpcMock).toHaveBeenNthCalledWith( + 1, + 'gateway:rpc', + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + 35_000, + ); + expect(invokeIpcMock).toHaveBeenNthCalledWith( + 2, + 'gateway:rpc', + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + 35_000, + ); + expect(h.read().messages.map((message) => message.content)).toEqual(['restored after retry']); + expect(h.read().error).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + '[chat.history] startup retry scheduled', + expect.objectContaining({ + sessionKey: 'agent:main:main', + attempt: 1, + errorKind: 'timeout', + }), + ); + warnSpy.mockRestore(); + }); + + it('stops retrying once the load no longer belongs to the active session', async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + invokeIpcMock.mockImplementationOnce(async () => { + h.set({ + currentSessionKey: 'agent:main:other', + loading: false, + messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }], + }); + return { success: false, error: 'RPC timeout: chat.history' }; + }); + + await actions.loadHistory(); + + expect(invokeIpcMock).toHaveBeenCalledTimes(1); + expect(h.read().currentSessionKey).toBe('agent:main:other'); + expect(h.read().messages.map((message) => message.content)).toEqual(['other session']); + expect(h.read().error).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('surfaces a final error only after startup retry budget is exhausted', async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + invokeIpcMock.mockResolvedValue({ + success: false, + error: 'RPC timeout: chat.history', + }); + + const loadPromise = actions.loadHistory(); + await vi.runAllTimersAsync(); + await loadPromise; + + expect(invokeIpcMock).toHaveBeenCalledTimes(2); + expect(h.read().messages).toEqual([]); + expect(h.read().error).toBe('RPC timeout: chat.history'); + expect(warnSpy).toHaveBeenCalledWith( + '[chat.history] startup retry exhausted', + expect.objectContaining({ + sessionKey: 'agent:main:main', + }), + ); + warnSpy.mockRestore(); + }); + + it('does not retry quiet history refreshes', async () => { + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + invokeIpcMock.mockResolvedValue({ + success: false, + error: 'RPC timeout: chat.history', + }); + + await actions.loadHistory(true); + + expect(invokeIpcMock).toHaveBeenCalledTimes(1); + expect(h.read().error).toBeNull(); + }); + + it('does not retry non-retryable startup failures', async () => { + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + invokeIpcMock.mockResolvedValue({ + success: false, + error: 'Validation failed: bad session key', + }); + + await actions.loadHistory(); + + expect(invokeIpcMock).toHaveBeenCalledTimes(1); + expect(h.read().error).toBe('Validation failed: bad session key'); + }); + it('filters out system messages from loaded history', async () => { const { createHistoryActions } = await import('@/stores/chat/history-actions'); const h = makeHarness(); diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts new file mode 100644 index 00000000..ada811f7 --- /dev/null +++ b/tests/unit/chat-store-history-retry.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { gatewayRpcMock, agentsState, hostApiFetchMock } = vi.hoisted(() => ({ + gatewayRpcMock: vi.fn(), + agentsState: { + agents: [] as Array>, + }, + hostApiFetchMock: vi.fn(), +})); + +vi.mock('@/stores/gateway', () => ({ + useGatewayStore: { + getState: () => ({ + status: { state: 'running', port: 18789, connectedAt: Date.now() }, + rpc: gatewayRpcMock, + }), + }, +})); + +vi.mock('@/stores/agents', () => ({ + useAgentsStore: { + getState: () => agentsState, + }, +})); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +describe('useChatStore startup history retry', () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + window.localStorage.clear(); + agentsState.agents = []; + gatewayRpcMock.mockReset(); + hostApiFetchMock.mockReset(); + hostApiFetchMock.mockResolvedValue({ messages: [] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses the longer timeout only for the initial foreground history load', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + 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, + showThinking: true, + }); + + gatewayRpcMock + .mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }], + }) + .mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'quiet refresh', timestamp: 1001 }], + }); + + await useChatStore.getState().loadHistory(false); + vi.advanceTimersByTime(1_000); + await useChatStore.getState().loadHistory(true); + + expect(gatewayRpcMock).toHaveBeenNthCalledWith( + 1, + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + 35_000, + ); + expect(gatewayRpcMock).toHaveBeenNthCalledWith( + 2, + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + undefined, + ); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 72_600); + setTimeoutSpy.mockRestore(); + }); + + it('keeps non-startup foreground loading safety timeout at 15 seconds', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + 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, + showThinking: true, + }); + + gatewayRpcMock + .mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }], + }) + .mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'second foreground load', timestamp: 1001 }], + }); + + await useChatStore.getState().loadHistory(false); + setTimeoutSpy.mockClear(); + await useChatStore.getState().loadHistory(false); + + expect(gatewayRpcMock).toHaveBeenNthCalledWith( + 2, + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + undefined, + ); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 15_000); + setTimeoutSpy.mockRestore(); + }); + + it('does not burn the first-load retry path when the first attempt becomes stale', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { useChatStore } = await import('@/stores/chat'); + + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }], + messages: [], + sessionLabels: {}, + sessionLastActivity: {}, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + showThinking: true, + }); + + let resolveFirstAttempt: ((value: { messages: Array<{ role: string; content: string; timestamp: number }> }) => void) | null = null; + gatewayRpcMock + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirstAttempt = resolve; + })) + .mockRejectedValueOnce(new Error('RPC timeout: chat.history')) + .mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'restored after retry', timestamp: 1002 }], + }); + + const firstLoad = useChatStore.getState().loadHistory(false); + useChatStore.setState({ + currentSessionKey: 'agent:main:other', + messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }], + }); + resolveFirstAttempt?.({ + messages: [{ role: 'assistant', content: 'stale original payload', timestamp: 1000 }], + }); + await firstLoad; + + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + messages: [], + }); + const secondLoad = useChatStore.getState().loadHistory(false); + await vi.runAllTimersAsync(); + await secondLoad; + + expect(gatewayRpcMock).toHaveBeenCalledTimes(3); + expect(gatewayRpcMock.mock.calls[0]).toEqual([ + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + 35_000, + ]); + expect(gatewayRpcMock.mock.calls[1]).toEqual([ + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + 35_000, + ]); + expect(gatewayRpcMock.mock.calls[2]).toEqual([ + 'chat.history', + { sessionKey: 'agent:main:main', limit: 200 }, + 35_000, + ]); + expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['restored after retry']); + expect(warnSpy).toHaveBeenCalledWith( + '[chat.history] startup retry scheduled', + expect.objectContaining({ + sessionKey: 'agent:main:main', + attempt: 1, + }), + ); + warnSpy.mockRestore(); + }); + + it('stops retrying once the user switches sessions mid-load', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { useChatStore } = await import('@/stores/chat'); + + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }], + messages: [], + sessionLabels: {}, + sessionLastActivity: {}, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + showThinking: true, + }); + + gatewayRpcMock.mockImplementationOnce(async () => { + useChatStore.setState({ + currentSessionKey: 'agent:main:other', + messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }], + loading: false, + }); + throw new Error('RPC timeout: chat.history'); + }); + + await useChatStore.getState().loadHistory(false); + + expect(gatewayRpcMock).toHaveBeenCalledTimes(1); + expect(useChatStore.getState().currentSessionKey).toBe('agent:main:other'); + expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['other session']); + expect(useChatStore.getState().error).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +});