From 8d70db66d3f0046cdef5fbce2ce5a86eab0959ef Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 17:34:49 +0900 Subject: fix(search_code): render prose snippets + make context work + path-is-file guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address bugs found by an end-to-end test of the tool: - HIGH: prose/text files (.md/.html/etc.) came back as bare headers with no snippet. cs's default 'auto' snippet mode emits a single 'content' string (no 'lines[]') for prose, which the renderer skipped. Force --snippet-mode=lines by default so every file type returns a lines[] window that renders. Also add a defensive 'content'-shape fallback in formatResults (+ widen the CsResult type) so a content result is never shown blank. - HIGH: the 'context' parameter was a no-op — cs ignores -C except in grep snippet mode. When context is supplied, switch to --snippet-mode=grep so -C actually widens the per-match window (verified 2 -> 26 lines); default (no context) keeps the richer lines window for code. - LOW: a 'path' pointing at a file (not a dir) silently returned 'No matches found' (cs --dir => null). Now stat the path and return an explanatory error (file vs nonexistent), pointing at read_file for a file. - MEDIUM/doc: clarify snippet_length (prose-mostly) and context descriptions. Tests: +5 (prose rendering live + stubbed content-shape; context widening; path-is-file; path-nonexistent). Full suite 603 pass, biome + tsc green. Note: the EACCES spill failure seen in testing is pre-existing platform infra (truncate.ts SPILL_ROOT, shared by all tools), not part of this tool. --- packages/core/src/tools/search-code.ts | 64 ++++++++++++++++++---- packages/core/tests/tools/search-code.test.ts | 77 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts index c9ad086..67e1e1b 100644 --- a/packages/core/src/tools/search-code.ts +++ b/packages/core/src/tools/search-code.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { stat } from "node:fs/promises"; import { relative, sep } from "node:path"; import { z } from "zod"; import type { ToolDefinition } from "../types/index.js"; @@ -41,7 +42,16 @@ interface CsResult { filename: string; location: string; score: number; - lines: CsLine[]; + /** Present in "lines"/"grep" snippet modes. */ + lines?: CsLine[]; + /** + * Present instead of `lines` in cs's "snippet" mode (the default "auto" + * mode selects it for prose). We force a lines-based mode (see buildFlags), + * but this is kept as a defensive fallback so a content-shape result is + * still rendered rather than shown as a bare header. + */ + content?: string; + matchlocations?: Array<[number, number]>; language?: string; total_lines?: number; } @@ -91,7 +101,9 @@ export function createSearchCodeTool(workingDirectory: string): ToolDefinition { .int() .min(0) .optional() - .describe(`Lines of context to show around each match (0-${MAX_CONTEXT}).`), + .describe( + `Lines of context to show before and after each matching line (0-${MAX_CONTEXT}). When set, switches to a grep-style per-line window.`, + ), result_limit: z .number() .int() @@ -106,7 +118,7 @@ export function createSearchCodeTool(workingDirectory: string): ToolDefinition { .min(MIN_SNIPPET_LENGTH) .optional() .describe( - `Size in bytes of each snippet shown (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Larger = more context per match.`, + `Snippet size in bytes for prose/text files (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Has little effect on code files, which use a fixed line window — use 'context' to widen code snippets.`, ), only: z .enum(["code", "comments", "strings", "declarations", "usages"]) @@ -133,6 +145,20 @@ export function createSearchCodeTool(workingDirectory: string): ToolDefinition { return `Error: Path "${relPath}" is outside the working directory.`; } + // cs's --dir expects a directory; pointing it at a file silently + // returns no matches. Catch that and give an actionable hint instead + // of a misleading "No matches found". + if (relPath !== ".") { + try { + const st = await stat(searchDir); + if (!st.isDirectory()) { + return `Error: Path "${relPath}" is a file, not a directory. The 'path' parameter scopes the search to a directory; use read_file to read a single file.`; + } + } catch { + return `Error: Path "${relPath}" does not exist in the working directory.`; + } + } + const flags = buildFlags(args, searchDir); // `--` terminates cs flag parsing so a query that begins with "-" // (e.g. "-hello" or "--foo") is treated as the positional search term @@ -230,9 +256,20 @@ function buildFlags(args: Record, searchDir: string): string[] const excludePattern = args.exclude_pattern as string | undefined; if (excludePattern?.trim()) flags.push("-x", excludePattern.trim()); + // Snippet mode selection. cs's default ("auto") emits a `lines[]` array for + // code but a single `content` string for prose (.md/.html/…), which our + // renderer can't show — so prose results would come back as bare headers. + // It also ignores -C/--context entirely in auto/lines mode. + // + // - No `context` given → force "lines": every file type (code AND prose) + // returns a `lines[]` window, so prose snippets render too. + // - `context` given → use "grep": the only mode where -C actually widens + // the window; it likewise returns `lines[]` for all file types. if (typeof args.context === "number") { const context = clamp(Math.floor(args.context), 0, MAX_CONTEXT); - flags.push("-C", String(context)); + flags.push("--snippet-mode", "grep", "-C", String(context)); + } else { + flags.push("--snippet-mode", "lines"); } const requestedLimit = @@ -267,12 +304,21 @@ function formatResults(results: CsResult[], absoluteWorkDir: string): string { 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}`; - }); + let body: string[]; + if (r.lines && r.lines.length > 0) { + body = r.lines.map((l) => { + const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " "; + return ` ${marker} ${l.line_number}: ${l.content}`; + }); + } else if (r.content && r.content.trim() !== "") { + // Fallback for cs's "snippet"-mode shape (no per-line numbers): show + // the snippet text itself so the result isn't a bare header. + body = r.content.split("\n").map((line) => ` ${line}`); + } else { + body = [" (match in file; no snippet available)"]; + } - blocks.push([header, ...lines].join("\n")); + blocks.push([header, ...body].join("\n")); } const count = results.length; diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts index 1632214..d43158a 100644 --- a/packages/core/tests/tools/search-code.test.ts +++ b/packages/core/tests/tools/search-code.test.ts @@ -70,6 +70,21 @@ describe("search_code tool", () => { 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); @@ -139,6 +154,33 @@ describe("search_code tool", () => { } }); + 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("surfaces raw output when cs returns unparseable JSON", async () => { const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-")); try { @@ -199,6 +241,41 @@ describe("search_code tool", () => { 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"); -- cgit v1.2.3