import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const configMocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), writeConfigFile: vi.fn().mockResolvedValue(undefined), })); vi.mock("../config/config.js", async (importOriginal) => ({ ...(await importOriginal()), readConfigFileSnapshot: configMocks.readConfigFileSnapshot, writeConfigFile: configMocks.writeConfigFile, })); import { agentsSetIdentityCommand } from "./agents.js"; const runtime = createTestRuntime(); type ConfigWritePayload = { agents?: { list?: Array<{ id: string; identity?: Record }> }; }; async function createIdentityWorkspace(subdir = "work") { const root = await makeTempWorkspace("openclaw-identity-"); const workspace = path.join(root, subdir); await fs.mkdir(workspace, { recursive: true }); return { root, workspace }; } async function writeIdentityFile(workspace: string, lines: string[]) { const identityPath = path.join(workspace, "IDENTITY.md"); await fs.writeFile(identityPath, `${lines.join("\n")}\n`, "utf-8"); return identityPath; } function getWrittenMainIdentity() { const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as ConfigWritePayload; return written.agents?.list?.find((entry) => entry.id === "main")?.identity; } async function runIdentityCommandFromWorkspace(workspace: string, fromIdentity = true) { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { agents: { list: [{ id: "main", workspace }] } }, }); await agentsSetIdentityCommand({ workspace, fromIdentity }, runtime); } describe("agents set-identity command", () => { beforeEach(() => { configMocks.readConfigFileSnapshot.mockClear(); configMocks.writeConfigFile.mockClear(); runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); }); it("sets identity from workspace IDENTITY.md", async () => { const { root, workspace } = await createIdentityWorkspace(); await writeIdentityFile(workspace, [ "- Name: OpenClaw", "- Creature: helpful sloth", "- Emoji: :)", "- Avatar: avatars/openclaw.png", "", ]); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { agents: { list: [ { id: "main", workspace }, { id: "ops", workspace: path.join(root, "ops") }, ], }, }, }); await agentsSetIdentityCommand({ workspace }, runtime); expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); expect(getWrittenMainIdentity()).toEqual({ name: "OpenClaw", theme: "helpful sloth", emoji: ":)", avatar: "avatars/openclaw.png", }); }); it("errors when multiple agents match the same workspace", async () => { const { workspace } = await createIdentityWorkspace("shared"); await writeIdentityFile(workspace, ["- Name: Echo"]); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { agents: { list: [ { id: "main", workspace }, { id: "ops", workspace }, ], }, }, }); await agentsSetIdentityCommand({ workspace }, runtime); expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Multiple agents match")); expect(runtime.exit).toHaveBeenCalledWith(1); expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); }); it("overrides identity file values with explicit flags", async () => { const { workspace } = await createIdentityWorkspace(); await writeIdentityFile(workspace, [ "- Name: OpenClaw", "- Theme: space lobster", "- Emoji: :)", "- Avatar: avatars/openclaw.png", "", ]); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { agents: { list: [{ id: "main", workspace }] } }, }); await agentsSetIdentityCommand( { workspace, fromIdentity: true, name: "Nova", emoji: "🦞", avatar: "https://example.com/override.png", }, runtime, ); expect(getWrittenMainIdentity()).toEqual({ name: "Nova", theme: "space lobster", emoji: "🦞", avatar: "https://example.com/override.png", }); }); it("reads identity from an explicit IDENTITY.md path", async () => { const { workspace } = await createIdentityWorkspace(); const identityPath = await writeIdentityFile(workspace, [ "- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", "- **Avatar:** avatars/c3po.png", "", ]); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { agents: { list: [{ id: "main" }] } }, }); await agentsSetIdentityCommand({ agent: "main", identityFile: identityPath }, runtime); expect(getWrittenMainIdentity()).toEqual({ name: "C-3PO", theme: "Flustered Protocol Droid", emoji: "🤖", avatar: "avatars/c3po.png", }); }); it("accepts avatar-only identity from IDENTITY.md", async () => { const { workspace } = await createIdentityWorkspace(); await writeIdentityFile(workspace, ["- Avatar: avatars/only.png"]); await runIdentityCommandFromWorkspace(workspace); expect(getWrittenMainIdentity()).toEqual({ avatar: "avatars/only.png", }); }); it("accepts avatar-only updates via flags", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { agents: { list: [{ id: "main" }] } }, }); await agentsSetIdentityCommand( { agent: "main", avatar: "https://example.com/avatar.png" }, runtime, ); expect(getWrittenMainIdentity()).toEqual({ avatar: "https://example.com/avatar.png", }); }); it("errors when identity data is missing", async () => { const { workspace } = await createIdentityWorkspace(); await runIdentityCommandFromWorkspace(workspace); expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("No identity data found")); expect(runtime.exit).toHaveBeenCalledWith(1); expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); }); });