diff --git a/src/agents/identity-avatar.test.ts b/src/agents/identity-avatar.test.ts index ff023d04b63..aeb48184f66 100644 --- a/src/agents/identity-avatar.test.ts +++ b/src/agents/identity-avatar.test.ts @@ -257,6 +257,57 @@ describe("resolveAgentAvatar", () => { await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true }); }); + it("prefers non-default agent avatar over ui.assistant.avatar with includeUiOverride", async () => { + const root = await createTempAvatarRoot(); + const mainWorkspace = path.join(root, "main"); + const workerWorkspace = path.join(root, "worker"); + await writeFile(path.join(mainWorkspace, "ui-avatar.png")); + await writeFile(path.join(workerWorkspace, "worker-avatar.png")); + + const cfg: OpenClawConfig = { + ui: { assistant: { avatar: "ui-avatar.png" } }, + agents: { + list: [ + { id: "main", workspace: mainWorkspace }, + { id: "worker", workspace: workerWorkspace, identity: { avatar: "worker-avatar.png" } }, + ], + }, + }; + + const workspaceReal = await fs.realpath(workerWorkspace); + const resolved = resolveAgentAvatar(cfg, "worker", { includeUiOverride: true }); + expect(resolved.kind).toBe("local"); + if (resolved.kind === "local") { + const resolvedReal = await fs.realpath(resolved.filePath); + expect(path.relative(workspaceReal, resolvedReal)).toBe("worker-avatar.png"); + } + }); + + it("falls back to ui.assistant.avatar for non-default agents without their own avatar", async () => { + const root = await createTempAvatarRoot(); + const mainWorkspace = path.join(root, "main"); + const workerWorkspace = path.join(root, "worker"); + await writeFile(path.join(workerWorkspace, "ui-avatar.png")); + + const cfg: OpenClawConfig = { + ui: { assistant: { avatar: "ui-avatar.png" } }, + agents: { + list: [ + { id: "main", workspace: mainWorkspace }, + { id: "worker", workspace: workerWorkspace }, + ], + }, + }; + + const workspaceReal = await fs.realpath(workerWorkspace); + const resolved = resolveAgentAvatar(cfg, "worker", { includeUiOverride: true }); + expect(resolved.kind).toBe("local"); + if (resolved.kind === "local") { + const resolvedReal = await fs.realpath(resolved.filePath); + expect(path.relative(workspaceReal, resolvedReal)).toBe("ui-avatar.png"); + } + }); + it("ui.assistant.avatar takes priority over IDENTITY.md avatar with includeUiOverride", async () => { const root = await createTempAvatarRoot(); const workspace = path.join(root, "work"); diff --git a/src/agents/identity-avatar.ts b/src/agents/identity-avatar.ts index 9f04d63f30e..2eb3df6d066 100644 --- a/src/agents/identity-avatar.ts +++ b/src/agents/identity-avatar.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { AVATAR_MAX_BYTES, hasAvatarUriScheme, @@ -12,7 +13,7 @@ import { } from "../shared/avatar-policy.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; -import { resolveAgentWorkspaceDir } from "./agent-scope.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; import { loadAgentIdentityFromWorkspace } from "./identity-file.js"; import { resolveAgentIdentity } from "./identity.js"; @@ -35,20 +36,26 @@ function resolveAvatarSource( agentId: string, opts?: { includeUiOverride?: boolean }, ): string | null { + const normalizedAgentId = normalizeAgentId(agentId); + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const fromUiConfig = normalizeOptionalString(cfg.ui?.assistant?.avatar) ?? null; if (opts?.includeUiOverride) { - const fromUiConfig = normalizeOptionalString(cfg.ui?.assistant?.avatar) ?? null; - if (fromUiConfig) { + if (normalizedAgentId === defaultAgentId && fromUiConfig) { return fromUiConfig; } } - const fromConfig = normalizeOptionalString(resolveAgentIdentity(cfg, agentId)?.avatar) ?? null; + const fromConfig = + normalizeOptionalString(resolveAgentIdentity(cfg, normalizedAgentId)?.avatar) ?? null; if (fromConfig) { return fromConfig; } - const workspace = resolveAgentWorkspaceDir(cfg, agentId); + const workspace = resolveAgentWorkspaceDir(cfg, normalizedAgentId); const fromIdentity = normalizeOptionalString(loadAgentIdentityFromWorkspace(workspace)?.avatar) ?? null; - return fromIdentity; + if (fromIdentity) { + return fromIdentity; + } + return opts?.includeUiOverride ? fromUiConfig : null; } function resolveExistingPath(value: string): string { diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index 3d2f34a302d..443ff48e2a6 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -3,6 +3,68 @@ import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; describe("resolveAssistantIdentity avatar normalization", () => { + it("keeps ui.assistant identity authoritative for the default agent", () => { + const cfg: OpenClawConfig = { + ui: { + assistant: { + name: "Main assistant", + avatar: "M", + }, + }, + agents: { + list: [{ id: "main", identity: { name: "Main agent", avatar: "A" } }], + }, + }; + + expect(resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" })).toMatchObject({ + agentId: "main", + name: "Main assistant", + avatar: "M", + }); + }); + + it("prefers non-default agent identity over global ui.assistant identity", () => { + const cfg: OpenClawConfig = { + ui: { + assistant: { + name: "AI大管家", + avatar: "M", + }, + }, + agents: { + list: [{ id: "main" }, { id: "fs-daying", identity: { name: "大颖", avatar: "D" } }], + }, + }; + + expect(resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" })).toMatchObject( + { + agentId: "fs-daying", + name: "大颖", + avatar: "D", + }, + ); + }); + + it("falls back to ui.assistant identity for non-default agents without their own identity", () => { + const cfg: OpenClawConfig = { + ui: { + assistant: { + name: "Main assistant", + avatar: "M", + }, + }, + agents: { + list: [{ id: "worker" }], + }, + }; + + expect(resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" })).toMatchObject({ + agentId: "worker", + name: "Main assistant", + avatar: "M", + }); + }); + it("drops sentence-like avatar placeholders", () => { const cfg: OpenClawConfig = { ui: { diff --git a/src/gateway/assistant-identity.ts b/src/gateway/assistant-identity.ts index 519887554a5..35a24408f9a 100644 --- a/src/gateway/assistant-identity.ts +++ b/src/gateway/assistant-identity.ts @@ -86,25 +86,31 @@ export function resolveAssistantIdentity(params: { agentId?: string | null; workspaceDir?: string | null; }): AssistantIdentity { - const agentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg)); + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg)); + const agentId = normalizeAgentId(params.agentId ?? defaultAgentId); + const isDefaultAgent = agentId === defaultAgentId; const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId); const configAssistant = params.cfg.ui?.assistant; const agentIdentity = resolveAgentIdentity(params.cfg, agentId); const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null; + const uiName = coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME); + const agentName = coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME); + const fileName = coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME); const name = - coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME) ?? - coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME) ?? - coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ?? + (isDefaultAgent ? (uiName ?? agentName ?? fileName) : (agentName ?? fileName ?? uiName)) ?? DEFAULT_ASSISTANT_IDENTITY.name; - const avatarCandidates = [ - coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR), + const uiAvatar = coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR); + const agentAvatarCandidates = [ coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR), coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR), coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR), coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR), ]; + const avatarCandidates = isDefaultAgent + ? [uiAvatar, ...agentAvatarCandidates] + : [...agentAvatarCandidates, uiAvatar]; const avatar = avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ?? DEFAULT_ASSISTANT_IDENTITY.avatar; diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index 831534a7219..aed79683800 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -101,6 +101,98 @@ describe("loadControlUiBootstrapConfig", () => { vi.unstubAllGlobals(); }); + it("does not apply default-agent bootstrap identity to an active non-default session", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "AI大管家", + assistantAvatar: "M", + assistantAgentId: "main", + serverVersion: "2026.4.27", + localMediaPreviewRoots: ["/tmp/openclaw"], + embedSandbox: "trusted", + allowExternalEmbedUrls: true, + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + sessionKey: "agent:fs-daying:main", + assistantName: "大颖", + assistantAvatar: "D", + assistantAvatarSource: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, + assistantAgentId: "fs-daying", + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(state.assistantName).toBe("大颖"); + expect(state.assistantAvatar).toBe("D"); + expect(state.assistantAgentId).toBe("fs-daying"); + expect(state.serverVersion).toBe("2026.4.27"); + expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]); + expect(state.embedSandboxMode).toBe("trusted"); + expect(state.allowExternalEmbedUrls).toBe(true); + + vi.unstubAllGlobals(); + }); + + it("keeps local assistant avatar override when default-agent bootstrap identity is skipped", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Main", + assistantAvatar: "M", + assistantAgentId: "main", + serverVersion: "2026.4.27", + localMediaPreviewRoots: [], + embedSandbox: "scripts", + allowExternalEmbedUrls: false, + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => JSON.stringify({ avatar: "data:image/png;base64,local" })), + setItem: vi.fn(), + removeItem: vi.fn(), + } as unknown as Storage); + + const state = { + basePath: "", + sessionKey: "agent:worker:main", + assistantName: "Worker", + assistantAvatar: "W", + assistantAvatarSource: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, + assistantAgentId: "worker", + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(state.assistantName).toBe("Worker"); + expect(state.assistantAvatar).toBe("data:image/png;base64,local"); + expect(state.assistantAvatarSource).toBe("data:image/png;base64,local"); + expect(state.assistantAvatarStatus).toBe("data"); + expect(state.assistantAvatarReason).toBeNull(); + expect(state.assistantAgentId).toBe("worker"); + + vi.unstubAllGlobals(); + }); + it("ignores failures", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index f94474bd02d..1dbb3d19438 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -6,7 +6,9 @@ import { import { normalizeAssistantIdentity } from "../assistant-identity.ts"; import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts"; import { normalizeBasePath } from "../navigation.ts"; +import { normalizeAgentId, parseAgentSessionKey } from "../session-key.ts"; import { loadLocalAssistantIdentity } from "../storage.ts"; +import { normalizeOptionalString } from "../string-coerce.ts"; export type ControlUiBootstrapState = { basePath: string; @@ -20,11 +22,37 @@ export type ControlUiBootstrapState = { localMediaPreviewRoots: string[]; embedSandboxMode: ControlUiEmbedSandboxMode; allowExternalEmbedUrls: boolean; + sessionKey?: string | null; hello?: { auth?: { deviceToken?: string | null } | null } | null; settings?: { token?: string | null } | null; password?: string | null; }; +function resolveActiveAgentId(state: ControlUiBootstrapState): string | null { + const sessionAgentId = parseAgentSessionKey(state.sessionKey)?.agentId; + if (sessionAgentId) { + return normalizeAgentId(sessionAgentId); + } + const currentAgentId = normalizeOptionalString(state.assistantAgentId); + return currentAgentId ? normalizeAgentId(currentAgentId) : null; +} + +function resolveBootstrapAgentId(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalizeAgentId(normalized) : null; +} + +function applyLocalAssistantAvatarOverride(state: ControlUiBootstrapState) { + const localAvatar = loadLocalAssistantIdentity().avatar; + if (!localAvatar) { + return; + } + state.assistantAvatar = localAvatar; + state.assistantAvatarSource = localAvatar; + state.assistantAvatarStatus = "data"; + state.assistantAvatarReason = null; +} + export async function loadControlUiBootstrapConfig( state: ControlUiBootstrapState, opts?: { applyIdentity?: boolean }, @@ -70,28 +98,26 @@ export async function loadControlUiBootstrapConfig( } const parsed = (await res.json()) as ControlUiBootstrapConfig; if (opts?.applyIdentity !== false) { - const normalized = normalizeAssistantIdentity({ - agentId: parsed.assistantAgentId ?? null, - name: parsed.assistantName, - avatar: parsed.assistantAvatar ?? null, - avatarSource: parsed.assistantAvatarSource ?? null, - avatarStatus: parsed.assistantAvatarStatus ?? null, - avatarReason: parsed.assistantAvatarReason ?? null, - }); - state.assistantName = normalized.name; - state.assistantAvatar = normalized.avatar; - state.assistantAvatarSource = normalized.avatarSource ?? null; - state.assistantAvatarStatus = normalized.avatarStatus ?? null; - state.assistantAvatarReason = normalized.avatarReason ?? null; - state.assistantAgentId = normalized.agentId ?? null; - // Local override always wins — same pattern as the user avatar. - const localAvatar = loadLocalAssistantIdentity().avatar; - if (localAvatar) { - state.assistantAvatar = localAvatar; - state.assistantAvatarSource = localAvatar; - state.assistantAvatarStatus = "data"; - state.assistantAvatarReason = null; + const activeAgentId = resolveActiveAgentId(state); + const bootstrapAgentId = resolveBootstrapAgentId(parsed.assistantAgentId ?? null); + if (!activeAgentId || !bootstrapAgentId || activeAgentId === bootstrapAgentId) { + const normalized = normalizeAssistantIdentity({ + agentId: parsed.assistantAgentId ?? null, + name: parsed.assistantName, + avatar: parsed.assistantAvatar ?? null, + avatarSource: parsed.assistantAvatarSource ?? null, + avatarStatus: parsed.assistantAvatarStatus ?? null, + avatarReason: parsed.assistantAvatarReason ?? null, + }); + state.assistantName = normalized.name; + state.assistantAvatar = normalized.avatar; + state.assistantAvatarSource = normalized.avatarSource ?? null; + state.assistantAvatarStatus = normalized.avatarStatus ?? null; + state.assistantAvatarReason = normalized.avatarReason ?? null; + state.assistantAgentId = normalized.agentId ?? null; } + // Local override always wins — same pattern as the user avatar. + applyLocalAssistantAvatarOverride(state); } state.serverVersion = parsed.serverVersion ?? null; state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots) diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index 681e52a2fa1..47371664359 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -239,4 +239,26 @@ describe("buildAgentContext", () => { expect(context.workspace).toBe("/tmp/default-workspace"); expect(context.model).toBe("openai/gpt-5.5 (+1 fallback)"); }); + + it("prefers per-agent configured identity over runtime global identity in agent panels", () => { + const context = buildAgentContext( + { + id: "fs-daying", + name: "File-system agent", + identity: { name: "大颖", emoji: "⚙️" }, + }, + null, + null, + "main", + { + agentId: "fs-daying", + name: "AI大管家", + avatar: "M", + emoji: "🤖", + }, + ); + + expect(context.identityName).toBe("大颖"); + expect(context.identityAvatar).toBe("⚙️"); + }); }); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index de43b7d7544..162305929ed 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -324,6 +324,25 @@ export function resolveAgentEmoji( return ""; } +function resolveAgentTextAvatar( + agent: { identity?: { emoji?: string; avatar?: string } }, + agentIdentity?: AgentIdentityResult | null, +): string | null { + const candidates = [ + normalizeOptionalString(agent.identity?.emoji), + normalizeOptionalString(agent.identity?.avatar), + normalizeOptionalString(agentIdentity?.emoji), + normalizeOptionalString(agentIdentity?.avatar), + ]; + for (const candidate of candidates) { + const textAvatar = resolveAssistantTextAvatar(candidate); + if (textAvatar) { + return textAvatar; + } + } + return null; +} + export function agentBadgeText(agentId: string, defaultId: string | null) { return defaultId && agentId === defaultId ? "default" : null; } @@ -395,12 +414,14 @@ export function buildAgentContext( ? resolveModelLabel(config.defaults?.model) : resolveModelLabel(agent.model); const identityName = - normalizeOptionalString(agentIdentity?.name) || normalizeOptionalString(agent.identity?.name) || normalizeOptionalString(agent.name) || + normalizeOptionalString(agentIdentity?.name) || config.entry?.name || agent.id; - const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—"; + const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) + ? "custom" + : (resolveAgentTextAvatar(agent, agentIdentity) ?? "—"); const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; const skillCount = skillFilter?.length ?? null; return {