fix(ui): scope agent identity to active session

Co-authored-by: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-27 12:45:00 +01:00
parent d25dd7c2bd
commit 74fb6be716
8 changed files with 322 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -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)

View File

@@ -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("⚙️");
});
});

View File

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