diff --git a/ui/src/e2e/agent-model-fallback-ownership.e2e.test.ts b/ui/src/e2e/agent-model-fallback-ownership.e2e.test.ts new file mode 100644 index 000000000000..2e1a80151638 --- /dev/null +++ b/ui/src/e2e/agent-model-fallback-ownership.e2e.test.ts @@ -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(); + } + }); +}); diff --git a/ui/src/lib/agents/display.test.ts b/ui/src/lib/agents/display.test.ts index a1a8199d2d34..e61657f367d8 100644 --- a/ui/src/lib/agents/display.test.ts +++ b/ui/src/lib/agents/display.test.ts @@ -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", diff --git a/ui/src/lib/agents/display.ts b/ui/src/lib/agents/display.ts index 3b7544f44093..e304f77d93fe 100644 --- a/ui/src/lib/agents/display.ts +++ b/ui/src/lib/agents/display.ts @@ -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[] { diff --git a/ui/src/pages/agents/panels-overview.ts b/ui/src/pages/agents/panels-overview.ts index 7f95db961d3f..16dfb4cef6d4 100644 --- a/ui/src/pages/agents/panels-overview.ts +++ b/ui/src/pages/agents/panels-overview.ts @@ -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; diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index c7e843a465f2..fdfcd87e95c2 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -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(".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 = {