Files
openclaw/src/commands/agents.bind.commands.test.ts
Gustavo Madeira Santana 96c7702526 Agents: add account-scoped bind and routing commands (#27195)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ad35a458a55427614a35c9d0713a7386172464ad
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-26 02:36:56 -05:00

201 lines
6.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: readConfigFileSnapshotMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../channels/plugins/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/plugins/index.js")>();
return {
...actual,
getChannelPlugin: (channel: string) => {
if (channel === "matrix-js") {
return {
id: "matrix-js",
setup: {
resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(),
},
};
}
return actual.getChannelPlugin(channel);
},
normalizeChannelId: (channel: string) => {
if (channel.trim().toLowerCase() === "matrix-js") {
return "matrix-js";
}
return actual.normalizeChannelId(channel);
},
};
});
import { agentsBindCommand, agentsBindingsCommand, agentsUnbindCommand } from "./agents.js";
const runtime = createTestRuntime();
describe("agents bind/unbind commands", () => {
beforeEach(() => {
readConfigFileSnapshotMock.mockClear();
writeConfigFileMock.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
});
it("lists all bindings by default", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
});
await agentsBindingsCommand({}, runtime);
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js"));
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("ops <- telegram accountId=work"),
);
});
it("binds routes to default agent when --agent is omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ bind: ["telegram"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("defaults matrix-js accountId to the target agent id when omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("upgrades existing channel-only binding when accountId is later provided", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
},
});
await agentsBindCommand({ bind: ["telegram:work"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "work" } }],
}),
);
expect(runtime.log).toHaveBeenCalledWith("Updated bindings:");
expect(runtime.exit).not.toHaveBeenCalled();
});
it("unbinds all routes for an agent", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
});
await agentsUnbindCommand({ agent: "ops", all: true }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("reports ownership conflicts during unbind and exits 1", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "ops" } }],
},
});
await agentsUnbindCommand({ agent: "ops", bind: ["telegram:ops"] }, runtime);
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith("Bindings are owned by another agent:");
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("keeps role-based bindings when removing channel-level discord binding", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
bindings: [
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
roles: ["111", "222"],
},
},
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
},
},
],
},
});
await agentsUnbindCommand({ bind: ["discord:guild-a"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
roles: ["111", "222"],
},
},
],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
});