diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 34e214b90..15ad4eaa6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking Changes - Renamed the bundled agent explore to scout, including its configuration keys, prompt files, and task definitions. Any configurations, allowlists, or invocations referencing explore must now use scout. +- Changed the public `selectLaunchAdapter()` result from `DapResolvedAdapter | null` to `LaunchAdapterSelection`; callers must handle `adapter`, `unavailable`, and `none` outcomes. ### Added @@ -12,6 +13,7 @@ ### Fixed +- Fixed Go debug launches falling back to native debuggers when Delve is unavailable; nested modules and `go.work` workspaces now resolve local Delve adapters before PATH, newly installed adapters are detected without restart, and missing adapter errors include install or configuration guidance. ([#5037](https://github.com/can1357/oh-my-pi/issues/5037)) - Fixed a memory leak (large retained JavaScriptCore heaps) in the TUI during session transcript rebuilds and refreshes by properly handling snapcompact archive image frames. - Fixed a crash in interactive TUI sessions (Cannot set cwd while another same-realm JS runtime is running) when the JS evaluation worker falls back to the in-process inline path. - Fixed compaction aborting when Amazon Bedrock credential resolution fails, ensuring it now falls back to trying an authenticated model. diff --git a/packages/coding-agent/src/dap/config.ts b/packages/coding-agent/src/dap/config.ts index bb5748fd7..0aaa27eea 100644 --- a/packages/coding-agent/src/dap/config.ts +++ b/packages/coding-agent/src/dap/config.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { isRecord, logger } from "@oh-my-pi/pi-utils"; +import { isRecord, logger, WhichCachePolicy } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { getConfigDirPaths } from "../config"; import { getPreloadedPluginRoots } from "../discovery/helpers"; @@ -9,7 +9,7 @@ import { hasRootMarkers, resolveCommand } from "../lsp/config"; import DEFAULTS from "./defaults.json" with { type: "json" }; import type { DapAdapterConfig, DapResolvedAdapter } from "./types"; -const EXTENSIONLESS_DEBUGGER_ORDER = ["gdb", "lldb-dap"] as const; +const EXTENSIONLESS_DEBUGGER_ORDER: readonly string[] = ["gdb", "lldb-dap"]; interface NormalizedConfig { adapters: Record; @@ -188,10 +188,18 @@ function resolveAdapterFromConfig( adapterName: string, configs: Record, cwd: string, + localRoots?: readonly string[], ): DapResolvedAdapter | null { const config = configs[adapterName]; if (!config) return null; - const resolvedCommand = resolveCommand(normalizeCommandForCwd(config.command, cwd), cwd); + const normalizedCommand = normalizeCommandForCwd(config.command, cwd); + const commandIsBare = + !path.isAbsolute(config.command) && !config.command.includes("/") && !config.command.includes("\\"); + const resolvedCommand = resolveCommand(normalizedCommand, cwd, { + cache: WhichCachePolicy.Fresh, + PATH: process.env.PATH, + localRoots: commandIsBare ? localRoots : undefined, + }); if (!resolvedCommand) return null; return { name: adapterName, @@ -219,33 +227,125 @@ export function getAvailableAdapters(cwd: string): DapResolvedAdapter[] { .filter((adapter): adapter is DapResolvedAdapter => adapter !== null); } -function getMatchingAdapters(program: string, cwd: string): DapResolvedAdapter[] { - const extension = path.extname(program).toLowerCase(); - const available = getAvailableAdapters(cwd); - if (!extension) { - // For extensionless binaries, only consider native debuggers (gdb, lldb-dap) - // or adapters that match by root markers. Don't silently fall back to - // unrelated adapters like debugpy for a C binary. - const nativeDebuggers: ReadonlySet = new Set(EXTENSIONLESS_DEBUGGER_ORDER); - return available.filter( - adapter => - nativeDebuggers.has(adapter.name) || - (adapter.rootMarkers.length > 0 && hasRootMarkers(cwd, adapter.rootMarkers)), - ); - } - const exactMatches = available.filter(adapter => adapter.fileTypes.includes(extension)); - if (exactMatches.length > 0) { - return exactMatches; - } - return available; +/** Launch adapter selection, including a configured adapter whose command is unavailable. */ +export type LaunchAdapterSelection = + | { kind: "adapter"; adapter: DapResolvedAdapter } + | { kind: "unavailable"; adapterName: string; command: string } + | { kind: "none" }; + +interface LaunchAdapterCandidate { + name: string; + rootDir: string | null; } -function sortAdaptersForLaunch(program: string, cwd: string, adapters: DapResolvedAdapter[]): DapResolvedAdapter[] { +function findRootMarkerInLaunchAncestry( + program: string, + cwd: string, + markers: string[], + programKind: LaunchProgramKind, +): string | null { + if (markers.length === 0) return null; + let dir = programKind === "directory" ? path.resolve(cwd, program) : path.dirname(path.resolve(cwd, program)); + while (true) { + if (hasRootMarkers(dir, markers)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function resolveAdapterForLaunch( + adapterName: string, + configs: Record, + cwd: string, + rootDir: string | null, +): DapResolvedAdapter | null { + const localRoots = rootDir && rootDir !== cwd ? [rootDir, cwd] : undefined; + return resolveAdapterFromConfig(adapterName, configs, cwd, localRoots); +} + +function unavailableAdapter( + candidate: LaunchAdapterCandidate, + configs: Record, +): LaunchAdapterSelection { + const config = configs[candidate.name]; + if (!config) return { kind: "none" }; + return { kind: "unavailable", adapterName: candidate.name, command: config.command }; +} + +function selectAutomaticLaunchAdapter( + program: string, + cwd: string, + programKind: LaunchProgramKind, + configs: Record, +): LaunchAdapterSelection { + const extension = path.extname(program).toLowerCase(); + if (extension) { + const configured: LaunchAdapterCandidate[] = []; + const available: DapResolvedAdapter[] = []; + for (const name in configs) { + const config = configs[name]; + if (!config || !(config.fileTypes ?? []).includes(extension)) continue; + const rootDir = findRootMarkerInLaunchAncestry(program, cwd, config.rootMarkers ?? [], programKind); + configured.push({ name, rootDir }); + const adapter = resolveAdapterForLaunch(name, configs, cwd, rootDir); + if (adapter) available.push(adapter); + } + const selected = sortAdaptersForLaunch(program, cwd, programKind, available)[0]; + if (selected) return { kind: "adapter", adapter: selected }; + const rootMatch = configured.find(candidate => candidate.rootDir !== null); + const unavailable = rootMatch ?? configured[0]; + if (unavailable) return unavailableAdapter(unavailable, configs); + } + + const available: DapResolvedAdapter[] = []; + const rootMatches: LaunchAdapterCandidate[] = []; + const directoryMatches: LaunchAdapterCandidate[] = []; + for (const name in configs) { + const config = configs[name]; + if (!config) continue; + const rootDir = findRootMarkerInLaunchAncestry(program, cwd, config.rootMarkers ?? [], programKind); + const candidate = { name, rootDir }; + if (rootDir) { + rootMatches.push(candidate); + if (config.acceptsDirectoryProgram === true) directoryMatches.push(candidate); + } + if (!EXTENSIONLESS_DEBUGGER_ORDER.includes(name) && !rootDir) continue; + const adapter = resolveAdapterForLaunch(name, configs, cwd, rootDir); + if (adapter) available.push(adapter); + } + + if (programKind === "directory" && directoryMatches.length > 0) { + const matchingNames = new Set(directoryMatches.map(candidate => candidate.name)); + const directoryAdapters = available.filter( + adapter => adapter.acceptsDirectoryProgram && matchingNames.has(adapter.name), + ); + const selected = sortAdaptersForLaunch(program, cwd, programKind, directoryAdapters)[0]; + if (selected) return { kind: "adapter", adapter: selected }; + const unavailable = directoryMatches[0]; + return unavailable ? unavailableAdapter(unavailable, configs) : { kind: "none" }; + } + + const directoryAdapters = + programKind === "directory" ? available.filter(adapter => adapter.acceptsDirectoryProgram) : available; + const candidates = directoryAdapters.length > 0 ? directoryAdapters : available; + const selected = sortAdaptersForLaunch(program, cwd, programKind, candidates)[0]; + if (selected) return { kind: "adapter", adapter: selected }; + const unavailable = rootMatches[0]; + return unavailable ? unavailableAdapter(unavailable, configs) : { kind: "none" }; +} + +function sortAdaptersForLaunch( + program: string, + cwd: string, + programKind: LaunchProgramKind, + adapters: DapResolvedAdapter[], +): DapResolvedAdapter[] { const extension = path.extname(program).toLowerCase(); const rootAware = adapters.map(adapter => ({ adapter, hasExtensionMatch: extension.length > 0 && adapter.fileTypes.includes(extension), - hasRootMatch: adapter.rootMarkers.length > 0 && hasRootMarkers(cwd, adapter.rootMarkers), + hasRootMatch: findRootMarkerInLaunchAncestry(program, cwd, adapter.rootMarkers, programKind) !== null, })); rootAware.sort((left, right) => { if (left.hasExtensionMatch !== right.hasExtensionMatch) { @@ -254,36 +354,33 @@ function sortAdaptersForLaunch(program: string, cwd: string, adapters: DapResolv if (left.hasRootMatch !== right.hasRootMatch) { return left.hasRootMatch ? -1 : 1; } - const leftDebuggerRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf( - left.adapter.name as (typeof EXTENSIONLESS_DEBUGGER_ORDER)[number], - ); - const rightDebuggerRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf( - right.adapter.name as (typeof EXTENSIONLESS_DEBUGGER_ORDER)[number], - ); - const normalizedLeftRank = leftDebuggerRank === -1 ? Number.MAX_SAFE_INTEGER : leftDebuggerRank; - const normalizedRightRank = rightDebuggerRank === -1 ? Number.MAX_SAFE_INTEGER : rightDebuggerRank; - if (normalizedLeftRank !== normalizedRightRank) { - return normalizedLeftRank - normalizedRightRank; - } + const leftRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(left.adapter.name); + const rightRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(right.adapter.name); + const normalizedLeftRank = leftRank === -1 ? Number.MAX_SAFE_INTEGER : leftRank; + const normalizedRightRank = rightRank === -1 ? Number.MAX_SAFE_INTEGER : rightRank; + const rankDelta = normalizedLeftRank - normalizedRightRank; + if (rankDelta !== 0) return rankDelta; return left.adapter.name.localeCompare(right.adapter.name); }); return rootAware.map(entry => entry.adapter); } +/** Selects a launch adapter or reports why matching configuration cannot run. */ export function selectLaunchAdapter( program: string, cwd: string, adapterName?: string, programKind: LaunchProgramKind = "file", -): DapResolvedAdapter | null { +): LaunchAdapterSelection { + const configs = getAdapterConfigs(cwd); if (adapterName) { - return resolveAdapter(adapterName, cwd); + const config = configs[adapterName]; + if (!config) return { kind: "none" }; + const rootDir = findRootMarkerInLaunchAncestry(program, cwd, config.rootMarkers ?? [], programKind); + const adapter = resolveAdapterForLaunch(adapterName, configs, cwd, rootDir); + return adapter ? { kind: "adapter", adapter } : { kind: "unavailable", adapterName, command: config.command }; } - const matches = getMatchingAdapters(program, cwd); - const candidates = - programKind === "directory" ? matches.filter(adapter => adapter.acceptsDirectoryProgram) : matches; - const sorted = sortAdaptersForLaunch(program, cwd, candidates.length > 0 ? candidates : matches); - return sorted[0] ?? null; + return selectAutomaticLaunchAdapter(program, cwd, programKind, configs); } export function selectAttachAdapter(cwd: string, adapterName?: string, port?: number): DapResolvedAdapter | null { diff --git a/packages/coding-agent/src/dap/defaults.json b/packages/coding-agent/src/dap/defaults.json index 5ea74ef25..c33eb20ba 100644 --- a/packages/coding-agent/src/dap/defaults.json +++ b/packages/coding-agent/src/dap/defaults.json @@ -64,7 +64,7 @@ "connectMode": "socket", "languages": ["go"], "fileTypes": [".go"], - "rootMarkers": ["go.mod", "go.sum"], + "rootMarkers": ["go.mod", "go.sum", "go.work"], "acceptsDirectoryProgram": true, "launchDefaults": { "request": "launch", diff --git a/packages/coding-agent/src/lsp/config.ts b/packages/coding-agent/src/lsp/config.ts index e9b650bd6..336c113ba 100644 --- a/packages/coding-agent/src/lsp/config.ts +++ b/packages/coding-agent/src/lsp/config.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { $which, isRecord, logger, pathIsWithin } from "@oh-my-pi/pi-utils"; +import { $which, isRecord, logger, pathIsWithin, type WhichOptions } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { getConfigDirPaths } from "../config"; import { type ClaudePluginRoot, getPreloadedPluginRoots } from "../discovery/helpers"; @@ -249,7 +249,7 @@ const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [ { markers: ["Gemfile", "Gemfile.lock"], binDir: "vendor/bundle/bin" }, { markers: ["Gemfile", "Gemfile.lock"], binDir: "bin" }, // Go - check project-local bin - { markers: ["go.mod", "go.sum"], binDir: "bin" }, + { markers: ["go.mod", "go.sum", "go.work"], binDir: "bin" }, ]; const WINDOWS_LOCAL_EXECUTABLE_EXTENSIONS = [".exe", ".cmd", ".bat"] as const; @@ -267,6 +267,21 @@ function resolveLocalCommand(basePath: string): string | null { return null; } +function resolveCommandFromLocalRoot(command: string, cwd: string): string | null { + for (const { markers, binDir } of LOCAL_BIN_PATHS) { + if (!hasRootMarkers(cwd, markers)) continue; + const resolved = resolveLocalCommand(path.join(cwd, binDir, command)); + if (resolved) return resolved; + } + return null; +} + +/** Controls project-local and PATH executable lookup. */ +export interface ResolveCommandOptions extends Pick { + /** Ordered project roots checked before PATH; defaults to the command cwd. */ + localRoots?: readonly string[]; +} + /** * Resolve a command to an executable path. * Checks project-local bin directories first, then falls back to $PATH. @@ -275,20 +290,19 @@ function resolveLocalCommand(basePath: string): string | null { * @param cwd - Working directory to search from * @returns Absolute path to the executable, or null if not found */ -export function resolveCommand(command: string, cwd: string): string | null { - // Check local bin directories based on project markers - for (const { markers, binDir } of LOCAL_BIN_PATHS) { - if (hasRootMarkers(cwd, markers)) { - const localPath = path.join(cwd, binDir, command); - const resolvedLocalPath = resolveLocalCommand(localPath); - if (resolvedLocalPath) { - return resolvedLocalPath; - } +export function resolveCommand(command: string, cwd: string, options?: ResolveCommandOptions): string | null { + if (options?.localRoots) { + for (const root of options.localRoots) { + const resolved = resolveCommandFromLocalRoot(command, root); + if (resolved) return resolved; } + } else { + const resolved = resolveCommandFromLocalRoot(command, cwd); + if (resolved) return resolved; } - // Fall back to $PATH - return $which(command); + if (!options) return $which(command); + return $which(command, { cache: options.cache, PATH: options.PATH }); } interface ConfigSource { diff --git a/packages/coding-agent/src/prompts/tools/debug.md b/packages/coding-agent/src/prompts/tools/debug.md index eddbabaa8..3412066ae 100644 --- a/packages/coding-agent/src/prompts/tools/debug.md +++ b/packages/coding-agent/src/prompts/tools/debug.md @@ -2,7 +2,7 @@ Debugger access. - You SHOULD prefer this over bash for program state, breakpoints, stepping, thread inspection, or interrupting a running process. -- `action: "launch"` starts a session; `program` required, `adapter` optional. Python: `adapter: "debugpy"`, `program` = target `.py`, interpreter/script flags in `args`. +- `action: "launch"` starts a session; `program` required, `adapter` optional. Python: `program` = target `.py`, interpreter/script flags in `args`. Go: `program` = package directory, `.go` file, or compiled binary. - `action: "attach"` connects to a running process: `pid` (local), `port` (remote), `adapter` forces a specific debugger. - **Breakpoints**: `set_breakpoint`/`remove_breakpoint` with source (`file`+`line`) or function (`function`); optional `condition`. - **Flow control**: `continue` (resume), `step_over`/`step_in`/`step_out` (single-step), `pause` (interrupt a running program). @@ -11,7 +11,7 @@ Debugger access. - Only one active debug session at a time. -- Valid `adapter` values: `gdb`, `lldb-dap`, `python -m debugpy.adapter`, `dlv dap` (must be installed locally). -- `program` must be an executable file or debug target, not a directory or bare interpreter name. -- Python debugging requires `debugpy`; `pip install debugpy` if unavailable. +- `adapter` is a configured id: `gdb`, `lldb-dap`, `debugpy`, `dlv`, `rdbg`, or any `dap.json` entry; its command must be installed. +- `program` is a target path, not a shell command. Directories require a directory-capable adapter such as `dlv`. +- Python requires `debugpy` (`pip install debugpy`); Go requires Delve (`go install github.com/go-delve/delve/cmd/dlv@latest`); Ruby requires `rdbg` (`gem install debug`). diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 3d0680d37..690243120 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -31,6 +31,7 @@ import { type DapThread, type DapVariable, dapSessionManager, + getAdapterConfigs, getAvailableAdapters, type LaunchProgramKind, resolveLaunchOverrides, @@ -50,6 +51,7 @@ import { formatStatusIcon, PREVIEW_LIMITS, replaceTabs, + shortenPath, TRUNCATE_LENGTHS, truncateToWidth, } from "./render-utils"; @@ -106,9 +108,9 @@ const debugActionSchema = type.enumerated( ); const debugSchema = type({ action: debugActionSchema, - "program?": type("string").describe("program path"), + "program?": type("string").describe("debug target path; Delve accepts Go package directories"), "args?": type("string[]").describe("program arguments"), - "adapter?": type("string").describe("debugger adapter (gdb, lldb-dap, debugpy, dlv)"), + "adapter?": type("string").describe("configured adapter id (gdb, lldb-dap, debugpy, dlv, rdbg, or dap.json entry)"), cwd: "string?", "file?": type("string").describe("source file"), "line?": type("number").describe("source line"), @@ -494,7 +496,33 @@ function buildOutcomeText(outcome: DapContinueOutcome, timeoutSec: number, verb: function getConfiguredAdapters(cwd: string): string { const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name); - return adapters.length > 0 ? adapters.join(", ") : "none"; + const names = adapters.length > 0 ? adapters.join(", ") : "none"; + return truncateToWidth(replaceTabs(names), TRUNCATE_LENGTHS.LONG); +} + +const ADAPTER_UNAVAILABLE_MESSAGES: Readonly> = { + debugpy: "adapter 'debugpy' is not available: python not found in PATH", + dlv: "adapter 'dlv' is not available: install with 'go install github.com/go-delve/delve/cmd/dlv@latest'", + rdbg: "adapter 'rdbg' is not available: install with 'gem install debug'", +}; + +const ADAPTER_CANONICAL_COMMANDS: Readonly> = { + debugpy: "python", + dlv: "dlv", + rdbg: "rdbg", +}; + +function formatAdapterUnavailable(adapterName: string, command: string, cwd: string): string { + const displayName = truncateToWidth(replaceTabs(adapterName), TRUNCATE_LENGTHS.SHORT); + const canonicalCommand = ADAPTER_CANONICAL_COMMANDS[adapterName] ?? adapterName; + if (command !== canonicalCommand) { + const displayCommand = truncateToWidth(replaceTabs(shortenPath(command)), TRUNCATE_LENGTHS.CONTENT); + return `adapter '${displayName}' is not available: configured command '${displayCommand}' did not resolve. Check the DAP adapter config for this workspace.`; + } + return ( + ADAPTER_UNAVAILABLE_MESSAGES[adapterName] ?? + `adapter '${displayName}' is not available. Installed adapters: ${getConfiguredAdapters(cwd)}` + ); } async function classifyLaunchProgram(program: string): Promise { @@ -515,7 +543,7 @@ function validateLaunchProgram( 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.`, + `launch program resolves to a directory: ${displayPath}. Pass an executable file path or choose an adapter that supports package directories.`, ); } @@ -711,15 +739,16 @@ export class DebugTool implements AgentTool { return cwd; } +interface NestedGoProgram { + moduleRoot: string; + program: string; +} + +async function writeExecutable(filePath: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); + await fs.chmod(filePath, 0o755); +} + +async function writeDlvOverride(cwd: string, command: string): Promise { + await fs.writeFile(path.join(cwd, "dap.json"), JSON.stringify({ adapters: { dlv: { command } } })); +} + +async function setupMissingDlvProject(cwd: string): Promise { + const missingCommand = path.join(cwd, "tools", "missing-dlv"); + await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/app\n\ngo 1.22\n"); + await writeExecutable(path.join(cwd, "bin", "gdb")); + await writeDlvOverride(cwd, missingCommand); + return missingCommand; +} + +async function setupNestedGoProgram(cwd: string): Promise { + const moduleRoot = path.join(cwd, "services", "api"); + const program = path.join(moduleRoot, "main.go"); + await fs.mkdir(moduleRoot, { recursive: true }); + await fs.writeFile(path.join(moduleRoot, "go.mod"), "module example.com/api\n\ngo 1.22\n"); + await fs.writeFile(program, "package main\n\nfunc main() {}\n"); + return { moduleRoot, program }; +} + +function requireSelectedAdapter(selection: LaunchAdapterSelection): DapResolvedAdapter { + if (selection.kind !== "adapter") { + throw new Error(`Expected an available adapter, received '${selection.kind}'`); + } + return selection.adapter; +} + afterEach(async () => { vi.restoreAllMocks(); if (ORIGINAL_OMP_PLUGIN_DIR === undefined) { @@ -63,8 +109,8 @@ describe("DAP adapter configuration", () => { expect(adapter?.launchDefaults).toEqual({ request: "launch", mainClass: "" }); expect(adapter?.attachDefaults).toEqual({ request: "attach", host: "127.0.0.1" }); - const selected = selectLaunchAdapter(path.join("src", "Main.java"), cwd); - expect(selected?.name).toBe("custom-jvm"); + const selected = requireSelectedAdapter(selectLaunchAdapter(path.join("src", "Main.java"), cwd)); + expect(selected.name).toBe("custom-jvm"); }); it("merges partial user overrides over built-in adapters", async () => { @@ -116,9 +162,9 @@ describe("DAP adapter configuration", () => { ].join("\n"), ); - const selected = selectLaunchAdapter("Main.kt", cwd); - expect(selected?.name).toBe("yaml-kotlin"); - expect(selected?.launchDefaults).toEqual({ request: "launch", projectRoot: "." }); + const selected = requireSelectedAdapter(selectLaunchAdapter("Main.kt", cwd)); + expect(selected.name).toBe("yaml-kotlin"); + expect(selected.launchDefaults).toEqual({ request: "launch", projectRoot: "." }); }); it("resolves relative adapter commands from the debug cwd", async () => { @@ -195,4 +241,100 @@ describe("DAP adapter configuration", () => { expect(config["missing-command"]).toBeUndefined(); expect(config.valid?.command).toBe("bun"); }); + + it("reports missing dlv for Go source instead of falling back to a native debugger", async () => { + const cwd = await makeTempDir("omp-dap-go-source-missing-"); + const missingCommand = await setupMissingDlvProject(cwd); + const program = path.join(cwd, "main.go"); + await fs.writeFile(program, "package main\n\nfunc main() {}\n"); + + const selection = selectLaunchAdapter(program, cwd); + + expect(selection).toEqual({ kind: "unavailable", adapterName: "dlv", command: missingCommand }); + }); + + it("reports missing dlv for Go package directories instead of selecting a native debugger", async () => { + const cwd = await makeTempDir("omp-dap-go-directory-missing-"); + const missingCommand = await setupMissingDlvProject(cwd); + const program = path.join(cwd, "cmd", "server"); + await fs.mkdir(program, { recursive: true }); + + const selection = selectLaunchAdapter(program, cwd, undefined, "directory"); + + expect(selection).toEqual({ kind: "unavailable", adapterName: "dlv", command: missingCommand }); + }); + + it("prefers a nested module adapter over cwd and PATH for inferred launches", async () => { + const cwd = await makeTempDir("omp-dap-go-nested-local-"); + const { moduleRoot, program } = await setupNestedGoProgram(cwd); + const nestedDlv = path.join(moduleRoot, "bin", "dlv"); + await writeExecutable(nestedDlv); + await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/repo\n\ngo 1.22\n"); + await writeExecutable(path.join(cwd, "bin", "dlv")); + const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv")); + + const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd)); + + expect(selected.resolvedCommand).toBe(nestedDlv); + expect(whichSpy).not.toHaveBeenCalled(); + }); + + it("uses a nested module adapter when dlv is requested explicitly", async () => { + const cwd = await makeTempDir("omp-dap-go-nested-explicit-"); + const { moduleRoot, program } = await setupNestedGoProgram(cwd); + const nestedDlv = path.join(moduleRoot, "bin", "dlv"); + await writeExecutable(nestedDlv); + const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv")); + + const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd, "dlv")); + + expect(selected.resolvedCommand).toBe(nestedDlv); + expect(whichSpy).not.toHaveBeenCalled(); + }); + + it("prefers the session cwd adapter over PATH after a nested-root miss", async () => { + const cwd = await makeTempDir("omp-dap-go-nested-cwd-"); + const { program } = await setupNestedGoProgram(cwd); + const cwdDlv = path.join(cwd, "bin", "dlv"); + await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/repo\n\ngo 1.22\n"); + await writeExecutable(cwdDlv); + const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv")); + + const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd)); + + expect(selected.resolvedCommand).toBe(cwdDlv); + expect(whichSpy).not.toHaveBeenCalled(); + }); + + it("resolves a local dlv for Go workspaces rooted by go.work", async () => { + const cwd = await makeTempDir("omp-dap-go-work-"); + const program = path.join(cwd, "cmd", "worker"); + const localDlv = path.join(cwd, "bin", "dlv"); + await fs.writeFile(path.join(cwd, "go.work"), "go 1.22\n\nuse ./cmd/worker\n"); + await fs.mkdir(program, { recursive: true }); + await writeExecutable(localDlv); + + const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd, undefined, "directory")); + + expect(selected.resolvedCommand).toBe(localDlv); + }); + + it("re-resolves an adapter installed after an earlier miss", async () => { + const cwd = await makeTempDir("omp-dap-go-fresh-"); + const program = path.join(cwd, "main.go"); + const command = path.join(cwd, "tools", process.platform === "win32" ? "dlv.cmd" : "dlv"); + await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/cache\n\ngo 1.22\n"); + await fs.writeFile(program, "package main\n\nfunc main() {}\n"); + await writeDlvOverride(cwd, command); + + expect(selectLaunchAdapter(program, cwd)).toEqual({ + kind: "unavailable", + adapterName: "dlv", + command, + }); + + await writeExecutable(command); + const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd)); + expect(selected.resolvedCommand).toBe(command); + }); }); diff --git a/packages/coding-agent/test/debug/dap-launch-failures.test.ts b/packages/coding-agent/test/debug/dap-launch-failures.test.ts index 6d02e49b7..6e1f63622 100644 --- a/packages/coding-agent/test/debug/dap-launch-failures.test.ts +++ b/packages/coding-agent/test/debug/dap-launch-failures.test.ts @@ -489,7 +489,10 @@ describe("DAP launch failure handling", () => { describe("DebugTool launch validation", () => { it("rejects directory programs when the selected adapter cannot debug a directory", async () => { - const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(TEST_ADAPTER); + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ + kind: "adapter", + adapter: TEST_ADAPTER, + }); try { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-program-")); try { @@ -523,7 +526,10 @@ describe("DebugTool launch validation", () => { launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true }, acceptsDirectoryProgram: true, }; - const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter); + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ + kind: "adapter", + adapter: dlvAdapter, + }); const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => { throw Object.assign(new Error("captured launch"), { capturedOptions: opts }); }); @@ -603,7 +609,10 @@ describe("DebugTool launch validation", () => { launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true }, acceptsDirectoryProgram: true, }; - const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter); + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ + kind: "adapter", + adapter: dlvAdapter, + }); const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => { throw Object.assign(new Error("captured launch"), { capturedOptions: opts }); }); @@ -635,7 +644,11 @@ describe("DebugTool launch validation", () => { }); it("throws targeted 'python not found in PATH' when adapter:'debugpy' is unresolvable for launch", async () => { - const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null); + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ + kind: "unavailable", + adapterName: "debugpy", + command: "python", + }); try { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-debugpy-")); try { @@ -685,8 +698,93 @@ describe("DebugTool launch validation", () => { } }); + it("shows the Delve install command when the canonical dlv adapter is unavailable", async () => { + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ + kind: "unavailable", + adapterName: "dlv", + command: "dlv", + }); + try { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-hint-")); + try { + await fs.writeFile(path.join(cwd, "main.go"), "package main\n\nfunc main() {}\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: "main.go" })).rejects.toThrow( + /go install github\.com\/go-delve\/delve\/cmd\/dlv@latest/, + ); + } finally { + await removeWithRetries(cwd); + } + } finally { + launchSpy.mockRestore(); + } + }); + + it("points to DAP configuration when a custom adapter command is unavailable", async () => { + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ + kind: "unavailable", + adapterName: "dlv", + command: "./bin/missing-dlv", + }); + try { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-config-")); + try { + await fs.writeFile(path.join(cwd, "main.go"), "package main\n\nfunc main() {}\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: "main.go" })).rejects.toThrow( + /configured command '\.\/bin\/missing-dlv' did not resolve.*DAP adapter config/, + ); + } finally { + await removeWithRetries(cwd); + } + } finally { + launchSpy.mockRestore(); + } + }); + + it("shows the rdbg install command for explicit Ruby attach", async () => { + const attachSpy = spyOn(dapModule, "selectAttachAdapter").mockReturnValue(null); + try { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-rdbg-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: "rdbg" })).rejects.toThrow( + /gem install debug/, + ); + } finally { + await removeWithRetries(cwd); + } + } finally { + attachSpy.mockRestore(); + } + }); + it("falls back to the generic 'No debugger adapter' error when adapter is unspecified", async () => { - const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null); + const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ kind: "none" }); try { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-noadapter-")); try {