feat: add persistent workbar browser tabs and scoped MCP routing

This commit is contained in:
mesalogo
2026-09-10 01:26:56 +08:00
parent 428b4c0d08
commit 80b0b18712
54 changed files with 5446 additions and 1468 deletions
+17 -12
View File
@@ -321,23 +321,27 @@
动态 Tab、“+”和终端细节见
[助手工作栏多终端页签 PRD](./docs/features/assistant-workbar/terminal-tabs-prd.md)。
- 升级后默认打开任务中心、工作区、浏览器和成果四个单实例应用,不根据当前页面或 Runtime
能力自动增删稳定应用目录入口。
- 升级后默认打开任务中心、工作区、一个浏览器实例和成果。任务中心与工作区是不可关闭的
单实例基础应用;浏览器是可关闭的多实例应用;成果保持可关闭的单实例应用。不可关闭不
等于锁定顺序。
- 资源监控作为本机单实例应用保留在能力目录,但不加入升级后的默认打开实例。面板只在可见
活动时采样,首期不持久化历史或提供后台负载告警。
- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。
“+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。
- 应用注册表声明单实例或多实例。在工作栏“+”目录中,单实例重复选择时聚焦已有 Tab;终端
从首期开始支持多实例,每次选择创建独立 Tab 和 Shell Session。主侧栏系统工具快捷入口
可以聚焦当前执行空间中最近使用的终端,额外终端仍从“+”目录创建。
- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task
只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,
也不得把 Job/Run 提升为可导航 UI 对象。
- 应用注册表分别声明单实例或多实例、默认打开、必须存在、可关闭和排序策略。在工作栏“+”目录中,
单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。主侧栏系统工具快捷
入口按当前上下文聚焦最近使用的匹配实例,额外实例仍从“+”目录创建。
- Task Center 是默认跟随当前项目的单实例索引,不使用多实例模式。用户可在同一 Tab 内切换
“当前项目”“全局任务”和“全部项目”;范围变化不得创建第二个 Task Center。每个 Task 只
关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也
不得把 Job/Run 提升为可导航 UI 对象。
- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
- 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。
- 用户可以主动排序栏目,并可关闭可关闭实例或恢复默认布局;任务中心和工作区不能隐藏或
关闭。应用不能用用户偏好机制实现自动能力裁剪。
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染
关闭按钮,也不响应关闭快捷命令。
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
@@ -354,8 +358,9 @@
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
尚未提交的文字改变 Agent 看到的页面。
- 浏览器工具栏操作的是当前 Conversation 中 Agent 使用的同一隔离浏览器会话,用户导航会
有意改变 Agent 下一步看到的页面。创建中、Agent 操作中或已进入完整交互窗口时禁用其余
- 浏览器工具栏操作的是该实例绑定的 Conversation 和 Browser Tab。同一 Conversation 的多个
Tab 共享隔离登录上下文,但页面、导航、操作队列和元素引用相互独立。模型请求开始时冻结
工具目标,用户切换工作栏 Tab 不改变进行中的 Agent 操作。创建中、Agent 操作中或已进入完整交互窗口时禁用其余
工具栏控件;仅当前导航仍在加载时保留“停止加载”,且该操作只中止页面加载,不关闭浏览器、
不释放 Runtime 上下文。没有会话的活动 Conversation 可以直接在地址栏输入 URL 并“前往”
创建会话,但必须复用 Agent 浏览器相同的 URL 策略与能力路径。
+31
View File
@@ -0,0 +1,31 @@
const { spawnSync } = require('node:child_process')
const { rmSync } = require('node:fs')
const { tmpdir } = require('node:os')
const { join } = require('node:path')
const { buildSync } = require('esbuild')
const output = join(tmpdir(), `goodbuddy-browser-tabs-e2e-${process.pid}.cjs`)
try {
buildSync({
entryPoints: [join(process.cwd(), 'tests', 'browser-tabs-electron-e2e.ts')],
outfile: output,
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
external: ['electron']
})
const environment = { ...process.env }
delete environment.ELECTRON_RUN_AS_NODE
const result = spawnSync(require('electron'), [output], {
cwd: process.cwd(),
env: environment,
stdio: 'inherit',
timeout: 120_000
})
if (result.error) throw result.error
process.exitCode = result.status ?? 1
} finally {
rmSync(output, { force: true })
}
@@ -87,13 +87,14 @@ type NavigationContribution =
| 工具 | 匹配实例存在 | 匹配实例不存在 | 上下文不可用 |
| --- | --- | --- | --- |
| 文件 | 激活单实例并保留其跟随或固定状态 | 创建跟随当前上下文的 `workspace` 单实例 | 打开面板并显示目录修复入口 |
| 文件 | 激活不可关闭的单实例并保留其跟随或固定状态 | 仅在迁移或布局修复时补回 `workspace` | 打开面板并显示目录修复入口 |
| 终端 | 激活当前执行空间最近的 `starting` 或 `running` 实例 | 创建一个绑定当前执行空间的实例 | 没有项目时使用本机 Home |
| 浏览器 | 激活单实例并保留其目标状态 | 创建跟随当前会话的 `browser` 单实例 | 打开面板并提示选择或创建对话 |
| 浏览器 | 激活绑定当前 Conversation 的最近使用实例 | 创建绑定当前 Conversation 的新实例 | 激活最近使用实例;不存在时创建待绑定实例 |
| 资源监控 | 激活 `resources` 单实例 | 创建本机 `resources` 单实例 | 部分指标不可用时显示原因并保留其余指标 |
文件和浏览器按以下优先级处理目标:已有有效固定目标、跟随模式的当前上下文、新实例的默认
上下文。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认工作区或当前会话。
文件保持单一实例原有绑定。浏览器快捷入口按当前 Conversation 匹配最近使用实例,而工作栏
“+”始终新建浏览器实例。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认
工作区或当前会话。
`closing`、`exited`、`interrupted` 和 `failed` 终端不属于可复用实例。当前执行空间只有这些
终端时,快捷按钮创建新实例并保留原 Tab 的历史状态。用户需要额外的并行终端时使用工作栏
@@ -154,7 +155,7 @@ warming/loading/live/partial/error -> inactive
## 7. 逻辑完整性评估
首期规则已覆盖固定入口、应用显隐、保存失败、系统工具单实例与多实例差异、本机 Home、
首期规则已覆盖固定入口、应用显隐、保存失败、系统工具匹配与显式多实例差异、本机 Home、
托管 SSH、无活动会话、目录失效、资源部分可用、采样停止、权限边界、键盘和窄窗口。其他
工具面板内部的取消、超时、
连接恢复和固定目标失效继续由助手工作栏负责,本功能不复制这些状态机。
@@ -86,10 +86,10 @@ Switch 更新成功后主导航立即生效;更新失败时恢复原状态并
### FR-6 文件浏览器入口
点击文件按钮时展开助手工作栏,并打开或聚焦单实例的“工作区”面板。已有实例保持其“跟随
当前上下文”或“固定到指定对象”状态;创建实例时默认跟随当前项目,没有项目时使用已配置
的默认工作区。跟随上下文或新建实例所需的路径不可用时,面板显示修复入口;已有有效固定
目标不受当前项目和默认工作区状态影响。
点击文件按钮时展开助手工作栏,并聚焦默认存在且不可关闭的“工作区”单实例。该实例保持
“跟随当前上下文”或“固定到指定对象”状态;迁移或布局修复发现实例缺失时补回,并默认跟随
当前项目。没有项目时使用已配置的默认工作区。跟随上下文所需的路径不可用时,面板显示修复
入口;已有有效固定目标不受当前项目和默认工作区状态影响。
### FR-7 终端入口
@@ -99,9 +99,10 @@ Switch 更新成功后主导航立即生效;更新失败时恢复原状态并
### FR-8 浏览器入口
点击浏览器按钮时展开助手工作栏,并打开或聚焦单实例的受控浏览器面板。已有实例保持其目标
状态;新实例默认跟随当前会话。新建或跟随模式下没有活动会话时,面板显示需要选择或创建
会话的空状态;已有有效固定目标继续显示。入口不打开系统默认浏览器,也不创建旁路 WebView。
点击浏览器按钮时展开助手工作栏,并优先聚焦绑定当前 Conversation 的最近使用浏览器实例;
没有匹配实例时创建一个。没有活动 Conversation 时聚焦最近使用的浏览器实例;完全不存在时
创建待绑定空实例。该快捷入口不会因连续点击创建重复实例;用户通过工作栏“+”选择浏览器时
则始终创建新实例。入口不打开系统默认浏览器,也不创建旁路 WebView。
### FR-9 资源监控入口
@@ -150,7 +151,8 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
- 运行记录在所有应用显隐组合下保持可见并可打开。
- 左下角不再用“本地工作区”与系统架构占据主要空间。
- 文件、终端、浏览器和资源监控均可从左下角打开正确的工作栏面板,且不产生第二套内容界面。
- 连续点击系统工具不会无意创建重复的文件或浏览器实例;终端遵守多实例规则。
- 连续点击系统工具不会无意创建重复的浏览器或终端实例;工作区始终复用不可关闭的固定实例。
用户通过工作栏“+”可以显式创建额外浏览器或终端实例。
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
最小化后停止请求新样本。
- CPU、内存、磁盘 IO 或 GoodBuddy 指标不可用时,页面区分部分可用与整体失败。
@@ -26,7 +26,8 @@ type WorkspaceView =
```
文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts`
承载。工作区与浏览器是单实例应用,终端是多实例应用。代码中尚无资源指标采集和资源监控
承载。工作区是不可关闭的单实例应用,浏览器和终端是多实例应用。浏览器多实例的跨进程
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
面板。现有侧栏底部 `user-card` 整行打开设置,并显示“本地工作区”和平台架构。
本次改动增加入口编排、从主侧栏到工作栏的命令通道,以及本机资源监控的 Main/Preload/
@@ -78,23 +79,28 @@ type WorkbarOpenIntent = {
requestId: number
appId: 'workspace' | 'terminal' | 'browser' | 'resources'
reason: 'sidebar-system-tool'
context?: { conversationId?: string; projectId?: string }
}
```
`requestId` 使连续点击同一工具仍能被消费。不要仅设置 `assistantSidebarTab`,因为用户可能已
关闭对应实例,且终端需要按执行空间寻找实例。
建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。文件和浏览器
已有实例时只激活实例,保留其“跟随当前上下文”或“固定到指定对象”状态:
建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。控制器必须
接收上下文和调用原因,不能只按 `appId` 查找第一个实例:
```ts
type WorkbarController = {
openOrActivate(appId: WorkbarAppId): Promise<WorkbarTabInstance>
openOrActivate(intent: WorkbarOpenIntent): Promise<WorkbarTabInstance>
}
```
若当前 `WorkbarShell` 没有命令式入口,先增加窄的回调或受控请求 Props,不引入全局事件总线。
工作区意图始终激活注册表中的不可关闭单实例;仅在迁移或布局损坏时补回。浏览器意图按
`conversationId` 激活最近使用的匹配实例,没有匹配实例时创建一个。工作栏“+”不走该去重
路径,选择浏览器时始终创建新实例。
### 3.3 终端匹配
工作栏“+”目录选择终端时每次创建新实例;侧栏系统工具按钮执行“进入当前终端”,采用:
@@ -330,7 +336,7 @@ resourceMonitor.getLocalSnapshot(input: {
- 固定入口顺序不受三个应用显隐组合影响。
- 运行记录和设置没有显隐控件。
- 四个系统工具按钮具有名称、工具提示、键盘行为和选中状态。
- 文件和浏览器复用单实例;终端优先聚焦当前执行空间实例。
- 文件复用不可关闭单实例;浏览器和终端按当前上下文聚焦匹配实例,工作栏“+”允许显式多实例。
- 工具按钮展开工作栏但不改变当前主页面。
- 显隐更新成功后主导航立即变化,失败时 Switch 和主导航恢复原状态。
- 窄窗口下工具按钮不重叠,焦点恢复正确。
@@ -370,7 +376,7 @@ resourceMonitor.getLocalSnapshot(input: {
3. 增加资源快照契约、Main 采集服务和 Preload API。
4. 为工作栏增加 `resources` 单实例和 `ResourceMonitorPanel`。
5. 拆分侧栏底部设置按钮,加入四个系统工具按钮。
6. 为工作栏增加 `openOrActivate` 意图接口和终端匹配规则。
6. 为工作栏增加带上下文的 `openOrActivate` 意图接口,以及浏览器和终端匹配规则。
7. 接入选中状态、焦点恢复、无障碍文案和响应式样式。
8. 完成 Renderer、契约、迁移和跨平台回归测试。
@@ -44,9 +44,9 @@
| 位置 | 图标 | 可见工具提示 | 点击结果 |
| --- | --- | --- | --- |
| 1 | `FolderOpen` | 文件 | 打开或聚焦工作区面板 |
| 1 | `FolderOpen` | 文件 | 聚焦不可关闭的工作区面板 |
| 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 |
| 3 | `Globe` | 浏览器 | 打开或聚焦受控浏览器面板 |
| 3 | `Globe` | 浏览器 | 聚焦当前 Conversation 的匹配浏览器实例 |
| 4 | `Activity` | 资源监控 | 打开或聚焦本机资源监控面板 |
| 末位 | `Settings` | 设置 | 打开设置页面 |
@@ -119,9 +119,10 @@
点击系统工具后,右侧工作栏按现有布局回流打开。主内容页面保持不变,侧栏不渲染工具内容。
- 文件:激活“工作区”Tab,焦点进入目录区域或错误修复入口。
- 文件:激活默认存在且不可关闭的“工作区”Tab,焦点进入目录区域或错误修复入口。
- 终端:激活匹配终端,焦点进入终端输入区;新建期间保留按钮宽度和进度状态。
- 浏览器:激活“浏览器”Tab,焦点进入工具栏;没有会话时进入空状态主操作。
- 浏览器:激活绑定当前 Conversation 的最近使用“浏览器”Tab;没有匹配实例时创建一个,
没有 Conversation 时按产品规则复用最近实例或进入待绑定空状态。
- 资源监控:激活“资源监控”Tab,焦点进入指标摘要;首个样本加载后开始绘制历史。
后台事件可以更新徽标,不能自动展开工作栏或切换当前 Tab。由用户点击快捷按钮触发的打开
@@ -105,7 +105,7 @@
- Given 当前项目有有效根目录
- When 用户点击文件按钮
- Then 助手工作栏展开
- And 已有工作区面板时聚焦该实例,没有时创建单实例
- And 聚焦默认存在且不可关闭的工作区单实例
- And 文件按钮通过选中状态表明该面板正在显示
### US-C2 处理无效工作区
@@ -114,7 +114,7 @@
验收:
- Given 工作区面板尚未创建或处于跟随模式
- Given 工作区面板处于跟随模式
- And 当前项目和默认工作区均不可用
- When 用户点击文件按钮
- Then 工作区面板仍打开
@@ -160,15 +160,23 @@
- Given 浏览器面板跟随当前上下文且当前会话已有浏览器会话
- When 用户点击浏览器按钮
- Then 工作栏聚焦同一个浏览器会话
- And 不打开系统默认浏览器或第二个 WebView
- Given 浏览器面板已固定到其他会话
- Then 工作栏聚焦绑定当前 Conversation 的最近使用浏览器实例
- And 不打开系统默认浏览器或无意创建重复实例
- Given 现有浏览器实例都绑定到其他 Conversation
- When 用户点击浏览器按钮
- Then 工作栏聚焦该面板并保持固定目标
- Given 浏览器面板尚未创建或处于跟随模式
- And 当前没有活动对话
- Then 工作栏创建并聚焦绑定当前 Conversation 的新浏览器实例
- Given 当前没有活动 Conversation
- And 已存在浏览器实例
- When 用户点击浏览器按钮
- Then 面板说明需要选择或创建对话
- Then 工作栏聚焦最近使用的浏览器实例并保持其目标
- Given 当前没有活动 Conversation
- And 不存在浏览器实例
- When 用户点击浏览器按钮
- Then 创建待选择目标的空实例
- Given 用户需要同一 Conversation 的另一个独立页面
- When 用户从工作栏“+”选择浏览器
- Then 每次创建拥有独立页面和导航状态的新实例
- And 同一 Conversation 内共享隔离登录上下文
### US-C6 查看本机资源
+4 -3
View File
@@ -7,9 +7,10 @@
| 文档 | 权威职责 |
| --- | --- |
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 |
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD
中。浏览器多实例涉及跨进程身份和工具路由,单独使用技术设计文档维护。
@@ -0,0 +1,177 @@
# 浏览器多实例技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 基础多实例已实施,Agent 多 Tab 工具待需求 |
| 版本 | 1.0 |
| 日期 | 2026-09-10 |
| 关联 PRD | [通用助手工作栏与执行空间 PRD](./prd.md) |
| 页签策略 | [助手工作栏多终端页签 PRD](./terminal-tabs-prd.md) |
## 1. 目标与边界
浏览器改为可关闭的多实例工作栏应用。每个工作栏浏览器实例对应一个独立 Browser Tab,拥有
独立页面、导航状态、操作队列、画面状态和元素引用空间。同一 Conversation 下的多个 Tab
共享隔离存储与代理上下文,使登录状态符合常规浏览器预期。
本设计不把多个工作栏 Tab 实现为同一页面的重复入口,不允许用户切换 Tab 后改变正在执行的
Agent 工具目标,也不在首期要求模型主动管理多个 Tab。
## 2. 改造前限制
改造前的链路以 `conversationId` 作为唯一浏览器身份:
- Renderer 只保存每个 Conversation 的一份 `BrowserLiveState`。
- Browser IPC、Preload API 和 `BrowserService` 操作只携带 `conversationId`。
- Main 每个 Conversation 只创建一个 Session、一个 `WebContentsView` 和一个 CDP Driver。
- 请求级 MCP 能力只保存 `browserConversationId`。
- snapshot 元素 `ref` 只在一个 Driver 的引用空间内有效。
因此只把工作栏注册策略改成 `multiple` 会导致多个 Tab 控制同一页面,并产生 viewport、清理和
元素引用竞态,不能作为可交付实现。
## 3. 身份与所有权
```ts
type BrowserTabId = string
type ConversationBrowserContext = {
conversationId: string
partitionId: string
tabs: Map<BrowserTabId, BrowserTab>
primaryTabId?: BrowserTabId
}
type BrowserTab = {
id: BrowserTabId
conversationId: string
view: WebContentsView
driver: CdpBrowserDriver
state: BrowserLiveState
}
```
- Conversation Browser Context 拥有隔离 partition、代理配置和全部 Tab。
- Browser Tab 拥有页面、Driver、串行操作队列、snapshot 代次、idle 状态和可见 viewport。
- `BrowserTabId` 是 Main 签发的不透明 ID。Renderer 和 Runtime 不能自行选择或复用。
- 工作栏实例保存公开的逻辑实例 ID 和 Conversation 绑定;进程重启后不恢复旧
`WebContentsView`,首次使用时由 Main 为该逻辑实例创建新的 Browser Tab。
- 删除 Conversation 时关闭该 Context 下全部 Tab;关闭一个工作栏浏览器实例只关闭对应
Browser Tab,不得调用 Conversation 级 Runtime 释放路径。
## 4. Renderer 与 viewport
Renderer 状态改为:
```ts
Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
```
每个浏览器工作栏实例持有自己的 `browserTabId`。只有当前可见实例可以提交 viewport。每次
获得可见权时生成新的 UUID `leaseToken`;提交请求携带 `browserTabId` 和 token,清理请求必须
回传同一个 token。Main 只接受当前 token 的清理,避免已隐藏实例的延迟 cleanup 隐藏新实例,
也避免 Renderer 重载后从零开始的数字序列无法覆盖旧租约。
非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏
收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。
浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。
切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B
查看会话 A 的浏览器时,viewport 和手动导航仍指向 A 的原 Tab。新建实例绑定 B。仅未绑定的
默认或恢复实例可在首次使用时取得当前会话绑定。
Tab 的跨会话可见性与 MCP 授权分开处理。B 的请求不能选择属于 A 的可见 Tab;A 已取得的
capability 在用户切到 B 后仍固定路由至 A 的原 Tab,使用租约也继续阻止关闭该 Tab。
## 5. IPC 与服务接口
共享契约增加有界操作:
```ts
browser.createTab({ conversationId, workbarInstanceId })
browser.listTabs({ conversationId })
browser.closeTab({ conversationId, tabId })
browser.setViewport({ conversationId, tabId, leaseToken, bounds })
browser.navigate({ conversationId, tabId, url })
browser.back({ conversationId, tabId })
browser.reload({ conversationId, tabId })
browser.stopLoading({ conversationId, tabId })
```
Main 必须验证 `tabId` 属于同一 `conversationId` 和当前窗口。BrowserService 将现有
Conversation 级 slot 拆为 Context 与 Tab 两层;同一 Tab 内操作继续串行,不同 Tab 可以并行,
但仍受窗口级和 Conversation 级有界数量限制。
现有 Conversation 级 `stop/release` 保留为销毁完整浏览器上下文的操作,不能用作关闭单个
Browser Tab。
## 6. Agent 与 MCP 路由
### 6.1 兼容阶段
第一阶段不修改模型可见的现有浏览器工具参数。模型请求开始时,Main 按以下顺序选择并冻结
`boundBrowserTabId`:
1. 当前可见且绑定该 Conversation 的 Browser Tab。
2. 该 Conversation 的 `primaryTabId`。
3. 新建一个 primary Browser Tab。
直连模型上下文和请求级 MCP capability 同时保存 `conversationId` 与
`boundBrowserTabId`。请求执行期间,用户切换、创建或关闭其他工作栏 Tab 不改变工具目标;
模型请求使用某个 Tab 时禁用该 Tab 的工作栏关闭操作,并说明请求仍在执行。用户先取消请求
后才能关闭;系统不得把进行中的操作改投其他 Tab。
该阶段 MCP transport、endpoint 和 token 生命周期不变,只扩展服务端 capability 内容和内部
路由。旧工具调用继续使用被冻结的 Tab。
### 6.2 Agent 多 Tab 阶段
只有明确需要 Agent 管理多个页面时,才增加:
- `browser_tab_list`
- `browser_tab_open`
- `browser_tab_close`
导航、snapshot、back 和 screenshot 增加可选 `tabId`;省略时使用请求绑定的默认 Tab。
`browser_snapshot` 返回 `tabId` 和 snapshot 代次。click、type 和 select 必须校验 capability、
`tabId`、引用代次和元素 `ref` 属于同一 Driver,禁止跨 Tab 猜测或解析引用。
## 7. 工作栏创建与快捷入口
- 从“+”选择浏览器时始终创建新工作栏实例,并默认绑定当前 Conversation。
- 没有活动 Conversation 时创建待绑定实例,不创建无归属 Main Browser Context。
- 主侧栏浏览器快捷按钮优先激活绑定当前 Conversation 的最近使用实例;没有匹配实例时创建
一个。没有活动 Conversation 时激活最近使用的浏览器实例;完全不存在时创建待绑定实例。
- 每个实例标题至少区分 Conversation 和当前页面;完整目标通过提示和可访问名称提供。
- 关闭工作栏 Browser Tab 只释放对应 Browser Tab。关闭浏览器上下文是独立危险操作,需要
明确说明会影响该 Conversation 下的全部 Tab 和 Agent 浏览器状态。
## 8. 持久化与恢复
持久化工作栏实例 ID、顺序、自定义标题和公开 Conversation 绑定,不持久化页面进程、CDP
引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示
未启动状态,首次导航或 Agent 使用时按原绑定创建新 Tab。
绑定的 Conversation 已删除或不可见时保留实例并显示目标失效,不自动绑定当前 Conversation。
## 9. 限制与验收
- 分别限制每个 Conversation 的 Tab 数和每个窗口的总活动 Tab 数;达到上限时在创建前反馈。
- 两个 Tab 的地址、历史、加载状态、截图和元素引用互不覆盖。
- 同一 Conversation 的 Tab 共享登录状态,不同 Conversation 保持隔离。
- 延迟 viewport cleanup 不能隐藏另一个已激活 Tab。
- 一个 Tab 的元素 `ref` 不能在另一个 Tab 中执行。
- 用户切换工作栏 Tab 不改变进行中模型请求的 MCP 浏览器目标。
- 关闭一个 Tab 不释放 Conversation Runtime,也不关闭同一 Conversation 的其他 Tab。
- 旧版无 `tabId` 的模型工具调用仍操作请求绑定的默认 Tab。
## 10. 实施顺序
1. 引入 Browser Context 与 Browser Tab 两层身份,拆分 partition 和页面生命周期。
2. 扩展 Shared、Preload、IPC 与 BrowserService 的 `tabId` 和 viewport `leaseToken`。
3. 改造 Renderer 状态与工作栏实例,开放浏览器 `multiple` 策略和单 Tab 关闭。
4. 将直连模型与 MCP capability 绑定到请求开始时确定的 Browser Tab。
5. 完成跨 Tab 引用隔离、并行队列、共享 Cookie、关闭和恢复测试。
6. 有明确 Agent 多页面需求后再开放模型可见的 Tab 管理工具。
+47 -23
View File
@@ -89,18 +89,20 @@ Runtime
```
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
- 用户主动打开、关闭、排序和停靠面板实例;应用不默认同时挂载全部能力。
- Task Center 是 Task 的单例应用级索引。每个 Task 只关联一条 Conversation,一条
Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通 Conversation、
Job、Run 或心跳事项。
- 用户主动打开、关闭、排序和停靠可关闭的面板实例;任务中心和工作区作为基础工作入口默认
存在且不可关闭。不可关闭不等于锁定顺序,排序能力由独立属性控制。
- Task Center 是默认跟随当前项目的单实例 Task 索引。每个 Task 只关联一条 Conversation,
一条 Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通
Conversation、Job、Run 或心跳事项。
- 当前能力、连接、数据和空状态可以动态变化。
- 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。
- 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。
- 同一能力需要并排比较不同目标时可以创建多个实例,每个实例拥有独立身份和范围绑定。
- 浏览器、终端及其他声明为多实例的能力可以创建多个实例,每个实例拥有独立身份和范围绑定。
### 3.2 当前上下文只提供默认值
任务中心作为全局索引不跟随当前会话,也不支持为同一列表打开多个目标实例。其他可绑定
任务中心只有一个工作栏实例,默认跟随当前项目,不跟随当前 Conversation。用户可以在同一
实例内显式切换到全局任务或全部项目,但不能为不同项目打开多个任务中心 Tab。其他可绑定
目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是
使用门槛:
@@ -212,17 +214,22 @@ Runtime
- 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。
- 已打开实例使用 `tablist`、`tab`、`tabpanel`;“+”是位于 `tablist` 外的普通命令按钮。
- 应用注册表声明 `single` 或 `multiple`。在工作栏“+”目录中重复选择单实例应用时聚焦已有
实例;选择多实例应用时每次创建新实例。主侧栏系统工具快捷入口可以聚焦当前执行空间中
最近使用的终端,额外终端仍从“+”目录创建。
- 支持方向键、Home、End、Enter、Space、关闭面板和正确焦点恢复。
- 应用注册表分别声明实例策略、默认打开、必须存在、是否可关闭和是否允许排序。`single` 或
`multiple` 只控制实例数量;`required` 控制布局修复,不能兼作关闭或排序策略。
- 在工作栏“+”目录中重复选择单实例应用时聚焦已有实例;选择浏览器、终端等多实例应用时
每次创建新实例。主侧栏系统工具快捷入口按当前上下文聚焦匹配实例,不能因连续点击无意
创建重复实例。
- 支持方向键、Home、End、Enter、Space、可关闭面板的关闭命令和正确焦点恢复。不可关闭
实例不渲染关闭按钮,也不响应关闭快捷命令。
- 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。
- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭实例后能力仍可从目录重新打开。
- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭可关闭实例后能力仍可从目录
重新打开。布局加载和恢复默认布局必须补回缺失的任务中心与工作区实例。
- 默认布局只恢复经过产品确认的少量常用面板,不自动打开全部标准能力。
- 每个实例显示稳定实例 ID、能力名称、跟随或固定状态与当前目标;同能力多实例不能只靠位置区分。
从当前聊天右栏迁移时,Task Center、工作区、浏览器和成果作为默认打开的单实例应用保留;
终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动,
从当前聊天右栏迁移时,Task Center 和工作区作为默认打开、单实例且不可关闭的基础应用保留;
浏览器保留一个默认打开实例,但注册为可关闭的多实例应用;成果保持默认打开、单实例且可
关闭;终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动,
不能自动隐藏或缩写到不可辨认,“+”不能随滚动消失。Task Center 继续作为 Task 的现有
入口;审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和
完整配置统一归属“智能心跳”菜单入口。当前阶段不新增独立自动化中心。
@@ -260,8 +267,13 @@ Task Center 保留为工作栏中的稳定入口,并在现有基础上适度
活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。
- 智能心跳的报告、建议、历史和配置不进入任务中心。
任务中心是单例索引,不使用其他能力的“跟随 / 固定目标”多实例模型。后台状态可以更新
徽标和排序,但不能自动打开面板或抢占用户当前工作。
任务中心是应用级单例,默认存在且不可关闭。它的数据范围默认跟随当前项目,并在标题或范围
控件中明确显示项目;用户可以切换到“全局任务”或“全部项目”,该选择只改变同一实例的列表
范围,不创建第二个任务中心 Tab。切换回“跟随当前项目”后,项目变化立即刷新列表。后台状态
可以更新徽标和排序,但不能抢占用户当前工作。
当前实现已按活动项目过滤 Scheduled Task 和审批,并提供“当前项目”“全局任务”“全部项目”
范围控件。
主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用
任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。
@@ -356,6 +368,10 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
### 7.6 工作区
工作区是默认存在、单实例且不可关闭的基础应用。它默认跟随当前项目;项目或目录不可用时
保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。用户仍可调整该 Tab 的顺序,
除非后续单独引入顺序锁定属性。
工作区面板允许用户选择:
- 当前项目目录。
@@ -429,10 +445,14 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
### 7.7 浏览器
浏览器是可关闭的多实例应用。升级后的默认布局保留一个浏览器实例;用户从“+”目录选择
浏览器时每次创建新的 Browser Tab。每个实例必须显示可区分的标题和绑定 Conversation,
不能让多个工作栏 Tab 只是同一页面的重复控制入口。
浏览器能力在目录中稳定可发现,打开面板后允许用户:
- 创建新的 GoodBuddy 隔离浏览器会话。
- 选择当前会话或其他受控浏览器会话。
- 在当前 Conversation 的隔离浏览器上下文中创建独立 Browser Tab。
- 选择当前 Conversation 或其他允许访问的 Conversation 作为实例目标。
- 查看状态、当前 URL、有界画面和错误。
- 通过顶部浏览器工具栏返回、刷新或停止加载、编辑地址并前往。
- 进入明确的交互模式或关闭浏览器。
@@ -441,10 +461,11 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
页面加载期间原位切换为“停止加载”;“关闭浏览器”是清楚独立于导航的会话生命周期操作,
必须与“停止加载”在文案和视觉上明确区分。现有“停止浏览器”语义统一改名为“关闭浏览器”。
浏览器面板和 Agent 操作同一条按 Conversation 归属的浏览器会话,不创建用户预览专用的
第二个页面。用户执行前往、返回或刷新会有意改变 Agent 下一步读取和操作的页面。所有
前往、返回和刷新请求都进入 `BrowserService`,并与 Agent 发起的浏览器操作串行执行;不在
Renderer 建立第二套导航队列、抢占规则或恢复状态机。
同一 Conversation 下的 Browser Tab 共享隔离存储和代理上下文,但每个 Tab 拥有独立页面、
导航状态、操作队列和元素引用空间。模型请求开始时绑定一个确定的 Browser Tab;用户切换
工作栏实例不能在请求途中改变 Agent 的工具目标。用户在该实例执行前往、返回或刷新会有意
改变绑定到同一 Tab 的 Agent 后续操作。所有请求都进入 `BrowserService`;Renderer 不建立
第二套导航队列、抢占规则或恢复状态机。
浏览器工具栏读取的权威状态至少包含:
@@ -472,7 +493,10 @@ type BrowserToolbarState = {
没有浏览器会话时仍显示工具栏。活动 Conversation 可以直接在地址栏输入非空 URL 并“前往”,
由 `BrowserService` 通过 Agent 浏览器相同的能力检查和 URL 策略创建会话并导航;不得为这一
空状态增加旁路 WebView 或宽松 URL 入口。没有活动 Conversation 时说明需要先选择或创建会话。
模型或后台浏览器活动可以更新徽标,但不得无条件打开面板或切换用户当前面板。
模型或后台浏览器活动可以更新对应实例的徽标,但不得无条件打开面板或切换用户当前面板。
浏览器多实例的跨进程身份、可见 viewport 所有权、关闭语义和 MCP 兼容方案以
[浏览器多实例技术设计](./browser-tabs-technical-design.md)为准。
浏览器面板只管理 GoodBuddy 受控浏览器,不表示可以控制用户已安装的浏览器。
@@ -1065,7 +1089,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
动态 Tab 以及本机与直接 SSH 多终端。
- [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 定义 Task、Conversation、
Job、Subjob、Run 与 Subagent。
- [Task Center PRD](../task-and-job/task-center-prd.md) 定义应用级 Task 索引。
- [Task Center PRD](../task-and-job/task-center-prd.md) 定义单实例、项目跟随和显式范围切换。
- [智能心跳 PRD](../smart-heartbeat/prd.md) 定义心跳入口、范围和长期边界。
- [会话监督 PRD](../conversation-supervision/prd.md) 定义监督判断、证据、预算和介入边界。
- [自动化平台总体设计](../../architecture/automation-platform-architecture.md) 定义 Plan、Job、Run、监督、预算和记忆。
@@ -1,5 +1,29 @@
# 工作栏实现与验证进度
## 2026-09-10 固定基础页签与浏览器多实例
- 工作栏注册表已分别声明实例策略、默认上下文、默认打开、必须存在、可关闭和排序属性。
任务中心与工作区保持默认存在且不可关闭;浏览器改为当前 Conversation 上下文的多实例。
布局加载按 `required` 补回缺失的基础页签,并修复失效活动实例。
- 任务中心增加“当前项目”“全局任务”“全部项目”范围,默认跟随活动项目;任务和审批按
Conversation 所属项目过滤,范围模式随工作栏布局持久化。
- 浏览器服务已拆分 Conversation Context 与 Browser Tab。每个 Tab 拥有独立页面、Driver、
操作队列、状态和元素引用空间,同一 Conversation 的 Tab 共享 partition、代理和 Cookie。
- Renderer 使用工作栏实例 UUID 原子创建或恢复 Browser Tab,并以 UUID viewport token 防止
延迟 cleanup 隐藏新活动 Tab。关闭单个实例只释放对应 Tab。
- 直连模型和请求级 MCP capability 在请求开始时优先绑定可见 Tab,其次绑定 primary Tab,
并持有使用租约。请求结束或 capability 撤销前,绑定 Tab 拒绝关闭;工具参数未增加模型可见
`tabId`,已有浏览器工具 schema 保持不变。
- 聚焦回归命令覆盖 Shared、BrowserService、Electron Session、IPC、Preload、MCP、Renderer
和 Workbar,13 个测试文件共 453 项通过;`npm run lint` 与 Web TypeScript 检查通过。
- `node build/run-browser-tabs-electron-e2e.cjs` 通过。该测试启动真实 Electron/Chromium 和本地
HTTP 页面,创建两个 `WebContentsView`,验证页面隔离、同 Conversation Cookie 共享、真实
loopback MCP 的固定 Tab 导航与快照、使用租约关闭保护,以及单 Tab 和最终 Context 清理。
- `npm run typecheck`、`npm run lint`、`npm run build:bundle` 和 `git diff --check` 通过。
- 本轮整库 `npm test` 为 3686 项通过、61 项跳过、4 项失败。失败位于 portable/release 的
ASAR 元数据 fixture、Runtime 版本探测和 DeepSeek Harness 的本地 MCP fixture;定向复跑仍
失败。本次工作栏、浏览器、IPC 和 MCP 聚焦回归均通过,不将整库结果记为通过。
## 2026-09-09 移动目录选择与 Git 控件修正
- 本轮在已有文件管理改动上修正 [FR-W3、FR-W4、FR-W6](./prd.md#76-工作区):
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施,跨平台验证中 |
| 版本 | 1.1 |
| 日期 | 2026-08-31 |
| 版本 | 1.2 |
| 日期 | 2026-09-10 |
| 适用产品 | GoodBuddy 桌面端 |
| 界面归属 | [通用助手工作栏与执行空间](./prd.md) |
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
@@ -18,7 +18,7 @@
- 已打开应用以同级 Tab 呈现。
- Tab 行始终提供“+”入口,用户按需打开应用。
- 应用通过统一注册表声明单实例或多实例策略。
- 应用通过统一注册表分别声明单实例或多实例、默认打开、可关闭和排序策略。
- “终端”作为首个多实例应用,自动使用当前项目的本机或托管 SSH 执行空间。
- 本机与远程交互式终端的生命周期、状态、性能和验收要求。
@@ -31,7 +31,7 @@
当前工作栏在聊天页面右侧固定显示“任务中心、工作区、浏览器、成果”四个 Tab。用户不能:
- 通过统一入口按需打开新的工作栏应用。
- 关闭并重新打开一个工作栏应用。
- 按应用策略关闭并重新打开可关闭的工作栏应用。
- 为支持多实例的应用创建第二个独立实例。
- 在 GoodBuddy 内使用本机或远程 Host 的交互式 Shell。
@@ -49,17 +49,51 @@ SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端
- 稳定应用 ID。
- 用户可见名称、图标和说明。
- `single` 或 `multiple` 实例策略。
- 是否默认打开、是否必须存在、是否可关闭和是否允许排序;这些属性彼此独立。
- 创建实例时可用的默认上下文。
- 当前不可用时的原因和修复入口。
目录中的应用可发现性与实例是否已经打开相互独立。应用不可用时保留入口并说明原因,不以
隐藏代替状态。
注册表字段如下:
| 字段 | 类型或允许值 | 含义 |
| --- | --- | --- |
| `id` | 稳定应用 ID | 关联图标、面板实现和持久化实例 |
| `label` | 有界文本 | 默认 Tab 名称和目录名称 |
| `icon` | 稳定图标 ID | 应用目录和 Tab 图标 |
| `description` | 有界文本 | 目录说明和完整提示 |
| `instancePolicy` | `single`、`multiple` | 同一应用允许一个还是多个实例 |
| `defaultContext` | `application`、`current-project`、`current-conversation` | 创建实例时使用的默认范围,不代表实例随后必须跟随 |
| `visibleAcrossContextSwitches` | boolean | 切换项目或会话后,已打开的 Tab 是否继续留在工作栏中 |
| `defaultOpen` | boolean | 新布局是否创建默认实例 |
| `required` | boolean | 布局加载时实例缺失是否必须补回 |
| `closable` | boolean | 是否渲染关闭入口并接受关闭命令 |
| `reorderable` | boolean | 是否允许用户改变 Tab 顺序 |
| `availability` | `available` 或带原因和修复入口的 `unavailable` | 当前能力状态 |
`defaultOpen`、`required` 和 `closable` 不互相推导。例如浏览器默认打开但不是必需实例;任务
中心和工作区既默认打开又必须存在。`required` 不表示锁定顺序,顺序只由 `reorderable` 控制。
当前五个应用的 `visibleAcrossContextSwitches` 均为 `true`。该属性记录应用目录的显示策略,
不改变内容归属,也不授予其他会话的 MCP 访问权限;当前没有使用 `false` 的应用,尚未实现
按上下文隐藏 Tab 的策略。
浏览器在创建时绑定当前 Conversation;切换会话后仍保留原 Tab、选中状态和页面,导航操作
继续使用原 `targetRef.conversationId`。此时新建浏览器才绑定新会话。默认或旧布局中尚未绑定
的浏览器在首次使用时绑定活动会话,此后不再随会话切换改绑。终端同样保留创建时执行目标;
任务中心、工作区和成果的内容继续跟随当前项目。
### 3.2 工作栏 Tab 实例
工作栏 Tab 是应用的一次打开实例。每个实例具有独立 ID、标题、应用类型和状态。Tab 行只
显示已经打开的实例,不把全部能力同时渲染为 Tab。
实例持久化字段只有 `id`、`appId`、`title` 和可选 `targetRef`。`targetRef` 使用公开引用:
终端接受 `local` 或 `projectId`,浏览器接受 `conversationId`。Main 签发的 Browser Tab ID、
Terminal Session ID、元素引用、凭据、路径和运行状态不进入工作栏布局。
### 3.3 终端实例
每个终端 Tab 对应一个独立交互式 Shell Session:
@@ -81,10 +115,10 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。
6. 多实例应用每次选择都创建新实例。
7. 终端从首期开始就是多实例应用。
8. 任务中心、工作区、浏览器和成果首期保持单实例;后续只需修改应用注册表策略即可开放
多实例,不重写 Tab 壳层。
9. 现有四个应用在升级后仍按当前默认布局打开,避免用户入口突然消失。
10. 用户关闭、切换或重排应用 Tab 后,“+”仍保持可见。
8. 任务中心和工作区保持单实例、默认打开且不可关闭;不可关闭不自动禁止排序。
9. 浏览器注册为可关闭的多实例应用,升级后保留一个默认实例;成果保持单实例、默认打开且
可关闭。
10. 用户关闭可关闭实例、切换或重排应用 Tab 后,“+”仍保持可见。
11. 选择“终端”后不再显示第二层目标选择器。存在当前项目时直接绑定该项目,由 Main
根据项目执行空间自动打开本机或托管 SSH Shell;没有当前项目时打开本机 Home Shell。
12. 远程首期使用直接 SSH PTY。SSH 断线后终端进入“已中断”,重新连接会创建一个新的
@@ -109,7 +143,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
### 5.2 产品目标
- 用一个可扩展的应用注册表统一单实例与多实例行为。
- 把现有固定四 Tab 迁移为用户实例,而不回归现有任务中心、工作区、浏览器和成果能力。
- 把现有 Tab 迁移到统一实例模型,同时保持任务中心和工作区始终存在,并允许浏览器和终端
创建多个实例。
- 建立真实的 Main/Preload/Renderer 终端生产链路,而不是用普通文本框模拟终端。
- 复用现有 SSH Host Key、凭据和连接池,不建立第二套 Host 管理。
- 保持终端输入输出有序、响应流畅、内存有界,并可靠清理本机和远程资源。
@@ -149,7 +184,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。
- Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角,
保持紧凑的停靠式终端界面。
- Tab 关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘焦点进入对应 Tab 时出现;
- 只有可关闭实例渲染 Tab 关闭按钮。关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘
焦点进入对应 Tab 时出现;
粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。
### 7.2 新建应用选择页
@@ -160,31 +196,34 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
2. 在工作栏内容区显示“新建工作栏应用”。
3. 列出稳定应用目录,每项显示名称、说明、单/多实例提示和当前可用状态。
4. 初始焦点进入第一个可用应用;Escape 或“取消”返回原活动实例和“+”按钮。
5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab。
6. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户
5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab;任务中心和工作区始终走此路径。
6. 选择浏览器时创建绑定当前 Conversation 的新 Browser Tab;没有活动 Conversation 时创建
待选择目标的空实例。
7. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户
重复选择本机或远程目标。
选择页是“+”触发的临时界面,不进入实例顺序,不持久化,也不占用一个普通 Tab。
### 7.3 首期应用注册表
| 应用 | 实例策略 | 升级后默认状态 | 再次从“+”选择 |
| --- | --- | --- | --- |
| 任务中心 | `single` | 打开 | 聚焦已有 Tab |
| 工作区 | `single` | 打开 | 聚焦已有 Tab |
| 浏览器 | `single` | 打开 | 聚焦已有 Tab |
| 成果 | `single` | 打开 | 聚焦已有 Tab |
| 终端 | `multiple` | 不打开 | 每次创建新 Tab |
| 应用 | 实例策略 | 默认上下文 | 默认打开 | 必须存在 | 可关闭 | 可排序 | 再次从“+”选择 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab |
| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab |
| 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab |
| 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab |
| 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab |
实例策略是应用注册数据,不能分散硬编码在多个点击处理器中。
实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。
### 7.4 Tab 操作
- 点击或使用方向键切换实例不会暂停或终止后台实例。
- 支持左右方向键、Home、End 和 roving `tabindex`。
- 关闭非活动 Tab 后焦点保持在当前活动 Tab。
- 关闭活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。
- 关闭最后一个实例后显示明确空状态和“打开应用”,Tab 行仍保留“+”。
- 任务中心和工作区没有关闭按钮,也不响应关闭快捷命令。
- 关闭可关闭的非活动 Tab 后焦点保持在当前活动 Tab。
- 关闭可关闭的活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。任务中心和工作区
始终存在,因此正常布局不会进入零实例空状态。
- 关闭正在运行的终端需要确认“关闭终端将结束 Shell 及其子进程”;取消是初始焦点。
- 已退出或已中断的终端可以直接关闭,不重复确认。
@@ -284,21 +323,45 @@ type TerminalSessionState =
```ts
type WorkbarAppDefinition = {
id: 'tasks' | 'workspace' | 'browser' | 'results' | 'terminal'
label: string
icon: WorkbarAppDefinition['id']
description: string
instancePolicy: 'single' | 'multiple'
defaultContext: 'application' | 'current-project' | 'current-conversation'
defaultOpen: boolean
required: boolean
closable: boolean
reorderable: boolean
availability:
| { state: 'available' }
| { state: 'unavailable'; reason: string; remedy: string }
}
type WorkbarTabInstance = {
id: string
appId: WorkbarAppDefinition['id']
title: string
targetRef?: {
type: 'project' | 'local'
id?: string
}
targetRef?:
| { type: 'local' }
| { type: 'project'; projectId: string }
| { type: 'conversation'; conversationId: string }
}
type WorkbarLayoutPreferences = {
instances: WorkbarTabInstance[]
activeInstanceId: string | null
expanded: boolean
dock: 'right'
widthRatio: number
taskScope: 'current-project' | 'global' | 'all-projects'
}
```
- 持久化工作栏打开状态、Tab 顺序、活动实例、应用 ID、自定义标题、公开目标引用和宽度比例。
- 加载布局时根据 `required` 补回缺失实例,并拒绝重复的单实例应用;应用策略不写入实例
数据,避免持久化状态覆盖注册表。
- `reorderable` 已进入共享契约;当前 Tab 壳层尚未提供拖放或键盘重排,不能把声明属性视为
已完成的排序功能。
- 不持久化终端正文、未执行输入、凭据、环境变量、PTY PID、SSH Channel 或派生 Host
identity。
- Main 创建终端后返回新的不透明 Session ID;Renderer 不能选择 PID 或 Channel。
@@ -474,16 +537,19 @@ Schema、假数据终端或不可用入口的中间产品。
### 18.1 工作栏应用与实例
- [ ] Tab 行末始终显示“+”,包括没有任何打开实例时。
- [ ] Tab 行末始终显示“+”;任务中心和工作区始终存在,因此正常布局不存在零实例状态。
- [ ] 点击“+”显示稳定应用目录,取消后返回原实例并恢复焦点。
- [ ] 选择已打开单实例应用只聚焦已有 Tab。
- [x] 任务中心和工作区默认存在且不可关闭,不渲染关闭按钮,也不响应关闭快捷命令。
- [x] 连续选择浏览器会创建页面、导航状态和元素引用相互独立的浏览器 Tab。
- [x] 连续选择终端会创建多个互相独立的终端 Tab。
- [ ] 关闭、切换和重排实例后,“+”仍保持可用。
- [ ] 关闭可关闭实例、切换和重排实例后,“+”仍保持可用。
- [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。
- [x] 关闭活动 Tab 后激活正确的相邻 Tab 并恢复焦点。
- [x] 关闭可关闭的活动 Tab 后激活正确的相邻 Tab 并恢复焦点。
- [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停
设备上出现,且不引发布局位移。
- [ ] 升级后现有任务中心、工作区、浏览器和成果仍按默认布局打开且行为不回归。
- [x] 升级后任务中心、工作区、一个浏览器实例和成果按默认布局打开;布局恢复会补回缺失的
任务中心与工作区。
- [ ] 在所有主要页面都能打开工作栏;切换主要页面不会销毁 Tab 实例或终端 Session。
### 18.2 本机终端
@@ -522,7 +588,8 @@ Schema、假数据终端或不可用入口的中间产品。
### 18.5 设计、无障碍和跨平台
- [ ] 仅使用键盘可以打开应用、创建终端、切换和关闭 Tab、复制、粘贴及重新连接。
- [ ] 仅使用键盘可以打开应用、创建浏览器和终端、切换 Tab、关闭可关闭 Tab、复制、粘贴及
重新连接。
- [ ] Tab、“+”、选择页和关闭确认使用正确语义并有可见焦点。
- [ ] 终端输出不逐行进入 live region,关键状态变化可被辅助技术感知。
- [ ] 浅色、深色、200% 文字缩放和窄窗口下可以完成核心流程。
@@ -535,7 +602,9 @@ Schema、假数据终端或不可用入口的中间产品。
### 19.1 自动验证
- 共享 Zod Schema 和边界值测试。
- 单/多实例注册策略、Tab 顺序、关闭焦点和“+”选择页组件测试。
- 单/多实例、默认打开、可关闭和排序策略,以及 Tab 顺序、关闭焦点和“+”选择页组件测试。
- 任务中心与工作区缺失时的布局修复、关闭命令拒绝和焦点保持测试。
- 浏览器多实例的页面隔离、Conversation 匹配和快捷入口去重测试。
- 本机 PTY Session Manager 的输入、resize、退出、幂等关闭和进程树测试。
- SSH PTY 的 Host identity、lease、断线、resize 和关闭测试。
- Main trusted sender、Session 所有权、IPC 输入边界和事件路由测试。
+1 -1
View File
@@ -5,7 +5,7 @@
## 权威文档
1. [Task 与 Job 统一领域模型](./task-and-job-model.md):术语、身份和对象关系。
2. [Task Center PRD](./task-center-prd.md):Task 的应用级索引。
2. [Task Center PRD](./task-center-prd.md):Task 的单实例项目跟随索引与范围切换。
3. [Scheduled Task PRD](./scheduled-task-prd.md):时间或事件触发的 Task。
4. [Goal Task PRD](./goal-task-prd.md):围绕可验证结果有界推进的 Task。
5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。
+29 -10
View File
@@ -4,20 +4,24 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | Scheduled Task 首期已实现;Goal/Event Task 与完整操作待实施 |
| 版本 | 0.3 |
| 日期 | 2026-08-19 |
| 状态 | Scheduled Task 与项目范围跟随已实现;Goal/Event Task 与完整操作待实施 |
| 版本 | 0.4 |
| 日期 | 2026-09-10 |
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
| 界面归属 | [通用助手工作栏与执行空间](../assistant-workbar/prd.md) |
## 1. 产品定义
Task Center 是所有产品级 Task 的应用级单例索引,不是 Automation Center,也不复制
Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。
Task Center 是工作栏中的单实例 Task 索引,默认跟随当前项目。它不是 Automation Center,
也不复制 Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。
每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。
Conversation 不因为关联 Task 而改变对象类型。
“单实例”和“项目范围”是两个独立属性:应用内只有一个 Task Center Tab,但该 Tab 默认展示
当前项目的 Task。用户可以在同一 Tab 内切换到“全局任务”或“全部项目”,不能为每个项目创建
独立 Task Center 实例。
## 2. 收录边界
收录:
@@ -53,7 +57,19 @@ Job/Subjob/Run 树、独立详情或路由。
Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。
## 4. 交互
## 4. 范围
范围控件提供:
- `当前项目`:默认值,跟随活动项目变化,只显示 `projectId` 匹配的 Task。
- `全局任务`:只显示没有 `projectId` 的 Task。
- `全部项目`:显示当前用户可见的项目 Task 和全局 Task,并保留每行的项目或 Global 标识。
没有活动项目时,“当前项目”显示明确空状态,并允许切换到“全局任务”或“全部项目”;系统不
自动改变用户已经选择的范围。范围模式作为工作栏公开偏好持久化,但“当前项目”模式不保存
具体项目 ID;重启后继续跟随届时的活动项目。
## 5. 交互
- 点击条目打开关联 Conversation,并定位到该 Task。
- 支持按需要关注、进行中、已暂停、已结束筛选。
@@ -63,7 +79,7 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看;
独立交付物在成果中查看。
## 5. 左侧 Conversation Task 列表
## 6. 左侧 Conversation Task 列表
左侧最近会话列表承担轻量 Task 发现,不替代 Task Center:
@@ -88,7 +104,7 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
- 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。
- 默认最多显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整任务区。
## 6. Conversation 任务区
## 7. Conversation 任务区
包含 Task 的 Conversation 顶部显示可折叠任务区:
@@ -100,14 +116,14 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
选择 Task 后显示名称、模式、聚合状态、计划、下次执行、最近结果和 Task 级操作。工具、审批、
错误和成果通过 Task 关联显示,但不暴露 Job/Run 层级。
## 7. 删除关系
## 8. 删除关系
- 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。
- 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。
- 删除最后一个 Task 后,左侧 Conversation 的展开按钮消失。
- 删除 Conversation 前必须显示关联 Task 数量,并先停止或结算活动执行。
## 8. 验收标准
## 9. 验收标准
- [x] Task Center 只展示产品级 Task。
- [x] 一条 Conversation 可以关联并展开多个 Task。
@@ -118,3 +134,6 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
- [x] 普通模型请求和工具调用不会误显示为 Task。
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
- [x] Smart Heartbeat 不进入 Task Center。
- [x] Task Center 默认只显示当前项目的 Task,切换项目后在同一 Tab 更新列表。
- [x] “全局任务”只显示无项目 Task,“全部项目”显示所有可见 Task 并标注所属范围。
- [x] 范围切换不创建第二个 Task Center Tab;任务中心保持默认存在且不可关闭。
@@ -5,7 +5,6 @@ import { join, relative } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import {
DIRECT_MODEL_PROCESS_TRUNCATION_MARKER,
LocalDirectModelProcessService,
buildDirectModelProcessEnvironment,
type DirectModelProcessChild,
@@ -277,7 +276,7 @@ describe('LocalDirectModelProcessService', () => {
await service.dispose()
})
it('returns nonzero exits normally and bounds stdout and stderr by retaining both edges', async () => {
it('returns nonzero exits with bounded previews and retains full output for paging', async () => {
const { access } = await workspace()
const child = new FakeChild()
let now = 1_000
@@ -310,12 +309,19 @@ describe('LocalDirectModelProcessService', () => {
expect(result.durationMs).toBe(125)
expect(result.stdoutTruncated).toBe(true)
expect(result.stdout).toBe(
`${'A'.repeat(48 * 1024)}` +
DIRECT_MODEL_PROCESS_TRUNCATION_MARKER +
'Z'.repeat(48 * 1024)
'A'.repeat(48 * 1024) + 'M'.repeat(10) + 'Z'.repeat(48 * 1024 - 10)
)
expect(result.stderr).toBe('ordinary error output')
expect(result.stderrTruncated).toBe(false)
expect(result.stderrReference).toBeUndefined()
const reference = result.stdoutReference!
expect(reference.totalBytes).toBe(96 * 1024 + 10)
await expect(service.readOutput('output', reference.handle, reference.nextCursor)).resolves.toMatchObject({
content: 'Z'.repeat(10), eof: true, nextCursor: reference.totalBytes
})
await expect(service.readOutput('other', reference.handle)).rejects.toThrow()
await service.releaseConversation('output')
await expect(service.readOutput('output', reference.handle)).rejects.toThrow()
await service.dispose()
})
@@ -439,14 +445,18 @@ describe('LocalDirectModelProcessService', () => {
}
)
const first = run('first')
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
const second = run('second')
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2))
firstChild.stdout.emit('data', 'x'.repeat(120000))
secondChild.stderr.emit('data', 'y'.repeat(120000))
await service.releaseConversation('first')
expect(terminated).toHaveLength(1)
await expect(first).resolves.toMatchObject({
terminationReason: 'cancelled'
})
await expect(service.readOutput('first', (await first).stdoutReference!.handle)).rejects.toThrow()
const replacementChild = new FakeChild()
children.push(replacementChild)
const replacement = run('first')
@@ -464,6 +474,7 @@ describe('LocalDirectModelProcessService', () => {
await expect(second).resolves.toMatchObject({
terminationReason: 'cancelled'
})
await expect(service.readOutput('second', (await second).stderrReference!.handle)).rejects.toThrow()
await service.dispose()
})
@@ -496,4 +507,37 @@ describe('LocalDirectModelProcessService', () => {
expect(result.stderr).toContain('warn')
await service.dispose()
})
it('pages complete stdout and stderr from a real shell', async () => {
const { access } = await workspace()
const service = new LocalDirectModelProcessService()
try {
expect((await service.getCapability()).available).toBe(true)
const command = process.platform === 'win32'
? "[Console]::Out.Write(('x' * 120000) + 'stdout-end'); [Console]::Error.Write(('y' * 120000) + 'stderr-end')"
: "printf '%120000sstdout-end' '' | tr ' ' x; printf '%120000sstderr-end' '' | tr ' ' y >&2"
const result = await service.execute({ command }, {
conversationId: 'real-pages', workspace: access,
signal: new AbortController().signal
})
for (const [preview, reference, expected] of [
[result.stdout, result.stdoutReference, 'x'.repeat(120000) + 'stdout-end'],
[result.stderr, result.stderrReference, 'y'.repeat(120000) + 'stderr-end']
] as const) {
expect(reference).toBeDefined()
let output = preview
let cursor = reference!.nextCursor
while (cursor < reference!.totalBytes) {
const page = await service.readOutput('real-pages', reference!.handle, cursor, 4096)
output += page.content
cursor = page.nextCursor
}
expect(output).toBe(expected)
}
await service.dispose()
await expect(service.readOutput('real-pages', result.stdoutReference!.handle)).rejects.toThrow()
} finally {
await service.dispose()
}
})
})
+64 -96
View File
@@ -15,16 +15,18 @@ import {
type WaitableProcessTreeChild
} from './child-process-termination'
import { buildCredentialFilteredUserEnvironment } from './process-environment'
import {
PagedOutputStore,
type PagedOutputPage,
type PagedOutputReference,
type PagedOutputWriter
} from './paged-output-store'
const DEFAULT_TIMEOUT_MS = 120_000
const MAXIMUM_TIMEOUT_MS = 10 * 60_000
const MAXIMUM_OUTPUT_BYTES = 96 * 1024
const RETAINED_OUTPUT_EDGE_BYTES = MAXIMUM_OUTPUT_BYTES / 2
const TERMINATION_WAIT_MS = 2_000
export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER =
'\n...[GoodBuddy output truncated]...\n'
export const processExecuteInputSchema = z
.object({
command: z.string().trim().min(1).max(100_000),
@@ -70,6 +72,8 @@ export type ProcessExecuteResult = {
stderr: string
stdoutTruncated: boolean
stderrTruncated: boolean
stdoutReference?: PagedOutputReference
stderrReference?: PagedOutputReference
}
export type DirectModelProcessCapability =
@@ -94,6 +98,7 @@ export interface DirectModelProcessService {
input: ProcessExecuteInput,
context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult>
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage>
releaseConversation(conversationId: string): Promise<void>
dispose(): Promise<void>
}
@@ -271,67 +276,6 @@ async function resolveProcessShell(
return undefined
}
class BoundedProcessOutput {
private prefix = Buffer.alloc(0)
private suffix = Buffer.alloc(0)
private totalBytes = 0
append(chunk: Buffer | string): void {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
this.totalBytes += value.byteLength
if (this.prefix.byteLength < RETAINED_OUTPUT_EDGE_BYTES) {
const remaining =
RETAINED_OUTPUT_EDGE_BYTES - this.prefix.byteLength
this.prefix = Buffer.concat([
this.prefix,
value.subarray(0, remaining)
])
}
if (value.byteLength >= RETAINED_OUTPUT_EDGE_BYTES) {
this.suffix = Buffer.from(
value.subarray(value.byteLength - RETAINED_OUTPUT_EDGE_BYTES)
)
return
}
const combined = Buffer.concat([this.suffix, value])
this.suffix =
combined.byteLength <= RETAINED_OUTPUT_EDGE_BYTES
? combined
: Buffer.from(
combined.subarray(
combined.byteLength - RETAINED_OUTPUT_EDGE_BYTES
)
)
}
result(): { text: string; truncated: boolean } {
if (this.totalBytes > MAXIMUM_OUTPUT_BYTES) {
return {
text: Buffer.concat([
this.prefix,
Buffer.from(DIRECT_MODEL_PROCESS_TRUNCATION_MARKER),
this.suffix
]).toString('utf8'),
truncated: true
}
}
const overlap = Math.max(
0,
this.prefix.byteLength +
this.suffix.byteLength -
this.totalBytes
)
return {
text: Buffer.concat([
this.prefix,
this.suffix.subarray(overlap)
]).toString('utf8'),
truncated: false
}
}
}
function shellArguments(
shell: ResolvedProcessShell,
command: string
@@ -399,6 +343,7 @@ export class LocalDirectModelProcessService
implements DirectModelProcessService
{
private readonly activeCalls = new Map<string, Set<ActiveCall>>()
private readonly outputs = new PagedOutputStore('process')
private readonly shell: Promise<ResolvedProcessShell | undefined>
private disposed = false
private disposePromise?: Promise<void>
@@ -496,31 +441,46 @@ export class LocalDirectModelProcessService
platform
)
const startedAt = (this.options.now ?? Date.now)()
let child: DirectModelProcessChild
try {
child = (
this.options.spawnProcess ??
(spawn as unknown as DirectModelProcessSpawn)
)(shell.executable, shellArguments(shell, input.command), {
cwd: workingDirectory.canonicalPath,
detached: platform !== 'win32',
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
throw new Error('无法启动命令 Shell', { cause: error })
}
return await this.waitForProcess(
child,
shell,
workingDirectory.displayPath,
input.timeoutMs,
signal,
startedAt
const stdout = await this.outputs.create(
context.conversationId, MAXIMUM_OUTPUT_BYTES
)
let stderr: PagedOutputWriter | undefined
try {
stderr = await this.outputs.create(
context.conversationId, MAXIMUM_OUTPUT_BYTES
)
signal.throwIfAborted()
let child: DirectModelProcessChild
try {
child = (
this.options.spawnProcess ??
(spawn as unknown as DirectModelProcessSpawn)
)(shell.executable, shellArguments(shell, input.command), {
cwd: workingDirectory.canonicalPath,
detached: platform !== 'win32',
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
throw new Error('无法启动命令 Shell', { cause: error })
}
return await this.waitForProcess(
child,
shell,
workingDirectory.displayPath,
input.timeoutMs,
signal,
startedAt,
stdout,
stderr
)
} catch (error) {
await Promise.all([stdout.abort(), stderr?.abort()])
throw error
}
}
private waitForProcess(
@@ -529,10 +489,10 @@ export class LocalDirectModelProcessService
cwd: string,
timeoutMs: number,
cancellationSignal: AbortSignal,
startedAt: number
startedAt: number,
stdout: PagedOutputWriter,
stderr: PagedOutputWriter
): Promise<ProcessExecuteResult> {
const stdout = new BoundedProcessOutput()
const stderr = new BoundedProcessOutput()
child.stdout?.on('data', (chunk) => stdout.append(chunk))
child.stderr?.on('data', (chunk) => stderr.append(chunk))
@@ -550,9 +510,9 @@ export class LocalDirectModelProcessService
child.stdout?.removeListener?.('error', failFromOutput)
child.stderr?.removeListener?.('error', failFromOutput)
}
const result = (): ProcessExecuteResult => {
const stdoutResult = stdout.result()
const stderrResult = stderr.result()
const result = async (): Promise<ProcessExecuteResult> => {
const stdoutResult = await stdout.finish()
const stderrResult = await stderr.finish()
return {
shell: { kind: shell.kind, label: shell.label },
cwd,
@@ -566,7 +526,9 @@ export class LocalDirectModelProcessService
stdout: stdoutResult.text,
stderr: stderrResult.text,
stdoutTruncated: stdoutResult.truncated,
stderrTruncated: stderrResult.truncated
stderrTruncated: stderrResult.truncated,
...(stdoutResult.reference ? { stdoutReference: stdoutResult.reference } : {}),
...(stderrResult.reference ? { stderrReference: stderrResult.reference } : {})
}
}
const finishResult = (): void => {
@@ -682,6 +644,11 @@ export class LocalDirectModelProcessService
call.controller.abort(abortError('对话进程资源已释放'))
}
await Promise.all(calls.map((call) => call.done))
await this.outputs.releaseOwner(conversationId)
}
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage> {
return this.outputs.read(ownerId, handle, cursor, limitBytes)
}
dispose(): Promise<void> {
@@ -698,5 +665,6 @@ export class LocalDirectModelProcessService
call.controller.abort(abortError('进程执行服务已关闭'))
}
await Promise.all(calls.map((call) => call.done))
await this.outputs.dispose()
}
}
+108 -3
View File
@@ -22,6 +22,7 @@ import {
KnowledgeMcpGateway,
type MagicNotesDatabase
} from './knowledge-mcp-gateway'
import { browserTabIdSchema } from '../../shared/contracts'
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
@@ -185,6 +186,28 @@ function testTool(name: string): Tool {
}
const gateways: KnowledgeMcpGateway[] = []
it('honors a requested knowledge result count above eight', async () => {
const { service, searchHybridMany } = createService()
const [sample] = await searchHybridMany([firstLibraryId])
searchHybridMany.mockClear()
searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({
...sample!,
result: {
...sample!.result,
chunk: { ...sample!.result.chunk, id: crypto.randomUUID() },
rank: index + 1
}
})))
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const controller = new AbortController()
const token = gateway.grant('large-search', [firstLibraryId], controller.signal)!
const references = await gateway.search(token, { query: 'evidence', limit: 12 })
expect(references).toHaveLength(12)
expect(searchHybridMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', 12, controller.signal)
await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow()
})
const databases: AssistantDatabase[] = []
const temporaryDirectories: string[] = []
const httpServers: Server[] = []
@@ -210,6 +233,22 @@ afterEach(async () => {
describe('KnowledgeMcpGateway', () => {
it('exposes the assigned browser through a request-scoped conversation', async () => {
const { service } = createService()
const browserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000301'
)
const primaryBrowserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000302'
)
const releaseBrowserUsage = vi.fn()
const acquireTabUsage = vi.fn(
(conversationId: string, tabId: typeof browserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: releaseBrowserUsage
})
)
const browserService = {
getOrigin: vi.fn(() => undefined),
navigate: vi.fn(async () => ({
@@ -222,21 +261,63 @@ describe('KnowledgeMcpGateway', () => {
select: vi.fn(),
back: vi.fn(),
screenshot: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
releaseConversation: vi.fn(async () => undefined),
acquireTabUsage,
listTabs: vi.fn(() => [
{
conversationId: 'browser-conversation',
tabId: primaryBrowserTabId,
primary: true,
status: 'ready' as const,
isLoading: false,
canGoBack: false,
createdAt: 1,
updatedAt: 1
},
{
conversationId: 'browser-conversation',
tabId: browserTabId,
primary: false,
status: 'ready' as const,
isLoading: false,
canGoBack: false,
createdAt: 1,
updatedAt: 1
}
])
}
const gateway = new KnowledgeMcpGateway(service, {
browserService
})
gateways.push(gateway)
await gateway.start()
expect(() =>
gateway.grant(
'wrong-browser-tab',
[],
new AbortController().signal,
'none',
undefined,
'browser-conversation',
browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000399'
)
)
).toThrow('浏览器标签页不存在或不属于当前对话')
const token = gateway.grant(
'browser-request',
[],
new AbortController().signal,
'none',
undefined,
'browser-conversation'
'browser-conversation',
browserTabId
)!
expect(acquireTabUsage).toHaveBeenCalledWith(
'browser-conversation',
browserTabId,
'browser-request'
)
expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames)
const client = new Client({
name: 'browser-loopback-test',
@@ -276,8 +357,32 @@ describe('KnowledgeMcpGateway', () => {
expect(browserService.navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com',
expect.any(AbortSignal)
expect.any(AbortSignal),
browserTabId
)
expect(browserService.listTabs).toHaveBeenCalledTimes(2)
vi.mocked(browserService.click).mockRejectedValueOnce(
new Error('浏览器标签页不存在或不属于当前对话')
)
await expect(
client.callTool({
name: 'browser_click',
arguments: { ref: 'b_boundTabReference' }
})
).resolves.toMatchObject({ isError: true })
expect(browserService.click).toHaveBeenCalledWith(
'browser-conversation',
'b_boundTabReference',
expect.any(AbortSignal),
browserTabId
)
expect(browserService.listTabs).toHaveBeenCalledTimes(2)
gateway.revoke(token)
gateway.revoke(token)
expect(releaseBrowserUsage).toHaveBeenCalledOnce()
await expect(
client.callTool({ name: 'browser_snapshot', arguments: {} })
).rejects.toThrow()
} finally {
await client.close()
}
+104 -25
View File
@@ -19,7 +19,11 @@ import {
type Tool
} from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { KnowledgeSearchReference } from '../../shared/contracts'
import type {
BrowserTabId,
BrowserTabSummary,
KnowledgeSearchReference
} from '../../shared/contracts'
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
import {
knowledgeToolNames,
@@ -70,6 +74,7 @@ import {
type BrowserToolName,
type BrowserToolService
} from '../browser/browser-model-tools'
import type { BrowserTabUsageLease } from '../browser/browser-service'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import { safeToolErrorDetail } from './approval-summary'
@@ -185,6 +190,8 @@ type Capability = {
configWorkspacePath?: string
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
browserConversationId?: string
browserTabId?: BrowserTabId
browserUsageLease?: BrowserTabUsageLease
expiresAt: number
signal: AbortSignal
brokerController: AbortController
@@ -230,7 +237,14 @@ export type KnowledgeMcpGatewayOptions = {
now?: () => number
magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService
browserService?: BrowserToolService
browserService?: BrowserToolService & {
listTabs(conversationId: string): BrowserTabSummary[]
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string
): BrowserTabUsageLease
}
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
@@ -356,7 +370,14 @@ export class KnowledgeMcpGateway {
private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService
private readonly browserService?: BrowserToolService
private readonly browserService?: BrowserToolService & {
listTabs(conversationId: string): BrowserTabSummary[]
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string
): BrowserTabUsageLease
}
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private server?: Server
private endpoint?: string
@@ -432,7 +453,9 @@ export class KnowledgeMcpGateway {
workspacePath: string
authorizeApply?: GoodBuddyConfigApplyAuthorizer
},
browserConversationId?: string
browserConversationId?: string,
browserTabId?: BrowserTabId,
browserUsageLease?: BrowserTabUsageLease
): string | undefined {
const effectiveMagicNotesAccess = this.magicNotesDatabase
? magicNotesAccess
@@ -443,6 +466,39 @@ export class KnowledgeMcpGateway {
const effectiveBrowserConversationId = this.browserService
? browserConversationId
: undefined
const effectiveBrowserTabId = effectiveBrowserConversationId
? browserTabId
: undefined
if (
effectiveBrowserConversationId &&
(!effectiveBrowserTabId ||
!this.browserService!
.listTabs(effectiveBrowserConversationId)
.some((tab) => tab.tabId === effectiveBrowserTabId))
) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
let effectiveBrowserUsageLease: BrowserTabUsageLease | undefined
if (effectiveBrowserConversationId && effectiveBrowserTabId) {
effectiveBrowserUsageLease = browserUsageLease ??
this.browserService!.acquireTabUsage(
effectiveBrowserConversationId,
effectiveBrowserTabId,
requestId
)
if (
effectiveBrowserUsageLease.conversationId !==
effectiveBrowserConversationId ||
effectiveBrowserUsageLease.tabId !== effectiveBrowserTabId ||
effectiveBrowserUsageLease.owner !== requestId
) {
effectiveBrowserUsageLease.release()
throw new Error('浏览器标签页使用租约与当前请求不匹配')
}
} else if (browserUsageLease) {
browserUsageLease.release()
throw new Error('浏览器标签页使用租约与当前请求不匹配')
}
if (
authorizedLibraryIds.length === 0 &&
effectiveMagicNotesAccess === 'none' &&
@@ -452,21 +508,28 @@ export class KnowledgeMcpGateway {
return undefined
}
signal.throwIfAborted()
return this.storeCapability({
requestId,
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
browserConversationId: effectiveBrowserConversationId,
...(effectiveConfigAccess !== 'none'
? {
configWorkspacePath: config?.workspacePath,
authorizeConfigApply: config?.authorizeApply
}
: {}),
signal,
customMcpServers: []
})
try {
return this.storeCapability({
requestId,
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
browserConversationId: effectiveBrowserConversationId,
browserTabId: effectiveBrowserTabId,
browserUsageLease: effectiveBrowserUsageLease,
...(effectiveConfigAccess !== 'none'
? {
configWorkspacePath: config?.workspacePath,
authorizeConfigApply: config?.authorizeApply
}
: {}),
signal,
customMcpServers: []
})
} catch (error) {
effectiveBrowserUsageLease?.release()
throw error
}
}
grantCustomMcp(
@@ -517,14 +580,27 @@ export class KnowledgeMcpGateway {
const abort = (): void => {
this.revoke(token)
}
const abortFromBrowser = (): void => {
this.revoke(token)
}
value.signal.addEventListener('abort', abort, { once: true })
value.browserUsageLease?.signal.addEventListener(
'abort',
abortFromBrowser,
{ once: true }
)
this.capabilities.set(token, {
...value,
expiresAt: this.now() + this.capabilityTtlMs,
brokerController,
references: new Map(),
removeAbortListener: () =>
removeAbortListener: () => {
value.signal.removeEventListener('abort', abort)
value.browserUsageLease?.signal.removeEventListener(
'abort',
abortFromBrowser
)
}
})
return token
}
@@ -542,6 +618,7 @@ export class KnowledgeMcpGateway {
capability.brokerController.abort(
new Error('MCP capability was revoked')
)
capability.browserUsageLease?.release()
for (const session of this.downstreamMcpSessions.values()) {
if (session.token === token) {
void this.closeDownstreamMcpSession(session)
@@ -724,7 +801,7 @@ export class KnowledgeMcpGateway {
...(capability.configAccess === 'write'
? goodbuddyConfigWriteToolNames
: []),
...(capability.browserConversationId
...(capability.browserConversationId && capability.browserTabId
? browserToolNames
: [])
]
@@ -1518,10 +1595,11 @@ export class KnowledgeMcpGateway {
}
)
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
@@ -1579,10 +1657,11 @@ export class KnowledgeMcpGateway {
}
}
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
if (browserTools?.ownsTool(name)) {
+34
View File
@@ -18,6 +18,40 @@ const toolPng = Buffer.from([
0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
it('preserves full supplied and cached history beyond message and byte caps', async () => {
const fetcher = vi.fn<typeof fetch>(async () => new Response(
createEventStream('reply'),
{ headers: { 'content-type': 'text/event-stream' } }
))
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://example.test',
model: 'test-model',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const history = Array.from({ length: 502 }, (_, index) => ({
role: index % 2 ? 'assistant' as const : 'user' as const,
content: `${index}: ${'x'.repeat(4_200)}`
}))
for (const suppliedHistory of [history, undefined]) {
for await (const event of runtime.run({
requestId: crypto.randomUUID(),
conversationId: 'full-history',
prompt: 'next',
history: suppliedHistory
}, new AbortController().signal)) {
expect(event).toBeDefined()
}
}
for (const [, init] of fetcher.mock.calls) {
const body = JSON.parse(String(init?.body))
expect(body.messages.slice(0, history.length)).toEqual(history)
}
expect(fetcher).toHaveBeenCalledTimes(2)
})
function createTextToolResult(text: string): ModelToolResult {
return {
parts: [{ type: 'text', text }],
+3 -20
View File
@@ -2625,24 +2625,8 @@ export class ModelAgentRuntime implements AgentRuntime {
conversationId: string,
messages: ConversationMessage[]
): void {
const retained: ConversationMessage[] = []
let bytes = 0
const compressionEnabled =
this.options.contextCompression?.settings.enabled === true
const maximumMessages = compressionEnabled ? 500 : 20
const maximumBytes = compressionEnabled
? 2 * 1024 * 1024
: 512 * 1024
for (const message of messages.slice(-maximumMessages).reverse()) {
const messageBytes = Buffer.byteLength(message.content)
if (bytes + messageBytes > maximumBytes) {
break
}
retained.unshift(message)
bytes += messageBytes
}
this.conversations.delete(conversationId)
this.conversations.set(conversationId, retained)
this.conversations.set(conversationId, [...messages])
while (this.conversations.size > 50) {
const oldest = this.conversations.keys().next().value
if (oldest) {
@@ -2658,9 +2642,7 @@ export class ModelAgentRuntime implements AgentRuntime {
request.history && request.history.length > 0
? request.history
: this.conversations.get(request.conversationId) ?? []
return this.options.contextCompression?.settings.enabled
? history
: history.slice(-20)
return history
}
private async *runImageGeneration(
@@ -3381,6 +3363,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
const toolContext: ModelToolCallContext = {
conversationId: request.conversationId,
browserTabId: request.browserTabId,
workMode: request.workMode ?? 'ask',
requestId: request.requestId,
runtimeTarget: 'model',
+291 -82
View File
@@ -7,9 +7,11 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import { browserTabIdSchema } from '../../shared/contracts'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import {
LocalWorkspaceAccess,
@@ -64,8 +66,15 @@ const png = Buffer.from([
0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]).toString('base64')
const firstBrowserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000201'
)
const secondBrowserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000202'
)
const toolContext = {
conversationId: 'provider-test-conversation',
browserTabId: firstBrowserTabId,
workMode: 'execute'
} satisfies ModelToolCallContext
@@ -177,18 +186,159 @@ describe('ModelToolProvider', () => {
await provider.dispose()
})
it('provides bounded workspace read, list, and atomic write tools', async () => {
it.each(['connect', 'listTools'] as const)(
'isolates MCP %s failures and retries without reconnecting healthy servers',
async (failureMethod) => {
const workspace = await createWorkspace()
const failure = new Error('server unavailable')
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const failedClient = {
...mocks.client,
connect: vi.fn().mockResolvedValue(undefined),
listTools: vi.fn().mockResolvedValue({ tools: [] }),
close: vi.fn().mockResolvedValue(undefined)
}
failedClient[failureMethod].mockRejectedValueOnce(failure)
mocks.Client.mockImplementationOnce(function () { return failedClient })
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'search', inputSchema: { type: 'object' } }]
})
const provider = new ModelToolProvider(workspace, [
createMcpServer(),
{ ...createMcpServer(), id: 'healthy', name: 'Healthy' }
], createBrowserService())
const signal = new AbortController().signal
try {
const tools = await provider.listTools(toolContext, signal)
expect(tools).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'workspace_read_text' }),
expect.objectContaining({ name: 'browser_snapshot' }),
expect.objectContaining({ serverName: 'Healthy' })
]))
expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(1)
expect(failedClient.close).toHaveBeenCalledOnce()
expect(mocks.client.close).not.toHaveBeenCalled()
expect(warning).toHaveBeenCalledWith(
expect.stringContaining('Search MCP'), expect.any(Error)
)
const recovered = await provider.listTools(toolContext, signal)
expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2)
expect(mocks.Client).toHaveBeenCalledTimes(3)
expect(mocks.client.connect).toHaveBeenCalledTimes(2)
await provider.callTool(
tools.find((tool) => tool.serverName === 'Healthy')!.name,
{}, signal, toolContext
)
expect(mocks.client.callTool).toHaveBeenCalledOnce()
} finally {
await provider.dispose()
warning.mockRestore()
}
}
)
it('loads tools from more than sixteen MCP servers', async () => {
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'search', inputSchema: { type: 'object' } }]
})
const provider = new ModelToolProvider(await createWorkspace(),
Array.from({ length: 17 }, (_, index) => ({
...createMcpServer(), id: `server-${index}`, name: `Server ${index}`
}))
)
try {
const tools = await provider.listTools(toolContext, new AbortController().signal)
expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(17)
expect(mocks.client.connect).toHaveBeenCalledTimes(17)
} finally {
await provider.dispose()
}
})
it('preserves cancellation during MCP discovery and permits a later retry', async () => {
const controller = new AbortController()
const reason = new Error('cancel discovery')
mocks.client.connect.mockImplementationOnce(async () => {
controller.abort(reason)
throw reason
})
const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer()])
try {
await expect(provider.listTools(toolContext, controller.signal)).rejects.toBe(reason)
expect(mocks.client.close).toHaveBeenCalledOnce()
await expect(provider.listTools(toolContext, new AbortController().signal))
.resolves.toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'workspace_read_text' })
]))
expect(mocks.client.connect).toHaveBeenCalledTimes(2)
} finally {
await provider.dispose()
}
})
it('omits failed dynamic catalogs and retries refresh while healthy tools remain usable', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } })
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'search', inputSchema: { type: 'object' } }]
})
const provider = new ModelToolProvider(await createWorkspace(), [
createMcpServer(true),
{ ...createMcpServer(), id: 'healthy', name: 'Healthy' }
])
const signal = new AbortController().signal
try {
await provider.listTools(toolContext, signal)
const options = mocks.Client.mock.calls[0]![1] as {
listChanged: { tools: { onChanged(error: Error | null): void } }
}
options.listChanged.tools.onChanged(null)
mocks.client.listTools.mockRejectedValueOnce(new Error('refresh unavailable'))
const tools = await provider.listTools(toolContext, signal)
expect(tools.filter((tool) => tool.source === 'mcp')).toEqual([
expect.objectContaining({ serverName: 'Healthy' })
])
expect(mocks.client.close).not.toHaveBeenCalled()
const recovered = await provider.listTools(toolContext, signal)
expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2)
expect(mocks.client.connect).toHaveBeenCalledTimes(2)
options.listChanged.tools.onChanged(null)
const controller = new AbortController()
const reason = new Error('cancel refresh')
mocks.client.listTools.mockImplementationOnce(async () => {
controller.abort(reason)
throw reason
})
await expect(provider.listTools(toolContext, controller.signal)).rejects.toBe(reason)
} finally {
await provider.dispose()
warning.mockRestore()
}
})
it('provides ripgrep, paged reads, and patch tools', async () => {
const workspace = await createWorkspace()
await mkdir(join(workspace, 'docs'))
await writeFile(join(workspace, 'docs', 'note.txt'), 'hello', 'utf8')
const provider = new ModelToolProvider(workspace)
await writeFile(join(workspace, 'docs', 'search.txt'), '中target\n', 'utf8')
const provider = new ModelToolProvider(
workspace,
[],
undefined,
undefined,
false,
{ ripgrepExecutablePath: rgPath }
)
const signal = new AbortController().signal
await expect(provider.listTools(toolContext, signal)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'workspace_rg' }),
expect.objectContaining({ name: 'workspace_read_text' }),
expect.objectContaining({ name: 'workspace_list_directory' }),
expect.objectContaining({ name: 'workspace_write_text' })
expect.objectContaining({ name: 'workspace_apply_patch' })
])
)
await expect(
@@ -198,32 +348,50 @@ describe('ModelToolProvider', () => {
signal,
toolContext
)
).resolves.toEqual({
parts: [{ type: 'text', text: 'hello' }],
contextBytes: 5
).resolves.toMatchObject({
parts: [{ type: 'text', text: expect.stringContaining('1: hello') }]
})
const listing = await provider.callTool(
'workspace_list_directory',
{ path: 'docs' },
'workspace_rg',
{ path: 'docs', filesOnly: true },
signal,
toolContext
)
expect(listing.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('"note.txt"')
text: expect.stringContaining('docs/note.txt')
})
])
const matches = await provider.callTool(
'workspace_rg',
{ path: 'docs', pattern: 'target' },
signal,
toolContext
)
expect(matches.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('docs/search.txt:1:2:中target')
})
])
const written = await provider.callTool(
'workspace_write_text',
{ path: 'docs/output.txt', content: 'saved' },
'workspace_apply_patch',
{
patch: [
'*** Begin Patch',
'*** Add File: docs/output.txt',
'+saved',
'*** End Patch'
].join('\n')
},
signal,
toolContext
)
expect(written.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('"bytesWritten":5')
text: expect.stringContaining('docs/output.txt')
})
])
await expect(
@@ -231,12 +399,15 @@ describe('ModelToolProvider', () => {
).resolves.toBe('saved')
})
it('delegates workspace tools and disposal to WorkspaceAccess', async () => {
it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => {
const readText = vi.fn(async () => ({
path: 'remote.txt',
name: 'remote.txt',
content: 'remote content',
size: 14
size: 14,
offsetBytes: 0,
bytesRead: 14,
truncated: false
}))
const listDirectory = vi.fn(async () => ({
path: '',
@@ -270,43 +441,19 @@ describe('ModelToolProvider', () => {
signal,
toolContext
)
).resolves.toEqual({
parts: [{ type: 'text', text: 'remote content' }],
contextBytes: 14
).resolves.toMatchObject({
parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }]
})
await provider.callTool(
'workspace_list_directory',
{},
signal,
toolContext
)
await provider.callTool(
'workspace_write_text',
{ path: 'output.txt', content: 'saved' },
signal,
toolContext
)
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.txt',
maximumBytes: 256 * 1024,
maximumBytes: 128 * 1024,
allowTruncated: true,
signal
})
)
expect(listDirectory).toHaveBeenCalledWith(
expect.objectContaining({
path: '.',
maximumEntries: 200,
includeOther: true,
signal
})
)
expect(writeTextAtomic).toHaveBeenCalledWith({
path: 'output.txt',
content: 'saved',
maximumBytes: 512 * 1024,
signal
})
expect(listDirectory).not.toHaveBeenCalled()
expect(writeTextAtomic).not.toHaveBeenCalled()
await provider.dispose()
expect(dispose).toHaveBeenCalledOnce()
})
@@ -361,8 +508,8 @@ describe('ModelToolProvider', () => {
for (const [name, argumentsValue] of [
[
'workspace_write_text',
{ path: 'output.txt', content: 'blocked' }
'workspace_apply_patch',
{ patch: '*** Begin Patch\n*** End Patch' }
],
['browser_click', { ref: 'b_target' }],
['note_create', { title: 'blocked' }],
@@ -389,8 +536,8 @@ describe('ModelToolProvider', () => {
expect(mocks.Client).not.toHaveBeenCalled()
const writeTool = {
name: 'workspace_write_text',
displayName: '写入工作区文本',
name: 'workspace_apply_patch',
displayName: '应用工作区补丁',
description: 'write',
inputSchema: {},
source: 'builtin'
@@ -398,7 +545,7 @@ describe('ModelToolProvider', () => {
expect(() =>
provider.getApproval(
writeTool,
{ path: 'output.txt', content: 'blocked' },
{ patch: '*** Begin Patch\n*** End Patch' },
'output.txt',
askContext
)
@@ -425,20 +572,10 @@ describe('ModelToolProvider', () => {
askContext
)
).resolves.toMatchObject({
parts: [{ type: 'text', text: 'remote content' }]
})
await expect(
provider.callTool(
'workspace_list_directory',
{},
signal,
askContext
)
).resolves.toMatchObject({
parts: [{ type: 'text', text: expect.any(String) }]
parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }]
})
expect(readText).toHaveBeenCalledOnce()
expect(listDirectory).toHaveBeenCalledOnce()
expect(listDirectory).not.toHaveBeenCalled()
})
it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => {
@@ -499,6 +636,7 @@ describe('ModelToolProvider', () => {
const askTools = await provider.listTools(askContext, signal)
expect(askTools.map((tool) => tool.name)).toEqual([
'workspace_read_text',
'knowledge_list',
'knowledge_search',
'note_list',
@@ -578,7 +716,11 @@ describe('ModelToolProvider', () => {
},
signal
)
).resolves.toEqual([])
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'workspace_read_text' })
])
)
const executeTools = await provider.listTools(
{ ...askContext, workMode: 'execute' },
signal
@@ -586,8 +728,7 @@ describe('ModelToolProvider', () => {
expect(executeTools.map((tool) => tool.name)).toEqual(
expect.arrayContaining([
'workspace_read_text',
'workspace_list_directory',
'workspace_write_text',
'workspace_apply_patch',
'knowledge_list',
'knowledge_search',
'note_search',
@@ -705,7 +846,9 @@ describe('ModelToolProvider', () => {
workspace,
[createMcpServer()],
undefined,
gateway
gateway,
false,
{ ripgrepExecutablePath: rgPath }
)
await expect(
validProvider.listTools(context, new AbortController().signal)
@@ -719,14 +862,16 @@ describe('ModelToolProvider', () => {
workspace,
[createMcpServer()],
undefined,
gateway
gateway,
false,
{ ripgrepExecutablePath: rgPath }
)
await expect(
overflowingProvider.listTools(
context,
new AbortController().signal
)
).rejects.toThrow('无法加载 MCP Server')
).resolves.toHaveLength(18)
await overflowingProvider.dispose()
})
@@ -799,6 +944,7 @@ describe('ModelToolProvider', () => {
stdoutTruncated: false,
stderrTruncated: false
})),
readOutput: vi.fn(),
releaseConversation: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined)
} satisfies DirectModelProcessService
@@ -994,10 +1140,12 @@ describe('ModelToolProvider', () => {
const provider = new ModelToolProvider(workspace, [], browserService)
const firstContext = {
conversationId: 'browser-conversation-one',
browserTabId: firstBrowserTabId,
workMode: 'execute'
} satisfies ModelToolCallContext
const secondContext = {
conversationId: 'browser-conversation-two',
browserTabId: secondBrowserTabId,
workMode: 'execute'
} satisfies ModelToolCallContext
const signal = new AbortController().signal
@@ -1051,12 +1199,14 @@ describe('ModelToolProvider', () => {
expect(browserService.screenshot).toHaveBeenNthCalledWith(
1,
firstContext.conversationId,
signal
signal,
firstBrowserTabId
)
expect(browserService.screenshot).toHaveBeenNthCalledWith(
2,
secondContext.conversationId,
signal
signal,
secondBrowserTabId
)
await provider.releaseConversation(firstContext.conversationId)
@@ -1090,6 +1240,63 @@ describe('ModelToolProvider', () => {
})
})
it('keeps a request on its bound tab when another tab changes or closes', async () => {
const workspace = await createWorkspace()
const browserService = createBrowserService()
const provider = new ModelToolProvider(workspace, [], browserService)
const conversationId = 'two-tab-conversation'
const firstRequest = {
conversationId,
browserTabId: firstBrowserTabId,
workMode: 'execute',
requestId: 'first-tab-request'
} satisfies ModelToolCallContext
const secondRequest = {
conversationId,
browserTabId: secondBrowserTabId,
workMode: 'execute',
requestId: 'second-tab-request'
} satisfies ModelToolCallContext
const signal = new AbortController().signal
await provider.callTool('browser_snapshot', {}, signal, firstRequest)
await provider.callTool('browser_snapshot', {}, signal, secondRequest)
expect(browserService.snapshot).toHaveBeenNthCalledWith(
1,
conversationId,
signal,
firstBrowserTabId
)
expect(browserService.snapshot).toHaveBeenNthCalledWith(
2,
conversationId,
signal,
secondBrowserTabId
)
vi.mocked(browserService.click).mockImplementation(
async (_conversationId, _ref, _signal, tabId) => {
if (tabId === firstBrowserTabId) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
}
)
await expect(
provider.callTool(
'browser_click',
{ ref: 'b_closedTabReference' },
signal,
firstRequest
)
).rejects.toThrow('浏览器标签页不存在或不属于当前对话')
expect(browserService.click).toHaveBeenCalledWith(
conversationId,
'b_closedTabReference',
signal,
firstBrowserTabId
)
})
it('exposes only allowlisted read-only Exa tools in Ask and Execute', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
@@ -1130,18 +1337,20 @@ describe('ModelToolProvider', () => {
workMode: 'ask'
} satisfies ModelToolCallContext
await expect(provider.listTools(askContext, signal)).resolves.toEqual([
expect.objectContaining({
name: 'web_search',
displayName: '联网搜索',
source: 'builtin'
}),
expect.objectContaining({
name: 'web_fetch',
displayName: '读取网页',
source: 'builtin'
})
])
await expect(provider.listTools(askContext, signal)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'web_search',
displayName: '联网搜索',
source: 'builtin'
}),
expect.objectContaining({
name: 'web_fetch',
displayName: '读取网页',
source: 'builtin'
})
])
)
await provider.callTool(
'web_search',
{ query: 'GoodBuddy current release', numResults: 3 },
+43 -32
View File
@@ -24,6 +24,7 @@ import type {
} from './runtime'
import {
BrowserModelTools,
browserToolNames,
type BrowserToolService
} from '../browser/browser-model-tools'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
@@ -45,12 +46,12 @@ import {
directModelSubagentInputSchema
} from '../assistant/direct-model-subagent-service'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import type { BrowserTabId } from '../../shared/contracts'
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
import { applyWorkspacePatch } from './workspace-apply-patch'
import { readWorkspaceLines } from './workspace-read-lines'
const MAX_MODEL_TOOLS = 100
const MAX_MCP_SERVERS = 16
const MAX_TOOL_RESULT_BYTES = 256 * 1024
const MCP_TIMEOUT_MS = 30_000
const MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
@@ -269,6 +270,7 @@ export type ModelToolResult = {
export type ModelToolCallContext = {
conversationId: string
browserTabId?: BrowserTabId
workMode: 'ask' | 'execute'
requestId?: string
runtimeTarget?: 'model'
@@ -635,7 +637,10 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
}
export class ModelToolProvider implements ModelToolProviderLike {
private mcpConnections?: Promise<ConnectedMcp[]>
private readonly mcpConnections = new Map<
ResolvedMcpServer,
Promise<ConnectedMcp | undefined>
>()
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
private readonly clients = new Set<Client>()
private readonly customMcpClients = new Set<Client>()
@@ -708,10 +713,13 @@ export class ModelToolProvider implements ModelToolProviderLike {
private getBrowserTools(
context: ModelToolCallContext
): BrowserModelTools | undefined {
return this.browserService && context.workMode === 'execute'
return this.browserService &&
context.workMode === 'execute' &&
context.browserTabId
? new BrowserModelTools({
service: this.browserService,
conversationId: context.conversationId
conversationId: context.conversationId,
browserTabId: context.browserTabId
})
: undefined
}
@@ -719,7 +727,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
private getReservedToolCount(): number {
return (
this.getBuiltinTools().length +
(this.browserService ? 7 : 0) +
(this.browserService ? browserToolNames.length : 0) +
(this.webSearchEnabled ? 2 : 0) +
(this.programming.processService ? 1 : 0) +
(this.programming.subagentService ? 1 : 0) +
@@ -1077,23 +1085,27 @@ export class ModelToolProvider implements ModelToolProviderLike {
signal: AbortSignal,
refreshDynamic = false
): Promise<Map<string, McpToolBinding>> {
if (this.mcpServers.length > MAX_MCP_SERVERS) {
throw new Error('直连模型最多可加载 16 个 MCP Server')
}
this.mcpConnections ??= Promise.all(
this.mcpServers.map((server) => this.connectMcpServer(server, signal))
)
.catch(async (error) => {
this.mcpConnections = undefined
const clients = [...this.customMcpClients]
this.customMcpClients.clear()
clients.forEach((client) => this.clients.delete(client))
await Promise.allSettled(
clients.map((client) => client.close())
)
throw error
signal.throwIfAborted()
const loaded = await Promise.all(
this.mcpServers.map((server) => {
let pending = this.mcpConnections.get(server)
if (!pending) {
pending = this.connectMcpServer(server, signal).catch((error) => {
this.mcpConnections.delete(server)
if (!signal.aborted) {
console.warn(`MCP discovery failed: ${server.name}`, error)
}
return undefined
})
this.mcpConnections.set(server, pending)
}
return pending
})
const connections = await this.mcpConnections
)
signal.throwIfAborted()
const connections = loaded.filter(
(connection): connection is ConnectedMcp => connection !== undefined
)
if (refreshDynamic) {
await Promise.all(
connections.map(async (connection) => {
@@ -1123,14 +1135,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
)
} catch (error) {
connection.dynamicToolsChanged = true
throw new Error(
`无法刷新 MCP Server「${connection.server.name}」的工具`,
{ cause: error }
)
connection.tools = []
if (!signal.aborted) {
console.warn(
`MCP refresh failed: ${connection.server.name}`,
error
)
}
}
})
)
}
signal.throwIfAborted()
const bindings = new Map<string, McpToolBinding>()
const reservedToolCount = this.getReservedToolCount()
for (const connection of connections) {
@@ -1782,7 +1798,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.clients.clear()
this.customMcpClients.clear()
this.webSearchClients.clear()
this.mcpConnections = undefined
this.mcpConnections.clear()
this.webSearchBindings = undefined
await Promise.allSettled([
...clients.map((client) => client.close()),
@@ -1794,12 +1810,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
async releaseConversation(conversationId: string): Promise<void> {
await Promise.allSettled([
this.browserService
? new BrowserModelTools({
service: this.browserService,
conversationId
}).release()
: undefined,
this.browserService?.releaseConversation(conversationId),
this.programming.processService?.releaseConversation(
conversationId
),
+104
View File
@@ -0,0 +1,104 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { PagedOutputStore, PAGED_OUTPUT_MAX_PAGE_BYTES } from './paged-output-store'
const stores: PagedOutputStore[] = []
const directories: string[] = []
afterEach(async () => {
await Promise.all(stores.splice(0).map((store) => store.dispose()))
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
async function createStore() {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-output-test-'))
directories.push(directory)
const store = new PagedOutputStore('test', { temporaryParentDirectory: directory })
stores.push(store)
return { store, directory }
}
describe('PagedOutputStore', () => {
it.each([1, 2, 3, 4, 7])('round trips split UTF-8 chunks with a %i byte page', async (limit) => {
const { store } = await createStore()
const text = 'a\u4f60\ud83d\ude00\ufffd\u597dZ'
const writer = await store.create('owner', 2)
for (const byte of Buffer.from(text)) writer.append(Buffer.from([byte]))
const saved = await writer.finish()
expect(saved.text).toBe('a')
let output = saved.text
let cursor = saved.reference!.nextCursor
while (cursor < saved.reference!.totalBytes) {
const page = await store.read('owner', writer.handle, cursor, limit)
expect(page.nextCursor).toBeGreaterThan(cursor)
expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(Math.max(limit, 4))
output += page.content
cursor = page.nextCursor
}
expect(output).toBe(text)
await expect(store.read('owner', writer.handle, cursor, limit)).resolves.toMatchObject({ content: '', eof: true, nextCursor: cursor })
await expect(store.read('owner', writer.handle, 2)).rejects.toThrow('UTF-8')
await expect(store.read('other', writer.handle)).rejects.toThrow()
for (const invalid of [-1, 0.5, cursor + 1]) {
await expect(store.read('owner', writer.handle, invalid)).rejects.toThrow()
}
for (const invalid of [0, 0.5, PAGED_OUTPUT_MAX_PAGE_BYTES + 1]) {
await expect(store.read('owner', writer.handle, 0, invalid)).rejects.toThrow()
}
})
it('keeps literal replacement characters in previews', async () => {
const { store } = await createStore()
const writer = await store.create('owner', 3)
writer.append('\ufffdrest')
const saved = await writer.finish()
expect(saved.text).toBe('\ufffd')
expect(saved.reference?.nextCursor).toBe(3)
})
it('retains output omitted to keep JSON-escaped previews bounded', async () => {
const { store } = await createStore()
const writer = await store.create('owner', 12)
writer.append('\u0000'.repeat(12))
const saved = await writer.finish()
expect(saved.text).toBe('\u0000\u0000')
expect(saved.reference?.nextCursor).toBe(2)
const page = await store.read('owner', writer.handle, saved.reference!.nextCursor)
expect(saved.text + page.content).toBe('\u0000'.repeat(12))
})
it('removes small, aborted, released and disposed output files', async () => {
const { store, directory } = await createStore()
const small = await store.create('owner', 10)
small.append('small')
expect(await small.finish()).toEqual({ text: 'small', truncated: false })
const retained = await store.create('owner', 1)
retained.append('retained')
await retained.finish()
const active = await store.create('owner', 1)
active.append('active')
const other = await store.create('other', 1)
other.append('other')
await other.finish()
const [childDirectory] = await readdir(directory)
await store.releaseOwner('owner')
expect(await readdir(join(directory, childDirectory!))).toHaveLength(1)
await expect(active.finish()).rejects.toThrow()
await expect(store.read('owner', retained.handle)).rejects.toThrow()
await expect(store.read('other', other.handle)).resolves.toMatchObject({ content: 'other' })
await store.create('other', 1)
await store.dispose()
expect(await readdir(directory)).toEqual([])
await expect(store.create('owner', 1)).rejects.toThrow()
})
it('cleans up when disposed during directory creation', async () => {
const { store, directory } = await createStore()
const pending = store.create('owner', 1)
const rejected = expect(pending).rejects.toThrow()
await store.dispose()
await rejected
expect(await readdir(directory)).toEqual([])
})
})
+296
View File
@@ -0,0 +1,296 @@
import { randomUUID } from 'node:crypto'
import {
closeSync,
openSync,
readSync,
writeSync
} from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export const PAGED_OUTPUT_DEFAULT_PAGE_BYTES = 32 * 1024
export const PAGED_OUTPUT_MAX_PAGE_BYTES = 32 * 1024
export type PagedOutputReference = {
handle: string
nextCursor: number
totalBytes: number
}
export type PagedOutputPage = {
handle: string
content: string
cursor: number
nextCursor: number
totalBytes: number
eof: boolean
}
type OutputEntry = {
ownerId: string
path: string
totalBytes: number
}
export type PagedOutputStoreOptions = {
temporaryParentDirectory?: string
}
export class PagedOutputWriter {
private preview = Buffer.alloc(0)
private totalBytes = 0
private failure?: Error
private closed = false
constructor(
private readonly store: PagedOutputStore,
readonly handle: string,
readonly ownerId: string,
readonly path: string,
private readonly descriptor: number,
private readonly previewBytes: number
) {}
append(chunk: Buffer | string): void {
if (this.closed || this.failure) {
return
}
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
try {
let offset = 0
while (offset < value.byteLength) {
const written = writeSync(this.descriptor, value, offset)
if (written === 0) throw new Error('Unable to write tool output')
offset += written
}
this.totalBytes += value.byteLength
if (this.preview.byteLength < this.previewBytes) {
const remaining = this.previewBytes - this.preview.byteLength
this.preview = Buffer.concat([
this.preview,
value.subarray(0, remaining)
])
}
} catch (error) {
this.failure =
error instanceof Error ? error : new Error('无法保存分页输出')
}
}
async finish(): Promise<{
text: string
truncated: boolean
reference?: PagedOutputReference
}> {
if (this.closed) {
throw new Error('分页输出已经关闭')
}
this.closed = true
closeSync(this.descriptor)
if (this.failure) {
await this.store.discard(this)
throw new Error('无法保存完整工具输出', { cause: this.failure })
}
let previewLength = utf8PrefixLength(this.preview)
let text = this.preview.subarray(0, previewLength).toString('utf8')
// Tool results are JSON: escaped control characters also consume context.
if (Buffer.byteLength(JSON.stringify(text)) - 2 > this.previewBytes) {
let low = 0
let high = previewLength
while (low < high) {
const middle = Math.ceil((low + high) / 2)
const candidate = utf8Prefix(this.preview.subarray(0, middle))
if (Buffer.byteLength(JSON.stringify(candidate)) - 2 <= this.previewBytes) {
low = middle
} else {
high = middle - 1
}
}
previewLength = utf8PrefixLength(this.preview, low)
text = this.preview.subarray(0, previewLength).toString('utf8')
}
if (this.totalBytes <= previewLength) {
await this.store.discard(this)
return { text, truncated: false }
}
this.store.retain(this, this.totalBytes)
return {
text,
truncated: true,
reference: {
handle: this.handle,
nextCursor: previewLength,
totalBytes: this.totalBytes
}
}
}
async abort(): Promise<void> {
if (!this.closed) {
this.closed = true
closeSync(this.descriptor)
}
await this.store.discard(this)
}
}
function utf8PrefixLength(value: Buffer, end = value.byteLength): number {
if (end === 0) return 0
let start = end - 1
while (start > 0 && (value[start]! & 0xc0) === 0x80) start -= 1
const lead = value[start]!
const width = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1
return start + width > end ? start : end
}
function utf8Prefix(value: Buffer): string {
return value.subarray(0, utf8PrefixLength(value)).toString('utf8')
}
export class PagedOutputStore {
private readonly entries = new Map<string, OutputEntry>()
private readonly writers = new Set<PagedOutputWriter>()
private directoryPromise?: Promise<string>
private disposed = false
private disposePromise?: Promise<void>
constructor(
private readonly handlePrefix: string,
private readonly options: PagedOutputStoreOptions = {}
) {}
async create(
ownerId: string,
previewBytes: number
): Promise<PagedOutputWriter> {
if (this.disposed) {
throw new Error('分页输出存储已关闭')
}
const directory = await this.getDirectory()
if (this.disposed) throw new Error('分页输出存储已关闭')
const id = randomUUID()
const path = join(directory, id)
const descriptor = openSync(path, 'wx')
const writer = new PagedOutputWriter(
this,
`${this.handlePrefix}:${id}`,
ownerId,
path,
descriptor,
previewBytes
)
this.writers.add(writer)
return writer
}
async read(
ownerId: string,
handle: string,
cursor = 0,
limitBytes = PAGED_OUTPUT_DEFAULT_PAGE_BYTES
): Promise<PagedOutputPage> {
const entry = this.entries.get(handle)
if (!entry || entry.ownerId !== ownerId) {
throw new Error('分页输出句柄不存在或不属于当前会话')
}
if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > entry.totalBytes) {
throw new Error('分页输出 cursor 无效')
}
if (
!Number.isSafeInteger(limitBytes) ||
limitBytes < 1 ||
limitBytes > PAGED_OUTPUT_MAX_PAGE_BYTES
) {
throw new Error(`分页输出每次最多读取 ${PAGED_OUTPUT_MAX_PAGE_BYTES} 字节`)
}
const requestedBytes = Math.min(limitBytes + 3, entry.totalBytes - cursor)
const buffer = Buffer.alloc(requestedBytes)
const descriptor = openSync(entry.path, 'r')
let bytesRead: number
try {
bytesRead = readSync(descriptor, buffer, 0, requestedBytes, cursor)
} finally {
closeSync(descriptor)
}
if (bytesRead && (buffer[0]! & 0xc0) === 0x80) {
throw new Error('分页输出 cursor 必须位于 UTF-8 字符边界')
}
let consumedBytes = utf8PrefixLength(buffer, Math.min(limitBytes, bytesRead))
// A tiny page still consumes one complete character (at most four bytes).
if (consumedBytes === 0 && bytesRead > 0) {
consumedBytes = utf8PrefixLength(buffer, Math.min(4, bytesRead))
}
// Preserve forward progress even for an incomplete final UTF-8 sequence.
if (consumedBytes === 0) consumedBytes = bytesRead
const content = buffer.subarray(0, consumedBytes).toString('utf8')
const nextCursor = cursor + consumedBytes
return {
handle,
content,
cursor,
nextCursor,
totalBytes: entry.totalBytes,
eof: nextCursor >= entry.totalBytes
}
}
async releaseOwner(ownerId: string): Promise<void> {
await Promise.all([...this.writers]
.filter((writer) => writer.ownerId === ownerId)
.map((writer) => writer.abort()))
const owned = [...this.entries].filter(
([, entry]) => entry.ownerId === ownerId
)
for (const [handle] of owned) {
this.entries.delete(handle)
}
await Promise.allSettled(
owned.map(([, entry]) => rm(entry.path, { force: true }))
)
}
dispose(): Promise<void> {
this.disposePromise ??= this.disposeOnce()
return this.disposePromise
}
private async disposeOnce(): Promise<void> {
this.disposed = true
await Promise.all([...this.writers].map((writer) => writer.abort()))
this.entries.clear()
if (this.directoryPromise) {
const directory = await this.directoryPromise.catch(() => undefined)
if (directory) {
await rm(directory, { recursive: true, force: true })
}
}
}
retain(writer: PagedOutputWriter, totalBytes: number): void {
this.writers.delete(writer)
this.entries.set(writer.handle, {
ownerId: writer.ownerId,
path: writer.path,
totalBytes
})
}
async discard(writer: PagedOutputWriter): Promise<void> {
this.writers.delete(writer)
this.entries.delete(writer.handle)
await rm(writer.path, { force: true })
}
private getDirectory(): Promise<string> {
this.directoryPromise ??= this.createDirectory()
return this.directoryPromise
}
private async createDirectory(): Promise<string> {
const parent = this.options.temporaryParentDirectory ?? tmpdir()
await mkdir(parent, { recursive: true })
return await mkdtemp(join(parent, `goodbuddy-${this.handlePrefix}-`))
}
}
+1 -1
View File
@@ -229,7 +229,7 @@ describe('runtime selection', () => {
})
).toEqual({
provider: 'opencode',
profileId: secondProfileId
profileId: defaultProfileId
})
expect(
resolveConfiguredAgentRuntimeSelection(configured, {
+7 -2
View File
@@ -49,6 +49,7 @@ export function resolveConfiguredAgentRuntimeSelection(
settings: ResolvedRuntimeSettings,
selection: AgentRuntimeSelection
): AgentRuntimeSelection {
if ('profileId' in selection && selection.profileId) return selection
if (
selection.provider !== 'opencode' &&
selection.provider !== 'continue' &&
@@ -87,7 +88,7 @@ export function applyRuntimeSelection(
}
if (selection.provider === 'model') {
const profile = requireProfile(settings, selection.profileId)
const profile = requireProfile(settings, selection.profileId ?? settings.defaultModelProfileId)
return {
target: 'model',
settings: {
@@ -108,7 +109,11 @@ export function applyRuntimeSelection(
const profile = selection.profileId
? requireProfile(settings, selection.profileId)
: undefined
: selection.provider === 'opencode'
? settings.opencodeModelProfile
: selection.provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
if (selection.provider === 'opencode') {
if (profile && !isAgentRuntimeModelProtocol(profile.protocol)) {
throw new Error(
+3
View File
@@ -4,6 +4,7 @@ import type {
AgentQuestionAnswer,
AgentRequest,
AgentRuntimeStatus,
BrowserTabId,
RuntimeConversationCompactInput,
RuntimeConversationCompactResult,
RuntimeNativeSnapshot
@@ -137,6 +138,8 @@ export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
trustedInstructions?: string
/** Main-process-only request-scoped authorization for built-in data tools. */
knowledgeCapabilityToken?: string
/** Main-process-only browser tab fixed for this request. */
browserTabId?: BrowserTabId
/**
* Main-process recovery cursor for an Agent-owned semantic prompt.
* It is never sent as prompt content and is ignored by local runtimes.
+43 -6
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { browserTabIdSchema } from '../../shared/contracts'
import {
BrowserModelTools,
browserBackInputSchema,
@@ -42,12 +43,16 @@ function createService(): BrowserToolService {
const signal = new AbortController().signal
const ref = 'b_abcdefghijklmnop'
const browserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000101'
)
describe('BrowserModelTools', () => {
it('publishes seven strict, bounded builtin tool definitions', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const definitions = tools.listTools()
expect(definitions.map((definition) => definition.name)).toEqual([
@@ -93,7 +98,8 @@ describe('BrowserModelTools', () => {
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const approval = tools.getApproval('browser_navigate', {
url: 'https://example.com/path?token=top-secret'
@@ -115,7 +121,8 @@ describe('BrowserModelTools', () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const first = tools.getApproval('browser_type', {
ref,
@@ -139,7 +146,8 @@ describe('BrowserModelTools', () => {
'conversation',
ref,
'top-secret',
signal
signal,
browserTabId
)
expect(JSON.stringify(result)).not.toContain('top-secret')
expect(result.parts[0]).toMatchObject({
@@ -158,7 +166,8 @@ describe('BrowserModelTools', () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const navigate = await tools.callTool(
'browser_navigate',
@@ -189,12 +198,40 @@ describe('BrowserModelTools', () => {
})
await tools.release()
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
expect(service.navigate).toHaveBeenCalledWith(
'conversation',
'https://example.com/',
signal,
browserTabId
)
expect(service.snapshot).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
expect(service.click).toHaveBeenCalledWith(
'conversation',
ref,
signal,
browserTabId
)
expect(service.back).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
expect(service.screenshot).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
})
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
expect(() =>
tools.getApproval('browser_click', { ref, extra: true })
+33 -8
View File
@@ -12,6 +12,7 @@ import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
} from './browser-limits'
import type { BrowserTabId } from '../../shared/contracts'
const MAX_REF_LENGTH = 64
@@ -195,6 +196,7 @@ export type BrowserToolService = Pick<
export type BrowserModelToolsOptions = {
service: BrowserToolService
conversationId: string
browserTabId: BrowserTabId
}
function createTextResult(value: unknown): ModelToolResult {
@@ -218,10 +220,12 @@ function navigationLabel(url: URL): string {
export class BrowserModelTools {
private readonly service: BrowserToolService
private readonly conversationId: string
private readonly browserTabId: BrowserTabId
constructor(options: BrowserModelToolsOptions) {
this.service = options.service
this.conversationId = options.conversationId
this.browserTabId = options.browserTabId
if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效')
}
@@ -249,7 +253,7 @@ export class BrowserModelTools {
throw new Error(`未知浏览器工具:${name}`)
}
const currentOrigin = safeOrigin(
this.service.getOrigin(this.conversationId)
this.service.getOrigin(this.conversationId, this.browserTabId)
)
let description: string
let argumentSummary: string
@@ -320,18 +324,32 @@ export class BrowserModelTools {
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.navigate(this.conversationId, input.url, signal)
await this.service.navigate(
this.conversationId,
input.url,
signal,
this.browserTabId
)
)
}
if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.snapshot(this.conversationId, signal)
await this.service.snapshot(
this.conversationId,
signal,
this.browserTabId
)
)
}
if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
await this.service.click(this.conversationId, input.ref, signal)
await this.service.click(
this.conversationId,
input.ref,
signal,
this.browserTabId
)
return createTextResult({ clicked: input.ref })
}
if (name === 'browser_type') {
@@ -340,7 +358,8 @@ export class BrowserModelTools {
this.conversationId,
input.ref,
input.text,
signal
signal,
this.browserTabId
)
return createTextResult({
typed: input.ref,
@@ -354,20 +373,26 @@ export class BrowserModelTools {
this.conversationId,
input.ref,
input.value,
signal
signal,
this.browserTabId
)
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
}
if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.back(this.conversationId, signal)
await this.service.back(
this.conversationId,
signal,
this.browserTabId
)
)
}
browserScreenshotInputSchema.parse(argumentsValue)
const screenshot = await this.service.screenshot(
this.conversationId,
signal
signal,
this.browserTabId
)
return {
parts: [
+314 -3
View File
@@ -31,6 +31,8 @@ function deferred<T>() {
function createHarness(options: {
maximumSessions?: number
maximumTabsPerConversation?: number
maximumTabsPerWindow?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
@@ -145,6 +147,8 @@ function createHarness(options: {
const service = new BrowserService({
policy: new BrowserUrlPolicy(dnsResolver),
maximumSessions: options.maximumSessions,
maximumTabsPerConversation: options.maximumTabsPerConversation,
maximumTabsPerWindow: options.maximumTabsPerWindow,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
liveFrameDelayMs: 0,
@@ -165,12 +169,21 @@ afterEach(() => {
})
describe('BrowserService', () => {
const firstLeaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
it('presents one session in the shared viewport and tracks user navigation', async () => {
const harness = createHarness()
const bounds = { x: 900, y: 120, width: 420, height: 640 }
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
harness.service.setViewport('conversation', bounds)
harness.service.setViewport(
'conversation',
bounds,
undefined,
firstLeaseToken
)
await harness.service.navigate(
'conversation',
@@ -196,7 +209,12 @@ describe('BrowserService', () => {
'https://example.com'
)
harness.service.setViewport()
harness.service.setViewport(
undefined,
undefined,
undefined,
firstLeaseToken
)
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.dispose()
})
@@ -222,6 +240,7 @@ describe('BrowserService', () => {
expect(states.map((state) => state.status)).toEqual([
'creating',
'ready',
'loading',
'ready',
'acting',
@@ -242,7 +261,7 @@ describe('BrowserService', () => {
isLoading: false,
canGoBack: false
})
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe(
'data:image/jpeg;base64,/9j/2Q=='
)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
@@ -435,6 +454,253 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('owns independent tabs inside one conversation and closes only the requested tab', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://first.example/', signal)
const [primary] = harness.service.listTabs('conversation', 11)
const sibling = await harness.service.createTab('conversation', 11, signal)
await harness.service.navigate(
'conversation',
'https://second.example/',
signal,
sibling.tabId,
11
)
await harness.service.snapshot(
'conversation',
signal,
sibling.tabId,
11
)
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.service.getTabCount('conversation')).toBe(2)
expect(harness.slots[0]?.driver.snapshot).not.toHaveBeenCalled()
expect(harness.slots[1]?.driver.snapshot).toHaveBeenCalledOnce()
await harness.service.closeTab('conversation', sibling.tabId, 11)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled()
expect(harness.service.listTabs('conversation', 11)[0]?.tabId).toBe(
primary?.tabId
)
await harness.service.dispose()
})
it('blocks closing a leased tab until every request releases it', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 11, signal)
const sibling = await harness.service.createTab('conversation', 11, signal)
const first = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-one',
11
)
const second = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-two',
11
)
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
await expect(
harness.service.closeTab('conversation', sibling.tabId, 11)
).resolves.toBeUndefined()
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)
).resolves.toBeUndefined()
await harness.service.dispose()
})
it('reports only the visible tab owned by the requesting window', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
expect(harness.service.getVisibleTabId('conversation', 21)).toBeUndefined()
harness.service.setViewport(
'conversation',
{ x: 0, y: 0, width: 320, height: 480 },
sibling.tabId,
firstLeaseToken,
21
)
expect(harness.service.getVisibleTabId('conversation', 21)).toBe(
sibling.tabId
)
expect(primary.tabId).not.toBe(sibling.tabId)
expect(() => harness.service.getVisibleTabId('conversation', 22)).toThrow(
'不属于当前窗口'
)
await harness.service.dispose()
})
it('aborts active tab leases when a conversation is force-released', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const lease = harness.service.acquireTabUsage(
'conversation',
tab.tabId,
'request',
21
)
await harness.service.releaseConversation('conversation', 21)
expect(lease.signal.aborted).toBe(true)
expect(harness.service.getTabCount('conversation')).toBe(0)
lease.release()
await harness.service.dispose()
})
it('enforces conversation/window ownership, tab limits, and opaque viewport leases', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
const bounds = { x: 10, y: 20, width: 300, height: 400 }
expect(
harness.service.setViewport(
'conversation',
bounds,
sibling.tabId,
firstLeaseToken,
21
)
).toBe(true)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
expect(
harness.service.setViewport(
undefined,
undefined,
undefined,
secondLeaseToken,
21
)
).toBe(false)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
await expect(
harness.service.navigate(
'conversation',
'https://example.com/',
signal,
primary.tabId,
22
)
).rejects.toThrow('不属于当前窗口')
await harness.service.dispose()
const conversationLimited = createHarness({
maximumTabsPerConversation: 2
})
await conversationLimited.service.createTab('limited', 31, signal)
await conversationLimited.service.createTab('limited', 31, signal)
await expect(
conversationLimited.service.createTab('limited', 31, signal)
).rejects.toThrow('2 个上限')
await conversationLimited.service.dispose()
const windowLimited = createHarness({ maximumTabsPerWindow: 2 })
await windowLimited.service.createTab('first', 41, signal)
await windowLimited.service.createTab('second', 41, signal)
await expect(
windowLimited.service.createTab('third', 41, signal)
).rejects.toThrow('2 个上限')
await windowLimited.service.dispose()
})
it('returns one live tab for concurrent requests from the same workbar instance', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const signal = new AbortController().signal
const first = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
const duplicate = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
gate.resolve()
const [firstTab, duplicateTab] = await Promise.all([first, duplicate])
expect(duplicateTab.tabId).toBe(firstTab.tabId)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.service.getOwnerWindowId('conversation')).toBe(21)
await harness.service.dispose()
})
it('disposes a tab creation that resolves after its context is closed', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const gate = deferred<void>()
const implementation = harness.createSession.getMockImplementation()!
harness.createSession.mockImplementationOnce(async (...args) => {
await gate.promise
return implementation(...args)
})
const pending = harness.service.createTab(
'conversation',
21,
signal,
'c22e845b-4bc8-4246-9543-dd18dbf20782'
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledTimes(2))
await harness.service.closeTab('conversation', primary.tabId, 21)
gate.resolve()
await expect(pending).rejects.toThrow()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('reserves window capacity while tab contexts are still being created', async () => {
const gate = deferred<void>()
const harness = createHarness({
maximumTabsPerWindow: 1,
sessionGate: gate.promise
})
const signal = new AbortController().signal
const first = harness.service.createTab('first', 51, signal)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
await expect(
harness.service.createTab('second', 51, signal)
).rejects.toThrow('1 个上限')
expect(harness.createSession).toHaveBeenCalledOnce()
gate.resolve()
await first
await harness.service.dispose()
})
it('enforces a hard maximum of three sessions', async () => {
const harness = createHarness({ maximumSessions: 3 })
const signal = new AbortController().signal
@@ -625,6 +891,42 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('does not expire a session while a serialized operation is active', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const gate = deferred<void>()
vi.mocked(harness.slots[0]!.driver.click).mockImplementationOnce(async () => gate.promise)
const click = harness.service.click('conversation', 'button_ref', signal)
await vi.advanceTimersByTimeAsync(500)
expect(harness.service.getSessionCount()).toBe(1)
gate.resolve()
await click
await vi.advanceTimersByTimeAsync(101)
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.dispose()
})
it('ignores stale viewport cleanup tokens', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const bounds = { x: 1, y: 2, width: 300, height: 400 }
harness.service.setViewport('conversation', bounds, tab.tabId, firstLeaseToken, 21)
harness.service.setViewport('conversation', bounds, tab.tabId, secondLeaseToken, 21)
expect(
harness.service.setViewport(undefined, undefined, undefined, firstLeaseToken, 21)
).toBe(false)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(bounds)
expect(
harness.service.setViewport(undefined, undefined, undefined, secondLeaseToken, 21)
).toBe(true)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.dispose()
})
it('tracks an approved origin across validated back navigation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
@@ -905,10 +1207,19 @@ describe('BrowserService', () => {
'https://example.com/',
new AbortController().signal
)
const [tab] = harness.service.listTabs('conversation')
harness.service.setViewport(
'conversation',
{ x: 1, y: 2, width: 300, height: 400 },
tab!.tabId,
firstLeaseToken
)
await expect(
harness.service.releaseConversation('conversation')
).rejects.toThrow('清理超时')
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.service.listTabs('conversation')).toEqual([])
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.releaseConversation('conversation')
await harness.service.dispose()
await harness.service.dispose()
File diff suppressed because it is too large Load Diff
@@ -11,10 +11,12 @@ import {
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve }
return { promise, resolve, reject }
}
function createHarness() {
@@ -348,6 +350,65 @@ describe('ElectronBrowserSession', () => {
)
})
it('creates sibling views in the same partition and cleans shared state after the final tab', async () => {
const first = createHarness()
const second = createHarness()
const createPartition = vi.fn(async () => first.partition)
const createView = vi
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
.mockResolvedValueOnce(first.window)
.mockResolvedValueOnce(second.window)
const root = await ElectronBrowserSession.create({
policy: first.policy,
createPartition,
createView,
createProxy: () => first.proxy
})
const sibling = await root.createTab(new AbortController().signal)
expect(createPartition).toHaveBeenCalledOnce()
expect(sibling.partition).toBe(root.partition)
expect(
(createView.mock.calls[1]?.[0].webPreferences as Record<string, unknown>)
.partition
).toBe(root.partition)
await root.dispose()
expect(first.partition.clearData).not.toHaveBeenCalled()
expect(first.proxy.dispose).not.toHaveBeenCalled()
await sibling.dispose()
expect(first.partition.clearData).toHaveBeenCalledOnce()
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(first.proxy.dispose).toHaveBeenCalledOnce()
})
it('cleans shared state once when sibling creation fails after root disposal', async () => {
const first = createHarness()
const second = createHarness()
const setupGate = deferred<Record<string, never>>()
second.sendCommand.mockImplementationOnce(() => setupGate.promise)
const createView = vi
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
.mockResolvedValueOnce(first.window)
.mockResolvedValueOnce(second.window)
const root = await ElectronBrowserSession.create({
policy: first.policy,
createPartition: async () => first.partition,
createView,
createProxy: () => first.proxy
})
const sibling = root.createTab(new AbortController().signal)
await vi.waitFor(() => expect(second.sendCommand).toHaveBeenCalled())
await root.dispose()
setupGate.reject(new Error('debugger failed'))
await expect(sibling).rejects.toThrow('无法创建浏览器标签页')
expect(first.partition.clearData).toHaveBeenCalledOnce()
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(first.proxy.dispose).toHaveBeenCalledOnce()
})
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
+125 -23
View File
@@ -126,6 +126,30 @@ type Listener = {
listener: BrowserEventListener
}
type SharedBrowserResources = {
partition: string
partitionSession: BrowserPartitionSession
proxy: FilteringProxyLike
createView: (options: Record<string, unknown>) => Promise<BrowserViewHandle>
setupTimeoutMs: number
cleanupTimeoutMs: number
references: number
cleanup?: Promise<void>
}
async function releaseSharedResources(
resources: SharedBrowserResources
): Promise<void> {
resources.references -= 1
if (resources.references !== 0) return
resources.cleanup ??= cleanupIsolatedState(
resources.partitionSession,
resources.proxy,
resources.cleanupTimeoutMs
)
await resources.cleanup
}
function managedBrowserUserAgent(): string {
const platform =
process.platform === 'win32'
@@ -243,14 +267,12 @@ export class ElectronBrowserSession {
private constructor(
private readonly policy: BrowserUrlPolicy,
private readonly partitionSession: BrowserPartitionSession,
private readonly view: BrowserViewHandle,
private readonly proxy: FilteringProxyLike,
partition: string,
private readonly cleanupTimeoutMs: number,
private readonly parentWindow?: BrowserParentWindowHandle
private readonly resources: SharedBrowserResources,
private readonly parentWindow?: BrowserParentWindowHandle,
private readonly listenForPartitionEvents = true
) {
this.partition = partition
this.partition = resources.partition
this.webContents = view.webContents
}
@@ -369,11 +391,16 @@ export class ElectronBrowserSession {
)
result = new ElectronBrowserSession(
options.policy,
partitionSession,
view,
managedProxy,
partition,
cleanupTimeoutMs,
{
partition,
partitionSession,
proxy: managedProxy,
createView,
setupTimeoutMs,
cleanupTimeoutMs,
references: 1
},
options.parentWindow
)
setupStage = '初始化浏览器协议'
@@ -480,14 +507,16 @@ export class ElectronBrowserSession {
this.emitNavigation(url)
}
})
this.listen(
this.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
if (this.listenForPartitionEvents) {
this.listen(
this.resources.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
}
contents.debugger.attach('1.3')
await contents.debugger.sendCommand('Page.enable')
this.assertOpen()
@@ -625,6 +654,83 @@ export class ElectronBrowserSession {
this.approvedOrigin = target.origin
}
async createTab(signal: AbortSignal): Promise<ElectronBrowserSession> {
this.assertOpen()
const resources = this.resources
resources.references += 1
let view: BrowserViewHandle | undefined
let viewAttached = false
let tab: ElectronBrowserSession | undefined
try {
view = await boundedSetup(
resources.createView({
webPreferences: {
partition: resources.partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
backgroundThrottling: false,
webSecurity: true,
allowRunningInsecureContent: false,
plugins: false,
devTools: false,
safeDialogs: true
}
}),
signal,
resources.setupTimeoutMs,
(lateView) => {
if (!lateView.webContents.isDestroyed()) {
lateView.webContents.close?.({ waitForBeforeUnload: false })
if (!lateView.webContents.isDestroyed()) {
lateView.webContents.destroy()
}
}
}
)
if (this.parentWindow?.contentView && view.nativeView) {
this.parentWindow.contentView.addChildView(view.nativeView)
viewAttached = true
}
if (!view.webContents.loadURL) {
throw new Error('浏览器视图导航不可用')
}
await boundedSetup(
view.webContents.loadURL('about:blank'),
signal,
resources.setupTimeoutMs
)
tab = new ElectronBrowserSession(
this.policy,
view,
resources,
this.parentWindow
)
await boundedSetup(tab.initialize(), signal, resources.setupTimeoutMs)
return tab
} catch (error) {
if (tab) {
await tab.dispose().catch(() => undefined)
} else {
if (view) {
if (viewAttached && view.nativeView) {
this.parentWindow?.contentView?.removeChildView(view.nativeView)
}
if (!view.webContents.isDestroyed()) {
view.webContents.close?.({ waitForBeforeUnload: false })
if (!view.webContents.isDestroyed()) {
view.webContents.destroy()
}
}
}
await releaseSharedResources(resources).catch(() => undefined)
}
throw new Error('无法创建浏览器标签页', { cause: error })
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
@@ -650,10 +756,6 @@ export class ElectronBrowserSession {
this.webContents.destroy()
}
}
await cleanupIsolatedState(
this.partitionSession,
this.proxy,
this.cleanupTimeoutMs
)
await releaseSharedResources(this.resources)
}
}
+418 -21
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import {
mkdtemp,
readFile,
@@ -13,7 +14,12 @@ import type {
AssistantProject,
ConversationQueueItem
} from '../shared/assistant-contracts'
import type { AgentEvent, BrowserLiveState } from '../shared/contracts'
import {
browserTabIdSchema,
type AgentEvent,
type BrowserLiveState,
type BrowserTabId
} from '../shared/contracts'
import type {
SshHostAgentConnectionStatus
} from '../shared/ssh-host-contracts'
@@ -391,12 +397,13 @@ describe('registerIpcHandlers computer capabilities', () => {
})
it('validates computer capability requests and restricts them to the trusted renderer', async () => {
const webContents = {
const webContents = Object.assign(new EventEmitter(), {
id: 7,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
})
const window = {
webContents,
isDestroyed: vi.fn(() => false),
@@ -444,6 +451,36 @@ describe('registerIpcHandlers computer capabilities', () => {
const stopLoading = vi.fn(async () => true)
const setViewport = vi.fn()
const releaseConversation = vi.fn(async () => {})
const createTab = vi.fn(async (conversationId: string) => ({
conversationId,
tabId: '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
}))
const listTabs = vi.fn(() => [])
const getVisibleTabId = vi.fn(() => undefined)
const acquireTabUsage = vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: vi.fn()
})
)
const closeTab = vi.fn(async () => {})
const snapshotBrowser = vi.fn(async () => ({
url: 'https://example.com/',
title: 'Example',
nodes: [],
truncated: false
}))
const clickBrowser = vi.fn(async () => {})
const typeBrowser = vi.fn(async () => {})
const selectBrowser = vi.fn(async () => {})
const screenshotBrowser = vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
}))
const selectFiles = vi.fn(
async (
_window: unknown,
@@ -505,9 +542,19 @@ describe('registerIpcHandlers computer capabilities', () => {
onRuntimeSettingsChanged,
undefined,
{
createTab,
listTabs,
getVisibleTabId,
acquireTabUsage,
closeTab,
navigate,
back,
reload,
snapshot: snapshotBrowser,
click: clickBrowser,
type: typeBrowser,
select: selectBrowser,
screenshot: screenshotBrowser,
stopLoading,
setViewport,
releaseConversation,
@@ -649,8 +696,16 @@ describe('registerIpcHandlers computer capabilities', () => {
)?.(event, 'unsupported')
).rejects.toThrow()
const browserTabId = browserTabIdSchema.parse(
'4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
)
const siblingBrowserTabId = browserTabIdSchema.parse(
'0387bd61-3a12-40ce-98d7-ef5d14cc8251'
)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
@@ -665,8 +720,22 @@ describe('registerIpcHandlers computer capabilities', () => {
status: 'ready'
})
)
const ownedStateSendCount = webContents.send.mock.calls.length
browserStateListener?.({
conversationId: 'foreign-conversation',
tabId: browserTabId,
ownerWindowId: 8,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledTimes(ownedStateSendCount)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'acting',
sessionActive: true,
isLoading: false,
@@ -678,6 +747,22 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: siblingBrowserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
updatedAt: 2
})
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
'frameDataUrl'
)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'stopped',
sessionActive: false,
isLoading: false,
@@ -686,6 +771,8 @@ describe('registerIpcHandlers computer capabilities', () => {
})
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
@@ -703,7 +790,37 @@ describe('registerIpcHandlers computer capabilities', () => {
})
).resolves.toBeUndefined()
expect(releaseConversation).toHaveBeenCalledWith(
'browser-conversation'
'browser-conversation',
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserCreateTab)?.(event, {
conversationId: 'browser-conversation',
workbarInstanceId: siblingBrowserTabId
})
).resolves.toMatchObject({ tabId: browserTabId })
expect(createTab).toHaveBeenCalledWith(
'browser-conversation',
7,
expect.any(AbortSignal),
siblingBrowserTabId
)
await expect(
electronMocks.handlers.get(ipcChannels.browserListTabs)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toEqual([])
expect(listTabs).toHaveBeenCalledWith('browser-conversation', 7)
await expect(
electronMocks.handlers.get(ipcChannels.browserCloseTab)?.(event, {
conversationId: 'browser-conversation',
tabId: browserTabId
})
).resolves.toBeUndefined()
expect(closeTab).toHaveBeenCalledWith(
'browser-conversation',
browserTabId,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
@@ -714,7 +831,9 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com/',
expect.any(AbortSignal)
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
@@ -723,7 +842,9 @@ describe('registerIpcHandlers computer capabilities', () => {
).resolves.toBeUndefined()
expect(back).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal)
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
@@ -732,7 +853,9 @@ describe('registerIpcHandlers computer capabilities', () => {
).resolves.toBeUndefined()
expect(reload).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal)
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
@@ -740,23 +863,55 @@ describe('registerIpcHandlers computer capabilities', () => {
{ conversationId: 'browser-conversation' }
)
).resolves.toBeUndefined()
expect(stopLoading).toHaveBeenCalledWith('browser-conversation')
expect(stopLoading).toHaveBeenCalledWith(
'browser-conversation',
undefined,
7
)
expect(
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
conversationId: 'browser-conversation',
leaseToken: siblingBrowserTabId,
bounds: { x: 900, y: 120, width: 320, height: 600 }
})
).toBeUndefined()
expect(setViewport).toHaveBeenCalledWith('browser-conversation', {
x: 900,
y: 120,
width: 320,
height: 600
})
expect(setViewport).toHaveBeenCalledWith(
'browser-conversation',
{ x: 900, y: 120, width: 320, height: 600 },
undefined,
siblingBrowserTabId,
7
)
expect(
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {})
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
leaseToken: siblingBrowserTabId
})
).toBeUndefined()
expect(setViewport).toHaveBeenLastCalledWith()
expect(setViewport).toHaveBeenLastCalledWith(
undefined,
undefined,
undefined,
siblingBrowserTabId,
7
)
navigate.mockImplementationOnce(
async (...args: unknown[]) =>
new Promise<never>((_resolve, reject) => {
const signal = args[2] as AbortSignal
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const abandonedNavigation = electronMocks.handlers.get(
ipcChannels.browserNavigate
)?.(event, {
conversationId: 'browser-conversation',
url: 'https://example.com/abandoned'
}) as Promise<void>
await vi.waitFor(() => expect(webContents.listenerCount('destroyed')).toBe(1))
webContents.emit('destroyed')
await expect(abandonedNavigation).rejects.toThrow('所属窗口已关闭')
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
@@ -4878,7 +5033,8 @@ describe('registerIpcHandlers agent terminal state', () => {
knowledgeGateway?: Record<string, unknown>,
magicNotesEnabled = false,
goodbuddyConfigService?: Record<string, unknown>,
capabilityServiceOverride?: Record<string, unknown>
capabilityServiceOverride?: Record<string, unknown>,
browserControl?: Record<string, unknown>
) {
const assistantDatabase = {
createTask: vi.fn(),
@@ -4990,6 +5146,7 @@ describe('registerIpcHandlers agent terminal state', () => {
}))
}
const webContents = {
id: 9,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -5068,7 +5225,7 @@ describe('registerIpcHandlers agent terminal state', () => {
{} as never,
onRuntimeSettingsChanged,
onBeforeClearLocalData,
undefined,
browserControl as never,
subagentService as never,
undefined,
{
@@ -7230,6 +7387,7 @@ describe('registerIpcHandlers agent terminal state', () => {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
browserTabId?: BrowserTabId
}> = []
const runtime = {
runtimeId: 'opencode',
@@ -7240,6 +7398,7 @@ describe('registerIpcHandlers agent terminal state', () => {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
browserTabId?: BrowserTabId
}) {
receivedRequests.push(request)
yield { requestId: request.requestId, type: 'done' }
@@ -7257,6 +7416,30 @@ describe('registerIpcHandlers agent terminal state', () => {
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
'builtin-browser'
])
const primaryTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000401'
)
const siblingTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000402'
)
const browserControl = {
listTabs: vi.fn(() => [
{ tabId: siblingTabId, primary: false },
{ tabId: primaryTabId, primary: true }
]),
getVisibleTabId: vi.fn(() => undefined),
acquireTabUsage: vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: vi.fn()
})
),
createTab: vi.fn(),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
@@ -7268,7 +7451,8 @@ describe('registerIpcHandlers agent terminal state', () => {
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds }
{ getEnabledBuiltinMcpServerIds },
browserControl
)
const event = trustedEvent(harness.webContents)
const askRequestId = '00000000-0000-4000-8000-000000000026'
@@ -7308,16 +7492,229 @@ describe('registerIpcHandlers agent terminal state', () => {
expect.any(AbortSignal),
'none',
undefined,
'browser-execute'
'browser-execute',
primaryTabId,
expect.objectContaining({
conversationId: 'browser-execute',
tabId: primaryTabId,
owner: executeRequestId
})
)
expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequests[1]).toMatchObject({
knowledgeCapabilityToken: 'browser-capability',
browserTabId: primaryTabId,
trustedInstructions: expect.stringContaining('browser_navigate')
})
expect(browserControl.listTabs).toHaveBeenCalledWith(
'browser-execute',
harness.webContents.id
)
expect(browserControl.createTab).not.toHaveBeenCalled()
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
'browser-execute',
primaryTabId,
executeRequestId,
harness.webContents.id
)
await harness.dispose()
})
it('prefers the owner window visible browser tab over the primary tab', async () => {
const requestId = '00000000-0000-4000-8000-000000000028'
const primaryTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000411'
)
const visibleTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000412'
)
const received: Array<{ browserTabId?: BrowserTabId }> = []
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string; browserTabId?: BrowserTabId }) {
received.push(request)
yield { requestId: request.requestId, type: 'done' }
}
}
const release = vi.fn()
const knowledgeGateway = {
grant: vi.fn(() => 'browser-capability'),
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const browserControl = {
listTabs: vi.fn(() => [
{ tabId: primaryTabId, primary: true },
{ tabId: visibleTabId, primary: false }
]),
getVisibleTabId: vi.fn(() => visibleTabId),
acquireTabUsage: vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release
})
),
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
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'visible-browser',
prompt: '使用当前网页',
workMode: 'execute',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(received[0]?.browserTabId).toBe(visibleTabId)
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
'visible-browser',
visibleTabId,
requestId,
harness.webContents.id
)
expect(browserControl.createTab).not.toHaveBeenCalled()
await harness.dispose()
})
it.each(['error', 'cancel'] as const)(
'releases a request-created browser tab lease on runtime %s',
async (outcome) => {
const requestId = outcome === 'error'
? '00000000-0000-4000-8000-000000000029'
: '00000000-0000-4000-8000-000000000030'
const tabId = browserTabIdSchema.parse(
outcome === 'error'
? '00000000-0000-4000-8000-000000000421'
: '00000000-0000-4000-8000-000000000422'
)
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }, signal: AbortSignal) {
markStarted()
if (outcome === 'error') throw new Error('runtime failed')
await new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
yield { requestId: request.requestId, type: 'done' }
}
}
const release = vi.fn()
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 createTab = vi.fn<
(
conversationId: string,
ownerWindowId?: number,
signal?: AbortSignal,
workbarInstanceId?: string
) => Promise<{ tabId: BrowserTabId }>
>(async () => ({ tabId }))
const browserControl = {
listTabs: vi.fn(() => []),
getVisibleTabId: vi.fn(() => undefined),
createTab,
acquireTabUsage: vi.fn(
(conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({
conversationId,
tabId: acquiredTabId,
owner,
signal: new AbortController().signal,
release
})
),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
browserControl
)
const event = trustedEvent(harness.webContents)
await harness.handler?.(event, {
requestId,
conversationId: `browser-${outcome}`,
prompt: '使用浏览器',
workMode: 'execute',
knowledgeLibraryIds: []
})
await started
if (outcome === 'cancel') {
harness.cancelHandler?.(event, requestId)
}
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce())
const workbarInstanceId = createTab.mock.calls[0]?.[3]
expect(workbarInstanceId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
)
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
`browser-${outcome}`,
tabId,
requestId,
harness.webContents.id
)
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'browser-capability'
)
await harness.dispose()
}
)
it.each(['ask', 'execute'] as const)(
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
async (workMode) => {
@@ -9400,7 +9797,7 @@ describe('registerIpcHandlers agent terminal state', () => {
) => Promise<string>
) {
authorization = await authorize({
scopeKey: 'model:builtin:workspace_write_text',
scopeKey: 'model:builtin:workspace_apply_patch',
title: '写入文件',
description: '写入 README.md'
})
+401 -32
View File
@@ -4,7 +4,8 @@ import {
clipboard,
dialog,
ipcMain,
shell
shell,
type IpcMainInvokeEvent
} from 'electron'
import {
lstat,
@@ -13,7 +14,7 @@ import {
realpath,
stat
} from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
@@ -27,11 +28,19 @@ import {
agentQuestionResponseSchema,
agentRequestSchema,
browserBackRequestSchema,
browserClickRequestSchema,
browserCloseTabRequestSchema,
browserCreateTabRequestSchema,
browserListTabsRequestSchema,
browserNavigateRequestSchema,
browserReloadRequestSchema,
browserScreenshotRequestSchema,
browserSelectRequestSchema,
browserSetViewportRequestSchema,
browserSnapshotRequestSchema,
browserStopLoadingRequestSchema,
browserStopRequestSchema,
browserTypeRequestSchema,
clipboardTextSchema,
conversationQueueUserInputSchema,
defaultRuntimeSettings,
@@ -62,6 +71,7 @@ import {
type AgentRequest,
type AppInfo,
type BrowserLiveState,
type BrowserTabId,
type ConversationQueueDispatch,
type ConversationQueueUserInput,
type KnowledgeSearchReference,
@@ -442,10 +452,102 @@ function runtimeTargetFor(
type ScopedDataCapability = {
token?: string
toolNames: readonly string[]
browserTabId?: BrowserTabId
}
function grantScopedDataCapability(input: {
type BrowserRequestTabUsageLease = {
readonly conversationId: string
readonly tabId: BrowserTabId
readonly owner: string
readonly signal: AbortSignal
release(): void
}
type BrowserCapabilityControl = {
createTab(
conversationId: string,
ownerWindowId?: number,
signal?: AbortSignal,
workbarInstanceId?: string
): Promise<{ tabId: BrowserTabId }>
listTabs(
conversationId: string,
ownerWindowId?: number
): Array<{ tabId: BrowserTabId; primary: boolean }>
getVisibleTabId(
conversationId: string,
ownerWindowId?: number
): BrowserTabId | undefined
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string,
ownerWindowId?: number
): BrowserRequestTabUsageLease
}
function requestWorkbarInstanceId(owner: string): string {
const bytes = createHash('sha256')
.update('goodbuddy:browser-request-workbar:')
.update(owner)
.digest()
.subarray(0, 16)
bytes[6] = (bytes[6]! & 0x0f) | 0x50
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = bytes.toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
async function bindBrowserTab(input: {
control?: BrowserCapabilityControl
conversationId: string
browserTabId?: BrowserTabId
owner: string
ownerWindowId?: number
signal: AbortSignal
}): Promise<BrowserRequestTabUsageLease> {
if (!input.control) {
throw new Error('GoodBuddy 内置浏览器服务不可用')
}
const tabs = input.control.listTabs(
input.conversationId,
input.ownerWindowId
)
let tabId: BrowserTabId
if (input.browserTabId) {
if (!tabs.some((tab) => tab.tabId === input.browserTabId)) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
tabId = input.browserTabId
} else {
const visibleTabId = input.control.getVisibleTabId(
input.conversationId,
input.ownerWindowId
)
const primary = tabs.find((tab) => tab.primary)
tabId = visibleTabId && tabs.some((tab) => tab.tabId === visibleTabId)
? visibleTabId
: primary?.tabId ??
(
await input.control.createTab(
input.conversationId,
input.ownerWindowId,
input.signal,
requestWorkbarInstanceId(input.owner)
)
).tabId
}
return input.control.acquireTabUsage(
input.conversationId,
tabId,
input.owner,
input.ownerWindowId
)
}
async function grantScopedDataCapability(input: {
gateway?: KnowledgeMcpGateway
browserControl?: BrowserCapabilityControl
runtime: AgentRuntime
enabledServers: readonly BuiltinMcpServerId[]
requestId: string
@@ -454,12 +556,15 @@ function grantScopedDataCapability(input: {
configAccess?: MagicNotesCapabilityAccess
workspacePath?: string
browserConversationId?: string
browserTabId?: BrowserTabId
ownerWindowId?: number
authorizeConfigApply?: (
event: GoodBuddyConfigApplyEvent,
signal: AbortSignal
) => Promise<boolean>
signal: AbortSignal
}): ScopedDataCapability {
abort?: (reason: unknown) => void
}): Promise<ScopedDataCapability> {
const enabledServers = new Set(input.enabledServers)
const libraryIds = enabledServers.has('knowledge-base')
? input.libraryIds
@@ -485,6 +590,22 @@ function grantScopedDataCapability(input: {
if (!input.gateway) {
throw new Error('GoodBuddy 内置工具服务不可用')
}
const browserUsageLease = browserConversationId
? await bindBrowserTab({
control: input.browserControl,
conversationId: browserConversationId,
browserTabId: input.browserTabId,
owner: input.requestId,
ownerWindowId: input.ownerWindowId,
signal: input.signal
})
: undefined
const browserTabId = browserUsageLease?.tabId
browserUsageLease?.signal.addEventListener(
'abort',
() => input.abort?.(browserUsageLease.signal.reason),
{ once: true }
)
const config =
configAccess !== 'none' && input.workspacePath
? {
@@ -500,7 +621,9 @@ function grantScopedDataCapability(input: {
input.signal,
magicNotesAccess,
config,
browserConversationId
browserConversationId,
browserTabId,
browserUsageLease
)
: config
? input.gateway.grant(
@@ -518,6 +641,7 @@ function grantScopedDataCapability(input: {
)
return {
token,
browserTabId,
toolNames: token
? input.gateway.getAvailableToolNames(token)
: []
@@ -1086,25 +1210,98 @@ export function registerIpcHandlers(
onRuntimeSettingsChanged: () => Promise<void>,
onBeforeClearLocalData?: () => Promise<void>,
browserControl?: {
createTab(
conversationId: string,
ownerWindowId?: number,
signal?: AbortSignal,
workbarInstanceId?: string
): Promise<unknown>
listTabs(
conversationId: string,
ownerWindowId?: number
): unknown[]
getVisibleTabId(
conversationId: string,
ownerWindowId?: number
): BrowserTabId | undefined
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string,
ownerWindowId?: number
): BrowserRequestTabUsageLease
closeTab(
conversationId: string,
tabId: BrowserTabId,
ownerWindowId?: number
): Promise<void>
navigate(
conversationId: string,
url: string,
signal: AbortSignal
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<{ url: string; origin: string }>
back(
conversationId: string,
signal: AbortSignal
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<{ url: string; origin: string }>
reload(
conversationId: string,
signal: AbortSignal
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<{ url: string; origin: string }>
stopLoading(conversationId: string): boolean | Promise<boolean>
snapshot(
conversationId: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<unknown>
click(
conversationId: string,
ref: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
type(
conversationId: string,
ref: string,
text: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
select(
conversationId: string,
ref: string,
value: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
screenshot(
conversationId: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<unknown>
stopLoading(
conversationId: string,
tabId?: BrowserTabId,
ownerWindowId?: number
): boolean | Promise<boolean>
setViewport(
conversationId?: string,
bounds?: { x: number; y: number; width: number; height: number }
): void
releaseConversation(conversationId: string): Promise<void>
bounds?: { x: number; y: number; width: number; height: number },
tabId?: BrowserTabId,
leaseToken?: string,
ownerWindowId?: number
): boolean | void
releaseConversation(conversationId: string, ownerWindowId?: number): Promise<void>
onState(listener: (state: BrowserLiveState) => void): () => void
},
subagentService?: SubagentService,
@@ -1378,21 +1575,22 @@ export function registerIpcHandlers(
window.on('unmaximize', notifyMaximizedChanged)
const lastSentBrowserFrames = new Map<string, string>()
const removeBrowserStateListener = browserControl?.onState((state) => {
if (!window.isDestroyed()) {
if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) {
const tabStateKey = `${state.conversationId}\u0000${state.tabId}`
const frame = state.frameDataUrl
let payload: BrowserLiveState = state
if (
frame &&
lastSentBrowserFrames.get(state.conversationId) === frame
lastSentBrowserFrames.get(tabStateKey) === frame
) {
payload = { ...state }
delete payload.frameDataUrl
}
if (frame) {
lastSentBrowserFrames.set(state.conversationId, frame)
lastSentBrowserFrames.set(tabStateKey, frame)
}
if (state.status === 'stopped') {
lastSentBrowserFrames.delete(state.conversationId)
lastSentBrowserFrames.delete(tabStateKey)
}
window.webContents.send(ipcChannels.browserState, payload)
}
@@ -2343,8 +2541,9 @@ export function registerIpcHandlers(
(id) => id !== 'builtin-browser'
)
: []
const notesCapability = grantScopedDataCapability({
const notesCapability = await grantScopedDataCapability({
gateway: knowledgeGateway,
browserControl: browserControl as BrowserCapabilityControl | undefined,
runtime: requestRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId,
@@ -2358,7 +2557,9 @@ export function registerIpcHandlers(
schedule.workMode === 'execute'
? runtimeConversationId
: undefined,
signal: controller.signal
ownerWindowId: window.webContents.id,
signal: controller.signal,
abort: (reason) => controller.abort(reason)
})
knowledgeCapabilityToken = notesCapability.token
const noteTools = notesCapability.toolNames
@@ -2456,6 +2657,9 @@ export function registerIpcHandlers(
trustedInstructions,
...(knowledgeCapabilityToken
? { knowledgeCapabilityToken }
: {}),
...(notesCapability.browserTabId
? { browserTabId: notesCapability.browserTabId }
: {})
}
for await (const agentEvent of requestRuntime.run(
@@ -3467,7 +3671,7 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const request = browserStopRequestSchema.parse(input)
await Promise.allSettled([
browserControl?.releaseConversation(request.conversationId),
browserControl?.releaseConversation(request.conversationId, event.sender.id),
selectedRuntimes
? selectedRuntimes.releaseConversation(request.conversationId)
: runtime.releaseConversation?.(request.conversationId)
@@ -3490,7 +3694,45 @@ export function registerIpcHandlers(
return browserControl
}
const runBrowserOperation = async <T>(
event: IpcMainInvokeEvent,
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs = 30_000
): Promise<T> => {
const controller = new AbortController()
const sender = event.sender
const senderDestroyed = (): void => {
controller.abort(new Error('浏览器请求所属窗口已关闭'))
}
if (sender.isDestroyed()) senderDestroyed()
else sender.once?.('destroyed', senderDestroyed)
try {
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(timeoutMs)
])
signal.throwIfAborted()
return await new Promise<T>((resolve, reject) => {
const aborted = (): void => reject(signal.reason)
signal.addEventListener('abort', aborted, { once: true })
void operation(signal).then(
(value) => {
signal.removeEventListener('abort', aborted)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', aborted)
reject(error)
}
)
})
} finally {
sender.removeListener?.('destroyed', senderDestroyed)
}
}
const runUiBrowserNavigation = async (
event: IpcMainInvokeEvent,
operation: (
control: NonNullable<typeof browserControl>,
signal: AbortSignal
@@ -3498,7 +3740,7 @@ export function registerIpcHandlers(
): Promise<void> => {
try {
const control = await requireBrowserControl()
await operation(control, new AbortController().signal)
await runBrowserOperation(event, (signal) => operation(control, signal))
} catch (error) {
if (!(error instanceof BrowserNavigationStoppedError)) {
throw error
@@ -3506,16 +3748,56 @@ export function registerIpcHandlers(
}
}
registerHandler(
ipcChannels.browserCreateTab,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserCreateTabRequestSchema.parse(input)
const control = await requireBrowserControl()
return runBrowserOperation(event, (signal) =>
control.createTab(
request.conversationId,
event.sender.id,
signal,
request.workbarInstanceId
)
)
}
)
registerHandler(ipcChannels.browserListTabs, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserListTabsRequestSchema.parse(input)
const control = await requireBrowserControl()
return control.listTabs(request.conversationId, event.sender.id)
})
registerHandler(
ipcChannels.browserCloseTab,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserCloseTabRequestSchema.parse(input)
const control = await requireBrowserControl()
await control.closeTab(
request.conversationId,
request.tabId,
event.sender.id
)
}
)
registerHandler(
ipcChannels.browserNavigate,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserNavigateRequestSchema.parse(input)
await runUiBrowserNavigation((control, signal) =>
await runUiBrowserNavigation(event, (control, signal) =>
control.navigate(
request.conversationId,
request.url,
signal
signal,
request.tabId,
event.sender.id
)
)
}
@@ -3526,10 +3808,12 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserBackRequestSchema.parse(input)
await runUiBrowserNavigation((control, signal) =>
await runUiBrowserNavigation(event, (control, signal) =>
control.back(
request.conversationId,
signal
signal,
request.tabId,
event.sender.id
)
)
}
@@ -3540,10 +3824,12 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserReloadRequestSchema.parse(input)
await runUiBrowserNavigation((control, signal) =>
await runUiBrowserNavigation(event, (control, signal) =>
control.reload(
request.conversationId,
signal
signal,
request.tabId,
event.sender.id
)
)
}
@@ -3554,7 +3840,79 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserStopLoadingRequestSchema.parse(input)
await browserControl?.stopLoading(request.conversationId)
await browserControl?.stopLoading(
request.conversationId,
request.tabId,
event.sender.id
)
}
)
registerHandler(ipcChannels.browserSnapshot, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserSnapshotRequestSchema.parse(input)
const control = await requireBrowserControl()
return runBrowserOperation(event, (signal) => control.snapshot(
request.conversationId,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(ipcChannels.browserClick, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserClickRequestSchema.parse(input)
const control = await requireBrowserControl()
await runBrowserOperation(event, (signal) => control.click(
request.conversationId,
request.ref,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(ipcChannels.browserType, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserTypeRequestSchema.parse(input)
const control = await requireBrowserControl()
await runBrowserOperation(event, (signal) => control.type(
request.conversationId,
request.ref,
request.text,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(ipcChannels.browserSelect, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserSelectRequestSchema.parse(input)
const control = await requireBrowserControl()
await runBrowserOperation(event, (signal) => control.select(
request.conversationId,
request.ref,
request.value,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(
ipcChannels.browserScreenshot,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserScreenshotRequestSchema.parse(input)
const control = await requireBrowserControl()
return runBrowserOperation(event, (signal) => control.screenshot(
request.conversationId,
signal,
request.tabId,
event.sender.id
))
}
)
@@ -3789,8 +4147,9 @@ export function registerIpcHandlers(
: undefined
: resolvedRuntimeSettings?.workspacePath
const controller = new AbortController()
const scopedCapability = grantScopedDataCapability({
const scopedCapability = await grantScopedDataCapability({
gateway: knowledgeGateway,
browserControl: browserControl as BrowserCapabilityControl | undefined,
runtime: selectedRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId: enrichedRequest.requestId,
@@ -3807,7 +4166,9 @@ export function registerIpcHandlers(
enrichedRequest.workMode === 'execute'
? enrichedRequest.conversationId
: undefined,
signal: controller.signal
ownerWindowId: event.sender.id,
signal: controller.signal,
abort: (reason) => controller.abort(reason)
})
const knowledgeCapabilityToken = scopedCapability.token
const availableTools = [
@@ -3846,6 +4207,9 @@ export function registerIpcHandlers(
const request: AgentExecutionRequest = knowledgeCapabilityToken
? { ...baseRequest, knowledgeCapabilityToken }
: baseRequest
if (scopedCapability.browserTabId) {
request.browserTabId = scopedCapability.browserTabId
}
const managedSshExecution =
agentRuntimeSelected &&
configExecutionSpace?.kind === 'ssh'
@@ -4649,7 +5013,6 @@ export function registerIpcHandlers(
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-500)
if (
persistedHistory.length !== request.history.length ||
persistedHistory.some(
@@ -6639,7 +7002,13 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const request = browserSetViewportRequestSchema.parse(input)
if (!request.conversationId || !request.bounds) {
browserControl?.setViewport()
browserControl?.setViewport(
undefined,
undefined,
undefined,
request.leaseToken,
event.sender.id
)
return
}
browserControl?.setViewport(request.conversationId, {
@@ -6647,7 +7016,7 @@ export function registerIpcHandlers(
y: request.bounds.y,
width: request.bounds.width,
height: request.bounds.height
})
}, request.tabId, request.leaseToken, event.sender.id)
}
)
registerHandler(
+15 -11
View File
@@ -515,12 +515,10 @@ const defaultSettings: StoredSettings = {
],
defaultModelProfileId,
opencodeModelSource: {
kind: 'profile',
profileId: defaultModelProfileId
kind: 'default'
},
continueModelSource: {
kind: 'profile',
profileId: defaultModelProfileId
kind: 'default'
},
opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl,
opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded,
@@ -820,7 +818,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
const normalizeSource = (
source: RuntimeSettings['opencodeModelSource']
): RuntimeSettings['opencodeModelSource'] => {
if (source.kind === 'platform') {
if (source.kind !== 'profile') {
return source
}
const profile = modelProfiles.find(
@@ -839,6 +837,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
if (!source || source.kind === 'platform') {
return { kind: 'platform' }
}
if (source.kind === 'default') return source
const profile = modelProfiles.find(
(candidate) => candidate.id === source.profileId
)
@@ -1431,6 +1430,9 @@ export class RuntimeSettingsStore {
modelProfiles: readonly ResolvedModelProfile[]
): ResolvedModelProfile | undefined {
const source = settings.deepseekHarnessModelSource
if (source.kind === 'default') {
return modelProfiles.find((profile) => profile.id === settings.defaultModelProfileId)
}
if (source.kind === 'profile') {
return modelProfiles.find(
(profile) => profile.id === source.profileId
@@ -1936,12 +1938,14 @@ export class RuntimeSettingsStore {
)
const opencodeModelProfile =
!agent.opencodeBaseUrl &&
settings.opencodeModelSource.kind === 'profile'
? profilesById.get(settings.opencodeModelSource.profileId)
settings.opencodeModelSource.kind !== 'platform'
? profilesById.get(settings.opencodeModelSource.kind === 'default'
? settings.defaultModelProfileId : settings.opencodeModelSource.profileId)
: undefined
const continueModelProfile =
settings.continueModelSource.kind === 'profile'
? profilesById.get(settings.continueModelSource.profileId)
settings.continueModelSource.kind !== 'platform'
? profilesById.get(settings.continueModelSource.kind === 'default'
? settings.defaultModelProfileId : settings.continueModelSource.profileId)
: undefined
const deepseekHarnessModelProfile =
this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles)
@@ -2362,7 +2366,7 @@ export class RuntimeSettingsStore {
source: RuntimeSettings['opencodeModelSource'],
runtimeLabel: 'OpenCode' | 'Continue'
): void => {
if (source.kind === 'platform') {
if (source.kind !== 'profile') {
return
}
const profile = modelProfiles.find(
@@ -2386,7 +2390,7 @@ export class RuntimeSettingsStore {
const repairRuntimeSource = (
source: RuntimeSettings['opencodeModelSource']
): RuntimeSettings['opencodeModelSource'] => {
if (source.kind === 'platform') {
if (source.kind !== 'profile') {
return source
}
const profile = modelProfiles.find(
+84 -22
View File
@@ -7,7 +7,23 @@ import {
type AgentRuntimeDetection,
type AgentRuntimeStatus,
type AppInfo,
type BrowserBackRequest,
type BrowserClickRequest,
type BrowserCloseTabRequest,
type BrowserCreateTabRequest,
type BrowserLiveState,
type BrowserListTabsRequest,
type BrowserNavigateRequest,
type BrowserReloadRequest,
type BrowserScreenshotRequest,
type BrowserScreenshotResult,
type BrowserSelectRequest,
type BrowserSetViewportRequest,
type BrowserSnapshotRequest,
type BrowserSnapshotResult,
type BrowserStopLoadingRequest,
type BrowserTabSummary,
type BrowserTypeRequest,
type ContextAttachment,
type ContextFileSelectionProgress,
type DesktopApi,
@@ -187,6 +203,7 @@ import type {
TerminalWriteRequest
} from '../shared/terminal-contracts'
const desktopApi: DesktopApi = {
app: {
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
@@ -307,32 +324,77 @@ const desktopApi: DesktopApi = {
}
},
browser: {
navigate: async (conversationId: string, url: string) => {
await ipcRenderer.invoke(ipcChannels.browserNavigate, {
conversationId,
url
})
createTab: (request: BrowserCreateTabRequest) =>
ipcRenderer.invoke(
ipcChannels.browserCreateTab,
request
) as Promise<BrowserTabSummary>,
listTabs: (request: BrowserListTabsRequest) =>
ipcRenderer.invoke(
ipcChannels.browserListTabs,
request
) as Promise<BrowserTabSummary[]>,
closeTab: async (request: BrowserCloseTabRequest) => {
await ipcRenderer.invoke(ipcChannels.browserCloseTab, request)
},
back: async (conversationId: string) => {
await ipcRenderer.invoke(ipcChannels.browserBack, {
conversationId
})
navigate: async (
requestOrConversation: BrowserNavigateRequest | string,
url?: string
) => {
await ipcRenderer.invoke(
ipcChannels.browserNavigate,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation, url }
: requestOrConversation
)
},
reload: async (conversationId: string) => {
await ipcRenderer.invoke(ipcChannels.browserReload, {
conversationId
})
back: async (requestOrConversation: BrowserBackRequest | string) => {
await ipcRenderer.invoke(
ipcChannels.browserBack,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation }
: requestOrConversation
)
},
stopLoading: async (conversationId: string) => {
await ipcRenderer.invoke(ipcChannels.browserStopLoading, {
conversationId
})
reload: async (requestOrConversation: BrowserReloadRequest | string) => {
await ipcRenderer.invoke(
ipcChannels.browserReload,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation }
: requestOrConversation
)
},
setViewport: async (conversationId, bounds) => {
await ipcRenderer.invoke(ipcChannels.browserSetViewport, {
...(conversationId ? { conversationId } : {}),
...(bounds ? { bounds } : {})
})
stopLoading: async (
requestOrConversation: BrowserStopLoadingRequest | string
) => {
await ipcRenderer.invoke(
ipcChannels.browserStopLoading,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation }
: requestOrConversation
)
},
snapshot: (request: BrowserSnapshotRequest) =>
ipcRenderer.invoke(
ipcChannels.browserSnapshot,
request
) as Promise<BrowserSnapshotResult>,
click: async (request: BrowserClickRequest) => {
await ipcRenderer.invoke(ipcChannels.browserClick, request)
},
type: async (request: BrowserTypeRequest) => {
await ipcRenderer.invoke(ipcChannels.browserType, request)
},
select: async (request: BrowserSelectRequest) => {
await ipcRenderer.invoke(ipcChannels.browserSelect, request)
},
screenshot: (request: BrowserScreenshotRequest) =>
ipcRenderer.invoke(
ipcChannels.browserScreenshot,
request
) as Promise<BrowserScreenshotResult>,
setViewport: async (request: BrowserSetViewportRequest) => {
await ipcRenderer.invoke(ipcChannels.browserSetViewport, request)
},
stop: async (conversationId: string) => {
await ipcRenderer.invoke(
+25 -1
View File
@@ -47,16 +47,40 @@ describe('sandboxed preload', () => {
/browser: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}terminal:/u
)?.groups?.body ?? ''
for (const method of [
'createTab:',
'listTabs:',
'closeTab:',
'navigate:',
'back:',
'reload:',
'stopLoading:',
'interact:',
'snapshot:',
'click:',
'type:',
'select:',
'screenshot:',
'setViewport:',
'stop:',
'onState:'
]) {
expect(browser).toContain(method)
}
for (const channel of [
'browserCreateTab',
'browserListTabs',
'browserCloseTab',
'browserSnapshot',
'browserClick',
'browserType',
'browserSelect',
'browserScreenshot',
'browserSetViewport'
]) {
expect(browser).toContain(`ipcChannels.${channel}`)
}
expect(browser).toContain('requestOrConversation: BrowserNavigateRequest')
expect(browser).toContain('setViewport: async (request: BrowserSetViewportRequest)')
expect(browser).not.toContain('leaseId')
expect(browser).not.toMatch(
/\b(?:webContents|debugger|session|partition|cookie)\b/iu
)
@@ -7,7 +7,18 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceFilePreview } from '../../shared/assistant-contracts'
import type {
AssistantProject,
AssistantTask,
WorkspaceFilePreview
} from '../../shared/assistant-contracts'
import type {
BrowserCreateTabRequest,
BrowserLiveState,
BrowserSetViewportRequest,
BrowserTabId,
BrowserTabSummary
} from '../../shared/contracts'
import {
RightAssistantSidebar,
type AssistantSidebarTab,
@@ -17,15 +28,74 @@ import {
afterEach(cleanup)
const firstBrowserTabId =
'00000000-0000-4000-8000-000000000401' as BrowserTabId
const secondBrowserTabId =
'00000000-0000-4000-8000-000000000402' as BrowserTabId
const browserApi = {
createTab: vi.fn<(request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>>(),
closeTab: vi.fn(async () => undefined),
setViewport: vi.fn<
(request: BrowserSetViewportRequest) => Promise<void>
>(async () => undefined)
}
function browserSummary(
conversationId: string,
tabId: BrowserTabId,
primary: boolean
): BrowserTabSummary {
return {
conversationId,
tabId,
primary,
status: 'ready',
isLoading: false,
canGoBack: false,
createdAt: 1,
updatedAt: 1
}
}
beforeEach(() => {
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1400
})
browserApi.createTab.mockReset()
browserApi.closeTab.mockReset().mockResolvedValue(undefined)
browserApi.setViewport.mockReset().mockResolvedValue(undefined)
const tabsByOwner = new Map<string, BrowserTabId>()
browserApi.createTab.mockImplementation(async ({ conversationId, workbarInstanceId }) => {
const ownershipKey = `${conversationId}:${workbarInstanceId}`
let tabId = tabsByOwner.get(ownershipKey)
if (!tabId) {
tabId = tabsByOwner.size === 0 ? firstBrowserTabId : secondBrowserTabId
tabsByOwner.set(ownershipKey, tabId)
}
return browserSummary(conversationId, tabId, tabsByOwner.size === 1)
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: { browser: browserApi }
})
})
function renderSidebar({
const currentProject: AssistantProject = {
id: '00000000-0000-4000-8000-000000000301',
name: '当前项目',
description: '',
rootPath: 'D:\\project',
defaultWorkMode: 'ask',
kind: 'user',
executionSpace: { kind: 'local', rootPath: 'D:\\project' },
status: 'active',
createdAt: '2026-09-09T00:00:00.000Z',
updatedAt: '2026-09-09T00:00:00.000Z'
}
function sidebarElement({
tab = 'tasks',
approvals = [],
artifacts = [],
@@ -36,8 +106,16 @@ function renderSidebar({
})),
onLoadArtifact = vi.fn(async () => undefined),
onLoadWorkspaceFile = vi.fn(),
tasks = [],
activeProject = currentProject,
workspaceProjectId,
restoreFocusRef
restoreFocusRef,
activeConversationId,
browserStates,
onBackBrowser,
onNavigateBrowser,
onReloadBrowser,
onStopLoadingBrowser
}: {
tab?: AssistantSidebarTab
approvals?: PendingSidebarApproval[]
@@ -58,31 +136,46 @@ function renderSidebar({
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
tasks?: AssistantTask[]
activeProject?: AssistantProject | null
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
} = {}): HTMLElement {
render(
activeConversationId?: string
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
} = {}): React.JSX.Element {
return (
<div>
<main className="workspace" />
<RightAssistantSidebar
approvals={approvals}
activeConversationId={activeConversationId}
artifacts={artifacts}
browserStates={browserStates}
schedules={[]}
tasks={[]}
tasks={tasks}
conversationTitles={new Map()}
currentProject={activeProject ?? undefined}
projectNames={new Map()}
onCreateCustomTask={vi.fn()}
onBackBrowser={onBackBrowser}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={onListWorkspaceDirectory}
onLoadArtifact={onLoadArtifact}
onLoadWorkspaceFile={onLoadWorkspaceFile}
onNavigateBrowser={onNavigateBrowser}
onLoadWorkspaceDiff={vi.fn()}
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
onRefreshChanges={vi.fn(async () => undefined)}
onReloadBrowser={onReloadBrowser}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onStopLoadingBrowser={onStopLoadingBrowser}
onOpenTask={vi.fn()}
onTabChange={vi.fn()}
open
@@ -92,7 +185,10 @@ function renderSidebar({
/>
</div>
)
}
function renderSidebar(options: Parameters<typeof sidebarElement>[0] = {}): HTMLElement {
render(sidebarElement(options))
return screen.getByRole('complementary', {
name: '助手工作栏'
})
@@ -257,7 +353,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(
screen.getAllByRole('tab').map((tab) => tab.textContent)
).toEqual(['任务中心', '工作区', '浏览器', '成果'])
).toEqual(['任务中心', '工作区', '浏览器 · 未绑定会话', '成果'])
expect(
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
@@ -289,6 +385,16 @@ describe('RightAssistantSidebar resizing', () => {
screen.getByRole('separator', { name: '调整助手工作栏宽度' })
).toHaveAttribute('tabindex', '-1')
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
expect(sidebar).not.toHaveClass('assistant-sidebar--browser-fullscreen')
fireEvent.click(screen.getByRole('tab', { name: /浏览器/u }))
expect(
screen.getByRole('button', { name: '全屏显示浏览器' })
).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(
screen.getByRole('button', { name: '全屏显示浏览器' })
)
fireEvent.keyDown(window, { key: 'Escape' })
expect(sidebar).not.toHaveClass(
'assistant-sidebar--browser-fullscreen'
@@ -332,6 +438,7 @@ describe('RightAssistantSidebar resizing', () => {
conversationId: 'conversation-1',
messageId: 'message-1',
approvalId: 'approval-1',
projectId: currentProject.id,
title: '写入工作区',
description: '更新 release.md',
toolName: 'write_file'
@@ -452,6 +559,102 @@ describe('RightAssistantSidebar resizing', () => {
expect(onListWorkspaceDirectory).toHaveBeenCalledTimes(2)
})
it('scopes approvals by their owning conversation project', () => {
renderSidebar({
approvals: [
{
conversationId: 'conversation-current',
projectId: currentProject.id,
messageId: 'message-current',
approvalId: 'approval-current',
title: '当前项目审批',
description: 'current'
},
{
conversationId: 'conversation-global',
messageId: 'message-global',
approvalId: 'approval-global',
title: '全局审批',
description: 'global'
}
]
})
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.queryByText('全局审批')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
)
)
expect(screen.getByText('全局审批')).toBeInTheDocument()
expect(screen.queryByText('当前项目审批')).not.toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument()
})
it('scopes tasks to the current project, global tasks, or all projects and persists the choice', () => {
const createTask = (
id: string,
title: string,
projectId?: string
): AssistantTask => ({
id,
projectId,
title,
instructions: title,
origin: 'schedule',
status: 'running',
createdAt: '2026-09-09T00:00:00.000Z'
})
renderSidebar({
tasks: [
createTask('task-current', '当前项目任务', currentProject.id),
createTask('task-global', '全局任务'),
createTask(
'task-other',
'其他项目任务',
'00000000-0000-4000-8000-000000000302'
)
]
})
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.queryByText('全局任务')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
)
)
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.queryByText('当前项目任务')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '所有项目' }
)
)
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.getByText('其他项目任务')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!))
.toEqual(expect.objectContaining({ taskScope: 'all-projects' }))
})
it('shows a clear current-project empty state when no project is active', () => {
renderSidebar({ activeProject: null })
expect(
screen.getByText(
'没有活动项目。请选择其他任务范围或先打开一个项目。'
)
).toBeInTheDocument()
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
})
it('keeps preview content and reading position when refreshing fails', async () => {
const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({
path: 'README.md', name: 'README.md', content: '# Original heading',
@@ -590,4 +793,342 @@ describe('RightAssistantSidebar resizing', () => {
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
})
it('keeps browser A selected and usable after switching to B, and binds a new browser to B', async () => {
const onNavigateBrowser = vi.fn(async () => undefined)
const onBackBrowser = vi.fn(async () => undefined)
const onReloadBrowser = vi.fn(async () => undefined)
const onStopLoadingBrowser = vi.fn(async () => undefined)
const browserStates = {
'conversation-a': {
[firstBrowserTabId]: {
...browserSummary('conversation-a', firstBrowserTabId, true),
sessionActive: true,
canGoBack: true,
url: 'https://a.example/'
}
},
'conversation-b': {
[secondBrowserTabId]: {
...browserSummary('conversation-b', secondBrowserTabId, true),
sessionActive: true,
url: 'https://b.example/'
}
}
}
const props = {
tab: 'browser' as const,
activeConversationId: 'conversation-a',
browserStates,
onNavigateBrowser,
onBackBrowser,
onReloadBrowser,
onStopLoadingBrowser
}
const view = render(sidebarElement(props))
await waitFor(() =>
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
)
const browserA = screen.getByRole('tab', { name: '浏览器 · conversation-a' })
const ownerA = browserApi.createTab.mock.calls[0]![0].workbarInstanceId
view.rerender(sidebarElement({ ...props, activeConversationId: 'conversation-b' }))
expect(browserA).toBeVisible()
expect(browserA).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
expect(browserApi.createTab).toHaveBeenCalledOnce()
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
target: { value: 'https://a.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith(
'conversation-a', firstBrowserTabId, 'https://a.example/next'
))
fireEvent.click(screen.getByRole('button', { name: '返回' }))
await waitFor(() => {
expect(onBackBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId)
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => {
expect(onReloadBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId)
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
})
view.rerender(sidebarElement({
...props,
activeConversationId: 'conversation-b',
browserStates: {
...browserStates,
'conversation-a': {
[firstBrowserTabId]: { ...browserStates['conversation-a'][firstBrowserTabId]!, isLoading: true }
}
}
}))
fireEvent.click(screen.getByRole('button', { name: '停止加载' }))
await waitFor(() => expect(onStopLoadingBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId))
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
expect(browserApi.createTab).toHaveBeenLastCalledWith({
conversationId: 'conversation-b',
workbarInstanceId: expect.any(String)
})
expect(browserApi.createTab.mock.calls[1]![0].workbarInstanceId).not.toBe(ownerA)
expect(screen.getByRole('tab', { name: '浏览器 · conversation-b' })).toHaveAttribute('aria-selected', 'true')
expect(browserA).toBeVisible()
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://b.example/'))
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenLastCalledWith(
'conversation-b', secondBrowserTabId, 'https://b.example/'
))
fireEvent.click(browserA)
expect(browserA).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
expect(browserApi.createTab).toHaveBeenCalledTimes(2)
})
it('keeps two browser instances independent and closes only the selected Main tab', async () => {
const conversationId = 'conversation-a'
const onNavigateBrowser = vi.fn(async () => undefined)
const states: Record<string, Record<string, BrowserLiveState>> = {
[conversationId]: {
[firstBrowserTabId]: {
conversationId,
tabId: firstBrowserTabId,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
url: 'https://first.example/',
updatedAt: 1
},
[secondBrowserTabId]: {
conversationId,
tabId: secondBrowserTabId,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
url: 'https://second.example/',
updatedAt: 2
}
}
}
renderSidebar({
tab: 'browser',
activeConversationId: conversationId,
browserStates: states,
onNavigateBrowser
})
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
const firstAddress = screen.getByRole('textbox', {
name: '浏览器地址'
})
await waitFor(() =>
expect(firstAddress).toHaveValue('https://first.example/')
)
fireEvent.change(firstAddress, {
target: { value: 'https://first.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() =>
expect(onNavigateBrowser).toHaveBeenCalledWith(
conversationId,
firstBrowserTabId,
'https://first.example/next'
)
)
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(
screen.getByText('浏览器', { selector: 'strong' }).closest('button')!
)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
const ownershipRequests = browserApi.createTab.mock.calls.map(
([request]) => request
)
expect(ownershipRequests[0]?.conversationId).toBe(conversationId)
expect(ownershipRequests[1]?.conversationId).toBe(conversationId)
expect(ownershipRequests[0]?.workbarInstanceId).not.toBe(
ownershipRequests[1]?.workbarInstanceId
)
const secondAddress = screen.getByRole('textbox', {
name: '浏览器地址'
})
await waitFor(() =>
expect(secondAddress).toHaveValue('https://second.example/')
)
fireEvent.change(secondAddress, {
target: { value: 'https://second.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() =>
expect(onNavigateBrowser).toHaveBeenLastCalledWith(
conversationId,
secondBrowserTabId,
'https://second.example/next'
)
)
fireEvent.click(
screen.getByRole('button', { name: /关闭浏览器 · conversation-a · 2/u })
)
await waitFor(() =>
expect(browserApi.closeTab).toHaveBeenCalledWith({
conversationId,
tabId: secondBrowserTabId
})
)
expect(
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
).toBeInTheDocument()
expect(screen.queryByRole('tab', { name: /· 2$/u })).not.toBeInTheDocument()
})
it('restores a browser by stable ownership and releases only its viewport lease', async () => {
const conversationId = 'conversation-restored'
const workbarInstanceId = '10000000-0000-4000-8000-000000000003'
localStorage.setItem(
'goodbuddy.workbar-layout.v1',
JSON.stringify({
instances: [
{
id: '10000000-0000-4000-8000-000000000001',
appId: 'tasks',
title: '任务中心'
},
{
id: '10000000-0000-4000-8000-000000000002',
appId: 'workspace',
title: '工作区'
},
{
id: '10000000-0000-4000-8000-000000000003',
appId: 'browser',
title: '浏览器 · 已恢复',
targetRef: { type: 'conversation', conversationId }
}
],
activeInstanceId: '10000000-0000-4000-8000-000000000001',
expanded: true,
dock: 'right',
widthRatio: 0.3,
taskScope: 'current-project'
})
)
const bounds = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({
bottom: 500,
height: 400,
left: 900,
right: 1300,
top: 100,
width: 400,
x: 900,
y: 100,
toJSON: () => ({})
})
try {
renderSidebar({ activeConversationId: 'different-conversation' })
expect(browserApi.createTab).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() =>
expect(browserApi.createTab).toHaveBeenCalledWith({
conversationId,
workbarInstanceId
})
)
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
tabId: firstBrowserTabId,
bounds: expect.any(Object),
leaseToken: expect.any(String)
})
)
)
const acquired = browserApi.setViewport.mock.calls.find(
([request]) => 'conversationId' in request
)?.[0]
if (!acquired || !('conversationId' in acquired)) {
throw new Error('Expected an acquired viewport lease')
}
expect(acquired.leaseToken).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
)
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenCalledWith({
leaseToken: acquired.leaseToken
})
)
expect(
browserApi.setViewport.mock.calls.some(
([request]) =>
Object.keys(request).length === 1 &&
request.leaseToken !== acquired.leaseToken
)
).toBe(false)
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() => {
const acquisitions = browserApi.setViewport.mock.calls
.map(([request]) => request)
.filter((request) => 'conversationId' in request)
expect(acquisitions).toHaveLength(2)
expect(acquisitions[1]!.leaseToken).not.toBe(
acquisitions[0]!.leaseToken
)
})
cleanup()
renderSidebar({ activeConversationId: 'different-conversation' })
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
expect(browserApi.createTab).toHaveBeenLastCalledWith({
conversationId,
workbarInstanceId
})
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({
conversationId,
tabId: firstBrowserTabId
})
)
)
} finally {
bounds.mockRestore()
}
})
it('keeps a browser workbar tab when closing its Main tab fails', async () => {
browserApi.closeTab.mockRejectedValueOnce(new Error('Close denied'))
renderSidebar({ tab: 'browser', activeConversationId: 'conversation-a' })
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
fireEvent.click(
screen.getByRole('button', { name: /关闭浏览器 · conversation-a/u })
)
expect(await screen.findByRole('alert')).toHaveTextContent('Close denied')
expect(
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
).toBeInTheDocument()
})
})
+93 -26
View File
@@ -39,12 +39,14 @@ const stylesheet = readFileSync(
function ControlledShell({
initialInstances = DEFAULT_WORKBAR_INSTANCES,
onCreate = vi.fn(),
onClose,
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
type: 'local'
}))
}: {
initialInstances?: readonly WorkbarTabInstance[]
onCreate?: (request: WorkbarInstanceCreateRequest) => void
onClose?: (instance: WorkbarTabInstance) => boolean | void | Promise<boolean | void>
onTerminalTarget?: () =>
| WorkbarTargetRef
| Promise<WorkbarTargetRef>
@@ -55,19 +57,27 @@ function ControlledShell({
)
return (
<WorkbarShell
activeInstanceId={activeId}
instances={instances}
onActiveInstanceChange={setActiveId}
onCloseInstance={(instance) =>
setInstances((current) =>
current.filter((candidate) => candidate.id !== instance.id)
)
}
onCreateInstance={onCreate}
onResolveTerminalTarget={onTerminalTarget}
renderPanel={(instance) => <p>{instance.title}内容</p>}
/>
<>
<output data-testid="active-workbar-instance">
{activeId ?? 'none'}
</output>
<WorkbarShell
activeInstanceId={activeId}
instances={instances}
onActiveInstanceChange={setActiveId}
onCloseInstance={async (instance) => {
const accepted = await onClose?.(instance)
if (accepted === false) return false
setInstances((current) =>
current.filter((candidate) => candidate.id !== instance.id)
)
return true
}}
onCreateInstance={onCreate}
onResolveTerminalTarget={onTerminalTarget}
renderPanel={(instance) => <p>{instance.title}内容</p>}
/>
</>
)
}
@@ -87,7 +97,7 @@ describe('WorkbarShell', () => {
)
})
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
it('provides fixed defaults plus a browser tab and keeps add outside the tablist', () => {
render(<ControlledShell />)
const tablist = screen.getByRole('tablist', {
@@ -113,6 +123,20 @@ describe('WorkbarShell', () => {
).not.toHaveClass('workbar-shell__tab-item--active')
})
it('does not expose close controls for fixed tabs', () => {
render(<ControlledShell />)
expect(
screen.queryByRole('button', { name: '关闭任务中心' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '关闭工作区' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '关闭浏览器' })
).toBeInTheDocument()
})
it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => {
render(<ControlledShell />)
const addButton = screen.getByRole('button', {
@@ -196,6 +220,22 @@ describe('WorkbarShell', () => {
)
})
it('allows another browser instance to be requested', () => {
const onCreate = vi.fn()
render(<ControlledShell onCreate={onCreate} />)
fireEvent.click(
screen.getByRole('button', { name: '打开工作栏应用' })
)
fireEvent.click(
screen.getByText('浏览器', { selector: 'strong' }).closest('button')!
)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ appId: 'browser' })
)
})
it('supports roving Arrow, Home, and End navigation', () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
@@ -214,19 +254,23 @@ describe('WorkbarShell', () => {
expect(tasks).toHaveFocus()
})
it('closes the active tab, activates its right neighbor, and restores focus', async () => {
render(<ControlledShell />)
const workspace = screen.getByRole('tab', { name: '工作区' })
fireEvent.click(workspace)
it('closes an active closable tab, activates its neighbor, and restores focus', async () => {
render(
<ControlledShell
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
/>
)
const browser = screen.getByRole('tab', { name: '浏览器' })
fireEvent.click(browser)
fireEvent.click(
screen.getByRole('button', { name: '关闭工作区' })
screen.getByRole('button', { name: '关闭浏览器' })
)
const browser = screen.getByRole('tab', { name: '浏览器' })
await waitFor(() => expect(browser).toHaveFocus())
expect(browser).toHaveAttribute('aria-selected', 'true')
const results = screen.getByRole('tab', { name: '成果' })
await waitFor(() => expect(results).toHaveFocus())
expect(results).toHaveAttribute('aria-selected', 'true')
expect(
screen.queryByRole('tab', { name: '工作区' })
screen.queryByRole('tab', { name: '浏览器' })
).not.toBeInTheDocument()
})
@@ -234,19 +278,39 @@ describe('WorkbarShell', () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
fireEvent.click(
screen.getByRole('button', { name: '关闭成果' })
screen.getByRole('button', { name: '关闭浏览器' })
)
await waitFor(() => expect(tasks).toHaveFocus())
expect(tasks).toHaveAttribute('aria-selected', 'true')
})
it('retains an instance when its close operation rejects', async () => {
const onClose = vi.fn(async () => {
throw new Error('Close denied')
})
render(
<ControlledShell
initialInstances={[terminalOne]}
onClose={onClose}
/>
)
fireEvent.click(
screen.getByRole('button', { name: `关闭${terminalOne.title}` })
)
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
expect(screen.getByRole('tab', { name: terminalOne.title }))
.toBeInTheDocument()
})
it('shows the empty state while preserving the add command after the last close', async () => {
render(
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
<ControlledShell initialInstances={[terminalOne]} />
)
fireEvent.click(
screen.getByRole('button', { name: '关闭任务中心' })
screen.getByRole('button', { name: `关闭${terminalOne.title}` })
)
const addButton = screen.getByRole('button', {
@@ -256,6 +320,9 @@ describe('WorkbarShell', () => {
expect(
screen.getByText('还没有打开的工作栏应用')
).toBeInTheDocument()
expect(screen.getByTestId('active-workbar-instance')).toHaveTextContent(
'none'
)
expect(addButton).toBeVisible()
})
})
+34 -23
View File
@@ -61,7 +61,7 @@ export type WorkbarShellProps = {
activeInstanceId: string | null
renderPanel: (instance: WorkbarTabInstance) => ReactNode
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
onActiveInstanceChange: (instanceId: string) => void
onActiveInstanceChange: (instanceId: string | null) => void
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
onCloseInstance: (
instance: WorkbarTabInstance
@@ -289,6 +289,12 @@ export function WorkbarShell({
const handleClose = async (
instance: WorkbarTabInstance
): Promise<void> => {
const definition = appDefinitions.find(
(candidate) => candidate.id === instance.appId
)
if (definition?.closable !== true) {
return
}
const closingIndex = instances.findIndex(
(candidate) => candidate.id === instance.id
)
@@ -299,19 +305,18 @@ export function WorkbarShell({
null)
: activeInstance
const closeResult = onCloseInstance(instance)
const closeAccepted =
closeResult &&
typeof (closeResult as PromiseLike<boolean | void>).then ===
'function'
? await closeResult
: closeResult
let closeAccepted: boolean | void
try {
closeAccepted = await onCloseInstance(instance)
} catch {
return
}
if (closeAccepted === false) {
return
}
pendingTabFocusRef.current = nextActive?.id ?? null
if (nextActive && nextActive.id !== activeInstanceId) {
onActiveInstanceChange(nextActive.id)
if (isActive && nextActive?.id !== activeInstanceId) {
onActiveInstanceChange(nextActive?.id ?? null)
}
}
@@ -331,6 +336,10 @@ export function WorkbarShell({
const selected =
!catalogOpen && instance.id === activeInstanceId
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
const closable =
appDefinitions.find(
(definition) => definition.id === instance.appId
)?.closable === true
return (
<div
className={joinClassNames(
@@ -376,19 +385,21 @@ export function WorkbarShell({
</span>
{renderTabAdornment?.(instance)}
</button>
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
{closable ? (
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
) : null}
</div>
)
})}
@@ -208,6 +208,9 @@ export const app = {
switching: 'Switching…',
picker: 'Runtime and model',
directModels: 'Direct models',
defaultDirect: 'Direct · Follow the global default model',
fixedRuntimeModel: 'Fixed Runtime model',
fixedModel: 'Fixed',
deepseekHarnessGroup:
'DeepSeek Harness (Developer preview · OpenAI-compatible)',
manage: 'Manage Runtime and model connections',
@@ -310,6 +310,14 @@ export const workspace = {
startedAt: 'Started {{time}}',
nextRunAt: 'Next run: {{time}}',
notStarted: 'Not run yet',
scope: {
ariaLabel: 'Task scope',
currentProject: 'Current project',
global: 'Global',
allProjects: 'All projects',
noCurrentProject:
'No project is active. Choose another task scope or open a project.'
},
filters: {
ariaLabel: 'Filter tasks',
attention: 'Attention',
@@ -365,6 +373,7 @@ export const workspace = {
},
browser: {
title: 'Live browser',
unbound: 'No conversation',
interact: 'Interact',
interacting: 'Interacting',
close: 'Close browser',
@@ -407,6 +416,8 @@ export const workspace = {
backBrowser: 'Could not go back',
reloadBrowser: 'Could not refresh the web page',
stopLoadingBrowser: 'Could not stop loading the page',
closeBrowser: 'Could not close this browser tab',
browserControlUnavailable: 'Browser tab controls are unavailable',
interactBrowser: 'Could not open the browser interaction window',
stopBrowser: 'Could not close the browser'
}
@@ -195,6 +195,9 @@ export const app = {
switching: '切换中…',
picker: 'Runtime 和模型',
directModels: '直连模型',
defaultDirect: '直连 · 跟随全局默认模型',
fixedRuntimeModel: '固定 Runtime 模型',
fixedModel: '固定',
deepseekHarnessGroup:
'DeepSeek Harness(开发者预览 · OpenAI 兼容)',
manage: '管理 Runtime 和模型连接',
@@ -302,6 +302,13 @@ export const workspace = {
startedAt: '{{time}} 开始',
nextRunAt: '下次运行:{{time}}',
notStarted: '尚未运行',
scope: {
ariaLabel: '任务范围',
currentProject: '当前项目',
global: '全局',
allProjects: '所有项目',
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
},
filters: {
ariaLabel: '筛选任务',
attention: '待关注',
@@ -355,6 +362,7 @@ export const workspace = {
},
browser: {
title: '实时浏览器',
unbound: '未绑定会话',
interact: '交互',
interacting: '交互中',
close: '关闭浏览器',
@@ -397,6 +405,8 @@ export const workspace = {
backBrowser: '返回上一页失败',
reloadBrowser: '刷新网页失败',
stopLoadingBrowser: '停止加载失败',
closeBrowser: '关闭此浏览器 Tab 失败',
browserControlUnavailable: '浏览器 Tab 控制不可用',
interactBrowser: '打开浏览器交互窗口失败',
stopBrowser: '关闭浏览器失败'
}
+2 -3
View File
@@ -17,7 +17,7 @@ function harnessSettings(
}
describe('DeepSeek Harness runtime selection', () => {
it('uses the configured Chat Completions profile', () => {
it('references the Runtime configuration without copying its profile', () => {
const selection = getRuntimeSelectionForProvider(
'deepseek-harness',
harnessSettings({
@@ -27,8 +27,7 @@ describe('DeepSeek Harness runtime selection', () => {
)
expect(selection).toEqual({
provider: 'deepseek-harness',
profileId: harnessProfileId
provider: 'deepseek-harness'
} satisfies Record<string, string>)
})
+52
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
agentRequestSchema,
browserCloseTabRequestSchema,
browserCreateTabRequestSchema,
browserLiveStateSchema,
browserNavigateRequestSchema,
browserSetViewportRequestSchema,
browserStopLoadingRequestSchema,
builtinEmbeddingConnectionId,
clipboardTextSchema,
@@ -23,9 +26,14 @@ describe('clipboard text contract', () => {
})
describe('browser control contracts', () => {
const tabId = '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
const leaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => {
const base = {
conversationId: 'conversation',
tabId,
status: 'ready' as const,
sessionActive: true,
isLoading: false,
@@ -51,10 +59,12 @@ describe('browser control contracts', () => {
expect(
browserNavigateRequestSchema.parse({
conversationId: 'conversation',
tabId,
url: 'https://example.com/'
})
).toEqual({
conversationId: 'conversation',
tabId,
url: 'https://example.com/'
})
expect(
@@ -70,6 +80,48 @@ describe('browser control contracts', () => {
}).success
).toBe(true)
})
it('uses opaque tab IDs and validates complete viewport ownership leases', () => {
expect(
browserCreateTabRequestSchema.parse({
conversationId: 'conversation',
workbarInstanceId
})
).toEqual({ conversationId: 'conversation', workbarInstanceId })
expect(
browserCloseTabRequestSchema.safeParse({
conversationId: 'conversation',
tabId
}).success
).toBe(true)
expect(
browserCloseTabRequestSchema.safeParse({
conversationId: 'conversation',
tabId: 'predictable-tab-name'
}).success
).toBe(false)
expect(
browserSetViewportRequestSchema.safeParse({
conversationId: 'conversation',
tabId,
leaseToken,
bounds: { x: 1, y: 2, width: 300, height: 400 }
}).success
).toBe(true)
expect(
browserSetViewportRequestSchema.safeParse({ tabId, leaseToken }).success
).toBe(false)
expect(
browserLiveStateSchema.safeParse({
conversationId: 'conversation',
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
updatedAt: 1
}).success
).toBe(false)
})
})
const baseInput = {
+185 -37
View File
@@ -30,8 +30,6 @@ import {
actorConversationSubagentActivitySchema,
legacyConversationSubagentActivitySchema,
legacyWorkModeSchema,
maximumConversationHistoryCharacters,
maximumConversationHistoryMessages,
type AssistantProject,
type AssistantArtifact,
type AssistantMemory,
@@ -346,11 +344,9 @@ export const agentRequestSchema = z
contextIds: z.array(z.string().uuid()).max(8).optional(),
history: z
.array(conversationHistoryMessageSchema)
.max(maximumConversationHistoryMessages)
.optional(),
historyMessageIds: z
.array(z.string().uuid())
.max(maximumConversationHistoryMessages)
.optional(),
currentUserMessageId: z.string().uuid().optional(),
currentAssistantMessageId: z.string().uuid().optional(),
@@ -359,18 +355,6 @@ export const agentRequestSchema = z
})
.strict()
.superRefine((request, context) => {
const historyLength =
request.history?.reduce(
(total, message) => total + message.content.length,
0
) ?? 0
if (historyLength > maximumConversationHistoryCharacters) {
context.addIssue({
code: 'custom',
path: ['history'],
message: `会话历史总长度不能超过 ${maximumConversationHistoryCharacters.toLocaleString()} 个字符`
})
}
if (
request.historyMessageIds &&
request.historyMessageIds.length !==
@@ -683,6 +667,7 @@ const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [
])
export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('default') }).strict(),
z.object({ kind: z.literal('platform') }).strict(),
z
.object({
@@ -1373,18 +1358,28 @@ export type AppInfo = {
shortcutStatus?: GlobalShortcutRegistrationStatus
}
export const browserTabIdSchema = z.string().uuid().brand<'BrowserTabId'>()
export type BrowserTabId = z.infer<typeof browserTabIdSchema>
export const browserWorkbarInstanceIdSchema = z.string().uuid()
export const browserViewportLeaseTokenSchema = z.string().uuid()
const browserStatusSchema = z.enum([
'creating',
'loading',
'ready',
'acting',
'interactive',
'failed',
'stopped'
])
export const browserLiveStateSchema = z
.object({
conversationId: conversationIdSchema,
status: z.enum([
'creating',
'loading',
'ready',
'acting',
'interactive',
'failed',
'stopped'
]),
tabId: browserTabIdSchema,
ownerWindowId: z.number().int().nonnegative().optional(),
status: browserStatusSchema,
sessionActive: z.boolean(),
isLoading: z.boolean(),
canGoBack: z.boolean(),
@@ -1404,19 +1399,73 @@ export const browserLiveStateSchema = z
export type BrowserLiveState = z.infer<typeof browserLiveStateSchema>
export const browserTabSummarySchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema,
primary: z.boolean(),
status: browserStatusSchema.exclude(['stopped']),
isLoading: z.boolean(),
canGoBack: z.boolean(),
url: z.string().max(8_192).optional(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative()
})
.strict()
export type BrowserTabSummary = z.infer<typeof browserTabSummarySchema>
export const browserCreateTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
workbarInstanceId: browserWorkbarInstanceIdSchema
})
.strict()
export type BrowserCreateTabRequest = z.infer<typeof browserCreateTabRequestSchema>
export const browserListTabsRequestSchema = z
.object({ conversationId: conversationIdSchema })
.strict()
export type BrowserListTabsRequest = z.infer<typeof browserListTabsRequestSchema>
export const browserTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema
})
.strict()
export type BrowserTabRequest = z.infer<typeof browserTabRequestSchema>
export const browserCloseTabRequestSchema = browserTabRequestSchema
export type BrowserCloseTabRequest = BrowserTabRequest
export const browserStopRequestSchema = z
.object({
conversationId: conversationIdSchema
})
.strict()
export const browserBackRequestSchema = browserStopRequestSchema
export const browserReloadRequestSchema = browserStopRequestSchema
export const browserStopLoadingRequestSchema = browserStopRequestSchema
const browserCompatibleTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema.optional()
})
.strict()
export const browserBackTabRequestSchema = browserCompatibleTabRequestSchema
export const browserReloadTabRequestSchema = browserCompatibleTabRequestSchema
export const browserStopLoadingTabRequestSchema = browserCompatibleTabRequestSchema
export const browserBackRequestSchema = browserBackTabRequestSchema
export const browserReloadRequestSchema = browserReloadTabRequestSchema
export const browserStopLoadingRequestSchema = browserStopLoadingTabRequestSchema
export const browserSnapshotRequestSchema = browserCompatibleTabRequestSchema
export const browserScreenshotRequestSchema = browserCompatibleTabRequestSchema
export const browserSetViewportRequestSchema = z
.object({
conversationId: conversationIdSchema.optional(),
tabId: browserTabIdSchema.optional(),
leaseToken: browserViewportLeaseTokenSchema,
bounds: z
.object({
x: z.number().int().nonnegative(),
@@ -1429,7 +1478,9 @@ export const browserSetViewportRequestSchema = z
})
.strict()
.refine(
(value) => Boolean(value.conversationId) === Boolean(value.bounds),
(value) =>
(!value.conversationId && !value.tabId && !value.bounds) ||
Boolean(value.conversationId && value.bounds),
'浏览器视口参数不完整'
)
@@ -1440,10 +1491,90 @@ export type BrowserViewportBounds = NonNullable<
export const browserNavigateRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema.optional(),
url: z.string().min(1).max(8_192)
})
.strict()
export type BrowserNavigateRequest = Omit<
z.infer<typeof browserNavigateRequestSchema>,
'tabId'
> & { tabId: BrowserTabId }
export const browserClickRequestSchema = browserCompatibleTabRequestSchema
.extend({ ref: z.string().min(1).max(256) })
.strict()
export const browserTypeRequestSchema = browserCompatibleTabRequestSchema
.extend({
ref: z.string().min(1).max(256),
text: z.string().max(16_384)
})
.strict()
export const browserSelectRequestSchema = browserCompatibleTabRequestSchema
.extend({
ref: z.string().min(1).max(256),
value: z.string().max(1_024)
})
.strict()
type BrowserOwnedRequest<T extends { tabId?: BrowserTabId }> = Omit<T, 'tabId'> & {
tabId: BrowserTabId
}
export type BrowserBackRequest = BrowserOwnedRequest<
z.infer<typeof browserBackTabRequestSchema>
>
export type BrowserReloadRequest = BrowserOwnedRequest<
z.infer<typeof browserReloadTabRequestSchema>
>
export type BrowserStopLoadingRequest = BrowserOwnedRequest<
z.infer<typeof browserStopLoadingTabRequestSchema>
>
export type BrowserSnapshotRequest = BrowserOwnedRequest<
z.infer<typeof browserSnapshotRequestSchema>
>
export type BrowserScreenshotRequest = BrowserOwnedRequest<
z.infer<typeof browserScreenshotRequestSchema>
>
export type BrowserClickRequest = BrowserOwnedRequest<
z.infer<typeof browserClickRequestSchema>
>
export type BrowserTypeRequest = BrowserOwnedRequest<
z.infer<typeof browserTypeRequestSchema>
>
export type BrowserSelectRequest = BrowserOwnedRequest<
z.infer<typeof browserSelectRequestSchema>
>
export type BrowserSetViewportRequest =
| {
conversationId: string
tabId: BrowserTabId
bounds: BrowserViewportBounds
leaseToken: string
}
| { leaseToken: string }
export type BrowserSnapshotResult = {
url: string
title: string
nodes: Array<{
ref: string
role: string
name: string
value?: string
disabled?: boolean
focused?: boolean
editable?: boolean
}>
truncated: boolean
}
export type BrowserScreenshotResult = {
type: 'image'
mimeType: 'image/jpeg'
data: string
}
export const knowledgeIdSchema = z.string().uuid()
export const knowledgeCreateSchema = z
.object({
@@ -1637,14 +1768,31 @@ export type DesktopApi = {
onEvent: (listener: (event: AgentEvent) => void) => () => void
}
browser: {
navigate: (conversationId: string, url: string) => Promise<void>
back: (conversationId: string) => Promise<void>
reload: (conversationId: string) => Promise<void>
stopLoading: (conversationId: string) => Promise<void>
setViewport: (
conversationId?: string,
bounds?: BrowserViewportBounds
) => Promise<void>
createTab?: (request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>
listTabs?: (request: BrowserListTabsRequest) => Promise<BrowserTabSummary[]>
closeTab?: (request: BrowserCloseTabRequest) => Promise<void>
navigate: {
(request: BrowserNavigateRequest): Promise<void>
(conversationId: string, url: string): Promise<void>
}
back: {
(request: BrowserBackRequest): Promise<void>
(conversationId: string): Promise<void>
}
reload: {
(request: BrowserReloadRequest): Promise<void>
(conversationId: string): Promise<void>
}
stopLoading: {
(request: BrowserStopLoadingRequest): Promise<void>
(conversationId: string): Promise<void>
}
snapshot?: (request: BrowserSnapshotRequest) => Promise<BrowserSnapshotResult>
click?: (request: BrowserClickRequest) => Promise<void>
type?: (request: BrowserTypeRequest) => Promise<void>
select?: (request: BrowserSelectRequest) => Promise<void>
screenshot?: (request: BrowserScreenshotRequest) => Promise<BrowserScreenshotResult>
setViewport: (request: BrowserSetViewportRequest) => Promise<void>
stop: (conversationId: string) => Promise<void>
onState: (listener: (state: BrowserLiveState) => void) => () => void
}
+8
View File
@@ -22,10 +22,18 @@ export const ipcChannels = {
agentQuestionRespond: 'agent:question:respond',
agentCompactConversation: 'agent:context:compact',
agentEvent: 'agent:event',
browserCreateTab: 'browser:tab:create',
browserListTabs: 'browser:tab:list',
browserCloseTab: 'browser:tab:close',
browserNavigate: 'browser:navigate',
browserBack: 'browser:back',
browserReload: 'browser:reload',
browserStopLoading: 'browser:stop-loading',
browserSnapshot: 'browser:snapshot',
browserClick: 'browser:click',
browserType: 'browser:type',
browserSelect: 'browser:select',
browserScreenshot: 'browser:screenshot',
browserSetViewport: 'browser:set-viewport',
browserStop: 'browser:stop',
browserState: 'browser:state',
+26 -51
View File
@@ -10,7 +10,7 @@ export const agentRuntimeSelectionSchema = z.discriminatedUnion(
z
.object({
provider: z.literal('model'),
profileId: runtimeSelectionProfileIdSchema
profileId: runtimeSelectionProfileIdSchema.optional()
})
.strict(),
z
@@ -48,17 +48,21 @@ export type RuntimeSelectionRepairSettings = {
}>
defaultModelProfileId: string
opencodeModelSource:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
continueModelSource:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
deepseekHarnessModelSource?:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
}
type RuntimeModelSource =
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
@@ -79,34 +83,9 @@ type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][numbe
export function getRuntimeSelectionForProvider(
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
settings: RuntimeSelectionDefaultSettings
_settings: RuntimeSelectionDefaultSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource
const resolvedProfile =
provider === 'opencode'
? settings.opencodeModelProfile
: provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
return {
provider,
...(source?.kind === 'profile'
? { profileId: source.profileId }
: !source && resolvedProfile
? { profileId: resolvedProfile.id }
: {})
}
return { provider }
}
export function getDefaultRuntimeSelection(
@@ -121,6 +100,20 @@ export function getDefaultRuntimeSelection(
: getRuntimeSelectionForProvider('model', settings)
}
// Resolve only for presentation or request diagnostics, never for persisted defaults.
export function getRuntimeSelectionProfileId(
selection: AgentRuntimeSelection,
settings: RuntimeSelectionDefaultSettings
): string | undefined {
if (selection.provider === 'auto') return undefined
if (selection.profileId) return selection.profileId
if (selection.provider === 'model') return settings.defaultModelProfileId
const source = selection.provider === 'opencode' ? settings.opencodeModelSource
: selection.provider === 'continue' ? settings.continueModelSource : settings.deepseekHarnessModelSource
return source?.kind === 'default' ? settings.defaultModelProfileId
: source?.kind === 'profile' ? source.profileId : undefined
}
export function isChannelModelProfileUsable(
profile: ChannelModelProfile
): boolean {
@@ -162,11 +155,7 @@ export function repairChannelRuntimeSelection(
isChannelModelProfileUsable(profile)
) ??
settings.modelProfiles.find(isChannelModelProfileUsable)
const defaultDirectSelection: AgentRuntimeSelection = {
provider: 'model',
profileId:
defaultDirectProfile?.id ?? settings.defaultModelProfileId
}
const defaultDirectSelection: AgentRuntimeSelection = { provider: 'model' }
if (selection.provider === 'auto') {
return defaultDirectSelection
}
@@ -196,7 +185,7 @@ export function repairChannelRuntimeSelection(
return repaired
}
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === repaired.profileId
(candidate) => candidate.id === (repaired.profileId ?? defaultDirectProfile?.id)
)
return profile && isChannelModelProfileUsable(profile)
? repaired
@@ -217,29 +206,15 @@ export function repairAgentRuntimeSelection(
return selection
}
if (selection.provider === 'model') {
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
const source =
selection.provider === 'opencode'
? settings.opencodeModelSource
: selection.provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
return {
provider: selection.provider,
...(source.kind === 'profile'
? { profileId: source.profileId }
: {})
return { provider: 'model' }
}
return { provider: selection.provider }
}
export function agentRuntimeSelectionKey(
selection: AgentRuntimeSelection
): string {
return `${selection.provider}:${'profileId' in selection
? selection.profileId ?? 'platform'
? selection.profileId ?? 'default'
: 'default'}`
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { workbarTargetRefSchema } from './workbar-contracts'
import { workbarExecutionTargetRefSchema } from './workbar-contracts'
export const TERMINAL_LIMITS = {
maximumSessionsPerWindow: 12,
@@ -35,7 +35,7 @@ export type TerminalSessionId = z.infer<
typeof terminalSessionIdSchema
>
export const terminalTargetSchema = workbarTargetRefSchema
export const terminalTargetSchema = workbarExecutionTargetRefSchema
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
export const terminalSessionStateSchema = z.enum([
+116 -9
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
WORKBAR_APP_DEFINITIONS,
WORKBAR_LIMITS,
normalizeWorkbarLayoutPreferences,
workbarAppDefinitionSchema,
workbarLayoutPreferencesSchema,
workbarTabInstanceSchema
@@ -14,20 +15,28 @@ const projectId = '00000000-0000-4000-8000-000000000201'
describe('workbar contracts', () => {
it('freezes the first application catalog and instance policies', () => {
expect(
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultContext, defaultOpen, required, closable, reorderable }) => ({
id,
instancePolicy,
defaultOpen
defaultContext,
defaultOpen,
required,
closable,
reorderable
}))
).toEqual([
{ id: 'tasks', instancePolicy: 'single', defaultOpen: true },
{ id: 'workspace', instancePolicy: 'single', defaultOpen: true },
{ id: 'browser', instancePolicy: 'single', defaultOpen: true },
{ id: 'results', instancePolicy: 'single', defaultOpen: true },
{ id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
{ id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
{ id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true },
{ id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true },
{
id: 'terminal',
instancePolicy: 'multiple',
defaultOpen: false
defaultContext: 'current-project',
defaultOpen: false,
required: false,
closable: true,
reorderable: true
}
])
for (const definition of WORKBAR_APP_DEFINITIONS) {
@@ -51,12 +60,52 @@ describe('workbar contracts', () => {
activeInstanceId: terminalId,
expanded: true,
dock: 'right',
widthRatio: 0.35
widthRatio: 0.35,
taskScope: 'current-project'
} as const
expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout)
})
it('persists a browser conversation binding without a runtime tab identifier', () => {
expect(
workbarTabInstanceSchema.parse({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器 · 会话 A',
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
})
).toEqual({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器 · 会话 A',
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
})
expect(
workbarTabInstanceSchema.safeParse({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器',
targetRef: { type: 'conversation', conversationId: 'conversation-a' },
tabId: '00000000-0000-4000-8000-000000000999'
}).success
).toBe(false)
})
it('explicitly keeps every existing app visible across context switches', () => {
for (const definition of WORKBAR_APP_DEFINITIONS) {
expect(definition.visibleAcrossContextSwitches).toBe(true)
const { visibleAcrossContextSwitches, ...withoutVisibility } = definition
expect(workbarAppDefinitionSchema.safeParse(withoutVisibility).success).toBe(false)
expect(
workbarAppDefinitionSchema.parse({
...withoutVisibility,
visibleAcrossContextSwitches
}).visibleAcrossContextSwitches
).toBe(true)
}
})
it('rejects unknown and sensitive target parameters', () => {
for (const targetRef of [
{ type: 'local', cwd: 'C:\\secret' },
@@ -102,7 +151,8 @@ describe('workbar contracts', () => {
activeInstanceId: taskId,
expanded: true,
dock: 'right',
widthRatio: 0.4
widthRatio: 0.4,
taskScope: 'current-project'
}
expect(
workbarLayoutPreferencesSchema.safeParse({
@@ -131,4 +181,61 @@ describe('workbar contracts', () => {
}).success
).toBe(false)
})
it('normalizes duplicate policies, fixed defaults, and stale active state', () => {
const browserId = '00000000-0000-4000-8000-000000000103'
const secondBrowserId = '00000000-0000-4000-8000-000000000104'
const workspaceId = '00000000-0000-4000-8000-000000000105'
const normalized = normalizeWorkbarLayoutPreferences(
{
instances: [
{ id: browserId, appId: 'browser', title: '浏览器' },
{ id: secondBrowserId, appId: 'browser', title: '浏览器 2' },
{ id: workspaceId, appId: 'workspace', title: '工作区' },
{ id: terminalId, appId: 'workspace', title: '重复工作区' }
],
activeInstanceId: terminalId,
expanded: true,
dock: 'right',
widthRatio: 0.4,
taskScope: 'all-projects'
},
[
{ id: taskId, appId: 'tasks', title: '任务中心' },
{ id: workspaceId, appId: 'workspace', title: '工作区' }
]
)
expect(normalized?.instances.map(({ id, appId }) => ({ id, appId })))
.toEqual([
{ id: taskId, appId: 'tasks' },
{ id: browserId, appId: 'browser' },
{ id: secondBrowserId, appId: 'browser' },
{ id: workspaceId, appId: 'workspace' }
])
expect(normalized?.activeInstanceId).toBe(taskId)
expect(normalized?.taskScope).toBe('all-projects')
})
it('preserves a valid empty layout with a null active instance', () => {
expect(
normalizeWorkbarLayoutPreferences(
{
instances: [],
activeInstanceId: null,
expanded: false,
dock: 'right',
widthRatio: 0.4
},
[]
)
).toEqual({
instances: [],
activeInstanceId: null,
expanded: false,
dock: 'right',
widthRatio: 0.4,
taskScope: 'current-project'
})
})
})
+153 -7
View File
@@ -38,7 +38,8 @@ export type WorkbarInstancePolicy = z.infer<
export const workbarDefaultContextSchema = z.enum([
'application',
'current-project'
'current-project',
'current-conversation'
])
export type WorkbarDefaultContext = z.infer<
typeof workbarDefaultContextSchema
@@ -78,7 +79,11 @@ export const workbarAppDefinitionSchema = z
),
instancePolicy: workbarInstancePolicySchema,
defaultContext: workbarDefaultContextSchema,
visibleAcrossContextSwitches: z.boolean(),
defaultOpen: z.boolean(),
required: z.boolean(),
closable: z.boolean(),
reorderable: z.boolean(),
availability: workbarAvailabilitySchema
})
.strict()
@@ -93,57 +98,77 @@ export type WorkbarAppDefinition = z.infer<
export const WORKBAR_APP_DEFINITIONS = [
{
id: 'tasks',
visibleAcrossContextSwitches: true,
label: '任务中心',
icon: 'tasks',
description: '查看和管理当前任务。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: true,
closable: false,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'workspace',
visibleAcrossContextSwitches: true,
label: '工作区',
icon: 'workspace',
description: '查看当前项目的工作区。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: true,
closable: false,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'browser',
visibleAcrossContextSwitches: true,
label: '浏览器',
icon: 'browser',
description: '浏览任务相关内容。',
instancePolicy: 'single',
defaultContext: 'application',
instancePolicy: 'multiple',
defaultContext: 'current-conversation',
defaultOpen: true,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'results',
visibleAcrossContextSwitches: true,
label: '成果',
icon: 'results',
description: '查看任务生成的成果。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'terminal',
visibleAcrossContextSwitches: true,
label: '终端',
icon: 'terminal',
description: '打开当前项目的用户终端。',
instancePolicy: 'multiple',
defaultContext: 'current-project',
defaultOpen: false,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
}
] as const satisfies readonly WorkbarAppDefinition[]
export const workbarTargetRefSchema = z.discriminatedUnion('type', [
export const workbarExecutionTargetRefSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('local') }).strict(),
z
.object({
@@ -152,6 +177,19 @@ export const workbarTargetRefSchema = z.discriminatedUnion('type', [
})
.strict()
])
export type WorkbarExecutionTargetRef = z.infer<
typeof workbarExecutionTargetRefSchema
>
export const workbarTargetRefSchema = z.union([
workbarExecutionTargetRefSchema,
z
.object({
type: z.literal('conversation'),
conversationId: z.string().min(1).max(128)
})
.strict()
])
export type WorkbarTargetRef = z.infer<
typeof workbarTargetRefSchema
>
@@ -165,13 +203,37 @@ export const workbarTabInstanceSchema = z
})
.strict()
.superRefine((instance, context) => {
if (instance.appId === 'terminal' && !instance.targetRef) {
if (
instance.appId === 'terminal' &&
(!instance.targetRef || instance.targetRef.type === 'conversation')
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Terminal instances require a public target reference'
})
}
if (
instance.appId === 'browser' &&
instance.targetRef &&
instance.targetRef.type !== 'conversation'
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Browser instances only accept conversation bindings'
})
}
if (
instance.appId !== 'browser' &&
instance.targetRef?.type === 'conversation'
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Conversation bindings are reserved for browser instances'
})
}
})
export type WorkbarTabInstance = z.infer<
typeof workbarTabInstanceSchema
@@ -183,7 +245,14 @@ const singleInstanceAppIds = new Set<WorkbarAppId>(
).map((definition) => definition.id)
)
export const workbarLayoutPreferencesSchema = z
export const workbarTaskScopeSchema = z.enum([
'current-project',
'global',
'all-projects'
])
export type WorkbarTaskScope = z.infer<typeof workbarTaskScopeSchema>
const workbarLayoutShapeSchema = z
.object({
instances: z
.array(workbarTabInstanceSchema)
@@ -191,9 +260,12 @@ export const workbarLayoutPreferencesSchema = z
activeInstanceId: z.string().uuid().nullable(),
expanded: z.boolean(),
dock: z.literal('right'),
widthRatio: z.number().finite().gt(0).lt(1)
widthRatio: z.number().finite().gt(0).lt(1),
taskScope: workbarTaskScopeSchema.default('current-project')
})
.strict()
export const workbarLayoutPreferencesSchema = workbarLayoutShapeSchema
.superRefine((layout, context) => {
const instanceIds = new Set<string>()
const seenSingleApps = new Set<WorkbarAppId>()
@@ -244,3 +316,77 @@ export const workbarLayoutPreferencesSchema = z
export type WorkbarLayoutPreferences = z.infer<
typeof workbarLayoutPreferencesSchema
>
export function normalizeWorkbarLayoutPreferences(
value: unknown,
defaultInstances: readonly WorkbarTabInstance[]
): WorkbarLayoutPreferences | undefined {
const parsed = workbarLayoutShapeSchema.safeParse(value)
if (!parsed.success) {
return undefined
}
const instanceIds = new Set<string>()
const seenSingleApps = new Set<WorkbarAppId>()
const instances: WorkbarTabInstance[] = []
for (const instance of parsed.data.instances) {
if (
instanceIds.has(instance.id) ||
(singleInstanceAppIds.has(instance.appId) &&
seenSingleApps.has(instance.appId))
) {
continue
}
instanceIds.add(instance.id)
if (singleInstanceAppIds.has(instance.appId)) {
seenSingleApps.add(instance.appId)
}
instances.push(instance)
}
for (const defaultInstance of defaultInstances) {
const definition = WORKBAR_APP_DEFINITIONS.find(
(candidate) => candidate.id === defaultInstance.appId
)
if (
definition?.required !== true ||
instances.some(
(instance) => instance.appId === defaultInstance.appId
)
) {
continue
}
const collidingIdIndex = instances.findIndex(
(instance) => instance.id === defaultInstance.id
)
if (collidingIdIndex >= 0) {
const [removed] = instances.splice(collidingIdIndex, 1)
if (removed && singleInstanceAppIds.has(removed.appId)) {
seenSingleApps.delete(removed.appId)
}
}
instances.splice(
WORKBAR_APP_DEFINITIONS.findIndex(
(candidate) => candidate.id === defaultInstance.appId
),
0,
defaultInstance
)
}
const boundedInstances = instances.slice(
0,
WORKBAR_LIMITS.maximumOpenInstances
)
const activeInstanceId = boundedInstances.some(
(instance) => instance.id === parsed.data.activeInstanceId
)
? parsed.data.activeInstanceId
: boundedInstances[0]?.id ?? null
return workbarLayoutPreferencesSchema.parse({
...parsed.data,
instances: boundedInstances,
activeInstanceId
})
}
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { app, BrowserWindow } from 'electron'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { KnowledgeService } from '../src/main/knowledge/knowledge-service'
import { KnowledgeMcpGateway } from '../src/main/agent/knowledge-mcp-gateway'
import { BrowserService } from '../src/main/browser/browser-service'
const conversationId = 'browser-tabs-electron-e2e'
const otherConversationId = 'browser-tabs-electron-e2e-other'
const firstWorkbarId = '00000000-0000-4000-8000-000000000901'
const secondWorkbarId = '00000000-0000-4000-8000-000000000902'
function page(title: string, body = ''): string {
return `<!doctype html><html><body><h1>${title}</h1>${body}</body></html>`
}
async function main(): Promise<void> {
await app.whenReady()
const target = createServer((request, response) => {
response.setHeader('content-type', 'text/html; charset=utf-8')
if (request.url === '/set-cookie') {
response.setHeader('set-cookie', 'shared-browser-tab=visible; Path=/; SameSite=Lax')
response.end(page('Cookie set'))
return
}
if (request.url === '/cookie') {
response.end(page('Cookie page', '<p id="cookie"></p><script>document.querySelector("#cookie").textContent = document.cookie</script>'))
return
}
response.end(page(request.url === '/mcp' ? 'MCP target' : request.url === '/second' ? 'Second tab' : 'First tab'))
})
await new Promise<void>((resolve, reject) => {
target.once('error', reject)
target.listen(0, '127.0.0.1', resolve)
})
const address = target.address()
assert(address && typeof address !== 'string')
const origin = `http://127.0.0.1:${address.port}`
const window = new BrowserWindow({ show: false, width: 900, height: 700 })
const browser = new BrowserService({ parentWindow: window })
const gateway = new KnowledgeMcpGateway(
{
database: { listKnowledgeBases: () => [] }
} as unknown as KnowledgeService,
{ browserService: browser }
)
const controller = new AbortController()
let client: Client | undefined
let token: string | undefined
try {
const first = await browser.createTab(
conversationId,
window.webContents.id,
controller.signal,
firstWorkbarId
)
const second = await browser.createTab(
conversationId,
window.webContents.id,
controller.signal,
secondWorkbarId
)
assert.notEqual(first.tabId, second.tabId)
await browser.navigate(conversationId, `${origin}/first`, controller.signal, first.tabId, window.webContents.id)
await browser.navigate(conversationId, `${origin}/second`, controller.signal, second.tabId, window.webContents.id)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id)), /First tab/u)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /Second tab/u)
await browser.navigate(conversationId, `${origin}/set-cookie`, controller.signal, first.tabId, window.webContents.id)
await browser.navigate(conversationId, `${origin}/cookie`, controller.signal, second.tabId, window.webContents.id)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /shared-browser-tab=visible/u)
await gateway.start()
const usage = browser.acquireTabUsage(conversationId, first.tabId, 'electron-e2e', window.webContents.id)
token = gateway.grant('electron-e2e', [], controller.signal, 'none', undefined, conversationId, first.tabId, usage)
assert(token)
client = new Client({ name: 'browser-tabs-electron-e2e', version: '1.0.0' })
await client.connect(
new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
requestInit: { headers: { Authorization: `Bearer ${token}` } }
})
)
const other = await browser.createTab(otherConversationId, window.webContents.id, controller.signal)
assert.notEqual(other.tabId, first.tabId)
await browser.navigate(otherConversationId, `${origin}/second`, controller.signal, other.tabId, window.webContents.id)
assert.match(JSON.stringify(await browser.snapshot(otherConversationId, controller.signal, other.tabId, window.webContents.id)), /Second tab/u)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id)), /Cookie set/u)
await assert.rejects(
browser.navigate(otherConversationId, `${origin}/mcp`, controller.signal, first.tabId, window.webContents.id),
/不属于当前对话/u
)
await assert.rejects(
browser.navigate(conversationId, `${origin}/mcp`, controller.signal, other.tabId, window.webContents.id),
/不属于当前对话/u
)
const bounds = { x: 0, y: 0, width: 800, height: 600 }
assert.equal(browser.setViewport(conversationId, bounds, first.tabId, 'first-viewport', window.webContents.id), true)
assert.equal(browser.getVisibleTabId(conversationId, window.webContents.id), first.tabId)
assert.equal(browser.setViewport(otherConversationId, bounds, other.tabId, 'other-viewport', window.webContents.id), true)
assert.equal(browser.getVisibleTabId(otherConversationId, window.webContents.id), other.tabId)
assert.equal(browser.getVisibleTabId(conversationId, window.webContents.id), undefined)
await client.callTool({ name: 'browser_navigate', arguments: { url: `${origin}/mcp` } })
const mcpSnapshot = await client.callTool({ name: 'browser_snapshot', arguments: {} })
assert.match(JSON.stringify(mcpSnapshot), /MCP target/u)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id)), /MCP target/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)
assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /shared-browser-tab=visible/u)
await assert.rejects(
browser.closeTab(conversationId, first.tabId, window.webContents.id),
/正在被.*请求使用/u
)
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), 1)
await browser.closeTab(conversationId, second.tabId, window.webContents.id)
assert.equal(browser.getTabCount(conversationId), 0)
console.log('Real Electron browser-tab and MCP E2E passed')
} finally {
if (token) gateway.revoke(token)
await client?.close().catch(() => undefined)
controller.abort()
await gateway.dispose().catch(() => undefined)
await browser.dispose().catch(() => undefined)
if (!window.isDestroyed()) window.destroy()
await new Promise<void>((resolve) => target.close(() => resolve()))
app.quit()
}
}
void main().catch((error: unknown) => {
console.error(error)
app.exit(1)
})