Files
openclaw/src/gateway/server-methods/agents-mutate.test.ts
Advait Paliwal 980f788731 feat(gateway): add agents.create/update/delete methods (#11045)
* feat(gateway): add agents.create/update/delete methods

* fix(lint): preserve memory-lancedb load error cause

* feat(gateway): trash agent files on agents.delete

* chore(protocol): regenerate Swift gateway models

* fix(gateway): stabilize agents.create dirs and agentDir

* feat(gateway): support avatar in agents.create

* fix: prep agents.create/update/delete handlers (#11045) (thanks @advaitpaliwal)

- Reuse movePathToTrash from browser/trash.ts (has ~/.Trash fallback on non-macOS)
- Fix partial-failure: workspace setup now runs before config write
- Always write Name to IDENTITY.md regardless of emoji/avatar
- Add unit tests for agents.create, agents.update, agents.delete
- Add CHANGELOG entry

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-02-07 16:47:58 -08:00

374 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi, beforeEach } from "vitest";
/* ------------------------------------------------------------------ */
/* Mocks */
/* ------------------------------------------------------------------ */
const mocks = vi.hoisted(() => ({
loadConfigReturn: {} as Record<string, unknown>,
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 () => {}),
}));
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<typeof import("../../agents/workspace.js")>(
"../../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<typeof import("node:fs/promises")>("node:fs/promises");
const patched = {
...actual,
access: mocks.fsAccess,
mkdir: mocks.fsMkdir,
appendFile: mocks.fsAppendFile,
};
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<string, unknown>) {
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 };
}
/* ------------------------------------------------------------------ */
/* 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") }),
);
});
});