summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/agents/loader.test.ts132
-rw-r--r--packages/core/tests/tools/summon.test.ts137
2 files changed, 269 insertions, 0 deletions
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts
new file mode 100644
index 0000000..88173ea
--- /dev/null
+++ b/packages/core/tests/agents/loader.test.ts
@@ -0,0 +1,132 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { expandAgentToolNames, getAgentDirPaths, loadAgent } from "../../src/agents/loader.js";
+
+describe("expandAgentToolNames", () => {
+ it("expands 'read' into the granular read tools", () => {
+ const out = expandAgentToolNames(["read"]);
+ expect(out).toContain("read_file");
+ expect(out).toContain("read_file_slice");
+ expect(out).toContain("list_files");
+ });
+
+ it("expands 'edit' into write_file", () => {
+ const out = expandAgentToolNames(["edit"]);
+ expect(out).toContain("write_file");
+ });
+
+ it("expands 'bash' into run_shell", () => {
+ const out = expandAgentToolNames(["bash"]);
+ expect(out).toContain("run_shell");
+ });
+
+ it("passes through non-group tool names unchanged", () => {
+ const out = expandAgentToolNames(["summon", "retrieve", "web_search", "youtube_transcribe"]);
+ expect(out).toEqual(
+ expect.arrayContaining(["summon", "retrieve", "web_search", "youtube_transcribe"]),
+ );
+ });
+
+ it("always includes 'todo' even when not requested", () => {
+ expect(expandAgentToolNames([])).toContain("todo");
+ expect(expandAgentToolNames(["read"])).toContain("todo");
+ expect(expandAgentToolNames(["summon"])).toContain("todo");
+ });
+
+ it("deduplicates when groups overlap with explicit names", () => {
+ const out = expandAgentToolNames(["read", "read_file"]);
+ // Each name should appear at most once
+ const counts = new Map<string, number>();
+ for (const t of out) counts.set(t, (counts.get(t) ?? 0) + 1);
+ for (const [, c] of counts) expect(c).toBe(1);
+ });
+});
+
+describe("getAgentDirPaths", () => {
+ it("returns just the global dir when no projectDir is supplied", () => {
+ const paths = getAgentDirPaths();
+ expect(paths).toHaveLength(1);
+ expect(paths[0]).toContain(".config/dispatch/agents");
+ });
+
+ it("appends the project-scoped dir when projectDir is supplied", () => {
+ const paths = getAgentDirPaths("/some/project");
+ expect(paths).toHaveLength(2);
+ expect(paths[1]).toBe("/some/project/.dispatch/agents");
+ });
+});
+
+describe("loadAgent — project-scoped sandbox", () => {
+ // `GLOBAL_AGENTS_DIR` is captured at module load via `os.homedir()`
+ // and can't be redirected at runtime. The project-scoped path,
+ // however, is computed per-call from the `projectDir` argument, so
+ // we exercise that branch instead. This is also the more common
+ // real-world case (per-project agent definitions).
+ let tmpProject: string;
+
+ beforeEach(() => {
+ tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-loader-test-"));
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpProject, { recursive: true, force: true });
+ });
+
+ function writeAgentToml(slug: string, body: string): void {
+ const agentsDir = path.join(tmpProject, ".dispatch", "agents");
+ fs.mkdirSync(agentsDir, { recursive: true });
+ fs.writeFileSync(path.join(agentsDir, `${slug}.toml`), body, "utf-8");
+ }
+
+ // Uses a slug unlikely to collide with anything the user might
+ // already have in ~/.config/dispatch/agents. `loadAgent` returns
+ // the FIRST match it finds across all scanned directories, and
+ // the global scope is scanned before the project scope — a slug
+ // that exists in both would resolve to the global one (which is
+ // real, not under our control). The "z-dispatch-test-*" prefix
+ // gives this fixture exclusive ownership of the slug.
+ const TEST_SLUG = "z-dispatch-test-fixture";
+
+ it("returns null for an unknown slug within the project scope", () => {
+ const agent = loadAgent("z-dispatch-test-does-not-exist", tmpProject);
+ expect(agent).toBeNull();
+ });
+
+ it("loads a TOML definition written to the project's .dispatch/agents", () => {
+ writeAgentToml(
+ TEST_SLUG,
+ [
+ 'name = "Fixture"',
+ 'description = "Sandbox fixture for loadAgent test."',
+ "skills = []",
+ 'tools = ["read", "bash"]',
+ "is_subagent = true",
+ "",
+ "[[models]]",
+ 'key_id = "opencode-1"',
+ 'model_id = "deepseek-v4-flash"',
+ "",
+ ].join("\n"),
+ );
+
+ const agent = loadAgent(TEST_SLUG, tmpProject);
+ expect(agent).not.toBeNull();
+ expect(agent?.slug).toBe(TEST_SLUG);
+ expect(agent?.name).toBe("Fixture");
+ expect(agent?.tools).toEqual(["read", "bash"]);
+ expect(agent?.is_subagent).toBe(true);
+ expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
+ expect(agent?.scope).toBe(tmpProject);
+ });
+
+ it("sanitizes the slug so path traversal can't reach outside the agents dir", () => {
+ // Even if a caller passes something gnarly, the lookup is by
+ // sanitized slug — no file outside the configured dirs should
+ // ever be opened. The sanitized form ("etc-passwd") obviously
+ // doesn't exist in the temp project, so the result is null.
+ const agent = loadAgent("../../../etc/passwd", tmpProject);
+ expect(agent).toBeNull();
+ });
+});
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
new file mode 100644
index 0000000..3909e48
--- /dev/null
+++ b/packages/core/tests/tools/summon.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ type AvailableAgent,
+ createSummonTool,
+ type SummonCallbacks,
+} from "../../src/tools/summon.js";
+
+const noopCallbacks: SummonCallbacks = {
+ spawn: async () => "agent-id-stub",
+ getResult: async () => ({ status: "done", result: "" }),
+};
+
+describe("createSummonTool — description content", () => {
+ it("lists the agent directories so the LLM knows where to look", () => {
+ const tool = createSummonTool(
+ "/tmp/work",
+ noopCallbacks,
+ [],
+ ["/home/u/.config/dispatch/agents", "/tmp/work/.dispatch/agents"],
+ );
+ expect(tool.description).toContain("/home/u/.config/dispatch/agents");
+ expect(tool.description).toContain("/tmp/work/.dispatch/agents");
+ expect(tool.description).toContain("read_file");
+ });
+
+ it("includes available agent slugs+names in the description", () => {
+ const agents: AvailableAgent[] = [
+ {
+ slug: "programmer",
+ name: "Programmer",
+ description: "Implements code from a plan.",
+ path: "/home/u/.config/dispatch/agents/programmer.toml",
+ },
+ {
+ slug: "researcher",
+ name: "Researcher",
+ description: "Investigates topics.",
+ path: "/home/u/.config/dispatch/agents/researcher.toml",
+ },
+ ];
+ const tool = createSummonTool("/tmp/work", noopCallbacks, agents, [
+ "/home/u/.config/dispatch/agents",
+ ]);
+ expect(tool.description).toContain("programmer");
+ expect(tool.description).toContain("Programmer");
+ expect(tool.description).toContain("Implements code from a plan");
+ expect(tool.description).toContain("researcher");
+ expect(tool.description).toContain("Investigates topics");
+ });
+
+ it("emits a 'no agents defined' notice when the catalog is empty", () => {
+ const tool = createSummonTool(
+ "/tmp/work",
+ noopCallbacks,
+ [],
+ ["/home/u/.config/dispatch/agents"],
+ );
+ expect(tool.description).toContain("No agent definitions are currently defined");
+ });
+});
+
+describe("createSummonTool — execute() argument forwarding", () => {
+ it("forwards agent slug through to callbacks.spawn", async () => {
+ const spawn = vi.fn(async () => "tab-xyz");
+ const tool = createSummonTool(
+ "/tmp/work",
+ { spawn, getResult: async () => ({ status: "done", result: "ok" }) },
+ [],
+ [],
+ );
+ await tool.execute({
+ task: "do thing",
+ agent: "programmer",
+ background: true,
+ });
+ expect(spawn).toHaveBeenCalledTimes(1);
+ const callArg = spawn.mock.calls[0]?.[0];
+ expect(callArg).toMatchObject({
+ task: "do thing",
+ agentSlug: "programmer",
+ });
+ });
+
+ it("omits agentSlug from the spawn payload when no agent param is given", async () => {
+ const spawn = vi.fn(async () => "tab-xyz");
+ const tool = createSummonTool(
+ "/tmp/work",
+ { spawn, getResult: async () => ({ status: "done", result: "ok" }) },
+ [],
+ [],
+ );
+ await tool.execute({
+ task: "do thing",
+ background: true,
+ });
+ expect(spawn).toHaveBeenCalledTimes(1);
+ const callArg = spawn.mock.calls[0]?.[0];
+ expect(callArg).not.toHaveProperty("agentSlug");
+ });
+
+ it("returns spawned agent_id when background=true (no blocking on result)", async () => {
+ const getResult = vi.fn(async () => ({ status: "done" as const, result: "should-not-see" }));
+ const tool = createSummonTool("/tmp/work", { spawn: async () => "id-42", getResult }, [], []);
+ const out = await tool.execute({ task: "x", background: true });
+ expect(out).toContain("id-42");
+ // Background mode must not block on getResult
+ expect(getResult).not.toHaveBeenCalled();
+ });
+
+ it("blocks on result and returns it when background=false (default)", async () => {
+ const tool = createSummonTool(
+ "/tmp/work",
+ {
+ spawn: async () => "id-1",
+ getResult: async () => ({ status: "done", result: "child-output" }),
+ },
+ [],
+ [],
+ );
+ const out = await tool.execute({ task: "x" });
+ expect(out).toBe("child-output");
+ });
+
+ it("surfaces child errors when blocking", async () => {
+ const tool = createSummonTool(
+ "/tmp/work",
+ {
+ spawn: async () => "id-1",
+ getResult: async () => ({ status: "error", error: "boom" }),
+ },
+ [],
+ [],
+ );
+ const out = await tool.execute({ task: "x" });
+ expect(out).toContain("boom");
+ });
+});