mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
improve: use agent identity as the sole assistant identity (#129067)
* refactor(config): remove duplicate assistant identity * test(config): isolate assistant migration coverage --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
@@ -529,10 +529,6 @@ See [Plugins](/tools/plugin).
|
||||
{
|
||||
ui: {
|
||||
seamColor: "#FF4500",
|
||||
assistant: {
|
||||
name: "OpenClaw",
|
||||
avatar: "CB", // emoji, short text, image URL, or data URI
|
||||
},
|
||||
prefs: {
|
||||
theme: "claw", // claw | knot | dash | custom
|
||||
themeMode: "system", // light | dark | system
|
||||
@@ -547,11 +543,12 @@ See [Plugins](/tools/plugin).
|
||||
}
|
||||
```
|
||||
|
||||
Agent display names, emoji, and avatars belong to each agent's `identity` block under `agents.list`; see [Agent configuration](/gateway/config-agents#agentslist-per-agent-overrides).
|
||||
|
||||
- `seamColor`: operator accent color for native app UI chrome (Talk Mode bubble
|
||||
tint, etc.). The Control UI user accent (`ui.prefs.accent`) takes precedence in
|
||||
`talk.config` payloads and the macOS app's config snapshot. If neither is set,
|
||||
the theme default applies.
|
||||
- `assistant`: Control UI identity override. Falls back to active agent identity.
|
||||
- `prefs`: cross-device operator preferences. This is the canonical home so agents can
|
||||
change them through the approval gate and every Control UI client stays in
|
||||
sync; browsers mirror the values into local storage for instant boot. An
|
||||
|
||||
@@ -126,7 +126,7 @@ Authenticated people have a durable Gateway profile with a display name, avatar,
|
||||
|
||||
GitHub-backed sign-in through Cloudflare Access or Tailscale Serve fills the read-only **GitHub account** row with the verified public avatar and account link without replacing a custom OpenClaw avatar. **Git co-author credit** is a separate, default-off toggle for future commits from shared sessions. See [User model](/concepts/user-model#gateway-profile-and-github-credit) for verification, retry, account-change, noreply privacy, and eligibility rules.
|
||||
|
||||
The assistant avatar override follows the same browser-local pattern: uploaded overrides overlay the gateway-resolved identity locally and never round-trip through `config.patch`. The shared `ui.assistant.avatar` config field is still available for non-UI clients that write the field directly.
|
||||
Set an agent's display name, emoji, and avatar under **Agent settings → Overview → Identity**. The identity is stored with that agent and is shared by Control UI clients.
|
||||
|
||||
## Runtime config endpoint
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
|
||||
import { AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-limits.js";
|
||||
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
||||
import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js";
|
||||
@@ -18,12 +17,11 @@ async function expectLocalAvatarPath(
|
||||
cfg: OpenClawConfig,
|
||||
workspace: string,
|
||||
expectedRelativePath: string,
|
||||
opts?: Parameters<typeof resolveAgentAvatar>[2],
|
||||
) {
|
||||
// Compare realpaths so symlinks or temp-dir normalization cannot hide an
|
||||
// avatar escaping the configured workspace.
|
||||
const workspaceReal = await fs.realpath(workspace);
|
||||
const resolved = resolveAgentAvatar(cfg, "main", opts);
|
||||
const resolved = resolveAgentAvatar(cfg, "main");
|
||||
expect(resolved.kind).toBe("local");
|
||||
if (resolved.kind === "local") {
|
||||
const resolvedReal = await fs.realpath(resolved.filePath);
|
||||
@@ -39,20 +37,6 @@ async function createTempAvatarRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
async function setupUiAndConfigAvatarWorkspace() {
|
||||
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" } }] },
|
||||
};
|
||||
return { cfg, workspace };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
@@ -257,121 +241,4 @@ describe("resolveAgentAvatar", () => {
|
||||
url: oversized,
|
||||
});
|
||||
});
|
||||
|
||||
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 { cfg, workspace } = await setupUiAndConfigAvatarWorkspace();
|
||||
|
||||
// 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 { cfg, workspace } = await setupUiAndConfigAvatarWorkspace();
|
||||
|
||||
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("scopes ui.assistant.avatar to the sole or retained compatibility owner", () => {
|
||||
const migratedCfg = retainLegacyDefaultAgentId(
|
||||
{
|
||||
ui: { assistant: { avatar: "https://example.com/ui-avatar.png" } },
|
||||
agents: { ownership: "explicit", list: [{ id: "research" }, { id: "ops" }] },
|
||||
},
|
||||
"ops",
|
||||
);
|
||||
|
||||
expect(resolveAgentAvatar(migratedCfg, "ops", { includeUiOverride: true })).toMatchObject({
|
||||
kind: "remote",
|
||||
url: "https://example.com/ui-avatar.png",
|
||||
});
|
||||
expect(resolveAgentAvatar(migratedCfg, "research", { includeUiOverride: true })).toEqual({
|
||||
kind: "none",
|
||||
reason: "missing",
|
||||
});
|
||||
expect(
|
||||
resolveAgentAvatar(
|
||||
{
|
||||
ui: { assistant: { avatar: "https://example.com/ui-avatar.png" } },
|
||||
agents: { ownership: "explicit", list: [{ id: "research" }, { id: "ops" }] },
|
||||
},
|
||||
"ops",
|
||||
{ includeUiOverride: true },
|
||||
),
|
||||
).toEqual({ kind: "none", reason: "missing" });
|
||||
|
||||
const rawLegacyCfg: OpenClawConfig = {
|
||||
ui: { assistant: { avatar: "https://example.com/raw-ui-avatar.png" } },
|
||||
agents: { list: [{ id: "research" }, { id: "ops", default: true }] },
|
||||
};
|
||||
expect(resolveAgentAvatar(rawLegacyCfg, "ops", { includeUiOverride: true })).toMatchObject({
|
||||
kind: "remote",
|
||||
url: "https://example.com/raw-ui-avatar.png",
|
||||
});
|
||||
expect(resolveAgentAvatar(rawLegacyCfg, "research", { includeUiOverride: true })).toEqual({
|
||||
kind: "none",
|
||||
reason: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
@@ -34,19 +33,8 @@ type AgentAvatarPublicSourceInput = {
|
||||
const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256;
|
||||
const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64;
|
||||
|
||||
function resolveAvatarSource(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { includeUiOverride?: boolean },
|
||||
): string | null {
|
||||
function resolveAvatarSource(cfg: OpenClawConfig, agentId: string): string | null {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const fromUiConfig = normalizeOptionalString(cfg.ui?.assistant?.avatar) ?? null;
|
||||
if (opts?.includeUiOverride) {
|
||||
// The shared UI avatar belongs only to the sole or retained compatibility owner.
|
||||
if (normalizedAgentId === tryResolveLegacyCompatibilityAgentId(cfg) && fromUiConfig) {
|
||||
return fromUiConfig;
|
||||
}
|
||||
}
|
||||
const fromConfig =
|
||||
normalizeOptionalString(resolveAgentIdentity(cfg, normalizedAgentId)?.avatar) ?? null;
|
||||
if (fromConfig) {
|
||||
@@ -100,12 +88,8 @@ export function resolvePublicAgentAvatarSource(
|
||||
}
|
||||
|
||||
/** Resolve the effective avatar for an agent, including config and IDENTITY.md. */
|
||||
export function resolveAgentAvatar(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { includeUiOverride?: boolean },
|
||||
): AgentAvatarResolution {
|
||||
const source = resolveAvatarSource(cfg, agentId, opts);
|
||||
export function resolveAgentAvatar(cfg: OpenClawConfig, agentId: string): AgentAvatarResolution {
|
||||
const source = resolveAvatarSource(cfg, agentId);
|
||||
if (!source) {
|
||||
return { kind: "none", reason: "missing" };
|
||||
}
|
||||
|
||||
@@ -440,6 +440,21 @@ function migrateFinalLayoutKills(raw: Record<string, unknown>, changes: string[]
|
||||
}
|
||||
}
|
||||
|
||||
function removeUiAssistantIdentity(raw: Record<string, unknown>, changes: string[]): void {
|
||||
const ui = getRecord(raw.ui);
|
||||
if (!ui || !Object.hasOwn(ui, "assistant")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The retired override was presentation-only. Translating it into agent identity
|
||||
// would unexpectedly change outbound channel identity.
|
||||
delete ui.assistant;
|
||||
if (Object.keys(ui).length === 0) {
|
||||
delete raw.ui;
|
||||
}
|
||||
changes.push("Removed retired ui.assistant; configure agents.list[].identity instead.");
|
||||
}
|
||||
|
||||
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec[] = [
|
||||
LEGACY_CONFIG_MIGRATION_RUNTIME_MEMORY_QMD,
|
||||
defineLegacyConfigMigration({
|
||||
@@ -556,6 +571,14 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec
|
||||
}
|
||||
},
|
||||
}),
|
||||
defineLegacyConfigMigration({
|
||||
id: "runtime.ui-assistant-identity",
|
||||
describe: "Remove the retired UI assistant identity override",
|
||||
legacyRules: [
|
||||
rule(["ui", "assistant"], "ui.assistant was retired; use agents.list[].identity instead."),
|
||||
],
|
||||
apply: removeUiAssistantIdentity,
|
||||
}),
|
||||
defineLegacyConfigMigration({
|
||||
id: "runtime.retired-config-keys",
|
||||
describe: "Migrate retired root and tool config keys",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED } from "./legacy-config-migrations.runtime.retired.js";
|
||||
|
||||
function migrateUiAssistant(raw: Record<string, unknown>) {
|
||||
const changes: string[] = [];
|
||||
const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED.find(
|
||||
(candidate) => candidate.id === "runtime.ui-assistant-identity",
|
||||
);
|
||||
expect(migration).toBeDefined();
|
||||
migration?.apply(raw, changes);
|
||||
return { raw, changes };
|
||||
}
|
||||
|
||||
describe("retired UI assistant identity migration", () => {
|
||||
it("removes the override without changing agent identity", () => {
|
||||
const result = migrateUiAssistant({
|
||||
ui: {
|
||||
seamColor: "#ff4500",
|
||||
assistant: { name: "UI name", avatar: "avatars/ui.png" },
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "worker", identity: { name: "Worker" } },
|
||||
{ id: "primary", default: true, identity: { name: "Main", emoji: "🦞" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.raw).toMatchObject({
|
||||
ui: { seamColor: "#ff4500" },
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "worker", identity: { name: "Worker" } },
|
||||
{ id: "primary", default: true, identity: { name: "Main", emoji: "🦞" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.raw).not.toHaveProperty("ui.assistant");
|
||||
expect(result.changes).toContain(
|
||||
"Removed retired ui.assistant; configure agents.list[].identity instead.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create an agent identity", () => {
|
||||
const result = migrateUiAssistant({
|
||||
ui: { assistant: { name: "OpenClaw", avatar: "🦞" } },
|
||||
});
|
||||
|
||||
expect(result.raw).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -603,8 +603,6 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`]
|
||||
"tts.providers.*.apiKey.id",
|
||||
"tts.providers.*.apiKey.provider",
|
||||
"tts.providers.*.apiKey.source",
|
||||
"ui.assistant.avatar",
|
||||
"ui.assistant.name",
|
||||
"ui.prefs.accent",
|
||||
"ui.prefs.chatFollowUpMode",
|
||||
"ui.prefs.chatPersistCommentary",
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
// Defines user-facing config field help text for docs and UI surfaces.
|
||||
export const AGENT_FIELD_HELP: Record<string, string> = {
|
||||
ui: "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.",
|
||||
ui: "UI presentation settings for accenting and operator display preferences. Use this for readability customization without changing runtime behavior.",
|
||||
"ui.seamColor":
|
||||
"Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.",
|
||||
"ui.prefs.accent":
|
||||
"User-selected Control UI accent color in #RRGGBB format. Overrides ui.seamColor; clear it to restore the configured seam color or theme default.",
|
||||
"ui.assistant":
|
||||
"Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.",
|
||||
"ui.assistant.name":
|
||||
"Display name shown for the assistant in UI views, chat chrome, and status contexts. Keep this stable so operators can reliably identify which assistant persona is active.",
|
||||
"ui.assistant.avatar":
|
||||
"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.",
|
||||
tui: "Terminal UI display settings. Use this section for terminal-only presentation preferences without changing Gateway or other UI behavior.",
|
||||
"tui.footer":
|
||||
"Terminal UI footer display settings. Keep optional context compact so session, model, goal, and token information stay readable.",
|
||||
|
||||
@@ -275,7 +275,6 @@ export const TARGET_KEYS = [
|
||||
"logging.redactPatterns",
|
||||
"update",
|
||||
"ui",
|
||||
"ui.assistant",
|
||||
"plugins",
|
||||
"plugins.enabled",
|
||||
"plugins.allow",
|
||||
@@ -490,7 +489,5 @@ export const FINAL_BACKLOG_TARGET_KEYS = [
|
||||
"skills.load.extraDirs",
|
||||
"skills.load.watch",
|
||||
"skills.workshop.allowSymlinkTargetWrites",
|
||||
"ui.assistant.avatar",
|
||||
"ui.assistant.name",
|
||||
"ui.seamColor",
|
||||
] as const;
|
||||
|
||||
@@ -725,9 +725,6 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
ui: "UI",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"ui.prefs.accent": "User Accent Color",
|
||||
"ui.assistant": "Assistant Appearance",
|
||||
"ui.assistant.name": "Assistant Name",
|
||||
"ui.assistant.avatar": "Assistant Avatar",
|
||||
tui: "Terminal UI",
|
||||
"tui.footer": "Terminal UI Footer",
|
||||
"browser.evaluateEnabled": "Browser Evaluate Enabled",
|
||||
|
||||
@@ -159,7 +159,7 @@ tools.media.models.*.request.auth.token tools.profile tools.sessions
|
||||
tools.web transcripts.enabled
|
||||
tts.auto tts.persona tts.personas.*.providers.*.apiKey tts.provider
|
||||
tts.providers.* tts.providers.*.apiKey
|
||||
ui.assistant.avatar ui.assistant.name ui.prefs.accent ui.prefs.chatFollowUpMode
|
||||
ui.prefs.accent ui.prefs.chatFollowUpMode
|
||||
ui.prefs.chatPersistCommentary ui.prefs.chatSendShortcut ui.prefs.chatShowThinking
|
||||
ui.prefs.chatShowToolCalls ui.prefs.locale
|
||||
ui.prefs.theme ui.prefs.themeMode update.auto.enabled update.channel
|
||||
|
||||
@@ -149,12 +149,6 @@ export type OpenClawConfig = {
|
||||
ui?: {
|
||||
/** Accent color for OpenClaw UI chrome (hex). */
|
||||
seamColor?: string;
|
||||
assistant?: {
|
||||
/** Assistant display name for UI surfaces. */
|
||||
name?: string;
|
||||
/** Assistant avatar (emoji, short text, or image URL/data URI). */
|
||||
avatar?: string;
|
||||
};
|
||||
/**
|
||||
* Operator display preferences. Canonical config home so agents can
|
||||
* change them through the approval gate and clients stay in sync; the
|
||||
|
||||
@@ -214,12 +214,6 @@ export const OpenClawSchemaShape = {
|
||||
ui: z
|
||||
.strictObject({
|
||||
seamColor: HexColorSchema.optional(),
|
||||
assistant: z
|
||||
.strictObject({
|
||||
name: z.string().max(50).optional(),
|
||||
avatar: z.string().max(2_000_000).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Operator display prefs. Canonical here (agent-writable via approval,
|
||||
// synced across devices); the Control UI mirrors them into local
|
||||
// storage for instant boot and offline fallback.
|
||||
|
||||
@@ -5,71 +5,27 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
|
||||
import { AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-limits.js";
|
||||
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
||||
import { withTestDir } from "../test-helpers/temp-dir.js";
|
||||
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||
|
||||
describe("resolveAssistantIdentity", () => {
|
||||
it("keeps ui.assistant identity authoritative for the default agent", () => {
|
||||
it("uses the selected agent identity", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
name: "Main assistant",
|
||||
avatar: "M",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", identity: { name: "Main agent", avatar: "A" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const identity = resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" });
|
||||
expect(identity.agentId).toBe("main");
|
||||
expect(identity.name).toBe("Main assistant");
|
||||
expect(identity.nameSource).toBe("config");
|
||||
expect(identity.avatar).toBe("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" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const identity = resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" });
|
||||
expect(identity.agentId).toBe("fs-daying");
|
||||
expect(identity.name).toBe("大颖");
|
||||
expect(identity.nameSource).toBe("agent");
|
||||
expect(identity.avatar).toBe("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" }],
|
||||
list: [
|
||||
{ id: "main", identity: { name: "Main agent", avatar: "M" } },
|
||||
{ id: "worker", identity: { name: "Worker agent", avatar: "W" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const identity = resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" });
|
||||
expect(identity.agentId).toBe("worker");
|
||||
expect(identity.name).toBe("Main assistant");
|
||||
expect(identity.nameSource).toBe("config");
|
||||
expect(identity.avatar).toBe("M");
|
||||
expect(identity.name).toBe("Worker agent");
|
||||
expect(identity.nameSource).toBe("agent");
|
||||
expect(identity.avatar).toBe("W");
|
||||
});
|
||||
|
||||
it("uses the first roster entry for presentation on an explicit fleet", () => {
|
||||
@@ -85,37 +41,6 @@ describe("resolveAssistantIdentity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies ui.assistant identity only as authoritative for the retained owner", () => {
|
||||
const baseCfg: OpenClawConfig = {
|
||||
ui: { assistant: { name: "Shared assistant", avatar: "S" } },
|
||||
agents: {
|
||||
ownership: "explicit",
|
||||
list: [
|
||||
{ id: "ops", identity: { name: "Ops agent", avatar: "O" } },
|
||||
{ id: "research", identity: { name: "Research agent", avatar: "R" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const ownerlessCfg = { ...baseCfg };
|
||||
const migratedCfg = retainLegacyDefaultAgentId(baseCfg, "ops");
|
||||
|
||||
expect(
|
||||
resolveAssistantIdentity({ cfg: migratedCfg, agentId: "ops", workspaceDir: "" }),
|
||||
).toEqual({
|
||||
agentId: "ops",
|
||||
name: "Shared assistant",
|
||||
nameSource: "config",
|
||||
avatar: "S",
|
||||
emoji: undefined,
|
||||
});
|
||||
expect(
|
||||
resolveAssistantIdentity({ cfg: migratedCfg, agentId: "research", workspaceDir: "" }),
|
||||
).toMatchObject({ name: "Research agent", avatar: "R" });
|
||||
expect(
|
||||
resolveAssistantIdentity({ cfg: ownerlessCfg, agentId: "ops", workspaceDir: "" }),
|
||||
).toMatchObject({ name: "Ops agent", avatar: "O" });
|
||||
});
|
||||
|
||||
it("identifies workspace and synthesized default names", async () => {
|
||||
await withTestDir({ prefix: "openclaw-assistant-identity-name-source-" }, async (workspace) => {
|
||||
await fs.writeFile(path.join(workspace, "IDENTITY.md"), "- Name: Pacino\n");
|
||||
@@ -129,10 +54,13 @@ describe("resolveAssistantIdentity", () => {
|
||||
|
||||
it("drops sentence-like avatar placeholders", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
avatar: "workspace-relative path, http(s) URL, or data URI",
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: { avatar: "workspace-relative path, http(s) URL, or data URI" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,11 +71,7 @@ describe("resolveAssistantIdentity", () => {
|
||||
|
||||
it("keeps short text avatars", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
avatar: "PS",
|
||||
},
|
||||
},
|
||||
agents: { list: [{ id: "main", identity: { avatar: "PS" } }] },
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS");
|
||||
@@ -155,11 +79,7 @@ describe("resolveAssistantIdentity", () => {
|
||||
|
||||
it("keeps path avatars", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
avatar: "avatars/openclaw.png",
|
||||
},
|
||||
},
|
||||
agents: { list: [{ id: "main", identity: { avatar: "avatars/openclaw.png" } }] },
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/openclaw.png");
|
||||
@@ -168,11 +88,7 @@ describe("resolveAssistantIdentity", () => {
|
||||
it("preserves long image data URLs without truncating past 200 chars", () => {
|
||||
const dataUrl = `data:image/png;base64,${"A".repeat(50_000)}`;
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
avatar: dataUrl,
|
||||
},
|
||||
},
|
||||
agents: { list: [{ id: "main", identity: { avatar: dataUrl } }] },
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(dataUrl);
|
||||
@@ -214,14 +130,13 @@ describe("resolveAssistantIdentity", () => {
|
||||
});
|
||||
|
||||
it.each(["data:text/plain,avatar", "slack://avatar.png"])(
|
||||
"lets a valid agent avatar win when the UI override is unsupported: %s",
|
||||
"uses the configured emoji when the agent avatar is unsupported: %s",
|
||||
(avatar) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: { assistant: { avatar } },
|
||||
agents: { list: [{ id: "main", identity: { avatar: "agent.png" } }] },
|
||||
agents: { list: [{ id: "main", identity: { avatar, emoji: "🦞" } }] },
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("agent.png");
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("🦞");
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gateway assistant identity resolver.
|
||||
// Combines UI, agent config, and workspace identity files for Control UI display.
|
||||
// Combines agent config and workspace identity files for Control UI display.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { listAgentEntries } from "../agents/agent-scope-config.js";
|
||||
@@ -32,7 +32,7 @@ type AssistantIdentity = {
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
type AssistantIdentityNameSource = "config" | "agent" | "workspace" | "default";
|
||||
type AssistantIdentityNameSource = "agent" | "workspace" | "default";
|
||||
type ResolvedAssistantIdentity = AssistantIdentity & {
|
||||
agentId: string;
|
||||
nameSource: AssistantIdentityNameSource;
|
||||
@@ -111,46 +111,25 @@ export function resolveAssistantIdentity(params: {
|
||||
const presentationAgentId =
|
||||
params.agentId ?? compatibilityAgentId ?? listAgentEntries(params.cfg)[0]?.id ?? "main";
|
||||
const agentId = normalizeAgentId(presentationAgentId);
|
||||
const isDefaultAgent =
|
||||
compatibilityAgentId !== undefined && agentId === normalizeAgentId(compatibilityAgentId);
|
||||
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 = normalizeIdentityValue("name", configAssistant?.name);
|
||||
const agentName = normalizeIdentityValue("name", agentIdentity?.name);
|
||||
const fileName = normalizeIdentityValue("name", fileIdentity?.name);
|
||||
let resolvedName: [string, AssistantIdentityNameSource] | undefined;
|
||||
if (isDefaultAgent) {
|
||||
resolvedName = uiName
|
||||
? [uiName, "config"]
|
||||
: agentName
|
||||
? [agentName, "agent"]
|
||||
: fileName
|
||||
? [fileName, "workspace"]
|
||||
: undefined;
|
||||
} else {
|
||||
resolvedName = agentName
|
||||
? [agentName, "agent"]
|
||||
: fileName
|
||||
? [fileName, "workspace"]
|
||||
: uiName
|
||||
? [uiName, "config"]
|
||||
: undefined;
|
||||
}
|
||||
const resolvedName: [string, AssistantIdentityNameSource] | undefined = agentName
|
||||
? [agentName, "agent"]
|
||||
: fileName
|
||||
? [fileName, "workspace"]
|
||||
: undefined;
|
||||
const [name, nameSource] = resolvedName ?? [DEFAULT_ASSISTANT_IDENTITY.name, "default"];
|
||||
|
||||
const uiAvatar = normalizeAvatarValue(configAssistant?.avatar);
|
||||
const agentAvatarCandidates = [
|
||||
const avatarCandidates = [
|
||||
normalizeAvatarValue(agentIdentity?.avatar),
|
||||
normalizeAvatarValue(agentIdentity?.emoji),
|
||||
normalizeAvatarValue(fileIdentity?.avatar),
|
||||
normalizeAvatarValue(fileIdentity?.emoji),
|
||||
];
|
||||
const avatarCandidates = isDefaultAgent
|
||||
? [uiAvatar, ...agentAvatarCandidates]
|
||||
: [...agentAvatarCandidates, uiAvatar];
|
||||
const avatar = avatarCandidates.find(Boolean) ?? DEFAULT_ASSISTANT_IDENTITY.avatar;
|
||||
|
||||
const emojiCandidates = [
|
||||
|
||||
@@ -1519,8 +1519,15 @@ describe("handleControlUiHttpRequest", () => {
|
||||
{
|
||||
root: { kind: "resolved", path: tmp },
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { name: "</script><script>alert(1)//", avatar: "evil.png" } },
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: { name: "</script><script>alert(1)//", avatar: "evil.png" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1714,12 +1721,19 @@ describe("handleControlUiHttpRequest", () => {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [{ id: "roboclaw", default: true, workspace: tmp }],
|
||||
},
|
||||
ui: {
|
||||
seamColor: "#1A2b3C",
|
||||
assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" },
|
||||
list: [
|
||||
{
|
||||
id: "roboclaw",
|
||||
default: true,
|
||||
workspace: tmp,
|
||||
identity: {
|
||||
name: "</script><script>alert(1)//",
|
||||
avatar: "</script>.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ui: { seamColor: "#1A2b3C" },
|
||||
gateway: {
|
||||
cliAgents: { enabled: true },
|
||||
controlUi: { environment: { label: "edge", color: "amber" } },
|
||||
@@ -2065,8 +2079,10 @@ describe("handleControlUiHttpRequest", () => {
|
||||
authorization: "Bearer test-token",
|
||||
},
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { avatar: "avatar.png" } },
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [{ id: "main", identity: { avatar: "avatar.png" } }],
|
||||
},
|
||||
},
|
||||
rateLimiter,
|
||||
});
|
||||
@@ -2651,8 +2667,10 @@ describe("handleControlUiHttpRequest", () => {
|
||||
basePath: "/openclaw",
|
||||
root: { kind: "resolved", path: tmp },
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -2683,8 +2701,10 @@ describe("handleControlUiHttpRequest", () => {
|
||||
basePath: "/__openclaw__",
|
||||
root: { kind: "resolved", path: tmp },
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -2717,8 +2737,10 @@ describe("handleControlUiHttpRequest", () => {
|
||||
// No basePath: simulates the default deployment from the issue report.
|
||||
root: { kind: "resolved", path: tmp },
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -2767,8 +2789,10 @@ describe("handleControlUiHttpRequest", () => {
|
||||
// and served the single-underscore endpoint.
|
||||
root: { kind: "resolved", path: tmp },
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -2803,8 +2827,10 @@ describe("handleControlUiHttpRequest", () => {
|
||||
basePath: "/openclaw",
|
||||
root: { kind: "resolved", path: tmp },
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { name: "Ops", avatar: "ops.png" } },
|
||||
agents: {
|
||||
defaults: { workspace: tmp },
|
||||
list: [{ id: "main", identity: { name: "Ops", avatar: "ops.png" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -309,14 +309,14 @@ describe("gateway e2e", () => {
|
||||
const sourceBeforeUnrelatedWrite = (await configIO.readConfigFileSnapshot()).sourceConfig;
|
||||
const nextUnrelatedSource = {
|
||||
...sourceBeforeUnrelatedWrite,
|
||||
ui: { assistant: { name: "unrelated-managed-write" } },
|
||||
ui: { seamColor: "#123456" },
|
||||
} satisfies OpenClawConfig;
|
||||
await writeConfigFile(nextUnrelatedSource);
|
||||
const persistedAfterUnrelatedWrite = JSON.parse(
|
||||
await fs.readFile(configPath, "utf-8"),
|
||||
) as OpenClawConfig;
|
||||
expect(persistedAfterUnrelatedWrite.channels?.whatsapp?.dmPolicy).toBe("disabled");
|
||||
expect(persistedAfterUnrelatedWrite.ui?.assistant?.name).toBe("unrelated-managed-write");
|
||||
expect(persistedAfterUnrelatedWrite.ui?.seamColor).toBe("#123456");
|
||||
}
|
||||
|
||||
const reconnected = await connectGatewayClient({
|
||||
@@ -561,7 +561,7 @@ describe("gateway e2e", () => {
|
||||
expect(setConfigOverride("logging.level", "warn").ok).toBe(true);
|
||||
await writeConfigFile({
|
||||
...initialConfig,
|
||||
ui: { assistant: { name: "override-active" } },
|
||||
ui: { seamColor: "#123456" },
|
||||
logging: { level: "debug" },
|
||||
});
|
||||
await expect
|
||||
@@ -571,7 +571,7 @@ describe("gateway e2e", () => {
|
||||
resetConfigOverrides();
|
||||
await writeConfigFile({
|
||||
...initialConfig,
|
||||
ui: { assistant: { name: "override-reset" } },
|
||||
ui: { seamColor: "#654321" },
|
||||
logging: { level: "debug" },
|
||||
});
|
||||
await expect
|
||||
|
||||
@@ -918,7 +918,9 @@ describe("gateway agent handler", () => {
|
||||
["data", "data:image/png;base64,aaaa"],
|
||||
["text", "PS"],
|
||||
] as const)("preserves %s avatar values in agent.identity.get", async (_kind, avatar) => {
|
||||
mocks.loadConfigReturn = { ui: { assistant: { avatar } } };
|
||||
mocks.loadConfigReturn = {
|
||||
agents: { list: [{ id: "main", identity: { avatar } }] },
|
||||
};
|
||||
|
||||
const respond = await invokeAgentIdentityGet(
|
||||
{ sessionKey: "agent:main:main" },
|
||||
@@ -931,7 +933,7 @@ describe("gateway agent handler", () => {
|
||||
it("prefixes same-origin avatar routes in agent.identity.get when Control UI has a base path", async () => {
|
||||
mocks.loadConfigReturn = {
|
||||
gateway: { controlUi: { basePath: "/openclaw" } },
|
||||
ui: { assistant: { avatar: "/avatar/main" } },
|
||||
agents: { list: [{ id: "main", identity: { avatar: "/avatar/main" } }] },
|
||||
};
|
||||
|
||||
const respond = await invokeAgentIdentityGet(
|
||||
|
||||
Reference in New Issue
Block a user