diff --git a/AGENTS.md b/AGENTS.md index 26e0a3e0..8cc80e16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -265,6 +265,39 @@ possible, and report the exact call count. This standing authorization does not cover bulk, high-cost, destructive, publishing, messaging, purchasing, or other consequential external actions. +### GoodBuddy Agent development validation + +Every source change that can alter the deployed GoodBuddy Agent or the +desktop-to-Agent production path must be validated on the shared Linux x64 test +Host while the change is being developed. This includes Agent daemon and +protocol changes, packaging and installation, attach/update behavior, Runtime +launch and process ownership, workspaces, model bridges, and lifecycle or +recovery behavior. Unit tests, mocks, fixtures, CI, and waiting for release +validation are not substitutes for this development-time check. + +- Reach the shared Host at `192.168.0.23` on the LAN when available, otherwise + use `10.7.0.23` over VPN. These are two routes to the same physical machine, + not two independent test targets or architecture results. +- Exercise the current source or package under development, not only the + previously published Agent. Run the affected production path as soon as it is + runnable and repeat the relevant scenario after the final change. +- Select the real-Host scenario from the changed surface: attach/bootstrap or + update for installation changes; Ask/Execute and a bounded real model request + for Runtime or model-bridge changes; and the applicable disconnect, restart, + cancellation, or reconnection sequence for lifecycle and recovery changes. +- Use the existing pinned Host identity and GoodBuddy credential storage. Never + add credentials to source, documentation, commands, logs, or test output, and + never hard-code these test addresses into product behavior or defaults. +- Keep all artifacts in GoodBuddy-owned test directories. Before stopping or + changing a remote process, verify that it belongs to the test run; never + modify unrelated Host files or processes. +- If neither route is reachable, report the real-Host validation as blocked and + do not present the Agent work as complete or defer its first real validation + to release time. + +The detailed scenario rules are documented in +[`docs/architecture/remote-host-and-goodbuddy-agent-design.md`](./docs/architecture/remote-host-and-goodbuddy-agent-design.md). + Before committing or pushing, inspect `git status`, `git diff`, and `git diff --cached`. Do not commit secrets, local databases, logs, generated credentials, or private user artifacts. diff --git a/UI-DESIGN.md b/UI-DESIGN.md index dcf5eb6f..22471e20 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -553,6 +553,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - OpenCode、Continue 和 DeepSeek Harness 后续的 Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;只选择 Conversation 或 Task,并按 Task 聚合执行状态,不显示 Job/Run 树或独立操作菜单。内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。 - 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。 - Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。 +- OpenCode 原生 Task 调用在本地项目和托管 SSH 项目中统一解析为子 Agent 状态卡,不显示为普通工具卡。状态卡持续显示 Agent 类型、任务说明、运行状态、完成输出或失败原因;身份徽标、Agent 名称和状态位于首层,任务说明作为主信息,Runtime 来源作为次级元数据,输出与错误在同一卡片内按需展开。父 Agent 的推理不冒充子 Agent 过程,必须与状态卡保持明确间距。状态不能只靠颜色表达。推理、子 Agent、工具和正文统一按事件首次出现顺序进入消息块;增量更新只更新原位置,Task 临时工具块重分类后必须原位替换,不能把子 Agent 卡统一提升到消息顶部。同一调用的增量事件按稳定调用 ID 更新,不能因 ACP 首帧尚无参数而永久保留重复工具卡或活动记录。一次请求内已观察到的子 Agent 均保留在消息与活动记录中;父请求取消、失败或意外中断时仍处于活动状态的卡片必须结算为对应终态,父请求成功结束但子 Agent 未报告终态时必须显示为失败而不是推断为完成。 - Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。 - Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。 - 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index 69b908d6..817dfe7b 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.11.10", + "agentVersion": "0.11.13", "protocol": { "major": 2, "minor": 0 diff --git a/docs/architecture/deepseek-harness-runtime-design.md b/docs/architecture/deepseek-harness-runtime-design.md index af025c03..2cccce89 100644 --- a/docs/architecture/deepseek-harness-runtime-design.md +++ b/docs/architecture/deepseek-harness-runtime-design.md @@ -325,12 +325,13 @@ GoodBuddy conversationId -> Harness sessionId + process generation - 历史以明确的“不可信会话数据”结构传入,不能拼接成系统指令。 - 用户分配的 Skill 只通过 Main 校验的包路径进入 Host,并在 Agent scope 注册;不得把 Skill 内容伪装成用户 Prompt。 -### 9.3 取消与超时 +### 9.3 取消与有界控制操作 - 用户取消时立即发送 `session/cancel`。 - 取消等待有界,超时后关闭连接并终止整个 Harness 进程。 -- 初始化、握手、Session 创建、Prompt、权限回传和关闭分别使用独立超时。 -- Prompt 超时与用户取消使用不同错误类型,不能被宽泛 catch 抹平。 +- 初始化、握手、请求准备、权限回传和关闭分别使用独立控制超时。 +- 生产 Prompt 不设置固定墙钟总时限,由 Harness 自身协议终态或用户取消结束。测试可以 + 显式注入短 Prompt 时限验证取消传播,超时错误与用户取消仍不能被宽泛 catch 抹平。 - 取消后仍可接收并丢弃该请求的最终协议结算帧,但不得写入下一请求。 ### 9.4 释放与退出 @@ -468,7 +469,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接: | stderr 累计 | 64 KiB | | Host 启动 | 10 秒基础预算 + 每插件 5 秒激活与最多 1 秒失败清理,插件序列最多 91 秒;Main 另预留 2 秒持久化失败状态 | | ACP 初始化与内部握手 | 每阶段 10 秒 | -| 单次 Prompt | 10 分钟 | +| 单次 Prompt | 无固定墙钟总时限,由 Harness 终态或用户取消结束 | | 有序关闭宽限期 | 2 秒 | 超过限制时应取消当前请求。协议帧、队列或 stderr 持续异常时,应终止 Runtime 进程,避免继续信任已失控的通道。 diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index 692d892f..a731acba 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -2,7 +2,7 @@ ## 状态 -本文记录截至 2026-08-29 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备 +本文记录截至 2026-08-30 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备 Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线, 详细事务与验收边界见 [SSH Host 远程环境准备与直连下载设计](./remote-host-environment-provisioning-design.md); @@ -13,8 +13,9 @@ Windows 到 Linux x64 的既有安装、 Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback -路径 capability 后,Agent `0.11.9` 已通过独立 workflow 发布 Linux x64/arm64 复合包和 -签名累计目录;当前桌面源码准备 `0.11.10`,修复 Host 更新并消除重复 payload 验证。失败的 +路径 capability 后,Agent `0.11.10` 已通过独立 workflow 发布 Linux x64/arm64 复合包和 +签名累计目录;当前桌面源码准备 `0.11.13`,显示本地与远端 OpenCode 原生 Task,并取消 +GoodBuddy 对生产 Prompt 的固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。 ## 产品语义 @@ -193,7 +194,13 @@ Execute 不经过 Ask 的 bubblewrap profile: - 继承 SSH 账号的正常环境、文件系统、进程和网络能力; - 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。 -两种模式都保留请求 deadline、输入/输出字节上限、取消和进程组清理。 +两种模式都保留输入/输出字节上限、用户取消和进程组清理。生产 Prompt 不设置固定墙钟 +总时限;只要 Runtime 尚未按自身协议结束,Main 和 Agent 就允许其持续运行。启动、握手、 +单个控制 RPC、重连尝试和关闭清理仍使用独立的有界超时,测试可以显式注入短 Prompt +时限验证取消升级,但该覆盖值不是生产默认。无界 Prompt 继续复用已发布的必填 +`deadlineAt` 字段,以 year-9999 哨兵表达;Main 与 Agent 必须同时理解该哨兵,Agent +`0.11.10` 及更早版本仍会按其 manifest 上限夹紧,因此桌面启用这一语义前必须配套更新 +到 Agent `0.11.13` 或更高版本。 ## ACP 与断线 @@ -207,13 +214,21 @@ Execute 不经过 Ask 的 bubblewrap profile: 调度、跨 channel 超车、批发送或第二套拥塞控制。 - Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。 - Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。 -- Runtime 事件进入桌面会话后,Renderer 和 Main 将完整工具详情保存到本地 SQLite,不再对已接受的工具输入、输出、错误或摘要施加额外的会话字段长度限制。 +- Runtime 事件先经过单事件、单请求输出和每次 Prompt 最多 100 个不同工具调用的边界; + 通过边界后,Renderer 和 Main 将已接受的工具详情保存到本地 SQLite,不再额外缩短其 + 输入、输出、错误或摘要。 +- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID + 解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP + 首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent + 状态卡,并持久化任务说明、终态与输出。 - 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。 - Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。 - 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。 - 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。 - 终态 close 由 Agent 在一个事务中为两个方向记录 sequence high-water tombstone,删除剩余 frame 和 active channel,并归零对应 journal quota。tombstone 用于拒绝迟到的旧 epoch frame,因此当前不会按时间自动裁剪。 -- 断线不主动终止正在运行的 Runtime。用户取消、deadline、显式关闭或 Agent shutdown 才触发停止。 +- 断线不主动终止正在运行的 Runtime。用户取消、显式关闭、输出边界或 Agent shutdown + 才触发停止;连接恢复只受当前用户取消信号和单次重连控制超时约束,不把 Prompt 已运行 + 时长当作停止条件。 - Agent 失联期间继续把 Runtime 输出写入本机 journal;重新连接只校验 controller identity、恢复 binding,并从 Main 最后确认的 cursor 后同步缺失数据。已接受的 指令和结果不确定的 Provider 请求都不自动重放。 @@ -224,8 +239,8 @@ Execute 不经过 Ask 的 bubblewrap profile: - 远端 OpenCode 通过 GoodBuddy Agent helper 和每次 Prompt 的私有 Unix socket 使用模型桥。 - Main 校验固定模型 profile、协议、请求路径和有界传输格式,并记录 Provider 返回的实际 usage;不限制 Prompt 内的模型调用轮数、工具调用次数、累计 Token - 或单次模型输出 Token。取消、Runtime deadline、请求/响应字节上限和结果不确定时 - 禁止自动重放仍然保留。 + 或单次模型输出 Token。取消、请求/响应字节上限和结果不确定时禁止自动重放仍然保留; + 模型桥不会为整个 Runtime Prompt 另设墙钟总时限。 - helper 可以接收同一 Prompt 内并发到达的模型桥请求;它在单一稳定模型桥上按到达 顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 HTTP 完整 flush 后才 发送 delivery ACK。 @@ -242,12 +257,55 @@ Execute 不经过 Ask 的 bubblewrap profile: 不修改既有 React state 对象。这样开发环境 StrictMode 重复调用 state updater 时, block metadata 与 canonical 消息正文保持一致。 +## Agent 开发期间的真实 Host 验证 + +任何会改变已部署 GoodBuddy Agent 或桌面到 Agent 生产链路的源码改动,都必须在开发期间 +使用共享 Linux x64 真实测试 Host 验证。适用范围包括 Agent daemon、Agent protocol +及共享 contract、Agent 组包与安装、attach/update、Runtime 启动与进程归属、Workspace、 +model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture、CI 或正式发布前回归 +都不能替代这一步;不得等到准备发布时才首次运行受影响的真实链路。 + +测试 Host 有两个网络入口: + +- `192.168.0.23`:局域网入口,在本地网络可达时优先使用; +- `10.7.0.23`:VPN 入口,局域网入口不可达时使用。 + +两者指向同一台物理 Host,只代表两条访问路由,不能计为两个独立 Host、两种架构或两份 +覆盖结果。测试只使用已固定的 Host identity 和 GoodBuddy 已有凭据存储;地址不得写入 +产品行为或默认配置,用户名、密码、私钥等凭据不得写入源码、文档、命令、日志或测试输出。 + +开发时应在改动链路达到可运行状态后尽早执行真实 Host 验证,并在最终相关改动后重复受 +影响的场景。必须使用当前源码构建或当前候选包,不能只验证 Host 上已经发布的旧 Agent。 +按改动范围至少选择以下场景: + +1. Agent 组包、安装或升级:验证当前候选的 attach-or-bootstrap、安装/更新、版本切换及 + 随后的连接。 +2. Runtime profile、进程启动/归属或 Ask/Execute 边界:通过桌面生产入口启动真实 Runtime, + 验证受影响模式;Ask 权限改动必须验证只读边界,Execute 权限改动必须验证所需写入、 + 进程和网络能力。 +3. Agent protocol、RPC、stream、model bridge 或模型消息链路:执行最小有界真实模型调用, + 禁用非必要工具和附件,并记录实际调用次数;大消息或工具链路改动还应覆盖对应的真实 + payload/工具场景。 +4. 会话、owner、取消、超时、断线或恢复逻辑:执行与改动直接相关的中断、取消、Agent/ + Runtime 重启、SSH 断开或重连序列,并确认不会错误重放结果未知的操作。 +5. Workspace 或 Host 文件路径:只在 GoodBuddy 专用测试目录操作,验证真实路径和权限, + 不得读取、覆盖或删除无关 Host 文件。 + +停止或修改远端进程前必须先核对其启动身份、PID/进程组和 GoodBuddy 归属。若两个入口均 +不可达,应立即把真实 Host 验证报告为开发阻塞,保持该验证未完成,不能把 Agent 改动表述 +为已完成,也不能把首次真实验证推迟到发布阶段。 + ## 已完成的 E2E 验收记录 - 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0` 和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布 - 的双架构验收。Agent `0.11.9` 后续已由原生 workflow 发布并公开验证双架构工件; - 当前源码 lock 固定为 `0.11.9`。 + 的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件; + 当前源码 lock 固定为候选 `0.11.13`。 +- 2026-08-30 在共享 Linux x64 Host 的隔离测试 HOME 中验证当前 `0.11.13` 源码候选: + 签名 Runtime 清单的测试墙钟上限为 1 秒,模型桥首轮故意延迟 2.515 秒后 Prompt 仍在 + 5.770 秒正常完成;随后 OpenCode 原生 Task 依次产生 `running`、`completed` 子 Agent + 事件并完成父请求。测试结束后隔离 Agent、Runtime、socket 和 Workspace 已全部清理, + Host current Agent 仍为 `0.11.10`。 - 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认 OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。 - 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生 @@ -319,7 +377,10 @@ v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/ 信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和 payload 仍按原始签名字节及 SHA-256 验证。 -## 发布前验证 +## 发布前完整回归(不能替代开发验证) + +以下清单只用于复核开发期间已经在真实 Host 上通过的链路,不得作为 Agent 改动的首次 +真实 Host 验证: 1. `npm test` 2. `npm run typecheck` diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index 85dd6851..3e1fe164 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -806,8 +806,9 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 - 通用 Artifact Event。 - HTML 工作区和成果的静态即时预览。 - Runtime Inspector Snapshot 与事件。 -- OpenCode 会话、子会话、Todo、工具、用量和取消。 -- 直连模型及现有 GoodBuddy Subagent 的统一展示。 +- OpenCode 会话、子会话、Todo、工具、用量和取消;本地与托管 SSH 的原生 Task 调用在 + Conversation 中统一显示为可追踪的 Subagent 状态,而不是普通工具参数卡。 +- 直连模型、OpenCode 原生 Subagent 及现有 GoodBuddy Subagent 的统一展示。 ### 阶段 2:监督、终端与受管进程 diff --git a/package-lock.json b/package-lock.json index e3314259..c647e875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.11.12", + "version": "0.11.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.11.12", + "version": "0.11.13", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index cd0f212c..271c57f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.11.12", + "version": "0.11.13", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/resources/release-notes.json b/resources/release-notes.json index fd06af49..19493327 100644 --- a/resources/release-notes.json +++ b/resources/release-notes.json @@ -1,6 +1,46 @@ { "formatVersion": 1, "releases": [ + { + "version": "0.11.13", + "releasedAt": "2026-08-30", + "notes": { + "zh-CN": { + "highlights": [ + "GoodBuddy 0.11.13 将本地与托管 SSH 项目中的 OpenCode 原生 Task 显示为独立子 Agent,并移除 GoodBuddy 固定的 Prompt 总时限,让 Runtime 决定长任务生命周期。" + ], + "features": [ + "**OpenCode 原生子 Agent。** OpenCode Task 调用现在以独立子 Agent 卡片显示,持续呈现任务、运行状态、输出和错误,不再同时重复为普通工具。" + ], + "fixes": [ + "**长任务不再被固定时限中止。** 移除本地 OpenCode、Continue、DeepSeek Harness 和托管 SSH Prompt 的 2/10 分钟外层总时限。任务可持续数小时或更久,直到正常完成或用户取消;Runtime 自身超时仍然生效。", + "**OpenCode 启动更可靠。** 改用本机健康接口检测启动完成,不再依赖固定日志格式,并为较慢设备保留更充足的冷启动时间,减少已启动却误报超时。", + "**HTTP 服务地址配置更灵活。** 模型连接、DeepSeek Harness 与微信绑定可使用用户明确配置的合法 HTTP 或 HTTPS 地址,包括局域网与公网服务;仍保留协议和 API Key 校验。" + ], + "notices": [ + "用户仍可随时停止任务;启动、控制请求、断线恢复、取消收敛、清理和输出大小仍有独立边界。", + "托管 SSH Host 需要单独发布的 Agent `0.11.13`。提供 Linux x64 和 arm64;桌面包不内置 Agent 或远程 Runtime。" + ] + }, + "en-US": { + "highlights": [ + "GoodBuddy 0.11.13 presents native OpenCode Tasks as dedicated subagents in local and managed SSH projects, and removes GoodBuddy's fixed Prompt deadline so the Runtime owns long-task lifetime." + ], + "features": [ + "**Native OpenCode subagents.** OpenCode Task calls now appear as dedicated subagent cards with the task, running state, output, and errors, without a duplicate generic-tool entry." + ], + "fixes": [ + "**Long tasks are no longer stopped by a fixed limit.** Removed the outer 2/10-minute total deadline from local OpenCode, Continue, DeepSeek Harness, and managed SSH Prompts. Tasks can run for hours or longer until completion or user cancellation; Runtime-owned timeouts still apply.", + "**More reliable OpenCode startup.** GoodBuddy now detects readiness through the local health endpoint instead of a fixed log format and allows more cold-start time on slower devices, avoiding false startup timeouts.", + "**More flexible HTTP service endpoints.** Model connections, DeepSeek Harness, and Weixin binding can use explicitly configured valid HTTP or HTTPS endpoints, including LAN and public services, while retaining protocol and API-key validation." + ], + "notices": [ + "Users can still stop tasks at any time. Startup, control requests, reconnection recovery, cancellation settlement, cleanup, and output sizes retain independent bounds.", + "Managed SSH Hosts require the separately published Agent `0.11.13`. Linux x64 and arm64 are provided; desktop packages do not embed the Agent or Remote Runtime." + ] + } + } + }, { "version": "0.11.12", "releasedAt": "2026-08-30", diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.test.ts b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts index 7e2d8917..a071b907 100644 --- a/src/agent-daemon/direct-linux-stdio-process-owner.test.ts +++ b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts @@ -5,6 +5,9 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts' +import { + UNBOUNDED_REMOTE_PROMPT_DEADLINE +} from '../shared/remote-agent-contracts' import { launchDirectLinuxStdioProcessOwner, reconcileOrphanedDirectLinuxStdioProcesses, @@ -249,6 +252,48 @@ describe('direct Linux stdio Runtime ownership', () => { registry.close() }) + it('does not stop an active prompt at the unbounded Runtime deadline', async () => { + vi.useFakeTimers() + try { + const registry = createRegistry() + const child = fakeChild() + const launched = identity() + const sendSignal = vi.fn(() => true) + const owner = await launchDirectLinuxStdioProcessOwner({ + manifest: manifest(), + profile: profile(), + identity: { launchId: 'launch-long', processId: 'process-long' }, + installationId: 'installation-1', + registry, + deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE, + maximumInputBytes: 1024, + platform: 'linux', + spawn: () => { + queueMicrotask(() => child.emit('spawn')) + return child + }, + randomOwnerToken: () => 'a'.repeat(32), + readProcessIdentity: async () => launched, + listPidNamespaceMembers: async () => [], + sendSignal + }) + + await vi.advanceTimersByTimeAsync(25 * 60 * 60_000) + expect(sendSignal).not.toHaveBeenCalled() + expect(await owner.reconcile()).toMatchObject({ + state: 'running', + processTree: 'running' + }) + await owner.stop({ + reason: 'user-cancelled', + deadlineAt: new Date(Date.now() + 10_000).toISOString() + }) + registry.close() + } finally { + vi.useRealTimers() + } + }) + it('startup reconciles only owners from the current installation', async () => { const registry = createRegistry() const reservation = { diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.ts b/src/agent-daemon/direct-linux-stdio-process-owner.ts index 86208728..aa2b1fc1 100644 --- a/src/agent-daemon/direct-linux-stdio-process-owner.ts +++ b/src/agent-daemon/direct-linux-stdio-process-owner.ts @@ -8,6 +8,9 @@ import { sha256DigestSchema } from '../shared/agent-protocol/contracts' import { canonicalJson } from '../shared/agent-protocol/canonical' +import { + UNBOUNDED_REMOTE_PROMPT_DEADLINE +} from '../shared/remote-agent-contracts' import { remoteRuntimeBundleManifestSchema, type RemoteRuntimeBundleManifest @@ -511,6 +514,10 @@ export class DirectLinuxStdioProcessOwner { } #scheduleDeadline(deadlineAt: string): void { + if (deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) { + this.#clearDeadline() + return + } const remaining = remainingMilliseconds(deadlineAt, this.#now()) this.#clearDeadline() const generation = this.#promptGeneration @@ -551,7 +558,10 @@ export async function launchDirectLinuxStdioProcessOwner( now(), limits.maximumPromptRuntimeMilliseconds ) - const timeoutMs = remainingMilliseconds(effectiveDeadlineAt, now()) + const timeoutMs = + effectiveDeadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE + ? DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS + : remainingMilliseconds(effectiveDeadlineAt, now()) const baseProfile = resolveProfile(options, manifest) const ownerToken = (options.randomOwnerToken ?? (() => randomBytes(16).toString('hex')))() if (!/^[a-f0-9]{32}$/u.test(ownerToken)) { @@ -1097,6 +1107,9 @@ function boundedPromptDeadline( now: number, maximumRuntimeMilliseconds: number ): string { + if (requestedDeadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) { + return requestedDeadlineAt + } const requested = Date.parse(requestedDeadlineAt) if (!Number.isFinite(requested) || requested <= now) { throw new DirectLinuxStdioProcessOwnerError( diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index cc4cbb46..52c04415 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { type AcpJournalCursor, - type RemotePromptOperationPreparation + type RemotePromptOperationPreparation, + UNBOUNDED_REMOTE_PROMPT_DEADLINE } from '../shared/remote-agent-contracts' import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts' import type { ControllerLease } from './controller-registry' @@ -813,6 +814,28 @@ describe('RuntimeAcpBackend', () => { ).rejects.toMatchObject({ code: 'deadline' }) }) + it('keeps an unbounded Runtime prompt alive without a wall-clock timer', async () => { + vi.useFakeTimers() + try { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE + }) + ) + + await vi.advanceTimersByTimeAsync(30 * 24 * 60 * 60_000) + expect(fixture.process.stops).toEqual([]) + await fixture.process.emit('stdout', 'still-running') + expect(fixture.outputFrames).toEqual(['still-running']) + } finally { + vi.useRealTimers() + } + }) + it('stops the process when its output quota is exceeded', async () => { const fixture = harness({ maximumOutputBytes: 3 }) await open(fixture) diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index 0793ffdf..905da0f1 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -25,6 +25,7 @@ import { acpJournalCursorSchema, remotePromptOperationAcceptanceSchema, remotePromptOperationPreparationSchema, + UNBOUNDED_REMOTE_PROMPT_DEADLINE, type AcpJournalCursor, type RemotePromptOperationAcceptance, type RemotePromptOperationPreparation @@ -1676,6 +1677,10 @@ export class RuntimeAcpBackend { #scheduleDeadline(binding: BindingState, deadlineAt: string): void { if (binding.deadlineTimer !== undefined) { clearTimeout(binding.deadlineTimer) + binding.deadlineTimer = undefined + } + if (deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) { + return } const delay = Math.max( 1, diff --git a/src/main/agent/acp-remote-runtime.test.ts b/src/main/agent/acp-remote-runtime.test.ts index 32f887c0..dcf80b5e 100644 --- a/src/main/agent/acp-remote-runtime.test.ts +++ b/src/main/agent/acp-remote-runtime.test.ts @@ -259,10 +259,9 @@ function runtime( assertHostCurrent: () => {}, cancellationGraceMs, operationTimeoutMs: overrides.operationTimeoutMs ?? 100, - promptTimeoutMs: - overrides.promptTimeoutMs ?? - overrides.operationTimeoutMs ?? - 100, + ...(overrides.promptTimeoutMs === undefined + ? {} + : { promptTimeoutMs: overrides.promptTimeoutMs }), usage: { runtime: 'opencode', provider: 'test', @@ -287,10 +286,9 @@ function factoryRuntime( assertHostCurrent: () => {}, cancellationGraceMs: 5, operationTimeoutMs: overrides.operationTimeoutMs ?? 100, - promptTimeoutMs: - overrides.promptTimeoutMs ?? - overrides.operationTimeoutMs ?? - 100, + ...(overrides.promptTimeoutMs === undefined + ? {} + : { promptTimeoutMs: overrides.promptTimeoutMs }), usage: { runtime: 'opencode', provider: 'test', @@ -1258,6 +1256,233 @@ describe('AcpRemoteRuntime', () => { ]) }) + it('merges incremental OpenCode Task input into native subagent events', async () => { + const client: { value?: AgentSideConnection } = {} + const server = fakeServer({ + prompt: async ({ sessionId }) => { + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'task-1', + title: 'Review application architecture', + kind: 'other', + status: 'in_progress', + rawInput: {} + } + }) + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'task-1', + status: 'in_progress', + rawInput: { + subagent_type: 'explorer', + description: 'Review application architecture', + prompt: 'Inspect the complete source tree.' + } + } + }) + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'task-1', + status: 'in_progress', + rawInput: { + subagent_type: 'explorer', + description: 'Review application architecture', + prompt: 'Inspect the complete source tree.' + } + } + }) + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'task-1', + status: 'completed', + rawOutput: 'Architecture review complete.' + } + }) + return { stopReason: 'end_turn' } + } + }) + client.value = server.client + + const events = await collect( + runtime(server).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + expect(events.map((event) => event.type)).toEqual([ + 'status', + 'tool', + 'subagent', + 'subagent', + 'done' + ]) + expect(events[2]).toMatchObject({ + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'task-1', + state: 'running' + }) + expect(events[3]).toMatchObject({ + state: 'completed', + output: 'Architecture review complete.' + }) + }) + + it('bounds native subagent output per event and per request', async () => { + const client: { value?: AgentSideConnection } = {} + const server = fakeServer({ + prompt: async ({ sessionId }) => { + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'task-bounded', + title: 'Bounded task', + kind: 'other', + status: 'completed', + rawInput: { + subagent_type: 'explorer', + prompt: 'Inspect the complete source tree.' + }, + rawOutput: 'x'.repeat(100) + } + }) + return { stopReason: 'end_turn' } + } + }) + client.value = server.client + + const events = await collect( + runtime(server, undefined, 5, { + maxEventCharacters: 10, + maxRequestOutputBytes: 1_000 + }).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + expect( + events.find((event) => event.type === 'subagent') + ).toMatchObject({ + state: 'completed', + output: 'xxxxx…' + }) + + const overBudgetServer = fakeServer({ + prompt: async ({ sessionId }) => { + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'task-over-budget', + title: 'Over-budget task', + kind: 'other', + status: 'completed', + rawInput: { + subagent_type: 'explorer', + prompt: 'Inspect the complete source tree.' + }, + rawOutput: 'x'.repeat(100) + } + }) + return { stopReason: 'end_turn' } + } + }) + client.value = overBudgetServer.client + await expect( + collect( + runtime(overBudgetServer, undefined, 5, { + maxEventCharacters: 1_000, + maxRequestOutputBytes: 80 + }).run( + { + ...request, + requestId: 'b5b07fa5-f722-4bbc-ad19-33a6435817f1' + }, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('远端 Runtime 请求输出超过安全上限') + }) + + it('rejects more than 100 distinct remote tool calls', async () => { + const client: { value?: AgentSideConnection } = {} + const server = fakeServer({ + prompt: async ({ sessionId }) => { + for (let index = 0; index <= 100; index += 1) { + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: `call-${index}`, + title: 'tool', + status: 'in_progress' + } + }) + } + return { stopReason: 'end_turn' } + } + }) + client.value = server.client + + await expect( + collect( + runtime(server).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow( + '远端 Runtime 单次运行的工具调用超过 100 个' + ) + }) + + it('does not impose a default wall-clock deadline on remote prompts', async () => { + vi.useFakeTimers() + try { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const server = fakeServer({ + prompt: async () => { + await gate + return { stopReason: 'end_turn' } + } + }) + const running = collect( + runtime(server).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + await vi.waitFor(() => expect(server.prepare).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(25 * 60 * 60_000) + expect(server.escalate).not.toHaveBeenCalled() + release() + await expect(running).resolves.toEqual([ + expect.objectContaining({ type: 'status' }), + expect.objectContaining({ type: 'done' }) + ]) + } finally { + vi.useRealTimers() + } + }) + it('routes Execute permission through the authorizer and denies mutating Ask permission', async () => { const client: { value?: AgentSideConnection } = {} const outcomes: string[] = [] @@ -1727,7 +1952,8 @@ describe('AcpRemoteRuntime', () => { }) }) const instance = runtime(server, store, 5, { - operationTimeoutMs: 20 + operationTimeoutMs: 20, + promptTimeoutMs: 20 }) await expect( diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts index 76458042..b86eeae2 100644 --- a/src/main/agent/acp-remote-runtime.ts +++ b/src/main/agent/acp-remote-runtime.ts @@ -15,11 +15,15 @@ import { import type { AgentRuntimeStatus } from '../../shared/contracts' +import { + maximumConversationToolActivities +} from '../../shared/assistant-contracts' import { assertRemotePromptAcceptanceMatchesPreparation, remotePromptOperationAcceptanceSchema, remotePromptOperationPreparationSchema, runtimeSessionBindingSchema, + UNBOUNDED_REMOTE_PROMPT_DEADLINE, type RemotePromptOperationAcceptance, type RemotePromptOperationPreparation, type RuntimeSessionBinding @@ -48,6 +52,10 @@ import { type RemoteRuntimeChannel } from './remote-runtime-channel' import type { RuntimeSessionBindingStore } from './runtime-session-binding-store' +import { + parseOpenCodeSubagentInput, + toOpenCodeSubagentEvent +} from './opencode-subagent' type BindingIdentity = Pick< RuntimeSessionBinding, @@ -98,6 +106,7 @@ export type AcpRemoteRuntimeOptions = { maxRequestOutputBytes?: number cancellationGraceMs?: number operationTimeoutMs?: number + /** Optional test/operator override. Production prompts are Runtime-controlled. */ promptTimeoutMs?: number maxPendingUpdates?: number maxPendingUpdateBytes?: number @@ -120,6 +129,16 @@ type ActivePrompt = { pendingUpdateBytes: number inboundPaused: boolean outputCharacters: number + toolCalls: Map< + string, + { + title?: string + kind?: string + status?: 'pending' | 'in_progress' | 'completed' | 'failed' + rawInput?: unknown + rawOutput?: unknown + } + > open: boolean completed: boolean interruption?: Error @@ -162,7 +181,6 @@ type ChannelContext = { const DEFAULT_MAX_EVENT_CHARACTERS = 100_000 const DEFAULT_MAX_REQUEST_OUTPUT_BYTES = 1_000_000 const DEFAULT_OPERATION_TIMEOUT_MS = 30_000 -const DEFAULT_PROMPT_TIMEOUT_MS = 120_000 const DEFAULT_CANCELLATION_GRACE_MS = 1_500 const DEFAULT_MAX_PENDING_UPDATES = 1_000 const DEFAULT_MAX_PENDING_UPDATE_BYTES = 4 * 1024 * 1024 @@ -188,6 +206,42 @@ function safeStringify(value: unknown, maximum: number): string | undefined { } } +function boundedRetainedValue( + value: unknown, + maximum: number +): unknown { + if (value === undefined) { + return undefined + } + if (typeof value === 'string') { + return value.length > maximum + ? `${value.slice(0, maximum)}…` + : value + } + try { + const serialized = JSON.stringify(value) + if (serialized === undefined) { + return undefined + } + if (serialized.length > maximum) { + return `${serialized.slice(0, maximum)}…` + } + return JSON.parse(serialized) as unknown + } catch { + return undefined + } +} + +function boundedRetainedToolInput( + value: unknown, + maximum: number +): unknown { + return ( + parseOpenCodeSubagentInput(value) ?? + boundedRetainedValue(value, maximum) + ) +} + class RemoteOperationTimeoutError extends Error { constructor(readonly operation: string) { super(`远端 Runtime ${operation}超时`) @@ -459,9 +513,8 @@ export class AcpRemoteRuntime implements AgentRuntime { DEFAULT_OPERATION_TIMEOUT_MS } - private get promptTimeoutMs(): number { - return this.options.promptTimeoutMs ?? - DEFAULT_PROMPT_TIMEOUT_MS + private get promptTimeoutMs(): number | undefined { + return this.options.promptTimeoutMs } private async awaitOperation( @@ -982,9 +1035,10 @@ export class AcpRemoteRuntime implements AgentRuntime { modelBridge?: RemoteModelBridgeSession ): RemotePromptOperationPreparation { const workMode = request.workMode === 'execute' ? 'execute' : 'ask' - const deadlineAt = new Date( - Date.now() + this.promptTimeoutMs - ).toISOString() + const deadlineAt = + this.promptTimeoutMs === undefined + ? UNBOUNDED_REMOTE_PROMPT_DEADLINE + : new Date(Date.now() + this.promptTimeoutMs).toISOString() const budget = { // The Agent applies this to the complete ACP stdin stream, not only // user content. The signed Runtime manifest supplies the effective @@ -1771,6 +1825,21 @@ export class AcpRemoteRuntime implements AgentRuntime { (event.input?.length ?? 0) + (event.output?.length ?? 0) + (event.error?.length ?? 0) + } else if (event.type === 'subagent') { + const fieldMaximum = Math.max(1, Math.floor(maximum / 2)) + const bounded = (value: string | undefined): string | undefined => + value && value.length > fieldMaximum + ? `${value.slice(0, fieldMaximum)}…` + : value + event = { + ...event, + output: bounded(event.output), + error: bounded(event.error) + } + characters = + (event.reason?.length ?? 0) + + (event.output?.length ?? 0) + + (event.error?.length ?? 0) } else if (event.type === 'status') { if (event.message.length > maximum) { event = { @@ -1814,22 +1883,87 @@ export class AcpRemoteRuntime implements AgentRuntime { update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update' ) { + const callId = update.toolCallId.slice(0, 256) + const previous = prompt.toolCalls.get(callId) + if ( + !previous && + prompt.toolCalls.size >= + maximumConversationToolActivities + ) { + throw new Error( + '远端 Runtime 单次运行的工具调用超过 100 个' + ) + } + const retainedMaximum = + this.options.maxEventCharacters ?? + DEFAULT_MAX_EVENT_CHARACTERS + const toolCall = { + title: + update.title === null + ? undefined + : update.title?.slice(0, 200) ?? previous?.title, + kind: + update.kind === null + ? undefined + : update.kind?.slice(0, 200) ?? previous?.kind, + status: + update.status === null + ? undefined + : update.status ?? previous?.status, + rawInput: + update.rawInput === undefined + ? previous?.rawInput + : boundedRetainedToolInput( + update.rawInput, + retainedMaximum + ), + rawOutput: + update.rawOutput === undefined + ? previous?.rawOutput + : boundedRetainedValue( + update.rawOutput, + retainedMaximum + ) + } + if (previous && isDeepStrictEqual(previous, toolCall)) { + return undefined + } + prompt.toolCalls.set(callId, toolCall) const name = ( - update.title ?? - update.kind ?? + toolCall.title ?? + toolCall.kind ?? '远端 Runtime 工具' ).slice(0, 200) - const input = safeStringify(update.rawInput, 100_000) - const output = safeStringify(update.rawOutput, 100_000) + const subagent = + this.options.runtimeId === 'opencode' + ? toOpenCodeSubagentEvent({ + requestId: prompt.requestId, + callId, + state: toolCall.status ?? 'pending', + input: toolCall.rawInput, + output: toolCall.rawOutput + }) + : undefined + if (subagent) { + return subagent + } + const input = safeStringify( + toolCall.rawInput, + retainedMaximum + ) + const output = safeStringify( + toolCall.rawOutput, + retainedMaximum + ) return { requestId: prompt.requestId, type: 'tool', - callId: update.toolCallId.slice(0, 256), + callId, name, state: - update.status === 'in_progress' + toolCall.status === 'in_progress' ? 'running' - : update.status ?? 'pending', + : toolCall.status ?? 'pending', summary: `远端 Runtime 工具:${name}`, ...(input ? { input } : {}), ...(output ? { output } : {}) @@ -1925,6 +2059,7 @@ export class AcpRemoteRuntime implements AgentRuntime { pendingUpdateBytes: 0, inboundPaused: false, outputCharacters: 0, + toolCalls: new Map(), open: true, completed: false, context @@ -1993,10 +2128,12 @@ export class AcpRemoteRuntime implements AgentRuntime { prompt.wake?.() prompt.wake = undefined }) - promptTimeout = setTimeout( - () => interrupt(new RemoteOperationTimeoutError('执行请求')), - this.promptTimeoutMs - ) + if (this.promptTimeoutMs !== undefined) { + promptTimeout = setTimeout( + () => interrupt(new RemoteOperationTimeoutError('执行请求')), + this.promptTimeoutMs + ) + } yield { requestId: request.requestId, type: 'status', diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index 1008975a..97cdcc82 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -357,6 +357,9 @@ describe('ContinueHostAdapter', () => { }) it('launches the prepared host through the injected launcher', async () => { + let now = Date.now() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + environmentRestorations.push(() => nowSpy.mockRestore()) const distribution = await createDistribution() const skillDirectory = join( distribution.cacheRoot, @@ -445,11 +448,14 @@ describe('ContinueHostAdapter', () => { const url = String(input) if (url.endsWith('/state')) { stateRequests += 1 + if (stateRequests === 2) { + now += 25 * 60 * 60_000 + } return new Response( JSON.stringify({ session: { history: - stateRequests === 1 + stateRequests <= 2 ? [] : [ { @@ -460,7 +466,7 @@ describe('ContinueHostAdapter', () => { } ], usage: - stateRequests === 1 + stateRequests <= 2 ? { promptTokens: 20, completionTokens: 10, @@ -478,7 +484,7 @@ describe('ContinueHostAdapter', () => { } } }, - isProcessing: false, + isProcessing: stateRequests === 2, messageQueueLength: 0, pendingPermission: null }) @@ -605,6 +611,90 @@ describe('ContinueHostAdapter', () => { expect(existsSync(generatedConfigPath)).toBe(false) }) + it('bounds each host control request without bounding the full run', async () => { + const distribution = await createDistribution() + let killed = false + const launchHost: ContinueHostLauncher = () => ({ + exitCode: null, + get killed() { + return killed + }, + stderr: null, + once: () => undefined, + kill: () => { + killed = true + return true + } + }) + vi.stubGlobal( + 'fetch', + vi.fn( + async ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + if (String(input).endsWith('/state')) { + return Response.json({ + session: { history: [] }, + isProcessing: false, + messageQueueLength: 0, + pendingPermission: null + }) + } + if (String(input).endsWith('/message')) { + return await new Promise((_resolve, reject) => { + const rejectOnAbort = (): void => + reject(init?.signal?.reason) + init?.signal?.addEventListener( + 'abort', + rejectOnAbort, + { once: true } + ) + if (init?.signal?.aborted) { + rejectOnAbort() + } + }) + } + return Response.json({}) + } + ) + ) + const adapter = new ContinueHostAdapter( + { + binaryPath: distribution.entryPath, + configPath: '', + workspace: process.cwd(), + cacheRoot: distribution.cacheRoot, + trustedBundleHashes: [distribution.sourceHash], + launchHost, + modelProfile: { + id: '00000000-0000-4000-8000-000000000012', + name: '独立模型', + baseUrl: 'https://model.example', + modelName: 'private-model', + protocol: 'anthropic-messages', + authentication: 'api-key', + apiKey: 'private-key' + } + }, + { + controlRequestTimeoutMs: 5, + terminateProcessTree: vi.fn(async (child) => { + child.kill() + }) + } + ) + + await expect( + adapter.run( + 'hello', + new AbortController().signal, + async () => 'deny' + ) + ).rejects.toThrow('Continue 宿主请求超时(/message)') + expect(killed).toBe(true) + }) + it('injects scoped knowledge into a temporary copy of a JSONC config', async () => { const distribution = await createDistribution() const configPath = join( diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index 533dd129..3d43e19c 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -26,6 +26,9 @@ import type { RuntimeNativeSnapshot, RuntimeSettings } from '../../shared/contracts' +import { + maximumConversationToolActivities +} from '../../shared/assistant-contracts' import { continueConfigurationPresetSchema, runtimeNativeInventoryLimits, @@ -66,8 +69,8 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts const maximumStreamEvents = 5_000 const maximumStreamEventBytes = 2 * 1024 * 1024 -const maximumToolCalls = 100 -const maximumExecutionMilliseconds = 10 * 60_000 +const maximumToolCalls = maximumConversationToolActivities +const defaultControlRequestTimeoutMs = 30_000 const knowledgeMcpName = 'goodbuddy-knowledge' const customMcpName = 'goodbuddy-custom-mcp' export const continueConfigurationRequiredMessage = @@ -246,6 +249,7 @@ export type ContinueHostAdapterDependencies = { maximumStreamEvents: number maximumStreamEventBytes: number maximumToolCalls: number + controlRequestTimeoutMs: number } export type ContinueHostRunOptions = { @@ -830,6 +834,7 @@ export class ContinueHostAdapter { maximumStreamEvents, maximumStreamEventBytes, maximumToolCalls, + controlRequestTimeoutMs: defaultControlRequestTimeoutMs, ...dependencies } } @@ -1140,16 +1145,32 @@ export class ContinueHostAdapter { path: string, init: RequestInit = {} ): Promise { - const response = await fetch(`${origin}${path}`, { - ...init, - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - ...init.headers - }, - redirect: 'error', - signal: init.signal - }) + const timeoutSignal = AbortSignal.timeout( + Math.max(1, this.dependencies.controlRequestTimeoutMs) + ) + const requestSignal = init.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal + let response: Response + try { + response = await fetch(`${origin}${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + ...init.headers + }, + redirect: 'error', + signal: requestSignal + }) + } catch (error) { + if (timeoutSignal.aborted && !init.signal?.aborted) { + throw new Error(`Continue 宿主请求超时(${path})`, { + cause: error + }) + } + throw error + } const body = await readBoundedResponseText(response, { maxBytes: maximumStateBytes, tooLargeMessage: 'Continue 宿主响应超过安全大小限制' @@ -1559,7 +1580,6 @@ export class ContinueHostAdapter { let streamEventCount = 0 let streamEventBytes = 0 const observedToolCallIds = new Set() - let executionTimeoutSignal: AbortSignal | undefined try { const initialState = await this.waitForStartup( child, @@ -1569,13 +1589,7 @@ export class ContinueHostAdapter { signal ) const startIndex = initialState.session.history.length - executionTimeoutSignal = AbortSignal.timeout( - maximumExecutionMilliseconds - ) - const executionSignal = AbortSignal.any([ - signal, - executionTimeoutSignal - ]) + const executionSignal = signal const message = runOptions.images && runOptions.images.length > 0 ? [ @@ -1598,9 +1612,8 @@ export class ContinueHostAdapter { signal: executionSignal }) - const expiresAt = Date.now() + maximumExecutionMilliseconds const handledPermissionIds = new Set() - while (Date.now() < expiresAt) { + while (true) { executionSignal.throwIfAborted() if (childFailure) { throw childFailure @@ -1817,20 +1830,15 @@ export class ContinueHostAdapter { } await delay(150, executionSignal) } - throw new Error('Continue 宿主执行超时') } catch (error) { if (error instanceof ContinueHostRunError) { throw error } - const normalizedError = - executionTimeoutSignal?.aborted && !signal.aborted - ? new Error('Continue 宿主执行超时', { cause: error }) - : error throw new ContinueHostRunError( - normalizedError instanceof Error - ? normalizedError.message + error instanceof Error + ? error.message : 'Continue 宿主执行失败', - { cause: normalizedError, tools: observedTools } + { cause: error, tools: observedTools } ) } finally { signal.removeEventListener('abort', abort) diff --git a/src/main/agent/deepseek-harness-runtime.test.ts b/src/main/agent/deepseek-harness-runtime.test.ts index 45fd067d..0a4898a8 100644 --- a/src/main/agent/deepseek-harness-runtime.test.ts +++ b/src/main/agent/deepseek-harness-runtime.test.ts @@ -44,6 +44,7 @@ function setup( nativeTools?: Array> toolsSupported?: boolean promptTimeoutMs?: number + useProductionPromptTimeout?: boolean maxEventCharacters?: number maxRequestOutputCharacters?: number supportsImageInput?: boolean @@ -51,6 +52,7 @@ function setup( initializationTimeoutMs?: number useDefaultInitializationTimeout?: boolean launchDelayMs?: number + prepare?: () => Promise> extensionPackages?: Array<{ id: string entrypoint: string @@ -170,7 +172,7 @@ function setup( } } if (method === 'goodbuddy/session/prepare') { - return { prepared: true } + return options.prepare?.() ?? { prepared: true } } if (method === 'goodbuddy/session/release') { return { released: true } @@ -253,7 +255,9 @@ function setup( initializationTimeoutMs: options.initializationTimeoutMs ?? 100 }), - promptTimeoutMs: options.promptTimeoutMs ?? 100, + ...(options.useProductionPromptTimeout + ? {} + : { promptTimeoutMs: options.promptTimeoutMs ?? 100 }), shutdownTimeoutMs: 10, maxStderrBytes: 16, maxEventCharacters: options.maxEventCharacters, @@ -1347,6 +1351,62 @@ describe('DeepSeekHarnessRuntime', () => { await harness.runtime.dispose() }) + it('does not submit a prompt after cancellation during preparation', async () => { + const prepare = deferred>() + const harness = setup({ prepare: () => prepare.promise }) + const controller = new AbortController() + const running = collect( + harness.runtime.run( + request('cancel-during-prepare'), + controller.signal + ) + ) + await vi.waitFor(() => + expect( + harness.requests.some( + ({ method }) => method === 'goodbuddy/session/prepare' + ) + ).toBe(true) + ) + + controller.abort(new Error('cancelled during preparation')) + prepare.resolve({ prepared: true }) + + await expect(running).rejects.toThrow( + 'cancelled during preparation' + ) + expect(harness.agent.prompt).not.toHaveBeenCalled() + expect(harness.notifications).toContainEqual({ + method: 'session/cancel', + params: { sessionId: 'session-1' } + }) + await harness.runtime.dispose() + }) + + it('bounds cancellation when the harness prompt never settles', async () => { + const harness = setup({ useProductionPromptTimeout: true }) + const controller = new AbortController() + const running = collect( + harness.runtime.run( + request('uncooperative-cancel'), + controller.signal + ) + ) + await vi.waitFor(() => + expect(harness.promptGates).toHaveLength(1) + ) + + controller.abort(new Error('cancelled by user')) + + await expect(running).rejects.toThrow('cancelled by user') + expect(harness.notifications).toContainEqual({ + method: 'session/cancel', + params: { sessionId: 'session-1' } + }) + expect(harness.child.terminate).toHaveBeenCalled() + await harness.runtime.dispose() + }) + it('fails on bounded stderr overflow without exposing stderr text', async () => { const harness = setup() const running = collect( @@ -1420,4 +1480,32 @@ describe('DeepSeekHarnessRuntime', () => { }) await expect(harness.runtime.dispose()).resolves.toBeUndefined() }) + + it('does not impose a default wall-clock deadline on prompts', async () => { + vi.useFakeTimers() + try { + const harness = setup({ useProductionPromptTimeout: true }) + const running = collect( + harness.runtime.run( + request('long-running'), + new AbortController().signal + ) + ) + await vi.waitFor(() => + expect(harness.promptGates).toHaveLength(1) + ) + await vi.advanceTimersByTimeAsync(25 * 60 * 60_000) + expect(harness.notifications).not.toContainEqual({ + method: 'session/cancel', + params: { sessionId: 'session-1' } + }) + harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) + await expect(running).resolves.toEqual([ + expect.objectContaining({ type: 'status' }), + expect.objectContaining({ type: 'done' }) + ]) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index bc2ec84c..15b15d9d 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -38,7 +38,6 @@ import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protoco const ACP_PACKAGE_NAME = '@agentclientprotocol/sdk' const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000 -const DEFAULT_PROMPT_TIMEOUT_MS = 10 * 60_000 const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000 const DEFAULT_MAX_STDERR_BYTES = 64 * 1024 const DEFAULT_MAX_EVENT_CHARACTERS = 64 * 1024 @@ -486,8 +485,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { ) } - private get promptTimeoutMs(): number { - return this.options.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS + private get promptTimeoutMs(): number | undefined { + return this.options.promptTimeoutMs } private get shutdownTimeoutMs(): number { @@ -1435,6 +1434,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } else { toolNames.delete(callId) } + const input = safeStringify(update.rawInput) + const output = safeStringify(update.rawOutput) return { requestId, type: 'tool', @@ -1442,12 +1443,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { name, state, summary: `DeepSeek Harness 工具:${name}`, - ...(safeStringify(update.rawInput) - ? { input: safeStringify(update.rawInput) } - : {}), - ...(safeStringify(update.rawOutput) - ? { output: safeStringify(update.rawOutput) } - : {}) + ...(input ? { input } : {}), + ...(output ? { output } : {}) } } return undefined @@ -1505,8 +1502,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { | { stopReason?: string } | undefined let promptError: unknown - const promptController = new AbortController() const cancel = (): void => { + run!.closed = true + run!.wake?.() + run!.wake = undefined void state!.agent .cancel({ sessionId: sessionId! @@ -1514,33 +1513,47 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { .catch(() => undefined) } signal.addEventListener('abort', cancel, { once: true }) - await withTimeout( - state.agent.extMethod(GOODBUDDY_PREPARE, { - sessionId, - requestId: request.requestId, - mode: - request.workMode === 'execute' ? 'execute' : 'ask' - }), - this.initializationTimeoutMs, - '请求准备' - ) - const prompt = withTimeout( - state.agent.prompt({ - sessionId, - prompt: [ - { - type: 'text', - text: flattenPrompt(request) - }, - ...(request.images ?? []).map((image) => ({ - type: 'image' as const, - data: image.data, - mimeType: image.mediaType - })) - ] - }), - this.promptTimeoutMs, - '请求' + if (signal.aborted) { + cancel() + } + try { + await withTimeout( + state.agent.extMethod(GOODBUDDY_PREPARE, { + sessionId, + requestId: request.requestId, + mode: + request.workMode === 'execute' ? 'execute' : 'ask' + }), + this.initializationTimeoutMs, + '请求准备' + ) + signal.throwIfAborted() + } catch (error) { + signal.removeEventListener('abort', cancel) + throw error + } + const promptOperation = state.agent.prompt({ + sessionId, + prompt: [ + { + type: 'text', + text: flattenPrompt(request) + }, + ...(request.images ?? []).map((image) => ({ + type: 'image' as const, + data: image.data, + mimeType: image.mediaType + })) + ] + }) + const prompt = ( + this.promptTimeoutMs === undefined + ? promptOperation + : withTimeout( + promptOperation, + this.promptTimeoutMs, + '请求' + ) ) .then((value) => { response = value @@ -1555,9 +1568,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { }) try { while (!completed || run.updates.length > 0) { + signal.throwIfAborted() if (this.fatalError) { throw this.fatalError } + if (run.closed && !completed) { + throw new Error( + 'DeepSeek Harness 请求在完成前已关闭' + ) + } if (run.updates.length === 0) { await this.waitForUpdate(run) continue @@ -1588,11 +1607,21 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { sessionId } } finally { - promptController.abort( - new Error('DeepSeek Harness 流式消费已结束') - ) + if (!completed) { + cancel() + } signal.removeEventListener('abort', cancel) - await prompt + if (completed) { + await prompt + } else { + await withTimeout( + prompt, + this.shutdownTimeoutMs, + '取消请求' + ).catch(async () => { + await this.terminate(state!.child) + }) + } } } finally { signal.removeEventListener('abort', abortTools) diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index fce3998b..2120dcfd 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -58,10 +58,6 @@ function fakeClient() { } as unknown as ReturnType } -function stdoutOf(child: SpawnedProcess): PassThrough { - return child.stdout as PassThrough -} - function stderrOf(child: SpawnedProcess): PassThrough { return child.stderr as PassThrough } @@ -261,19 +257,16 @@ function embeddedRuntime( client: ReturnType, overrides: Partial< ConstructorParameters[0] - > = {} + > = {}, + dependencyOverrides: Partial = {} ): OpenCodeRuntime { const child = fakeChild() const { deps } = dependencies(child, { + ...dependencyOverrides, createClient: vi.fn( () => client ) as unknown as typeof createOpencodeClient }) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:4010\n' - ) - }, 0) return new OpenCodeRuntime(options(overrides), deps) } @@ -459,11 +452,6 @@ describe('OpenCodeRuntime embedded launcher', () => { ) const child = fakeChild() const { deps, spawnMock } = dependencies(child) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:3012\n' - ) - }, 0) const runtime = new OpenCodeRuntime( options({ skillPackages: [ @@ -537,11 +525,6 @@ describe('OpenCodeRuntime embedded launcher', () => { it('injects an independent model profile without persisting its key', async () => { const child = fakeChild() const { deps, spawnMock } = dependencies(child) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:3011\n' - ) - }, 0) const runtime = new OpenCodeRuntime( options({ modelProfile: { @@ -615,11 +598,6 @@ describe('OpenCodeRuntime embedded launcher', () => { ]) ) Object.assign(process.env, inheritedCredentials) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:3013\n' - ) - }, 0) const runtime = new OpenCodeRuntime( options({ modelProfile: { @@ -744,7 +722,8 @@ describe('OpenCodeRuntime embedded launcher', () => { authentication: 'api-key', apiKey: 'local-probe-key' } - }) + }), + { startupTimeoutMs: 20_000 } ) const controller = new AbortController() const timeout = setTimeout( @@ -822,11 +801,6 @@ describe('OpenCodeRuntime embedded launcher', () => { }) => { const child = fakeChild() const { deps, spawnMock } = dependencies(child) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:3012\n' - ) - }, 0) const runtime = new OpenCodeRuntime( options({ modelProfile: { @@ -913,11 +887,6 @@ describe('OpenCodeRuntime embedded launcher', () => { process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'https://telemetry.invalid' try { - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:3010\n' - ) - }, 0) const runtime = new OpenCodeRuntime(options(), deps) await expect(runtime.getStatus()).resolves.toMatchObject({ available: true @@ -1055,7 +1024,9 @@ describe('OpenCodeRuntime embedded launcher', () => { it('terminates startup when the request is aborted', async () => { const child = fakeChild() - const { deps, spawnMock } = dependencies(child) + const { deps, spawnMock } = dependencies(child, { + checkServerHealth: vi.fn().mockResolvedValue(false) + }) const runtime = new OpenCodeRuntime(options(), deps) const controller = new AbortController() const stream = runtime.run( @@ -1075,6 +1046,25 @@ describe('OpenCodeRuntime embedded launcher', () => { expect(child.kill).toHaveBeenCalledWith('SIGTERM') }) + it('bounds session setup without imposing a full-run deadline', async () => { + const harness = runClient([]) + ;( + harness.session.create as unknown as ReturnType + ).mockImplementation( + () => new Promise(() => undefined) + ) + const runtime = embeddedRuntime( + harness.client, + {}, + { controlRequestTimeoutMs: 5 } + ) + + await expect(collectRun(runtime)).rejects.toThrow( + 'OpenCode 创建会话超时' + ) + await runtime.dispose() + }) + it('keeps external baseUrl mode free of binary detection and spawning', async () => { const child = fakeChild() const { deps, spawnMock, detectBinary, createClient } = dependencies(child) @@ -1538,11 +1528,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => { ? questionEvent.value.questionId : '' await runtime.respondToQuestion(questionId, [['先写测试']]) - expect(setup.questionReply).toHaveBeenCalledWith({ - requestID: 'question-1', - directory: process.cwd(), - answers: [['先写测试']] - }) + expect(setup.questionReply).toHaveBeenCalledWith( + { + requestID: 'question-1', + directory: process.cwd(), + answers: [['先写测试']] + }, + { signal: expect.any(AbortSignal) } + ) await expect(stream.next()).resolves.toMatchObject({ value: { type: 'done' } }) @@ -1631,20 +1624,212 @@ describe('OpenCodeRuntime embedded permission mediation', () => { runtime.respondToQuestion(secondId, [['second answer']]) ]) expect(setup.questionReply).toHaveBeenCalledTimes(2) - expect(setup.questionReply).toHaveBeenNthCalledWith(1, { - requestID: 'shared-question', - directory: process.cwd(), - answers: [['first answer']] - }) - expect(setup.questionReply).toHaveBeenNthCalledWith(2, { - requestID: 'shared-question', - directory: process.cwd(), - answers: [['second answer']] - }) + expect(setup.questionReply).toHaveBeenNthCalledWith( + 1, + { + requestID: 'shared-question', + directory: process.cwd(), + answers: [['first answer']] + }, + { signal: expect.any(AbortSignal) } + ) + expect(setup.questionReply).toHaveBeenNthCalledWith( + 2, + { + requestID: 'shared-question', + directory: process.cwd(), + answers: [['second answer']] + }, + { signal: expect.any(AbortSignal) } + ) await Promise.all([first.next(), second.next()]) await runtime.dispose() }) + it('emits native Task calls as subagents instead of generic tools', async () => { + const taskInput = { + subagent_type: 'explorer', + description: 'Review application architecture', + prompt: 'Inspect the complete source tree.' + } + const setup = runClient([ + { + id: 'task-running', + type: 'message.part.updated', + properties: { + sessionID: 'session-1', + part: { + id: 'part-task-1', + callID: 'call-task-1', + type: 'tool', + tool: 'task', + state: { + status: 'running', + input: taskInput, + time: { start: 1 } + } + } + } + }, + { + id: 'task-completed', + type: 'message.part.updated', + properties: { + sessionID: 'session-1', + part: { + id: 'part-task-1', + callID: 'call-task-1', + type: 'tool', + tool: 'task', + state: { + status: 'completed', + input: taskInput, + output: 'Architecture review complete.', + title: 'Review application architecture', + metadata: {}, + time: { start: 1, end: 2 } + } + } + } + }, + { + id: 'idle', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + ]) + const runtime = embeddedRuntime(setup.client) + + const events = await collectRun(runtime) + const subagents = events.filter( + (event) => event.type === 'subagent' + ) + + expect(subagents).toHaveLength(2) + expect(subagents[0]).toMatchObject({ + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'call-task-1', + state: 'running', + reason: 'Review application architecture' + }) + expect(subagents[1]).toMatchObject({ + childTaskId: + subagents[0]?.type === 'subagent' + ? subagents[0].childTaskId + : undefined, + state: 'completed', + output: 'Architecture review complete.' + }) + expect(events.some((event) => event.type === 'tool')).toBe(false) + await runtime.dispose() + }) + + it('keeps non-Task tools with Task-shaped input as generic tools', async () => { + const setup = runClient([ + { + id: 'custom-tool-completed', + type: 'message.part.updated', + properties: { + sessionID: 'session-1', + part: { + id: 'part-custom-1', + callID: 'call-custom-1', + type: 'tool', + tool: 'custom_delegate', + state: { + status: 'completed', + input: { + subagent_type: 'explorer', + prompt: 'Inspect the complete source tree.' + }, + output: 'Custom tool result.', + title: 'Custom delegate', + metadata: {}, + time: { start: 1, end: 2 } + } + } + } + }, + { + id: 'idle', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + ]) + const runtime = embeddedRuntime(setup.client) + + const events = await collectRun(runtime) + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'tool', + callId: 'call-custom-1', + name: 'custom_delegate', + state: 'completed' + }) + ) + expect(events.some((event) => event.type === 'subagent')).toBe(false) + await runtime.dispose() + }) + + it('does not impose a default wall-clock deadline on OpenCode runs', async () => { + vi.useFakeTimers() + try { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const setup = runClient([]) + ;( + setup.event.subscribe as unknown as ReturnType + ).mockResolvedValue({ + stream: (async function* () { + await gate + yield { + id: 'idle', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + })() + }) + const runtime = new OpenCodeRuntime( + options({ + baseUrl: 'http://127.0.0.1:4096', + embedded: false + }), + dependencies(fakeChild(), { + createClient: vi.fn( + () => setup.client + ) as unknown as typeof createOpencodeClient + }).deps + ) + const stream = runtime.run( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + prompt: 'work for more than a day', + workMode: 'execute' + }, + new AbortController().signal + ) + + await expect(stream.next()).resolves.toMatchObject({ + value: { type: 'status' } + }) + const pending = stream.next() + await vi.advanceTimersByTimeAsync(25 * 60 * 60_000) + expect(setup.session.abort).not.toHaveBeenCalled() + release() + await expect(pending).resolves.toMatchObject({ + value: { type: 'done' } + }) + await runtime.dispose() + } finally { + vi.useRealTimers() + } + }) + it('aborts an OpenCode run at its total execution deadline', async () => { const setup = runClient([]) ;( @@ -1722,11 +1907,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => { executionTimeoutMs: 20 }).deps ) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:4010\n' - ) - }, 0) const stream = runtime.run( { requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', @@ -1903,11 +2083,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => { () => setup.client ) as unknown as typeof createOpencodeClient }) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:4010\n' - ) - }, 0) const runtime = new OpenCodeRuntime( options({ knowledgeGateway: gateway }), deps @@ -2004,11 +2179,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => { () => setup.client ) as unknown as typeof createOpencodeClient }) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:4010\n' - ) - }, 0) const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { @@ -2107,11 +2277,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => { () => setup.client ) as unknown as typeof createOpencodeClient }) - setTimeout(() => { - stdoutOf(child).write( - 'opencode server listening on http://127.0.0.1:4010\n' - ) - }, 0) const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { @@ -2357,11 +2522,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => { { signal: expect.any(AbortSignal) } ) expect(permissionReply).toHaveBeenCalledOnce() - expect(permissionReply).toHaveBeenCalledWith({ - requestID: 'permission-1', - directory: process.cwd(), - reply: 'once' - }) + expect(permissionReply).toHaveBeenCalledWith( + { + requestID: 'permission-1', + directory: process.cwd(), + reply: 'once' + }, + { signal: expect.any(AbortSignal) } + ) expect(events).toContainEqual( expect.objectContaining({ type: 'reasoning', @@ -2453,14 +2621,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => { requestID: 'permission-1', directory: process.cwd(), reply: 'once' - } + }, + { signal: expect.any(AbortSignal) } ], [ { requestID: 'permission-2', directory: process.cwd(), reply: 'once' - } + }, + { signal: expect.any(AbortSignal) } ] ]) await runtime.dispose() @@ -2626,10 +2796,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await collectRun(runtime) await runtime.releaseConversation('conversation-1') - expect(session.delete).toHaveBeenCalledWith({ - sessionID: 'session-1', - directory: process.cwd() - }) + expect(session.delete).toHaveBeenCalledWith( + { + sessionID: 'session-1', + directory: process.cwd() + }, + { signal: expect.any(AbortSignal) } + ) await runtime.dispose() }) @@ -2649,11 +2822,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await collectRun(runtime, 'execute') expect(permissionReply).toHaveBeenCalledOnce() - expect(permissionReply).toHaveBeenCalledWith({ - requestID: 'permission-1', - directory: process.cwd(), - reply: 'reject' - }) + expect(permissionReply).toHaveBeenCalledWith( + { + requestID: 'permission-1', + directory: process.cwd(), + reply: 'reject' + }, + { signal: expect.any(AbortSignal) } + ) await runtime.dispose() }) diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 32b04deb..b3e646b3 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -19,8 +19,12 @@ import { join, resolve } from 'node:path' import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import type { AgentQuestionAnswer, - AgentRuntimeStatus + AgentRuntimeStatus, + SubagentEvent } from '../../shared/contracts' +import { + maximumConversationToolActivities +} from '../../shared/assistant-contracts' import { boundedRuntimeIdentifierSchema, runtimeNativeInventoryLimits, @@ -60,18 +64,19 @@ import { requestProcessTreeTermination, waitForProcessExit } from './child-process-termination' +import { toOpenCodeSubagentEvent } from './opencode-subagent' const STARTUP_TIMEOUT_MS = 10_000 const STARTUP_POLL_INTERVAL_MS = 100 const STARTUP_PROBE_TIMEOUT_MS = 500 +const CONTROL_REQUEST_TIMEOUT_MS = 30_000 const MAX_PERMISSION_NAME_LENGTH = 128 const MAX_PERMISSION_PATTERNS = 32 const MAX_PERMISSION_PATTERN_LENGTH = 1_024 const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024 const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024 -const MAX_TOOL_CALLS_PER_RUN = 100 +const MAX_TOOL_CALLS_PER_RUN = maximumConversationToolActivities const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024 -const MAX_EXECUTION_MILLISECONDS = 10 * 60_000 const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024 const MAX_QUESTIONS_PER_REQUEST = 4 const MAX_QUESTION_OPTIONS = 20 @@ -466,7 +471,8 @@ export type OpenCodeRuntimeDependencies = { ) => Promise platform: NodeJS.Platform startupTimeoutMs: number - executionTimeoutMs: number + controlRequestTimeoutMs: number + executionTimeoutMs?: number } export type OpenCodeRuntimeOptions = { @@ -723,7 +729,7 @@ export class OpenCodeRuntime implements AgentRuntime { checkServerHealth: defaultCheckServerHealth, platform: process.platform, startupTimeoutMs: STARTUP_TIMEOUT_MS, - executionTimeoutMs: MAX_EXECUTION_MILLISECONDS, + controlRequestTimeoutMs: CONTROL_REQUEST_TIMEOUT_MS, ...dependencies } } @@ -1115,6 +1121,51 @@ export class OpenCodeRuntime implements AgentRuntime { } } + private async controlRequest( + label: string, + operation: (signal: AbortSignal) => Promise, + callerSignal?: AbortSignal, + onAbortedResult?: (value: T) => void | Promise + ): Promise { + const timeoutController = new AbortController() + const timeout = setTimeout(() => { + timeoutController.abort( + new Error(`OpenCode ${label}超时`) + ) + }, Math.max(1, this.dependencies.controlRequestTimeoutMs)) + timeout.unref?.() + const controlSignal = callerSignal + ? AbortSignal.any([callerSignal, timeoutController.signal]) + : timeoutController.signal + try { + controlSignal.throwIfAborted() + const request = operation(controlSignal) + if (onAbortedResult) { + void request + .then(async (value) => { + if (controlSignal.aborted) { + await onAbortedResult(value) + } + }) + .catch(() => undefined) + } + return await awaitWithAbort( + request, + controlSignal + ) + } catch (error) { + if ( + timeoutController.signal.aborted && + !callerSignal?.aborted + ) { + throw timeoutController.signal.reason + } + throw error + } finally { + clearTimeout(timeout) + } + } + private async initializeClient( signal?: AbortSignal ): Promise { @@ -1148,9 +1199,14 @@ export class OpenCodeRuntime implements AgentRuntime { async getStatus(): Promise { try { const client = await this.getClient() - const response = await client.session.list({ - directory: this.options.defaultWorkspace - }) + const response = await this.controlRequest( + '连接检查', + (signal) => + client.session.list( + { directory: this.options.defaultWorkspace }, + { signal } + ) + ) if (response.error) { throw new Error('OpenCode Server 返回错误') @@ -1189,15 +1245,17 @@ export class OpenCodeRuntime implements AgentRuntime { hidden: boolean }> > { - const operation = client.app.agents( - { - directory: this.options.defaultWorkspace - }, - signal ? { signal } : undefined + const response = await this.controlRequest( + '读取 Agent 清单', + (controlSignal) => + client.app.agents( + { + directory: this.options.defaultWorkspace + }, + { signal: controlSignal } + ), + signal ) - const response = signal - ? await awaitWithAbort(operation, signal) - : await operation if (response.error || !response.data) { throw new Error('OpenCode Agent 清单不可用') } @@ -1294,10 +1352,17 @@ export class OpenCodeRuntime implements AgentRuntime { if (!controlled) { try { const client = await this.getClient() - const response = await client.session.list({ - directory: this.options.defaultWorkspace, - limit: 1 - }) + const response = await this.controlRequest( + '连接检查', + (signal) => + client.session.list( + { + directory: this.options.defaultWorkspace, + limit: 1 + }, + { signal } + ) + ) if (response.error || !response.data) { throw new Error('connection check failed') } @@ -1337,18 +1402,40 @@ export class OpenCodeRuntime implements AgentRuntime { skillsResult, resourcesResult ] = await Promise.allSettled([ - client.session.list({ - directory, - limit: 1 - }), + this.controlRequest('连接检查', (signal) => + client.session.list( + { + directory, + limit: 1 + }, + { signal } + ) + ), this.discoverAgents(client), - client.tool.ids({ directory }), - client.command.list({ directory }), - client.lsp.status({ directory }), - client.formatter.status({ directory }), - client.mcp.status({ directory }), - client.app.skills({ directory }), - client.experimental.resource.list({ directory }) + this.controlRequest('读取工具清单', (signal) => + client.tool.ids({ directory }, { signal }) + ), + this.controlRequest('读取命令清单', (signal) => + client.command.list({ directory }, { signal }) + ), + this.controlRequest('读取 LSP 状态', (signal) => + client.lsp.status({ directory }, { signal }) + ), + this.controlRequest('读取格式化器状态', (signal) => + client.formatter.status({ directory }, { signal }) + ), + this.controlRequest('读取 MCP 状态', (signal) => + client.mcp.status({ directory }, { signal }) + ), + this.controlRequest('读取技能清单', (signal) => + client.app.skills({ directory }, { signal }) + ), + this.controlRequest('读取资源清单', (signal) => + client.experimental.resource.list( + { directory }, + { signal } + ) + ) ]) if ( availabilityResult.status === 'rejected' || @@ -1640,16 +1727,34 @@ export class OpenCodeRuntime implements AgentRuntime { created: false } } - const creation: Promise = client.session - .create( - { - title: 'GoodBuddy 对话', - directory, - ...(agent ? { agent } : {}), - ...(permission ? { permission } : {}) - }, - { signal } - ) + const creation: Promise = this.controlRequest( + '创建会话', + (controlSignal) => + client.session.create( + { + title: 'GoodBuddy 对话', + directory, + ...(agent ? { agent } : {}), + ...(permission ? { permission } : {}) + }, + { signal: controlSignal } + ), + signal, + async (response) => { + const sessionId = response.data?.id + if (sessionId) { + await client.session + .delete( + { + sessionID: sessionId, + directory + }, + { signal: AbortSignal.timeout(1_000) } + ) + .catch(() => undefined) + } + } + ) .then((response) => { if (!response.data) { throw new Error('OpenCode 会话创建失败') @@ -1698,23 +1803,29 @@ export class OpenCodeRuntime implements AgentRuntime { request: AgentExecutionRequest, signal: AbortSignal ): AsyncGenerator { - const deadline = new AbortController() - const deadlineTimer = setTimeout( - () => - deadline.abort( - new Error( - `OpenCode 执行超过 ${executionDeadlineLabel( - this.dependencies.executionTimeoutMs - )}总时限` + const configuredTimeout = this.dependencies.executionTimeoutMs + const deadline = + configuredTimeout === undefined + ? undefined + : new AbortController() + const deadlineTimer = + configuredTimeout === undefined || deadline === undefined + ? undefined + : setTimeout( + () => + deadline.abort( + new Error( + `OpenCode 执行超过 ${executionDeadlineLabel( + configuredTimeout + )}总时限` + ) + ), + configuredTimeout ) - ), - this.dependencies.executionTimeoutMs - ) - deadlineTimer.unref?.() - const executionSignal = AbortSignal.any([ - signal, - deadline.signal - ]) + deadlineTimer?.unref?.() + const executionSignal = deadline + ? AbortSignal.any([signal, deadline.signal]) + : signal let releaseEmbedded: (() => void) | undefined let releaseConversation: (() => void) | undefined try { @@ -1727,12 +1838,14 @@ export class OpenCodeRuntime implements AgentRuntime { ) yield* this.runUnlocked(request, executionSignal) } catch (error) { - if (deadline.signal.aborted && !signal.aborted) { + if (deadline?.signal.aborted && !signal.aborted) { throw deadline.signal.reason } throw error } finally { - clearTimeout(deadlineTimer) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } releaseConversation?.() releaseEmbedded?.() } @@ -1776,8 +1889,13 @@ export class OpenCodeRuntime implements AgentRuntime { } | undefined if (runtimeControl?.command) { - const commandResponse = await awaitWithAbort( - client.command.list({ directory }, { signal }), + const commandResponse = await this.controlRequest( + '验证原生命令', + (controlSignal) => + client.command.list( + { directory }, + { signal: controlSignal } + ), signal ) if (commandResponse.error || !commandResponse.data) { @@ -1817,18 +1935,22 @@ export class OpenCodeRuntime implements AgentRuntime { this.usesEmbeddedPermissionMediation() && this.options.knowledgeGateway?.getEndpoint() ) { + const knowledgeEndpoint = + this.options.knowledgeGateway.getEndpoint()! knowledgeMcpName = `goodbuddy-data-${createHash('sha256') .update(`${request.conversationId}\0${request.requestId}`) .digest('hex') .slice(0, 20)}` - const added = await awaitWithAbort( - client.mcp.add( + const added = await this.controlRequest( + '连接内置只读工具', + (controlSignal) => + client.mcp.add( { directory, name: knowledgeMcpName, config: { type: 'remote', - url: this.options.knowledgeGateway.getEndpoint()!, + url: knowledgeEndpoint, enabled: true, headers: { Authorization: `Bearer ${request.knowledgeCapabilityToken}` @@ -1836,7 +1958,7 @@ export class OpenCodeRuntime implements AgentRuntime { oauth: false } }, - { signal } + { signal: controlSignal } ), signal ) @@ -1863,6 +1985,8 @@ export class OpenCodeRuntime implements AgentRuntime { this.options.knowledgeGateway?.getEndpoint() && this.options.mcpServers?.length ) { + const customMcpEndpoint = + this.options.knowledgeGateway.getEndpoint()! customMcpToken = this.options.knowledgeGateway.grantCustomMcp( request.requestId, this.options.mcpServers, @@ -1878,14 +2002,16 @@ export class OpenCodeRuntime implements AgentRuntime { .update(`${request.conversationId}\0${request.requestId}`) .digest('hex') .slice(0, 20)}` - const added = await awaitWithAbort( - client.mcp.add( + const added = await this.controlRequest( + '连接自定义 MCP 工具', + (controlSignal) => + client.mcp.add( { directory, name: customMcpName, config: { type: 'remote', - url: this.options.knowledgeGateway.getEndpoint()!, + url: customMcpEndpoint, enabled: true, headers: { Authorization: `Bearer ${customMcpToken}` @@ -1893,7 +2019,7 @@ export class OpenCodeRuntime implements AgentRuntime { oauth: false } }, - { signal } + { signal: controlSignal } ), signal ) @@ -1942,8 +2068,13 @@ export class OpenCodeRuntime implements AgentRuntime { ] let disabledTools: Record | undefined if (request.workMode !== 'execute') { - const tools = await awaitWithAbort( - client.tool.ids({ directory }, { signal }), + const tools = await this.controlRequest( + '读取工具清单', + (controlSignal) => + client.tool.ids( + { directory }, + { signal: controlSignal } + ), signal ) if (tools.error || !tools.data) { @@ -1969,14 +2100,16 @@ export class OpenCodeRuntime implements AgentRuntime { ) const sessionId = session.id if (!session.created) { - const update = await awaitWithAbort( - client.session.update( + const update = await this.controlRequest( + '更新会话权限', + (controlSignal) => + client.session.update( { sessionID: sessionId, directory, permission }, - { signal } + { signal: controlSignal } ), signal ) @@ -1991,8 +2124,13 @@ export class OpenCodeRuntime implements AgentRuntime { message: 'OpenCode 正在处理请求' } - const subscription = await awaitWithAbort( - client.event.subscribe({ directory }, { signal }), + const subscription = await this.controlRequest( + '订阅事件流', + (controlSignal) => + client.event.subscribe( + { directory }, + { signal: controlSignal } + ), signal ) @@ -2012,6 +2150,7 @@ export class OpenCodeRuntime implements AgentRuntime { input?: string output?: string error?: string + subagent?: SubagentEvent } >() const reasoningPartIds = new Set() @@ -2063,30 +2202,39 @@ export class OpenCodeRuntime implements AgentRuntime { }, { signal } ) - : client.session.promptAsync( - { - sessionID: sessionId, - directory, - model: this.options.modelProfile - ? { - providerID: resolveOpenCodeProvider( - this.options.modelProfile - ).id, - modelID: this.options.modelProfile.modelName - } - : undefined, - ...(selectedAgent ? { agent: selectedAgent } : {}), - system: - nativeSkillIds.length > 0 - ? undefined - : this.options.skillInstructions || undefined, - ...(disabledTools ? { tools: disabledTools } : {}), - parts: [ - { type: 'text' as const, text: promptText }, - ...imageParts - ] - }, - { signal } + : this.controlRequest( + '提交请求', + (controlSignal) => + client.session.promptAsync( + { + sessionID: sessionId, + directory, + model: this.options.modelProfile + ? { + providerID: resolveOpenCodeProvider( + this.options.modelProfile + ).id, + modelID: this.options.modelProfile.modelName + } + : undefined, + ...(selectedAgent + ? { agent: selectedAgent } + : {}), + system: + nativeSkillIds.length > 0 + ? undefined + : this.options.skillInstructions || undefined, + ...(disabledTools + ? { tools: disabledTools } + : {}), + parts: [ + { type: 'text' as const, text: promptText }, + ...imageParts + ] + }, + { signal: controlSignal } + ), + signal ) prompt.catch(() => undefined) @@ -2178,23 +2326,39 @@ export class OpenCodeRuntime implements AgentRuntime { typeof part.state.output === 'string' ? part.state.output.slice(0, 16_000) : undefined + const subagent = + toolName.trim().toLowerCase() === 'task' + ? toOpenCodeSubagentEvent({ + requestId: request.requestId, + callId, + state: part.state.status, + input: part.state.input, + output, + error + }) + : undefined toolStates.set(callId, { name: toolName, state, ...(input ? { input } : {}), ...(output ? { output } : {}), - ...(error ? { error } : {}) + ...(error ? { error } : {}), + ...(subagent ? { subagent } : {}) }) - yield { - requestId: request.requestId, - type: 'tool', - callId, - name: toolName, - state, - summary: `OpenCode 工具:${toolName}`, - ...(input ? { input } : {}), - ...(output ? { output } : {}), - ...(error ? { error } : {}) + if (subagent) { + yield subagent + } else { + yield { + requestId: request.requestId, + type: 'tool', + callId, + name: toolName, + state, + summary: `OpenCode 工具:${toolName}`, + ...(input ? { input } : {}), + ...(output ? { output } : {}), + ...(error ? { error } : {}) + } } } } @@ -2309,12 +2473,21 @@ export class OpenCodeRuntime implements AgentRuntime { { cause: error } ) } + const permissionId = properties.id repliedPermissionIds.add(properties.id) - const rejection = await client.permission.reply({ - requestID: properties.id, - directory, - reply: 'reject' - }) + const rejection = await this.controlRequest( + '回复权限请求', + (controlSignal) => + client.permission.reply( + { + requestID: permissionId, + directory, + reply: 'reject' + }, + { signal: controlSignal } + ), + signal + ) if (rejection.error || rejection.data !== true) { throw new Error('OpenCode 权限拒绝回复失败', { cause: error @@ -2355,14 +2528,23 @@ export class OpenCodeRuntime implements AgentRuntime { const allowKnowledge = request.workMode === 'ask' && knowledgeToolIds.includes(permissionRequest.permission) - const response = await client.permission.reply({ - requestID: permissionRequest.id, - directory, - reply: - request.workMode === 'execute' || allowKnowledge - ? 'once' - : 'reject' - }) + const response = await this.controlRequest( + '回复权限请求', + (controlSignal) => + client.permission.reply( + { + requestID: permissionRequest.id, + directory, + reply: + request.workMode === 'execute' || + allowKnowledge + ? 'once' + : 'reject' + }, + { signal: controlSignal } + ), + signal + ) if (response.error || response.data !== true) { throw new Error('OpenCode 权限回复失败') } @@ -2414,6 +2596,9 @@ export class OpenCodeRuntime implements AgentRuntime { ) } for (const [callId, tool] of failedTools) { + if (tool.subagent) { + continue + } yield { requestId: request.requestId, type: 'tool', @@ -2449,16 +2634,30 @@ export class OpenCodeRuntime implements AgentRuntime { abortSession() for (const [callId, tool] of toolStates) { if (tool.state === 'pending' || tool.state === 'running') { - yield { - requestId: request.requestId, - type: 'tool', - callId, - name: tool.name, - state: 'failed', - summary: `OpenCode 工具:${tool.name}`, - ...(tool.input ? { input: tool.input } : {}), - ...(tool.output ? { output: tool.output } : {}), - ...(tool.error ? { error: tool.error } : {}) + if (tool.subagent) { + yield { + ...tool.subagent, + state: signal.aborted ? 'cancelled' : 'failed', + ...(signal.aborted + ? { reason: tool.subagent.reason ?? '父请求已取消' } + : { + error: + tool.subagent.error ?? + 'OpenCode 子 Agent 未返回完成状态' + }) + } + } else { + yield { + requestId: request.requestId, + type: 'tool', + callId, + name: tool.name, + state: 'failed', + summary: `OpenCode 工具:${tool.name}`, + ...(tool.input ? { input: tool.input } : {}), + ...(tool.output ? { output: tool.output } : {}), + ...(tool.error ? { error: tool.error } : {}) + } } } } @@ -2505,16 +2704,30 @@ export class OpenCodeRuntime implements AgentRuntime { } const response = answers ? answers.length === pending.questionCount - ? await pending.client.question.reply({ - requestID: pending.upstreamQuestionId, - directory: pending.directory, - answers - }) + ? await this.controlRequest( + '提交提问回答', + (signal) => + pending.client.question.reply( + { + requestID: pending.upstreamQuestionId, + directory: pending.directory, + answers + }, + { signal } + ) + ) : undefined - : await pending.client.question.reject({ - requestID: pending.upstreamQuestionId, - directory: pending.directory - }) + : await this.controlRequest( + '取消提问', + (signal) => + pending.client.question.reject( + { + requestID: pending.upstreamQuestionId, + directory: pending.directory + }, + { signal } + ) + ) if (!response) { throw new Error('OpenCode 提问回答数量不匹配') } @@ -2536,17 +2749,25 @@ export class OpenCodeRuntime implements AgentRuntime { '外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact' ) } - const deadline = new AbortController() - const deadlineTimer = setTimeout( - () => - deadline.abort(new Error('OpenCode 原生 Compact 执行超时')), - this.dependencies.executionTimeoutMs - ) - deadlineTimer.unref?.() - const executionSignal = AbortSignal.any([ - signal, - deadline.signal - ]) + const configuredTimeout = this.dependencies.executionTimeoutMs + const deadline = + configuredTimeout === undefined + ? undefined + : new AbortController() + const deadlineTimer = + configuredTimeout === undefined || deadline === undefined + ? undefined + : setTimeout( + () => + deadline.abort( + new Error('OpenCode 原生 Compact 执行超时') + ), + configuredTimeout + ) + deadlineTimer?.unref?.() + const executionSignal = deadline + ? AbortSignal.any([signal, deadline.signal]) + : signal let releaseEmbedded: (() => void) | undefined let releaseConversation: (() => void) | undefined try { @@ -2567,9 +2788,14 @@ export class OpenCodeRuntime implements AgentRuntime { } } const client = await this.getClient(executionSignal) - const context = await client.v2.session.context( - { sessionID: sessionId }, - { signal: executionSignal } + const context = await this.controlRequest( + '读取原生上下文', + (controlSignal) => + client.v2.session.context( + { sessionID: sessionId }, + { signal: controlSignal } + ), + executionSignal ) if (context.error || !context.data) { throw new Error('OpenCode 原生上下文不可用,无法执行 Compact') @@ -2602,14 +2828,18 @@ export class OpenCodeRuntime implements AgentRuntime { } executionSignal.throwIfAborted() const subscriptionController = new AbortController() - const subscription = await client.event.subscribe( - { directory: this.options.defaultWorkspace }, - { - signal: AbortSignal.any([ - executionSignal, - subscriptionController.signal - ]) - } + const subscriptionSignal = AbortSignal.any([ + executionSignal, + subscriptionController.signal + ]) + const subscription = await this.controlRequest( + '订阅 Compact 事件流', + (controlSignal) => + client.event.subscribe( + { directory: this.options.defaultWorkspace }, + { signal: controlSignal } + ), + subscriptionSignal ) const usageEvents: RuntimeModelUsageEvent[] = [] const reportedMessageIds = new Set() @@ -2689,12 +2919,14 @@ export class OpenCodeRuntime implements AgentRuntime { await usageCapture.catch(() => undefined) } } catch (error) { - if (deadline.signal.aborted && !signal.aborted) { + if (deadline?.signal.aborted && !signal.aborted) { throw deadline.signal.reason } throw error } finally { - clearTimeout(deadlineTimer) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } releaseConversation?.() releaseEmbedded?.() } @@ -2724,11 +2956,17 @@ export class OpenCodeRuntime implements AgentRuntime { if (!sessionId || !this.client) { return } - await this.client.session - .delete({ - sessionID: sessionId, - directory: this.options.defaultWorkspace - }) + await this.controlRequest( + '释放会话', + (signal) => + this.client!.session.delete( + { + sessionID: sessionId, + directory: this.options.defaultWorkspace + }, + { signal } + ) + ) .catch(() => undefined) } } diff --git a/src/main/agent/opencode-subagent.test.ts b/src/main/agent/opencode-subagent.test.ts new file mode 100644 index 00000000..ac499a39 --- /dev/null +++ b/src/main/agent/opencode-subagent.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { toOpenCodeSubagentEvent } from './opencode-subagent' + +const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' + +describe('OpenCode native subagent events', () => { + it('maps Task tool updates to stable bounded subagent identities', () => { + const running = toOpenCodeSubagentEvent({ + requestId, + callId: 'call-task-1', + state: 'running', + input: { + subagent_type: 'explorer', + description: 'Review application architecture', + prompt: 'Inspect the complete source tree.' + } + }) + const completed = toOpenCodeSubagentEvent({ + requestId, + callId: 'call-task-1', + state: 'completed', + input: { + subagent_type: 'explorer', + description: 'Review application architecture', + prompt: 'Inspect the complete source tree.' + }, + output: 'Architecture review complete.' + }) + + expect(running).toMatchObject({ + type: 'subagent', + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'call-task-1', + state: 'running', + reason: 'Review application architecture' + }) + expect(completed).toMatchObject({ + childTaskId: running?.childTaskId, + expertId: running?.expertId, + state: 'completed', + output: 'Architecture review complete.' + }) + }) + + it('does not classify unrelated tools as subagents', () => { + expect( + toOpenCodeSubagentEvent({ + requestId, + callId: 'call-read-1', + state: 'completed', + input: { filePath: 'README.md' }, + output: 'contents' + }) + ).toBeUndefined() + }) +}) diff --git a/src/main/agent/opencode-subagent.ts b/src/main/agent/opencode-subagent.ts new file mode 100644 index 00000000..131347fc --- /dev/null +++ b/src/main/agent/opencode-subagent.ts @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import type { SubagentEvent } from '../../shared/contracts' +import { boundedToolDetail } from './approval-summary' + +type OpenCodeSubagentState = + | 'pending' + | 'running' + | 'in_progress' + | 'completed' + | 'failed' + | 'error' + +export type OpenCodeSubagentToolUpdate = { + requestId: string + callId: string + state: OpenCodeSubagentState + input: unknown + output?: unknown + error?: unknown +} + +export type OpenCodeSubagentInput = { + subagent_type: string + prompt: string + description?: string + command?: string +} + +function isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) +} + +function boundedText( + value: unknown, + maximum: number +): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const normalized = value.trim() + return normalized ? normalized.slice(0, maximum) : undefined +} + +function boundedDetail( + value: unknown, + maximum: number +): string | undefined { + if (typeof value === 'string') { + return boundedText(value, maximum) + } + if (value === undefined || value === null) { + return undefined + } + return boundedToolDetail(value, maximum) +} + +function deterministicUuid(value: string): string { + const bytes = createHash('sha256').update(value).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) + ].join('-') +} + +export function parseOpenCodeSubagentInput( + value: unknown +): OpenCodeSubagentInput | undefined { + if (!isRecord(value)) { + return undefined + } + const subagentType = boundedText(value.subagent_type, 80) + const prompt = boundedText(value.prompt, 20_000) + if (!subagentType || !prompt) { + return undefined + } + const description = boundedText(value.description, 240) + const command = boundedText(value.command, 240) + return { + subagent_type: subagentType, + prompt, + ...(description ? { description } : {}), + ...(command ? { command } : {}) + } +} + +export function toOpenCodeSubagentEvent( + update: OpenCodeSubagentToolUpdate +): SubagentEvent | undefined { + const input = parseOpenCodeSubagentInput(update.input) + if (!input) { + return undefined + } + const expertName = input.subagent_type + const description = + input.description ?? input.command + const state = + update.state === 'pending' + ? 'queued' + : update.state === 'running' || + update.state === 'in_progress' + ? 'running' + : update.state === 'completed' + ? 'completed' + : 'failed' + const failed = state === 'failed' + const output = failed + ? undefined + : boundedDetail(update.output, 16_000) + const error = failed + ? boundedDetail(update.error ?? update.output, 1_000) + : undefined + + return { + requestId: update.requestId, + type: 'subagent', + childTaskId: deterministicUuid( + `opencode-subagent-call\0${update.requestId}\0${update.callId}` + ), + expertId: deterministicUuid( + `opencode-subagent-type\0${expertName}` + ), + expertName, + routingMode: 'native', + runtimeCallId: update.callId.slice(0, 256), + state, + ...(description ? { reason: description } : {}), + ...(output ? { output } : {}), + ...(error ? { error } : {}) + } +} diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 7a820f49..ece6bf38 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -3308,6 +3308,20 @@ describe('AssistantDatabase', () => { output: '远程输出'.repeat(10_000) } ], + subagents: [ + { + childTaskId: + '00000000-0000-4000-8000-000000000513', + expertId: + '00000000-0000-4000-8000-000000000514', + expertName: 'explorer', + routingMode: 'native' as const, + runtimeCallId: 'call-task-1', + state: 'completed' as const, + reason: 'Review application architecture', + output: 'Architecture review complete.' + } + ], contextCompressions: [ { state: 'completed' as const, @@ -3368,6 +3382,20 @@ describe('AssistantDatabase', () => { output: '远程输出'.repeat(10_000) } ], + subagents: [ + { + childTaskId: + '00000000-0000-4000-8000-000000000513', + expertId: + '00000000-0000-4000-8000-000000000514', + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'call-task-1', + state: 'completed', + reason: 'Review application architecture', + output: 'Architecture review complete.' + } + ], contextCompressions: [ { state: 'completed', @@ -4278,7 +4306,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('durably interrupts active tool metadata during startup recovery', async () => { + it('durably interrupts active tool and subagent metadata during startup recovery', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-conversation-recovery-') ) @@ -4289,6 +4317,8 @@ describe('AssistantDatabase', () => { const messageId = '00000000-0000-4000-8000-000000000218' const cancelledMessageId = '00000000-0000-4000-8000-000000000219' + const subagentMessageId = + '00000000-0000-4000-8000-000000000220' const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.replaceConversations([ @@ -4342,6 +4372,38 @@ describe('AssistantDatabase', () => { error: 'runtime parser detail' } ] + }, + { + id: subagentMessageId, + role: 'assistant', + content: '父请求已完成', + createdAt: 1_775_000_003_000, + state: 'complete', + status: '已完成', + subagents: [ + { + childTaskId: + '00000000-0000-4000-8000-000000000221', + expertId: + '00000000-0000-4000-8000-000000000222', + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'task-1', + state: 'running', + reason: '检查项目' + }, + { + childTaskId: + '00000000-0000-4000-8000-000000000223', + expertId: + '00000000-0000-4000-8000-000000000224', + expertName: 'worker', + routingMode: 'native', + runtimeCallId: 'task-2', + state: 'completed', + output: '已完成' + } + ] } ] } @@ -4370,6 +4432,21 @@ describe('AssistantDatabase', () => { expect.objectContaining({ name: 'failed-tool', state: 'failed' }) ] }) + expect(recovered.listConversations()[0]?.messages[2]).toMatchObject({ + id: subagentMessageId, + state: 'complete', + status: '已完成', + subagents: [ + expect.objectContaining({ + runtimeCallId: 'task-1', + state: 'cancelled' + }), + expect.objectContaining({ + runtimeCallId: 'task-2', + state: 'completed' + }) + ] + }) expect(recovered.listConversations()[0]?.messages[1]).toMatchObject({ id: cancelledMessageId, state: 'error', diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index e1d179d1..b3c71813 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -215,6 +215,7 @@ type MessageMetadata = { contextCompression?: ConversationSnapshot['messages'][number]['contextCompression'] contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions'] tools?: ConversationSnapshot['messages'][number]['tools'] + subagents?: ConversationSnapshot['messages'][number]['subagents'] sources?: string[] sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences'] knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval'] @@ -903,6 +904,16 @@ function interruptActiveTools( ) } +function interruptActiveSubagents( + subagents: MessageMetadata['subagents'] +): MessageMetadata['subagents'] { + return subagents?.map((subagent) => + subagent.state === 'queued' || subagent.state === 'running' + ? { ...subagent, state: 'cancelled' as const } + : subagent + ) +} + function interruptActiveToolBlocks( blocks: MessageMetadata['blocks'] ): MessageMetadata['blocks'] { @@ -1017,6 +1028,9 @@ function toConversationSnapshot( tools: interrupted ? interruptActiveTools(metadata.tools) : metadata.tools, + subagents: interrupted + ? interruptActiveSubagents(metadata.subagents) + : metadata.subagents, sources: metadata.sources, sourceReferences: metadata.sourceReferences, knowledgeRetrieval: metadata.knowledgeRetrieval, @@ -1040,6 +1054,7 @@ function serializeConversationMessageMetadata( contextCompression: message.contextCompression, contextCompressions: message.contextCompressions, tools: message.tools, + subagents: message.subagents, sources: message.sources, sourceReferences: message.sourceReferences, knowledgeRetrieval: message.knowledgeRetrieval, @@ -1417,7 +1432,7 @@ export class AssistantDatabase { const metadata = JSON.parse( message.metadata_json ) as MessageMetadata - const hasActiveTool = Boolean( + const hasActiveActivity = Boolean( metadata.tools?.some( (tool) => tool.state === 'pending' || tool.state === 'running' @@ -1427,9 +1442,17 @@ export class AssistantDatabase { block.type === 'tool' && (block.tool.state === 'pending' || block.tool.state === 'running') + ) || + metadata.subagents?.some( + (subagent) => + subagent.state === 'queued' || + subagent.state === 'running' ) ) - if (message.state !== 'streaming' && !hasActiveTool) { + if ( + message.state !== 'streaming' && + !hasActiveActivity + ) { continue } updateMessage.run( @@ -1441,7 +1464,10 @@ export class AssistantDatabase { ? interruptedMessageStatus : metadata.status, tools: interruptActiveTools(metadata.tools), - blocks: interruptActiveToolBlocks(metadata.blocks) + blocks: interruptActiveToolBlocks(metadata.blocks), + subagents: interruptActiveSubagents( + metadata.subagents + ) }), message.id ) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 470d84e7..831079b0 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -6706,6 +6706,62 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it('does not fail a completed native subagent for its provisional Task tool', async () => { + const runtime = { + capability: 'chat', + requiresToolApproval: false, + supportsToolExecution: true, + getStatus: vi.fn(), + dispose: vi.fn(), + async *run(request: { requestId: string }) { + yield { + requestId: request.requestId, + type: 'tool', + callId: 'call-task-1', + name: 'task', + state: 'running', + summary: 'OpenCode 工具:task' + } + yield { + requestId: request.requestId, + type: 'subagent', + childTaskId: '00000000-0000-4000-8000-000000000601', + expertId: '00000000-0000-4000-8000-000000000701', + expertName: 'explore', + routingMode: 'native', + runtimeCallId: 'call-task-1', + state: 'completed', + output: 'Desktop files inspected.' + } + yield { requestId: request.requestId, type: 'done' } + } + } + const harness = createHarness(runtime) + const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' + + harness.handler?.(trustedEvent(harness.webContents), { + requestId, + conversationId: 'conversation-1', + prompt: 'inspect the desktop', + workMode: 'execute' + }) + + await vi.waitFor(() => + expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( + requestId, + 'completed' + ) + ) + expect( + harness.assistantDatabase.updateTaskStatus + ).not.toHaveBeenCalledWith( + requestId, + 'failed', + expect.any(String) + ) + await harness.dispose() + }) + it.each(['opencode', 'continue'] as const)( 'preserves read-only Ask mode at the %s Runtime boundary', async (runtimeId) => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 849e2ce7..7951f8f3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3488,6 +3488,13 @@ export function registerIpcHandlers( if (publicEvent.type === 'tool') { toolStates.set(publicEvent.callId, publicEvent) } + if ( + publicEvent.type === 'subagent' && + publicEvent.routingMode === 'native' && + publicEvent.runtimeCallId + ) { + toolStates.delete(publicEvent.runtimeCallId) + } if (publicEvent.type === 'question') { pendingAgentQuestions.set(publicEvent.questionId, { requestId: request.requestId, diff --git a/src/main/release-notes-service.test.ts b/src/main/release-notes-service.test.ts index 113a920d..2bddb08d 100644 --- a/src/main/release-notes-service.test.ts +++ b/src/main/release-notes-service.test.ts @@ -88,6 +88,16 @@ describe('ReleaseNotesService', () => { }) }) + it('does not present older notes as a missing current release', async () => { + const { service, settingsStore } = await createService('0.8.19') + await settingsStore.setLastSeenReleaseNotesVersion('0.8.11') + + await expect(service.getPending()).resolves.toEqual({ + currentVersion: '0.8.19', + releases: [] + }) + }) + it('persists acknowledgement and does not show the release again', async () => { const { service, settingsStore } = await createService('0.8.18') diff --git a/src/main/release-notes-service.ts b/src/main/release-notes-service.ts index fdb31cc2..f9c383fa 100644 --- a/src/main/release-notes-service.ts +++ b/src/main/release-notes-service.ts @@ -57,6 +57,12 @@ export class ReleaseNotesService { async getPending(): Promise { const releases = await this.loadReleases() const currentVersion = this.dependencies.currentVersion + if (!releases.some((release) => release.version === currentVersion)) { + return { + currentVersion, + releases: [] + } + } const lastSeenVersion = await this.dependencies.settingsStore.getLastSeenReleaseNotesVersion() const pending = releases.filter((release) => { diff --git a/src/main/release-notes-source.test.ts b/src/main/release-notes-source.test.ts index f9691aaa..bcc5c537 100644 --- a/src/main/release-notes-source.test.ts +++ b/src/main/release-notes-source.test.ts @@ -11,8 +11,15 @@ describe('packaged release notes', () => { 'utf8' ) ) as unknown + const packageSource = JSON.parse( + await readFile(join(process.cwd(), 'package.json'), 'utf8') + ) as { version?: unknown } const parsed = releaseNotesFileSchema.parse(source) + expect(typeof packageSource.version).toBe('string') + expect(parsed.releases).toContainEqual( + expect.objectContaining({ version: packageSource.version }) + ) expect(parsed.releases).toContainEqual( expect.objectContaining({ version: '0.8.19' }) ) diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.ts b/src/main/remote-agent/protocol-remote-runtime-channel.ts index 73361e32..ceecf5c2 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.ts @@ -22,7 +22,8 @@ import { AgentFrameError } from '../../shared/agent-protocol/frame' import { assertRemotePromptAcceptanceMatchesPreparation, remotePromptOperationAcceptanceSchema, - remotePromptOperationPreparationSchema + remotePromptOperationPreparationSchema, + UNBOUNDED_REMOTE_PROMPT_DEADLINE } from '../../shared/remote-agent-contracts' import { modelBridgePolicySchema @@ -66,6 +67,9 @@ const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge' const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1 const DEFAULT_CONTROL_TIMEOUT_MS = 15_000 const DEFAULT_MAXIMUM_PENDING_CONTROL_REQUESTS = 32 +const UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS = Date.parse( + UNBOUNDED_REMOTE_PROMPT_DEADLINE +) const MAXIMUM_PENDING_OUTPUT_WRITES = 256 const MAXIMUM_OUTPUT_WRITE_BYTES = AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes @@ -1038,11 +1042,16 @@ export class ProtocolRemoteRuntimeChannel ].some( (bridge) => bridge.providerDeliveryMayBeUncertain ) + const recoveryDeadlineExpired = + this.#recoveryDeadlineAt !== undefined && + this.#recoveryDeadlineAt !== + UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS && + Date.now() >= this.#recoveryDeadlineAt if ( this.#recoveryDeadlineAt === undefined || this.#recoverySignal === undefined || this.#recoverySignal.aborted || - Date.now() >= this.#recoveryDeadlineAt || + recoveryDeadlineExpired || this.#activeBinarySends > 0 || this.#pendingControlRequests > 0 || providerDeliveryMayBeUncertain || @@ -1090,11 +1099,13 @@ export class ProtocolRemoteRuntimeChannel await this.#flushRecoveryPersistence() const deadlineAt = this.#recoveryDeadlineAt! const callerSignal = this.#recoverySignal! - const remaining = deadlineAt - Date.now() - if (remaining <= 0) { + if ( + deadlineAt !== UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS && + Date.now() >= deadlineAt + ) { throw new ProtocolRemoteRuntimeChannelError( 'Remote Runtime recovery deadline expired', - 'aborted' + 'transport' ) } const recoveryController = new AbortController() @@ -1108,6 +1119,13 @@ export class ProtocolRemoteRuntimeChannel ) } callerSignal.addEventListener('abort', abort, { once: true }) + const recoveryTimeoutMs = + deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS + ? this.#state.controlTimeoutMs + : Math.min( + this.#state.controlTimeoutMs, + Math.max(0, deadlineAt - Date.now()) + ) const timeout = setTimeout(() => { recoveryController.abort( new DOMException( @@ -1115,7 +1133,7 @@ export class ProtocolRemoteRuntimeChannel 'TimeoutError' ) ) - }, remaining) + }, recoveryTimeoutMs) try { await this.#state.connection.reconnect!( recoveryController.signal @@ -1151,10 +1169,7 @@ export class ProtocolRemoteRuntimeChannel channelId: this.#state.openResult.channelId, channelEpoch: this.#state.openResult.channelEpoch }, - Math.min( - this.#state.controlTimeoutMs, - Math.max(1, deadlineAt - Date.now()) - ), + this.#state.controlTimeoutMs, recoveryController.signal ), 'Remote Runtime resume response is invalid' @@ -1205,10 +1220,7 @@ export class ProtocolRemoteRuntimeChannel acknowledgedSequence: resume.cursors.lastMainAckSequence }, - Math.min( - this.#state.controlTimeoutMs, - Math.max(1, deadlineAt - Date.now()) - ), + this.#state.controlTimeoutMs, recoveryController.signal ), 'Remote Runtime replay response is invalid' diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index e550aa4f..0496606a 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -8515,7 +8515,7 @@ describe('App', () => { ) }) - it('shows bounded Subagent states and records child expert activity', async () => { + it('shows Subagent states and records child expert activity', async () => { render() fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { target: { value: '分析复杂问题' } @@ -8568,19 +8568,26 @@ describe('App', () => { } }) - const statusRegion = await screen.findByLabelText('子专家状态') + const statusRegion = await screen.findByLabelText('子代理状态') expect(within(statusRegion).getByText('研究专家')).toBeInTheDocument() expect(within(statusRegion).getByText('等待中')).toBeInTheDocument() expect(within(statusRegion).getByText('代码专家')).toBeInTheDocument() expect(within(statusRegion).getByText('进行中')).toBeInTheDocument() expect(within(statusRegion).getByText('安全专家')).toBeInTheDocument() expect(within(statusRegion).getByText('失败')).toBeInTheDocument() + const subagentCards = within(statusRegion).getAllByRole('group') + fireEvent.click( + within(subagentCards[2]!).getByText('安全专家', { + selector: 'strong' + }) + ) + fireEvent(subagentCards[2]!, new Event('toggle')) expect( within(statusRegion).getByText('无法读取必要上下文') ).toBeInTheDocument() expect( - within(statusRegion).queryByText('第四位专家') - ).not.toBeInTheDocument() + within(statusRegion).getByText('第四位专家') + ).toBeInTheDocument() act(() => { agentListener?.({ @@ -8599,9 +8606,20 @@ describe('App', () => { output: '代码专家的部分结论' }) }) - expect(within(statusRegion).getByText('已完成')).toBeInTheDocument() + expect(within(statusRegion).getAllByText('已完成')).toHaveLength(2) expect(within(statusRegion).getByText('已取消')).toBeInTheDocument() expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument() + for (const [index, expertName] of [ + '研究专家', + '代码专家' + ].entries()) { + fireEvent.click( + within(subagentCards[index]!).getByText(expertName, { + selector: 'strong' + }) + ) + fireEvent(subagentCards[index]!, new Event('toggle')) + } expect( within(statusRegion).getByText('研究专家的独立结论') ).toBeInTheDocument() @@ -8627,7 +8645,7 @@ describe('App', () => { fireEvent.click(screen.getByText('运行记录')) expect(await screen.findAllByText('子专家')).toHaveLength(4) expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0) - expect(screen.getAllByText(/手动指定/u).length).toBeGreaterThan(0) + expect(screen.getAllByText(/指定专家/u).length).toBeGreaterThan(0) expect( screen.getByText(/研究专家的独立结论/u) ).toBeInTheDocument() @@ -8636,6 +8654,144 @@ describe('App', () => { ).toBeInTheDocument() }) + it('replaces an incremental OpenCode Task tool with a native subagent card', async () => { + render() + fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { + target: { value: '检查架构' } + }) + fireEvent.click(screen.getByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + const request = run.mock.calls[0]?.[0] + if (!request) { + throw new Error('Missing request') + } + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'tool', + callId: 'call-task-1', + name: 'Review application architecture', + state: 'running', + summary: '远端 Runtime 工具:Review application architecture' + }) + agentListener?.({ + requestId: request.requestId, + type: 'reasoning', + delta: '等待子代理结果' + }) + }) + expect(await screen.findByText('等待子代理结果')).toBeInTheDocument() + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'subagent', + childTaskId: '00000000-0000-4000-8000-000000000611', + expertId: '00000000-0000-4000-8000-000000000711', + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'call-task-1', + state: 'running', + reason: 'Review application architecture' + }) + agentListener?.({ + requestId: request.requestId, + type: 'text', + delta: '父 Agent 最终回复' + }) + }) + + const region = await screen.findByLabelText('子代理状态') + expect(within(region).getByText('explorer')).toBeInTheDocument() + expect(within(region).getByText('OpenCode 原生')) + .toBeInTheDocument() + expect( + screen.queryByRole('region', { + name: '工具执行,共 1 项' + }) + ).not.toBeInTheDocument() + const assistantArticle = screen + .getByText('父 Agent 最终回复') + .closest('article') + const orderedBlocks = [ + ...assistantArticle!.querySelectorAll('.message-blocks > *') + ].map((element) => element.textContent) + expect(orderedBlocks).toEqual([ + expect.stringContaining('Review application architecture'), + expect.stringContaining('等待子代理结果'), + expect.stringContaining('父 Agent 最终回复') + ]) + fireEvent.click(screen.getByText('运行记录')) + expect(await screen.findAllByText('子专家')).toHaveLength(1) + expect( + screen.queryByText( + '远端 Runtime 工具:Review application architecture' + ) + ).not.toBeInTheDocument() + }) + + it('fails an unterminated subagent when the parent reports done', async () => { + render() + fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { + target: { value: '检查未完成子任务' } + }) + fireEvent.click(screen.getByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + const request = run.mock.calls[0]?.[0] + if (!request) { + throw new Error('Missing request') + } + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'subagent', + childTaskId: '00000000-0000-4000-8000-000000000612', + expertId: '00000000-0000-4000-8000-000000000712', + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'call-task-2', + state: 'running', + reason: '检查未完成路径' + }) + agentListener?.({ + requestId: request.requestId, + type: 'done' + }) + }) + + const region = await screen.findByLabelText('子代理状态') + expect(within(region).getByText('失败')).toBeInTheDocument() + const subagentCard = within(region).getByRole('group') + fireEvent.click( + within(subagentCard).getByText('explorer', { + selector: 'strong' + }) + ) + fireEvent(subagentCard, new Event('toggle')) + expect( + within(region).getByText( + '父请求已结束,但子 Agent 未报告完成状态。' + ) + ).toBeInTheDocument() + await waitFor(() => { + const persistedMessages = vi + .mocked(api.conversations.saveLocal) + .mock.calls.flatMap(([batch]) => + batch.flatMap((conversation) => conversation.messages) + ) + expect( + persistedMessages.some( + (message) => + message.subagents?.[0]?.state === 'failed' && + message.subagents[0].error === + '父请求已结束,但子 Agent 未报告完成状态。' + ) + ).toBe(true) + }) + }) + it('offers once, session, permanent, and deny for a tool call', async () => { render() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index da88537f..f1337aab 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -128,6 +128,7 @@ import { conversationMessageBlocksSchema, conversationSubagentActivitySchema, interactiveWorkModes, + maximumConversationSubagentActivities, normalizeInteractiveWorkMode, projectChannelLabels } from '../../shared/assistant-contracts' @@ -619,9 +620,57 @@ function upsertMessageToolBlock( ] } +function upsertMessageSubagentBlock( + blocks: ConversationMessageBlock[] | undefined, + childTaskId: string, + runtimeCallId?: string +): ConversationMessageBlock[] | undefined { + if (!blocks) { + return blocks + } + if ( + blocks.some( + (block) => + block.type === 'subagent' && + block.childTaskId === childTaskId + ) + ) { + return blocks + } + const provisionalIndex = runtimeCallId + ? blocks.findIndex( + (block) => + block.type === 'tool' && + block.tool.callId === runtimeCallId + ) + : -1 + if (provisionalIndex >= 0) { + return blocks.map((block, index) => + index === provisionalIndex + ? { + id: block.id, + type: 'subagent' as const, + childTaskId + } + : block + ) + } + if (blocks.length >= maxMessageBlocks) { + return blocks + } + return [ + ...blocks, + { + id: crypto.randomUUID(), + type: 'subagent', + childTaskId + } + ] +} + function terminalizeMessageToolBlocks( blocks: ConversationMessageBlock[] | undefined, - state: 'failed' | 'cancelled' + state: 'failed' | 'cancelled' | 'interrupted' ): ConversationMessageBlock[] | undefined { return blocks?.map((block) => block.type === 'tool' && @@ -1212,7 +1261,8 @@ function isConversation(value: unknown): value is Conversation { ))) && (entry.subagents === undefined || (Array.isArray(entry.subagents) && - entry.subagents.length <= 3 && + entry.subagents.length <= + maximumConversationSubagentActivities && entry.subagents.every( (subagent) => conversationSubagentActivitySchema.safeParse(subagent) @@ -4183,7 +4233,10 @@ function App(): React.JSX.Element { conversationId: run.conversationId, parentTaskId: event.requestId, expertId: event.expertId, - routingMode: event.routingMode, + routingMode: + event.routingMode === 'native' + ? undefined + : event.routingMode, title: event.expertName, instructions: event.reason ?? @@ -4205,8 +4258,23 @@ function App(): React.JSX.Element { ? current.map((task) => task.id === event.childTaskId ? childTask : task ) - : [...current, childTask].slice(0, 100) + : [...current, childTask].slice( + 0, + maximumConversationSubagentActivities + ) }) + if (event.runtimeCallId) { + setActivityRecords((current) => + current.filter( + (record) => + !( + record.requestId === event.requestId && + record.kind === 'tool' && + record.callId === event.runtimeCallId + ) + ) + ) + } recordActivity({ conversationId: run.conversationId, requestId: event.requestId, @@ -4216,7 +4284,9 @@ function App(): React.JSX.Element { detail: [ event.routingMode === 'smart' ? tRef.current('chat.subagents.smart') - : tRef.current('chat.subagents.manual'), + : event.routingMode === 'native' + ? tRef.current('chat.subagents.native') + : tRef.current('chat.subagents.manual'), event.reason, event.error ] @@ -4239,6 +4309,7 @@ function App(): React.JSX.Element { expertId: event.expertId, expertName: event.expertName, routingMode: event.routingMode, + runtimeCallId: event.runtimeCallId, state: event.state, reason: event.reason, output: event.output, @@ -4246,10 +4317,27 @@ function App(): React.JSX.Element { } if (index >= 0) { subagents[index] = subagent - } else if (subagents.length < 3) { + } else if ( + subagents.length < + maximumConversationSubagentActivities + ) { subagents.push(subagent) } - return { ...message, subagents } + const runtimeCallId = event.runtimeCallId + return { + ...message, + subagents, + tools: runtimeCallId + ? message.tools?.filter( + (tool) => tool.callId !== runtimeCallId + ) + : message.tools, + blocks: upsertMessageSubagentBlock( + message.blocks, + event.childTaskId, + runtimeCallId + ) + } }) } else if (event.type === 'approval') { recordActivity({ @@ -4342,6 +4430,12 @@ function App(): React.JSX.Element { ? 'cancelled' : 'failed' : 'completed' + const incompleteSubagentError = tRef.current( + 'chat.subagents.incomplete' + ) + const incompleteActivityDetail = tRef.current( + 'chat.status.activityIncomplete' + ) updateRequestActivity( event.requestId, terminalStatus, @@ -4349,25 +4443,27 @@ function App(): React.JSX.Element { ? event.message : tRef.current('chat.status.taskCompleted') ) - if (event.type === 'error') { - setActivityRecords((current) => - current.map((record) => - record.requestId === event.requestId && - record.kind !== 'request' && - (record.status === 'pending' || - record.status === 'running') - ? { - ...record, - status: terminalStatus, - detail: `${record.detail}\n${event.message}`.slice( - 0, - 4_000 - ) - } - : record - ) + setActivityRecords((current) => + current.map((record) => + record.requestId === event.requestId && + record.kind !== 'request' && + (record.status === 'pending' || + record.status === 'running') + ? { + ...record, + status: + event.type === 'done' + ? 'interrupted' + : terminalStatus, + detail: `${record.detail}\n${ + event.type === 'done' + ? incompleteActivityDetail + : event.message + }`.slice(0, 4_000) + } + : record ) - } + ) recordActivity({ conversationId: run.conversationId, requestId: event.requestId, @@ -4382,6 +4478,25 @@ function App(): React.JSX.Element { : tRef.current('chat.status.runtimeCompleted'), status: terminalStatus }) + setAssistantTasks((current) => + current.map((task) => + task.parentTaskId === event.requestId && + (task.status === 'queued' || task.status === 'running') + ? { + ...task, + status: + event.type === 'done' + ? 'failed' + : terminalStatus, + completedAt: new Date().toISOString(), + error: + event.type === 'error' + ? event.message + : incompleteSubagentError + } + : task + ) + ) updateMessage(run.conversationId, run.messageId, (message) => { const representedToolError = event.type === 'error' && @@ -4394,7 +4509,7 @@ function App(): React.JSX.Element { ? event.status === 'cancelled' ? ('cancelled' as const) : ('failed' as const) - : undefined + : ('interrupted' as const) const fallbackError = event.type === 'error' && !representedToolError && @@ -4436,6 +4551,26 @@ function App(): React.JSX.Element { : tool ) : message.tools, + subagents: message.subagents?.map((subagent) => + subagent.state === 'queued' || + subagent.state === 'running' + ? { + ...subagent, + state: + event.type === 'error' + ? event.status === 'cancelled' + ? ('cancelled' as const) + : ('failed' as const) + : ('failed' as const), + ...(event.type === 'error' && + event.status !== 'cancelled' + ? { error: event.message.slice(0, 1_000) } + : event.type === 'done' + ? { error: incompleteSubagentError } + : {}) + } + : subagent + ), blocks: toolTerminalState ? terminalizeMessageToolBlocks( appendMessageContentBlock( diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index db4cc171..871db5af 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -246,26 +246,110 @@ describe('ChatTimeline', () => { /> ) - const region = screen.getByLabelText('子专家状态') + const region = screen.getByLabelText('子代理状态') const summary = screen.getByText('综合结果').parentElement const messageBody = region.parentElement expect(summary?.parentElement).toBe(messageBody) expect( Array.from(messageBody?.children ?? []).indexOf(region) - ).toBeLessThan( + ).toBeGreaterThan( Array.from(messageBody?.children ?? []).indexOf(summary!) ) const cards = within(region).getAllByRole('group') expect(cards).toHaveLength(3) + const experts = ['研究专家', '代码专家', '安全专家'] for (const [index, output] of [ '研究专家的独立结论', '代码专家的独立结论', '安全专家的独立结论' ].entries()) { - expect(within(cards[index]!).getByText(output)).toBeInTheDocument() + expect( + within(cards[index]!).queryByText(output) + ).not.toBeInTheDocument() expect(cards[index]).not.toHaveAttribute('open') - fireEvent.click(within(cards[index]!).getByText(/专家$/u)) + fireEvent.click( + within(cards[index]!).getByText(experts[index]!, { + selector: 'strong' + }) + ) + fireEvent(cards[index]!, new Event('toggle')) expect(cards[index]).toHaveAttribute('open') + expect(within(cards[index]!).getByText(output)).toBeInTheDocument() } }) + + it('labels native OpenCode subagents and shows their task description', () => { + const messages: Message[] = [ + { + id: 'assistant-message', + role: 'assistant', + content: '', + createdAt: 1_775_000_000_000, + state: 'streaming', + blocks: [ + { + id: '00000000-0000-4000-8000-000000000121', + type: 'reasoning', + content: '先分析桌面目录' + }, + { + id: '00000000-0000-4000-8000-000000000122', + type: 'subagent', + childTaskId: + '00000000-0000-4000-8000-000000000111' + }, + { + id: '00000000-0000-4000-8000-000000000123', + type: 'text', + content: '父 Agent 最终回复' + } + ], + subagents: [ + { + childTaskId: '00000000-0000-4000-8000-000000000111', + expertId: '00000000-0000-4000-8000-000000000211', + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'call-task-1', + state: 'running', + reason: 'Review application architecture' + } + ] + } + ] + + render( + + ) + + expect(screen.getByText('子代理')).toBeInTheDocument() + expect(screen.getByText('OpenCode 原生')).toBeInTheDocument() + expect(screen.getByText('任务')).toBeInTheDocument() + expect(screen.getByText('进行中')).toBeInTheDocument() + expect( + screen.getByText('Review application architecture') + ).toBeInTheDocument() + const assistantArticle = screen + .getByText('父 Agent 最终回复') + .closest('article') + const orderedBlocks = [ + ...assistantArticle!.querySelectorAll('.message-blocks > *') + ].map((element) => element.textContent) + expect(orderedBlocks).toEqual([ + expect.stringContaining('先分析桌面目录'), + expect.stringContaining('Review application architecture'), + expect.stringContaining('父 Agent 最终回复') + ]) + }) }) diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 6d991010..e05e2c05 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -1,14 +1,19 @@ import { Bot, + CheckCircle2, + ChevronRight, + Clock3, Download, FileText, Library, ClockFading, + LoaderCircle, ShieldCheck, TerminalSquare, - UserRound + UserRound, + XCircle } from 'lucide-react' -import { memo, useEffect, useRef } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { ApprovalDecision, @@ -78,19 +83,40 @@ export type ImageViewerItem = { type MessageBlockRenderItem = | { kind: 'block' - block: Exclude + block: Extract< + ConversationMessageBlock, + { type: 'text' | 'reasoning' } + > } | { kind: 'tools' id: string tools: ToolActivity[] } + | { + kind: 'subagents' + id: string + childTaskIds: string[] + } function groupMessageBlocks( blocks: ConversationMessageBlock[] ): MessageBlockRenderItem[] { const items: MessageBlockRenderItem[] = [] for (const block of blocks) { + if (block.type === 'subagent') { + const previous = items.at(-1) + if (previous?.kind === 'subagents') { + previous.childTaskIds.push(block.childTaskId) + } else { + items.push({ + kind: 'subagents', + id: block.id, + childTaskIds: [block.childTaskId] + }) + } + continue + } if (block.type !== 'tool') { items.push({ kind: 'block', block }) continue @@ -219,6 +245,90 @@ function ToolExecutionList({ ) } +const SubagentStatusCard = memo(function SubagentStatusCard({ + subagent +}: { + subagent: SubagentActivity +}): React.JSX.Element { + const { t } = useTranslation('app') + const [expanded, setExpanded] = useState(false) + const StateIcon = + subagent.state === 'completed' + ? CheckCircle2 + : subagent.state === 'running' + ? LoaderCircle + : subagent.state === 'queued' + ? Clock3 + : XCircle + const source = + subagent.routingMode === 'smart' + ? t('chat.subagents.smart') + : subagent.routingMode === 'native' + ? t('chat.subagents.native') + : t('chat.subagents.manual') + + return ( +
setExpanded(event.currentTarget.open)} + > + + + + + + {subagent.expertName} + + {subagent.reason && ( + + {t('chat.subagents.task')} + {subagent.reason} + + )} + + {source} + + + + + + {expanded && ( +
+ {subagent.output ? ( +
+ {t('chat.subagents.output')} +
+ {subagent.output} +
+
+ ) : ( +

{t('chat.subagents.noOutput')}

+ )} + {subagent.error && + (subagent.state === 'failed' || + subagent.state === 'cancelled') && ( +
+ {t('chat.subagents.error')} +

{subagent.error}

+
+ )} +
+ )} +
+ ) +}) + function SubagentStatusList({ subagents }: { @@ -231,44 +341,11 @@ function SubagentStatusList({ aria-label={t('chat.subagents.region')} className="subagent-status-list" > - {subagents.slice(0, 3).map((subagent) => ( -
( + - - -
- {subagent.output ? ( -
- {t('chat.subagents.output')} -
- {subagent.output} -
-
- ) : ( -

{t('chat.subagents.noOutput')}

- )} - {(subagent.error || subagent.reason) && - (subagent.state === 'failed' || - subagent.state === 'cancelled') && ( -
- {t('chat.subagents.error')} -

{subagent.error ?? subagent.reason}

-
- )} -
-
+ subagent={subagent} + /> ))} ) @@ -327,6 +404,20 @@ function ChatMessageRowView({ const compressionMarkers = message.contextCompressions ?? (message.contextCompression ? [message.contextCompression] : []) + const subagentsById = new Map( + (message.subagents ?? []).map((subagent) => [ + subagent.childTaskId, + subagent + ]) + ) + const orderedSubagentIds = new Set( + message.blocks + ?.filter((block) => block.type === 'subagent') + .map((block) => block.childTaskId) + ) + const unorderedSubagents = message.subagents?.filter( + (subagent) => !orderedSubagentIds.has(subagent.childTaskId) + ) const compressionLabel = ( compression: ConversationContextCompressionMarker ): string => @@ -468,14 +559,19 @@ function ChatMessageRowView({ })} )} - {message.subagents && message.subagents.length > 0 && ( - - )} {message.blocks && message.blocks.length > 0 ? (
{groupMessageBlocks(message.blocks).map((item) => item.kind === 'tools' ? ( + ) : item.kind === 'subagents' ? ( + { + const subagent = subagentsById.get(childTaskId) + return subagent ? [subagent] : [] + })} + /> ) : item.block.type === 'reasoning' ? ( )} + {unorderedSubagents && unorderedSubagents.length > 0 && ( + + )} {message.artifactIds?.map((artifactId) => { const candidate = artifactById.get(artifactId) const artifact = diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index 289e4ee8..9bca53c8 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -322,6 +322,8 @@ export const app = { taskCompleted: 'Task completed', taskFailed: 'Task failed', runtimeCompleted: 'Agent Runtime completed its response', + activityIncomplete: + 'The parent request ended before this activity reported completion', answerSubmitted: 'Answer submitted; OpenCode is continuing', questionSkipped: 'Question skipped' }, @@ -345,12 +347,17 @@ export const app = { }, subagents: { region: 'Subagent status', - smart: 'Smart routing', - manual: 'Selected manually', + badge: 'Subagent', + smart: 'GoodBuddy smart routing', + manual: 'GoodBuddy selected expert', + native: 'Native OpenCode', fallbackTask: '{{name}} subagent task', - output: 'Expert output', + task: 'Task', + output: 'Subagent output', error: 'Execution details', - noOutput: 'This expert has no output to display yet.', + noOutput: 'This subagent has no output to display yet.', + incomplete: + 'The parent request ended before the subagent reported completion.', states: { queued: 'Queued', running: 'Running', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 1804db1d..5d5fcf6d 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -308,6 +308,7 @@ export const app = { taskCompleted: '任务执行完成', taskFailed: '任务执行失败', runtimeCompleted: 'Agent Runtime 已完成响应', + activityIncomplete: '父请求已结束,但该活动未报告完成状态', answerSubmitted: '回答已提交,OpenCode 正在继续执行', questionSkipped: '已跳过问题' }, @@ -330,13 +331,17 @@ export const app = { } }, subagents: { - region: '子专家状态', - smart: '智能路由', - manual: '手动指定', + region: '子代理状态', + badge: '子代理', + smart: 'GoodBuddy 智能路由', + manual: 'GoodBuddy 指定专家', + native: 'OpenCode 原生', fallbackTask: '{{name}} 子专家任务', - output: '专家输出', + task: '任务', + output: '子代理输出', error: '执行说明', - noOutput: '该专家暂时没有可显示的输出。', + noOutput: '子代理暂时没有可显示的输出。', + incomplete: '父请求已结束,但子 Agent 未报告完成状态。', states: { queued: '等待中', running: '进行中', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index cf8d57d8..30545344 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -4907,7 +4907,8 @@ button > svg { } .message-blocks .message-reasoning, -.message-blocks .tool-execution-list { +.message-blocks .tool-execution-list, +.message-blocks .subagent-status-list { margin: 0; } @@ -5259,25 +5260,25 @@ button > svg { .subagent-status-list { display: grid; - margin-top: var(--space-2); + margin: var(--space-2) 0 var(--space-4); gap: var(--space-2); } .subagent-status-card { border: 1px solid var(--border-default); - border-radius: var(--radius-control); - background: var(--surface-subtle); + border-radius: var(--radius-card); + background: var(--surface-raised); color: var(--text-secondary); overflow: hidden; } .subagent-status-card > summary { display: grid; - align-items: center; - padding: var(--space-2) var(--space-3); + align-items: start; + padding: var(--space-3) var(--space-4); cursor: pointer; - gap: var(--space-2); - grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--space-3); + grid-template-columns: minmax(0, 1fr) auto auto; list-style: none; } @@ -5294,38 +5295,130 @@ button > svg { outline-offset: -2px; } +.subagent-status-card--queued { + box-shadow: inset 3px 0 0 var(--warning); +} + .subagent-status-card--running { - border-color: var(--accent-selected); + box-shadow: inset 3px 0 0 var(--accent); +} + +.subagent-status-card--completed { + box-shadow: inset 3px 0 0 var(--success); } .subagent-status-card--failed, .subagent-status-card--cancelled { border-color: var(--danger-border); background: var(--danger-subtle); + box-shadow: inset 3px 0 0 var(--danger); } .subagent-status-card__identity { display: grid; min-width: 0; + gap: var(--space-2); +} + +.subagent-status-card__heading { + display: flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +.subagent-status-card__badge { + display: inline-flex; + min-height: 22px; + padding: 2px var(--space-2); + align-items: center; + border: 1px solid var(--accent-selected); + border-radius: var(--radius-control); + background: var(--accent-subtle); + color: var(--accent); + font-size: var(--font-caption); + font-weight: 700; gap: var(--space-1); } -.subagent-status-card summary strong, -.subagent-status-card summary > span:last-child { +.subagent-status-card__heading > strong { color: var(--text-primary); - font-size: var(--font-body); + font-size: var(--font-section-title); + overflow-wrap: anywhere; } -.subagent-status-card summary small { +.subagent-status-card__task { + display: grid; + min-width: 0; + gap: var(--space-1); +} + +.subagent-status-card__task > small, +.subagent-status-card__source { color: var(--text-muted); font-size: var(--font-caption); } +.subagent-status-card__task > span { + color: var(--text-primary); + font-size: var(--font-body); + line-height: 1.5; + overflow-wrap: anywhere; +} + +.subagent-status-card__status { + display: inline-flex; + min-height: 24px; + padding: 2px var(--space-2); + align-items: center; + border-radius: var(--radius-control); + font-size: var(--font-caption); + font-weight: 700; + gap: var(--space-1); + white-space: nowrap; +} + +.subagent-status-card__status--queued { + background: var(--warning-subtle); + color: var(--warning); +} + +.subagent-status-card__status--running { + background: var(--accent-subtle); + color: var(--accent); +} + +.subagent-status-card__status--completed { + background: var(--success-subtle); + color: var(--success); +} + +.subagent-status-card__status--failed, +.subagent-status-card__status--cancelled { + background: var(--danger-subtle); + color: var(--danger); +} + +.subagent-status-card__status--running > svg { + animation: subagent-status-spin 900ms linear infinite; +} + +.subagent-status-card__chevron { + margin-top: var(--space-1); + color: var(--text-muted); + transition: transform var(--motion-fast) ease-out; +} + +.subagent-status-card[open] .subagent-status-card__chevron { + transform: rotate(90deg); +} + .subagent-status-card__details { display: grid; - padding: var(--space-3); + padding: var(--space-3) var(--space-4) var(--space-4); border-top: 1px solid var(--border-subtle); - background: var(--surface-raised); + background: var(--surface-subtle); gap: var(--space-3); } @@ -5349,6 +5442,12 @@ button > svg { color: var(--danger); } +@keyframes subagent-status-spin { + to { + transform: rotate(360deg); + } +} + .approval-card { display: grid; align-items: center; diff --git a/src/shared/assistant-contracts.test.ts b/src/shared/assistant-contracts.test.ts index e6532e4f..2a6db1ca 100644 --- a/src/shared/assistant-contracts.test.ts +++ b/src/shared/assistant-contracts.test.ts @@ -242,4 +242,49 @@ describe('conversation persistence contracts', () => { expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch) }) + + it('accepts persisted native OpenCode subagent activity', () => { + const batch = [ + { + header: { + id: '00000000-0000-4000-8000-000000000311', + title: 'OpenCode 子 Agent', + updatedAt: 1 + }, + messages: [ + { + id: '00000000-0000-4000-8000-000000000312', + role: 'assistant', + content: '', + createdAt: 1, + state: 'complete', + blocks: [ + { + id: '00000000-0000-4000-8000-000000000315', + type: 'subagent', + childTaskId: + '00000000-0000-4000-8000-000000000313' + } + ], + subagents: [ + { + childTaskId: + '00000000-0000-4000-8000-000000000313', + expertId: + '00000000-0000-4000-8000-000000000314', + expertName: 'explorer', + routingMode: 'native', + runtimeCallId: 'call-task-1', + state: 'completed', + reason: 'Review application architecture', + output: 'Architecture review complete.' + } + ] + } + ] + } + ] + + expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch) + }) }) diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 2d0e5d77..305350be 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -172,6 +172,13 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [ type: z.literal('tool'), tool: conversationToolActivitySchema }) + .strict(), + z + .object({ + id: assistantIdSchema, + type: z.literal('subagent'), + childTaskId: assistantIdSchema + }) .strict() ]) @@ -186,12 +193,16 @@ export type ConversationMessageBlock = z.infer< typeof conversationMessageBlockSchema > +export const maximumConversationToolActivities = 100 +export const maximumConversationSubagentActivities = 100 + export const conversationSubagentActivitySchema = z .object({ childTaskId: assistantIdSchema, expertId: assistantIdSchema, expertName: z.string().trim().min(1).max(80), - routingMode: z.enum(['manual', 'smart']), + routingMode: z.enum(['manual', 'smart', 'native']), + runtimeCallId: z.string().trim().min(1).max(256).optional(), state: z.enum([ 'queued', 'running', @@ -240,10 +251,13 @@ export const conversationMessageSchema = z .array(conversationContextCompressionMarkerSchema) .max(2) .optional(), - tools: z.array(conversationToolActivitySchema).max(100).optional(), + tools: z + .array(conversationToolActivitySchema) + .max(maximumConversationToolActivities) + .optional(), subagents: z .array(conversationSubagentActivitySchema) - .max(3) + .max(maximumConversationSubagentActivities) .optional(), sources: z.array(z.string().max(8_192)).max(100).optional(), sourceReferences: z diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index e3877c63..0e69308e 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -21,6 +21,7 @@ import { conversationAttachmentSchema, conversationHistoryMessageSchema, conversationContextCompressionStateSchema, + conversationSubagentActivitySchema, legacyWorkModeSchema, maximumConversationHistoryCharacters, maximumConversationHistoryMessages, @@ -830,7 +831,7 @@ export const runtimeSettingsInputSchema = z code: 'custom', path: ['deepseekHarnessModelSource'], message: - 'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接' + 'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接' }) } const compressionSource = settings.contextCompression?.modelSource @@ -1171,24 +1172,11 @@ export const approvalDecisionSchema = z.enum([ export type ApprovalDecision = z.infer -export const subagentEventSchema = z - .object({ +export const subagentEventSchema = + conversationSubagentActivitySchema + .extend({ requestId: z.string().uuid(), - type: z.literal('subagent'), - childTaskId: z.string().uuid(), - expertId: z.string().uuid(), - expertName: z.string().trim().min(1).max(80), - routingMode: z.enum(['manual', 'smart']), - state: z.enum([ - 'queued', - 'running', - 'completed', - 'failed', - 'cancelled' - ]), - reason: z.string().trim().min(1).max(240).optional(), - output: z.string().optional(), - error: z.string().trim().min(1).max(1_000).optional() + type: z.literal('subagent') }) .strict() diff --git a/src/shared/remote-agent-contracts.ts b/src/shared/remote-agent-contracts.ts index c80d61cb..a9deaf32 100644 --- a/src/shared/remote-agent-contracts.ts +++ b/src/shared/remote-agent-contracts.ts @@ -15,6 +15,9 @@ import { modelBridgePolicySchema } from './model-bridge-contracts' +export const UNBOUNDED_REMOTE_PROMPT_DEADLINE = + '9999-12-31T23:59:59.999Z' + export const runtimeSessionBindingStateSchema = z.enum([ 'opening', 'ready',