diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 16:33:02 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 16:33:02 +0900 |
| commit | 0f99a0c92707b44aef7c627012c4711bad5a8efd (patch) | |
| tree | 4b9fdc0895b30339d6501ed6387b02bc364bcdb6 /packages/core/tests/tools/search-code.test.ts | |
| parent | c0c08720cceb75b5e635e71190ae1f956f535133 (diff) | |
| download | dispatch-0f99a0c92707b44aef7c627012c4711bad5a8efd.tar.gz dispatch-0f99a0c92707b44aef7c627012c4711bad5a8efd.zip | |
feat: add search_code tool wrapping the cs code-search engine
Add a dedicated, permission-gated search_code tool that wraps boyter/cs
(code spelunker) — a fast, relevance-ranked, structure-aware code search
engine — giving agents a better default than grep/find for exploratory
'where is X / how does Y work' searches (ranked results, snippets, ~5x
smaller payloads).
- packages/core/src/tools/search-code.ts: createSearchCodeTool factory;
-f json invocation, workdir path containment, graceful missing-binary
handling (DISPATCH_CS_BIN override), readable per-file formatted output.
- Wire-up: export from core; register in agent-manager (both child-whitelist
and parent perm paths) behind new perm_search_code; add to summon catalog
+ tools enum; frontend ToolPermissions + settings.
- Docker: build a patched, statically-linked cs (pinned v3.1.0 commit) in a
golang builder stage and bundle at /usr/local/bin/cs.
- docker/cs/luau-declarations.patch: additive Luau declaration table so
--only-declarations / definition ranking works for Roblox .luau files
(upstream has Lua but not Luau). Applied during the Docker build.
- Tests: new search-code.test.ts (stubbed JSON formatting + live-cs
integration, skipped when cs absent); agent-manager/routes mocks +
perm-gating assertions; loader pass-through.
All tests (596), biome, and tsc (core/api/frontend) pass. cs-builder Docker
stage verified to build and produce a working patched binary.
Diffstat (limited to 'packages/core/tests/tools/search-code.test.ts')
| -rw-r--r-- | packages/core/tests/tools/search-code.test.ts | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts new file mode 100644 index 0000000..b805d15 --- /dev/null +++ b/packages/core/tests/tools/search-code.test.ts @@ -0,0 +1,194 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, 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" +`; + +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("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("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("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 }); + } + }); + + // ── 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("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."); + }); + }); +}); + +/** + * 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; +} |
