From 0415c403a75530619fe647c97ef599414459bc47 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Tue, 25 Aug 2026 00:21:13 -0700 Subject: [PATCH] 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> --- docs/gateway/configuration-reference.md | 7 +- docs/web/control-ui.md | 2 +- src/agents/identity-avatar.test.ts | 135 +----------------- src/agents/identity-avatar.ts | 22 +-- ...egacy-config-migrations.runtime.retired.ts | 23 +++ ...ig-migrations.runtime.ui-assistant.test.ts | 51 +++++++ .../schema.help.quality.test.ts.snap | 2 - src/config/schema.help.agents.ts | 8 +- .../schema.help.quality.test-fixtures.ts | 3 - src/config/schema.labels.ts | 3 - src/config/schema.tiers.ts | 2 +- src/config/types.openclaw.ts | 6 - src/config/zod-schema.root-shape.ts | 6 - src/gateway/assistant-identity.test.ts | 127 +++------------- src/gateway/assistant-identity.ts | 37 ++--- src/gateway/control-ui.http.test.ts | 64 ++++++--- src/gateway/gateway.test.ts | 8 +- .../agent.reset-and-identity.test-utils.ts | 6 +- 18 files changed, 165 insertions(+), 347 deletions(-) create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.ui-assistant.test.ts diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 1b6bab7ee743..cd29ab87f7cb 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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 diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index f386e35e800b..e82efbd94369 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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 diff --git a/src/agents/identity-avatar.test.ts b/src/agents/identity-avatar.test.ts index 281976915759..f83b7b5ae660 100644 --- a/src/agents/identity-avatar.test.ts +++ b/src/agents/identity-avatar.test.ts @@ -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[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 }); - }); }); diff --git a/src/agents/identity-avatar.ts b/src/agents/identity-avatar.ts index 569ca12a329f..4cc48d31bdea 100644 --- a/src/agents/identity-avatar.ts +++ b/src/agents/identity-avatar.ts @@ -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" }; } diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts index a23d487905ed..834fd6fc874a 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts @@ -440,6 +440,21 @@ function migrateFinalLayoutKills(raw: Record, changes: string[] } } +function removeUiAssistantIdentity(raw: Record, 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", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ui-assistant.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ui-assistant.test.ts new file mode 100644 index 000000000000..b6def9c63a75 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ui-assistant.test.ts @@ -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) { + 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({}); + }); +}); diff --git a/src/config/__snapshots__/schema.help.quality.test.ts.snap b/src/config/__snapshots__/schema.help.quality.test.ts.snap index e2fe5e053ce9..a6e51d775434 100644 --- a/src/config/__snapshots__/schema.help.quality.test.ts.snap +++ b/src/config/__snapshots__/schema.help.quality.test.ts.snap @@ -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", diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index 9c26c2ba2405..81f2a85b5013 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -1,16 +1,10 @@ // Defines user-facing config field help text for docs and UI surfaces. export const AGENT_FIELD_HELP: Record = { - 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.", diff --git a/src/config/schema.help.quality.test-fixtures.ts b/src/config/schema.help.quality.test-fixtures.ts index ab97dc25d0b2..dce48692682d 100644 --- a/src/config/schema.help.quality.test-fixtures.ts +++ b/src/config/schema.help.quality.test-fixtures.ts @@ -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; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 08b89b0ca002..79bfc9df8d7c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -725,9 +725,6 @@ export const FIELD_LABELS: Record = { 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", diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index 88ab505c4c6c..a78f852c44c4 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -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 diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 38e1b1ab05e7..8a15340c98af 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -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 diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 4132e584ea3e..7344990b0c2f 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -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. diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index 0248c1a386de..9422fe2ea009 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -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("🦞"); }, ); diff --git a/src/gateway/assistant-identity.ts b/src/gateway/assistant-identity.ts index 43c7f271d273..ad224ff3db65 100644 --- a/src/gateway/assistant-identity.ts +++ b/src/gateway/assistant-identity.ts @@ -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 = [ diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 2a862459265a..f139530b1f93 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -1519,8 +1519,15 @@ describe("handleControlUiHttpRequest", () => { { root: { kind: "resolved", path: tmp }, config: { - agents: { defaults: { workspace: tmp } }, - ui: { assistant: { name: ".png" }, + list: [ + { + id: "roboclaw", + default: true, + workspace: tmp, + identity: { + name: ".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" } }], + }, }, }, ); diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index 8dc25cd31753..75b46db759ec 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -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 diff --git a/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts b/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts index 2fd079b4f907..1ea346a71ff4 100644 --- a/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts +++ b/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts @@ -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(