Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd304fa1d | ||
|
|
7c6f15d3c0 | ||
|
|
6dc219f3a3 | ||
|
|
acc5a8e2d8 | ||
|
|
9f0f231e3b | ||
|
|
967796539f | ||
|
|
8e08ab0bac | ||
|
|
edba31e873 | ||
|
|
0804f5ccd2 | ||
|
|
29639ff516 | ||
|
|
22c80b2d94 |
@@ -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.
|
||||
|
||||
@@ -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 并发写入时间线。
|
||||
@@ -625,6 +626,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
|
||||
- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.9",
|
||||
"agentVersion": "0.11.13",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -163,7 +163,7 @@ Electron utilityProcess
|
||||
├─ 本地 Shell / Filesystem Provider
|
||||
├─ 最小工具集与 Main 代理 MCP
|
||||
└─ Main 明确启用的第三方 Cordis 插件
|
||||
│ HTTPS
|
||||
│ HTTP/HTTPS
|
||||
▼
|
||||
用户选择的 OpenAI 兼容模型连接
|
||||
```
|
||||
@@ -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 释放与退出
|
||||
@@ -426,8 +427,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
|
||||
|
||||
- 协议必须是 `openai-chat-completions`。
|
||||
- 认证必须是 API Key。
|
||||
- 公网服务地址必须使用 HTTPS;`localhost`、`127.0.0.1` 和 `::1` 回环地址可以使用 HTTP。
|
||||
- 服务地址可以使用自定义主机、端口和部署路径,但不得包含用户名、密码、查询参数或片段。
|
||||
- 服务地址支持 HTTP 和 HTTPS,不根据主机位置、端口、凭据、查询参数或片段增加额外限制。
|
||||
- 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。
|
||||
- 模型名称和服务地址由 Main 传入受控 Host。
|
||||
- 图片能力只读取所选 GoodBuddy 模型连接的 `supportsImageInput`;Main、Utility 启动配置、ACP 能力和 Pi-AI 模型输入模态必须使用同一个布尔值。
|
||||
@@ -469,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 进程,避免继续信任已失控的通道。
|
||||
|
||||
@@ -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.7` 已通过独立 workflow 发布 Linux x64/arm64 复合包和
|
||||
签名累计目录;当前源码准备 `0.11.9`,修复 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,12 +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 事件先经过单事件、单请求输出和每次 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 请求都不自动重放。
|
||||
@@ -223,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。
|
||||
@@ -241,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.7` 后续已由原生 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 模型轮次请求一个原生
|
||||
@@ -283,7 +342,12 @@ Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进
|
||||
产生冲突的项目创建和设置入口。远端 RPC 拒绝只向
|
||||
Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。
|
||||
`runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding,
|
||||
不会把确定性失败错误保留为 `outcome-unknown`。
|
||||
不会把确定性失败错误保留为 `outcome-unknown`。未确认终态的 Prompt 仍不会自动重放;
|
||||
用户之后显式发送的新 Prompt 会关闭旧的非就绪 Main binding,并以新的 ACP session
|
||||
继续当前 Conversation,避免一次恢复不确定永久阻塞后续输入。
|
||||
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
|
||||
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
|
||||
的有界转换,再登记为运行中进程。
|
||||
|
||||
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
||||
v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id`
|
||||
@@ -313,7 +377,10 @@ v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/
|
||||
信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和
|
||||
payload 仍按原始签名字节及 SHA-256 验证。
|
||||
|
||||
## 发布前验证
|
||||
## 发布前完整回归(不能替代开发验证)
|
||||
|
||||
以下清单只用于复核开发期间已经在真实 Host 上通过的链路,不得作为 Agent 改动的首次
|
||||
真实 Host 验证:
|
||||
|
||||
1. `npm test`
|
||||
2. `npm run typecheck`
|
||||
|
||||
@@ -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:监督、终端与受管进程
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ GoodBuddy 应能够:
|
||||
1. 所有新 IPC 继续执行 Zod 校验和可信主窗口校验。
|
||||
2. 项目根目录、成果路径和上下文路径必须 canonicalize 并验证目录包含关系。
|
||||
3. Child Runtime 环境变量改用最小 allowlist,避免继承无关密钥。
|
||||
4. 远程委派仅允许 HTTPS,开发环境只放行 loopback。
|
||||
4. 远程委派支持用户配置的 HTTP 和 HTTPS 地址,不按主机位置增加额外限制。
|
||||
5. 语音、窗口捕获和浏览器上下文分别授权。
|
||||
6. 自动化不得绕过工具审批和项目权限。
|
||||
7. 记忆必须保留来源和作用域。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.11.9",
|
||||
"version": "0.11.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.11.9",
|
||||
"version": "0.11.13",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.11.9",
|
||||
"version": "0.11.13",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -2,12 +2,108 @@
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.11.9",
|
||||
"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",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**魔法笔记更新时间。** 魔法笔记列表现在显示每篇笔记的最后更新时间,便于快速判断最近修改过的内容。"
|
||||
],
|
||||
"fixes": [
|
||||
"**远程会话中断后可以继续输入。** 修复托管 SSH 请求结果不确定后,同一会话的每条后续输入都会重复显示“恢复状态不确定”的问题。未确认的旧请求仍不会自动重放;用户再次明确发送时,GoodBuddy 会建立新的 ACP session 并继续当前会话。",
|
||||
"**Ask 模式远程 Runtime 可以稳定启动。** 修复 Linux Host 上 `bwrap` 启动身份检查存在竞态,导致 Ask 模式可能在模型调用前被拒绝的问题。Agent 现在会验证从固定沙箱启动器到预期签名可执行文件的受限转换。"
|
||||
],
|
||||
"notices": [
|
||||
"**远程项目技术预览与 Agent 要求。** 远程项目仍默认关闭且为技术预览。Host 需要更新到单独发布的 Agent `0.11.10` 才能获得 Ask 模式启动修复;提供 Linux x64 和 arm64,桌面包不内置 Agent 或远程 Runtime。本次不构建 LoongArch 预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Magic Notes update times.** The Magic Notes list now shows when each note was last updated, making recently edited content easier to identify."
|
||||
],
|
||||
"fixes": [
|
||||
"**Remote conversations continue after interruptions.** Fixed every later message in a managed SSH conversation repeatedly failing with an uncertain-recovery error after a prompt outcome could not be confirmed. The uncertain prompt is still never replayed automatically; when the user explicitly sends again, GoodBuddy starts a new ACP session and continues the current conversation.",
|
||||
"**Reliable Ask-mode Remote Runtime startup.** Fixed a race in Linux Host `bwrap` launch identity verification that could reject Ask-mode requests before the model was called. The Agent now verifies the bounded transition from the fixed sandbox launcher to the expected signed executable."
|
||||
],
|
||||
"notices": [
|
||||
"**Remote Projects technical preview and Agent requirement.** Remote Projects remain disabled by default and in technical preview. Hosts must update to the separately published Agent `0.11.10` to receive the Ask-mode startup fix. Linux x64 and arm64 are provided; desktop packages do not embed the Agent or Remote Runtime. This release does not include a LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.11",
|
||||
"releasedAt": "2026-08-30",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**远程会话完整保存。** 修复托管 SSH 远程项目的工具结果较长时,会话持久化失败并可能让已完成回复仍显示为生成中的问题。Runtime 已返回的工具输入、输出、错误和摘要现在会完整保存到本地会话,不再受额外的会话字段长度限制。",
|
||||
"**魔法笔记列表快捷输入更易发现。** 编辑区现在提示输入 `-`、`1.`、`[ ]` 或 `[x]` 后按空格即可创建项目符号、编号或待办清单,并增加行为覆盖以防快捷输入回退。"
|
||||
],
|
||||
"notices": []
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Complete remote conversation saves.** Fixed conversation persistence failing for managed SSH Remote Projects when tool results were long, which could leave a completed reply displayed as still generating. Tool input, output, errors, and summaries already returned by the Runtime are now saved completely without additional conversation-field length limits.",
|
||||
"**Discoverable Magic Notes list shortcuts.** The editor now explains that typing `-`, `1.`, `[ ]`, or `[x]` followed by Space creates a bullet, numbered, or checklist item, with behavior coverage to prevent regressions."
|
||||
],
|
||||
"notices": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.10",
|
||||
"releasedAt": "2026-08-29",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.9 修复正式环境中的 Host 更新失败,并显著缩短 Agent 与 OpenCode Runtime 的安装、恢复和日常启动校验。"
|
||||
"GoodBuddy 0.11.10 修复正式环境中的 Host 更新失败,并显著缩短 Agent 与 OpenCode Runtime 的安装、恢复和日常启动校验。"
|
||||
],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
@@ -21,7 +117,7 @@
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.9 fixes Host updates in production and substantially reduces Agent and OpenCode Runtime verification during installation, recovery, and normal startup."
|
||||
"GoodBuddy 0.11.10 fixes Host updates in production and substantially reduces Agent and OpenCode Runtime verification during installation, recovery, and normal startup."
|
||||
],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
|
||||
@@ -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,
|
||||
@@ -119,6 +122,47 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('waits for bubblewrap to exec the supervised bridge helper', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const bwrapIdentity = identity()
|
||||
const helperIdentity = {
|
||||
...bwrapIdentity,
|
||||
executablePath: '/agent/node'
|
||||
}
|
||||
const spawn: DirectLinuxStdioSpawn = () => {
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
}
|
||||
const readProcessIdentity = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(bwrapIdentity)
|
||||
.mockResolvedValue(helperIdentity)
|
||||
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: {
|
||||
...profile(),
|
||||
processExecutable: '/agent/node'
|
||||
},
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn,
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity
|
||||
})
|
||||
|
||||
expect(readProcessIdentity).toHaveBeenCalledTimes(2)
|
||||
expect(owner.processIdentity).toEqual(helperIdentity)
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('rejects PID reuse before sending any signal', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
@@ -208,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<typeof process.kill>(() => 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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +34,7 @@ const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
|
||||
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
|
||||
const DEFAULT_MAXIMUM_LISTENERS = 8
|
||||
const DEFAULT_STOP_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS = 5_000
|
||||
const TERM_GRACE_MS = 2_000
|
||||
const POLL_INTERVAL_MS = 25
|
||||
const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN'
|
||||
@@ -510,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
|
||||
@@ -550,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)) {
|
||||
@@ -603,8 +614,18 @@ export async function launchDirectLinuxStdioProcessOwner(
|
||||
await waitForSpawn(child, options.signal, timeoutMs)
|
||||
if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID')
|
||||
const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity
|
||||
const processIdentity = await readIdentity(child.pid)
|
||||
assertLaunchedIdentity(processIdentity, profile)
|
||||
const processIdentity = await waitForLaunchedIdentity({
|
||||
child,
|
||||
pid: child.pid,
|
||||
profile,
|
||||
readIdentity,
|
||||
signal: options.signal,
|
||||
deadline: Math.min(
|
||||
Date.parse(effectiveDeadlineAt),
|
||||
now() + DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS
|
||||
),
|
||||
now
|
||||
})
|
||||
const supervisorIdentityDigest = supervisorDigest({
|
||||
installationId,
|
||||
ownerId,
|
||||
@@ -871,15 +892,72 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
|
||||
}
|
||||
}
|
||||
|
||||
function assertLaunchedIdentity(
|
||||
identity: LinuxRuntimeProcessIdentity,
|
||||
async function waitForLaunchedIdentity(input: {
|
||||
child: DirectLinuxStdioChild
|
||||
pid: number
|
||||
profile: OpenCodeLaunchProfile
|
||||
): void {
|
||||
if (
|
||||
identity.executablePath !== profile.processExecutable ||
|
||||
identity.processGroupId !== identity.pid
|
||||
) {
|
||||
throw identityError('Spawned Runtime does not match the launch identity')
|
||||
readIdentity: typeof readLinuxRuntimeProcessIdentity
|
||||
signal?: AbortSignal
|
||||
deadline: number
|
||||
now: () => number
|
||||
}): Promise<LinuxRuntimeProcessIdentity> {
|
||||
for (;;) {
|
||||
input.signal?.throwIfAborted()
|
||||
if (
|
||||
input.child.exitCode !== null ||
|
||||
input.child.signalCode !== null
|
||||
) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Runtime process exited during launch',
|
||||
'process'
|
||||
)
|
||||
}
|
||||
let identity: LinuxRuntimeProcessIdentity
|
||||
try {
|
||||
identity = await input.readIdentity(input.pid)
|
||||
} catch (error) {
|
||||
if (!isMissingProcess(error)) {
|
||||
throw error
|
||||
}
|
||||
if (input.now() >= input.deadline) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Runtime launch identity was not observed',
|
||||
'process'
|
||||
)
|
||||
}
|
||||
await delay(
|
||||
Math.min(
|
||||
POLL_INTERVAL_MS,
|
||||
Math.max(1, input.deadline - input.now())
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (identity.processGroupId !== identity.pid) {
|
||||
throw identityError(
|
||||
'Spawned Runtime does not match the launch identity'
|
||||
)
|
||||
}
|
||||
if (identity.executablePath === input.profile.processExecutable) {
|
||||
return identity
|
||||
}
|
||||
if (identity.executablePath !== input.profile.executable) {
|
||||
throw identityError(
|
||||
'Spawned Runtime does not match the launch identity'
|
||||
)
|
||||
}
|
||||
if (input.now() >= input.deadline) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Runtime launch did not reach its supervised executable',
|
||||
'process'
|
||||
)
|
||||
}
|
||||
await delay(
|
||||
Math.min(
|
||||
POLL_INTERVAL_MS,
|
||||
Math.max(1, input.deadline - input.now())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,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(
|
||||
|
||||
@@ -113,7 +113,9 @@ describe('installed Agent bundle verifier', () => {
|
||||
if (process.platform !== 'win32') {
|
||||
writeBundle()
|
||||
chmodSync(join(installationDirectory, 'goodbuddy-agent'), 0o777)
|
||||
await expect(verifyBundle()).rejects.toThrow('mode mismatch')
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
'entrypoint metadata changed'
|
||||
)
|
||||
resetInstallation()
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,9 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
'--opencode-entrypoint',
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
])
|
||||
expect(profile.processExecutable).toBe(
|
||||
resolve(agentInstallationDirectory, 'node')
|
||||
)
|
||||
expect(profile.args.join('\0')).not.toMatch(
|
||||
/api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu
|
||||
)
|
||||
|
||||
@@ -266,7 +266,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
|
||||
return {
|
||||
executable: OPENCODE_BWRAP_EXECUTABLE,
|
||||
processExecutable: OPENCODE_BWRAP_EXECUTABLE,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
args,
|
||||
cwd: scratchDirectory,
|
||||
env: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
@@ -599,7 +597,7 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
|
||||
it.each(['prompt-running', 'outcome-unknown'] as const)(
|
||||
'does not replace a %s binding after identity changes',
|
||||
'retires a %s binding before starting an explicit later prompt',
|
||||
async (state) => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const active = {
|
||||
@@ -625,7 +623,13 @@ describe('AcpRemoteRuntime', () => {
|
||||
lastMainAckSequence: '0'
|
||||
}
|
||||
await store.put(active)
|
||||
const factory = vi.fn(async () => fakeServer().channel)
|
||||
const replacement = fakeServer()
|
||||
const factory = vi.fn(
|
||||
async (bindingId: string) => {
|
||||
expect(bindingId).not.toBe(active.bindingId)
|
||||
return replacement.channel
|
||||
}
|
||||
)
|
||||
const instance = factoryRuntime(factory, store, {
|
||||
identity: {
|
||||
...identity,
|
||||
@@ -633,22 +637,28 @@ describe('AcpRemoteRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
instance.run(
|
||||
{ ...request, requestId: 'prompt-2' },
|
||||
new AbortController().signal
|
||||
)
|
||||
await collect(
|
||||
instance.run(
|
||||
{ ...request, requestId: 'prompt-2' },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('不会自动重放')
|
||||
)
|
||||
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
expect(factory).toHaveBeenCalledOnce()
|
||||
expect(factory).not.toHaveBeenCalledWith(active.bindingId)
|
||||
await expect(
|
||||
store.getById(active.bindingId)
|
||||
).resolves.toMatchObject({
|
||||
bindingId: active.bindingId,
|
||||
state: 'closed',
|
||||
activePromptOperationId: undefined
|
||||
})
|
||||
await expect(
|
||||
store.getByConversation(request.conversationId)
|
||||
).resolves.toMatchObject({
|
||||
bindingId: active.bindingId,
|
||||
state: 'outcome-unknown',
|
||||
activePromptOperationId: request.requestId
|
||||
bindingId: factory.mock.calls[0]![0],
|
||||
state: 'ready',
|
||||
activePromptOperationId: undefined
|
||||
})
|
||||
await instance.dispose()
|
||||
}
|
||||
@@ -1246,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<void>((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[] = []
|
||||
@@ -1715,7 +1952,8 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
})
|
||||
const instance = runtime(server, store, 5, {
|
||||
operationTimeoutMs: 20
|
||||
operationTimeoutMs: 20,
|
||||
promptTimeoutMs: 20
|
||||
})
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -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<T>(
|
||||
@@ -833,7 +886,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return interrupted
|
||||
}
|
||||
|
||||
private async persistRejectedPrompt(
|
||||
private async persistClosedBinding(
|
||||
binding: RuntimeSessionBinding
|
||||
): Promise<RuntimeSessionBinding> {
|
||||
const closed = runtimeSessionBindingSchema.parse({
|
||||
@@ -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
|
||||
@@ -1198,24 +1252,19 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal
|
||||
): Promise<SessionRecord> {
|
||||
const old = await this.awaitLocalOperation(
|
||||
let binding = await this.awaitLocalOperation(
|
||||
'读取会话绑定',
|
||||
this.options.bindingStore.getByConversation(conversationId),
|
||||
signal
|
||||
)
|
||||
if (
|
||||
old &&
|
||||
old.state !== 'closed' &&
|
||||
old.state !== 'ready'
|
||||
binding &&
|
||||
binding.state !== 'closed' &&
|
||||
binding.state !== 'ready'
|
||||
) {
|
||||
await this.persistInterrupted(old)
|
||||
throw new Error(
|
||||
old.state === 'prompt-running'
|
||||
? '远端 ACP 会话存在未确认的活动请求,结果未知且不会自动重放'
|
||||
: '远端 ACP 会话恢复状态不确定,已中断且不会自动重放'
|
||||
)
|
||||
await this.persistClosedBinding(binding)
|
||||
binding = undefined
|
||||
}
|
||||
let binding = old
|
||||
let bindingId = binding?.bindingId ?? randomUUID()
|
||||
if (
|
||||
binding?.state === 'ready' &&
|
||||
@@ -1437,7 +1486,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
error,
|
||||
'runtime/preparePrompt'
|
||||
)
|
||||
? this.persistRejectedPrompt(binding)
|
||||
? this.persistClosedBinding(binding)
|
||||
: this.persistInterrupted(binding)
|
||||
await persist.catch(() => undefined)
|
||||
}
|
||||
@@ -1483,7 +1532,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
error,
|
||||
'runtime/preparePrompt'
|
||||
)
|
||||
? this.persistRejectedPrompt(current.binding)
|
||||
? this.persistClosedBinding(current.binding)
|
||||
: this.persistInterrupted(current.binding)
|
||||
current.binding = await persist.catch(() => current.binding)
|
||||
this.sessions.delete(conversationId)
|
||||
@@ -1776,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 = {
|
||||
@@ -1819,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 } : {})
|
||||
@@ -1930,6 +2059,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
pendingUpdateBytes: 0,
|
||||
inboundPaused: false,
|
||||
outputCharacters: 0,
|
||||
toolCalls: new Map(),
|
||||
open: true,
|
||||
completed: false,
|
||||
context
|
||||
@@ -1998,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',
|
||||
|
||||
@@ -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<Response> => {
|
||||
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<Response>((_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(
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<string>()
|
||||
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<string>()
|
||||
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)
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
{ deepseekHarnessLauncher: vi.fn() }
|
||||
)
|
||||
).toThrow(
|
||||
'DeepSeek Harness 需要使用 API Key 的安全 OpenAI 兼容 Chat Completions 模型连接'
|
||||
'DeepSeek Harness 需要使用 API Key 的 OpenAI 兼容 Chat Completions 模型连接'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export function createAgentRuntime(
|
||||
const profile = settings?.deepseekHarnessModelProfile
|
||||
if (!profile || !isDeepSeekHarnessModelProfile(profile)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 需要使用 API Key 的安全 OpenAI 兼容 Chat Completions 模型连接'
|
||||
'DeepSeek Harness 需要使用 API Key 的 OpenAI 兼容 Chat Completions 模型连接'
|
||||
)
|
||||
}
|
||||
if (!profile.apiKey) {
|
||||
|
||||
@@ -44,6 +44,7 @@ function setup(
|
||||
nativeTools?: Array<Record<string, unknown>>
|
||||
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<Record<string, unknown>>
|
||||
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<Record<string, unknown>>()
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,7 +50,7 @@ async function fixture() {
|
||||
launchOptions: {
|
||||
cwd: workspace,
|
||||
signal: new AbortController().signal,
|
||||
baseUrl: 'https://gateway.example/openai/v1',
|
||||
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: false,
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
@@ -102,7 +102,7 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
expect(utility.messages[0]).toMatchObject({
|
||||
type: 'start',
|
||||
config: {
|
||||
baseUrl: 'https://gateway.example/openai/v1',
|
||||
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: false,
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
|
||||
@@ -235,10 +235,9 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://gateway.example/v1',
|
||||
'https://user:secret@gateway.example/v1',
|
||||
'https://gateway.example/v1?api-version=2025-01-01'
|
||||
])('rejects unsafe endpoint %s before forking', async (baseUrl) => {
|
||||
'file:///private/config',
|
||||
'ftp://gateway.example/v1'
|
||||
])('rejects unsupported endpoint %s before forking', async (baseUrl) => {
|
||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||
const fork = vi.fn()
|
||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||
@@ -250,7 +249,7 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
|
||||
await expect(
|
||||
launcher({ ...launchOptions, baseUrl })
|
||||
).rejects.toThrow('HTTPS')
|
||||
).rejects.toThrow('HTTP 或 HTTPS')
|
||||
expect(fork).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,7 +149,7 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
}
|
||||
if (!isDeepSeekHarnessCompatibleBaseUrl(options.baseUrl)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 模型地址必须使用 HTTPS 或本机回环 HTTP,且不得包含凭据、查询参数或片段'
|
||||
'DeepSeek Harness 模型地址必须使用 HTTP 或 HTTPS'
|
||||
)
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -58,10 +58,6 @@ function fakeClient() {
|
||||
} as unknown as ReturnType<typeof createOpencodeClient>
|
||||
}
|
||||
|
||||
function stdoutOf(child: SpawnedProcess): PassThrough {
|
||||
return child.stdout as PassThrough
|
||||
}
|
||||
|
||||
function stderrOf(child: SpawnedProcess): PassThrough {
|
||||
return child.stderr as PassThrough
|
||||
}
|
||||
@@ -91,6 +87,7 @@ function dependencies(
|
||||
spawnMock: ReturnType<typeof vi.fn>
|
||||
detectBinary: ReturnType<typeof vi.fn>
|
||||
createClient: ReturnType<typeof vi.fn>
|
||||
checkServerHealth: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const spawnMock = vi.fn(() => child)
|
||||
const detectBinary = vi.fn().mockResolvedValue({
|
||||
@@ -98,18 +95,21 @@ function dependencies(
|
||||
detail: 'OpenCode CLI 已就绪'
|
||||
})
|
||||
const createClient = vi.fn(() => fakeClient())
|
||||
const checkServerHealth = vi.fn().mockResolvedValue(true)
|
||||
return {
|
||||
deps: {
|
||||
spawn: spawnMock as unknown as typeof spawn,
|
||||
detectBinary,
|
||||
createClient: createClient as unknown as typeof createOpencodeClient,
|
||||
checkServerHealth,
|
||||
platform: 'linux',
|
||||
startupTimeoutMs: 100,
|
||||
...overrides
|
||||
},
|
||||
spawnMock,
|
||||
detectBinary,
|
||||
createClient
|
||||
createClient,
|
||||
checkServerHealth
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,19 +257,16 @@ function embeddedRuntime(
|
||||
client: ReturnType<typeof createOpencodeClient>,
|
||||
overrides: Partial<
|
||||
ConstructorParameters<typeof OpenCodeRuntime>[0]
|
||||
> = {}
|
||||
> = {},
|
||||
dependencyOverrides: Partial<OpenCodeRuntimeDependencies> = {}
|
||||
): 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)
|
||||
}
|
||||
|
||||
@@ -301,6 +298,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
detail: 'OpenCode CLI 已就绪'
|
||||
})
|
||||
const createClient = vi.fn(() => fakeClient())
|
||||
const checkServerHealth = vi.fn().mockResolvedValue(true)
|
||||
const spawnMock = vi.fn((command: string) => {
|
||||
if (command === 'taskkill.exe') {
|
||||
queueMicrotask(() => {
|
||||
@@ -308,11 +306,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
})
|
||||
return killerChild
|
||||
}
|
||||
setTimeout(() => {
|
||||
stdoutOf(serverChild).write(
|
||||
'opencode server listening securely on http://127.0.0.1:43210\n'
|
||||
)
|
||||
}, 0)
|
||||
return serverChild
|
||||
})
|
||||
const configPath = './private/opencode.json'
|
||||
@@ -325,6 +318,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
spawn: spawnMock as unknown as typeof spawn,
|
||||
detectBinary,
|
||||
createClient: createClient as unknown as typeof createOpencodeClient,
|
||||
checkServerHealth,
|
||||
platform: 'win32'
|
||||
}
|
||||
)
|
||||
@@ -357,6 +351,14 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
})
|
||||
})
|
||||
)
|
||||
const firstSpawn = spawnMock.mock.calls[0] as unknown as
|
||||
| [string, string[]]
|
||||
| undefined
|
||||
const serverPort = firstSpawn?.[1]
|
||||
?.find((argument) => argument.startsWith('--port='))
|
||||
?.slice('--port='.length)
|
||||
expect(serverPort).toMatch(/^\d+$/u)
|
||||
const serverUrl = `http://127.0.0.1:${serverPort}`
|
||||
const clientOptions = (
|
||||
createClient.mock.calls as unknown as Array<
|
||||
[
|
||||
@@ -375,12 +377,17 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
}
|
||||
| undefined
|
||||
expect(clientOptions).toMatchObject({
|
||||
baseUrl: 'http://127.0.0.1:43210',
|
||||
baseUrl: serverUrl,
|
||||
directory: process.cwd(),
|
||||
headers: {
|
||||
Authorization: expect.stringMatching(/^Basic /u)
|
||||
}
|
||||
})
|
||||
expect(checkServerHealth).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
clientOptions?.headers?.Authorization,
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
const spawnOptions = (
|
||||
spawnMock.mock.calls as unknown as Array<
|
||||
[string, string[], { env?: NodeJS.ProcessEnv }]
|
||||
@@ -445,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: [
|
||||
@@ -523,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: {
|
||||
@@ -601,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: {
|
||||
@@ -730,7 +722,8 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
authentication: 'api-key',
|
||||
apiKey: 'local-probe-key'
|
||||
}
|
||||
})
|
||||
}),
|
||||
{ startupTimeoutMs: 20_000 }
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
@@ -808,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: {
|
||||
@@ -899,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
|
||||
@@ -960,36 +943,39 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://127.0.0.1:4321',
|
||||
'http://0.0.0.0:4321',
|
||||
'http://example.com:4321',
|
||||
'http://127.0.0.1',
|
||||
'http://127.0.0.1:4321/admin'
|
||||
])('rejects an unsafe listening URL: %s', async (url) => {
|
||||
it('polls the known loopback URL without waiting for stdout', async () => {
|
||||
const child = fakeChild()
|
||||
const { deps, createClient } = dependencies(child, {
|
||||
spawn: vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
stdoutOf(child).write(`opencode server listening on ${url}\n`)
|
||||
closeChild(child, 7)
|
||||
})
|
||||
return child
|
||||
}) as unknown as typeof spawn
|
||||
const { deps, checkServerHealth, createClient } = dependencies(child, {
|
||||
startupTimeoutMs: 1_000
|
||||
})
|
||||
checkServerHealth
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: 'OpenCode Server 启动前退出(code 7)'
|
||||
available: true
|
||||
})
|
||||
expect(checkServerHealth).toHaveBeenCalledTimes(2)
|
||||
const [url, authorization, signal] =
|
||||
checkServerHealth.mock.calls[0] ?? []
|
||||
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect(authorization).toMatch(/^Basic /)
|
||||
expect(signal).toBeInstanceOf(AbortSignal)
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: url,
|
||||
directory: process.cwd(),
|
||||
headers: { Authorization: authorization }
|
||||
})
|
||||
expect(createClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times out, terminates the process tree, and does not expose stderr', async () => {
|
||||
const child = fakeChild()
|
||||
const secret = 'private-config-token'
|
||||
const { deps } = dependencies(child, { startupTimeoutMs: 5 })
|
||||
const { deps } = dependencies(child, {
|
||||
startupTimeoutMs: 5,
|
||||
checkServerHealth: vi.fn().mockResolvedValue(false)
|
||||
})
|
||||
stderrOf(child).write(secret)
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
|
||||
@@ -1038,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(
|
||||
@@ -1058,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<typeof vi.fn>
|
||||
).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)
|
||||
@@ -1521,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' }
|
||||
})
|
||||
@@ -1614,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<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const setup = runClient([])
|
||||
;(
|
||||
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
|
||||
).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([])
|
||||
;(
|
||||
@@ -1705,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',
|
||||
@@ -1886,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
|
||||
@@ -1987,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: {
|
||||
@@ -2090,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: {
|
||||
@@ -2340,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',
|
||||
@@ -2436,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()
|
||||
@@ -2609,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()
|
||||
})
|
||||
|
||||
@@ -2632,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()
|
||||
})
|
||||
|
||||
|
||||
+469
-236
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
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 } : {})
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export function applyRuntimeSelection(
|
||||
!isDeepSeekHarnessModelProfile(selectedProfile)
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 仅支持使用 API Key 的安全 OpenAI 兼容 Chat Completions 连接'
|
||||
'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接'
|
||||
)
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -3299,6 +3299,29 @@ describe('AssistantDatabase', () => {
|
||||
createdAt: 1_775_000_000_001,
|
||||
state: 'complete' as const,
|
||||
status: '已完成',
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed' as const,
|
||||
summary: '完整远程工具结果',
|
||||
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,
|
||||
@@ -3350,6 +3373,29 @@ describe('AssistantDatabase', () => {
|
||||
content: '生成完成',
|
||||
state: 'complete',
|
||||
status: '已完成',
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed',
|
||||
summary: '完整远程工具结果',
|
||||
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',
|
||||
@@ -4260,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-')
|
||||
)
|
||||
@@ -4271,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([
|
||||
@@ -4324,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: '已完成'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4352,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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('ChannelSettingsStore', () => {
|
||||
const bound = await store.saveWeixinBinding({
|
||||
accountId: 'account-123456',
|
||||
userId: 'user-654321',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
baseUrl: 'http://ilinkai.weixin.qq.com',
|
||||
token: 'weixin-private-token'
|
||||
})
|
||||
expect(bound.weixin).toMatchObject({
|
||||
|
||||
@@ -300,9 +300,13 @@ const weixinBindingSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.max(2_048)
|
||||
.refine((value) => new URL(value).protocol === 'https:', {
|
||||
message: '微信服务地址必须使用 HTTPS'
|
||||
}),
|
||||
.refine(
|
||||
(value) =>
|
||||
['http:', 'https:'].includes(new URL(value).protocol),
|
||||
{
|
||||
message: '微信服务地址必须使用 HTTP 或 HTTPS'
|
||||
}
|
||||
),
|
||||
token: z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
dshHome: 'C:\\controlled-dsh-home',
|
||||
skillPackages: []
|
||||
})
|
||||
).rejects.toThrow('secure OpenAI-compatible')
|
||||
).rejects.toThrow('HTTP or HTTPS OpenAI-compatible')
|
||||
})
|
||||
|
||||
it('suppresses console payloads instead of contaminating stdout', () => {
|
||||
|
||||
@@ -93,7 +93,7 @@ function validateHostConfig(
|
||||
!isDeepSeekHarnessCompatibleBaseUrl(config.baseUrl)
|
||||
) {
|
||||
throw new Error(
|
||||
'Controlled Harness requires a secure OpenAI-compatible Chat Completions endpoint'
|
||||
'Controlled Harness requires an HTTP or HTTPS OpenAI-compatible Chat Completions endpoint'
|
||||
)
|
||||
}
|
||||
if (!config.credentialRefs.length) {
|
||||
|
||||
+66
-1
@@ -3917,7 +3917,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
role: 'assistant' as const,
|
||||
content: '增量内容',
|
||||
createdAt: 1,
|
||||
state: 'streaming' as const
|
||||
state: 'streaming' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed' as const,
|
||||
summary: '完整远程工具结果',
|
||||
output: '远程输出'.repeat(10_000)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6697,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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ export class ReleaseNotesService {
|
||||
async getPending(): Promise<ReleaseNotesSnapshot> {
|
||||
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) => {
|
||||
|
||||
@@ -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' })
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -458,7 +458,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
const { store } = await createStore({
|
||||
GOODBUDDY_MODEL_API_KEY: apiKey,
|
||||
GOODBUDDY_MODEL_BASE_URL:
|
||||
'https://gateway.example/openai/v1',
|
||||
'http://gateway.example/openai/v1?api-version=1',
|
||||
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
||||
})
|
||||
|
||||
@@ -467,7 +467,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
deepseekHarnessModelProfile: {
|
||||
id: 'goodbuddy-platform-harness',
|
||||
name: '管理员预置模型',
|
||||
baseUrl: 'https://gateway.example/openai/v1',
|
||||
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||
modelName: 'qwen-plus',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
@@ -486,28 +486,11 @@ describe('RuntimeSettingsStore', () => {
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an insecure public endpoint',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'http://gateway.example/v1',
|
||||
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
||||
}
|
||||
],
|
||||
[
|
||||
'an endpoint with embedded credentials',
|
||||
'an unsupported endpoint protocol',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL:
|
||||
'https://user:secret@gateway.example/v1',
|
||||
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
||||
}
|
||||
],
|
||||
[
|
||||
'an endpoint with a query string',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL:
|
||||
'https://gateway.example/v1?api-version=1',
|
||||
'ftp://gateway.example/v1',
|
||||
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -2290,7 +2290,7 @@ export class RuntimeSettingsStore {
|
||||
}
|
||||
if (!isDeepSeekHarnessModelProfile(profile)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 仅支持使用 API Key 的安全 OpenAI 兼容 Chat Completions 连接'
|
||||
'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(<App />)
|
||||
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(<App />)
|
||||
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(<App />)
|
||||
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(<App />)
|
||||
|
||||
|
||||
+161
-26
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
<ChatTimeline
|
||||
artifactById={new Map()}
|
||||
conversationId="conversation-1"
|
||||
hiddenMessageCount={0}
|
||||
isUnusedConversation={false}
|
||||
locale="zh-CN"
|
||||
messageStartIndex={0}
|
||||
messages={messages}
|
||||
{...callbacks}
|
||||
retryContent=""
|
||||
totalMessageCount={messages.length}
|
||||
/>
|
||||
)
|
||||
|
||||
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 最终回复')
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ConversationMessageBlock, { type: 'tool' }>
|
||||
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 (
|
||||
<details
|
||||
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary>
|
||||
<span className="subagent-status-card__identity">
|
||||
<span className="subagent-status-card__heading">
|
||||
<span className="subagent-status-card__badge">
|
||||
<Bot aria-hidden="true" size={13} />
|
||||
{t('chat.subagents.badge')}
|
||||
</span>
|
||||
<strong>{subagent.expertName}</strong>
|
||||
</span>
|
||||
{subagent.reason && (
|
||||
<span className="subagent-status-card__task">
|
||||
<small>{t('chat.subagents.task')}</small>
|
||||
<span>{subagent.reason}</span>
|
||||
</span>
|
||||
)}
|
||||
<small className="subagent-status-card__source">
|
||||
{source}
|
||||
</small>
|
||||
</span>
|
||||
<span
|
||||
className={`subagent-status-card__status subagent-status-card__status--${subagent.state}`}
|
||||
>
|
||||
<StateIcon aria-hidden="true" size={14} />
|
||||
{t(`chat.subagents.states.${subagent.state}`)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="subagent-status-card__chevron"
|
||||
size={15}
|
||||
/>
|
||||
</summary>
|
||||
{expanded && (
|
||||
<div className="subagent-status-card__details">
|
||||
{subagent.output ? (
|
||||
<section>
|
||||
<strong>{t('chat.subagents.output')}</strong>
|
||||
<div className="markdown-content">
|
||||
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<p>{t('chat.subagents.noOutput')}</p>
|
||||
)}
|
||||
{subagent.error &&
|
||||
(subagent.state === 'failed' ||
|
||||
subagent.state === 'cancelled') && (
|
||||
<section className="subagent-status-card__error">
|
||||
<strong>{t('chat.subagents.error')}</strong>
|
||||
<p>{subagent.error}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
})
|
||||
|
||||
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) => (
|
||||
<details
|
||||
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
||||
{subagents.map((subagent) => (
|
||||
<SubagentStatusCard
|
||||
key={subagent.childTaskId}
|
||||
>
|
||||
<summary>
|
||||
<Bot aria-hidden="true" size={15} />
|
||||
<span className="subagent-status-card__identity">
|
||||
<strong>{subagent.expertName}</strong>
|
||||
<small>
|
||||
{subagent.routingMode === 'smart'
|
||||
? t('chat.subagents.smart')
|
||||
: t('chat.subagents.manual')}
|
||||
</small>
|
||||
</span>
|
||||
<span>{t(`chat.subagents.states.${subagent.state}`)}</span>
|
||||
</summary>
|
||||
<div className="subagent-status-card__details">
|
||||
{subagent.output ? (
|
||||
<section>
|
||||
<strong>{t('chat.subagents.output')}</strong>
|
||||
<div className="markdown-content">
|
||||
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<p>{t('chat.subagents.noOutput')}</p>
|
||||
)}
|
||||
{(subagent.error || subagent.reason) &&
|
||||
(subagent.state === 'failed' ||
|
||||
subagent.state === 'cancelled') && (
|
||||
<section className="subagent-status-card__error">
|
||||
<strong>{t('chat.subagents.error')}</strong>
|
||||
<p>{subagent.error ?? subagent.reason}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
subagent={subagent}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
@@ -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({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{message.subagents && message.subagents.length > 0 && (
|
||||
<SubagentStatusList subagents={message.subagents} />
|
||||
)}
|
||||
{message.blocks && message.blocks.length > 0 ? (
|
||||
<div className="message-blocks">
|
||||
{groupMessageBlocks(message.blocks).map((item) =>
|
||||
item.kind === 'tools' ? (
|
||||
<ToolExecutionList key={item.id} tools={item.tools} />
|
||||
) : item.kind === 'subagents' ? (
|
||||
<SubagentStatusList
|
||||
key={item.id}
|
||||
subagents={item.childTaskIds.flatMap((childTaskId) => {
|
||||
const subagent = subagentsById.get(childTaskId)
|
||||
return subagent ? [subagent] : []
|
||||
})}
|
||||
/>
|
||||
) : item.block.type === 'reasoning' ? (
|
||||
<MessageReasoning
|
||||
content={item.block.content}
|
||||
@@ -510,6 +606,9 @@ function ChatMessageRowView({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{unorderedSubagents && unorderedSubagents.length > 0 && (
|
||||
<SubagentStatusList subagents={unorderedSubagents} />
|
||||
)}
|
||||
{message.artifactIds?.map((artifactId) => {
|
||||
const candidate = artifactById.get(artifactId)
|
||||
const artifact =
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import Quill from 'quill'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MagicNoteRichContent } from '../../shared/magic-notes-contracts'
|
||||
import { MagicNoteEditor } from './MagicNoteEditor'
|
||||
@@ -45,6 +46,46 @@ describe('MagicNoteEditor', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['-', 'bullet'],
|
||||
['*', 'bullet'],
|
||||
['1.', 'ordered'],
|
||||
['[ ]', 'unchecked'],
|
||||
['[x]', 'checked']
|
||||
])(
|
||||
'keeps the native Quill %s list shortcut enabled',
|
||||
async (marker, expectedList) => {
|
||||
const onChange = vi.fn<(content: MagicNoteRichContent) => void>()
|
||||
const { container } = render(
|
||||
<MagicNoteEditor
|
||||
ariaLabel="笔记正文"
|
||||
onChange={onChange}
|
||||
onError={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const editor = container.querySelector<HTMLElement>('.ql-editor')
|
||||
const quillContainer = container.querySelector<HTMLElement>(
|
||||
'.magic-note-editor__content'
|
||||
)
|
||||
expect(editor).not.toBeNull()
|
||||
expect(quillContainer).not.toBeNull()
|
||||
|
||||
const quill = Quill.find(quillContainer!) as Quill
|
||||
quill.setText(marker, 'user')
|
||||
quill.setSelection(marker.length, 0, 'silent')
|
||||
fireEvent.keyDown(editor!, { key: ' ' })
|
||||
|
||||
await waitFor(() => {
|
||||
const content = onChange.mock.calls.at(-1)?.[0]
|
||||
expect(
|
||||
content?.ops.some(
|
||||
(operation) => operation.attributes?.list === expectedList
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('intercepts a pasted image before Quill and inserts it once', async () => {
|
||||
const onChange = vi.fn<(content: MagicNoteRichContent) => void>()
|
||||
const { container } = render(
|
||||
|
||||
@@ -399,6 +399,11 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /发布笔记/ })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
const updatedTime = screen
|
||||
.getByRole('button', { name: /发布笔记/ })
|
||||
.querySelector('time')
|
||||
expect(updatedTime).toHaveAttribute('datetime', detail.updatedAt)
|
||||
expect(updatedTime).toHaveTextContent('更新于')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||
const selectedTodoButton = screen
|
||||
|
||||
@@ -1657,12 +1657,21 @@ export function MagicNotesWorkspace({
|
||||
{note.preview || t('notes.noPreview')}
|
||||
</span>
|
||||
<span className="magic-note-list-item__meta">
|
||||
{t(
|
||||
note.entryCount === 1
|
||||
? 'notes.entryCountOne'
|
||||
: 'notes.entryCountOther',
|
||||
{ count: note.entryCount }
|
||||
)}
|
||||
<span>
|
||||
{t(
|
||||
note.entryCount === 1
|
||||
? 'notes.entryCountOne'
|
||||
: 'notes.entryCountOther',
|
||||
{ count: note.entryCount }
|
||||
)}
|
||||
</span>
|
||||
<time dateTime={note.updatedAt}>
|
||||
{t('notes.updatedAt', {
|
||||
date: dateFormatter.format(
|
||||
new Date(note.updatedAt)
|
||||
)
|
||||
})}
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -82,13 +82,15 @@ export const magicNotes = {
|
||||
noPreview: 'No entries yet',
|
||||
entryCountOne: '{{count}} entry',
|
||||
entryCountOther: '{{count}} entries',
|
||||
updatedAt: 'Updated {{date}}',
|
||||
emptySelectionTitle: 'No note selected',
|
||||
emptySelectionDescription:
|
||||
'Select a note on the left, or create a note to start writing.',
|
||||
newEntryLabel: 'New entry content',
|
||||
composerImmediateHint: 'AI comments after you stop typing for 5 seconds',
|
||||
composerImmediateHint:
|
||||
'Type -, 1., [ ], or [x] then Space to start a list; AI comments after 5 seconds of inactivity',
|
||||
composerRichTextHint:
|
||||
'Supports rich text and pasting or dropping local images, videos, and attachments',
|
||||
'Type -, 1., [ ], or [x] then Space to start a list; you can also paste or drop local files',
|
||||
emptyEntries: 'No entries yet. Write the first entry above.',
|
||||
editEntryLabel: 'Edit entry content',
|
||||
entryAt: '{{date}} entry'
|
||||
|
||||
@@ -742,7 +742,7 @@ export const settings = {
|
||||
incompatibleImageProtocol:
|
||||
'Incompatible (image generation protocol is unsupported)',
|
||||
incompatibleHarnessProtocol:
|
||||
'Incompatible (requires Chat Completions, an API key, and a secure endpoint)',
|
||||
'Incompatible (requires Chat Completions, an API key, and an HTTP/HTTPS endpoint)',
|
||||
secureStorageWarning:
|
||||
'Secure system key storage is unavailable. Use an environment variable to avoid storing an API Key in plaintext.'
|
||||
},
|
||||
|
||||
@@ -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: '进行中',
|
||||
|
||||
@@ -77,11 +77,14 @@ export const magicNotes = {
|
||||
noPreview: '还没有记录',
|
||||
entryCountOne: '{{count}} 条记录',
|
||||
entryCountOther: '{{count}} 条记录',
|
||||
updatedAt: '更新于 {{date}}',
|
||||
emptySelectionTitle: '还没有选择笔记',
|
||||
emptySelectionDescription: '从左侧选择笔记,或新建一篇笔记开始记录。',
|
||||
newEntryLabel: '新记录内容',
|
||||
composerImmediateHint: '停止输入 5 秒后自动评论',
|
||||
composerRichTextHint: '支持富文本,可粘贴或拖入本地图片、视频和附件',
|
||||
composerImmediateHint:
|
||||
'输入 -、1.、[ ] 或 [x] 后按空格可创建列表;停止输入 5 秒后自动评论',
|
||||
composerRichTextHint:
|
||||
'输入 -、1.、[ ] 或 [x] 后按空格可创建列表;也可粘贴或拖入本地文件',
|
||||
emptyEntries: '还没有记录,在上方写下第一条内容。',
|
||||
editEntryLabel: '编辑记录内容',
|
||||
entryAt: '{{date}} 的记录'
|
||||
|
||||
@@ -681,7 +681,7 @@ export const settings = {
|
||||
incompatible: '不兼容',
|
||||
incompatibleImageProtocol: '不兼容(不支持图像生成协议)',
|
||||
incompatibleHarnessProtocol:
|
||||
'不兼容(需 Chat Completions、API Key 和安全地址)',
|
||||
'不兼容(需 Chat Completions、API Key 和 HTTP/HTTPS 地址)',
|
||||
secureStorageWarning:
|
||||
'当前系统密钥服务不可用。为了避免明文落盘,请使用环境变量提供 API Key。'
|
||||
},
|
||||
|
||||
+130
-15
@@ -1609,8 +1609,24 @@
|
||||
}
|
||||
|
||||
.magic-note-list-item__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-list-item__meta > :where(span, time) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-note-list-item__meta time {
|
||||
flex-shrink: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.magic-notes-stream-pane {
|
||||
@@ -4891,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;
|
||||
}
|
||||
|
||||
@@ -5243,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;
|
||||
}
|
||||
|
||||
@@ -5278,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);
|
||||
}
|
||||
|
||||
@@ -5333,6 +5442,12 @@ button > svg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@keyframes subagent-status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject,
|
||||
localConversationSaveBatchSchema,
|
||||
persistedProjectExecutionSpaceSchema,
|
||||
projectCreateSchema,
|
||||
projectExecutionSpaceSchema,
|
||||
@@ -203,3 +204,87 @@ describe('conversation branch contracts', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation persistence contracts', () => {
|
||||
it('preserves complete tool details without persistence length limits', () => {
|
||||
const input = 'input'.repeat(10_000)
|
||||
const output = 'output'.repeat(20_000)
|
||||
const error = 'error'.repeat(10_000)
|
||||
const batch = [
|
||||
{
|
||||
header: {
|
||||
id: '00000000-0000-4000-8000-000000000301',
|
||||
title: '远程工具会话',
|
||||
updatedAt: 1
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000302',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed',
|
||||
summary: '完整远程工具结果',
|
||||
input,
|
||||
output,
|
||||
error
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,8 +133,8 @@ export type ConversationQueueItem = z.infer<
|
||||
|
||||
export const conversationToolActivitySchema = z
|
||||
.object({
|
||||
callId: z.string().max(256).optional(),
|
||||
name: z.string().max(200),
|
||||
callId: z.string().optional(),
|
||||
name: z.string(),
|
||||
state: z.enum([
|
||||
'pending',
|
||||
'running',
|
||||
@@ -144,10 +144,10 @@ export const conversationToolActivitySchema = z
|
||||
'cancelled',
|
||||
'interrupted'
|
||||
]),
|
||||
summary: z.string().max(2_000),
|
||||
input: z.string().max(4_000).optional(),
|
||||
output: z.string().max(16_000).optional(),
|
||||
error: z.string().max(2_000).optional()
|
||||
summary: z.string(),
|
||||
input: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-18
@@ -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<typeof approvalDecisionSchema>
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -9,9 +9,14 @@ describe('DeepSeek Harness model compatibility', () => {
|
||||
'https://api.deepseek.com',
|
||||
'https://gateway.example/openai/v1',
|
||||
'https://gateway.example:8443/v1',
|
||||
'http://gateway.example/v1',
|
||||
'http://192.168.1.50:11434/v1',
|
||||
'http://127.0.0.1:11434/v1',
|
||||
'http://localhost:1234/v1',
|
||||
'http://[::1]:8080/v1'
|
||||
'http://[::1]:8080/v1',
|
||||
'https://gateway.example/v1?api-version=2025-01-01',
|
||||
'https://gateway.example/v1#chat',
|
||||
'http://user:password@gateway.example/v1'
|
||||
])('accepts OpenAI-compatible base URL %s', (baseUrl) => {
|
||||
expect(isDeepSeekHarnessCompatibleBaseUrl(baseUrl)).toBe(true)
|
||||
expect(
|
||||
@@ -24,12 +29,10 @@ describe('DeepSeek Harness model compatibility', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://gateway.example/v1',
|
||||
'file:///private/config',
|
||||
'https://user:secret@gateway.example/v1',
|
||||
'https://gateway.example/v1?api-version=2025-01-01',
|
||||
'https://gateway.example/v1#chat'
|
||||
])('rejects unsafe or unsupported base URL %s', (baseUrl) => {
|
||||
'ftp://gateway.example/v1',
|
||||
'not-a-url'
|
||||
])('rejects unsupported base URL %s', (baseUrl) => {
|
||||
expect(isDeepSeekHarnessCompatibleBaseUrl(baseUrl)).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,35 +4,14 @@ type DeepSeekHarnessModelProfile = {
|
||||
authentication: string
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalized = hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/gu, '')
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
normalized === '127.0.0.1' ||
|
||||
normalized === '::1'
|
||||
)
|
||||
}
|
||||
|
||||
export function isDeepSeekHarnessCompatibleBaseUrl(
|
||||
value: string
|
||||
): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
!url.hostname ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
url.protocol === 'https:' ||
|
||||
(url.protocol === 'http:' &&
|
||||
isLoopbackHostname(url.hostname))
|
||||
Boolean(url.hostname) &&
|
||||
(url.protocol === 'http:' || url.protocol === 'https:')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user