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 | |
| 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')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 18 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 29 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/tools/search-code.ts | 262 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 2 | ||||
| -rw-r--r-- | packages/core/tests/agents/loader.test.ts | 2 | ||||
| -rw-r--r-- | packages/core/tests/tools/search-code.test.ts | 194 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolPermissions.svelte | 5 | ||||
| -rw-r--r-- | packages/frontend/src/lib/settings.svelte.ts | 2 |
10 files changed, 522 insertions, 1 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 85dd160..48b869e 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -19,6 +19,7 @@ import { createReadTabTool, createRetrieveTool, createRunShellTool, + createSearchCodeTool, createSendToTabTool, createSkillsWatcher, createSummonTool, @@ -75,6 +76,8 @@ const TOOL_DESCRIPTIONS: Record<string, string> = { write_file: "Write content to a file (creates parent directories if needed)", run_shell: "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Set background=true to run in the background and get a job_id for later retrieval. Do NOT run destructive or irreversible commands unless the user explicitly requests them.", + search_code: + "Search the codebase by query using the 'cs' code search engine (relevance-ranked, structure-aware). Returns the most relevant files first with matching snippets and line numbers. Better than grep/find for exploratory 'where is X / how does Y work' searches; use run_shell with rg for exhaustive exact-match lists.", todo: "Create/maintain a todo list to plan and track work. Declarative whole-list write: send the entire list in `todos` each call (it replaces the previous list). Statuses: pending, in_progress, completed, cancelled.", summon: "Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.", @@ -403,9 +406,10 @@ export class AgentManager { const permSendToTab = getSetting("perm_send_to_tab") === "allow"; const permReadTab = getSetting("perm_read_tab") === "allow"; const permWebSearch = getSetting("perm_web_search") === "allow"; + const permSearchCode = getSetting("perm_search_code") === "allow"; const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow"; const sysPrompt = getSetting("system_prompt") ?? ""; - const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`; + const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( @@ -479,6 +483,12 @@ export class AgentManager { tool: createRunShellTool(workingDirectory, tabAgent.shellStore), }); } + if (allowed.has("search_code")) { + toolEntries.push({ + name: "search_code", + tool: createSearchCodeTool(workingDirectory), + }); + } if (allowed.has("web_search")) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } @@ -565,6 +575,12 @@ export class AgentManager { tool: createRunShellTool(workingDirectory, tabAgent.shellStore), }); } + if (permSearchCode) { + toolEntries.push({ + name: "search_code", + tool: createSearchCodeTool(workingDirectory), + }); + } if (permWebSearch) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 014022a..970ac1d 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -419,6 +419,14 @@ vi.mock("@dispatch/core", () => ({ execute: async () => "mock", }; }, + createSearchCodeTool(_wd: string) { + return { + name: "search_code", + description: "search code", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, createYoutubeTranscribeTool() { return { name: "youtube_transcribe", @@ -1441,6 +1449,27 @@ describe("AgentManager", () => { }); }); + describe("search_code permission gating", () => { + // Reuses the parent-path tool construction to confirm the perm flag wires + // the search_code tool on/off correctly. + async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> { + for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v); + const manager = new AgentManager(); + await manager.processMessage(tabId, "go"); + return constructedAgents.at(-1)?.toolNames ?? []; + } + + it("grants search_code when perm_search_code is allowed", async () => { + const tools = await toolsForPerms("tab-cs-on", { perm_search_code: "allow" }); + expect(tools).toContain("search_code"); + }); + + it("omits search_code when perm_search_code is not allowed", async () => { + const tools = await toolsForPerms("tab-cs-off", {}); + expect(tools).not.toContain("search_code"); + }); + }); + // ─── Usage side-channel persistence ────────────────────────────── // // `usage` AgentEvents (one per LLM round-trip) are persisted as invisible diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index a8db5ce..c85d43d 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -283,6 +283,14 @@ vi.mock("@dispatch/core", () => ({ execute: async () => "mock", }; }, + createSearchCodeTool(_wd: string) { + return { + name: "search_code", + description: "search code", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, createYoutubeTranscribeTool() { return { name: "youtube_transcribe", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8334102..e5e6f9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -94,6 +94,7 @@ export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js"; export { createToolRegistry } from "./tools/registry.js"; export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js"; export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js"; +export { createSearchCodeTool } from "./tools/search-code.js"; export { createSendToTabTool, type ResolvedTabRef, diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts new file mode 100644 index 0000000..39cbbdd --- /dev/null +++ b/packages/core/src/tools/search-code.ts @@ -0,0 +1,262 @@ +import { spawn } from "node:child_process"; +import { relative } from "node:path"; +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; +import { canonicalize } from "./path-utils.js"; + +// Resolve the `cs` binary: an explicit override wins, otherwise rely on PATH. +// The deployed images build a patched, statically-linked `cs` into +// /usr/local/bin/cs (see Dockerfile); local dev can point DISPATCH_CS_BIN at a +// custom build. Read at call time so the environment can change at runtime +// (and so tests can point it at a stub or temp build). +function resolveCsBin(): string { + return process.env.DISPATCH_CS_BIN || "cs"; +} + +const DEFAULT_RESULT_LIMIT = 20; +const MAX_RESULT_LIMIT = 100; +const MAX_CONTEXT = 20; +const MIN_SNIPPET_LENGTH = 50; +const MAX_SNIPPET_LENGTH = 2000; +const TIMEOUT_MS = 30_000; + +/** Maps the `only` enum to the corresponding cs flag. */ +const ONLY_FLAGS: Record<string, string> = { + code: "--only-code", + comments: "--only-comments", + strings: "--only-strings", + declarations: "--only-declarations", + usages: "--only-usages", +}; + +/** One line within a cs JSON match result. */ +interface CsLine { + line_number: number; + content: string; + match_positions?: Array<[number, number]>; +} + +/** A single file result in cs `-f json` output. */ +interface CsResult { + filename: string; + location: string; + score: number; + lines: CsLine[]; + language?: string; + total_lines?: number; +} + +export function createSearchCodeTool(workingDirectory: string): ToolDefinition { + return { + name: "search_code", + description: + "Search the codebase by query using `cs` (code spelunker) — a fast, relevance-ranked code search engine. " + + "Prefer this over grep/find for EXPLORATORY 'where is X / how does Y work' searches: it ranks the most " + + "relevant files first and returns matching snippets with line numbers, so you spend fewer turns and tokens. " + + "It respects .gitignore and skips hidden/binary files. " + + 'Query syntax: space-separated terms are AND\'d; supports OR, NOT, "exact phrases", fuzzy~1, /regex/, and ' + + "metadata filters like lang:Go, file:test, path:src. " + + "It is a ranked text search, NOT a semantic/LSP index: it won't resolve types or imports. For an EXHAUSTIVE " + + "list of every exact match (e.g. before a rename), use run_shell with ripgrep (rg) instead.", + parameters: z.object({ + query: z + .string() + .describe( + 'The search query. Terms are AND\'d by default. Supports OR, NOT, "phrases", fuzzy~1, /regex/, and filters like lang:Go, file:test, path:src.', + ), + path: z + .string() + .optional() + .describe( + "Subdirectory to scope the search to, relative to the working directory. Defaults to the whole working directory.", + ), + case_sensitive: z + .boolean() + .optional() + .describe("Make the search case-sensitive. Default: false (case-insensitive)."), + include_ext: z + .string() + .optional() + .describe( + 'Comma-separated list of file extensions to limit the search to (case-sensitive), e.g. "go,ts,lua".', + ), + exclude_pattern: z + .string() + .optional() + .describe( + 'Comma-separated list of path patterns to exclude (case-sensitive), e.g. "vendor,_test.go".', + ), + context: z + .number() + .int() + .min(0) + .optional() + .describe(`Lines of context to show around each match (0-${MAX_CONTEXT}).`), + result_limit: z + .number() + .int() + .min(1) + .optional() + .describe( + `Maximum number of file results to return. Default: ${DEFAULT_RESULT_LIMIT}, max: ${MAX_RESULT_LIMIT}.`, + ), + snippet_length: z + .number() + .int() + .min(MIN_SNIPPET_LENGTH) + .optional() + .describe( + `Size in bytes of each snippet shown (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Larger = more context per match.`, + ), + only: z + .enum(["code", "comments", "strings", "declarations", "usages"]) + .optional() + .describe( + "Restrict matches structurally: code, comments, strings, declarations (definitions like func/class/type), " + + "or usages (call sites). Best-effort and language-dependent — strong for Go/TypeScript/Python/Lua/Luau, " + + "unavailable for unsupported languages (which fall back to plain text ranking).", + ), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const query = args.query as string; + if (typeof query !== "string" || query.trim() === "") { + return "Error: query is required."; + } + + // Resolve and contain the optional search path within the workdir. + // Canonicalize so a symlink-in-workdir pointing outside is detected, + // matching the containment semantics of list_files / read_file. + const relPath = (args.path as string | undefined) ?? "."; + const absoluteWorkDir = await canonicalize(workingDirectory); + const searchDir = await canonicalize(workingDirectory, relPath); + if (searchDir !== absoluteWorkDir && !searchDir.startsWith(`${absoluteWorkDir}/`)) { + return `Error: Path "${relPath}" is outside the working directory.`; + } + + const flags = buildFlags(args, searchDir); + const spawnArgs = [...flags, query]; + + let stdout = ""; + let stderr = ""; + const result = await new Promise<{ error?: string; errorCode?: string }>((resolve) => { + const child = spawn(resolveCsBin(), spawnArgs, { + cwd: workingDirectory, + env: process.env, + timeout: TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + child.stderr?.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + child.on("close", () => resolve({})); + child.on("error", (err) => + resolve({ error: err.message, errorCode: (err as NodeJS.ErrnoException).code }), + ); + }); + + if (result.error) { + // The binary is missing or not executable — give an actionable hint. + if (result.errorCode === "ENOENT" || result.error.includes("ENOENT")) { + return missingBinaryError(); + } + return `Error: failed to run cs: ${result.error}`; + } + + // cs prints `null` (and exit 0) when there are no matches. + const trimmed = stdout.trim(); + if (trimmed === "" || trimmed === "null") { + return "No matches found."; + } + + let parsed: CsResult[]; + try { + parsed = JSON.parse(trimmed) as CsResult[]; + } catch { + // Couldn't parse — surface what cs produced so the caller isn't blind. + const detail = stderr.trim() ? `\nstderr: ${stderr.trim()}` : ""; + return `Error: could not parse cs output as JSON.${detail}\n\nRaw output:\n${trimmed.slice(0, 2000)}`; + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + return "No matches found."; + } + + return formatResults(parsed, absoluteWorkDir); + }, + }; +} + +/** Build the cs CLI flags (everything except the trailing query). */ +function buildFlags(args: Record<string, unknown>, searchDir: string): string[] { + const flags: string[] = ["-f", "json", "--dir", searchDir]; + + if (args.case_sensitive === true) flags.push("-c"); + + const includeExt = args.include_ext as string | undefined; + if (includeExt?.trim()) flags.push("-i", includeExt.trim()); + + const excludePattern = args.exclude_pattern as string | undefined; + if (excludePattern?.trim()) flags.push("-x", excludePattern.trim()); + + if (typeof args.context === "number") { + const context = clamp(Math.floor(args.context), 0, MAX_CONTEXT); + flags.push("-C", String(context)); + } + + const requestedLimit = + typeof args.result_limit === "number" + ? clamp(Math.floor(args.result_limit), 1, MAX_RESULT_LIMIT) + : DEFAULT_RESULT_LIMIT; + flags.push("--result-limit", String(requestedLimit)); + + if (typeof args.snippet_length === "number") { + const snippet = clamp(Math.floor(args.snippet_length), MIN_SNIPPET_LENGTH, MAX_SNIPPET_LENGTH); + flags.push("-n", String(snippet)); + } + + const only = args.only as string | undefined; + if (only && ONLY_FLAGS[only]) flags.push(ONLY_FLAGS[only]); + + return flags; +} + +/** Render cs JSON results into compact, readable per-file blocks. */ +function formatResults(results: CsResult[], absoluteWorkDir: string): string { + const blocks: string[] = []; + for (const r of results) { + // Present paths relative to the workdir so output is portable and compact. + const rel = r.location.startsWith(absoluteWorkDir) + ? relative(absoluteWorkDir, r.location) || r.filename + : r.location; + const lang = r.language ? ` [${r.language}]` : ""; + const score = typeof r.score === "number" ? ` (score ${r.score.toFixed(2)})` : ""; + const header = `${rel}${lang}${score}`; + + const lines = (r.lines ?? []).map((l) => { + const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " "; + return ` ${marker} ${l.line_number}: ${l.content}`; + }); + + blocks.push([header, ...lines].join("\n")); + } + + const count = results.length; + const heading = `Found matches in ${count} file${count === 1 ? "" : "s"} (ranked by relevance):`; + return [heading, "", blocks.join("\n\n")].join("\n"); +} + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +function missingBinaryError(): string { + return [ + "Error: search_code requires the 'cs' (code spelunker) binary, which was not found.", + "Install it with: go install github.com/boyter/cs/[email protected]", + "or set the DISPATCH_CS_BIN environment variable to the path of a cs binary.", + "(In the official Docker images cs is bundled at /usr/local/bin/cs.)", + ].join("\n"); +} diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts index 4820e89..250af25 100644 --- a/packages/core/src/tools/summon.ts +++ b/packages/core/src/tools/summon.ts @@ -172,6 +172,7 @@ export function createSummonTool( " - list_files: List files and directories", " - write_file: Write/edit files", " - run_shell: Execute shell commands", + " - search_code: Search the codebase with the cs ranked code-search engine", " - todo: Track work items", " - summon: Spawn its own child agents (enables nesting)", " - retrieve: Collect results from its children (required if summon is given)", @@ -229,6 +230,7 @@ export function createSummonTool( "list_files", "write_file", "run_shell", + "search_code", "todo", "summon", "retrieve", 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; +} diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte index 0caf0fa..db5055c 100644 --- a/packages/frontend/src/lib/components/ToolPermissions.svelte +++ b/packages/frontend/src/lib/components/ToolPermissions.svelte @@ -47,6 +47,11 @@ const toolPermissions: ToolPermission[] = [ label: "YouTube transcripts", description: "Allow the AI to fetch YouTube video transcripts", }, + { + id: "search_code", + label: "Search code", + description: "Allow the AI to search the codebase with the cs ranked code-search engine", + }, ]; const { diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 123008d..30361a7 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -14,6 +14,7 @@ let toolPerms = $state<Record<string, boolean>>({ external_directory: false, web_search: false, youtube_transcribe: false, + search_code: false, }); let savedToolPerms = $state<Record<string, boolean>>({ read: true, @@ -26,6 +27,7 @@ let savedToolPerms = $state<Record<string, boolean>>({ external_directory: false, web_search: false, youtube_transcribe: false, + search_code: false, }); let skillChecks = $state<Record<string, boolean>>({}); let chunkLimit = $state(100); |
