diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 71cda0444d62..b878f4cbad5f 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 21a26d6a51bf..a0ca89950027 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -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(); + 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(); const host = makeChatHost({ diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index 8f1d767b9ba3..52e01f3cec02 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -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 { 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, { diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 143112e77d72..0f871ecf78eb 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -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") { diff --git a/ui/src/pages/chat/models.test.ts b/ui/src/pages/chat/models.test.ts index d0cbfc10d365..2c03ec0b2c3f 100644 --- a/ui/src/pages/chat/models.test.ts +++ b/ui/src/pages/chat/models.test.ts @@ -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, - }, - ]); - }); -}); diff --git a/ui/src/pages/chat/models.ts b/ui/src/pages/chat/models.ts index d5933f94e283..02fdfc9871f7 100644 --- a/ui/src/pages/chat/models.ts +++ b/ui/src/pages/chat/models.ts @@ -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,