fix(debug): stopped native fallback for missing delve

- Kept missing language-specific adapters from falling through to native debuggers.
- Resolved nested launch roots before session-local binaries and PATH, including explicit adapters and go.work workspaces.
- Added actionable install/configuration errors and deterministic regression coverage.

Fixes #5037
This commit is contained in:
can1357
2026-07-10 14:04:32 +02:00
parent 520f6f9bcc
commit e8add61016
8 changed files with 465 additions and 82 deletions
+2
View File
@@ -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.
+139 -42
View File
@@ -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<string, unknown>;
@@ -188,10 +188,18 @@ function resolveAdapterFromConfig(
adapterName: string,
configs: Record<string, DapAdapterConfig>,
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<string> = 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<string, DapAdapterConfig>,
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<string, DapAdapterConfig>,
): 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<string, DapAdapterConfig>,
): 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 {
+1 -1
View File
@@ -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",
+27 -13
View File
@@ -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<WhichOptions, "cache" | "PATH"> {
/** 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 {
@@ -2,7 +2,7 @@ Debugger access.
<instruction>
- 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.
<caution>
- 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`).
</caution>
+41 -11
View File
@@ -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<Record<string, string>> = {
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<Record<string, string>> = {
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<LaunchProgramKind> {
@@ -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<typeof debugSchema, DebugToolDetails
const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
const program = resolveToCwd(params.program, commandCwd);
const programKind = await classifyLaunchProgram(program);
const adapter = selectLaunchAdapter(program, commandCwd, params.adapter, programKind);
if (!adapter) {
if (params.adapter === "debugpy") {
throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
}
const selection = selectLaunchAdapter(program, commandCwd, params.adapter, programKind);
if (selection.kind === "unavailable") {
throw new ToolError(formatAdapterUnavailable(selection.adapterName, selection.command, commandCwd));
}
if (selection.kind === "none") {
throw new ToolError(
`No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
);
}
const { adapter } = selection;
validateLaunchProgram(program, commandCwd, programKind, adapter);
const extraLaunchArguments = resolveLaunchOverrides(adapter, program, programKind);
const snapshot = await dapSessionManager.launch(
@@ -738,8 +767,9 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
const adapter = selectAttachAdapter(commandCwd, params.adapter, params.port);
if (!adapter) {
if (params.adapter === "debugpy") {
throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
if (params.adapter) {
const command = getAdapterConfigs(commandCwd)[params.adapter]?.command ?? params.adapter;
throw new ToolError(formatAdapterUnavailable(params.adapter, command, commandCwd));
}
throw new ToolError(
`No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
@@ -2,7 +2,14 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { getAdapterConfigs, resolveAdapter, selectLaunchAdapter } from "../../src/dap/config";
import * as piUtils from "@oh-my-pi/pi-utils";
import {
getAdapterConfigs,
type LaunchAdapterSelection,
resolveAdapter,
selectLaunchAdapter,
} from "../../src/dap/config";
import type { DapResolvedAdapter } from "../../src/dap/types";
import { injectPluginDirRoots } from "../../src/discovery/helpers";
const tempDirs: string[] = [];
@@ -15,6 +22,45 @@ async function makeTempDir(prefix: string): Promise<string> {
return cwd;
}
interface NestedGoProgram {
moduleRoot: string;
program: string;
}
async function writeExecutable(filePath: string): Promise<void> {
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<void> {
await fs.writeFile(path.join(cwd, "dap.json"), JSON.stringify({ adapters: { dlv: { command } } }));
}
async function setupMissingDlvProject(cwd: string): Promise<string> {
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<NestedGoProgram> {
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);
});
});
@@ -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 {