mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
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:
committed by
GitHub
parent
3fb14cd050
commit
a948419141
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user