Optimize gateway comms reload behavior and strengthen regression coverage (#496)

This commit is contained in:
Lingxuan Zuo
2026-03-15 20:36:48 +08:00
committed by GitHub
Unverified
parent 08960d700f
commit 1dbe4a8466
36 changed files with 1511 additions and 197 deletions

View File

@@ -551,6 +551,7 @@ export function createGatewayHttpTransportInvoker(
if (typeof method !== 'string') {
throw new Error('gateway:rpc requires method string');
}
validateGatewayRpcParams(method, params);
const timeoutMs =
typeof timeoutOverride === 'number' && timeoutOverride > 0
@@ -857,6 +858,7 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
if (typeof method !== 'string') {
throw new Error('gateway:rpc requires method string');
}
validateGatewayRpcParams(method, params);
const requestTimeoutMs =
typeof timeoutOverride === 'number' && timeoutOverride > 0
@@ -887,6 +889,17 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
};
}
function validateGatewayRpcParams(method: string, params: unknown): void {
if (method !== 'config.patch') return;
if (!params || typeof params !== 'object' || Array.isArray(params)) {
throw new Error('gateway:rpc config.patch requires object params');
}
const patch = (params as Record<string, unknown>).patch;
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new Error('gateway:rpc config.patch requires object patch');
}
}
let defaultTransportsInitialized = false;
export function initializeDefaultTransports(): void {

View File

@@ -129,6 +129,14 @@ function shouldFallbackToBrowser(message: string): boolean {
|| normalized.includes('window is not defined');
}
function allowLocalhostFallback(): boolean {
try {
return window.localStorage.getItem('clawx:allow-localhost-fallback') === '1';
} catch {
return false;
}
}
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const startedAt = Date.now();
const method = init?.method || 'GET';
@@ -160,6 +168,17 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
if (!shouldFallbackToBrowser(message)) {
throw normalized;
}
if (!allowLocalhostFallback()) {
trackUiEvent('hostapi.fetch_error', {
path,
method,
source: 'ipc-proxy',
durationMs: Date.now() - startedAt,
message: 'localhost fallback blocked by policy',
code: 'CHANNEL_UNAVAILABLE',
});
throw normalized;
}
}
// Browser-only fallback (non-Electron environments).

View File

@@ -140,6 +140,15 @@ let _historyPollTimer: ReturnType<typeof setTimeout> | null = null;
// error (e.g. "terminated"), it may retry internally and recover. We wait
// before committing the error to give the recovery path a chance.
let _errorRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
let _loadSessionsInFlight: Promise<void> | null = null;
let _lastLoadSessionsAt = 0;
const _historyLoadInFlight = new Map<string, Promise<void>>();
const _lastHistoryLoadAtBySession = new Map<string, number>();
const SESSION_LOAD_MIN_INTERVAL_MS = 1_200;
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
const HISTORY_POLL_SILENCE_WINDOW_MS = 2_500;
const CHAT_EVENT_DEDUPE_TTL_MS = 30_000;
const _chatEventDedupe = new Map<string, number>();
function clearErrorRecoveryTimer(): void {
if (_errorRecoveryTimer) {
@@ -155,6 +164,46 @@ function clearHistoryPoll(): void {
}
}
function pruneChatEventDedupe(now: number): void {
for (const [key, ts] of _chatEventDedupe.entries()) {
if (now - ts > CHAT_EVENT_DEDUPE_TTL_MS) {
_chatEventDedupe.delete(key);
}
}
}
function buildChatEventDedupeKey(eventState: string, event: Record<string, unknown>): string | null {
const runId = event.runId != null ? String(event.runId) : '';
const sessionKey = event.sessionKey != null ? String(event.sessionKey) : '';
const seq = event.seq != null ? String(event.seq) : '';
if (runId || sessionKey || seq || eventState) {
return [runId, sessionKey, seq, eventState].join('|');
}
const msg = (event.message && typeof event.message === 'object')
? event.message as Record<string, unknown>
: null;
if (msg) {
const messageId = msg.id != null ? String(msg.id) : '';
const stopReason = msg.stopReason ?? msg.stop_reason;
if (messageId || stopReason) {
return `msg|${messageId}|${String(stopReason ?? '')}|${eventState}`;
}
}
return null;
}
function isDuplicateChatEvent(eventState: string, event: Record<string, unknown>): boolean {
const key = buildChatEventDedupeKey(eventState, event);
if (!key) return false;
const now = Date.now();
pruneChatEventDedupe(now);
if (_chatEventDedupe.has(key)) {
return true;
}
_chatEventDedupe.set(key, now);
return false;
}
const DEFAULT_CANONICAL_PREFIX = 'agent:main';
const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`;
@@ -1040,118 +1089,139 @@ export const useChatStore = create<ChatState>((set, get) => ({
// ── Load sessions via sessions.list ──
loadSessions: async () => {
try {
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>('sessions.list', {});
if (data) {
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
key: String(s.key || ''),
label: s.label ? String(s.label) : undefined,
displayName: s.displayName ? String(s.displayName) : undefined,
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
model: s.model ? String(s.model) : undefined,
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
})).filter((s: ChatSession) => s.key);
const now = Date.now();
if (_loadSessionsInFlight) {
await _loadSessionsInFlight;
return;
}
if (now - _lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) {
return;
}
const canonicalBySuffix = new Map<string, string>();
for (const session of sessions) {
if (!session.key.startsWith('agent:')) continue;
const parts = session.key.split(':');
if (parts.length < 3) continue;
const suffix = parts.slice(2).join(':');
if (suffix && !canonicalBySuffix.has(suffix)) {
canonicalBySuffix.set(suffix, session.key);
_loadSessionsInFlight = (async () => {
try {
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>('sessions.list', {});
if (data) {
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
key: String(s.key || ''),
label: s.label ? String(s.label) : undefined,
displayName: s.displayName ? String(s.displayName) : undefined,
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
model: s.model ? String(s.model) : undefined,
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
})).filter((s: ChatSession) => s.key);
const canonicalBySuffix = new Map<string, string>();
for (const session of sessions) {
if (!session.key.startsWith('agent:')) continue;
const parts = session.key.split(':');
if (parts.length < 3) continue;
const suffix = parts.slice(2).join(':');
if (suffix && !canonicalBySuffix.has(suffix)) {
canonicalBySuffix.set(suffix, session.key);
}
}
}
// Deduplicate: if both short and canonical existed, keep canonical only
const seen = new Set<string>();
const dedupedSessions = sessions.filter((s) => {
if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false;
if (seen.has(s.key)) return false;
seen.add(s.key);
return true;
});
// Deduplicate: if both short and canonical existed, keep canonical only
const seen = new Set<string>();
const dedupedSessions = sessions.filter((s) => {
if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false;
if (seen.has(s.key)) return false;
seen.add(s.key);
return true;
});
const { currentSessionKey, sessions: localSessions } = get();
let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY;
if (!nextSessionKey.startsWith('agent:')) {
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
if (canonicalMatch) {
nextSessionKey = canonicalMatch;
const { currentSessionKey, sessions: localSessions } = get();
let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY;
if (!nextSessionKey.startsWith('agent:')) {
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
if (canonicalMatch) {
nextSessionKey = canonicalMatch;
}
}
}
if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) {
// Preserve only locally-created pending sessions. On initial boot the
// default ghost key (`agent:main:main`) should yield to real history.
const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey);
if (!hasLocalPendingSession) {
nextSessionKey = dedupedSessions[0].key;
if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) {
// Preserve only locally-created pending sessions. On initial boot the
// default ghost key (`agent:main:main`) should yield to real history.
const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey);
if (!hasLocalPendingSession) {
nextSessionKey = dedupedSessions[0].key;
}
}
}
const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey
? [
...dedupedSessions,
{ key: nextSessionKey, displayName: nextSessionKey },
]
: dedupedSessions;
const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey
? [
...dedupedSessions,
{ key: nextSessionKey, displayName: nextSessionKey },
]
: dedupedSessions;
const discoveredActivity = Object.fromEntries(
sessionsWithCurrent
.filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
.map((session) => [session.key, session.updatedAt!]),
);
set((state) => ({
sessions: sessionsWithCurrent,
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
sessionLastActivity: {
...state.sessionLastActivity,
...discoveredActivity,
},
}));
if (currentSessionKey !== nextSessionKey) {
get().loadHistory();
}
// Background: fetch first user message for every non-main session to populate labels upfront.
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
if (sessionsToLabel.length > 0) {
void Promise.all(
sessionsToLabel.map(async (session) => {
try {
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: session.key, limit: 1000 },
);
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
const firstUser = msgs.find((m) => m.role === 'user');
const lastMsg = msgs[msgs.length - 1];
set((s) => {
const next: Partial<typeof s> = {};
if (firstUser) {
const labelText = getMessageText(firstUser.content).trim();
if (labelText) {
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText;
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
}
}
if (lastMsg?.timestamp) {
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
}
return next;
});
} catch { /* ignore per-session errors */ }
}),
const discoveredActivity = Object.fromEntries(
sessionsWithCurrent
.filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
.map((session) => [session.key, session.updatedAt!]),
);
set((state) => ({
sessions: sessionsWithCurrent,
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
sessionLastActivity: {
...state.sessionLastActivity,
...discoveredActivity,
},
}));
if (currentSessionKey !== nextSessionKey) {
void get().loadHistory();
}
// Background: fetch first user message for every non-main session to populate labels upfront.
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
if (sessionsToLabel.length > 0) {
void Promise.all(
sessionsToLabel.map(async (session) => {
try {
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: session.key, limit: 1000 },
);
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
const firstUser = msgs.find((m) => m.role === 'user');
const lastMsg = msgs[msgs.length - 1];
set((s) => {
const next: Partial<typeof s> = {};
if (firstUser) {
const labelText = getMessageText(firstUser.content).trim();
if (labelText) {
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText;
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
}
}
if (lastMsg?.timestamp) {
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
}
return next;
});
} catch {
// ignore per-session errors
}
}),
);
}
}
} catch (err) {
console.warn('Failed to load sessions:', err);
} finally {
_lastLoadSessionsAt = Date.now();
}
} catch (err) {
console.warn('Failed to load sessions:', err);
})();
try {
await _loadSessionsInFlight;
} finally {
_loadSessionsInFlight = null;
}
},
@@ -1289,9 +1359,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
loadHistory: async (quiet = false) => {
const { currentSessionKey } = get();
const existingLoad = _historyLoadInFlight.get(currentSessionKey);
if (existingLoad) {
await existingLoad;
return;
}
const lastLoadAt = _lastHistoryLoadAtBySession.get(currentSessionKey) || 0;
if (quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) {
return;
}
if (!quiet) set({ loading: true, error: null });
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
const loadPromise = (async () => {
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
// Before filtering: attach images/files from tool_result messages to the next assistant message
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role));
@@ -1395,22 +1477,31 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ sending: false, activeRunId: null, pendingFinal: false });
}
}
};
};
try {
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 },
);
if (data) {
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
try {
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 },
);
if (data) {
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
}
applyLoadedMessages(rawMessages, thinkingLevel);
} else {
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else {
set({ messages: [], loading: false });
}
}
applyLoadedMessages(rawMessages, thinkingLevel);
} else {
} catch (err) {
console.warn('Failed to load chat history:', err);
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
@@ -1418,13 +1509,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ messages: [], loading: false });
}
}
} catch (err) {
console.warn('Failed to load chat history:', err);
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else {
set({ messages: [], loading: false });
})();
_historyLoadInFlight.set(currentSessionKey, loadPromise);
try {
await loadPromise;
} finally {
_lastHistoryLoadAtBySession.set(currentSessionKey, Date.now());
const active = _historyLoadInFlight.get(currentSessionKey);
if (active === loadPromise) {
_historyLoadInFlight.delete(currentSessionKey);
}
}
},
@@ -1501,6 +1595,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
_historyPollTimer = setTimeout(pollHistory, POLL_INTERVAL);
return;
}
if (Date.now() - _lastChatEventAt < HISTORY_POLL_SILENCE_WINDOW_MS) {
_historyPollTimer = setTimeout(pollHistory, POLL_INTERVAL);
return;
}
state.loadHistory(true);
_historyPollTimer = setTimeout(pollHistory, POLL_INTERVAL);
};
@@ -1635,6 +1733,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
// Only process events for the active run (or if no active run set)
if (activeRunId && runId && runId !== activeRunId) return;
if (isDuplicateChatEvent(eventState, event)) return;
_lastChatEventAt = Date.now();
// Defensive: if state is missing but we have a message, try to infer state.

View File

@@ -10,6 +10,12 @@ import type { GatewayStatus } from '../types/gateway';
let gatewayInitPromise: Promise<void> | null = null;
let gatewayEventUnsubscribers: Array<() => void> | null = null;
const gatewayEventDedupe = new Map<string, number>();
const GATEWAY_EVENT_DEDUPE_TTL_MS = 30_000;
const LOAD_SESSIONS_MIN_INTERVAL_MS = 1_200;
const LOAD_HISTORY_MIN_INTERVAL_MS = 800;
let lastLoadSessionsAt = 0;
let lastLoadHistoryAt = 0;
interface GatewayHealth {
ok: boolean;
@@ -32,6 +38,66 @@ interface GatewayState {
clearError: () => void;
}
function pruneGatewayEventDedupe(now: number): void {
for (const [key, ts] of gatewayEventDedupe) {
if (now - ts > GATEWAY_EVENT_DEDUPE_TTL_MS) {
gatewayEventDedupe.delete(key);
}
}
}
function buildGatewayEventDedupeKey(event: Record<string, unknown>): string | null {
const runId = event.runId != null ? String(event.runId) : '';
const sessionKey = event.sessionKey != null ? String(event.sessionKey) : '';
const seq = event.seq != null ? String(event.seq) : '';
const state = event.state != null ? String(event.state) : '';
if (runId || sessionKey || seq || state) {
return [runId, sessionKey, seq, state].join('|');
}
const message = event.message;
if (message && typeof message === 'object') {
const msg = message as Record<string, unknown>;
const messageId = msg.id != null ? String(msg.id) : '';
const stopReason = msg.stopReason ?? msg.stop_reason;
if (messageId || stopReason) {
return `msg|${messageId}|${String(stopReason ?? '')}`;
}
}
return null;
}
function shouldProcessGatewayEvent(event: Record<string, unknown>): boolean {
const key = buildGatewayEventDedupeKey(event);
if (!key) return true;
const now = Date.now();
pruneGatewayEventDedupe(now);
if (gatewayEventDedupe.has(key)) {
return false;
}
gatewayEventDedupe.set(key, now);
return true;
}
function maybeLoadSessions(
state: { loadSessions: () => Promise<void> },
force = false,
): void {
const now = Date.now();
if (!force && now - lastLoadSessionsAt < LOAD_SESSIONS_MIN_INTERVAL_MS) return;
lastLoadSessionsAt = now;
void state.loadSessions();
}
function maybeLoadHistory(
state: { loadHistory: (quiet?: boolean) => Promise<void> },
force = false,
): void {
const now = Date.now();
if (!force && now - lastLoadHistoryAt < LOAD_HISTORY_MIN_INTERVAL_MS) return;
lastLoadHistoryAt = now;
void state.loadHistory(true);
}
function handleGatewayNotification(notification: { method?: string; params?: Record<string, unknown> } | undefined): void {
const payload = notification;
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
@@ -53,11 +119,13 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec
state: p.state ?? data.state,
message: p.message ?? data.message,
};
import('./chat')
.then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent(normalizedEvent);
})
.catch(() => {});
if (shouldProcessGatewayEvent(normalizedEvent)) {
import('./chat')
.then(({ useChatStore }) => {
useChatStore.getState().handleChatEvent(normalizedEvent);
})
.catch(() => {});
}
}
const runId = p.runId ?? data.runId;
@@ -71,7 +139,7 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec
resolvedSessionKey !== state.currentSessionKey
|| !state.sessions.some((session) => session.key === resolvedSessionKey);
if (shouldRefreshSessions) {
void state.loadSessions();
maybeLoadSessions(state, true);
}
state.handleChatEvent({
@@ -93,14 +161,14 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec
|| !state.sessions.some((session) => session.key === resolvedSessionKey)
);
if (shouldRefreshSessions) {
void state.loadSessions();
maybeLoadSessions(state);
}
const matchesCurrentSession = resolvedSessionKey == null || resolvedSessionKey === state.currentSessionKey;
const matchesActiveRun = runId != null && state.activeRunId != null && String(runId) === state.activeRunId;
if (matchesCurrentSession || matchesActiveRun) {
void state.loadHistory(true);
maybeLoadHistory(state);
}
if ((matchesCurrentSession || matchesActiveRun) && state.sending) {
useChatStore.setState({
@@ -123,15 +191,18 @@ function handleGatewayChatMessage(data: unknown): void {
: chatData;
if (payload.state) {
if (!shouldProcessGatewayEvent(payload)) return;
useChatStore.getState().handleChatEvent(payload);
return;
}
useChatStore.getState().handleChatEvent({
const normalized = {
state: 'final',
message: payload,
runId: chatData.runId ?? payload.runId,
});
};
if (!shouldProcessGatewayEvent(normalized)) return;
useChatStore.getState().handleChatEvent(normalized);
}).catch(() => {});
}

View File

@@ -161,7 +161,13 @@ export const useSettingsStore = create<SettingsState>()(
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
setDevModeUnlocked: (devModeUnlocked) => set({ devModeUnlocked }),
setDevModeUnlocked: (devModeUnlocked) => {
set({ devModeUnlocked });
void hostApiFetch('/api/settings/devModeUnlocked', {
method: 'PUT',
body: JSON.stringify({ value: devModeUnlocked }),
}).catch(() => { });
},
markSetupComplete: () => set({ setupComplete: true }),
resetSettings: () => set(defaultSettings),
}),