Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING (behavioral): launch sessions now default to `breakOnExceptions: "uncaught"`** — a crashing script pauses at the uncaught exception (`lastStop.reason: "exception"`, stack/locals inspectable, `exceptionInfo` where supported) instead of running to termination. Applies uniformly to Python, JavaScript, Java, Go (panics), .NET, Rust (panics), and the mock adapter; Ruby keeps the old run-to-termination behavior (rdbg has no uncaught-only filter). Pass `breakOnExceptions: "none"` to restore the old behavior per session. Attach sessions are unchanged — no default is ever applied on attach (fixes #244)

### Added
- **`expectedContent` breakpoint assertions + loud snapping** — `set_breakpoint` accepts an optional `expectedContent`: the exact text you expect on the target line (whitespace-trimmed). On a mismatch the breakpoint is NOT set and the error shows expected vs actual plus the surrounding lines — converting an off-by-one line number from confusing session behavior into an immediate, self-explanatory failure. When an adapter binds a breakpoint to a different line than requested, the response now reports it prominently (`requested line N, bound to line M` in `message`/`warning`, with `requestedLine` alongside the bound `line`) instead of silently mutating the line; asynchronous relocations (js-debug-style breakpoint events) surface in `list_breakpoints` as `line` ≠ `requestedLine`. Session-layer live-sync failures now also reach the `set_breakpoint` response as a `warning` (previously discarded). Content assertions require a server-readable source file (rejected for Java FQCNs and attach sessions with a clear error), and the source-line cache is now mtime-validated so mid-session edits are seen immediately. The new `DEBUG_MCP_BP_ADDRESSING` env flag (`line` | `assert` | `content`, default `content`) restricts addressing features — runtime behavior, tool schema, server instructions, and prompt text all gate together, enabling controlled A/B comparisons of agent debugging behavior (#271)

- **Stop-reason normalization + `stopReason` in pause results** — a new per-adapter `normalizeStopReason` policy hook maps misleading raw DAP stop reasons to canonical ones before they drive auto-continue, `lastStop`, and `exceptionInfo` enrichment: CodeLLDB reports an explicit pause (delivered via SIGSTOP) as `"exception"`, and js-debug reports it as `"step"` — both now surface as `"pause"`, with the adapter's original value preserved as `lastStop.rawReason`. Real exceptions (SIGSEGV, panics) are never reclassified, and normalized pauses no longer trigger a spurious `exceptionInfo` request. `pause_execution` now reports `data.stopReason`/`data.rawStopReason` for fresh stops (stale earlier stops are never echoed)

- **Optimized-binary warning on `get_local_variables`** — when the adapter annotates the locals scope (Delve's `"Locals (warning: optimized function)"` for optimized frames), the response now reports the actual scope name and a `warning` field with remediation guidance (rebuild with `-gcflags="all=-N -l"`, or use Delve debug mode). Previously Go's exact-name scope match silently returned `[]` for optimized frames; the scope is now prefix-matched so available variables are returned. `examples/go/README.md` no longer suggests `buildFlags` with `mode: "exec"` (Delve ignores it there)
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,10 @@ Starts debugging for a session.

**Returns**: Debug result with success status

#### `setBreakpoint(sessionId: string, file: string, line: number, condition?: string): Promise<Breakpoint>`
Sets a breakpoint in a file. Internally sends a DAP `setBreakpoints` request for all breakpoints in the same source file.
#### `setBreakpoint(sessionId: string, bp: { file: string; line: number; condition?: string; logMessage?: string; suspendPolicy?: 'all' | 'thread'; requestedLine?: number }): Promise<{ breakpoint: Breakpoint; warning?: string }>`
Sets a breakpoint in a file. Internally sends a DAP `setBreakpoints` request for all breakpoints in the same source file. `requestedLine` records the originally requested line for loud snapping (issue #271); `warning` carries live-sync failures.

**Returns**: Breakpoint information
**Returns**: Breakpoint information plus an optional sync warning

#### `continue(sessionId: string, threadId?: number): Promise<DebugResult>`
Resumes execution from a breakpoint.
Expand Down
1 change: 1 addition & 0 deletions docs/docker-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Here's the recommended configuration for your MCP settings file:
- The `:rw` suffix allows read-write access (required for debugging)
- The Docker entrypoint (`scripts/docker-entry.sh`) runs `dist/bundle.cjs` and passes through command-line arguments (e.g., `stdio`). It does not hardcode `--log-level` or `--log-file`
- When using the debugger, provide paths relative to the project root (e.g., `examples/test.py` not `/workspace/examples/test.py`)
- Optional env flags pass through with `-e`, e.g. `-e DEBUG_MCP_BP_ADDRESSING=line` restricts breakpoint addressing features (default: all enabled; see the set_breakpoint section of the tool reference)

## Rust support in Docker

Expand Down
23 changes: 23 additions & 0 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ Sets a breakpoint in a source file.
- `sessionId` (string, required): The ID of the debug session.
- `file` (string, required): Path to the source file (absolute or relative to project root).
- `line` (number, required): Line number where to set breakpoint (1-indexed).
- `expectedContent` (string, optional): Assert the exact text of the target line (leading/trailing whitespace ignored) before setting. On a mismatch the breakpoint is **not** set and the error shows the actual content of that line and its neighbors — a fast, self-explanatory failure instead of a breakpoint that silently lands on the wrong line. See [Content assertions and loud snapping](#content-assertions-and-loud-snapping).
- `condition` (string, optional): Conditional expression — only break (or log) when it evaluates truthy.
- `logMessage` (string, optional): Create a **logpoint** instead of a pausing breakpoint — see [Logpoints](#logpoints) below.
- `suspendPolicy` (string, optional): Suspend policy when the breakpoint is hit — `"all"` suspends all threads (default), `"thread"` suspends only the event thread. Only supported by the Java/JDI adapter.
Expand Down Expand Up @@ -167,6 +168,28 @@ Sets a breakpoint in a source file.
- The response includes the absolute path even if you provide a relative path
- Setting breakpoints on non-executable lines (comments, blank lines, declarations) may cause unexpected behavior
- Executable lines that work well: assignments, function calls, conditionals, returns
- The top-level `content` field echoes the bound line's text (same as `context.lineContent`)

#### Content assertions and loud snapping

`expectedContent` is a checksum on intent: agents that compute line numbers from a code listing routinely land one line off, and a breakpoint on a blank line or brace produces confusing session behavior much later. With `expectedContent`, the mismatch fails at set time:

```
Breakpoint not set: line 12 of /abs/app.py does not match expectedContent.
Expected: "total = sum(prices)"
Actual: "return total"
Context:
10 | prices = load()
11 | total = sum(prices)
> 12 | return total
13 |
14 | def main():
The file may have changed since you last read it. Pick the correct line from the context above.
```

Relatedly, when a debug adapter *accepts* a breakpoint but binds it to a different line (adapters snap requests on non-executable lines to the nearest valid one), the response reports it prominently instead of silently mutating the line: `message` and `warning` carry `"requested line 12, bound to line 13: \`...\`"`, and the response includes `requestedLine` alongside the bound `line`. Adapters that relocate breakpoints asynchronously (after the response) surface the move in `list_breakpoints`, where `line` ≠ `requestedLine` marks a snapped breakpoint.

`expectedContent` requires a source file the server can read: it is rejected for Java FQCN breakpoints and attach-mode sessions (remote filesystems). Both addressing aids can be restricted with the `DEBUG_MCP_BP_ADDRESSING` environment variable (`line` = pre-existing behavior, `assert` = + expectedContent/loud snapping, `content` = all features; default `content`) — useful for controlled comparisons of agent behavior.

#### Logpoints

Expand Down
43 changes: 38 additions & 5 deletions packages/adapter-mock/src/mock-adapter-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,16 +511,33 @@ class MockDebugAdapterProcess {
}
}

/**
* Breakpoint snap simulation (issue #271): when the launched program path
* contains "snap", odd requested lines bind one line down (odd -> next
* even is idempotent, so replace-all re-syncs of already-snapped lines
* don't drift). "snap-event" additionally defers: the response echoes the
* requested line unverified, then a DAP breakpoint(changed) event delivers
* the relocation + verification asynchronously.
*/
private snapLine(line: number | undefined): number | undefined {
if (line === undefined) return line;
return line % 2 === 1 ? line + 1 : line;
}

private handleSetBreakpoints(request: DebugProtocol.SetBreakpointsRequest): void {
const args = request.arguments;
const breakpoints: DebugProtocol.Breakpoint[] = [];

const simulateSnapEvent = /snap-event/i.test(this.programPath);
const simulateSnap = !simulateSnapEvent && /snap/i.test(this.programPath);

if (args.breakpoints) {
for (const bp of args.breakpoints) {
const id = Math.floor(Math.random() * 100000);
const boundLine = simulateSnap ? this.snapLine(bp.line) : bp.line;
breakpoints.push({
id: Math.floor(Math.random() * 100000),
verified: true,
line: bp.line,
id,
verified: !simulateSnapEvent,
line: boundLine,
source: args.source,
// Retained for the run simulation: logpoint lines log instead of stopping
...(bp.logMessage !== undefined ? { logMessage: bp.logMessage } : {})
Expand All @@ -529,7 +546,7 @@ class MockDebugAdapterProcess {
}

this.breakpoints.set(args.source?.path || 'unknown', breakpoints);

this.sendResponse({
seq: 0,
type: 'response',
Expand All @@ -540,6 +557,22 @@ class MockDebugAdapterProcess {
breakpoints
}
});

if (simulateSnapEvent) {
for (const bp of breakpoints) {
setTimeout(() => {
const relocated = { ...bp, verified: true, line: this.snapLine(bp.line) };
bp.verified = true;
bp.line = relocated.line;
this.sendEvent({
seq: 0,
type: 'event',
event: 'breakpoint',
body: { reason: 'changed', breakpoint: relocated }
} as DebugProtocol.BreakpointEvent);
}, 100);
}
}
}

private handleThreads(request: DebugProtocol.ThreadsRequest): void {
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ export interface Breakpoint {
message?: string;
/** Breakpoint id assigned by the debug adapter (from setBreakpoints responses / breakpoint events) */
adapterId?: number;
/**
* The line originally requested by the client, recorded before the adapter
* had a chance to bind elsewhere. Present only in assert/content addressing
* modes (issue #271); `line` !== `requestedLine` means the adapter snapped
* the breakpoint. Never sent to the adapter.
*/
requestedLine?: number;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion skills/debugging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Do NOT reach for it when a single glance at the code or one log line would answe

```text
1. create_debug_session {language: "python"} -> sessionId
2. set_breakpoint {sessionId, file: "<ABSOLUTE path>", line: N}
2. set_breakpoint {sessionId, file: "<ABSOLUTE path>", line: N, expectedContent: "<exact line text>"}
3. start_debugging {sessionId, scriptPath: "<ABSOLUTE path>"}
4. get_stack_trace {sessionId} -> frames (use frame.id, never assume 0)
5. get_scopes {sessionId, frameId: <frame.id>} -> scope variablesReference
Expand All @@ -46,6 +46,7 @@ Rules that prevent 90% of failed sessions:

1. State a hypothesis about where reality diverges from expectation *before* setting breakpoints.
2. Set at most two breakpoints: last-known-good and first-known-bad. Run, inspect, halve the interval. Bisection beats stepping line-by-line from the top. Move the window mid-session with `remove_breakpoint` / `clear_breakpoints`; `list_breakpoints` shows what is currently set (with verified state and adapter ids).
- Pass `expectedContent: "<exact line text>"` with every line-addressed breakpoint: if your line number is stale or off by one, the set fails immediately with the actual nearby lines instead of binding somewhere surprising. A response saying `requested line N, bound to line M` means the adapter moved the breakpoint — trust the bound line.
3. When pausing is too disruptive (hot loops, live or attached processes), use a **logpoint**: `set_breakpoint` with `logMessage: "x={x}"` streams interpolated values into `get_output` without stopping the program (Python/JS/Go/Rust; Java and .NET reject it with a clear error).
4. At each pause, record what you *learned* (variable values, actual control flow), not just where you are.
5. When the diverging line is found, inspect every input to that line before concluding — the bug is usually an operand, not the operator.
Expand Down
Loading
Loading