Optimize gateway comms reload behavior and strengthen regression coverage (#496)
This commit is contained in:
committed by
GitHub
Unverified
parent
08960d700f
commit
1dbe4a8466
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user