fix: allow closing browser tabs during active requests

Allow users to close leased browser tabs without cancelling the AI request or revoking its other tools. Closed-page operations report a recoverable error; explicit navigation creates a dedicated replacement tab for direct-model and MCP calls.

Add regression coverage and real Electron lifecycle checks, and update browser behavior documentation.

Release note: 浏览器标签页被 AI 请求占用时也可关闭,其他任务继续运行;再次导航会打开独立新页。
This commit is contained in:
mesalogo
2026-09-13 11:21:37 +08:00
parent 032ebc313f
commit b84250f62a
14 changed files with 534 additions and 72 deletions
@@ -326,6 +326,7 @@ describe('KnowledgeMcpGateway', () => {
})
)
const browserService = {
createTab: vi.fn(),
getOrigin: vi.fn(() => undefined),
navigate: vi.fn(async () => ({
url: 'https://example.com/',
@@ -464,6 +465,89 @@ describe('KnowledgeMcpGateway', () => {
}
})
it('keeps shared tools and the MCP session alive after tab closure and navigates a dedicated replacement', async () => {
const { service } = createService()
const browserService = new BrowserService()
const controller = new AbortController()
const lease = browserService.reserveRequestTab('conversation', 'request', 21)
const gateway = new KnowledgeMcpGateway(service, { browserService })
gateways.push(gateway)
const token = gateway.grant('request', [firstLibraryId], controller.signal, 'none', undefined, 'conversation', lease.tabId, lease)!
const navigate = vi.spyOn(browserService, 'navigate').mockImplementation(
async (_conversationId, _url, signal) => new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
)
const snapshot = vi.spyOn(browserService, 'snapshot').mockResolvedValue({} as never)
const replacementController = new AbortController()
const replacementTabId = browserTabIdSchema.parse(crypto.randomUUID())
const replacementLease = {
conversationId: 'conversation', tabId: replacementTabId, owner: 'request',
signal: replacementController.signal, release: vi.fn()
}
const createTab = vi.spyOn(browserService, 'createTab').mockResolvedValue({ tabId: replacementTabId } as never)
const acquire = vi.spyOn(browserService, 'acquireTabUsage').mockReturnValue(replacementLease)
await gateway.start()
const client = new Client({ name: 'closed-tab-test', version: '1.0.0' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
requestInit: { headers: { Authorization: `Bearer ${token}` } }
}))
const pending = client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } })
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce())
lease.release()
await expect(pending).resolves.toMatchObject({
isError: true, content: [{ type: 'text', text: expect.stringContaining('标签页已关闭') }]
})
expect(navigate.mock.calls[0]![2].aborted).toBe(true)
expect(controller.signal.aborted).toBe(false)
await expect(client.callTool({ name: 'knowledge_search', arguments: { query: 'evidence' } }))
.resolves.toMatchObject({ content: [{ type: 'text', text: expect.stringContaining(firstLibraryId) }] })
expect((await client.listTools()).tools.map(tool => tool.name)).toContain('browser_navigate')
for (const name of browserToolNames.filter(name => name !== 'browser_navigate')) {
await expect(client.callTool({ name, arguments: {} })).resolves.toMatchObject({
isError: true, content: [{ type: 'text', text: expect.stringContaining('标签页已关闭') }]
})
}
expect(snapshot).not.toHaveBeenCalled()
expect(createTab).not.toHaveBeenCalled()
await expect(client.callTool({ name: 'browser_navigate', arguments: {} })).resolves.toMatchObject({ isError: true })
expect(createTab).not.toHaveBeenCalled()
navigate.mockResolvedValue({ url: 'https://example.com/', origin: 'https://example.com' })
await client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } })
expect(createTab).toHaveBeenCalledWith('conversation', undefined, expect.any(AbortSignal),
`browser-recovery:request:${lease.tabId}`)
expect(acquire).toHaveBeenCalledExactlyOnceWith('conversation', replacementTabId, 'request')
expect(navigate).toHaveBeenLastCalledWith('conversation', 'https://example.com/', expect.any(AbortSignal), replacementTabId)
await client.callTool({ name: 'browser_snapshot', arguments: {} })
expect(snapshot).toHaveBeenCalledWith('conversation', expect.any(AbortSignal), replacementTabId)
expect(createTab).toHaveBeenCalledOnce()
snapshot.mockImplementationOnce(async (_conversationId, signal) => new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
}))
const callController = new AbortController()
const canceled = client.callTool({ name: 'browser_snapshot', arguments: {} }, undefined, {
signal: callController.signal
})
const cancellation = expect(canceled).rejects.toThrow('cancel this call')
await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(2))
callController.abort(new Error('cancel this call'))
await cancellation
await vi.waitFor(() => expect(snapshot.mock.calls[1]![1].aborted).toBe(true))
expect(replacementController.signal.aborted).toBe(false)
expect(controller.signal.aborted).toBe(false)
await expect(client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } }))
.resolves.not.toHaveProperty('isError', true)
expect(createTab).toHaveBeenCalledOnce()
gateway.revoke(token)
expect(replacementLease.release).toHaveBeenCalledOnce()
} finally {
await client.close()
await gateway.dispose()
await browserService.dispose()
}
})
it('exposes GoodBuddy config reads in Ask and apply only in Execute', async () => {
const { service } = createService()
const configService = {