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
@@ -245,4 +245,48 @@ describe('BrowserModelTools', () => {
tools.callTool('browser_snapshot', {}, controller.signal)
).rejects.toHaveProperty('name', 'AbortError')
})
it('validates explicit recovery navigation before creating a dedicated replacement', async () => {
const service = createService()
service.acquireTabUsage = vi.fn(() => { throw new Error('closed') })
service.createTab = vi.fn()
const tools = new BrowserModelTools({
service, conversationId: 'conversation', browserTabId, recoverClosedTab: true
})
for (const args of [{}, { url: 'file:///private' }, { url: 'https://example.com', extra: true }]) {
await expect(tools.callTool('browser_navigate', args, signal)).rejects.toThrow()
}
await expect(tools.callTool('browser_snapshot', {}, signal)).rejects.toHaveProperty('name', 'BrowserTabClosedError')
await expect(tools.callTool('browser_navigate', { url: 'https://example.com' }, AbortSignal.abort()))
.rejects.toHaveProperty('name', 'AbortError')
expect(service.createTab).not.toHaveBeenCalled()
expect(service.navigate).not.toHaveBeenCalled()
})
it('preserves request cancellation and releases the per-call lease', async () => {
const service = createService()
const leaseController = new AbortController()
const release = vi.fn()
service.acquireTabUsage = vi.fn(() => ({
conversationId: 'conversation', tabId: browserTabId, owner: 'request',
signal: leaseController.signal, release
}))
service.createTab = vi.fn()
vi.mocked(service.snapshot).mockImplementation(async (_conversationId, signal) =>
new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
)
const tools = new BrowserModelTools({
service, conversationId: 'conversation', browserTabId, recoverClosedTab: true
})
const controller = new AbortController()
const reason = new Error('cancel request')
const pending = tools.callTool('browser_snapshot', {}, controller.signal)
const cancelled = expect(pending).rejects.toBe(reason)
controller.abort(reason)
await cancelled
expect(release).toHaveBeenCalledOnce()
expect(service.createTab).not.toHaveBeenCalled()
})
})
+69 -10
View File
@@ -7,7 +7,7 @@ import type {
} from '../agent/model-tool-provider'
import type { RuntimeApprovalRequest } from '../agent/runtime'
import { canonicalizeBrowserUrl } from './browser-url-policy'
import type { BrowserService } from './browser-service'
import type { BrowserService, BrowserTabUsageLease } from './browser-service'
import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
@@ -191,12 +191,21 @@ export type BrowserToolService = Pick<
| 'back'
| 'screenshot'
| 'releaseConversation'
>
> & Partial<Pick<BrowserService, 'acquireTabUsage' | 'createTab'>>
export type BrowserModelToolsOptions = {
service: BrowserToolService
conversationId: string
browserTabId: BrowserTabId
// The scoped gateway manages its own binding and lease lifecycle.
recoverClosedTab?: boolean
}
export class BrowserTabClosedError extends Error {
constructor(options?: ErrorOptions) {
super('浏览器标签页已关闭,请调用 browser_navigate 打开新标签页。', options)
this.name = 'BrowserTabClosedError'
}
}
function createTextResult(value: unknown): ModelToolResult {
@@ -220,12 +229,15 @@ function navigationLabel(url: URL): string {
export class BrowserModelTools {
private readonly service: BrowserToolService
private readonly conversationId: string
private readonly browserTabId: BrowserTabId
private browserTabId: BrowserTabId
private readonly recoveryOwner = randomUUID()
private readonly recoverClosedTab: boolean
constructor(options: BrowserModelToolsOptions) {
this.service = options.service
this.conversationId = options.conversationId
this.browserTabId = options.browserTabId
this.recoverClosedTab = options.recoverClosedTab === true
if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效')
}
@@ -321,6 +333,53 @@ export class BrowserModelTools {
if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${name}`)
}
if (!this.recoverClosedTab || !this.service.acquireTabUsage || !this.service.createTab) {
return this.callBoundTool(name, argumentsValue, signal, this.browserTabId)
}
let lease: BrowserTabUsageLease
try {
lease = this.service.acquireTabUsage(
this.conversationId, this.browserTabId, this.recoveryOwner
)
} catch (error) {
if (name !== 'browser_navigate') {
throw new BrowserTabClosedError({ cause: error })
}
const input = browserNavigateInputSchema.parse(argumentsValue)
canonicalizeBrowserUrl(input.url)
const tab = await this.service.createTab(
this.conversationId, undefined, signal,
`browser-recovery:${this.recoveryOwner}:${this.browserTabId}`
)
signal.throwIfAborted()
lease = this.service.acquireTabUsage(
this.conversationId, tab.tabId, this.recoveryOwner
)
this.browserTabId = tab.tabId
}
const browserSignal = AbortSignal.any([signal, lease.signal])
try {
const result = await this.callBoundTool(name, argumentsValue, browserSignal, lease.tabId)
browserSignal.throwIfAborted()
return result
} catch (error) {
signal.throwIfAborted()
if (lease.signal.aborted) {
throw new BrowserTabClosedError({ cause: error })
}
throw error
} finally {
lease.release()
}
}
private async callBoundTool(
name: BrowserToolName,
argumentsValue: Record<string, unknown>,
signal: AbortSignal,
browserTabId: BrowserTabId
): Promise<ModelToolResult> {
signal.throwIfAborted()
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult(
@@ -328,7 +387,7 @@ export class BrowserModelTools {
this.conversationId,
input.url,
signal,
this.browserTabId
browserTabId
)
)
}
@@ -338,7 +397,7 @@ export class BrowserModelTools {
await this.service.snapshot(
this.conversationId,
signal,
this.browserTabId
browserTabId
)
)
}
@@ -348,7 +407,7 @@ export class BrowserModelTools {
this.conversationId,
input.ref,
signal,
this.browserTabId
browserTabId
)
return createTextResult({ clicked: input.ref })
}
@@ -359,7 +418,7 @@ export class BrowserModelTools {
input.ref,
input.text,
signal,
this.browserTabId
browserTabId
)
return createTextResult({
typed: input.ref,
@@ -374,7 +433,7 @@ export class BrowserModelTools {
input.ref,
input.value,
signal,
this.browserTabId
browserTabId
)
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
}
@@ -384,7 +443,7 @@ export class BrowserModelTools {
await this.service.back(
this.conversationId,
signal,
this.browserTabId
browserTabId
)
)
}
@@ -392,7 +451,7 @@ export class BrowserModelTools {
const screenshot = await this.service.screenshot(
this.conversationId,
signal,
this.browserTabId
browserTabId
)
return {
parts: [
+8 -8
View File
@@ -227,7 +227,8 @@ describe('BrowserService', () => {
expect(restored.tabId).toBe(first.tabId)
expect(harness.service.getTabCount()).toBe(1)
first.release()
await expect(harness.service.closeTab('conversation', first.tabId, 21)).rejects.toThrow('正在被活动请求使用')
await harness.service.closeTab('conversation', first.tabId, 21)
expect(second.signal.aborted).toBe(true)
second.release()
await harness.service.closeTab('conversation', first.tabId, 21)
await harness.service.closeTab('conversation', first.tabId, 21)
@@ -557,7 +558,7 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('blocks closing a leased tab until every request releases it', async () => {
it('closes a leased tab and ends its leases without closing sibling tabs', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 11, signal)
@@ -577,15 +578,14 @@ describe('BrowserService', () => {
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
await expect(
harness.service.closeTab('conversation', sibling.tabId, 11)
).resolves.toBeUndefined()
expect(first.signal.aborted).toBe(true)
expect(second.signal.aborted).toBe(true)
expect(harness.service.listTabs('conversation', 11).map((tab) => tab.tabId)).toEqual([sibling.tabId])
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
expect(harness.slots[1]?.session.dispose).not.toHaveBeenCalled()
first.release()
first.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
second.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
+5 -6
View File
@@ -31,9 +31,6 @@ export class BrowserNavigationStoppedError extends Error {
}
}
export const BROWSER_TAB_IN_USE_ERROR =
'浏览器标签页正在被活动请求使用,无法关闭'
export type BrowserTabUsageLease = {
readonly conversationId: string
readonly tabId: BrowserTabId
@@ -908,12 +905,14 @@ export class BrowserService {
[...this.creations.entries()].some(([id, slot]) => slot.tabId === tabId && id !== conversationId) ||
[...this.requestTabs.values()].some((slot) => slot.tabId === tabId && slot.conversationId !== conversationId)
) throw new Error('浏览器标签页不存在或不属于当前对话')
if (reservation?.tabId === tabId && reservation.usageLeases.size > 0) {
throw new Error(BROWSER_TAB_IN_USE_ERROR)
if (reservation?.tabId === tabId) {
this.releaseRequestTab(conversationId)
}
if (creation?.tabId === tabId) {
creation.controller.abort(new Error('浏览器会话已关闭'))
}
return
}
if (tab.usageLeases.size > 0) throw new Error(BROWSER_TAB_IN_USE_ERROR)
await this.releaseTab(conversation!, tab)
}