import { describe, expect, it, vi, beforeEach } from "vitest"; /* ------------------------------------------------------------------ */ /* Mocks */ /* ------------------------------------------------------------------ */ const mocks = vi.hoisted(() => ({ loadConfigReturn: {} as Record, listAgentEntries: vi.fn(() => [] as Array<{ agentId: string }>), findAgentEntryIndex: vi.fn(() => -1), applyAgentConfig: vi.fn((_cfg: unknown, _opts: unknown) => ({})), pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })), writeConfigFile: vi.fn(async () => {}), ensureAgentWorkspace: vi.fn(async () => {}), resolveAgentDir: vi.fn(() => "/agents/test-agent"), resolveAgentWorkspaceDir: vi.fn(() => "/workspace/test-agent"), resolveSessionTranscriptsDirForAgent: vi.fn(() => "/transcripts/test-agent"), listAgentsForGateway: vi.fn(() => ({ defaultId: "main", mainKey: "agent:main:main", scope: "global", agents: [], })), movePathToTrash: vi.fn(async () => "/trashed"), fsAccess: vi.fn(async () => {}), fsMkdir: vi.fn(async () => undefined), fsAppendFile: vi.fn(async () => {}), fsReadFile: vi.fn(async () => ""), fsStat: vi.fn(async () => null), })); vi.mock("../../config/config.js", () => ({ loadConfig: () => mocks.loadConfigReturn, writeConfigFile: mocks.writeConfigFile, })); vi.mock("../../commands/agents.config.js", () => ({ applyAgentConfig: mocks.applyAgentConfig, findAgentEntryIndex: mocks.findAgentEntryIndex, listAgentEntries: mocks.listAgentEntries, pruneAgentConfig: mocks.pruneAgentConfig, })); vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: () => ["main"], resolveAgentDir: mocks.resolveAgentDir, resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, })); vi.mock("../../agents/workspace.js", async () => { const actual = await vi.importActual( "../../agents/workspace.js", ); return { ...actual, ensureAgentWorkspace: mocks.ensureAgentWorkspace, }; }); vi.mock("../../config/sessions/paths.js", () => ({ resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent, })); vi.mock("../../browser/trash.js", () => ({ movePathToTrash: mocks.movePathToTrash, })); vi.mock("../../utils.js", () => ({ resolveUserPath: (p: string) => `/resolved${p.startsWith("/") ? "" : "/"}${p}`, })); vi.mock("../session-utils.js", () => ({ listAgentsForGateway: mocks.listAgentsForGateway, })); // Mock node:fs/promises – agents.ts uses `import fs from "node:fs/promises"` // which resolves to the module namespace default, so we spread actual and // override the methods we need, plus set `default` explicitly. vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); const patched = { ...actual, access: mocks.fsAccess, mkdir: mocks.fsMkdir, appendFile: mocks.fsAppendFile, readFile: mocks.fsReadFile, stat: mocks.fsStat, }; return { ...patched, default: patched }; }); /* ------------------------------------------------------------------ */ /* Import after mocks are set up */ /* ------------------------------------------------------------------ */ const { agentsHandlers } = await import("./agents.js"); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function makeCall(method: keyof typeof agentsHandlers, params: Record) { const respond = vi.fn(); const handler = agentsHandlers[method]; const promise = handler({ params, respond, context: {} as never, req: { type: "req" as const, id: "1", method }, client: null, isWebchatConnect: () => false, }); return { respond, promise }; } function createEnoentError() { const err = new Error("ENOENT") as NodeJS.ErrnoException; err.code = "ENOENT"; return err; } function createErrnoError(code: string) { const err = new Error(code) as NodeJS.ErrnoException; err.code = code; return err; } beforeEach(() => { mocks.fsReadFile.mockImplementation(async () => { throw createEnoentError(); }); mocks.fsStat.mockImplementation(async () => { throw createEnoentError(); }); }); /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe("agents.create", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({})); }); it("creates a new agent successfully", async () => { const { respond, promise } = makeCall("agents.create", { name: "Test Agent", workspace: "/home/user/agents/test", }); await promise; expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ ok: true, agentId: "test-agent", name: "Test Agent", }), undefined, ); expect(mocks.ensureAgentWorkspace).toHaveBeenCalled(); expect(mocks.writeConfigFile).toHaveBeenCalled(); }); it("ensures workspace is set up before writing config", async () => { const callOrder: string[] = []; mocks.ensureAgentWorkspace.mockImplementation(async () => { callOrder.push("ensureAgentWorkspace"); }); mocks.writeConfigFile.mockImplementation(async () => { callOrder.push("writeConfigFile"); }); const { promise } = makeCall("agents.create", { name: "Order Test", workspace: "/tmp/ws", }); await promise; expect(callOrder.indexOf("ensureAgentWorkspace")).toBeLessThan( callOrder.indexOf("writeConfigFile"), ); }); it("rejects creating an agent with reserved 'main' id", async () => { const { respond, promise } = makeCall("agents.create", { name: "main", workspace: "/tmp/ws", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("reserved") }), ); }); it("rejects creating a duplicate agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(0); const { respond, promise } = makeCall("agents.create", { name: "Existing", workspace: "/tmp/ws", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("already exists") }), ); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("rejects invalid params (missing name)", async () => { const { respond, promise } = makeCall("agents.create", { workspace: "/tmp/ws", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("invalid") }), ); }); it("always writes Name to IDENTITY.md even without emoji/avatar", async () => { const { promise } = makeCall("agents.create", { name: "Plain Agent", workspace: "/tmp/ws", }); await promise; expect(mocks.fsAppendFile).toHaveBeenCalledWith( expect.stringContaining("IDENTITY.md"), expect.stringContaining("- Name: Plain Agent"), "utf-8", ); }); it("writes emoji and avatar to IDENTITY.md when provided", async () => { const { promise } = makeCall("agents.create", { name: "Fancy Agent", workspace: "/tmp/ws", emoji: "🤖", avatar: "https://example.com/avatar.png", }); await promise; expect(mocks.fsAppendFile).toHaveBeenCalledWith( expect.stringContaining("IDENTITY.md"), expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/), "utf-8", ); }); }); describe("agents.update", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; mocks.findAgentEntryIndex.mockReturnValue(0); mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({})); }); it("updates an existing agent successfully", async () => { const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", name: "Updated Name", }); await promise; expect(respond).toHaveBeenCalledWith(true, { ok: true, agentId: "test-agent" }, undefined); expect(mocks.writeConfigFile).toHaveBeenCalled(); }); it("rejects updating a nonexistent agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); const { respond, promise } = makeCall("agents.update", { agentId: "nonexistent", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("not found") }), ); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("ensures workspace when workspace changes", async () => { const { promise } = makeCall("agents.update", { agentId: "test-agent", workspace: "/new/workspace", }); await promise; expect(mocks.ensureAgentWorkspace).toHaveBeenCalled(); }); it("does not ensure workspace when workspace is unchanged", async () => { const { promise } = makeCall("agents.update", { agentId: "test-agent", name: "Just a rename", }); await promise; expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); }); }); describe("agents.delete", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; mocks.findAgentEntryIndex.mockReturnValue(0); mocks.pruneAgentConfig.mockReturnValue({ config: {}, removedBindings: 2 }); }); it("deletes an existing agent and trashes files by default", async () => { const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; expect(respond).toHaveBeenCalledWith( true, { ok: true, agentId: "test-agent", removedBindings: 2 }, undefined, ); expect(mocks.writeConfigFile).toHaveBeenCalled(); // moveToTrashBestEffort calls fs.access then movePathToTrash for each dir expect(mocks.movePathToTrash).toHaveBeenCalled(); }); it("skips file deletion when deleteFiles is false", async () => { mocks.fsAccess.mockClear(); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", deleteFiles: false, }); await promise; expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({ ok: true }), undefined); // moveToTrashBestEffort should not be called at all expect(mocks.fsAccess).not.toHaveBeenCalled(); }); it("rejects deleting the main agent", async () => { const { respond, promise } = makeCall("agents.delete", { agentId: "main", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("cannot be deleted") }), ); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("rejects deleting a nonexistent agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); const { respond, promise } = makeCall("agents.delete", { agentId: "ghost", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("not found") }), ); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("rejects invalid params (missing agentId)", async () => { const { respond, promise } = makeCall("agents.delete", {}); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("invalid") }), ); }); }); describe("agents.files.list", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; }); it("includes BOOTSTRAP.md when onboarding has not completed", async () => { const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); await promise; const [, result] = respond.mock.calls[0] ?? []; const files = (result as { files: Array<{ name: string }> }).files; expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(true); }); it("hides BOOTSTRAP.md when workspace onboarding is complete", async () => { mocks.fsReadFile.mockImplementation(async (filePath: string | URL | number) => { if (String(filePath).endsWith("workspace-state.json")) { return JSON.stringify({ onboardingCompletedAt: "2026-02-15T14:00:00.000Z", }); } throw createEnoentError(); }); const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); await promise; const [, result] = respond.mock.calls[0] ?? []; const files = (result as { files: Array<{ name: string }> }).files; expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(false); }); it("falls back to showing BOOTSTRAP.md when workspace state cannot be read", async () => { mocks.fsReadFile.mockImplementation(async (filePath: string | URL | number) => { if (String(filePath).endsWith("workspace-state.json")) { throw createErrnoError("EACCES"); } throw createEnoentError(); }); const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); await promise; const [, result] = respond.mock.calls[0] ?? []; const files = (result as { files: Array<{ name: string }> }).files; expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(true); }); });