fix(coding-agent): address PR #1388 review feedback
Refactor: - session.ts: extract mapDebugpyMissingModule helper; replace the duplicated inline check in launch/attach catch blocks. Add jsdoc on DapStartRequestFailure.settled documenting per-call ownership and how throwPreferredDapStartError consumes it. - path-utils.ts: replace the no-op keepOpaqueResourceUri branch with an OPAQUE_RESOURCE_SCHEMES Set so the structure carries the intent. Functionally equivalent; new opaque schemes become a one-line Set change. Tests: - dap-launch-failures: cover the debugpy stderr -> 'pip install debugpy' rewrite for launch and attach, plus a negative case (non-debugpy adapter with the substring in stderr is left untouched). - dap-launch-failures: model the delayed-launch-failure case the new settled-race in throwPreferredDapStartError defends against. FakeDapClient gains optional launchErrorDelayMs/attachErrorDelayMs. - dap-launch-failures (DebugTool): assert adapter:'debugpy' early-throw surfaces 'python not found in PATH' on both launch and attach when selectLaunchAdapter/selectAttachAdapter return null, and the unspecified-adapter path still falls back to the generic 'No debugger adapter' error. - find.ts: export validateFindPathInputs and pin the new backslash-escape semantics (\, no longer trips the comma-joined heuristic) plus the existing brace-expansion and rejection paths. - patch.ts: cover the post-write verification error message. The user-facing ToolError must contain the caller-supplied relative path and not the absolute resolvedPath (which still lives in the structured context for log correlation). - split-internal-url-sel: reword two mcp:// test comments that described a 'peeler refuses' guard that doesn't exist; rename the tests to reflect the actual opaque-scheme rule.
This commit is contained in:
@@ -35,7 +35,9 @@ class FakeDapClient {
|
||||
readonly cwd: string,
|
||||
readonly options: {
|
||||
launchError?: string;
|
||||
launchErrorDelayMs?: number;
|
||||
attachError?: string;
|
||||
attachErrorDelayMs?: number;
|
||||
configurationDoneError?: string;
|
||||
rejectStopWaiters?: boolean;
|
||||
},
|
||||
@@ -62,9 +64,11 @@ class FakeDapClient {
|
||||
|
||||
async sendRequest(command: string): Promise<unknown> {
|
||||
if (command === "launch" && this.options.launchError) {
|
||||
if (this.options.launchErrorDelayMs) await Bun.sleep(this.options.launchErrorDelayMs);
|
||||
throw new Error(this.options.launchError);
|
||||
}
|
||||
if (command === "attach" && this.options.attachError) {
|
||||
if (this.options.attachErrorDelayMs) await Bun.sleep(this.options.attachErrorDelayMs);
|
||||
throw new Error(this.options.attachError);
|
||||
}
|
||||
if (command === "configurationDone" && this.options.configurationDoneError) {
|
||||
@@ -198,6 +202,90 @@ describe("DAP launch failure handling", () => {
|
||||
expect(message).toContain("ENOENT");
|
||||
expect(message).toContain(TEST_ADAPTER.name);
|
||||
});
|
||||
|
||||
it("surfaces 'pip install debugpy' when launch stderr mentions missing module", async () => {
|
||||
const manager = new DapSessionManager();
|
||||
const debugpyAdapter: DapResolvedAdapter = { ...TEST_ADAPTER, name: "debugpy" };
|
||||
const fake = new FakeDapClient(debugpyAdapter, process.cwd(), {
|
||||
launchError: "ImportError: No module named 'debugpy'",
|
||||
});
|
||||
spyOn(DapClient, "spawn").mockResolvedValue(fake as unknown as DapClient);
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await manager.launch({ adapter: debugpyAdapter, program: "/bin/echo", cwd: process.cwd() });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
message = (error as Error).message;
|
||||
}
|
||||
|
||||
expect(message).toContain("pip install debugpy");
|
||||
expect(message).toContain("debugpy");
|
||||
});
|
||||
|
||||
it("surfaces 'pip install debugpy' when attach stderr mentions missing module", async () => {
|
||||
const manager = new DapSessionManager();
|
||||
const debugpyAdapter: DapResolvedAdapter = { ...TEST_ADAPTER, name: "debugpy" };
|
||||
const fake = new FakeDapClient(debugpyAdapter, process.cwd(), {
|
||||
attachError: 'ModuleNotFoundError: No module named "debugpy"',
|
||||
});
|
||||
spyOn(DapClient, "spawn").mockResolvedValue(fake as unknown as DapClient);
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await manager.attach({ adapter: debugpyAdapter, cwd: process.cwd(), pid: 123 });
|
||||
} catch (error) {
|
||||
message = (error as Error).message;
|
||||
}
|
||||
|
||||
expect(message).toContain("pip install debugpy");
|
||||
});
|
||||
|
||||
it("does NOT rewrite to 'pip install debugpy' for non-debugpy adapters even when stderr mentions the module", async () => {
|
||||
const manager = new DapSessionManager();
|
||||
const fake = new FakeDapClient(TEST_ADAPTER, process.cwd(), {
|
||||
launchError: "incidental log line: No module named debugpy was here but the adapter is lldb-dap",
|
||||
});
|
||||
spyOn(DapClient, "spawn").mockResolvedValue(fake as unknown as DapClient);
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await manager.launch({ adapter: TEST_ADAPTER, program: "/bin/echo", cwd: process.cwd() });
|
||||
} catch (error) {
|
||||
message = (error as Error).message;
|
||||
}
|
||||
|
||||
expect(message).not.toContain("pip install debugpy");
|
||||
expect(message).toContain("incidental log line");
|
||||
});
|
||||
|
||||
it("prefers a delayed launch failure over the configurationDone cascade", async () => {
|
||||
// Models real adapter I/O where the launch failure arrives via socket
|
||||
// several ticks after configurationDone has already rejected. The old
|
||||
// `await Promise.resolve()` (one microtask) would miss the late launch
|
||||
// rejection and surface only the configurationDone cascade.
|
||||
const manager = new DapSessionManager();
|
||||
const fake = new FakeDapClient(TEST_ADAPTER, process.cwd(), {
|
||||
launchError: "launch: 'C:\\repo\\program' is not a valid executable",
|
||||
launchErrorDelayMs: 10,
|
||||
configurationDoneError: "configurationDone: Expected process to be stopped.",
|
||||
});
|
||||
spyOn(DapClient, "spawn").mockResolvedValue(fake as unknown as DapClient);
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await manager.launch({ adapter: TEST_ADAPTER, program: "C:\\repo\\program", cwd: process.cwd() });
|
||||
} catch (error) {
|
||||
message = (error as Error).message;
|
||||
}
|
||||
|
||||
// The combined error must include the launch failure as the preferred
|
||||
// error — not just the configurationDone cascade. Both messages are
|
||||
// present in the combined form (see combineDapStartErrors), but the
|
||||
// regression-prone case is omitting the launch line entirely.
|
||||
expect(message).toContain("launch: 'C:\\repo\\program' is not a valid executable");
|
||||
expect(message).toContain("configurationDone: Expected process to be stopped.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DebugTool launch validation", () => {
|
||||
@@ -221,4 +309,84 @@ describe("DebugTool launch validation", () => {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws targeted 'python not found in PATH' when adapter:'debugpy' is unresolvable for launch", async () => {
|
||||
const dapModule = await import("../../src/dap");
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null);
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-debugpy-"));
|
||||
try {
|
||||
await fs.writeFile(path.join(cwd, "main.py"), "print('hi')");
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(
|
||||
tool.execute("call", { action: "launch", program: "main.py", adapter: "debugpy" }),
|
||||
).rejects.toThrow(/debugpy.*python not found in PATH/);
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
launchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws targeted 'python not found in PATH' when adapter:'debugpy' is unresolvable for attach", async () => {
|
||||
const dapModule = await import("../../src/dap");
|
||||
const attachSpy = spyOn(dapModule, "selectAttachAdapter").mockReturnValue(null);
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-debugpy-attach-"));
|
||||
try {
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(tool.execute("call", { action: "attach", pid: 1234, adapter: "debugpy" })).rejects.toThrow(
|
||||
/debugpy.*python not found in PATH/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
attachSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the generic 'No debugger adapter' error when adapter is unspecified", async () => {
|
||||
const dapModule = await import("../../src/dap");
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null);
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-noadapter-"));
|
||||
try {
|
||||
await fs.writeFile(path.join(cwd, "main.py"), "print('hi')");
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(tool.execute("call", { action: "launch", program: "main.py" })).rejects.toThrow(
|
||||
/No debugger adapter available/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
launchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user