Fix chat load history (#1004)
This commit is contained in:
@@ -184,12 +184,13 @@ export class GatewayManager extends EventEmitter {
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
|
||||
public static readonly RESTART_COOLDOWN_MS = 5_000;
|
||||
private static readonly GATEWAY_READY_FALLBACK_MS = 30_000;
|
||||
private static readonly GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS = [1_500, 3_000, 5_000, 8_000, 12_000, 30_000] as const;
|
||||
private static readonly INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS = 5 * 60_000;
|
||||
private lastRestartAt = 0;
|
||||
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
|
||||
private isAutoReconnectStart = false;
|
||||
private gatewayReadyFallbackTimer: NodeJS.Timeout | null = null;
|
||||
private gatewayReadyFallbackAttempt = 0;
|
||||
private readonly capabilityMonitor = new GatewayCapabilityMonitor();
|
||||
private diagnostics: GatewayDiagnosticsSnapshot = {
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
@@ -227,7 +228,7 @@ export class GatewayManager extends EventEmitter {
|
||||
// so that async file I/O and key generation don't block module loading.
|
||||
|
||||
this.on('gateway:ready', () => {
|
||||
this.clearGatewayReadyFallback();
|
||||
this.resetGatewayReadyFallback();
|
||||
this.clearInitialReadyHeartbeatRecoveryTimer();
|
||||
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||
logger.info('Gateway subsystems ready (event received)');
|
||||
@@ -337,6 +338,7 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
this.isAutoReconnectStart = false; // consume the flag
|
||||
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts, gatewayReady: false });
|
||||
this.resetGatewayReadyFallback();
|
||||
|
||||
// Check if Python environment is ready (self-healing) asynchronously.
|
||||
// Fire-and-forget: only needs to run once, not on every retry.
|
||||
@@ -780,23 +782,40 @@ export class GatewayManager extends EventEmitter {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
this.reloadDebounceTimer = null;
|
||||
}
|
||||
this.clearGatewayReadyFallback();
|
||||
this.resetGatewayReadyFallback();
|
||||
this.clearInitialReadyHeartbeatRecoveryTimer();
|
||||
}
|
||||
|
||||
private clearGatewayReadyFallback(): void {
|
||||
private clearGatewayReadyFallbackTimer(): void {
|
||||
if (this.gatewayReadyFallbackTimer) {
|
||||
clearTimeout(this.gatewayReadyFallbackTimer);
|
||||
this.gatewayReadyFallbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleGatewayReadyFallback(): void {
|
||||
this.clearGatewayReadyFallback();
|
||||
private resetGatewayReadyFallback(): void {
|
||||
this.clearGatewayReadyFallbackTimer();
|
||||
this.gatewayReadyFallbackAttempt = 0;
|
||||
}
|
||||
|
||||
private getNextGatewayReadyFallbackDelayMs(): number {
|
||||
const delays = GatewayManager.GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS;
|
||||
const index = Math.min(this.gatewayReadyFallbackAttempt, delays.length - 1);
|
||||
const delayMs = delays[index]!;
|
||||
this.gatewayReadyFallbackAttempt += 1;
|
||||
return delayMs;
|
||||
}
|
||||
|
||||
private scheduleGatewayReadyFallback(delayMs?: number): void {
|
||||
if (this.status.state !== 'running' || this.status.gatewayReady) {
|
||||
return;
|
||||
}
|
||||
this.clearGatewayReadyFallbackTimer();
|
||||
const effectiveDelayMs = delayMs ?? this.getNextGatewayReadyFallbackDelayMs();
|
||||
this.gatewayReadyFallbackTimer = setTimeout(() => {
|
||||
this.gatewayReadyFallbackTimer = null;
|
||||
void this.probeGatewayReadyFallback();
|
||||
}, GatewayManager.GATEWAY_READY_FALLBACK_MS);
|
||||
}, effectiveDelayMs);
|
||||
}
|
||||
|
||||
private async probeGatewayReadyFallback(): Promise<void> {
|
||||
@@ -815,6 +834,7 @@ export class GatewayManager extends EventEmitter {
|
||||
});
|
||||
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||
logger.info('Gateway ready fallback RPC router probe succeeded');
|
||||
this.resetGatewayReadyFallback();
|
||||
this.setStatus({ gatewayReady: true });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
43
harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
Normal file
43
harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: fix-gateway-ready-chat-history-reload
|
||||
title: Fix delayed sidebar chat history reload after gateway restart
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Align gateway readiness signaling with sidebar history refresh after restart.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
|
||||
- electron/gateway/manager.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
- src/pages/Settings/index.tsx
|
||||
- src/pages/Setup/index.tsx
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/e2e/gateway-lifecycle.spec.ts
|
||||
expectedUserBehavior:
|
||||
- After gateway restart, sidebar history reloads as soon as the gateway becomes RPC-ready.
|
||||
- UI does not show a fully healthy green running state while gatewayReady is still false.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/e2e/gateway-lifecycle.spec.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Gateway running-but-not-ready state is surfaced distinctly from fully ready.
|
||||
- Sidebar reloads sessions/history when gatewayReady becomes true after a restart.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -108,22 +108,22 @@ export function Sidebar() {
|
||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||
const isGatewayReady = isGatewayRunning && gatewayStatus.gatewayReady !== false;
|
||||
const gatewayRuntimeKey = `${gatewayStatus.pid ?? 'none'}:${gatewayStatus.connectedAt ?? 'none'}:${gatewayStatus.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGatewayReady) return;
|
||||
let cancelled = false;
|
||||
const hasExistingMessages = useChatStore.getState().messages.length > 0;
|
||||
(async () => {
|
||||
await Promise.allSettled([
|
||||
loadSessions(),
|
||||
loadHistory(hasExistingMessages),
|
||||
loadHistory(false),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isGatewayReady, loadHistory, loadSessions]);
|
||||
}, [gatewayRuntimeKey, isGatewayReady, loadHistory, loadSessions]);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
|
||||
@@ -297,7 +297,14 @@ export function Sidebar() {
|
||||
return (
|
||||
<div key={s.key} className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||
onClick={() => {
|
||||
if (currentSessionKey === s.key) {
|
||||
void loadHistory(false);
|
||||
} else {
|
||||
switchSession(s.key);
|
||||
}
|
||||
navigate('/');
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg px-2.5 py-1.5 text-meta transition-colors pr-7',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
|
||||
@@ -251,6 +251,8 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
const showAgentPicker = mentionableAgents.length > 0;
|
||||
const showModelPicker = modelOptions.length > 1;
|
||||
const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents();
|
||||
const isGatewayUsable = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false;
|
||||
const inputDisabled = disabled || !isGatewayUsable;
|
||||
const skillTokenRanges = useMemo(() => findSkillTokenRanges(input), [input]);
|
||||
const openArtifactPreview = useArtifactPanel((s) => s.openPreview);
|
||||
|
||||
@@ -289,10 +291,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
|
||||
// Focus textarea on mount (avoids Windows focus loss after session delete + native dialog)
|
||||
useEffect(() => {
|
||||
if (!disabled && textareaRef.current) {
|
||||
if (!inputDisabled && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [disabled]);
|
||||
}, [inputDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetAgentId) return;
|
||||
@@ -586,8 +588,8 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
|
||||
const allReady = attachments.length === 0 || attachments.every(a => a.status === 'ready');
|
||||
const hasFailedAttachments = attachments.some((a) => a.status === 'error');
|
||||
const canSend = (input.trim() || attachments.length > 0) && allReady && !disabled && !sending;
|
||||
const canStop = sending && !disabled && !!onStop;
|
||||
const canSend = (input.trim() || attachments.length > 0) && allReady && !inputDisabled && !sending;
|
||||
const canStop = sending && !inputDisabled && !!onStop;
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!canSend) return;
|
||||
@@ -824,8 +826,8 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
isComposingRef.current = false;
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
placeholder={disabled ? t('composer.gatewayDisconnectedPlaceholder') : ''}
|
||||
disabled={disabled}
|
||||
placeholder={inputDisabled ? t('composer.gatewayDisconnectedPlaceholder') : ''}
|
||||
disabled={inputDisabled}
|
||||
data-testid="chat-composer-input"
|
||||
className={cn(
|
||||
'relative min-h-[48px] max-h-[240px] resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none bg-transparent p-0 text-sm leading-relaxed placeholder:text-muted-foreground/60',
|
||||
@@ -843,7 +845,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
size="icon"
|
||||
className="shrink-0 h-8 w-8 rounded-lg text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors"
|
||||
onClick={pickFiles}
|
||||
disabled={disabled || sending}
|
||||
disabled={inputDisabled || sending}
|
||||
title={t('composer.attachFiles')}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
@@ -863,7 +865,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
setSkillPickerOpen(false);
|
||||
setPickerOpen((open) => !open);
|
||||
}}
|
||||
disabled={disabled || sending}
|
||||
disabled={inputDisabled || sending}
|
||||
title={t('composer.pickAgent')}
|
||||
>
|
||||
<AtSign className="h-3.5 w-3.5" />
|
||||
@@ -904,7 +906,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
setPickerOpen(false);
|
||||
setSkillPickerOpen((open) => !open);
|
||||
}}
|
||||
disabled={disabled || sending}
|
||||
disabled={inputDisabled || sending}
|
||||
title={t('composer.pickSkill')}
|
||||
>
|
||||
<span>{t('composer.skillButton')}</span>
|
||||
@@ -1052,12 +1054,17 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 text-tiny text-muted-foreground/60 px-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full", gatewayStatus.state === 'running' ? "bg-green-500/80" : "bg-red-500/80")} />
|
||||
<div className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
isGatewayUsable ? "bg-green-500/80" : "bg-red-500/80",
|
||||
)} />
|
||||
<span>
|
||||
{t('composer.gatewayStatus', {
|
||||
state: gatewayStatus.state === 'running'
|
||||
state: isGatewayUsable
|
||||
? t('composer.gatewayConnected')
|
||||
: gatewayStatus.state,
|
||||
: gatewayStatus.state === 'running'
|
||||
? 'starting'
|
||||
: gatewayStatus.state,
|
||||
port: gatewayStatus.port,
|
||||
pid: gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : '',
|
||||
})}
|
||||
|
||||
@@ -573,15 +573,17 @@ export function Settings() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-meta font-medium border",
|
||||
gatewayStatus.state === 'running' ? "bg-green-500/10 text-green-600 dark:text-green-500 border-green-500/20" :
|
||||
gatewayStatus.state === 'error' ? "bg-red-500/10 text-red-600 dark:text-red-500 border-red-500/20" :
|
||||
"bg-black/5 dark:bg-white/5 text-muted-foreground border-transparent"
|
||||
gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false ? "bg-green-500/10 text-green-600 dark:text-green-500 border-green-500/20" :
|
||||
gatewayStatus.state === 'running' ? "bg-red-500/10 text-red-600 dark:text-red-500 border-red-500/20" :
|
||||
gatewayStatus.state === 'error' ? "bg-red-500/10 text-red-600 dark:text-red-500 border-red-500/20" :
|
||||
"bg-black/5 dark:bg-white/5 text-muted-foreground border-transparent"
|
||||
)}>
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full",
|
||||
gatewayStatus.state === 'running' ? "bg-green-500" :
|
||||
gatewayStatus.state === 'error' ? "bg-red-500" : "bg-muted-foreground"
|
||||
gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false ? "bg-green-500" :
|
||||
gatewayStatus.state === 'running' ? "bg-red-500" :
|
||||
gatewayStatus.state === 'error' ? "bg-red-500" : "bg-muted-foreground"
|
||||
)} />
|
||||
{gatewayStatus.state}
|
||||
{gatewayStatus.state === 'running' && gatewayStatus.gatewayReady === false ? 'starting' : gatewayStatus.state}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={restartGateway} className="rounded-full h-8 px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
|
||||
@@ -854,8 +854,12 @@ function CompleteContent({ installedSkills }: CompleteContentProps) {
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<span>{t('complete.gateway')}</span>
|
||||
<span className={gatewayStatus.state === 'running' ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{gatewayStatus.state === 'running' ? `✓ ${t('complete.running')}` : gatewayStatus.state}
|
||||
<span className={gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false ? 'text-green-400' : gatewayStatus.state === 'running' ? 'text-red-400' : 'text-yellow-400'}>
|
||||
{gatewayStatus.state === 'running'
|
||||
? gatewayStatus.gatewayReady !== false
|
||||
? `✓ ${t('complete.running')}`
|
||||
: 'starting'
|
||||
: gatewayStatus.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,7 @@ const _historyLoadInFlight = new Map<string, Promise<void>>();
|
||||
const _lastHistoryLoadAtBySession = new Map<string, number>();
|
||||
const _forceNextHistoryLoadBySession = new Set<string>();
|
||||
const _foregroundHistoryLoadSeen = new Set<string>();
|
||||
const _sessionHistoryCache = new Map<string, { messages: RawMessage[]; thinkingLevel: string | null }>();
|
||||
const SESSION_LOAD_MIN_INTERVAL_MS = 1_200;
|
||||
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
|
||||
const HISTORY_POLL_SILENCE_WINDOW_MS = 2_500;
|
||||
@@ -87,6 +88,40 @@ function forceNextHistoryLoad(sessionKey: string): void {
|
||||
_forceNextHistoryLoadBySession.add(sessionKey);
|
||||
}
|
||||
|
||||
function cloneHistoryMessages(messages: RawMessage[]): RawMessage[] {
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
_attachedFiles: message._attachedFiles?.map((file) => ({ ...file })),
|
||||
}));
|
||||
}
|
||||
|
||||
function cacheSessionHistory(sessionKey: string, messages: RawMessage[], thinkingLevel: string | null): void {
|
||||
_sessionHistoryCache.set(sessionKey, {
|
||||
messages: cloneHistoryMessages(messages),
|
||||
thinkingLevel,
|
||||
});
|
||||
}
|
||||
|
||||
function getCachedSessionHistory(sessionKey: string): { messages: RawMessage[]; thinkingLevel: string | null } | null {
|
||||
const cached = _sessionHistoryCache.get(sessionKey);
|
||||
if (!cached) return null;
|
||||
return {
|
||||
messages: cloneHistoryMessages(cached.messages),
|
||||
thinkingLevel: cached.thinkingLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function clearCachedSessionHistory(sessionKey: string): void {
|
||||
_sessionHistoryCache.delete(sessionKey);
|
||||
}
|
||||
|
||||
function getHistoryForegroundLoadKey(sessionKey: string): string {
|
||||
const gatewayState = useGatewayStore.getState?.() as { status?: { pid?: number; connectedAt?: number; port?: number } } | undefined;
|
||||
const gatewayStatus = gatewayState?.status;
|
||||
const gatewayRuntimeKey = `${gatewayStatus?.pid ?? 'none'}:${gatewayStatus?.connectedAt ?? 'none'}:${gatewayStatus?.port ?? 'none'}`;
|
||||
return `${gatewayRuntimeKey}|${sessionKey}`;
|
||||
}
|
||||
|
||||
function pruneChatEventDedupe(now: number): void {
|
||||
for (const [key, ts] of _chatEventDedupe.entries()) {
|
||||
if (now - ts > CHAT_EVENT_DEDUPE_TTL_MS) {
|
||||
@@ -1014,7 +1049,7 @@ function clearSessionEntryFromMap<T extends Record<string, unknown>>(entries: T,
|
||||
function buildSessionSwitchPatch(
|
||||
state: Pick<
|
||||
ChatState,
|
||||
'currentSessionKey' | 'messages' | 'sessions' | 'sessionLabels' | 'sessionLastActivity'
|
||||
'currentSessionKey' | 'messages' | 'sessions' | 'sessionLabels' | 'sessionLastActivity' | 'thinkingLevel'
|
||||
>,
|
||||
nextSessionKey: string,
|
||||
): Partial<ChatState> {
|
||||
@@ -1030,6 +1065,7 @@ function buildSessionSwitchPatch(
|
||||
const nextSessions = leavingEmpty
|
||||
? state.sessions.filter((session) => session.key !== state.currentSessionKey)
|
||||
: state.sessions;
|
||||
const cachedNextSession = getCachedSessionHistory(nextSessionKey);
|
||||
|
||||
return {
|
||||
currentSessionKey: nextSessionKey,
|
||||
@@ -1041,7 +1077,8 @@ function buildSessionSwitchPatch(
|
||||
sessionLastActivity: leavingEmpty
|
||||
? clearSessionEntryFromMap(state.sessionLastActivity, state.currentSessionKey)
|
||||
: state.sessionLastActivity,
|
||||
messages: [],
|
||||
messages: cachedNextSession?.messages ?? [],
|
||||
thinkingLevel: cachedNextSession?.thinkingLevel ?? state.thinkingLevel ?? null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
@@ -1645,6 +1682,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// newSession() design that avoids sessions.reset to preserve history.
|
||||
|
||||
deleteSession: async (key: string) => {
|
||||
clearCachedSessionHistory(key);
|
||||
// Soft-delete the session's JSONL transcript on disk.
|
||||
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||
// sessions.list skips it automatically.
|
||||
@@ -1772,10 +1810,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
loadHistory: async (quiet = false) => {
|
||||
const { currentSessionKey } = get();
|
||||
const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(currentSessionKey);
|
||||
const foregroundLoadKey = getHistoryForegroundLoadKey(currentSessionKey);
|
||||
const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(foregroundLoadKey);
|
||||
const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad);
|
||||
const forceLoad = _forceNextHistoryLoadBySession.delete(currentSessionKey);
|
||||
const existingLoad = _historyLoadInFlight.get(currentSessionKey);
|
||||
const shouldShowForegroundLoading = !quiet && get().messages.length === 0;
|
||||
if (existingLoad) {
|
||||
await existingLoad;
|
||||
if (!forceLoad) {
|
||||
@@ -1791,15 +1831,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!quiet) set({ loading: true, error: null, runError: null });
|
||||
if (shouldShowForegroundLoading) set({ loading: true, error: null, runError: null });
|
||||
|
||||
// Safety guard: if history loading takes too long, force loading to false
|
||||
// to prevent the UI from being stuck in a spinner forever.
|
||||
let loadingTimedOut = false;
|
||||
const loadingSafetyTimer = quiet ? null : setTimeout(() => {
|
||||
const loadingSafetyTimer = shouldShowForegroundLoading ? setTimeout(() => {
|
||||
loadingTimedOut = true;
|
||||
set({ loading: false });
|
||||
}, getHistoryLoadingSafetyTimeout(isInitialForegroundLoad));
|
||||
}, getHistoryLoadingSafetyTimeout(isInitialForegroundLoad)) : null;
|
||||
|
||||
const loadPromise = (async () => {
|
||||
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
||||
@@ -1833,7 +1873,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const hasMessages = state.messages.length > 0;
|
||||
return {
|
||||
loading: false,
|
||||
error: !quiet && errorMessage ? errorMessage : state.error,
|
||||
error: shouldShowForegroundLoading && errorMessage ? errorMessage : state.error,
|
||||
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
|
||||
};
|
||||
});
|
||||
@@ -1901,6 +1941,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
loading: false,
|
||||
runError: latestTerminalAssistantErrorMessage,
|
||||
});
|
||||
cacheSessionHistory(currentSessionKey, finalMessages, thinkingLevel);
|
||||
|
||||
// Extract first user message text as a session label for display in the toolbar.
|
||||
// Skip main sessions (key ends with ":main") — they rely on the Gateway-provided
|
||||
@@ -2027,7 +2068,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
const applied = applyLoadedMessages(rawMessages, thinkingLevel);
|
||||
if (applied && isInitialForegroundLoad) {
|
||||
_foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||
_foregroundHistoryLoadSeen.add(foregroundLoadKey);
|
||||
}
|
||||
} else {
|
||||
if (isCurrentSession() && isInitialForegroundLoad && classifyHistoryStartupRetryError(lastError)) {
|
||||
@@ -2042,7 +2083,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (fallbackMessages.length > 0) {
|
||||
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||
if (applied && isInitialForegroundLoad) {
|
||||
_foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||
_foregroundHistoryLoadSeen.add(foregroundLoadKey);
|
||||
}
|
||||
} else {
|
||||
applyLoadFailure(
|
||||
@@ -2057,7 +2098,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (fallbackMessages.length > 0) {
|
||||
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||
if (applied && isInitialForegroundLoad) {
|
||||
_foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||
_foregroundHistoryLoadSeen.add(foregroundLoadKey);
|
||||
}
|
||||
} else {
|
||||
applyLoadFailure(String(err));
|
||||
|
||||
@@ -48,7 +48,10 @@ export function createHistoryActions(
|
||||
return {
|
||||
loadHistory: async (quiet = false) => {
|
||||
const { currentSessionKey } = get();
|
||||
const isInitialForegroundLoad = !quiet && !foregroundHistoryLoadSeen.has(currentSessionKey);
|
||||
const gatewayState = useGatewayStore.getState?.() as { status?: { pid?: number; connectedAt?: number; port?: number } } | undefined;
|
||||
const gatewayStatus = gatewayState?.status;
|
||||
const foregroundLoadKey = `${gatewayStatus?.pid ?? 'none'}:${gatewayStatus?.connectedAt ?? 'none'}:${gatewayStatus?.port ?? 'none'}|${currentSessionKey}`;
|
||||
const isInitialForegroundLoad = !quiet && !foregroundHistoryLoadSeen.has(foregroundLoadKey);
|
||||
const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad);
|
||||
if (!quiet) set({ loading: true, error: null });
|
||||
|
||||
@@ -278,7 +281,7 @@ export function createHistoryActions(
|
||||
}
|
||||
const applied = applyLoadedMessages(rawMessages, thinkingLevel);
|
||||
if (applied && isInitialForegroundLoad) {
|
||||
foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||
foregroundHistoryLoadSeen.add(foregroundLoadKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -297,7 +300,7 @@ export function createHistoryActions(
|
||||
if (fallbackMessages.length > 0) {
|
||||
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||
if (applied && isInitialForegroundLoad) {
|
||||
foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||
foregroundHistoryLoadSeen.add(foregroundLoadKey);
|
||||
}
|
||||
} else if (errorKind === 'gateway_startup') {
|
||||
// Suppress error UI for gateway startup -- the history will load
|
||||
@@ -317,7 +320,7 @@ export function createHistoryActions(
|
||||
if (fallbackMessages.length > 0) {
|
||||
const applied = applyLoadedMessages(fallbackMessages, null);
|
||||
if (applied && isInitialForegroundLoad) {
|
||||
foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||
foregroundHistoryLoadSeen.add(foregroundLoadKey);
|
||||
}
|
||||
} else {
|
||||
applyLoadFailure(String(err));
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { completeSetup, expect, 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 gateway lifecycle resilience', () => {
|
||||
test('app remains fully navigable while gateway is disconnected', async ({ page }) => {
|
||||
// In E2E mode, gateway auto-start is skipped, so the app starts
|
||||
@@ -109,38 +118,63 @@ test.describe('ClawX gateway lifecycle resilience', () => {
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
});
|
||||
|
||||
test('app handles rapid gateway status transitions without crashing', async ({ electronApp, page }) => {
|
||||
test('chat sidebar history reloads when gateway becomes ready after restart', async ({ electronApp, page }) => {
|
||||
await installIpcMocks(electronApp, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 100, connectedAt: 1, gatewayReady: false },
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 100, connectedAt: 1, gatewayReady: false },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: 'agent:main:main', displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: 'agent:main:main', limit: 200 }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello', timestamp: 1000 },
|
||||
{ role: 'assistant', content: 'history after ready', timestamp: 1001 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-new-chat').click();
|
||||
await expect(page.getByText(/gateway starting \| port: 18789/i)).toBeVisible();
|
||||
await expect(page.getByText('history after ready')).toHaveCount(0);
|
||||
|
||||
// Simulate rapid status transitions like those seen in the bug log:
|
||||
// running → stopped → starting → error → reconnecting → running
|
||||
const states = [
|
||||
{ state: 'running', port: 18789, pid: 100 },
|
||||
{ state: 'stopped', port: 18789 },
|
||||
{ state: 'starting', port: 18789 },
|
||||
{ state: 'error', port: 18789, error: 'Port 18789 still occupied after 30000ms' },
|
||||
{ state: 'reconnecting', port: 18789, reconnectAttempts: 1 },
|
||||
{ state: 'starting', port: 18789 },
|
||||
{ state: 'running', port: 18789, pid: 200, connectedAt: Date.now() },
|
||||
];
|
||||
await electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
win?.webContents.send('gateway:status-changed', {
|
||||
state: 'running',
|
||||
port: 18789,
|
||||
pid: 200,
|
||||
connectedAt: 2,
|
||||
gatewayReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
for (const status of states) {
|
||||
await electronApp.evaluate(({ BrowserWindow }, s) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
win?.webContents.send('gateway:status-changed', s);
|
||||
}, status);
|
||||
// Small delay between transitions to be more realistic
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// Verify the app is still stable after rapid transitions
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
// Navigate to verify no page is in a broken state
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('models-page')).toBeVisible();
|
||||
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||
await expect(page.getByText('history after ready')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/gateway connected \| port: 18789/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +214,48 @@ describe('ChatInput agent targeting', () => {
|
||||
expect(onSend).toHaveBeenCalledWith('Hello direct agent', undefined, 'research');
|
||||
});
|
||||
|
||||
it('disables the input while gateway is running but not yet ready', () => {
|
||||
gatewayState.status = { state: 'running', port: 18789, gatewayReady: false };
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
|
||||
renderChatInput();
|
||||
|
||||
expect(screen.getByTestId('chat-composer-input')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows starting status while gateway is running but not yet ready', () => {
|
||||
gatewayState.status = { state: 'running', port: 18789, gatewayReady: false };
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
|
||||
renderChatInput();
|
||||
|
||||
expect(screen.getByText(/gateway starting \| port: 18789/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the skill trigger after the @ agent picker', () => {
|
||||
agentsState.agents = [
|
||||
{
|
||||
|
||||
@@ -194,7 +194,171 @@ describe('useChatStore startup history retry', () => {
|
||||
{ sessionKey: 'agent:main:main', limit: 200 },
|
||||
undefined,
|
||||
);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 15_000);
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 15_000);
|
||||
setTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps cached session messages visible without foreground loading overlay during refresh', async () => {
|
||||
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,
|
||||
});
|
||||
|
||||
gatewayRpcMock
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }],
|
||||
});
|
||||
|
||||
useChatStore.setState({ currentSessionKey: 'agent:main:other' });
|
||||
await useChatStore.getState().loadHistory(false);
|
||||
|
||||
gatewayRpcMock.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }] });
|
||||
}, 10);
|
||||
}));
|
||||
|
||||
useChatStore.getState().switchSession('agent:main:other');
|
||||
|
||||
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']);
|
||||
expect(useChatStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('switchSession restores cached session messages immediately while refreshing in background', async () => {
|
||||
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,
|
||||
});
|
||||
|
||||
gatewayRpcMock
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }],
|
||||
});
|
||||
|
||||
useChatStore.setState({ currentSessionKey: 'agent:main:other' });
|
||||
await useChatStore.getState().loadHistory(false);
|
||||
|
||||
gatewayRpcMock.mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }],
|
||||
});
|
||||
|
||||
useChatStore.getState().switchSession('agent:main:other');
|
||||
|
||||
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']);
|
||||
});
|
||||
|
||||
it('treats the same session as a fresh foreground load after gateway runtime changes', 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,
|
||||
});
|
||||
|
||||
gatewayRpcMock
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'first runtime', timestamp: 1000 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'second runtime', timestamp: 1001 }],
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadHistory(false);
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('@/stores/gateway', () => ({
|
||||
useGatewayStore: {
|
||||
getState: () => ({
|
||||
status: { state: 'running', port: 18789, connectedAt: Date.now() + 5_000 },
|
||||
rpc: gatewayRpcMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
const { useChatStore: useChatStoreReloaded } = await import('@/stores/chat');
|
||||
useChatStoreReloaded.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,
|
||||
});
|
||||
|
||||
setTimeoutSpy.mockClear();
|
||||
await useChatStoreReloaded.getState().loadHistory(false);
|
||||
|
||||
expect(gatewayRpcMock).toHaveBeenLastCalledWith(
|
||||
'chat.history',
|
||||
{ sessionKey: 'agent:main:main', limit: 200 },
|
||||
35_000,
|
||||
);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
|
||||
setTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -93,12 +93,11 @@ describe('GatewayManager gatewayReady fallback', () => {
|
||||
// Call the private scheduleGatewayReadyFallback method
|
||||
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
|
||||
|
||||
// Before timeout, no gatewayReady update
|
||||
await vi.advanceTimersByTimeAsync(29_000);
|
||||
// The first readiness probe happens quickly after handshake, not after 30s.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(statusUpdates.find((u) => u.gatewayReady === true)).toBeUndefined();
|
||||
|
||||
// After 30s fallback timeout, a successful lightweight RPC marks the gateway ready.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const readyUpdate = statusUpdates.find((u) => u.gatewayReady === true);
|
||||
expect(readyUpdate).toBeDefined();
|
||||
expect(rpcSpy).toHaveBeenCalledWith('system-presence', {}, 5_000);
|
||||
@@ -121,7 +120,7 @@ describe('GatewayManager gatewayReady fallback', () => {
|
||||
|
||||
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(statusUpdates.find((u) => u.gatewayReady === true)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -146,7 +145,7 @@ describe('GatewayManager gatewayReady fallback', () => {
|
||||
manager.emit('gateway:ready', {});
|
||||
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
|
||||
|
||||
// After 30s, no duplicate gatewayReady=true
|
||||
// After enough time, no duplicate gatewayReady=true
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user