diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 41b332fd48d..87769ca9843 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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 }, ); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 49e297dd246..42d7e643b17 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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", () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 837f19bde3d..590ebcf5caf 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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(); + 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 }; diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index ca52d394e33..628c9e7d50e 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -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 = { diff --git a/src/ui-app-settings.agents-files-refresh.test.ts b/src/ui-app-settings.agents-files-refresh.test.ts new file mode 100644 index 00000000000..18a623b5bbe --- /dev/null +++ b/src/ui-app-settings.agents-files-refresh.test.ts @@ -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(); + return { ...actual, loadAgents: loadAgentsMock }; +}); + +vi.mock("../ui/src/ui/controllers/config.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: loadConfigMock, + loadConfigSchema: vi.fn(async () => undefined), + }; +}); + +vi.mock("../ui/src/ui/controllers/agent-identity.ts", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadAgentIdentities: loadAgentIdentitiesMock, + loadAgentIdentity: loadAgentIdentityMock, + }; +}); + +vi.mock("../ui/src/ui/controllers/agent-skills.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadAgentSkills: loadAgentSkillsMock }; +}); + +vi.mock("../ui/src/ui/controllers/agent-files.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadAgentFiles: loadAgentFilesMock }; +}); + +vi.mock("../ui/src/ui/controllers/channels.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadChannels: loadChannelsMock }; +}); + +vi.mock("../ui/src/ui/controllers/cron.ts", async (importOriginal) => { + const actual = await importOriginal(); + 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[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[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(); + }); +}); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 6532f08cec4..5bb944768dc 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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); } diff --git a/ui/src/ui/views/agents-panels-overview.ts b/ui/src/ui/views/agents-panels-overview.ts index eefb35e2bcf..92775327361 100644 --- a/ui/src/ui/views/agents-panels-overview.ts +++ b/ui/src/ui/views/agents-panels-overview.ts @@ -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; diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index a9b30e549db..3bba0c43fa7 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -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)"); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 5d329ac34df..e408e9af013 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -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() ||