fix(ui): preserve legacy model picker fallback

This commit is contained in:
Patrick Erichsen
2026-08-12 20:07:45 -07:00
parent 0520c7f08e
commit 87f4b76181
5 changed files with 119 additions and 11 deletions
+33 -3
View File
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import type {
AgentsFilesListResult,
AgentsListResult,
@@ -68,8 +68,12 @@ function setPageGateway(
client: GatewayBrowserClient | null,
connected = true,
sourceChanged = false,
featureMethods?: string[],
) {
page.gateway.applySnapshot(snapshot(client, connected), { initial: false, sourceChanged });
page.gateway.applySnapshot(snapshot(client, connected, featureMethods), {
initial: false,
sourceChanged,
});
}
function deferred<T>() {
@@ -83,13 +87,14 @@ function deferred<T>() {
function snapshot(
client: GatewayBrowserClient | null,
connected = true,
featureMethods?: string[],
): ApplicationGatewaySnapshot {
return {
client,
phase: connected ? "connected" : "stopped",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: null,
hello: featureMethods ? ({ features: { methods: featureMethods } } as GatewayHelloOk) : null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
@@ -331,6 +336,31 @@ describe("AgentsPage gateway lifecycle", () => {
expect(request).toHaveBeenCalledWith("models.list", { view: "configured", agentId: "main" });
});
it("uses agent-scoped chat metadata when a legacy Gateway lacks models.list", async () => {
const request = vi.fn(async () => ({
models: [
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai", available: false },
],
}));
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
page.routeData = { panel: "overview" } as AgentsRouteData;
setPageGateway(page, { request } as unknown as GatewayBrowserClient, true, false, [
"chat.metadata",
]);
page.agentsSelectedId = "main";
page.loadActivePanelData();
await vi.waitFor(() =>
expect(page.chatModelCatalog).toEqual([
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true },
]),
);
expect(request).toHaveBeenCalledOnce();
expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" });
});
it("caches separate configured model catalogs for the default and worker agents", async () => {
const defaultModels = [
{ id: "default-model", name: "Default account model", provider: "openai" },
+7 -2
View File
@@ -40,13 +40,14 @@ import {
} from "../../lib/cron/index.ts";
import {
canCallGatewayMethod,
isGatewayMethodAdvertised,
type GatewayMethodOperatorScope,
} from "../../lib/gateway-methods.ts";
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
import { GatewayPageController } from "../../lit/gateway-page-controller.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { loadModels } from "../chat/models.ts";
import { loadModelPickerModels } from "../chat/models.ts";
import { loadAgentFileContent, saveAgentFile } from "./files.ts";
import {
resetIdentityDraft,
@@ -575,7 +576,11 @@ class AgentsPage
const request = { client, generation, agentId };
this.chatModelCatalogRequest = request;
this.chatModelCatalogError = null;
void loadModels(client, { agentId, refresh: true })
void loadModelPickerModels(client, {
agentId,
modelsListAdvertised: isGatewayMethodAdvertised(this.gateway.snapshot ?? {}, "models.list"),
refresh: true,
})
.then((models) => {
if (this.isCurrentRequest(client, generation, agentId)) {
this.chatModelCatalog = models;
+39
View File
@@ -73,6 +73,23 @@ export async function loadModels(
return inFlight;
}
export async function loadModelPickerModels(
client: GatewayBrowserClient,
opts: {
agentId?: string;
modelsListAdvertised: boolean | null;
refresh?: boolean;
signal?: AbortSignal;
timeoutMs?: number;
},
): Promise<ModelCatalogEntry[]> {
const models =
opts.modelsListAdvertised === false
? await requestMetadataModels(client, opts)
: await loadModels(client, opts);
return models.filter((entry) => entry.available !== false);
}
export function applyModelCatalogResult(models: unknown): ModelCatalogEntry[] | null {
if (!Array.isArray(models)) {
return null;
@@ -100,3 +117,25 @@ async function requestModels(
: await client.request<{ models: ModelCatalogEntry[] }>("models.list", params);
return result?.models ?? [];
}
async function requestMetadataModels(
client: GatewayBrowserClient,
opts: { agentId?: string; signal?: AbortSignal; timeoutMs?: number },
): Promise<ModelCatalogEntry[]> {
const requestOptions =
opts.signal || opts.timeoutMs
? {
...(opts.signal ? { signal: opts.signal } : {}),
...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}),
}
: undefined;
const params = opts.agentId ? { agentId: opts.agentId } : {};
const result = requestOptions
? await client.request<{ models?: ModelCatalogEntry[] }>(
"chat.metadata",
params,
requestOptions,
)
: await client.request<{ models?: ModelCatalogEntry[] }>("chat.metadata", params);
return result?.models ?? [];
}
+25 -1
View File
@@ -12,7 +12,7 @@ import { NewSessionModelControl } from "./model-control.ts";
function contextWith(
models: ModelCatalogEntry[],
runtime = "openclaw",
featureMethods: string[] = [],
featureMethods: string[] = ["models.list"],
) {
const request = vi.fn().mockResolvedValue({ models });
const context = {
@@ -408,6 +408,30 @@ describe("new-session model runtime", () => {
expect(container.querySelector('[data-chat-model-catalog-retry="true"]')).toBeNull();
});
it("uses agent-scoped chat metadata when a legacy Gateway lacks models.list", async () => {
const models: ModelCatalogEntry[] = [
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai", available: false },
];
const { context, request } = contextWith(models, "openclaw", ["chat.metadata"]);
const control = new NewSessionModelControl(() => undefined);
control.load(context, "main", true);
await vi.waitFor(() =>
expect(
renderControl(control, context).querySelectorAll("[data-chat-model-option]"),
).toHaveLength(1),
);
expect(request).toHaveBeenCalledWith(
"chat.metadata",
{ agentId: "main" },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(request.mock.calls.map(([method]) => method)).toEqual(["chat.metadata"]);
expect(renderControl(control, context).textContent).not.toContain("GPT-5.6 Sol");
});
it("keeps a successful empty catalog explicit when its refresh fails", async () => {
const refresh = deferred<{ models: ModelCatalogEntry[] }>();
const { context, request } = contextWith([]);
+15 -5
View File
@@ -21,7 +21,7 @@ import {
renderChatModelControls,
type ChatModelCatalogState,
} from "../chat/components/chat-model-controls.ts";
import { loadModels } from "../chat/models.ts";
import { loadModelPickerModels } from "../chat/models.ts";
import type { NewSessionPreference } from "./preferences.ts";
const NEW_SESSION_METADATA_RETRY_WINDOW_MS = 60_000;
@@ -88,6 +88,7 @@ async function requestNewSessionMetadata(
client: NewSessionMetadataClient,
agentId: string,
signal: AbortSignal,
modelsListAdvertised: boolean | null,
): Promise<{ models?: ModelCatalogEntry[] }> {
const deadlineAt = Date.now() + NEW_SESSION_METADATA_RETRY_WINDOW_MS;
let latestStartupError: Error | undefined;
@@ -106,8 +107,9 @@ async function requestNewSessionMetadata(
}
try {
const models = await loadModels(client, {
const models = await loadModelPickerModels(client, {
agentId,
modelsListAdvertised,
refresh: true,
signal,
timeoutMs: Math.min(DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, remainingMs),
@@ -316,7 +318,11 @@ export class NewSessionModelControl {
this.notify();
}
private startMetadataRequest(client: NewSessionMetadataClient, agentId: string) {
private startMetadataRequest(
client: NewSessionMetadataClient,
agentId: string,
modelsListAdvertised: boolean | null,
) {
this.cancelMetadataRequest();
const controller = new AbortController();
const requestId = ++this.metadataRequestId;
@@ -331,7 +337,7 @@ export class NewSessionModelControl {
status: this.metadataState.hasSnapshot ? "refreshing" : "loading",
});
void requestNewSessionMetadata(client, agentId, controller.signal).then(
void requestNewSessionMetadata(client, agentId, controller.signal, modelsListAdvertised).then(
(result) => {
// Aborted transports may still resolve. Only the request that still
// owns the control may publish catalog data or restore preferences.
@@ -470,7 +476,11 @@ export class NewSessionModelControl {
this.notify();
return;
}
this.startMetadataRequest(client, normalizedAgentId);
this.startMetadataRequest(
client,
normalizedAgentId,
isGatewayMethodAdvertised(snapshot, "models.list"),
);
}
isRestoringPreference(): boolean {