diff --git a/ui/src/app/app-host.chat-metadata.test.ts b/ui/src/app/app-host.chat-metadata.test.ts new file mode 100644 index 000000000000..85fb6336d5ea --- /dev/null +++ b/ui/src/app/app-host.chat-metadata.test.ts @@ -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(); +}); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 77cc2b5b88e9..450d7c1dbea4 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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); } diff --git a/ui/src/lib/chat/chat-metadata-store.test.ts b/ui/src/lib/chat/chat-metadata-store.test.ts new file mode 100644 index 000000000000..460b16dfd6ff --- /dev/null +++ b/ui/src/lib/chat/chat-metadata-store.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function clientWith(request: ReturnType): 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(); + 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(); + 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(); + const newer = deferred(); + 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); + }); +}); diff --git a/ui/src/lib/chat/chat-metadata-store.ts b/ui/src/lib/chat/chat-metadata-store.ts new file mode 100644 index 000000000000..64ed0480366f --- /dev/null +++ b/ui/src/lib/chat/chat-metadata-store.ts @@ -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; + revalidationPending?: Promise; + latestRequest?: Promise; +}; + +const chatMetadataCache = new WeakMap>(); + +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 { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, delayMs); + }); +} + +async function requestChatMetadata( + client: GatewayBrowserClient, + agentId: string | null | undefined, + opts?: { startupRetryWindowMs?: number }, +): Promise { + const params = agentId ? { agentId } : {}; + const retryWindowMs = opts?.startupRetryWindowMs; + if (retryWindowMs === undefined) { + return client.request("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("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, +): Promise { + 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 { + 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 { + // 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); +} diff --git a/ui/src/pages/agents/agents-page.test.ts b/ui/src/pages/agents/agents-page.test.ts index 5332f5453b5e..0840c08efdc8 100644 --- a/ui/src/pages/agents/agents-page.test.ts +++ b/ui/src/pages/agents/agents-page.test.ts @@ -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(); diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 5b9dd48b7159..9aa348ebcc76 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -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(); 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; } }) diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index 76ffcf1d648c..ac1072eaea5b 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -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; diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index 61c79b9bee53..4bea6ffa0545 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -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; diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 74a1c583393b..f3f79f9f9cf1 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -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); diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index 4fed9683a1bf..8f1d767b9ba3 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -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 }; - -const chatMetadataCache = new WeakMap>(); - 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 { - 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, ): void { - metadataCacheFor(client).set(chatMetadataAgentKey(agentId), { kind: "result", result }); -} - -function loadChatMetadata( - client: GatewayBrowserClient, - agentId: string | null | undefined, -): Promise { - 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("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, -): void { - if (host.client) { - chatMetadataCache.delete(host.client); - } host.chatMetadataRequestVersion += 1; } diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 28e1df7dcebf..143112e77d72 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -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 () => { diff --git a/ui/src/pages/new-session/model-control.test.ts b/ui/src/pages/new-session/model-control.test.ts index adad463698b5..20b7ca7ccb73 100644 --- a/ui/src/pages/new-session/model-control.test.ts +++ b/ui/src/pages/new-session/model-control.test.ts @@ -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() { 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); - }); }); diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index fe81c14bd556..54780ed02395 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -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; type GatewayAgentRuntime = NonNullable & { 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 { - if (signal.aborted) { - return Promise.reject(abortError(signal)); - } - - return new Promise((resolve, reject) => { - let timer: ReturnType | 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) {