Skip to content

Commit 8937f50

Browse files
committed
feat(web): the scratch workspace answers search, and the end-to-end check covers the agent's file tools
Quick Open, the Search view and workspace.findFiles (the agent's list_files) wait forever for a provider registered for the folder's scheme. The scratch workspace now registers a file and a text search provider (proposed APIs, declared by this built-in extension). e2e: list, search, read and edit through the real tool loop, Quick Open, and a wait for the run to finish between prompts (the send button is the stop button while a run is active).
1 parent 9a875a4 commit 8937f50

4 files changed

Lines changed: 194 additions & 16 deletions

File tree

‎web/test/e2e.mjs‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,24 +129,66 @@ try {
129129
await activateTab(/LevelCode AI/);
130130
await page.waitFor(async () => (await inChat('!!d.getElementById("send")')) === true, { ms: 30000, label: 'chat webview' });
131131
check('the chat webview renders from the editor', true);
132-
await inChat(`(() => { const i = d.getElementById('input'); i.focus(); if ('value' in i) { i.value = 'Please create a hello file'; } else { i.textContent = 'Please create a hello file'; } i.dispatchEvent(new Event('input', { bubbles: true })); d.getElementById('send').click(); return true; })()`);
133-
await page.waitFor(() => stub.state.chats >= 2, { ms: 60000, label: 'two model turns' }).catch(() => null);
134-
check('the agent called the model through the gateway and got the tool result back', stub.state.chats >= 2, 'turns: ' + stub.state.chats);
135-
const body = await scratchFile('/hello.txt');
136-
check('write_file created hello.txt in the scratch workspace', body === 'hello from the browser\n', JSON.stringify(body));
132+
/** Send a prompt and wait for the agent to finish its turns (a tool call, then its answer). */
133+
const idle = () => inChat(`!d.getElementById('send').classList.contains('stop')`);
134+
const ask = async (prompt, turns = 2) => {
135+
await page.waitFor(async () => (await idle()) === true, { ms: 90000, label: 'the agent to be idle' }).catch(() => null);
136+
const start = stub.state.chats;
137+
await inChat(`(() => { const i = d.getElementById('input'); i.focus(); if ('value' in i) { i.value = ${JSON.stringify(prompt)}; } else { i.textContent = ${JSON.stringify(prompt)}; } i.dispatchEvent(new Event('input', { bubbles: true })); d.getElementById('send').click(); return true; })()`);
138+
await page.waitFor(() => stub.state.chats >= start + turns, { ms: 60000, label: 'model turns for ' + prompt }).catch(() => null);
139+
const t0 = Date.now();
140+
await page.waitFor(async () => (await idle()) === true, { ms: 90000, label: 'the run to finish' }).catch(() => null);
141+
if (process.env.E2E_DEBUG) { console.log(' run finished ' + (Date.now() - t0) + ' ms after its last model turn'); }
142+
await sleep(300);
143+
return stub.state.chats - start;
144+
};
145+
/** The tool result the model was handed on its most recent request. */
146+
const lastToolResult = () => {
147+
const msgs = (stub.state.lastChatBody && stub.state.lastChatBody.messages) || [];
148+
const t = [...msgs].reverse().find((m) => m.role === 'tool');
149+
return t ? (typeof t.content === 'string' ? t.content : JSON.stringify(t.content)) : '';
150+
};
151+
152+
const turns = await ask('Please create a hello file');
153+
check('the agent called the model through the gateway and got the tool result back', turns >= 2, 'turns: ' + turns);
154+
check('write_file created hello.txt in the scratch workspace', (await scratchFile('/hello.txt')) === 'hello from the browser\n', JSON.stringify(await scratchFile('/hello.txt')));
137155
const tools = (stub.state.lastChatBody && stub.state.lastChatBody.tools || []).map((t) => t.function && t.function.name);
138-
check('the agent was not offered a shell', tools.length > 0 && !tools.includes('run_command') && tools.includes('write_file'), tools.join(','));
139-
await sleep(1500);
156+
check('the agent was not offered a shell', tools.length > 0 && !tools.includes('run_command') && !tools.includes('read_command_output') && tools.includes('write_file'), tools.join(','));
140157
await shot(page, '3-agent');
141158

159+
await ask('Please list the files');
160+
const listed = lastToolResult();
161+
if (process.env.E2E_DEBUG) { console.log(' chat log:', JSON.stringify(((await inChat(`(d.getElementById('log') || {}).innerText`)) || '').slice(-700))); console.log(' turns so far:', stub.state.chats, 'last request tools:', (stub.state.lastChatBody.messages || []).slice(-3).map((m) => m.role + ':' + String(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)).slice(0, 80)).join(' | ')); }
162+
check('list_files sees the scratch workspace (findFiles is answered by the scratch search provider)', /README\.md/.test(listed) && /hello\.txt/.test(listed), JSON.stringify(listed.slice(0, 120)));
163+
await ask('Please search the project');
164+
const found = lastToolResult();
165+
check('search finds text without ripgrep', /hello\.txt:1:hello from the browser/.test(found), JSON.stringify(found.slice(0, 160)));
166+
await ask('Please read the greeting');
167+
check('read_file returns the file', /hello from the browser/.test(lastToolResult()), JSON.stringify(lastToolResult().slice(0, 80)));
168+
await ask('Please change the greeting');
169+
check('edit_file edits it in place', (await scratchFile('/hello.txt')) === 'hello again\n', JSON.stringify(await scratchFile('/hello.txt')));
170+
await shot(page, '3b-edited');
171+
172+
// Quick Open is answered by the same provider.
173+
await page.key('p', 'KeyP', 4);
174+
let quick = await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box')`), { ms: 4000, label: 'quick open' }).catch(() => null);
175+
if (!quick) { await page.key('p', 'KeyP', 2); quick = await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box')`), { ms: 4000, label: 'quick open' }).catch(() => null); }
176+
if (quick) {
177+
await page.type('hello');
178+
await sleep(1500);
179+
const entries = await page.eval(`[...document.querySelectorAll('.quick-input-list .monaco-list-row')].map((e) => e.getAttribute('aria-label') || e.textContent)`);
180+
check('Quick Open finds files in the scratch workspace', entries.some((t) => /hello\.txt/.test(t)), JSON.stringify(entries.slice(0, 4)));
181+
await page.key('Escape', 'Escape');
182+
} else { check('Quick Open finds files in the scratch workspace', false, 'the quick open widget did not appear'); }
183+
142184
/* 4. a reload keeps the session and the files */
143185
const before = stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length;
144186
await page.goto(editorOrigin + '/');
145187
await workbenchReady();
146188
await page.waitFor(() => stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length > before, { ms: 30000, label: 'account call after reload' }).catch(() => null);
147189
const after = stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length;
148190
check('after a reload the editor is still signed in', after > before, `${before} -> ${after}`);
149-
check('after a reload the scratch file is still there', (await scratchFile('/hello.txt')) === 'hello from the browser\n');
191+
check('after a reload the scratch file is still there', (await scratchFile('/hello.txt')) === 'hello again\n');
150192
await shot(page, '4-reload');
151193
} catch (e) {
152194
check('the run completed', false, String((e && e.stack) || e));

‎web/test/stub-backend.mjs‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ function selfSigned() {
4646
*/
4747
export function defaultModel(messages) {
4848
const last = messages[messages.length - 1] || {};
49-
if (last.role === 'tool') { return { text: 'Done: wrote the file you asked for.' }; }
49+
if (last.role === 'tool') { return { text: 'Done: finished what you asked for.' }; }
5050
const text = String(typeof last.content === 'string' ? last.content : JSON.stringify(last.content || ''));
51-
if (/\bcreate\b/i.test(text)) {
52-
return {
53-
text: 'I will create the file now.',
54-
toolCalls: [{ name: 'write_file', args: { path: 'hello.txt', content: 'hello from the browser\n', explanation: 'Create the greeting file' } }],
55-
};
56-
}
51+
const call = (name, args, say) => ({ text: say, toolCalls: [{ name, args }] });
52+
if (/\bcreate\b/i.test(text)) { return call('write_file', { path: 'hello.txt', content: 'hello from the browser\n', explanation: 'Create the greeting file' }, 'I will create the file now.'); }
53+
if (/\blist\b/i.test(text)) { return call('list_files', {}, 'Let me look at the files.'); }
54+
if (/\bsearch\b/i.test(text)) { return call('search', { query: 'hello', explanation: 'Find the greeting' }, 'Searching.'); }
55+
if (/\bread\b/i.test(text)) { return call('read_file', { path: 'hello.txt', explanation: 'Read the greeting file' }, 'Reading it.'); }
56+
if (/\bchange\b/i.test(text)) { return call('edit_file', { path: 'hello.txt', old_str: 'hello from the browser', new_str: 'hello again', explanation: 'Change the greeting' }, 'Changing it.'); }
5757
return { text: 'Hello from the stand-in gateway.' };
5858
}
5959

@@ -67,7 +67,7 @@ export async function startStub(o) {
6767
const codes = new Map(); // code -> { challenge, at }
6868
const refreshes = new Set();
6969
const log = [];
70-
const state = { log, codes, signIns: 0, chats: 0, lastChatBody: null };
70+
const state = { log, codes, signIns: 0, chats: 0, lastChatBody: null, chatBodies: [] };
7171

7272
const handler = async (req, res) => {
7373
const url = new URL(req.url, 'http://stub');
@@ -159,6 +159,7 @@ export async function startStub(o) {
159159
const body = JSON.parse((await readBody()) || '{}');
160160
state.chats++;
161161
state.lastChatBody = body;
162+
state.chatBodies.push(body);
162163
const turn = model(body.messages || [], body);
163164
res.writeHead(200, Object.assign({ 'content-type': 'text/event-stream', 'cache-control': 'no-store' }, cors));
164165
const send = (obj) => res.write('data: ' + JSON.stringify(obj) + '\n\n');

‎web/workspace/extension.js‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,131 @@ class ScratchFileSystem {
212212
}
213213
}
214214

215+
/* ----- search ----------------------------------------------------------------------------- */
216+
217+
// Quick Open, the Search view and workspace.findFiles (which the agent's list_files uses) ask the
218+
// provider registered for the folder's scheme. Without one they wait forever, so the scratch
219+
// workspace answers its own questions. Everything is in IndexedDB; a scan is cheap at this size.
220+
221+
/** A VS Code glob as a RegExp over a '/'-relative path. A pattern with no '/' matches at any depth. */
222+
function globToRegExp(glob) {
223+
const src = String(glob);
224+
const anyDepth = !src.includes('/');
225+
const conv = (g) => {
226+
let re = '';
227+
for (let i = 0; i < g.length;) {
228+
const c = g[i];
229+
if (c === '*') {
230+
if (g[i + 1] === '*') {
231+
i += 2;
232+
if (g[i] === '/') { i++; re += '(?:.*/)?'; } else { re += '.*'; }
233+
} else { re += '[^/]*'; i++; }
234+
} else if (c === '?') { re += '[^/]'; i++; }
235+
else if (c === '{') {
236+
const j = g.indexOf('}', i);
237+
if (j < 0) { re += '\\{'; i++; }
238+
else { re += '(?:' + g.slice(i + 1, j).split(',').map(conv).join('|') + ')'; i = j + 1; }
239+
} else if (c === '[') {
240+
const j = g.indexOf(']', i + 1);
241+
if (j < 0) { re += '\\['; i++; }
242+
else { re += '[' + g.slice(i + 1, j).replace(/^!/, '^').replace(/\\/g, '\\\\') + ']'; i = j + 1; }
243+
} else { re += c.replace(/[.+^${}()|\\\/]/g, '\\$&'); i++; }
244+
}
245+
return re;
246+
};
247+
return new RegExp('^' + (anyDepth ? '(?:.*/)?' : '') + conv(src.replace(/^\.\//, '').replace(/^\//, '')) + '$');
248+
}
249+
250+
/** True when `rel` or any directory above it matches one of the globs. */
251+
function matchesAny(rel, res) {
252+
if (!res.length) { return false; }
253+
const parts = rel.split('/');
254+
let cur = '';
255+
for (const part of parts) {
256+
cur = cur ? cur + '/' + part : part;
257+
if (res.some((r) => r.test(cur))) { return true; }
258+
}
259+
return false;
260+
}
261+
262+
function subsequence(hay, needle) {
263+
let j = 0;
264+
for (let i = 0; i < hay.length && j < needle.length; i++) { if (hay[i] === needle[j]) { j++; } }
265+
return j === needle.length;
266+
}
267+
268+
class ScratchSearch {
269+
constructor(fsp) { this.fsp = fsp; }
270+
271+
async _files(folder, includes, excludes) {
272+
const root = norm(folder.path);
273+
const prefix = under(root);
274+
const inc = (includes || []).map(globToRegExp);
275+
const exc = (excludes || []).map(globToRegExp);
276+
const nodes = await this.fsp._range(root);
277+
const out = [];
278+
for (const n of nodes) {
279+
if (n.type !== FILE) { continue; }
280+
const rel = n.path.slice(prefix.length);
281+
if (matchesAny(rel, exc)) { continue; }
282+
if (inc.length && !inc.some((r) => r.test(rel))) { continue; }
283+
out.push({ rel, node: n });
284+
}
285+
return out;
286+
}
287+
288+
async provideFileSearchResults(query, options, token) {
289+
const want = String(query.pattern || '').toLowerCase().replace(/\\/g, '/');
290+
const files = await this._files(options.folder, options.includes, options.excludes);
291+
const hits = [];
292+
for (const f of files) {
293+
if (token.isCancellationRequested) { break; }
294+
const hay = f.rel.toLowerCase();
295+
if (!want || hay.includes(want) || subsequence(hay, want)) { hits.push(f); }
296+
}
297+
const max = options.maxResults || 10000;
298+
return hits.slice(0, max).map((f) => vscode.Uri.from({ scheme: SCHEME, path: f.node.path }));
299+
}
300+
301+
async provideTextSearchResults(query, options, progress, token) {
302+
const files = await this._files(options.folder, options.includes, options.excludes);
303+
let re;
304+
try {
305+
const src = query.isRegExp ? query.pattern : query.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
306+
re = new RegExp(query.isWordMatch ? '\\b(?:' + src + ')\\b' : src, query.isCaseSensitive ? 'g' : 'gi');
307+
} catch (e) { return { limitHit: false }; }
308+
const max = options.maxResults || 10000;
309+
const maxSize = options.maxFileSize || 1024 * 1024;
310+
let count = 0;
311+
const decoder = new TextDecoder('utf-8');
312+
for (const f of files) {
313+
if (token.isCancellationRequested) { break; }
314+
const data = f.node.data;
315+
if (!data || data.byteLength > maxSize) { continue; }
316+
if (data.subarray(0, 8000).includes(0)) { continue; } // binary
317+
const lines = decoder.decode(data).split('\n');
318+
const uri = vscode.Uri.from({ scheme: SCHEME, path: f.node.path });
319+
for (let ln = 0; ln < lines.length; ln++) {
320+
const line = lines[ln].replace(/\r$/, '');
321+
re.lastIndex = 0;
322+
const ranges = [];
323+
const previews = [];
324+
let m;
325+
while ((m = re.exec(line)) !== null) {
326+
if (m[0].length === 0) { re.lastIndex++; continue; }
327+
ranges.push(new vscode.Range(ln, m.index, ln, m.index + m[0].length));
328+
previews.push(new vscode.Range(0, m.index, 0, m.index + m[0].length));
329+
}
330+
if (!ranges.length) { continue; }
331+
progress.report({ uri, ranges, preview: { text: line, matches: previews } });
332+
count += ranges.length;
333+
if (count >= max) { return { limitHit: true }; }
334+
}
335+
}
336+
return { limitHit: false };
337+
}
338+
}
339+
215340
/* ----- activation ------------------------------------------------------------------------- */
216341

217342
async function seedWelcome(fsp) {
@@ -238,6 +363,15 @@ async function activate(context) {
238363

239364
await seedWelcome(fsp);
240365

366+
// Search is a proposed API; this extension is built in and declares it (package.json).
367+
if (typeof vscode.workspace.registerFileSearchProvider === 'function') {
368+
const search = new ScratchSearch(fsp);
369+
context.subscriptions.push(
370+
vscode.workspace.registerFileSearchProvider(SCHEME, search),
371+
vscode.workspace.registerTextSearchProvider(SCHEME, search),
372+
);
373+
}
374+
241375
const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50);
242376
item.text = '$(archive) Scratch';
243377
item.tooltip = 'Scratch workspace: files are saved in this browser only.';

‎web/workspace/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"engines": { "vscode": "^1.126.0" },
99
"categories": ["Other"],
1010
"browser": "./extension.js",
11+
"enabledApiProposals": ["fileSearchProvider", "textSearchProvider"],
1112
"activationEvents": ["onFileSystem:levelcode-scratch", "onStartupFinished"],
1213
"capabilities": {
1314
"virtualWorkspaces": true,

0 commit comments

Comments
 (0)