feat: add persistent workbar browser tabs and scoped MCP routing
This commit is contained in:
+17
-12
@@ -321,23 +321,27 @@
|
|||||||
动态 Tab、“+”和终端细节见
|
动态 Tab、“+”和终端细节见
|
||||||
[助手工作栏多终端页签 PRD](./docs/features/assistant-workbar/terminal-tabs-prd.md)。
|
[助手工作栏多终端页签 PRD](./docs/features/assistant-workbar/terminal-tabs-prd.md)。
|
||||||
|
|
||||||
- 升级后默认打开任务中心、工作区、浏览器和成果四个单实例应用,不根据当前页面或 Runtime
|
- 升级后默认打开任务中心、工作区、一个浏览器实例和成果。任务中心与工作区是不可关闭的
|
||||||
能力自动增删稳定应用目录入口。
|
单实例基础应用;浏览器是可关闭的多实例应用;成果保持可关闭的单实例应用。不可关闭不
|
||||||
|
等于锁定顺序。
|
||||||
- 资源监控作为本机单实例应用保留在能力目录,但不加入升级后的默认打开实例。面板只在可见
|
- 资源监控作为本机单实例应用保留在能力目录,但不加入升级后的默认打开实例。面板只在可见
|
||||||
活动时采样,首期不持久化历史或提供后台负载告警。
|
活动时采样,首期不持久化历史或提供后台负载告警。
|
||||||
- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。
|
- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。
|
||||||
“+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。
|
“+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。
|
||||||
- 应用注册表声明单实例或多实例。在工作栏“+”目录中,单实例重复选择时聚焦已有 Tab;终端
|
- 应用注册表分别声明单实例或多实例、默认打开、必须存在、可关闭和排序策略。在工作栏“+”目录中,
|
||||||
从首期开始支持多实例,每次选择创建独立 Tab 和 Shell Session。主侧栏系统工具快捷入口
|
单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。主侧栏系统工具快捷
|
||||||
可以聚焦当前执行空间中最近使用的终端,额外终端仍从“+”目录创建。
|
入口按当前上下文聚焦最近使用的匹配实例,额外实例仍从“+”目录创建。
|
||||||
- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task
|
- Task Center 是默认跟随当前项目的单实例索引,不使用多实例模式。用户可在同一 Tab 内切换
|
||||||
只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,
|
“当前项目”“全局任务”和“全部项目”;范围变化不得创建第二个 Task Center。每个 Task 只
|
||||||
也不得把 Job/Run 提升为可导航 UI 对象。
|
关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也
|
||||||
|
不得把 Job/Run 提升为可导航 UI 对象。
|
||||||
- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。
|
- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。
|
||||||
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
|
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
|
||||||
- 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。
|
- 用户可以主动排序栏目,并可关闭可关闭实例或恢复默认布局;任务中心和工作区不能隐藏或
|
||||||
|
关闭。应用不能用用户偏好机制实现自动能力裁剪。
|
||||||
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
||||||
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。
|
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染
|
||||||
|
关闭按钮,也不响应关闭快捷命令。
|
||||||
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
|
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
|
||||||
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
|
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
|
||||||
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
|
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
|
||||||
@@ -354,8 +358,9 @@
|
|||||||
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
|
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
|
||||||
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
|
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
|
||||||
尚未提交的文字改变 Agent 看到的页面。
|
尚未提交的文字改变 Agent 看到的页面。
|
||||||
- 浏览器工具栏操作的是当前 Conversation 中 Agent 使用的同一隔离浏览器会话,用户导航会
|
- 浏览器工具栏操作的是该实例绑定的 Conversation 和 Browser Tab。同一 Conversation 的多个
|
||||||
有意改变 Agent 下一步看到的页面。创建中、Agent 操作中或已进入完整交互窗口时禁用其余
|
Tab 共享隔离登录上下文,但页面、导航、操作队列和元素引用相互独立。模型请求开始时冻结
|
||||||
|
工具目标,用户切换工作栏 Tab 不改变进行中的 Agent 操作。创建中、Agent 操作中或已进入完整交互窗口时禁用其余
|
||||||
工具栏控件;仅当前导航仍在加载时保留“停止加载”,且该操作只中止页面加载,不关闭浏览器、
|
工具栏控件;仅当前导航仍在加载时保留“停止加载”,且该操作只中止页面加载,不关闭浏览器、
|
||||||
不释放 Runtime 上下文。没有会话的活动 Conversation 可以直接在地址栏输入 URL 并“前往”
|
不释放 Runtime 上下文。没有会话的活动 Conversation 可以直接在地址栏输入 URL 并“前往”
|
||||||
创建会话,但必须复用 Agent 浏览器相同的 URL 策略与能力路径。
|
创建会话,但必须复用 Agent 浏览器相同的 URL 策略与能力路径。
|
||||||
|
|||||||
@@ -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 |
|
| 终端 | 激活当前执行空间最近的 `starting` 或 `running` 实例 | 创建一个绑定当前执行空间的实例 | 没有项目时使用本机 Home |
|
||||||
| 浏览器 | 激活单实例并保留其目标状态 | 创建跟随当前会话的 `browser` 单实例 | 打开面板并提示选择或创建对话 |
|
| 浏览器 | 激活绑定当前 Conversation 的最近使用实例 | 创建绑定当前 Conversation 的新实例 | 激活最近使用实例;不存在时创建待绑定实例 |
|
||||||
| 资源监控 | 激活 `resources` 单实例 | 创建本机 `resources` 单实例 | 部分指标不可用时显示原因并保留其余指标 |
|
| 资源监控 | 激活 `resources` 单实例 | 创建本机 `resources` 单实例 | 部分指标不可用时显示原因并保留其余指标 |
|
||||||
|
|
||||||
文件和浏览器按以下优先级处理目标:已有有效固定目标、跟随模式的当前上下文、新实例的默认
|
文件保持单一实例原有绑定。浏览器快捷入口按当前 Conversation 匹配最近使用实例,而工作栏
|
||||||
上下文。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认工作区或当前会话。
|
“+”始终新建浏览器实例。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认
|
||||||
|
工作区或当前会话。
|
||||||
|
|
||||||
`closing`、`exited`、`interrupted` 和 `failed` 终端不属于可复用实例。当前执行空间只有这些
|
`closing`、`exited`、`interrupted` 和 `failed` 终端不属于可复用实例。当前执行空间只有这些
|
||||||
终端时,快捷按钮创建新实例并保留原 Tab 的历史状态。用户需要额外的并行终端时使用工作栏
|
终端时,快捷按钮创建新实例并保留原 Tab 的历史状态。用户需要额外的并行终端时使用工作栏
|
||||||
@@ -154,7 +155,7 @@ warming/loading/live/partial/error -> inactive
|
|||||||
|
|
||||||
## 7. 逻辑完整性评估
|
## 7. 逻辑完整性评估
|
||||||
|
|
||||||
首期规则已覆盖固定入口、应用显隐、保存失败、系统工具单实例与多实例差异、本机 Home、
|
首期规则已覆盖固定入口、应用显隐、保存失败、系统工具匹配与显式多实例差异、本机 Home、
|
||||||
托管 SSH、无活动会话、目录失效、资源部分可用、采样停止、权限边界、键盘和窄窗口。其他
|
托管 SSH、无活动会话、目录失效、资源部分可用、采样停止、权限边界、键盘和窄窗口。其他
|
||||||
工具面板内部的取消、超时、
|
工具面板内部的取消、超时、
|
||||||
连接恢复和固定目标失效继续由助手工作栏负责,本功能不复制这些状态机。
|
连接恢复和固定目标失效继续由助手工作栏负责,本功能不复制这些状态机。
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ Switch 更新成功后主导航立即生效;更新失败时恢复原状态并
|
|||||||
|
|
||||||
### FR-6 文件浏览器入口
|
### FR-6 文件浏览器入口
|
||||||
|
|
||||||
点击文件按钮时展开助手工作栏,并打开或聚焦单实例的“工作区”面板。已有实例保持其“跟随
|
点击文件按钮时展开助手工作栏,并聚焦默认存在且不可关闭的“工作区”单实例。该实例保持
|
||||||
当前上下文”或“固定到指定对象”状态;创建实例时默认跟随当前项目,没有项目时使用已配置
|
“跟随当前上下文”或“固定到指定对象”状态;迁移或布局修复发现实例缺失时补回,并默认跟随
|
||||||
的默认工作区。跟随上下文或新建实例所需的路径不可用时,面板显示修复入口;已有有效固定
|
当前项目。没有项目时使用已配置的默认工作区。跟随上下文所需的路径不可用时,面板显示修复
|
||||||
目标不受当前项目和默认工作区状态影响。
|
入口;已有有效固定目标不受当前项目和默认工作区状态影响。
|
||||||
|
|
||||||
### FR-7 终端入口
|
### FR-7 终端入口
|
||||||
|
|
||||||
@@ -99,9 +99,10 @@ Switch 更新成功后主导航立即生效;更新失败时恢复原状态并
|
|||||||
|
|
||||||
### FR-8 浏览器入口
|
### FR-8 浏览器入口
|
||||||
|
|
||||||
点击浏览器按钮时展开助手工作栏,并打开或聚焦单实例的受控浏览器面板。已有实例保持其目标
|
点击浏览器按钮时展开助手工作栏,并优先聚焦绑定当前 Conversation 的最近使用浏览器实例;
|
||||||
状态;新实例默认跟随当前会话。新建或跟随模式下没有活动会话时,面板显示需要选择或创建
|
没有匹配实例时创建一个。没有活动 Conversation 时聚焦最近使用的浏览器实例;完全不存在时
|
||||||
会话的空状态;已有有效固定目标继续显示。入口不打开系统默认浏览器,也不创建旁路 WebView。
|
创建待绑定空实例。该快捷入口不会因连续点击创建重复实例;用户通过工作栏“+”选择浏览器时
|
||||||
|
则始终创建新实例。入口不打开系统默认浏览器,也不创建旁路 WebView。
|
||||||
|
|
||||||
### FR-9 资源监控入口
|
### FR-9 资源监控入口
|
||||||
|
|
||||||
@@ -150,7 +151,8 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
|
|||||||
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
||||||
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
||||||
- 文件、终端、浏览器和资源监控均可从左下角打开正确的工作栏面板,且不产生第二套内容界面。
|
- 文件、终端、浏览器和资源监控均可从左下角打开正确的工作栏面板,且不产生第二套内容界面。
|
||||||
- 连续点击系统工具不会无意创建重复的文件或浏览器实例;终端遵守多实例规则。
|
- 连续点击系统工具不会无意创建重复的浏览器或终端实例;工作区始终复用不可关闭的固定实例。
|
||||||
|
用户通过工作栏“+”可以显式创建额外浏览器或终端实例。
|
||||||
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
|
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
|
||||||
最小化后停止请求新样本。
|
最小化后停止请求新样本。
|
||||||
- CPU、内存、磁盘 IO 或 GoodBuddy 指标不可用时,页面区分部分可用与整体失败。
|
- CPU、内存、磁盘 IO 或 GoodBuddy 指标不可用时,页面区分部分可用与整体失败。
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ type WorkspaceView =
|
|||||||
```
|
```
|
||||||
|
|
||||||
文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts`
|
文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts`
|
||||||
承载。工作区与浏览器是单实例应用,终端是多实例应用。代码中尚无资源指标采集和资源监控
|
承载。工作区是不可关闭的单实例应用,浏览器和终端是多实例应用。浏览器多实例的跨进程
|
||||||
|
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
|
||||||
面板。现有侧栏底部 `user-card` 整行打开设置,并显示“本地工作区”和平台架构。
|
面板。现有侧栏底部 `user-card` 整行打开设置,并显示“本地工作区”和平台架构。
|
||||||
|
|
||||||
本次改动增加入口编排、从主侧栏到工作栏的命令通道,以及本机资源监控的 Main/Preload/
|
本次改动增加入口编排、从主侧栏到工作栏的命令通道,以及本机资源监控的 Main/Preload/
|
||||||
@@ -78,23 +79,28 @@ type WorkbarOpenIntent = {
|
|||||||
requestId: number
|
requestId: number
|
||||||
appId: 'workspace' | 'terminal' | 'browser' | 'resources'
|
appId: 'workspace' | 'terminal' | 'browser' | 'resources'
|
||||||
reason: 'sidebar-system-tool'
|
reason: 'sidebar-system-tool'
|
||||||
|
context?: { conversationId?: string; projectId?: string }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`requestId` 使连续点击同一工具仍能被消费。不要仅设置 `assistantSidebarTab`,因为用户可能已
|
`requestId` 使连续点击同一工具仍能被消费。不要仅设置 `assistantSidebarTab`,因为用户可能已
|
||||||
关闭对应实例,且终端需要按执行空间寻找实例。
|
关闭对应实例,且终端需要按执行空间寻找实例。
|
||||||
|
|
||||||
建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。文件和浏览器
|
建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。控制器必须
|
||||||
已有实例时只激活实例,保留其“跟随当前上下文”或“固定到指定对象”状态:
|
接收上下文和调用原因,不能只按 `appId` 查找第一个实例:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type WorkbarController = {
|
type WorkbarController = {
|
||||||
openOrActivate(appId: WorkbarAppId): Promise<WorkbarTabInstance>
|
openOrActivate(intent: WorkbarOpenIntent): Promise<WorkbarTabInstance>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
若当前 `WorkbarShell` 没有命令式入口,先增加窄的回调或受控请求 Props,不引入全局事件总线。
|
若当前 `WorkbarShell` 没有命令式入口,先增加窄的回调或受控请求 Props,不引入全局事件总线。
|
||||||
|
|
||||||
|
工作区意图始终激活注册表中的不可关闭单实例;仅在迁移或布局损坏时补回。浏览器意图按
|
||||||
|
`conversationId` 激活最近使用的匹配实例,没有匹配实例时创建一个。工作栏“+”不走该去重
|
||||||
|
路径,选择浏览器时始终创建新实例。
|
||||||
|
|
||||||
### 3.3 终端匹配
|
### 3.3 终端匹配
|
||||||
|
|
||||||
工作栏“+”目录选择终端时每次创建新实例;侧栏系统工具按钮执行“进入当前终端”,采用:
|
工作栏“+”目录选择终端时每次创建新实例;侧栏系统工具按钮执行“进入当前终端”,采用:
|
||||||
@@ -330,7 +336,7 @@ resourceMonitor.getLocalSnapshot(input: {
|
|||||||
- 固定入口顺序不受三个应用显隐组合影响。
|
- 固定入口顺序不受三个应用显隐组合影响。
|
||||||
- 运行记录和设置没有显隐控件。
|
- 运行记录和设置没有显隐控件。
|
||||||
- 四个系统工具按钮具有名称、工具提示、键盘行为和选中状态。
|
- 四个系统工具按钮具有名称、工具提示、键盘行为和选中状态。
|
||||||
- 文件和浏览器复用单实例;终端优先聚焦当前执行空间实例。
|
- 文件复用不可关闭单实例;浏览器和终端按当前上下文聚焦匹配实例,工作栏“+”允许显式多实例。
|
||||||
- 工具按钮展开工作栏但不改变当前主页面。
|
- 工具按钮展开工作栏但不改变当前主页面。
|
||||||
- 显隐更新成功后主导航立即变化,失败时 Switch 和主导航恢复原状态。
|
- 显隐更新成功后主导航立即变化,失败时 Switch 和主导航恢复原状态。
|
||||||
- 窄窗口下工具按钮不重叠,焦点恢复正确。
|
- 窄窗口下工具按钮不重叠,焦点恢复正确。
|
||||||
@@ -370,7 +376,7 @@ resourceMonitor.getLocalSnapshot(input: {
|
|||||||
3. 增加资源快照契约、Main 采集服务和 Preload API。
|
3. 增加资源快照契约、Main 采集服务和 Preload API。
|
||||||
4. 为工作栏增加 `resources` 单实例和 `ResourceMonitorPanel`。
|
4. 为工作栏增加 `resources` 单实例和 `ResourceMonitorPanel`。
|
||||||
5. 拆分侧栏底部设置按钮,加入四个系统工具按钮。
|
5. 拆分侧栏底部设置按钮,加入四个系统工具按钮。
|
||||||
6. 为工作栏增加 `openOrActivate` 意图接口和终端匹配规则。
|
6. 为工作栏增加带上下文的 `openOrActivate` 意图接口,以及浏览器和终端匹配规则。
|
||||||
7. 接入选中状态、焦点恢复、无障碍文案和响应式样式。
|
7. 接入选中状态、焦点恢复、无障碍文案和响应式样式。
|
||||||
8. 完成 Renderer、契约、迁移和跨平台回归测试。
|
8. 完成 Renderer、契约、迁移和跨平台回归测试。
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,9 @@
|
|||||||
|
|
||||||
| 位置 | 图标 | 可见工具提示 | 点击结果 |
|
| 位置 | 图标 | 可见工具提示 | 点击结果 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | `FolderOpen` | 文件 | 打开或聚焦工作区面板 |
|
| 1 | `FolderOpen` | 文件 | 聚焦不可关闭的工作区面板 |
|
||||||
| 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 |
|
| 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 |
|
||||||
| 3 | `Globe` | 浏览器 | 打开或聚焦受控浏览器面板 |
|
| 3 | `Globe` | 浏览器 | 聚焦当前 Conversation 的匹配浏览器实例 |
|
||||||
| 4 | `Activity` | 资源监控 | 打开或聚焦本机资源监控面板 |
|
| 4 | `Activity` | 资源监控 | 打开或聚焦本机资源监控面板 |
|
||||||
| 末位 | `Settings` | 设置 | 打开设置页面 |
|
| 末位 | `Settings` | 设置 | 打开设置页面 |
|
||||||
|
|
||||||
@@ -119,9 +119,10 @@
|
|||||||
|
|
||||||
点击系统工具后,右侧工作栏按现有布局回流打开。主内容页面保持不变,侧栏不渲染工具内容。
|
点击系统工具后,右侧工作栏按现有布局回流打开。主内容页面保持不变,侧栏不渲染工具内容。
|
||||||
|
|
||||||
- 文件:激活“工作区”Tab,焦点进入目录区域或错误修复入口。
|
- 文件:激活默认存在且不可关闭的“工作区”Tab,焦点进入目录区域或错误修复入口。
|
||||||
- 终端:激活匹配终端,焦点进入终端输入区;新建期间保留按钮宽度和进度状态。
|
- 终端:激活匹配终端,焦点进入终端输入区;新建期间保留按钮宽度和进度状态。
|
||||||
- 浏览器:激活“浏览器”Tab,焦点进入工具栏;没有会话时进入空状态主操作。
|
- 浏览器:激活绑定当前 Conversation 的最近使用“浏览器”Tab;没有匹配实例时创建一个,
|
||||||
|
没有 Conversation 时按产品规则复用最近实例或进入待绑定空状态。
|
||||||
- 资源监控:激活“资源监控”Tab,焦点进入指标摘要;首个样本加载后开始绘制历史。
|
- 资源监控:激活“资源监控”Tab,焦点进入指标摘要;首个样本加载后开始绘制历史。
|
||||||
|
|
||||||
后台事件可以更新徽标,不能自动展开工作栏或切换当前 Tab。由用户点击快捷按钮触发的打开
|
后台事件可以更新徽标,不能自动展开工作栏或切换当前 Tab。由用户点击快捷按钮触发的打开
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
- Given 当前项目有有效根目录
|
- Given 当前项目有有效根目录
|
||||||
- When 用户点击文件按钮
|
- When 用户点击文件按钮
|
||||||
- Then 助手工作栏展开
|
- Then 助手工作栏展开
|
||||||
- And 已有工作区面板时聚焦该实例,没有时创建单实例
|
- And 聚焦默认存在且不可关闭的工作区单实例
|
||||||
- And 文件按钮通过选中状态表明该面板正在显示
|
- And 文件按钮通过选中状态表明该面板正在显示
|
||||||
|
|
||||||
### US-C2 处理无效工作区
|
### US-C2 处理无效工作区
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- Given 工作区面板尚未创建或处于跟随模式
|
- Given 工作区面板处于跟随模式
|
||||||
- And 当前项目和默认工作区均不可用
|
- And 当前项目和默认工作区均不可用
|
||||||
- When 用户点击文件按钮
|
- When 用户点击文件按钮
|
||||||
- Then 工作区面板仍打开
|
- Then 工作区面板仍打开
|
||||||
@@ -160,15 +160,23 @@
|
|||||||
|
|
||||||
- Given 浏览器面板跟随当前上下文且当前会话已有浏览器会话
|
- Given 浏览器面板跟随当前上下文且当前会话已有浏览器会话
|
||||||
- When 用户点击浏览器按钮
|
- When 用户点击浏览器按钮
|
||||||
- Then 工作栏聚焦同一个浏览器会话
|
- Then 工作栏聚焦绑定当前 Conversation 的最近使用浏览器实例
|
||||||
- And 不打开系统默认浏览器或第二个 WebView
|
- And 不打开系统默认浏览器或无意创建重复实例
|
||||||
- Given 浏览器面板已固定到其他会话
|
- Given 现有浏览器实例都绑定到其他 Conversation
|
||||||
- When 用户点击浏览器按钮
|
- When 用户点击浏览器按钮
|
||||||
- Then 工作栏聚焦该面板并保持固定目标
|
- Then 工作栏创建并聚焦绑定当前 Conversation 的新浏览器实例
|
||||||
- Given 浏览器面板尚未创建或处于跟随模式
|
- Given 当前没有活动 Conversation
|
||||||
- And 当前没有活动对话
|
- And 已存在浏览器实例
|
||||||
- When 用户点击浏览器按钮
|
- When 用户点击浏览器按钮
|
||||||
- Then 面板说明需要选择或创建对话
|
- Then 工作栏聚焦最近使用的浏览器实例并保持其目标
|
||||||
|
- Given 当前没有活动 Conversation
|
||||||
|
- And 不存在浏览器实例
|
||||||
|
- When 用户点击浏览器按钮
|
||||||
|
- Then 创建待选择目标的空实例
|
||||||
|
- Given 用户需要同一 Conversation 的另一个独立页面
|
||||||
|
- When 用户从工作栏“+”选择浏览器
|
||||||
|
- Then 每次创建拥有独立页面和导航状态的新实例
|
||||||
|
- And 同一 Conversation 内共享隔离登录上下文
|
||||||
|
|
||||||
### US-C6 查看本机资源
|
### US-C6 查看本机资源
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
| 文档 | 权威职责 |
|
| 文档 | 权威职责 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
| [通用助手工作栏与执行空间 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 相关改动的实现事实、验证证据与剩余阻塞 |
|
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
|
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
|
||||||
|
|
||||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD
|
||||||
两份现有 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 管理工具。
|
||||||
@@ -89,18 +89,20 @@ Runtime
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
|
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
|
||||||
- 用户主动打开、关闭、排序和停靠面板实例;应用不默认同时挂载全部能力。
|
- 用户主动打开、关闭、排序和停靠可关闭的面板实例;任务中心和工作区作为基础工作入口默认
|
||||||
- Task Center 是 Task 的单例应用级索引。每个 Task 只关联一条 Conversation,一条
|
存在且不可关闭。不可关闭不等于锁定顺序,排序能力由独立属性控制。
|
||||||
Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通 Conversation、
|
- Task Center 是默认跟随当前项目的单实例 Task 索引。每个 Task 只关联一条 Conversation,
|
||||||
Job、Run 或心跳事项。
|
一条 Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通
|
||||||
|
Conversation、Job、Run 或心跳事项。
|
||||||
- 当前能力、连接、数据和空状态可以动态变化。
|
- 当前能力、连接、数据和空状态可以动态变化。
|
||||||
- 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。
|
- 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。
|
||||||
- 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。
|
- 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。
|
||||||
- 同一能力需要并排比较不同目标时可以创建多个实例,每个实例拥有独立身份和范围绑定。
|
- 浏览器、终端及其他声明为多实例的能力可以创建多个实例,每个实例拥有独立身份和范围绑定。
|
||||||
|
|
||||||
### 3.2 当前上下文只提供默认值
|
### 3.2 当前上下文只提供默认值
|
||||||
|
|
||||||
任务中心作为全局索引不跟随当前会话,也不支持为同一列表打开多个目标实例。其他可绑定
|
任务中心只有一个工作栏实例,默认跟随当前项目,不跟随当前 Conversation。用户可以在同一
|
||||||
|
实例内显式切换到全局任务或全部项目,但不能为不同项目打开多个任务中心 Tab。其他可绑定
|
||||||
目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是
|
目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是
|
||||||
使用门槛:
|
使用门槛:
|
||||||
|
|
||||||
@@ -212,17 +214,22 @@ Runtime
|
|||||||
|
|
||||||
- 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。
|
- 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。
|
||||||
- 已打开实例使用 `tablist`、`tab`、`tabpanel`;“+”是位于 `tablist` 外的普通命令按钮。
|
- 已打开实例使用 `tablist`、`tab`、`tabpanel`;“+”是位于 `tablist` 外的普通命令按钮。
|
||||||
- 应用注册表声明 `single` 或 `multiple`。在工作栏“+”目录中重复选择单实例应用时聚焦已有
|
- 应用注册表分别声明实例策略、默认打开、必须存在、是否可关闭和是否允许排序。`single` 或
|
||||||
实例;选择多实例应用时每次创建新实例。主侧栏系统工具快捷入口可以聚焦当前执行空间中
|
`multiple` 只控制实例数量;`required` 控制布局修复,不能兼作关闭或排序策略。
|
||||||
最近使用的终端,额外终端仍从“+”目录创建。
|
- 在工作栏“+”目录中重复选择单实例应用时聚焦已有实例;选择浏览器、终端等多实例应用时
|
||||||
- 支持方向键、Home、End、Enter、Space、关闭面板和正确焦点恢复。
|
每次创建新实例。主侧栏系统工具快捷入口按当前上下文聚焦匹配实例,不能因连续点击无意
|
||||||
|
创建重复实例。
|
||||||
|
- 支持方向键、Home、End、Enter、Space、可关闭面板的关闭命令和正确焦点恢复。不可关闭
|
||||||
|
实例不渲染关闭按钮,也不响应关闭快捷命令。
|
||||||
- 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。
|
- 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。
|
||||||
- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭实例后能力仍可从目录重新打开。
|
- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭可关闭实例后能力仍可从目录
|
||||||
|
重新打开。布局加载和恢复默认布局必须补回缺失的任务中心与工作区实例。
|
||||||
- 默认布局只恢复经过产品确认的少量常用面板,不自动打开全部标准能力。
|
- 默认布局只恢复经过产品确认的少量常用面板,不自动打开全部标准能力。
|
||||||
- 每个实例显示稳定实例 ID、能力名称、跟随或固定状态与当前目标;同能力多实例不能只靠位置区分。
|
- 每个实例显示稳定实例 ID、能力名称、跟随或固定状态与当前目标;同能力多实例不能只靠位置区分。
|
||||||
|
|
||||||
从当前聊天右栏迁移时,Task Center、工作区、浏览器和成果作为默认打开的单实例应用保留;
|
从当前聊天右栏迁移时,Task Center 和工作区作为默认打开、单实例且不可关闭的基础应用保留;
|
||||||
终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动,
|
浏览器保留一个默认打开实例,但注册为可关闭的多实例应用;成果保持默认打开、单实例且可
|
||||||
|
关闭;终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动,
|
||||||
不能自动隐藏或缩写到不可辨认,“+”不能随滚动消失。Task Center 继续作为 Task 的现有
|
不能自动隐藏或缩写到不可辨认,“+”不能随滚动消失。Task Center 继续作为 Task 的现有
|
||||||
入口;审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和
|
入口;审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和
|
||||||
完整配置统一归属“智能心跳”菜单入口。当前阶段不新增独立自动化中心。
|
完整配置统一归属“智能心跳”菜单入口。当前阶段不新增独立自动化中心。
|
||||||
@@ -260,8 +267,13 @@ Task Center 保留为工作栏中的稳定入口,并在现有基础上适度
|
|||||||
活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。
|
活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。
|
||||||
- 智能心跳的报告、建议、历史和配置不进入任务中心。
|
- 智能心跳的报告、建议、历史和配置不进入任务中心。
|
||||||
|
|
||||||
任务中心是单例索引,不使用其他能力的“跟随 / 固定目标”多实例模型。后台状态可以更新
|
任务中心是应用级单例,默认存在且不可关闭。它的数据范围默认跟随当前项目,并在标题或范围
|
||||||
徽标和排序,但不能自动打开面板或抢占用户当前工作。
|
控件中明确显示项目;用户可以切换到“全局任务”或“全部项目”,该选择只改变同一实例的列表
|
||||||
|
范围,不创建第二个任务中心 Tab。切换回“跟随当前项目”后,项目变化立即刷新列表。后台状态
|
||||||
|
可以更新徽标和排序,但不能抢占用户当前工作。
|
||||||
|
|
||||||
|
当前实现已按活动项目过滤 Scheduled Task 和审批,并提供“当前项目”“全局任务”“全部项目”
|
||||||
|
范围控件。
|
||||||
|
|
||||||
主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用
|
主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用
|
||||||
任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。
|
任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。
|
||||||
@@ -356,6 +368,10 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
|||||||
|
|
||||||
### 7.6 工作区
|
### 7.6 工作区
|
||||||
|
|
||||||
|
工作区是默认存在、单实例且不可关闭的基础应用。它默认跟随当前项目;项目或目录不可用时
|
||||||
|
保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。用户仍可调整该 Tab 的顺序,
|
||||||
|
除非后续单独引入顺序锁定属性。
|
||||||
|
|
||||||
工作区面板允许用户选择:
|
工作区面板允许用户选择:
|
||||||
|
|
||||||
- 当前项目目录。
|
- 当前项目目录。
|
||||||
@@ -429,10 +445,14 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
|||||||
|
|
||||||
### 7.7 浏览器
|
### 7.7 浏览器
|
||||||
|
|
||||||
|
浏览器是可关闭的多实例应用。升级后的默认布局保留一个浏览器实例;用户从“+”目录选择
|
||||||
|
浏览器时每次创建新的 Browser Tab。每个实例必须显示可区分的标题和绑定 Conversation,
|
||||||
|
不能让多个工作栏 Tab 只是同一页面的重复控制入口。
|
||||||
|
|
||||||
浏览器能力在目录中稳定可发现,打开面板后允许用户:
|
浏览器能力在目录中稳定可发现,打开面板后允许用户:
|
||||||
|
|
||||||
- 创建新的 GoodBuddy 隔离浏览器会话。
|
- 在当前 Conversation 的隔离浏览器上下文中创建独立 Browser Tab。
|
||||||
- 选择当前会话或其他受控浏览器会话。
|
- 选择当前 Conversation 或其他允许访问的 Conversation 作为实例目标。
|
||||||
- 查看状态、当前 URL、有界画面和错误。
|
- 查看状态、当前 URL、有界画面和错误。
|
||||||
- 通过顶部浏览器工具栏返回、刷新或停止加载、编辑地址并前往。
|
- 通过顶部浏览器工具栏返回、刷新或停止加载、编辑地址并前往。
|
||||||
- 进入明确的交互模式或关闭浏览器。
|
- 进入明确的交互模式或关闭浏览器。
|
||||||
@@ -441,10 +461,11 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
|||||||
页面加载期间原位切换为“停止加载”;“关闭浏览器”是清楚独立于导航的会话生命周期操作,
|
页面加载期间原位切换为“停止加载”;“关闭浏览器”是清楚独立于导航的会话生命周期操作,
|
||||||
必须与“停止加载”在文案和视觉上明确区分。现有“停止浏览器”语义统一改名为“关闭浏览器”。
|
必须与“停止加载”在文案和视觉上明确区分。现有“停止浏览器”语义统一改名为“关闭浏览器”。
|
||||||
|
|
||||||
浏览器面板和 Agent 操作同一条按 Conversation 归属的浏览器会话,不创建用户预览专用的
|
同一 Conversation 下的 Browser Tab 共享隔离存储和代理上下文,但每个 Tab 拥有独立页面、
|
||||||
第二个页面。用户执行前往、返回或刷新会有意改变 Agent 下一步读取和操作的页面。所有
|
导航状态、操作队列和元素引用空间。模型请求开始时绑定一个确定的 Browser Tab;用户切换
|
||||||
前往、返回和刷新请求都进入 `BrowserService`,并与 Agent 发起的浏览器操作串行执行;不在
|
工作栏实例不能在请求途中改变 Agent 的工具目标。用户在该实例执行前往、返回或刷新会有意
|
||||||
Renderer 建立第二套导航队列、抢占规则或恢复状态机。
|
改变绑定到同一 Tab 的 Agent 后续操作。所有请求都进入 `BrowserService`;Renderer 不建立
|
||||||
|
第二套导航队列、抢占规则或恢复状态机。
|
||||||
|
|
||||||
浏览器工具栏读取的权威状态至少包含:
|
浏览器工具栏读取的权威状态至少包含:
|
||||||
|
|
||||||
@@ -472,7 +493,10 @@ type BrowserToolbarState = {
|
|||||||
没有浏览器会话时仍显示工具栏。活动 Conversation 可以直接在地址栏输入非空 URL 并“前往”,
|
没有浏览器会话时仍显示工具栏。活动 Conversation 可以直接在地址栏输入非空 URL 并“前往”,
|
||||||
由 `BrowserService` 通过 Agent 浏览器相同的能力检查和 URL 策略创建会话并导航;不得为这一
|
由 `BrowserService` 通过 Agent 浏览器相同的能力检查和 URL 策略创建会话并导航;不得为这一
|
||||||
空状态增加旁路 WebView 或宽松 URL 入口。没有活动 Conversation 时说明需要先选择或创建会话。
|
空状态增加旁路 WebView 或宽松 URL 入口。没有活动 Conversation 时说明需要先选择或创建会话。
|
||||||
模型或后台浏览器活动可以更新徽标,但不得无条件打开面板或切换用户当前面板。
|
模型或后台浏览器活动可以更新对应实例的徽标,但不得无条件打开面板或切换用户当前面板。
|
||||||
|
|
||||||
|
浏览器多实例的跨进程身份、可见 viewport 所有权、关闭语义和 MCP 兼容方案以
|
||||||
|
[浏览器多实例技术设计](./browser-tabs-technical-design.md)为准。
|
||||||
|
|
||||||
浏览器面板只管理 GoodBuddy 受控浏览器,不表示可以控制用户已安装的浏览器。
|
浏览器面板只管理 GoodBuddy 受控浏览器,不表示可以控制用户已安装的浏览器。
|
||||||
|
|
||||||
@@ -1065,7 +1089,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
|||||||
动态 Tab 以及本机与直接 SSH 多终端。
|
动态 Tab 以及本机与直接 SSH 多终端。
|
||||||
- [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 定义 Task、Conversation、
|
- [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 定义 Task、Conversation、
|
||||||
Job、Subjob、Run 与 Subagent。
|
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](../smart-heartbeat/prd.md) 定义心跳入口、范围和长期边界。
|
||||||
- [会话监督 PRD](../conversation-supervision/prd.md) 定义监督判断、证据、预算和介入边界。
|
- [会话监督 PRD](../conversation-supervision/prd.md) 定义监督判断、证据、预算和介入边界。
|
||||||
- [自动化平台总体设计](../../architecture/automation-platform-architecture.md) 定义 Plan、Job、Run、监督、预算和记忆。
|
- [自动化平台总体设计](../../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 控件修正
|
## 2026-09-09 移动目录选择与 Git 控件修正
|
||||||
|
|
||||||
- 本轮在已有文件管理改动上修正 [FR-W3、FR-W4、FR-W6](./prd.md#76-工作区):
|
- 本轮在已有文件管理改动上修正 [FR-W3、FR-W4、FR-W6](./prd.md#76-工作区):
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 状态 | 已实施,跨平台验证中 |
|
| 状态 | 已实施,跨平台验证中 |
|
||||||
| 版本 | 1.1 |
|
| 版本 | 1.2 |
|
||||||
| 日期 | 2026-08-31 |
|
| 日期 | 2026-09-10 |
|
||||||
| 适用产品 | GoodBuddy 桌面端 |
|
| 适用产品 | GoodBuddy 桌面端 |
|
||||||
| 界面归属 | [通用助手工作栏与执行空间](./prd.md) |
|
| 界面归属 | [通用助手工作栏与执行空间](./prd.md) |
|
||||||
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
|
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
- 已打开应用以同级 Tab 呈现。
|
- 已打开应用以同级 Tab 呈现。
|
||||||
- Tab 行始终提供“+”入口,用户按需打开应用。
|
- Tab 行始终提供“+”入口,用户按需打开应用。
|
||||||
- 应用通过统一注册表声明单实例或多实例策略。
|
- 应用通过统一注册表分别声明单实例或多实例、默认打开、可关闭和排序策略。
|
||||||
- “终端”作为首个多实例应用,自动使用当前项目的本机或托管 SSH 执行空间。
|
- “终端”作为首个多实例应用,自动使用当前项目的本机或托管 SSH 执行空间。
|
||||||
- 本机与远程交互式终端的生命周期、状态、性能和验收要求。
|
- 本机与远程交互式终端的生命周期、状态、性能和验收要求。
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
当前工作栏在聊天页面右侧固定显示“任务中心、工作区、浏览器、成果”四个 Tab。用户不能:
|
当前工作栏在聊天页面右侧固定显示“任务中心、工作区、浏览器、成果”四个 Tab。用户不能:
|
||||||
|
|
||||||
- 通过统一入口按需打开新的工作栏应用。
|
- 通过统一入口按需打开新的工作栏应用。
|
||||||
- 关闭并重新打开一个工作栏应用。
|
- 按应用策略关闭并重新打开可关闭的工作栏应用。
|
||||||
- 为支持多实例的应用创建第二个独立实例。
|
- 为支持多实例的应用创建第二个独立实例。
|
||||||
- 在 GoodBuddy 内使用本机或远程 Host 的交互式 Shell。
|
- 在 GoodBuddy 内使用本机或远程 Host 的交互式 Shell。
|
||||||
|
|
||||||
@@ -49,17 +49,51 @@ SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端
|
|||||||
- 稳定应用 ID。
|
- 稳定应用 ID。
|
||||||
- 用户可见名称、图标和说明。
|
- 用户可见名称、图标和说明。
|
||||||
- `single` 或 `multiple` 实例策略。
|
- `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 实例
|
### 3.2 工作栏 Tab 实例
|
||||||
|
|
||||||
工作栏 Tab 是应用的一次打开实例。每个实例具有独立 ID、标题、应用类型和状态。Tab 行只
|
工作栏 Tab 是应用的一次打开实例。每个实例具有独立 ID、标题、应用类型和状态。Tab 行只
|
||||||
显示已经打开的实例,不把全部能力同时渲染为 Tab。
|
显示已经打开的实例,不把全部能力同时渲染为 Tab。
|
||||||
|
|
||||||
|
实例持久化字段只有 `id`、`appId`、`title` 和可选 `targetRef`。`targetRef` 使用公开引用:
|
||||||
|
终端接受 `local` 或 `projectId`,浏览器接受 `conversationId`。Main 签发的 Browser Tab ID、
|
||||||
|
Terminal Session ID、元素引用、凭据、路径和运行状态不进入工作栏布局。
|
||||||
|
|
||||||
### 3.3 终端实例
|
### 3.3 终端实例
|
||||||
|
|
||||||
每个终端 Tab 对应一个独立交互式 Shell Session:
|
每个终端 Tab 对应一个独立交互式 Shell Session:
|
||||||
@@ -81,10 +115,10 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
|||||||
5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。
|
5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。
|
||||||
6. 多实例应用每次选择都创建新实例。
|
6. 多实例应用每次选择都创建新实例。
|
||||||
7. 终端从首期开始就是多实例应用。
|
7. 终端从首期开始就是多实例应用。
|
||||||
8. 任务中心、工作区、浏览器和成果首期保持单实例;后续只需修改应用注册表策略即可开放
|
8. 任务中心和工作区保持单实例、默认打开且不可关闭;不可关闭不自动禁止排序。
|
||||||
多实例,不重写 Tab 壳层。
|
9. 浏览器注册为可关闭的多实例应用,升级后保留一个默认实例;成果保持单实例、默认打开且
|
||||||
9. 现有四个应用在升级后仍按当前默认布局打开,避免用户入口突然消失。
|
可关闭。
|
||||||
10. 用户关闭、切换或重排应用 Tab 后,“+”仍保持可见。
|
10. 用户关闭可关闭实例、切换或重排应用 Tab 后,“+”仍保持可见。
|
||||||
11. 选择“终端”后不再显示第二层目标选择器。存在当前项目时直接绑定该项目,由 Main
|
11. 选择“终端”后不再显示第二层目标选择器。存在当前项目时直接绑定该项目,由 Main
|
||||||
根据项目执行空间自动打开本机或托管 SSH Shell;没有当前项目时打开本机 Home Shell。
|
根据项目执行空间自动打开本机或托管 SSH Shell;没有当前项目时打开本机 Home Shell。
|
||||||
12. 远程首期使用直接 SSH PTY。SSH 断线后终端进入“已中断”,重新连接会创建一个新的
|
12. 远程首期使用直接 SSH PTY。SSH 断线后终端进入“已中断”,重新连接会创建一个新的
|
||||||
@@ -109,7 +143,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
|||||||
### 5.2 产品目标
|
### 5.2 产品目标
|
||||||
|
|
||||||
- 用一个可扩展的应用注册表统一单实例与多实例行为。
|
- 用一个可扩展的应用注册表统一单实例与多实例行为。
|
||||||
- 把现有固定四 Tab 迁移为用户实例,而不回归现有任务中心、工作区、浏览器和成果能力。
|
- 把现有 Tab 迁移到统一实例模型,同时保持任务中心和工作区始终存在,并允许浏览器和终端
|
||||||
|
创建多个实例。
|
||||||
- 建立真实的 Main/Preload/Renderer 终端生产链路,而不是用普通文本框模拟终端。
|
- 建立真实的 Main/Preload/Renderer 终端生产链路,而不是用普通文本框模拟终端。
|
||||||
- 复用现有 SSH Host Key、凭据和连接池,不建立第二套 Host 管理。
|
- 复用现有 SSH Host Key、凭据和连接池,不建立第二套 Host 管理。
|
||||||
- 保持终端输入输出有序、响应流畅、内存有界,并可靠清理本机和远程资源。
|
- 保持终端输入输出有序、响应流畅、内存有界,并可靠清理本机和远程资源。
|
||||||
@@ -149,7 +184,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
|||||||
- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。
|
- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。
|
||||||
- Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角,
|
- Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角,
|
||||||
保持紧凑的停靠式终端界面。
|
保持紧凑的停靠式终端界面。
|
||||||
- Tab 关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘焦点进入对应 Tab 时出现;
|
- 只有可关闭实例渲染 Tab 关闭按钮。关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘
|
||||||
|
焦点进入对应 Tab 时出现;
|
||||||
粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。
|
粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。
|
||||||
|
|
||||||
### 7.2 新建应用选择页
|
### 7.2 新建应用选择页
|
||||||
@@ -160,31 +196,34 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
|||||||
2. 在工作栏内容区显示“新建工作栏应用”。
|
2. 在工作栏内容区显示“新建工作栏应用”。
|
||||||
3. 列出稳定应用目录,每项显示名称、说明、单/多实例提示和当前可用状态。
|
3. 列出稳定应用目录,每项显示名称、说明、单/多实例提示和当前可用状态。
|
||||||
4. 初始焦点进入第一个可用应用;Escape 或“取消”返回原活动实例和“+”按钮。
|
4. 初始焦点进入第一个可用应用;Escape 或“取消”返回原活动实例和“+”按钮。
|
||||||
5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab。
|
5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab;任务中心和工作区始终走此路径。
|
||||||
6. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户
|
6. 选择浏览器时创建绑定当前 Conversation 的新 Browser Tab;没有活动 Conversation 时创建
|
||||||
|
待选择目标的空实例。
|
||||||
|
7. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户
|
||||||
重复选择本机或远程目标。
|
重复选择本机或远程目标。
|
||||||
|
|
||||||
选择页是“+”触发的临时界面,不进入实例顺序,不持久化,也不占用一个普通 Tab。
|
选择页是“+”触发的临时界面,不进入实例顺序,不持久化,也不占用一个普通 Tab。
|
||||||
|
|
||||||
### 7.3 首期应用注册表
|
### 7.3 首期应用注册表
|
||||||
|
|
||||||
| 应用 | 实例策略 | 升级后默认状态 | 再次从“+”选择 |
|
| 应用 | 实例策略 | 默认上下文 | 默认打开 | 必须存在 | 可关闭 | 可排序 | 再次从“+”选择 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| 任务中心 | `single` | 打开 | 聚焦已有 Tab |
|
| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab |
|
||||||
| 工作区 | `single` | 打开 | 聚焦已有 Tab |
|
| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab |
|
||||||
| 浏览器 | `single` | 打开 | 聚焦已有 Tab |
|
| 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab |
|
||||||
| 成果 | `single` | 打开 | 聚焦已有 Tab |
|
| 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab |
|
||||||
| 终端 | `multiple` | 不打开 | 每次创建新 Tab |
|
| 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab |
|
||||||
|
|
||||||
实例策略是应用注册数据,不能分散硬编码在多个点击处理器中。
|
实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。
|
||||||
|
|
||||||
### 7.4 Tab 操作
|
### 7.4 Tab 操作
|
||||||
|
|
||||||
- 点击或使用方向键切换实例不会暂停或终止后台实例。
|
- 点击或使用方向键切换实例不会暂停或终止后台实例。
|
||||||
- 支持左右方向键、Home、End 和 roving `tabindex`。
|
- 支持左右方向键、Home、End 和 roving `tabindex`。
|
||||||
- 关闭非活动 Tab 后焦点保持在当前活动 Tab。
|
- 任务中心和工作区没有关闭按钮,也不响应关闭快捷命令。
|
||||||
- 关闭活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。
|
- 关闭可关闭的非活动 Tab 后焦点保持在当前活动 Tab。
|
||||||
- 关闭最后一个实例后显示明确空状态和“打开应用”,Tab 行仍保留“+”。
|
- 关闭可关闭的活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。任务中心和工作区
|
||||||
|
始终存在,因此正常布局不会进入零实例空状态。
|
||||||
- 关闭正在运行的终端需要确认“关闭终端将结束 Shell 及其子进程”;取消是初始焦点。
|
- 关闭正在运行的终端需要确认“关闭终端将结束 Shell 及其子进程”;取消是初始焦点。
|
||||||
- 已退出或已中断的终端可以直接关闭,不重复确认。
|
- 已退出或已中断的终端可以直接关闭,不重复确认。
|
||||||
|
|
||||||
@@ -284,21 +323,45 @@ type TerminalSessionState =
|
|||||||
```ts
|
```ts
|
||||||
type WorkbarAppDefinition = {
|
type WorkbarAppDefinition = {
|
||||||
id: 'tasks' | 'workspace' | 'browser' | 'results' | 'terminal'
|
id: 'tasks' | 'workspace' | 'browser' | 'results' | 'terminal'
|
||||||
|
label: string
|
||||||
|
icon: WorkbarAppDefinition['id']
|
||||||
|
description: string
|
||||||
instancePolicy: 'single' | 'multiple'
|
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 = {
|
type WorkbarTabInstance = {
|
||||||
id: string
|
id: string
|
||||||
appId: WorkbarAppDefinition['id']
|
appId: WorkbarAppDefinition['id']
|
||||||
title: string
|
title: string
|
||||||
targetRef?: {
|
targetRef?:
|
||||||
type: 'project' | 'local'
|
| { type: 'local' }
|
||||||
id?: string
|
| { 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、自定义标题、公开目标引用和宽度比例。
|
- 持久化工作栏打开状态、Tab 顺序、活动实例、应用 ID、自定义标题、公开目标引用和宽度比例。
|
||||||
|
- 加载布局时根据 `required` 补回缺失实例,并拒绝重复的单实例应用;应用策略不写入实例
|
||||||
|
数据,避免持久化状态覆盖注册表。
|
||||||
|
- `reorderable` 已进入共享契约;当前 Tab 壳层尚未提供拖放或键盘重排,不能把声明属性视为
|
||||||
|
已完成的排序功能。
|
||||||
- 不持久化终端正文、未执行输入、凭据、环境变量、PTY PID、SSH Channel 或派生 Host
|
- 不持久化终端正文、未执行输入、凭据、环境变量、PTY PID、SSH Channel 或派生 Host
|
||||||
identity。
|
identity。
|
||||||
- Main 创建终端后返回新的不透明 Session ID;Renderer 不能选择 PID 或 Channel。
|
- Main 创建终端后返回新的不透明 Session ID;Renderer 不能选择 PID 或 Channel。
|
||||||
@@ -474,16 +537,19 @@ Schema、假数据终端或不可用入口的中间产品。
|
|||||||
|
|
||||||
### 18.1 工作栏应用与实例
|
### 18.1 工作栏应用与实例
|
||||||
|
|
||||||
- [ ] Tab 行末始终显示“+”,包括没有任何打开实例时。
|
- [ ] Tab 行末始终显示“+”;任务中心和工作区始终存在,因此正常布局不存在零实例状态。
|
||||||
- [ ] 点击“+”显示稳定应用目录,取消后返回原实例并恢复焦点。
|
- [ ] 点击“+”显示稳定应用目录,取消后返回原实例并恢复焦点。
|
||||||
- [ ] 选择已打开单实例应用只聚焦已有 Tab。
|
- [ ] 选择已打开单实例应用只聚焦已有 Tab。
|
||||||
|
- [x] 任务中心和工作区默认存在且不可关闭,不渲染关闭按钮,也不响应关闭快捷命令。
|
||||||
|
- [x] 连续选择浏览器会创建页面、导航状态和元素引用相互独立的浏览器 Tab。
|
||||||
- [x] 连续选择终端会创建多个互相独立的终端 Tab。
|
- [x] 连续选择终端会创建多个互相独立的终端 Tab。
|
||||||
- [ ] 关闭、切换和重排实例后,“+”仍保持可用。
|
- [ ] 关闭可关闭实例、切换和重排实例后,“+”仍保持可用。
|
||||||
- [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。
|
- [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。
|
||||||
- [x] 关闭活动 Tab 后激活正确的相邻 Tab 并恢复焦点。
|
- [x] 关闭可关闭的活动 Tab 后激活正确的相邻 Tab 并恢复焦点。
|
||||||
- [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停
|
- [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停
|
||||||
设备上出现,且不引发布局位移。
|
设备上出现,且不引发布局位移。
|
||||||
- [ ] 升级后现有任务中心、工作区、浏览器和成果仍按默认布局打开且行为不回归。
|
- [x] 升级后任务中心、工作区、一个浏览器实例和成果按默认布局打开;布局恢复会补回缺失的
|
||||||
|
任务中心与工作区。
|
||||||
- [ ] 在所有主要页面都能打开工作栏;切换主要页面不会销毁 Tab 实例或终端 Session。
|
- [ ] 在所有主要页面都能打开工作栏;切换主要页面不会销毁 Tab 实例或终端 Session。
|
||||||
|
|
||||||
### 18.2 本机终端
|
### 18.2 本机终端
|
||||||
@@ -522,7 +588,8 @@ Schema、假数据终端或不可用入口的中间产品。
|
|||||||
|
|
||||||
### 18.5 设计、无障碍和跨平台
|
### 18.5 设计、无障碍和跨平台
|
||||||
|
|
||||||
- [ ] 仅使用键盘可以打开应用、创建终端、切换和关闭 Tab、复制、粘贴及重新连接。
|
- [ ] 仅使用键盘可以打开应用、创建浏览器和终端、切换 Tab、关闭可关闭 Tab、复制、粘贴及
|
||||||
|
重新连接。
|
||||||
- [ ] Tab、“+”、选择页和关闭确认使用正确语义并有可见焦点。
|
- [ ] Tab、“+”、选择页和关闭确认使用正确语义并有可见焦点。
|
||||||
- [ ] 终端输出不逐行进入 live region,关键状态变化可被辅助技术感知。
|
- [ ] 终端输出不逐行进入 live region,关键状态变化可被辅助技术感知。
|
||||||
- [ ] 浅色、深色、200% 文字缩放和窄窗口下可以完成核心流程。
|
- [ ] 浅色、深色、200% 文字缩放和窄窗口下可以完成核心流程。
|
||||||
@@ -535,7 +602,9 @@ Schema、假数据终端或不可用入口的中间产品。
|
|||||||
### 19.1 自动验证
|
### 19.1 自动验证
|
||||||
|
|
||||||
- 共享 Zod Schema 和边界值测试。
|
- 共享 Zod Schema 和边界值测试。
|
||||||
- 单/多实例注册策略、Tab 顺序、关闭焦点和“+”选择页组件测试。
|
- 单/多实例、默认打开、可关闭和排序策略,以及 Tab 顺序、关闭焦点和“+”选择页组件测试。
|
||||||
|
- 任务中心与工作区缺失时的布局修复、关闭命令拒绝和焦点保持测试。
|
||||||
|
- 浏览器多实例的页面隔离、Conversation 匹配和快捷入口去重测试。
|
||||||
- 本机 PTY Session Manager 的输入、resize、退出、幂等关闭和进程树测试。
|
- 本机 PTY Session Manager 的输入、resize、退出、幂等关闭和进程树测试。
|
||||||
- SSH PTY 的 Host identity、lease、断线、resize 和关闭测试。
|
- SSH PTY 的 Host identity、lease、断线、resize 和关闭测试。
|
||||||
- Main trusted sender、Session 所有权、IPC 输入边界和事件路由测试。
|
- Main trusted sender、Session 所有权、IPC 输入边界和事件路由测试。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 权威文档
|
## 权威文档
|
||||||
|
|
||||||
1. [Task 与 Job 统一领域模型](./task-and-job-model.md):术语、身份和对象关系。
|
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。
|
3. [Scheduled Task PRD](./scheduled-task-prd.md):时间或事件触发的 Task。
|
||||||
4. [Goal Task PRD](./goal-task-prd.md):围绕可验证结果有界推进的 Task。
|
4. [Goal Task PRD](./goal-task-prd.md):围绕可验证结果有界推进的 Task。
|
||||||
5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。
|
5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。
|
||||||
|
|||||||
@@ -4,20 +4,24 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 状态 | Scheduled Task 首期已实现;Goal/Event Task 与完整操作待实施 |
|
| 状态 | Scheduled Task 与项目范围跟随已实现;Goal/Event Task 与完整操作待实施 |
|
||||||
| 版本 | 0.3 |
|
| 版本 | 0.4 |
|
||||||
| 日期 | 2026-08-19 |
|
| 日期 | 2026-09-10 |
|
||||||
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
|
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
|
||||||
| 界面归属 | [通用助手工作栏与执行空间](../assistant-workbar/prd.md) |
|
| 界面归属 | [通用助手工作栏与执行空间](../assistant-workbar/prd.md) |
|
||||||
|
|
||||||
## 1. 产品定义
|
## 1. 产品定义
|
||||||
|
|
||||||
Task Center 是所有产品级 Task 的应用级单例索引,不是 Automation Center,也不复制
|
Task Center 是工作栏中的单实例 Task 索引,默认跟随当前项目。它不是 Automation Center,
|
||||||
Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。
|
也不复制 Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。
|
||||||
|
|
||||||
每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。
|
每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。
|
||||||
Conversation 不因为关联 Task 而改变对象类型。
|
Conversation 不因为关联 Task 而改变对象类型。
|
||||||
|
|
||||||
|
“单实例”和“项目范围”是两个独立属性:应用内只有一个 Task Center Tab,但该 Tab 默认展示
|
||||||
|
当前项目的 Task。用户可以在同一 Tab 内切换到“全局任务”或“全部项目”,不能为每个项目创建
|
||||||
|
独立 Task Center 实例。
|
||||||
|
|
||||||
## 2. 收录边界
|
## 2. 收录边界
|
||||||
|
|
||||||
收录:
|
收录:
|
||||||
@@ -53,7 +57,19 @@ Job/Subjob/Run 树、独立详情或路由。
|
|||||||
|
|
||||||
Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。
|
Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。
|
||||||
|
|
||||||
## 4. 交互
|
## 4. 范围
|
||||||
|
|
||||||
|
范围控件提供:
|
||||||
|
|
||||||
|
- `当前项目`:默认值,跟随活动项目变化,只显示 `projectId` 匹配的 Task。
|
||||||
|
- `全局任务`:只显示没有 `projectId` 的 Task。
|
||||||
|
- `全部项目`:显示当前用户可见的项目 Task 和全局 Task,并保留每行的项目或 Global 标识。
|
||||||
|
|
||||||
|
没有活动项目时,“当前项目”显示明确空状态,并允许切换到“全局任务”或“全部项目”;系统不
|
||||||
|
自动改变用户已经选择的范围。范围模式作为工作栏公开偏好持久化,但“当前项目”模式不保存
|
||||||
|
具体项目 ID;重启后继续跟随届时的活动项目。
|
||||||
|
|
||||||
|
## 5. 交互
|
||||||
|
|
||||||
- 点击条目打开关联 Conversation,并定位到该 Task。
|
- 点击条目打开关联 Conversation,并定位到该 Task。
|
||||||
- 支持按需要关注、进行中、已暂停、已结束筛选。
|
- 支持按需要关注、进行中、已暂停、已结束筛选。
|
||||||
@@ -63,7 +79,7 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
|||||||
- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看;
|
- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看;
|
||||||
独立交付物在成果中查看。
|
独立交付物在成果中查看。
|
||||||
|
|
||||||
## 5. 左侧 Conversation Task 列表
|
## 6. 左侧 Conversation Task 列表
|
||||||
|
|
||||||
左侧最近会话列表承担轻量 Task 发现,不替代 Task Center:
|
左侧最近会话列表承担轻量 Task 发现,不替代 Task Center:
|
||||||
|
|
||||||
@@ -88,7 +104,7 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
|||||||
- 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。
|
- 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。
|
||||||
- 默认最多显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整任务区。
|
- 默认最多显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整任务区。
|
||||||
|
|
||||||
## 6. Conversation 任务区
|
## 7. Conversation 任务区
|
||||||
|
|
||||||
包含 Task 的 Conversation 顶部显示可折叠任务区:
|
包含 Task 的 Conversation 顶部显示可折叠任务区:
|
||||||
|
|
||||||
@@ -100,14 +116,14 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
|||||||
选择 Task 后显示名称、模式、聚合状态、计划、下次执行、最近结果和 Task 级操作。工具、审批、
|
选择 Task 后显示名称、模式、聚合状态、计划、下次执行、最近结果和 Task 级操作。工具、审批、
|
||||||
错误和成果通过 Task 关联显示,但不暴露 Job/Run 层级。
|
错误和成果通过 Task 关联显示,但不暴露 Job/Run 层级。
|
||||||
|
|
||||||
## 7. 删除关系
|
## 8. 删除关系
|
||||||
|
|
||||||
- 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。
|
- 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。
|
||||||
- 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。
|
- 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。
|
||||||
- 删除最后一个 Task 后,左侧 Conversation 的展开按钮消失。
|
- 删除最后一个 Task 后,左侧 Conversation 的展开按钮消失。
|
||||||
- 删除 Conversation 前必须显示关联 Task 数量,并先停止或结算活动执行。
|
- 删除 Conversation 前必须显示关联 Task 数量,并先停止或结算活动执行。
|
||||||
|
|
||||||
## 8. 验收标准
|
## 9. 验收标准
|
||||||
|
|
||||||
- [x] Task Center 只展示产品级 Task。
|
- [x] Task Center 只展示产品级 Task。
|
||||||
- [x] 一条 Conversation 可以关联并展开多个 Task。
|
- [x] 一条 Conversation 可以关联并展开多个 Task。
|
||||||
@@ -118,3 +134,6 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
|||||||
- [x] 普通模型请求和工具调用不会误显示为 Task。
|
- [x] 普通模型请求和工具调用不会误显示为 Task。
|
||||||
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
|
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
|
||||||
- [x] Smart Heartbeat 不进入 Task Center。
|
- [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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { LocalWorkspaceAccess } from '../workspace'
|
import { LocalWorkspaceAccess } from '../workspace'
|
||||||
import {
|
import {
|
||||||
DIRECT_MODEL_PROCESS_TRUNCATION_MARKER,
|
|
||||||
LocalDirectModelProcessService,
|
LocalDirectModelProcessService,
|
||||||
buildDirectModelProcessEnvironment,
|
buildDirectModelProcessEnvironment,
|
||||||
type DirectModelProcessChild,
|
type DirectModelProcessChild,
|
||||||
@@ -277,7 +276,7 @@ describe('LocalDirectModelProcessService', () => {
|
|||||||
await service.dispose()
|
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 { access } = await workspace()
|
||||||
const child = new FakeChild()
|
const child = new FakeChild()
|
||||||
let now = 1_000
|
let now = 1_000
|
||||||
@@ -310,12 +309,19 @@ describe('LocalDirectModelProcessService', () => {
|
|||||||
expect(result.durationMs).toBe(125)
|
expect(result.durationMs).toBe(125)
|
||||||
expect(result.stdoutTruncated).toBe(true)
|
expect(result.stdoutTruncated).toBe(true)
|
||||||
expect(result.stdout).toBe(
|
expect(result.stdout).toBe(
|
||||||
`${'A'.repeat(48 * 1024)}` +
|
'A'.repeat(48 * 1024) + 'M'.repeat(10) + 'Z'.repeat(48 * 1024 - 10)
|
||||||
DIRECT_MODEL_PROCESS_TRUNCATION_MARKER +
|
|
||||||
'Z'.repeat(48 * 1024)
|
|
||||||
)
|
)
|
||||||
expect(result.stderr).toBe('ordinary error output')
|
expect(result.stderr).toBe('ordinary error output')
|
||||||
expect(result.stderrTruncated).toBe(false)
|
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()
|
await service.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -439,14 +445,18 @@ describe('LocalDirectModelProcessService', () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
const first = run('first')
|
const first = run('first')
|
||||||
|
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
|
||||||
const second = run('second')
|
const second = run('second')
|
||||||
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2))
|
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')
|
await service.releaseConversation('first')
|
||||||
expect(terminated).toHaveLength(1)
|
expect(terminated).toHaveLength(1)
|
||||||
await expect(first).resolves.toMatchObject({
|
await expect(first).resolves.toMatchObject({
|
||||||
terminationReason: 'cancelled'
|
terminationReason: 'cancelled'
|
||||||
})
|
})
|
||||||
|
await expect(service.readOutput('first', (await first).stdoutReference!.handle)).rejects.toThrow()
|
||||||
const replacementChild = new FakeChild()
|
const replacementChild = new FakeChild()
|
||||||
children.push(replacementChild)
|
children.push(replacementChild)
|
||||||
const replacement = run('first')
|
const replacement = run('first')
|
||||||
@@ -464,6 +474,7 @@ describe('LocalDirectModelProcessService', () => {
|
|||||||
await expect(second).resolves.toMatchObject({
|
await expect(second).resolves.toMatchObject({
|
||||||
terminationReason: 'cancelled'
|
terminationReason: 'cancelled'
|
||||||
})
|
})
|
||||||
|
await expect(service.readOutput('second', (await second).stderrReference!.handle)).rejects.toThrow()
|
||||||
await service.dispose()
|
await service.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -496,4 +507,37 @@ describe('LocalDirectModelProcessService', () => {
|
|||||||
expect(result.stderr).toContain('warn')
|
expect(result.stderr).toContain('warn')
|
||||||
await service.dispose()
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,16 +15,18 @@ import {
|
|||||||
type WaitableProcessTreeChild
|
type WaitableProcessTreeChild
|
||||||
} from './child-process-termination'
|
} from './child-process-termination'
|
||||||
import { buildCredentialFilteredUserEnvironment } from './process-environment'
|
import { buildCredentialFilteredUserEnvironment } from './process-environment'
|
||||||
|
import {
|
||||||
|
PagedOutputStore,
|
||||||
|
type PagedOutputPage,
|
||||||
|
type PagedOutputReference,
|
||||||
|
type PagedOutputWriter
|
||||||
|
} from './paged-output-store'
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 120_000
|
const DEFAULT_TIMEOUT_MS = 120_000
|
||||||
const MAXIMUM_TIMEOUT_MS = 10 * 60_000
|
const MAXIMUM_TIMEOUT_MS = 10 * 60_000
|
||||||
const MAXIMUM_OUTPUT_BYTES = 96 * 1024
|
const MAXIMUM_OUTPUT_BYTES = 96 * 1024
|
||||||
const RETAINED_OUTPUT_EDGE_BYTES = MAXIMUM_OUTPUT_BYTES / 2
|
|
||||||
const TERMINATION_WAIT_MS = 2_000
|
const TERMINATION_WAIT_MS = 2_000
|
||||||
|
|
||||||
export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER =
|
|
||||||
'\n...[GoodBuddy output truncated]...\n'
|
|
||||||
|
|
||||||
export const processExecuteInputSchema = z
|
export const processExecuteInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
command: z.string().trim().min(1).max(100_000),
|
command: z.string().trim().min(1).max(100_000),
|
||||||
@@ -70,6 +72,8 @@ export type ProcessExecuteResult = {
|
|||||||
stderr: string
|
stderr: string
|
||||||
stdoutTruncated: boolean
|
stdoutTruncated: boolean
|
||||||
stderrTruncated: boolean
|
stderrTruncated: boolean
|
||||||
|
stdoutReference?: PagedOutputReference
|
||||||
|
stderrReference?: PagedOutputReference
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirectModelProcessCapability =
|
export type DirectModelProcessCapability =
|
||||||
@@ -94,6 +98,7 @@ export interface DirectModelProcessService {
|
|||||||
input: ProcessExecuteInput,
|
input: ProcessExecuteInput,
|
||||||
context: DirectModelProcessExecutionContext
|
context: DirectModelProcessExecutionContext
|
||||||
): Promise<ProcessExecuteResult>
|
): Promise<ProcessExecuteResult>
|
||||||
|
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage>
|
||||||
releaseConversation(conversationId: string): Promise<void>
|
releaseConversation(conversationId: string): Promise<void>
|
||||||
dispose(): Promise<void>
|
dispose(): Promise<void>
|
||||||
}
|
}
|
||||||
@@ -271,67 +276,6 @@ async function resolveProcessShell(
|
|||||||
return undefined
|
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(
|
function shellArguments(
|
||||||
shell: ResolvedProcessShell,
|
shell: ResolvedProcessShell,
|
||||||
command: string
|
command: string
|
||||||
@@ -399,6 +343,7 @@ export class LocalDirectModelProcessService
|
|||||||
implements DirectModelProcessService
|
implements DirectModelProcessService
|
||||||
{
|
{
|
||||||
private readonly activeCalls = new Map<string, Set<ActiveCall>>()
|
private readonly activeCalls = new Map<string, Set<ActiveCall>>()
|
||||||
|
private readonly outputs = new PagedOutputStore('process')
|
||||||
private readonly shell: Promise<ResolvedProcessShell | undefined>
|
private readonly shell: Promise<ResolvedProcessShell | undefined>
|
||||||
private disposed = false
|
private disposed = false
|
||||||
private disposePromise?: Promise<void>
|
private disposePromise?: Promise<void>
|
||||||
@@ -496,6 +441,15 @@ export class LocalDirectModelProcessService
|
|||||||
platform
|
platform
|
||||||
)
|
)
|
||||||
const startedAt = (this.options.now ?? Date.now)()
|
const startedAt = (this.options.now ?? Date.now)()
|
||||||
|
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
|
let child: DirectModelProcessChild
|
||||||
try {
|
try {
|
||||||
child = (
|
child = (
|
||||||
@@ -519,8 +473,14 @@ export class LocalDirectModelProcessService
|
|||||||
workingDirectory.displayPath,
|
workingDirectory.displayPath,
|
||||||
input.timeoutMs,
|
input.timeoutMs,
|
||||||
signal,
|
signal,
|
||||||
startedAt
|
startedAt,
|
||||||
|
stdout,
|
||||||
|
stderr
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.all([stdout.abort(), stderr?.abort()])
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private waitForProcess(
|
private waitForProcess(
|
||||||
@@ -529,10 +489,10 @@ export class LocalDirectModelProcessService
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
cancellationSignal: AbortSignal,
|
cancellationSignal: AbortSignal,
|
||||||
startedAt: number
|
startedAt: number,
|
||||||
|
stdout: PagedOutputWriter,
|
||||||
|
stderr: PagedOutputWriter
|
||||||
): Promise<ProcessExecuteResult> {
|
): Promise<ProcessExecuteResult> {
|
||||||
const stdout = new BoundedProcessOutput()
|
|
||||||
const stderr = new BoundedProcessOutput()
|
|
||||||
child.stdout?.on('data', (chunk) => stdout.append(chunk))
|
child.stdout?.on('data', (chunk) => stdout.append(chunk))
|
||||||
child.stderr?.on('data', (chunk) => stderr.append(chunk))
|
child.stderr?.on('data', (chunk) => stderr.append(chunk))
|
||||||
|
|
||||||
@@ -550,9 +510,9 @@ export class LocalDirectModelProcessService
|
|||||||
child.stdout?.removeListener?.('error', failFromOutput)
|
child.stdout?.removeListener?.('error', failFromOutput)
|
||||||
child.stderr?.removeListener?.('error', failFromOutput)
|
child.stderr?.removeListener?.('error', failFromOutput)
|
||||||
}
|
}
|
||||||
const result = (): ProcessExecuteResult => {
|
const result = async (): Promise<ProcessExecuteResult> => {
|
||||||
const stdoutResult = stdout.result()
|
const stdoutResult = await stdout.finish()
|
||||||
const stderrResult = stderr.result()
|
const stderrResult = await stderr.finish()
|
||||||
return {
|
return {
|
||||||
shell: { kind: shell.kind, label: shell.label },
|
shell: { kind: shell.kind, label: shell.label },
|
||||||
cwd,
|
cwd,
|
||||||
@@ -566,7 +526,9 @@ export class LocalDirectModelProcessService
|
|||||||
stdout: stdoutResult.text,
|
stdout: stdoutResult.text,
|
||||||
stderr: stderrResult.text,
|
stderr: stderrResult.text,
|
||||||
stdoutTruncated: stdoutResult.truncated,
|
stdoutTruncated: stdoutResult.truncated,
|
||||||
stderrTruncated: stderrResult.truncated
|
stderrTruncated: stderrResult.truncated,
|
||||||
|
...(stdoutResult.reference ? { stdoutReference: stdoutResult.reference } : {}),
|
||||||
|
...(stderrResult.reference ? { stderrReference: stderrResult.reference } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const finishResult = (): void => {
|
const finishResult = (): void => {
|
||||||
@@ -682,6 +644,11 @@ export class LocalDirectModelProcessService
|
|||||||
call.controller.abort(abortError('对话进程资源已释放'))
|
call.controller.abort(abortError('对话进程资源已释放'))
|
||||||
}
|
}
|
||||||
await Promise.all(calls.map((call) => call.done))
|
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> {
|
dispose(): Promise<void> {
|
||||||
@@ -698,5 +665,6 @@ export class LocalDirectModelProcessService
|
|||||||
call.controller.abort(abortError('进程执行服务已关闭'))
|
call.controller.abort(abortError('进程执行服务已关闭'))
|
||||||
}
|
}
|
||||||
await Promise.all(calls.map((call) => call.done))
|
await Promise.all(calls.map((call) => call.done))
|
||||||
|
await this.outputs.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
KnowledgeMcpGateway,
|
KnowledgeMcpGateway,
|
||||||
type MagicNotesDatabase
|
type MagicNotesDatabase
|
||||||
} from './knowledge-mcp-gateway'
|
} from './knowledge-mcp-gateway'
|
||||||
|
import { browserTabIdSchema } from '../../shared/contracts'
|
||||||
|
|
||||||
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
|
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
|
||||||
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
|
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
|
||||||
@@ -185,6 +186,28 @@ function testTool(name: string): Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gateways: KnowledgeMcpGateway[] = []
|
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 databases: AssistantDatabase[] = []
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
const httpServers: Server[] = []
|
const httpServers: Server[] = []
|
||||||
@@ -210,6 +233,22 @@ afterEach(async () => {
|
|||||||
describe('KnowledgeMcpGateway', () => {
|
describe('KnowledgeMcpGateway', () => {
|
||||||
it('exposes the assigned browser through a request-scoped conversation', async () => {
|
it('exposes the assigned browser through a request-scoped conversation', async () => {
|
||||||
const { service } = createService()
|
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 = {
|
const browserService = {
|
||||||
getOrigin: vi.fn(() => undefined),
|
getOrigin: vi.fn(() => undefined),
|
||||||
navigate: vi.fn(async () => ({
|
navigate: vi.fn(async () => ({
|
||||||
@@ -222,21 +261,63 @@ describe('KnowledgeMcpGateway', () => {
|
|||||||
select: vi.fn(),
|
select: vi.fn(),
|
||||||
back: vi.fn(),
|
back: vi.fn(),
|
||||||
screenshot: 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, {
|
const gateway = new KnowledgeMcpGateway(service, {
|
||||||
browserService
|
browserService
|
||||||
})
|
})
|
||||||
gateways.push(gateway)
|
gateways.push(gateway)
|
||||||
await gateway.start()
|
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(
|
const token = gateway.grant(
|
||||||
'browser-request',
|
'browser-request',
|
||||||
[],
|
[],
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
'none',
|
'none',
|
||||||
undefined,
|
undefined,
|
||||||
'browser-conversation'
|
'browser-conversation',
|
||||||
|
browserTabId
|
||||||
)!
|
)!
|
||||||
|
expect(acquireTabUsage).toHaveBeenCalledWith(
|
||||||
|
'browser-conversation',
|
||||||
|
browserTabId,
|
||||||
|
'browser-request'
|
||||||
|
)
|
||||||
expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames)
|
expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames)
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
name: 'browser-loopback-test',
|
name: 'browser-loopback-test',
|
||||||
@@ -276,8 +357,32 @@ describe('KnowledgeMcpGateway', () => {
|
|||||||
expect(browserService.navigate).toHaveBeenCalledWith(
|
expect(browserService.navigate).toHaveBeenCalledWith(
|
||||||
'browser-conversation',
|
'browser-conversation',
|
||||||
'https://example.com',
|
'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 {
|
} finally {
|
||||||
await client.close()
|
await client.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
type Tool
|
type Tool
|
||||||
} from '@modelcontextprotocol/sdk/types.js'
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
import { z } from 'zod'
|
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 { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
||||||
import {
|
import {
|
||||||
knowledgeToolNames,
|
knowledgeToolNames,
|
||||||
@@ -70,6 +74,7 @@ import {
|
|||||||
type BrowserToolName,
|
type BrowserToolName,
|
||||||
type BrowserToolService
|
type BrowserToolService
|
||||||
} from '../browser/browser-model-tools'
|
} from '../browser/browser-model-tools'
|
||||||
|
import type { BrowserTabUsageLease } from '../browser/browser-service'
|
||||||
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
||||||
import { safeToolErrorDetail } from './approval-summary'
|
import { safeToolErrorDetail } from './approval-summary'
|
||||||
|
|
||||||
@@ -185,6 +190,8 @@ type Capability = {
|
|||||||
configWorkspacePath?: string
|
configWorkspacePath?: string
|
||||||
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
||||||
browserConversationId?: string
|
browserConversationId?: string
|
||||||
|
browserTabId?: BrowserTabId
|
||||||
|
browserUsageLease?: BrowserTabUsageLease
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
brokerController: AbortController
|
brokerController: AbortController
|
||||||
@@ -230,7 +237,14 @@ export type KnowledgeMcpGatewayOptions = {
|
|||||||
now?: () => number
|
now?: () => number
|
||||||
magicNotesDatabase?: MagicNotesDatabase
|
magicNotesDatabase?: MagicNotesDatabase
|
||||||
configService?: GoodBuddyConfigService
|
configService?: GoodBuddyConfigService
|
||||||
browserService?: BrowserToolService
|
browserService?: BrowserToolService & {
|
||||||
|
listTabs(conversationId: string): BrowserTabSummary[]
|
||||||
|
acquireTabUsage(
|
||||||
|
conversationId: string,
|
||||||
|
tabId: BrowserTabId,
|
||||||
|
owner: string
|
||||||
|
): BrowserTabUsageLease
|
||||||
|
}
|
||||||
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +370,14 @@ export class KnowledgeMcpGateway {
|
|||||||
private readonly maximumBodyBytes: number
|
private readonly maximumBodyBytes: number
|
||||||
private readonly magicNotesDatabase?: MagicNotesDatabase
|
private readonly magicNotesDatabase?: MagicNotesDatabase
|
||||||
private readonly configService?: GoodBuddyConfigService
|
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 readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||||
private server?: Server
|
private server?: Server
|
||||||
private endpoint?: string
|
private endpoint?: string
|
||||||
@@ -432,7 +453,9 @@ export class KnowledgeMcpGateway {
|
|||||||
workspacePath: string
|
workspacePath: string
|
||||||
authorizeApply?: GoodBuddyConfigApplyAuthorizer
|
authorizeApply?: GoodBuddyConfigApplyAuthorizer
|
||||||
},
|
},
|
||||||
browserConversationId?: string
|
browserConversationId?: string,
|
||||||
|
browserTabId?: BrowserTabId,
|
||||||
|
browserUsageLease?: BrowserTabUsageLease
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const effectiveMagicNotesAccess = this.magicNotesDatabase
|
const effectiveMagicNotesAccess = this.magicNotesDatabase
|
||||||
? magicNotesAccess
|
? magicNotesAccess
|
||||||
@@ -443,6 +466,39 @@ export class KnowledgeMcpGateway {
|
|||||||
const effectiveBrowserConversationId = this.browserService
|
const effectiveBrowserConversationId = this.browserService
|
||||||
? browserConversationId
|
? browserConversationId
|
||||||
: undefined
|
: 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 (
|
if (
|
||||||
authorizedLibraryIds.length === 0 &&
|
authorizedLibraryIds.length === 0 &&
|
||||||
effectiveMagicNotesAccess === 'none' &&
|
effectiveMagicNotesAccess === 'none' &&
|
||||||
@@ -452,12 +508,15 @@ export class KnowledgeMcpGateway {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
|
try {
|
||||||
return this.storeCapability({
|
return this.storeCapability({
|
||||||
requestId,
|
requestId,
|
||||||
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
||||||
magicNotesAccess: effectiveMagicNotesAccess,
|
magicNotesAccess: effectiveMagicNotesAccess,
|
||||||
configAccess: effectiveConfigAccess,
|
configAccess: effectiveConfigAccess,
|
||||||
browserConversationId: effectiveBrowserConversationId,
|
browserConversationId: effectiveBrowserConversationId,
|
||||||
|
browserTabId: effectiveBrowserTabId,
|
||||||
|
browserUsageLease: effectiveBrowserUsageLease,
|
||||||
...(effectiveConfigAccess !== 'none'
|
...(effectiveConfigAccess !== 'none'
|
||||||
? {
|
? {
|
||||||
configWorkspacePath: config?.workspacePath,
|
configWorkspacePath: config?.workspacePath,
|
||||||
@@ -467,6 +526,10 @@ export class KnowledgeMcpGateway {
|
|||||||
signal,
|
signal,
|
||||||
customMcpServers: []
|
customMcpServers: []
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
effectiveBrowserUsageLease?.release()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
grantCustomMcp(
|
grantCustomMcp(
|
||||||
@@ -517,14 +580,27 @@ export class KnowledgeMcpGateway {
|
|||||||
const abort = (): void => {
|
const abort = (): void => {
|
||||||
this.revoke(token)
|
this.revoke(token)
|
||||||
}
|
}
|
||||||
|
const abortFromBrowser = (): void => {
|
||||||
|
this.revoke(token)
|
||||||
|
}
|
||||||
value.signal.addEventListener('abort', abort, { once: true })
|
value.signal.addEventListener('abort', abort, { once: true })
|
||||||
|
value.browserUsageLease?.signal.addEventListener(
|
||||||
|
'abort',
|
||||||
|
abortFromBrowser,
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
this.capabilities.set(token, {
|
this.capabilities.set(token, {
|
||||||
...value,
|
...value,
|
||||||
expiresAt: this.now() + this.capabilityTtlMs,
|
expiresAt: this.now() + this.capabilityTtlMs,
|
||||||
brokerController,
|
brokerController,
|
||||||
references: new Map(),
|
references: new Map(),
|
||||||
removeAbortListener: () =>
|
removeAbortListener: () => {
|
||||||
value.signal.removeEventListener('abort', abort)
|
value.signal.removeEventListener('abort', abort)
|
||||||
|
value.browserUsageLease?.signal.removeEventListener(
|
||||||
|
'abort',
|
||||||
|
abortFromBrowser
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
@@ -542,6 +618,7 @@ export class KnowledgeMcpGateway {
|
|||||||
capability.brokerController.abort(
|
capability.brokerController.abort(
|
||||||
new Error('MCP capability was revoked')
|
new Error('MCP capability was revoked')
|
||||||
)
|
)
|
||||||
|
capability.browserUsageLease?.release()
|
||||||
for (const session of this.downstreamMcpSessions.values()) {
|
for (const session of this.downstreamMcpSessions.values()) {
|
||||||
if (session.token === token) {
|
if (session.token === token) {
|
||||||
void this.closeDownstreamMcpSession(session)
|
void this.closeDownstreamMcpSession(session)
|
||||||
@@ -724,7 +801,7 @@ export class KnowledgeMcpGateway {
|
|||||||
...(capability.configAccess === 'write'
|
...(capability.configAccess === 'write'
|
||||||
? goodbuddyConfigWriteToolNames
|
? goodbuddyConfigWriteToolNames
|
||||||
: []),
|
: []),
|
||||||
...(capability.browserConversationId
|
...(capability.browserConversationId && capability.browserTabId
|
||||||
? browserToolNames
|
? browserToolNames
|
||||||
: [])
|
: [])
|
||||||
]
|
]
|
||||||
@@ -1518,10 +1595,11 @@ export class KnowledgeMcpGateway {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
const capability = this.getCapability(token)
|
const capability = this.getCapability(token)
|
||||||
const browserTools = capability.browserConversationId
|
const browserTools = capability.browserConversationId && capability.browserTabId
|
||||||
? new BrowserModelTools({
|
? new BrowserModelTools({
|
||||||
service: this.browserService!,
|
service: this.browserService!,
|
||||||
conversationId: capability.browserConversationId
|
conversationId: capability.browserConversationId,
|
||||||
|
browserTabId: capability.browserTabId
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
const browserDefinitions = browserTools
|
const browserDefinitions = browserTools
|
||||||
@@ -1579,10 +1657,11 @@ export class KnowledgeMcpGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const capability = this.getCapability(token)
|
const capability = this.getCapability(token)
|
||||||
const browserTools = capability.browserConversationId
|
const browserTools = capability.browserConversationId && capability.browserTabId
|
||||||
? new BrowserModelTools({
|
? new BrowserModelTools({
|
||||||
service: this.browserService!,
|
service: this.browserService!,
|
||||||
conversationId: capability.browserConversationId
|
conversationId: capability.browserConversationId,
|
||||||
|
browserTabId: capability.browserTabId
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
if (browserTools?.ownsTool(name)) {
|
if (browserTools?.ownsTool(name)) {
|
||||||
|
|||||||
@@ -18,6 +18,40 @@ const toolPng = Buffer.from([
|
|||||||
0x0d, 0x0a, 0x1a, 0x0a
|
0x0d, 0x0a, 0x1a, 0x0a
|
||||||
]).toString('base64')
|
]).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 {
|
function createTextToolResult(text: string): ModelToolResult {
|
||||||
return {
|
return {
|
||||||
parts: [{ type: 'text', text }],
|
parts: [{ type: 'text', text }],
|
||||||
|
|||||||
@@ -2625,24 +2625,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
conversationId: string,
|
conversationId: string,
|
||||||
messages: ConversationMessage[]
|
messages: ConversationMessage[]
|
||||||
): void {
|
): 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.delete(conversationId)
|
||||||
this.conversations.set(conversationId, retained)
|
this.conversations.set(conversationId, [...messages])
|
||||||
while (this.conversations.size > 50) {
|
while (this.conversations.size > 50) {
|
||||||
const oldest = this.conversations.keys().next().value
|
const oldest = this.conversations.keys().next().value
|
||||||
if (oldest) {
|
if (oldest) {
|
||||||
@@ -2658,9 +2642,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
request.history && request.history.length > 0
|
request.history && request.history.length > 0
|
||||||
? request.history
|
? request.history
|
||||||
: this.conversations.get(request.conversationId) ?? []
|
: this.conversations.get(request.conversationId) ?? []
|
||||||
return this.options.contextCompression?.settings.enabled
|
return history
|
||||||
? history
|
|
||||||
: history.slice(-20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async *runImageGeneration(
|
private async *runImageGeneration(
|
||||||
@@ -3381,6 +3363,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
}
|
}
|
||||||
const toolContext: ModelToolCallContext = {
|
const toolContext: ModelToolCallContext = {
|
||||||
conversationId: request.conversationId,
|
conversationId: request.conversationId,
|
||||||
|
browserTabId: request.browserTabId,
|
||||||
workMode: request.workMode ?? 'ask',
|
workMode: request.workMode ?? 'ask',
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
runtimeTarget: 'model',
|
runtimeTarget: 'model',
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
} from 'node:fs/promises'
|
} from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { rgPath } from '@vscode/ripgrep'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||||
|
import { browserTabIdSchema } from '../../shared/contracts'
|
||||||
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
||||||
import {
|
import {
|
||||||
LocalWorkspaceAccess,
|
LocalWorkspaceAccess,
|
||||||
@@ -64,8 +66,15 @@ const png = Buffer.from([
|
|||||||
0x0d, 0x0a, 0x1a, 0x0a
|
0x0d, 0x0a, 0x1a, 0x0a
|
||||||
]).toString('base64')
|
]).toString('base64')
|
||||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]).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 = {
|
const toolContext = {
|
||||||
conversationId: 'provider-test-conversation',
|
conversationId: 'provider-test-conversation',
|
||||||
|
browserTabId: firstBrowserTabId,
|
||||||
workMode: 'execute'
|
workMode: 'execute'
|
||||||
} satisfies ModelToolCallContext
|
} satisfies ModelToolCallContext
|
||||||
|
|
||||||
@@ -177,18 +186,159 @@ describe('ModelToolProvider', () => {
|
|||||||
await provider.dispose()
|
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()
|
const workspace = await createWorkspace()
|
||||||
await mkdir(join(workspace, 'docs'))
|
await mkdir(join(workspace, 'docs'))
|
||||||
await writeFile(join(workspace, 'docs', 'note.txt'), 'hello', 'utf8')
|
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
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
await expect(provider.listTools(toolContext, signal)).resolves.toEqual(
|
await expect(provider.listTools(toolContext, signal)).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'workspace_rg' }),
|
||||||
expect.objectContaining({ name: 'workspace_read_text' }),
|
expect.objectContaining({ name: 'workspace_read_text' }),
|
||||||
expect.objectContaining({ name: 'workspace_list_directory' }),
|
expect.objectContaining({ name: 'workspace_apply_patch' })
|
||||||
expect.objectContaining({ name: 'workspace_write_text' })
|
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
@@ -198,32 +348,50 @@ describe('ModelToolProvider', () => {
|
|||||||
signal,
|
signal,
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
).resolves.toEqual({
|
).resolves.toMatchObject({
|
||||||
parts: [{ type: 'text', text: 'hello' }],
|
parts: [{ type: 'text', text: expect.stringContaining('1: hello') }]
|
||||||
contextBytes: 5
|
|
||||||
})
|
})
|
||||||
const listing = await provider.callTool(
|
const listing = await provider.callTool(
|
||||||
'workspace_list_directory',
|
'workspace_rg',
|
||||||
{ path: 'docs' },
|
{ path: 'docs', filesOnly: true },
|
||||||
signal,
|
signal,
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
expect(listing.parts).toEqual([
|
expect(listing.parts).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'text',
|
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(
|
const written = await provider.callTool(
|
||||||
'workspace_write_text',
|
'workspace_apply_patch',
|
||||||
{ path: 'docs/output.txt', content: 'saved' },
|
{
|
||||||
|
patch: [
|
||||||
|
'*** Begin Patch',
|
||||||
|
'*** Add File: docs/output.txt',
|
||||||
|
'+saved',
|
||||||
|
'*** End Patch'
|
||||||
|
].join('\n')
|
||||||
|
},
|
||||||
signal,
|
signal,
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
expect(written.parts).toEqual([
|
expect(written.parts).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: expect.stringContaining('"bytesWritten":5')
|
text: expect.stringContaining('docs/output.txt')
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
await expect(
|
await expect(
|
||||||
@@ -231,12 +399,15 @@ describe('ModelToolProvider', () => {
|
|||||||
).resolves.toBe('saved')
|
).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 () => ({
|
const readText = vi.fn(async () => ({
|
||||||
path: 'remote.txt',
|
path: 'remote.txt',
|
||||||
name: 'remote.txt',
|
name: 'remote.txt',
|
||||||
content: 'remote content',
|
content: 'remote content',
|
||||||
size: 14
|
size: 14,
|
||||||
|
offsetBytes: 0,
|
||||||
|
bytesRead: 14,
|
||||||
|
truncated: false
|
||||||
}))
|
}))
|
||||||
const listDirectory = vi.fn(async () => ({
|
const listDirectory = vi.fn(async () => ({
|
||||||
path: '',
|
path: '',
|
||||||
@@ -270,43 +441,19 @@ describe('ModelToolProvider', () => {
|
|||||||
signal,
|
signal,
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
).resolves.toEqual({
|
).resolves.toMatchObject({
|
||||||
parts: [{ type: 'text', text: 'remote content' }],
|
parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }]
|
||||||
contextBytes: 14
|
|
||||||
})
|
})
|
||||||
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(readText).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
path: 'remote.txt',
|
path: 'remote.txt',
|
||||||
maximumBytes: 256 * 1024,
|
maximumBytes: 128 * 1024,
|
||||||
|
allowTruncated: true,
|
||||||
signal
|
signal
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
expect(listDirectory).toHaveBeenCalledWith(
|
expect(listDirectory).not.toHaveBeenCalled()
|
||||||
expect.objectContaining({
|
expect(writeTextAtomic).not.toHaveBeenCalled()
|
||||||
path: '.',
|
|
||||||
maximumEntries: 200,
|
|
||||||
includeOther: true,
|
|
||||||
signal
|
|
||||||
})
|
|
||||||
)
|
|
||||||
expect(writeTextAtomic).toHaveBeenCalledWith({
|
|
||||||
path: 'output.txt',
|
|
||||||
content: 'saved',
|
|
||||||
maximumBytes: 512 * 1024,
|
|
||||||
signal
|
|
||||||
})
|
|
||||||
await provider.dispose()
|
await provider.dispose()
|
||||||
expect(dispose).toHaveBeenCalledOnce()
|
expect(dispose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
@@ -361,8 +508,8 @@ describe('ModelToolProvider', () => {
|
|||||||
|
|
||||||
for (const [name, argumentsValue] of [
|
for (const [name, argumentsValue] of [
|
||||||
[
|
[
|
||||||
'workspace_write_text',
|
'workspace_apply_patch',
|
||||||
{ path: 'output.txt', content: 'blocked' }
|
{ patch: '*** Begin Patch\n*** End Patch' }
|
||||||
],
|
],
|
||||||
['browser_click', { ref: 'b_target' }],
|
['browser_click', { ref: 'b_target' }],
|
||||||
['note_create', { title: 'blocked' }],
|
['note_create', { title: 'blocked' }],
|
||||||
@@ -389,8 +536,8 @@ describe('ModelToolProvider', () => {
|
|||||||
expect(mocks.Client).not.toHaveBeenCalled()
|
expect(mocks.Client).not.toHaveBeenCalled()
|
||||||
|
|
||||||
const writeTool = {
|
const writeTool = {
|
||||||
name: 'workspace_write_text',
|
name: 'workspace_apply_patch',
|
||||||
displayName: '写入工作区文本',
|
displayName: '应用工作区补丁',
|
||||||
description: 'write',
|
description: 'write',
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
source: 'builtin'
|
source: 'builtin'
|
||||||
@@ -398,7 +545,7 @@ describe('ModelToolProvider', () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
provider.getApproval(
|
provider.getApproval(
|
||||||
writeTool,
|
writeTool,
|
||||||
{ path: 'output.txt', content: 'blocked' },
|
{ patch: '*** Begin Patch\n*** End Patch' },
|
||||||
'output.txt',
|
'output.txt',
|
||||||
askContext
|
askContext
|
||||||
)
|
)
|
||||||
@@ -425,20 +572,10 @@ describe('ModelToolProvider', () => {
|
|||||||
askContext
|
askContext
|
||||||
)
|
)
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
parts: [{ type: 'text', text: 'remote content' }]
|
parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }]
|
||||||
})
|
|
||||||
await expect(
|
|
||||||
provider.callTool(
|
|
||||||
'workspace_list_directory',
|
|
||||||
{},
|
|
||||||
signal,
|
|
||||||
askContext
|
|
||||||
)
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
parts: [{ type: 'text', text: expect.any(String) }]
|
|
||||||
})
|
})
|
||||||
expect(readText).toHaveBeenCalledOnce()
|
expect(readText).toHaveBeenCalledOnce()
|
||||||
expect(listDirectory).toHaveBeenCalledOnce()
|
expect(listDirectory).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => {
|
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)
|
const askTools = await provider.listTools(askContext, signal)
|
||||||
expect(askTools.map((tool) => tool.name)).toEqual([
|
expect(askTools.map((tool) => tool.name)).toEqual([
|
||||||
|
'workspace_read_text',
|
||||||
'knowledge_list',
|
'knowledge_list',
|
||||||
'knowledge_search',
|
'knowledge_search',
|
||||||
'note_list',
|
'note_list',
|
||||||
@@ -578,7 +716,11 @@ describe('ModelToolProvider', () => {
|
|||||||
},
|
},
|
||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
).resolves.toEqual([])
|
).resolves.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'workspace_read_text' })
|
||||||
|
])
|
||||||
|
)
|
||||||
const executeTools = await provider.listTools(
|
const executeTools = await provider.listTools(
|
||||||
{ ...askContext, workMode: 'execute' },
|
{ ...askContext, workMode: 'execute' },
|
||||||
signal
|
signal
|
||||||
@@ -586,8 +728,7 @@ describe('ModelToolProvider', () => {
|
|||||||
expect(executeTools.map((tool) => tool.name)).toEqual(
|
expect(executeTools.map((tool) => tool.name)).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'workspace_read_text',
|
'workspace_read_text',
|
||||||
'workspace_list_directory',
|
'workspace_apply_patch',
|
||||||
'workspace_write_text',
|
|
||||||
'knowledge_list',
|
'knowledge_list',
|
||||||
'knowledge_search',
|
'knowledge_search',
|
||||||
'note_search',
|
'note_search',
|
||||||
@@ -705,7 +846,9 @@ describe('ModelToolProvider', () => {
|
|||||||
workspace,
|
workspace,
|
||||||
[createMcpServer()],
|
[createMcpServer()],
|
||||||
undefined,
|
undefined,
|
||||||
gateway
|
gateway,
|
||||||
|
false,
|
||||||
|
{ ripgrepExecutablePath: rgPath }
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
validProvider.listTools(context, new AbortController().signal)
|
validProvider.listTools(context, new AbortController().signal)
|
||||||
@@ -719,14 +862,16 @@ describe('ModelToolProvider', () => {
|
|||||||
workspace,
|
workspace,
|
||||||
[createMcpServer()],
|
[createMcpServer()],
|
||||||
undefined,
|
undefined,
|
||||||
gateway
|
gateway,
|
||||||
|
false,
|
||||||
|
{ ripgrepExecutablePath: rgPath }
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
overflowingProvider.listTools(
|
overflowingProvider.listTools(
|
||||||
context,
|
context,
|
||||||
new AbortController().signal
|
new AbortController().signal
|
||||||
)
|
)
|
||||||
).rejects.toThrow('无法加载 MCP Server')
|
).resolves.toHaveLength(18)
|
||||||
await overflowingProvider.dispose()
|
await overflowingProvider.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -799,6 +944,7 @@ describe('ModelToolProvider', () => {
|
|||||||
stdoutTruncated: false,
|
stdoutTruncated: false,
|
||||||
stderrTruncated: false
|
stderrTruncated: false
|
||||||
})),
|
})),
|
||||||
|
readOutput: vi.fn(),
|
||||||
releaseConversation: vi.fn(async () => undefined),
|
releaseConversation: vi.fn(async () => undefined),
|
||||||
dispose: vi.fn(async () => undefined)
|
dispose: vi.fn(async () => undefined)
|
||||||
} satisfies DirectModelProcessService
|
} satisfies DirectModelProcessService
|
||||||
@@ -994,10 +1140,12 @@ describe('ModelToolProvider', () => {
|
|||||||
const provider = new ModelToolProvider(workspace, [], browserService)
|
const provider = new ModelToolProvider(workspace, [], browserService)
|
||||||
const firstContext = {
|
const firstContext = {
|
||||||
conversationId: 'browser-conversation-one',
|
conversationId: 'browser-conversation-one',
|
||||||
|
browserTabId: firstBrowserTabId,
|
||||||
workMode: 'execute'
|
workMode: 'execute'
|
||||||
} satisfies ModelToolCallContext
|
} satisfies ModelToolCallContext
|
||||||
const secondContext = {
|
const secondContext = {
|
||||||
conversationId: 'browser-conversation-two',
|
conversationId: 'browser-conversation-two',
|
||||||
|
browserTabId: secondBrowserTabId,
|
||||||
workMode: 'execute'
|
workMode: 'execute'
|
||||||
} satisfies ModelToolCallContext
|
} satisfies ModelToolCallContext
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
@@ -1051,12 +1199,14 @@ describe('ModelToolProvider', () => {
|
|||||||
expect(browserService.screenshot).toHaveBeenNthCalledWith(
|
expect(browserService.screenshot).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
firstContext.conversationId,
|
firstContext.conversationId,
|
||||||
signal
|
signal,
|
||||||
|
firstBrowserTabId
|
||||||
)
|
)
|
||||||
expect(browserService.screenshot).toHaveBeenNthCalledWith(
|
expect(browserService.screenshot).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
secondContext.conversationId,
|
secondContext.conversationId,
|
||||||
signal
|
signal,
|
||||||
|
secondBrowserTabId
|
||||||
)
|
)
|
||||||
|
|
||||||
await provider.releaseConversation(firstContext.conversationId)
|
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 () => {
|
it('exposes only allowlisted read-only Exa tools in Ask and Execute', async () => {
|
||||||
const workspace = await createWorkspace()
|
const workspace = await createWorkspace()
|
||||||
mocks.client.listTools.mockResolvedValue({
|
mocks.client.listTools.mockResolvedValue({
|
||||||
@@ -1130,7 +1337,8 @@ describe('ModelToolProvider', () => {
|
|||||||
workMode: 'ask'
|
workMode: 'ask'
|
||||||
} satisfies ModelToolCallContext
|
} satisfies ModelToolCallContext
|
||||||
|
|
||||||
await expect(provider.listTools(askContext, signal)).resolves.toEqual([
|
await expect(provider.listTools(askContext, signal)).resolves.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'web_search',
|
name: 'web_search',
|
||||||
displayName: '联网搜索',
|
displayName: '联网搜索',
|
||||||
@@ -1142,6 +1350,7 @@ describe('ModelToolProvider', () => {
|
|||||||
source: 'builtin'
|
source: 'builtin'
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
)
|
||||||
await provider.callTool(
|
await provider.callTool(
|
||||||
'web_search',
|
'web_search',
|
||||||
{ query: 'GoodBuddy current release', numResults: 3 },
|
{ query: 'GoodBuddy current release', numResults: 3 },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
} from './runtime'
|
} from './runtime'
|
||||||
import {
|
import {
|
||||||
BrowserModelTools,
|
BrowserModelTools,
|
||||||
|
browserToolNames,
|
||||||
type BrowserToolService
|
type BrowserToolService
|
||||||
} from '../browser/browser-model-tools'
|
} from '../browser/browser-model-tools'
|
||||||
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
||||||
@@ -45,12 +46,12 @@ import {
|
|||||||
directModelSubagentInputSchema
|
directModelSubagentInputSchema
|
||||||
} from '../assistant/direct-model-subagent-service'
|
} from '../assistant/direct-model-subagent-service'
|
||||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
|
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
|
||||||
|
import type { BrowserTabId } from '../../shared/contracts'
|
||||||
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
|
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
|
||||||
import { applyWorkspacePatch } from './workspace-apply-patch'
|
import { applyWorkspacePatch } from './workspace-apply-patch'
|
||||||
import { readWorkspaceLines } from './workspace-read-lines'
|
import { readWorkspaceLines } from './workspace-read-lines'
|
||||||
|
|
||||||
const MAX_MODEL_TOOLS = 100
|
const MAX_MODEL_TOOLS = 100
|
||||||
const MAX_MCP_SERVERS = 16
|
|
||||||
const MAX_TOOL_RESULT_BYTES = 256 * 1024
|
const MAX_TOOL_RESULT_BYTES = 256 * 1024
|
||||||
const MCP_TIMEOUT_MS = 30_000
|
const MCP_TIMEOUT_MS = 30_000
|
||||||
const MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
|
const MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
|
||||||
@@ -269,6 +270,7 @@ export type ModelToolResult = {
|
|||||||
|
|
||||||
export type ModelToolCallContext = {
|
export type ModelToolCallContext = {
|
||||||
conversationId: string
|
conversationId: string
|
||||||
|
browserTabId?: BrowserTabId
|
||||||
workMode: 'ask' | 'execute'
|
workMode: 'ask' | 'execute'
|
||||||
requestId?: string
|
requestId?: string
|
||||||
runtimeTarget?: 'model'
|
runtimeTarget?: 'model'
|
||||||
@@ -635,7 +637,10 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ModelToolProvider implements ModelToolProviderLike {
|
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 webSearchBindings?: Promise<Map<string, McpToolBinding>>
|
||||||
private readonly clients = new Set<Client>()
|
private readonly clients = new Set<Client>()
|
||||||
private readonly customMcpClients = new Set<Client>()
|
private readonly customMcpClients = new Set<Client>()
|
||||||
@@ -708,10 +713,13 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
private getBrowserTools(
|
private getBrowserTools(
|
||||||
context: ModelToolCallContext
|
context: ModelToolCallContext
|
||||||
): BrowserModelTools | undefined {
|
): BrowserModelTools | undefined {
|
||||||
return this.browserService && context.workMode === 'execute'
|
return this.browserService &&
|
||||||
|
context.workMode === 'execute' &&
|
||||||
|
context.browserTabId
|
||||||
? new BrowserModelTools({
|
? new BrowserModelTools({
|
||||||
service: this.browserService,
|
service: this.browserService,
|
||||||
conversationId: context.conversationId
|
conversationId: context.conversationId,
|
||||||
|
browserTabId: context.browserTabId
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -719,7 +727,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
private getReservedToolCount(): number {
|
private getReservedToolCount(): number {
|
||||||
return (
|
return (
|
||||||
this.getBuiltinTools().length +
|
this.getBuiltinTools().length +
|
||||||
(this.browserService ? 7 : 0) +
|
(this.browserService ? browserToolNames.length : 0) +
|
||||||
(this.webSearchEnabled ? 2 : 0) +
|
(this.webSearchEnabled ? 2 : 0) +
|
||||||
(this.programming.processService ? 1 : 0) +
|
(this.programming.processService ? 1 : 0) +
|
||||||
(this.programming.subagentService ? 1 : 0) +
|
(this.programming.subagentService ? 1 : 0) +
|
||||||
@@ -1077,23 +1085,27 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
refreshDynamic = false
|
refreshDynamic = false
|
||||||
): Promise<Map<string, McpToolBinding>> {
|
): Promise<Map<string, McpToolBinding>> {
|
||||||
if (this.mcpServers.length > MAX_MCP_SERVERS) {
|
signal.throwIfAborted()
|
||||||
throw new Error('直连模型最多可加载 16 个 MCP Server')
|
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)
|
||||||
}
|
}
|
||||||
this.mcpConnections ??= Promise.all(
|
return undefined
|
||||||
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
|
|
||||||
})
|
})
|
||||||
const connections = await this.mcpConnections
|
this.mcpConnections.set(server, pending)
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
})
|
||||||
|
)
|
||||||
|
signal.throwIfAborted()
|
||||||
|
const connections = loaded.filter(
|
||||||
|
(connection): connection is ConnectedMcp => connection !== undefined
|
||||||
|
)
|
||||||
if (refreshDynamic) {
|
if (refreshDynamic) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
connections.map(async (connection) => {
|
connections.map(async (connection) => {
|
||||||
@@ -1123,14 +1135,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
connection.dynamicToolsChanged = true
|
connection.dynamicToolsChanged = true
|
||||||
throw new Error(
|
connection.tools = []
|
||||||
`无法刷新 MCP Server「${connection.server.name}」的工具`,
|
if (!signal.aborted) {
|
||||||
{ cause: error }
|
console.warn(
|
||||||
|
`MCP refresh failed: ${connection.server.name}`,
|
||||||
|
error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
signal.throwIfAborted()
|
||||||
const bindings = new Map<string, McpToolBinding>()
|
const bindings = new Map<string, McpToolBinding>()
|
||||||
const reservedToolCount = this.getReservedToolCount()
|
const reservedToolCount = this.getReservedToolCount()
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
@@ -1782,7 +1798,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
this.clients.clear()
|
this.clients.clear()
|
||||||
this.customMcpClients.clear()
|
this.customMcpClients.clear()
|
||||||
this.webSearchClients.clear()
|
this.webSearchClients.clear()
|
||||||
this.mcpConnections = undefined
|
this.mcpConnections.clear()
|
||||||
this.webSearchBindings = undefined
|
this.webSearchBindings = undefined
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
...clients.map((client) => client.close()),
|
...clients.map((client) => client.close()),
|
||||||
@@ -1794,12 +1810,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
|||||||
|
|
||||||
async releaseConversation(conversationId: string): Promise<void> {
|
async releaseConversation(conversationId: string): Promise<void> {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
this.browserService
|
this.browserService?.releaseConversation(conversationId),
|
||||||
? new BrowserModelTools({
|
|
||||||
service: this.browserService,
|
|
||||||
conversationId
|
|
||||||
}).release()
|
|
||||||
: undefined,
|
|
||||||
this.programming.processService?.releaseConversation(
|
this.programming.processService?.releaseConversation(
|
||||||
conversationId
|
conversationId
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}-`))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,7 +229,7 @@ describe('runtime selection', () => {
|
|||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
profileId: secondProfileId
|
profileId: defaultProfileId
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function resolveConfiguredAgentRuntimeSelection(
|
|||||||
settings: ResolvedRuntimeSettings,
|
settings: ResolvedRuntimeSettings,
|
||||||
selection: AgentRuntimeSelection
|
selection: AgentRuntimeSelection
|
||||||
): AgentRuntimeSelection {
|
): AgentRuntimeSelection {
|
||||||
|
if ('profileId' in selection && selection.profileId) return selection
|
||||||
if (
|
if (
|
||||||
selection.provider !== 'opencode' &&
|
selection.provider !== 'opencode' &&
|
||||||
selection.provider !== 'continue' &&
|
selection.provider !== 'continue' &&
|
||||||
@@ -87,7 +88,7 @@ export function applyRuntimeSelection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selection.provider === 'model') {
|
if (selection.provider === 'model') {
|
||||||
const profile = requireProfile(settings, selection.profileId)
|
const profile = requireProfile(settings, selection.profileId ?? settings.defaultModelProfileId)
|
||||||
return {
|
return {
|
||||||
target: 'model',
|
target: 'model',
|
||||||
settings: {
|
settings: {
|
||||||
@@ -108,7 +109,11 @@ export function applyRuntimeSelection(
|
|||||||
|
|
||||||
const profile = selection.profileId
|
const profile = selection.profileId
|
||||||
? requireProfile(settings, selection.profileId)
|
? requireProfile(settings, selection.profileId)
|
||||||
: undefined
|
: selection.provider === 'opencode'
|
||||||
|
? settings.opencodeModelProfile
|
||||||
|
: selection.provider === 'continue'
|
||||||
|
? settings.continueModelProfile
|
||||||
|
: settings.deepseekHarnessModelProfile
|
||||||
if (selection.provider === 'opencode') {
|
if (selection.provider === 'opencode') {
|
||||||
if (profile && !isAgentRuntimeModelProtocol(profile.protocol)) {
|
if (profile && !isAgentRuntimeModelProtocol(profile.protocol)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AgentQuestionAnswer,
|
AgentQuestionAnswer,
|
||||||
AgentRequest,
|
AgentRequest,
|
||||||
AgentRuntimeStatus,
|
AgentRuntimeStatus,
|
||||||
|
BrowserTabId,
|
||||||
RuntimeConversationCompactInput,
|
RuntimeConversationCompactInput,
|
||||||
RuntimeConversationCompactResult,
|
RuntimeConversationCompactResult,
|
||||||
RuntimeNativeSnapshot
|
RuntimeNativeSnapshot
|
||||||
@@ -137,6 +138,8 @@ export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
|
|||||||
trustedInstructions?: string
|
trustedInstructions?: string
|
||||||
/** Main-process-only request-scoped authorization for built-in data tools. */
|
/** Main-process-only request-scoped authorization for built-in data tools. */
|
||||||
knowledgeCapabilityToken?: string
|
knowledgeCapabilityToken?: string
|
||||||
|
/** Main-process-only browser tab fixed for this request. */
|
||||||
|
browserTabId?: BrowserTabId
|
||||||
/**
|
/**
|
||||||
* Main-process recovery cursor for an Agent-owned semantic prompt.
|
* Main-process recovery cursor for an Agent-owned semantic prompt.
|
||||||
* It is never sent as prompt content and is ignored by local runtimes.
|
* It is never sent as prompt content and is ignored by local runtimes.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { browserTabIdSchema } from '../../shared/contracts'
|
||||||
import {
|
import {
|
||||||
BrowserModelTools,
|
BrowserModelTools,
|
||||||
browserBackInputSchema,
|
browserBackInputSchema,
|
||||||
@@ -42,12 +43,16 @@ function createService(): BrowserToolService {
|
|||||||
|
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
const ref = 'b_abcdefghijklmnop'
|
const ref = 'b_abcdefghijklmnop'
|
||||||
|
const browserTabId = browserTabIdSchema.parse(
|
||||||
|
'00000000-0000-4000-8000-000000000101'
|
||||||
|
)
|
||||||
|
|
||||||
describe('BrowserModelTools', () => {
|
describe('BrowserModelTools', () => {
|
||||||
it('publishes seven strict, bounded builtin tool definitions', () => {
|
it('publishes seven strict, bounded builtin tool definitions', () => {
|
||||||
const tools = new BrowserModelTools({
|
const tools = new BrowserModelTools({
|
||||||
service: createService(),
|
service: createService(),
|
||||||
conversationId: 'conversation'
|
conversationId: 'conversation',
|
||||||
|
browserTabId
|
||||||
})
|
})
|
||||||
const definitions = tools.listTools()
|
const definitions = tools.listTools()
|
||||||
expect(definitions.map((definition) => definition.name)).toEqual([
|
expect(definitions.map((definition) => definition.name)).toEqual([
|
||||||
@@ -93,7 +98,8 @@ describe('BrowserModelTools', () => {
|
|||||||
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
|
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
|
||||||
const tools = new BrowserModelTools({
|
const tools = new BrowserModelTools({
|
||||||
service: createService(),
|
service: createService(),
|
||||||
conversationId: 'conversation'
|
conversationId: 'conversation',
|
||||||
|
browserTabId
|
||||||
})
|
})
|
||||||
const approval = tools.getApproval('browser_navigate', {
|
const approval = tools.getApproval('browser_navigate', {
|
||||||
url: 'https://example.com/path?token=top-secret'
|
url: 'https://example.com/path?token=top-secret'
|
||||||
@@ -115,7 +121,8 @@ describe('BrowserModelTools', () => {
|
|||||||
const service = createService()
|
const service = createService()
|
||||||
const tools = new BrowserModelTools({
|
const tools = new BrowserModelTools({
|
||||||
service,
|
service,
|
||||||
conversationId: 'conversation'
|
conversationId: 'conversation',
|
||||||
|
browserTabId
|
||||||
})
|
})
|
||||||
const first = tools.getApproval('browser_type', {
|
const first = tools.getApproval('browser_type', {
|
||||||
ref,
|
ref,
|
||||||
@@ -139,7 +146,8 @@ describe('BrowserModelTools', () => {
|
|||||||
'conversation',
|
'conversation',
|
||||||
ref,
|
ref,
|
||||||
'top-secret',
|
'top-secret',
|
||||||
signal
|
signal,
|
||||||
|
browserTabId
|
||||||
)
|
)
|
||||||
expect(JSON.stringify(result)).not.toContain('top-secret')
|
expect(JSON.stringify(result)).not.toContain('top-secret')
|
||||||
expect(result.parts[0]).toMatchObject({
|
expect(result.parts[0]).toMatchObject({
|
||||||
@@ -158,7 +166,8 @@ describe('BrowserModelTools', () => {
|
|||||||
const service = createService()
|
const service = createService()
|
||||||
const tools = new BrowserModelTools({
|
const tools = new BrowserModelTools({
|
||||||
service,
|
service,
|
||||||
conversationId: 'conversation'
|
conversationId: 'conversation',
|
||||||
|
browserTabId
|
||||||
})
|
})
|
||||||
const navigate = await tools.callTool(
|
const navigate = await tools.callTool(
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
@@ -189,12 +198,40 @@ describe('BrowserModelTools', () => {
|
|||||||
})
|
})
|
||||||
await tools.release()
|
await tools.release()
|
||||||
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
|
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 () => {
|
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
|
||||||
const tools = new BrowserModelTools({
|
const tools = new BrowserModelTools({
|
||||||
service: createService(),
|
service: createService(),
|
||||||
conversationId: 'conversation'
|
conversationId: 'conversation',
|
||||||
|
browserTabId
|
||||||
})
|
})
|
||||||
expect(() =>
|
expect(() =>
|
||||||
tools.getApproval('browser_click', { ref, extra: true })
|
tools.getApproval('browser_click', { ref, extra: true })
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
|
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
|
||||||
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
|
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
|
||||||
} from './browser-limits'
|
} from './browser-limits'
|
||||||
|
import type { BrowserTabId } from '../../shared/contracts'
|
||||||
|
|
||||||
const MAX_REF_LENGTH = 64
|
const MAX_REF_LENGTH = 64
|
||||||
|
|
||||||
@@ -195,6 +196,7 @@ export type BrowserToolService = Pick<
|
|||||||
export type BrowserModelToolsOptions = {
|
export type BrowserModelToolsOptions = {
|
||||||
service: BrowserToolService
|
service: BrowserToolService
|
||||||
conversationId: string
|
conversationId: string
|
||||||
|
browserTabId: BrowserTabId
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTextResult(value: unknown): ModelToolResult {
|
function createTextResult(value: unknown): ModelToolResult {
|
||||||
@@ -218,10 +220,12 @@ function navigationLabel(url: URL): string {
|
|||||||
export class BrowserModelTools {
|
export class BrowserModelTools {
|
||||||
private readonly service: BrowserToolService
|
private readonly service: BrowserToolService
|
||||||
private readonly conversationId: string
|
private readonly conversationId: string
|
||||||
|
private readonly browserTabId: BrowserTabId
|
||||||
|
|
||||||
constructor(options: BrowserModelToolsOptions) {
|
constructor(options: BrowserModelToolsOptions) {
|
||||||
this.service = options.service
|
this.service = options.service
|
||||||
this.conversationId = options.conversationId
|
this.conversationId = options.conversationId
|
||||||
|
this.browserTabId = options.browserTabId
|
||||||
if (!this.conversationId || this.conversationId.length > 500) {
|
if (!this.conversationId || this.conversationId.length > 500) {
|
||||||
throw new Error('浏览器对话标识无效')
|
throw new Error('浏览器对话标识无效')
|
||||||
}
|
}
|
||||||
@@ -249,7 +253,7 @@ export class BrowserModelTools {
|
|||||||
throw new Error(`未知浏览器工具:${name}`)
|
throw new Error(`未知浏览器工具:${name}`)
|
||||||
}
|
}
|
||||||
const currentOrigin = safeOrigin(
|
const currentOrigin = safeOrigin(
|
||||||
this.service.getOrigin(this.conversationId)
|
this.service.getOrigin(this.conversationId, this.browserTabId)
|
||||||
)
|
)
|
||||||
let description: string
|
let description: string
|
||||||
let argumentSummary: string
|
let argumentSummary: string
|
||||||
@@ -320,18 +324,32 @@ export class BrowserModelTools {
|
|||||||
if (name === 'browser_navigate') {
|
if (name === 'browser_navigate') {
|
||||||
const input = browserNavigateInputSchema.parse(argumentsValue)
|
const input = browserNavigateInputSchema.parse(argumentsValue)
|
||||||
return createTextResult(
|
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') {
|
if (name === 'browser_snapshot') {
|
||||||
browserSnapshotInputSchema.parse(argumentsValue)
|
browserSnapshotInputSchema.parse(argumentsValue)
|
||||||
return createTextResult(
|
return createTextResult(
|
||||||
await this.service.snapshot(this.conversationId, signal)
|
await this.service.snapshot(
|
||||||
|
this.conversationId,
|
||||||
|
signal,
|
||||||
|
this.browserTabId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (name === 'browser_click') {
|
if (name === 'browser_click') {
|
||||||
const input = browserClickInputSchema.parse(argumentsValue)
|
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 })
|
return createTextResult({ clicked: input.ref })
|
||||||
}
|
}
|
||||||
if (name === 'browser_type') {
|
if (name === 'browser_type') {
|
||||||
@@ -340,7 +358,8 @@ export class BrowserModelTools {
|
|||||||
this.conversationId,
|
this.conversationId,
|
||||||
input.ref,
|
input.ref,
|
||||||
input.text,
|
input.text,
|
||||||
signal
|
signal,
|
||||||
|
this.browserTabId
|
||||||
)
|
)
|
||||||
return createTextResult({
|
return createTextResult({
|
||||||
typed: input.ref,
|
typed: input.ref,
|
||||||
@@ -354,20 +373,26 @@ export class BrowserModelTools {
|
|||||||
this.conversationId,
|
this.conversationId,
|
||||||
input.ref,
|
input.ref,
|
||||||
input.value,
|
input.value,
|
||||||
signal
|
signal,
|
||||||
|
this.browserTabId
|
||||||
)
|
)
|
||||||
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
|
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
|
||||||
}
|
}
|
||||||
if (name === 'browser_back') {
|
if (name === 'browser_back') {
|
||||||
browserBackInputSchema.parse(argumentsValue)
|
browserBackInputSchema.parse(argumentsValue)
|
||||||
return createTextResult(
|
return createTextResult(
|
||||||
await this.service.back(this.conversationId, signal)
|
await this.service.back(
|
||||||
|
this.conversationId,
|
||||||
|
signal,
|
||||||
|
this.browserTabId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
browserScreenshotInputSchema.parse(argumentsValue)
|
browserScreenshotInputSchema.parse(argumentsValue)
|
||||||
const screenshot = await this.service.screenshot(
|
const screenshot = await this.service.screenshot(
|
||||||
this.conversationId,
|
this.conversationId,
|
||||||
signal
|
signal,
|
||||||
|
this.browserTabId
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
parts: [
|
parts: [
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function deferred<T>() {
|
|||||||
|
|
||||||
function createHarness(options: {
|
function createHarness(options: {
|
||||||
maximumSessions?: number
|
maximumSessions?: number
|
||||||
|
maximumTabsPerConversation?: number
|
||||||
|
maximumTabsPerWindow?: number
|
||||||
idleTimeoutMs?: number
|
idleTimeoutMs?: number
|
||||||
cleanupTimeoutMs?: number
|
cleanupTimeoutMs?: number
|
||||||
dispose?: () => Promise<void>
|
dispose?: () => Promise<void>
|
||||||
@@ -145,6 +147,8 @@ function createHarness(options: {
|
|||||||
const service = new BrowserService({
|
const service = new BrowserService({
|
||||||
policy: new BrowserUrlPolicy(dnsResolver),
|
policy: new BrowserUrlPolicy(dnsResolver),
|
||||||
maximumSessions: options.maximumSessions,
|
maximumSessions: options.maximumSessions,
|
||||||
|
maximumTabsPerConversation: options.maximumTabsPerConversation,
|
||||||
|
maximumTabsPerWindow: options.maximumTabsPerWindow,
|
||||||
idleTimeoutMs: options.idleTimeoutMs,
|
idleTimeoutMs: options.idleTimeoutMs,
|
||||||
cleanupTimeoutMs: options.cleanupTimeoutMs,
|
cleanupTimeoutMs: options.cleanupTimeoutMs,
|
||||||
liveFrameDelayMs: 0,
|
liveFrameDelayMs: 0,
|
||||||
@@ -165,12 +169,21 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('BrowserService', () => {
|
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 () => {
|
it('presents one session in the shared viewport and tracks user navigation', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
const bounds = { x: 900, y: 120, width: 420, height: 640 }
|
const bounds = { x: 900, y: 120, width: 420, height: 640 }
|
||||||
const states: BrowserLiveState[] = []
|
const states: BrowserLiveState[] = []
|
||||||
harness.service.onState((state) => states.push(state))
|
harness.service.onState((state) => states.push(state))
|
||||||
harness.service.setViewport('conversation', bounds)
|
harness.service.setViewport(
|
||||||
|
'conversation',
|
||||||
|
bounds,
|
||||||
|
undefined,
|
||||||
|
firstLeaseToken
|
||||||
|
)
|
||||||
|
|
||||||
await harness.service.navigate(
|
await harness.service.navigate(
|
||||||
'conversation',
|
'conversation',
|
||||||
@@ -196,7 +209,12 @@ describe('BrowserService', () => {
|
|||||||
'https://example.com'
|
'https://example.com'
|
||||||
)
|
)
|
||||||
|
|
||||||
harness.service.setViewport()
|
harness.service.setViewport(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
firstLeaseToken
|
||||||
|
)
|
||||||
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
|
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
|
||||||
await harness.service.dispose()
|
await harness.service.dispose()
|
||||||
})
|
})
|
||||||
@@ -222,6 +240,7 @@ describe('BrowserService', () => {
|
|||||||
|
|
||||||
expect(states.map((state) => state.status)).toEqual([
|
expect(states.map((state) => state.status)).toEqual([
|
||||||
'creating',
|
'creating',
|
||||||
|
'ready',
|
||||||
'loading',
|
'loading',
|
||||||
'ready',
|
'ready',
|
||||||
'acting',
|
'acting',
|
||||||
@@ -242,7 +261,7 @@ describe('BrowserService', () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
canGoBack: 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=='
|
'data:image/jpeg;base64,/9j/2Q=='
|
||||||
)
|
)
|
||||||
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
|
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
|
||||||
@@ -435,6 +454,253 @@ describe('BrowserService', () => {
|
|||||||
await harness.service.dispose()
|
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 () => {
|
it('enforces a hard maximum of three sessions', async () => {
|
||||||
const harness = createHarness({ maximumSessions: 3 })
|
const harness = createHarness({ maximumSessions: 3 })
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
@@ -625,6 +891,42 @@ describe('BrowserService', () => {
|
|||||||
await harness.service.dispose()
|
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 () => {
|
it('tracks an approved origin across validated back navigation', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
@@ -905,10 +1207,19 @@ describe('BrowserService', () => {
|
|||||||
'https://example.com/',
|
'https://example.com/',
|
||||||
new AbortController().signal
|
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(
|
await expect(
|
||||||
harness.service.releaseConversation('conversation')
|
harness.service.releaseConversation('conversation')
|
||||||
).rejects.toThrow('清理超时')
|
).rejects.toThrow('清理超时')
|
||||||
expect(harness.service.getSessionCount()).toBe(0)
|
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.releaseConversation('conversation')
|
||||||
await harness.service.dispose()
|
await harness.service.dispose()
|
||||||
await harness.service.dispose()
|
await harness.service.dispose()
|
||||||
|
|||||||
+913
-762
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,12 @@ import {
|
|||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
let resolve!: (value: T) => void
|
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
|
resolve = resolvePromise
|
||||||
|
reject = rejectPromise
|
||||||
})
|
})
|
||||||
return { promise, resolve }
|
return { promise, resolve, reject }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHarness() {
|
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 () => {
|
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
const session = await ElectronBrowserSession.create({
|
const session = await ElectronBrowserSession.create({
|
||||||
|
|||||||
@@ -126,6 +126,30 @@ type Listener = {
|
|||||||
listener: BrowserEventListener
|
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 {
|
function managedBrowserUserAgent(): string {
|
||||||
const platform =
|
const platform =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
@@ -243,14 +267,12 @@ export class ElectronBrowserSession {
|
|||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly policy: BrowserUrlPolicy,
|
private readonly policy: BrowserUrlPolicy,
|
||||||
private readonly partitionSession: BrowserPartitionSession,
|
|
||||||
private readonly view: BrowserViewHandle,
|
private readonly view: BrowserViewHandle,
|
||||||
private readonly proxy: FilteringProxyLike,
|
private readonly resources: SharedBrowserResources,
|
||||||
partition: string,
|
private readonly parentWindow?: BrowserParentWindowHandle,
|
||||||
private readonly cleanupTimeoutMs: number,
|
private readonly listenForPartitionEvents = true
|
||||||
private readonly parentWindow?: BrowserParentWindowHandle
|
|
||||||
) {
|
) {
|
||||||
this.partition = partition
|
this.partition = resources.partition
|
||||||
this.webContents = view.webContents
|
this.webContents = view.webContents
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,11 +391,16 @@ export class ElectronBrowserSession {
|
|||||||
)
|
)
|
||||||
result = new ElectronBrowserSession(
|
result = new ElectronBrowserSession(
|
||||||
options.policy,
|
options.policy,
|
||||||
partitionSession,
|
|
||||||
view,
|
view,
|
||||||
managedProxy,
|
{
|
||||||
partition,
|
partition,
|
||||||
|
partitionSession,
|
||||||
|
proxy: managedProxy,
|
||||||
|
createView,
|
||||||
|
setupTimeoutMs,
|
||||||
cleanupTimeoutMs,
|
cleanupTimeoutMs,
|
||||||
|
references: 1
|
||||||
|
},
|
||||||
options.parentWindow
|
options.parentWindow
|
||||||
)
|
)
|
||||||
setupStage = '初始化浏览器协议'
|
setupStage = '初始化浏览器协议'
|
||||||
@@ -480,14 +507,16 @@ export class ElectronBrowserSession {
|
|||||||
this.emitNavigation(url)
|
this.emitNavigation(url)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (this.listenForPartitionEvents) {
|
||||||
this.listen(
|
this.listen(
|
||||||
this.partitionSession,
|
this.resources.partitionSession,
|
||||||
'will-download',
|
'will-download',
|
||||||
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
|
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
item.cancel?.()
|
item.cancel?.()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
contents.debugger.attach('1.3')
|
contents.debugger.attach('1.3')
|
||||||
await contents.debugger.sendCommand('Page.enable')
|
await contents.debugger.sendCommand('Page.enable')
|
||||||
this.assertOpen()
|
this.assertOpen()
|
||||||
@@ -625,6 +654,83 @@ export class ElectronBrowserSession {
|
|||||||
this.approvedOrigin = target.origin
|
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> {
|
async dispose(): Promise<void> {
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return
|
return
|
||||||
@@ -650,10 +756,6 @@ export class ElectronBrowserSession {
|
|||||||
this.webContents.destroy()
|
this.webContents.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await cleanupIsolatedState(
|
await releaseSharedResources(this.resources)
|
||||||
this.partitionSession,
|
|
||||||
this.proxy,
|
|
||||||
this.cleanupTimeoutMs
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+418
-21
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
import {
|
import {
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
readFile,
|
readFile,
|
||||||
@@ -13,7 +14,12 @@ import type {
|
|||||||
AssistantProject,
|
AssistantProject,
|
||||||
ConversationQueueItem
|
ConversationQueueItem
|
||||||
} from '../shared/assistant-contracts'
|
} from '../shared/assistant-contracts'
|
||||||
import type { AgentEvent, BrowserLiveState } from '../shared/contracts'
|
import {
|
||||||
|
browserTabIdSchema,
|
||||||
|
type AgentEvent,
|
||||||
|
type BrowserLiveState,
|
||||||
|
type BrowserTabId
|
||||||
|
} from '../shared/contracts'
|
||||||
import type {
|
import type {
|
||||||
SshHostAgentConnectionStatus
|
SshHostAgentConnectionStatus
|
||||||
} from '../shared/ssh-host-contracts'
|
} 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 () => {
|
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' },
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||||
isDestroyed: vi.fn(() => false),
|
isDestroyed: vi.fn(() => false),
|
||||||
send: vi.fn()
|
send: vi.fn()
|
||||||
}
|
})
|
||||||
const window = {
|
const window = {
|
||||||
webContents,
|
webContents,
|
||||||
isDestroyed: vi.fn(() => false),
|
isDestroyed: vi.fn(() => false),
|
||||||
@@ -444,6 +451,36 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
const stopLoading = vi.fn(async () => true)
|
const stopLoading = vi.fn(async () => true)
|
||||||
const setViewport = vi.fn()
|
const setViewport = vi.fn()
|
||||||
const releaseConversation = vi.fn(async () => {})
|
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(
|
const selectFiles = vi.fn(
|
||||||
async (
|
async (
|
||||||
_window: unknown,
|
_window: unknown,
|
||||||
@@ -505,9 +542,19 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
onRuntimeSettingsChanged,
|
onRuntimeSettingsChanged,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
|
createTab,
|
||||||
|
listTabs,
|
||||||
|
getVisibleTabId,
|
||||||
|
acquireTabUsage,
|
||||||
|
closeTab,
|
||||||
navigate,
|
navigate,
|
||||||
back,
|
back,
|
||||||
reload,
|
reload,
|
||||||
|
snapshot: snapshotBrowser,
|
||||||
|
click: clickBrowser,
|
||||||
|
type: typeBrowser,
|
||||||
|
select: selectBrowser,
|
||||||
|
screenshot: screenshotBrowser,
|
||||||
stopLoading,
|
stopLoading,
|
||||||
setViewport,
|
setViewport,
|
||||||
releaseConversation,
|
releaseConversation,
|
||||||
@@ -649,8 +696,16 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
)?.(event, 'unsupported')
|
)?.(event, 'unsupported')
|
||||||
).rejects.toThrow()
|
).rejects.toThrow()
|
||||||
|
|
||||||
|
const browserTabId = browserTabIdSchema.parse(
|
||||||
|
'4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
|
||||||
|
)
|
||||||
|
const siblingBrowserTabId = browserTabIdSchema.parse(
|
||||||
|
'0387bd61-3a12-40ce-98d7-ef5d14cc8251'
|
||||||
|
)
|
||||||
browserStateListener?.({
|
browserStateListener?.({
|
||||||
conversationId: 'browser-conversation',
|
conversationId: 'browser-conversation',
|
||||||
|
tabId: browserTabId,
|
||||||
|
ownerWindowId: 7,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
sessionActive: true,
|
sessionActive: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -665,8 +720,22 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
status: 'ready'
|
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?.({
|
browserStateListener?.({
|
||||||
conversationId: 'browser-conversation',
|
conversationId: 'browser-conversation',
|
||||||
|
tabId: browserTabId,
|
||||||
|
ownerWindowId: 7,
|
||||||
status: 'acting',
|
status: 'acting',
|
||||||
sessionActive: true,
|
sessionActive: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -678,6 +747,22 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
|
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
|
||||||
browserStateListener?.({
|
browserStateListener?.({
|
||||||
conversationId: 'browser-conversation',
|
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',
|
status: 'stopped',
|
||||||
sessionActive: false,
|
sessionActive: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -686,6 +771,8 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
})
|
})
|
||||||
browserStateListener?.({
|
browserStateListener?.({
|
||||||
conversationId: 'browser-conversation',
|
conversationId: 'browser-conversation',
|
||||||
|
tabId: browserTabId,
|
||||||
|
ownerWindowId: 7,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
sessionActive: true,
|
sessionActive: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -703,7 +790,37 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
})
|
})
|
||||||
).resolves.toBeUndefined()
|
).resolves.toBeUndefined()
|
||||||
expect(releaseConversation).toHaveBeenCalledWith(
|
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(
|
await expect(
|
||||||
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
||||||
@@ -714,7 +831,9 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
expect(navigate).toHaveBeenCalledWith(
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
'browser-conversation',
|
'browser-conversation',
|
||||||
'https://example.com/',
|
'https://example.com/',
|
||||||
expect.any(AbortSignal)
|
expect.any(AbortSignal),
|
||||||
|
undefined,
|
||||||
|
7
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
|
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
|
||||||
@@ -723,7 +842,9 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
).resolves.toBeUndefined()
|
).resolves.toBeUndefined()
|
||||||
expect(back).toHaveBeenCalledWith(
|
expect(back).toHaveBeenCalledWith(
|
||||||
'browser-conversation',
|
'browser-conversation',
|
||||||
expect.any(AbortSignal)
|
expect.any(AbortSignal),
|
||||||
|
undefined,
|
||||||
|
7
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
|
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
|
||||||
@@ -732,7 +853,9 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
).resolves.toBeUndefined()
|
).resolves.toBeUndefined()
|
||||||
expect(reload).toHaveBeenCalledWith(
|
expect(reload).toHaveBeenCalledWith(
|
||||||
'browser-conversation',
|
'browser-conversation',
|
||||||
expect.any(AbortSignal)
|
expect.any(AbortSignal),
|
||||||
|
undefined,
|
||||||
|
7
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
||||||
@@ -740,23 +863,55 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
{ conversationId: 'browser-conversation' }
|
{ conversationId: 'browser-conversation' }
|
||||||
)
|
)
|
||||||
).resolves.toBeUndefined()
|
).resolves.toBeUndefined()
|
||||||
expect(stopLoading).toHaveBeenCalledWith('browser-conversation')
|
expect(stopLoading).toHaveBeenCalledWith(
|
||||||
|
'browser-conversation',
|
||||||
|
undefined,
|
||||||
|
7
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
|
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
|
||||||
conversationId: 'browser-conversation',
|
conversationId: 'browser-conversation',
|
||||||
|
leaseToken: siblingBrowserTabId,
|
||||||
bounds: { x: 900, y: 120, width: 320, height: 600 }
|
bounds: { x: 900, y: 120, width: 320, height: 600 }
|
||||||
})
|
})
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
expect(setViewport).toHaveBeenCalledWith('browser-conversation', {
|
expect(setViewport).toHaveBeenCalledWith(
|
||||||
x: 900,
|
'browser-conversation',
|
||||||
y: 120,
|
{ x: 900, y: 120, width: 320, height: 600 },
|
||||||
width: 320,
|
undefined,
|
||||||
height: 600
|
siblingBrowserTabId,
|
||||||
})
|
7
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {})
|
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
|
||||||
|
leaseToken: siblingBrowserTabId
|
||||||
|
})
|
||||||
).toBeUndefined()
|
).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())
|
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
|
||||||
await expect(
|
await expect(
|
||||||
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
||||||
@@ -4878,7 +5033,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
knowledgeGateway?: Record<string, unknown>,
|
knowledgeGateway?: Record<string, unknown>,
|
||||||
magicNotesEnabled = false,
|
magicNotesEnabled = false,
|
||||||
goodbuddyConfigService?: Record<string, unknown>,
|
goodbuddyConfigService?: Record<string, unknown>,
|
||||||
capabilityServiceOverride?: Record<string, unknown>
|
capabilityServiceOverride?: Record<string, unknown>,
|
||||||
|
browserControl?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
const assistantDatabase = {
|
const assistantDatabase = {
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
@@ -4990,6 +5146,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
const webContents = {
|
const webContents = {
|
||||||
|
id: 9,
|
||||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||||
send: vi.fn()
|
send: vi.fn()
|
||||||
@@ -5068,7 +5225,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
onRuntimeSettingsChanged,
|
onRuntimeSettingsChanged,
|
||||||
onBeforeClearLocalData,
|
onBeforeClearLocalData,
|
||||||
undefined,
|
browserControl as never,
|
||||||
subagentService as never,
|
subagentService as never,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
@@ -7230,6 +7387,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
requestId: string
|
requestId: string
|
||||||
knowledgeCapabilityToken?: string
|
knowledgeCapabilityToken?: string
|
||||||
trustedInstructions?: string
|
trustedInstructions?: string
|
||||||
|
browserTabId?: BrowserTabId
|
||||||
}> = []
|
}> = []
|
||||||
const runtime = {
|
const runtime = {
|
||||||
runtimeId: 'opencode',
|
runtimeId: 'opencode',
|
||||||
@@ -7240,6 +7398,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
requestId: string
|
requestId: string
|
||||||
knowledgeCapabilityToken?: string
|
knowledgeCapabilityToken?: string
|
||||||
trustedInstructions?: string
|
trustedInstructions?: string
|
||||||
|
browserTabId?: BrowserTabId
|
||||||
}) {
|
}) {
|
||||||
receivedRequests.push(request)
|
receivedRequests.push(request)
|
||||||
yield { requestId: request.requestId, type: 'done' }
|
yield { requestId: request.requestId, type: 'done' }
|
||||||
@@ -7257,6 +7416,30 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
|
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
|
||||||
'builtin-browser'
|
'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(
|
const harness = createHarness(
|
||||||
runtime,
|
runtime,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -7268,7 +7451,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
knowledgeGateway,
|
knowledgeGateway,
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
{ getEnabledBuiltinMcpServerIds }
|
{ getEnabledBuiltinMcpServerIds },
|
||||||
|
browserControl
|
||||||
)
|
)
|
||||||
const event = trustedEvent(harness.webContents)
|
const event = trustedEvent(harness.webContents)
|
||||||
const askRequestId = '00000000-0000-4000-8000-000000000026'
|
const askRequestId = '00000000-0000-4000-8000-000000000026'
|
||||||
@@ -7308,16 +7492,229 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
'none',
|
'none',
|
||||||
undefined,
|
undefined,
|
||||||
'browser-execute'
|
'browser-execute',
|
||||||
|
primaryTabId,
|
||||||
|
expect.objectContaining({
|
||||||
|
conversationId: 'browser-execute',
|
||||||
|
tabId: primaryTabId,
|
||||||
|
owner: executeRequestId
|
||||||
|
})
|
||||||
)
|
)
|
||||||
expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined()
|
expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined()
|
||||||
expect(receivedRequests[1]).toMatchObject({
|
expect(receivedRequests[1]).toMatchObject({
|
||||||
knowledgeCapabilityToken: 'browser-capability',
|
knowledgeCapabilityToken: 'browser-capability',
|
||||||
|
browserTabId: primaryTabId,
|
||||||
trustedInstructions: expect.stringContaining('browser_navigate')
|
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()
|
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)(
|
it.each(['ask', 'execute'] as const)(
|
||||||
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
|
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
|
||||||
async (workMode) => {
|
async (workMode) => {
|
||||||
@@ -9400,7 +9797,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
) => Promise<string>
|
) => Promise<string>
|
||||||
) {
|
) {
|
||||||
authorization = await authorize({
|
authorization = await authorize({
|
||||||
scopeKey: 'model:builtin:workspace_write_text',
|
scopeKey: 'model:builtin:workspace_apply_patch',
|
||||||
title: '写入文件',
|
title: '写入文件',
|
||||||
description: '写入 README.md'
|
description: '写入 README.md'
|
||||||
})
|
})
|
||||||
|
|||||||
+401
-32
@@ -4,7 +4,8 @@ import {
|
|||||||
clipboard,
|
clipboard,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
shell
|
shell,
|
||||||
|
type IpcMainInvokeEvent
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import {
|
import {
|
||||||
lstat,
|
lstat,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
realpath,
|
realpath,
|
||||||
stat
|
stat
|
||||||
} from 'node:fs/promises'
|
} from 'node:fs/promises'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { basename, extname, isAbsolute, join } from 'node:path'
|
import { basename, extname, isAbsolute, join } from 'node:path'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -27,11 +28,19 @@ import {
|
|||||||
agentQuestionResponseSchema,
|
agentQuestionResponseSchema,
|
||||||
agentRequestSchema,
|
agentRequestSchema,
|
||||||
browserBackRequestSchema,
|
browserBackRequestSchema,
|
||||||
|
browserClickRequestSchema,
|
||||||
|
browserCloseTabRequestSchema,
|
||||||
|
browserCreateTabRequestSchema,
|
||||||
|
browserListTabsRequestSchema,
|
||||||
browserNavigateRequestSchema,
|
browserNavigateRequestSchema,
|
||||||
browserReloadRequestSchema,
|
browserReloadRequestSchema,
|
||||||
|
browserScreenshotRequestSchema,
|
||||||
|
browserSelectRequestSchema,
|
||||||
browserSetViewportRequestSchema,
|
browserSetViewportRequestSchema,
|
||||||
|
browserSnapshotRequestSchema,
|
||||||
browserStopLoadingRequestSchema,
|
browserStopLoadingRequestSchema,
|
||||||
browserStopRequestSchema,
|
browserStopRequestSchema,
|
||||||
|
browserTypeRequestSchema,
|
||||||
clipboardTextSchema,
|
clipboardTextSchema,
|
||||||
conversationQueueUserInputSchema,
|
conversationQueueUserInputSchema,
|
||||||
defaultRuntimeSettings,
|
defaultRuntimeSettings,
|
||||||
@@ -62,6 +71,7 @@ import {
|
|||||||
type AgentRequest,
|
type AgentRequest,
|
||||||
type AppInfo,
|
type AppInfo,
|
||||||
type BrowserLiveState,
|
type BrowserLiveState,
|
||||||
|
type BrowserTabId,
|
||||||
type ConversationQueueDispatch,
|
type ConversationQueueDispatch,
|
||||||
type ConversationQueueUserInput,
|
type ConversationQueueUserInput,
|
||||||
type KnowledgeSearchReference,
|
type KnowledgeSearchReference,
|
||||||
@@ -442,10 +452,102 @@ function runtimeTargetFor(
|
|||||||
type ScopedDataCapability = {
|
type ScopedDataCapability = {
|
||||||
token?: string
|
token?: string
|
||||||
toolNames: readonly 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
|
gateway?: KnowledgeMcpGateway
|
||||||
|
browserControl?: BrowserCapabilityControl
|
||||||
runtime: AgentRuntime
|
runtime: AgentRuntime
|
||||||
enabledServers: readonly BuiltinMcpServerId[]
|
enabledServers: readonly BuiltinMcpServerId[]
|
||||||
requestId: string
|
requestId: string
|
||||||
@@ -454,12 +556,15 @@ function grantScopedDataCapability(input: {
|
|||||||
configAccess?: MagicNotesCapabilityAccess
|
configAccess?: MagicNotesCapabilityAccess
|
||||||
workspacePath?: string
|
workspacePath?: string
|
||||||
browserConversationId?: string
|
browserConversationId?: string
|
||||||
|
browserTabId?: BrowserTabId
|
||||||
|
ownerWindowId?: number
|
||||||
authorizeConfigApply?: (
|
authorizeConfigApply?: (
|
||||||
event: GoodBuddyConfigApplyEvent,
|
event: GoodBuddyConfigApplyEvent,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => Promise<boolean>
|
) => Promise<boolean>
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}): ScopedDataCapability {
|
abort?: (reason: unknown) => void
|
||||||
|
}): Promise<ScopedDataCapability> {
|
||||||
const enabledServers = new Set(input.enabledServers)
|
const enabledServers = new Set(input.enabledServers)
|
||||||
const libraryIds = enabledServers.has('knowledge-base')
|
const libraryIds = enabledServers.has('knowledge-base')
|
||||||
? input.libraryIds
|
? input.libraryIds
|
||||||
@@ -485,6 +590,22 @@ function grantScopedDataCapability(input: {
|
|||||||
if (!input.gateway) {
|
if (!input.gateway) {
|
||||||
throw new Error('GoodBuddy 内置工具服务不可用')
|
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 =
|
const config =
|
||||||
configAccess !== 'none' && input.workspacePath
|
configAccess !== 'none' && input.workspacePath
|
||||||
? {
|
? {
|
||||||
@@ -500,7 +621,9 @@ function grantScopedDataCapability(input: {
|
|||||||
input.signal,
|
input.signal,
|
||||||
magicNotesAccess,
|
magicNotesAccess,
|
||||||
config,
|
config,
|
||||||
browserConversationId
|
browserConversationId,
|
||||||
|
browserTabId,
|
||||||
|
browserUsageLease
|
||||||
)
|
)
|
||||||
: config
|
: config
|
||||||
? input.gateway.grant(
|
? input.gateway.grant(
|
||||||
@@ -518,6 +641,7 @@ function grantScopedDataCapability(input: {
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
|
browserTabId,
|
||||||
toolNames: token
|
toolNames: token
|
||||||
? input.gateway.getAvailableToolNames(token)
|
? input.gateway.getAvailableToolNames(token)
|
||||||
: []
|
: []
|
||||||
@@ -1086,25 +1210,98 @@ export function registerIpcHandlers(
|
|||||||
onRuntimeSettingsChanged: () => Promise<void>,
|
onRuntimeSettingsChanged: () => Promise<void>,
|
||||||
onBeforeClearLocalData?: () => Promise<void>,
|
onBeforeClearLocalData?: () => Promise<void>,
|
||||||
browserControl?: {
|
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(
|
navigate(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
url: string,
|
url: string,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
tabId?: BrowserTabId,
|
||||||
|
ownerWindowId?: number
|
||||||
): Promise<{ url: string; origin: string }>
|
): Promise<{ url: string; origin: string }>
|
||||||
back(
|
back(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
tabId?: BrowserTabId,
|
||||||
|
ownerWindowId?: number
|
||||||
): Promise<{ url: string; origin: string }>
|
): Promise<{ url: string; origin: string }>
|
||||||
reload(
|
reload(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
tabId?: BrowserTabId,
|
||||||
|
ownerWindowId?: number
|
||||||
): Promise<{ url: string; origin: string }>
|
): 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(
|
setViewport(
|
||||||
conversationId?: string,
|
conversationId?: string,
|
||||||
bounds?: { x: number; y: number; width: number; height: number }
|
bounds?: { x: number; y: number; width: number; height: number },
|
||||||
): void
|
tabId?: BrowserTabId,
|
||||||
releaseConversation(conversationId: string): Promise<void>
|
leaseToken?: string,
|
||||||
|
ownerWindowId?: number
|
||||||
|
): boolean | void
|
||||||
|
releaseConversation(conversationId: string, ownerWindowId?: number): Promise<void>
|
||||||
onState(listener: (state: BrowserLiveState) => void): () => void
|
onState(listener: (state: BrowserLiveState) => void): () => void
|
||||||
},
|
},
|
||||||
subagentService?: SubagentService,
|
subagentService?: SubagentService,
|
||||||
@@ -1378,21 +1575,22 @@ export function registerIpcHandlers(
|
|||||||
window.on('unmaximize', notifyMaximizedChanged)
|
window.on('unmaximize', notifyMaximizedChanged)
|
||||||
const lastSentBrowserFrames = new Map<string, string>()
|
const lastSentBrowserFrames = new Map<string, string>()
|
||||||
const removeBrowserStateListener = browserControl?.onState((state) => {
|
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
|
const frame = state.frameDataUrl
|
||||||
let payload: BrowserLiveState = state
|
let payload: BrowserLiveState = state
|
||||||
if (
|
if (
|
||||||
frame &&
|
frame &&
|
||||||
lastSentBrowserFrames.get(state.conversationId) === frame
|
lastSentBrowserFrames.get(tabStateKey) === frame
|
||||||
) {
|
) {
|
||||||
payload = { ...state }
|
payload = { ...state }
|
||||||
delete payload.frameDataUrl
|
delete payload.frameDataUrl
|
||||||
}
|
}
|
||||||
if (frame) {
|
if (frame) {
|
||||||
lastSentBrowserFrames.set(state.conversationId, frame)
|
lastSentBrowserFrames.set(tabStateKey, frame)
|
||||||
}
|
}
|
||||||
if (state.status === 'stopped') {
|
if (state.status === 'stopped') {
|
||||||
lastSentBrowserFrames.delete(state.conversationId)
|
lastSentBrowserFrames.delete(tabStateKey)
|
||||||
}
|
}
|
||||||
window.webContents.send(ipcChannels.browserState, payload)
|
window.webContents.send(ipcChannels.browserState, payload)
|
||||||
}
|
}
|
||||||
@@ -2343,8 +2541,9 @@ export function registerIpcHandlers(
|
|||||||
(id) => id !== 'builtin-browser'
|
(id) => id !== 'builtin-browser'
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const notesCapability = grantScopedDataCapability({
|
const notesCapability = await grantScopedDataCapability({
|
||||||
gateway: knowledgeGateway,
|
gateway: knowledgeGateway,
|
||||||
|
browserControl: browserControl as BrowserCapabilityControl | undefined,
|
||||||
runtime: requestRuntime,
|
runtime: requestRuntime,
|
||||||
enabledServers: enabledBuiltinMcpServers,
|
enabledServers: enabledBuiltinMcpServers,
|
||||||
requestId,
|
requestId,
|
||||||
@@ -2358,7 +2557,9 @@ export function registerIpcHandlers(
|
|||||||
schedule.workMode === 'execute'
|
schedule.workMode === 'execute'
|
||||||
? runtimeConversationId
|
? runtimeConversationId
|
||||||
: undefined,
|
: undefined,
|
||||||
signal: controller.signal
|
ownerWindowId: window.webContents.id,
|
||||||
|
signal: controller.signal,
|
||||||
|
abort: (reason) => controller.abort(reason)
|
||||||
})
|
})
|
||||||
knowledgeCapabilityToken = notesCapability.token
|
knowledgeCapabilityToken = notesCapability.token
|
||||||
const noteTools = notesCapability.toolNames
|
const noteTools = notesCapability.toolNames
|
||||||
@@ -2456,6 +2657,9 @@ export function registerIpcHandlers(
|
|||||||
trustedInstructions,
|
trustedInstructions,
|
||||||
...(knowledgeCapabilityToken
|
...(knowledgeCapabilityToken
|
||||||
? { knowledgeCapabilityToken }
|
? { knowledgeCapabilityToken }
|
||||||
|
: {}),
|
||||||
|
...(notesCapability.browserTabId
|
||||||
|
? { browserTabId: notesCapability.browserTabId }
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
for await (const agentEvent of requestRuntime.run(
|
for await (const agentEvent of requestRuntime.run(
|
||||||
@@ -3467,7 +3671,7 @@ export function registerIpcHandlers(
|
|||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
const request = browserStopRequestSchema.parse(input)
|
const request = browserStopRequestSchema.parse(input)
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
browserControl?.releaseConversation(request.conversationId),
|
browserControl?.releaseConversation(request.conversationId, event.sender.id),
|
||||||
selectedRuntimes
|
selectedRuntimes
|
||||||
? selectedRuntimes.releaseConversation(request.conversationId)
|
? selectedRuntimes.releaseConversation(request.conversationId)
|
||||||
: runtime.releaseConversation?.(request.conversationId)
|
: runtime.releaseConversation?.(request.conversationId)
|
||||||
@@ -3490,7 +3694,45 @@ export function registerIpcHandlers(
|
|||||||
return browserControl
|
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 (
|
const runUiBrowserNavigation = async (
|
||||||
|
event: IpcMainInvokeEvent,
|
||||||
operation: (
|
operation: (
|
||||||
control: NonNullable<typeof browserControl>,
|
control: NonNullable<typeof browserControl>,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
@@ -3498,7 +3740,7 @@ export function registerIpcHandlers(
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const control = await requireBrowserControl()
|
const control = await requireBrowserControl()
|
||||||
await operation(control, new AbortController().signal)
|
await runBrowserOperation(event, (signal) => operation(control, signal))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof BrowserNavigationStoppedError)) {
|
if (!(error instanceof BrowserNavigationStoppedError)) {
|
||||||
throw error
|
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(
|
registerHandler(
|
||||||
ipcChannels.browserNavigate,
|
ipcChannels.browserNavigate,
|
||||||
async (event, input: unknown) => {
|
async (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
const request = browserNavigateRequestSchema.parse(input)
|
const request = browserNavigateRequestSchema.parse(input)
|
||||||
await runUiBrowserNavigation((control, signal) =>
|
await runUiBrowserNavigation(event, (control, signal) =>
|
||||||
control.navigate(
|
control.navigate(
|
||||||
request.conversationId,
|
request.conversationId,
|
||||||
request.url,
|
request.url,
|
||||||
signal
|
signal,
|
||||||
|
request.tabId,
|
||||||
|
event.sender.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3526,10 +3808,12 @@ export function registerIpcHandlers(
|
|||||||
async (event, input: unknown) => {
|
async (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
const request = browserBackRequestSchema.parse(input)
|
const request = browserBackRequestSchema.parse(input)
|
||||||
await runUiBrowserNavigation((control, signal) =>
|
await runUiBrowserNavigation(event, (control, signal) =>
|
||||||
control.back(
|
control.back(
|
||||||
request.conversationId,
|
request.conversationId,
|
||||||
signal
|
signal,
|
||||||
|
request.tabId,
|
||||||
|
event.sender.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3540,10 +3824,12 @@ export function registerIpcHandlers(
|
|||||||
async (event, input: unknown) => {
|
async (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
const request = browserReloadRequestSchema.parse(input)
|
const request = browserReloadRequestSchema.parse(input)
|
||||||
await runUiBrowserNavigation((control, signal) =>
|
await runUiBrowserNavigation(event, (control, signal) =>
|
||||||
control.reload(
|
control.reload(
|
||||||
request.conversationId,
|
request.conversationId,
|
||||||
signal
|
signal,
|
||||||
|
request.tabId,
|
||||||
|
event.sender.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3554,7 +3840,79 @@ export function registerIpcHandlers(
|
|||||||
async (event, input: unknown) => {
|
async (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
const request = browserStopLoadingRequestSchema.parse(input)
|
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
|
: undefined
|
||||||
: resolvedRuntimeSettings?.workspacePath
|
: resolvedRuntimeSettings?.workspacePath
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const scopedCapability = grantScopedDataCapability({
|
const scopedCapability = await grantScopedDataCapability({
|
||||||
gateway: knowledgeGateway,
|
gateway: knowledgeGateway,
|
||||||
|
browserControl: browserControl as BrowserCapabilityControl | undefined,
|
||||||
runtime: selectedRuntime,
|
runtime: selectedRuntime,
|
||||||
enabledServers: enabledBuiltinMcpServers,
|
enabledServers: enabledBuiltinMcpServers,
|
||||||
requestId: enrichedRequest.requestId,
|
requestId: enrichedRequest.requestId,
|
||||||
@@ -3807,7 +4166,9 @@ export function registerIpcHandlers(
|
|||||||
enrichedRequest.workMode === 'execute'
|
enrichedRequest.workMode === 'execute'
|
||||||
? enrichedRequest.conversationId
|
? enrichedRequest.conversationId
|
||||||
: undefined,
|
: undefined,
|
||||||
signal: controller.signal
|
ownerWindowId: event.sender.id,
|
||||||
|
signal: controller.signal,
|
||||||
|
abort: (reason) => controller.abort(reason)
|
||||||
})
|
})
|
||||||
const knowledgeCapabilityToken = scopedCapability.token
|
const knowledgeCapabilityToken = scopedCapability.token
|
||||||
const availableTools = [
|
const availableTools = [
|
||||||
@@ -3846,6 +4207,9 @@ export function registerIpcHandlers(
|
|||||||
const request: AgentExecutionRequest = knowledgeCapabilityToken
|
const request: AgentExecutionRequest = knowledgeCapabilityToken
|
||||||
? { ...baseRequest, knowledgeCapabilityToken }
|
? { ...baseRequest, knowledgeCapabilityToken }
|
||||||
: baseRequest
|
: baseRequest
|
||||||
|
if (scopedCapability.browserTabId) {
|
||||||
|
request.browserTabId = scopedCapability.browserTabId
|
||||||
|
}
|
||||||
const managedSshExecution =
|
const managedSshExecution =
|
||||||
agentRuntimeSelected &&
|
agentRuntimeSelected &&
|
||||||
configExecutionSpace?.kind === 'ssh'
|
configExecutionSpace?.kind === 'ssh'
|
||||||
@@ -4649,7 +5013,6 @@ export function registerIpcHandlers(
|
|||||||
(message) =>
|
(message) =>
|
||||||
message.state === 'complete' && message.content.trim()
|
message.state === 'complete' && message.content.trim()
|
||||||
)
|
)
|
||||||
.slice(-500)
|
|
||||||
if (
|
if (
|
||||||
persistedHistory.length !== request.history.length ||
|
persistedHistory.length !== request.history.length ||
|
||||||
persistedHistory.some(
|
persistedHistory.some(
|
||||||
@@ -6639,7 +7002,13 @@ export function registerIpcHandlers(
|
|||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
const request = browserSetViewportRequestSchema.parse(input)
|
const request = browserSetViewportRequestSchema.parse(input)
|
||||||
if (!request.conversationId || !request.bounds) {
|
if (!request.conversationId || !request.bounds) {
|
||||||
browserControl?.setViewport()
|
browserControl?.setViewport(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
request.leaseToken,
|
||||||
|
event.sender.id
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
browserControl?.setViewport(request.conversationId, {
|
browserControl?.setViewport(request.conversationId, {
|
||||||
@@ -6647,7 +7016,7 @@ export function registerIpcHandlers(
|
|||||||
y: request.bounds.y,
|
y: request.bounds.y,
|
||||||
width: request.bounds.width,
|
width: request.bounds.width,
|
||||||
height: request.bounds.height
|
height: request.bounds.height
|
||||||
})
|
}, request.tabId, request.leaseToken, event.sender.id)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
registerHandler(
|
registerHandler(
|
||||||
|
|||||||
@@ -515,12 +515,10 @@ const defaultSettings: StoredSettings = {
|
|||||||
],
|
],
|
||||||
defaultModelProfileId,
|
defaultModelProfileId,
|
||||||
opencodeModelSource: {
|
opencodeModelSource: {
|
||||||
kind: 'profile',
|
kind: 'default'
|
||||||
profileId: defaultModelProfileId
|
|
||||||
},
|
},
|
||||||
continueModelSource: {
|
continueModelSource: {
|
||||||
kind: 'profile',
|
kind: 'default'
|
||||||
profileId: defaultModelProfileId
|
|
||||||
},
|
},
|
||||||
opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl,
|
opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl,
|
||||||
opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded,
|
opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded,
|
||||||
@@ -820,7 +818,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
|||||||
const normalizeSource = (
|
const normalizeSource = (
|
||||||
source: RuntimeSettings['opencodeModelSource']
|
source: RuntimeSettings['opencodeModelSource']
|
||||||
): RuntimeSettings['opencodeModelSource'] => {
|
): RuntimeSettings['opencodeModelSource'] => {
|
||||||
if (source.kind === 'platform') {
|
if (source.kind !== 'profile') {
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
const profile = modelProfiles.find(
|
const profile = modelProfiles.find(
|
||||||
@@ -839,6 +837,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
|||||||
if (!source || source.kind === 'platform') {
|
if (!source || source.kind === 'platform') {
|
||||||
return { kind: 'platform' }
|
return { kind: 'platform' }
|
||||||
}
|
}
|
||||||
|
if (source.kind === 'default') return source
|
||||||
const profile = modelProfiles.find(
|
const profile = modelProfiles.find(
|
||||||
(candidate) => candidate.id === source.profileId
|
(candidate) => candidate.id === source.profileId
|
||||||
)
|
)
|
||||||
@@ -1431,6 +1430,9 @@ export class RuntimeSettingsStore {
|
|||||||
modelProfiles: readonly ResolvedModelProfile[]
|
modelProfiles: readonly ResolvedModelProfile[]
|
||||||
): ResolvedModelProfile | undefined {
|
): ResolvedModelProfile | undefined {
|
||||||
const source = settings.deepseekHarnessModelSource
|
const source = settings.deepseekHarnessModelSource
|
||||||
|
if (source.kind === 'default') {
|
||||||
|
return modelProfiles.find((profile) => profile.id === settings.defaultModelProfileId)
|
||||||
|
}
|
||||||
if (source.kind === 'profile') {
|
if (source.kind === 'profile') {
|
||||||
return modelProfiles.find(
|
return modelProfiles.find(
|
||||||
(profile) => profile.id === source.profileId
|
(profile) => profile.id === source.profileId
|
||||||
@@ -1936,12 +1938,14 @@ export class RuntimeSettingsStore {
|
|||||||
)
|
)
|
||||||
const opencodeModelProfile =
|
const opencodeModelProfile =
|
||||||
!agent.opencodeBaseUrl &&
|
!agent.opencodeBaseUrl &&
|
||||||
settings.opencodeModelSource.kind === 'profile'
|
settings.opencodeModelSource.kind !== 'platform'
|
||||||
? profilesById.get(settings.opencodeModelSource.profileId)
|
? profilesById.get(settings.opencodeModelSource.kind === 'default'
|
||||||
|
? settings.defaultModelProfileId : settings.opencodeModelSource.profileId)
|
||||||
: undefined
|
: undefined
|
||||||
const continueModelProfile =
|
const continueModelProfile =
|
||||||
settings.continueModelSource.kind === 'profile'
|
settings.continueModelSource.kind !== 'platform'
|
||||||
? profilesById.get(settings.continueModelSource.profileId)
|
? profilesById.get(settings.continueModelSource.kind === 'default'
|
||||||
|
? settings.defaultModelProfileId : settings.continueModelSource.profileId)
|
||||||
: undefined
|
: undefined
|
||||||
const deepseekHarnessModelProfile =
|
const deepseekHarnessModelProfile =
|
||||||
this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles)
|
this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles)
|
||||||
@@ -2362,7 +2366,7 @@ export class RuntimeSettingsStore {
|
|||||||
source: RuntimeSettings['opencodeModelSource'],
|
source: RuntimeSettings['opencodeModelSource'],
|
||||||
runtimeLabel: 'OpenCode' | 'Continue'
|
runtimeLabel: 'OpenCode' | 'Continue'
|
||||||
): void => {
|
): void => {
|
||||||
if (source.kind === 'platform') {
|
if (source.kind !== 'profile') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const profile = modelProfiles.find(
|
const profile = modelProfiles.find(
|
||||||
@@ -2386,7 +2390,7 @@ export class RuntimeSettingsStore {
|
|||||||
const repairRuntimeSource = (
|
const repairRuntimeSource = (
|
||||||
source: RuntimeSettings['opencodeModelSource']
|
source: RuntimeSettings['opencodeModelSource']
|
||||||
): RuntimeSettings['opencodeModelSource'] => {
|
): RuntimeSettings['opencodeModelSource'] => {
|
||||||
if (source.kind === 'platform') {
|
if (source.kind !== 'profile') {
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
const profile = modelProfiles.find(
|
const profile = modelProfiles.find(
|
||||||
|
|||||||
+84
-22
@@ -7,7 +7,23 @@ import {
|
|||||||
type AgentRuntimeDetection,
|
type AgentRuntimeDetection,
|
||||||
type AgentRuntimeStatus,
|
type AgentRuntimeStatus,
|
||||||
type AppInfo,
|
type AppInfo,
|
||||||
|
type BrowserBackRequest,
|
||||||
|
type BrowserClickRequest,
|
||||||
|
type BrowserCloseTabRequest,
|
||||||
|
type BrowserCreateTabRequest,
|
||||||
type BrowserLiveState,
|
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 ContextAttachment,
|
||||||
type ContextFileSelectionProgress,
|
type ContextFileSelectionProgress,
|
||||||
type DesktopApi,
|
type DesktopApi,
|
||||||
@@ -187,6 +203,7 @@ import type {
|
|||||||
TerminalWriteRequest
|
TerminalWriteRequest
|
||||||
} from '../shared/terminal-contracts'
|
} from '../shared/terminal-contracts'
|
||||||
|
|
||||||
|
|
||||||
const desktopApi: DesktopApi = {
|
const desktopApi: DesktopApi = {
|
||||||
app: {
|
app: {
|
||||||
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
|
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
|
||||||
@@ -307,32 +324,77 @@ const desktopApi: DesktopApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
browser: {
|
browser: {
|
||||||
navigate: async (conversationId: string, url: string) => {
|
createTab: (request: BrowserCreateTabRequest) =>
|
||||||
await ipcRenderer.invoke(ipcChannels.browserNavigate, {
|
ipcRenderer.invoke(
|
||||||
conversationId,
|
ipcChannels.browserCreateTab,
|
||||||
url
|
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) => {
|
navigate: async (
|
||||||
await ipcRenderer.invoke(ipcChannels.browserBack, {
|
requestOrConversation: BrowserNavigateRequest | string,
|
||||||
conversationId
|
url?: string
|
||||||
})
|
) => {
|
||||||
|
await ipcRenderer.invoke(
|
||||||
|
ipcChannels.browserNavigate,
|
||||||
|
typeof requestOrConversation === 'string'
|
||||||
|
? { conversationId: requestOrConversation, url }
|
||||||
|
: requestOrConversation
|
||||||
|
)
|
||||||
},
|
},
|
||||||
reload: async (conversationId: string) => {
|
back: async (requestOrConversation: BrowserBackRequest | string) => {
|
||||||
await ipcRenderer.invoke(ipcChannels.browserReload, {
|
await ipcRenderer.invoke(
|
||||||
conversationId
|
ipcChannels.browserBack,
|
||||||
})
|
typeof requestOrConversation === 'string'
|
||||||
|
? { conversationId: requestOrConversation }
|
||||||
|
: requestOrConversation
|
||||||
|
)
|
||||||
},
|
},
|
||||||
stopLoading: async (conversationId: string) => {
|
reload: async (requestOrConversation: BrowserReloadRequest | string) => {
|
||||||
await ipcRenderer.invoke(ipcChannels.browserStopLoading, {
|
await ipcRenderer.invoke(
|
||||||
conversationId
|
ipcChannels.browserReload,
|
||||||
})
|
typeof requestOrConversation === 'string'
|
||||||
|
? { conversationId: requestOrConversation }
|
||||||
|
: requestOrConversation
|
||||||
|
)
|
||||||
},
|
},
|
||||||
setViewport: async (conversationId, bounds) => {
|
stopLoading: async (
|
||||||
await ipcRenderer.invoke(ipcChannels.browserSetViewport, {
|
requestOrConversation: BrowserStopLoadingRequest | string
|
||||||
...(conversationId ? { conversationId } : {}),
|
) => {
|
||||||
...(bounds ? { bounds } : {})
|
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) => {
|
stop: async (conversationId: string) => {
|
||||||
await ipcRenderer.invoke(
|
await ipcRenderer.invoke(
|
||||||
|
|||||||
@@ -47,16 +47,40 @@ describe('sandboxed preload', () => {
|
|||||||
/browser: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}terminal:/u
|
/browser: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}terminal:/u
|
||||||
)?.groups?.body ?? ''
|
)?.groups?.body ?? ''
|
||||||
for (const method of [
|
for (const method of [
|
||||||
|
'createTab:',
|
||||||
|
'listTabs:',
|
||||||
|
'closeTab:',
|
||||||
'navigate:',
|
'navigate:',
|
||||||
'back:',
|
'back:',
|
||||||
'reload:',
|
'reload:',
|
||||||
'stopLoading:',
|
'stopLoading:',
|
||||||
'interact:',
|
'snapshot:',
|
||||||
|
'click:',
|
||||||
|
'type:',
|
||||||
|
'select:',
|
||||||
|
'screenshot:',
|
||||||
|
'setViewport:',
|
||||||
'stop:',
|
'stop:',
|
||||||
'onState:'
|
'onState:'
|
||||||
]) {
|
]) {
|
||||||
expect(browser).toContain(method)
|
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(
|
expect(browser).not.toMatch(
|
||||||
/\b(?:webContents|debugger|session|partition|cookie)\b/iu
|
/\b(?:webContents|debugger|session|partition|cookie)\b/iu
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,18 @@ import {
|
|||||||
waitFor
|
waitFor
|
||||||
} from '@testing-library/react'
|
} from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 {
|
import {
|
||||||
RightAssistantSidebar,
|
RightAssistantSidebar,
|
||||||
type AssistantSidebarTab,
|
type AssistantSidebarTab,
|
||||||
@@ -17,15 +28,74 @@ import {
|
|||||||
|
|
||||||
afterEach(cleanup)
|
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(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 1400
|
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',
|
tab = 'tasks',
|
||||||
approvals = [],
|
approvals = [],
|
||||||
artifacts = [],
|
artifacts = [],
|
||||||
@@ -36,8 +106,16 @@ function renderSidebar({
|
|||||||
})),
|
})),
|
||||||
onLoadArtifact = vi.fn(async () => undefined),
|
onLoadArtifact = vi.fn(async () => undefined),
|
||||||
onLoadWorkspaceFile = vi.fn(),
|
onLoadWorkspaceFile = vi.fn(),
|
||||||
|
tasks = [],
|
||||||
|
activeProject = currentProject,
|
||||||
workspaceProjectId,
|
workspaceProjectId,
|
||||||
restoreFocusRef
|
restoreFocusRef,
|
||||||
|
activeConversationId,
|
||||||
|
browserStates,
|
||||||
|
onBackBrowser,
|
||||||
|
onNavigateBrowser,
|
||||||
|
onReloadBrowser,
|
||||||
|
onStopLoadingBrowser
|
||||||
}: {
|
}: {
|
||||||
tab?: AssistantSidebarTab
|
tab?: AssistantSidebarTab
|
||||||
approvals?: PendingSidebarApproval[]
|
approvals?: PendingSidebarApproval[]
|
||||||
@@ -58,31 +136,46 @@ function renderSidebar({
|
|||||||
path: string,
|
path: string,
|
||||||
offsetBytes?: number
|
offsetBytes?: number
|
||||||
) => Promise<WorkspaceFilePreview>
|
) => Promise<WorkspaceFilePreview>
|
||||||
|
tasks?: AssistantTask[]
|
||||||
|
activeProject?: AssistantProject | null
|
||||||
workspaceProjectId?: string
|
workspaceProjectId?: string
|
||||||
restoreFocusRef?: { current: HTMLElement | null }
|
restoreFocusRef?: { current: HTMLElement | null }
|
||||||
} = {}): HTMLElement {
|
activeConversationId?: string
|
||||||
render(
|
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>
|
<div>
|
||||||
<main className="workspace" />
|
<main className="workspace" />
|
||||||
<RightAssistantSidebar
|
<RightAssistantSidebar
|
||||||
approvals={approvals}
|
approvals={approvals}
|
||||||
|
activeConversationId={activeConversationId}
|
||||||
artifacts={artifacts}
|
artifacts={artifacts}
|
||||||
|
browserStates={browserStates}
|
||||||
schedules={[]}
|
schedules={[]}
|
||||||
tasks={[]}
|
tasks={tasks}
|
||||||
conversationTitles={new Map()}
|
conversationTitles={new Map()}
|
||||||
|
currentProject={activeProject ?? undefined}
|
||||||
projectNames={new Map()}
|
projectNames={new Map()}
|
||||||
onCreateCustomTask={vi.fn()}
|
onCreateCustomTask={vi.fn()}
|
||||||
|
onBackBrowser={onBackBrowser}
|
||||||
onImportArtifacts={vi.fn(async () => undefined)}
|
onImportArtifacts={vi.fn(async () => undefined)}
|
||||||
onListWorkspaceDirectory={onListWorkspaceDirectory}
|
onListWorkspaceDirectory={onListWorkspaceDirectory}
|
||||||
onLoadArtifact={onLoadArtifact}
|
onLoadArtifact={onLoadArtifact}
|
||||||
onLoadWorkspaceFile={onLoadWorkspaceFile}
|
onLoadWorkspaceFile={onLoadWorkspaceFile}
|
||||||
|
onNavigateBrowser={onNavigateBrowser}
|
||||||
onLoadWorkspaceDiff={vi.fn()}
|
onLoadWorkspaceDiff={vi.fn()}
|
||||||
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
||||||
onRefreshChanges={vi.fn(async () => undefined)}
|
onRefreshChanges={vi.fn(async () => undefined)}
|
||||||
|
onReloadBrowser={onReloadBrowser}
|
||||||
onRemoveSchedule={vi.fn(async () => undefined)}
|
onRemoveSchedule={vi.fn(async () => undefined)}
|
||||||
onRespondApproval={vi.fn()}
|
onRespondApproval={vi.fn()}
|
||||||
onRunSchedule={vi.fn(async () => undefined)}
|
onRunSchedule={vi.fn(async () => undefined)}
|
||||||
onSetScheduleEnabled={vi.fn(async () => undefined)}
|
onSetScheduleEnabled={vi.fn(async () => undefined)}
|
||||||
|
onStopLoadingBrowser={onStopLoadingBrowser}
|
||||||
onOpenTask={vi.fn()}
|
onOpenTask={vi.fn()}
|
||||||
onTabChange={vi.fn()}
|
onTabChange={vi.fn()}
|
||||||
open
|
open
|
||||||
@@ -92,7 +185,10 @@ function renderSidebar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar(options: Parameters<typeof sidebarElement>[0] = {}): HTMLElement {
|
||||||
|
render(sidebarElement(options))
|
||||||
return screen.getByRole('complementary', {
|
return screen.getByRole('complementary', {
|
||||||
name: '助手工作栏'
|
name: '助手工作栏'
|
||||||
})
|
})
|
||||||
@@ -257,7 +353,7 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole('tab').map((tab) => tab.textContent)
|
screen.getAllByRole('tab').map((tab) => tab.textContent)
|
||||||
).toEqual(['任务中心', '工作区', '浏览器', '成果'])
|
).toEqual(['任务中心', '工作区', '浏览器 · 未绑定会话', '成果'])
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole('tab', { name: '预览' })
|
screen.queryByRole('tab', { name: '预览' })
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
@@ -289,6 +385,16 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
screen.getByRole('separator', { name: '调整助手工作栏宽度' })
|
screen.getByRole('separator', { name: '调整助手工作栏宽度' })
|
||||||
).toHaveAttribute('tabindex', '-1')
|
).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' })
|
fireEvent.keyDown(window, { key: 'Escape' })
|
||||||
expect(sidebar).not.toHaveClass(
|
expect(sidebar).not.toHaveClass(
|
||||||
'assistant-sidebar--browser-fullscreen'
|
'assistant-sidebar--browser-fullscreen'
|
||||||
@@ -332,6 +438,7 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
conversationId: 'conversation-1',
|
conversationId: 'conversation-1',
|
||||||
messageId: 'message-1',
|
messageId: 'message-1',
|
||||||
approvalId: 'approval-1',
|
approvalId: 'approval-1',
|
||||||
|
projectId: currentProject.id,
|
||||||
title: '写入工作区',
|
title: '写入工作区',
|
||||||
description: '更新 release.md',
|
description: '更新 release.md',
|
||||||
toolName: 'write_file'
|
toolName: 'write_file'
|
||||||
@@ -452,6 +559,102 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
expect(onListWorkspaceDirectory).toHaveBeenCalledTimes(2)
|
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 () => {
|
it('keeps preview content and reading position when refreshing fails', async () => {
|
||||||
const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({
|
const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({
|
||||||
path: 'README.md', name: 'README.md', content: '# Original heading',
|
path: 'README.md', name: 'README.md', content: '# Original heading',
|
||||||
@@ -590,4 +793,342 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
screen.queryByRole('tab', { name: '预览' })
|
screen.queryByRole('tab', { name: '预览' })
|
||||||
).not.toBeInTheDocument()
|
).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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ const stylesheet = readFileSync(
|
|||||||
function ControlledShell({
|
function ControlledShell({
|
||||||
initialInstances = DEFAULT_WORKBAR_INSTANCES,
|
initialInstances = DEFAULT_WORKBAR_INSTANCES,
|
||||||
onCreate = vi.fn(),
|
onCreate = vi.fn(),
|
||||||
|
onClose,
|
||||||
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
|
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
|
||||||
type: 'local'
|
type: 'local'
|
||||||
}))
|
}))
|
||||||
}: {
|
}: {
|
||||||
initialInstances?: readonly WorkbarTabInstance[]
|
initialInstances?: readonly WorkbarTabInstance[]
|
||||||
onCreate?: (request: WorkbarInstanceCreateRequest) => void
|
onCreate?: (request: WorkbarInstanceCreateRequest) => void
|
||||||
|
onClose?: (instance: WorkbarTabInstance) => boolean | void | Promise<boolean | void>
|
||||||
onTerminalTarget?: () =>
|
onTerminalTarget?: () =>
|
||||||
| WorkbarTargetRef
|
| WorkbarTargetRef
|
||||||
| Promise<WorkbarTargetRef>
|
| Promise<WorkbarTargetRef>
|
||||||
@@ -55,19 +57,27 @@ function ControlledShell({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<output data-testid="active-workbar-instance">
|
||||||
|
{activeId ?? 'none'}
|
||||||
|
</output>
|
||||||
<WorkbarShell
|
<WorkbarShell
|
||||||
activeInstanceId={activeId}
|
activeInstanceId={activeId}
|
||||||
instances={instances}
|
instances={instances}
|
||||||
onActiveInstanceChange={setActiveId}
|
onActiveInstanceChange={setActiveId}
|
||||||
onCloseInstance={(instance) =>
|
onCloseInstance={async (instance) => {
|
||||||
|
const accepted = await onClose?.(instance)
|
||||||
|
if (accepted === false) return false
|
||||||
setInstances((current) =>
|
setInstances((current) =>
|
||||||
current.filter((candidate) => candidate.id !== instance.id)
|
current.filter((candidate) => candidate.id !== instance.id)
|
||||||
)
|
)
|
||||||
}
|
return true
|
||||||
|
}}
|
||||||
onCreateInstance={onCreate}
|
onCreateInstance={onCreate}
|
||||||
onResolveTerminalTarget={onTerminalTarget}
|
onResolveTerminalTarget={onTerminalTarget}
|
||||||
renderPanel={(instance) => <p>{instance.title}内容</p>}
|
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 />)
|
render(<ControlledShell />)
|
||||||
|
|
||||||
const tablist = screen.getByRole('tablist', {
|
const tablist = screen.getByRole('tablist', {
|
||||||
@@ -113,6 +123,20 @@ describe('WorkbarShell', () => {
|
|||||||
).not.toHaveClass('workbar-shell__tab-item--active')
|
).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 () => {
|
it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => {
|
||||||
render(<ControlledShell />)
|
render(<ControlledShell />)
|
||||||
const addButton = screen.getByRole('button', {
|
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', () => {
|
it('supports roving Arrow, Home, and End navigation', () => {
|
||||||
render(<ControlledShell />)
|
render(<ControlledShell />)
|
||||||
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
||||||
@@ -214,19 +254,23 @@ describe('WorkbarShell', () => {
|
|||||||
expect(tasks).toHaveFocus()
|
expect(tasks).toHaveFocus()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes the active tab, activates its right neighbor, and restores focus', async () => {
|
it('closes an active closable tab, activates its neighbor, and restores focus', async () => {
|
||||||
render(<ControlledShell />)
|
render(
|
||||||
const workspace = screen.getByRole('tab', { name: '工作区' })
|
<ControlledShell
|
||||||
fireEvent.click(workspace)
|
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const browser = screen.getByRole('tab', { name: '浏览器' })
|
||||||
|
fireEvent.click(browser)
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '关闭工作区' })
|
screen.getByRole('button', { name: '关闭浏览器' })
|
||||||
)
|
)
|
||||||
|
|
||||||
const browser = screen.getByRole('tab', { name: '浏览器' })
|
const results = screen.getByRole('tab', { name: '成果' })
|
||||||
await waitFor(() => expect(browser).toHaveFocus())
|
await waitFor(() => expect(results).toHaveFocus())
|
||||||
expect(browser).toHaveAttribute('aria-selected', 'true')
|
expect(results).toHaveAttribute('aria-selected', 'true')
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole('tab', { name: '工作区' })
|
screen.queryByRole('tab', { name: '浏览器' })
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -234,19 +278,39 @@ describe('WorkbarShell', () => {
|
|||||||
render(<ControlledShell />)
|
render(<ControlledShell />)
|
||||||
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '关闭成果' })
|
screen.getByRole('button', { name: '关闭浏览器' })
|
||||||
)
|
)
|
||||||
|
|
||||||
await waitFor(() => expect(tasks).toHaveFocus())
|
await waitFor(() => expect(tasks).toHaveFocus())
|
||||||
expect(tasks).toHaveAttribute('aria-selected', 'true')
|
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 () => {
|
it('shows the empty state while preserving the add command after the last close', async () => {
|
||||||
render(
|
render(
|
||||||
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
|
<ControlledShell initialInstances={[terminalOne]} />
|
||||||
)
|
)
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '关闭任务中心' })
|
screen.getByRole('button', { name: `关闭${terminalOne.title}` })
|
||||||
)
|
)
|
||||||
|
|
||||||
const addButton = screen.getByRole('button', {
|
const addButton = screen.getByRole('button', {
|
||||||
@@ -256,6 +320,9 @@ describe('WorkbarShell', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText('还没有打开的工作栏应用')
|
screen.getByText('还没有打开的工作栏应用')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('active-workbar-instance')).toHaveTextContent(
|
||||||
|
'none'
|
||||||
|
)
|
||||||
expect(addButton).toBeVisible()
|
expect(addButton).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export type WorkbarShellProps = {
|
|||||||
activeInstanceId: string | null
|
activeInstanceId: string | null
|
||||||
renderPanel: (instance: WorkbarTabInstance) => ReactNode
|
renderPanel: (instance: WorkbarTabInstance) => ReactNode
|
||||||
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
|
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
|
||||||
onActiveInstanceChange: (instanceId: string) => void
|
onActiveInstanceChange: (instanceId: string | null) => void
|
||||||
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
|
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
|
||||||
onCloseInstance: (
|
onCloseInstance: (
|
||||||
instance: WorkbarTabInstance
|
instance: WorkbarTabInstance
|
||||||
@@ -289,6 +289,12 @@ export function WorkbarShell({
|
|||||||
const handleClose = async (
|
const handleClose = async (
|
||||||
instance: WorkbarTabInstance
|
instance: WorkbarTabInstance
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
const definition = appDefinitions.find(
|
||||||
|
(candidate) => candidate.id === instance.appId
|
||||||
|
)
|
||||||
|
if (definition?.closable !== true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const closingIndex = instances.findIndex(
|
const closingIndex = instances.findIndex(
|
||||||
(candidate) => candidate.id === instance.id
|
(candidate) => candidate.id === instance.id
|
||||||
)
|
)
|
||||||
@@ -299,19 +305,18 @@ export function WorkbarShell({
|
|||||||
null)
|
null)
|
||||||
: activeInstance
|
: activeInstance
|
||||||
|
|
||||||
const closeResult = onCloseInstance(instance)
|
let closeAccepted: boolean | void
|
||||||
const closeAccepted =
|
try {
|
||||||
closeResult &&
|
closeAccepted = await onCloseInstance(instance)
|
||||||
typeof (closeResult as PromiseLike<boolean | void>).then ===
|
} catch {
|
||||||
'function'
|
return
|
||||||
? await closeResult
|
}
|
||||||
: closeResult
|
|
||||||
if (closeAccepted === false) {
|
if (closeAccepted === false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pendingTabFocusRef.current = nextActive?.id ?? null
|
pendingTabFocusRef.current = nextActive?.id ?? null
|
||||||
if (nextActive && nextActive.id !== activeInstanceId) {
|
if (isActive && nextActive?.id !== activeInstanceId) {
|
||||||
onActiveInstanceChange(nextActive.id)
|
onActiveInstanceChange(nextActive?.id ?? null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +336,10 @@ export function WorkbarShell({
|
|||||||
const selected =
|
const selected =
|
||||||
!catalogOpen && instance.id === activeInstanceId
|
!catalogOpen && instance.id === activeInstanceId
|
||||||
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
|
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
|
||||||
|
const closable =
|
||||||
|
appDefinitions.find(
|
||||||
|
(definition) => definition.id === instance.appId
|
||||||
|
)?.closable === true
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={joinClassNames(
|
className={joinClassNames(
|
||||||
@@ -376,6 +385,7 @@ export function WorkbarShell({
|
|||||||
</span>
|
</span>
|
||||||
{renderTabAdornment?.(instance)}
|
{renderTabAdornment?.(instance)}
|
||||||
</button>
|
</button>
|
||||||
|
{closable ? (
|
||||||
<button
|
<button
|
||||||
aria-label={t('sidebar.workbar.close', {
|
aria-label={t('sidebar.workbar.close', {
|
||||||
title: instance.title
|
title: instance.title
|
||||||
@@ -389,6 +399,7 @@ export function WorkbarShell({
|
|||||||
>
|
>
|
||||||
<X aria-hidden="true" />
|
<X aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ export const app = {
|
|||||||
switching: 'Switching…',
|
switching: 'Switching…',
|
||||||
picker: 'Runtime and model',
|
picker: 'Runtime and model',
|
||||||
directModels: 'Direct models',
|
directModels: 'Direct models',
|
||||||
|
defaultDirect: 'Direct · Follow the global default model',
|
||||||
|
fixedRuntimeModel: 'Fixed Runtime model',
|
||||||
|
fixedModel: 'Fixed',
|
||||||
deepseekHarnessGroup:
|
deepseekHarnessGroup:
|
||||||
'DeepSeek Harness (Developer preview · OpenAI-compatible)',
|
'DeepSeek Harness (Developer preview · OpenAI-compatible)',
|
||||||
manage: 'Manage Runtime and model connections',
|
manage: 'Manage Runtime and model connections',
|
||||||
|
|||||||
@@ -310,6 +310,14 @@ export const workspace = {
|
|||||||
startedAt: 'Started {{time}}',
|
startedAt: 'Started {{time}}',
|
||||||
nextRunAt: 'Next run: {{time}}',
|
nextRunAt: 'Next run: {{time}}',
|
||||||
notStarted: 'Not run yet',
|
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: {
|
filters: {
|
||||||
ariaLabel: 'Filter tasks',
|
ariaLabel: 'Filter tasks',
|
||||||
attention: 'Attention',
|
attention: 'Attention',
|
||||||
@@ -365,6 +373,7 @@ export const workspace = {
|
|||||||
},
|
},
|
||||||
browser: {
|
browser: {
|
||||||
title: 'Live browser',
|
title: 'Live browser',
|
||||||
|
unbound: 'No conversation',
|
||||||
interact: 'Interact',
|
interact: 'Interact',
|
||||||
interacting: 'Interacting',
|
interacting: 'Interacting',
|
||||||
close: 'Close browser',
|
close: 'Close browser',
|
||||||
@@ -407,6 +416,8 @@ export const workspace = {
|
|||||||
backBrowser: 'Could not go back',
|
backBrowser: 'Could not go back',
|
||||||
reloadBrowser: 'Could not refresh the web page',
|
reloadBrowser: 'Could not refresh the web page',
|
||||||
stopLoadingBrowser: 'Could not stop loading the 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',
|
interactBrowser: 'Could not open the browser interaction window',
|
||||||
stopBrowser: 'Could not close the browser'
|
stopBrowser: 'Could not close the browser'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ export const app = {
|
|||||||
switching: '切换中…',
|
switching: '切换中…',
|
||||||
picker: 'Runtime 和模型',
|
picker: 'Runtime 和模型',
|
||||||
directModels: '直连模型',
|
directModels: '直连模型',
|
||||||
|
defaultDirect: '直连 · 跟随全局默认模型',
|
||||||
|
fixedRuntimeModel: '固定 Runtime 模型',
|
||||||
|
fixedModel: '固定',
|
||||||
deepseekHarnessGroup:
|
deepseekHarnessGroup:
|
||||||
'DeepSeek Harness(开发者预览 · OpenAI 兼容)',
|
'DeepSeek Harness(开发者预览 · OpenAI 兼容)',
|
||||||
manage: '管理 Runtime 和模型连接',
|
manage: '管理 Runtime 和模型连接',
|
||||||
|
|||||||
@@ -302,6 +302,13 @@ export const workspace = {
|
|||||||
startedAt: '{{time}} 开始',
|
startedAt: '{{time}} 开始',
|
||||||
nextRunAt: '下次运行:{{time}}',
|
nextRunAt: '下次运行:{{time}}',
|
||||||
notStarted: '尚未运行',
|
notStarted: '尚未运行',
|
||||||
|
scope: {
|
||||||
|
ariaLabel: '任务范围',
|
||||||
|
currentProject: '当前项目',
|
||||||
|
global: '全局',
|
||||||
|
allProjects: '所有项目',
|
||||||
|
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
|
||||||
|
},
|
||||||
filters: {
|
filters: {
|
||||||
ariaLabel: '筛选任务',
|
ariaLabel: '筛选任务',
|
||||||
attention: '待关注',
|
attention: '待关注',
|
||||||
@@ -355,6 +362,7 @@ export const workspace = {
|
|||||||
},
|
},
|
||||||
browser: {
|
browser: {
|
||||||
title: '实时浏览器',
|
title: '实时浏览器',
|
||||||
|
unbound: '未绑定会话',
|
||||||
interact: '交互',
|
interact: '交互',
|
||||||
interacting: '交互中',
|
interacting: '交互中',
|
||||||
close: '关闭浏览器',
|
close: '关闭浏览器',
|
||||||
@@ -397,6 +405,8 @@ export const workspace = {
|
|||||||
backBrowser: '返回上一页失败',
|
backBrowser: '返回上一页失败',
|
||||||
reloadBrowser: '刷新网页失败',
|
reloadBrowser: '刷新网页失败',
|
||||||
stopLoadingBrowser: '停止加载失败',
|
stopLoadingBrowser: '停止加载失败',
|
||||||
|
closeBrowser: '关闭此浏览器 Tab 失败',
|
||||||
|
browserControlUnavailable: '浏览器 Tab 控制不可用',
|
||||||
interactBrowser: '打开浏览器交互窗口失败',
|
interactBrowser: '打开浏览器交互窗口失败',
|
||||||
stopBrowser: '关闭浏览器失败'
|
stopBrowser: '关闭浏览器失败'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function harnessSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DeepSeek Harness runtime selection', () => {
|
describe('DeepSeek Harness runtime selection', () => {
|
||||||
it('uses the configured Chat Completions profile', () => {
|
it('references the Runtime configuration without copying its profile', () => {
|
||||||
const selection = getRuntimeSelectionForProvider(
|
const selection = getRuntimeSelectionForProvider(
|
||||||
'deepseek-harness',
|
'deepseek-harness',
|
||||||
harnessSettings({
|
harnessSettings({
|
||||||
@@ -27,8 +27,7 @@ describe('DeepSeek Harness runtime selection', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(selection).toEqual({
|
expect(selection).toEqual({
|
||||||
provider: 'deepseek-harness',
|
provider: 'deepseek-harness'
|
||||||
profileId: harnessProfileId
|
|
||||||
} satisfies Record<string, string>)
|
} satisfies Record<string, string>)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
agentRequestSchema,
|
agentRequestSchema,
|
||||||
|
browserCloseTabRequestSchema,
|
||||||
|
browserCreateTabRequestSchema,
|
||||||
browserLiveStateSchema,
|
browserLiveStateSchema,
|
||||||
browserNavigateRequestSchema,
|
browserNavigateRequestSchema,
|
||||||
|
browserSetViewportRequestSchema,
|
||||||
browserStopLoadingRequestSchema,
|
browserStopLoadingRequestSchema,
|
||||||
builtinEmbeddingConnectionId,
|
builtinEmbeddingConnectionId,
|
||||||
clipboardTextSchema,
|
clipboardTextSchema,
|
||||||
@@ -23,9 +26,14 @@ describe('clipboard text contract', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('browser control contracts', () => {
|
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', () => {
|
it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => {
|
||||||
const base = {
|
const base = {
|
||||||
conversationId: 'conversation',
|
conversationId: 'conversation',
|
||||||
|
tabId,
|
||||||
status: 'ready' as const,
|
status: 'ready' as const,
|
||||||
sessionActive: true,
|
sessionActive: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -51,10 +59,12 @@ describe('browser control contracts', () => {
|
|||||||
expect(
|
expect(
|
||||||
browserNavigateRequestSchema.parse({
|
browserNavigateRequestSchema.parse({
|
||||||
conversationId: 'conversation',
|
conversationId: 'conversation',
|
||||||
|
tabId,
|
||||||
url: 'https://example.com/'
|
url: 'https://example.com/'
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
conversationId: 'conversation',
|
conversationId: 'conversation',
|
||||||
|
tabId,
|
||||||
url: 'https://example.com/'
|
url: 'https://example.com/'
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
@@ -70,6 +80,48 @@ describe('browser control contracts', () => {
|
|||||||
}).success
|
}).success
|
||||||
).toBe(true)
|
).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 = {
|
const baseInput = {
|
||||||
|
|||||||
+181
-33
@@ -30,8 +30,6 @@ import {
|
|||||||
actorConversationSubagentActivitySchema,
|
actorConversationSubagentActivitySchema,
|
||||||
legacyConversationSubagentActivitySchema,
|
legacyConversationSubagentActivitySchema,
|
||||||
legacyWorkModeSchema,
|
legacyWorkModeSchema,
|
||||||
maximumConversationHistoryCharacters,
|
|
||||||
maximumConversationHistoryMessages,
|
|
||||||
type AssistantProject,
|
type AssistantProject,
|
||||||
type AssistantArtifact,
|
type AssistantArtifact,
|
||||||
type AssistantMemory,
|
type AssistantMemory,
|
||||||
@@ -346,11 +344,9 @@ export const agentRequestSchema = z
|
|||||||
contextIds: z.array(z.string().uuid()).max(8).optional(),
|
contextIds: z.array(z.string().uuid()).max(8).optional(),
|
||||||
history: z
|
history: z
|
||||||
.array(conversationHistoryMessageSchema)
|
.array(conversationHistoryMessageSchema)
|
||||||
.max(maximumConversationHistoryMessages)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
historyMessageIds: z
|
historyMessageIds: z
|
||||||
.array(z.string().uuid())
|
.array(z.string().uuid())
|
||||||
.max(maximumConversationHistoryMessages)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
currentUserMessageId: z.string().uuid().optional(),
|
currentUserMessageId: z.string().uuid().optional(),
|
||||||
currentAssistantMessageId: z.string().uuid().optional(),
|
currentAssistantMessageId: z.string().uuid().optional(),
|
||||||
@@ -359,18 +355,6 @@ export const agentRequestSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((request, context) => {
|
.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 (
|
if (
|
||||||
request.historyMessageIds &&
|
request.historyMessageIds &&
|
||||||
request.historyMessageIds.length !==
|
request.historyMessageIds.length !==
|
||||||
@@ -683,6 +667,7 @@ const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [
|
|||||||
])
|
])
|
||||||
|
|
||||||
export const runtimeModelSourceSchema = 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({ kind: z.literal('platform') }).strict(),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -1373,10 +1358,13 @@ export type AppInfo = {
|
|||||||
shortcutStatus?: GlobalShortcutRegistrationStatus
|
shortcutStatus?: GlobalShortcutRegistrationStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export const browserLiveStateSchema = z
|
export const browserTabIdSchema = z.string().uuid().brand<'BrowserTabId'>()
|
||||||
.object({
|
export type BrowserTabId = z.infer<typeof browserTabIdSchema>
|
||||||
conversationId: conversationIdSchema,
|
|
||||||
status: z.enum([
|
export const browserWorkbarInstanceIdSchema = z.string().uuid()
|
||||||
|
export const browserViewportLeaseTokenSchema = z.string().uuid()
|
||||||
|
|
||||||
|
const browserStatusSchema = z.enum([
|
||||||
'creating',
|
'creating',
|
||||||
'loading',
|
'loading',
|
||||||
'ready',
|
'ready',
|
||||||
@@ -1384,7 +1372,14 @@ export const browserLiveStateSchema = z
|
|||||||
'interactive',
|
'interactive',
|
||||||
'failed',
|
'failed',
|
||||||
'stopped'
|
'stopped'
|
||||||
]),
|
])
|
||||||
|
|
||||||
|
export const browserLiveStateSchema = z
|
||||||
|
.object({
|
||||||
|
conversationId: conversationIdSchema,
|
||||||
|
tabId: browserTabIdSchema,
|
||||||
|
ownerWindowId: z.number().int().nonnegative().optional(),
|
||||||
|
status: browserStatusSchema,
|
||||||
sessionActive: z.boolean(),
|
sessionActive: z.boolean(),
|
||||||
isLoading: z.boolean(),
|
isLoading: z.boolean(),
|
||||||
canGoBack: z.boolean(),
|
canGoBack: z.boolean(),
|
||||||
@@ -1404,19 +1399,73 @@ export const browserLiveStateSchema = z
|
|||||||
|
|
||||||
export type BrowserLiveState = z.infer<typeof browserLiveStateSchema>
|
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
|
export const browserStopRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
conversationId: conversationIdSchema
|
conversationId: conversationIdSchema
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
export const browserBackRequestSchema = browserStopRequestSchema
|
const browserCompatibleTabRequestSchema = z
|
||||||
export const browserReloadRequestSchema = browserStopRequestSchema
|
.object({
|
||||||
export const browserStopLoadingRequestSchema = browserStopRequestSchema
|
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
|
export const browserSetViewportRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
conversationId: conversationIdSchema.optional(),
|
conversationId: conversationIdSchema.optional(),
|
||||||
|
tabId: browserTabIdSchema.optional(),
|
||||||
|
leaseToken: browserViewportLeaseTokenSchema,
|
||||||
bounds: z
|
bounds: z
|
||||||
.object({
|
.object({
|
||||||
x: z.number().int().nonnegative(),
|
x: z.number().int().nonnegative(),
|
||||||
@@ -1429,7 +1478,9 @@ export const browserSetViewportRequestSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.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
|
export const browserNavigateRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
conversationId: conversationIdSchema,
|
conversationId: conversationIdSchema,
|
||||||
|
tabId: browserTabIdSchema.optional(),
|
||||||
url: z.string().min(1).max(8_192)
|
url: z.string().min(1).max(8_192)
|
||||||
})
|
})
|
||||||
.strict()
|
.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 knowledgeIdSchema = z.string().uuid()
|
||||||
export const knowledgeCreateSchema = z
|
export const knowledgeCreateSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -1637,14 +1768,31 @@ export type DesktopApi = {
|
|||||||
onEvent: (listener: (event: AgentEvent) => void) => () => void
|
onEvent: (listener: (event: AgentEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
browser: {
|
browser: {
|
||||||
navigate: (conversationId: string, url: string) => Promise<void>
|
createTab?: (request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>
|
||||||
back: (conversationId: string) => Promise<void>
|
listTabs?: (request: BrowserListTabsRequest) => Promise<BrowserTabSummary[]>
|
||||||
reload: (conversationId: string) => Promise<void>
|
closeTab?: (request: BrowserCloseTabRequest) => Promise<void>
|
||||||
stopLoading: (conversationId: string) => Promise<void>
|
navigate: {
|
||||||
setViewport: (
|
(request: BrowserNavigateRequest): Promise<void>
|
||||||
conversationId?: string,
|
(conversationId: string, url: string): Promise<void>
|
||||||
bounds?: BrowserViewportBounds
|
}
|
||||||
) => 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>
|
stop: (conversationId: string) => Promise<void>
|
||||||
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,18 @@ export const ipcChannels = {
|
|||||||
agentQuestionRespond: 'agent:question:respond',
|
agentQuestionRespond: 'agent:question:respond',
|
||||||
agentCompactConversation: 'agent:context:compact',
|
agentCompactConversation: 'agent:context:compact',
|
||||||
agentEvent: 'agent:event',
|
agentEvent: 'agent:event',
|
||||||
|
browserCreateTab: 'browser:tab:create',
|
||||||
|
browserListTabs: 'browser:tab:list',
|
||||||
|
browserCloseTab: 'browser:tab:close',
|
||||||
browserNavigate: 'browser:navigate',
|
browserNavigate: 'browser:navigate',
|
||||||
browserBack: 'browser:back',
|
browserBack: 'browser:back',
|
||||||
browserReload: 'browser:reload',
|
browserReload: 'browser:reload',
|
||||||
browserStopLoading: 'browser:stop-loading',
|
browserStopLoading: 'browser:stop-loading',
|
||||||
|
browserSnapshot: 'browser:snapshot',
|
||||||
|
browserClick: 'browser:click',
|
||||||
|
browserType: 'browser:type',
|
||||||
|
browserSelect: 'browser:select',
|
||||||
|
browserScreenshot: 'browser:screenshot',
|
||||||
browserSetViewport: 'browser:set-viewport',
|
browserSetViewport: 'browser:set-viewport',
|
||||||
browserStop: 'browser:stop',
|
browserStop: 'browser:stop',
|
||||||
browserState: 'browser:state',
|
browserState: 'browser:state',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const agentRuntimeSelectionSchema = z.discriminatedUnion(
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
provider: z.literal('model'),
|
provider: z.literal('model'),
|
||||||
profileId: runtimeSelectionProfileIdSchema
|
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
z
|
z
|
||||||
@@ -48,17 +48,21 @@ export type RuntimeSelectionRepairSettings = {
|
|||||||
}>
|
}>
|
||||||
defaultModelProfileId: string
|
defaultModelProfileId: string
|
||||||
opencodeModelSource:
|
opencodeModelSource:
|
||||||
|
| { kind: 'default' }
|
||||||
| { kind: 'platform' }
|
| { kind: 'platform' }
|
||||||
| { kind: 'profile'; profileId: string }
|
| { kind: 'profile'; profileId: string }
|
||||||
continueModelSource:
|
continueModelSource:
|
||||||
|
| { kind: 'default' }
|
||||||
| { kind: 'platform' }
|
| { kind: 'platform' }
|
||||||
| { kind: 'profile'; profileId: string }
|
| { kind: 'profile'; profileId: string }
|
||||||
deepseekHarnessModelSource?:
|
deepseekHarnessModelSource?:
|
||||||
|
| { kind: 'default' }
|
||||||
| { kind: 'platform' }
|
| { kind: 'platform' }
|
||||||
| { kind: 'profile'; profileId: string }
|
| { kind: 'profile'; profileId: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeModelSource =
|
type RuntimeModelSource =
|
||||||
|
| { kind: 'default' }
|
||||||
| { kind: 'platform' }
|
| { kind: 'platform' }
|
||||||
| { kind: 'profile'; profileId: string }
|
| { kind: 'profile'; profileId: string }
|
||||||
|
|
||||||
@@ -79,34 +83,9 @@ type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][numbe
|
|||||||
|
|
||||||
export function getRuntimeSelectionForProvider(
|
export function getRuntimeSelectionForProvider(
|
||||||
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
|
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
|
||||||
settings: RuntimeSelectionDefaultSettings
|
_settings: RuntimeSelectionDefaultSettings
|
||||||
): AgentRuntimeSelection {
|
): AgentRuntimeSelection {
|
||||||
if (provider === 'model') {
|
return { provider }
|
||||||
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 }
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultRuntimeSelection(
|
export function getDefaultRuntimeSelection(
|
||||||
@@ -121,6 +100,20 @@ export function getDefaultRuntimeSelection(
|
|||||||
: getRuntimeSelectionForProvider('model', settings)
|
: 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(
|
export function isChannelModelProfileUsable(
|
||||||
profile: ChannelModelProfile
|
profile: ChannelModelProfile
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -162,11 +155,7 @@ export function repairChannelRuntimeSelection(
|
|||||||
isChannelModelProfileUsable(profile)
|
isChannelModelProfileUsable(profile)
|
||||||
) ??
|
) ??
|
||||||
settings.modelProfiles.find(isChannelModelProfileUsable)
|
settings.modelProfiles.find(isChannelModelProfileUsable)
|
||||||
const defaultDirectSelection: AgentRuntimeSelection = {
|
const defaultDirectSelection: AgentRuntimeSelection = { provider: 'model' }
|
||||||
provider: 'model',
|
|
||||||
profileId:
|
|
||||||
defaultDirectProfile?.id ?? settings.defaultModelProfileId
|
|
||||||
}
|
|
||||||
if (selection.provider === 'auto') {
|
if (selection.provider === 'auto') {
|
||||||
return defaultDirectSelection
|
return defaultDirectSelection
|
||||||
}
|
}
|
||||||
@@ -196,7 +185,7 @@ export function repairChannelRuntimeSelection(
|
|||||||
return repaired
|
return repaired
|
||||||
}
|
}
|
||||||
const profile = settings.modelProfiles.find(
|
const profile = settings.modelProfiles.find(
|
||||||
(candidate) => candidate.id === repaired.profileId
|
(candidate) => candidate.id === (repaired.profileId ?? defaultDirectProfile?.id)
|
||||||
)
|
)
|
||||||
return profile && isChannelModelProfileUsable(profile)
|
return profile && isChannelModelProfileUsable(profile)
|
||||||
? repaired
|
? repaired
|
||||||
@@ -217,29 +206,15 @@ export function repairAgentRuntimeSelection(
|
|||||||
return selection
|
return selection
|
||||||
}
|
}
|
||||||
if (selection.provider === 'model') {
|
if (selection.provider === 'model') {
|
||||||
return {
|
return { provider: 'model' }
|
||||||
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: selection.provider }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function agentRuntimeSelectionKey(
|
export function agentRuntimeSelectionKey(
|
||||||
selection: AgentRuntimeSelection
|
selection: AgentRuntimeSelection
|
||||||
): string {
|
): string {
|
||||||
return `${selection.provider}:${'profileId' in selection
|
return `${selection.provider}:${'profileId' in selection
|
||||||
? selection.profileId ?? 'platform'
|
? selection.profileId ?? 'default'
|
||||||
: 'default'}`
|
: 'default'}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { workbarTargetRefSchema } from './workbar-contracts'
|
import { workbarExecutionTargetRefSchema } from './workbar-contracts'
|
||||||
|
|
||||||
export const TERMINAL_LIMITS = {
|
export const TERMINAL_LIMITS = {
|
||||||
maximumSessionsPerWindow: 12,
|
maximumSessionsPerWindow: 12,
|
||||||
@@ -35,7 +35,7 @@ export type TerminalSessionId = z.infer<
|
|||||||
typeof terminalSessionIdSchema
|
typeof terminalSessionIdSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
export const terminalTargetSchema = workbarTargetRefSchema
|
export const terminalTargetSchema = workbarExecutionTargetRefSchema
|
||||||
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
|
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
|
||||||
|
|
||||||
export const terminalSessionStateSchema = z.enum([
|
export const terminalSessionStateSchema = z.enum([
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
WORKBAR_APP_DEFINITIONS,
|
WORKBAR_APP_DEFINITIONS,
|
||||||
WORKBAR_LIMITS,
|
WORKBAR_LIMITS,
|
||||||
|
normalizeWorkbarLayoutPreferences,
|
||||||
workbarAppDefinitionSchema,
|
workbarAppDefinitionSchema,
|
||||||
workbarLayoutPreferencesSchema,
|
workbarLayoutPreferencesSchema,
|
||||||
workbarTabInstanceSchema
|
workbarTabInstanceSchema
|
||||||
@@ -14,20 +15,28 @@ const projectId = '00000000-0000-4000-8000-000000000201'
|
|||||||
describe('workbar contracts', () => {
|
describe('workbar contracts', () => {
|
||||||
it('freezes the first application catalog and instance policies', () => {
|
it('freezes the first application catalog and instance policies', () => {
|
||||||
expect(
|
expect(
|
||||||
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({
|
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultContext, defaultOpen, required, closable, reorderable }) => ({
|
||||||
id,
|
id,
|
||||||
instancePolicy,
|
instancePolicy,
|
||||||
defaultOpen
|
defaultContext,
|
||||||
|
defaultOpen,
|
||||||
|
required,
|
||||||
|
closable,
|
||||||
|
reorderable
|
||||||
}))
|
}))
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ id: 'tasks', instancePolicy: 'single', defaultOpen: true },
|
{ id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
|
||||||
{ id: 'workspace', instancePolicy: 'single', defaultOpen: true },
|
{ id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
|
||||||
{ id: 'browser', instancePolicy: 'single', defaultOpen: true },
|
{ id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true },
|
||||||
{ id: 'results', instancePolicy: 'single', defaultOpen: true },
|
{ id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true },
|
||||||
{
|
{
|
||||||
id: 'terminal',
|
id: 'terminal',
|
||||||
instancePolicy: 'multiple',
|
instancePolicy: 'multiple',
|
||||||
defaultOpen: false
|
defaultContext: 'current-project',
|
||||||
|
defaultOpen: false,
|
||||||
|
required: false,
|
||||||
|
closable: true,
|
||||||
|
reorderable: true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
for (const definition of WORKBAR_APP_DEFINITIONS) {
|
for (const definition of WORKBAR_APP_DEFINITIONS) {
|
||||||
@@ -51,12 +60,52 @@ describe('workbar contracts', () => {
|
|||||||
activeInstanceId: terminalId,
|
activeInstanceId: terminalId,
|
||||||
expanded: true,
|
expanded: true,
|
||||||
dock: 'right',
|
dock: 'right',
|
||||||
widthRatio: 0.35
|
widthRatio: 0.35,
|
||||||
|
taskScope: 'current-project'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout)
|
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', () => {
|
it('rejects unknown and sensitive target parameters', () => {
|
||||||
for (const targetRef of [
|
for (const targetRef of [
|
||||||
{ type: 'local', cwd: 'C:\\secret' },
|
{ type: 'local', cwd: 'C:\\secret' },
|
||||||
@@ -102,7 +151,8 @@ describe('workbar contracts', () => {
|
|||||||
activeInstanceId: taskId,
|
activeInstanceId: taskId,
|
||||||
expanded: true,
|
expanded: true,
|
||||||
dock: 'right',
|
dock: 'right',
|
||||||
widthRatio: 0.4
|
widthRatio: 0.4,
|
||||||
|
taskScope: 'current-project'
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
workbarLayoutPreferencesSchema.safeParse({
|
workbarLayoutPreferencesSchema.safeParse({
|
||||||
@@ -131,4 +181,61 @@ describe('workbar contracts', () => {
|
|||||||
}).success
|
}).success
|
||||||
).toBe(false)
|
).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'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export type WorkbarInstancePolicy = z.infer<
|
|||||||
|
|
||||||
export const workbarDefaultContextSchema = z.enum([
|
export const workbarDefaultContextSchema = z.enum([
|
||||||
'application',
|
'application',
|
||||||
'current-project'
|
'current-project',
|
||||||
|
'current-conversation'
|
||||||
])
|
])
|
||||||
export type WorkbarDefaultContext = z.infer<
|
export type WorkbarDefaultContext = z.infer<
|
||||||
typeof workbarDefaultContextSchema
|
typeof workbarDefaultContextSchema
|
||||||
@@ -78,7 +79,11 @@ export const workbarAppDefinitionSchema = z
|
|||||||
),
|
),
|
||||||
instancePolicy: workbarInstancePolicySchema,
|
instancePolicy: workbarInstancePolicySchema,
|
||||||
defaultContext: workbarDefaultContextSchema,
|
defaultContext: workbarDefaultContextSchema,
|
||||||
|
visibleAcrossContextSwitches: z.boolean(),
|
||||||
defaultOpen: z.boolean(),
|
defaultOpen: z.boolean(),
|
||||||
|
required: z.boolean(),
|
||||||
|
closable: z.boolean(),
|
||||||
|
reorderable: z.boolean(),
|
||||||
availability: workbarAvailabilitySchema
|
availability: workbarAvailabilitySchema
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -93,57 +98,77 @@ export type WorkbarAppDefinition = z.infer<
|
|||||||
export const WORKBAR_APP_DEFINITIONS = [
|
export const WORKBAR_APP_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
id: 'tasks',
|
id: 'tasks',
|
||||||
|
visibleAcrossContextSwitches: true,
|
||||||
label: '任务中心',
|
label: '任务中心',
|
||||||
icon: 'tasks',
|
icon: 'tasks',
|
||||||
description: '查看和管理当前任务。',
|
description: '查看和管理当前任务。',
|
||||||
instancePolicy: 'single',
|
instancePolicy: 'single',
|
||||||
defaultContext: 'current-project',
|
defaultContext: 'current-project',
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
|
required: true,
|
||||||
|
closable: false,
|
||||||
|
reorderable: true,
|
||||||
availability: { state: 'available' }
|
availability: { state: 'available' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'workspace',
|
id: 'workspace',
|
||||||
|
visibleAcrossContextSwitches: true,
|
||||||
label: '工作区',
|
label: '工作区',
|
||||||
icon: 'workspace',
|
icon: 'workspace',
|
||||||
description: '查看当前项目的工作区。',
|
description: '查看当前项目的工作区。',
|
||||||
instancePolicy: 'single',
|
instancePolicy: 'single',
|
||||||
defaultContext: 'current-project',
|
defaultContext: 'current-project',
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
|
required: true,
|
||||||
|
closable: false,
|
||||||
|
reorderable: true,
|
||||||
availability: { state: 'available' }
|
availability: { state: 'available' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'browser',
|
id: 'browser',
|
||||||
|
visibleAcrossContextSwitches: true,
|
||||||
label: '浏览器',
|
label: '浏览器',
|
||||||
icon: 'browser',
|
icon: 'browser',
|
||||||
description: '浏览任务相关内容。',
|
description: '浏览任务相关内容。',
|
||||||
instancePolicy: 'single',
|
instancePolicy: 'multiple',
|
||||||
defaultContext: 'application',
|
defaultContext: 'current-conversation',
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
|
required: false,
|
||||||
|
closable: true,
|
||||||
|
reorderable: true,
|
||||||
availability: { state: 'available' }
|
availability: { state: 'available' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'results',
|
id: 'results',
|
||||||
|
visibleAcrossContextSwitches: true,
|
||||||
label: '成果',
|
label: '成果',
|
||||||
icon: 'results',
|
icon: 'results',
|
||||||
description: '查看任务生成的成果。',
|
description: '查看任务生成的成果。',
|
||||||
instancePolicy: 'single',
|
instancePolicy: 'single',
|
||||||
defaultContext: 'current-project',
|
defaultContext: 'current-project',
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
|
required: false,
|
||||||
|
closable: true,
|
||||||
|
reorderable: true,
|
||||||
availability: { state: 'available' }
|
availability: { state: 'available' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'terminal',
|
id: 'terminal',
|
||||||
|
visibleAcrossContextSwitches: true,
|
||||||
label: '终端',
|
label: '终端',
|
||||||
icon: 'terminal',
|
icon: 'terminal',
|
||||||
description: '打开当前项目的用户终端。',
|
description: '打开当前项目的用户终端。',
|
||||||
instancePolicy: 'multiple',
|
instancePolicy: 'multiple',
|
||||||
defaultContext: 'current-project',
|
defaultContext: 'current-project',
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
|
required: false,
|
||||||
|
closable: true,
|
||||||
|
reorderable: true,
|
||||||
availability: { state: 'available' }
|
availability: { state: 'available' }
|
||||||
}
|
}
|
||||||
] as const satisfies readonly WorkbarAppDefinition[]
|
] 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({ type: z.literal('local') }).strict(),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -152,6 +177,19 @@ export const workbarTargetRefSchema = z.discriminatedUnion('type', [
|
|||||||
})
|
})
|
||||||
.strict()
|
.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<
|
export type WorkbarTargetRef = z.infer<
|
||||||
typeof workbarTargetRefSchema
|
typeof workbarTargetRefSchema
|
||||||
>
|
>
|
||||||
@@ -165,13 +203,37 @@ export const workbarTabInstanceSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((instance, context) => {
|
.superRefine((instance, context) => {
|
||||||
if (instance.appId === 'terminal' && !instance.targetRef) {
|
if (
|
||||||
|
instance.appId === 'terminal' &&
|
||||||
|
(!instance.targetRef || instance.targetRef.type === 'conversation')
|
||||||
|
) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['targetRef'],
|
path: ['targetRef'],
|
||||||
message: 'Terminal instances require a public target reference'
|
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<
|
export type WorkbarTabInstance = z.infer<
|
||||||
typeof workbarTabInstanceSchema
|
typeof workbarTabInstanceSchema
|
||||||
@@ -183,7 +245,14 @@ const singleInstanceAppIds = new Set<WorkbarAppId>(
|
|||||||
).map((definition) => definition.id)
|
).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({
|
.object({
|
||||||
instances: z
|
instances: z
|
||||||
.array(workbarTabInstanceSchema)
|
.array(workbarTabInstanceSchema)
|
||||||
@@ -191,9 +260,12 @@ export const workbarLayoutPreferencesSchema = z
|
|||||||
activeInstanceId: z.string().uuid().nullable(),
|
activeInstanceId: z.string().uuid().nullable(),
|
||||||
expanded: z.boolean(),
|
expanded: z.boolean(),
|
||||||
dock: z.literal('right'),
|
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()
|
.strict()
|
||||||
|
|
||||||
|
export const workbarLayoutPreferencesSchema = workbarLayoutShapeSchema
|
||||||
.superRefine((layout, context) => {
|
.superRefine((layout, context) => {
|
||||||
const instanceIds = new Set<string>()
|
const instanceIds = new Set<string>()
|
||||||
const seenSingleApps = new Set<WorkbarAppId>()
|
const seenSingleApps = new Set<WorkbarAppId>()
|
||||||
@@ -244,3 +316,77 @@ export const workbarLayoutPreferencesSchema = z
|
|||||||
export type WorkbarLayoutPreferences = z.infer<
|
export type WorkbarLayoutPreferences = z.infer<
|
||||||
typeof workbarLayoutPreferencesSchema
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user