diff options
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/agents/loader.test.ts | 132 | ||||
| -rw-r--r-- | packages/core/tests/tools/summon.test.ts | 137 |
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"); + }); +}); |
