mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): respect agent-owned model fallbacks (#113812)
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
This commit is contained in:
committed by
GitHub
parent
935ee7fedf
commit
eb555bd033
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user