summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 17:34:49 +0900
committerAdam Malczewski <[email protected]>2026-06-02 17:34:49 +0900
commit8d70db66d3f0046cdef5fbce2ce5a86eab0959ef (patch)
treece15598edf72e325b4079a36d468ae1de4152570 /packages/core/tests
parent370fce3aca9249fa11645206f6457c59354a1445 (diff)
downloaddispatch-8d70db66d3f0046cdef5fbce2ce5a86eab0959ef.tar.gz
dispatch-8d70db66d3f0046cdef5fbce2ce5a86eab0959ef.zip
fix(search_code): render prose snippets + make context work + path-is-file guard
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 <file> => 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.
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/tools/search-code.test.ts77
1 files changed, 77 insertions, 0 deletions
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");