Skip to content

Commit 384a590

Browse files
committed
Agents UI: fix effective model and file hydration
1 parent 27188fa commit 384a590

9 files changed

Lines changed: 326 additions & 8 deletions

src/gateway/protocol/schema/agents-models-skills.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ export const AgentSummarySchema = Type.Object(
2828
{ additionalProperties: false },
2929
),
3030
),
31+
workspace: Type.Optional(NonEmptyString),
32+
model: Type.Optional(
33+
Type.Object(
34+
{
35+
primary: Type.Optional(NonEmptyString),
36+
fallbacks: Type.Optional(Type.Array(NonEmptyString)),
37+
},
38+
{ additionalProperties: false },
39+
),
40+
),
3141
},
3242
{ additionalProperties: false },
3343
);

src/gateway/session-utils.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,60 @@ describe("gateway session utils", () => {
548548
expect(agents.map((agent) => agent.id)).toEqual(["main"]);
549549
});
550550
});
551+
552+
test("listAgentsForGateway includes effective workspace + model for default agent", () => {
553+
const cfg = {
554+
session: { mainKey: "main" },
555+
agents: {
556+
defaults: {
557+
workspace: "/tmp/default-workspace",
558+
model: {
559+
primary: "openai/gpt-5.4",
560+
fallbacks: ["openai-codex/gpt-5.2-codex"],
561+
},
562+
},
563+
list: [{ id: "main", default: true }],
564+
},
565+
} as OpenClawConfig;
566+
567+
const result = listAgentsForGateway(cfg);
568+
expect(result.agents[0]).toMatchObject({
569+
id: "main",
570+
workspace: "/tmp/default-workspace",
571+
model: {
572+
primary: "openai/gpt-5.4",
573+
fallbacks: ["openai-codex/gpt-5.2-codex"],
574+
},
575+
});
576+
});
577+
578+
test("listAgentsForGateway respects per-agent fallback override (including explicit empty list)", () => {
579+
const cfg = {
580+
session: { mainKey: "main" },
581+
agents: {
582+
defaults: {
583+
model: {
584+
primary: "openai/gpt-5.4",
585+
fallbacks: ["openai-codex/gpt-5.2-codex"],
586+
},
587+
},
588+
list: [
589+
{ id: "main", default: true },
590+
{
591+
id: "ops",
592+
model: {
593+
primary: "anthropic/claude-opus-4-6",
594+
fallbacks: [],
595+
},
596+
},
597+
],
598+
},
599+
} as OpenClawConfig;
600+
601+
const result = listAgentsForGateway(cfg);
602+
const ops = result.agents.find((agent) => agent.id === "ops");
603+
expect(ops?.model).toEqual({ primary: "anthropic/claude-opus-4-6" });
604+
});
551605
});
552606

553607
describe("resolveSessionModelRef", () => {

src/gateway/session-utils.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
3+
import {
4+
resolveAgentEffectiveModelPrimary,
5+
resolveAgentModelFallbacksOverride,
6+
resolveAgentWorkspaceDir,
7+
resolveDefaultAgentId,
8+
} from "../agents/agent-scope.js";
49
import { lookupContextTokens, resolveContextTokensForModel } from "../agents/context.js";
510
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
611
import {
@@ -17,6 +22,7 @@ import {
1722
resolveSubagentSessionStatus,
1823
} from "../agents/subagent-registry-read.js";
1924
import { type OpenClawConfig, loadConfig } from "../config/config.js";
25+
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
2026
import { resolveStateDir } from "../config/paths.js";
2127
import {
2228
buildGroupDisplayName,
@@ -576,6 +582,41 @@ function listConfiguredAgentIds(cfg: OpenClawConfig): string[] {
576582
: sorted;
577583
}
578584

585+
function normalizeFallbackList(values: readonly string[]): string[] {
586+
const out: string[] = [];
587+
const seen = new Set<string>();
588+
for (const value of values) {
589+
const trimmed = value.trim();
590+
if (!trimmed) {
591+
continue;
592+
}
593+
const key = trimmed.toLowerCase();
594+
if (seen.has(key)) {
595+
continue;
596+
}
597+
seen.add(key);
598+
out.push(trimmed);
599+
}
600+
return out;
601+
}
602+
603+
function resolveGatewayAgentModel(
604+
cfg: OpenClawConfig,
605+
agentId: string,
606+
): GatewayAgentRow["model"] | undefined {
607+
const primary = resolveAgentEffectiveModelPrimary(cfg, agentId)?.trim();
608+
const fallbackOverride = resolveAgentModelFallbacksOverride(cfg, agentId);
609+
const defaultFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.model);
610+
const fallbacks = normalizeFallbackList(fallbackOverride ?? defaultFallbacks);
611+
if (!primary && fallbacks.length === 0) {
612+
return undefined;
613+
}
614+
return {
615+
...(primary ? { primary } : {}),
616+
...(fallbacks.length > 0 ? { fallbacks } : {}),
617+
};
618+
}
619+
579620
export function listAgentsForGateway(cfg: OpenClawConfig): {
580621
defaultId: string;
581622
mainKey: string;
@@ -625,10 +666,13 @@ export function listAgentsForGateway(cfg: OpenClawConfig): {
625666
}
626667
const agents = agentIds.map((id) => {
627668
const meta = configuredById.get(id);
669+
const model = resolveGatewayAgentModel(cfg, id);
628670
return {
629671
id,
630672
name: meta?.name,
631673
identity: meta?.identity,
674+
workspace: resolveAgentWorkspaceDir(cfg, id),
675+
...(model ? { model } : {}),
632676
};
633677
});
634678
return { defaultId, mainKey, scope, agents };

src/shared/session-types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ export type GatewayAgentIdentity = {
66
avatarUrl?: string;
77
};
88

9+
export type GatewayAgentModel = {
10+
primary?: string;
11+
fallbacks?: string[];
12+
};
13+
914
export type GatewayAgentRow = {
1015
id: string;
1116
name?: string;
1217
identity?: GatewayAgentIdentity;
18+
workspace?: string;
19+
model?: GatewayAgentModel;
1320
};
1421

1522
export type SessionsListResultBase<TDefaults, TRow> = {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const loadAgentsMock = vi.hoisted(() =>
4+
vi.fn(async (host: { agentsList?: unknown }) => {
5+
host.agentsList = {
6+
defaultId: "main",
7+
mainKey: "main",
8+
scope: "per-sender",
9+
agents: [{ id: "main" }],
10+
};
11+
}),
12+
);
13+
const loadConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
14+
const loadAgentIdentitiesMock = vi.hoisted(() => vi.fn(async () => undefined));
15+
const loadAgentIdentityMock = vi.hoisted(() => vi.fn(async () => undefined));
16+
const loadAgentSkillsMock = vi.hoisted(() => vi.fn(async () => undefined));
17+
const loadAgentFilesMock = vi.hoisted(() => vi.fn(async () => undefined));
18+
const loadChannelsMock = vi.hoisted(() => vi.fn(async () => undefined));
19+
20+
vi.mock("../ui/src/ui/controllers/agents.ts", async (importOriginal) => {
21+
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/agents.ts")>();
22+
return { ...actual, loadAgents: loadAgentsMock };
23+
});
24+
25+
vi.mock("../ui/src/ui/controllers/config.ts", async (importOriginal) => {
26+
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/config.ts")>();
27+
return {
28+
...actual,
29+
loadConfig: loadConfigMock,
30+
loadConfigSchema: vi.fn(async () => undefined),
31+
};
32+
});
33+
34+
vi.mock("../ui/src/ui/controllers/agent-identity.ts", async (importOriginal) => {
35+
const actual =
36+
await importOriginal<typeof import("../ui/src/ui/controllers/agent-identity.ts")>();
37+
return {
38+
...actual,
39+
loadAgentIdentities: loadAgentIdentitiesMock,
40+
loadAgentIdentity: loadAgentIdentityMock,
41+
};
42+
});
43+
44+
vi.mock("../ui/src/ui/controllers/agent-skills.ts", async (importOriginal) => {
45+
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/agent-skills.ts")>();
46+
return { ...actual, loadAgentSkills: loadAgentSkillsMock };
47+
});
48+
49+
vi.mock("../ui/src/ui/controllers/agent-files.ts", async (importOriginal) => {
50+
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/agent-files.ts")>();
51+
return { ...actual, loadAgentFiles: loadAgentFilesMock };
52+
});
53+
54+
vi.mock("../ui/src/ui/controllers/channels.ts", async (importOriginal) => {
55+
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/channels.ts")>();
56+
return { ...actual, loadChannels: loadChannelsMock };
57+
});
58+
59+
vi.mock("../ui/src/ui/controllers/cron.ts", async (importOriginal) => {
60+
const actual = await importOriginal<typeof import("../ui/src/ui/controllers/cron.ts")>();
61+
return {
62+
...actual,
63+
loadCronJobs: vi.fn(async () => undefined),
64+
loadCronRuns: vi.fn(async () => undefined),
65+
loadCronStatus: vi.fn(async () => undefined),
66+
};
67+
});
68+
69+
import { refreshActiveTab } from "../ui/src/ui/app-settings.ts";
70+
71+
type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
72+
73+
function createHost(agentsPanel: AgentsPanel): Parameters<typeof refreshActiveTab>[0] {
74+
return {
75+
tab: "agents",
76+
connected: true,
77+
agentsPanel,
78+
agentsList: null,
79+
agentsSelectedId: null,
80+
settings: {
81+
gatewayUrl: "",
82+
token: "",
83+
sessionKey: "main",
84+
lastActiveSessionKey: "main",
85+
theme: "claw",
86+
themeMode: "system",
87+
chatFocusMode: false,
88+
chatShowThinking: true,
89+
chatShowToolCalls: true,
90+
splitRatio: 0.6,
91+
navCollapsed: false,
92+
navWidth: 220,
93+
navGroupsCollapsed: {},
94+
borderRadius: 50,
95+
},
96+
theme: "claw",
97+
themeMode: "system",
98+
themeResolved: "dark",
99+
applySessionKey: "main",
100+
sessionKey: "main",
101+
chatHasAutoScrolled: false,
102+
logsAtBottom: false,
103+
eventLog: [],
104+
eventLogBuffer: [],
105+
basePath: "",
106+
} as Parameters<typeof refreshActiveTab>[0];
107+
}
108+
109+
describe("refreshActiveTab (agents/files)", () => {
110+
beforeEach(() => {
111+
loadAgentsMock.mockClear();
112+
loadConfigMock.mockClear();
113+
loadAgentIdentitiesMock.mockClear();
114+
loadAgentIdentityMock.mockClear();
115+
loadAgentSkillsMock.mockClear();
116+
loadAgentFilesMock.mockClear();
117+
loadChannelsMock.mockClear();
118+
});
119+
120+
it("loads agent files when the active agents panel is files", async () => {
121+
const host = createHost("files");
122+
await refreshActiveTab(host);
123+
124+
expect(loadAgentFilesMock).toHaveBeenCalledTimes(1);
125+
expect(loadAgentFilesMock).toHaveBeenCalledWith(host, "main");
126+
});
127+
128+
it("does not load agent files on non-files panels", async () => {
129+
const host = createHost("overview");
130+
await refreshActiveTab(host);
131+
132+
expect(loadAgentFilesMock).not.toHaveBeenCalled();
133+
});
134+
});

ui/src/ui/app-settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "./app-polling.ts";
99
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
1010
import type { OpenClawApp } from "./app.ts";
11+
import { loadAgentFiles } from "./controllers/agent-files.ts";
1112
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
1213
import { loadAgentSkills } from "./controllers/agent-skills.ts";
1314
import { loadAgents } from "./controllers/agents.ts";
@@ -246,6 +247,9 @@ export async function refreshActiveTab(host: SettingsHost) {
246247
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
247248
if (agentId) {
248249
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
250+
if (host.agentsPanel === "files") {
251+
void loadAgentFiles(host as unknown as OpenClawApp, agentId);
252+
}
249253
if (host.agentsPanel === "skills") {
250254
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
251255
}

ui/src/ui/views/agents-panels-overview.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,31 @@ export function renderAgentOverview(params: {
4949
onSelectPanel,
5050
} = params;
5151
const config = resolveAgentConfig(configForm, agent.id);
52+
const agentModel = agent.model;
5253
const workspaceFromFiles =
5354
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
5455
const workspace =
55-
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
56+
workspaceFromFiles ||
57+
config.entry?.workspace ||
58+
config.defaults?.workspace ||
59+
agent.workspace ||
60+
"default";
5661
const model = config.entry?.model
5762
? resolveModelLabel(config.entry?.model)
58-
: resolveModelLabel(config.defaults?.model);
59-
const defaultModel = resolveModelLabel(config.defaults?.model);
63+
: config.defaults?.model
64+
? resolveModelLabel(config.defaults?.model)
65+
: resolveModelLabel(agentModel);
66+
const defaultModel = resolveModelLabel(config.defaults?.model ?? agentModel);
6067
const entryPrimary = resolveModelPrimary(config.entry?.model);
6168
const defaultPrimary =
6269
resolveModelPrimary(config.defaults?.model) ||
63-
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
70+
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null) ||
71+
(configForm ? null : resolveModelPrimary(agentModel));
6472
const effectivePrimary = entryPrimary ?? defaultPrimary ?? null;
65-
const modelFallbacks = resolveModelFallbacks(config.entry?.model);
73+
const modelFallbacks =
74+
resolveModelFallbacks(config.entry?.model) ??
75+
resolveModelFallbacks(config.defaults?.model) ??
76+
(configForm ? null : resolveModelFallbacks(agentModel));
6677
const fallbackChips = modelFallbacks ?? [];
6778
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
6879
const skillCount = skillFilter?.length ?? null;

0 commit comments

Comments
 (0)