perf(webui): render cached model catalog while the chat pane refreshes (#124868)

* perf(webui): render cached model catalog while the chat pane refreshes

* test(webui): fix seed-test types and shrink assertion baseline
This commit is contained in:
Peter Steinberger
2026-08-16 15:52:26 -07:00
committed by GitHub
parent 7344ce4341
commit 800a0bb52a
6 changed files with 92 additions and 89 deletions
+1 -2
View File
@@ -1850,7 +1850,7 @@ src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts 5
src/agents/embedded-agent-runner/run/abortable.ts 2
src/agents/embedded-agent-runner/run/attempt-async-tasks.ts 1
src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts 2
src/agents/embedded-agent-runner/run/attempt-client-tools.ts 1
src/agents/embedded-agent-runner/run/attempt-client-tools.ts 2
src/agents/embedded-agent-runner/run/attempt-context-summary.ts 2
src/agents/embedded-agent-runner/run/attempt-execution-phase.ts 1
src/agents/embedded-agent-runner/run/attempt-finalize.ts 3
@@ -4247,7 +4247,6 @@ ui/src/pages/chat/connect-error.ts 1
ui/src/pages/chat/critical-observer-notice.ts 1
ui/src/pages/chat/export.ts 1
ui/src/pages/chat/input-history.ts 3
ui/src/pages/chat/models.ts 1
ui/src/pages/chat/performance.ts 1
ui/src/pages/chat/realtime-talk-gateway-relay.ts 1
ui/src/pages/chat/realtime-talk-google-live.ts 2
+64
View File
@@ -2,11 +2,13 @@
import { reduceSessionProjection } from "@openclaw/gateway-client/browser";
import { expectDefined } from "@openclaw/normalization-core";
import { render } from "lit";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../../test/helpers/promise.js";
import { GatewayRequestError } from "../../api/gateway.ts";
import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import { rememberChatMetadata } from "../../lib/chat/chat-metadata-store.ts";
import {
buildFallbackSlashCommands,
buildSlashCommandsFromEntries,
@@ -30,6 +32,7 @@ import { refreshChatAvatar } from "./chat-avatar.ts";
import * as chatCommandExecutor from "./chat-command-executor.ts";
import type { executeSlashCommand } from "./chat-command-executor.ts";
import { makeChatHost, makeRequestMock } from "./chat-host.test-support.ts";
import { renderChatPaneComposerControls } from "./chat-pane-session-controls.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import {
getPendingChatPickerPatch,
@@ -423,6 +426,67 @@ describe("refreshChat", () => {
expect(host.request).not.toHaveBeenCalledWith("commands.list", expect.anything());
});
it("renders cached models while startup metadata refreshes", async () => {
const startup = createDeferred<unknown>();
const host = makeChatHost({
chatModelSwitchPromises: {},
hello: {
features: { methods: ["chat.metadata", "chat.startup"] },
} as TestChatHost["hello"],
requestHandlers: {
"chat.startup": () => startup.promise,
},
});
const cachedModel = {
available: true,
id: "cached-model",
name: "Cached Model",
provider: "openai",
};
rememberChatMetadata(expectDefined(host.client, "chat host client"), "main", {
commands: [],
models: [cachedModel],
});
const refresh = refreshPageChat(asChatPageHost(host), {
awaitHistory: true,
deferBranches: true,
startup: true,
});
expect(host.chatModelCatalog).toEqual([cachedModel]);
expect(asChatPageHost(host).chatModelsLoading).toBe(true);
const container = document.createElement("div");
render(
renderChatPaneComposerControls({
state: asChatPageHost(host),
selectedSession: undefined,
agentDefaultModel: undefined,
modelAccess: { allowed: true, requiredScope: "operator.write" },
effortAccess: { allowed: true, requiredScope: "operator.write" },
onModelSetup: vi.fn(),
}),
container,
);
expect(container.querySelector('[data-chat-model-catalog-state="refreshing"]')).not.toBeNull();
expect(container.textContent).toContain("Refreshing models…");
expect(container.textContent).not.toContain("Loading models…");
startup.resolve({
messages: [],
metadata: {
commands: [],
models: [{ ...cachedModel, id: "fresh-model", name: "Fresh Model" }],
},
});
await expect(refresh).resolves.toBeUndefined();
await waitForFast(() =>
expect(host.chatModelCatalog).toEqual([
{ ...cachedModel, id: "fresh-model", name: "Fresh Model" },
]),
);
});
it("fills omitted startup metadata immediately and populates models and commands", async () => {
const startup = createDeferred<unknown>();
const host = makeChatHost({
+26 -22
View File
@@ -2,6 +2,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import {
loadChatMetadata,
peekChatMetadata,
rememberChatMetadata,
type ChatMetadataResult,
} from "../../lib/chat/chat-metadata-store.ts";
@@ -17,7 +18,7 @@ import { flushChatQueueForEvent } from "./chat-send-actions.ts";
import { flushChatQueueAfterIdleSessionReconciliation } from "./chat-session.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import { resolveChatAgentId } from "./chat-state-route.ts";
import { applyModelCatalogResult, loadModels } from "./models.ts";
import { loadModels } from "./models.ts";
import {
reconcileChatRunFromCurrentSessionRow,
reconcileChatRunFromSessionRow,
@@ -50,8 +51,6 @@ type ChatMetadataRequest = {
};
type ChatMetadataRefreshOptions = {
preserveModelCatalogOnFallback?: boolean;
refreshModelCatalog?: boolean;
requestVersion?: number;
};
@@ -90,7 +89,8 @@ function applyChatMetadataResult(
result: ChatMetadataResult,
fields: { commands?: boolean; models?: boolean } = {},
): ChatMetadataApplyResult {
const models = fields.models === false ? undefined : applyModelCatalogResult(result.models);
const models =
fields.models === false || !Array.isArray(result.models) ? undefined : result.models;
if (models) {
host.chatModelCatalog = models;
host.chatModelCatalogError = null;
@@ -106,6 +106,17 @@ function applyChatMetadataResult(
return { commands: commandsApplied, models: Boolean(models) };
}
function seedChatModelCatalogFromStore(host: ChatPageHost, client: GatewayBrowserClient): void {
const cached = peekChatMetadata(client, resolveChatAgentId(host));
if (!Array.isArray(cached?.models)) {
return;
}
// A warm snapshot turns mount-time loading into refreshing; the in-flight
// request still owns the authoritative apply.
host.chatModelCatalog = cached.models;
host.chatModelCatalogError = null;
}
function ownsChatMetadataRequest(request: ChatMetadataRequest): boolean {
return (
request.host.client === request.client &&
@@ -115,17 +126,14 @@ function ownsChatMetadataRequest(request: ChatMetadataRequest): boolean {
);
}
async function refreshCompatibilityModelCatalog(
request: ChatMetadataRequest,
opts?: { refresh?: boolean },
) {
async function refreshCompatibilityModelCatalog(request: ChatMetadataRequest) {
const agentId = request.agentId?.trim();
if (!agentId) {
return;
}
const models = await loadModels(request.client, {
agentId,
...(opts?.refresh ? { refresh: true } : { preparedOnly: true }),
preparedOnly: true,
});
if (ownsChatMetadataRequest(request)) {
request.host.chatModelCatalog = models;
@@ -144,7 +152,6 @@ async function refreshCompatibilityCommands(request: ChatMetadataRequest) {
async function refreshMissingChatMetadata(
request: ChatMetadataRequest,
applied: ChatMetadataApplyResult,
opts?: ChatMetadataRefreshOptions,
): Promise<void> {
if (!ownsChatMetadataRequest(request)) {
return;
@@ -152,14 +159,9 @@ async function refreshMissingChatMetadata(
const commandsRefresh = applied.commands
? Promise.resolve()
: refreshCompatibilityCommands(request);
const preserveModels = opts?.preserveModelCatalogOnFallback;
const modelsRefresh =
applied.models || preserveModels
? Promise.resolve()
: refreshCompatibilityModelCatalog(
request,
opts?.refreshModelCatalog ? { refresh: true } : undefined,
);
const modelsRefresh = applied.models
? Promise.resolve()
: refreshCompatibilityModelCatalog(request);
await Promise.allSettled([commandsRefresh, modelsRefresh]);
}
@@ -181,9 +183,10 @@ export async function refreshChatMetadata(
const agentId = resolveChatAgentId(host);
const request = { host, client, agentId, version: requestVersion };
host.chatModelsLoading = true;
seedChatModelCatalogFromStore(host, client);
try {
if (isGatewayMethodAdvertised(host, "chat.metadata") === false) {
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT, opts);
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT);
return EMPTY_CHAT_METADATA_APPLY_RESULT;
}
@@ -193,12 +196,12 @@ export async function refreshChatMetadata(
}
const metadataApplied = applyChatMetadataResult(host, client, agentId, result);
if (!metadataApplied.models || !metadataApplied.commands) {
await refreshMissingChatMetadata(request, metadataApplied, opts);
await refreshMissingChatMetadata(request, metadataApplied);
}
return metadataApplied;
} catch {
if (ownsChatMetadataRequest(request)) {
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT, opts);
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT);
}
return EMPTY_CHAT_METADATA_APPLY_RESULT;
} finally {
@@ -388,8 +391,9 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) {
const startupMetadataRequestVersion = ownsStartupMetadata
? ++host.chatMetadataRequestVersion
: null;
if (ownsStartupMetadata) {
if (ownsStartupMetadata && host.client) {
host.chatModelsLoading = true;
seedChatModelCatalogFromStore(host, host.client);
}
const refresh = refreshChat(host, {
-23
View File
@@ -1725,29 +1725,6 @@ describe("refreshChatMetadata", () => {
expect(request).toHaveBeenCalledTimes(2);
});
it("preserves startup models when the gateway does not advertise chat metadata", async () => {
const request = vi.fn(async (method: string) => {
expect(method).toBe("commands.list");
return { commands: [] };
});
const startupCatalog = [
{ id: "startup-model", name: "Startup Model", provider: "openai", available: true },
];
const state = createMetadataState(request, {
chatMetadataRequestVersion: 4,
chatModelCatalog: startupCatalog,
chatModelsLoading: true,
hello: { features: { methods: ["chat.startup"] } },
});
await refreshChatMetadata(state, { preserveModelCatalogOnFallback: true });
expect(state.chatMetadataRequestVersion).toBe(5);
expect(state.chatModelCatalog).toBe(startupCatalog);
expect(state.chatModelsLoading).toBe(false);
expect(request).toHaveBeenCalledTimes(1);
});
it("loads agent-scoped compatibility models for a non-default agent", async () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "models.list") {
+1 -35
View File
@@ -1,7 +1,7 @@
// Control UI tests cover models behavior.
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { applyModelCatalogResult, loadModels } from "./models.ts";
import { loadModels } from "./models.ts";
describe("loadModels", () => {
it("requests the configured model list view", async () => {
@@ -140,37 +140,3 @@ describe("loadModels", () => {
expect(request).toHaveBeenCalledTimes(2);
});
});
describe("applyModelCatalogResult", () => {
it("preserves availability from metadata results", () => {
expect(
applyModelCatalogResult([
{
id: "gpt-5.5",
name: "GPT-5.5",
provider: "openai",
available: true,
},
{
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
provider: "codex",
available: false,
},
]),
).toEqual([
{
id: "gpt-5.5",
name: "GPT-5.5",
provider: "openai",
available: true,
},
{
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
provider: "codex",
available: false,
},
]);
});
});
-7
View File
@@ -92,13 +92,6 @@ export async function loadModels(
return inFlight;
}
export function applyModelCatalogResult(models: unknown): ModelCatalogEntry[] | null {
if (!Array.isArray(models)) {
return null;
}
return models as ModelCatalogEntry[];
}
async function requestModels(
client: GatewayBrowserClient,
fallback: ModelCatalogEntry[] | undefined,