Skip to content

Commit 4fbcc54

Browse files
authored
Merge pull request #93 from levelcodeai/fix/agent-runtool-dbg-scope
fix(ai): runTool's dbg was out of scope — the auto-preview never opened
2 parents 119fef2 + b999443 commit 4fbcc54

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs) {
311311
async function runTool(tu, ctx) {
312312
const root = ctx.root;
313313
const input = tu.input || {};
314+
// The same logger runAgent uses, resolved HERE: runAgent's `dbg` is local to runAgent, so it is not
315+
// in scope in this function — and a bare dbg(...) below is a ReferenceError only once the line runs.
316+
const dbg = ctx.dbg || (() => {});
314317
try {
315318
if (tu.name === 'list_files') {
316319
ctx.post({ type: 'agentTool', icon: 'list-tree', text: 'list_files ' + (input.glob || '') });
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* The agent's background run_command, EXECUTED — run: node test/agentRunCommand.test.js
3+
*
4+
* The bug this locks down: runTool logged through a bare `dbg(...)`, but `dbg` was only ever declared
5+
* inside runAgent — a different function. An out-of-scope name is not an error until the line runs,
6+
* so nothing failed until a background command printed a local address:
7+
*
8+
* entry.previewUrl = url;
9+
* dbg('preview.detected', …); // ReferenceError: dbg is not defined
10+
* Promise.resolve(ctx.openPreview(url))… // never reached
11+
*
12+
* The built-in browser therefore never opened when the agent started a dev server, and the exception
13+
* escaped into the child's live stdout handler. Two more calls sat behind the same missing name: the
14+
* `.catch` on that openPreview promise, and the `catch` of the fire-and-forget background launcher —
15+
* where it turned a HANDLED start failure into an unhandled rejection.
16+
*
17+
* Why this file runs the code when its neighbours (agentMaxSteps, agentNoWorkspace) assert from
18+
* source: a name that is out of scope is invisible to a regex and to `node --check`, and this repo has
19+
* no type-check gate — so only executing the line finds it. runTool is not exported, and going in
20+
* through runAgent is the point anyway: it proves the logger the HOST passes is the one runTool ends
21+
* up with. So the real runAgent → runTool → run_command → runCommand → onChunk chain runs here, with
22+
* the three things it needs from outside replaced: `vscode` (as workspacePaths.test.js does), the
23+
* provider (two scripted turns instead of a network call), and child_process.spawn (a fake child the
24+
* test feeds stdout into — no process is ever started).
25+
*--------------------------------------------------------------------------------------------*/
26+
// @ts-check
27+
'use strict';
28+
29+
const assert = require('assert');
30+
const fs = require('fs');
31+
const os = require('os');
32+
const path = require('path');
33+
const Module = require('module');
34+
const { EventEmitter } = require('events');
35+
36+
// ---- the outside world, replaced --------------------------------------------------------------
37+
38+
// vscode: one EMPTY workspace folder — no rules file and no .levelcode/mcp.json, so the run has nothing
39+
// to load and no MCP server to start.
40+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-run-'));
41+
const vscodeMock = { workspace: { workspaceFolders: [{ uri: { fsPath: root }, name: 'app' }] } };
42+
43+
// child_process: spawn hands back a child the test drives by hand. Until it is asked to stop one,
44+
// runCommand only LISTENS on a child (stdout/stderr 'data', then 'close' / 'error'), so three emitters
45+
// are the whole surface.
46+
const spawned = [];
47+
function fakeSpawn() {
48+
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter() });
49+
spawned.push(child);
50+
return child;
51+
}
52+
const childProcessMock = Object.assign({}, require('child_process'), { spawn: fakeSpawn });
53+
54+
const origLoad = Module._load;
55+
// @ts-ignore — test-only loader shim
56+
Module._load = function (request, parent, isMain) {
57+
if (request === 'vscode') { return vscodeMock; }
58+
if (request === 'child_process') { return childProcessMock; }
59+
return origLoad.call(this, request, parent, isMain);
60+
};
61+
62+
// The provider: scripted turns instead of a network call. Patched on the module object agent.js shares,
63+
// and BEFORE agent.js loads, so it holds however agent.js chooses to import it.
64+
const providers = require('../providers/index');
65+
let script = [];
66+
providers.streamAgentTurn = async () => {
67+
const turn = script.shift();
68+
if (!turn) { throw new Error('the agent asked for a turn the script does not have'); }
69+
return turn;
70+
};
71+
72+
const { runAgent } = require('../agent');
73+
74+
// ---- harness ----------------------------------------------------------------------------------
75+
76+
let n = 0;
77+
async function testAsync(name, fn) { await fn(); n++; console.log(' ok - ' + name); }
78+
79+
// A rejection nobody handles is how two of the three call sites failed, and left alone it would kill
80+
// this process with a stack and no test name. Collect them, so the test that caused one is the one
81+
// that fails.
82+
const unhandled = [];
83+
process.on('unhandledRejection', (e) => { unhandled.push(e); });
84+
/** One full turn of the event loop: long enough for a promise chain to settle AND for Node to have
85+
* reported any rejection left unhandled. */
86+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
87+
88+
let seq = 0;
89+
/**
90+
* Run one agent goal whose only action is a background `run_command`, to the end of the RUN. The
91+
* command does not end — that is what background means — so the returned `child` is still live, and
92+
* the test plays the dev server by emitting on `child.stdout`.
93+
*/
94+
async function startBackground(overrides) {
95+
const id = 'toolu_bg' + (++seq);
96+
const posted = [], logged = [];
97+
const before = spawned.length;
98+
script = [
99+
{ stop_reason: 'tool_use', content: [{ type: 'tool_use', id, name: 'run_command', input: { command: 'npm run dev', background: true, explanation: 'Start the dev server' } }] },
100+
{ stop_reason: 'end_turn', content: [{ type: 'text', text: 'Done: the dev server is starting.' }] }
101+
];
102+
const ctx = Object.assign({
103+
messages: [{ role: 'user', content: 'start the dev server' }],
104+
maxSteps: 5,
105+
post: (m) => posted.push(m),
106+
dbg: (label, data) => logged.push({ label, data }),
107+
approve: async () => true, // manual mode asks before every command — say yes
108+
commandRuns: new Map(), commandStops: new Map(),
109+
signal: new AbortController().signal
110+
}, overrides);
111+
await runAgent(ctx);
112+
const end = posted.filter((m) => m.type === 'agentError' || m.type === 'agentDone');
113+
assert.deepStrictEqual(end.map((m) => m.reason || m.message), ['done'], 'the scripted run did not finish cleanly');
114+
assert.strictEqual(spawned.length, before + 1, 'run_command did not reach the fake spawn exactly once');
115+
return { id, logged, child: spawned[before] };
116+
}
117+
118+
const LOCAL = 'http://localhost:5173/';
119+
const BANNER = ' ➜ Local: ' + LOCAL + '\n'; // the line Vite prints once it is serving
120+
121+
(async () => {
122+
try {
123+
// ---- 1. the reported bug: the preview must open ----------------------------------------------
124+
125+
await testAsync('PREVIEW: a local address in background output opens the built-in browser', async () => {
126+
const opened = [];
127+
const run = await startBackground({ openPreview: async (url) => { opened.push(url); } });
128+
assert.deepStrictEqual(opened, [], 'nothing has been printed yet');
129+
130+
// Emitted the way a real child does it: synchronously, from its stdout 'data' event. This is
131+
// the call that threw `dbg is not defined` — and it threw BEFORE openPreview was reached.
132+
assert.doesNotThrow(() => run.child.stdout.emit('data', Buffer.from(BANNER)),
133+
'the stdout handler of a live command threw');
134+
assert.deepStrictEqual(opened, [LOCAL], 'the address the server advertised never reached openPreview');
135+
136+
// …and it is logged through the logger the RUN was given. runAgent hands its ctx to runTool, so
137+
// ctx.dbg is the one logger both share; a private no-op in runTool would pass the line above
138+
// and still leave `levelcode.ai.debug` blind to every preview.
139+
assert.deepStrictEqual(run.logged.filter((l) => l.label === 'preview.detected'),
140+
[{ label: 'preview.detected', data: { id: run.id, url: LOCAL } }]);
141+
});
142+
143+
await testAsync('PREVIEW: a failed open is logged, not left as an unhandled rejection', async () => {
144+
// The second call site: the .catch on the openPreview promise. Its whole job is to keep a
145+
// preview failure away from a running command — but with `dbg` out of scope the handler itself
146+
// threw, so the rejection it existed to absorb came straight back as an unhandled one.
147+
const run = await startBackground({ openPreview: async () => { throw new Error('simple browser is disabled'); } });
148+
run.child.stdout.emit('data', Buffer.from(BANNER));
149+
await settle();
150+
assert.deepStrictEqual(unhandled, [], 'a rejected preview escaped as an unhandled rejection');
151+
assert.deepStrictEqual(run.logged.filter((l) => l.label === 'preview.rejected'),
152+
[{ label: 'preview.rejected', data: { id: run.id, error: 'simple browser is disabled' } }]);
153+
});
154+
155+
// ---- 2. the same missing name, in the background launcher ------------------------------------
156+
157+
await testAsync('BACKGROUND: a failed start is logged, not left as an unhandled rejection', async () => {
158+
// The third call site: the catch of the fire-and-forget launcher around runCommand. It is purely
159+
// defensive — runCommand resolves even when spawn itself throws — so the only way in is a failure
160+
// while the child is being wired up. A stop registry that throws is the smallest such failure.
161+
const run = await startBackground({ commandStops: { set() { throw new Error('stop registry unavailable'); } } });
162+
await settle();
163+
assert.deepStrictEqual(unhandled, [], 'the handler for a failed start threw, making a handled error an unhandled one');
164+
assert.deepStrictEqual(run.logged.filter((l) => l.label === 'bg.error'),
165+
[{ label: 'bg.error', data: { id: run.id, msg: 'stop registry unavailable' } }]);
166+
});
167+
168+
// ---- 3. the logger is optional ---------------------------------------------------------------
169+
170+
await testAsync('LOGGER: a host that passes no dbg still gets its preview', async () => {
171+
// runAgent already treats ctx.dbg as optional. runTool has to match, or putting the name in scope
172+
// only trades `dbg is not defined` for `dbg is not a function` in any host without a debug sink.
173+
const opened = [];
174+
const run = await startBackground({ dbg: undefined, openPreview: async (url) => { opened.push(url); } });
175+
assert.doesNotThrow(() => run.child.stdout.emit('data', Buffer.from(BANNER)));
176+
assert.deepStrictEqual(opened, [LOCAL]);
177+
});
178+
179+
await settle();
180+
assert.deepStrictEqual(unhandled, [], 'an unhandled rejection surfaced after its test had passed');
181+
} finally {
182+
fs.rmSync(root, { recursive: true, force: true });
183+
}
184+
console.log('\nagentRunCommand: ' + n + ' tests passed.');
185+
})().catch((e) => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)