mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 11:17:40 +00:00
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>
This commit is contained in:
373
src/gateway/server-methods/agents-mutate.test.ts
Normal file
373
src/gateway/server-methods/agents-mutate.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
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") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user