Summary
get_local_variables hard-anchors to stackFrames[0]. When a pause_execution lands inside a blocking syscall — the normal case for any long-running program — frame 0 is a stdlib/libc frame with no locals, so the tool returns an empty array even though the user frame is one frame down and already visible in the default (filtered) stack.
The sting: the implementation has already fetched the data it then discards. session-manager-data.ts loops over every frame collecting scopes (the fan-out from #438), and then narrows the result to stackFrames[0]'s Local scope.
// src/session/session-manager-data.ts:436
const topFrame = stackFrames[0];
...
// the loop directly below fetches scopes for ALL frames
for (const frame of stackFrames) { const scopes = await this.getScopes(sessionId, frame.id); ... }
This matters because get_local_variables is the tool an agent reaches for to inspect a paused long-running process, and its documented purpose is "returns just the local variables without needing to traverse stack->scopes->variables manually". In exactly that scenario it returns nothing, and the agent has no signal that the data is one hop away.
Reproduction (C/C++, verified on v0.24.2)
main is frame 1 of the default, already-filtered stack — not something includeInternals was hiding.
Cross-adapter inconsistency
Same fixture shape, same pause-in-sleep, opposite outcomes:
| Adapter |
get_local_variables at a sleep pause |
| java |
✅ counter: 78 from PauseTest.main (policy classifies Thread.sleep internals as hidden) |
| dotnet |
✅ counter from PauseTest.Main |
| cpp / rust / ruby / javascript |
❌ empty — "The Local scope is empty." |
So the behavior is an artifact of how completely each adapter policy classifies internal frames, not a deliberate contract. Observed on 13 independent runs across 5 languages and all three build groups (local / npx / docker, both transports) in a full /testdebugger sweep.
Relationship to existing issues
Suggested fix
Make the frame choice adapter-agnostic instead of chasing per-adapter internal-frame classification: pick the first frame whose Local scope is non-empty (falling back to stackFrames[0] if none is), and name the chosen frame in the existing frame field so the caller can see the tool walked down. The scopes are already fetched, so this costs no extra DAP round-trips.
If the empty result is ever genuinely correct, the message should say the frame is a runtime/stdlib frame and point at the user frame that does have locals — the current "The Local scope is empty." is true but not actionable.
Relates to #369, #465.
Summary
get_local_variableshard-anchors tostackFrames[0]. When apause_executionlands inside a blocking syscall — the normal case for any long-running program — frame 0 is a stdlib/libc frame with no locals, so the tool returns an empty array even though the user frame is one frame down and already visible in the default (filtered) stack.The sting: the implementation has already fetched the data it then discards.
session-manager-data.tsloops over every frame collecting scopes (the fan-out from #438), and then narrows the result tostackFrames[0]'s Local scope.This matters because
get_local_variablesis the tool an agent reaches for to inspect a paused long-running process, and its documented purpose is "returns just the local variables without needing to traverse stack->scopes->variables manually". In exactly that scenario it returns nothing, and the agent has no signal that the data is one hop away.Reproduction (C/C++, verified on v0.24.2)
create_debug_session { language: "cpp" } start_debugging { scriptPath: "examples/cpp/pause_test.cpp", dapLaunchArgs: { stopOnEntry: false } } pause_execution { } → { stopReason: "pause", location: { file: ".../this_thread_sleep.h", line: 80 } } get_local_variables { } → { "variables": [], "count": 0, "frame": { "name": "void std::this_thread::sleep_for<...>(...)", "file": "/usr/include/c++/13/bits/this_thread_sleep.h", "line": 80 }, "message": "The Local scope is empty." } get_stack_trace { } // DEFAULT call — already filtered → stackFrames[0] = std::this_thread::sleep_for (this_thread_sleep.h:80) stackFrames[1] = main (pause_test.cpp:20) ← VISIBLE, not hidden count: 5, hiddenFrames: 2 get_scopes { frameId: <main> } → Local scope 1074 get_variables { scope: 1074 } → [ { "name": "counter", "value": "348", "type": "long long" } ]mainis frame 1 of the default, already-filtered stack — not somethingincludeInternalswas hiding.Cross-adapter inconsistency
Same fixture shape, same pause-in-sleep, opposite outcomes:
get_local_variablesat a sleep pausecounter: 78fromPauseTest.main(policy classifiesThread.sleepinternals as hidden)counterfromPauseTest.Main"The Local scope is empty."So the behavior is an artifact of how completely each adapter policy classifies internal frames, not a deliberate contract. Observed on 13 independent runs across 5 languages and all three build groups (local / npx / docker, both transports) in a full
/testdebuggersweep.Relationship to existing issues
hiddenFrames+ anote, which they did not before. But Ergonomics: hiddenFrames heuristic doesn't recognize LLDB/libc internal frames (rust/cpp) as internal #369's stated consequence is still live: "get_local_variablesat that point returns … instead of the caller's actual locals (e.g. a loop counter one frame up)". Today it returns empty rather than libc locals — more honest, equally unusable.Suggested fix
Make the frame choice adapter-agnostic instead of chasing per-adapter internal-frame classification: pick the first frame whose Local scope is non-empty (falling back to
stackFrames[0]if none is), and name the chosen frame in the existingframefield so the caller can see the tool walked down. The scopes are already fetched, so this costs no extra DAP round-trips.If the empty result is ever genuinely correct, the
messageshould say the frame is a runtime/stdlib frame and point at the user frame that does have locals — the current "The Local scope is empty." is true but not actionable.Relates to #369, #465.