perf(webui): reuse the chat metadata catalog across pages (#124794)

* refactor(webui): centralize chat metadata caching

* test(webui): cover shared chat metadata store

* refactor(webui): move ChatMetadataResult type to its store owner

* fix(webui): let the metadata store own revalidation lifetime

* fix(webui): satisfy no-promise-executor-return in retry delay
This commit is contained in:
Peter Steinberger
2026-08-16 14:03:04 -07:00
committed by GitHub
parent 3fb14cd050
commit a948419141
13 changed files with 606 additions and 390 deletions
+47
View File
@@ -0,0 +1,47 @@
/* @vitest-environment jsdom */
import { afterEach, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { peekChatMetadata, rememberChatMetadata } from "../lib/chat/chat-metadata-store.ts";
import "./app-host.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "./context.ts";
type ChatMetadataShell = HTMLElement & {
runtime: { context: ApplicationContext };
handleGatewayEvent: (event: { event: string; payload: unknown }) => void;
synchronizeGateway: (snapshot: ApplicationGatewaySnapshot) => void;
};
afterEach(() => {
vi.useRealTimers();
});
it("invalidates chat metadata on config changes and same-client reconnects", () => {
vi.useFakeTimers();
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const connected = {
client,
phase: "connected",
sessionKey: "agent:main:main",
} as ApplicationGatewaySnapshot;
const context = {
gateway: { snapshot: connected },
runtimeConfig: {
state: { configFormDirty: false, configSnapshot: null },
ensureLoaded: vi.fn(async () => null),
refresh: vi.fn(async () => null),
},
} as unknown as ApplicationContext;
const shell = document.createElement("openclaw-app-shell") as unknown as ChatMetadataShell;
shell.runtime = { context };
shell.synchronizeGateway(connected);
rememberChatMetadata(client, "main", { commands: [], models: [] });
shell.handleGatewayEvent({ event: "config.changed", payload: {} });
expect(peekChatMetadata(client, "main")).toBeUndefined();
rememberChatMetadata(client, "main", { commands: [], models: [] });
shell.synchronizeGateway({ ...connected, phase: "reconnecting" });
shell.synchronizeGateway(connected);
expect(peekChatMetadata(client, "main")).toBeUndefined();
});
+14
View File
@@ -24,6 +24,7 @@ import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
import { i18n, t } from "../i18n/index.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
import type { BoardFace } from "../lib/board/settings.ts";
import { invalidateChatMetadataStore } from "../lib/chat/chat-metadata-store.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { createIdleImport } from "../lib/idle-import.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
@@ -422,6 +423,12 @@ class OpenClawShell
this.shellNavigation.selectChatSession(sessionKey, agentId);
}
private readonly handleGatewayEvent = (event: GatewayEventFrame) => {
if (event.event === "config.changed") {
const client = this.context?.gateway?.snapshot.client;
if (client) {
invalidateChatMetadataStore(client);
}
}
this.shellGateway.handleGatewayEvent(event);
};
@@ -588,6 +595,13 @@ class OpenClawShell
}
private synchronizeGateway(snapshot: ApplicationContext["gateway"]["snapshot"]) {
if (this.previousGatewayPhase !== "connected" && snapshot.phase === "connected") {
// A reconnect can retain the browser client, so object identity alone
// cannot keep metadata from crossing logical Gateway connections.
if (snapshot.client) {
invalidateChatMetadataStore(snapshot.client);
}
}
this.shellGateway.synchronizeGateway(snapshot);
}
+233
View File
@@ -0,0 +1,233 @@
import {
DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS,
gatewayStartupUnavailableDetails,
} from "@openclaw/gateway-client/browser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import {
invalidateChatMetadataStore,
loadChatMetadata,
peekChatMetadata,
rememberChatMetadata,
revalidateChatMetadata,
type ChatMetadataResult,
} from "./chat-metadata-store.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function clientWith(request: ReturnType<typeof vi.fn>): GatewayBrowserClient {
return { request } as unknown as GatewayBrowserClient;
}
function metadata(modelId: string): ChatMetadataResult {
return {
commands: [],
models: [{ id: modelId, name: modelId, provider: "openai" }],
};
}
function startupUnavailableError(retryAfterMs = 250): GatewayRequestError {
return new GatewayRequestError({
code: "UNAVAILABLE",
message: "gateway startup sidecars are still initializing",
details: gatewayStartupUnavailableDetails(),
retryable: true,
retryAfterMs,
});
}
afterEach(() => {
vi.useRealTimers();
});
describe("chat metadata store", () => {
it("returns a cached result without requesting it again", async () => {
const result = metadata("cached-model");
const request = vi.fn().mockResolvedValue(result);
const client = clientWith(request);
await expect(loadChatMetadata(client, " main ")).resolves.toBe(result);
await expect(loadChatMetadata(client, "main")).resolves.toBe(result);
expect(request).toHaveBeenCalledOnce();
});
it("shares one pending load between concurrent readers", async () => {
const pending = deferred<ChatMetadataResult>();
const request = vi.fn().mockReturnValue(pending.promise);
const client = clientWith(request);
const first = loadChatMetadata(client, "main");
const second = loadChatMetadata(client, "main");
expect(second).toBe(first);
expect(request).toHaveBeenCalledOnce();
pending.resolve(metadata("shared-model"));
await expect(first).resolves.toEqual(metadata("shared-model"));
});
it("clears a failed pending load so a later read can retry", async () => {
const result = metadata("recovered-model");
const request = vi
.fn()
.mockRejectedValueOnce(new Error("metadata unavailable"))
.mockResolvedValueOnce(result);
const client = clientWith(request);
await expect(loadChatMetadata(client, "main")).rejects.toThrow("metadata unavailable");
await expect(loadChatMetadata(client, "main")).resolves.toBe(result);
expect(request).toHaveBeenCalledTimes(2);
});
it("uses remembered startup metadata as the current snapshot", async () => {
const result = metadata("startup-model");
const request = vi.fn();
const client = clientWith(request);
rememberChatMetadata(client, "main", result);
expect(peekChatMetadata(client, "main")).toBe(result);
await expect(loadChatMetadata(client, "main")).resolves.toBe(result);
expect(request).not.toHaveBeenCalled();
});
it("drops every agent snapshot when the client store is invalidated", async () => {
const main = metadata("main-model");
const worker = metadata("worker-model");
const request = vi.fn().mockResolvedValue(main);
const client = clientWith(request);
rememberChatMetadata(client, "main", main);
rememberChatMetadata(client, "worker", worker);
invalidateChatMetadataStore(client);
expect(peekChatMetadata(client, "main")).toBeUndefined();
expect(peekChatMetadata(client, "worker")).toBeUndefined();
await expect(loadChatMetadata(client, "main")).resolves.toBe(main);
expect(request).toHaveBeenCalledOnce();
});
it("keeps the stale snapshot readable while one fresh revalidation replaces it", async () => {
const oldResult = metadata("old-model");
const nextResult = metadata("next-model");
const refresh = deferred<ChatMetadataResult>();
const request = vi.fn().mockReturnValue(refresh.promise);
const client = clientWith(request);
rememberChatMetadata(client, "main", oldResult);
const first = revalidateChatMetadata(client, "main");
const second = revalidateChatMetadata(client, "main");
expect(second).toBe(first);
expect(peekChatMetadata(client, "main")).toBe(oldResult);
expect(request).toHaveBeenCalledOnce();
refresh.resolve(nextResult);
await expect(first).resolves.toBe(nextResult);
expect(peekChatMetadata(client, "main")).toBe(nextResult);
});
it("does not let an older plain load clobber a newer revalidation", async () => {
const older = deferred<ChatMetadataResult>();
const newer = deferred<ChatMetadataResult>();
const request = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
const client = clientWith(request);
const olderLoad = loadChatMetadata(client, "main");
const newerLoad = revalidateChatMetadata(client, "main");
newer.resolve(metadata("new-model"));
await newerLoad;
older.resolve(metadata("old-model"));
await olderLoad;
expect(peekChatMetadata(client, "main")).toEqual(metadata("new-model"));
});
it("retries canonical startup unavailability and caches the recovered catalog", async () => {
vi.useFakeTimers();
const result = metadata("recovered-model");
const request = vi
.fn()
.mockRejectedValueOnce(startupUnavailableError(250))
.mockResolvedValueOnce(result);
const client = clientWith(request);
const refresh = revalidateChatMetadata(client, "main", {
startupRetryWindowMs: 60_000,
});
await vi.advanceTimersByTimeAsync(249);
expect(request).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
await expect(refresh).resolves.toBe(result);
expect(request).toHaveBeenCalledTimes(2);
expect(peekChatMetadata(client, "main")).toBe(result);
});
it("does not retry unrelated retryable unavailable errors", async () => {
vi.useFakeTimers();
const request = vi.fn().mockRejectedValue(
new GatewayRequestError({
code: "UNAVAILABLE",
message: "database temporarily unavailable",
details: { reason: "database-busy" },
retryable: true,
retryAfterMs: 250,
}),
);
const client = clientWith(request);
const refresh = revalidateChatMetadata(client, "main", {
startupRetryWindowMs: 60_000,
});
const rejection = expect(refresh).rejects.toThrow("database temporarily unavailable");
await vi.advanceTimersByTimeAsync(2_000);
await rejection;
expect(request).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it("stops startup retries at the configured deadline", async () => {
vi.useFakeTimers();
const startedAt = Date.UTC(2026, 7, 2);
vi.setSystemTime(startedAt);
const attemptTimes: number[] = [];
const request = vi.fn().mockImplementation(() => {
attemptTimes.push(Date.now());
return Promise.reject(startupUnavailableError(2_000));
});
const client = clientWith(request);
const refresh = revalidateChatMetadata(client, "main", {
startupRetryWindowMs: 60_000,
});
const rejection = expect(refresh).rejects.toThrow("gateway startup sidecars");
await vi.advanceTimersByTimeAsync(60_000);
await rejection;
expect(attemptTimes).toHaveLength(30);
expect(attemptTimes[0]).toBe(startedAt);
expect(attemptTimes.at(-1)).toBe(startedAt + 58_000);
expect(request).toHaveBeenNthCalledWith(
1,
"chat.metadata",
{ agentId: "main" },
{ timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS },
);
expect(request).toHaveBeenLastCalledWith(
"chat.metadata",
{ agentId: "main" },
{ timeoutMs: 2_000 },
);
expect(vi.getTimerCount()).toBe(0);
});
});
+176
View File
@@ -0,0 +1,176 @@
import {
DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS,
resolveGatewayStartupRetryAfterMs,
} from "@openclaw/gateway-client/browser";
import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ModelCatalogEntry } from "../../api/types.ts";
export type ChatMetadataResult = CommandsListResult & {
models?: ModelCatalogEntry[];
};
type ChatMetadataEntry = {
result?: ChatMetadataResult;
loadPending?: Promise<ChatMetadataResult>;
revalidationPending?: Promise<ChatMetadataResult>;
latestRequest?: Promise<ChatMetadataResult>;
};
const chatMetadataCache = new WeakMap<GatewayBrowserClient, Map<string, ChatMetadataEntry>>();
function chatMetadataAgentKey(agentId: string | null | undefined): string {
return agentId?.trim() ?? "";
}
function metadataEntryFor(
client: GatewayBrowserClient,
agentId: string | null | undefined,
): ChatMetadataEntry {
const key = chatMetadataAgentKey(agentId);
let cache = chatMetadataCache.get(client);
if (!cache) {
cache = new Map();
chatMetadataCache.set(client, cache);
}
let entry = cache.get(key);
if (!entry) {
entry = {};
cache.set(key, entry);
}
return entry;
}
function waitForMetadataRetry(delayMs: number): Promise<void> {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, delayMs);
});
}
async function requestChatMetadata(
client: GatewayBrowserClient,
agentId: string | null | undefined,
opts?: { startupRetryWindowMs?: number },
): Promise<ChatMetadataResult> {
const params = agentId ? { agentId } : {};
const retryWindowMs = opts?.startupRetryWindowMs;
if (retryWindowMs === undefined) {
return client.request<ChatMetadataResult>("chat.metadata", params);
}
const deadlineAt = Date.now() + retryWindowMs;
let latestStartupError: Error | undefined;
while (true) {
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) {
throw latestStartupError ?? new Error("New-session metadata retry deadline elapsed");
}
try {
return await client.request<ChatMetadataResult>("chat.metadata", params, {
timeoutMs: Math.min(DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, remainingMs),
});
} catch (error) {
const requestError =
error instanceof Error
? error
: new Error("New-session metadata request failed", { cause: error });
const retryAfterMs = resolveGatewayStartupRetryAfterMs(requestError);
if (retryAfterMs === null) {
throw requestError;
}
const retryRemainingMs = deadlineAt - Date.now();
if (retryRemainingMs <= 0) {
throw requestError;
}
latestStartupError = requestError;
await waitForMetadataRetry(Math.min(retryAfterMs, retryRemainingMs));
}
}
}
function beginChatMetadataRequest(
entry: ChatMetadataEntry,
pendingKey: "loadPending" | "revalidationPending",
request: Promise<ChatMetadataResult>,
): Promise<ChatMetadataResult> {
const pending = request
.then((result) => {
// The newest request owns the snapshot even when an older load settles later.
if (entry.latestRequest === pending) {
entry.result = result;
}
return result;
})
.finally(() => {
if (entry[pendingKey] === pending) {
entry[pendingKey] = undefined;
}
});
entry[pendingKey] = pending;
entry.latestRequest = pending;
return pending;
}
export function peekChatMetadata(
client: GatewayBrowserClient,
agentId: string | null | undefined,
): ChatMetadataResult | undefined {
return chatMetadataCache.get(client)?.get(chatMetadataAgentKey(agentId))?.result;
}
export function loadChatMetadata(
client: GatewayBrowserClient,
agentId: string | null | undefined,
): Promise<ChatMetadataResult> {
const entry = metadataEntryFor(client, agentId);
if (entry.result) {
return Promise.resolve(entry.result);
}
if (entry.loadPending) {
return entry.loadPending;
}
if (entry.revalidationPending) {
return entry.revalidationPending;
}
return beginChatMetadataRequest(entry, "loadPending", requestChatMetadata(client, agentId));
}
export function revalidateChatMetadata(
client: GatewayBrowserClient,
agentId: string | null | undefined,
opts?: { startupRetryWindowMs?: number },
): Promise<ChatMetadataResult> {
// Shared revalidation outlives any one caller: consumers drop interest through
// ownership checks, while completion warms the cache for the next mount.
const entry = metadataEntryFor(client, agentId);
if (entry.revalidationPending) {
return entry.revalidationPending;
}
return beginChatMetadataRequest(
entry,
"revalidationPending",
requestChatMetadata(client, agentId, opts),
);
}
export function rememberChatMetadata(
client: GatewayBrowserClient,
agentId: string | null | undefined,
result: ChatMetadataResult,
): void {
const entry = metadataEntryFor(client, agentId);
entry.result = result;
entry.loadPending = undefined;
entry.revalidationPending = undefined;
entry.latestRequest = undefined;
}
export function invalidateChatMetadataStore(client: GatewayBrowserClient): void {
chatMetadataCache.delete(client);
}
+3
View File
@@ -12,6 +12,7 @@ import type {
} from "../../api/types.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { AgentsPanel } from "../../lib/agents/panels.ts";
import { invalidateChatMetadataStore } from "../../lib/chat/chat-metadata-store.ts";
import { loadCronJobsPage, type CronState } from "../../lib/cron/index.ts";
import type { AgentsRouteData } from "./route.ts";
import "./agents-page.ts";
@@ -461,6 +462,7 @@ describe("AgentsPage gateway lifecycle", () => {
page.loadActivePanelData();
page.gateway.invalidate();
invalidateChatMetadataStore(page.client as GatewayBrowserClient);
page.loadActivePanelData();
await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(nextModels));
@@ -491,6 +493,7 @@ describe("AgentsPage gateway lifecycle", () => {
setPageGateway(page, client, false);
expect(page.chatModelCatalog).toEqual([]);
invalidateChatMetadataStore(client);
setPageGateway(page, client);
page.loadActivePanelData();
+12 -13
View File
@@ -29,6 +29,11 @@ import {
type AgentsState,
} from "../../lib/agents/index.ts";
import { DEFAULT_AGENT_PANEL, type AgentsPanel } from "../../lib/agents/panels.ts";
import {
loadChatMetadata,
peekChatMetadata,
revalidateChatMetadata,
} from "../../lib/chat/chat-metadata-store.ts";
import { currentConfigObject } from "../../lib/config/config-state-model.ts";
import {
createInitialCronState,
@@ -120,9 +125,7 @@ class AgentsPage
private agentIdentitySource: ApplicationContext["agentIdentity"] | null = null;
private hasBoundSessions = false;
private sessionsSource: ApplicationContext["sessions"] | null = null;
private chatModelCatalogClient: GatewayBrowserClient | null = null;
private chatModelCatalogAgentId: string | null = null;
private readonly chatModelCatalogByAgentId = new Map<string, ModelCatalogEntry[]>();
private chatModelCatalogRequest: {
client: GatewayBrowserClient;
generation: number;
@@ -138,9 +141,7 @@ class AgentsPage
}
this.invalidateTransientRequests();
this.chatModelCatalog = [];
this.chatModelCatalogClient = null;
this.chatModelCatalogAgentId = null;
this.chatModelCatalogByAgentId.clear();
this.chatModelCatalogError = null;
},
onSnapshot: () => this.syncGatewayState(),
@@ -344,9 +345,7 @@ class AgentsPage
this.agentsList = null;
this.agentsSelectedId = null;
this.chatModelCatalog = [];
this.chatModelCatalogClient = null;
this.chatModelCatalogAgentId = null;
this.chatModelCatalogByAgentId.clear();
this.chatModelCatalogError = null;
this.resetSelectionState();
}
@@ -550,10 +549,10 @@ class AgentsPage
if (!client || !this.connected || !agentId) {
return;
}
if (!options.refresh && this.chatModelCatalogClient === client) {
const cached = this.chatModelCatalogByAgentId.get(agentId);
if (!options.refresh) {
const cached = peekChatMetadata(client, agentId);
if (cached) {
this.chatModelCatalog = cached;
this.chatModelCatalog = cached.models ?? [];
this.chatModelCatalogAgentId = agentId;
this.chatModelCatalogError = null;
return;
@@ -576,15 +575,15 @@ class AgentsPage
this.chatModelCatalogError = null;
// Chat metadata carries the selected agent's already-prepared startup models
// without initiating the live discovery reserved for explicit picker use.
void client
.request<{ models?: ModelCatalogEntry[] }>("chat.metadata", { agentId })
const metadataRequest = options.refresh
? revalidateChatMetadata(client, agentId)
: loadChatMetadata(client, agentId);
void metadataRequest
.then((result) => {
if (this.isCurrentRequest(client, generation, agentId)) {
const models = result.models ?? [];
this.chatModelCatalog = models;
this.chatModelCatalogClient = client;
this.chatModelCatalogAgentId = agentId;
this.chatModelCatalogByAgentId.set(agentId, models);
this.chatModelCatalogError = null;
}
})
+1 -6
View File
@@ -3,16 +3,15 @@ import {
readSessionMessageSequence,
} from "@openclaw/gateway-client/browser";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
AgentsListResult,
GatewaySessionRow,
GatewaySessionsDefaults,
ModelCatalogEntry,
SessionBranch,
SessionsListResult,
} from "../../api/types.ts";
import type { ChatMetadataResult } from "../../lib/chat/chat-metadata-store.ts";
import {
isAssistantHeartbeatAckForDisplay,
stripHeartbeatTokenForDisplay,
@@ -573,10 +572,6 @@ function reconcileLoadedHistoryTail(options: {
};
}
export type ChatMetadataResult = CommandsListResult & {
models?: ModelCatalogEntry[];
};
export type ChatEventPayload = {
runId?: string;
sessionKey: string;
+2 -2
View File
@@ -33,9 +33,9 @@ import { stopChatRealtimeTalk } from "./chat-realtime.ts";
import { retryReconnectableQueuedChatSends } from "./chat-send-actions.ts";
import { retireChatModelSelectionOwnership } from "./chat-session.ts";
import {
invalidateChatMetadataCache,
refreshChatModelAuthStatus,
refreshPageChat,
retireChatMetadataRequests,
} from "./chat-state-refresh.ts";
import { resolveChatAgentId, selectedChatSessionRow } from "./chat-state-route.ts";
import { releaseChatMediaResourceSubscriber } from "./components/chat-message-media.ts";
@@ -234,7 +234,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
invalidateChatAvatarCache(state);
invalidateAssistantIdentityCache(state.client);
state.assistantIdentityRequestVersion += 1;
invalidateChatMetadataCache(state);
retireChatMetadataRequests(state);
this.swarmHydrator?.dispose();
this.swarmHydrator = null;
this.taskSuggestionsRequestVersion += 1;
+2 -2
View File
@@ -54,7 +54,7 @@ import { setChatError } from "./chat-send-queue-state.ts";
import { applySelectedChatAgent } from "./chat-session.ts";
import { handlePageGatewayEvent } from "./chat-state-events.ts";
import { createPageState } from "./chat-state-page.ts";
import { invalidateChatMetadataCache, refreshPageChat } from "./chat-state-refresh.ts";
import { refreshPageChat, retireChatMetadataRequests } from "./chat-state-refresh.ts";
import { resetChatViewState } from "./chat-view-state.ts";
import { detailSlotOpen } from "./components/chat-detail-slot.ts";
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
@@ -548,7 +548,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneSessionCreation {
invalidateChatAvatarCache(state);
invalidateAssistantIdentityCache(state.client);
state.assistantIdentityRequestVersion += 1;
invalidateChatMetadataCache(state);
retireChatMetadataRequests(state);
void refreshChatAvatar(state).finally(() => state.requestUpdate?.());
}
handleQuestionPromptEvent(this.questionPromptState, event);
+8 -68
View File
@@ -1,5 +1,10 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import {
loadChatMetadata,
rememberChatMetadata,
type ChatMetadataResult,
} from "../../lib/chat/chat-metadata-store.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { loadModelAuthStatus } from "../../lib/model-auth.ts";
@@ -7,7 +12,7 @@ import { isSessionRunActive } from "../../lib/session-run-state.ts";
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
import { refreshChatAvatar, resolveAgentIdForSession } from "./chat-avatar.ts";
import { applyRemoteSlashCommandsResult, refreshSlashCommands } from "./chat-commands.ts";
import { loadChatHistory, type ChatMetadataResult } from "./chat-history.ts";
import { loadChatHistory } from "./chat-history.ts";
import { flushChatQueueForEvent } from "./chat-send-actions.ts";
import { flushChatQueueAfterIdleSessionReconciliation } from "./chat-session.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
@@ -50,79 +55,14 @@ type ChatMetadataRefreshOptions = {
requestVersion?: number;
};
type ChatMetadataCacheEntry =
| { kind: "result"; result: ChatMetadataResult }
| { kind: "pending"; pending: Promise<ChatMetadataResult> };
const chatMetadataCache = new WeakMap<GatewayBrowserClient, Map<string, ChatMetadataCacheEntry>>();
const EMPTY_CHAT_METADATA_APPLY_RESULT: ChatMetadataApplyResult = {
commands: false,
models: false,
};
function chatMetadataAgentKey(agentId: string | null | undefined): string {
return agentId?.trim() ?? "";
}
function metadataCacheFor(client: GatewayBrowserClient): Map<string, ChatMetadataCacheEntry> {
let cache = chatMetadataCache.get(client);
if (!cache) {
cache = new Map();
chatMetadataCache.set(client, cache);
}
return cache;
}
function rememberChatMetadata(
client: GatewayBrowserClient,
agentId: string | null | undefined,
result: ChatMetadataResult,
export function retireChatMetadataRequests(
host: Pick<ChatPageHost, "chatMetadataRequestVersion">,
): void {
metadataCacheFor(client).set(chatMetadataAgentKey(agentId), { kind: "result", result });
}
function loadChatMetadata(
client: GatewayBrowserClient,
agentId: string | null | undefined,
): Promise<ChatMetadataResult> {
const cache = metadataCacheFor(client);
const key = chatMetadataAgentKey(agentId);
const cached = cache.get(key);
if (cached?.kind === "result") {
return Promise.resolve(cached.result);
}
if (cached?.kind === "pending") {
return cached.pending;
}
const pending = client
.request<ChatMetadataResult>("chat.metadata", agentId ? { agentId } : {})
.then(
(result) => {
const current = cache.get(key);
if (current?.kind === "pending" && current.pending === pending) {
cache.set(key, { kind: "result", result });
}
return result;
},
(error: unknown) => {
const current = cache.get(key);
if (current?.kind === "pending" && current.pending === pending) {
cache.delete(key);
}
throw error;
},
);
cache.set(key, { kind: "pending", pending });
return pending;
}
export function invalidateChatMetadataCache(
host: Pick<ChatPageHost, "chatMetadataRequestVersion" | "client">,
): void {
if (host.client) {
chatMetadataCache.delete(host.client);
}
host.chatMetadataRequestVersion += 1;
}
+14 -35
View File
@@ -16,9 +16,9 @@ import { handlePageGatewayEvent } from "./chat-state-events.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import { createPageState } from "./chat-state-page.ts";
import {
invalidateChatMetadataCache,
refreshChatMetadata,
refreshChatModelAuthStatus,
retireChatMetadataRequests,
} from "./chat-state-refresh.ts";
import { resolveChatAvatarUrl, selectedChatSessionRow } from "./chat-state-route.ts";
import { scheduleControlUiAfterPaint } from "./performance.ts";
@@ -1670,51 +1670,30 @@ describe("refreshChatMetadata", () => {
]);
});
it("does not let an older same-agent response overwrite the newest catalog", async () => {
let resolveFirst: (value: {
it("does not publish metadata after the pane retires its request owner", async () => {
let resolveMetadata: (value: {
commands: never[];
models: Array<{ id: string; name: string; provider: string }>;
}) => void = () => {};
let resolveSecond: (value: {
commands: never[];
models: Array<{ id: string; name: string; provider: string }>;
}) => void = () => {};
const firstMetadata = new Promise<{
const pending = new Promise<{
commands: never[];
models: Array<{ id: string; name: string; provider: string }>;
}>((resolve) => {
resolveFirst = resolve;
resolveMetadata = resolve;
});
const secondMetadata = new Promise<{
commands: never[];
models: Array<{ id: string; name: string; provider: string }>;
}>((resolve) => {
resolveSecond = resolve;
});
let requestCount = 0;
const request = vi.fn(async () => {
requestCount += 1;
return await (requestCount === 1 ? firstMetadata : secondMetadata);
});
const state = createMetadataState(request);
const request = vi.fn().mockReturnValue(pending);
const existingCatalog = [{ id: "existing-model", name: "Existing Model", provider: "openai" }];
const state = createMetadataState(request, { chatModelCatalog: existingCatalog });
const firstRefresh = refreshChatMetadata(state);
invalidateChatMetadataCache(state);
const secondRefresh = refreshChatMetadata(state);
resolveSecond({
const refresh = refreshChatMetadata(state);
retireChatMetadataRequests(state);
resolveMetadata({
commands: [],
models: [{ id: "new-model", name: "New Model", provider: "openai" }],
models: [{ id: "late-model", name: "Late Model", provider: "openai" }],
});
await secondRefresh;
resolveFirst({
commands: [],
models: [{ id: "old-model", name: "Old Model", provider: "openai" }],
});
await firstRefresh;
await refresh;
expect(state.chatModelCatalog).toEqual([
{ id: "new-model", name: "New Model", provider: "openai" },
]);
expect(state.chatModelCatalog).toBe(existingCatalog);
});
it("loads compatibility models when the gateway does not advertise chat metadata", async () => {
+68 -151
View File
@@ -1,10 +1,5 @@
import {
DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS,
gatewayStartupUnavailableDetails,
} from "@openclaw/gateway-client/browser";
import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GatewayRequestError } from "../../api/gateway.ts";
import type { GatewayAgentRow, ModelCatalogEntry } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { NewSessionModelControl } from "./model-control.ts";
@@ -46,16 +41,6 @@ function contextWith(
return { context, navigate, request };
}
function startupUnavailableError(retryAfterMs = 250): GatewayRequestError {
return new GatewayRequestError({
code: "UNAVAILABLE",
message: "gateway startup sidecars are still initializing",
details: gatewayStartupUnavailableDetails(),
retryable: true,
retryAfterMs,
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
@@ -159,6 +144,9 @@ describe("new-session model runtime", () => {
const container = renderControl(control, context);
expect(container.querySelector('[data-chat-model-target-group="cliAgents"]')).not.toBeNull();
expect(container.querySelector('[data-chat-model-target="anthropic"]')).not.toBeNull();
expect(
container.querySelector('[data-chat-model-select="true"]')?.getAttribute("aria-disabled"),
).toBe("false");
expect(container.textContent).not.toContain("History only");
});
@@ -234,6 +222,70 @@ describe("new-session model runtime", () => {
pending.resolve({ models: [] });
});
it("renders a cached catalog immediately while a remounted control revalidates", async () => {
const models: ModelCatalogEntry[] = [
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai" },
];
const refresh = deferred<{ models: ModelCatalogEntry[] }>();
const { context, request } = contextWith(models);
const firstControl = new NewSessionModelControl(() => undefined);
firstControl.load(context, "main", true);
await vi.waitFor(() =>
expect(
renderControl(firstControl, context).querySelector(
'[data-chat-model-option="openai/gpt-5.6-luna"]',
),
).not.toBeNull(),
);
request.mockReturnValueOnce(refresh.promise);
const remountedControl = new NewSessionModelControl(() => undefined);
remountedControl.load(context, "main", true);
const container = renderControl(remountedControl, context);
expect(container.querySelector('[data-chat-model-catalog-state="refreshing"]')).not.toBeNull();
expect(container.querySelector('[data-chat-model-select="true"]')?.textContent).not.toContain(
"Loading models",
);
expect(
container.querySelector('[data-chat-model-option="openai/gpt-5.6-luna"]'),
).not.toBeNull();
refresh.resolve({ models });
});
it("keeps a shared metadata request alive when its first control is torn down", async () => {
const models: ModelCatalogEntry[] = [
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai" },
];
const pending = deferred<{ models: ModelCatalogEntry[] }>();
const { context, request } = contextWith([]);
request.mockImplementationOnce((_method, _params, options?: { signal?: AbortSignal }) => {
options?.signal?.addEventListener(
"abort",
() => pending.reject(new DOMException("metadata request aborted", "AbortError")),
{ once: true },
);
return pending.promise;
});
const firstControl = new NewSessionModelControl(() => undefined);
firstControl.load(context, "main", true);
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
firstControl.reset();
const remountedControl = new NewSessionModelControl(() => undefined);
remountedControl.load(context, "main", true);
pending.resolve({ models });
await vi.waitFor(() => {
const container = renderControl(remountedControl, context);
expect(container.querySelector("[data-chat-model-catalog-state]")).toBeNull();
expect(
container.querySelector('[data-chat-model-option="openai/gpt-5.6-luna"]'),
).not.toBeNull();
});
expect(request).toHaveBeenCalledOnce();
});
it("waits for selected-agent defaults after chat metadata resolves", async () => {
const { context, request } = contextWith([
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai", reasoning: true },
@@ -763,15 +815,7 @@ describe("new-session model runtime", () => {
]);
const control = new NewSessionModelControl(() => undefined);
control.load(context, "main", true);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith(
"chat.metadata",
{ agentId: "main" },
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
),
);
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
await vi.waitFor(() => {
control.selected = "openai/gpt-5.6-luna";
expect(control.resolveAgentRuntime({ context })).toEqual({
@@ -831,131 +875,4 @@ describe("new-session model runtime", () => {
await vi.waitFor(() => expect(control.resolveAgentRuntime({ context })).toBeUndefined());
});
it("retries canonical startup-sidecars unavailability and restores the catalog", async () => {
vi.useFakeTimers();
const models: ModelCatalogEntry[] = [
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
reasoning: true,
},
{
id: "gpt-5.6-terra",
name: "GPT-5.6 Terra",
provider: "openai",
reasoning: true,
},
];
const { context, request } = contextWith(models);
request.mockReset();
request.mockRejectedValueOnce(startupUnavailableError(250)).mockResolvedValueOnce({ models });
const control = new NewSessionModelControl(() => undefined);
control.load(context, "main", true, {
preference: { model: "openai/gpt-5.6-terra", thinkingLevel: "high" },
});
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(249);
expect(request).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
expect(request).toHaveBeenCalledTimes(2);
expect(control.selected).toBe("openai/gpt-5.6-terra");
expect(control.thinkingLevel).toBe("high");
expect(control.isRestoringPreference()).toBe(false);
});
it("does not retry other retryable UNAVAILABLE errors", async () => {
vi.useFakeTimers();
const { context, request } = contextWith([]);
request.mockReset();
request.mockRejectedValue(
new GatewayRequestError({
code: "UNAVAILABLE",
message: "database temporarily unavailable",
details: { reason: "database-busy" },
retryable: true,
retryAfterMs: 250,
}),
);
const control = new NewSessionModelControl(() => undefined);
control.load(context, "main", true);
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(2_000);
expect(request).toHaveBeenCalledOnce();
});
it("aborts a pending startup retry when the catalog task is invalidated", async () => {
vi.useFakeTimers();
const { context, request } = contextWith([]);
request.mockReset();
request.mockRejectedValue(startupUnavailableError(2_000));
const control = new NewSessionModelControl(() => undefined);
control.load(context, "main", true);
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(1);
control.invalidate();
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(2_000);
expect(request).toHaveBeenCalledOnce();
});
it("stops startup-sidecars retries at the 60 second deadline", async () => {
vi.useFakeTimers();
const startedAt = Date.UTC(2026, 7, 2);
vi.setSystemTime(startedAt);
const { context, request } = contextWith([]);
const attemptTimes: number[] = [];
request.mockReset();
request.mockImplementation(() => {
attemptTimes.push(Date.now());
return Promise.reject(startupUnavailableError(2_000));
});
const control = new NewSessionModelControl(() => undefined);
control.load(context, "main", true);
await vi.advanceTimersByTimeAsync(60_000);
expect(attemptTimes).toHaveLength(30);
expect(attemptTimes[0]).toBe(startedAt);
expect(attemptTimes.at(-1)).toBe(startedAt + 58_000);
expect(request).toHaveBeenNthCalledWith(
1,
"chat.metadata",
{ agentId: "main" },
{
signal: expect.any(AbortSignal),
timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS,
},
);
expect(request).toHaveBeenLastCalledWith(
"chat.metadata",
{ agentId: "main" },
{
signal: expect.any(AbortSignal),
timeoutMs: 2_000,
},
);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(10_000);
expect(request).toHaveBeenCalledTimes(30);
});
});
+26 -113
View File
@@ -1,7 +1,3 @@
import {
DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS,
resolveGatewayStartupRetryAfterMs,
} from "@openclaw/gateway-client/browser";
import type {
SessionCatalog,
SessionsCatalogListResult,
@@ -9,6 +5,7 @@ import type {
import type { GatewayAgentRow, ModelCatalogEntry } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import { peekChatMetadata, revalidateChatMetadata } from "../../lib/chat/chat-metadata-store.ts";
import {
buildQualifiedChatModelValue,
normalizeChatModelProviderId,
@@ -24,8 +21,6 @@ import {
} from "../chat/components/chat-model-controls.ts";
import type { NewSessionPreference } from "./preferences.ts";
const NEW_SESSION_METADATA_RETRY_WINDOW_MS = 60_000;
type NewSessionMetadataClient = NonNullable<ApplicationContext["gateway"]["snapshot"]["client"]>;
type GatewayAgentRuntime = NonNullable<GatewayAgentRow["agentRuntime"]> & {
cloudPlacementSupported?: boolean;
@@ -54,90 +49,6 @@ type ReconciledNewSessionSelection = {
repaired: boolean;
};
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new DOMException("New-session metadata retry aborted", "AbortError");
}
function waitForMetadataRetry(delayMs: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return Promise.reject(abortError(signal));
}
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof globalThis.setTimeout> | null = null;
const cleanup = () => {
if (timer !== null) {
globalThis.clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
};
const onAbort = () => {
cleanup();
reject(abortError(signal));
};
signal.addEventListener("abort", onAbort, { once: true });
timer = globalThis.setTimeout(() => {
cleanup();
resolve();
}, delayMs);
});
}
async function requestNewSessionMetadata(
client: NewSessionMetadataClient,
agentId: string,
signal: AbortSignal,
): Promise<{ models?: ModelCatalogEntry[] }> {
const deadlineAt = Date.now() + NEW_SESSION_METADATA_RETRY_WINDOW_MS;
let latestStartupError: Error | undefined;
while (true) {
if (signal.aborted) {
throw abortError(signal);
}
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) {
if (latestStartupError !== undefined) {
throw latestStartupError;
}
throw new Error("New-session metadata retry deadline elapsed");
}
try {
return await client.request<{ models?: ModelCatalogEntry[] }>(
"chat.metadata",
{ agentId },
{
signal,
timeoutMs: Math.min(DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, remainingMs),
},
);
} catch (error) {
const requestError =
error instanceof Error
? error
: new Error("New-session metadata request failed", { cause: error });
const retryAfterMs = resolveGatewayStartupRetryAfterMs(requestError);
if (retryAfterMs === null) {
throw requestError;
}
const retryRemainingMs = deadlineAt - Date.now();
if (retryRemainingMs <= 0) {
throw requestError;
}
latestStartupError = requestError;
await waitForMetadataRetry(Math.min(retryAfterMs, retryRemainingMs), signal);
}
}
}
type DraftModelTarget = {
entry?: ModelCatalogEntry;
model: string;
@@ -191,7 +102,6 @@ export class NewSessionModelControl {
| {
agentId: string;
client: NewSessionMetadataClient;
controller: AbortController;
id: number;
}
| undefined;
@@ -231,13 +141,11 @@ export class NewSessionModelControl {
}
private cancelMetadataRequest() {
const active = this.activeMetadataRequest;
if (!active) {
if (!this.activeMetadataRequest) {
return;
}
this.activeMetadataRequest = undefined;
this.metadataRequestId += 1;
active.controller.abort();
}
private clearCatalogTargets() {
@@ -320,39 +228,44 @@ export class NewSessionModelControl {
this.notify();
}
private publishMetadataCatalog(catalog: ModelCatalogEntry[], status: NewSessionMetadataStatus) {
this.metadataState = { catalog, hasSnapshot: true, status };
if (this.pendingSelectionGeneration === this.selectionGeneration) {
this.restorePreference(this.pendingPreference, this.pendingAgent, this.pendingContext);
}
this.restoringPreference = false;
this.notify();
}
private startMetadataRequest(client: NewSessionMetadataClient, agentId: string) {
this.cancelMetadataRequest();
const controller = new AbortController();
const requestId = ++this.metadataRequestId;
this.activeMetadataRequest = {
agentId,
client,
controller,
id: requestId,
};
this.updateMetadataState({
...this.metadataState,
status: this.metadataState.hasSnapshot ? "refreshing" : "loading",
});
const cached = peekChatMetadata(client, agentId);
if (Array.isArray(cached?.models)) {
this.publishMetadataCatalog(cached.models, "refreshing");
} else {
this.updateMetadataState({
...this.metadataState,
status: this.metadataState.hasSnapshot ? "refreshing" : "loading",
});
}
void requestNewSessionMetadata(client, agentId, controller.signal).then(
void revalidateChatMetadata(client, agentId, {
startupRetryWindowMs: 60_000,
}).then(
(result) => {
// Aborted transports may still resolve. Only the request that still
// owns the control may publish catalog data or restore preferences.
// Only the request that still owns the control may publish catalog data
// or restore preferences.
if (this.activeMetadataRequest?.id !== requestId) {
return;
}
this.activeMetadataRequest = undefined;
this.metadataState = {
catalog: Array.isArray(result.models) ? result.models : [],
hasSnapshot: true,
status: "ready",
};
if (this.pendingSelectionGeneration === this.selectionGeneration) {
this.restorePreference(this.pendingPreference, this.pendingAgent, this.pendingContext);
}
this.restoringPreference = false;
this.notify();
this.publishMetadataCatalog(Array.isArray(result.models) ? result.models : [], "ready");
},
() => {
if (this.activeMetadataRequest?.id !== requestId) {