fix(agent-browser): restore bounded presentation lifecycle
This commit is contained in:
@@ -426,6 +426,21 @@ async function installCodingFirstChatHost(
|
||||
ok: true,
|
||||
data: { status, ok: status >= 200 && status < 300, json },
|
||||
});
|
||||
const browserSnapshot = (overrides: Record<string, unknown> = {}) => ({
|
||||
browserId: null,
|
||||
projectId: project.id,
|
||||
projectPath: null,
|
||||
state: 'closed',
|
||||
generation: 0,
|
||||
url: '',
|
||||
title: '',
|
||||
visible: false,
|
||||
bounds: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
eventCursor: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (
|
||||
@@ -528,6 +543,47 @@ async function installCodingFirstChatHost(
|
||||
: [],
|
||||
});
|
||||
}
|
||||
if (path.startsWith('/api/agent-browser/state?')) {
|
||||
return respond({ success: true, browser: browserSnapshot() });
|
||||
}
|
||||
if (path === '/api/agent-browser/open' && method === 'POST') {
|
||||
return respond({
|
||||
success: true,
|
||||
browser: browserSnapshot({
|
||||
browserId: 'browser-e2e',
|
||||
state: 'attached',
|
||||
generation: 1,
|
||||
url: body?.url,
|
||||
visible: true,
|
||||
bounds: body?.bounds,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (path === '/api/agent-browser/present' && method === 'POST') {
|
||||
return respond({
|
||||
success: true,
|
||||
browser: browserSnapshot({
|
||||
browserId: 'browser-e2e',
|
||||
state: 'attached',
|
||||
generation: 1,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
visible: body?.visible === true,
|
||||
bounds: body?.bounds ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (path === '/api/agent-browser/diagnostics' && method === 'POST') {
|
||||
return respond({ success: true, browser: browserSnapshot() });
|
||||
}
|
||||
if (path === '/api/agent-browser/cdp/events' && method === 'POST') {
|
||||
return respond({
|
||||
success: true,
|
||||
page: { events: [], nextCursor: 0, hasMore: false },
|
||||
});
|
||||
}
|
||||
if (path === '/api/agent-browser/close' && method === 'POST') {
|
||||
return respond({ success: true, browser: browserSnapshot() });
|
||||
}
|
||||
if (path === '/api/coding/projects/conversations' && method === 'POST') {
|
||||
state.conversationCreated = true;
|
||||
return respond({ conversation }, 201);
|
||||
@@ -1039,6 +1095,20 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
|
||||
await expect(conversationHeader.getByRole('button', { name: '归档' })).toHaveCount(0);
|
||||
await expect(conversationHeader.getByRole('button', { name: '智能体设置' })).toHaveCount(0);
|
||||
await expect(conversationHeader.getByRole('button', { name: '打开编程工具' })).toHaveCount(0);
|
||||
const browserToggle = conversationHeader.getByRole('button', { name: '打开开发浏览器' });
|
||||
await expect(browserToggle).toBeVisible();
|
||||
await browserToggle.click();
|
||||
await expect(page.getByTestId('agent-browser-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('agent-browser-diagnostics')).toHaveAttribute('data-state', 'closed');
|
||||
await expect.poll(async () => {
|
||||
const state = await readState(electronApp);
|
||||
return state.captured.some((request) => request.path.startsWith('/api/agent-browser/state?'));
|
||||
}).toBe(true);
|
||||
expect((await readState(electronApp)).captured.some(
|
||||
(request) => request.path === '/api/agent-browser/diagnostics',
|
||||
)).toBe(false);
|
||||
await page.getByRole('button', { name: '关闭开发浏览器' }).last().click();
|
||||
await expect(page.getByTestId('agent-browser-panel')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '项目设置' })).toHaveCount(1);
|
||||
await expect(page.getByTestId('coding-conversation-sidebar')).toBeVisible();
|
||||
const activeProcess = page.getByTestId('coding-process-group');
|
||||
|
||||
@@ -1294,6 +1294,76 @@ describe('AgentBrowserModule', () => {
|
||||
expect(adapter.views[0].visible).toBe(false);
|
||||
});
|
||||
|
||||
it('waits for the matching browser generation to be presented without polling', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
const opened = await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://localhost:3000',
|
||||
visible: false,
|
||||
});
|
||||
let settled = false;
|
||||
const presented = module.waitForPresentation({
|
||||
projectPath,
|
||||
generation: opened.generation,
|
||||
timeoutMs: 1_000,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
await module.present({
|
||||
projectPath,
|
||||
visible: true,
|
||||
bounds: { x: 1, y: 2, width: 640, height: 480 },
|
||||
});
|
||||
|
||||
await expect(presented).resolves.toMatchObject({
|
||||
state: 'attached',
|
||||
generation: opened.generation,
|
||||
visible: true,
|
||||
bounds: { x: 1, y: 2, width: 640, height: 480 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps diagnostics enabled until every explicit owner releases them', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
url: 'http://localhost:3000',
|
||||
diagnosticsOwner: 'agent:conversation-a:run-a',
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 800, height: 600 },
|
||||
});
|
||||
const commands = adapter.views[0].webContents.debugger.commands;
|
||||
expect(commands.map(({ method }) => method)).toEqual(expect.arrayContaining([
|
||||
'Runtime.enable',
|
||||
'Log.enable',
|
||||
'Network.enable',
|
||||
'Page.enable',
|
||||
]));
|
||||
|
||||
await module.setDiagnostics({ projectPath, enabled: true, owner: 'renderer' });
|
||||
await module.setDiagnostics({
|
||||
projectPath,
|
||||
enabled: false,
|
||||
owner: 'agent:conversation-a:run-a',
|
||||
});
|
||||
expect(commands.some(({ method }) => method === 'Runtime.disable')).toBe(false);
|
||||
|
||||
await module.setDiagnostics({ projectPath, enabled: false, owner: 'renderer' });
|
||||
expect(commands.map(({ method }) => method)).toEqual(expect.arrayContaining([
|
||||
'Page.disable',
|
||||
'Network.disable',
|
||||
'Log.disable',
|
||||
'Runtime.disable',
|
||||
]));
|
||||
});
|
||||
|
||||
it('tears down the native view when initial navigation fails', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.onCreate = (view) => {
|
||||
|
||||
167
tests/unit/agent-browser-panel.test.tsx
Normal file
167
tests/unit/agent-browser-panel.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AgentBrowserPanel } from '@/pages/Chat/AgentBrowserPanel';
|
||||
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
|
||||
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
const hostEventListeners = vi.hoisted(
|
||||
() => new Map<string, (payload: unknown) => void>(),
|
||||
);
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-events', () => ({
|
||||
subscribeHostEvent: (eventName: string, handler: (payload: unknown) => void) => {
|
||||
hostEventListeners.set(eventName, handler);
|
||||
return () => hostEventListeners.delete(eventName);
|
||||
},
|
||||
}));
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
|
||||
observe(element: Element) {
|
||||
this.callback([{ target: element } as ResizeObserverEntry], this as unknown as ResizeObserver);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<AgentBrowserSnapshot> = {}): AgentBrowserSnapshot {
|
||||
return {
|
||||
browserId: 'browser-a',
|
||||
projectId: 'project-a',
|
||||
projectPath: 'D:/repo',
|
||||
state: 'attached',
|
||||
generation: 1,
|
||||
url: 'http://127.0.0.1:4173/',
|
||||
title: 'App',
|
||||
visible: false,
|
||||
bounds: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
eventCursor: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function body(init?: RequestInit): Record<string, unknown> {
|
||||
return JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function Harness({ initialOpen = false }: { initialOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(initialOpen);
|
||||
return (
|
||||
<AgentBrowserPanel
|
||||
projectId="project-a"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentBrowserPanel', () => {
|
||||
beforeEach(() => {
|
||||
hostApiFetchMock.mockReset();
|
||||
hostEventListeners.clear();
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
value: MockResizeObserver,
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 600, y: 80, left: 600, top: 80, right: 1000, bottom: 680,
|
||||
width: 400, height: 600, toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path.startsWith('/api/agent-browser/state?')) {
|
||||
return { success: true, browser: snapshot() };
|
||||
}
|
||||
if (path === '/api/agent-browser/present') {
|
||||
return {
|
||||
success: true,
|
||||
browser: snapshot({
|
||||
visible: body(init).visible === true,
|
||||
bounds: body(init).bounds as AgentBrowserSnapshot['bounds'],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (path === '/api/agent-browser/diagnostics') {
|
||||
return { success: true, browser: snapshot({ visible: true }) };
|
||||
}
|
||||
if (path === '/api/agent-browser/close') {
|
||||
return {
|
||||
success: true,
|
||||
browser: snapshot({ browserId: null, state: 'closed', visible: false }),
|
||||
};
|
||||
}
|
||||
if (path === '/api/agent-browser/cdp/events') {
|
||||
return { success: true, page: { events: [], nextCursor: 0, hasMore: false } };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does no browser API work while closed and presents an agent-opened snapshot', async () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument();
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
hostEventListeners.get('agent-browser:show')?.(snapshot());
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/agent-browser/present',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
const presentation = hostApiFetchMock.mock.calls.find(
|
||||
([path, init]) => path === '/api/agent-browser/present'
|
||||
&& body(init as RequestInit).visible === true,
|
||||
);
|
||||
expect(body(presentation?.[1] as RequestInit)).toMatchObject({
|
||||
visible: true,
|
||||
bounds: { x: 600, y: 80, width: 400, height: 600 },
|
||||
});
|
||||
});
|
||||
|
||||
it('enables Renderer diagnostics only after the drawer is explicitly expanded', async () => {
|
||||
render(<Harness initialOpen />);
|
||||
await screen.findByTestId('agent-browser-panel');
|
||||
await waitFor(() => expect(hostApiFetchMock.mock.calls.some(
|
||||
([path]) => String(path).startsWith('/api/agent-browser/state?'),
|
||||
)).toBe(true));
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/diagnostics')).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开调试面板' }));
|
||||
await waitFor(() => {
|
||||
const request = hostApiFetchMock.mock.calls.find(([path]) => path === '/api/agent-browser/diagnostics');
|
||||
expect(body(request?.[1] as RequestInit)).toMatchObject({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes browser state once when the app regains focus', async () => {
|
||||
render(<Harness initialOpen />);
|
||||
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(
|
||||
([path]) => String(path).startsWith('/api/agent-browser/state?'),
|
||||
)).toHaveLength(1));
|
||||
|
||||
hostApiFetchMock.mockClear();
|
||||
act(() => window.dispatchEvent(new Event('focus')));
|
||||
|
||||
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(
|
||||
([path]) => String(path).startsWith('/api/agent-browser/state?'),
|
||||
)).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,51 @@ describe('Agent Browser Host API routes', () => {
|
||||
.toHaveBeenCalledWith('agent-browser:show', expect.objectContaining({ browserId: 'browser-1' }));
|
||||
});
|
||||
|
||||
it('lets the trusted Renderer address the active project by id without exposing its path', async () => {
|
||||
const projectPath = await temporaryProject('niancode-agent-browser-renderer-project-');
|
||||
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
|
||||
const response = createResponse();
|
||||
|
||||
await handleAgentBrowserRoutes(
|
||||
createRequest('POST', {
|
||||
project_id: 'project-1',
|
||||
url: 'http://127.0.0.1:5173',
|
||||
bounds: { x: 10, y: 20, width: 640, height: 480 },
|
||||
}, {
|
||||
[RENDERER_CAPABILITY_HEADER]: getRendererCapability(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/agent-browser/open'),
|
||||
context(projectPath, { open }),
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(open).toHaveBeenCalledWith(expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
projectPath,
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects project-id addressing without the Renderer capability', async () => {
|
||||
const projectPath = await temporaryProject('niancode-agent-browser-untrusted-project-');
|
||||
const open = vi.fn();
|
||||
const response = createResponse();
|
||||
|
||||
await handleAgentBrowserRoutes(
|
||||
createRequest('POST', {
|
||||
project_id: 'project-1',
|
||||
url: 'http://127.0.0.1:5173',
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/agent-browser/open'),
|
||||
context(projectPath, { open }),
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(403);
|
||||
expect(response.json()).toMatchObject({ success: false, code: 'TARGET_DENIED' });
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards only an explicit data-injection opt-in to the browser service', async () => {
|
||||
const projectPath = await temporaryProject('niancode-agent-browser-preview-route-');
|
||||
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
|
||||
|
||||
@@ -17,6 +17,36 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('coding composition background sleep', () => {
|
||||
it('closes the shared browser before an idle background runtime sleeps', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-sleep-browser-project-'));
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-sleep-browser-user-'));
|
||||
roots.push(projectPath, userDataDir);
|
||||
const close = vi.fn(async () => undefined);
|
||||
const composition = createCodingComposition({
|
||||
storage: createMemoryCodingProjectStorage(),
|
||||
browser: { close } as unknown as AgentBrowserModule,
|
||||
paths: {
|
||||
executablePath: process.execPath,
|
||||
cliPath: path.join(projectPath, 'unused-cli.js'),
|
||||
serverPath: path.join(projectPath, 'unused-server.mjs'),
|
||||
userDataDir,
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
},
|
||||
});
|
||||
const stopAgentServer = vi.spyOn(PiAgentServerProcess.prototype, 'stop')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
try {
|
||||
await composition.sleep('background_sleep');
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(stopAgentServer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
stopAgentServer.mockRestore();
|
||||
await composition.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not stop the shared Agent Server when work starts during worker cleanup', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-sleep-race-project-'));
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-sleep-race-user-'));
|
||||
|
||||
@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||
import { AgentBrowserFault } from '../../electron/agent-browser/fault';
|
||||
import type { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
|
||||
import {
|
||||
PiSubagentChildError,
|
||||
@@ -35,6 +37,42 @@ async function post(
|
||||
}
|
||||
|
||||
describe('managed Pi extension bridge', () => {
|
||||
it('preserves structured Agent Browser faults across the product bridge', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-browser-fault-'));
|
||||
roots.push(root);
|
||||
const host = new PiManagedExtensionHost();
|
||||
hosts.push(host);
|
||||
host.configureProductTools({
|
||||
beginRun: async () => undefined,
|
||||
execute: async () => {
|
||||
throw new AgentBrowserFault(
|
||||
'VIEWPORT_NOT_READY',
|
||||
'开发浏览器显示区域没有及时准备好。',
|
||||
true,
|
||||
7,
|
||||
);
|
||||
},
|
||||
} as unknown as PiProductTools);
|
||||
const registration = await host.registerWorker({
|
||||
conversationId: 'conversation-a', generation: 1, projectId: 'project-a',
|
||||
projectPath: root, extensionsDir: root,
|
||||
});
|
||||
await host.bindRun('conversation-a', 1, 'run-a');
|
||||
|
||||
const response = await post(registration, {
|
||||
action: 'product.invoke', conversationId: 'conversation-a', workerGeneration: 1,
|
||||
runId: 'run-a', resourceId: 'browser-a', toolName: 'agent_browser', input: { action: 'status' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
code: 'VIEWPORT_NOT_READY',
|
||||
error: 'VIEWPORT_NOT_READY: 开发浏览器显示区域没有及时准备好。',
|
||||
retryable: true,
|
||||
generation: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('single-flights the shared managed extension for concurrent worker registration', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-concurrent-'));
|
||||
roots.push(root);
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import { AgentBrowserFault } from '../../electron/agent-browser/fault';
|
||||
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
||||
import {
|
||||
ConversationChangeTracker,
|
||||
@@ -395,6 +396,99 @@ describe('PI-090 product tools', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('presents an agent-opened browser before returning and releases diagnostics with the run', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-browser-present-');
|
||||
const hidden = {
|
||||
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
|
||||
state: 'attached' as const, generation: 3, url: 'http://127.0.0.1:4173/', title: 'App',
|
||||
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
|
||||
};
|
||||
const visible = {
|
||||
...hidden,
|
||||
visible: true,
|
||||
bounds: { x: 10, y: 20, width: 800, height: 600 },
|
||||
};
|
||||
const order: string[] = [];
|
||||
const browser = {
|
||||
open: vi.fn(async () => {
|
||||
order.push('open');
|
||||
return hidden;
|
||||
}),
|
||||
waitForPresentation: vi.fn(async () => {
|
||||
order.push('wait');
|
||||
return visible;
|
||||
}),
|
||||
setDiagnostics: vi.fn(async () => visible),
|
||||
} as unknown as AgentBrowserModule;
|
||||
const requestAgentBrowserPresentation = vi.fn(() => {
|
||||
order.push('show');
|
||||
});
|
||||
const tools = new PiProductTools({
|
||||
browser,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
requestAgentBrowserPresentation,
|
||||
});
|
||||
const result = await tools.execute('agent_browser', {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
||||
projectId: 'project-a', projectPath: root, skillIds: [],
|
||||
}, { action: 'open', url: 'http://127.0.0.1:4173/' });
|
||||
|
||||
expect(order).toEqual(['open', 'show', 'wait']);
|
||||
expect(browser.open).toHaveBeenCalledWith(expect.objectContaining({
|
||||
diagnosticsOwner: 'agent:conversation-a:run-a',
|
||||
}));
|
||||
expect(browser.waitForPresentation).toHaveBeenCalledWith({
|
||||
projectPath: root,
|
||||
generation: 3,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect(JSON.parse(result.content[0].text)).toMatchObject({ visible: true });
|
||||
|
||||
await tools.settleRun('conversation-a', 'run-a');
|
||||
expect(browser.setDiagnostics).toHaveBeenCalledWith({
|
||||
projectPath: root,
|
||||
enabled: false,
|
||||
owner: 'agent:conversation-a:run-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('closes an agent-opened browser when presentation times out', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-browser-timeout-');
|
||||
const hidden = {
|
||||
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
|
||||
state: 'attached' as const, generation: 4, url: 'http://127.0.0.1:4173/', title: 'App',
|
||||
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
|
||||
};
|
||||
const close = vi.fn(async () => ({ ...hidden, state: 'closed' as const }));
|
||||
const browser = {
|
||||
open: vi.fn(async () => hidden),
|
||||
waitForPresentation: vi.fn(async () => {
|
||||
throw new AgentBrowserFault(
|
||||
'VIEWPORT_NOT_READY',
|
||||
'开发浏览器显示区域没有及时准备好。',
|
||||
true,
|
||||
hidden.generation,
|
||||
);
|
||||
}),
|
||||
close,
|
||||
} as unknown as AgentBrowserModule;
|
||||
const tools = new PiProductTools({
|
||||
browser,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
requestAgentBrowserPresentation: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(tools.execute('agent_browser', {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
||||
projectId: 'project-a', projectPath: root, skillIds: [],
|
||||
}, { action: 'open', url: 'http://127.0.0.1:4173/' })).rejects.toMatchObject({
|
||||
code: 'VIEWPORT_NOT_READY',
|
||||
});
|
||||
expect(close).toHaveBeenCalledWith(root);
|
||||
});
|
||||
|
||||
it('dispatches all Data Service tools only through the capability registry', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-tools-');
|
||||
const invoke = vi.fn().mockImplementation(({ toolName, context }) => Promise.resolve({
|
||||
|
||||
Reference in New Issue
Block a user