Files
openclaw/scripts/capture-model-picker-proof.mts
T
Peter Steinberger 475d20a034 fix(ui): stop the model picker from nagging about refresh failures (#125661)
* fix(ui): stop the model picker from nagging about refresh failures

A failed background catalog refresh rendered a "Couldn't refresh models"
banner plus a Retry button on top of a complete, working model list, and
replaced the composer trigger's model name with that error text. The
picker already keeps the last-known catalog and re-requests it on every
open, so the operator was being asked to press a button for something the
UI does by itself.

The error stays recorded on the host — it is what drops the stale
availability gate so the composer remains usable — but it is no longer
surfaced while there are models to show. Only a genuinely empty catalog
still says "Models unavailable". The new-session picker gains the chat
picker's open-triggers-revalidate behavior so re-opening it is the retry
there too, instead of dead-ending until a page reload.

Two adjacent simplifications in the same surface:

- Model rows reserve their provider-icon slot as an invisible stem, so
  names line up with the provider heading label (same 34px stem grouped
  and filtered, nothing shifts while typing).
- The "Using agent default" footer is gone: the default row already
  carries a DEFAULT badge and the checkmark. Typing "default" in the
  picker search now matches the default model instead.

Proof: scripts/capture-model-picker-proof.mts captures the open picker
against a mocked gateway; alignment delta 0px (was -24px), search
"default" matches the default row (was nothing), and a failed models.list
leaves no catalog-state element with all rows intact.

* fix(ci): register the model-picker proof script and await its picker revalidation

The unused-file scan needs every scripts/ entry point referenced, like the
sibling ui:proof:* recipes. The catalog-reconnect assertion also has to wait
for the picker's own metadata request instead of reading the log the moment
the rows render.

* test(ui): split model-catalog scenarios out of the new-session e2e file

The catalog-reconnect file hit the 1000-line cap. Its model-catalog metadata
failure/recovery pair is a separate surface from CLI-agent targets, terminal
start, and draft reconnect, so it moves to its own file rather than earning a
max-lines suppression.

* test(audit): give the pinned-reader contract test a realistic timeout

It adds a pinned-SHA git worktree and cold-compiles the audit and state
modules under tsx, which takes minutes on a contended runner. The 120s
default made it fail by construction; it timed out on an unrelated PR shard
while passing locally at ~55s.
2026-08-18 00:58:19 -07:00

155 lines
5.4 KiB
TypeScript

#!/usr/bin/env node
// Captures chat model-picker proof shots: the open picker inheriting the agent
// default, the same picker with a session override, and the picker after a
// failed catalog refresh. Also reports the x offset between the provider
// heading label and the model row name (the row-alignment change).
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
} from "../ui/src/test-helpers/control-ui-e2e.ts";
function readOption(name: string): string | undefined {
const prefix = `--${name}=`;
const inline = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = process.argv.indexOf(`--${name}`);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const outputDir = path.resolve(
readOption("output-dir") ?? ".artifacts/control-ui-e2e/model-picker-proof",
);
const label = readOption("label") ?? "after";
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
if (!canRunPlaywrightChromium(executablePath)) {
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
}
const models = [
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", contextWindow: 400_000 },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai", contextWindow: 1_000_000 },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai", contextWindow: 1_000_000 },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", provider: "openai", contextWindow: 1_000_000 },
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
provider: "anthropic",
contextWindow: 200_000,
},
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic", contextWindow: 200_000 },
];
await mkdir(outputDir, { recursive: true });
const server = await startControlUiE2eServer(undefined, { source: true });
const browser = await chromium.launch({ executablePath });
const context = await browser.newContext({
colorScheme: "dark",
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
page.setDefaultTimeout(30_000);
try {
const gateway = await installMockGateway(page, { agentModel: "openai/gpt-5.5", models });
await page.goto(`${server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
const composer = page.locator(".agent-chat__input");
await composer.waitFor({ state: "visible" });
const trigger = composer.locator('[data-chat-model-select="true"]');
const menu = composer.locator(".chat-controls__model-menu");
const openPicker = async () => {
await trigger.click();
await menu.waitFor({ state: "visible" });
await page.waitForTimeout(500);
};
const closePicker = async () => {
await page.keyboard.press("Escape");
await page.waitForTimeout(250);
};
const shoot = async (name: string) => {
const box = await menu.boundingBox();
if (!box) {
throw new Error(`model menu has no layout box for ${name}`);
}
await page.screenshot({
path: path.join(outputDir, `${label}-${name}.png`),
clip: {
x: Math.max(0, box.x - 8),
y: Math.max(0, box.y - 8),
width: box.width + 16,
height: box.height + 16,
},
});
};
await openPicker();
await shoot("default");
const headingLabel = menu.locator(".chat-controls__provider-heading span").last();
const rowName = menu.locator(".chat-controls__model-option-name").first();
const headingBox = await headingLabel.boundingBox();
const rowBox = await rowName.boundingBox();
const search = menu.locator("[data-chat-model-search]");
await search.fill("default");
await page.waitForTimeout(400);
await shoot("search-default");
const searchMatches = await menu
.locator("[data-chat-model-option]:not([hidden])")
.evaluateAll((rows) => rows.map((row) => row.textContent?.replace(/\s+/gu, " ").trim() ?? ""));
await search.fill("");
await page.waitForTimeout(250);
const override = menu.locator('[data-chat-model-option="openai/gpt-5.6-terra"]');
await override.click();
await page.waitForTimeout(600);
await openPicker();
await shoot("override");
await closePicker();
// A fresh page drops the per-client catalog cache, so the picker's own load
// reaches the mock gateway and fails there.
await gateway.setMethodResponse("models.list", {
__mockError: { code: "UNAVAILABLE", message: "mock catalog refresh failed" },
});
await page.reload();
await composer.waitFor({ state: "visible" });
await page.waitForTimeout(1000);
await openPicker();
await page.waitForTimeout(1500);
await shoot("refresh-failure");
const catalogStateText = await menu.locator("[data-chat-model-catalog-state]").allTextContents();
const rowsAfterFailure = await menu.locator("[data-chat-model-option]").count();
await closePicker();
console.log(
JSON.stringify(
{
label,
outputDir,
providerHeadingLabelX: headingBox?.x ?? null,
modelRowNameX: rowBox?.x ?? null,
alignmentDeltaPx: headingBox && rowBox ? rowBox.x - headingBox.x : null,
searchDefaultMatches: searchMatches,
catalogStateAfterRefreshFailure: catalogStateText,
modelRowsAfterRefreshFailure: rowsAfterFailure,
},
null,
2,
),
);
} finally {
await context.close();
await browser.close();
await server.close();
}