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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user