committed by
GitHub
Unverified
parent
87ab12849c
commit
4ff6861042
@@ -8,6 +8,14 @@ import { hostApiFetch } from '@/lib/host-api';
|
|||||||
import { useGatewayStore } from './gateway';
|
import { useGatewayStore } from './gateway';
|
||||||
import { useAgentsStore } from './agents';
|
import { useAgentsStore } from './agents';
|
||||||
import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils';
|
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 {
|
import {
|
||||||
DEFAULT_CANONICAL_PREFIX,
|
DEFAULT_CANONICAL_PREFIX,
|
||||||
DEFAULT_SESSION_KEY,
|
DEFAULT_SESSION_KEY,
|
||||||
@@ -52,6 +60,7 @@ let _loadSessionsInFlight: Promise<void> | null = null;
|
|||||||
let _lastLoadSessionsAt = 0;
|
let _lastLoadSessionsAt = 0;
|
||||||
const _historyLoadInFlight = new Map<string, Promise<void>>();
|
const _historyLoadInFlight = new Map<string, Promise<void>>();
|
||||||
const _lastHistoryLoadAtBySession = new Map<string, number>();
|
const _lastHistoryLoadAtBySession = new Map<string, number>();
|
||||||
|
const _foregroundHistoryLoadSeen = new Set<string>();
|
||||||
const SESSION_LOAD_MIN_INTERVAL_MS = 1_200;
|
const SESSION_LOAD_MIN_INTERVAL_MS = 1_200;
|
||||||
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
|
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
|
||||||
const HISTORY_POLL_SILENCE_WINDOW_MS = 2_500;
|
const HISTORY_POLL_SILENCE_WINDOW_MS = 2_500;
|
||||||
@@ -1304,6 +1313,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
loadHistory: async (quiet = false) => {
|
loadHistory: async (quiet = false) => {
|
||||||
const { currentSessionKey } = get();
|
const { currentSessionKey } = get();
|
||||||
|
const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(currentSessionKey);
|
||||||
|
const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad);
|
||||||
const existingLoad = _historyLoadInFlight.get(currentSessionKey);
|
const existingLoad = _historyLoadInFlight.get(currentSessionKey);
|
||||||
if (existingLoad) {
|
if (existingLoad) {
|
||||||
await existingLoad;
|
await existingLoad;
|
||||||
@@ -1323,7 +1334,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const loadingSafetyTimer = quiet ? null : setTimeout(() => {
|
const loadingSafetyTimer = quiet ? null : setTimeout(() => {
|
||||||
loadingTimedOut = true;
|
loadingTimedOut = true;
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
}, 15_000);
|
}, getHistoryLoadingSafetyTimeout(isInitialForegroundLoad));
|
||||||
|
|
||||||
const loadPromise = (async () => {
|
const loadPromise = (async () => {
|
||||||
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
||||||
@@ -1367,7 +1378,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// Guard: if the user switched sessions while this async load was in
|
// Guard: if the user switched sessions while this async load was in
|
||||||
// flight, discard the result to prevent overwriting the new session's
|
// flight, discard the result to prevent overwriting the new session's
|
||||||
// messages with stale data from the old session.
|
// 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
|
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
||||||
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
||||||
@@ -1466,13 +1477,53 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
set({ sending: false, activeRunId: null, pendingFinal: false });
|
set({ sending: false, activeRunId: null, pendingFinal: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
|
let data: Record<string, unknown> | 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<Record<string, unknown>>(
|
||||||
'chat.history',
|
'chat.history',
|
||||||
{ sessionKey: currentSessionKey, limit: 200 },
|
{ 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) {
|
if (data) {
|
||||||
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
||||||
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
||||||
@@ -1480,20 +1531,40 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyLoadedMessages(rawMessages, thinkingLevel);
|
const applied = applyLoadedMessages(rawMessages, thinkingLevel);
|
||||||
|
if (applied && isInitialForegroundLoad) {
|
||||||
|
_foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||||
|
}
|
||||||
} else {
|
} 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);
|
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||||
|
if (applied && isInitialForegroundLoad) {
|
||||||
|
_foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
applyLoadFailure('Failed to load chat history');
|
applyLoadFailure(
|
||||||
|
(lastError instanceof Error ? lastError.message : String(lastError))
|
||||||
|
|| 'Failed to load chat history',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to load chat history:', err);
|
console.warn('Failed to load chat history:', err);
|
||||||
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||||
|
if (applied && isInitialForegroundLoad) {
|
||||||
|
_foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
applyLoadFailure(String(err));
|
applyLoadFailure(String(err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import {
|
import {
|
||||||
clearHistoryPoll,
|
clearHistoryPoll,
|
||||||
enrichWithCachedImages,
|
enrichWithCachedImages,
|
||||||
@@ -12,9 +13,18 @@ import {
|
|||||||
toMs,
|
toMs,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils';
|
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 { RawMessage } from './types';
|
||||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||||
|
|
||||||
|
const foregroundHistoryLoadSeen = new Set<string>();
|
||||||
|
|
||||||
async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise<RawMessage[]> {
|
async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise<RawMessage[]> {
|
||||||
if (!isCronSessionKey(sessionKey)) return [];
|
if (!isCronSessionKey(sessionKey)) return [];
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +45,8 @@ export function createHistoryActions(
|
|||||||
return {
|
return {
|
||||||
loadHistory: async (quiet = false) => {
|
loadHistory: async (quiet = false) => {
|
||||||
const { currentSessionKey } = get();
|
const { currentSessionKey } = get();
|
||||||
|
const isInitialForegroundLoad = !quiet && !foregroundHistoryLoadSeen.has(currentSessionKey);
|
||||||
|
const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad);
|
||||||
if (!quiet) set({ loading: true, error: null });
|
if (!quiet) set({ loading: true, error: null });
|
||||||
|
|
||||||
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
||||||
@@ -75,7 +87,7 @@ export function createHistoryActions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
|
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
|
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
||||||
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
||||||
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role) && !isInternalMessage(msg));
|
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role) && !isInternalMessage(msg));
|
||||||
@@ -173,36 +185,103 @@ export function createHistoryActions(
|
|||||||
set({ sending: false, activeRunId: null, pendingFinal: false });
|
set({ sending: false, activeRunId: null, pendingFinal: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await invokeIpc(
|
let result: { success: boolean; result?: Record<string, unknown>; 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',
|
'gateway:rpc',
|
||||||
'chat.history',
|
'chat.history',
|
||||||
{ sessionKey: currentSessionKey, limit: 200 }
|
{ sessionKey: currentSessionKey, limit: 200 },
|
||||||
|
...(historyTimeoutOverride != null ? [historyTimeoutOverride] as const : []),
|
||||||
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||||
|
|
||||||
if (result.success && result.result) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
const data = result.result;
|
||||||
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
||||||
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
||||||
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
|
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
|
||||||
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
}
|
}
|
||||||
applyLoadedMessages(rawMessages, thinkingLevel);
|
const applied = applyLoadedMessages(rawMessages, thinkingLevel);
|
||||||
} else {
|
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);
|
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||||
} else {
|
if (applied && isInitialForegroundLoad) {
|
||||||
applyLoadFailure(result.error || 'Failed to load chat history');
|
foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
applyLoadFailure(
|
||||||
|
result?.error
|
||||||
|
|| (lastError instanceof Error ? lastError.message : String(lastError))
|
||||||
|
|| 'Failed to load chat history',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to load chat history:', err);
|
console.warn('Failed to load chat history:', err);
|
||||||
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||||
|
if (applied && isInitialForegroundLoad) {
|
||||||
|
foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
applyLoadFailure(String(err));
|
applyLoadFailure(String(err));
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/stores/chat/history-startup-retry.ts
Normal file
79
src/stores/chat/history-startup-retry.ts
Normal file
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
102
tests/e2e/chat-history-startup-retry.spec.ts
Normal file
102
tests/e2e/chat-history-startup-retry.spec.ts
Normal file
@@ -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<string, unknown>)
|
||||||
|
.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<string, unknown>)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
const invokeIpcMock = vi.fn();
|
const invokeIpcMock = vi.fn();
|
||||||
const hostApiFetchMock = vi.fn();
|
const hostApiFetchMock = vi.fn();
|
||||||
|
const gatewayStoreGetStateMock = vi.fn();
|
||||||
const clearHistoryPoll = vi.fn();
|
const clearHistoryPoll = vi.fn();
|
||||||
const enrichWithCachedImages = vi.fn((messages) => messages);
|
const enrichWithCachedImages = vi.fn((messages) => messages);
|
||||||
const enrichWithToolResultFiles = vi.fn((messages) => messages);
|
const enrichWithToolResultFiles = vi.fn((messages) => messages);
|
||||||
@@ -30,6 +31,12 @@ vi.mock('@/lib/host-api', () => ({
|
|||||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/gateway', () => ({
|
||||||
|
useGatewayStore: {
|
||||||
|
getState: () => gatewayStoreGetStateMock(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/stores/chat/helpers', () => ({
|
vi.mock('@/stores/chat/helpers', () => ({
|
||||||
clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args),
|
clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args),
|
||||||
enrichWithCachedImages: (...args: unknown[]) => enrichWithCachedImages(...args),
|
enrichWithCachedImages: (...args: unknown[]) => enrichWithCachedImages(...args),
|
||||||
@@ -83,8 +90,13 @@ function makeHarness(initial?: Partial<ChatLikeState>) {
|
|||||||
describe('chat history actions', () => {
|
describe('chat history actions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useRealTimers();
|
||||||
invokeIpcMock.mockResolvedValue({ success: true, result: { messages: [] } });
|
invokeIpcMock.mockResolvedValue({ success: true, result: { messages: [] } });
|
||||||
hostApiFetchMock.mockResolvedValue({ 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 () => {
|
it('uses cron session fallback when gateway history is empty', async () => {
|
||||||
@@ -156,10 +168,158 @@ describe('chat history actions', () => {
|
|||||||
await actions.loadHistory();
|
await actions.loadHistory();
|
||||||
|
|
||||||
expect(h.read().messages.map((message) => message.content)).toEqual(['still here']);
|
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);
|
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 () => {
|
it('filters out system messages from loaded history', async () => {
|
||||||
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
||||||
const h = makeHarness();
|
const h = makeHarness();
|
||||||
|
|||||||
266
tests/unit/chat-store-history-retry.test.ts
Normal file
266
tests/unit/chat-store-history-retry.test.ts
Normal file
@@ -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<Record<string, unknown>>,
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user