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

resolveAvatarSource in identity-avatar.ts only checked
agents.list[].identity.avatar and IDENTITY.md, ignoring the
ui.assistant.avatar config key written by the CLI config set command
and the Appearance UI.

This meant that `openclaw config set ui.assistant.avatar path/to/img.png`
had no effect on the HTTP avatar endpoint (/avatar/:id), causing the
avatar to not display in the control UI even when the backend served
the correct image from IDENTITY.md.

The fix mirrors the priority order already used by resolveAssistantIdentity
in gateway/assistant-identity.ts:
  1. ui.assistant.avatar (highest)
  2. agents.list[].identity.avatar
  3. IDENTITY.md avatar (lowest)

Tests added for all three priority cases.
This commit is contained in:
Hanna Rosengren
2026-04-04 11:03:42 +02:00
committed by Altay
parent 329fbc3f89
commit f87a167369
3 changed files with 89 additions and 5 deletions

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