diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 1a248a0..fb2a462 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -13,6 +13,7 @@ const vscode = require('vscode'); const fs = require('fs'); const path = require('path'); const cp = require('child_process'); +const os = require('os'); // MCP servers need a cwd even when no folder is open const providers = require('./providers/index'); const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify'); const { classifyCommand, dangerLabel } = require('./commandSafety'); @@ -68,7 +69,28 @@ function buildSystem(menu) { const list = menu.map((s) => '- ' + s.name + ': ' + s.description).join('\n'); return SYSTEM_BASE + '\n\nAvailable skills (call use_skill with the name):\n' + list; } +// The tools that resolve a PATH or a CWD against the workspace root. With no folder open they have +// nothing to resolve against, so they are withheld from the model rather than offered and left to fail +// one call at a time β€” a tool that is present but always errors is worse than one that is absent. +// +// Everything NOT in here works fine rootless: update_plan and ask_user are pure conversation, use_skill +// reads from the extension, and every MCP tool talks to its own server (the GitHub server does not care +// whether you have a folder open). That is the whole reason the old blanket refusal was wrong. +// +// read_command_output is included because it reads the output of a background run_command, and without +// a root there is no way to have started one. +const NEEDS_ROOT = new Set([ + 'list_files', 'read_file', 'search', 'edit_file', 'write_file', 'delete_file', + 'run_command', 'read_command_output' +]); +const PORTABLE_TOOLS = TOOLS.filter((t) => !NEEDS_ROOT.has(t.name)); + const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4); +// The same estimate for the rootless list, and it has to exist separately rather than be derived at +// call time: the plain path deliberately never re-stringifies (see toolsTokensEst below), so without a +// second constant a rootless run reports the FULL schema cost for a list it never sent β€” about 1000 +// tokens, two thirds of the tool budget, charged against a window that never spent it. +const PORTABLE_TOOLS_TOKENS_EST = Math.round(JSON.stringify(PORTABLE_TOOLS).length / 4); // Cross-session memory recall (docs/levelcode-sessions-memory.md). Added to a run's tools ONLY when the host // wires ctx.recallSessions (memory + the recall setting on), so it costs nothing otherwise. Read-only and @@ -636,7 +658,7 @@ async function setupMcp(ctx, wsFolders, dbg) { // lazy option. Only the FIRST run of a session pays it β€” mcpClient keeps handles in a module // registry, and connectAll reuses a live one. ctx.post({ type: 'agentStatus', text: 'starting MCP servers…' }); - const { handles, problems: connectProblems } = await connectAll(trusted, { cwd: ctx.root }); + const { handles, problems: connectProblems } = await connectAll(trusted, { cwd: ctx.root || os.homedir() }); for (const p of connectProblems) { dbg('mcp.connect', p); ctx.post({ type: 'agentTool', icon: 'warning', text: 'πŸ”Œ mcp Β· "' + p.server + '" failed to start β€” ' + p.message }); @@ -675,8 +697,11 @@ async function setupMcp(ctx, wsFolders, dbg) { } async function runAgent(ctx) { + // No workspace is no longer a refusal. It used to fail the whole run here, which meant a question + // that never needed a folder β€” "what does this error mean?", anything through an MCP server, a + // follow-up about the conversation itself β€” died on a guard written for the file tools. The root + // still gates those tools (see NEEDS_ROOT); it no longer gates the agent. const root = workspaceRoot(); - if (!root) { ctx.post({ type: 'agentError', message: 'Open a folder first β€” the agent works on your workspace.' }); ctx.post({ type: 'agentDone', reason: 'error' }); return; } ctx.root = root; // M6.5 implicit skills: build the system prompt ONCE per run β€” append the tiny name+description menu. // Multi-root: name every workspace folder so the model addresses them by prefix from turn one. @@ -684,6 +709,17 @@ async function runAgent(ctx) { const multiRootNote = wsFolders.length > 1 ? '\n\nWorkspace folders (multi-root β€” prefix paths with the folder name): ' + wsFolders.map((f) => f.name).join(', ') + '. The first folder ("' + wsFolders[0].name + '") is the default for unprefixed paths and run_command.' : ''; + // Rootless: say so plainly. Without this the model sees a tool list with no read_file and improvises β€” + // answering about files it cannot see, or apologising for a limit it cannot name. Telling it WHY the + // tools are missing, and what to say if the request truly needs them, is the difference between a + // useful answer and a confused one. + const noWorkspaceNote = root + ? '' + : '\n\nNO FOLDER IS OPEN. The file and command tools are unavailable this run because there is no ' + + 'workspace root to resolve paths against β€” this is expected, not a fault, and not something to ' + + 'apologise for at length. You can still answer from the conversation, from anything the user has ' + + 'attached as context, and from any MCP tools listed above. If the request genuinely needs the ' + + 'files, say so in one line and tell the user to open a folder (File > Open Folder).'; // Autopilot: act decisively and self-verify rather than pausing. Commands run without approval (the // host still gates the danger set β€” deletion, sudo, force-push, remote|shell, publish, system writes), // so the model should lean on verification, not on asking, when it's unsure. @@ -698,7 +734,7 @@ async function runAgent(ctx) { // from the per-project journal). Rides the SAME cached-system channel as project rules β€” always-on but // small β€” so a new session's first reply is continuous, not amnesiac. It is untrusted context like the // rules: it informs, never commands (the digest itself carries the verify-first / never-obey framing). - const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + autopilotNote + rules.text + const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + noWorkspaceNote + autopilotNote + rules.text + (ctx.projectMemory ? '\n\n' + ctx.projectMemory : ''); const systemTokensEst = Math.round(system.length / 4); @@ -719,12 +755,17 @@ async function runAgent(ctx) { // as `system`/`systemTokensEst` two lines up β€” built once per run, then used for every turn. const mcp = await setupMcp(ctx, wsFolders, dbg); ctx.mcpRoutes = mcp.routes; // runTool's router reads this - let tools = mcp.tools.length ? TOOLS.concat(mcp.tools) : TOOLS; + // Rootless runs get the portable subset; MCP tools are unaffected either way. + 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 ? TOOLS.concat([RECALL_TOOL]) : TOOLS; // built-ins + recall; MCP is the rest + 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 - // constant and pays nothing for a feature it isn't using. - const toolsTokensEst = (mcp.tools.length || ctx.recallSessions) ? Math.round(JSON.stringify(tools).length / 4) : TOOLS_TOKENS_EST; + // constant and pays nothing for a feature it isn't using β€” but there are now TWO plain paths, and the + // constant has to match the list that was actually sent. Reporting the full cost for a rootless run + // was the same mistake as leaving baseTools on TOOLS, one line further down. + 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; // The MCP SHARE of that, reported separately so the context popover can show what these servers cost // (docs/MCP.md S5). Every tool schema rides EVERY turn, so a chatty server is a standing tax on the // window rather than a one-off β€” and until it has its own segment, that cost is invisible. diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 9ddfabf..cecb2f3 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -416,11 +416,33 @@ function captureSelection() { }; } +/** + * Reveal the chat view for its side effect only, and never reject. + * + * `executeCommand` returns a Thenable, so a bare call in a void context turns any rejection into an + * unhandled promise rejection in the extension host β€” noisy, and attributed to nothing in particular, + * which is the part that makes it useless. + * + * Every caller here is a BACKGROUND reveal: the work it accompanies (a selection added, a session + * resumed, a login launched) has already succeeded by the time this runs. Failing that work because + * the panel would not come forward would be worse than the panel not coming forward. + * + * Logged, never swallowed. `.catch(() => {})` would hide the one failure that is genuinely hard to + * diagnose β€” a chat surface that silently never appears β€” so `why` names the caller in the log. + * + * NOT for a command handler whose whole job IS the reveal: `levelcode.ai.focus` returns the thenable + * instead, so VS Code reports the failure to the user who asked for it. See moveChatToSidebar. + */ +function focusChatView(why) { + return Promise.resolve(vscode.commands.executeCommand('levelcodeAi.chat.focus')) + .then(undefined, (e) => dbg('chat.focus.failed', { why, msg: String((e && e.message) || e) })); +} + function addSelection() { const sel = captureSelection(); if (!sel) { vscode.window.showInformationMessage('LevelCode AI: select some code first.'); return; } pendingContext = sel.block; - vscode.commands.executeCommand('levelcodeAi.chat.focus'); + focusChatView('addSelection'); post({ type: 'context', label: sel.label }); } @@ -465,7 +487,7 @@ async function addContext() { } } } - vscode.commands.executeCommand('levelcodeAi.chat.focus'); + focusChatView('addContext'); postContextFiles(); } @@ -1052,7 +1074,7 @@ async function resumeSession(id) { post({ type: 'sessionResumed', id, title: (r.entry && r.entry.title) || 'Session', note: r.note || '', tier: r.plan && r.plan.tier, turns }); postContextFiles(); refreshSessions(); // the resumed session bumps to the top β€” keep both surfaces current - vscode.commands.executeCommand('levelcodeAi.chat.focus'); + focusChatView('resumeSession'); dbg('sessions.resumed', { id, tier: r.plan && r.plan.tier, restored: agentMessages.length, shown: turns.length }); } @@ -2295,7 +2317,7 @@ async function openChatInEditor(opts) { // resolveWebviewView then makes it live, and without this the chat would have no surface at all. activeWebview = undefined; pendingTranscriptReplay = 'Back in the sidebar'; - vscode.commands.executeCommand('levelcodeAi.chat.focus'); + focusChatView('editorClosed'); } dbg('chat.closedEditor', {}); }); @@ -2432,7 +2454,7 @@ class SessionsViewProvider { // The real session index for this workspace (empty on a fresh install β€” the view shows its // own empty state). Posted to THIS view's webview, not the chat's. case 'listSessions': view.webview.postMessage({ type: 'sessions', entries: sessionList() }); break; - case 'newSession': newChat(); vscode.commands.executeCommand('levelcodeAi.chat.focus'); break; + case 'newSession': newChat(); focusChatView('sessions.newSession'); break; case 'sessionAction': await handleSessionAction(msg.action, msg.id); break; // Memory tab (Β§M3): list the recorded outcomes + facts; act on them; open the file. case 'listMemory': { const mm = sessionsManager(); view.webview.postMessage({ type: 'memoryList', items: mm ? mm.memoryItems() : [], facts: mm ? mm.factsList() : [] }); break; } @@ -2528,7 +2550,7 @@ async function postAccount(open) { // second login and no interceptable code ever travels through the custom scheme. async function handleLaunch() { dbg('account.launch', {}); - vscode.commands.executeCommand('levelcodeAi.chat.focus'); + focusChatView('account.launch'); const token = ctx ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null; if (!token) { await accountSignIn(); } } diff --git a/extensions/levelcode-ai/test/agentNoWorkspace.test.js b/extensions/levelcode-ai/test/agentNoWorkspace.test.js new file mode 100644 index 0000000..09874b5 --- /dev/null +++ b/extensions/levelcode-ai/test/agentNoWorkspace.test.js @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * The agent runs without a folder open β€” run: node test/agentNoWorkspace.test.js + * + * What this replaces: runAgent used to open with a blanket refusal β€” + * + * if (!root) { post({ type: 'agentError', message: 'Open a folder first β€” …' }); return; } + * + * 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 sitting 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. + * + * Asserted from SOURCE: standing up a real runAgent needs a live VS Code host and a provider, which + * this pure-unit suite deliberately does not stand up β€” the same approach agentMaxSteps.test.js takes. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const agent = fs.readFileSync(path.join(__dirname, '..', 'agent.js'), 'utf8'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +/** The `NEEDS_ROOT` set as shipped, read out of the source rather than restated here. */ +function needsRoot() { + const m = /const NEEDS_ROOT = new Set\(\[([\s\S]*?)\]\);/.exec(agent); + assert.ok(m, 'agent.js no longer declares NEEDS_ROOT'); + return (m[1].match(/'([a-z_]+)'/g) || []).map((s) => s.replace(/'/g, '')); +} + +/** Every built-in tool name, from the TOOLS array. */ +function allToolNames() { + const start = agent.indexOf('const TOOLS = ['); + assert.ok(start > 0, 'agent.js no longer declares TOOLS'); + const block = agent.slice(start, agent.indexOf('\n];', start)); + return (block.match(/\{ name: '([a-z_]+)'/g) || []).map((s) => s.replace(/.*'([a-z_]+)'.*/, '$1')); +} + +test('the blanket refusal is gone', () => { + assert.ok(!/Open a folder first/.test(agent), + 'runAgent still refuses outright when no folder is open β€” that guard belongs on the tools, not the run'); + // And the root is still READ β€” the tools need it, and losing it would silently disable them everywhere. + assert.match(agent, /const root = workspaceRoot\(\);\s*\n\s*ctx\.root = root;/, + 'runAgent must still resolve and carry the root, even when it is null'); +}); + +test('the tools that need a root are withheld, and only those', () => { + const gated = needsRoot(); + // 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. + // read_command_output is in this list because run_command is: it reads the output of a background + // command, so rootless it can only ever refer to a run that could not have started. Review caught it + // missing β€” the un-gating it guards against is a plausible edit, since the tool takes no path and + // reads as portable at a glance. + for (const name of ['list_files', 'read_file', 'search', 'edit_file', 'write_file', 'delete_file', + 'run_command', 'read_command_output']) { + assert.ok(gated.includes(name), name + ' resolves a workspace path but is not in NEEDS_ROOT'); + } + // …and nothing that works fine without one. Gating these would rebuild the old refusal a tool at a + // time: they are the entire reason a rootless run is still useful. + for (const name of ['update_plan', 'ask_user', 'use_skill']) { + assert.ok(!gated.includes(name), name + ' needs no workspace β€” gating it removes the point of the change'); + } + // The set must name real tools, or a rename silently un-gates one. + const known = allToolNames(); + const unknown = gated.filter((g) => !known.includes(g)); + assert.deepStrictEqual(unknown, [], 'NEEDS_ROOT names tools that no longer exist: ' + unknown.join(', ')); +}); + +test('the portable subset is what a rootless run actually offers', () => { + assert.match(agent, /const PORTABLE_TOOLS = TOOLS\.filter\(\(t\) => !NEEDS_ROOT\.has\(t\.name\)\)/, + 'PORTABLE_TOOLS must be derived from NEEDS_ROOT, not maintained as a second hand-written list'); + assert.match(agent, /const builtins = root \? TOOLS : PORTABLE_TOOLS;/, + 'the run no longer switches its tool list on the root'); + // Both assemblies must use it. baseTools feeds the context-usage split; if it kept the full TOOLS the + // popover would bill the user for tools that were never sent. + assert.match(agent, /let tools = mcp\.tools\.length \? builtins\.concat\(mcp\.tools\) : builtins;/, + 'the model is still handed the unfiltered TOOLS'); + assert.match(agent, /const baseTools = ctx\.recallSessions \? builtins\.concat\(\[RECALL_TOOL\]\) : builtins;/, + 'baseTools still counts the full TOOLS β€” the context popover would report tools that were not sent'); +}); + +test('the context popover is billed for the list that was actually sent', () => { + // Review found this one line below the baseTools fix, which is the same bug: the token estimate fell + // back to a module constant built from the FULL tool list, so a rootless run with no MCP reported the + // cost of eight schemas it never sent β€” ~1000 tokens, about two thirds of the tool budget, charged + // against a window that never spent it. Worse than a missing feature: it is a meter reading high. + assert.match(agent, /const PORTABLE_TOOLS_TOKENS_EST = Math\.round\(JSON\.stringify\(PORTABLE_TOOLS\)\.length \/ 4\);/, + 'no rootless token estimate β€” the popover reports the full tool cost for a list that was not sent'); + assert.match(agent, /const builtinsTokensEst = root \? TOOLS_TOKENS_EST : PORTABLE_TOOLS_TOKENS_EST;/, + 'the estimate no longer switches on the root'); + assert.match(agent, /const toolsTokensEst = \(mcp\.tools\.length \|\| ctx\.recallSessions\)[\s\S]{0,120}: builtinsTokensEst;/, + 'the plain path still falls back to the full-TOOLS constant'); + + // The two constants must actually differ, or the guard above passes on a list that gates nothing β€” + // the vacuous-pass this whole change would otherwise be measured by. + assert.ok(/PORTABLE_TOOLS = TOOLS\.filter/.test(agent), 'PORTABLE_TOOLS is no longer a strict subset'); + const gated = needsRoot(); + assert.ok(gated.length > 0, 'NEEDS_ROOT is empty β€” the two estimates would be identical and this test vacuous'); +}); + +test('MCP is unaffected by a missing root β€” that is half the point', () => { + // The GitHub server, the filesystem server pointed somewhere else, anything stdio: none of them need + // the editor to have a folder open. But they are spawned with a cwd, so a null one has to resolve to + // something real rather than being passed through. + assert.match(agent, /connectAll\(trusted, \{ cwd: ctx\.root \|\| os\.homedir\(\) \}\)/, + 'MCP servers are spawned with a null cwd when no folder is open'); + assert.match(agent, /^const os = require\('os'\);/m, "agent.js does not require 'os'"); + assert.ok(!/NEEDS_ROOT[\s\S]{0,400}mcp/i.test(agent.slice(agent.indexOf('const NEEDS_ROOT'), agent.indexOf('const PORTABLE_TOOLS'))), + 'MCP tools must not be filtered by NEEDS_ROOT β€” they are not workspace tools'); +}); + +test('the model is told WHY the tools are missing', () => { + // Without this it sees a tool list with no read_file and improvises: answering about files it cannot + // see, or apologising at length for a limit it cannot name. + const m = /const noWorkspaceNote = root[\s\S]*?;\n/.exec(agent); + assert.ok(m, 'no rootless system-prompt note β€” the model gets a truncated tool list and no explanation'); + const note = m[0]; + assert.match(note, /NO FOLDER IS OPEN/, 'the note must state the condition plainly'); + assert.match(note, /MCP/, 'the note must point at what DOES still work, not only at what does not'); + assert.match(note, /Open Folder/, 'the note must tell the user the way out when the request really needs files'); + assert.match(note, /^\s*const noWorkspaceNote = root\s*\n?\s*\? ''/m, + 'the note must be empty when a folder IS open, or every normal run pays for it'); + + // And it has to reach the prompt. A note that is built and never concatenated is the classic version + // of this bug β€” it looks right in review and does nothing. + assert.match(agent, /\+ multiRootNote \+ noWorkspaceNote \+ autopilotNote/, + 'noWorkspaceNote is never added to the system prompt'); +}); + +console.log('\nagentNoWorkspace: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/chatSurface.test.js b/extensions/levelcode-ai/test/chatSurface.test.js index 0485fd0..90e9af6 100644 --- a/extensions/levelcode-ai/test/chatSurface.test.js +++ b/extensions/levelcode-ai/test/chatSurface.test.js @@ -144,7 +144,9 @@ test('RESTORE: a sidebar that was never resolved is revealed rather than assumed // If the container has not been opened this session, sidebarChatView is undefined β€” restoring by // writing to it would throw, and doing nothing would leave the chat with no surface at all. const open = fnBody(ext, 'openChatInEditor'); - assert.match(open, /if \(sidebarChatView\) \{[\s\S]*\} else \{[\s\S]*levelcodeAi\.chat\.focus/, + // The reveal now goes through focusChatView() so its rejection cannot go unhandled; what this test + // cares about is unchanged β€” the else-branch must still reveal the view rather than assume it. + assert.match(open, /if \(sidebarChatView\) \{[\s\S]*\} else \{[\s\S]*focusChatView\(/, 'the never-resolved sidebar case is unhandled'); }); @@ -310,4 +312,46 @@ test('MOVE BACK: there is a button on the tab, and it reuses the dispose hand-ov assert.match(body, /levelcodeAi\.chat\.focus/, 'with no panel open the command must still reveal the chat, not do nothing'); }); +test('FOCUS: no reveal of the chat view is left to reject unhandled', () => { + // One guard for the whole class, rather than six assertions that each name a function. `executeCommand` + // returns a Thenable, so a bare call in a void context makes any rejection an unhandled promise + // rejection in the extension host β€” attributed to nothing, which is what makes it useless. + // + // Scanning every call site means the NEXT one is covered too. That matters here: this pattern was + // copied into six places over time precisely because nothing was watching for it. + const CALL = "vscode.commands.executeCommand('levelcodeAi.chat.focus')"; + const bare = []; + for (let i = ext.indexOf(CALL); i >= 0; i = ext.indexOf(CALL, i + 1)) { + const before = ext.slice(Math.max(0, i - 40), i); + const after = ext.slice(i + CALL.length, i + CALL.length + 40); + const handled = /\breturn\s+$/.test(before) // returned β€” a command handler VS Code awaits + || /\bawait\s+$/.test(before) // awaited by a caller that catches + || /=>\s*$/.test(before) // concise arrow body: also a return + || /Promise\.resolve\($/.test(before) // wrapped by focusChatView + || /^\s*\)?\s*\.(then|catch)\(/.test(after); // handled inline + if (!handled) { bare.push('line ' + ext.slice(0, i).split('\n').length); } + } + assert.deepStrictEqual(bare, [], + 'these reveals are fire-and-forget β€” a rejection becomes an unhandled promise rejection.\n' + + 'Use focusChatView(why) for a background reveal, or `return` it when the command IS the reveal:\n ' + + bare.join('\n ')); +}); + +test('FOCUS: the shared helper logs the failure and names who caused it', () => { + // The whole complaint was "attributed to nothing", so swallowing it silently would answer the letter + // of the review and none of it. A chat surface that never appears, with no trace, is the failure + // that costs an afternoon. + const body = fnBody(ext, 'focusChatView'); + assert.match(body, /dbg\('chat\.focus\.failed'/, 'the failure is not logged β€” .catch(() => {}) is not a fix'); + assert.match(body, /\bwhy\b/, 'the log must name the caller, or it is as unattributed as the rejection was'); + assert.ok(!/\bthrow\b/.test(body), 'the helper must not rethrow β€” every caller uses it in a void context'); + // Either `.then(undefined, …)` or `.catch(…)`. They are equivalent here and pinning one would fail a + // refactor that changes nothing; what must not disappear is the rejection handler itself. + assert.match(body, /\.then\(undefined,|\.catch\(/, 'no rejection handler β€” the helper can still reject'); + + // And it must be the thing the background callers actually use. + const callers = (ext.match(/focusChatView\('/g) || []).length; + assert.ok(callers >= 6, 'expected the background reveals to route through the helper, found ' + callers); +}); + console.log('\nchatSurface: ' + n + ' tests passed.');