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
@@ -6,7 +6,7 @@
| --- | --- | | --- | --- |
| 状态 | 基础多实例已实施,Agent 多 Tab 工具待需求 | | 状态 | 基础多实例已实施,Agent 多 Tab 工具待需求 |
| 版本 | 1.0 | | 版本 | 1.0 |
| 日期 | 2026-09-10 | | 日期 | 2026-09-13 |
| 关联 PRD | [通用助手工作栏与执行空间 PRD](./prd.md) | | 关联 PRD | [通用助手工作栏与执行空间 PRD](./prd.md) |
| 页签策略 | [助手工作栏多终端页签 PRD](./terminal-tabs-prd.md) | | 页签策略 | [助手工作栏多终端页签 PRD](./terminal-tabs-prd.md) |
@@ -86,7 +86,7 @@ Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
默认或恢复实例可在首次使用时取得当前会话绑定。 默认或恢复实例可在首次使用时取得当前会话绑定。
Tab 的跨会话可见性与 MCP 授权分开处理。B 的请求不能选择属于 A 的可见 Tab;A 已取得的 Tab 的跨会话可见性与 MCP 授权分开处理。B 的请求不能选择属于 A 的可见 Tab;A 已取得的
capability 在用户切到 B 后仍固定路由至 A 的原 Tab,使用租约也继续阻止关闭该 Tab。 capability 在用户切到 B 后仍固定路由至 A 的原 Tab;使用租约不阻止用户关闭该 Tab。
内部点击实例只同步应用类型,不再次按当前 Conversation 改选实例。Main 状态携带对应的 内部点击实例只同步应用类型,不再次按当前 Conversation 改选实例。Main 状态携带对应的
`workbarInstanceId`,Renderer 据此绑定请求创建的同一 Tab,而不是另建空白页。收到 `stopped` `workbarInstanceId`,Renderer 据此绑定请求创建的同一 Tab,而不是另建空白页。收到 `stopped`
后清除该实例的旧 Tab 绑定,下一次使用通过原逻辑实例重新创建,保留地址草稿。 后清除该实例的旧 Tab 绑定,下一次使用通过原逻辑实例重新创建,保留地址草稿。
@@ -136,11 +136,16 @@ Conversation 归属,不新建租约或把页面重新归属到子级对话。
直连模型上下文和请求级 MCP capability 同时保存 `conversationId` 与 直连模型上下文和请求级 MCP capability 同时保存 `conversationId` 与
`boundBrowserTabId`。请求执行期间,用户切换、创建或关闭其他工作栏 Tab 不改变工具目标; `boundBrowserTabId`。请求执行期间,用户切换、创建或关闭其他工作栏 Tab 不改变工具目标;
模型请求使用某个 Tab 时禁用该 Tab 的工作栏关闭操作,并说明请求仍在执行。用户先取消请求 用户可直接关闭模型正在使用的 Tab。关闭释放该 Tab 的页面和使用租约,中止其浏览器操作,
后才能关闭;系统不得把进行中的操作改投其他 Tab。 不取消整个模型请求、不撤销 MCP capability,也不关闭同级 Tab。系统不得把进行中的操作改投其他 Tab。
关闭后,快照等非导航工具返回“浏览器标签页已关闭,请调用 browser_navigate 打开新标签页”,
不自动创建页面或使用现有 primary/sibling。只有显式且参数有效的 `browser_navigate` 才创建
专用替代 Tab 并绑定后续工具;旧 Tab 身份与元素引用不复用。Gateway 与直连 Provider 均隔离
单次工具取消信号、Tab 使用租约信号和请求信号,取消一次工具调用不终止后续调用。
该阶段 MCP transport、endpoint 和 token 生命周期不变,只扩展服务端 capability 内容和内部 该阶段 MCP transport、endpoint 和 token 生命周期不变,只扩展服务端 capability 内容和内部
路由。旧工具调用继续使用被冻结的 Tab。 路由。工具调用使用当前请求绑定的 Tab,只有上述显式导航恢复会更换绑定。
### 6.2 Agent 多 Tab 阶段 ### 6.2 Agent 多 Tab 阶段
@@ -189,6 +194,8 @@ primary, including one opened by an Agent, is not adopted by a new instance. New
- 一个 Tab 的元素 `ref` 不能在另一个 Tab 中执行。 - 一个 Tab 的元素 `ref` 不能在另一个 Tab 中执行。
- 用户切换工作栏 Tab 不改变进行中模型请求的 MCP 浏览器目标。 - 用户切换工作栏 Tab 不改变进行中模型请求的 MCP 浏览器目标。
- 关闭一个 Tab 不释放 Conversation Runtime,也不关闭同一 Conversation 的其他 Tab。 - 关闭一个 Tab 不释放 Conversation Runtime,也不关闭同一 Conversation 的其他 Tab。
- 关闭已租用 Tab 后,同一 MCP 会话仍可列出工具;旧目标快照报错且不创建页面,显式导航创建
不同身份的替代 Tab,同级页面、导航状态与其他 Conversation 的可见 viewport 保持不变。
- 旧版无 `tabId` 的模型工具调用仍操作请求绑定的默认 Tab。 - 旧版无 `tabId` 的模型工具调用仍操作请求绑定的默认 Tab。
## 10. 实施顺序 ## 10. 实施顺序
+4
View File
@@ -483,6 +483,10 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
改变绑定到同一 Tab 的 Agent 后续操作。所有请求都进入 `BrowserService`;Renderer 不建立 改变绑定到同一 Tab 的 Agent 后续操作。所有请求都进入 `BrowserService`;Renderer 不建立
第二套导航队列、抢占规则或恢复状态机。 第二套导航队列、抢占规则或恢复状态机。
用户可关闭 Agent 正在使用的标签页,无需先取消整轮请求。关闭只中止该页的浏览器操作,
其他标签页和请求的其他工具继续可用。后续快照等操作应提示标签页已关闭;Agent 显式导航
时打开专用新标签页并继续操作,不能自动接管用户的其他页面。
浏览器工具栏读取的权威状态至少包含: 浏览器工具栏读取的权威状态至少包含:
```ts ```ts
+28 -3
View File
@@ -1,5 +1,30 @@
# 工作栏实现与验证进度 # 工作栏实现与验证进度
## 2026-09-13 浏览器租用标签页关闭与显式恢复
- 更新 `tests/browser-tabs-electron-e2e.ts`,移除两处租用期间拒绝关闭的旧断言。
当前行为见 [Agent 与 MCP 路由](./browser-tabs-technical-design.md#6-agent-与-mcp-路由)
和 [浏览器需求](./prd.md#77-浏览器)。同步修改 BrowserService、IPC、Gateway 和直连模型工具。
- Windows 真实 Electron/Chromium 与 loopback HTTP/MCP 验证通过:关闭已物化的预留页和
已租用主标签页,等待原 WebContents 的 `destroyed` 事件,确认租约结束而请求信号未取消。
同一 token 和 MCP session 仍可列出工具;旧目标快照返回关闭提示且不创建页面。
显式 `browser_navigate` 创建不同身份的专用替代页,后续 MCP 快照读取该页。
- 同 Conversation 的 sibling 仅在主标签页关闭后取得 `primary` 标记,页面 URL、导航状态、
时间戳、工作栏身份和 Cookie 内容保持不变;另一 Conversation 的页面与可见 viewport
保持不变。撤销 capability 后完成替代页、同级页和 Context 清理。
- 已先检查 runner:esbuild 输出临时 CJS,启动真实 Electron,子进程上限 120 秒,结束后
删除临时 bundle。`node build/run-browser-tabs-electron-e2e.cjs` 最终退出码 0;
前三轮分别修正测试对销毁事件时序、服务旧 ID 错误文案和 sibling 主标签标记的假设。
测试文件 ESLint 与限定文件的 `git diff --check` 通过。
- 覆盖边界:本轮通过 Main BrowserService 和真实 MCP transport 操作页面,未点击完整
Renderer/Preload 关闭入口,未注入执行中的工具取消,也未运行直连模型循环或其他共享工具。
浏览器、Gateway、直连 Provider、Model Runtime 和 IPC 回归共 359 项通过、1 项跳过,
包括执行中关闭、请求继续完成和其他工具继续可用。全库 typecheck、lint 和 diff 检查通过。
全量 `npm test` 在 Agent 离线依赖安装用例失败后达到 200 秒执行上限;无外部模型调用。
- 本轮未修改 Agent、桥接或 Runtime 产品源码,也未连接远程 Host。远程调用若进入同一桌面
Gateway,会使用上述服务端路由;远端 Runtime 到桌面的实际传输、取消传播及恢复仍需真实
Host 验证,本记录不将本机 loopback 结果算作远程覆盖。
## 2026-09-12 浏览器刷新与关闭后的错误清理 ## 2026-09-12 浏览器刷新与关闭后的错误清理
- 按 [浏览器工具栏规则](./prd.md#77-浏览器) 修复空白标签页允许刷新的问题; - 按 [浏览器工具栏规则](./prd.md#77-浏览器) 修复空白标签页允许刷新的问题;
@@ -83,7 +108,7 @@
132 项通过。随后新增的 Ask 指令、继承 Runtime 用量持久化及计划模式 App 场景,3 项通过。 132 项通过。随后新增的 Ask 指令、继承 Runtime 用量持久化及计划模式 App 场景,3 项通过。
- 扩展真实 Electron/MCP E2E 在独立临时 profile 通过:四个未使用的请求预留不创建会话、 - 扩展真实 Electron/MCP E2E 在独立临时 profile 通过:四个未使用的请求预留不创建会话、
WebContents 或 UI 事件;首次 MCP 导航物化相同 Tab/工作栏身份,恢复不额外创建页面, WebContents 或 UI 事件;首次 MCP 导航物化相同 Tab/工作栏身份,恢复不额外创建页面,
并验证独立页面、共享 Cookie、请求租约关闭保护和清理。 并验证独立页面、共享 Cookie、当时的请求租约关闭保护和清理;关闭保护已于 2026-09-13 移除。
- 当前源码的共享 Linux x64 Host 验证已通过,覆盖工作区目标保护和重命名/Git Diff, - 当前源码的共享 Linux x64 Host 验证已通过,覆盖工作区目标保护和重命名/Git Diff,
并通过桌面托管 ACP → 隔离 Agent → OpenCode 完成 Ask/Execute 和继承 Runtime 指标持久化; 并通过桌面托管 ACP → 隔离 Agent → OpenCode 完成 Ask/Execute 和继承 Runtime 指标持久化;
该组共 3 次真实模型调用,清理后无所属进程残留。完整记录见 该组共 3 次真实模型调用,清理后无所属进程残留。完整记录见
@@ -120,13 +145,13 @@
- Renderer 使用工作栏实例 UUID 原子创建或恢复 Browser Tab,并以 UUID viewport token 防止 - Renderer 使用工作栏实例 UUID 原子创建或恢复 Browser Tab,并以 UUID viewport token 防止
延迟 cleanup 隐藏新活动 Tab。关闭单个实例只释放对应 Tab。 延迟 cleanup 隐藏新活动 Tab。关闭单个实例只释放对应 Tab。
- 直连模型和请求级 MCP capability 在请求开始时优先绑定可见 Tab,其次绑定 primary Tab, - 直连模型和请求级 MCP capability 在请求开始时优先绑定可见 Tab,其次绑定 primary Tab,
并持有使用租约。请求结束或 capability 撤销前,绑定 Tab 拒绝关闭;工具参数未增加模型可见 并持有使用租约。当时绑定 Tab 拒绝关闭,该行为已于 2026-09-13 改为允许关闭和显式导航恢复;工具参数未增加模型可见
`tabId`,已有浏览器工具 schema 保持不变。 `tabId`,已有浏览器工具 schema 保持不变。
- 聚焦回归命令覆盖 Shared、BrowserService、Electron Session、IPC、Preload、MCP、Renderer - 聚焦回归命令覆盖 Shared、BrowserService、Electron Session、IPC、Preload、MCP、Renderer
和 Workbar,13 个测试文件共 453 项通过;`npm run lint` 与 Web TypeScript 检查通过。 和 Workbar,13 个测试文件共 453 项通过;`npm run lint` 与 Web TypeScript 检查通过。
- `node build/run-browser-tabs-electron-e2e.cjs` 通过。该测试启动真实 Electron/Chromium 和本地 - `node build/run-browser-tabs-electron-e2e.cjs` 通过。该测试启动真实 Electron/Chromium 和本地
HTTP 页面,创建两个 `WebContentsView`,验证页面隔离、同 Conversation Cookie 共享、真实 HTTP 页面,创建两个 `WebContentsView`,验证页面隔离、同 Conversation Cookie 共享、真实
loopback MCP 的固定 Tab 导航与快照、使用租约关闭保护,以及单 Tab 和最终 Context 清理。 loopback MCP 的固定 Tab 导航与快照、当时的使用租约关闭保护,以及单 Tab 和最终 Context 清理。
- `npm run typecheck`、`npm run lint`、`npm run build:bundle` 和 `git diff --check` 通过。 - `npm run typecheck`、`npm run lint`、`npm run build:bundle` 和 `git diff --check` 通过。
- 本轮整库 `npm test` 为 3686 项通过、61 项跳过、4 项失败。失败位于 portable/release 的 - 本轮整库 `npm test` 为 3686 项通过、61 项跳过、4 项失败。失败位于 portable/release 的
ASAR 元数据 fixture、Runtime 版本探测和 DeepSeek Harness 的本地 MCP fixture;定向复跑仍 ASAR 元数据 fixture、Runtime 版本探测和 DeepSeek Harness 的本地 MCP fixture;定向复跑仍
@@ -326,6 +326,7 @@ describe('KnowledgeMcpGateway', () => {
}) })
) )
const browserService = { const browserService = {
createTab: vi.fn(),
getOrigin: vi.fn(() => undefined), getOrigin: vi.fn(() => undefined),
navigate: vi.fn(async () => ({ navigate: vi.fn(async () => ({
url: 'https://example.com/', 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 () => { it('exposes GoodBuddy config reads in Ask and apply only in Execute', async () => {
const { service } = createService() const { service } = createService()
const configService = { const configService = {
+49 -16
View File
@@ -70,11 +70,12 @@ import {
import type { LaunchEnvironmentProvider } from '../local-tool-environment' import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import { import {
BrowserModelTools, BrowserModelTools,
browserNavigateInputSchema,
browserToolNames, browserToolNames,
type BrowserToolName, type BrowserToolName,
type BrowserToolService type BrowserToolService
} from '../browser/browser-model-tools' } from '../browser/browser-model-tools'
import type { BrowserTabUsageLease } from '../browser/browser-service' import type { BrowserService, BrowserTabUsageLease } from '../browser/browser-service'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver' import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import { safeToolErrorDetail } from './approval-summary' import { safeToolErrorDetail } from './approval-summary'
@@ -238,6 +239,7 @@ export type KnowledgeMcpGatewayOptions = {
magicNotesDatabase?: MagicNotesDatabase magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService configService?: GoodBuddyConfigService
browserService?: BrowserToolService & { browserService?: BrowserToolService & {
createTab: BrowserService['createTab']
listTabs(conversationId: string): BrowserTabSummary[] listTabs(conversationId: string): BrowserTabSummary[]
acquireTabUsage( acquireTabUsage(
conversationId: string, conversationId: string,
@@ -371,6 +373,7 @@ export class KnowledgeMcpGateway {
private readonly magicNotesDatabase?: MagicNotesDatabase private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService private readonly configService?: GoodBuddyConfigService
private readonly browserService?: BrowserToolService & { private readonly browserService?: BrowserToolService & {
createTab: BrowserService['createTab']
listTabs(conversationId: string): BrowserTabSummary[] listTabs(conversationId: string): BrowserTabSummary[]
acquireTabUsage( acquireTabUsage(
conversationId: string, conversationId: string,
@@ -581,15 +584,7 @@ export class KnowledgeMcpGateway {
const abort = (): void => { const abort = (): void => {
this.revoke(token) this.revoke(token)
} }
const abortFromBrowser = (): void => {
this.revoke(token)
}
value.signal.addEventListener('abort', abort, { once: true }) value.signal.addEventListener('abort', abort, { once: true })
value.browserUsageLease?.signal.addEventListener(
'abort',
abortFromBrowser,
{ once: true }
)
this.capabilities.set(token, { this.capabilities.set(token, {
...value, ...value,
expiresAt: this.now() + this.capabilityTtlMs, expiresAt: this.now() + this.capabilityTtlMs,
@@ -597,10 +592,6 @@ export class KnowledgeMcpGateway {
references: new Map(), references: new Map(),
removeAbortListener: () => { removeAbortListener: () => {
value.signal.removeEventListener('abort', abort) value.signal.removeEventListener('abort', abort)
value.browserUsageLease?.signal.removeEventListener(
'abort',
abortFromBrowser
)
} }
}) })
return token return token
@@ -1658,7 +1649,7 @@ export class KnowledgeMcpGateway {
} }
} }
const capability = this.getCapability(token) const capability = this.getCapability(token)
const browserTools = capability.browserConversationId && capability.browserTabId let browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({ ? new BrowserModelTools({
service: this.browserService!, service: this.browserService!,
conversationId: capability.browserConversationId, conversationId: capability.browserConversationId,
@@ -1666,12 +1657,52 @@ export class KnowledgeMcpGateway {
}) })
: undefined : undefined
if (browserTools?.ownsTool(name)) { if (browserTools?.ownsTool(name)) {
let lease = capability.browserUsageLease!
try { try {
const callSignal = AbortSignal.any([
extra.signal,
capability.signal,
capability.brokerController.signal
])
callSignal.throwIfAborted()
if (lease.signal.aborted) {
if (name !== 'browser_navigate') {
throw new Error('浏览器标签页已关闭,请调用 browser_navigate 打开新标签页。')
}
browserNavigateInputSchema.parse(input)
// A stable, request-specific ID also deduplicates concurrent recovery calls.
const tab = await this.browserService!.createTab(
capability.browserConversationId!,
undefined,
callSignal,
`browser-recovery:${capability.requestId}:${lease.tabId}`
)
callSignal.throwIfAborted()
if (capability.browserUsageLease === lease) {
const replacement = this.browserService!.acquireTabUsage(
capability.browserConversationId!,
tab.tabId,
capability.requestId
)
capability.browserTabId = tab.tabId
capability.browserUsageLease = replacement
lease.release()
}
lease = capability.browserUsageLease!
browserTools = new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId!,
browserTabId: capability.browserTabId!
})
}
const browserSignal = AbortSignal.any([callSignal, lease.signal])
browserSignal.throwIfAborted()
const result = await browserTools.callTool( const result = await browserTools.callTool(
name, name,
input, input,
extra.signal browserSignal
) )
browserSignal.throwIfAborted()
return { return {
content: result.parts.map((part) => content: result.parts.map((part) =>
part.type === 'text' part.type === 'text'
@@ -1685,7 +1716,9 @@ export class KnowledgeMcpGateway {
} }
} catch (error) { } catch (error) {
const detail = const detail =
safeToolErrorDetail(error, 2_000) ?? (lease.signal.aborted
? '浏览器标签页已关闭,请调用 browser_navigate 打开新标签页。'
: safeToolErrorDetail(error, 2_000)) ??
'内置浏览器工具执行失败' '内置浏览器工具执行失败'
return { return {
isError: true, isError: true,
@@ -11,6 +11,7 @@ import { rgPath } from '@vscode/ripgrep'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools' import type { BrowserToolService } from '../browser/browser-model-tools'
import { BrowserService } from '../browser/browser-service'
import { browserTabIdSchema } from '../../shared/contracts' import { browserTabIdSchema } from '../../shared/contracts'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver' import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import { import {
@@ -1432,6 +1433,71 @@ describe('ModelToolProvider', () => {
}) })
}) })
it('recovers a closed direct-model tab across calls without cancelling the request', async () => {
const workspace = await createWorkspace()
await writeFile(join(workspace, 'note.txt'), 'request still active')
const service = new BrowserService()
const original = service.reserveRequestTab('browser-owner', 'request')
const provider = new ModelToolProvider(workspace, [], service)
const context = {
...toolContext, requestId: 'request', browserConversationId: 'browser-owner', browserTabId: original.tabId
}
const controller = new AbortController()
const navigate = vi.spyOn(service, 'navigate').mockImplementation(
async (_conversationId, _url, signal) => new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
)
const snapshot = vi.spyOn(service, 'snapshot').mockResolvedValue({} as never)
const createTab = vi.spyOn(service, 'createTab').mockImplementation(async () => {
const replacement = service.reserveRequestTab('browser-owner', 'replacement')
return { tabId: replacement.tabId } as never
})
const acquire = vi.spyOn(service, 'acquireTabUsage')
try {
const pending = provider.callTool('browser_navigate', { url: 'https://example.com/' }, controller.signal, context)
const closed = expect(pending).rejects.toMatchObject({
name: 'RecoverableModelToolError', nextAction: expect.stringContaining('browser_navigate')
})
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce())
await service.closeTab('browser-owner', original.tabId)
await closed
expect(controller.signal.aborted).toBe(false)
for (const [name, args] of [
['browser_snapshot', {}], ['browser_click', { ref: 'b_old' }],
['browser_type', { ref: 'b_old', text: 'value' }],
['browser_select', { ref: 'b_old', value: 'value' }],
['browser_back', {}], ['browser_screenshot', {}]
] as const) {
await expect(provider.callTool(name, args, controller.signal, context)).rejects.toMatchObject({
name: 'RecoverableModelToolError', nextAction: expect.stringContaining('browser_navigate')
})
}
expect(createTab).not.toHaveBeenCalled()
expect(snapshot).not.toHaveBeenCalled()
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, controller.signal, context))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('request still active') }] })
navigate.mockResolvedValue({ url: 'https://example.com/', origin: 'https://example.com' })
await provider.callTool('browser_navigate', { url: 'https://example.com/' }, controller.signal, context)
const replacementTabId = navigate.mock.calls[1]![3]!
expect(replacementTabId).not.toBe(original.tabId)
expect(createTab).toHaveBeenCalledWith('browser-owner', undefined, controller.signal,
expect.stringContaining(`:${original.tabId}`))
expect(acquire).toHaveBeenCalledWith('browser-owner', replacementTabId, expect.any(String))
await provider.listTools(context, controller.signal)
await provider.callTool('browser_snapshot', {}, controller.signal, context)
expect(snapshot).toHaveBeenLastCalledWith('browser-owner', expect.any(AbortSignal), replacementTabId)
await expect(provider.callTool('browser_snapshot', {}, controller.signal, { ...context, requestId: 'other-request' }))
.rejects.toMatchObject({ name: 'RecoverableModelToolError' })
expect(createTab).toHaveBeenCalledOnce()
expect(controller.signal.aborted).toBe(false)
} finally {
original.release()
await provider.dispose()
await service.dispose()
}
})
it('keeps a request on its bound tab when another tab changes or closes', async () => { it('keeps a request on its bound tab when another tab changes or closes', async () => {
const workspace = await createWorkspace() const workspace = await createWorkspace()
const browserService = createBrowserService() const browserService = createBrowserService()
+22 -8
View File
@@ -24,6 +24,7 @@ import type {
} from './runtime' } from './runtime'
import { import {
BrowserModelTools, BrowserModelTools,
BrowserTabClosedError,
browserToolNames, browserToolNames,
type BrowserToolService type BrowserToolService
} from '../browser/browser-model-tools' } from '../browser/browser-model-tools'
@@ -661,6 +662,8 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
} }
export class ModelToolProvider implements ModelToolProviderLike { export class ModelToolProvider implements ModelToolProviderLike {
// ModelAgentRuntime reuses one context for all tool calls in a request.
private readonly browserToolsByContext = new WeakMap<ModelToolCallContext, BrowserModelTools>()
private readonly mcpConnections = new Map< private readonly mcpConnections = new Map<
ResolvedMcpServer, ResolvedMcpServer,
Promise<ConnectedMcp | undefined> Promise<ConnectedMcp | undefined>
@@ -738,16 +741,20 @@ export class ModelToolProvider implements ModelToolProviderLike {
private getBrowserTools( private getBrowserTools(
context: ModelToolCallContext context: ModelToolCallContext
): BrowserModelTools | undefined { ): BrowserModelTools | undefined {
return this.browserService && if (!this.browserService || context.workMode !== 'execute' || !context.browserTabId) {
context.workMode === 'execute' && return undefined
context.browserTabId }
? new BrowserModelTools({ let tools = this.browserToolsByContext.get(context)
if (!tools) {
tools = new BrowserModelTools({
service: this.browserService, service: this.browserService,
conversationId: conversationId: context.browserConversationId ?? context.conversationId,
context.browserConversationId ?? context.conversationId, browserTabId: context.browserTabId,
browserTabId: context.browserTabId recoverClosedTab: true
}) })
: undefined this.browserToolsByContext.set(context, tools)
}
return tools
} }
private getReservedToolCount(): number { private getReservedToolCount(): number {
@@ -1707,6 +1714,13 @@ export class ModelToolProvider implements ModelToolProviderLike {
try { try {
return await browserTools.callTool(name, argumentsValue, signal) return await browserTools.callTool(name, argumentsValue, signal)
} catch (error) { } catch (error) {
if (error instanceof BrowserTabClosedError) {
throw new RecoverableModelToolError(
error.message,
'调用 browser_navigate 打开新标签页,然后获取新快照继续操作',
{ cause: error }
)
}
if (error instanceof BrowserStaleReferenceError) { if (error instanceof BrowserStaleReferenceError) {
throw new RecoverableModelToolError( throw new RecoverableModelToolError(
error.message, error.message,
@@ -245,4 +245,48 @@ describe('BrowserModelTools', () => {
tools.callTool('browser_snapshot', {}, controller.signal) tools.callTool('browser_snapshot', {}, controller.signal)
).rejects.toHaveProperty('name', 'AbortError') ).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' } from '../agent/model-tool-provider'
import type { RuntimeApprovalRequest } from '../agent/runtime' import type { RuntimeApprovalRequest } from '../agent/runtime'
import { canonicalizeBrowserUrl } from './browser-url-policy' import { canonicalizeBrowserUrl } from './browser-url-policy'
import type { BrowserService } from './browser-service' import type { BrowserService, BrowserTabUsageLease } from './browser-service'
import { import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH, MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
@@ -191,12 +191,21 @@ export type BrowserToolService = Pick<
| 'back' | 'back'
| 'screenshot' | 'screenshot'
| 'releaseConversation' | 'releaseConversation'
> > & Partial<Pick<BrowserService, 'acquireTabUsage' | 'createTab'>>
export type BrowserModelToolsOptions = { export type BrowserModelToolsOptions = {
service: BrowserToolService service: BrowserToolService
conversationId: string conversationId: string
browserTabId: BrowserTabId 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 { function createTextResult(value: unknown): ModelToolResult {
@@ -220,12 +229,15 @@ function navigationLabel(url: URL): string {
export class BrowserModelTools { export class BrowserModelTools {
private readonly service: BrowserToolService private readonly service: BrowserToolService
private readonly conversationId: string private readonly conversationId: string
private readonly browserTabId: BrowserTabId private browserTabId: BrowserTabId
private readonly recoveryOwner = randomUUID()
private readonly recoverClosedTab: boolean
constructor(options: BrowserModelToolsOptions) { constructor(options: BrowserModelToolsOptions) {
this.service = options.service this.service = options.service
this.conversationId = options.conversationId this.conversationId = options.conversationId
this.browserTabId = options.browserTabId this.browserTabId = options.browserTabId
this.recoverClosedTab = options.recoverClosedTab === true
if (!this.conversationId || this.conversationId.length > 500) { if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效') throw new Error('浏览器对话标识无效')
} }
@@ -321,6 +333,53 @@ export class BrowserModelTools {
if (!this.ownsTool(name)) { if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${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') { if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue) const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult( return createTextResult(
@@ -328,7 +387,7 @@ export class BrowserModelTools {
this.conversationId, this.conversationId,
input.url, input.url,
signal, signal,
this.browserTabId browserTabId
) )
) )
} }
@@ -338,7 +397,7 @@ export class BrowserModelTools {
await this.service.snapshot( await this.service.snapshot(
this.conversationId, this.conversationId,
signal, signal,
this.browserTabId browserTabId
) )
) )
} }
@@ -348,7 +407,7 @@ export class BrowserModelTools {
this.conversationId, this.conversationId,
input.ref, input.ref,
signal, signal,
this.browserTabId browserTabId
) )
return createTextResult({ clicked: input.ref }) return createTextResult({ clicked: input.ref })
} }
@@ -359,7 +418,7 @@ export class BrowserModelTools {
input.ref, input.ref,
input.text, input.text,
signal, signal,
this.browserTabId browserTabId
) )
return createTextResult({ return createTextResult({
typed: input.ref, typed: input.ref,
@@ -374,7 +433,7 @@ export class BrowserModelTools {
input.ref, input.ref,
input.value, input.value,
signal, signal,
this.browserTabId browserTabId
) )
return createTextResult({ selected: input.ref, value: '[已隐藏]' }) return createTextResult({ selected: input.ref, value: '[已隐藏]' })
} }
@@ -384,7 +443,7 @@ export class BrowserModelTools {
await this.service.back( await this.service.back(
this.conversationId, this.conversationId,
signal, signal,
this.browserTabId browserTabId
) )
) )
} }
@@ -392,7 +451,7 @@ export class BrowserModelTools {
const screenshot = await this.service.screenshot( const screenshot = await this.service.screenshot(
this.conversationId, this.conversationId,
signal, signal,
this.browserTabId browserTabId
) )
return { return {
parts: [ parts: [
+8 -8
View File
@@ -227,7 +227,8 @@ describe('BrowserService', () => {
expect(restored.tabId).toBe(first.tabId) expect(restored.tabId).toBe(first.tabId)
expect(harness.service.getTabCount()).toBe(1) expect(harness.service.getTabCount()).toBe(1)
first.release() 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() second.release()
await harness.service.closeTab('conversation', first.tabId, 21) await harness.service.closeTab('conversation', first.tabId, 21)
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() 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 harness = createHarness()
const signal = new AbortController().signal const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 11, signal) const primary = await harness.service.createTab('conversation', 11, signal)
@@ -577,15 +578,14 @@ describe('BrowserService', () => {
await expect( await expect(
harness.service.closeTab('conversation', primary.tabId, 11) harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
await expect(
harness.service.closeTab('conversation', sibling.tabId, 11)
).resolves.toBeUndefined() ).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()
first.release() first.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
second.release() second.release()
await expect( await expect(
harness.service.closeTab('conversation', primary.tabId, 11) 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 = { export type BrowserTabUsageLease = {
readonly conversationId: string readonly conversationId: string
readonly tabId: BrowserTabId readonly tabId: BrowserTabId
@@ -908,12 +905,14 @@ export class BrowserService {
[...this.creations.entries()].some(([id, slot]) => slot.tabId === tabId && id !== conversationId) || [...this.creations.entries()].some(([id, slot]) => slot.tabId === tabId && id !== conversationId) ||
[...this.requestTabs.values()].some((slot) => slot.tabId === tabId && slot.conversationId !== conversationId) [...this.requestTabs.values()].some((slot) => slot.tabId === tabId && slot.conversationId !== conversationId)
) throw new Error('浏览器标签页不存在或不属于当前对话') ) throw new Error('浏览器标签页不存在或不属于当前对话')
if (reservation?.tabId === tabId && reservation.usageLeases.size > 0) { if (reservation?.tabId === tabId) {
throw new Error(BROWSER_TAB_IN_USE_ERROR) this.releaseRequestTab(conversationId)
}
if (creation?.tabId === tabId) {
creation.controller.abort(new Error('浏览器会话已关闭'))
} }
return return
} }
if (tab.usageLeases.size > 0) throw new Error(BROWSER_TAB_IN_USE_ERROR)
await this.releaseTab(conversation!, tab) await this.releaseTab(conversation!, tab)
} }
+88
View File
@@ -7739,6 +7739,94 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it('completes the runtime request and releases its lease after browser lease abort', async () => {
const requestId = '00000000-0000-4000-8000-000000000031'
const tabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000423'
)
const leaseController = new AbortController()
const release = vi.fn()
const lease = {
conversationId: 'browser-lease-abort',
tabId,
owner: requestId,
signal: leaseController.signal,
release
}
let runtimeSignal: AbortSignal | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }, signal: AbortSignal) {
runtimeSignal = signal
leaseController.abort(new Error('browser tab closed'))
signal.throwIfAborted()
yield { requestId: request.requestId, type: 'done' }
}
}
let grantedLease: { release(): void } | undefined
const knowledgeGateway = {
grant: vi.fn((...args: unknown[]) => {
grantedLease = args[7] as { release(): void }
return 'browser-capability'
}),
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
drainReferences: vi.fn(() => []),
revoke: vi.fn(() => grantedLease?.release())
}
const browserControl = {
listTabs: vi.fn(() => [{ tabId, primary: true }]),
getVisibleTabId: vi.fn(() => tabId),
acquireTabUsage: vi.fn(() => lease),
createTab: vi.fn(),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
browserControl
)
try {
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: lease.conversationId,
prompt: 'Continue after the browser tab closes',
workMode: 'execute',
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce())
expect(grantedLease).toBe(lease)
expect(lease.signal.aborted).toBe(true)
expect(runtimeSignal?.aborted).toBe(false)
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, payload]) => payload)
expect(publicEvents).toContainEqual({ requestId, type: 'done' })
expect(publicEvents).not.toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'error' })
]))
expect(knowledgeGateway.revoke).toHaveBeenCalledWith('browser-capability')
} finally {
await harness.dispose()
}
})
it.each(['error', 'cancel'] as const)( it.each(['error', 'cancel'] as const)(
'releases a request-created browser tab lease on runtime %s', 'releases a request-created browser tab lease on runtime %s',
async (outcome) => { async (outcome) => {
+2 -10
View File
@@ -552,7 +552,6 @@ async function grantScopedDataCapability(input: {
signal: AbortSignal signal: AbortSignal
) => Promise<boolean> ) => Promise<boolean>
signal: AbortSignal signal: AbortSignal
abort?: (reason: unknown) => void
}): Promise<ScopedDataCapability> { }): Promise<ScopedDataCapability> {
const enabledServers = new Set(input.enabledServers) const enabledServers = new Set(input.enabledServers)
const libraryIds = enabledServers.has('knowledge-base') const libraryIds = enabledServers.has('knowledge-base')
@@ -590,11 +589,6 @@ async function grantScopedDataCapability(input: {
}) })
: undefined : undefined
const browserTabId = browserUsageLease?.tabId const browserTabId = browserUsageLease?.tabId
browserUsageLease?.signal.addEventListener(
'abort',
() => input.abort?.(browserUsageLease.signal.reason),
{ once: true }
)
const config = const config =
configAccess !== 'none' && input.workspacePath configAccess !== 'none' && input.workspacePath
? { ? {
@@ -2485,8 +2479,7 @@ export function registerIpcHandlers(
? runtimeConversationId ? runtimeConversationId
: undefined, : undefined,
ownerWindowId: window.webContents.id, ownerWindowId: window.webContents.id,
signal: controller.signal, signal: controller.signal
abort: (reason) => controller.abort(reason)
}) })
knowledgeCapabilityToken = notesCapability.token knowledgeCapabilityToken = notesCapability.token
const noteTools = [ const noteTools = [
@@ -4060,8 +4053,7 @@ export function registerIpcHandlers(
? enrichedRequest.conversationId ? enrichedRequest.conversationId
: undefined, : undefined,
ownerWindowId: event.sender.id, ownerWindowId: event.sender.id,
signal: controller.signal, signal: controller.signal
abort: (reason) => controller.abort(reason)
}) })
const knowledgeCapabilityToken = scopedCapability.token const knowledgeCapabilityToken = scopedCapability.token
const availableTools = [ const availableTools = [
+51 -4
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { once } from 'node:events'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { app, BrowserWindow, webContents } from 'electron' import { app, BrowserWindow, webContents } from 'electron'
import { Client } from '@modelcontextprotocol/sdk/client/index.js' 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(restoredRequestTab.workbarInstanceId, reserved.tabId)
assert.equal(browser.getTabCount(), 1) assert.equal(browser.getTabCount(), 1)
assert.match(JSON.stringify(await client.callTool({ name: 'browser_snapshot', arguments: {} })), /MCP target/u) 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) gateway.revoke(token)
token = undefined token = undefined
await client.close() await client.close()
@@ -97,7 +106,7 @@ async function main(): Promise<void> {
await browser.closeTab(conversationId, reserved.tabId, window.webContents.id) await browser.closeTab(conversationId, reserved.tabId, window.webContents.id)
assert.equal(browser.getSessionCount(), 0) assert.equal(browser.getSessionCount(), 0)
removeStateListener() 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) await browser.navigate(conversationId, `${origin}/first`, controller.signal, undefined, window.webContents.id)
const [unownedPrimary] = browser.listTabs(conversationId, 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.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) 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( await assert.rejects(
browser.closeTab(conversationId, first.tabId, window.webContents.id), browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id),
/正在被.*请求使用/u /当前浏览器标签页尚未导航/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) await browser.closeTab(otherConversationId, other.tabId, window.webContents.id)
assert.equal(browser.getTabCount(otherConversationId), 0) assert.equal(browser.getTabCount(otherConversationId), 0)
assert.equal(browser.getTabCount(conversationId), 2) assert.equal(browser.getTabCount(conversationId), 2)
gateway.revoke(token) gateway.revoke(token)
token = undefined token = undefined
await browser.closeTab(conversationId, first.tabId, window.webContents.id) 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) assert.equal(browser.getTabCount(conversationId), 1)
await browser.closeTab(conversationId, second.tabId, window.webContents.id) await browser.closeTab(conversationId, second.tabId, window.webContents.id)
assert.equal(browser.getTabCount(conversationId), 0) assert.equal(browser.getTabCount(conversationId), 0)