summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/tests/tools
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/tests/tools')
-rw-r--r--packages/core/tests/tools/bash-arity.test.ts36
-rw-r--r--packages/core/tests/tools/key-usage.test.ts317
-rw-r--r--packages/core/tests/tools/list-files.test.ts94
-rw-r--r--packages/core/tests/tools/lsp-tool.test.ts110
-rw-r--r--packages/core/tests/tools/read-file.test.ts142
-rw-r--r--packages/core/tests/tools/read-tab.test.ts101
-rw-r--r--packages/core/tests/tools/registry.test.ts143
-rw-r--r--packages/core/tests/tools/run-shell.test.ts78
-rw-r--r--packages/core/tests/tools/search-code.test.ts511
-rw-r--r--packages/core/tests/tools/send-to-tab.test.ts185
-rw-r--r--packages/core/tests/tools/summon.test.ts349
-rw-r--r--packages/core/tests/tools/task-list.test.ts158
-rw-r--r--packages/core/tests/tools/write-file.test.ts152
13 files changed, 0 insertions, 2376 deletions
diff --git a/packages/core/tests/tools/bash-arity.test.ts b/packages/core/tests/tools/bash-arity.test.ts
deleted file mode 100644
index a01a6a5..0000000
--- a/packages/core/tests/tools/bash-arity.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { prefix } from "../../src/tools/bash-arity.js";
-
-describe("BashArity.prefix", () => {
- it("returns arity-2 prefix for known command 'git'", () => {
- expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
- });
-
- it("returns arity-3 prefix for npm", () => {
- expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]);
- });
-
- it("returns arity-2 prefix for bun", () => {
- expect(prefix(["bun", "install", "--frozen-lockfile"])).toEqual(["bun", "install"]);
- });
-
- it("returns just the command for unknown command", () => {
- expect(prefix(["unknowncmd", "arg1", "arg2"])).toEqual(["unknowncmd"]);
- });
-
- it("returns empty array for empty tokens", () => {
- expect(prefix([])).toEqual([]);
- });
-
- it("handles single token for unknown command", () => {
- expect(prefix(["ls"])).toEqual(["ls"]);
- });
-
- it("handles git with fewer tokens than arity", () => {
- expect(prefix(["git"])).toEqual(["git"]);
- });
-
- it("handles case-insensitive matching", () => {
- expect(prefix(["GIT", "checkout", "main"])).toEqual(["GIT", "checkout"]);
- });
-});
diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts
deleted file mode 100644
index 643e30e..0000000
--- a/packages/core/tests/tools/key-usage.test.ts
+++ /dev/null
@@ -1,317 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// The tool imports `getAccountUsageWithSource` from `claude.ts`, which
-// transitively imports `db/index.js` (top-level `import { Database } from
-// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject
-// stub fetchers and never hit the real fetchers/DB, so stubbing the db module
-// is enough to let the import chain resolve.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => {
- throw new Error("db not available in this test");
- }),
-}));
-
-import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js";
-import type { OpencodeUsageReport } from "../../src/credentials/opencode.js";
-import {
- createKeyUsageTool,
- formatKeyUsage,
- type KeyUsageCallbacks,
-} from "../../src/tools/key-usage.js";
-import type { KeyDefinition, KeyState } from "../../src/types/index.js";
-
-// ─── Builders ─────────────────────────────────────────────────
-
-function keyState(
- def: Partial<KeyDefinition> & { id: string; provider: string },
- overrides: Partial<Omit<KeyState, "definition">> = {},
-): KeyState {
- return {
- definition: { base_url: "https://example.test", ...def },
- status: "active",
- ...overrides,
- };
-}
-
-function account(id: string, source = `/creds/${id}.json`): ClaudeAccount {
- return {
- id,
- label: id,
- source,
- credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 },
- };
-}
-
-/** Build the tool with explicit stub fetchers — no network, no DB. */
-function buildTool(opts: {
- keys: KeyState[];
- accounts?: ClaudeAccount[];
- anthropic?: (a: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
- opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>;
-}) {
- const callbacks: KeyUsageCallbacks = {
- listKeys: () => opts.keys,
- listClaudeAccounts: () => opts.accounts ?? [],
- fetchAnthropicUsage: opts.anthropic ?? (async () => null),
- fetchOpencodeUsage: opts.opencode ?? (async () => null),
- };
- return createKeyUsageTool(callbacks);
-}
-
-const HOUR = 3_600_000;
-
-describe("key_usage tool", () => {
- it("reports all keys when no key_id is given", async () => {
- const reset5h = Date.now() + 2 * HOUR;
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max", "/creds/max.json")],
- anthropic: async () => ({
- source: "live",
- report: {
- fiveHour: { utilization: 0.25, resetsAt: reset5h },
- sevenDay: { utilization: 0.6 },
- },
- }),
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.4 },
- monthly: { utilization: 0.7 },
- }),
- });
-
- const out = await tool.execute({});
-
- // Both keys present with providers.
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).toContain("[opencode-1] provider: opencode-go");
- // Remaining = (1 - utilization) * 100.
- expect(out).toContain("5-hour: 75% remaining");
- expect(out).toContain("week: 40% remaining");
- expect(out).toContain("5-hour: 90% remaining");
- expect(out).toContain("week: 60% remaining");
- expect(out).toContain("month: 30% remaining");
- expect(out).toContain("data: live (fetched just now)");
- });
-
- it("filters to a single key when key_id is given and does not fetch others", async () => {
- const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } }));
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.2 } },
- }),
- opencode: opencodeFetch,
- });
-
- const out = await tool.execute({ key_id: "claude-max" });
-
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).not.toContain("opencode-1");
- expect(opencodeFetch).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error for an unknown key_id", async () => {
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- });
-
- const out = await tool.execute({ key_id: "nope" });
-
- expect(out).toContain('no key found with id "nope"');
- expect(out).toContain("claude-max");
- expect(out).toContain("opencode-1");
- });
-
- it("reports cached data with the source's last-fetched timestamp", async () => {
- const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5);
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "cache",
- cachedAt,
- report: { fiveHour: { utilization: 0.5 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z");
- expect(out).toContain("5-hour: 50% remaining");
- });
-
- it("omits the month window for anthropic (no monthly bucket)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour:");
- expect(out).toContain("week:");
- expect(out).not.toContain("month:");
- });
-
- it("includes the month window for opencode-go", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.2 },
- monthly: { utilization: 0.3 },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("month: 70% remaining");
- });
-
- it("surfaces exhausted status with the last error", async () => {
- const exhaustedAt = Date.now() - HOUR;
- const tool = buildTool({
- keys: [
- keyState(
- { id: "opencode-1", provider: "opencode-go" },
- { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt },
- ),
- ],
- opencode: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("status: EXHAUSTED");
- expect(out).toContain("last error: 429 rate limit exceeded");
- });
-
- it("flags providers without usage support", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "gem", provider: "google" })],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("[gem] provider: google");
- expect(out).toContain("not supported");
- });
-
- it("reports unavailable when a supported provider returns no usage", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- expect(out).toContain("no cached usage");
- });
-
- it("reports unavailable for anthropic keys with no account credentials", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("no Claude account credentials available");
- });
-
- it("treats a fetcher that throws as unavailable (does not crash)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => {
- throw new Error("network down");
- },
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- });
-
- it("reports when no keys are configured at all", async () => {
- const tool = buildTool({ keys: [] });
- const out = await tool.execute({});
- expect(out).toBe("No API keys are configured.");
- });
-
- it("clamps out-of-range utilization to 0–100%", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining
- weekly: { utilization: -0.5 }, // negative → 100% remaining
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour: 0% remaining");
- expect(out).toContain("week: 100% remaining");
- });
-});
-
-describe("formatKeyUsage (pure)", () => {
- const now = Date.UTC(2025, 5, 1, 12, 0, 0);
-
- it("formats reset timestamps with ISO + relative time", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "claude-max",
- provider: "anthropic",
- status: "active",
- dataSource: "live",
- windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }],
- },
- ],
- now,
- );
-
- expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)");
- });
-
- it("renders a past reset/exhaustion time as 'ago'", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "opencode-1",
- provider: "opencode-go",
- status: "exhausted",
- exhaustedAt: now - 2 * HOUR,
- lastError: "boom",
- windows: [],
- },
- ],
- now,
- );
-
- expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)");
- expect(out).toContain("last error: boom");
- });
-
- it("returns a friendly message when no entries match", () => {
- expect(formatKeyUsage([], now)).toBe("No API keys matched.");
- });
-});
diff --git a/packages/core/tests/tools/list-files.test.ts b/packages/core/tests/tools/list-files.test.ts
deleted file mode 100644
index f371717..0000000
--- a/packages/core/tests/tools/list-files.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createListFilesTool } from "../../src/tools/list-files.js";
-
-describe("list_files tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("lists directory contents", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "file1.txt"), "a");
- await writeFile(join(workDir, "file2.txt"), "b");
- await mkdir(join(workDir, "subdir"));
- const result = await tool.execute({ path: "." });
- expect(result).toContain("file1.txt");
- expect(result).toContain("file2.txt");
- expect(result).toContain("subdir/");
- });
-
- it("defaults to current directory when path is undefined", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "hi");
- const result = await tool.execute({});
- expect(result).toContain("hello.txt");
- });
-
- it("blocks path traversal", async () => {
- const tool = createListFilesTool(workDir);
- const result = await tool.execute({ path: "../" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, relPath))` — when relPath
- // is absolute, `join` does NOT short-circuit. The old code silently
- // rewrote `/some/path` to `<workdir>/some/path` and either returned an
- // ENOENT-style error or, worse, listed an unrelated path. After the fix,
- // absolute paths resolve to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("lists an absolute path that lives under the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "alpha.txt"), "a");
- await writeFile(join(workDir, "beta.txt"), "b");
- const result = await tool.execute({ path: workDir });
- expect(result).toContain("alpha.txt");
- expect(result).toContain("beta.txt");
- // "Error listing files" would indicate the path was mangled into a
- // non-existent location.
- expect(result).not.toMatch(/error listing/i);
- });
-
- it("rejects absolute paths outside the workdir with the workdir error (not a generic ENOENT)", async () => {
- const tool = createListFilesTool(workDir);
- // Use a tmpdir path that's definitely not under workDir. Under the
- // bug, this got rewritten to `<workdir>/tmp/...` and produced an
- // `Error listing files` ENOENT message instead of the workdir error.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}`);
- const result = await tool.execute({ path: evilPath });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // A directory symlink inside the workdir pointing to an external
- // directory is the classic escape vector for a `ls` style tool.
- // `canonicalize` must resolve the symlink so the listing is denied.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- await writeFile(join(externalDir, "secret.txt"), "secret");
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks listing through a symlinked directory that escapes the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await symlink(externalDir, join(workDir, "peek"));
- const result = await tool.execute({ path: "peek" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("secret.txt");
- });
- });
-});
diff --git a/packages/core/tests/tools/lsp-tool.test.ts b/packages/core/tests/tools/lsp-tool.test.ts
deleted file mode 100644
index 7f26522..0000000
--- a/packages/core/tests/tools/lsp-tool.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import type { LspManager } from "../../src/lsp/manager.js";
-import type { ResolvedLspServer } from "../../src/lsp/server.js";
-import { createLspTool, type LspToolContext } from "../../src/tools/lsp.js";
-
-const SERVER: ResolvedLspServer = {
- id: "luau-lsp",
- extensions: [".luau"],
- spawn: () => ({ process: {} as never }),
-};
-
-function makeManager(overrides: Partial<LspManager> = {}): LspManager {
- return {
- hasServerForFile: vi.fn(() => true),
- touchFile: vi.fn(async () => {}),
- getDiagnostics: vi.fn(() => ({})),
- request: vi.fn(async () => []),
- getClients: vi.fn(async () => []),
- shutdownAll: vi.fn(async () => {}),
- ...overrides,
- } as unknown as LspManager;
-}
-
-function ctx(manager: LspManager, servers = [SERVER]): () => LspToolContext {
- return () => ({ manager, workingDirectory: "/work", servers });
-}
-
-describe("createLspTool", () => {
- it("exposes the expected schema/name", () => {
- const tool = createLspTool(ctx(makeManager()));
- expect(tool.name).toBe("lsp");
- expect(tool.description).toMatch(/luau-lsp/i);
- });
-
- it("errors when no servers are configured", async () => {
- const tool = createLspTool(ctx(makeManager(), []));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/no LSP servers are configured/i);
- });
-
- it("errors when no server matches the file", async () => {
- const manager = makeManager({ hasServerForFile: vi.fn(() => false) as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.ts" });
- expect(out).toMatch(/no configured LSP server matches/i);
- });
-
- it("diagnostics: touches the file then reports errors", async () => {
- const touchFile = vi.fn(async () => {});
- const getDiagnostics = vi.fn(() => ({
- "/work/a.luau": [
- {
- range: { start: { line: 2, character: 1 }, end: { line: 2, character: 9 } },
- severity: 1,
- message: "bad type",
- },
- ],
- }));
- const manager = makeManager({
- touchFile: touchFile as never,
- getDiagnostics: getDiagnostics as never,
- });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(touchFile).toHaveBeenCalledOnce();
- expect(out).toContain("ERROR [3:2] bad type");
- });
-
- it("diagnostics: reports clean when no errors", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/No errors reported/i);
- });
-
- it("hover: requires line and character", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "hover", path: "a.luau" });
- expect(out).toMatch(/requires both 'line' and 'character'/i);
- });
-
- it("hover: converts 1-based coords to 0-based on the wire", async () => {
- const request = vi.fn(async () => [{ contents: "hi" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "hover", path: "a.luau", line: 5, character: 3 });
- expect(request).toHaveBeenCalledOnce();
- const arg = request.mock.calls[0]?.[0] as { method: string; params: { position: unknown } };
- expect(arg.method).toBe("textDocument/hover");
- expect(arg.params.position).toEqual({ line: 4, character: 2 });
- });
-
- it("references: includes declaration context", async () => {
- const request = vi.fn(async () => []);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "references", path: "a.luau", line: 1, character: 1 });
- const arg = request.mock.calls[0]?.[0] as { params: { context?: unknown } };
- expect(arg.params.context).toEqual({ includeDeclaration: true });
- });
-
- it("documentSymbol: does not require a position", async () => {
- const request = vi.fn(async () => [{ name: "foo" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "documentSymbol", path: "a.luau" });
- const arg = request.mock.calls[0]?.[0] as { method: string };
- expect(arg.method).toBe("textDocument/documentSymbol");
- expect(out).toContain("foo");
- });
-});
diff --git a/packages/core/tests/tools/read-file.test.ts b/packages/core/tests/tools/read-file.test.ts
deleted file mode 100644
index 90165d8..0000000
--- a/packages/core/tests/tools/read-file.test.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { randomBytes } from "node:crypto";
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createReadFileTool } from "../../src/tools/read-file.js";
-import { SPILL_ROOT } from "../../src/tools/truncate.js";
-
-describe("read_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("reads an existing file", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "Hello, world!");
- const result = await tool.execute({ path: "hello.txt" });
- expect(result).toContain("Hello, world!");
- expect(result).toContain("[file: hello.txt — lines 1-1 of 1]");
- });
-
- it("returns error for non-existent file", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "missing.txt" });
- expect(result).toMatch(/not found/i);
- });
-
- it("blocks path traversal", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "../etc/passwd" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- it("respects offset and limit", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "multi.txt"), "line1\nline2\nline3\nline4\nline5");
- const result = await tool.execute({ path: "multi.txt", offset: 2, limit: 2 });
- expect(result).toContain("line2");
- expect(result).toContain("line3");
- expect(result).not.toContain("line1");
- expect(result).not.toContain("line4");
- expect(result).toContain("[file: multi.txt — lines 2-3 of 5]");
- });
-
- it("truncates long lines and points to read_file_slice", async () => {
- const tool = createReadFileTool(workDir);
- const longLine = "x".repeat(3000);
- await writeFile(join(workDir, "wide.txt"), longLine);
- const result = await tool.execute({ path: "wide.txt" });
- expect(result).toContain("[line 1 truncated, total 3,000 chars");
- expect(result).toContain("use read_file_slice");
- });
-
- // The universal truncator writes oversized tool output to
- // `${SPILL_ROOT}/<tabId>/<callId>.txt` and the truncation notice tells
- // the AI to read that absolute path back. A previous implementation
- // used `resolve(join(workingDirectory, filePath))` which silently
- // concatenated the absolute spill path *under* the workdir, producing
- // a non-existent path and ENOENT — breaking the entire spill-and-resume
- // flow. These tests guard that contract.
- describe("absolute path handling (spill-file regression)", () => {
- let spillSubdir: string;
-
- beforeEach(async () => {
- spillSubdir = join(SPILL_ROOT, `test-${Date.now()}-${randomBytes(4).toString("hex")}`);
- await mkdir(spillSubdir, { recursive: true });
- });
-
- afterEach(async () => {
- await rm(spillSubdir, { recursive: true, force: true });
- });
-
- it("reads a spill file via its absolute path", async () => {
- const tool = createReadFileTool(workDir);
- const spillFile = join(spillSubdir, "call-abc.txt");
- const payload = "spilled output line 1\nspilled output line 2";
- await writeFile(spillFile, payload);
-
- const result = await tool.execute({ path: spillFile });
-
- expect(result).toContain("spilled output line 1");
- expect(result).toContain("spilled output line 2");
- expect(result).not.toMatch(/not found/i);
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("still rejects absolute paths that are neither in the workdir nor the spill root", async () => {
- const tool = createReadFileTool(workDir);
- // Path check happens before file read, so /etc/hostname existing
- // (or not) is irrelevant — we just need an absolute path outside
- // both the workdir and SPILL_ROOT.
- const result = await tool.execute({ path: "/etc/hostname" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlinks must resolve consistently across the agent permission gate
- // and the tool itself. The containment check operates on the canonical
- // path — so a symlink-in-workdir that points outside is treated as
- // "outside" and gated like any other external path. Lexical-only
- // checks would let these slip through silently.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("follows symlinks that stay inside the workdir", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "real.txt"), "real content");
- await symlink(join(workDir, "real.txt"), join(workDir, "link.txt"));
- const result = await tool.execute({ path: "link.txt" });
- expect(result).toContain("real content");
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("blocks symlinks that escape the workdir", async () => {
- const tool = createReadFileTool(workDir);
- const secret = join(externalDir, "secret.txt");
- await writeFile(secret, "leaked secret");
- // Create a symlink *inside* workDir pointing to a file *outside*
- // workDir. Lexical-only path validation would see "workdir/trap.txt"
- // (under workdir) and allow it. Canonical resolution sees the
- // symlink's target and correctly rejects.
- await symlink(secret, join(workDir, "trap.txt"));
- const result = await tool.execute({ path: "trap.txt" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("leaked secret");
- });
- });
-});
diff --git a/packages/core/tests/tools/read-tab.test.ts b/packages/core/tests/tools/read-tab.test.ts
deleted file mode 100644
index 71e419c..0000000
--- a/packages/core/tests/tools/read-tab.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { createReadTabTool, type ReadTabCallbacks } from "../../src/tools/read-tab.js";
-import type { TabResolution } from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<ReadTabCallbacks> = {}): ReadTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- getLastResponse: () => ({ text: "the answer is 42", status: "idle" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- ...overrides,
- };
-}
-
-describe("createReadTabTool — schema & description", () => {
- it("is a non-blocking snapshot read", () => {
- const tool = createReadTabTool(makeCallbacks());
- expect(tool.name).toBe("read_tab");
- expect(tool.description).toContain("SNAPSHOT");
- expect(tool.description.toLowerCase()).toContain("does not block");
- });
-});
-
-describe("createReadTabTool — execute()", () => {
- it("returns the last assistant response wrapped in a tab_response tag", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("<tab_response");
- expect(out).toContain('tab="targ"');
- expect(out).toContain('status="idle"');
- expect(out).toContain("the answer is 42");
- expect(out).toContain("</tab_response>");
- });
-
- it("notes that a running tab's response is its previous completed turn", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: "older turn", status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("still running");
- expect(out).toContain("older turn");
- });
-
- it("explains when a tab has no completed response yet (idle)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "idle" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("no assistant responses yet");
- });
-
- it("explains when a tab is still on its first turn (running, no prior text)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("still working on its first turn");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("returns a helpful error when the id is unknown", async () => {
- const tool = createReadTabTool(makeCallbacks({ resolveShortId: () => ({ status: "none" }) }));
- const out = await tool.execute({ tab_id: "zzzz" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- }),
- );
- const out = await tool.execute({ tab_id: "abcd" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- });
-});
diff --git a/packages/core/tests/tools/registry.test.ts b/packages/core/tests/tools/registry.test.ts
deleted file mode 100644
index cad75d2..0000000
--- a/packages/core/tests/tools/registry.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { z } from "zod";
-import { createToolRegistry } from "../../src/tools/registry.js";
-import type { ToolDefinition } from "../../src/types/index.js";
-
-const mockTool: ToolDefinition = {
- name: "mock_tool",
- description: "A mock tool for testing",
- parameters: z.object({ input: z.string() }),
- execute: async (_args) => "mock result",
-};
-
-const anotherTool: ToolDefinition = {
- name: "another_tool",
- description: "Another mock tool",
- parameters: z.object({ value: z.number() }),
- execute: async (_args) => "another result",
-};
-
-/** A non-trivial tool that exercises nested objects, required fields, and enums. */
-const complexTool: ToolDefinition = {
- name: "complex_tool",
- description: "A tool with nested parameters",
- parameters: z.object({
- command: z.string().describe("Shell command to run"),
- options: z.object({
- timeout: z.number().optional().describe("Timeout in milliseconds"),
- shell: z.enum(["bash", "sh", "zsh"]).describe("Shell to use"),
- }),
- flags: z.array(z.string()).optional().describe("Additional flags"),
- }),
- execute: async (_args) => "complex result",
-};
-
-describe("createToolRegistry", () => {
- it("returns all tools via getTools()", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tools = registry.getTools();
- expect(tools).toHaveLength(2);
- expect(tools.map((t) => t.name)).toContain("mock_tool");
- expect(tools.map((t) => t.name)).toContain("another_tool");
- });
-
- it("retrieves specific tool by name", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tool = registry.getTool("mock_tool");
- expect(tool).toBeDefined();
- expect(tool?.name).toBe("mock_tool");
- });
-
- it("returns undefined for unknown tool", () => {
- const registry = createToolRegistry([mockTool]);
- expect(registry.getTool("nonexistent")).toBeUndefined();
- });
-
- describe("getAISDKTools", () => {
- it("returns correct keys for all tools", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools).toHaveProperty("mock_tool");
- expect(aiTools).toHaveProperty("another_tool");
- });
-
- it("AI SDK tools have description from ToolDefinition", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools.mock_tool.description).toBe("A mock tool for testing");
- });
-
- it("AI SDK tools surface schema via inputSchema, not parameters", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- // v6 uses inputSchema; v4 used parameters — this verifies the migration
- expect(aiTools.mock_tool).toHaveProperty("inputSchema");
- expect(aiTools.mock_tool).not.toHaveProperty("parameters");
- });
-
- it("AI SDK tools have no execute callback so the SDK does not auto-run", () => {
- const registry = createToolRegistry([mockTool, anotherTool, complexTool]);
- const aiTools = registry.getAISDKTools();
- for (const [name, sdkTool] of Object.entries(aiTools)) {
- expect(
- (sdkTool as Record<string, unknown>).execute,
- `Tool "${name}" should not have an execute callback`,
- ).toBeUndefined();
- }
- });
-
- it("inputSchema produces valid JSONSchema7 for a simple tool", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.mock_tool.inputSchema;
- // jsonSchema() wraps the raw JSONSchema7; it should expose the schema
- // as a `jsonSchema` property on the Schema object
- expect(schema).toBeDefined();
- // The wrapped schema object should carry the JSON Schema definition
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema).toBeDefined();
- expect(schemaObj.jsonSchema.type).toBe("object");
- const props = schemaObj.jsonSchema.properties as Record<string, unknown>;
- expect(props).toHaveProperty("input");
- });
-
- it("inputSchema produces correct JSONSchema7 for a non-trivial nested tool", () => {
- const registry = createToolRegistry([complexTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.complex_tool.inputSchema;
- expect(schema).toBeDefined();
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema.type).toBe("object");
-
- const props = schemaObj.jsonSchema.properties as Record<string, Record<string, unknown>>;
-
- // Top-level required field "command"
- expect(props).toHaveProperty("command");
- expect(props.command.type).toBe("string");
-
- // Nested object "options"
- expect(props).toHaveProperty("options");
- expect(props.options.type).toBe("object");
- const optProps = props.options.properties as Record<string, Record<string, unknown>>;
- expect(optProps).toHaveProperty("shell");
- expect(optProps.shell.enum).toEqual(["bash", "sh", "zsh"]);
-
- // Optional array "flags" present as a property
- expect(props).toHaveProperty("flags");
- expect(props.flags.type).toBe("array");
-
- // Required fields should include "command" and "options"
- const required = schemaObj.jsonSchema.required as string[];
- expect(required).toContain("command");
- expect(required).toContain("options");
- });
-
- it("getTool still returns the original ToolDefinition with execute", () => {
- const registry = createToolRegistry([mockTool]);
- const def = registry.getTool("mock_tool");
- expect(def).toBeDefined();
- expect(typeof def?.execute).toBe("function");
- expect(def?.name).toBe("mock_tool");
- });
- });
-});
diff --git a/packages/core/tests/tools/run-shell.test.ts b/packages/core/tests/tools/run-shell.test.ts
deleted file mode 100644
index cb66d1c..0000000
--- a/packages/core/tests/tools/run-shell.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { createRunShellTool } from "../../src/tools/run-shell.js";
-
-describe("run_shell tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("executes a simple echo command", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo hello" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("hello");
- expect(result.exitCode).toBe(0);
- });
-
- it("returns non-zero exit code on failure", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "exit 42" });
- const result = JSON.parse(raw);
- expect(result.exitCode).toBe(42);
- });
-
- it("captures stderr", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo errormsg >&2" });
- const result = JSON.parse(raw);
- expect(result.stderr.trim()).toBe("errormsg");
- });
-
- it("handles timeout", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "sleep 10", timeout: 100 });
- const result = JSON.parse(raw);
- // Either times out (non-zero exit) or returns an error
- expect(result.exitCode !== 0 || result.error !== undefined).toBe(true);
- }, 5000);
-
- it("executes in the working directory", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "pwd" });
- const result = JSON.parse(raw);
- // On macOS /tmp is symlinked; use includes check
- expect(result.stdout.trim()).toContain(workDir.replace(/^\/private/, ""));
- });
-
- it("calls onOutput callback with stdout chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- const raw = await tool.execute({ command: "echo streaming" }, { onOutput });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("streaming");
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("streaming"), "stdout");
- });
-
- it("calls onOutput callback with stderr chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- await tool.execute({ command: "echo errdata >&2" }, { onOutput });
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("errdata"), "stderr");
- });
-
- it("works without context (backward compatible)", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo nocontext" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("nocontext");
- });
-});
diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts
deleted file mode 100644
index c4e933c..0000000
--- a/packages/core/tests/tools/search-code.test.ts
+++ /dev/null
@@ -1,511 +0,0 @@
-import { spawnSync } from "node:child_process";
-import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { mkdtemp as mkdtempP, rm as rmP, writeFile as writeFileP } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createSearchCodeTool } from "../../src/tools/search-code.js";
-
-// A tiny stub that impersonates `cs`: it ignores its args and prints whatever
-// JSON we put in the CS_STUB_OUTPUT env var. This makes JSON→text formatting
-// tests fully deterministic without needing a real cs binary in CI.
-function writeStub(dir: string, body: string): string {
- const stubPath = join(dir, "cs-stub.sh");
- writeFileSync(stubPath, body, { mode: 0o755 });
- chmodSync(stubPath, 0o755);
- return stubPath;
-}
-
-const ECHO_ENV_STUB = `#!/usr/bin/env bash
-printf '%s' "$CS_STUB_OUTPUT"
-`;
-
-// A stub that writes to stderr and exits non-zero, impersonating a cs failure
-// (bad flag, invalid regex, etc.).
-const FAIL_STUB = `#!/usr/bin/env bash
-echo "cs: simulated failure on stderr" >&2
-exit 3
-`;
-
-describe("search_code tool", () => {
- let workDir: string;
- const savedBin = process.env.DISPATCH_CS_BIN;
- const savedStubOut = process.env.CS_STUB_OUTPUT;
-
- beforeEach(async () => {
- workDir = await mkdtempP(join(tmpdir(), "dispatch-cs-test-"));
- });
-
- afterEach(async () => {
- await rmP(workDir, { recursive: true, force: true });
- if (savedBin === undefined) delete process.env.DISPATCH_CS_BIN;
- else process.env.DISPATCH_CS_BIN = savedBin;
- if (savedStubOut === undefined) delete process.env.CS_STUB_OUTPUT;
- else process.env.CS_STUB_OUTPUT = savedStubOut;
- });
-
- it("exposes the expected name and schema", () => {
- const tool = createSearchCodeTool(workDir);
- expect(tool.name).toBe("search_code");
- expect(tool.description).toContain("cs");
- // query is required; a representative set of optional knobs exist.
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect(shape.query).toBeDefined();
- expect(shape.path).toBeDefined();
- expect(shape.only).toBeDefined();
- expect(shape.result_limit).toBeDefined();
- });
-
- it("requires a non-empty query", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: " " });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("query is required");
- });
-
- it("does not crash when params are the wrong type (model hallucination)", async () => {
- const tool = createSearchCodeTool(workDir);
- // A non-string query must be rejected gracefully, not throw.
- const q = await tool.execute({ query: ["a", "b"] as unknown as string });
- expect(q).toMatch(/^Error:/);
- expect(q).toContain("query is required");
- // A non-string include_ext (array) must not throw "x.trim is not a function".
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const out = await tool.execute({
- query: "x",
- include_ext: ["ts", "go"] as unknown as string,
- exclude_pattern: { a: 1 } as unknown as string,
- });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("rejects a path outside the working directory", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything", path: "../../etc" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("outside the working directory");
- });
-
- it("rejects a path that points at a file, not a directory", async () => {
- await writeFileP(join(workDir, "a-file.ts"), "const x = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "a-file.ts" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("is a file, not a directory");
- });
-
- it("rejects a path that does not exist", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "no/such/dir" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("does not exist");
- });
-
- it("returns an actionable error when the cs binary is missing", async () => {
- process.env.DISPATCH_CS_BIN = "/nonexistent/path/to/cs-binary-xyz";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("requires the 'cs'");
- expect(out).toContain("DISPATCH_CS_BIN");
- });
-
- it("reports no matches when cs outputs null", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "nothinghere" });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("formats cs JSON results into readable per-file blocks", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const csJson = JSON.stringify([
- {
- filename: "web-search.ts",
- location: join(workDir, "packages/core/src/tools/web-search.ts"),
- score: 5.24,
- language: "TypeScript",
- total_lines: 106,
- lines: [
- { line_number: 7, content: "" },
- {
- line_number: 8,
- content: "export function createWebSearchTool(): ToolDefinition {",
- match_positions: [[16, 35]],
- },
- { line_number: 9, content: "\treturn {" },
- ],
- },
- {
- filename: "index.ts",
- location: join(workDir, "packages/core/src/index.ts"),
- score: 1.1,
- language: "TypeScript",
- lines: [{ line_number: 113, content: 'export { createWebSearchTool } from "./web.js";' }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
-
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "createWebSearchTool" });
-
- expect(out).toContain("Found matches in 2 files");
- // Paths are rendered relative to the workdir.
- expect(out).toContain("packages/core/src/tools/web-search.ts [TypeScript] (score 5.24)");
- expect(out).not.toContain(workDir);
- // Matched line is marked with '>'; line numbers + content present.
- expect(out).toContain("> 8: export function createWebSearchTool(): ToolDefinition {");
- expect(out).toContain(" 7: ");
- expect(out).toContain("packages/core/src/index.ts [TypeScript] (score 1.10)");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("renders cs 'content'-shape (prose) results instead of a bare header", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- // cs's snippet mode emits `content` + `matchlocations` and no `lines`.
- const csJson = JSON.stringify([
- {
- filename: "notes.md",
- location: join(workDir, "docs/notes.md"),
- score: 0.42,
- language: "Markdown",
- content: "Some heading\nthe orchestration paragraph that matched",
- matchlocations: [[13, 26]],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "orchestration" });
- expect(out).toContain("docs/notes.md [Markdown] (score 0.42)");
- // The snippet text must be present, not a bare header.
- expect(out).toContain("the orchestration paragraph that matched");
- expect(out).not.toContain("no snippet available");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("truncates an excessively long snippet line", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const longContent = `const x = "${"Z".repeat(5000)}";`;
- const csJson = JSON.stringify([
- {
- filename: "big.ts",
- location: join(workDir, "big.ts"),
- score: 1,
- language: "TypeScript",
- lines: [{ line_number: 1, content: longContent, match_positions: [[10, 14]] }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toContain("line truncated");
- // No single output line should approach the raw 5k length.
- const longest = Math.max(...out.split("\n").map((l) => l.length));
- expect(longest).toBeLessThan(700);
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("surfaces raw output when cs returns unparseable JSON", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "this is not json";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("could not parse cs output");
- expect(out).toContain("this is not json");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("reports an error (not 'No matches') when cs exits non-zero", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, FAIL_STUB);
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("exited with code 3");
- // stderr from cs is surfaced to the caller.
- expect(out).toContain("simulated failure on stderr");
- expect(out).not.toContain("No matches found");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- // ── Live integration: only runs when a real `cs` binary is available. ──
- const liveCsBin = findRealCs();
- describe.runIf(liveCsBin)("live cs binary", () => {
- it("finds a real match and ranks the defining file", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // Seed a small tree with a clear match.
- await writeFileP(
- join(workDir, "alpha.ts"),
- "export function findTheNeedle() {\n return 42;\n}\n",
- );
- await writeFileP(join(workDir, "beta.ts"), "const x = 1;\n// nothing relevant here\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "findTheNeedle" });
- expect(out).toContain("alpha.ts");
- expect(out).toContain("findTheNeedle");
- expect(out).not.toContain("Error:");
- });
-
- it("treats a dash-leading query as a search term, not a cs flag", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // A literal token beginning with '-' must not be parsed as a flag.
- await writeFileP(join(workDir, "dash.ts"), "const dashToken = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "-dashToken" });
- // Whether or not cs ranks a hit, it must NOT error out on flag parsing.
- expect(out).not.toContain("unknown shorthand flag");
- expect(out).not.toMatch(/^Error: cs exited/);
- });
-
- it("renders snippet lines for prose (markdown) matches", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(
- join(workDir, "doc.md"),
- "# Title\n\nThis paragraph mentions widgetronics in prose.\n",
- );
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "widgetronics" });
- expect(out).toContain("doc.md");
- // The matching prose text must be shown, not just a bare header.
- expect(out).toContain("widgetronics");
- expect(out).not.toContain("no snippet available");
- });
-
- it("widens the snippet window when context is given", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- const body = Array.from({ length: 21 }, (_, i) => `line ${i + 1}`);
- body[10] = "const findContextTarget = 1;";
- await writeFileP(join(workDir, "ctx.ts"), `${body.join("\n")}\n`);
- const tool = createSearchCodeTool(workDir);
- const countSnippetLines = (s: string) =>
- s.split("\n").filter((l) => /^\s+>?\s*\d+:/.test(l)).length;
- const narrow = await tool.execute({
- query: "findContextTarget",
- context: 0,
- result_limit: 1,
- });
- const wide = await tool.execute({
- query: "findContextTarget",
- context: 6,
- result_limit: 1,
- });
- expect(countSnippetLines(wide)).toBeGreaterThan(countSnippetLines(narrow));
- });
-
- it("returns 'No matches found.' for a query with no hits", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "alpha.ts"), "export const a = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "zzz_nonexistent_token_qqq" });
- expect(out).toBe("No matches found.");
- });
-
- it("tags .luau files as Luau", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "mod.luau"), "function Mod.doThing()\n\treturn 1\nend\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "doThing" });
- expect(out).toContain("mod.luau");
- expect(out).toContain("[Luau]");
- });
- });
-
- // ── Luau declaration detection: needs a cs built with the Luau patch
- // (docker/cs/luau-declarations.patch). Skipped on an unpatched/older cs. ──
- const luauCsBin = findLuauCapableCs(liveCsBin);
- describe.runIf(luauCsBin)("live cs binary (Luau declaration patch)", () => {
- // A small Luau module exercising every declaration form the patch adds.
- const LUAU_MODULE = [
- "local Mod = {}",
- "",
- "export type StuntResult = {",
- "\tscore: number,",
- "}",
- "",
- "type LaunchConfig = StuntResult",
- "",
- "function Mod.getDefaults(): LaunchConfig",
- "\treturn { score = 0 }",
- "end",
- "",
- "local function helperThing(x: number): number",
- "\treturn x + 1",
- "end",
- "",
- "Mod.live = Mod.getDefaults()",
- "local used = helperThing(1)",
- "",
- ].join("\n");
-
- beforeEach(async () => {
- process.env.DISPATCH_CS_BIN = luauCsBin as string;
- await writeFileP(join(workDir, "Mod.luau"), LUAU_MODULE);
- });
-
- it("detects `function Mod.x` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("function Mod.getDefaults");
- expect(out).not.toContain("No matches found");
- });
-
- it("detects `local function` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "helperThing", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("local function helperThing");
- });
-
- it("detects `type` / `export type` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const exportType = await tool.execute({ query: "StuntResult", only: "declarations" });
- expect(exportType).toContain("export type StuntResult");
- const aliasType = await tool.execute({ query: "LaunchConfig", only: "declarations" });
- expect(aliasType).toContain("type LaunchConfig");
- });
-
- it("excludes declaration lines when only=usages", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "usages" });
- // The call site is a usage; the `function Mod.getDefaults` definition is not.
- expect(out).toContain("Mod.live = Mod.getDefaults()");
- expect(out).not.toContain("function Mod.getDefaults");
- });
- });
-
- // ── Fuzzy mid-word matching: needs a cs built with the fuzzy patch
- // (docker/cs/fuzzy-distance.patch). Skipped on an unpatched/older cs. ──
- const fuzzyCsBin = findFuzzyCapableCs(liveCsBin);
- describe.runIf(fuzzyCsBin)("live cs binary (fuzzy edit-distance patch)", () => {
- beforeEach(() => {
- process.env.DISPATCH_CS_BIN = fuzzyCsBin as string;
- });
-
- it("matches a mid-word deletion within distance 1", async () => {
- await writeFileP(
- join(workDir, "phys.ts"),
- "export function computeSlipAngle() {\n\treturn 0;\n}\n",
- );
- const tool = createSearchCodeTool(workDir);
- // "computSlipAngle" drops the 'e' mid-word — edit distance 1.
- const out = await tool.execute({ query: "computSlipAngle~1" });
- expect(out).toContain("phys.ts");
- expect(out).toContain("computeSlipAngle");
- expect(out).not.toBe("No matches found.");
- });
-
- it("matches a mid-word insertion within distance 1", async () => {
- await writeFileP(join(workDir, "tire.ts"), "const tireFriction = 1;\n");
- const tool = createSearchCodeTool(workDir);
- // "tireFricction" has an extra 'c' — edit distance 1.
- const out = await tool.execute({ query: "tireFricction~1" });
- expect(out).toContain("tire.ts");
- expect(out).toContain("tireFriction");
- });
- });
-});
-
-/**
- * Locate a usable `cs` binary for live tests. Honors DISPATCH_CS_TEST_BIN, then
- * a `cs` on PATH. Returns null when none is runnable, so the live suite is
- * skipped rather than failing in environments without cs.
- */
-function findRealCs(): string | null {
- const candidates = [process.env.DISPATCH_CS_TEST_BIN, "cs"].filter(Boolean) as string[];
- for (const bin of candidates) {
- try {
- const res = spawnSync(bin, ["--version"], { stdio: "ignore" });
- if (res.status === 0) return bin;
- } catch {
- // try next
- }
- }
- return null;
-}
-
-/**
- * Probe a `cs` binary against a throwaway corpus and return its trimmed stdout
- * (or "" on any failure). Used by the capability gates below so patch-dependent
- * live tests run only on a cs that actually has the patch — and skip (not fail)
- * on an unpatched/older binary.
- */
-function probeCs(bin: string, files: Record<string, string>, args: string[]): string {
- let dir: string | undefined;
- try {
- dir = mkdtempSync(join(tmpdir(), "dispatch-cs-probe-"));
- for (const [name, body] of Object.entries(files)) {
- writeFileSync(join(dir, name), body);
- }
- const res = spawnSync(bin, ["-f", "json", "--dir", dir, ...args], {
- encoding: "utf8",
- });
- if (res.status !== 0 || !res.stdout) return "";
- return res.stdout.trim();
- } catch {
- return "";
- } finally {
- if (dir) rmSync(dir, { recursive: true, force: true });
- }
-}
-
-/**
- * Return the cs binary only if it recognises Luau declarations (i.e. was built
- * with docker/cs/luau-declarations.patch): a `--only-declarations` search for a
- * top-level `function` in a .luau file yields a result. Otherwise null → skip.
- */
-function findLuauCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.luau": "function Probe.thing()\n\treturn 1\nend\n" }, [
- "--only-declarations",
- "--",
- "thing",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
-
-/**
- * Return the cs binary only if its fuzzy matcher honours mid-word edits (i.e.
- * was built with docker/cs/fuzzy-distance.patch): a distance-1 deletion matches.
- * Otherwise null → skip.
- */
-function findFuzzyCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.txt": "const x = computeSlipAngle;\n" }, [
- "--",
- "computSlipAngle~1",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
diff --git a/packages/core/tests/tools/send-to-tab.test.ts b/packages/core/tests/tools/send-to-tab.test.ts
deleted file mode 100644
index 21d8032..0000000
--- a/packages/core/tests/tools/send-to-tab.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- createSendToTabTool,
- type SendToTabCallbacks,
- type TabResolution,
-} from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<SendToTabCallbacks> = {}): SendToTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- deliver: () => ({ status: "started" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- self: { id: "self-id", handle: "self" },
- canReadTab: true,
- ...overrides,
- };
-}
-
-describe("createSendToTabTool — schema & description", () => {
- it("exposes tab_id and message params and a fire-and-forget description", () => {
- const tool = createSendToTabTool(makeCallbacks());
- expect(tool.name).toBe("send_to_tab");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description.toLowerCase()).toContain("queued");
- // Description must steer the model away from busy-waiting for a reply.
- expect(tool.description.toLowerCase()).toContain("do not sleep");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-
- it("mentions read_tab in the description only when canReadTab is true", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: true }));
- expect(tool.description).toContain("read_tab");
- });
-
- it("never mentions read_tab in the description when canReadTab is false", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: false }));
- expect(tool.description).not.toContain("read_tab");
- // Still tells the agent a reply will wake it + to end its turn.
- expect(tool.description.toLowerCase()).toContain("wake you with a new message");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-});
-
-describe("createSendToTabTool — execute()", () => {
- it("delivers to a resolved target and reports the started status", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "hello there" });
- expect(deliver).toHaveBeenCalledTimes(1);
- const [targetId, delivered] = deliver.mock.calls[0] ?? [];
- expect(targetId).toBe("target-id");
- // Provenance header names the sending tab's handle and marks it as a
- // peer agent (not the recipient's own user).
- expect(delivered).toContain("[message from tab self");
- expect(delivered).toContain("another agent");
- expect(delivered).toContain("hello there");
- // Reply contract: the recipient must answer via send_to_tab back to the
- // sender's handle, not as a plain text reply to its own user.
- expect(delivered).toContain('send_to_tab tool with tab_id "self"');
- expect(delivered).toContain("ONLY reply if");
- expect(out).toContain("idle");
- expect(out).toContain("targ");
- // Sender is steered away from busy-waiting and told to end its turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("points the sender at read_tab in the result only when canReadTab is true", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: true }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("read_tab");
- });
-
- it("omits read_tab from the result when canReadTab is false", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: false }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).not.toContain("read_tab");
- // Still steers away from busy-waiting and toward ending the turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("reports the queued status when the target is busy", async () => {
- const deliver = vi.fn(() => ({ status: "queued" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping" });
- expect(out.toLowerCase()).toContain("queued");
- expect(out.toLowerCase()).toContain("busy");
- });
-
- it("reports a HELD message when delivery is suppressed (auto-wake limit hit)", async () => {
- const deliver = vi.fn(() => ({ status: "suppressed" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping again" });
- expect(out).toContain("HELD");
- expect(out.toLowerCase()).toContain("limit");
- // It must steer the sender away from retrying in a loop.
- expect(out.toLowerCase()).toContain("do not keep resending");
- expect(out.toLowerCase()).toContain("human");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createSendToTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: " ", message: "hi" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("rejects an empty message", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: " " });
- expect(out).toContain("Error");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error and open-tab list when the id is unknown", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({ status: "none" }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "zzzz", message: "hi" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "abcd", message: "hi" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("refuses to send to its own tab", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ok",
- tab: { id: "self-id", title: "Me", handle: "self" },
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "self", message: "hi" });
- expect(out).toContain("cannot send a message to your own tab");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("surfaces a thrown delivery error instead of crashing", async () => {
- const tool = createSendToTabTool(
- makeCallbacks({
- deliver: () => {
- throw new Error("boom");
- },
- }),
- );
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("Error delivering message");
- expect(out).toContain("boom");
- });
-});
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
deleted file mode 100644
index 4885a94..0000000
--- a/packages/core/tests/tools/summon.test.ts
+++ /dev/null
@@ -1,349 +0,0 @@
-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");
- });
-
- it("shows two groups when userAgentEnabled is true", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- true,
- );
- expect(tool.description).toContain("Subagents (spawned as child tabs):");
- expect(tool.description).toContain(
- "User agents (spawned as independent top-level tabs, requires top_level=true):",
- );
- expect(tool.description).toContain("programmer");
- expect(tool.description).toContain("default");
- });
-
- it("hides user agents group when userAgentEnabled is false", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- false,
- );
- expect(tool.description).toContain("Available agents:");
- expect(tool.description).not.toContain("User agents");
- // "default" appears in generic description text, so check for the slug listing format
- expect(tool.description).not.toContain("- default: Default");
- });
-});
-
-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("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", agent: "test-agent", 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", agent: "test-agent" });
- // Foreground summons prefix the blocked result with `agent_id: <id>` so
- // the frontend's ToolCallDisplay regex can surface the "Open Tab" button
- // (see summon.ts). Assert both the prefix and the child output survive.
- expect(out).toContain("agent_id: id-1");
- expect(out).toBe("agent_id: id-1\n\nchild-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", agent: "test-agent" });
- expect(out).toContain("boom");
- });
-
- it("returns fire-and-forget message when top_level=true", async () => {
- const spawn = vi.fn(async () => "ua-tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- true, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true,
- });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-tab-1");
- expect(out).toContain("fire-and-forget");
- expect(getResult).not.toHaveBeenCalled();
-
- // Verify topLevel was forwarded to spawn
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true });
- });
-
- it("ignores top_level when userAgentEnabled is false", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "result" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- false, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true, // should be ignored
- });
- // Should behave as a normal foreground summon, not fire-and-forget
- expect(out).not.toContain("fire-and-forget");
- expect(getResult).toHaveBeenCalled();
- });
-});
-
-describe("createSummonTool — user-agent-only mode (perm_user_agent without perm_summon)", () => {
- // userAgentEnabled=true, subagentEnabled=false → the tool spawns ONLY
- // top-level user agents. `top_level` is implied (and forced), the
- // subagent/parallel-work prose is dropped, and only the user-agent
- // catalog group is shown.
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
-
- function userAgentOnlyTool(
- spawn = vi.fn(async () => "ua-1"),
- getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" })),
- ) {
- return {
- spawn,
- getResult,
- tool: createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- subagents,
- userAgents,
- ["/agents"],
- true, // userAgentEnabled
- false, // subagentEnabled
- ),
- };
- }
-
- it("describes spawning user agents and omits subagent/parallel-work prose", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("Spawn an independent top-level user agent");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description).not.toContain("Pattern for parallel work");
- expect(tool.description).not.toContain("Set background=true");
- });
-
- it("lists only the user-agent catalog group, not subagents", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("User agents (spawned as independent top-level tabs):");
- expect(tool.description).toContain("default");
- // Subagents must not be advertised in user-agent-only mode.
- expect(tool.description).not.toContain("Subagents (spawned as child tabs):");
- expect(tool.description).not.toContain("- programmer: Programmer");
- });
-
- it("only lists user-agent slugs in the 'agent' parameter description", () => {
- const { tool } = userAgentOnlyTool();
- const agentParam = (tool.parameters as unknown as { shape: { agent: { description: string } } })
- .shape.agent;
- expect(agentParam.description).toContain("default");
- expect(agentParam.description).not.toContain("programmer");
- });
-
- it("omits the top_level parameter (it is implied)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("top_level" in shape).toBe(false);
- });
-
- it("omits the background parameter (user agents are fire-and-forget)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("background" in shape).toBe(false);
- });
-
- it("forces topLevel=true on spawn even when top_level is not passed", async () => {
- const spawn = vi.fn(async () => "ua-99");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const { tool } = userAgentOnlyTool(spawn, getResult);
- const out = await tool.execute({ task: "do stuff", agent: "default" });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-99");
- expect(out).toContain("fire-and-forget");
- // Never blocks on a result for fire-and-forget user agents.
- expect(getResult).not.toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true, agentSlug: "default" });
- });
-});
-
-describe("createSummonTool — subagentEnabled defaults preserve legacy behavior", () => {
- it("defaults subagentEnabled=true so omitting it keeps subagent spawning", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "child" }));
- // No userAgentEnabled/subagentEnabled args → legacy subagent-only mode.
- const tool = createSummonTool("/tmp/work", { spawn, getResult }, [], []);
- const out = await tool.execute({ task: "x", agent: "programmer" });
- // Foreground subagent summon blocks and returns the child result.
- expect(out).toBe("agent_id: tab-1\n\nchild");
- expect(getResult).toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).not.toHaveProperty("topLevel");
- });
-});
diff --git a/packages/core/tests/tools/task-list.test.ts b/packages/core/tests/tools/task-list.test.ts
deleted file mode 100644
index 5903fec..0000000
--- a/packages/core/tests/tools/task-list.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createTaskListTool, TaskList } from "../../src/tools/task-list.js";
-import type { TaskItem } from "../../src/types/index.js";
-
-describe("TaskList (declarative store)", () => {
- it("starts empty", () => {
- const list = new TaskList();
- expect(list.getTasks()).toEqual([]);
- });
-
- it("setTasks replaces the whole list and assigns positional ids", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "first", status: "in_progress" },
- { content: "second", status: "pending" },
- ]);
- expect(result).toEqual([
- { id: "task-1", content: "first", status: "in_progress" },
- { id: "task-2", content: "second", status: "pending" },
- ]);
- expect(list.getTasks()).toEqual(result);
- });
-
- it("a second setTasks fully replaces the previous list (no append)", () => {
- const list = new TaskList();
- list.setTasks([
- { content: "a", status: "completed" },
- { content: "b", status: "completed" },
- { content: "c", status: "pending" },
- ]);
- const next = list.setTasks([{ content: "only", status: "in_progress" }]);
- expect(next).toEqual([{ id: "task-1", content: "only", status: "in_progress" }]);
- expect(list.getTasks()).toHaveLength(1);
- });
-
- it("preserves all four statuses", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "p", status: "pending" },
- { content: "i", status: "in_progress" },
- { content: "c", status: "completed" },
- { content: "x", status: "cancelled" },
- ]);
- expect(result.map((t) => t.status)).toEqual([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
- ]);
- });
-
- it("defaults missing/invalid status to pending", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "no status" },
- { content: "bogus", status: "done" },
- { content: "junk", status: 42 },
- ]);
- expect(result.map((t) => t.status)).toEqual(["pending", "pending", "pending"]);
- });
-
- it("an empty array clears the list", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- expect(list.setTasks([])).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("getTasks returns copies (no external mutation leaks in)", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const snapshot = list.getTasks();
- snapshot[0].content = "mutated";
- expect(list.getTasks()[0].content).toBe("x");
- });
-
- it("onChange fires on every setTasks with the new snapshot", () => {
- const list = new TaskList();
- const seen: TaskItem[][] = [];
- const unsubscribe = list.onChange((tasks) => seen.push(tasks));
- list.setTasks([{ content: "a", status: "pending" }]);
- list.setTasks([{ content: "b", status: "completed" }]);
- expect(seen).toHaveLength(2);
- expect(seen[0]).toEqual([{ id: "task-1", content: "a", status: "pending" }]);
- expect(seen[1]).toEqual([{ id: "task-1", content: "b", status: "completed" }]);
- unsubscribe();
- list.setTasks([{ content: "c", status: "pending" }]);
- expect(seen).toHaveLength(2);
- });
-});
-
-describe("createTaskListTool", () => {
- it("exposes a single declarative `todos` parameter and the name `todo`", () => {
- const tool = createTaskListTool(new TaskList());
- expect(tool.name).toBe("todo");
- // One top-level param: the whole-list `todos` array.
- const shape = (tool.parameters as { shape: Record<string, unknown> }).shape;
- expect(Object.keys(shape)).toEqual(["todos"]);
- });
-
- it("execute updates the store and echoes the list WITHOUT ids", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({
- todos: [
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ],
- });
- expect(JSON.parse(out)).toEqual([
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ]);
- // Store has ids; the echo does not.
- expect(list.getTasks()).toEqual([
- { id: "task-1", content: "plan", status: "completed" },
- { id: "task-2", content: "build", status: "in_progress" },
- ]);
- });
-
- it("execute fires onChange so the UI broadcast is wired", async () => {
- const list = new TaskList();
- const cb = vi.fn();
- list.onChange(cb);
- const tool = createTaskListTool(list);
- await tool.execute({ todos: [{ content: "x", status: "pending" }] });
- expect(cb).toHaveBeenCalledTimes(1);
- });
-
- it("execute with an empty array clears the store", async () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [] });
- expect(JSON.parse(out)).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("execute defaults invalid status to pending in both store and echo", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [{ content: "x", status: "done" }] });
- expect(JSON.parse(out)).toEqual([{ content: "x", status: "pending" }]);
- expect(list.getTasks()[0].status).toBe("pending");
- });
-
- it("execute rejects a non-array todos param", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: "nope" });
- expect(out).toMatch(/Error/);
- });
-
- it("execute rejects items missing a content string", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: [{ status: "pending" }] });
- expect(out).toMatch(/Error/);
- });
-});
diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts
deleted file mode 100644
index 0dedbfc..0000000
--- a/packages/core/tests/tools/write-file.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { access, mkdtemp, readdir, readFile, rm, symlink } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createWriteFileTool } from "../../src/tools/write-file.js";
-
-describe("write_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("writes a new file", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "output.txt",
- content: "test content",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "output.txt"), "utf8");
- expect(written).toBe("test content");
- });
-
- it("creates parent directories", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "nested/dir/file.txt",
- content: "nested",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "nested/dir/file.txt"), "utf8");
- expect(written).toBe("nested");
- });
-
- it("blocks path traversal", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, filePath))` — when filePath
- // is absolute, `join` does NOT short-circuit, it concatenates. The old code
- // silently rewrote `/etc/foo` to `<workdir>/etc/foo` and "succeeded" by
- // writing to the wrong location. After the fix, absolute paths resolve
- // to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("writes an absolute path that lives under the workdir to the expected location", async () => {
- const tool = createWriteFileTool(workDir);
- const absoluteTarget = join(workDir, "abs.txt");
- const result = await tool.execute({ path: absoluteTarget, content: "abs content" });
- expect(result).toMatch(/successfully wrote/i);
- // File must exist at exactly `absoluteTarget`, NOT at
- // `<workdir>/<workdir>/abs.txt` (the old mangled location).
- const written = await readFile(absoluteTarget, "utf8");
- expect(written).toBe("abs content");
- });
-
- it("rejects absolute paths outside the workdir instead of silently mangling them", async () => {
- const tool = createWriteFileTool(workDir);
- // Pick a path under tmpdir that's definitely not under workDir.
- // Under the bug, this got rewritten to `<workdir>/tmp/...` and the
- // write "succeeded" at the wrong location.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}.txt`);
- const result = await tool.execute({ path: evilPath, content: "should not land" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlink containment: even when the *leaf* doesn't exist yet (the
- // common case for write_file creating a new file), `canonicalize`
- // must walk up to the nearest existing ancestor and resolve symlinks
- // there. Otherwise, a directory symlink inside workdir pointing
- // outside lets a write escape the workspace.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks writes that escape through a parent symlink (leaf does not exist yet)", async () => {
- const tool = createWriteFileTool(workDir);
- // `escape` is a symlink *inside* workdir to a directory *outside*.
- await symlink(externalDir, join(workDir, "escape"));
- const result = await tool.execute({
- path: "escape/payload.txt",
- content: "malicious payload",
- });
- expect(result).toMatch(/outside the working directory/i);
- // And the file must NOT exist in externalDir.
- await expect(access(join(externalDir, "payload.txt"))).rejects.toThrow();
- // And externalDir should be empty (nothing leaked through).
- const entries = await readdir(externalDir);
- expect(entries).toEqual([]);
- });
- });
-
- describe("onAfterWrite hook", () => {
- it("appends the hook's returned string to a successful write", async () => {
- const tool = createWriteFileTool(workDir, async (abs) => `DIAGNOSTICS for ${abs}`);
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).toContain("DIAGNOSTICS for");
- expect(result).toContain(join(workDir, "a.luau"));
- });
-
- it("does not append when the hook returns empty string", async () => {
- const tool = createWriteFileTool(workDir, async () => "");
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result.trim()).toMatch(/^Successfully wrote to "a\.luau"\.$/);
- });
-
- it("does not run the hook when the write is blocked (traversal)", async () => {
- let called = false;
- const tool = createWriteFileTool(workDir, async () => {
- called = true;
- return "should not appear";
- });
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- expect(called).toBe(false);
- });
-
- it("swallows hook errors so a throwing hook never fails the write", async () => {
- const tool = createWriteFileTool(workDir, async () => {
- throw new Error("lsp blew up");
- });
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).not.toContain("lsp blew up");
- });
-
- it("passes the canonical absolute path to the hook", async () => {
- let seen = "";
- const tool = createWriteFileTool(workDir, async (abs) => {
- seen = abs;
- return "";
- });
- await tool.execute({ path: "nested/b.luau", content: "x" });
- expect(seen).toBe(join(workDir, "nested/b.luau"));
- });
- });
-});