diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 4fe74e04..3aef79f9 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -103,7 +103,7 @@ - [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。 - [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。 - [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;默认不上传诊断,用户可主动附加有界的最近桌面诊断摘要。失败后保留草稿、诊断选择和同一请求编号;不会附加对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。 -- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。 +- [x] **内网兼容**:允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书;反馈、微信凭据与媒体等固定平台服务仍保持严格证书校验。 ### 开源、构建与发布 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 3ad0a43c..92ac6946 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -669,8 +669,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 成果查看器,不撑高列表,也不显示 Job/Run 树。 - “新建定制任务”使用共享 Modal,明确选择当前或新 Conversation。聊天入口默认当前 Conversation,Task Center 入口默认新 Conversation;选择必须持续可见。 -- 创建 Modal 默认 Execute,并允许主动切换 Ask。Execute 持续显示实际 Runtime、Project、 - 工作目录、工具和审批摘要;不支持工具时不得静默降级。 +- 定时消息创建 Modal 使用关联 Conversation 的 Runtime 与 Ask/Execute 模式,不再提供独立 + 模式开关。未运行计划的详情显示会话当前模式,已运行任务显示该次执行记录的模式; + 不得用旧计划字段表示下一次执行权限。不支持工具时不得静默降级。 ### 13.6 运行记录 diff --git a/docs/features/assistant-workbar/browser-tabs-technical-design.md b/docs/features/assistant-workbar/browser-tabs-technical-design.md index 5983d743..b7374225 100644 --- a/docs/features/assistant-workbar/browser-tabs-technical-design.md +++ b/docs/features/assistant-workbar/browser-tabs-technical-design.md @@ -76,6 +76,9 @@ Record> 非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏 收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。 +普通导航、快照与页面操作只同步轻量导航状态,不自动截图、等待画面或传输 JPEG;只有显式 +`browser_screenshot` 调用生成图片。侧栏内终端关闭确认显示期间,同样隐藏原生浏览器视图, +确认或取消后恢复当前实例,避免原生子视图遮挡 HTML 确认按钮。 浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。 切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B @@ -84,6 +87,11 @@ Record> Tab 的跨会话可见性与 MCP 授权分开处理。B 的请求不能选择属于 A 的可见 Tab;A 已取得的 capability 在用户切到 B 后仍固定路由至 A 的原 Tab,使用租约也继续阻止关闭该 Tab。 +内部点击实例只同步应用类型,不再次按当前 Conversation 改选实例。Main 状态携带对应的 +`workbarInstanceId`,Renderer 据此绑定请求创建的同一 Tab,而不是另建空白页。收到 `stopped` +后清除该实例的旧 Tab 绑定,下一次使用通过原逻辑实例重新创建,保留地址草稿与错误反馈。 +请求创建的实例也遵守现有 32 个工作栏页签上限;满额时保留 Main 页面及其状态,并就地提示 +关闭一个可关闭页签。释放位置后再绑定该页面,不写入超限布局或丢弃 Agent 页面。 ## 5. IPC 与服务接口 @@ -116,7 +124,12 @@ Browser Tab。 1. 当前可见且绑定该 Conversation 的 Browser Tab。 2. 该 Conversation 的 `primaryTabId`。 -3. 新建一个 primary Browser Tab。 +3. 预留 primary Browser Tab 身份,首次实际浏览器操作时才创建页面与 Conversation Context。 + +未使用浏览器的请求只持有随请求释放的内存预留,不创建 Chromium 资源、不触发浏览器 UI、 +也不占用活动浏览器会话名额。物化后使用同一 Tab 身份和工作栏实例归属;取消、能力撤销及 +服务关闭释放未使用的预留,不保存到磁盘。编程 Subagent 继承父请求的 Browser Tab 与浏览器 +Conversation 归属,不新建租约或把页面重新归属到子级对话。 直连模型上下文和请求级 MCP capability 同时保存 `conversationId` 与 `boundBrowserTabId`。请求执行期间,用户切换、创建或关闭其他工作栏 Tab 不改变工具目标; diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index ae097e41..3030f32b 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -403,6 +403,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 支持方向键、Home、End 和 Escape,Escape 或选定操作后焦点返回触发按钮,删除入口独立分隔。 菜单提供重命名、移动、删除、属性、复制路径和本机默认应用打开;删除须明确确认对象 与不可恢复性。操作失败保留输入与上下文。本机和远程均支持文件管理,系统默认打开仅本机可用。 + 重命名允许仅修改同一目录项的名称大小写,不得覆盖另一个已存在的文件、目录或硬链接目录项。 移动通过与创建项目相同的目录选择流程指定目标:本机打开系统目录选择器,远程浏览当前 SSH Host 的目录。选择目录后保留原名,确认后执行;取消不移动文件。目标限于当前工作区, 选择外部目录时在弹窗内说明限制,不切换项目或工作区范围。取消按钮使用共享次按钮样式, @@ -416,6 +417,8 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 - FR-W6:Git 工作区底部提供默认收起的提交历史区,独立滚动,可调整高度。按当前分支 分页读取提交,展示摘要、作者、时间、短 OID 和 refs。选中提交显示该次修改文件, 再选文件进入内联提交 Diff;逐层返回保留分支、分页、选择、滚动位置与焦点。 + 提交 Diff 保留重命名前后路径和实际内容修改;后台工作区刷新不得使正在加载的历史或详情 + 永久停留在忙碌状态。 历史展开入口使用带方向箭头的紧凑列表行;提交行沿用文件列表的表面、悬停和焦点样式, 摘要与元信息左对齐分行展示,长内容省略并提供完整提示,不撑宽侧栏。 - Git 错误和无更改提示只在 Git 工作区显示,不阻断文件浏览。 @@ -429,7 +432,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 - 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。 刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。 - Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。 -- 手动刷新或任务结束更新 Git 状态时,同步重读根目录和已展开目录,保留目录展开状态, +- 手动刷新或任务结束更新 Git 状态时,同步重读根目录、当前浏览目录和已展开目录并去重,保留目录展开状态, 刷新失败保留已有目录列表;重新展开目录时重读。目录读取错误在对应目录旁显示, 可就地重试。正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。 无首次提交的仓库也应显示新增和暂存文件。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 1d638c99..8b932f94 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,47 @@ # 工作栏实现与验证进度 +## 2026-09-10 提交审查修复 + +- [FR-W3、FR-W6](./prd.md#76-工作区):同一目录项可仅修改大小写,仍拒绝覆盖其他文件、 + 目录和硬链接目录项;提交 Diff 同时使用重命名前后路径;分支后台刷新不再使历史请求 + 遗留 busy;自动目录刷新包含当前浏览目录并去重。 +- 浏览器请求仅预留目标身份,首次导航才创建实际资源;Main 状态携带工作栏实例身份, + Renderer 直接绑定同一页面,停止或回收后清除旧绑定。跨会话显式选择不会被应用类型 + 同步覆盖,终端关闭确认期间释放原生浏览器 viewport。规则以 + [浏览器多实例技术设计](./browser-tabs-technical-design.md) 为准。 +- 删除普通浏览器操作的自动截图、固定等待、重试及 JPEG 状态缓存/IPC 字段,保留显式截图。 +- 整合复核补齐现有 32 页签容量处理与 Harness 代理的已发现工具复用。新增容量回归验证 + 满额时布局仍有效、页面保留,关闭一项后显示原页面;Harness 回归验证发现接口后续失败时 + 已知工具调用不再触发发现。相关三文件 66 项通过,最新 typecheck 与 lint 通过。 +- 工作区聚焦测试 43 项通过、2 项需大小写敏感文件系统而跳过;IPC 与 Sidebar 聚焦回归 + 132 项通过。随后新增的 Ask 指令、继承 Runtime 用量持久化及计划模式 App 场景,3 项通过。 +- 扩展真实 Electron/MCP E2E 在独立临时 profile 通过:四个未使用的请求预留不创建会话、 + WebContents 或 UI 事件;首次 MCP 导航物化相同 Tab/工作栏身份,恢复不额外创建页面, + 并验证独立页面、共享 Cookie、请求租约关闭保护和清理。 +- 当前源码的共享 Linux x64 Host 验证已通过,覆盖工作区目标保护和重命名/Git Diff, + 并通过桌面托管 ACP → 隔离 Agent → OpenCode 完成 Ask/Execute 和继承 Runtime 指标持久化; + 该组共 3 次真实模型调用,清理后无所属进程残留。完整记录见 + [远程 Host 开发验证](../remote-host/technical-design.md#agent-开发期间的真实-host-验证)。 +- 最终整库 `npm test` 为 341 个文件通过、9 个文件跳过,3839 项通过、66 项按平台或手动 + 条件跳过、0 失败;`npm run typecheck`、`npm run lint`、开发期 `npm run build` 和 + `git diff --check` 通过,19 份修改 Markdown 的相对链接目标检查通过。 + 先前整合中的一轮为 3823 项通过、6 项失败、66 项跳过,另有被移动的测试路径加载失败, + 不作为最终结果;源码稳定后完整重跑得到上述全绿结果。 +- 原文档解析焦点回归改为等待 Modal 的焦点效果,保留焦点与背景隔离断言,没有改动产品逻辑。 +- Windows 独立 Electron profile 的完整 App → Preload → Main UI 验证通过;最终复测使用 + 本轮最终开发构建。启用浏览器后普通 Execute 没有创建原生浏览器视图,工具导航只创建 + 一张同时显示在工作栏的页面,跨会话选择保留原页面与 viewport。 +- 实际终端连通后,从浏览器关闭该终端可正常显示确认并操作按钮;原生视图可见性按 + 可见 → 确认时隐藏 → 取消后可见变化,终端关闭清理完成。无效地址 `http://` 被拒绝后, + 原控件可重试有效地址并关闭页面;关闭端口返回了过滤代理错误页,因此该 UI 场景不作为 + Chromium `loadURL` 拒绝的实机证据。服务与 Renderer 释放绑定回归另有自动测试覆盖。 +- 桌面使用确定性 loopback 模型服务驱动真实生产路径,共 24 次本地 completion 请求, + 未发出外部模型请求;可选真实 Provider 探针没有找到已加密保存的 OpenAI 兼容文本连接, + 未据此宣称桌面真实模型验收通过。Ask 与编程 Subagent 的桌面结果见 + [直连模型验收记录](../direct-model-agent/progress.md#2026-09-10-提交审查后的桌面-ui-验收)。 + 测试应用与具名浏览器会话已退出,所属 Electron/Node 进程及 fixture 监听端口均为 0; + 临时截图和隔离 profile 保留在本机测试目录,不进入仓库。 + ## 2026-09-10 固定基础页签与浏览器多实例 - 工作栏注册表已分别声明实例策略、默认上下文、默认打开、必须存在、可关闭和排序属性。 diff --git a/docs/features/computer-control/progress.md b/docs/features/computer-control/progress.md index eef43008..1e93c1bd 100644 --- a/docs/features/computer-control/progress.md +++ b/docs/features/computer-control/progress.md @@ -92,14 +92,11 @@ Linux x64 和 Linux arm64。 ### P0:右侧没有浏览器实时画面,已修复 -浏览器窗口默认使用 `show: false`,但 BrowserService 现在从模型实际操作的同一 -会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏 -新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、 -加载中、操作中、用户交互中、就绪、失败和已停止状态。用户可点击“交互”打开 -同一会话的子窗口辅助 Agent;交互时主窗口暂时禁用,关闭时先刷新最终画面再将 -浏览器窗口最小化,页面和会话继续保留。用户也可明确关闭当前对话的浏览器会话。 -当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建 -第二个预览浏览器,因此显示内容与模型受控页面一致。 +早期版本通过同一浏览器会话的 JPEG 帧提供预览。当前工作栏直接嵌入 Agent 操作的原生 +`WebContentsView`,不再自动截图或传输预览帧,也不再用独立交互窗口阻塞主界面。 +页面显示、标签页归属、失败重试和显式截图的当前规则统一见 +[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md); +该路径的最新验证证据见[工作栏进度](../assistant-workbar/progress.md)。 ### 已实现:浏览器工具栏 diff --git a/docs/features/deepseek-harness/technical-design.md b/docs/features/deepseek-harness/technical-design.md index 8d700d74..ff90a5b6 100644 --- a/docs/features/deepseek-harness/technical-design.md +++ b/docs/features/deepseek-harness/technical-design.md @@ -381,6 +381,9 @@ GoodBuddy conversationId -> Harness sessionId + process generation - 工作区不是 containment 边界;绝对路径和命令可访问当前用户本来有权访问的主机资源。 - 已启用插件注册的工具与内置工具使用同一分发路径;GoodBuddy 不增加插件权限矩阵或逐工具确认。 - Main 代理的 MCP 工具继续执行分配、schema、活动请求、模式和 RuntimeAuthorizer 校验。 + `goodbuddy/tools/list` 按 Session 保存本次发现的工具定义;`goodbuddy/tools/call` 复用该定义 + 校验参数与授权,不因调用健康工具而重新等待故障 MCP 的发现。发现重试留在清单刷新, + Session 释放及 Runtime dispose 清除内存定义,不持久化第二份能力状态。 - 所有工具调用仍作为活动事件记录;Ask 和 delegation 路径继续固定拒绝。 ### 10.4 Runtime OS 沙箱 @@ -446,8 +449,17 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接: 任意 Body 配置入口,因此 DeepSeek Harness 不接收自定义 Body。完整支持矩阵和合并规则 见[模型连接请求定制](../model-connections/technical-design.md)。 - API Key 继续保存在 GoodBuddy 加密设置中。 -- 启动环境提供的部署连接只由 Main 自动解析,不在 Renderer 中显示为可选来源。 -- 未显式指定 Harness 模型且没有可用的管理员预置连接时,Main 优先使用当前默认的兼容 GoodBuddy 模型连接;默认连接不兼容时使用首个兼容连接。只有不存在任何兼容连接时才提示用户前往模型或 Runtime 设置。 +- `platform` 在设置页显示为“管理员环境优先,回退 GoodBuddy 兼容连接”,不是 Harness + 自有配置文件。Main 优先读取完整的 `GOODBUDDY_MODEL_BASE_URL`、 + `GOODBUDDY_MODEL_NAME` 和 `GOODBUDDY_MODEL_API_KEY` 环境预置;不完整时使用 + 当前默认兼容 GoodBuddy 连接,再回退首个兼容连接。 +- `default` 只跟随 GoodBuddy 默认兼容连接或首个兼容连接,跳过独立管理员预置; + `profile` 使用指定的兼容连接。没有兼容连接时,仍允许选择 `platform`,因为完整 + 管理员预置不依赖已保存连接的协议;运行时缺少实际连接才报告不可用。 +- Renderer 通过 Main 的只读平台来源投影展示实际管理员模型或兼容回退,不读取环境 + 或密钥,也不根据 `credentialSource` 猜测。投影直接复用本节所述的生产 resolver, + 字段、非持久化与草稿展示边界见 + [Harness 平台来源公开投影](../model-connections/technical-design.md#9-harness-平台来源公开投影)。 不允许选择 Harness 自有的用户配置文件或自定义 Host。Runtime 始终使用随当前 GoodBuddy 版本发布的内置 Host,并通过完整内部能力握手。 diff --git a/docs/features/direct-model-agent/logic-design.md b/docs/features/direct-model-agent/logic-design.md index 5a7365f6..f94a2d56 100644 --- a/docs/features/direct-model-agent/logic-design.md +++ b/docs/features/direct-model-agent/logic-design.md @@ -55,6 +55,9 @@ 工具清单隐藏不是最终授权边界。`callTool` 仍须重新验证 Runtime、工作模式、执行空间和父请求 是否活动,以拒绝旧快照或伪造调用。 +FR-4 / FR-12 / US-B0:Ask 工作区搜索和读取不依赖知识库、联网搜索或委派开关,不需要 +切到 Execute 或额外批准。MCP 服务器失败也不改变本机只读工具的可用性。 + `output_read` 在直连模型拥有进程或 Subagent 服务时提供,Ask/Execute 均可用,不取决于 本轮 Shell 可用性、是否允许再次委派或委派深度。读取只接受当前会话的句柄;其他 Runtime 不注册该工具,调用边界同样拒绝。 @@ -148,6 +151,8 @@ type SubagentDelegateResult = { - 子级不能请求更高模式。 - 子级使用父请求开始时的工具与能力快照;运行中设置变化不重写快照。 - 子级调用 MCP、浏览器或知识能力时继续经过这些能力已有的作用域和生命周期边界。 +- FR-5 / FR-7 / US-C1:Execute 子级使用父请求的浏览器标签页,不另开标签页或改变所有权; + 子级结束后父模型仍可继续操作该页,父请求继续负责其使用租约。 - Execute 子级不弹出额外的“Subagent 执行授权”,也不获得父级没有的 MCP 分配。 ## 9. 并发和顺序 diff --git a/docs/features/direct-model-agent/progress.md b/docs/features/direct-model-agent/progress.md index f133b589..8860d168 100644 --- a/docs/features/direct-model-agent/progress.md +++ b/docs/features/direct-model-agent/progress.md @@ -18,7 +18,20 @@ DeepSeek Harness 和托管 SSH 路径不注入这些工具。既有功能的 Win 全量项目验证已通过;本次分页验证记录见下方 2026-09-10 条目。macOS 与 Linux 真机验证 仍需由对应平台完成。 -## 已确认的当前基线 +## 2026-09-10 提交审查后的桌面 UI 验收 + +- Windows 隔离配置使用本轮最终开发构建,通过普通输入框发送触发完整 + App → Preload → Main → ModelAgentRuntime → ModelToolProvider 路径。 +- Ask 在已保存 `toolApproval: policy` 下完成实际 `workspace_read_text` 与 `workspace_rg`, + 读取同一专用哨兵,24 字节测试文件与校验值保持不变。未放开 Ask 写工具。 +- 父请求调用实际编程 Subagent;子级在父浏览器归属下完成导航和快照,子级页面显示在 + 父级工作栏 Tab,没有额外空白原生页面。 +- 本组使用确定性 loopback 模型服务,不声称执行了桌面真实 Provider 推理。 + 请求计数、浏览器/终端 UI 场景、明确覆盖边界及清理结果统一记录在 + [工作栏验收进度](../assistant-workbar/progress.md#2026-09-10-提交审查修复)。 +- 最终整库回归与静态检查结果同见该记录;测试进程已退出,不改变用户安装版或工作区。 + +## 设计前基线(历史) - [x] 直连模型当前内置工作区读、目录列表和文本写入,但没有进程工具。 - [x] 内置 MCP 当前没有 Shell;自定义 MCP 不能替代开箱即用的进程能力。 @@ -203,6 +216,26 @@ DeepSeek Harness 和托管 SSH 路径不注入这些工具。既有功能的 Win - 本目录中文文档已按 `deai-writing` 审校,扫描阻断项为 0;保留技术枚举、权限限制和历史 验证记录中的明确边界。本次未运行全量测试或生产构建,也不作为其他平台真机验收。 +## 2026-09-10 直连工具接线回归修复 + +- FR-4 / FR-12 / US-B0:普通 Ask 不再依赖可选能力才进入工具循环;明确允许工作区搜索、 + 文本读取和当前会话输出续读绕过 Ask 默认拒绝 authorizer,保留写入、补丁、进程和浏览器 + 的 Ask 拒绝。内部无工具摘要路径不变。 +- FR-5 / FR-7 / US-C1:编程 Subagent 继承父浏览器标签页及所属 Conversation;子级历史和 + 输出仍独立,浏览器资源不转移所有权。回归通过真实 Runtime、Provider 和 Subagent 服务, + 浏览器使用校验父 Conversation/标签页身份的测试替身,并确认子级结束后父级可继续调用。 +- MCP 已发现工具调用不再触发其他失败服务器的发现重试;覆盖连接失败、清单失败、动态 + 刷新失效、健康调用、配置替换与 dispose 清空。发现重试仍只发生在下一次 `listTools`。 +- 验证命令:`npm test -- --run src/main/agent/model-tool-provider.test.ts src/main/agent/model-runtime.test.ts`。 + 最终结果为 `122 passed, 1 skipped`(Provider 36;Runtime 86 通过、1 跳过)。 + Ask 回归实际执行随包 ripgrep、工作区分页读取和真实本机输出续读;模型响应均为测试夹具。 +- 本次真实模型调用精确为 0,没有外部网络或远端 Host 操作;未运行全量测试、类型检查、 + Lint 或构建。桌面 IPC 提示词、浏览器租约生产入口和最小真实模型验收由集成工作继续验证, + 以上聚焦测试不作为真实浏览器、真实模型或整条桌面路径验收。 +- 源码检查:GoodBuddy Agent 不导入这两个 Runtime/Provider;本机 DeepSeek Harness 的 + MCP 代理复用 Provider 发现快照规则,托管 SSH Runtime 不经过该本机构造路径。本次未 + 修改远端 Agent、桥接或启动器。 + ## 进度维护要求 - 只有真实生产路径完成并验证后才能勾选实施项。 diff --git a/docs/features/direct-model-agent/technical-design.md b/docs/features/direct-model-agent/technical-design.md index f72eed88..34e2026b 100644 --- a/docs/features/direct-model-agent/technical-design.md +++ b/docs/features/direct-model-agent/technical-design.md @@ -280,6 +280,10 @@ type DirectModelSubagentContext = { - 子级复用父 Provider;Main 写入的 `delegationDepth=1` 同时在清单和调用边界过滤 `subagent_delegate`。 - Ask 子级使用 Ask 清单;Execute 子级使用 Execute 清单。 +- Execute 子级经 `ModelSubagentRequestContext` 继承父 `browserTabId` 和浏览器所属 + `browserConversationId`。后者只在 Main 内部请求与工具上下文中传递:浏览器操作使用父 + Conversation,历史与分页输出仍使用子 Conversation。子级不新建标签页或申请使用租约, + 结束时只释放自己的临时会话,父标签页和租约仍由父请求生命周期管理。 ### 7.3 调度和结果 @@ -327,6 +331,25 @@ type ModelToolCallContext = { - `releaseConversation` 取消并释放该会话拥有的浏览器、Subagent 和进程。 - `dispose` 等待有界清理,不因模型或子进程不响应而阻塞应用退出。 +### 8.1 Ask 工具循环 + +普通 Ask 请求进入工具循环,不再依赖知识、联网搜索或 Subagent 是否启用。Runtime 对 +`workspace_rg`、`workspace_read_text`、`output_read` 三个明确只读工具免去 Ask authorizer +调用,避免生产 authorizer 的默认拒绝使已列出的只读工具无法执行。Execute 仍沿用既有 +authorizer;补丁、进程和其他写入不因此获准。Provider 按清单和调用边界继续校验模式, +浏览器工具不加入 Ask 免审批集合。内部上下文摘要使用 `noModelTools`,保留无工具问答路径。 + +### 8.2 MCP 发现与调用 + +`listTools` 发现 MCP 并保存当前绑定快照;已知工具调用只查该快照,不在每次调用前重试 +其他失败服务器。未知名称直接失败。失败服务器只在下一次发现入口重试,动态清单刷新失败 +时从新快照中移除该服务器工具,下一次发现再刷新;健康连接继续复用。 + +绑定只在 Provider 内存中存在,`dispose` 与连接缓存一起清空。MCP 配置保存通过 +`refreshCapabilities → onRuntimeSettingsChanged → reconfigureRuntimes` 替换 Runtime, +`SelectedRuntimeManager.reset` 使后续请求创建新 Provider;退役 Runtime 按原有会话生命周期 +清理。没有新增持久缓存、失败重试日志或配置兼容层。 + DeepSeek Harness 的 Main 工具代理必须使用仅包含分配 MCP 和 Web 的 Provider 视图,不能因为 共享 `ModelToolProvider` 类而自动看到直连模型编程工具。 diff --git a/docs/features/direct-model-agent/user-stories.md b/docs/features/direct-model-agent/user-stories.md index 88a771af..e8fc716a 100644 --- a/docs/features/direct-model-agent/user-stories.md +++ b/docs/features/direct-model-agent/user-stories.md @@ -88,6 +88,7 @@ Given 直连模型在 TLS 建连、网络传输或单次请求超时时失败且 Given 用户在 Ask 或 Execute 中处理本机项目,When 模型需要定位代码或读取大文件,Then 它可以使用随包 ripgrep 搜索内容或列出文件,并按行分页读取命中位置,不要求用户预装 `rg`。 +Given Ask 未启用知识库、联网搜索或编程委派,Then 工作区搜索和读取仍可使用,不要求额外批准。 ### US-B1 修改后运行验证 @@ -126,6 +127,8 @@ Given 用户选择 Ask,Then 补丁工具不出现在清单中且伪造调用 Given 父直连模型正在处理复杂请求,When 它调用 `subagent_delegate` 并给出独立任务, Then 子级使用同一项目、模型连接和 Execute 能力完成工作,And 最终结果返回父模型综合。 +Given 父请求已有浏览器标签页,When 委派任务需要浏览器,Then 子级使用同一页;子级完成后, +父模型仍可继续操作该页,不因子会话清理而关闭父页。 ### US-C2 Ask 中只读委派 diff --git a/docs/features/knowledge-base/external-knowledge-technical-design.md b/docs/features/knowledge-base/external-knowledge-technical-design.md index a91ee7a5..fa81e3ec 100644 --- a/docs/features/knowledge-base/external-knowledge-technical-design.md +++ b/docs/features/knowledge-base/external-knowledge-technical-design.md @@ -621,6 +621,15 @@ node scripts/external-knowledge-probe.mjs --extended --output= Rerank、RAGFlow GraphRAG 和 Knowledge Compilation 检索。脚本只有固定 GET/POST 读取与 检索端点,不实现或调用远端创建、更新、上传和删除。 +FastGPT 报告将请求成功与命中数量分开:HTTP 必须成功、业务 `code` 必须为 `200`, +且 `data` 或 `data.list` 必须是数组,才记录 `success: true` 和 `count`。成功的空数组 +记录 `count: 0`;HTTP、业务、响应结构、JSON 解析或网络失败记录 `success: false` 与 +固定失败类别,不用零命中代替失败。目录失败时不继续调用详情和检索。 +`features.searchModes` 只包含基础检索实际成功的模式;`rerankProbed` 表示实际尝试, +`rerankSucceeded` 单独表示成功。历史基线保留原始证据,不追填未观测的成功字段。 +`tests/external-knowledge-probe-fastgpt.test.ts` 使用内存 fetch fixture 运行真实 CLI, +覆盖错误、成功零命中和非空结果,不连接外部知识服务。 + ### 14.7 CRUD 验收边界 | 对象 | Create | Read | Update | Delete | 验收方式 | diff --git a/docs/features/model-connections/README.md b/docs/features/model-connections/README.md index bb83461a..f272896a 100644 --- a/docs/features/model-connections/README.md +++ b/docs/features/model-connections/README.md @@ -12,6 +12,10 @@ Provider 要求的附加元数据;GoodBuddy 只在直连链路或 Runtime 明 | [技术设计](./technical-design.md) | 契约、持久化、合并优先级、Runtime 支持矩阵和验证 | | [功能进度](./progress.md) | 已验证实现、剩余工作和验证证据 | +模型连接选择准确性也在本目录维护:技术设计第 8 节定义通道直连回退修复, +UI 设计末节定义 Harness 来源标签和公开设置展示;Harness 的实际解析顺序由其 +[技术设计](../deepseek-harness/technical-design.md#121-配置来源)唯一维护。 + 本次改动是现有模型连接的有界扩展,不新增独立业务对象或工作流,因此不单独建立 PRD、 User Stories 和逻辑设计;本页定义产品边界,技术设计定义唯一的请求合并规则。 diff --git a/docs/features/model-connections/progress.md b/docs/features/model-connections/progress.md index beeecfd1..8eb81c18 100644 --- a/docs/features/model-connections/progress.md +++ b/docs/features/model-connections/progress.md @@ -1,5 +1,23 @@ # 模型连接请求定制进度 +## 2026-09-10:配置准确性修复 + +- 通道直连回退修复已按[技术设计第 8 节](./technical-design.md#8-通道直连模型选择修复) + 实现;测试把修复结果交给现有 Main resolver,不仅断言返回对象。 +- Harness 设置使用专用来源标签、关联帮助及 Main 直接从生产 resolver 派生的只读 + `deepseekHarnessPlatformModel` 投影;准确区分完整管理员预置、兼容回退和无连接, + 不再从通用环境凭据状态推断。投影不含地址、密钥或请求定制,也不写入设置文件。 +- 无兼容连接时管理员来源仍可选择;未保存候选与实际已保存来源分别展示。UI 验证涵盖 + 中英文、兼容默认、首个兼容回退、管理员覆盖、部分环境回退、无连接、草稿预览和保存动作。 +- 聚焦命令 `npx vitest run src/main/agent/runtime-selection-contracts.test.ts src/renderer/src/SettingsPanel.test.tsx` + 对应的两个文件在本次五文件联合运行中全部通过,共 96 项。真实模型调用 0 次; + 本次未运行全量测试、typecheck、lint 或真实 Harness 请求。 + 通道修复集成测试随后归入 Main 测试目录,避免 Shared/Web 类型项目导入主进程实现。 +- 后续聚焦验证 `npx vitest run src/main/runtime-settings-store.test.ts src/renderer/src/SettingsPanel.test.tsx` + 全部通过:2 文件、181 项。Store 覆盖完整、部分与旧环境变量、默认/首个兼容回退、 + 无连接、精确字段白名单、非持久化以及不改变所选来源。真实模型/Host 调用均为 0; + 未运行全量测试、构建、typecheck 或 lint。 + ## 2026-09-03 ### 已完成 diff --git a/docs/features/model-connections/technical-design.md b/docs/features/model-connections/technical-design.md index 64302a94..21377f5e 100644 --- a/docs/features/model-connections/technical-design.md +++ b/docs/features/model-connections/technical-design.md @@ -85,3 +85,33 @@ JSON 对象,标准认证 Header 也由 schema 拒绝。 Utility 启动配置和 Pi-AI Provider 配置。 - 远程:Main/Agent 网关合并、认证优先级、摘要绑定、大小限制和不重放。 - UI:默认值、保存、无效 JSON 保留和 Runtime 支持说明。 + +## 8. 通道直连模型选择修复 + +`repairChannelRuntimeSelection` 只在默认连接可用于文本通道时保留动态 +`{ provider: 'model' }`。默认连接为图像生成、API Key 认证但明确缺少凭据,或默认 ID +已不存在时,若找到可用文本连接,修复结果必须携带该连接的 `profileId`,使后续 +`applyRuntimeSelection` 使用已验证回退,而不是重新解析到不可用的全局默认值。 +已有可用显式引用保持不变;没有可用连接时不虚构 ID 或声称可运行。 + +共享回归测试将修复结果直接传入 Main 的生产 resolver,验证动态默认、显式固定、 +图像默认、缺少凭据和失效默认 ID。该修复不改变 OpenCode、Continue 或托管 SSH 的 +Runtime 选择路径。 + +## 9. Harness 平台来源公开投影 + +`RuntimeSettings.deepseekHarnessPlatformModel` 是可选只读元数据,描述 `platform` +选项在当前已保存模型设置与管理员环境下的实际解析结果: + +- `{ source: 'environment', name, modelName }`:使用完整管理员预置。 +- `{ source: 'profile', profileId, name, modelName }`:使用兼容 GoodBuddy 连接回退。 +- `{ source: 'unavailable' }`:没有可解析的连接。 + +Main 的 `toPublicSettings` 复用 `resolveDeepSeekHarnessModelProfile`,只在调用中将来源 +设为 `platform`,不改变用户已保存的 `default`、`profile` 或 `platform` 选择。投影逐字段 +构造,不包含地址、API Key、自定义 Header/Body 或凭据状态,不写入设置文件、迁移或新状态。 +公开契约保持可选,缺失时 UI 明确来源信息不可用,不从通用模型或环境凭据猜测。 + +`source: 'profile'` 描述来源分支,不代表凭据来源:仅环境密钥或旧环境变量可能仍影响该 +GoodBuddy 连接,但不会被误报为完整管理员预置。连接解析不代替真实模型请求或可用性测试。 +UI 的未保存兼容候选只作草稿预览,保存后继续以 Main 返回的投影为准。 diff --git a/docs/features/model-connections/ui-design.md b/docs/features/model-connections/ui-design.md index 70c7392d..f79b333e 100644 --- a/docs/features/model-connections/ui-design.md +++ b/docs/features/model-connections/ui-design.md @@ -36,3 +36,20 @@ OpenCode 与 DeepSeek Harness 不使用该值。 - 无效状态不能只依赖颜色,必须同时提供错误文字和 `aria-invalid`。 - 文本域遵循标准键盘编辑、选择、复制和粘贴行为;保存继续使用设置页唯一的主保存操作。 - 焦点边框、字体、间距、颜色和深浅主题全部复用全局语义令牌。 + +## Harness 模型来源展示 + +DeepSeek Harness 的 `platform` 选项命名为“管理员环境优先,回退 GoodBuddy 兼容连接”, +不能沿用 OpenCode/Continue 的“Runtime 自有配置”标签。来源规则以 +[Harness 模型配置](../deepseek-harness/technical-design.md#121-配置来源)为准。 +帮助文字与选择器通过 `aria-describedby` 关联,并明确此选项不加载用户 profile 或 +配置文件;无兼容 GoodBuddy 连接时仍可选择管理员环境来源。 + +概览使用 Main 提供的[平台来源投影](./technical-design.md#9-harness-平台来源公开投影), +明确显示实际管理员预置模型,或“未配置完整的管理员预置”及已保存兼容回退的名称与模型; +没有可解析连接时显示明确空状态,但保留 `platform` 选项。不得从通用模型名称或 +`credentialSource` 猜测管理员来源,也不得显示密钥或读取 Main 环境。 + +没有管理员覆盖且草稿兼容候选发生变化时,额外显示“草稿兼容候选(未保存)”,保留实际 +已保存来源;草稿失去兼容连接时也明确说明。管理员覆盖生效时不把草稿回退显示为实际模型。 +保存完成后以 Main 重新解析的投影刷新概览。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 3b548791..7e387671 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -302,6 +302,10 @@ Execute 直接启动已签名 Runtime: 时提交 Task 终态;事务成功后生成器才继续并向 Agent ACK。断电发生在同一 sequence 中间时会重读该 sequence,已写事件按 `(binding, operation, sequence, eventIndex)` 去重, 未写事件继续归并,不会重复 Provider 或工具执行。 +- 远程 context metrics 与对话级压缩后的估算值按本次请求实际使用的 Runtime 选择写入 + `context_state_json`。实时接收与恢复入口都传入请求的选择;数据库在未传入时按对话、 + 项目、auto 顺序解析。继承项目设置的对话保持 `runtime_selection_json = NULL`, + 不为接收 usage 固定对话 Runtime;去重和消息/指标写入仍在同一 SQLite 事务中完成。 - ACK 后 Agent 删除已确认事件,只保留小型操作终态与游标用于幂等核对;未 ACK 输出继续 保留。Main 按 transcript page 的最高连续 sequence ACK,终态仍立即 ACK。单事件、单页、 单 Prompt 总字节和事件数都有上限;Agent 始终为唯一终态预留一个事件和最大事件字节, @@ -360,12 +364,28 @@ Execute 直接启动已签名 Runtime: - Unix socket 接收端按 `readableLength` 中已经缓冲的字节增量排空一个声明长度的帧, 不会在部分大响应到达时反复请求尚未缓冲的完整剩余长度;短读、连接结束、错误和取消 都会使当前交换失败。原生 Linux 回归覆盖至少 256 KiB 的 broker 响应。 +- Unix broker 的请求超时 `0` 表示不设置请求总时限,不等于零毫秒连接等待。 + 连接仍有独立的默认 5 秒超时;只有显式正数请求时限才与连接时限取较小值, + 用户取消仍关闭连接并中止 dispatch。 - Renderer 把相邻 text/reasoning delta 合并为消息 block 时始终替换最后一个 block, 不修改既有 React state 对象。这样开发环境 StrictMode 重复调用 state updater 时, block metadata 与 canonical 消息正文保持一致。 ## Agent 开发期间的真实 Host 验证 +本轮 `020a9ad` 工作树修复使用当前源码在共享 Linux x64 Host 验证:默认 Unix broker +完成 256 KiB 响应并传递取消;生产 AssistantDatabase 在临时库中写入继承项目的指标, +重开后去重重放,未固定对话选择。当前 Workspace 协议的目标存在/同名/硬链接重命名 +保护、字面路径 diff、重命名 diff 和 merge commit diff 均通过,Git 使用 Host 已配置身份。 +当前 Desktop `createManagedRemoteAcpRuntime` 经真实 SSH attach、隔离 Agent、OpenCode +和 Agent gateway 完成 Ask 与 Execute,均得到两字符回答和 done。Execute 的真实 +model-usage 转换为指标后通过生产数据库持久化与去重,继承设置保持未固定。 +共 3 次真实 Provider 调用(2 次 Ask、1 次 Execute),均 HTTP 200、completed 且已交付; +前两次运行在模型完成后被测试 harness 的 usage 元数据/事件名断言拦截,并非产品失败。 +未修改产品来适配 harness:构建时直接解析 ACP SDK 的 ESM 入口,凭据只通过既有 +safeStorage 与模型控制协议使用。各次隔离 Agent 停止后剩余所属进程数为 0,测试目录和 +上传文件均已回收,原 Host registry 与已安装组件未改变。此次不覆盖 UI 点击或安装升级。 + 2026-09-10 已发布模型桥兼容修复:从 `agent-v0.11.22` 读取的源码与当前源码分别在共享 Linux x64 Host 启动隔离 Agent daemon。当前 Main 的 `createManagedRemoteAcpRuntime` 经真实 SSH attach、Agent protocol、OpenCode 和 Agent gateway,在两版 Agent 上各完成 diff --git a/docs/features/task-and-job/scheduled-task-prd.md b/docs/features/task-and-job/scheduled-task-prd.md index 8e352839..1f4229fc 100644 --- a/docs/features/task-and-job/scheduled-task-prd.md +++ b/docs/features/task-and-job/scheduled-task-prd.md @@ -160,6 +160,10 @@ GoodBuddy Desktop · 项目工作目录 已保存计划中的旧工作模式和 Runtime 字段不再控制执行;用户在关联会话或项目中调整配置。 +会话任务详情中的模式与执行来源保持一致:从未运行的计划显示关联会话当前有效模式; +已开始或已结束的 Task 显示该次执行记录的实际模式,不被计划旧字段或会话后续切换覆盖。 +缺少实际模式记录时明确显示不可用,不从旧计划字段推断。 + ### 6.3 审批 - 使用普通会话的授权和提问流程。Agent Runtime 的 Execute 工具调用无需通用 GoodBuddy 审批,工具活动仍显示在会话中。 diff --git a/docs/features/task-and-job/task-center-prd.md b/docs/features/task-and-job/task-center-prd.md index d4d6ac73..3b211080 100644 --- a/docs/features/task-and-job/task-center-prd.md +++ b/docs/features/task-and-job/task-center-prd.md @@ -116,6 +116,9 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run 选择 Task 后显示名称、模式、聚合状态、计划、下次执行、最近结果和 Task 级操作。工具、审批、 错误和成果通过 Task 关联显示,但不暴露 Job/Run 层级。 +模式来源遵循[Scheduled Task 的执行时配置](./scheduled-task-prd.md#62-执行时配置): +未运行计划跟随会话当前有效模式,已执行 Task 保留实际模式;计划旧值不得覆盖展示。 + ## 8. 删除关系 - 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。 diff --git a/scripts/external-knowledge-probe.mjs b/scripts/external-knowledge-probe.mjs index 9ea68ef5..6a3cd71a 100644 --- a/scripts/external-knowledge-probe.mjs +++ b/scripts/external-knowledge-probe.mjs @@ -266,6 +266,25 @@ async function probeDify(configuration_) { } } +function fastGptListResult(result) { + if (!result) return { success: false, failure: 'request-failed' } + if (!result.response.ok) return { success: false, failure: 'http-error' } + const body = asRecord(result.body) + if (body?.code !== 200) return { success: false, failure: 'business-error' } + const data = asRecord(body.data) + const list = Array.isArray(body.data) ? body.data : data?.list + if (!Array.isArray(list)) return { success: false, failure: 'invalid-shape' } + return { success: true, list } +} + +function fastGptRetrievalShape(result) { + const { list, ...outcome } = fastGptListResult(result) + return { + ...outcome, + ...(list ? resultShape(list, 'score') : {}) + } +} + async function probeFastGpt(configuration_) { const context = { apiKey: configuration_.apiKey, operations: [] } const base = baseUrl('fastgpt', configuration_.baseUrl) @@ -277,12 +296,8 @@ async function probeFastGpt(configuration_) { ) const catalogBody = asRecord(catalog?.body) const rawData = catalogBody?.data - const nestedData = asRecord(rawData) - const items = Array.isArray(rawData) - ? rawData - : Array.isArray(nestedData?.list) - ? nestedData.list - : [] + const { list, ...catalogOutcome } = fastGptListResult(catalog) + const items = list ?? [] const datasets = items.filter((item) => asRecord(item)?.type === 'dataset') const first = asRecord(datasets[0]) let detail @@ -308,14 +323,7 @@ async function probeFastGpt(configuration_) { }) } ) - const body = asRecord(result?.body) - const data = asRecord(body?.data) - const list = Array.isArray(body?.data) - ? body.data - : Array.isArray(data?.list) - ? data.list - : [] - retrievals.push({ searchMode, ...resultShape(list, 'score') }) + retrievals.push({ searchMode, ...fastGptRetrievalShape(result) }) } if (extended) { const result = await request( @@ -334,17 +342,10 @@ async function probeFastGpt(configuration_) { }) } ) - const body = asRecord(result?.body) - const data = asRecord(body?.data) - const list = Array.isArray(body?.data) - ? body.data - : Array.isArray(data?.list) - ? data.list - : [] retrievals.push({ searchMode: 'mixedRecall', usingRerank: true, - ...resultShape(list, 'score') + ...fastGptRetrievalShape(result) }) } } @@ -360,7 +361,8 @@ async function probeFastGpt(configuration_) { : undefined }, catalog: { - count: datasets.length, + ...catalogOutcome, + ...(catalogOutcome.success ? { count: datasets.length } : {}), envelope: Array.isArray(rawData) ? 'data-array' : 'data-object', itemKeys: unionKeys(items), itemTypes: [...new Set(items.map((item) => asRecord(item)?.type).filter((value) => typeof value === 'string'))].sort() @@ -371,8 +373,9 @@ async function probeFastGpt(configuration_) { }, retrievals, features: { - searchModes: [...new Set(retrievals.filter((item) => item.count >= 0).map((item) => item.searchMode))], - rerankProbed: extended, + searchModes: [...new Set(retrievals.filter((item) => item.success && !item.usingRerank).map((item) => item.searchMode))], + rerankProbed: retrievals.some((item) => item.usingRerank), + rerankSucceeded: retrievals.some((item) => item.usingRerank && item.success), extensionQueryProbed: false }, operations: context.operations diff --git a/src/agent-daemon/model-bridge-broker.ts b/src/agent-daemon/model-bridge-broker.ts index df6e79d1..b11afb10 100644 --- a/src/agent-daemon/model-bridge-broker.ts +++ b/src/agent-daemon/model-bridge-broker.ts @@ -414,7 +414,9 @@ export function createUnixModelBridgeExchange(options: { try { await waitForConnection( socket, - Math.min(connectTimeoutMs, requestTimeoutMs), + requestTimeoutMs === 0 + ? connectTimeoutMs + : Math.min(connectTimeoutMs, requestTimeoutMs), context.signal ) await writePacket(socket, { diff --git a/src/agent-daemon/model-bridge.test.ts b/src/agent-daemon/model-bridge.test.ts index c502d1c0..4788070c 100644 --- a/src/agent-daemon/model-bridge.test.ts +++ b/src/agent-daemon/model-bridge.test.ts @@ -12,6 +12,8 @@ import { type RequestOptions } from 'node:http' import { tmpdir } from 'node:os' +import net from 'node:net' +import { Duplex } from 'node:stream' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -612,6 +614,47 @@ describe('model bridge loopback helper', () => { describe('model bridge Unix broker', () => { const runOnUnix = process.platform === 'win32' ? it.skip : it + it('allows a delayed connection with the default unlimited request timeout', async () => { + const socket = new Duplex({ + read() {}, + write(chunk: Buffer, _encoding, callback) { + const packet = JSON.parse(chunk.subarray(4).toString()) as { + type: string + requestId: string + } + const response = Buffer.from(JSON.stringify( + packet.type === 'model-request' + ? { version: 1, type: 'model-response', requestId: packet.requestId, ok: true, response: validResponse } + : { version: 1, type: 'delivery-accepted', requestId: packet.requestId } + )) + const frame = Buffer.alloc(4 + response.length) + frame.writeUInt32BE(response.length) + response.copy(frame, 4) + this.push(frame) + callback() + } + }) + vi.spyOn(net.Socket.prototype, 'connect').mockReturnValueOnce(socket as net.Socket) + const connect = setTimeout(() => socket.emit('connect'), 30) + try { + const exchange = createUnixModelBridgeExchange({ + socketPath: resolve(tmpdir(), 'delayed-model-bridge.sock'), + connectTimeoutMs: 500 + }) + const result = await exchange({ + method: 'POST', path: '/v1/messages', headers: {}, bodyBase64: '' + }, { requestId: 'delayed-connect', signal: new AbortController().signal }) + expect('response' in result).toBe(true) + if ('response' in result) { + expect(result.response).toEqual(validResponse) + await result.acknowledgeDelivery() + } + } finally { + clearTimeout(connect) + socket.destroy() + } + }) + runOnUnix('rejects a socket path beyond the Unix kernel limit', () => { const scratch = resolve( privateTemporaryDirectory(), @@ -643,8 +686,7 @@ describe('model bridge Unix broker', () => { expect(lstatSync(socketPath).mode & 0o777).toBe(0o600) const exchange = createUnixModelBridgeExchange({ - socketPath, - requestTimeoutMs: 1_000 + socketPath }) const request: RemoteModelGatewayRequest = { method: 'POST', diff --git a/src/main/agent/deepseek-harness-runtime.test.ts b/src/main/agent/deepseek-harness-runtime.test.ts index d6c0c952..78156a68 100644 --- a/src/main/agent/deepseek-harness-runtime.test.ts +++ b/src/main/agent/deepseek-harness-runtime.test.ts @@ -1116,6 +1116,7 @@ describe('DeepSeekHarnessRuntime', () => { await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) + await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) await expect( harness.extension('goodbuddy/tools/call', { @@ -1145,6 +1146,8 @@ describe('DeepSeekHarnessRuntime', () => { await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) + await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) + vi.mocked(provider.listTools).mockRejectedValue(new Error('unrelated server unavailable')) await expect( harness.extension('goodbuddy/tools/call', { @@ -1158,6 +1161,7 @@ describe('DeepSeekHarnessRuntime', () => { ] }) expect(authorize).toHaveBeenCalledTimes(1) + expect(provider.listTools).toHaveBeenCalledOnce() expect(provider.callTool).toHaveBeenCalledWith( mcpTool().name, { kind: 'cube' }, @@ -1283,6 +1287,7 @@ describe('DeepSeekHarnessRuntime', () => { await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) + await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) await expect( harness.extension('goodbuddy/tools/call', { @@ -1311,6 +1316,7 @@ describe('DeepSeekHarnessRuntime', () => { await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) + await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) await expect( harness.extension('goodbuddy/tools/call', { diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index 7767e0a2..098a3a39 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -16,7 +16,7 @@ import type { RuntimeAuthorizer, RuntimeEvent } from './runtime' -import type { ModelToolProviderLike } from './model-tool-provider' +import type { ModelToolDefinition, ModelToolProviderLike } from './model-tool-provider' import type { RuntimeSkillPackage } from '../capabilities/capability-service' import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader' import { @@ -449,6 +449,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { private fatalError?: Error private stderrBytes = 0 private readonly sessions = new Map() + private readonly proxyToolCatalogs = new Map() private readonly sessionInitializations = new Map< string, Promise @@ -844,12 +845,11 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { context, connection.signal ) - return { - tools: boundedProxyToolCatalog( - tools, - context.workMode - ) - } + const catalog = boundedProxyToolCatalog(tools, context.workMode) + this.proxyToolCatalogs.set(params.sessionId, tools.filter( + (tool) => tool.source === 'mcp' || isMainWebTool(tool) + )) + return { tools: catalog } } if (method === GOODBUDDY_TOOLS_CALL) { const sessionId = params.sessionId @@ -882,10 +882,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { knowledgeCapabilityToken: run.request.knowledgeCapabilityToken } - const tools = await this.options.toolProvider.listTools( - context, - run.toolController.signal - ) + const tools = this.proxyToolCatalogs.get(sessionId as string) ?? [] const tool = tools.find( (candidate) => candidate.name === name && @@ -1599,6 +1596,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { async releaseConversation(conversationId: string): Promise { const sessionId = this.sessions.get(conversationId) this.sessions.delete(conversationId) + if (sessionId) this.proxyToolCatalogs.delete(sessionId) if (!sessionId || !this.state) { await this.options.toolProvider ?.releaseConversation(conversationId) @@ -1646,6 +1644,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } this.activeRuns.clear() this.sessions.clear() + this.proxyToolCatalogs.clear() this.sessionInitializations.clear() this.conversationTails.clear() if (!state) { diff --git a/src/main/agent/knowledge-mcp-gateway.test.ts b/src/main/agent/knowledge-mcp-gateway.test.ts index 82e2918c..f494e7c3 100644 --- a/src/main/agent/knowledge-mcp-gateway.test.ts +++ b/src/main/agent/knowledge-mcp-gateway.test.ts @@ -23,6 +23,7 @@ import { type MagicNotesDatabase } from './knowledge-mcp-gateway' import { browserTabIdSchema } from '../../shared/contracts' +import { BrowserService } from '../browser/browser-service' const firstLibraryId = '11111111-1111-4111-8111-111111111111' const secondLibraryId = '22222222-2222-4222-8222-222222222222' @@ -231,6 +232,81 @@ afterEach(async () => { }) describe('KnowledgeMcpGateway', () => { + it('grants reserved parent and inherited child targets without materializing a browser', async () => { + const { service } = createService() + const createSession = vi.fn(async (): Promise => { + throw new Error('unused requests must not create a browser') + }) + const browserService = new BrowserService({ createSession }) + const gateway = new KnowledgeMcpGateway(service, { browserService }) + gateways.push(gateway) + const parentController = new AbortController() + const parent = browserService.reserveRequestTab('conversation', 'parent', 21) + const child = browserService.acquireTabUsage('conversation', parent.tabId, 'child', 21) + try { + expect(browserService.listTabs('conversation')).toEqual([]) + expect(() => gateway.grant('unleased', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId)) + .toThrow('不属于当前对话') + const parentToken = gateway.grant('parent', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId, parent)! + const childToken = gateway.grant('child', [], new AbortController().signal, 'none', undefined, 'conversation', child.tabId, child)! + expect(gateway.getAvailableToolNames(parentToken)).toEqual(browserToolNames) + expect(gateway.getAvailableToolNames(childToken)).toEqual(browserToolNames) + expect(createSession).not.toHaveBeenCalled() + parentController.abort() + expect(parent.signal.aborted).toBe(true) + expect(child.signal.aborted).toBe(false) + expect(browserService.getOwnerWindowId('conversation')).toBe(21) + + await gateway.start() + const navigate = vi.spyOn(browserService, 'navigate').mockResolvedValue({ + url: 'https://example.com/', + origin: 'https://example.com' + }) + const client = new Client({ name: 'reserved-child-test', version: '1.0.0' }) + try { + await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { + requestInit: { headers: { Authorization: `Bearer ${childToken}` } } + })) + await client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } }) + expect(navigate).toHaveBeenCalledWith('conversation', 'https://example.com/', expect.any(AbortSignal), parent.tabId) + expect(createSession).not.toHaveBeenCalled() + } finally { + await client.close() + } + gateway.revoke(childToken) + expect(child.signal.aborted).toBe(true) + expect(browserService.getOwnerWindowId('conversation')).toBeUndefined() + } finally { + await gateway.dispose() + await browserService.dispose() + } + }) + + it('rejects mismatched or ended reservation leases and releases pre-canceled grants', async () => { + const { service } = createService() + const browserService = new BrowserService() + const gateway = new KnowledgeMcpGateway(service, { browserService }) + gateways.push(gateway) + try { + const mismatch = browserService.reserveRequestTab('conversation', 'owner', 21) + expect(() => gateway.grant('other-owner', [], new AbortController().signal, 'none', undefined, 'conversation', mismatch.tabId, mismatch)) + .toThrow('与当前请求不匹配') + expect(mismatch.signal.aborted).toBe(true) + const released = browserService.reserveRequestTab('conversation', 'released', 21) + released.release() + expect(() => gateway.grant('released', [], new AbortController().signal, 'none', undefined, 'conversation', released.tabId, released)) + .toThrow('租约已终止') + const canceled = browserService.reserveRequestTab('conversation', 'canceled', 21) + expect(() => gateway.grant('canceled', [], AbortSignal.abort(new Error('request canceled')), 'none', undefined, 'conversation', canceled.tabId, canceled)) + .toThrow('request canceled') + expect(canceled.signal.aborted).toBe(true) + expect(browserService.getOwnerWindowId('conversation')).toBeUndefined() + expect(browserService.getSessionCount()).toBe(0) + } finally { + await browserService.dispose() + } + }) + it('exposes the assigned browser through a request-scoped conversation', async () => { const { service } = createService() const browserTabId = browserTabIdSchema.parse( diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index 469f3061..e5e40d8b 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -472,9 +472,9 @@ export class KnowledgeMcpGateway { if ( effectiveBrowserConversationId && (!effectiveBrowserTabId || - !this.browserService! + (!browserUsageLease && !this.browserService! .listTabs(effectiveBrowserConversationId) - .some((tab) => tab.tabId === effectiveBrowserTabId)) + .some((tab) => tab.tabId === effectiveBrowserTabId))) ) { throw new Error('浏览器标签页不存在或不属于当前对话') } @@ -507,8 +507,9 @@ export class KnowledgeMcpGateway { ) { return undefined } - signal.throwIfAborted() try { + signal.throwIfAborted() + effectiveBrowserUsageLease?.signal.throwIfAborted() return this.storeCapability({ requestId, libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]), diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 675e85c5..eab19978 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -2,8 +2,10 @@ import { createHash } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' +import { rgPath } from '@vscode/ripgrep' import { describe, expect, it, vi } from 'vitest' import { + ModelToolProvider, RecoverableModelToolError, type ModelToolDefinition, type ModelToolProviderLike, @@ -12,6 +14,10 @@ import { import { ModelAgentRuntime } from './model-runtime' import type { RuntimeEvent } from './runtime' import { SubagentScheduler } from '../assistant/subagent-scheduler' +import { LocalWorkspaceAccess } from '../workspace' +import { LocalDirectModelProcessService } from './direct-model-process-service' +import { browserTabIdSchema } from '../../shared/contracts' +import type { BrowserToolService } from '../browser/browser-model-tools' const toolPng = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, @@ -2193,39 +2199,151 @@ describe('ModelAgentRuntime', () => { expect(events.at(-1)).toMatchObject({ type: 'done' }) }) - it('keeps browser and workspace tools out of Ask mode', async () => { - const fetcher = vi.fn(async () => - new Response('data: {"choices":[{"delta":{"content":"只读回答"}}]}\n\ndata: [DONE]\n\n', { - status: 200, - headers: { 'content-type': 'text/event-stream' } - }) + it('runs Ask workspace search, text reads and output paging with the production deny authorizer and no optional capabilities', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-tools-')) + const workspaceAccess = new LocalWorkspaceAccess(workspace) + const processService = new LocalDirectModelProcessService() + const toolProvider = new ModelToolProvider( + workspaceAccess, [], undefined, undefined, false, + { processService, ripgrepExecutablePath: rgPath } + ) + const signal = new AbortController().signal + const conversationId = 'conversation-ask' + const responses: unknown[] = [] + const fetcher = vi.fn(async () => Response.json(responses.shift())) + const runtime = new ModelAgentRuntime({ + baseUrl: 'https://example.test/v1', + model: 'test-model', + protocol: 'openai-chat-completions', + authentication: 'none', + fetcher, + toolProvider, + workspaceAccess + }) + const authorize = vi.fn(async () => 'deny' as const) + try { + await writeFile(join(workspace, 'README.md'), 'readonly-marker\n') + await writeFile(join(workspace, 'output.cjs'), + "process.stdout.write('x'.repeat(110000) + 'retained-tail')") + // Seed output as a preceding Execute operation in the same conversation. + const previous = await processService.execute( + { command: 'node output.cjs', timeoutMs: 10_000 }, + { conversationId, workspace: workspaceAccess, signal } ) - const toolProvider = createToolProvider() - const runtime = new ModelAgentRuntime({ - baseUrl: 'http://127.0.0.1:11434/v1', - model: 'qwen3', - protocol: 'openai-chat-completions', - authentication: 'none', - fetcher, - toolProvider + expect(previous.exitCode).toBe(0) + const reference = previous.stdoutReference! + expect(reference).toBeDefined() + const calls = [ + { name: 'workspace_rg', arguments: { pattern: 'readonly-marker', glob: ['README.md'] } }, + { name: 'workspace_read_text', arguments: { path: 'README.md' } }, + { name: 'output_read', arguments: { handle: reference.handle, cursor: reference.nextCursor } } + ] + responses.push({ + choices: [{ message: { + role: 'assistant', content: null, + tool_calls: calls.map((call, index) => ({ + id: `ask-${index}`, type: 'function', + function: { name: call.name, arguments: JSON.stringify(call.arguments) } + })) + } }] + }, { choices: [{ message: { role: 'assistant', content: '只读完成' } }] }) + const events: RuntimeEvent[] = [] + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId, + prompt: '搜索并读取文件,续读已有输出', workMode: 'ask' + }, signal, authorize)) events.push(event) + + const firstBody = JSON.parse(String(fetcher.mock.calls[0]![1]!.body)) + expect(firstBody.tools.map((tool: { function: { name: string } }) => + tool.function.name)).toEqual(['workspace_rg', 'workspace_read_text', 'output_read']) + const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) + const results = secondBody.messages.filter( + (message: { role: string }) => message.role === 'tool' + ) + expect(results).toHaveLength(3) + expect(results[0].content).toContain('README.md:1:1:readonly-marker') + expect(results[1].content).toContain('readonly-marker') + expect(JSON.parse(results[2].content)).toMatchObject({ + content: expect.stringContaining('retained-tail'), eof: true }) - - for await (const _event of runtime.run( - { - requestId: crypto.randomUUID(), - conversationId: 'conversation-ask', - prompt: '只读', - workMode: 'ask' - }, - new AbortController().signal - )) { - void _event - } - - expect(toolProvider.listTools).not.toHaveBeenCalled() - expect(toolProvider.callTool).not.toHaveBeenCalled() + expect(authorize).not.toHaveBeenCalled() + expect(events.filter((event) => + event.type === 'tool' && event.state === 'completed')).toHaveLength(3) + expect(events.at(-1)).toMatchObject({ type: 'done' }) + expect(await readFile(join(workspace, 'README.md'), 'utf8')).toBe('readonly-marker\n') + } finally { + await runtime.dispose() + await rm(workspace, { recursive: true, force: true }) + } }) + it.each(['workspace_apply_patch', 'workspace_write_text', 'process_execute', 'browser_snapshot'])( + 'rejects unlisted %s in Ask at the runtime boundary', + async (name) => { + const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-denial-')) + const fetcher = vi.fn(async () => Response.json({ + choices: [{ message: { + role: 'assistant', content: null, + tool_calls: [{ id: 'spoof', type: 'function', function: { + name, arguments: JSON.stringify({ + path: 'forbidden.txt', content: 'forbidden', + patch: '*** Begin Patch\n*** Add File: forbidden.txt\n+forbidden\n*** End Patch', + command: 'echo forbidden' + }) + } }] + } }] + })) + const runtime = new ModelAgentRuntime({ + baseUrl: 'https://example.test/v1', model: 'test-model', + protocol: 'openai-chat-completions', authentication: 'none', + fetcher, defaultWorkspace: workspace + }) + const authorize = vi.fn(async () => 'deny' as const) + try { + const consume = async () => { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId: 'ask-denied', + prompt: '只读', workMode: 'ask' + }, new AbortController().signal, authorize)) void event + } + await expect(consume()).rejects.toThrow('未知工具') + expect(authorize).not.toHaveBeenCalled() + await expect(readFile(join(workspace, 'forbidden.txt'))).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await runtime.dispose() + await rm(workspace, { recursive: true, force: true }) + } + } + ) + + it.each(['openai-chat-completions', 'openai-responses', 'anthropic-messages'] as const)( + 'lists only workspace reads and retained output in plain Ask via %s', + async (protocol) => { + const fetcher = vi.fn(async () => Response.json( + protocol === 'anthropic-messages' + ? { content: [{ type: 'text', text: '只读回答' }], stop_reason: 'end_turn' } + : protocol === 'openai-responses' + ? { id: 'resp-ask', output: [{ type: 'message', content: [{ type: 'output_text', text: '只读回答' }] }] } + : { choices: [{ message: { role: 'assistant', content: '只读回答' } }] } + )) + const runtime = new ModelAgentRuntime({ + baseUrl: 'https://example.test/v1', model: 'test-model', + protocol, authentication: 'none', fetcher + }) + try { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId: 'plain-ask', + prompt: '只读', workMode: 'ask' + }, new AbortController().signal, async () => 'deny')) void event + const body = JSON.parse(String(fetcher.mock.calls[0]![1]!.body)) + expect(body.tools.map((tool: { name?: string; function?: { name: string } }) => + tool.function?.name ?? tool.name)).toEqual(['workspace_read_text', 'output_read']) + } finally { + await runtime.dispose() + } + } + ) + it('uses the OpenAI Responses endpoint and streams output text', async () => { const fetcher = vi.fn(async () => new Response( @@ -2603,6 +2721,94 @@ describe('ModelAgentRuntime', () => { } ) + it('lets an Execute Subagent borrow the parent browser tab without changing tab ownership', async () => { + const conversationId = 'parent-browser-owner' + const browserTabId = browserTabIdSchema.parse('00000000-0000-4000-8000-000000000203') + let parentReleased = false + const browserService: BrowserToolService = { + getOrigin: vi.fn(() => 'https://example.test'), + navigate: vi.fn(), click: vi.fn(), type: vi.fn(), + select: vi.fn(), back: vi.fn(), screenshot: vi.fn(), + snapshot: vi.fn(async (owner, _signal, tabId) => { + // BrowserService resolves tabs under their owning conversation, not + // merely by tab ID; a child's temporary conversation cannot own it. + expect(owner).toBe(conversationId) + expect(tabId).toBe(browserTabId) + expect(parentReleased).toBe(false) + return { url: 'https://example.test/', title: 'Parent page', nodes: [], truncated: false } + }), + releaseConversation: vi.fn(async (owner) => { + if (owner === conversationId) parentReleased = true + }) + } + const toolResponse = (id: string, name: string, args: Record) => ({ + choices: [{ message: { + role: 'assistant', content: null, + tool_calls: [{ id, type: 'function', function: { + name, arguments: JSON.stringify(args) + } }] + } }] + }) + const responses = [ + toolResponse('delegate', 'subagent_delegate', { task: '读取父请求浏览器页面' }), + toolResponse('child-browser', 'browser_snapshot', {}), + { choices: [{ message: { role: 'assistant', content: '子级已读页面' } }] }, + toolResponse('parent-browser', 'browser_snapshot', {}), + { choices: [{ message: { role: 'assistant', content: '父级继续使用同一页面' } }] } + ] + const fetcher = vi.fn(async () => Response.json(responses.shift())) + const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 1, timeoutMs: 10_000 }) + const runtime = new ModelAgentRuntime({ + baseUrl: 'https://example.test/v1', model: 'test-model', + protocol: 'openai-chat-completions', authentication: 'none', + fetcher, browserService, directModelSubagentScheduler: scheduler + }) + const listTools = vi.spyOn(ModelToolProvider.prototype, 'listTools') + const callTool = vi.spyOn(ModelToolProvider.prototype, 'callTool') + try { + const events: RuntimeEvent[] = [] + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId, browserTabId, + prompt: '委派读取页面后继续使用页面', workMode: 'execute' + }, new AbortController().signal, async () => 'once')) events.push(event) + expect(fetcher).toHaveBeenCalledTimes(5) + const childContext = listTools.mock.calls.find( + ([context]) => context.delegationDepth === 1 + )![0] + expect(childContext).toMatchObject({ + workMode: 'execute', browserTabId, + browserConversationId: conversationId + }) + expect(childContext.conversationId).not.toBe(conversationId) + const childBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) + const childTools = childBody.tools.map( + (tool: { function: { name: string } }) => tool.function.name + ) + expect(childTools).toEqual(expect.arrayContaining(['browser_navigate', 'browser_snapshot', 'browser_click'])) + expect(childTools).not.toContain('subagent_delegate') + expect(callTool).toHaveBeenCalledWith( + 'browser_snapshot', {}, expect.any(AbortSignal), + expect.objectContaining({ + conversationId: childContext.conversationId, + browserConversationId: conversationId, browserTabId + }) + ) + expect(browserService.snapshot).toHaveBeenCalledTimes(2) + expect(browserService.releaseConversation).toHaveBeenCalledWith(childContext.conversationId) + expect(parentReleased).toBe(false) + expect(events.filter((event) => event.type === 'subagent').at(-1)) + .toMatchObject({ state: 'completed' }) + await runtime.releaseConversation(conversationId) + expect(parentReleased).toBe(true) + } finally { + listTools.mockRestore() + callTool.mockRestore() + await runtime.dispose() + scheduler.dispose() + await scheduler.waitForIdle() + } + }) + it('delegates one programming Subagent that can run a real project command without recursion', async () => { const command = process.platform === 'win32' diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 6711cbcb..9c36c746 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -86,6 +86,12 @@ type ConversationMessage = { content: string } +// Temporary Subagents retain separate history/output ownership while borrowing +// the parent's browser tab and its owning conversation. They acquire no lease. +type ModelExecutionRequest = AgentExecutionRequest & { + browserConversationId?: string +} + type ProviderConversationMessage = Pick< ConversationMessage, 'role' | 'content' @@ -120,6 +126,11 @@ type AgentRunCompressionState = { } const scopedReadToolNameSet = new Set(scopedReadToolNames) +const askWorkspaceReadToolNames = new Set([ + 'workspace_rg', + 'workspace_read_text', + 'output_read' +]) type AnthropicApiMessage = { role: 'user' | 'assistant' @@ -1678,6 +1689,9 @@ export class ModelAgentRuntime implements AgentRuntime { { requestId: input.context.childRunId, conversationId: input.context.conversationId, + browserTabId: input.requestContext.browserTabId, + browserConversationId: + input.requestContext.browserConversationId, projectId: input.context.projectId, workMode: input.context.workMode, prompt: input.task, @@ -3331,7 +3345,7 @@ export class ModelAgentRuntime implements AgentRuntime { } private async *runToolExecution( - request: AgentExecutionRequest, + request: ModelExecutionRequest, signal: AbortSignal, authorize: RuntimeAuthorizer | undefined, system: string, @@ -3364,6 +3378,7 @@ export class ModelAgentRuntime implements AgentRuntime { const toolContext: ModelToolCallContext = { conversationId: request.conversationId, browserTabId: request.browserTabId, + browserConversationId: request.browserConversationId, workMode: request.workMode ?? 'ask', requestId: request.requestId, runtimeTarget: 'model', @@ -3376,6 +3391,9 @@ export class ModelAgentRuntime implements AgentRuntime { subagentBridge: { requestContext: { authorize, + browserTabId: request.browserTabId, + browserConversationId: + request.browserConversationId ?? request.conversationId, knowledgeCapabilityToken: request.knowledgeCapabilityToken, emitEvent: emitNestedEvent @@ -3730,6 +3748,8 @@ export class ModelAgentRuntime implements AgentRuntime { try { if ( tool.name === 'subagent_delegate' || + (toolContext.workMode === 'ask' && + askWorkspaceReadToolNames.has(tool.name)) || (scopedReadToolNameSet.has(tool.name) && Boolean(request.knowledgeCapabilityToken)) || tool.name === 'web_search' || @@ -3894,7 +3914,7 @@ export class ModelAgentRuntime implements AgentRuntime { } async *run( - request: AgentExecutionRequest, + request: ModelExecutionRequest, signal: AbortSignal, authorize?: RuntimeAuthorizer ): AsyncGenerator { @@ -3952,7 +3972,10 @@ export class ModelAgentRuntime implements AgentRuntime { } yield result.value } - const executionRequest = prepared.request + const executionRequest: ModelExecutionRequest = { + ...prepared.request, + browserConversationId: request.browserConversationId + } yield { requestId: request.requestId, @@ -3971,9 +3994,7 @@ export class ModelAgentRuntime implements AgentRuntime { if ( executionRequest.workMode === 'execute' || (executionRequest.workMode === 'ask' && - (Boolean(executionRequest.knowledgeCapabilityToken) || - this.options.webSearchEnabled === true || - this.directModelSubagentEnabled)) + this.toolProvider !== noModelTools) ) { yield* this.runToolExecution( executionRequest, diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index 8221e753..6f371684 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -235,6 +235,19 @@ describe('ModelToolProvider', () => { expect.stringContaining('Search MCP'), expect.any(Error) ) + const healthyName = tools.find((tool) => tool.serverName === 'Healthy')!.name + for (let index = 0; index < 2; index += 1) { + await provider.callTool(healthyName, {}, signal, toolContext) + } + await expect(provider.callTool('unknown_mcp', {}, signal, toolContext)) + .rejects.toThrow('未知工具') + // No 30-second failed-server reconnect may precede a known healthy call. + expect(mocks.Client).toHaveBeenCalledTimes(2) + expect(mocks.client.connect).toHaveBeenCalledOnce() + expect(mocks.client.listTools).toHaveBeenCalledOnce() + expect(failedClient[failureMethod]).toHaveBeenCalledOnce() + expect(mocks.client.callTool).toHaveBeenCalledTimes(2) + const recovered = await provider.listTools(toolContext, signal) expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2) expect(mocks.Client).toHaveBeenCalledTimes(3) @@ -243,7 +256,7 @@ describe('ModelToolProvider', () => { tools.find((tool) => tool.serverName === 'Healthy')!.name, {}, signal, toolContext ) - expect(mocks.client.callTool).toHaveBeenCalledOnce() + expect(mocks.client.callTool).toHaveBeenCalledTimes(3) } finally { await provider.dispose() warning.mockRestore() @@ -251,6 +264,33 @@ describe('ModelToolProvider', () => { } ) + it('clears discovered bindings on disposal and does not carry them to a replacement configuration', async () => { + const workspace = await createWorkspace() + mocks.client.listTools.mockResolvedValue({ + tools: [{ name: 'search', inputSchema: { type: 'object' } }] + }) + const provider = new ModelToolProvider(workspace, [createMcpServer()]) + const replacement = new ModelToolProvider(workspace, []) + const signal = new AbortController().signal + try { + const tools = await provider.listTools(toolContext, signal) + const name = tools.find((tool) => tool.source === 'mcp')!.name + await provider.callTool(name, {}, signal, toolContext) + await provider.dispose() + await expect(provider.callTool(name, {}, signal, toolContext)) + .rejects.toThrow('未知工具') + expect((await replacement.listTools(toolContext, signal)) + .filter((tool) => tool.source === 'mcp')).toEqual([]) + await expect(replacement.callTool(name, {}, signal, toolContext)) + .rejects.toThrow('未知工具') + expect(mocks.client.callTool).toHaveBeenCalledOnce() + expect(mocks.Client).toHaveBeenCalledOnce() + } finally { + await provider.dispose() + await replacement.dispose() + } + }) + it('loads tools from more than sixteen MCP servers', async () => { mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'search', inputSchema: { type: 'object' } }] @@ -302,7 +342,7 @@ describe('ModelToolProvider', () => { ]) const signal = new AbortController().signal try { - await provider.listTools(toolContext, signal) + const originalTools = await provider.listTools(toolContext, signal) const options = mocks.Client.mock.calls[0]![1] as { listChanged: { tools: { onChanged(error: Error | null): void } } } @@ -312,6 +352,15 @@ describe('ModelToolProvider', () => { expect(tools.filter((tool) => tool.source === 'mcp')).toEqual([ expect.objectContaining({ serverName: 'Healthy' }) ]) + await expect(provider.callTool( + originalTools.find((tool) => tool.serverName === 'Search MCP')!.name, + {}, signal, toolContext + )).rejects.toThrow('未知工具') + await provider.callTool( + tools.find((tool) => tool.serverName === 'Healthy')!.name, + {}, signal, toolContext + ) + expect(mocks.client.listTools).toHaveBeenCalledTimes(3) expect(mocks.client.close).not.toHaveBeenCalled() const recovered = await provider.listTools(toolContext, signal) expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2) diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index 39d3e567..1c9eb383 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -291,6 +291,7 @@ export type ModelToolResult = { export type ModelToolCallContext = { conversationId: string browserTabId?: BrowserTabId + browserConversationId?: string workMode: 'ask' | 'execute' requestId?: string runtimeTarget?: 'model' @@ -302,6 +303,8 @@ export type ModelToolCallContext = { export type ModelSubagentRequestContext = { authorize?: RuntimeAuthorizer + browserTabId?: BrowserTabId + browserConversationId?: string knowledgeCapabilityToken?: string emitEvent: (event: RuntimeEvent) => void } @@ -663,6 +666,7 @@ export class ModelToolProvider implements ModelToolProviderLike { Promise >() private webSearchBindings?: Promise> + private mcpBindings = new Map() private readonly clients = new Set() private readonly customMcpClients = new Set() private readonly webSearchClients = new Set() @@ -739,7 +743,8 @@ export class ModelToolProvider implements ModelToolProviderLike { context.browserTabId ? new BrowserModelTools({ service: this.browserService, - conversationId: context.conversationId, + conversationId: + context.browserConversationId ?? context.conversationId, browserTabId: context.browserTabId }) : undefined @@ -1211,6 +1216,7 @@ export class ModelToolProvider implements ModelToolProviderLike { bindings.set(binding.definition.name, binding) } } + this.mcpBindings = bindings return bindings } @@ -1823,7 +1829,8 @@ export class ModelToolProvider implements ModelToolProviderLike { ) } - const binding = (await this.getMcpBindings(signal)).get(name) + // Calls use the discovered catalog; only listTools retries failed servers. + const binding = this.mcpBindings.get(name) if (!binding) { throw new Error('模型请求了未知工具') } @@ -1881,6 +1888,7 @@ export class ModelToolProvider implements ModelToolProviderLike { this.customMcpClients.clear() this.webSearchClients.clear() this.mcpConnections.clear() + this.mcpBindings.clear() this.webSearchBindings = undefined await Promise.allSettled([ ...clients.map((client) => client.close()), diff --git a/src/main/agent/runtime-selection-contracts.test.ts b/src/main/agent/runtime-selection-contracts.test.ts new file mode 100644 index 00000000..c928b02b --- /dev/null +++ b/src/main/agent/runtime-selection-contracts.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { applyRuntimeSelection } from './runtime-selection' +import type { ResolvedRuntimeSettings } from '../runtime-settings-store' +import { defaultRuntimeCustomizationSettings } from '../../shared/contracts' +import { + repairChannelRuntimeSelection, + type AgentRuntimeSelection, + type RuntimeSelectionRepairSettings +} from '../../shared/runtime-selection-contracts' + +const defaultId = '00000000-0000-4000-8000-000000000001' +const fallbackId = '00000000-0000-4000-8000-000000000002' +const missingId = '00000000-0000-4000-8000-000000000003' + +function settings( + defaultProfile: Partial +): RuntimeSelectionRepairSettings { + return { + defaultModelProfileId: defaultId, + modelProfiles: [ + { + id: defaultId, protocol: 'openai-chat-completions', + authentication: 'api-key', apiKeyConfigured: true, ...defaultProfile + }, + { + id: fallbackId, protocol: 'openai-chat-completions', + authentication: 'none', apiKeyConfigured: false + } + ], + opencodeModelSource: { kind: 'platform' }, + continueModelSource: { kind: 'platform' } + } +} + +function resolvedSettings(input: RuntimeSelectionRepairSettings): ResolvedRuntimeSettings { + return { + provider: 'model', modelBaseUrl: 'https://model.example/v1', modelName: 'default', + modelProtocol: 'openai-chat-completions', modelAuthentication: 'api-key', + imageGenerationQuality: 'auto', + modelProfiles: input.modelProfiles.map((profile) => ({ + id: profile.id, name: profile.id, baseUrl: 'https://model.example/v1', + modelName: profile.id, + protocol: profile.protocol as ResolvedRuntimeSettings['modelProtocol'], + authentication: profile.authentication!, + apiKey: profile.apiKeyConfigured ? 'fixture-key' : undefined + })), + defaultModelProfileId: input.defaultModelProfileId, + opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '', + opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', + continueMode: 'chat', subagentSmartRoutingEnabled: false, + knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: '', + knowledgeEmbeddingModel: '', knowledgeRerankEnabled: false, + knowledgeRerankEndpoint: '', knowledgeRerankModel: '', + runtimeCustomization: defaultRuntimeCustomizationSettings, + workspacePath: '.', toolApproval: 'always' + } +} + +describe('channel direct model repair', () => { + const selections: AgentRuntimeSelection[] = [ + { provider: 'auto' }, + { provider: 'model' }, + { provider: 'model', profileId: defaultId }, + { provider: 'model', profileId: missingId } + ] + it.each([ + { protocol: 'openai-images-generations' }, + { apiKeyConfigured: false }, + { id: missingId } + ])('pins a verified fallback when the default is unusable: %j', (defaultProfile) => { + const input = settings(defaultProfile) + if (defaultProfile.id) input.modelProfiles = input.modelProfiles.slice(1) + for (const selection of selections) { + const repaired = repairChannelRuntimeSelection(selection, input) + expect(repaired).toEqual({ provider: 'model', profileId: fallbackId }) + const resolved = applyRuntimeSelection(resolvedSettings(input), repaired) + expect(resolved.settings.defaultModelProfileId).toBe(fallbackId) + expect(resolved.settings.modelProtocol).toBe('openai-chat-completions') + expect(resolved.settings.modelAuthentication).toBe('none') + } + }) + + it('keeps a healthy default dynamic and preserves valid explicit pins', () => { + const input = settings({}) + for (const selection of [selections[0]!, selections[1]!, selections[3]!]) { + const repaired = repairChannelRuntimeSelection(selection, input) + expect(repaired).toEqual({ provider: 'model' }) + const changed = { ...resolvedSettings(input), defaultModelProfileId: fallbackId } + expect(applyRuntimeSelection(changed, repaired).settings.defaultModelProfileId).toBe(fallbackId) + } + for (const profileId of [defaultId, fallbackId]) { + const selection = { provider: 'model', profileId } as const + expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection) + } + }) + + it('preserves a valid pin even when the global default is an image model', () => { + const input = settings({ protocol: 'openai-images-generations' }) + const selection = { provider: 'model', profileId: fallbackId } as const + expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection) + }) + + it('does not invent a profile when no usable fallback exists', () => { + const input = settings({ apiKeyConfigured: false }) + input.modelProfiles = input.modelProfiles.slice(0, 1) + expect(repairChannelRuntimeSelection({ provider: 'auto' }, input)) + .toEqual({ provider: 'model' }) + }) +}) diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 0cedf5a5..902cebf0 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -10,6 +10,7 @@ import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts' import { AssistantDatabase } from './assistant-database' +import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' const temporaryDirectories: string[] = [] const channelDefaultProfileId = @@ -3108,6 +3109,79 @@ describe('AssistantDatabase', () => { inspected.close() }) + it('persists inherited remote metrics and replay without pinning the conversation', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-inherited-remote-metrics-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + let database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.createSshProject(validatedSshProjectWrite()) + const conversationId = '00000000-0000-4000-8000-000000000701' + const taskId = '00000000-0000-4000-8000-000000000702' + const assistantMessageId = '00000000-0000-4000-8000-000000000704' + database.saveLocalConversations([{ + header: { id: conversationId, projectId: project.id, title: '继承', updatedAt: 0 }, + messages: [] + }]) + database.createTask({ + id: taskId, projectId: project.id, conversationId, + title: '继承', instructions: '回复', workMode: 'ask', + remoteRecovery: { + recoverable: true, + currentUserMessageId: '00000000-0000-4000-8000-000000000703', + currentAssistantMessageId: assistantMessageId + } + }) + const provenance = { + taskId, conversationId, assistantMessageId, + bindingId: 'inherited-metrics', operationId: 'inherited-metrics', eventIndex: 0 + } + const metrics = { + ...provenance, semanticSequence: '1', + event: { + requestId: taskId, type: 'context-metrics' as const, + contextTokens: 123, source: 'provider' as const, + effectiveTriggerTokens: 1000, compressionEnabled: false + } + } + expect(database.appendRemoteConversationTaskEventOnce(metrics)).toBe(true) + expect(database.getConversation(conversationId)).toMatchObject({ + contextMetrics: { runtimeSelectionKey: agentRuntimeSelectionKey({ provider: 'opencode' }), contextTokens: 123 } + }) + database.close() + database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + expect(database.appendRemoteConversationTaskEventOnce(metrics)).toBe(false) + const runtimeSelection = { provider: 'opencode' as const, profileId: channelDefaultProfileId } + expect(database.appendRemoteConversationTaskEventOnce({ + ...metrics, semanticSequence: '2', runtimeSelection + })).toBe(true) + expect(database.getConversation(conversationId).contextMetrics?.runtimeSelectionKey) + .toBe(agentRuntimeSelectionKey(runtimeSelection)) + expect(database.appendRemoteConversationTaskEventOnce({ + ...provenance, semanticSequence: '3', runtimeSelection, + event: { + requestId: taskId, type: 'context-compression', state: 'completed', + scope: 'conversation', estimatedBeforeTokens: 123, estimatedAfterTokens: 45, + effectiveTriggerTokens: 1000, recentRawTokens: 45 + } + })).toBe(true) + expect(database.getConversation(conversationId)).toMatchObject({ + contextMetrics: { + runtimeSelectionKey: agentRuntimeSelectionKey(runtimeSelection), + contextTokens: 45, source: 'estimated', basis: 'conversation' + } + }) + expect(database.getConversation(conversationId).runtimeSelection).toBeUndefined() + database.close() + const raw = new DatabaseSync(databasePath) + expect(raw.prepare('SELECT runtime_selection_json FROM conversations WHERE id = ?').get(conversationId)) + .toEqual({ runtime_selection_json: null }) + raw.close() + }) + it('creates and recovers a remote-authoritative conversation task', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-conversation-recovery-') diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 5d7f382a..472e301a 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -113,6 +113,7 @@ export type RemoteConversationTaskEventInput = Omit< > & { conversationId: string assistantMessageId: string + runtimeSelection?: AgentRuntimeSelection event: | AgentEvent | { @@ -4940,6 +4941,10 @@ export class AssistantDatabase { const assistantMessageId = assistantIdSchema.parse( input.assistantMessageId ) + const requestRuntimeSelection = + input.runtimeSelection === undefined + ? undefined + : agentRuntimeSelectionSchema.parse(input.runtimeSelection) const publicEvent = stripRemoteEventProvenance(input.event) if (publicEvent.requestId !== input.taskId) { throw new Error('远程事件的请求 ID 与任务不匹配') @@ -5054,13 +5059,16 @@ export class AssistantDatabase { } const conversation = database .prepare( - `SELECT runtime_selection_json, context_state_json - FROM conversations - WHERE id = ?` + `SELECT c.runtime_selection_json, c.context_state_json, + p.runtime_selection_json AS project_runtime_selection_json + FROM conversations c + LEFT JOIN projects p ON p.id = c.project_id + WHERE c.id = ?` ) .get(conversationId) as | { runtime_selection_json: string | null + project_runtime_selection_json: string | null context_state_json: string | null } | undefined @@ -5070,13 +5078,12 @@ export class AssistantDatabase { let contextState = parseConversationContextState( conversation.context_state_json ) + const runtimeSelection = + requestRuntimeSelection ?? + parseRuntimeSelection(conversation.runtime_selection_json) ?? + parseRuntimeSelection(conversation.project_runtime_selection_json) ?? + { provider: 'auto' as const } if (publicEvent.type === 'context-metrics') { - const runtimeSelection = parseRuntimeSelection( - conversation.runtime_selection_json - ) - if (!runtimeSelection) { - throw new Error('远程事件的对话缺少 Runtime 选择') - } contextState = { ...contextState, contextMetrics: { @@ -5091,9 +5098,6 @@ export class AssistantDatabase { publicEvent.type === 'context-compression' && publicEvent.scope !== 'agent-run' ) { - const runtimeSelection = parseRuntimeSelection( - conversation.runtime_selection_json - ) const estimatedAfterTokens = publicEvent.estimatedAfterTokens contextState = { @@ -5105,8 +5109,7 @@ export class AssistantDatabase { } : {}), ...(publicEvent.state === 'completed' && - estimatedAfterTokens !== undefined && - runtimeSelection + estimatedAfterTokens !== undefined ? { contextMetrics: { runtimeSelectionKey: diff --git a/src/main/browser/browser-service.test.ts b/src/main/browser/browser-service.test.ts index 1aee5533..11178511 100644 --- a/src/main/browser/browser-service.test.ts +++ b/src/main/browser/browser-service.test.ts @@ -151,7 +151,6 @@ function createHarness(options: { maximumTabsPerWindow: options.maximumTabsPerWindow, idleTimeoutMs: options.idleTimeoutMs, cleanupTimeoutMs: options.cleanupTimeoutMs, - liveFrameDelayMs: 0, createSession, createDriver: (contents) => { const slot = byContents.get(contents) @@ -173,6 +172,185 @@ describe('BrowserService', () => { const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca' const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251' + it('reserves four unused requests without resources, events, or consuming session capacity', async () => { + const harness = createHarness({ maximumSessions: 3 }) + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + const leases = ['one', 'two', 'three', 'four'].map((id) => + harness.service.reserveRequestTab(id, `request-${id}`, 21) + ) + expect(harness.service.getSessionCount()).toBe(0) + expect(harness.service.getTabCount()).toBe(0) + expect(harness.createSession).not.toHaveBeenCalled() + expect(states).toEqual([]) + expect(harness.service.getOwnerWindowId('one')).toBe(21) + for (const lease of leases) { + lease.release() + lease.release() + expect(lease.signal.aborted).toBe(true) + expect(harness.service.getOwnerWindowId(lease.conversationId)).toBeUndefined() + } + const replacement = harness.service.reserveRequestTab('one', 'replacement', 22) + expect(replacement.tabId).not.toBe(leases[0]!.tabId) + await expect(harness.service.navigate('one', 'https://example.com/', new AbortController().signal, leases[0]!.tabId, 22)) + .rejects.toThrow('不属于当前对话') + expect(harness.createSession).not.toHaveBeenCalled() + replacement.release() + await harness.service.dispose() + }) + + it('shares the reserved identity through navigation, workbar lookup, and all usage leases', async () => { + const harness = createHarness() + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + const first = harness.service.reserveRequestTab('conversation', 'first', 21) + const second = harness.service.reserveRequestTab('conversation', 'second', 21) + expect(second.tabId).toBe(first.tabId) + expect(() => harness.service.reserveRequestTab('conversation', 'wrong-window', 22)).toThrow('不属于当前窗口') + const signal = new AbortController().signal + await Promise.all([ + harness.service.navigate('conversation', 'https://example.com/first', signal, first.tabId), + harness.service.navigate('conversation', 'https://example.com/second', signal, second.tabId) + ]) + expect(harness.createSession).toHaveBeenCalledOnce() + expect(states[0]).toMatchObject({ + status: 'creating', + tabId: first.tabId, + workbarInstanceId: first.tabId, + ownerWindowId: 21 + }) + expect(states.every((state) => state.workbarInstanceId === first.tabId)).toBe(true) + expect(harness.service.listTabs('conversation', 21)).toEqual([ + expect.objectContaining({ tabId: first.tabId, workbarInstanceId: first.tabId }) + ]) + const restored = await harness.service.createTab('conversation', 21, signal, first.tabId) + expect(restored.tabId).toBe(first.tabId) + expect(harness.service.getTabCount()).toBe(1) + first.release() + await expect(harness.service.closeTab('conversation', first.tabId, 21)).rejects.toThrow('正在被活动请求使用') + second.release() + await harness.service.closeTab('conversation', first.tabId, 21) + await harness.service.closeTab('conversation', first.tabId, 21) + await harness.service.dispose() + }) + + it('keeps a fresh explicit workbar separate from a reserved request target', async () => { + const harness = createHarness() + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + const request = harness.service.reserveRequestTab('conversation', 'request', 21) + const signal = new AbortController().signal + const explicit = await harness.service.createTab('conversation', 21, signal, workbarInstanceId) + expect(explicit.tabId).not.toBe(request.tabId) + expect(explicit.workbarInstanceId).toBe(workbarInstanceId) + expect(harness.service.getTabCount()).toBe(1) + expect(states.every((state) => state.tabId === explicit.tabId)).toBe(true) + await harness.service.navigate('conversation', 'https://example.com/', signal, request.tabId, 21) + expect(harness.service.listTabs('conversation', 21)).toHaveLength(2) + expect(states.find((state) => state.tabId === request.tabId)).toMatchObject({ + status: 'creating', + workbarInstanceId: request.tabId + }) + expect(explicit.url).toBeUndefined() + request.release() + await harness.service.dispose() + }) + + it('publishes the explicit workbar identity from the first creating event', async () => { + const harness = createHarness() + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + const tab = await harness.service.createTab('conversation', 21, new AbortController().signal, workbarInstanceId) + expect(states[0]).toMatchObject({ status: 'creating', tabId: tab.tabId, workbarInstanceId }) + expect(states.every((state) => state.workbarInstanceId === workbarInstanceId)).toBe(true) + expect(tab.workbarInstanceId).toBe(workbarInstanceId) + await expect(harness.service.createTab('conversation', 22, new AbortController().signal, workbarInstanceId)).rejects.toThrow('不属于当前窗口') + await harness.service.dispose() + }) + + it('cleans unused reservations on conversation release, service clear, and disposal', async () => { + const harness = createHarness() + const first = harness.service.reserveRequestTab('first', 'first', 21) + await expect(harness.service.releaseConversation('first', 22)).rejects.toThrow('不属于当前窗口') + expect(first.signal.aborted).toBe(false) + await harness.service.releaseConversation('first', 21) + expect(first.signal.aborted).toBe(true) + expect(harness.service.getOwnerWindowId('first')).toBeUndefined() + const second = harness.service.reserveRequestTab('second', 'second', 21) + await harness.service.clearSessions() + expect(second.signal.aborted).toBe(true) + const third = harness.service.reserveRequestTab('third', 'third', 21) + await harness.service.dispose() + expect(third.signal.aborted).toBe(true) + expect(harness.createSession).not.toHaveBeenCalled() + expect(() => harness.service.reserveRequestTab('fourth', 'fourth', 21)).toThrow('已关闭') + }) + + it('abandons materialization when the final request lease is released', async () => { + const gate = deferred() + const harness = createHarness({ sessionGate: gate.promise }) + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + const request = harness.service.reserveRequestTab('conversation', 'request', 21) + const navigation = harness.service.navigate('conversation', 'https://example.com/', new AbortController().signal, request.tabId) + const rejection = expect(navigation).rejects.toThrow('已取消') + await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce()) + request.release() + gate.resolve() + await rejection + expect(harness.service.getSessionCount()).toBe(0) + await vi.waitFor(() => expect(harness.slots[0]!.session.dispose).toHaveBeenCalledOnce()) + expect(states.at(-1)?.status).toBe('stopped') + await harness.service.dispose() + }) + + it('preserves shared reserved materialization when one request cancels', async () => { + const gate = deferred() + const harness = createHarness({ sessionGate: gate.promise }) + const first = harness.service.reserveRequestTab('conversation', 'first', 21) + const second = harness.service.reserveRequestTab('conversation', 'second', 21) + const firstNavigation = harness.service.navigate('conversation', 'https://example.com/first', first.signal, first.tabId) + const canceled = expect(firstNavigation).rejects.toThrow('租约已终止') + const secondNavigation = harness.service.navigate('conversation', 'https://example.com/second', second.signal, second.tabId) + await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce()) + first.release() + await canceled + gate.resolve() + await secondNavigation + expect(harness.service.listTabs('conversation', 21)).toEqual([ + expect.objectContaining({ tabId: second.tabId, workbarInstanceId: second.tabId, url: 'https://example.com/second' }) + ]) + expect(harness.slots[0]!.session.dispose).not.toHaveBeenCalled() + second.release() + await harness.service.dispose() + }) + + it('retains a materialized reserved tab until the request lease stops protecting it from idle expiry', async () => { + vi.useFakeTimers() + const harness = createHarness({ idleTimeoutMs: 100 }) + const lease = harness.service.reserveRequestTab('conversation', 'request', 21) + await harness.service.navigate('conversation', 'https://example.com/', lease.signal, lease.tabId) + await vi.advanceTimersByTimeAsync(500) + expect(harness.service.getSessionCount()).toBe(1) + lease.release() + await vi.advanceTimersByTimeAsync(101) + expect(harness.service.getSessionCount()).toBe(0) + await harness.service.dispose() + }) + + it('keeps stale close idempotent but rejects another conversation or window', async () => { + const harness = createHarness() + const first = await harness.service.createTab('first', 21) + const sibling = await harness.service.createTab('first', 21) + const other = await harness.service.createTab('other', 21) + await harness.service.closeTab('first', sibling.tabId, 21) + await harness.service.closeTab('first', sibling.tabId, 21) + await expect(harness.service.closeTab('first', sibling.tabId, 22)).rejects.toThrow('不属于当前窗口') + await expect(harness.service.closeTab('first', other.tabId, 21)).rejects.toThrow('不属于当前对话') + await expect(harness.service.closeTab('missing', first.tabId, 21)).rejects.toThrow('不属于当前对话') + await harness.service.dispose() + }) + it('presents one session in the shared viewport and tracks user navigation', async () => { const harness = createHarness() const bounds = { x: 900, y: 120, width: 420, height: 640 } @@ -219,7 +397,7 @@ describe('BrowserService', () => { await harness.service.dispose() }) - it('publishes browser status and live frames through session cleanup', async () => { + it('publishes lightweight browser status through session cleanup', async () => { const harness = createHarness() const states: Array<{ status: string @@ -261,9 +439,7 @@ describe('BrowserService', () => { isLoading: false, canGoBack: false }) - expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe( - 'data:image/jpeg;base64,/9j/2Q==' - ) + expect(states.every((state) => state.frameDataUrl === undefined)).toBe(true) expect(states.at(-1)?.frameDataUrl).toBeUndefined() const replayed: string[] = [] const removeReplayListener = harness.service.onState((state) => { @@ -275,163 +451,55 @@ describe('BrowserService', () => { await harness.service.dispose() }) - it('does not publish ready after a session is stopped during frame capture', async () => { + it('ordinary actions only refresh navigation metadata and never capture images', async () => { + const nativeCapture = vi.fn(async () => { + throw new Error('native capture unavailable while hidden') + }) + const harness = createHarness({ captureScreenshot: nativeCapture }) + const signal = new AbortController().signal + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + await harness.service.navigate('conversation', 'https://example.com/', signal) + await harness.service.snapshot('conversation', signal) + await harness.service.click('conversation', 'button_ref', signal) + await harness.service.type('conversation', 'input_ref', 'text', signal) + await harness.service.select('conversation', 'select_ref', 'value', signal) + await harness.service.reload('conversation', signal) + await harness.service.back('conversation', signal) + const slot = harness.slots[0]! + expect(nativeCapture).not.toHaveBeenCalled() + expect(slot.driver.screenshot).not.toHaveBeenCalled() + expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(7) + expect(states.at(-1)?.status).toBe('ready') + expect(states.every((state) => !('frameDataUrl' in state))).toBe(true) + + const screenshot = await harness.service.screenshot('conversation', signal) + expect(screenshot.data).toBe('/9j/2Q==') + expect(nativeCapture).toHaveBeenCalledOnce() + expect(slot.driver.screenshot).toHaveBeenCalledOnce() + expect(states.at(-1)?.status).toBe('ready') + expect(states.every((state) => !('frameDataUrl' in state))).toBe(true) + await harness.service.dispose() + }) + + it('does not publish ready after a session is stopped during metadata refresh', async () => { const harness = createHarness() const signal = new AbortController().signal const states: BrowserLiveState[] = [] harness.service.onState((state) => states.push(state)) - await harness.service.navigate( - 'conversation', - 'https://example.com/', - signal - ) - const slot = harness.slots[0] - if (!slot) { - throw new Error('slot missing') - } - vi.mocked(slot.driver.screenshot).mockImplementationOnce( - async (operationSignal) => - new Promise((_resolve, reject) => { - operationSignal.addEventListener( - 'abort', - () => reject(operationSignal.reason), - { once: true } - ) - }) - ) - - const click = harness.service.click( - 'conversation', - 'button_ref', - signal - ) - await vi.waitFor(() => - expect(slot.driver.screenshot).toHaveBeenCalledTimes(2) + await harness.service.navigate('conversation', 'https://example.com/', signal) + const slot = harness.slots[0]! + vi.mocked(slot.driver.getNavigationMetadata).mockImplementationOnce( + async (operationSignal) => new Promise((_resolve, reject) => { + operationSignal.addEventListener('abort', () => reject(operationSignal.reason), { once: true }) + }) ) + const click = harness.service.click('conversation', 'button_ref', signal) + const rejected = expect(click).rejects.toThrow('浏览器会话已释放') + await vi.waitFor(() => expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(2)) await harness.service.releaseConversation('conversation') - - await expect(click).rejects.toThrow('浏览器会话已释放') + await rejected expect(states.at(-1)?.status).toBe('stopped') - }) - - it('falls back to CDP when native capture cannot produce the live frame', async () => { - const nativeCapture = vi.fn(async () => { - throw new Error('native capture unavailable while hidden') - }) - const harness = createHarness({ - captureScreenshot: nativeCapture - }) - const states: BrowserLiveState[] = [] - harness.service.onState((state) => states.push(state)) - - await harness.service.navigate( - 'conversation', - 'https://example.com/', - new AbortController().signal - ) - - expect(nativeCapture).toHaveBeenCalledOnce() - expect(harness.slots[0]?.driver.screenshot).toHaveBeenCalledOnce() - expect(states.at(-1)).toMatchObject({ - status: 'ready', - frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==' - }) - await harness.service.dispose() - }) - - it('retries live capture while a newly committed page starts painting', async () => { - let attempts = 0 - const harness = createHarness({ - captureScreenshot: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('page has not painted yet') - } - return { - type: 'image', - mimeType: 'image/jpeg', - data: '/9j/2Q==' - } - }, - driverScreenshot: async () => { - throw new Error('CDP frame not ready') - } - }) - const states: BrowserLiveState[] = [] - harness.service.onState((state) => states.push(state)) - - await harness.service.navigate( - 'conversation', - 'https://example.com/', - new AbortController().signal - ) - - expect(attempts).toBe(2) - expect(states.at(-1)).toMatchObject({ - status: 'ready', - frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==' - }) - await harness.service.dispose() - }) - - it('reports a live-frame failure instead of waiting indefinitely', async () => { - const harness = createHarness({ - captureScreenshot: async () => { - throw new Error('native capture failed') - }, - driverScreenshot: async () => { - throw new Error('CDP capture failed') - } - }) - const states: BrowserLiveState[] = [] - harness.service.onState((state) => states.push(state)) - - await harness.service.navigate( - 'conversation', - 'https://example.com/', - new AbortController().signal - ) - - expect(states.at(-1)).toMatchObject({ - status: 'failed', - error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作' - }) - await harness.service.dispose() - }) - - it('keeps the last frame when a later refresh cannot capture a minimized window', async () => { - let nativeAttempts = 0 - const harness = createHarness({ - captureScreenshot: async () => { - nativeAttempts += 1 - if (nativeAttempts === 1) { - return { - type: 'image', - mimeType: 'image/jpeg', - data: '/9j/2Q==' - } - } - throw new Error('minimized native capture unavailable') - }, - driverScreenshot: async () => { - throw new Error('minimized CDP capture unavailable') - } - }) - const states: BrowserLiveState[] = [] - harness.service.onState((state) => states.push(state)) - const signal = new AbortController().signal - await harness.service.navigate( - 'conversation', - 'https://example.com/', - signal - ) - - await harness.service.click('conversation', 'button_ref', signal) - - expect(states.at(-1)).toMatchObject({ - status: 'ready', - frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==' - }) await harness.service.dispose() }) @@ -1180,7 +1248,7 @@ describe('BrowserService', () => { await harness.service.dispose() }) - it('does not resolve DNS again while refreshing frame metadata', async () => { + it('does not resolve DNS again while refreshing navigation metadata', async () => { const harness = createHarness() const signal = new AbortController().signal await harness.service.navigate( diff --git a/src/main/browser/browser-service.ts b/src/main/browser/browser-service.ts index 63253156..2bdf0e56 100644 --- a/src/main/browser/browser-service.ts +++ b/src/main/browser/browser-service.ts @@ -84,7 +84,6 @@ export type BrowserServiceOptions = { maximumTabsPerWindow?: number idleTimeoutMs?: number cleanupTimeoutMs?: number - liveFrameDelayMs?: number parentWindow?: BrowserParentWindowHandle createSession?: ( policy: BrowserUrlPolicy, @@ -134,12 +133,20 @@ type BrowserConversationSlot = { } type ConversationCreation = { + tabId: BrowserTabId controller: AbortController promise: Promise waiters: Set ownerWindowId?: number } +type RequestTabReservation = Pick< + BrowserTabSlot, + 'conversationId' | 'tabId' | 'usageLeases' +> & { + ownerWindowId?: number +} + type ViewportLease = { leaseToken: string ownerWindowId?: number @@ -227,7 +234,6 @@ export class BrowserService { private readonly maximumTabsPerWindow: number private readonly idleTimeoutMs: number private readonly cleanupTimeoutMs: number - private readonly liveFrameDelayMs: number private readonly createSession: NonNullable private readonly createDriver: NonNullable private readonly conversations = new Map() @@ -238,6 +244,7 @@ export class BrowserService { private readonly stateListeners = new Set<(state: BrowserLiveState) => void>() private readonly liveStates = new Map() private readonly ownedTabCreations = new Map() + private readonly requestTabs = new Map() private viewport?: ViewportLease private lifecycle = new AbortController() private clearOperation?: Promise @@ -253,7 +260,6 @@ export class BrowserService { options.maximumTabsPerWindow ?? DEFAULT_MAXIMUM_TABS_PER_WINDOW this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS - this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100 this.createSession = options.createSession ?? ((policy, signal) => defaultCreateSession(policy, signal, options.parentWindow)) @@ -269,9 +275,6 @@ export class BrowserService { throw new Error('浏览器服务限制配置无效') } } - if (!Number.isSafeInteger(this.liveFrameDelayMs) || this.liveFrameDelayMs < 0) { - throw new Error('浏览器服务限制配置无效') - } } getOrigin(conversationId: string, tabId?: BrowserTabId): string | undefined { @@ -292,7 +295,8 @@ export class BrowserService { getOwnerWindowId(conversationId: string): number | undefined { return this.conversations.get(conversationId)?.ownerWindowId ?? - this.creations.get(conversationId)?.ownerWindowId + this.creations.get(conversationId)?.ownerWindowId ?? + this.requestTabs.get(conversationId)?.ownerWindowId } onState(listener: (state: BrowserLiveState) => void): () => void { @@ -349,9 +353,9 @@ export class BrowserService { } private emitState( - tab: Pick, + tab: Pick, status: BrowserLiveState['status'], - update: Partial> = {} + update: Partial> = {} ): void { const key = stateKey(tab.conversationId, tab.tabId) const previous = this.liveStates.get(key) @@ -359,6 +363,9 @@ export class BrowserService { const state: BrowserLiveState = { conversationId: tab.conversationId, tabId: tab.tabId, + ...((tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId) + ? { workbarInstanceId: tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId } + : {}), ...(current ? this.conversations.get(tab.conversationId)?.ownerWindowId !== undefined ? { ownerWindowId: this.conversations.get(tab.conversationId)!.ownerWindowId } @@ -375,7 +382,6 @@ export class BrowserService { (status === 'creating' ? true : status === 'stopped' ? false : current?.isLoading ?? false), canGoBack: update.canGoBack ?? previous?.canGoBack ?? false, ...(previous?.url ? { url: previous.url } : {}), - ...(status !== 'stopped' && previous?.frameDataUrl ? { frameDataUrl: previous.frameDataUrl } : {}), ...update, updatedAt: Date.now() } @@ -392,7 +398,7 @@ export class BrowserService { } private assertWindowOwner( - conversation: BrowserConversationSlot, + conversation: { ownerWindowId?: number }, ownerWindowId?: number ): void { if (ownerWindowId === undefined) return @@ -540,11 +546,19 @@ export class BrowserService { private async getOrCreateConversation( conversationId: string, signal: AbortSignal, - ownerWindowId?: number + ownerWindowId?: number, + workbarInstanceId?: string, + requestTabId?: BrowserTabId ): Promise { + signal.throwIfAborted() if (this.disposed || this.clearing || this.lifecycle.signal.aborted) { throw new Error('浏览器服务已关闭') } + const requestTab = this.requestTabs.get(conversationId) + if (requestTab) { + this.assertWindowOwner(requestTab, ownerWindowId) + ownerWindowId ??= requestTab.ownerWindowId + } const existing = this.conversations.get(conversationId) if (existing && !existing.released) { this.assertWindowOwner(existing, ownerWindowId) @@ -562,23 +576,30 @@ export class BrowserService { const releaseReservation = this.reserveTab(conversationId, ownerWindowId) const controller = new AbortController() const waiters = new Set() - const tabId = createTabId() + const tabId = requestTabId ?? createTabId() + const instanceId = workbarInstanceId ?? tabId const promise = this.createConversation( conversationId, tabId, ownerWindowId, AbortSignal.any([this.lifecycle.signal, controller.signal]), - () => waiters.size > 0 + () => waiters.size > 0, + instanceId ) void promise.finally(releaseReservation).catch(() => undefined) - creation = { controller, promise, waiters, ownerWindowId } + creation = { tabId, controller, promise, waiters, ownerWindowId } this.creations.set(conversationId, creation) - this.emitState({ conversationId, tabId }, 'creating') + this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'creating') const current = creation const remove = (): void => { if (this.creations.get(conversationId) === current) this.creations.delete(conversationId) } - void promise.then(remove, remove) + void promise.then(remove, () => { + remove() + if (this.liveStates.get(stateKey(conversationId, tabId))?.status === 'creating') { + this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'stopped') + } + }) } const waiter = Symbol(conversationId) creation.waiters.add(waiter) @@ -600,7 +621,8 @@ export class BrowserService { tabId: BrowserTabId, ownerWindowId: number | undefined, signal: AbortSignal, - hasWaiters: () => boolean + hasWaiters: () => boolean, + workbarInstanceId: string ): Promise { const session = await this.createSession(this.policy, signal) if (this.disposed || signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) { @@ -618,7 +640,16 @@ export class BrowserService { lastUsedAt: Date.now(), released: false } - const tab = await this.createTabSlot(undefined, conversationId, session, tabId) + const tab = await this.createTabSlot(undefined, conversationId, session, tabId, workbarInstanceId) + if (signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) { + tab.removeLoadingListener() + tab.removeNavigationListener() + tab.driver.dispose() + await boundedCleanup(session.dispose(), this.cleanupTimeoutMs) + throw signal.reason ?? new Error('浏览器会话创建已取消') + } + this.adoptRequestTab(conversation, tab) + conversation.ownedTabs.set(workbarInstanceId, tabId) conversation.tabs.set(tabId, tab) this.conversations.set(conversationId, conversation) this.emitState(tab, 'ready') @@ -627,6 +658,14 @@ export class BrowserService { return conversation } + private adoptRequestTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): void { + const reservation = this.requestTabs.get(conversation.conversationId) + if (reservation?.tabId !== tab.tabId) return + tab.usageLeases = reservation.usageLeases + conversation.ownerWindowId ??= reservation.ownerWindowId + this.requestTabs.delete(conversation.conversationId) + } + async createTab( conversationId: string, ownerWindowId?: number, @@ -636,6 +675,9 @@ export class BrowserService { if (workbarInstanceId) { const key = ownershipKey(conversationId, workbarInstanceId) const existingConversation = this.conversations.get(conversationId) + if (existingConversation) this.assertWindowOwner(existingConversation, ownerWindowId) + const pendingConversation = this.creations.get(conversationId) + if (pendingConversation) this.assertWindowOwner(pendingConversation, ownerWindowId) const existingTabId = existingConversation?.ownedTabs.get(workbarInstanceId) const existingTab = existingTabId ? existingConversation?.tabs.get(existingTabId) : undefined if (existingTab && !existingTab.released) return this.summarizeTab(existingTab) @@ -677,17 +719,13 @@ export class BrowserService { workbarInstanceId?: string ): Promise { const existed = this.conversations.has(conversationId) || this.creations.has(conversationId) - const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId) + const reservation = this.requestTabs.get(conversationId) + const requestTabId = reservation && reservation.tabId === workbarInstanceId ? reservation.tabId : undefined + const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId, workbarInstanceId, requestTabId) if (workbarInstanceId) { const ownedTabId = conversation.ownedTabs.get(workbarInstanceId) const ownedTab = ownedTabId ? conversation.tabs.get(ownedTabId) : undefined if (ownedTab && !ownedTab.released) return this.summarizeTab(ownedTab) - const primary = conversation.tabs.get(conversation.primaryTabId) - if (primary && !primary.workbarInstanceId && !existed) { - primary.workbarInstanceId = workbarInstanceId - conversation.ownedTabs.set(workbarInstanceId, primary.tabId) - return this.summarizeTab(primary) - } } else if (!existed) { return this.summarizeTab(conversation.tabs.get(conversation.primaryTabId)!) } @@ -709,9 +747,14 @@ export class BrowserService { await boundedCleanup(session.dispose(), this.cleanupTimeoutMs) throw effectiveSignal.reason ?? new Error('浏览器会话已关闭') } - const tabId = createTabId() - this.emitState({ conversationId, tabId }, 'creating') + const tabId = requestTabId ?? createTabId() + this.emitState({ conversationId, tabId, workbarInstanceId }, 'creating') const tab = await this.createTabSlot(conversation, conversationId, session, tabId, workbarInstanceId) + if (effectiveSignal.aborted || conversation.released) { + await this.releaseTab(conversation, tab) + throw effectiveSignal.reason ?? new Error('浏览器会话已关闭') + } + this.adoptRequestTab(conversation, tab) if (workbarInstanceId) conversation.ownedTabs.set(workbarInstanceId, tabId) this.touchConversation(conversationId) this.emitState(tab, 'ready') @@ -748,6 +791,36 @@ export class BrowserService { return conversation.tabs.has(tabId) ? tabId : undefined } + reserveRequestTab( + conversationId: string, + owner: string, + ownerWindowId?: number + ): BrowserTabUsageLease { + if (this.disposed || this.clearing || this.releaseRequests.has(conversationId)) { + throw new Error('浏览器服务已关闭') + } + if (!owner || owner.length > 500) { + throw new Error('浏览器标签页使用租约所有者无效') + } + const conversation = this.conversations.get(conversationId) + let reservation = this.requestTabs.get(conversationId) + if (!reservation && conversation && !conversation.released) { + return this.acquireTabUsage(conversationId, conversation.primaryTabId, owner, ownerWindowId) + } + if (!reservation) { + const creation = this.creations.get(conversationId) + if (creation) this.assertWindowOwner(creation, ownerWindowId) + reservation = { + conversationId, + tabId: creation?.tabId ?? createTabId(), + ownerWindowId: ownerWindowId ?? creation?.ownerWindowId, + usageLeases: new Map() + } + this.requestTabs.set(conversationId, reservation) + } + return this.acquireTabUsage(conversationId, reservation.tabId, owner, ownerWindowId) + } + acquireTabUsage( conversationId: string, tabId: BrowserTabId, @@ -758,18 +831,18 @@ export class BrowserService { throw new Error('浏览器标签页使用租约所有者无效') } const conversation = this.conversations.get(conversationId) - if (!conversation || conversation.released) { - throw new Error('浏览器标签页不存在或不属于当前对话') - } - this.assertWindowOwner(conversation, ownerWindowId) - const tab = conversation.tabs.get(tabId) - if (!tab || tab.released) { + const reservation = this.requestTabs.get(conversationId) + if (conversation) this.assertWindowOwner(conversation, ownerWindowId) + if (reservation) this.assertWindowOwner(reservation, ownerWindowId) + const tab = conversation?.tabs.get(tabId) ?? + (reservation?.tabId === tabId ? reservation : undefined) + if (!tab || conversation?.released) { throw new Error('浏览器标签页不存在或不属于当前对话') } const token = Symbol(owner) const controller = new AbortController() tab.usageLeases.set(token, { owner, controller }) - if (conversation.idleTimer) { + if (conversation?.idleTimer) { clearTimeout(conversation.idleTimer) conversation.idleTimer = undefined } @@ -782,9 +855,24 @@ export class BrowserService { release: (): void => { if (released) return released = true - if (tab.usageLeases.delete(token) && this.hasTab(tab)) { - this.touchConversation(conversationId) + if (tab.usageLeases.delete(token)) { + if (this.conversations.get(conversationId)?.tabs.has(tabId)) { + this.touchConversation(conversationId) + } else if ( + tab.usageLeases.size === 0 && + this.requestTabs.get(conversationId) === tab + ) { + this.requestTabs.delete(conversationId) + const creation = this.creations.get(conversationId) + if (creation?.tabId === tabId) { + creation.controller.abort(new Error('浏览器会话创建已取消')) + } + this.ownedTabCreations.get(ownershipKey(conversationId, tabId))?.controller.abort( + new Error('浏览器标签页创建已取消') + ) + } } + controller.abort(new Error('浏览器标签页使用租约已终止')) } } } @@ -794,6 +882,7 @@ export class BrowserService { return { conversationId: tab.conversationId, tabId: tab.tabId, + ...(tab.workbarInstanceId ? { workbarInstanceId: tab.workbarInstanceId } : {}), primary: this.conversations.get(tab.conversationId)?.primaryTabId === tab.tabId, status: state?.status && state.status !== 'stopped' ? state.status : 'ready', @@ -807,12 +896,25 @@ export class BrowserService { async closeTab(conversationId: string, tabId: BrowserTabId, ownerWindowId?: number): Promise { const conversation = this.conversations.get(conversationId) - if (!conversation || conversation.released) throw new Error('浏览器标签页不存在或不属于当前对话') - this.assertWindowOwner(conversation, ownerWindowId) - const tab = conversation.tabs.get(tabId) - if (!tab) throw new Error('浏览器标签页不存在或不属于当前对话') + if (conversation) this.assertWindowOwner(conversation, ownerWindowId) + const reservation = this.requestTabs.get(conversationId) + if (reservation) this.assertWindowOwner(reservation, ownerWindowId) + const creation = this.creations.get(conversationId) + if (creation) this.assertWindowOwner(creation, ownerWindowId) + const tab = conversation?.tabs.get(tabId) + if (!tab) { + if ( + [...this.conversations.values()].some((slot) => slot.tabs.has(tabId)) || + [...this.creations.entries()].some(([id, slot]) => slot.tabId === tabId && id !== conversationId) || + [...this.requestTabs.values()].some((slot) => slot.tabId === tabId && slot.conversationId !== conversationId) + ) throw new Error('浏览器标签页不存在或不属于当前对话') + if (reservation?.tabId === tabId && reservation.usageLeases.size > 0) { + throw new Error(BROWSER_TAB_IN_USE_ERROR) + } + return + } if (tab.usageLeases.size > 0) throw new Error(BROWSER_TAB_IN_USE_ERROR) - await this.releaseTab(conversation, tab) + await this.releaseTab(conversation!, tab) } private requireTab(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): BrowserTabSlot { @@ -948,6 +1050,15 @@ export class BrowserService { ): Promise<{ url: string; origin: string }> { let tab: BrowserTabSlot | undefined try { + if (tabId) { + const knownTab = this.conversations.get(conversationId)?.tabs.has(tabId) || + this.requestTabs.get(conversationId)?.tabId === tabId || + this.creations.get(conversationId)?.tabId === tabId + if (!knownTab) throw new Error('浏览器标签页不存在或不属于当前对话') + if (this.requestTabs.get(conversationId)?.tabId === tabId) { + await this.createTab(conversationId, ownerWindowId, signal, tabId) + } + } const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId) tab = conversation.tabs.get(tabId ?? conversation.primaryTabId) if (!tab || tab.released) throw new Error('浏览器标签页不存在或不属于当前对话') @@ -972,7 +1083,7 @@ export class BrowserService { if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined) throw new Error('浏览器快照来源与当前会话不一致') } - await this.captureFrame(tab, effectiveSignal, target.href) + await this.refreshNavigationState(tab, effectiveSignal, target.href) return { ...snapshot, url: target.href } }, tabId, ownerWindowId) } @@ -981,7 +1092,7 @@ export class BrowserService { await this.runInTab(conversationId, signal, 'acting', true, '浏览器点击', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) await tab.driver.click(ref, effectiveSignal) - await this.captureFrame(tab, effectiveSignal) + await this.refreshNavigationState(tab, effectiveSignal) }, tabId, ownerWindowId) } @@ -989,7 +1100,7 @@ export class BrowserService { await this.runInTab(conversationId, signal, 'acting', false, '浏览器输入', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) await tab.driver.type(ref, text, effectiveSignal) - await this.captureFrame(tab, effectiveSignal) + await this.refreshNavigationState(tab, effectiveSignal) }, tabId, ownerWindowId) } @@ -997,7 +1108,7 @@ export class BrowserService { await this.runInTab(conversationId, signal, 'acting', false, '浏览器选择', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) await tab.driver.select(ref, value, effectiveSignal) - await this.captureFrame(tab, effectiveSignal) + await this.refreshNavigationState(tab, effectiveSignal) }, tabId, ownerWindowId) } @@ -1030,7 +1141,7 @@ export class BrowserService { } } screenshot ??= await tab.driver.screenshot(effectiveSignal) - await this.captureFrame(tab, effectiveSignal, undefined, screenshot) + await this.refreshNavigationState(tab, effectiveSignal) return screenshot }, tabId, ownerWindowId) } @@ -1082,7 +1193,7 @@ export class BrowserService { const finalTarget = await this.policy.validateRedirect(result.url, signal) if (tab.session.getCurrentOrigin() !== finalTarget.origin) throw new Error(originMismatchMessage) tab.origin = finalTarget.origin - await this.captureFrame(tab, signal, finalTarget.url.href) + await this.refreshNavigationState(tab, signal, finalTarget.url.href) return { url: finalTarget.url.href, origin: finalTarget.origin } } catch (error) { if (error instanceof BrowserNavigationStoppedError) { @@ -1095,11 +1206,10 @@ export class BrowserService { } } - private async captureFrame( + private async refreshNavigationState( tab: BrowserTabSlot, signal: AbortSignal, - url?: string, - screenshot?: BrowserScreenshot + url?: string ): Promise { let committedUrl = url const previous = this.liveStates.get(stateKey(tab.conversationId, tab.tabId)) @@ -1114,65 +1224,19 @@ export class BrowserService { } catch { signal.throwIfAborted() } - let frame = screenshot - if (!frame) { - if (this.liveFrameDelayMs > 0) { - await waitFor(new Promise((resolve) => setTimeout(resolve, this.liveFrameDelayMs)), signal) - } - const deadline = AbortSignal.any([signal, AbortSignal.timeout(6_000)]) - for (let attempt = 0; attempt < 3 && !frame; attempt += 1) { - if (attempt > 0) { - try { - await waitFor(new Promise((resolve) => setTimeout(resolve, attempt * 150)), deadline) - } catch { - signal.throwIfAborted() - break - } - } - if (tab.session.captureScreenshot) { - try { - frame = await tab.session.captureScreenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)])) - } catch { - signal.throwIfAborted() - } - } - if (!frame && !deadline.aborted) { - try { - frame = await tab.driver.screenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)])) - } catch { - signal.throwIfAborted() - } - } - } - } signal.throwIfAborted() if (!this.hasTab(tab)) return - if (!frame) { - if (previous?.frameDataUrl) { - this.emitState(tab, 'ready', { - ...(committedUrl ? { url: committedUrl } : {}), - canGoBack, - frameDataUrl: previous.frameDataUrl - }) - } else { - this.emitState(tab, 'failed', { - ...(committedUrl ? { url: committedUrl } : {}), - canGoBack, - error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作' - }) - } - return - } this.emitState(tab, 'ready', { ...(committedUrl ? { url: committedUrl } : {}), - canGoBack, - frameDataUrl: `data:${frame.mimeType};base64,${frame.data}` + canGoBack }) } async releaseConversation(conversationId: string, ownerWindowId?: number): Promise { const ownedConversation = this.conversations.get(conversationId) if (ownedConversation) this.assertWindowOwner(ownedConversation, ownerWindowId) + const reservation = this.requestTabs.get(conversationId) + if (reservation) this.assertWindowOwner(reservation, ownerWindowId) const creation = this.creations.get(conversationId) if ( ownerWindowId !== undefined && @@ -1182,6 +1246,7 @@ export class BrowserService { throw new Error('浏览器对话不属于当前窗口') } this.releaseRequests.add(conversationId) + this.releaseRequestTab(conversationId) let emitted = false try { const pendingCreation = this.creations.get(conversationId) @@ -1215,6 +1280,16 @@ export class BrowserService { } } + private releaseRequestTab(conversationId: string): void { + const reservation = this.requestTabs.get(conversationId) + if (!reservation) return + this.requestTabs.delete(conversationId) + for (const lease of reservation.usageLeases.values()) { + lease.controller.abort(new Error('浏览器标签页使用租约已终止')) + } + reservation.usageLeases.clear() + } + private async releaseTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): Promise { if (tab.released) return tab.released = true @@ -1287,6 +1362,7 @@ export class BrowserService { private async performClearSessions(): Promise { this.clearing = true this.lifecycle.abort(new Error('浏览器会话已清除')) + for (const id of this.requestTabs.keys()) this.releaseRequestTab(id) const requested = new Set(this.creations.keys()) for (const id of requested) this.releaseRequests.add(id) try { @@ -1308,6 +1384,7 @@ export class BrowserService { this.disposed = true this.clearing = true this.lifecycle.abort(new Error('浏览器服务已关闭')) + for (const id of this.requestTabs.keys()) this.releaseRequestTab(id) for (const [id, creation] of this.creations) { this.releaseRequests.add(id) creation.controller.abort(new Error('浏览器服务已关闭')) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 81ed8fb6..41e6a6b2 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -710,7 +710,7 @@ describe('registerIpcHandlers computer capabilities', () => { sessionActive: true, isLoading: false, canGoBack: false, - frameDataUrl: 'data:image/jpeg;base64,frame', + workbarInstanceId: browserTabId, updatedAt: 1 }) expect(webContents.send).toHaveBeenCalledWith( @@ -740,11 +740,12 @@ describe('registerIpcHandlers computer capabilities', () => { sessionActive: true, isLoading: false, canGoBack: false, - frameDataUrl: 'data:image/jpeg;base64,frame', + workbarInstanceId: browserTabId, updatedAt: 2 }) const repeatedFramePayload = webContents.send.mock.calls.at(-1)?.[1] expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl') + expect(repeatedFramePayload).toHaveProperty('workbarInstanceId', browserTabId) browserStateListener?.({ conversationId: 'browser-conversation', tabId: siblingBrowserTabId, @@ -753,11 +754,11 @@ describe('registerIpcHandlers computer capabilities', () => { sessionActive: true, isLoading: false, canGoBack: false, - frameDataUrl: 'data:image/jpeg;base64,frame', + workbarInstanceId: siblingBrowserTabId, updatedAt: 2 }) expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty( - 'frameDataUrl' + 'workbarInstanceId', siblingBrowserTabId ) browserStateListener?.({ conversationId: 'browser-conversation', @@ -777,12 +778,12 @@ describe('registerIpcHandlers computer capabilities', () => { sessionActive: true, isLoading: false, canGoBack: false, - frameDataUrl: 'data:image/jpeg;base64,frame', + workbarInstanceId: browserTabId, updatedAt: 4 }) expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty( - 'frameDataUrl', - 'data:image/jpeg;base64,frame' + 'workbarInstanceId', + browserTabId ) await expect( electronMocks.handlers.get(ipcChannels.browserStop)?.(event, { @@ -4774,7 +4775,8 @@ describe('registerIpcHandlers Runtime customization', () => { listPendingConversationQueueIds: vi.fn(() => []), getProject: vi.fn(() => ({ id: projectId, - rootPath: 'C:\\ProjectWorkspace' + rootPath: 'C:\\ProjectWorkspace', + runtimeSelection: undefined as { provider: 'opencode' } | undefined })), getConversation: vi.fn(() => ({ id: conversationId, @@ -4968,6 +4970,17 @@ describe('registerIpcHandlers Runtime customization', () => { ).rejects.toThrow('对话 Runtime 或 Project 已更改') persistedRuntimeSelection = undefined + settingsStore.getResolvedSettings.mockResolvedValue({ + provider: 'model', + modelProfiles: [], + opencodeBaseUrl: '', + workspacePath: 'C:\\DefaultWorkspace' + }) + assistantDatabase.getProject.mockReturnValue({ + id: projectId, + rootPath: 'C:\\ProjectWorkspace', + runtimeSelection: { provider: 'opencode' } + }) await expect( electronMocks.handlers.get( ipcChannels.agentCompactConversation @@ -5887,6 +5900,83 @@ describe('registerIpcHandlers agent terminal state', () => { ) }) + it('persists remote usage with the inherited project Runtime without pinning the conversation', async () => { + const requestId = '00000000-0000-4000-8000-000000000790' + const conversationId = 'inherited-remote-usage' + const selection = { provider: 'opencode', profileId: '00000000-0000-4000-8000-000000000791' } as const + const provenance = { + source: 'remote-semantic-transcript', + bindingId: 'inherited-usage', + operationId: requestId, + semanticSequence: '1', + eventIndex: 0 + } as const + const selectedRuntime = { + runtimeId: 'opencode', + capability: 'chat', + supportsToolExecution: true, + async *run(request: { requestId: string }) { + yield { + requestId: request.requestId, type: 'context-metrics', + contextTokens: 42, effectiveTriggerTokens: 1000, + compressionEnabled: false, source: 'provider', + remoteProvenance: provenance + } as const + yield { + requestId: request.requestId, type: 'done', + remoteProvenance: { ...provenance, semanticSequence: '2' } + } as const + } + } + const { harness, projectId } = createManagedSshHarness(selectedRuntime) + const project = harness.assistantDatabase.getProject(projectId) + harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: selection }) + harness.assistantDatabase.getConversation.mockReturnValue({ + id: conversationId, projectId, messages: [] + }) + await harness.handler?.(trustedEvent(harness.webContents), { + ...managedSshRequest(projectId, requestId, conversationId), + runtimeSelection: undefined, + currentUserMessageId: '00000000-0000-4000-8000-000000000792', + currentAssistantMessageId: '00000000-0000-4000-8000-000000000793' + }) + await vi.waitFor(() => expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith( + expect.objectContaining({ event: expect.objectContaining({ type: 'done' }) }) + )) + expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId, + runtimeSelection: selection, + event: expect.objectContaining({ type: 'context-metrics', contextTokens: 42 }) + }) + ) + expect(harness.assistantDatabase.getConversation(conversationId)).not.toHaveProperty('runtimeSelection') + await harness.dispose() + }) + + it('advertises read-only workspace and delegation tools in the production Ask instruction', async () => { + const received: string[] = [] + const runtime = { + runtimeId: 'model', capability: 'chat', supportsToolExecution: true, + async *run(request: { requestId: string; trustedInstructions?: string }) { + received.push(request.trustedInstructions ?? '') + yield { type: 'done', requestId: request.requestId } as const + } + } + const harness = createHarness(runtime) + const requestId = '00000000-0000-4000-8000-000000000794' + await harness.handler?.(trustedEvent(harness.webContents), { + requestId, conversationId: 'ask-workspace', prompt: 'Read the source', workMode: 'ask' + }) + await vi.waitFor(() => expect(received).toHaveLength(1)) + for (const name of ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']) { + expect(received[0]).toContain(name) + } + expect(received[0]).not.toContain('workspace_apply_patch') + expect(received[0]).not.toContain('process_execute') + await harness.dispose() + }) + it('durably commits remote text before resume, deduplicates it, and preserves its semantic payload', async () => { const requestId = '00000000-0000-4000-8000-000000000763' const provenance = { @@ -7650,18 +7740,18 @@ describe('registerIpcHandlers agent terminal state', () => { 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 createTab = vi.fn() const browserControl = { listTabs: vi.fn(() => []), getVisibleTabId: vi.fn(() => undefined), createTab, + reserveRequestTab: vi.fn((conversationId: string, owner: string) => ({ + conversationId, + tabId, + owner, + signal: new AbortController().signal, + release + })), acquireTabUsage: vi.fn( (conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({ conversationId, @@ -7702,13 +7792,10 @@ describe('registerIpcHandlers agent terminal state', () => { } 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( + expect(createTab).not.toHaveBeenCalled() + expect(browserControl.acquireTabUsage).not.toHaveBeenCalled() + expect(browserControl.reserveRequestTab).toHaveBeenCalledWith( `browser-${outcome}`, - tabId, requestId, harness.webContents.id ) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3b23e721..e2c4a9c9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,7 +14,7 @@ import { realpath, stat } from 'node:fs/promises' -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { basename, extname, isAbsolute, join } from 'node:path' import { z } from 'zod' @@ -464,12 +464,11 @@ type BrowserRequestTabUsageLease = { } type BrowserCapabilityControl = { - createTab( + reserveRequestTab( conversationId: string, - ownerWindowId?: number, - signal?: AbortSignal, - workbarInstanceId?: string - ): Promise<{ tabId: BrowserTabId }> + owner: string, + ownerWindowId?: number + ): BrowserRequestTabUsageLease listTabs( conversationId: string, ownerWindowId?: number @@ -486,18 +485,6 @@ type BrowserCapabilityControl = { ): 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 @@ -509,6 +496,7 @@ async function bindBrowserTab(input: { if (!input.control) { throw new Error('GoodBuddy 内置浏览器服务不可用') } + input.signal.throwIfAborted() const tabs = input.control.listTabs( input.conversationId, input.ownerWindowId @@ -525,17 +513,17 @@ async function bindBrowserTab(input: { input.ownerWindowId ) const primary = tabs.find((tab) => tab.primary) - tabId = visibleTabId && tabs.some((tab) => tab.tabId === visibleTabId) + const existingTabId = visibleTabId && tabs.some((tab) => tab.tabId === visibleTabId) ? visibleTabId - : primary?.tabId ?? - ( - await input.control.createTab( - input.conversationId, - input.ownerWindowId, - input.signal, - requestWorkbarInstanceId(input.owner) - ) - ).tabId + : primary?.tabId + if (!existingTabId) { + return input.control.reserveRequestTab( + input.conversationId, + input.owner, + input.ownerWindowId + ) + } + tabId = existingTabId } return input.control.acquireTabUsage( input.conversationId, @@ -1573,26 +1561,9 @@ export function registerIpcHandlers( } window.on('maximize', notifyMaximizedChanged) window.on('unmaximize', notifyMaximizedChanged) - const lastSentBrowserFrames = new Map() const removeBrowserStateListener = browserControl?.onState((state) => { if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) { - const tabStateKey = `${state.conversationId}\u0000${state.tabId}` - const frame = state.frameDataUrl - let payload: BrowserLiveState = state - if ( - frame && - lastSentBrowserFrames.get(tabStateKey) === frame - ) { - payload = { ...state } - delete payload.frameDataUrl - } - if (frame) { - lastSentBrowserFrames.set(tabStateKey, frame) - } - if (state.status === 'stopped') { - lastSentBrowserFrames.delete(tabStateKey) - } - window.webContents.send(ipcChannels.browserState, payload) + window.webContents.send(ipcChannels.browserState, state) } }) const abortActiveRequests = ( @@ -2069,6 +2040,7 @@ export function registerIpcHandlers( assistantDatabase.appendRemoteConversationTaskEventOnce({ taskId: task.taskId, conversationId: task.conversationId, + runtimeSelection, assistantMessageId: task.currentAssistantMessageId, bindingId: provenance.bindingId, operationId: provenance.operationId, @@ -2513,7 +2485,12 @@ export function registerIpcHandlers( abort: (reason) => controller.abort(reason) }) knowledgeCapabilityToken = notesCapability.token - const noteTools = notesCapability.toolNames + const noteTools = [ + ...(!agentRuntimeSelected + ? ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate'] + : []), + ...notesCapability.toolNames + ] const noteToolSummary = noteTools.join(', ') const modeInstruction = schedule.workMode === 'execute' @@ -4079,6 +4056,9 @@ export function registerIpcHandlers( const knowledgeCapabilityToken = scopedCapability.token const availableTools = [ ...(webSearchEnabled ? ['web_search', 'web_fetch'] : []), + ...(!agentRuntimeSelected && !imageGeneration + ? ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate'] + : []), ...scopedCapability.toolNames ] const hasAvailableTools = availableTools.length > 0 @@ -4263,6 +4243,7 @@ export function registerIpcHandlers( ? assistantDatabase.appendRemoteConversationTaskEventOnce({ taskId: request.requestId, conversationId: request.conversationId, + runtimeSelection: request.runtimeSelection ?? configProject?.runtimeSelection, assistantMessageId: remoteConversationRecovery.currentAssistantMessageId, bindingId: provenance.bindingId, @@ -4290,6 +4271,7 @@ export function registerIpcHandlers( ? assistantDatabase.appendRemoteConversationTaskEventOnce({ taskId: request.requestId, conversationId: request.conversationId, + runtimeSelection: request.runtimeSelection ?? configProject?.runtimeSelection, assistantMessageId: remoteConversationRecovery.currentAssistantMessageId, bindingId: provenance.bindingId, @@ -4911,9 +4893,15 @@ export function registerIpcHandlers( request.conversationId ) const settings = await settingsStore.getResolvedSettings() + const project = conversation.projectId + ? assistantDatabase.getProject(conversation.projectId) + : undefined + const savedRuntimeSelection = + conversation.runtimeSelection ?? project?.runtimeSelection const persistedRuntimeSelection = - conversation.runtimeSelection ?? - getDefaultRuntimeSelection(settings) + savedRuntimeSelection && savedRuntimeSelection.provider !== 'auto' + ? savedRuntimeSelection + : getDefaultRuntimeSelection(settings) if ( conversation.projectId !== request.projectId || agentRuntimeSelectionKey(persistedRuntimeSelection) !== @@ -4946,9 +4934,6 @@ export function registerIpcHandlers( settings, request.runtimeSelection ) - const project = request.projectId - ? assistantDatabase.getProject(request.projectId) - : undefined if (project?.executionSpace?.kind === 'ssh') { await requireRemoteProjectsEnabled() } @@ -8520,7 +8505,6 @@ export function registerIpcHandlers( removeLocalToolEnvironmentProgressListener?.() await localToolEnvironmentService?.dispose() removeBrowserStateListener?.() - lastSentBrowserFrames.clear() removeRemoteAgentConnectionStatusListener?.() clearInterval(scheduleInterval) for (const timeout of queueDispatchTimers.values()) { diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 8dfef1c1..b8716809 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -732,6 +732,98 @@ describe('RuntimeSettingsStore', () => { }) }) + it.each([ + { name: 'complete administrator override', environment: { + GOODBUDDY_MODEL_API_KEY: 'administrator-secret', + GOODBUDDY_MODEL_BASE_URL: 'https://administrator.example/v1?private=marker', + GOODBUDDY_MODEL_NAME: 'administrator-model' + }, compatibleDefault: true, expected: 'environment' }, + { name: 'API key only with incompatible default', environment: { + GOODBUDDY_MODEL_API_KEY: 'administrator-secret' + }, compatibleDefault: false, expected: 'first' }, + { name: 'API key only with compatible default', environment: { + GOODBUDDY_MODEL_API_KEY: 'administrator-secret' + }, compatibleDefault: true, expected: 'default' }, + { name: 'address and model without API key', environment: { + GOODBUDDY_MODEL_BASE_URL: 'https://administrator.example/v1', + GOODBUDDY_MODEL_NAME: 'administrator-model' + }, compatibleDefault: false, expected: 'first' }, + { name: 'legacy environment credentials', environment: { + GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-secret', + GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example/v1', + GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model' + }, compatibleDefault: false, expected: 'first' }, + { name: 'compatible saved default', environment: {}, + compatibleDefault: true, expected: 'default' }, + { name: 'first compatible saved connection', environment: {}, + compatibleDefault: false, expected: 'first' }, + { name: 'no compatible connection', environment: {}, + compatibleDefault: false, expected: 'unavailable' } + ])('projects the actual Harness platform source: $name', async ({ + environment, compatibleDefault, expected + }) => { + const { store, filePath } = await createStore(environment) + const firstId = '00000000-0000-4000-8000-000000000061' + const defaultId = '00000000-0000-4000-8000-000000000062' + const profile = { + name: 'First compatible', baseUrl: 'https://saved.example/v1?private=marker', + modelName: 'saved-model', protocol: 'openai-chat-completions' as const, + authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, + apiKey: { action: 'replace' as const, value: 'saved-secret' }, + requestHeaders: { 'X-Private-Header': 'header-marker' }, + requestBody: { privateMetadata: 'body-marker' } + } + const input = settings({ + modelProfiles: [ + ...(expected === 'unavailable' ? [] : [{ ...profile, id: firstId }]), + { + ...profile, id: defaultId, name: 'Preferred connection', + protocol: compatibleDefault ? 'openai-chat-completions' : 'anthropic-messages' + } + ], + defaultModelProfileId: defaultId + }) + const publicSettings = await store.update(input) + const resolved = (await store.getResolvedSettings()).deepseekHarnessModelProfile + const projection = publicSettings.deepseekHarnessPlatformModel + if (expected === 'unavailable') { + expect(resolved).toBeUndefined() + expect(projection).toEqual({ source: 'unavailable' }) + } else { + expect(resolved).toBeDefined() + expect(projection).toEqual({ + source: expected === 'environment' ? 'environment' : 'profile', + ...(expected === 'environment' + ? {} + : { profileId: expected === 'default' ? defaultId : firstId }), + name: resolved!.name, + modelName: resolved!.modelName + }) + if (expected === 'environment') { + expect(projection?.source).toBe('environment') + expect(resolved?.modelName).toBe('administrator-model') + } + } + const serialized = JSON.stringify(projection) + for (const excluded of [ + 'https://', 'apiKey', 'baseUrl', 'requestHeaders', 'requestBody', + 'administrator-secret', 'saved-secret', 'legacy-secret', 'header-marker', 'body-marker' + ]) { + expect(serialized).not.toContain(excluded) + } + expect(await readFile(filePath, 'utf8')).not.toContain('deepseekHarnessPlatformModel') + expect((await store.getPublicSettings()).deepseekHarnessPlatformModel).toEqual(projection) + + // Merely inspecting the platform option must not change a selected default rule. + if (expected !== 'unavailable') { + const followingDefault = await store.update({ + ...input, deepseekHarnessModelSource: { kind: 'default' } + }) + expect(followingDefault.deepseekHarnessModelSource).toEqual({ kind: 'default' }) + expect(followingDefault.deepseekHarnessPlatformModel).toEqual(projection) + } + }) + it('resolves a controlled platform Harness profile without exposing its credential', async () => { const apiKey = 'platform-harness-secret' const { store } = await createStore({ diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index 1ffb4579..48289bac 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -1740,6 +1740,25 @@ export class RuntimeSettingsStore { } }) const deepseekHarnessModelSource = settings.deepseekHarnessModelSource + const harnessPlatformProfile = this.resolveDeepSeekHarnessModelProfile( + { ...settings, deepseekHarnessModelSource: { kind: 'platform' } }, + resolvedModelProfiles + ) + const deepseekHarnessPlatformModel: RuntimeSettings['deepseekHarnessPlatformModel'] = + !harnessPlatformProfile + ? { source: 'unavailable' } + : harnessPlatformProfile.id === platformHarnessProfileId + ? { + source: 'environment', + name: harnessPlatformProfile.name, + modelName: harnessPlatformProfile.modelName + } + : { + source: 'profile', + profileId: harnessPlatformProfile.id, + name: harnessPlatformProfile.name, + modelName: harnessPlatformProfile.modelName + } const embeddingEnvironmentApiKey = this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() const embeddingStoredApiKey = embeddingEnvironmentApiKey @@ -1846,6 +1865,7 @@ export class RuntimeSettingsStore { : settings.opencodeModelSource, continueModelSource: settings.continueModelSource, deepseekHarnessModelSource, + deepseekHarnessPlatformModel, secureStorageAvailable: this.cipher.isAvailable(), toolApproval: settings.toolApproval, configured: { diff --git a/src/main/workspace/workspace-management.test.ts b/src/main/workspace/workspace-management.test.ts index 8267f5ca..b7792ce4 100644 --- a/src/main/workspace/workspace-management.test.ts +++ b/src/main/workspace/workspace-management.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { link, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' @@ -14,7 +14,7 @@ async function repository(): Promise { return root } function git(root: string, ...args: string[]): string { - return execFileSync('git', ['-C', root, '-c', 'user.name=Workspace Test', '-c', 'user.email=workspace@example.invalid', ...args], { encoding: 'utf8', windowsHide: true }).trim() + return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', windowsHide: true }).trim() } it('creates, moves, inspects and deletes without overwriting an existing file', async () => { const root = await repository() @@ -35,7 +35,7 @@ it('reads unborn history, initial commit files and literal paths, and switches w git(root, 'add', '.') git(root, 'commit', '-m', 'Initial subject') const oid = git(root, 'rev-parse', 'HEAD') - await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: 'Workspace Test' }] }) + await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: git(root, 'log', '-1', '--format=%an') }] }) await expect(manageWorkspace(root, { kind: 'commitFiles', oid })).resolves.toMatchObject({ files: [{ path: '[test].txt', status: 'A' }] }) await expect(manageWorkspace(root, { kind: 'commitDiff', oid, path: '[test].txt' })).resolves.toMatchObject({ patch: expect.stringContaining('+initial') }) await writeFile(join(root, '[test].txt'), 'dirty\n') @@ -65,3 +65,88 @@ it('reports switch conflicts without discarding dirty content and pages stable h const second = await manageWorkspace(root, { kind: 'history', offset: 50, head: first.head }) expect(second).toMatchObject({ hasMore: false, commits: [{ subject: 'page 0' }, { subject: 'initial' }] }) }, 30_000) + +it.each([false, true])('preserves rename metadata and actual edits in a literal commit diff (edited: %s)', async (edited) => { + const root = await repository() + const previousPath = '[old];$(echo).txt' + const path = '[new];$(echo).txt' + const original = Array.from({ length: 20 }, (_, index) => `original line ${index}\n`).join('') + await writeFile(join(root, previousPath), original) + await writeFile(join(root, 'unrelated.txt'), 'unrelated before\n') + git(root, 'add', '.'); git(root, 'commit', '-m', 'before rename') + await rename(join(root, previousPath), join(root, path)) + if (edited) await writeFile(join(root, path), original.replace('original line 10\n', 'edited line 10\n')) + await writeFile(join(root, 'unrelated.txt'), 'unrelated after\n') + git(root, 'add', '.'); git(root, 'commit', '-m', 'rename') + const oid = git(root, 'rev-parse', 'HEAD') + const files = await manageWorkspace(root, { kind: 'commitFiles', oid }) + expect(files).toMatchObject({ files: expect.arrayContaining([{ path, previousPath, status: edited ? expect.stringMatching(/^R\d+$/) : 'R100' }]) }) + const result = await manageWorkspace(root, { kind: 'commitDiff', oid, path }) + expect(result.kind).toBe('commitDiff') + if (result.kind !== 'commitDiff') return + expect(result.truncated).toBe(false) + expect(result.patch).toContain(`rename from ${previousPath}`) + expect(result.patch).toContain(`rename to ${path}`) + expect(result.patch).not.toContain('new file mode') + expect(result.patch).not.toContain('unrelated') + if (edited) { + expect(result.patch).toContain('-original line 10') + expect(result.patch).toContain('+edited line 10') + expect(result.patch).not.toContain('+original line 0') + } else { + expect(result.patch).toContain('similarity index 100%') + expect(result.patch).not.toContain('@@') + } +}) + +it.each(['file', 'directory'] as const)('renames a %s by case only on the current filesystem', async (type) => { + const root = await repository() + const source = join(root, 'MixedCase') + if (type === 'directory') { + await mkdir(source) + await writeFile(join(source, 'keep.txt'), 'keep') + } else await writeFile(source, 'keep') + const alias = await lstat(join(root, 'mixedcase')).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return undefined + }) + if (alias) expect(alias.ino).toBe((await lstat(source)).ino) + await expect(manageWorkspace(root, { kind: 'move', path: 'MixedCase', destination: 'mixedcase' })).resolves.toEqual({ kind: 'done' }) + const names = await readdir(root) + expect(names).toContain('mixedcase') + expect(names).not.toContain('MixedCase') + expect(await readFile(join(root, 'mixedcase', ...(type === 'directory' ? ['keep.txt'] : [])), 'utf8')).toBe('keep') +}) + +it.each(['file', 'hardlink', 'directory'] as const)('rejects moving over a separate existing %s without changing either entry', async (type) => { + const root = await repository() + if (type === 'directory') { + await mkdir(join(root, 'source')) + await mkdir(join(root, 'destination')) + await writeFile(join(root, 'source/keep.txt'), 'source') + await writeFile(join(root, 'destination/keep.txt'), 'destination') + } else { + await writeFile(join(root, 'source'), 'source') + if (type === 'hardlink') await link(join(root, 'source'), join(root, 'destination')) + else await writeFile(join(root, 'destination'), 'destination') + } + await expect(manageWorkspace(root, { kind: 'move', path: 'source', destination: 'destination' })).rejects.toThrow('Destination already exists') + expect(await readFile(join(root, type === 'directory' ? 'source/keep.txt' : 'source'), 'utf8')).toBe('source') + expect(await readFile(join(root, type === 'directory' ? 'destination/keep.txt' : 'destination'), 'utf8')).toBe(type === 'hardlink' ? 'source' : 'destination') +}) + +it.for([false, true])('rejects a distinct case-differing destination on case-sensitive filesystems (hardlink: %s)', async (hardlink, context) => { + const root = await repository() + await writeFile(join(root, 'Case.txt'), 'source') + const alias = await lstat(join(root, 'case.txt')).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return undefined + }) + if (alias) return context.skip() + if (hardlink) await link(join(root, 'Case.txt'), join(root, 'case.txt')) + else await writeFile(join(root, 'case.txt'), 'destination') + await expect(manageWorkspace(root, { kind: 'move', path: 'Case.txt', destination: 'case.txt' })).rejects.toThrow('Destination already exists') + expect(await readdir(root)).toEqual(expect.arrayContaining(['Case.txt', 'case.txt'])) + expect(await readFile(join(root, 'Case.txt'), 'utf8')).toBe('source') + expect(await readFile(join(root, 'case.txt'), 'utf8')).toBe(hardlink ? 'source' : 'destination') +}) diff --git a/src/main/workspace/workspace-management.ts b/src/main/workspace/workspace-management.ts index 353d3c6d..a9e844a2 100644 --- a/src/main/workspace/workspace-management.ts +++ b/src/main/workspace/workspace-management.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' -import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' +import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises' +import { basename, dirname, resolve } from 'node:path' import { workspaceManagementActionSchema, type WorkspaceManagementAction, type WorkspaceManagementResult } from '../../shared/workspace-management-contracts' import { getCanonicalWorkspace, isPathInside } from '../workspace-file-access' @@ -18,6 +18,18 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme else resolveOutput(stdout) }) }) + const commitFiles = async (oid: string): Promise> => { + const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', oid]) + const fields = output.split('\0') + const files: Extract['files'] = [] + for (let index = 0; index + 1 < fields.length;) { + const status = fields[index++]! + const first = fields[index++]! + const renamed = /^[RC]/.test(status) + files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) }) + } + return { kind: 'commitFiles', files } + } if (['createFile', 'createDirectory', 'move', 'delete', 'properties'].includes(action.kind) && 'path' in action) { const target = resolve(root, action.path) const parent = await realpath(dirname(target)) @@ -36,13 +48,24 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme const destination = resolve(root, action.destination) const destinationParent = await realpath(dirname(destination)) if (!isPathInside(root, destinationParent) || !isPathInside(root, destination) || destination === root) throw new Error('Destination is outside the workspace') - try { await lstat(destination) } catch (error) { + const destinationMetadata = await lstat(destination).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - signal?.throwIfAborted() - await rename(target, destination) - return { kind: 'done' } + return undefined + }) + if (destinationMetadata) { + const sourceName = basename(target) + const destinationName = basename(destination) + // Equal inodes alone also match separate hard links. Permit only a + // differently cased alias of the same entry, not an existing name. + const caseAlias = parent === destinationParent && sourceName !== destinationName && + sourceName.toLowerCase() === destinationName.toLowerCase() && + metadata.dev === destinationMetadata.dev && metadata.ino === destinationMetadata.ino + const names = caseAlias ? await readdir(parent) : [] + if (!caseAlias || !names.includes(sourceName) || names.includes(destinationName)) throw new Error('Destination already exists') } - throw new Error('Destination already exists') + signal?.throwIfAborted() + await rename(target, destination) + return { kind: 'done' } } } return { kind: 'done' } @@ -86,20 +109,12 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme for (let index = 0; index + 4 < fields.length; index += 5) commits.push({ oid: fields[index]!.trim(), subject: fields[index + 1]!, author: fields[index + 2]!, time: fields[index + 3]!, refs: fields[index + 4]! }) return { kind: 'history', head, commits: commits.slice(0, 50), hasMore: commits.length > 50 } } - case 'commitFiles': { - const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', action.oid]) - const fields = output.split('\0') - const files: Extract['files'] = [] - for (let index = 0; index + 1 < fields.length;) { - const status = fields[index++]! - const first = fields[index++]! - const renamed = /^[RC]/.test(status) - files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) }) - } - return { kind: 'commitFiles', files } - } + case 'commitFiles': + return commitFiles(action.oid) case 'commitDiff': { - const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '--no-ext-diff', '--no-textconv', action.oid, '--', action.path]) + const file = (await commitFiles(action.oid)).files.find((file) => file.path === action.path) + const paths = file?.previousPath ? [file.previousPath, file.path] : [action.path] + const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '-M', '--no-ext-diff', '--no-textconv', action.oid, '--', ...paths]) return { kind: 'commitDiff', patch: patch.slice(0, 256 * 1024), truncated: patch.length > 256 * 1024 } } default: throw new Error('Unsupported workspace action') diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 29e58fb0..d506233a 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -125,6 +125,7 @@ import { UiLocaleProvider } from "./i18n/UiLocaleProvider"; let agentListener: ((event: AgentEvent) => void) | undefined; let browserListener: ((state: BrowserLiveState) => void) | undefined; +const browserListeners = new Set<(state: BrowserLiveState) => void>(); let fileSelectionProgressListener: Parameters[0] | undefined; let newConversationListener: (() => void) | undefined; @@ -269,9 +270,12 @@ const api: DesktopApi = { setViewport: vi.fn(async () => {}), stop: vi.fn(async () => {}), onState: vi.fn((listener) => { - browserListener = listener; + browserListeners.add(listener); + browserListener = (state) => { + for (const notify of browserListeners) notify(state); + }; return () => { - browserListener = undefined; + browserListeners.delete(listener); }; }), }, @@ -1161,6 +1165,7 @@ describe("App", () => { newConversationListener = undefined; beforeQuitListener = undefined; browserListener = undefined; + browserListeners.clear(); fileSelectionProgressListener = undefined; maximizedChangedListener = undefined; speechRecognitionMocks.startPcmRecording.mockResolvedValue({ @@ -1690,7 +1695,7 @@ describe("App", () => { const taskRegion = await screen.findByRole("region", { name: "当前会话的任务", }); - expect(within(taskRegion).getByText("Execute")).toBeInTheDocument(); + expect(within(taskRegion).getByText("Ask")).toBeInTheDocument(); }); it("refreshes scheduled Task state on queue-only events and guards manual runs through completion", async () => { @@ -10564,7 +10569,6 @@ describe("App", () => { createBrowserState(conversationId ?? "", { canGoBack: false, url: "https://example.com/", - frameDataUrl: "data:image/jpeg;base64,/9j/2Q==", updatedAt: Date.now(), }), ); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1acc7b3c..d030e740 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6032,17 +6032,11 @@ function App(): React.JSX.Element { } return next; } - const previous = conversationStates[tabId]; return { ...current, [state.conversationId]: { ...conversationStates, - [tabId]: state.frameDataUrl || !previous?.frameDataUrl - ? state - : { - ...state, - frameDataUrl: previous.frameDataUrl, - }, + [tabId]: state, }, }; }); @@ -8989,6 +8983,14 @@ function App(): React.JSX.Element { taskStrip={ !conversation.remote ? ( project.id === conversation.projectId)?.defaultWorkMode + ) + } locale={locale} onRemoveSchedule={removeAssistantSchedule} onRunSchedule={runAssistantSchedule} diff --git a/src/renderer/src/ConversationTaskStrip.test.tsx b/src/renderer/src/ConversationTaskStrip.test.tsx index 90cb3272..496322b7 100644 --- a/src/renderer/src/ConversationTaskStrip.test.tsx +++ b/src/renderer/src/ConversationTaskStrip.test.tsx @@ -6,6 +6,7 @@ import { waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AssistantSchedule, AssistantTask } from '../../shared/assistant-contracts' import { ConversationTaskStrip } from './ConversationTaskStrip' afterEach(cleanup) @@ -23,6 +24,7 @@ describe('ConversationTaskStrip', () => { render( { screen.queryByRole('region', { name: '当前会话的任务' }) ).not.toBeInTheDocument() }) + + it.each(['running', 'waiting_approval', 'completed', 'failed', 'interrupted'] as const)( + 'shows the actual %s Task mode, not the schedule or current conversation mode', + (status) => { + const { task, schedule, props } = modeFixtures() + render( + + ) + expect(screen.getByText('Ask')).toBeInTheDocument() + expect(screen.queryByText('Execute')).not.toBeInTheDocument() + } + ) + + it('tracks current conversation mode for an unrun schedule instead of its legacy mode', () => { + const { task, schedule, props } = modeFixtures() + const { rerender } = render( + + ) + expect(screen.getByText('Ask')).toBeInTheDocument() + expect(screen.queryByText('Execute')).not.toBeInTheDocument() + rerender( + + ) + expect(screen.getByText('Execute')).toBeInTheDocument() + }) + + it('does not invent a mode when the conversation or historical Task mode is unavailable', () => { + const { task, schedule, props } = modeFixtures() + const { rerender } = render( + + ) + expect(screen.queryByText('Execute')).not.toBeInTheDocument() + expect(screen.queryByText('Ask')).not.toBeInTheDocument() + rerender( + + ) + expect(screen.queryByText('Execute')).not.toBeInTheDocument() + expect(screen.queryByText('Ask')).not.toBeInTheDocument() + }) }) + +function modeFixtures() { + const task: AssistantTask = { + id: 'task', conversationId: 'conversation', scheduleId: 'schedule', + title: '模式回归', instructions: '', origin: 'schedule', status: 'idle', + workMode: 'execute', createdAt: '2026-08-19T00:00:00.000Z' + } + const schedule: AssistantSchedule = { + id: 'schedule', taskId: task.id, conversationId: 'conversation', + title: task.title, prompt: '', workMode: 'execute', recurrence: 'daily', + nextRunAt: '2026-08-20T09:00:00.000Z', enabled: true, + createdAt: task.createdAt, updatedAt: task.createdAt + } + return { + task, schedule, + props: { + locale: 'zh-CN', selectedTaskId: task.id, + onRemoveSchedule: vi.fn(async () => undefined), + onRunSchedule: vi.fn(async () => undefined), + onSelectTask: vi.fn(), + onSetScheduleEnabled: vi.fn(async () => undefined) + } + } +} diff --git a/src/renderer/src/ConversationTaskStrip.tsx b/src/renderer/src/ConversationTaskStrip.tsx index 15325639..4238decf 100644 --- a/src/renderer/src/ConversationTaskStrip.tsx +++ b/src/renderer/src/ConversationTaskStrip.tsx @@ -8,7 +8,8 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import type { AssistantSchedule, - AssistantTask + AssistantTask, + WorkMode } from '../../shared/assistant-contracts' import { findTaskSchedule, @@ -16,6 +17,7 @@ import { } from './TaskScheduleActions' type ConversationTaskStripProps = { + conversationMode?: WorkMode locale: string onRemoveSchedule: (scheduleId: string) => Promise onRunSchedule: (scheduleId: string) => Promise @@ -30,6 +32,7 @@ type ConversationTaskStripProps = { } export function ConversationTaskStrip({ + conversationMode, locale, onRemoveSchedule, onRunSchedule, @@ -51,6 +54,17 @@ export function ConversationTaskStrip({ const selectedSchedule = selectedTask ? findTaskSchedule(selectedTask, schedules) : undefined + const unrunSchedule = + selectedSchedule && + !selectedSchedule.lastRunAt && + !selectedTask?.startedAt && + !selectedTask?.completedAt && + (selectedTask?.status === 'idle' || + selectedTask?.status === 'queued' || + selectedTask?.status === 'paused') + const selectedMode = unrunSchedule + ? conversationMode + : selectedTask?.workMode const expanded = manuallyExpanded || (Boolean(selectedTaskId) && collapsedTaskId !== selectedTaskId) @@ -136,11 +150,9 @@ export function ConversationTaskStrip({
{t('task.fields.mode')}
- {selectedSchedule - ? t(`task.mode.${selectedSchedule.workMode}`) - : selectedTask.workMode - ? t(`task.mode.${selectedTask.workMode}`) - : t('task.mode.unavailable')} + {selectedMode + ? t(`task.mode.${selectedMode}`) + : t('task.mode.unavailable')}
diff --git a/src/renderer/src/DocumentParsingSettingsSection.test.tsx b/src/renderer/src/DocumentParsingSettingsSection.test.tsx index f2b72418..863c907d 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.test.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.test.tsx @@ -529,7 +529,7 @@ describe('DocumentParsingSettingsSection', () => { const close = screen.getByRole('button', { name: '关闭结果' }) - expect(close).toHaveFocus() + await waitFor(() => expect(close).toHaveFocus()) const backgroundSection = trigger.closest( '.settings-section' ) @@ -543,7 +543,7 @@ describe('DocumentParsingSettingsSection', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument() ) - expect(trigger).toHaveFocus() + await waitFor(() => expect(trigger).toHaveFocus()) expect(backgroundSection?.inert).toBe(false) expect(test).toHaveBeenCalledWith('chat-attachment') expect(update).not.toHaveBeenCalled() diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 19509a02..a626f125 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -1,4 +1,5 @@ import { + act, cleanup, fireEvent, render, @@ -7,6 +8,9 @@ import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSnapshot } from '../../shared/terminal-contracts' +import { workbarLayoutPreferencesSchema } from '../../shared/workbar-contracts' +import { DEFAULT_WORKBAR_INSTANCES } from './WorkbarShell' import type { AssistantProject, AssistantTask, @@ -26,18 +30,39 @@ import { type SidebarArtifact } from './RightAssistantSidebar' +vi.mock('./TerminalPanel', () => ({ + TerminalPanel: ({ onSessionChange }: { + onSessionChange?: (snapshot: TerminalSnapshot) => void + }) => ( + + ) +})) + afterEach(cleanup) const firstBrowserTabId = '00000000-0000-4000-8000-000000000401' as BrowserTabId const secondBrowserTabId = '00000000-0000-4000-8000-000000000402' as BrowserTabId +const browserStateListeners = new Set<(state: BrowserLiveState) => void>() const browserApi = { createTab: vi.fn<(request: BrowserCreateTabRequest) => Promise>(), closeTab: vi.fn(async () => undefined), setViewport: vi.fn< (request: BrowserSetViewportRequest) => Promise - >(async () => undefined) + >(async () => undefined), + onState: (listener: (state: BrowserLiveState) => void) => { + browserStateListeners.add(listener) + return () => { browserStateListeners.delete(listener) } + } } function browserSummary( @@ -59,6 +84,7 @@ function browserSummary( beforeEach(() => { localStorage.clear() + browserStateListeners.clear() Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1400 @@ -115,7 +141,8 @@ function sidebarElement({ onBackBrowser, onNavigateBrowser, onReloadBrowser, - onStopLoadingBrowser + onStopLoadingBrowser, + onTabChange = vi.fn() }: { tab?: AssistantSidebarTab approvals?: PendingSidebarApproval[] @@ -146,6 +173,7 @@ function sidebarElement({ onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise + onTabChange?: (tab: AssistantSidebarTab) => void } = {}): React.JSX.Element { return (
@@ -177,7 +205,7 @@ function sidebarElement({ onSetScheduleEnabled={vi.fn(async () => undefined)} onStopLoadingBrowser={onStopLoadingBrowser} onOpenTask={vi.fn()} - onTabChange={vi.fn()} + onTabChange={onTabChange} open restoreFocusRef={restoreFocusRef} tab={tab} @@ -1177,4 +1205,166 @@ describe('RightAssistantSidebar resizing', () => { screen.getByRole('tab', { name: '浏览器 · conversation-a' }) ).toBeInTheDocument() }) + + it('rebinds a released tab before retrying navigation and can close a released instance', async () => { + const conversationId = 'retry-browser' + const onNavigateBrowser = vi.fn(async () => undefined) + renderSidebar({ tab: 'browser', activeConversationId: conversationId, onNavigateBrowser }) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce()) + const owner = browserApi.createTab.mock.calls[0]![0].workbarInstanceId + const stopped: BrowserLiveState = { + ...browserSummary(conversationId, firstBrowserTabId, true), + status: 'stopped', + sessionActive: false + } + act(() => { + for (const listener of browserStateListeners) listener(stopped) + }) + browserApi.createTab.mockResolvedValueOnce(browserSummary(conversationId, secondBrowserTabId, true)) + fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), { + target: { value: 'https://retry.example/' } + }) + fireEvent.click(screen.getByRole('button', { name: '前往' })) + await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith( + conversationId, secondBrowserTabId, 'https://retry.example/' + )) + expect(browserApi.createTab).toHaveBeenLastCalledWith({ conversationId, workbarInstanceId: owner }) + act(() => { + for (const listener of browserStateListeners) listener({ ...stopped, tabId: secondBrowserTabId }) + }) + fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · retry-browser' })) + await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · retry-browser' })).not.toBeInTheDocument()) + expect(browserApi.closeTab).not.toHaveBeenCalled() + }) + + it('shows the request-created Main tab without creating a second blank page', async () => { + const conversationId = 'request-browser' + const workbarInstanceId = '00000000-0000-4000-8000-000000000499' + const onNavigateBrowser = vi.fn(async () => undefined) + renderSidebar({ + tab: 'browser', + activeConversationId: conversationId, + onNavigateBrowser, + browserStates: { + [conversationId]: { + [firstBrowserTabId]: { + ...browserSummary(conversationId, firstBrowserTabId, true), + workbarInstanceId, + sessionActive: true, + url: 'https://agent.example/' + } + } + } + }) + await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://agent.example/')) + expect(browserApi.createTab).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '前往' })) + await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith( + conversationId, firstBrowserTabId, 'https://agent.example/' + )) + expect(browserApi.createTab).not.toHaveBeenCalled() + }) + + it('retains a request page at layout capacity and binds it after a slot is closed', async () => { + const conversationId = 'full-workbar' + const instances = [ + ...DEFAULT_WORKBAR_INSTANCES.filter((instance) => instance.appId === 'tasks' || instance.appId === 'workspace'), + ...Array.from({ length: 30 }, (_, index) => ({ + id: crypto.randomUUID(), + appId: 'browser' as const, + title: `parked-${index}`, + targetRef: { type: 'conversation' as const, conversationId } + })) + ] + localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({ + instances, activeInstanceId: instances[0]!.id, + expanded: true, dock: 'right', widthRatio: 0.3, + taskScope: 'current-project' + })) + const workbarInstanceId = '00000000-0000-4000-8000-000000000497' + renderSidebar({ + tab: 'browser', activeConversationId: conversationId, + browserStates: { + [conversationId]: { + [firstBrowserTabId]: { + ...browserSummary(conversationId, firstBrowserTabId, true), + workbarInstanceId, sessionActive: true, url: 'https://retained.example/' + } + } + } + }) + expect(screen.getByRole('alert')).toHaveTextContent('请关闭一个可关闭页签') + expect(screen.getAllByRole('tab')).toHaveLength(32) + expect(browserApi.createTab).not.toHaveBeenCalled() + expect(workbarLayoutPreferencesSchema.safeParse( + JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!) + ).success).toBe(true) + fireEvent.click(screen.getByRole('button', { name: '关闭parked-0' })) + await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://retained.example/')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getAllByRole('tab')).toHaveLength(32) + expect(browserApi.createTab).not.toHaveBeenCalled() + const restored = workbarLayoutPreferencesSchema.parse( + JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!) + ) + expect(restored.instances.some((instance) => instance.id === workbarInstanceId)).toBe(true) + expect(restored.activeInstanceId).toBe(workbarInstanceId) + }) + + it('preserves an explicit cross-conversation tab selection when the external app follows it', async () => { + const onTabChange = vi.fn() + const view = render(sidebarElement({ + tab: 'browser', activeConversationId: 'conversation-a', onTabChange + })) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce()) + view.rerender(sidebarElement({ + tab: 'browser', activeConversationId: 'conversation-b', onTabChange + })) + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2)) + fireEvent.click(screen.getByRole('tab', { name: '任务中心' })) + view.rerender(sidebarElement({ + tab: 'tasks', activeConversationId: 'conversation-b', onTabChange + })) + const browserA = screen.getByRole('tab', { name: '浏览器 · conversation-a' }) + fireEvent.click(browserA) + expect(onTabChange).toHaveBeenLastCalledWith('browser') + view.rerender(sidebarElement({ + tab: 'browser', activeConversationId: 'conversation-b', onTabChange + })) + expect(browserA).toHaveAttribute('aria-selected', 'true') + }) + + it('releases the native browser viewport while confirming another terminal close', async () => { + 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({ tab: 'browser', activeConversationId: 'modal-browser' }) + await waitFor(() => expect(browserApi.setViewport).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) }) + )) + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('终端', { selector: 'strong' }).closest('button')!) + fireEvent.click(await screen.findByRole('button', { name: 'Start test terminal' })) + fireEvent.click(screen.getByRole('tab', { name: '浏览器 · modal-browser' })) + await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith( + expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) }) + )) + const activeViewport = browserApi.setViewport.mock.calls.at(-1)![0] + fireEvent.click(screen.getByRole('button', { name: '关闭终端 1' })) + await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ + leaseToken: activeViewport.leaseToken + })) + const dialog = screen.getByRole('alertdialog') + fireEvent.click(within(dialog).getByRole('button', { name: /取消/u })) + await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith( + expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) }) + )) + } finally { + bounds.mockRestore() + } + }) }) diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 195d5858..3bb80a04 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -1021,6 +1021,7 @@ export function RightAssistantSidebar({ if (instance.appId === 'browser') { browserMruRef.current[instance.id] = ++nextBrowserMruRef.current } + lastExternalTabRef.current = instance.appId onTabChange(instance.appId) } }, @@ -1160,6 +1161,71 @@ export function RightAssistantSidebar({ [activeConversationId, conversationTitles, t] ) + useEffect(() => window.goodbuddy.browser?.onState?.((state) => { + if (state.status !== 'stopped') return + const next = { ...browserTabIdsRef.current } + let changed = false + for (const [instanceId, tabId] of Object.entries(next)) { + if (tabId === state.tabId) { + delete next[instanceId] + changed = true + } + } + if (changed) { + browserTabIdsRef.current = next + setBrowserTabIds(next) + } + }), []) + + useEffect(() => { + const liveTabs = Object.values(browserStates ?? {}).flatMap((states) => + Object.values(states).filter((state) => + state.workbarInstanceId && + (state.sessionActive || state.status === 'creating') + ) + ) + const nextBindings = { ...browserTabIdsRef.current } + let changed = false + const newInstances: WorkbarTabInstance[] = [] + for (const state of liveTabs) { + const instanceId = state.workbarInstanceId! + if (nextBindings[instanceId] !== state.tabId) { + nextBindings[instanceId] = state.tabId + changed = true + } + if (!workbarInstances.some((instance) => instance.id === instanceId)) { + newInstances.push({ + id: instanceId, + appId: 'browser', + title: `${t('sidebar.tabs.browser.label')} · ${conversationTitles.get(state.conversationId) ?? state.conversationId}`, + targetRef: { type: 'conversation', conversationId: state.conversationId } + }) + } + } + if (changed) { + browserTabIdsRef.current = nextBindings + setBrowserTabIds(nextBindings) + } + const additions = newInstances.slice( + 0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - workbarInstances.length) + ) + if (additions.length > 0) { + setWorkbarInstances((current) => [ + ...current, + ...additions + .filter((candidate) => !current.some((instance) => instance.id === candidate.id)) + .slice(0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - current.length)) + ]) + const requested = additions.find((instance) => + instance.targetRef?.type === 'conversation' && + instance.targetRef.conversationId === activeConversationId + ) + if (requested && tab === 'browser') { + setActiveWorkbarInstanceId(requested.id) + } + } + }, [activeConversationId, browserStates, conversationTitles, tab, t, workbarInstances]) + const removeWorkbarInstance = useCallback( (instanceId: string): void => { setWorkbarInstances((current) => @@ -1567,10 +1633,20 @@ export function RightAssistantSidebar({ } } + const hasUnboundRequestBrowser = Object.values( + browserStates?.[activeConversationId ?? ''] ?? {} + ).some((state) => + state.workbarInstanceId && + (state.sessionActive || state.status === 'creating') && + !workbarInstances.some((instance) => instance.id === state.workbarInstanceId) + ) + useEffect(() => { if (!open || activeWorkbarInstance?.appId !== 'browser') { return } + // Bind a newly materialized request tab before opening a fallback instance. + if (hasUnboundRequestBrowser) return void ensureBrowserTab(activeWorkbarInstance).catch((reason: unknown) => { setActionError( reason instanceof Error @@ -1578,7 +1654,7 @@ export function RightAssistantSidebar({ : t('sidebar.errors.browserControlUnavailable') ) }) - }, [activeWorkbarInstance, ensureBrowserTab, open, t]) + }, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, t]) return (
}