fix(agent-browser): restore bounded presentation lifecycle

This commit is contained in:
2026-09-06 12:50:18 +08:00
parent 5c61110f46
commit 9fed0cc7c4
20 changed files with 1714 additions and 49 deletions

View File

@@ -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');

View File

@@ -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) => {

View 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));
});
});

View File

@@ -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));

View File

@@ -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-'));

View File

@@ -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);

View File

@@ -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({