fix(avatar): check ui.assistant.avatar in resolveAvatarSource (#60778)

Merged via squash.

Prepared head SHA: df8d953a14
Co-authored-by: hannasdev <4538260+hannasdev@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
Hanna
2026-04-04 23:36:02 +02:00
committed by GitHub
parent 63cabcb524
commit 8b06ca205a
4 changed files with 90 additions and 5 deletions

View File

@@ -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 `<tool_call>`, `<function_calls>`, 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

View File

@@ -15,9 +15,10 @@ async function expectLocalAvatarPath(
cfg: OpenClawConfig,
workspace: string,
expectedRelativePath: string,
opts?: Parameters<typeof resolveAgentAvatar>[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 });
});
});

View File

@@ -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" };
}

View File

@@ -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({