Agents UI: fix effective model and file hydration

This commit is contained in:
Vignesh Natarajan 2026-03-28 21:10:06 -07:00
parent 27188fa39f
commit 384a590e54
No known key found for this signature in database
GPG Key ID: C5E014CC92E2A144
9 changed files with 326 additions and 8 deletions

View File

@ -28,6 +28,16 @@ export const AgentSummarySchema = Type.Object(
{ additionalProperties: false },
),
),
workspace: Type.Optional(NonEmptyString),
model: Type.Optional(
Type.Object(
{
primary: Type.Optional(NonEmptyString),
fallbacks: Type.Optional(Type.Array(NonEmptyString)),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);

View File

@ -548,6 +548,60 @@ describe("gateway session utils", () => {
expect(agents.map((agent) => agent.id)).toEqual(["main"]);
});
});
test("listAgentsForGateway includes effective workspace + model for default agent", () => {
const cfg = {
session: { mainKey: "main" },
agents: {
defaults: {
workspace: "/tmp/default-workspace",
model: {
primary: "openai/gpt-5.4",
fallbacks: ["openai-codex/gpt-5.2-codex"],
},
},
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
const result = listAgentsForGateway(cfg);
expect(result.agents[0]).toMatchObject({
id: "main",
workspace: "/tmp/default-workspace",
model: {
primary: "openai/gpt-5.4",
fallbacks: ["openai-codex/gpt-5.2-codex"],
},
});
});
test("listAgentsForGateway respects per-agent fallback override (including explicit empty list)", () => {
const cfg = {
session: { mainKey: "main" },
agents: {
defaults: {
model: {
primary: "openai/gpt-5.4",
fallbacks: ["openai-codex/gpt-5.2-codex"],
},
},
list: [
{ id: "main", default: true },
{
id: "ops",
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: [],
},
},
],
},
} as OpenClawConfig;
const result = listAgentsForGateway(cfg);
const ops = result.agents.find((agent) => agent.id === "ops");
expect(ops?.model).toEqual({ primary: "anthropic/claude-opus-4-6" });
});
});
describe("resolveSessionModelRef", () => {

View File

@ -1,6 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
resolveAgentEffectiveModelPrimary,
resolveAgentModelFallbacksOverride,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { lookupContextTokens, resolveContextTokensForModel } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
@ -17,6 +22,7 @@ import {
resolveSubagentSessionStatus,
} from "../agents/subagent-registry-read.js";
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import { resolveStateDir } from "../config/paths.js";
import {
buildGroupDisplayName,
@ -576,6 +582,41 @@ function listConfiguredAgentIds(cfg: OpenClawConfig): string[] {
: sorted;
}
function normalizeFallbackList(values: readonly string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const trimmed = value.trim();
if (!trimmed) {
continue;
}
const key = trimmed.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
out.push(trimmed);
}
return out;
}
function resolveGatewayAgentModel(
cfg: OpenClawConfig,
agentId: string,
): GatewayAgentRow["model"] | undefined {
const primary = resolveAgentEffectiveModelPrimary(cfg, agentId)?.trim();
const fallbackOverride = resolveAgentModelFallbacksOverride(cfg, agentId);
const defaultFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.model);
const fallbacks = normalizeFallbackList(fallbackOverride ?? defaultFallbacks);
if (!primary && fallbacks.length === 0) {
return undefined;
}
return {
...(primary ? { primary } : {}),
...(fallbacks.length > 0 ? { fallbacks } : {}),
};
}
export function listAgentsForGateway(cfg: OpenClawConfig): {
defaultId: string;
mainKey: string;
@ -625,10 +666,13 @@ export function listAgentsForGateway(cfg: OpenClawConfig): {
}
const agents = agentIds.map((id) => {
const meta = configuredById.get(id);
const model = resolveGatewayAgentModel(cfg, id);
return {
id,
name: meta?.name,
identity: meta?.identity,
workspace: resolveAgentWorkspaceDir(cfg, id),
...(model ? { model } : {}),
};
});
return { defaultId, mainKey, scope, agents };

View File

@ -6,10 +6,17 @@ export type GatewayAgentIdentity = {
avatarUrl?: string;
};
export type GatewayAgentModel = {
primary?: string;
fallbacks?: string[];
};
export type GatewayAgentRow = {
id: string;
name?: string;
identity?: GatewayAgentIdentity;
workspace?: string;
model?: GatewayAgentModel;
};
export type SessionsListResultBase<TDefaults, TRow> = {

View File

@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadAgentsMock = vi.hoisted(() =>
vi.fn(async (host: { agentsList?: unknown }) => {
host.agentsList = {
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [{ id: "main" }],
};
}),
);
const loadConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentIdentitiesMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentIdentityMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentSkillsMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentFilesMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadChannelsMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../ui/src/ui/controllers/agents.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/agents.ts")>();
return { ...actual, loadAgents: loadAgentsMock };
});
vi.mock("../ui/src/ui/controllers/config.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/config.ts")>();
return {
...actual,
loadConfig: loadConfigMock,
loadConfigSchema: vi.fn(async () => undefined),
};
});
vi.mock("../ui/src/ui/controllers/agent-identity.ts", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../ui/src/ui/controllers/agent-identity.ts")>();
return {
...actual,
loadAgentIdentities: loadAgentIdentitiesMock,
loadAgentIdentity: loadAgentIdentityMock,
};
});
vi.mock("../ui/src/ui/controllers/agent-skills.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/agent-skills.ts")>();
return { ...actual, loadAgentSkills: loadAgentSkillsMock };
});
vi.mock("../ui/src/ui/controllers/agent-files.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/agent-files.ts")>();
return { ...actual, loadAgentFiles: loadAgentFilesMock };
});
vi.mock("../ui/src/ui/controllers/channels.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/channels.ts")>();
return { ...actual, loadChannels: loadChannelsMock };
});
vi.mock("../ui/src/ui/controllers/cron.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/cron.ts")>();
return {
...actual,
loadCronJobs: vi.fn(async () => undefined),
loadCronRuns: vi.fn(async () => undefined),
loadCronStatus: vi.fn(async () => undefined),
};
});
import { refreshActiveTab } from "../ui/src/ui/app-settings.ts";
type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
function createHost(agentsPanel: AgentsPanel): Parameters<typeof refreshActiveTab>[0] {
return {
tab: "agents",
connected: true,
agentsPanel,
agentsList: null,
agentsSelectedId: null,
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
},
theme: "claw",
themeMode: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
} as Parameters<typeof refreshActiveTab>[0];
}
describe("refreshActiveTab (agents/files)", () => {
beforeEach(() => {
loadAgentsMock.mockClear();
loadConfigMock.mockClear();
loadAgentIdentitiesMock.mockClear();
loadAgentIdentityMock.mockClear();
loadAgentSkillsMock.mockClear();
loadAgentFilesMock.mockClear();
loadChannelsMock.mockClear();
});
it("loads agent files when the active agents panel is files", async () => {
const host = createHost("files");
await refreshActiveTab(host);
expect(loadAgentFilesMock).toHaveBeenCalledTimes(1);
expect(loadAgentFilesMock).toHaveBeenCalledWith(host, "main");
});
it("does not load agent files on non-files panels", async () => {
const host = createHost("overview");
await refreshActiveTab(host);
expect(loadAgentFilesMock).not.toHaveBeenCalled();
});
});

View File

@ -8,6 +8,7 @@ import {
} from "./app-polling.ts";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
import type { OpenClawApp } from "./app.ts";
import { loadAgentFiles } from "./controllers/agent-files.ts";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents } from "./controllers/agents.ts";
@ -246,6 +247,9 @@ export async function refreshActiveTab(host: SettingsHost) {
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
if (agentId) {
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
if (host.agentsPanel === "files") {
void loadAgentFiles(host as unknown as OpenClawApp, agentId);
}
if (host.agentsPanel === "skills") {
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
}

View File

@ -49,20 +49,31 @@ export function renderAgentOverview(params: {
onSelectPanel,
} = params;
const config = resolveAgentConfig(configForm, agent.id);
const agentModel = agent.model;
const workspaceFromFiles =
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
const workspace =
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
workspaceFromFiles ||
config.entry?.workspace ||
config.defaults?.workspace ||
agent.workspace ||
"default";
const model = config.entry?.model
? resolveModelLabel(config.entry?.model)
: resolveModelLabel(config.defaults?.model);
const defaultModel = resolveModelLabel(config.defaults?.model);
: config.defaults?.model
? resolveModelLabel(config.defaults?.model)
: resolveModelLabel(agentModel);
const defaultModel = resolveModelLabel(config.defaults?.model ?? agentModel);
const entryPrimary = resolveModelPrimary(config.entry?.model);
const defaultPrimary =
resolveModelPrimary(config.defaults?.model) ||
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null) ||
(configForm ? null : resolveModelPrimary(agentModel));
const effectivePrimary = entryPrimary ?? defaultPrimary ?? null;
const modelFallbacks = resolveModelFallbacks(config.entry?.model);
const modelFallbacks =
resolveModelFallbacks(config.entry?.model) ??
resolveModelFallbacks(config.defaults?.model) ??
(configForm ? null : resolveModelFallbacks(agentModel));
const fallbackChips = modelFallbacks ?? [];
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null;

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
agentLogoUrl,
buildAgentContext,
resolveConfiguredCronModelSuggestions,
resolveAgentAvatarUrl,
resolveEffectiveModelFallbacks,
@ -131,3 +132,50 @@ describe("resolveAgentAvatarUrl", () => {
expect(resolveAgentAvatarUrl({ identity: { avatar: "🦞" } })).toBeNull();
});
});
describe("buildAgentContext", () => {
it("falls back to agent payload workspace/model when config form is unavailable", () => {
const context = buildAgentContext(
{
id: "main",
workspace: "/tmp/agent-workspace",
model: {
primary: "openai/gpt-5.4",
fallbacks: ["openai-codex/gpt-5.2-codex"],
},
},
null,
null,
"main",
null,
);
expect(context.workspace).toBe("/tmp/agent-workspace");
expect(context.model).toBe("openai/gpt-5.4 (+1 fallback)");
expect(context.isDefault).toBe(true);
});
it("uses configured defaults when agent-specific overrides are absent", () => {
const context = buildAgentContext(
{ id: "main" },
{
agents: {
defaults: {
workspace: "/tmp/default-workspace",
model: {
primary: "openai/gpt-5.4",
fallbacks: ["openai-codex/gpt-5.2-codex"],
},
},
list: [{ id: "main" }],
},
},
null,
"main",
null,
);
expect(context.workspace).toBe("/tmp/default-workspace");
expect(context.model).toBe("openai/gpt-5.4 (+1 fallback)");
});
});

View File

@ -329,10 +329,16 @@ export function buildAgentContext(
const workspaceFromFiles =
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
const workspace =
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
workspaceFromFiles ||
config.entry?.workspace ||
config.defaults?.workspace ||
agent.workspace ||
"default";
const modelLabel = config.entry?.model
? resolveModelLabel(config.entry?.model)
: resolveModelLabel(config.defaults?.model);
: config.defaults?.model
? resolveModelLabel(config.defaults?.model)
: resolveModelLabel(agent.model);
const identityName =
agentIdentity?.name?.trim() ||
agent.identity?.name?.trim() ||