fix(ui): always load live active-chat models

This commit is contained in:
Patrick Erichsen
2026-08-11 11:03:08 -07:00
parent 8d5fc45f5f
commit 3785628e3a
12 changed files with 133 additions and 71 deletions
+21 -17
View File
@@ -573,19 +573,11 @@ suite.define(() => {
});
});
it("refreshes the configured usable catalog after advertised chat metadata", async () => {
it("uses the live usable catalog instead of unavailable metadata models", async () => {
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
const gateway = await installMockGateway(page, {
agentModel: "openai/gpt-5.3-codex-spark",
models: [
{ 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,
},
],
agentModel: "openai/gpt-5.6-sol",
models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }],
methodResponses: {
"chat.startup": {
agentsList: {
@@ -603,9 +595,9 @@ suite.define(() => {
models: [
{ 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",
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
available: false,
},
],
@@ -614,7 +606,7 @@ suite.define(() => {
count: 1,
defaults: {
contextTokens: 200_000,
model: "gpt-5.3-codex-spark",
model: "gpt-5.6-sol",
modelProvider: "openai",
},
path: "",
@@ -640,7 +632,10 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.metadata");
expect(await gateway.getRequests("models.list")).toHaveLength(0);
await gateway.waitForRequest("models.list");
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { view: "configured" } }),
]);
const composer = page.locator(".agent-chat__input");
const providers = composer.locator("[data-chat-model-provider]");
@@ -651,12 +646,21 @@ suite.define(() => {
.poll(() => composer.locator('[data-chat-model-provider-group="openai"]').textContent())
.toContain("GPT-5.5");
await expect
.poll(() => composer.locator('[data-chat-model-provider-group="codex"]').count())
.poll(() => composer.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').count())
.toBe(0);
// The advertised default is unavailable, so no usable catalog row is
// marked as the default and no synthetic empty row is introduced.
await expect.poll(() => composer.locator('[data-chat-model-default="true"]').count()).toBe(0);
await expect.poll(() => composer.locator('[data-chat-model-option=""]').count()).toBe(0);
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await composer.locator('[data-chat-model-select="true"]').click();
await page.screenshot({
animations: "disabled",
fullPage: true,
path: `${artifactDir}/usable-model-picker.png`,
});
}
});
});
+3 -3
View File
@@ -142,9 +142,9 @@ suite.define(() => {
expect(response?.status()).toBe(200);
await gateway.waitForRequest("agents.list");
await gateway.waitForRequest("config.get");
const modelRequest = await gateway.waitForRequest("chat.metadata");
expect(modelRequest.params).toEqual({ agentId: "main" });
expect(await gateway.getRequests("models.list")).toHaveLength(0);
const modelRequest = await gateway.waitForRequest("models.list");
expect(modelRequest.params).toEqual({ agentId: "main", view: "configured" });
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
const select = page.locator("select.settings-select").first();
await select.waitFor({ state: "visible", timeout: 10_000 });
@@ -35,8 +35,8 @@ function requestHasParam(request: { params?: unknown }, key: string, value: unkn
}
const TERMINAL_START_FEATURE_METHODS = [
"chat.metadata",
"chat.startup",
"models.list",
"sessions.catalog.list",
"sessions.catalog.startTerminal",
"sessions.create",
@@ -78,8 +78,8 @@ suite.define(() => {
const gateway = await installMockGateway(page, {
cliAgentsEnabled: true,
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"sessions.create",
"sessions.dispatch",
"sessions.catalog.list",
@@ -450,7 +450,7 @@ suite.define(() => {
const gateway = await installMockGateway(page, {
agentModel: "openai/gpt-5.6-luna",
methodResponses: {
"chat.metadata": {
"models.list": {
sequence: [
{
__mockError: {
@@ -458,7 +458,7 @@ suite.define(() => {
message: "metadata request timed out",
},
},
{ commands: [], models },
{ models },
],
},
},
@@ -467,7 +467,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await gateway.waitForRequest("chat.metadata");
await gateway.waitForRequest("models.list");
const modelSelect = page.locator('[data-chat-model-select="true"]');
await expect.poll(() => modelSelect.textContent()).toContain("Models unavailable");
@@ -479,7 +479,7 @@ suite.define(() => {
await page.locator('[data-chat-model-catalog-retry="true"]').click();
await expect.poll(async () => (await gateway.getRequests("chat.metadata")).length).toBe(2);
await expect.poll(async () => (await gateway.getRequests("models.list")).length).toBe(2);
await expect.poll(() => page.locator("[data-chat-model-option]").count()).toBe(3);
expect(await page.locator('[data-chat-model-catalog-state="error"]').count()).toBe(0);
} finally {
@@ -503,7 +503,7 @@ suite.define(() => {
};
const gateway = await installMockGateway(page, {
methodResponses: {
"chat.metadata": {
"models.list": {
sequence: [
{
__mockError: {
@@ -514,7 +514,7 @@ suite.define(() => {
retryAfterMs: 100,
},
},
{ commands: [], models: [recoveredModel] },
{ models: [recoveredModel] },
],
},
},
@@ -522,7 +522,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}new`);
await expect.poll(async () => (await gateway.getRequests("chat.metadata")).length).toBe(2);
await expect.poll(async () => (await gateway.getRequests("models.list")).length).toBe(2);
const modelSelect = page.locator(
'.new-session-page__composer [data-chat-model-select="true"]',
@@ -532,9 +532,9 @@ suite.define(() => {
.poll(() => page.locator('[data-chat-model-option="openai/gpt-5.6-luna"]').textContent())
.toContain(recoveredModel.name);
expect(await gateway.getRequests("chat.metadata")).toEqual([
expect.objectContaining({ params: { agentId: "main" } }),
expect.objectContaining({ params: { agentId: "main" } }),
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { agentId: "main", view: "configured" } }),
expect.objectContaining({ params: { agentId: "main", view: "configured" } }),
]);
} finally {
await context.close();
@@ -45,6 +45,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"sessions.create",
"sessions.dispatch",
"sessions.reclaim",
@@ -13,6 +13,7 @@ async function openDraft(
featureMethods = [
"chat.metadata",
"chat.startup",
"models.list",
"projects.list",
"sessions.create",
"sessions.dispatch",
@@ -41,7 +42,14 @@ suite.define(() => {
it("keeps read-scoped operators out of new-session entry and submission paths", async () => {
const { context, gateway, page } = await openDraft(
["operator.read"],
["chat.metadata", "chat.startup", "projects.list", "sessions.create", "sessions.dispatch"],
[
"chat.metadata",
"chat.startup",
"models.list",
"projects.list",
"sessions.create",
"sessions.dispatch",
],
);
try {
const sidebarCreate = page.locator(".sidebar-brand__new-thread");
@@ -136,6 +144,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"fs.listDir",
"sessions.create",
"worktrees.branches",
@@ -197,6 +206,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"fs.listDir",
"sessions.create",
"worktrees.branches",
@@ -264,6 +274,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"fs.listDir",
"sessions.create",
"worktrees.branches",
@@ -328,7 +339,7 @@ suite.define(() => {
it("blocks creation when the connected Gateway explicitly omits sessions.create", async () => {
const { context, gateway, page } = await openDraft(
["operator.admin", "operator.read", "operator.write"],
["chat.metadata", "chat.startup"],
["chat.metadata", "chat.startup", "models.list"],
);
try {
await expect.poll(() => page.locator(".sidebar-brand__new-thread").isDisabled()).toBe(true);
+16 -2
View File
@@ -359,6 +359,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"sessions.create",
"sessions.dispatch",
"projects.list",
@@ -480,6 +481,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"fs.listDir",
"projects.list",
"projects.register",
@@ -543,7 +545,13 @@ suite.define(() => {
workspace: WORKSPACE,
workspaceGit: true,
operatorScopes: ["operator.read", "operator.write"],
featureMethods: ["chat.metadata", "chat.startup", "projects.list", "sessions.create"],
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"projects.list",
"sessions.create",
],
methodResponses: { "projects.list": { projects: [] } },
});
@@ -567,7 +575,13 @@ suite.define(() => {
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
featureMethods: ["chat.metadata", "chat.startup", "sessions.create", "system.info"],
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"sessions.create",
"system.info",
],
methodResponses: {
"system.info": {
machineName: "Peters-Mac-Studio",
+1 -1
View File
@@ -16,8 +16,8 @@ import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"
export { controlUiSessionPath, controlUiSessionUrl, waitForConfirmModal };
const NEW_SESSION_FEATURE_METHODS = [
"chat.metadata",
"chat.startup",
"models.list",
"sessions.create",
"sessions.dispatch",
] as const;
@@ -728,15 +728,15 @@ suite.define(() => {
await navigateInApp(page, "chat");
await waitForCommittedChatRoute(page);
const metadataRequests = (await gateway.getRequests("chat.metadata")).length;
const modelRequests = (await gateway.getRequests("models.list")).length;
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.deferNext("chat.metadata");
await gateway.deferNext("models.list");
await gateway.deferNext("worktrees.branches");
await navigateInApp(page, "new-session");
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
await expect
.poll(async () => (await gateway.getRequests("chat.metadata")).length)
.toBe(metadataRequests + 1);
.poll(async () => (await gateway.getRequests("models.list")).length)
.toBe(modelRequests + 1);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
@@ -745,7 +745,7 @@ suite.define(() => {
const start = page.getByRole("button", { name: "Start session" });
await expect.poll(() => start.isDisabled()).toBe(true);
await gateway.resolveDeferred("chat.metadata", { models });
await gateway.resolveDeferred("models.list", { models });
await expect.poll(() => start.isDisabled()).toBe(true);
await gateway.rejectDeferred("worktrees.branches", {
code: "UNAVAILABLE",
@@ -828,6 +828,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"models.list",
"fs.listDir",
"sessions.create",
"worktrees.branches",
+6 -1
View File
@@ -267,7 +267,12 @@ export async function refreshChatMetadata(
if (!ownsChatMetadataRequest(request)) {
return EMPTY_CHAT_METADATA_APPLY_RESULT;
}
const metadataApplied = applyChatMetadataResult(host, client, agentId, result);
// chat.metadata remains the compatibility source for commands only. Picker inventory must
// always come from the live, agent-scoped models.list result so stale static models cannot
// reappear when chat.startup omits metadata or an older Gateway serves this fallback path.
const metadataApplied = applyChatMetadataResult(host, client, agentId, result, {
models: false,
});
if (!metadataApplied.models || !metadataApplied.commands) {
await refreshMissingChatMetadata(request, metadataApplied, opts);
}
+52 -26
View File
@@ -1434,7 +1434,17 @@ describe("refreshChatMetadata", () => {
}>((resolve) => {
resolveMetadata = resolve;
});
const liveModel = {
id: "work-model",
name: "Work Model",
provider: "openai",
available: true,
};
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "models.list") {
expect(params).toEqual({ agentId: "work", view: "configured" });
return { models: [liveModel] };
}
expect(method).toBe("chat.metadata");
expect(params).toEqual({ agentId: "work" });
return await metadata;
@@ -1445,38 +1455,37 @@ describe("refreshChatMetadata", () => {
state.sessionKey = "agent:work:another";
resolveMetadata?.({
commands: [],
models: [{ id: "work-model", name: "Work Model", provider: "openai", available: true }],
models: [{ id: "stale-model", name: "Stale Model", provider: "openai", available: false }],
});
await refresh;
expect(state.chatModelCatalog).toEqual([
{ id: "work-model", name: "Work Model", provider: "openai", available: true },
]);
expect(request).toHaveBeenCalledTimes(1);
expect(state.chatModelCatalog).toEqual([liveModel]);
expect(request).toHaveBeenCalledTimes(2);
});
it("reuses same-agent metadata and fetches a cross-agent catalog", async () => {
const request = vi.fn(async (_method: string, params?: { agentId?: string }) => ({
commands: [],
models: [
{
id: `${params?.agentId}-model`,
name: `${params?.agentId} Model`,
provider: "openai",
},
],
}));
const request = vi.fn(async (method: string, params?: { agentId?: string }) => {
const model = {
id: `${params?.agentId}-model`,
name: `${params?.agentId} Model`,
provider: "openai",
};
return method === "models.list" ? { models: [model] } : { commands: [], models: [model] };
});
const state = createMetadataState(request);
await refreshChatMetadata(state);
state.sessionKey = "agent:work:second";
await refreshChatMetadata(state);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledTimes(2);
state.sessionKey = "agent:other:main";
await refreshChatMetadata(state);
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenLastCalledWith("chat.metadata", { agentId: "other" });
expect(request).toHaveBeenCalledTimes(4);
expect(request).toHaveBeenLastCalledWith("models.list", {
agentId: "other",
view: "configured",
});
});
it("ignores metadata after switching to a different agent", async () => {
@@ -1531,10 +1540,21 @@ describe("refreshChatMetadata", () => {
}>((resolve) => {
resolveOther = resolve;
});
const request = vi.fn(
async (_method: string, params?: { agentId?: string }) =>
await (params?.agentId === "work" ? workMetadata : otherMetadata),
);
const request = vi.fn(async (method: string, params?: { agentId?: string }) => {
if (method === "models.list") {
const isWork = params?.agentId === "work";
return {
models: [
{
id: isWork ? "work-model" : "other-model",
name: isWork ? "Work Model" : "Other Model",
provider: "openai",
},
],
};
}
return await (params?.agentId === "work" ? workMetadata : otherMetadata);
});
const state = createMetadataState(request);
const workRefresh = refreshChatMetadata(state);
@@ -1580,10 +1600,13 @@ describe("refreshChatMetadata", () => {
}>((resolve) => {
resolveSecond = resolve;
});
let requestCount = 0;
const request = vi.fn(async () => {
requestCount += 1;
return await (requestCount === 1 ? firstMetadata : secondMetadata);
let metadataRequestCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "models.list") {
return { models: [{ id: "new-model", name: "New Model", provider: "openai" }] };
}
metadataRequestCount += 1;
return await (metadataRequestCount === 1 ? firstMetadata : secondMetadata);
});
const state = createMetadataState(request);
@@ -1728,6 +1751,9 @@ describe("refreshChatMetadata", () => {
resolveCommands = resolve;
});
const request = vi.fn(async (method: string) => {
if (method === "models.list") {
return { models: [] };
}
expect(method).toBe("commands.list");
return await commands;
});
@@ -332,7 +332,7 @@ describe("new-session model runtime", () => {
it.each([
["generic transport error", new Error("metadata unavailable")],
["request timeout", new Error("gateway request timeout for chat.metadata")],
["request timeout", new Error("gateway request timeout for models.list")],
])("renders %s as unavailable instead of a default-only catalog", async (_label, error) => {
const { context, request } = contextWith([]);
request.mockRejectedValueOnce(error);
+1 -1
View File
@@ -608,7 +608,7 @@ export class NewSessionModelControl {
loading: false,
modelCatalog: this.catalog,
modelCatalogState: {
// chat.metadata and agents.list hydrate independently. Do not expose a
// models.list and agents.list hydrate independently. Do not expose a
// ready catalog until the selected agent can supply its concrete defaults.
hasSnapshot: agentDefaultsAvailable && this.metadataState.hasSnapshot,
...(this.metadataState.status === "error" ? { onRetry: this.retryMetadata } : {}),