diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/tests/tools | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-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.ts | 36 | ||||
| -rw-r--r-- | packages/core/tests/tools/key-usage.test.ts | 317 | ||||
| -rw-r--r-- | packages/core/tests/tools/list-files.test.ts | 94 | ||||
| -rw-r--r-- | packages/core/tests/tools/lsp-tool.test.ts | 110 | ||||
| -rw-r--r-- | packages/core/tests/tools/read-file.test.ts | 142 | ||||
| -rw-r--r-- | packages/core/tests/tools/read-tab.test.ts | 101 | ||||
| -rw-r--r-- | packages/core/tests/tools/registry.test.ts | 143 | ||||
| -rw-r--r-- | packages/core/tests/tools/run-shell.test.ts | 78 | ||||
| -rw-r--r-- | packages/core/tests/tools/search-code.test.ts | 511 | ||||
| -rw-r--r-- | packages/core/tests/tools/send-to-tab.test.ts | 185 | ||||
| -rw-r--r-- | packages/core/tests/tools/summon.test.ts | 349 | ||||
| -rw-r--r-- | packages/core/tests/tools/task-list.test.ts | 158 | ||||
| -rw-r--r-- | packages/core/tests/tools/write-file.test.ts | 152 |
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")); - }); - }); -}); |
