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
+51 -4
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
import { once } from 'node:events'
import { createServer } from 'node:http'
import { app, BrowserWindow, webContents } from 'electron'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
@@ -88,7 +89,15 @@ async function main(): Promise<void> {
assert.equal(restoredRequestTab.workbarInstanceId, reserved.tabId)
assert.equal(browser.getTabCount(), 1)
assert.match(JSON.stringify(await client.callTool({ name: 'browser_snapshot', arguments: {} })), /MCP target/u)
await assert.rejects(browser.closeTab(conversationId, reserved.tabId, window.webContents.id), /正在被.*请求使用/u)
await browser.closeTab(conversationId, reserved.tabId, window.webContents.id)
assert.equal(reserved.signal.aborted, true)
assert.equal(controller.signal.aborted, false)
assert.equal(browser.getSessionCount(), 0)
const closedReservationSnapshot = await client.callTool({ name: 'browser_snapshot', arguments: {} })
assert.equal(closedReservationSnapshot.isError, true)
assert.match(JSON.stringify(closedReservationSnapshot), /标签页已关闭.*browser_navigate/u)
assert((await client.listTools()).tools.some((tool) => tool.name === 'browser_navigate'))
assert.equal(browser.getSessionCount(), 0)
gateway.revoke(token)
token = undefined
await client.close()
@@ -97,7 +106,7 @@ async function main(): Promise<void> {
await browser.closeTab(conversationId, reserved.tabId, window.webContents.id)
assert.equal(browser.getSessionCount(), 0)
removeStateListener()
console.log('MCP navigation materialized the exact reserved tab/workbar identity and released its request lease')
console.log('MCP reservation materialized the exact tab/workbar identity; leased close preserved the MCP session without recreating a page')
await browser.navigate(conversationId, `${origin}/first`, controller.signal, undefined, window.webContents.id)
const [unownedPrimary] = browser.listTabs(conversationId, window.webContents.id)
@@ -180,16 +189,54 @@ async function main(): Promise<void> {
assert.equal(browser.getVisibleTabId(otherConversationId, window.webContents.id), other.tabId)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /shared-browser-tab=visible/u)
const closedContents = webContents.getAllWebContents().find((contents) => contents.getURL() === `${origin}/mcp`)
assert(closedContents)
const siblingBeforeClose = browser.listTabs(conversationId, window.webContents.id).find((tab) => tab.tabId === second.tabId)
assert(siblingBeforeClose)
const destroyed = once(closedContents, 'destroyed', { signal: AbortSignal.timeout(5_000) })
await browser.closeTab(conversationId, first.tabId, window.webContents.id)
await destroyed
assert.equal(closedContents.isDestroyed(), true)
assert.equal(usage.signal.aborted, true)
assert.equal(controller.signal.aborted, false)
assert.equal(browser.getTabCount(conversationId), 1)
assert(gateway.getAvailableToolNames(token).includes('browser_navigate'))
assert((await client.listTools()).tools.some((tool) => tool.name === 'browser_snapshot'))
const closedSnapshot = await client.callTool({ name: 'browser_snapshot', arguments: {} })
assert.equal(closedSnapshot.isError, true)
assert.match(JSON.stringify(closedSnapshot), /标签页已关闭.*browser_navigate/u)
await assert.rejects(
browser.closeTab(conversationId, first.tabId, window.webContents.id),
/正在被.*请求使用/u
browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id),
/当前浏览器标签页尚未导航/u
)
assert.equal(browser.getTabCount(conversationId), 1)
const replacementNavigation = await client.callTool({ name: 'browser_navigate', arguments: { url: `${origin}/mcp` } })
assert.notEqual(replacementNavigation.isError, true)
const replacement = browser.listTabs(conversationId, window.webContents.id).find((tab) => tab.tabId !== second.tabId)
assert(replacement)
assert.notEqual(replacement.tabId, first.tabId)
assert.equal(browser.getTabCount(conversationId), 2)
const replacementSnapshot = await client.callTool({ name: 'browser_snapshot', arguments: {} })
assert.notEqual(replacementSnapshot.isError, true)
assert.match(JSON.stringify(replacementSnapshot), /MCP target/u)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, replacement.tabId, window.webContents.id)), /MCP target/u)
// Closing the primary promotes its sibling without changing that sibling's page.
assert.deepEqual(browser.listTabs(conversationId, window.webContents.id).find((tab) => tab.tabId === second.tabId), {
...siblingBeforeClose,
primary: true
})
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /shared-browser-tab=visible/u)
assert.match(JSON.stringify(await browser.snapshot(otherConversationId, controller.signal, other.tabId, window.webContents.id)), /Second tab/u)
assert.equal(browser.getVisibleTabId(otherConversationId, window.webContents.id), other.tabId)
console.log('Leased Electron tab destroyed; same MCP capability reported the closed target and explicitly navigated a dedicated replacement, leaving siblings untouched')
await browser.closeTab(otherConversationId, other.tabId, window.webContents.id)
assert.equal(browser.getTabCount(otherConversationId), 0)
assert.equal(browser.getTabCount(conversationId), 2)
gateway.revoke(token)
token = undefined
await browser.closeTab(conversationId, first.tabId, window.webContents.id)
assert.equal(browser.getTabCount(conversationId), 2)
await browser.closeTab(conversationId, replacement.tabId, window.webContents.id)
assert.equal(browser.getTabCount(conversationId), 1)
await browser.closeTab(conversationId, second.tabId, window.webContents.id)
assert.equal(browser.getTabCount(conversationId), 0)