feat(agent): a missing workspace gates the file tools, not the whole run - #78
Conversation
Follow-up to #76, which fixed the two instances in the code it introduced and left the older ones alone to keep that review diff readable. `executeCommand` returns a Thenable, so a bare call in a void context turns any rejection into an unhandled promise rejection in the extension host — attributed to nothing, which is the part that makes it useless. SIX SITES, NOT SEVEN. The brief for this change listed the `levelcode.ai.focus` registration as needing a `return`. It does not: `() => vscode.commands.executeCommand(…)` is a concise arrow body, so it already returns the thenable and VS Code already reports its failures. Left alone. The other six are all BACKGROUND reveals — addSelection, addContext, resumeSession, the editor panel's onDidDispose, the sessions view's newSession, and handleLaunch. In each the accompanying work has already succeeded by the time the reveal runs, so failing that work because the panel would not come forward would be worse than the panel not coming forward. They route through one `focusChatView(why)` helper that logs and never rejects. Logged, not swallowed: the complaint was "attributed to nothing", so `.catch(() => {})` would answer the letter of it and none of the substance. `why` names the caller in the log. ONE GUARD FOR THE WHOLE CLASS. Rather than six assertions naming six functions, the test scans every occurrence of the call and accepts only returned / awaited / concise-arrow / helper-wrapped / inline-handled forms. That covers the seventh site and the eighth nobody has written yet — which matters, because this pattern reached six copies precisely because nothing was watching for it. Verified non-vacuous against develop: the same scan reports exactly the six bare sites there (lines 423, 468, 1055, 2298, 2435, 2531) and zero here. Bypasses, each reverted and confirmed to fail: - a single site returned to a bare call; the switch-case site returned to a bare call - the focus command wrapped in a block body so it stops returning (proves the guard protects the site that was already correct) - the helper swallowing silently; the log dropping its caller attribution One assertion deliberately relaxed after bypassing it: pinning `.then(undefined, …)` over `.catch(…)` failed a refactor with no behavioural difference. It now accepts either and still fails when the handler stops logging. 23 tests in chatSurface, 32 suites green.
runAgent opened with a blanket refusal — "Open a folder first — the agent works on your
workspace." — written for the file tools but placed where it failed the ENTIRE run.
So with no folder open you could not ask what an error meant, could not reach a single MCP
server (the GitHub server does not care whether you have a folder open), and could not ask
about the file open in the editor in front of you. The reported case was exactly that:
"Explain what the current file does", refused, for a request that never needed a workspace.
The root still gates the tools that resolve a path or a cwd against it. It no longer gates
the agent.
withheld rootless : list_files, read_file, search, edit_file, write_file, delete_file,
run_command, read_command_output
still available : update_plan, ask_user, use_skill + every MCP tool
Withheld rather than offered-and-failing: a tool that is present but errors on every call
is worse than one that is absent, because the model retries it.
THREE THINGS THAT WOULD HAVE MADE THIS A WORSE EXPERIENCE THAN THE REFUSAL:
- PORTABLE_TOOLS is DERIVED from NEEDS_ROOT, not a second hand-written list, so the two
cannot disagree about a tool.
- baseTools switches too. Left on the full TOOLS it would bill the context popover for
schemas that were never sent.
- The model is TOLD why the tools are missing. Without that it sees a list with no
read_file and improvises — answering about files it cannot see, or apologising at
length for a limit it cannot name. The note also points at what still works and, if the
request genuinely needs files, at File > Open Folder.
MCP servers are still spawned with a cwd, so a null root now falls back to os.homedir()
rather than being passed through.
Guards, each bypass-verified by reverting the fix:
- the blanket refusal restored (the reported bug)
- run_command un-gated; ask_user gated (which would rebuild the refusal a tool at a time)
- NEEDS_ROOT naming a tool that no longer exists, i.e. rename drift
- the tool list no longer switching on the root; baseTools billing for unsent tools
- MCP spawned with a null cwd
- the note built but never concatenated into the prompt — the classic version of this bug,
which looks right in review and does nothing
5 tests in agentNoWorkspace, 33 suites green.
There was a problem hiding this comment.
Pull request overview
This PR adjusts the LevelCode AI agent so it can run when no workspace folder is open by gating only the workspace-dependent built-in file/command tools, while keeping MCP tools (and conversational tools like ask_user) available.
Changes:
- Remove the blanket “open a folder first” refusal from
runAgent, and instead switch the built-in tool list to a portable subset whenworkspaceRoot()is null. - Ensure MCP servers still start in rootless mode by falling back to
os.homedir()as the spawn cwd, and add a rootless system-prompt note explaining missing tools. - Add regression tests for rootless agent behavior, and introduce a shared
focusChatView()helper to prevent unhandled promise rejections from chat reveal calls.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| extensions/levelcode-ai/agent.js | Switch built-in tool exposure based on workspace root, keep MCP available, and add rootless prompt guidance + homedir cwd fallback. |
| extensions/levelcode-ai/extension.js | Add focusChatView(why) helper and route background chat reveals through it to avoid unhandled rejections. |
| extensions/levelcode-ai/test/chatSurface.test.js | Update/extend tests to enforce that chat-focus calls are not fire-and-forget and are centrally handled/logged. |
| extensions/levelcode-ai/test/agentNoWorkspace.test.js | New unit suite verifying the agent runs rootless while properly gating only the root-dependent built-in tools. |
Suppressed comments (1)
extensions/levelcode-ai/agent.js:758
- toolsTokensEst falls back to TOOLS_TOKENS_EST even when builtins switches to PORTABLE_TOOLS (rootless + no MCP + no recall). That makes the contextUsage payload report the wrong tool-token cost in the no-workspace case. Use a builtins-specific cached estimate (e.g., PORTABLE_TOOLS_TOKENS_EST) for the fallback path.
const builtins = root ? TOOLS : PORTABLE_TOOLS;
let tools = mcp.tools.length ? builtins.concat(mcp.tools) : builtins;
if (ctx.recallSessions) { tools = tools.concat([RECALL_TOOL]); } // cross-session recall (host-gated by memory settings)
const baseTools = ctx.recallSessions ? builtins.concat([RECALL_TOOL]) : builtins; // built-ins + recall; MCP is the rest
// Recomputed only when MCP or recall actually contributed tools, so the plain path keeps the module
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const PORTABLE_TOOLS = TOOLS.filter((t) => !NEEDS_ROOT.has(t.name)); | ||
|
|
||
| const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4); | ||
|
|
| // Everything that resolves a path or a cwd. Miss one and it is offered rootless, then fails on the | ||
| // model's first call — which is worse than not offering it, because the model retries. | ||
| for (const name of ['list_files', 'read_file', 'search', 'edit_file', 'write_file', 'delete_file', 'run_command']) { | ||
| assert.ok(gated.includes(name), name + ' resolves a workspace path but is not in NEEDS_ROOT'); | ||
| } |
…lly sent Both review points on #78 were right. 1. THE TOKEN ESTIMATE STILL COUNTED THE FULL TOOL LIST `toolsTokensEst` falls back to a module constant on the plain path (no MCP, no recall) so that path never re-stringifies. But that constant was built from TOOLS, and after this PR there are TWO plain paths — a rootless run sends PORTABLE_TOOLS and was billed for TOOLS. Measured: ~1000 tokens, about two thirds of the tool budget, reported against a window that never spent it. That is worse than a missing feature; it is a meter reading high, and the context popover exists precisely so that number can be trusted. This is the same mistake as leaving baseTools on TOOLS, one line further down — which I fixed, described in the PR body, and then missed here. PORTABLE_TOOLS_TOKENS_EST is a second constant rather than a call-time derivation, because keeping the plain path free of JSON.stringify is the whole reason the constant exists. 2. THE TEST DID NOT PIN read_command_output It asserted seven of the eight gated tools. read_command_output was the omission, and it is the plausible one to lose: it takes no path and reads as portable at a glance, so nothing would have objected to un-gating it. Rootless it can only ever refer to a background run_command that could not have started. Guards, each bypass-verified by reverting the fix: - read_command_output un-gated (the second review point) - the estimate reverting to the full-TOOLS constant (the first) - the estimate no longer switching on the root; the rootless estimate deleted - PORTABLE_TOOLS no longer a filtered subset, which would make the estimate guard vacuous 6 tests in agentNoWorkspace, 33 suites green.
|
Both right. Fixed in 3dde9b2. 1. The token estimate still counted the full tool listCorrect, and this is the same mistake as leaving
Measured:
That's worse than a missing feature; it's a meter reading high, and the context popover exists precisely so that number can be trusted.
const builtinsTokensEst = root ? TOOLS_TOKENS_EST : PORTABLE_TOOLS_TOKENS_EST;
const toolsTokensEst = (mcp.tools.length || ctx.recallSessions) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst;2. The test did not pin
|
| bypass | caught |
|---|---|
read_command_output un-gated — your second point |
✓ |
the estimate reverting to the full-TOOLS constant — your first |
✓ |
| the estimate no longer switching on the root | ✓ |
| the rootless estimate deleted | ✓ |
PORTABLE_TOOLS no longer a filtered subset |
✓ |
The last one matters: if PORTABLE_TOOLS stopped being a strict subset the two estimates would be identical and the new guard would pass vacuously, so the test asserts the subset relation and a non-empty NEEDS_ROOT rather than trusting it.
6 tests in agentNoWorkspace, 33 suites green.
runAgentopened with a blanket refusal:Written for the file tools, but placed where it failed the entire run. So with no folder open you could not ask what an error meant, could not reach a single MCP server, and could not ask about the file open in the editor in front of you.
The reported case was exactly that — "Explain what the current file does", refused, for a request that never needed a workspace.
The root still gates the tools. It no longer gates the agent.
list_files,read_file,search,edit_file,write_file,delete_file,run_command,read_command_outputupdate_plan,ask_user,use_skill+ every MCP toolWithheld rather than offered-and-failing: a tool that's present but errors on every call is worse than one that's absent, because the model retries it.
The MCP half is most of the value here. None of those servers care whether the editor has a folder open — the GitHub server least of all — and until now a missing workspace disabled all of them.
Three things that would have made this worse than the refusal
PORTABLE_TOOLSis derived fromNEEDS_ROOT, not a second hand-written list, so the two can't disagree about a tool.baseToolsswitches too. Left on the fullTOOLSit would bill the context-usage popover for schemas that were never sent.The model is told why the tools are missing. Without that it sees a list with no
read_fileand improvises — answering about files it can't see, or apologising at length for a limit it can't name. The note also points at what still works, and atFile > Open Folderwhen the request genuinely needs files.MCP servers are still spawned with a cwd, so a null root now falls back to
os.homedir()rather than being passed straight through.Guards
Each bypass-verified by reverting the fix:
run_commandun-gatedask_usergated — rebuilding the refusal a tool at a timeNEEDS_ROOTnaming a tool that no longer exists (rename drift)baseToolsbilling for tools that were never sentThat last one is the classic version of this bug — it looks right in review and does nothing.
What this does not do
It doesn't give the agent a way to read the open editor's file without a folder.
read_fileresolves against the workspace root, and a rootless "read whatever is in the active tab" tool is a separate design question. Today the answer comes from the conversation, attached context, and MCP. Worth a follow-up if the no-folder case turns out to be common.5 tests in
agentNoWorkspace, 33 suites green.