|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * The agent's run_command over the REAL LevelCode Shell runtime — run: node web/test/unit/shell-agent-paths.test.js |
| 3 | + * |
| 4 | + * agent.js (extensions/levelcode-ai) has always named workspace files by `Uri.fsPath`. The shell |
| 5 | + * (web/shell/src/runtime.js) mounts each folder at its URI path and refuses a working directory that is not inside |
| 6 | + * one. On a Mac or Linux the two are the same string. In a browser on Windows they are not: VS Code takes the |
| 7 | + * platform from the user agent (src/vs/base/common/platform.ts), and `uriToFsPath` (src/vs/base/common/uri.ts) turns |
| 8 | + * every `/` into `\` for any scheme — `file:///proj`, a folder opened from the computer, has the fsPath `\proj`. The |
| 9 | + * shell was handed that, and every command answered "the working directory \proj is not inside a workspace folder". |
| 10 | + * |
| 11 | + * So this runs the real thing end to end — runAgent → run_command → runCommand → host.spawn → the runtime's |
| 12 | + * spawnAgent → a fake engine (web/test/helpers/shell-runtime-fakes.js) and back through agent.js's commit hook — |
| 13 | + * with the folders' fsPath computed as VS Code computes it on Windows. A scripted provider stands in for the model. |
| 14 | + *--------------------------------------------------------------------------------------------*/ |
| 15 | +// @ts-check |
| 16 | +'use strict'; |
| 17 | + |
| 18 | +// A promise that never settles ends the process with status 0 and no output: a suite that did not finish fails. |
| 19 | +let finished = false; |
| 20 | +process.on('exit', (code) => { if (!finished && code === 0) { console.error('not ok - the tests did not finish'); process.exitCode = 1; } }); |
| 21 | + |
| 22 | +const assert = require('assert'); |
| 23 | +const path = require('path'); |
| 24 | +const Module = require('module'); |
| 25 | + |
| 26 | +const { createRig, flush, Uri } = require(path.join(__dirname, '..', 'helpers', 'shell-runtime-fakes.js')); |
| 27 | +const { planMounts, toMountPath } = require(path.join(__dirname, '..', '..', 'shell', 'src', 'runtime.js')); |
| 28 | + |
| 29 | +const AI = path.join(__dirname, '..', '..', '..', 'extensions', 'levelcode-ai'); |
| 30 | +const te = new TextEncoder(); |
| 31 | + |
| 32 | +/** The `vscode` agent.js and host.js are given: the rig's workspace, and the few members they read besides. */ |
| 33 | +const vscodeMock = /** @type {any} */ ({}); |
| 34 | +const origLoad = Module._load; |
| 35 | +// @ts-ignore — test-only loader shim |
| 36 | +Module._load = function (request, parent, isMain) { |
| 37 | + if (request === 'vscode') { return vscodeMock; } |
| 38 | + return origLoad.call(this, request, parent, isMain); |
| 39 | +}; |
| 40 | +const providers = require(path.join(AI, 'providers', 'index')); |
| 41 | +/** @type {any[]} */ let script = []; |
| 42 | +/** @type {any[]} */ const turns = []; |
| 43 | +providers.streamAgentTurn = async (/** @type {any} */ o) => { turns.push({ messages: JSON.parse(JSON.stringify(o.messages)) }); const t = script.shift(); if (!t) { throw new Error('script exhausted'); } return t; }; |
| 44 | + |
| 45 | +/** |
| 46 | + * Uri.fsPath as VS Code computes it in a browser on Windows: uri.ts uriToFsPath with isWindows true. |
| 47 | + * @param {any} u |
| 48 | + */ |
| 49 | +function windowsFsPath(u) { |
| 50 | + let v; |
| 51 | + if (u.authority && u.path.length > 1 && u.scheme === 'file') { v = '//' + u.authority + u.path; } |
| 52 | + else if (/^\/[A-Za-z]:/.test(u.path)) { v = u.path[1].toLowerCase() + u.path.slice(2); } |
| 53 | + else { v = u.path; } |
| 54 | + return v.replace(/\//g, '\\'); |
| 55 | +} |
| 56 | + |
| 57 | +/** |
| 58 | + * A workspace in a browser on Windows, the shell runtime over it, and agent.js + host.js loaded fresh against it, |
| 59 | + * with the runtime registered as the shell exactly as web/shell/src/extension.js exports it. |
| 60 | + * @param {Array<{ name: string, uri: string }>} folders |
| 61 | + */ |
| 62 | +function setup(folders) { |
| 63 | + const rig = createRig({ workspace: { folders } }); |
| 64 | + for (const f of rig.ws.vscode.workspace.workspaceFolders) { Object.defineProperty(f.uri, 'fsPath', { get() { return windowsFsPath(this); } }); } |
| 65 | + const ws = rig.ws.vscode.workspace; |
| 66 | + ws.findFiles = async () => []; |
| 67 | + ws.asRelativePath = (/** @type {any} */ u) => String(u.path || u); |
| 68 | + ws.textDocuments = []; |
| 69 | + for (const k of Object.keys(vscodeMock)) { delete vscodeMock[k]; } |
| 70 | + Object.assign(vscodeMock, { |
| 71 | + Uri: { file: (/** @type {string} */ p) => new Uri('file', '', p), parse: Uri.parse }, |
| 72 | + FileType: rig.ws.vscode.FileType, |
| 73 | + workspace: ws, |
| 74 | + env: { appRoot: '' }, |
| 75 | + extensions: { getExtension: () => undefined }, |
| 76 | + }); |
| 77 | + for (const k of Object.keys(require.cache)) { if (k.startsWith(AI + path.sep) && !k.includes(path.sep + 'providers' + path.sep)) { delete require.cache[k]; } } |
| 78 | + /** @type {any} */ (globalThis).__LEVELCODE_BROWSER_HOST__ = true; |
| 79 | + const agent = require(path.join(AI, 'agent')); |
| 80 | + const host = require(path.join(AI, 'host')); |
| 81 | + host.registerShell({ info: rig.rt.info, spawn: (/** @type {string} */ c, /** @type {any} */ o) => rig.rt.spawnAgent(c, o), forDisplay: (/** @type {string} */ t) => t }); |
| 82 | + /** @type {any[]} */ const runs = []; |
| 83 | + /** @type {any[]} */ const starts = []; |
| 84 | + rig.rt.onAgentRun((e) => { if (e.type === 'start') { starts.push(e); } }); |
| 85 | + return { rig, agent, host, runs, starts }; |
| 86 | +} |
| 87 | + |
| 88 | +const tool = (/** @type {string} */ id, /** @type {string} */ name, /** @type {any} */ input) => ({ stop_reason: 'tool_use', content: [{ type: 'tool_use', id, name, input }] }); |
| 89 | +const say = (/** @type {string} */ text) => ({ stop_reason: 'end_turn', content: [{ type: 'text', text }] }); |
| 90 | +const resultFor = (/** @type {string} */ id) => { for (const t of turns) { for (const m of t.messages) { if (m.role === 'user' && Array.isArray(m.content)) { const r = m.content.find((/** @type {any} */ c) => c.tool_use_id === id); if (r) { return r.content; } } } } return null; }; |
| 91 | +/** @param {any} [over] */ |
| 92 | +function ctxFor(over) { |
| 93 | + /** @type {any[]} */ const posted = []; |
| 94 | + return Object.assign({ |
| 95 | + messages: [{ role: 'user', content: 'go' }], maxSteps: 6, post: (/** @type {any} */ m) => posted.push(m), dbg: () => {}, |
| 96 | + approve: async () => true, commandRuns: new Map(), commandStops: new Map(), signal: new AbortController().signal, posted, |
| 97 | + }, over); |
| 98 | +} |
| 99 | + |
| 100 | +let n = 0; |
| 101 | +async function test(/** @type {string} */ name, /** @type {() => Promise<void>} */ fn) { turns.length = 0; await fn(); n++; console.log(' ok - ' + name); } |
| 102 | + |
| 103 | +(async () => { |
| 104 | + try { |
| 105 | + await test('a browser on Windows: run_command runs in the folder — the shell is handed /proj, not \\proj — and its output comes back', async () => { |
| 106 | + const { rig, agent, runs, starts } = setup([{ name: 'proj', uri: 'file:///proj' }]); |
| 107 | + assert.strictEqual(rig.ws.vscode.workspace.workspaceFolders[0].uri.fsPath, '\\proj', 'the premise: the folder\'s fsPath has a backslash'); |
| 108 | + rig.engines.state.onRun = async (run, w, h) => { runs.push(run); h.out(run.id, 'stdout', 'hi\n'); await flush(); h.done({ exitCode: 0 }); }; |
| 109 | + script = [tool('t1', 'run_command', { command: 'echo hi', explanation: 'x' }), say('Done: x')]; |
| 110 | + await agent.runAgent(ctxFor({})); |
| 111 | + assert.strictEqual(resultFor('t1'), 'hi\n'); |
| 112 | + assert.strictEqual(runs.length, 1); |
| 113 | + assert.strictEqual(runs[0].cwd, '/proj'); |
| 114 | + // The mirror tab hears the same folder, in the terms it resolves a line's links in (terminal/index.js cwdsOf). |
| 115 | + assert.strictEqual(starts[0].cwd, '/proj'); |
| 116 | + assert.strictEqual(toMountPath(planMounts(rig.rt.folders()), starts[0].cwd), '/proj'); |
| 117 | + rig.rt.dispose(); |
| 118 | + }); |
| 119 | + |
| 120 | + await test('a browser on Windows: the folder run_command picks by name is the one it runs in', async () => { |
| 121 | + const { rig, agent, runs } = setup([{ name: 'proj', uri: 'file:///proj' }, { name: 'lib', uri: 'file:///lib' }]); |
| 122 | + rig.engines.state.onRun = async (run, w, h) => { runs.push(run); h.done({ exitCode: 0 }); }; |
| 123 | + script = [tool('t1', 'run_command', { command: 'ls', folder: 'lib', explanation: 'x' }), say('Done: x')]; |
| 124 | + await agent.runAgent(ctxFor({})); |
| 125 | + assert.strictEqual(runs.length, 1, String(resultFor('t1'))); |
| 126 | + assert.strictEqual(runs[0].cwd, '/lib'); |
| 127 | + rig.rt.dispose(); |
| 128 | + }); |
| 129 | + |
| 130 | + await test('a browser on Windows: what the command changed comes back to the review by folder, read from the right file, and is verified', async () => { |
| 131 | + const { rig, agent, runs } = setup([{ name: 'proj', uri: 'file:///proj' }]); |
| 132 | + rig.ws.put('/proj/old.txt', 'a\n'); |
| 133 | + rig.engines.state.onRun = async (run, w, h) => { |
| 134 | + runs.push(run); |
| 135 | + h.done({ exitCode: 0, changes: [ |
| 136 | + { kind: 'write', path: '/proj/old.txt', existed: true, before: te.encode('a\n'), after: te.encode('b\n') }, |
| 137 | + { kind: 'write', path: '/proj/new.txt', existed: false, before: null, after: te.encode('hi\n') }, |
| 138 | + ] }); |
| 139 | + }; |
| 140 | + /** @type {any[]} */ const edits = []; |
| 141 | + const ctx = ctxFor({ applyEdit: async (/** @type {any} */ r) => { edits.push(r); return true; }, applyDelete: async () => true, touched: new Set() }); |
| 142 | + script = [tool('t1', 'run_command', { command: 'make', explanation: 'x' }), say('Done: x')]; |
| 143 | + await agent.runAgent(ctx); |
| 144 | + // old.txt is compared with what is on disk now (read through host.uriFor): a path the folders do not contain |
| 145 | + // would have been "changed while the command ran", or "outside the workspace folders". |
| 146 | + assert.deepStrictEqual(edits, [{ path: 'new.txt', exists: false, proposed: 'hi\n' }, { path: 'old.txt', exists: true, proposed: 'b\n' }], String(resultFor('t1'))); |
| 147 | + assert.match(String(resultFor('t1')), /2 files changed, ready for Keep\/Undo/); |
| 148 | + assert.deepStrictEqual([...ctx.touched].sort(), ['/proj/new.txt', '/proj/old.txt'], 'what the verify step looks at, as host.uriFor maps it'); |
| 149 | + rig.rt.dispose(); |
| 150 | + }); |
| 151 | + |
| 152 | + await test('a browser on Windows: the verify command runs in the folder too', async () => { |
| 153 | + const { rig, agent, runs } = setup([{ name: 'proj', uri: 'file:///proj' }]); |
| 154 | + rig.engines.state.onRun = async (run, w, h) => { |
| 155 | + runs.push(run); |
| 156 | + if (run.script === 'make') { h.done({ exitCode: 0, changes: [{ kind: 'write', path: '/proj/new.txt', existed: false, before: null, after: te.encode('hi\n') }] }); } |
| 157 | + else { h.done({ exitCode: 0 }); } |
| 158 | + }; |
| 159 | + const ctx = ctxFor({ |
| 160 | + applyEdit: async () => true, applyDelete: async () => true, touched: new Set(), |
| 161 | + verify: { enabled: true, command: 'grep -q hi new.txt', maxRounds: 1, includeWarnings: false }, |
| 162 | + getNewDiagnostics: async () => ({ text: '', count: 0 }), getTouchedUris: () => [], |
| 163 | + }); |
| 164 | + script = [tool('t1', 'run_command', { command: 'make', explanation: 'x' }), say('Done: x')]; |
| 165 | + await agent.runAgent(ctx); |
| 166 | + assert.deepStrictEqual(runs.map((r) => r.script + ' @ ' + r.cwd), ['make @ /proj', 'grep -q hi new.txt @ /proj']); |
| 167 | + assert.ok(ctx.posted.some((/** @type {any} */ m) => m.type === 'verifyDone' && m.ok === true && m.hadCommand === true)); |
| 168 | + rig.rt.dispose(); |
| 169 | + }); |
| 170 | + |
| 171 | + console.log('\nshell-agent-paths: ' + n + ' tests passed.'); |
| 172 | + finished = true; |
| 173 | + } finally { |
| 174 | + Module._load = origLoad; |
| 175 | + delete (/** @type {any} */ (globalThis)).__LEVELCODE_BROWSER_HOST__; |
| 176 | + } |
| 177 | +})().catch((e) => { console.error(e); process.exit(1); }); |
0 commit comments