diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5246c39e0..c97003469 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `debug` tool refusing every `dlv` launch on Go modules. The launch handler ran `validateLaunchProgram` before adapter selection and rejected any directory program with `launch program resolves to a directory`, while dlv's default `mode=debug` requires a Go package path (a directory or `.go` source file). Adapter resolution now precedes validation, the rejection only fires when the resolved adapter does not advertise `acceptsDirectoryProgram` (set on `dlv` in `dap/defaults.json`), and dlv's `mode` is derived from the program shape — directories and `.go` files launch as `mode=debug`, other files as `mode=exec` — so `omp` can debug both Go packages and pre-built binaries ([#2020](https://github.com/can1357/oh-my-pi/issues/2020)). + ## [15.10.0] - 2026-06-06 ### Breaking Changes diff --git a/packages/coding-agent/src/dap/config.ts b/packages/coding-agent/src/dap/config.ts index 271a38fc6..93d35573f 100644 --- a/packages/coding-agent/src/dap/config.ts +++ b/packages/coding-agent/src/dap/config.ts @@ -27,6 +27,7 @@ function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null { rootMarkers: normalizeStringArray(config.rootMarkers), launchDefaults: normalizeObject(config.launchDefaults), attachDefaults: normalizeObject(config.attachDefaults), + acceptsDirectoryProgram: config.acceptsDirectoryProgram === true, ...(connectMode ? { connectMode } : {}), }; } @@ -64,6 +65,7 @@ export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAda launchDefaults: config.launchDefaults ?? {}, attachDefaults: config.attachDefaults ?? {}, connectMode: config.connectMode ?? "stdio", + acceptsDirectoryProgram: config.acceptsDirectoryProgram === true, }; } @@ -148,3 +150,33 @@ export function selectAttachAdapter(cwd: string, adapterName?: string, port?: nu } return available[0] ?? null; } + +/** How the launch `program` resolves on disk. `"missing"` is reserved for + * programs the adapter creates on demand (rare); we treat them like files. */ +export type LaunchProgramKind = "file" | "directory" | "missing"; + +/** Compute adapter-specific launch arguments that depend on the resolved + * program. Returned values are spread over `adapter.launchDefaults` so they + * take precedence over the static defaults but can still be overridden by + * the fields `DapSessionManager.launch` sets explicitly (program, cwd, args). + * + * Currently scoped to dlv, where `mode` selects how the program path is + * interpreted: directories and `.go` source files debug as a Go package + * (`mode=debug`), anything else is treated as a compiled binary (`mode=exec`). + */ +export function resolveLaunchOverrides( + adapter: DapResolvedAdapter, + program: string, + programKind: LaunchProgramKind, +): Record { + if (adapter.name === "dlv") { + const extension = path.extname(program).toLowerCase(); + if (programKind === "directory" || extension === ".go") { + return { mode: "debug" }; + } + if (programKind === "file") { + return { mode: "exec" }; + } + } + return {}; +} diff --git a/packages/coding-agent/src/dap/defaults.json b/packages/coding-agent/src/dap/defaults.json index dd3725236..5ea74ef25 100644 --- a/packages/coding-agent/src/dap/defaults.json +++ b/packages/coding-agent/src/dap/defaults.json @@ -65,6 +65,7 @@ "languages": ["go"], "fileTypes": [".go"], "rootMarkers": ["go.mod", "go.sum"], + "acceptsDirectoryProgram": true, "launchDefaults": { "request": "launch", "mode": "debug", diff --git a/packages/coding-agent/src/dap/session.ts b/packages/coding-agent/src/dap/session.ts index 0dc66c63b..57be0afc1 100644 --- a/packages/coding-agent/src/dap/session.ts +++ b/packages/coding-agent/src/dap/session.ts @@ -259,6 +259,7 @@ export class DapSessionManager { session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true; const launchArguments: DapLaunchArguments = { ...options.adapter.launchDefaults, + ...(options.extraLaunchArguments ?? {}), program: options.program, cwd: options.cwd, args: options.args, diff --git a/packages/coding-agent/src/dap/types.ts b/packages/coding-agent/src/dap/types.ts index eb4659de9..542e9088c 100644 --- a/packages/coding-agent/src/dap/types.ts +++ b/packages/coding-agent/src/dap/types.ts @@ -488,6 +488,10 @@ export interface DapAdapterConfig { * On Linux, connects via a unix domain socket. * On macOS, the adapter dials into a local TCP listener (--client-addr). */ connectMode?: "stdio" | "socket"; + /** When true, the adapter accepts a directory as the launch `program` + * (e.g. dlv treats it as a Go package path). When false/undefined, the + * debug tool rejects directory programs upfront. */ + acceptsDirectoryProgram?: boolean; } export interface DapResolvedAdapter { @@ -501,6 +505,7 @@ export interface DapResolvedAdapter { launchDefaults: Record; attachDefaults: Record; connectMode: "stdio" | "socket"; + acceptsDirectoryProgram: boolean; } export interface DapBreakpointRecord { @@ -589,6 +594,11 @@ export interface DapLaunchSessionOptions { program: string; args?: string[]; cwd: string; + /** Per-launch overrides merged over `adapter.launchDefaults`. Used to + * inject adapter-specific values that depend on the resolved program + * (e.g. dlv's `mode` switches between `debug` and `exec` based on + * whether `program` is a Go package path or a compiled binary). */ + extraLaunchArguments?: Record; } export interface DapAttachSessionOptions { diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 70606ba4c..caf54efe6 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -22,6 +22,7 @@ import { type DapFunctionBreakpointRecord, type DapInstructionBreakpointRecord, type DapModule, + type DapResolvedAdapter, type DapScope, type DapSessionSummary, type DapSource, @@ -30,6 +31,8 @@ import { type DapVariable, dapSessionManager, getAvailableAdapters, + type LaunchProgramKind, + resolveLaunchOverrides, selectAttachAdapter, selectLaunchAdapter, } from "../dap"; @@ -489,16 +492,23 @@ function getConfiguredAdapters(cwd: string): string { const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name); return adapters.length > 0 ? adapters.join(", ") : "none"; } -async function validateLaunchProgram(program: string, cwd: string): Promise { - let isDirectory: boolean; + +async function classifyLaunchProgram(program: string): Promise { try { - isDirectory = (await fs.stat(program)).isDirectory(); + return (await fs.stat(program)).isDirectory() ? "directory" : "file"; } catch (error) { - if (isEnoent(error)) return; + if (isEnoent(error)) return "missing"; throw error; } - if (!isDirectory) return; +} +function validateLaunchProgram( + program: string, + cwd: string, + programKind: LaunchProgramKind, + adapter: DapResolvedAdapter, +): void { + if (programKind !== "directory" || adapter.acceptsDirectoryProgram) return; const displayPath = formatPathRelativeToCwd(program, cwd, { trailingSlash: true }); throw new ToolError( `launch program resolves to a directory: ${displayPath}. Pass an executable file path, or for Python use adapter "debugpy" with program set to the .py file.`, @@ -676,7 +686,7 @@ export class DebugTool implements AgentTool { }); describe("DebugTool launch validation", () => { - it("rejects directory-valued launch programs before adapter selection", async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-program-")); + it("rejects directory programs when the selected adapter cannot debug a directory", async () => { + const dapModule = await import("../../src/dap"); + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(TEST_ADAPTER); try { - await fs.mkdir(path.join(cwd, "python")); - const session: ToolSession = { - cwd, - hasUI: false, - getSessionFile: () => null, - getSessionSpawns: () => "*", - settings: Settings.isolated({ "debug.enabled": true }), - }; - const tool = new DebugTool(session); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-program-")); + try { + await fs.mkdir(path.join(cwd, "python")); + 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: "python" })).rejects.toThrow( - /launch program resolves to a directory.*python/, - ); + await expect(tool.execute("call", { action: "launch", program: "python" })).rejects.toThrow( + /launch program resolves to a directory.*python/, + ); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } } finally { - await fs.rm(cwd, { recursive: true, force: true }); + launchSpy.mockRestore(); + } + }); + + it("allows directory programs when the selected adapter accepts them (dlv on a Go package)", async () => { + const dapModule = await import("../../src/dap"); + const dlvAdapter: DapResolvedAdapter = { + ...TEST_ADAPTER, + name: "dlv", + command: "dlv", + resolvedCommand: "dlv", + launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true }, + acceptsDirectoryProgram: true, + }; + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter); + const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => { + throw Object.assign(new Error("captured launch"), { capturedOptions: opts }); + }); + try { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-dir-")); + try { + await fs.mkdir(path.join(cwd, "cmd")); + const session: ToolSession = { + cwd, + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated({ "debug.enabled": true }), + }; + const tool = new DebugTool(session); + + // Validation must pass and propagate to dapSessionManager.launch; we + // stop the actual spawn there and inspect the launch arguments. + await expect(tool.execute("call", { action: "launch", program: "cmd", adapter: "dlv" })).rejects.toThrow( + /captured launch/, + ); + expect(sessionLaunchSpy).toHaveBeenCalledTimes(1); + const [opts] = sessionLaunchSpy.mock.calls[0]!; + expect(opts.extraLaunchArguments).toEqual({ mode: "debug" }); + expect(opts.program).toBe(path.join(cwd, "cmd")); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + } finally { + sessionLaunchSpy.mockRestore(); + launchSpy.mockRestore(); + } + }); + + it("dlv launch with a compiled binary switches mode from debug to exec", async () => { + const dapModule = await import("../../src/dap"); + const dlvAdapter: DapResolvedAdapter = { + ...TEST_ADAPTER, + name: "dlv", + command: "dlv", + resolvedCommand: "dlv", + launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true }, + acceptsDirectoryProgram: true, + }; + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter); + const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => { + throw Object.assign(new Error("captured launch"), { capturedOptions: opts }); + }); + try { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-exec-")); + try { + await fs.writeFile(path.join(cwd, "hello"), "#!/usr/bin/env sh\necho hi\n"); + 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: "hello", adapter: "dlv" })).rejects.toThrow( + /captured launch/, + ); + const [opts] = sessionLaunchSpy.mock.calls[0]!; + expect(opts.extraLaunchArguments).toEqual({ mode: "exec" }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + } finally { + sessionLaunchSpy.mockRestore(); + launchSpy.mockRestore(); } });