Fix chat load history (#1004)

This commit is contained in:
paisley
2026-05-11 19:25:39 +08:00
committed by GitHub
parent 087b38a39a
commit abce8a8b96
12 changed files with 448 additions and 82 deletions

View File

@@ -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) {

View 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
---

View File

@@ -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',

View File

@@ -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}` : '',
})}

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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));

View File

@@ -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));

View File

@@ -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();
});
});

View File

@@ -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 = [
{

View File

@@ -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();
});

View File

@@ -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);
});