|
| 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