From 8b06ca205ad4f4f1e68e32c50fe256eef0babc67 Mon Sep 17 00:00:00 2001 From: Hanna <4538260+hannasdev@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:36:02 +0200 Subject: [PATCH] fix(avatar): check ui.assistant.avatar in resolveAvatarSource (#60778) Merged via squash. Prepared head SHA: df8d953a14446a26f77e3f0fe7d5072d52abfe5a Co-authored-by: hannasdev <4538260+hannasdev@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/identity-avatar.test.ts | 71 +++++++++++++++++++++++++++++- src/agents/identity-avatar.ts | 20 +++++++-- src/gateway/server-http.ts | 3 +- 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c77e5a8b667..c2797865ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai - Agents/exec: restore `host=node` routing for node-pinned and `host=auto` sessions, while still blocking sandboxed `auto` sessions from jumping to gateway. (#60788) Thanks @openperf. - Agents/compaction: keep assistant tool calls and displaced tool results in the same compaction chunk so strict summarization providers stop rejecting orphaned tool pairs. (#58849) Thanks @openperf. - Outbound/sanitizer: strip leaked ``, ``, and model special tokens from shared user-visible assistant text, including truncated tool-call streams, so internal scaffolding no longer bleeds into replies across surfaces. (#60619) Thanks @oliviareid-svg. +- Control UI/avatar: honor `ui.assistant.avatar` when serving `/avatar/:agentId` so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev. ## 2026.4.2 diff --git a/src/agents/identity-avatar.test.ts b/src/agents/identity-avatar.test.ts index 4bb05bfe354..a36e6b76c11 100644 --- a/src/agents/identity-avatar.test.ts +++ b/src/agents/identity-avatar.test.ts @@ -15,9 +15,10 @@ async function expectLocalAvatarPath( cfg: OpenClawConfig, workspace: string, expectedRelativePath: string, + opts?: Parameters[2], ) { const workspaceReal = await fs.realpath(workspace); - const resolved = resolveAgentAvatar(cfg, "main"); + const resolved = resolveAgentAvatar(cfg, "main", opts); expect(resolved.kind).toBe("local"); if (resolved.kind === "local") { const resolvedReal = await fs.realpath(resolved.filePath); @@ -164,4 +165,72 @@ describe("resolveAgentAvatar", () => { const data = resolveAgentAvatar(cfg, "data"); expect(data.kind).toBe("data"); }); + + it("resolves local avatar from ui.assistant.avatar when no agents.list identity is set", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + const avatarPath = path.join(workspace, "ui-avatar.png"); + await writeFile(avatarPath); + + const cfg: OpenClawConfig = { + ui: { assistant: { avatar: "ui-avatar.png" } }, + agents: { list: [{ id: "main", workspace }] }, + }; + + await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true }); + }); + + it("ui.assistant.avatar ignored without includeUiOverride (outbound callers)", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + const uiAvatarPath = path.join(workspace, "ui-avatar.png"); + const cfgAvatarPath = path.join(workspace, "cfg-avatar.png"); + await writeFile(uiAvatarPath); + await writeFile(cfgAvatarPath); + + const cfg: OpenClawConfig = { + ui: { assistant: { avatar: "ui-avatar.png" } }, + agents: { list: [{ id: "main", workspace, identity: { avatar: "cfg-avatar.png" } }] }, + }; + + // Without the opt-in, outbound callers get the per-agent identity avatar, not the UI override. + await expectLocalAvatarPath(cfg, workspace, "cfg-avatar.png"); + }); + + it("ui.assistant.avatar takes priority over agents.list identity.avatar with includeUiOverride", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + const uiAvatarPath = path.join(workspace, "ui-avatar.png"); + const cfgAvatarPath = path.join(workspace, "cfg-avatar.png"); + await writeFile(uiAvatarPath); + await writeFile(cfgAvatarPath); + + const cfg: OpenClawConfig = { + ui: { assistant: { avatar: "ui-avatar.png" } }, + agents: { list: [{ id: "main", workspace, identity: { avatar: "cfg-avatar.png" } }] }, + }; + + await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true }); + }); + + it("ui.assistant.avatar takes priority over IDENTITY.md avatar with includeUiOverride", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + const uiAvatarPath = path.join(workspace, "ui-avatar.png"); + const identityAvatarPath = path.join(workspace, "identity-avatar.png"); + await writeFile(uiAvatarPath); + await writeFile(identityAvatarPath); + await fs.writeFile( + path.join(workspace, "IDENTITY.md"), + "- Avatar: identity-avatar.png\n", + "utf-8", + ); + + const cfg: OpenClawConfig = { + ui: { assistant: { avatar: "ui-avatar.png" } }, + agents: { list: [{ id: "main", workspace }] }, + }; + + await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true }); + }); }); diff --git a/src/agents/identity-avatar.ts b/src/agents/identity-avatar.ts index f30a5d33453..ae22337b974 100644 --- a/src/agents/identity-avatar.ts +++ b/src/agents/identity-avatar.ts @@ -24,7 +24,17 @@ function normalizeAvatarValue(value: string | undefined | null): string | null { return trimmed ? trimmed : null; } -function resolveAvatarSource(cfg: OpenClawConfig, agentId: string): string | null { +function resolveAvatarSource( + cfg: OpenClawConfig, + agentId: string, + opts?: { includeUiOverride?: boolean }, +): string | null { + if (opts?.includeUiOverride) { + const fromUiConfig = normalizeAvatarValue(cfg.ui?.assistant?.avatar); + if (fromUiConfig) { + return fromUiConfig; + } + } const fromConfig = normalizeAvatarValue(resolveAgentIdentity(cfg, agentId)?.avatar); if (fromConfig) { return fromConfig; @@ -73,8 +83,12 @@ function resolveLocalAvatarPath(params: { return { ok: true, filePath: realPath }; } -export function resolveAgentAvatar(cfg: OpenClawConfig, agentId: string): AgentAvatarResolution { - const source = resolveAvatarSource(cfg, agentId); +export function resolveAgentAvatar( + cfg: OpenClawConfig, + agentId: string, + opts?: { includeUiOverride?: boolean }, +): AgentAvatarResolution { + const source = resolveAvatarSource(cfg, agentId, opts); if (!source) { return { kind: "none", reason: "missing" }; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index fe59c923d9e..97854c6a50d 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -946,7 +946,8 @@ export function createGatewayHttpServer(opts: { run: () => handleControlUiAvatarRequest(req, res, { basePath: controlUiBasePath, - resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId), + resolveAvatar: (agentId) => + resolveAgentAvatar(configSnapshot, agentId, { includeUiOverride: true }), }), }); requestStages.push({