summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 16:33:02 +0900
committerAdam Malczewski <[email protected]>2026-06-02 16:33:02 +0900
commit0f99a0c92707b44aef7c627012c4711bad5a8efd (patch)
tree4b9fdc0895b30339d6501ed6387b02bc364bcdb6 /packages/core/tests
parentc0c08720cceb75b5e635e71190ae1f956f535133 (diff)
downloaddispatch-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')
-rw-r--r--packages/core/tests/agents/loader.test.ts2
-rw-r--r--packages/core/tests/tools/search-code.test.ts194
2 files changed, 196 insertions, 0 deletions
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts
index 92f9877..a223a4f 100644
--- a/packages/core/tests/agents/loader.test.ts
+++ b/packages/core/tests/agents/loader.test.ts
@@ -43,6 +43,7 @@ describe("expandAgentToolNames", () => {
"retrieve",
"web_search",
"youtube_transcribe",
+ "search_code",
"send_to_tab",
"read_tab",
]);
@@ -52,6 +53,7 @@ describe("expandAgentToolNames", () => {
"retrieve",
"web_search",
"youtube_transcribe",
+ "search_code",
"send_to_tab",
"read_tab",
]),
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;
+}