From 4e4555d25e78887afc9c67ce6d04675ad3a7e17b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 21:13:26 +0000 Subject: [PATCH 1/4] Detect malformed WarpGrep Windows results and harden error formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7 — On Windows, upstream SDK path parsing truncates drive-letter paths (e.g. C:/Users/.../auth.ts → file: 'C'), producing confusing output. This adds detection for malformed contexts and surfaces an actionable error with a workaround suggestion. Also fixes the 'Search failed: undefined' output when the SDK omits an error string. https://claude.ai/code/session_01G5qa1GyHGNwWu2ko6uZ53N --- index.test.ts | 109 +++++++++++++++++++++++++++++++++++++++++++++++++- index.ts | 32 ++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/index.test.ts b/index.test.ts index 7296aa0..b164177 100644 --- a/index.test.ts +++ b/index.test.ts @@ -9,7 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { CompactClient } from "@morphllm/morphsdk"; +import { CompactClient, WarpGrepClient } from "@morphllm/morphsdk"; // These are internal to the plugin but duplicated here for testing. const EXISTING_CODE_MARKER = "// ... existing code ..."; @@ -991,3 +991,110 @@ describe("ToolContext path resolution", () => { } }); }); + +// --------------------------------------------------------------------------- +// WarpGrep malformed Windows result detection (Issue #7) +// --------------------------------------------------------------------------- + +describe("warpgrep_codebase_search malformed Windows results", () => { + /** + * Helper: patch WarpGrepClient.prototype.execute to return a fake result, + * import the plugin fresh, call the tool, then restore the original. + */ + async function executeSearchWithMockedResult(fakeResult: unknown): Promise { + const original = WarpGrepClient.prototype.execute; + // The plugin calls warpGrep!.execute() which is an async generator. + // We mock it to yield nothing and return the fakeResult. + WarpGrepClient.prototype.execute = function* () { + return fakeResult; + } as any; + + try { + const { default: MorphPlugin } = await importPluginWithEnv({ + MORPH_API_KEY: "sk-test-key", + }); + const hooks = await MorphPlugin(makePluginInput("/tmp/morph-warpgrep-test")); + const result = await hooks.tool.warpgrep_codebase_search.execute( + { search_term: "test query" }, + makeToolContext("/tmp/morph-warpgrep-test"), + ); + return result as string; + } finally { + WarpGrepClient.prototype.execute = original; + } + } + + test("malformed result with file:'C' returns actionable Windows error", async () => { + const result = await executeSearchWithMockedResult({ + success: true, + contexts: [ + { file: "C", content: "", lines: "*" }, + ], + }); + + expect(result).toContain("malformed file contexts on Windows"); + expect(result).toContain("`C`"); + expect(result).toContain("upstream SDK"); + expect(result).toContain("grep"); + expect(result).toContain("read"); + }); + + test("multiple malformed contexts still triggers error", async () => { + const result = await executeSearchWithMockedResult({ + success: true, + contexts: [ + { file: "C", content: "", lines: "*" }, + { file: "D", content: "", lines: "*" }, + ], + }); + + expect(result).toContain("malformed file contexts on Windows"); + }); + + test("missing SDK error string does not produce 'Search failed: undefined'", async () => { + const result = await executeSearchWithMockedResult({ + success: false, + error: undefined, + }); + + expect(result).not.toContain("undefined"); + expect(result).toContain("Search failed"); + expect(result).toContain("no error details"); + }); + + test("explicit SDK error string is preserved", async () => { + const result = await executeSearchWithMockedResult({ + success: false, + error: "timeout after 60s", + }); + + expect(result).toContain("Search failed: timeout after 60s"); + }); + + test("valid search results still format normally", async () => { + const result = await executeSearchWithMockedResult({ + success: true, + contexts: [ + { + file: "src/auth.ts", + content: "export function login() { return true; }", + lines: [[1, 5]] as Array<[number, number]>, + }, + ], + }); + + expect(result).toContain("Relevant context found:"); + expect(result).toContain("src/auth.ts"); + expect(result).toContain("export function login()"); + expect(result).not.toContain("malformed"); + }); + + test("empty contexts returns 'no relevant code' message", async () => { + const result = await executeSearchWithMockedResult({ + success: true, + contexts: [], + }); + + expect(result).toContain("No relevant code found"); + }); +}); diff --git a/index.ts b/index.ts index fc3b5b5..f591fe5 100644 --- a/index.ts +++ b/index.ts @@ -327,18 +327,48 @@ function buildMorphSystemRoutingHint(): string | null { return lines.length > 1 ? lines.join("\n") : null; } +/** + * Check if a single WarpGrep context looks like a truncated Windows drive-letter path. + * e.g. { file: 'C', content: '', lines: '*' } + */ +function isMalformedContext(ctx: { file: string; content: string; lines?: string | unknown }): boolean { + return /^[A-Za-z]$/.test(ctx.file) && !ctx.content && ctx.lines === "*"; +} + +/** + * Check if a WarpGrep result contains malformed Windows-style contexts. + * Returns true when all (or the majority of) contexts are malformed. + */ +function hasMalformedContexts(contexts: { file: string; content: string; lines?: string | unknown }[]): boolean { + if (contexts.length === 0) return false; + const malformedCount = contexts.filter(isMalformedContext).length; + return malformedCount > 0 && malformedCount >= contexts.length / 2; +} + /** * Format WarpGrep results for tool output */ function formatWarpGrepResult(result: WarpGrepResult): string { if (!result.success) { - return `Search failed: ${result.error}`; + return `Search failed: ${result.error || "search returned no error details."}`; } if (!result.contexts || result.contexts.length === 0) { return "No relevant code found. Try rephrasing your search term."; } + if (hasMalformedContexts(result.contexts)) { + const malformedFiles = result.contexts + .filter(isMalformedContext) + .map((ctx) => ctx.file); + console.error( + `[morph-plugin] Malformed WarpGrep contexts detected: ${malformedFiles.length} context(s) with file values: ${JSON.stringify(malformedFiles)}${result.summary ? ` | summary: ${result.summary}` : ""}`, + ); + return `Search returned malformed file contexts on Windows (for example \`${malformedFiles[0]}\` instead of a full file path). +This appears to be an upstream SDK Windows path parsing bug. +Temporary workaround: use \`grep\` + \`read\` for local code search until the SDK fix lands.`; + } + const parts: string[] = []; parts.push("Relevant context found:"); From dc5aa5ab8991b24f7202758e35c8f9de29ea4645 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 21:22:23 +0000 Subject: [PATCH 2/4] Simplify WarpGrep regression tests to two focused cases --- index.test.ts | 100 ++++++++++++++------------------------------------ 1 file changed, 28 insertions(+), 72 deletions(-) diff --git a/index.test.ts b/index.test.ts index b164177..9385f54 100644 --- a/index.test.ts +++ b/index.test.ts @@ -992,19 +992,9 @@ describe("ToolContext path resolution", () => { }); }); -// --------------------------------------------------------------------------- -// WarpGrep malformed Windows result detection (Issue #7) -// --------------------------------------------------------------------------- - -describe("warpgrep_codebase_search malformed Windows results", () => { - /** - * Helper: patch WarpGrepClient.prototype.execute to return a fake result, - * import the plugin fresh, call the tool, then restore the original. - */ - async function executeSearchWithMockedResult(fakeResult: unknown): Promise { +describe("formatWarpGrepResult edge cases", () => { + async function executeSearch(fakeResult: unknown): Promise { const original = WarpGrepClient.prototype.execute; - // The plugin calls warpGrep!.execute() which is an async generator. - // We mock it to yield nothing and return the fakeResult. WarpGrepClient.prototype.execute = function* () { return fakeResult; } as any; @@ -1014,87 +1004,53 @@ describe("warpgrep_codebase_search malformed Windows results", () => { MORPH_API_KEY: "sk-test-key", }); const hooks = await MorphPlugin(makePluginInput("/tmp/morph-warpgrep-test")); - const result = await hooks.tool.warpgrep_codebase_search.execute( - { search_term: "test query" }, + return (await hooks.tool.warpgrep_codebase_search.execute( + { search_term: "auth flow" }, makeToolContext("/tmp/morph-warpgrep-test"), - ); - return result as string; + )) as string; } finally { WarpGrepClient.prototype.execute = original; } } - test("malformed result with file:'C' returns actionable Windows error", async () => { - const result = await executeSearchWithMockedResult({ - success: true, - contexts: [ - { file: "C", content: "", lines: "*" }, - ], - }); - - expect(result).toContain("malformed file contexts on Windows"); - expect(result).toContain("`C`"); - expect(result).toContain("upstream SDK"); - expect(result).toContain("grep"); - expect(result).toContain("read"); - }); - - test("multiple malformed contexts still triggers error", async () => { - const result = await executeSearchWithMockedResult({ + test("truncated Windows drive-letter contexts produce actionable error, valid contexts still render", async () => { + const malformed = await executeSearch({ success: true, contexts: [ { file: "C", content: "", lines: "*" }, { file: "D", content: "", lines: "*" }, + { file: "E", content: "", lines: "*" }, ], }); - expect(result).toContain("malformed file contexts on Windows"); - }); + expect(malformed).toContain("malformed"); + expect(malformed).toContain("grep"); + expect(malformed).not.toContain(" { - const result = await executeSearchWithMockedResult({ - success: false, - error: undefined, - }); - - expect(result).not.toContain("undefined"); - expect(result).toContain("Search failed"); - expect(result).toContain("no error details"); - }); - - test("explicit SDK error string is preserved", async () => { - const result = await executeSearchWithMockedResult({ - success: false, - error: "timeout after 60s", - }); - - expect(result).toContain("Search failed: timeout after 60s"); - }); - - test("valid search results still format normally", async () => { - const result = await executeSearchWithMockedResult({ + const valid = await executeSearch({ success: true, contexts: [ - { - file: "src/auth.ts", - content: "export function login() { return true; }", - lines: [[1, 5]] as Array<[number, number]>, - }, + { file: "src/auth.ts", content: "export function login() {}", lines: [[1, 10]] as Array<[number, number]> }, ], }); - expect(result).toContain("Relevant context found:"); - expect(result).toContain("src/auth.ts"); - expect(result).toContain("export function login()"); - expect(result).not.toContain("malformed"); + expect(valid).toContain("Relevant context found:"); + expect(valid).toContain(' { - const result = await executeSearchWithMockedResult({ - success: true, - contexts: [], - }); + test("missing or undefined error field never surfaces as literal 'undefined'", async () => { + const noError = await executeSearch({ success: false }); + const nullError = await executeSearch({ success: false, error: null }); + const emptyError = await executeSearch({ success: false, error: "" }); + + for (const result of [noError, nullError, emptyError]) { + expect(result).toMatch(/^Search failed:/); + expect(result).not.toContain("undefined"); + expect(result).not.toContain("null"); + } - expect(result).toContain("No relevant code found"); + const withError = await executeSearch({ success: false, error: "timeout after 60s" }); + expect(withError).toBe("Search failed: timeout after 60s"); }); }); From a9c28d43156d48d51a5fd041a8a6a6f807c54a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 21:28:15 +0000 Subject: [PATCH 3/4] Replace Windows-specific detection with general path validation Validate that search result file paths are plausible (contain a path separator or dot-extension with non-empty content) rather than checking for a specific truncation pattern. Tests cover paths from all major OS conventions. https://claude.ai/code/session_01G5qa1GyHGNwWu2ko6uZ53N --- index.test.ts | 63 +++++++++++++++++++++++++++------------------------ index.ts | 39 ++++++++++++------------------- 2 files changed, 47 insertions(+), 55 deletions(-) diff --git a/index.test.ts b/index.test.ts index 9385f54..c37a166 100644 --- a/index.test.ts +++ b/index.test.ts @@ -1013,44 +1013,47 @@ describe("formatWarpGrepResult edge cases", () => { } } - test("truncated Windows drive-letter contexts produce actionable error, valid contexts still render", async () => { - const malformed = await executeSearch({ - success: true, - contexts: [ - { file: "C", content: "", lines: "*" }, - { file: "D", content: "", lines: "*" }, - { file: "E", content: "", lines: "*" }, - ], - }); - - expect(malformed).toContain("malformed"); - expect(malformed).toContain("grep"); - expect(malformed).not.toContain(" { + // Implausible: bare letters, empty strings, whitespace, no separators or extensions + for (const file of ["C", "", " ", "noextension"]) { + const result = await executeSearch({ + success: true, + contexts: [{ file, content: "", lines: "*" }], + }); + expect(result).toContain("malformed"); + expect(result).not.toContain(" }, - ], - }); + // Valid paths across all major OS conventions + const validPaths = [ + { file: "src/auth.ts", content: "code" }, // unix relative + { file: "/usr/local/bin/server.js", content: "code" }, // unix absolute + { file: "C:\\Users\\dev\\project\\main.rs", content: "code" }, // windows absolute + { file: "packages\\core\\index.ts", content: "code" }, // windows relative + { file: "../sibling/lib.py", content: "code" }, // relative with .. + { file: "./config.yaml", content: "code" }, // relative with ./ + { file: "Makefile.toml", content: "code" }, // dot-extension only + ]; - expect(valid).toContain("Relevant context found:"); - expect(valid).toContain(' }], + }); + expect(result).toContain("Relevant context found:"); + expect(result).toContain(` { - const noError = await executeSearch({ success: false }); - const nullError = await executeSearch({ success: false, error: null }); - const emptyError = await executeSearch({ success: false, error: "" }); - - for (const result of [noError, nullError, emptyError]) { + test("falsy error fields never surface literally; explicit errors pass through", async () => { + for (const error of [undefined, null, ""]) { + const result = await executeSearch({ success: false, error }); expect(result).toMatch(/^Search failed:/); expect(result).not.toContain("undefined"); expect(result).not.toContain("null"); } - const withError = await executeSearch({ success: false, error: "timeout after 60s" }); - expect(withError).toBe("Search failed: timeout after 60s"); + const result = await executeSearch({ success: false, error: "timeout after 60s" }); + expect(result).toBe("Search failed: timeout after 60s"); }); }); diff --git a/index.ts b/index.ts index f591fe5..0190aa7 100644 --- a/index.ts +++ b/index.ts @@ -328,21 +328,14 @@ function buildMorphSystemRoutingHint(): string | null { } /** - * Check if a single WarpGrep context looks like a truncated Windows drive-letter path. - * e.g. { file: 'C', content: '', lines: '*' } + * Minimal check for a plausible file path on any OS: + * - at least one path separator (/ or \) OR a dot-extension + * - not empty / whitespace-only */ -function isMalformedContext(ctx: { file: string; content: string; lines?: string | unknown }): boolean { - return /^[A-Za-z]$/.test(ctx.file) && !ctx.content && ctx.lines === "*"; -} +const PLAUSIBLE_PATH_RE = /[/\\]|\.[\w]+$/; -/** - * Check if a WarpGrep result contains malformed Windows-style contexts. - * Returns true when all (or the majority of) contexts are malformed. - */ -function hasMalformedContexts(contexts: { file: string; content: string; lines?: string | unknown }[]): boolean { - if (contexts.length === 0) return false; - const malformedCount = contexts.filter(isMalformedContext).length; - return malformedCount > 0 && malformedCount >= contexts.length / 2; +function isValidContext(ctx: { file: string; content: string }): boolean { + return Boolean(ctx.file) && PLAUSIBLE_PATH_RE.test(ctx.file) && ctx.content.length > 0; } /** @@ -357,22 +350,18 @@ function formatWarpGrepResult(result: WarpGrepResult): string { return "No relevant code found. Try rephrasing your search term."; } - if (hasMalformedContexts(result.contexts)) { - const malformedFiles = result.contexts - .filter(isMalformedContext) - .map((ctx) => ctx.file); - console.error( - `[morph-plugin] Malformed WarpGrep contexts detected: ${malformedFiles.length} context(s) with file values: ${JSON.stringify(malformedFiles)}${result.summary ? ` | summary: ${result.summary}` : ""}`, - ); - return `Search returned malformed file contexts on Windows (for example \`${malformedFiles[0]}\` instead of a full file path). -This appears to be an upstream SDK Windows path parsing bug. -Temporary workaround: use \`grep\` + \`read\` for local code search until the SDK fix lands.`; + const valid = result.contexts.filter(isValidContext); + + if (valid.length === 0) { + const sample = result.contexts.slice(0, 3).map((c) => c.file); + return `Search returned malformed file contexts (file values: ${JSON.stringify(sample)}). +Fallback: use \`grep\` + \`read\` for local code search.`; } const parts: string[] = []; parts.push("Relevant context found:"); - for (const ctx of result.contexts) { + for (const ctx of valid) { const rangeStr = !ctx.lines || ctx.lines === "*" ? "*" @@ -382,7 +371,7 @@ Temporary workaround: use \`grep\` + \`read\` for local code search until the SD parts.push("\nFile contents:\n"); - for (const ctx of result.contexts) { + for (const ctx of valid) { const rangeStr = !ctx.lines || ctx.lines === "*" ? "" From f31f0e0fb05b646f9717789d819f7bb8653c2c74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 21:54:34 +0000 Subject: [PATCH 4/4] Bump @morphllm/morphsdk to 0.2.160 Includes upstream fix for Windows drive-letter path parsing in parseFinishFiles. https://claude.ai/code/session_01G5qa1GyHGNwWu2ko6uZ53N --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 021b426..de61246 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "ci": "bun test && bun run build && bun run typecheck" }, "dependencies": { - "@morphllm/morphsdk": "^0.2.148", + "@morphllm/morphsdk": "0.2.160", "@opencode-ai/plugin": "latest", "@opencode-ai/sdk": "^1.2.22" },