fix(ui): respect agent-owned model fallbacks (#113812)

Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
This commit is contained in:
Peter Steinberger
2026-07-25 12:45:30 -07:00
committed by GitHub
parent 935ee7fedf
commit eb555bd033
5 changed files with 187 additions and 3 deletions
@@ -0,0 +1,131 @@
// Real browser proof that agent model fallbacks follow the Gateway's ownership contract.
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const primaryModel = "openai/gpt-5.4";
const inheritedFallback = "anthropic/claude-sonnet-4-6";
let browser: Browser;
let server: ControlUiE2eServer;
describeControlUiE2e("Control UI agent model fallback ownership", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it.each([
{
name: "inherits global fallbacks when the agent has no model override",
model: undefined,
expectedFallbacks: [inheritedFallback],
},
{
name: "does not inherit global fallbacks for a string primary",
model: primaryModel,
expectedFallbacks: [],
},
{
name: "does not inherit global fallbacks for an object primary",
model: { primary: primaryModel },
expectedFallbacks: [],
},
{
name: "preserves explicitly disabled agent fallbacks",
model: { primary: primaryModel, fallbacks: [] },
expectedFallbacks: [],
},
{
name: "displays the agent's own fallback instead of the global fallback",
model: { primary: primaryModel, fallbacks: ["google/gemini-3-pro"] },
expectedFallbacks: ["google/gemini-3-pro"],
},
])("$name", async ({ model, expectedFallbacks }) => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const writer = { id: "writer", ...(model === undefined ? {} : { model }) };
const config = {
agents: {
defaults: { model: { primary: primaryModel, fallbacks: [inheritedFallback] } },
list: [{ id: "main" }, writer],
},
};
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [
{ id: "main", identity: { name: "Main" }, name: "Main" },
{ id: "writer", identity: { name: "Writer" }, name: "Writer" },
],
},
"config.get": {
config,
sourceConfig: config,
hash: "agent-model-fallback-ownership",
issues: [],
raw: JSON.stringify(config),
valid: true,
},
},
});
try {
const response = await page.goto(`${server.baseUrl}settings/agents?agent=writer`);
expect(response?.status()).toBe(200);
await gateway.waitForRequest("agents.list");
await gateway.waitForRequest("config.get");
const agentPicker = page.locator("openclaw-agents-page openclaw-agent-select");
await agentPicker.locator(".agent-select__trigger").click();
await agentPicker
.locator("wa-dropdown-item[data-agent-option]")
.filter({ hasText: "Writer" })
.evaluate((item) => (item as HTMLElement).click());
await expect
.poll(() =>
agentPicker.evaluate((picker) => (picker as HTMLElement & { value: string }).value),
)
.toBe("writer");
await page.getByRole("button", { name: "Overview", exact: true }).click();
const fallbackInput = page.locator(".agent-chip-input");
await fallbackInput.waitFor({ timeout: 10_000 });
await expect
.poll(async () =>
(await fallbackInput.locator(".chip").allTextContents()).map((value) =>
value.replace("×", "").trim(),
),
)
.toEqual(expectedFallbacks);
expect(await gateway.getRequests("config.set")).toHaveLength(0);
} finally {
await context.close();
}
});
});
+12
View File
@@ -64,6 +64,18 @@ describe("resolveEffectiveModelFallbacks", () => {
expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual(["openai/gpt-5-nano"]);
});
it.each([
{ name: "a string primary", model: "openai/gpt-5.4" },
{ name: "an object primary", model: { primary: "openai/gpt-5.4" } },
])("does not inherit global fallbacks for $name", ({ model }) => {
expect(
resolveEffectiveModelFallbacks(model, {
primary: "openai/gpt-5.4",
fallbacks: ["anthropic/claude-sonnet-4-6"],
}),
).toStrictEqual([]);
});
it("keeps explicit empty entry fallback lists", () => {
const entryModel = {
primary: "openai/gpt-5-mini",
+7 -1
View File
@@ -493,7 +493,13 @@ export function resolveEffectiveModelFallbacks(
entryModel?: unknown,
defaultModel?: unknown,
): string[] | null {
return resolveModelFallbacks(entryModel) ?? resolveModelFallbacks(defaultModel);
const entryFallbacks = resolveModelFallbacks(entryModel);
if (entryFallbacks !== null) {
return entryFallbacks;
}
// An agent-owned primary is strict; only an inherited primary can use
// the global fallback chain, matching the Gateway's model routing.
return resolveModelPrimary(entryModel) ? [] : resolveModelFallbacks(defaultModel);
}
export function parseFallbackList(value: string): string[] {
+2 -2
View File
@@ -16,6 +16,7 @@ import {
resolveAgentConfig,
resolveAgentRuntimeLabel,
resolveAgentTextAvatar,
resolveEffectiveModelFallbacks,
resolveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
@@ -93,8 +94,7 @@ export function renderAgentOverview(params: {
const effectivePrimary = entryPrimary ?? defaultPrimary ?? null;
const selectedPrimary = isDefault ? effectivePrimary : entryPrimary;
const modelFallbacks =
resolveModelFallbacks(config.entry?.model) ??
resolveModelFallbacks(config.defaults?.model) ??
resolveEffectiveModelFallbacks(config.entry?.model, config.defaults?.model) ??
(configForm ? null : resolveModelFallbacks(agentModel));
const fallbackChips = modelFallbacks ?? [];
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
+35
View File
@@ -272,6 +272,41 @@ describe("renderAgents", () => {
);
});
it.each([
{ name: "a string primary", model: "openai/gpt-5.4" },
{ name: "an object primary", model: { primary: "openai/gpt-5.4" } },
])("does not display inherited fallback chips for $name", ({ model }) => {
const container = document.createElement("div");
const fallback = "anthropic/claude-sonnet-4-6";
render(
renderAgents(
createProps({
selectedAgentId: "beta",
config: {
form: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.4", fallbacks: [fallback] },
},
list: [{ id: "alpha" }, { id: "beta", model }],
},
},
loading: false,
saving: false,
dirty: false,
},
}),
),
container,
);
expect(container.querySelectorAll(".agent-chip-input .chip")).toHaveLength(0);
expect(container.querySelector<HTMLInputElement>(".agent-chip-input input")?.placeholder).toBe(
"provider/model",
);
});
it("remounts overview model controls when switching selected agents", async () => {
const container = document.createElement("div");
const configForm = {