feat: implement PI-090 product tools

This commit is contained in:
2026-08-23 15:14:50 +08:00
parent 77cdee7e73
commit 13ab383e53
32 changed files with 2035 additions and 49 deletions

View File

@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, expect, it } from 'vitest';
import { loadGameAssetCandidates } from '@electron/opencode/game-asset-browser';
import { loadGameAssetCandidates } from '@electron/coding-projects/game-asset-browser';
const temporaryDirectories: string[] = [];

View File

@@ -7,7 +7,7 @@ import {
loadGameAssetReview,
recordGameAssetReviewActions,
recordGameAssetReviewAction,
} from '@electron/opencode/game-asset-review';
} from '@electron/coding-projects/game-asset-review';
const temporaryDirectories: string[] = [];

View File

@@ -104,6 +104,56 @@ describe('Pi event projector', () => {
}));
});
it('projects versioned product-tool details and suppresses unknown versions', async () => {
const projector = new PiEventProjector({ createId: () => 'unused' });
let snapshot = emptySnapshot();
snapshot.nodes.push({
kind: 'tool', id: 'tool-task-state', toolCallId: 'call-task-state', toolName: 'task_state',
title: 'task_state', inputText: '{}', status: 'running', output: [],
});
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'tool_execution_end',
toolCallId: 'call-task-state',
isError: false,
result: {
content: [{ type: 'text', text: 'done' }],
details: {
schema: 'task-state.v1',
tasks: [{ id: 'task-a', title: 'Implement product tools', status: 'complete' }],
},
},
}));
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool',
id: 'tool-task-state',
details: {
schema: 'task-state.v1',
tasks: [{ id: 'task-a', title: 'Implement product tools', status: 'complete' }],
},
}));
snapshot.nodes.push({
kind: 'tool', id: 'tool-browser', toolCallId: 'call-browser', toolName: 'agent_browser',
title: 'agent_browser', inputText: '{}', status: 'running', output: [],
});
snapshot = apply(snapshot, await projector.project(snapshot, {
type: 'tool_execution_end',
toolCallId: 'call-browser',
isError: false,
result: {
content: [{ type: 'text', text: 'RAW_BROWSER_OUTPUT' }],
details: { schema: 'agent-browser.v2', attachmentId: 'RAW_ATTACHMENT' },
},
}));
expect(JSON.stringify(snapshot)).not.toContain('RAW_BROWSER_OUTPUT');
expect(JSON.stringify(snapshot)).not.toContain('RAW_ATTACHMENT');
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool',
id: 'tool-browser',
output: [expect.objectContaining({ text: 'Tool details are unavailable for this version.' })],
}));
});
it('keeps one assistant UI identity while content-index deltas become an authoritative message', async () => {
const projector = new PiEventProjector({
createId: () => 'assistant-ui-a',

View File

@@ -1,6 +1,6 @@
// @vitest-environment node
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
@@ -8,6 +8,9 @@ import { afterEach, describe, expect, it } from 'vitest';
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
import { PiSubagentScheduler } from '../../electron/coding-runtime/pi/subagent';
import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool';
import type { AgentBrowserModule } from '../../electron/agent-browser';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
type ExtensionHandler = (...arguments_: unknown[]) => Promise<unknown> | unknown;
type ExtensionTool = {
@@ -24,6 +27,98 @@ afterEach(async () => {
});
describe('Makelore Pi extension bundle', () => {
it('executes versioned product tools through the authenticated real bundle', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-bundle-'));
roots.push(root);
await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8');
const browser = {
async getSnapshot() {
return {
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
state: 'attached', generation: 1, url: 'http://127.0.0.1:5173/', title: 'App',
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
};
},
} as unknown as AgentBrowserModule;
const host = new PiManagedExtensionHost();
const productTools = new PiProductTools({
browser,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
host.configureProductTools(productTools);
hosts.push(host);
const worker = await host.registerWorker({
conversationId: 'conversation-tools', generation: 1, projectId: 'project-a',
projectPath: root, skillIds: ['agent-browser'], extensionsDir: root,
});
await host.bindRun('conversation-tools', 1, 'run-tools');
const previous = {
bridge: process.env.MAKELORE_PI_BRIDGE_URL,
token: process.env.MAKELORE_PI_WORKER_TOKEN,
context: process.env.MAKELORE_PI_CONTEXT_FILE,
role: process.env.MAKELORE_PI_WORKER_ROLE,
};
Object.assign(process.env, worker.env);
try {
const module = await import(
/* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?tools=${Date.now()}`
) as {
default(factory: {
registerTool(tool: ExtensionTool): void;
on(event: string, handler: ExtensionHandler): void;
}): void;
};
const tools = new Map<string, ExtensionTool>();
const handlers = new Map<string, ExtensionHandler>();
module.default({
registerTool: (tool) => tools.set(tool.name, tool),
on: (event, handler) => handlers.set(event, handler),
});
await expect(tools.get('task_state')?.execute?.(
'task-state-a',
{ tasks: [{ id: 'one', title: 'Inspect', status: 'complete' }] },
new AbortController().signal,
)).resolves.toMatchObject({ details: { schema: 'task-state.v1' } });
await expect(tools.get('changed_file')?.execute?.(
'changed-a', { paths: ['notes.txt'] }, new AbortController().signal,
)).resolves.toMatchObject({ details: { schema: 'changed-file.v1', paths: ['notes.txt'] } });
await expect(tools.get('runtime_context')?.execute?.(
'context-a', {}, new AbortController().signal,
)).resolves.toMatchObject({
details: {
schema: 'runtime-context.v1',
skills: expect.arrayContaining([expect.objectContaining({ id: 'agent-browser', selected: true })]),
},
});
const browserResult = await tools.get('agent_browser')?.execute?.(
'browser-a', { action: 'status' }, new AbortController().signal,
);
expect(browserResult).toMatchObject({ details: { schema: 'agent-browser.v1', action: 'status' } });
expect(JSON.stringify(browserResult)).not.toContain(root);
await writeFile(path.join(root, 'notes.txt'), 'changed by write tool\n', 'utf8');
await handlers.get('tool_call')?.({
toolName: 'write', toolCallId: 'write-a', input: { path: path.join(root, 'notes.txt') },
}, {
signal: new AbortController().signal,
ui: { setStatus: () => undefined },
});
await handlers.get('tool_result')?.({ toolName: 'write', toolCallId: 'write-a' });
expect(productTools.getChanges('conversation-tools')?.files).toEqual([
expect.objectContaining({ path: 'notes.txt' }),
]);
} finally {
for (const [key, value] of Object.entries(previous)) {
const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL'
: key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN'
: key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE'
: 'MAKELORE_PI_WORKER_ROLE';
if (value === undefined) delete process.env[environmentKey];
else process.env[environmentKey] = value;
}
}
});
it('loads the real bundle and releases its project lease on tool_result', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-bundle-'));
roots.push(root);
@@ -69,7 +164,10 @@ describe('Makelore Pi extension bundle', () => {
registerTool: (tool) => tools.set(tool.name, tool),
on: (event, handler) => handlers.set(event, handler),
});
expect([...tools.keys()]).toEqual(['ask_user', 'subagent']);
expect([...tools.keys()]).toEqual([
'ask_user', 'subagent', 'agent_browser', 'game_asset_browser',
'game_asset_review', 'task_state', 'changed_file', 'runtime_context',
]);
const updates: unknown[] = [];
const subagentResult = await tools.get('subagent')?.execute?.(

View File

@@ -161,7 +161,7 @@ describe('managed Pi worker opener', () => {
expect(argv).toContain('grilling');
expect(argv).toContain('--session-id');
expect(argv).toContain('--extension');
expect(argv).toContain('makelore-runtime-v2.mjs');
expect(argv).toContain('makelore-runtime-v3.mjs');
expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(1);
expect(argv).not.toContain('PRIVATE MANAGED PROMPT');
expect(argv).not.toContain('provider-secret-value');

View File

@@ -0,0 +1,212 @@
// @vitest-environment node
import { execFile } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import type { AgentBrowserModule } from '../../electron/agent-browser';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import { ConversationChangeTracker } from '../../electron/coding-projects/conversation-change-tracker';
import {
buildProductCodingCommandCatalog,
listProductCodingSkills,
} from '../../electron/coding-projects/skill-registry';
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol';
const exec = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
async function git(root: string, ...args: string[]): Promise<void> {
await exec('git', ['-C', root, ...args], { windowsHide: true });
}
async function initializeRepository(root: string): Promise<void> {
await git(root, 'init');
await git(root, 'config', 'user.email', 'pi-tools@example.invalid');
await git(root, 'config', 'user.name', 'PI Tools');
await writeFile(path.join(root, 'existing.txt'), 'baseline\n', 'utf8');
await mkdir(path.join(root, 'src'), { recursive: true });
await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 1;\n', 'utf8');
await git(root, 'add', '.');
await git(root, 'commit', '-m', 'baseline');
}
describe('PI-090 product tools', () => {
it('tracks touched paths precisely and performs a project refresh after bash', async () => {
const root = await temporaryRoot('makelore-pi-changes-');
await initializeRepository(root);
await writeFile(path.join(root, 'existing.txt'), 'pre-existing dirty\n', 'utf8');
await writeFile(path.join(root, 'pre-existing-untracked.txt'), 'remove during run\n', 'utf8');
const tracker = new ConversationChangeTracker();
const started = await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
});
expect(started.git).toBe(true);
expect(started.baselineHead).toMatch(/^[a-f0-9]{40}$/);
await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 2;\n', 'utf8');
const precise = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['src/app.ts']);
expect(precise.files.map((file) => file.path)).toEqual(['src/app.ts']);
expect(precise.files[0]).toMatchObject({ status: 'modified' });
expect(precise.files[0]?.diff).toContain('value = 2');
await writeFile(path.join(root, 'bash-created.txt'), 'created by command\n', 'utf8');
await rm(path.join(root, 'pre-existing-untracked.txt'));
await tracker.markProjectRefresh('conversation-a', 'run-a');
const settled = await tracker.settleRun('conversation-a', 'run-a');
expect(settled?.files.map((file) => file.path)).toEqual([
'bash-created.txt', 'pre-existing-untracked.txt', 'src/app.ts',
]);
expect(settled?.files.find((file) => file.path === 'bash-created.txt')).toMatchObject({
status: 'untracked', preview: 'created by command\n',
});
expect(settled?.files.find((file) => file.path === 'pre-existing-untracked.txt')).toMatchObject({
status: 'deleted',
});
expect(JSON.stringify(settled)).not.toContain(root);
expect(settled?.files.some((file) => file.path === 'existing.txt')).toBe(false);
});
it('bounds untracked previews without hiding append-only changes beyond the preview', async () => {
const root = await temporaryRoot('makelore-pi-bounded-preview-');
await initializeRepository(root);
const target = path.join(root, 'large.txt');
await writeFile(target, 'a'.repeat(9 * 1024), 'utf8');
const tracker = new ConversationChangeTracker();
await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
});
await writeFile(target, `${'a'.repeat(9 * 1024)}tail`, 'utf8');
await tracker.markProjectRefresh('conversation-a', 'run-a');
const snapshot = await tracker.settleRun('conversation-a', 'run-a');
const file = snapshot?.files.find(({ path: filePath }) => filePath === 'large.txt');
expect(file).toMatchObject({ status: 'untracked', truncated: true });
expect(Buffer.byteLength(file?.preview ?? '', 'utf8')).toBeLessThanOrEqual(8 * 1024);
expect(JSON.stringify(snapshot)).not.toContain(root);
});
it('supports no-git projects and rejects paths outside the project', async () => {
const root = await temporaryRoot('makelore-pi-no-git-');
await writeFile(path.join(root, 'notes.txt'), 'local notes\n', 'utf8');
const tracker = new ConversationChangeTracker();
expect((await tracker.beginRun({
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
})).git).toBe(false);
const snapshot = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['notes.txt']);
expect(snapshot.files).toEqual([expect.objectContaining({
path: 'notes.txt', status: 'modified', preview: 'local notes\n',
})]);
await expect(tracker.recordTouchedPaths(
'conversation-a', 'run-a', ['../secret.txt'],
)).rejects.toThrow('escapes the project');
await expect(tracker.recordTouchedPaths(
'conversation-a', 'run-a', [path.resolve(root, 'notes.txt')],
)).rejects.toThrow('project-relative');
});
it('projects only bundled selected skills and safe command metadata', async () => {
const skills = await listProductCodingSkills(
path.resolve('resources/coding-skills'),
['agent-browser', 'grilling'],
);
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
'agent-browser', 'grilling',
]);
expect(JSON.stringify(skills)).not.toContain(path.resolve('resources/coding-skills'));
const commands = buildProductCodingCommandCatalog(skills, [
{ name: 'custom', description: 'Custom Pi command' },
{ name: 'compact', description: 'Must not shadow Makelore' },
]);
expect(commands).toContainEqual(expect.objectContaining({ name: 'compact', source: 'makelore' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
expect(commands.some(({ name }) => name === 'planning-with-files')).toBe(false);
await expect(listProductCodingSkills(
path.resolve('resources/coding-skills'), ['not-installed'],
)).rejects.toThrow('Unknown bundled coding skill');
});
it('accepts only safe versioned product detail projections', () => {
expect(productToolDetails({
schema: 'changed-file.v1', paths: ['src/app.ts', '.niancode/project.json'],
})).toEqual({
schema: 'changed-file.v1', paths: ['src/app.ts', '.niancode/project.json'],
});
expect(productToolDetails({
schema: 'changed-file.v1', paths: ['C:\\private\\secret.txt'],
})).toBeNull();
expect(productToolDetails({
schema: 'agent-browser.v1', action: 'send_cdp', attachmentId: 'attachment-a',
})).toBeNull();
expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull();
});
it('stores browser screenshots as attachment ids and never returns base64', async () => {
const root = await temporaryRoot('makelore-pi-browser-tool-');
const attachments = new CodingAttachmentStore(path.join(root, 'attachments'), {
createId: () => 'attachment-a',
});
const calls: unknown[] = [];
const browser = {
async sendCdp(input: unknown) {
calls.push(input);
return { kind: 'inline', value: { data: Buffer.from('png-data').toString('base64') } };
},
} as unknown as AgentBrowserModule;
const tools = new PiProductTools({
browser,
attachments,
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
const result = await tools.execute('agent_browser', {
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
projectId: 'project-a', projectPath: root, skillIds: ['agent-browser'],
}, { action: 'send_cdp', method: 'Page.captureScreenshot', params: { format: 'png' } });
expect(calls).toHaveLength(1);
expect(result).toMatchObject({
details: {
schema: 'agent-browser.v1', action: 'send_cdp',
attachmentId: 'attachment-a', mime: 'image/png',
},
});
expect(JSON.stringify(result)).not.toContain(Buffer.from('png-data').toString('base64'));
expect((await attachments.read('attachment-a')).data.toString()).toBe('png-data');
});
it('loads game asset review state through the vendor-neutral product module', async () => {
const root = await temporaryRoot('makelore-pi-game-tool-');
await writeFile(path.join(root, 'ASSET_PLAN.md'), [
'```json',
JSON.stringify({ assets: [{ id: 'hero', name: 'Hero', category: 'visual', status: 'candidate' }] }),
'```',
].join('\n'), 'utf8');
const tools = new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
const result = await tools.execute('game_asset_browser', {
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'review-a',
projectId: 'project-a', projectPath: root, skillIds: [],
}, {});
expect(result.details).toEqual({
schema: 'game-assets.v1', invocationId: 'review-a', candidateIds: ['hero'],
status: 'pending', pendingAssetIds: ['hero'], approvedAssetIds: [], discardedAssetIds: [],
});
expect(JSON.stringify(result)).not.toContain(root);
expect(JSON.stringify(result)).not.toContain('data:');
});
});

View File

@@ -167,7 +167,7 @@ describe('Pi worker process', () => {
'--no-context-files',
'--no-approve',
'--tools',
'read,bash,edit,write,grep,find,ls,ask_user,subagent',
'read,bash,edit,write,grep,find,ls,ask_user,subagent,agent_browser,game_asset_browser,game_asset_review,task_state,changed_file,runtime_context',
'--model', 'model-a',
]);
expect(buildPiRpcArgs('sessions', ['--no-session'], ['read', 'grep', 'find', 'ls']))

View File

@@ -84,6 +84,54 @@ describe('Pi session projector', () => {
}));
});
it('hydrates product-tool details and never restores unknown raw details', async () => {
const snapshot = await projectPiSessionSnapshot({
snapshot: baseSnapshot(),
workerGeneration: 1,
state: { sessionId: 'session-a', isStreaming: false, isCompacting: false },
entries: {
leafId: 'entry-unknown-result',
entries: [
{
type: 'message', id: 'entry-assistant', parentId: null,
message: {
role: 'assistant', stopReason: 'toolUse', usage: { input: 1, output: 1 },
content: [
{ type: 'toolCall', id: 'call-known', name: 'changed_file', arguments: {} },
{ type: 'toolCall', id: 'call-unknown', name: 'runtime_context', arguments: {} },
],
},
},
{
type: 'message', id: 'entry-known-result', parentId: 'entry-assistant',
message: {
role: 'toolResult', toolCallId: 'call-known', toolName: 'changed_file', content: [],
details: { schema: 'changed-file.v1', paths: ['src/example.ts'] },
},
},
{
type: 'message', id: 'entry-unknown-result', parentId: 'entry-known-result',
message: {
role: 'toolResult', toolCallId: 'call-unknown', toolName: 'runtime_context', content: [],
details: { schema: 'runtime-context.v9', raw: 'RAW_CONTEXT_SECRET' },
},
},
],
},
});
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool',
toolCallId: 'call-known',
details: { schema: 'changed-file.v1', paths: ['src/example.ts'] },
}));
expect(JSON.stringify(snapshot)).not.toContain('RAW_CONTEXT_SECRET');
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'tool',
toolCallId: 'call-unknown',
output: [expect.objectContaining({ text: 'Tool details are unavailable for this version.' })],
}));
});
it('hydrates only the authoritative active leaf path', async () => {
const snapshot = await projectPiSessionSnapshot({
snapshot: baseSnapshot(),

View File

@@ -105,7 +105,16 @@ describe('locked Pi worker process smoke', () => {
success: true,
});
expect(worker.stderrDiagnostic).not.toContain('Failed to load extension');
expect(await readActiveTools(probe.resultPath)).toContain('subagent');
expect(await readActiveTools(probe.resultPath)).toEqual(expect.arrayContaining([
'ask_user',
'subagent',
'agent_browser',
'game_asset_browser',
'game_asset_review',
'task_state',
'changed_file',
'runtime_context',
]));
await expect(worker.stop()).resolves.toMatchObject({ mode: 'stdin-close', code: 0 });
} finally {
await worker.stop().catch(() => undefined);
@@ -233,4 +242,64 @@ describe('locked Pi worker process smoke', () => {
},
15_000,
);
it.skipIf(!packagedRuntimeRoot)(
'loads product tools in the staged production-closure parent runtime',
async () => {
const requireFromProject = createRequire(resolve('package.json'));
const electronExecutable = requireFromProject('electron') as string;
const packageRoot = realpathSync(packagedRuntimeRoot as string);
const root = await mkdtemp(join(tmpdir(), 'makelore-pi-staged-product-tools-'));
scratchRoots.push(root);
const configDir = join(root, 'config');
const sessionDir = join(root, 'sessions');
const cwd = join(root, 'project');
await Promise.all([mkdir(configDir), mkdir(sessionDir), mkdir(cwd)]);
const extensionHost = new PiManagedExtensionHost();
const extension = await extensionHost.registerWorker({
conversationId: 'staged-product-tools-parent',
generation: 1,
projectId: 'staged-project',
extensionsDir: join(root, 'extensions'),
projectPath: cwd,
});
await extensionHost.bindRun('staged-product-tools-parent', 1, 'staged-run');
const probe = await materializeActiveToolsProbe(root);
const worker = new PiWorkerProcess({
executablePath: electronExecutable,
cliPath: join(packageRoot, 'dist', 'cli.js'),
cwd,
configDir,
sessionDir,
additionalArgs: [
'--extension', extension.extensionPath,
'--extension', probe.extensionPath,
],
env: { ...extension.env, MAKELORE_PI_ACTIVE_TOOLS_FILE: probe.resultPath },
sensitiveValues: extension.sensitiveValues,
commandTimeoutMs: 5_000,
});
try {
await worker.start();
await expect(worker.request({ type: 'get_state' })).resolves.toMatchObject({
type: 'response', command: 'get_state', success: true,
});
expect(await readActiveTools(probe.resultPath)).toEqual(expect.arrayContaining([
'agent_browser',
'game_asset_browser',
'game_asset_review',
'task_state',
'changed_file',
'runtime_context',
]));
expect(worker.stderrDiagnostic).not.toContain('Failed to load extension');
await expect(worker.stop()).resolves.toMatchObject({ mode: 'stdin-close', code: 0 });
} finally {
await worker.stop().catch(() => undefined);
await extension.dispose();
await extensionHost.close();
}
},
15_000,
);
});