Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd304fa1d | ||
|
|
7c6f15d3c0 | ||
|
|
6dc219f3a3 | ||
|
|
acc5a8e2d8 | ||
|
|
9f0f231e3b | ||
|
|
967796539f | ||
|
|
8e08ab0bac |
@@ -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
|
not cover bulk, high-cost, destructive, publishing, messaging, purchasing, or
|
||||||
other consequential external actions.
|
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
|
Before committing or pushing, inspect `git status`, `git diff`, and
|
||||||
`git diff --cached`. Do not commit secrets, local databases, logs, generated
|
`git diff --cached`. Do not commit secrets, local databases, logs, generated
|
||||||
credentials, or private user artifacts.
|
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 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。
|
- OpenCode、Continue 和 DeepSeek Harness 后续的 Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;只选择 Conversation 或 Task,并按 Task 聚合执行状态,不显示 Job/Run 树或独立操作菜单。内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。
|
||||||
- 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。
|
- 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。
|
||||||
- Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。
|
- 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 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
|
||||||
- Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。
|
- Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。
|
||||||
- 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。
|
- 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。
|
||||||
@@ -626,6 +627,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
|||||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||||
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
|
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
|
||||||
|
- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
|
||||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"agentVersion": "0.11.9",
|
"agentVersion": "0.11.13",
|
||||||
"protocol": {
|
"protocol": {
|
||||||
"major": 2,
|
"major": 2,
|
||||||
"minor": 0
|
"minor": 0
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ Electron utilityProcess
|
|||||||
├─ 本地 Shell / Filesystem Provider
|
├─ 本地 Shell / Filesystem Provider
|
||||||
├─ 最小工具集与 Main 代理 MCP
|
├─ 最小工具集与 Main 代理 MCP
|
||||||
└─ Main 明确启用的第三方 Cordis 插件
|
└─ Main 明确启用的第三方 Cordis 插件
|
||||||
│ HTTPS
|
│ HTTP/HTTPS
|
||||||
▼
|
▼
|
||||||
用户选择的 OpenAI 兼容模型连接
|
用户选择的 OpenAI 兼容模型连接
|
||||||
```
|
```
|
||||||
@@ -325,12 +325,13 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
|||||||
- 历史以明确的“不可信会话数据”结构传入,不能拼接成系统指令。
|
- 历史以明确的“不可信会话数据”结构传入,不能拼接成系统指令。
|
||||||
- 用户分配的 Skill 只通过 Main 校验的包路径进入 Host,并在 Agent scope 注册;不得把 Skill 内容伪装成用户 Prompt。
|
- 用户分配的 Skill 只通过 Main 校验的包路径进入 Host,并在 Agent scope 注册;不得把 Skill 内容伪装成用户 Prompt。
|
||||||
|
|
||||||
### 9.3 取消与超时
|
### 9.3 取消与有界控制操作
|
||||||
|
|
||||||
- 用户取消时立即发送 `session/cancel`。
|
- 用户取消时立即发送 `session/cancel`。
|
||||||
- 取消等待有界,超时后关闭连接并终止整个 Harness 进程。
|
- 取消等待有界,超时后关闭连接并终止整个 Harness 进程。
|
||||||
- 初始化、握手、Session 创建、Prompt、权限回传和关闭分别使用独立超时。
|
- 初始化、握手、请求准备、权限回传和关闭分别使用独立控制超时。
|
||||||
- Prompt 超时与用户取消使用不同错误类型,不能被宽泛 catch 抹平。
|
- 生产 Prompt 不设置固定墙钟总时限,由 Harness 自身协议终态或用户取消结束。测试可以
|
||||||
|
显式注入短 Prompt 时限验证取消传播,超时错误与用户取消仍不能被宽泛 catch 抹平。
|
||||||
- 取消后仍可接收并丢弃该请求的最终协议结算帧,但不得写入下一请求。
|
- 取消后仍可接收并丢弃该请求的最终协议结算帧,但不得写入下一请求。
|
||||||
|
|
||||||
### 9.4 释放与退出
|
### 9.4 释放与退出
|
||||||
@@ -426,8 +427,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
|
|||||||
|
|
||||||
- 协议必须是 `openai-chat-completions`。
|
- 协议必须是 `openai-chat-completions`。
|
||||||
- 认证必须是 API Key。
|
- 认证必须是 API Key。
|
||||||
- 公网服务地址必须使用 HTTPS;`localhost`、`127.0.0.1` 和 `::1` 回环地址可以使用 HTTP。
|
- 服务地址支持 HTTP 和 HTTPS,不根据主机位置、端口、凭据、查询参数或片段增加额外限制。
|
||||||
- 服务地址可以使用自定义主机、端口和部署路径,但不得包含用户名、密码、查询参数或片段。
|
|
||||||
- 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。
|
- 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。
|
||||||
- 模型名称和服务地址由 Main 传入受控 Host。
|
- 模型名称和服务地址由 Main 传入受控 Host。
|
||||||
- 图片能力只读取所选 GoodBuddy 模型连接的 `supportsImageInput`;Main、Utility 启动配置、ACP 能力和 Pi-AI 模型输入模态必须使用同一个布尔值。
|
- 图片能力只读取所选 GoodBuddy 模型连接的 `supportsImageInput`;Main、Utility 启动配置、ACP 能力和 Pi-AI 模型输入模态必须使用同一个布尔值。
|
||||||
@@ -469,7 +469,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
|
|||||||
| stderr 累计 | 64 KiB |
|
| stderr 累计 | 64 KiB |
|
||||||
| Host 启动 | 10 秒基础预算 + 每插件 5 秒激活与最多 1 秒失败清理,插件序列最多 91 秒;Main 另预留 2 秒持久化失败状态 |
|
| Host 启动 | 10 秒基础预算 + 每插件 5 秒激活与最多 1 秒失败清理,插件序列最多 91 秒;Main 另预留 2 秒持久化失败状态 |
|
||||||
| ACP 初始化与内部握手 | 每阶段 10 秒 |
|
| ACP 初始化与内部握手 | 每阶段 10 秒 |
|
||||||
| 单次 Prompt | 10 分钟 |
|
| 单次 Prompt | 无固定墙钟总时限,由 Harness 终态或用户取消结束 |
|
||||||
| 有序关闭宽限期 | 2 秒 |
|
| 有序关闭宽限期 | 2 秒 |
|
||||||
|
|
||||||
超过限制时应取消当前请求。协议帧、队列或 stderr 持续异常时,应终止 Runtime 进程,避免继续信任已失控的通道。
|
超过限制时应取消当前请求。协议帧、队列或 stderr 持续异常时,应终止 Runtime 进程,避免继续信任已失控的通道。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
本文记录截至 2026-08-29 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
本文记录截至 2026-08-30 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||||
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
|
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
|
||||||
详细事务与验收边界见
|
详细事务与验收边界见
|
||||||
[SSH Host 远程环境准备与直连下载设计](./remote-host-environment-provisioning-design.md);
|
[SSH Host 远程环境准备与直连下载设计](./remote-host-environment-provisioning-design.md);
|
||||||
@@ -13,8 +13,9 @@ Windows 到 Linux x64 的既有安装、
|
|||||||
Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实
|
Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实
|
||||||
Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与
|
Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与
|
||||||
OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback
|
OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback
|
||||||
路径 capability 后,Agent `0.11.9` 已通过独立 workflow 发布 Linux x64/arm64 复合包和
|
路径 capability 后,Agent `0.11.10` 已通过独立 workflow 发布 Linux x64/arm64 复合包和
|
||||||
签名累计目录;当前桌面源码准备 `0.11.10`,修复 Host 更新并消除重复 payload 验证。失败的
|
签名累计目录;当前桌面源码准备 `0.11.13`,显示本地与远端 OpenCode 原生 Task,并取消
|
||||||
|
GoodBuddy 对生产 Prompt 的固定墙钟总时限。失败的
|
||||||
`agent-v0.11.3` 保持不可变且未发布。
|
`agent-v0.11.3` 保持不可变且未发布。
|
||||||
|
|
||||||
## 产品语义
|
## 产品语义
|
||||||
@@ -193,7 +194,13 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
|||||||
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
|
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
|
||||||
- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。
|
- 不进行 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 与断线
|
## ACP 与断线
|
||||||
|
|
||||||
@@ -207,13 +214,21 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
|||||||
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
||||||
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
||||||
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
||||||
- Runtime 事件进入桌面会话后,Renderer 和 Main 将完整工具详情保存到本地 SQLite,不再对已接受的工具输入、输出、错误或摘要施加额外的会话字段长度限制。
|
- Runtime 事件先经过单事件、单请求输出和每次 Prompt 最多 100 个不同工具调用的边界;
|
||||||
|
通过边界后,Renderer 和 Main 将已接受的工具详情保存到本地 SQLite,不再额外缩短其
|
||||||
|
输入、输出、错误或摘要。
|
||||||
|
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
|
||||||
|
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
|
||||||
|
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
|
||||||
|
状态卡,并持久化任务说明、终态与输出。
|
||||||
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
|
- 同一 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 不自动重放。
|
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
|
||||||
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
|
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
|
||||||
- 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。
|
- 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。
|
||||||
- 终态 close 由 Agent 在一个事务中为两个方向记录 sequence high-water tombstone,删除剩余 frame 和 active channel,并归零对应 journal quota。tombstone 用于拒绝迟到的旧 epoch frame,因此当前不会按时间自动裁剪。
|
- 终态 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
|
- Agent 失联期间继续把 Runtime 输出写入本机 journal;重新连接只校验 controller
|
||||||
identity、恢复 binding,并从 Main 最后确认的 cursor 后同步缺失数据。已接受的
|
identity、恢复 binding,并从 Main 最后确认的 cursor 后同步缺失数据。已接受的
|
||||||
指令和结果不确定的 Provider 请求都不自动重放。
|
指令和结果不确定的 Provider 请求都不自动重放。
|
||||||
@@ -224,8 +239,8 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
|||||||
- 远端 OpenCode 通过 GoodBuddy Agent helper 和每次 Prompt 的私有 Unix socket 使用模型桥。
|
- 远端 OpenCode 通过 GoodBuddy Agent helper 和每次 Prompt 的私有 Unix socket 使用模型桥。
|
||||||
- Main 校验固定模型 profile、协议、请求路径和有界传输格式,并记录 Provider
|
- Main 校验固定模型 profile、协议、请求路径和有界传输格式,并记录 Provider
|
||||||
返回的实际 usage;不限制 Prompt 内的模型调用轮数、工具调用次数、累计 Token
|
返回的实际 usage;不限制 Prompt 内的模型调用轮数、工具调用次数、累计 Token
|
||||||
或单次模型输出 Token。取消、Runtime deadline、请求/响应字节上限和结果不确定时
|
或单次模型输出 Token。取消、请求/响应字节上限和结果不确定时禁止自动重放仍然保留;
|
||||||
禁止自动重放仍然保留。
|
模型桥不会为整个 Runtime Prompt 另设墙钟总时限。
|
||||||
- helper 可以接收同一 Prompt 内并发到达的模型桥请求;它在单一稳定模型桥上按到达
|
- helper 可以接收同一 Prompt 内并发到达的模型桥请求;它在单一稳定模型桥上按到达
|
||||||
顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 HTTP 完整 flush 后才
|
顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 HTTP 完整 flush 后才
|
||||||
发送 delivery ACK。
|
发送 delivery ACK。
|
||||||
@@ -242,12 +257,55 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
|||||||
不修改既有 React state 对象。这样开发环境 StrictMode 重复调用 state updater 时,
|
不修改既有 React state 对象。这样开发环境 StrictMode 重复调用 state updater 时,
|
||||||
block metadata 与 canonical 消息正文保持一致。
|
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 验收记录
|
## 已完成的 E2E 验收记录
|
||||||
|
|
||||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||||
的双架构验收。Agent `0.11.9` 后续已由原生 workflow 发布并公开验证双架构工件;
|
的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||||
当前源码 lock 固定为 `0.11.9`。
|
当前源码 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`,并确认
|
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
||||||
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
||||||
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
||||||
@@ -284,7 +342,12 @@ Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进
|
|||||||
产生冲突的项目创建和设置入口。远端 RPC 拒绝只向
|
产生冲突的项目创建和设置入口。远端 RPC 拒绝只向
|
||||||
Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。
|
Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。
|
||||||
`runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding,
|
`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
|
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
||||||
v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id`
|
v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id`
|
||||||
@@ -314,7 +377,10 @@ v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/
|
|||||||
信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和
|
信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和
|
||||||
payload 仍按原始签名字节及 SHA-256 验证。
|
payload 仍按原始签名字节及 SHA-256 验证。
|
||||||
|
|
||||||
## 发布前验证
|
## 发布前完整回归(不能替代开发验证)
|
||||||
|
|
||||||
|
以下清单只用于复核开发期间已经在真实 Host 上通过的链路,不得作为 Agent 改动的首次
|
||||||
|
真实 Host 验证:
|
||||||
|
|
||||||
1. `npm test`
|
1. `npm test`
|
||||||
2. `npm run typecheck`
|
2. `npm run typecheck`
|
||||||
|
|||||||
@@ -806,8 +806,9 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
|||||||
- 通用 Artifact Event。
|
- 通用 Artifact Event。
|
||||||
- HTML 工作区和成果的静态即时预览。
|
- HTML 工作区和成果的静态即时预览。
|
||||||
- Runtime Inspector Snapshot 与事件。
|
- Runtime Inspector Snapshot 与事件。
|
||||||
- OpenCode 会话、子会话、Todo、工具、用量和取消。
|
- OpenCode 会话、子会话、Todo、工具、用量和取消;本地与托管 SSH 的原生 Task 调用在
|
||||||
- 直连模型及现有 GoodBuddy Subagent 的统一展示。
|
Conversation 中统一显示为可追踪的 Subagent 状态,而不是普通工具参数卡。
|
||||||
|
- 直连模型、OpenCode 原生 Subagent 及现有 GoodBuddy Subagent 的统一展示。
|
||||||
|
|
||||||
### 阶段 2:监督、终端与受管进程
|
### 阶段 2:监督、终端与受管进程
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ GoodBuddy 应能够:
|
|||||||
1. 所有新 IPC 继续执行 Zod 校验和可信主窗口校验。
|
1. 所有新 IPC 继续执行 Zod 校验和可信主窗口校验。
|
||||||
2. 项目根目录、成果路径和上下文路径必须 canonicalize 并验证目录包含关系。
|
2. 项目根目录、成果路径和上下文路径必须 canonicalize 并验证目录包含关系。
|
||||||
3. Child Runtime 环境变量改用最小 allowlist,避免继承无关密钥。
|
3. Child Runtime 环境变量改用最小 allowlist,避免继承无关密钥。
|
||||||
4. 远程委派仅允许 HTTPS,开发环境只放行 loopback。
|
4. 远程委派支持用户配置的 HTTP 和 HTTPS 地址,不按主机位置增加额外限制。
|
||||||
5. 语音、窗口捕获和浏览器上下文分别授权。
|
5. 语音、窗口捕获和浏览器上下文分别授权。
|
||||||
6. 自动化不得绕过工具审批和项目权限。
|
6. 自动化不得绕过工具审批和项目权限。
|
||||||
7. 记忆必须保留来源和作用域。
|
7. 记忆必须保留来源和作用域。
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.11",
|
"version": "0.11.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.11",
|
"version": "0.11.13",
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.25.1",
|
"@agentclientprotocol/sdk": "0.25.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.11",
|
"version": "0.11.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||||
"desktopName": "GoodBuddy",
|
"desktopName": "GoodBuddy",
|
||||||
|
|||||||
@@ -1,6 +1,78 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"releases": [
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "0.11.13",
|
||||||
|
"releasedAt": "2026-08-30",
|
||||||
|
"notes": {
|
||||||
|
"zh-CN": {
|
||||||
|
"highlights": [
|
||||||
|
"GoodBuddy 0.11.13 将本地与托管 SSH 项目中的 OpenCode 原生 Task 显示为独立子 Agent,并移除 GoodBuddy 固定的 Prompt 总时限,让 Runtime 决定长任务生命周期。"
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
"**OpenCode 原生子 Agent。** OpenCode Task 调用现在以独立子 Agent 卡片显示,持续呈现任务、运行状态、输出和错误,不再同时重复为普通工具。"
|
||||||
|
],
|
||||||
|
"fixes": [
|
||||||
|
"**长任务不再被固定时限中止。** 移除本地 OpenCode、Continue、DeepSeek Harness 和托管 SSH Prompt 的 2/10 分钟外层总时限。任务可持续数小时或更久,直到正常完成或用户取消;Runtime 自身超时仍然生效。",
|
||||||
|
"**OpenCode 启动更可靠。** 改用本机健康接口检测启动完成,不再依赖固定日志格式,并为较慢设备保留更充足的冷启动时间,减少已启动却误报超时。",
|
||||||
|
"**HTTP 服务地址配置更灵活。** 模型连接、DeepSeek Harness 与微信绑定可使用用户明确配置的合法 HTTP 或 HTTPS 地址,包括局域网与公网服务;仍保留协议和 API Key 校验。"
|
||||||
|
],
|
||||||
|
"notices": [
|
||||||
|
"用户仍可随时停止任务;启动、控制请求、断线恢复、取消收敛、清理和输出大小仍有独立边界。",
|
||||||
|
"托管 SSH Host 需要单独发布的 Agent `0.11.13`。提供 Linux x64 和 arm64;桌面包不内置 Agent 或远程 Runtime。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
"highlights": [
|
||||||
|
"GoodBuddy 0.11.13 presents native OpenCode Tasks as dedicated subagents in local and managed SSH projects, and removes GoodBuddy's fixed Prompt deadline so the Runtime owns long-task lifetime."
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
"**Native OpenCode subagents.** OpenCode Task calls now appear as dedicated subagent cards with the task, running state, output, and errors, without a duplicate generic-tool entry."
|
||||||
|
],
|
||||||
|
"fixes": [
|
||||||
|
"**Long tasks are no longer stopped by a fixed limit.** Removed the outer 2/10-minute total deadline from local OpenCode, Continue, DeepSeek Harness, and managed SSH Prompts. Tasks can run for hours or longer until completion or user cancellation; Runtime-owned timeouts still apply.",
|
||||||
|
"**More reliable OpenCode startup.** GoodBuddy now detects readiness through the local health endpoint instead of a fixed log format and allows more cold-start time on slower devices, avoiding false startup timeouts.",
|
||||||
|
"**More flexible HTTP service endpoints.** Model connections, DeepSeek Harness, and Weixin binding can use explicitly configured valid HTTP or HTTPS endpoints, including LAN and public services, while retaining protocol and API-key validation."
|
||||||
|
],
|
||||||
|
"notices": [
|
||||||
|
"Users can still stop tasks at any time. Startup, control requests, reconnection recovery, cancellation settlement, cleanup, and output sizes retain independent bounds.",
|
||||||
|
"Managed SSH Hosts require the separately published Agent `0.11.13`. Linux x64 and arm64 are provided; desktop packages do not embed the Agent or Remote Runtime."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.11.12",
|
||||||
|
"releasedAt": "2026-08-30",
|
||||||
|
"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",
|
"version": "0.11.11",
|
||||||
"releasedAt": "2026-08-30",
|
"releasedAt": "2026-08-30",
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { join } from 'node:path'
|
|||||||
import { PassThrough } from 'node:stream'
|
import { PassThrough } from 'node:stream'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||||
|
import {
|
||||||
|
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||||
|
} from '../shared/remote-agent-contracts'
|
||||||
import {
|
import {
|
||||||
launchDirectLinuxStdioProcessOwner,
|
launchDirectLinuxStdioProcessOwner,
|
||||||
reconcileOrphanedDirectLinuxStdioProcesses,
|
reconcileOrphanedDirectLinuxStdioProcesses,
|
||||||
@@ -119,6 +122,47 @@ describe('direct Linux stdio Runtime ownership', () => {
|
|||||||
registry.close()
|
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 () => {
|
it('rejects PID reuse before sending any signal', async () => {
|
||||||
const registry = createRegistry()
|
const registry = createRegistry()
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
@@ -208,6 +252,48 @@ describe('direct Linux stdio Runtime ownership', () => {
|
|||||||
registry.close()
|
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 () => {
|
it('startup reconciles only owners from the current installation', async () => {
|
||||||
const registry = createRegistry()
|
const registry = createRegistry()
|
||||||
const reservation = {
|
const reservation = {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
sha256DigestSchema
|
sha256DigestSchema
|
||||||
} from '../shared/agent-protocol/contracts'
|
} from '../shared/agent-protocol/contracts'
|
||||||
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
||||||
|
import {
|
||||||
|
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||||
|
} from '../shared/remote-agent-contracts'
|
||||||
import {
|
import {
|
||||||
remoteRuntimeBundleManifestSchema,
|
remoteRuntimeBundleManifestSchema,
|
||||||
type RemoteRuntimeBundleManifest
|
type RemoteRuntimeBundleManifest
|
||||||
@@ -31,6 +34,7 @@ const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
|
|||||||
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
|
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
|
||||||
const DEFAULT_MAXIMUM_LISTENERS = 8
|
const DEFAULT_MAXIMUM_LISTENERS = 8
|
||||||
const DEFAULT_STOP_TIMEOUT_MS = 10_000
|
const DEFAULT_STOP_TIMEOUT_MS = 10_000
|
||||||
|
const DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS = 5_000
|
||||||
const TERM_GRACE_MS = 2_000
|
const TERM_GRACE_MS = 2_000
|
||||||
const POLL_INTERVAL_MS = 25
|
const POLL_INTERVAL_MS = 25
|
||||||
const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN'
|
const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN'
|
||||||
@@ -510,6 +514,10 @@ export class DirectLinuxStdioProcessOwner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#scheduleDeadline(deadlineAt: string): void {
|
#scheduleDeadline(deadlineAt: string): void {
|
||||||
|
if (deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) {
|
||||||
|
this.#clearDeadline()
|
||||||
|
return
|
||||||
|
}
|
||||||
const remaining = remainingMilliseconds(deadlineAt, this.#now())
|
const remaining = remainingMilliseconds(deadlineAt, this.#now())
|
||||||
this.#clearDeadline()
|
this.#clearDeadline()
|
||||||
const generation = this.#promptGeneration
|
const generation = this.#promptGeneration
|
||||||
@@ -550,7 +558,10 @@ export async function launchDirectLinuxStdioProcessOwner(
|
|||||||
now(),
|
now(),
|
||||||
limits.maximumPromptRuntimeMilliseconds
|
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 baseProfile = resolveProfile(options, manifest)
|
||||||
const ownerToken = (options.randomOwnerToken ?? (() => randomBytes(16).toString('hex')))()
|
const ownerToken = (options.randomOwnerToken ?? (() => randomBytes(16).toString('hex')))()
|
||||||
if (!/^[a-f0-9]{32}$/u.test(ownerToken)) {
|
if (!/^[a-f0-9]{32}$/u.test(ownerToken)) {
|
||||||
@@ -603,8 +614,18 @@ export async function launchDirectLinuxStdioProcessOwner(
|
|||||||
await waitForSpawn(child, options.signal, timeoutMs)
|
await waitForSpawn(child, options.signal, timeoutMs)
|
||||||
if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID')
|
if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID')
|
||||||
const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity
|
const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity
|
||||||
const processIdentity = await readIdentity(child.pid)
|
const processIdentity = await waitForLaunchedIdentity({
|
||||||
assertLaunchedIdentity(processIdentity, profile)
|
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({
|
const supervisorIdentityDigest = supervisorDigest({
|
||||||
installationId,
|
installationId,
|
||||||
ownerId,
|
ownerId,
|
||||||
@@ -871,15 +892,72 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertLaunchedIdentity(
|
async function waitForLaunchedIdentity(input: {
|
||||||
identity: LinuxRuntimeProcessIdentity,
|
child: DirectLinuxStdioChild
|
||||||
|
pid: number
|
||||||
profile: OpenCodeLaunchProfile
|
profile: OpenCodeLaunchProfile
|
||||||
): void {
|
readIdentity: typeof readLinuxRuntimeProcessIdentity
|
||||||
if (
|
signal?: AbortSignal
|
||||||
identity.executablePath !== profile.processExecutable ||
|
deadline: number
|
||||||
identity.processGroupId !== identity.pid
|
now: () => number
|
||||||
) {
|
}): Promise<LinuxRuntimeProcessIdentity> {
|
||||||
throw identityError('Spawned Runtime does not match the launch identity')
|
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,
|
now: number,
|
||||||
maximumRuntimeMilliseconds: number
|
maximumRuntimeMilliseconds: number
|
||||||
): string {
|
): string {
|
||||||
|
if (requestedDeadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) {
|
||||||
|
return requestedDeadlineAt
|
||||||
|
}
|
||||||
const requested = Date.parse(requestedDeadlineAt)
|
const requested = Date.parse(requestedDeadlineAt)
|
||||||
if (!Number.isFinite(requested) || requested <= now) {
|
if (!Number.isFinite(requested) || requested <= now) {
|
||||||
throw new DirectLinuxStdioProcessOwnerError(
|
throw new DirectLinuxStdioProcessOwnerError(
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ describe('OpenCode bubblewrap launch profile', () => {
|
|||||||
'--opencode-entrypoint',
|
'--opencode-entrypoint',
|
||||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||||
])
|
])
|
||||||
|
expect(profile.processExecutable).toBe(
|
||||||
|
resolve(agentInstallationDirectory, 'node')
|
||||||
|
)
|
||||||
expect(profile.args.join('\0')).not.toMatch(
|
expect(profile.args.join('\0')).not.toMatch(
|
||||||
/api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu
|
/api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ export function createOpenCodeLaunchProfile(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
executable: OPENCODE_BWRAP_EXECUTABLE,
|
executable: OPENCODE_BWRAP_EXECUTABLE,
|
||||||
processExecutable: OPENCODE_BWRAP_EXECUTABLE,
|
processExecutable: runtimeCommand.executable,
|
||||||
args,
|
args,
|
||||||
cwd: scratchDirectory,
|
cwd: scratchDirectory,
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
type AcpJournalCursor,
|
type AcpJournalCursor,
|
||||||
type RemotePromptOperationPreparation
|
type RemotePromptOperationPreparation,
|
||||||
|
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||||
} from '../shared/remote-agent-contracts'
|
} from '../shared/remote-agent-contracts'
|
||||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||||
import type { ControllerLease } from './controller-registry'
|
import type { ControllerLease } from './controller-registry'
|
||||||
@@ -813,6 +814,28 @@ describe('RuntimeAcpBackend', () => {
|
|||||||
).rejects.toMatchObject({ code: 'deadline' })
|
).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 () => {
|
it('stops the process when its output quota is exceeded', async () => {
|
||||||
const fixture = harness({ maximumOutputBytes: 3 })
|
const fixture = harness({ maximumOutputBytes: 3 })
|
||||||
await open(fixture)
|
await open(fixture)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
acpJournalCursorSchema,
|
acpJournalCursorSchema,
|
||||||
remotePromptOperationAcceptanceSchema,
|
remotePromptOperationAcceptanceSchema,
|
||||||
remotePromptOperationPreparationSchema,
|
remotePromptOperationPreparationSchema,
|
||||||
|
UNBOUNDED_REMOTE_PROMPT_DEADLINE,
|
||||||
type AcpJournalCursor,
|
type AcpJournalCursor,
|
||||||
type RemotePromptOperationAcceptance,
|
type RemotePromptOperationAcceptance,
|
||||||
type RemotePromptOperationPreparation
|
type RemotePromptOperationPreparation
|
||||||
@@ -1676,6 +1677,10 @@ export class RuntimeAcpBackend {
|
|||||||
#scheduleDeadline(binding: BindingState, deadlineAt: string): void {
|
#scheduleDeadline(binding: BindingState, deadlineAt: string): void {
|
||||||
if (binding.deadlineTimer !== undefined) {
|
if (binding.deadlineTimer !== undefined) {
|
||||||
clearTimeout(binding.deadlineTimer)
|
clearTimeout(binding.deadlineTimer)
|
||||||
|
binding.deadlineTimer = undefined
|
||||||
|
}
|
||||||
|
if (deadlineAt === UNBOUNDED_REMOTE_PROMPT_DEADLINE) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const delay = Math.max(
|
const delay = Math.max(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -259,10 +259,9 @@ function runtime(
|
|||||||
assertHostCurrent: () => {},
|
assertHostCurrent: () => {},
|
||||||
cancellationGraceMs,
|
cancellationGraceMs,
|
||||||
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
|
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
|
||||||
promptTimeoutMs:
|
...(overrides.promptTimeoutMs === undefined
|
||||||
overrides.promptTimeoutMs ??
|
? {}
|
||||||
overrides.operationTimeoutMs ??
|
: { promptTimeoutMs: overrides.promptTimeoutMs }),
|
||||||
100,
|
|
||||||
usage: {
|
usage: {
|
||||||
runtime: 'opencode',
|
runtime: 'opencode',
|
||||||
provider: 'test',
|
provider: 'test',
|
||||||
@@ -287,10 +286,9 @@ function factoryRuntime(
|
|||||||
assertHostCurrent: () => {},
|
assertHostCurrent: () => {},
|
||||||
cancellationGraceMs: 5,
|
cancellationGraceMs: 5,
|
||||||
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
|
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
|
||||||
promptTimeoutMs:
|
...(overrides.promptTimeoutMs === undefined
|
||||||
overrides.promptTimeoutMs ??
|
? {}
|
||||||
overrides.operationTimeoutMs ??
|
: { promptTimeoutMs: overrides.promptTimeoutMs }),
|
||||||
100,
|
|
||||||
usage: {
|
usage: {
|
||||||
runtime: 'opencode',
|
runtime: 'opencode',
|
||||||
provider: 'test',
|
provider: 'test',
|
||||||
@@ -599,7 +597,7 @@ describe('AcpRemoteRuntime', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each(['prompt-running', 'outcome-unknown'] as const)(
|
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) => {
|
async (state) => {
|
||||||
const store = new MemoryRuntimeSessionBindingStore()
|
const store = new MemoryRuntimeSessionBindingStore()
|
||||||
const active = {
|
const active = {
|
||||||
@@ -625,7 +623,13 @@ describe('AcpRemoteRuntime', () => {
|
|||||||
lastMainAckSequence: '0'
|
lastMainAckSequence: '0'
|
||||||
}
|
}
|
||||||
await store.put(active)
|
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, {
|
const instance = factoryRuntime(factory, store, {
|
||||||
identity: {
|
identity: {
|
||||||
...identity,
|
...identity,
|
||||||
@@ -633,22 +637,28 @@ describe('AcpRemoteRuntime', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(
|
await collect(
|
||||||
collect(
|
instance.run(
|
||||||
instance.run(
|
{ ...request, requestId: 'prompt-2' },
|
||||||
{ ...request, requestId: 'prompt-2' },
|
new AbortController().signal
|
||||||
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(
|
await expect(
|
||||||
store.getByConversation(request.conversationId)
|
store.getByConversation(request.conversationId)
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
bindingId: active.bindingId,
|
bindingId: factory.mock.calls[0]![0],
|
||||||
state: 'outcome-unknown',
|
state: 'ready',
|
||||||
activePromptOperationId: request.requestId
|
activePromptOperationId: undefined
|
||||||
})
|
})
|
||||||
await instance.dispose()
|
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 () => {
|
it('routes Execute permission through the authorizer and denies mutating Ask permission', async () => {
|
||||||
const client: { value?: AgentSideConnection } = {}
|
const client: { value?: AgentSideConnection } = {}
|
||||||
const outcomes: string[] = []
|
const outcomes: string[] = []
|
||||||
@@ -1715,7 +1952,8 @@ describe('AcpRemoteRuntime', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const instance = runtime(server, store, 5, {
|
const instance = runtime(server, store, 5, {
|
||||||
operationTimeoutMs: 20
|
operationTimeoutMs: 20,
|
||||||
|
promptTimeoutMs: 20
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -15,11 +15,15 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AgentRuntimeStatus
|
AgentRuntimeStatus
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
|
import {
|
||||||
|
maximumConversationToolActivities
|
||||||
|
} from '../../shared/assistant-contracts'
|
||||||
import {
|
import {
|
||||||
assertRemotePromptAcceptanceMatchesPreparation,
|
assertRemotePromptAcceptanceMatchesPreparation,
|
||||||
remotePromptOperationAcceptanceSchema,
|
remotePromptOperationAcceptanceSchema,
|
||||||
remotePromptOperationPreparationSchema,
|
remotePromptOperationPreparationSchema,
|
||||||
runtimeSessionBindingSchema,
|
runtimeSessionBindingSchema,
|
||||||
|
UNBOUNDED_REMOTE_PROMPT_DEADLINE,
|
||||||
type RemotePromptOperationAcceptance,
|
type RemotePromptOperationAcceptance,
|
||||||
type RemotePromptOperationPreparation,
|
type RemotePromptOperationPreparation,
|
||||||
type RuntimeSessionBinding
|
type RuntimeSessionBinding
|
||||||
@@ -48,6 +52,10 @@ import {
|
|||||||
type RemoteRuntimeChannel
|
type RemoteRuntimeChannel
|
||||||
} from './remote-runtime-channel'
|
} from './remote-runtime-channel'
|
||||||
import type { RuntimeSessionBindingStore } from './runtime-session-binding-store'
|
import type { RuntimeSessionBindingStore } from './runtime-session-binding-store'
|
||||||
|
import {
|
||||||
|
parseOpenCodeSubagentInput,
|
||||||
|
toOpenCodeSubagentEvent
|
||||||
|
} from './opencode-subagent'
|
||||||
|
|
||||||
type BindingIdentity = Pick<
|
type BindingIdentity = Pick<
|
||||||
RuntimeSessionBinding,
|
RuntimeSessionBinding,
|
||||||
@@ -98,6 +106,7 @@ export type AcpRemoteRuntimeOptions = {
|
|||||||
maxRequestOutputBytes?: number
|
maxRequestOutputBytes?: number
|
||||||
cancellationGraceMs?: number
|
cancellationGraceMs?: number
|
||||||
operationTimeoutMs?: number
|
operationTimeoutMs?: number
|
||||||
|
/** Optional test/operator override. Production prompts are Runtime-controlled. */
|
||||||
promptTimeoutMs?: number
|
promptTimeoutMs?: number
|
||||||
maxPendingUpdates?: number
|
maxPendingUpdates?: number
|
||||||
maxPendingUpdateBytes?: number
|
maxPendingUpdateBytes?: number
|
||||||
@@ -120,6 +129,16 @@ type ActivePrompt = {
|
|||||||
pendingUpdateBytes: number
|
pendingUpdateBytes: number
|
||||||
inboundPaused: boolean
|
inboundPaused: boolean
|
||||||
outputCharacters: number
|
outputCharacters: number
|
||||||
|
toolCalls: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title?: string
|
||||||
|
kind?: string
|
||||||
|
status?: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||||
|
rawInput?: unknown
|
||||||
|
rawOutput?: unknown
|
||||||
|
}
|
||||||
|
>
|
||||||
open: boolean
|
open: boolean
|
||||||
completed: boolean
|
completed: boolean
|
||||||
interruption?: Error
|
interruption?: Error
|
||||||
@@ -162,7 +181,6 @@ type ChannelContext = {
|
|||||||
const DEFAULT_MAX_EVENT_CHARACTERS = 100_000
|
const DEFAULT_MAX_EVENT_CHARACTERS = 100_000
|
||||||
const DEFAULT_MAX_REQUEST_OUTPUT_BYTES = 1_000_000
|
const DEFAULT_MAX_REQUEST_OUTPUT_BYTES = 1_000_000
|
||||||
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000
|
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000
|
||||||
const DEFAULT_PROMPT_TIMEOUT_MS = 120_000
|
|
||||||
const DEFAULT_CANCELLATION_GRACE_MS = 1_500
|
const DEFAULT_CANCELLATION_GRACE_MS = 1_500
|
||||||
const DEFAULT_MAX_PENDING_UPDATES = 1_000
|
const DEFAULT_MAX_PENDING_UPDATES = 1_000
|
||||||
const DEFAULT_MAX_PENDING_UPDATE_BYTES = 4 * 1024 * 1024
|
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 {
|
class RemoteOperationTimeoutError extends Error {
|
||||||
constructor(readonly operation: string) {
|
constructor(readonly operation: string) {
|
||||||
super(`远端 Runtime ${operation}超时`)
|
super(`远端 Runtime ${operation}超时`)
|
||||||
@@ -459,9 +513,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
DEFAULT_OPERATION_TIMEOUT_MS
|
DEFAULT_OPERATION_TIMEOUT_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
private get promptTimeoutMs(): number {
|
private get promptTimeoutMs(): number | undefined {
|
||||||
return this.options.promptTimeoutMs ??
|
return this.options.promptTimeoutMs
|
||||||
DEFAULT_PROMPT_TIMEOUT_MS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async awaitOperation<T>(
|
private async awaitOperation<T>(
|
||||||
@@ -833,7 +886,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
return interrupted
|
return interrupted
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistRejectedPrompt(
|
private async persistClosedBinding(
|
||||||
binding: RuntimeSessionBinding
|
binding: RuntimeSessionBinding
|
||||||
): Promise<RuntimeSessionBinding> {
|
): Promise<RuntimeSessionBinding> {
|
||||||
const closed = runtimeSessionBindingSchema.parse({
|
const closed = runtimeSessionBindingSchema.parse({
|
||||||
@@ -982,9 +1035,10 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
modelBridge?: RemoteModelBridgeSession
|
modelBridge?: RemoteModelBridgeSession
|
||||||
): RemotePromptOperationPreparation {
|
): RemotePromptOperationPreparation {
|
||||||
const workMode = request.workMode === 'execute' ? 'execute' : 'ask'
|
const workMode = request.workMode === 'execute' ? 'execute' : 'ask'
|
||||||
const deadlineAt = new Date(
|
const deadlineAt =
|
||||||
Date.now() + this.promptTimeoutMs
|
this.promptTimeoutMs === undefined
|
||||||
).toISOString()
|
? UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||||
|
: new Date(Date.now() + this.promptTimeoutMs).toISOString()
|
||||||
const budget = {
|
const budget = {
|
||||||
// The Agent applies this to the complete ACP stdin stream, not only
|
// The Agent applies this to the complete ACP stdin stream, not only
|
||||||
// user content. The signed Runtime manifest supplies the effective
|
// user content. The signed Runtime manifest supplies the effective
|
||||||
@@ -1198,24 +1252,19 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
request: AgentExecutionRequest,
|
request: AgentExecutionRequest,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<SessionRecord> {
|
): Promise<SessionRecord> {
|
||||||
const old = await this.awaitLocalOperation(
|
let binding = await this.awaitLocalOperation(
|
||||||
'读取会话绑定',
|
'读取会话绑定',
|
||||||
this.options.bindingStore.getByConversation(conversationId),
|
this.options.bindingStore.getByConversation(conversationId),
|
||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
old &&
|
binding &&
|
||||||
old.state !== 'closed' &&
|
binding.state !== 'closed' &&
|
||||||
old.state !== 'ready'
|
binding.state !== 'ready'
|
||||||
) {
|
) {
|
||||||
await this.persistInterrupted(old)
|
await this.persistClosedBinding(binding)
|
||||||
throw new Error(
|
binding = undefined
|
||||||
old.state === 'prompt-running'
|
|
||||||
? '远端 ACP 会话存在未确认的活动请求,结果未知且不会自动重放'
|
|
||||||
: '远端 ACP 会话恢复状态不确定,已中断且不会自动重放'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
let binding = old
|
|
||||||
let bindingId = binding?.bindingId ?? randomUUID()
|
let bindingId = binding?.bindingId ?? randomUUID()
|
||||||
if (
|
if (
|
||||||
binding?.state === 'ready' &&
|
binding?.state === 'ready' &&
|
||||||
@@ -1437,7 +1486,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
error,
|
error,
|
||||||
'runtime/preparePrompt'
|
'runtime/preparePrompt'
|
||||||
)
|
)
|
||||||
? this.persistRejectedPrompt(binding)
|
? this.persistClosedBinding(binding)
|
||||||
: this.persistInterrupted(binding)
|
: this.persistInterrupted(binding)
|
||||||
await persist.catch(() => undefined)
|
await persist.catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -1483,7 +1532,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
error,
|
error,
|
||||||
'runtime/preparePrompt'
|
'runtime/preparePrompt'
|
||||||
)
|
)
|
||||||
? this.persistRejectedPrompt(current.binding)
|
? this.persistClosedBinding(current.binding)
|
||||||
: this.persistInterrupted(current.binding)
|
: this.persistInterrupted(current.binding)
|
||||||
current.binding = await persist.catch(() => current.binding)
|
current.binding = await persist.catch(() => current.binding)
|
||||||
this.sessions.delete(conversationId)
|
this.sessions.delete(conversationId)
|
||||||
@@ -1776,6 +1825,21 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
(event.input?.length ?? 0) +
|
(event.input?.length ?? 0) +
|
||||||
(event.output?.length ?? 0) +
|
(event.output?.length ?? 0) +
|
||||||
(event.error?.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') {
|
} else if (event.type === 'status') {
|
||||||
if (event.message.length > maximum) {
|
if (event.message.length > maximum) {
|
||||||
event = {
|
event = {
|
||||||
@@ -1819,22 +1883,87 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
update.sessionUpdate === 'tool_call' ||
|
update.sessionUpdate === 'tool_call' ||
|
||||||
update.sessionUpdate === 'tool_call_update'
|
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 = (
|
const name = (
|
||||||
update.title ??
|
toolCall.title ??
|
||||||
update.kind ??
|
toolCall.kind ??
|
||||||
'远端 Runtime 工具'
|
'远端 Runtime 工具'
|
||||||
).slice(0, 200)
|
).slice(0, 200)
|
||||||
const input = safeStringify(update.rawInput, 100_000)
|
const subagent =
|
||||||
const output = safeStringify(update.rawOutput, 100_000)
|
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 {
|
return {
|
||||||
requestId: prompt.requestId,
|
requestId: prompt.requestId,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
callId: update.toolCallId.slice(0, 256),
|
callId,
|
||||||
name,
|
name,
|
||||||
state:
|
state:
|
||||||
update.status === 'in_progress'
|
toolCall.status === 'in_progress'
|
||||||
? 'running'
|
? 'running'
|
||||||
: update.status ?? 'pending',
|
: toolCall.status ?? 'pending',
|
||||||
summary: `远端 Runtime 工具:${name}`,
|
summary: `远端 Runtime 工具:${name}`,
|
||||||
...(input ? { input } : {}),
|
...(input ? { input } : {}),
|
||||||
...(output ? { output } : {})
|
...(output ? { output } : {})
|
||||||
@@ -1930,6 +2059,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
pendingUpdateBytes: 0,
|
pendingUpdateBytes: 0,
|
||||||
inboundPaused: false,
|
inboundPaused: false,
|
||||||
outputCharacters: 0,
|
outputCharacters: 0,
|
||||||
|
toolCalls: new Map(),
|
||||||
open: true,
|
open: true,
|
||||||
completed: false,
|
completed: false,
|
||||||
context
|
context
|
||||||
@@ -1998,10 +2128,12 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
|||||||
prompt.wake?.()
|
prompt.wake?.()
|
||||||
prompt.wake = undefined
|
prompt.wake = undefined
|
||||||
})
|
})
|
||||||
promptTimeout = setTimeout(
|
if (this.promptTimeoutMs !== undefined) {
|
||||||
() => interrupt(new RemoteOperationTimeoutError('执行请求')),
|
promptTimeout = setTimeout(
|
||||||
this.promptTimeoutMs
|
() => interrupt(new RemoteOperationTimeoutError('执行请求')),
|
||||||
)
|
this.promptTimeoutMs
|
||||||
|
)
|
||||||
|
}
|
||||||
yield {
|
yield {
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'status',
|
type: 'status',
|
||||||
|
|||||||
@@ -357,6 +357,9 @@ describe('ContinueHostAdapter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('launches the prepared host through the injected launcher', async () => {
|
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 distribution = await createDistribution()
|
||||||
const skillDirectory = join(
|
const skillDirectory = join(
|
||||||
distribution.cacheRoot,
|
distribution.cacheRoot,
|
||||||
@@ -445,11 +448,14 @@ describe('ContinueHostAdapter', () => {
|
|||||||
const url = String(input)
|
const url = String(input)
|
||||||
if (url.endsWith('/state')) {
|
if (url.endsWith('/state')) {
|
||||||
stateRequests += 1
|
stateRequests += 1
|
||||||
|
if (stateRequests === 2) {
|
||||||
|
now += 25 * 60 * 60_000
|
||||||
|
}
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
session: {
|
session: {
|
||||||
history:
|
history:
|
||||||
stateRequests === 1
|
stateRequests <= 2
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -460,7 +466,7 @@ describe('ContinueHostAdapter', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
usage:
|
usage:
|
||||||
stateRequests === 1
|
stateRequests <= 2
|
||||||
? {
|
? {
|
||||||
promptTokens: 20,
|
promptTokens: 20,
|
||||||
completionTokens: 10,
|
completionTokens: 10,
|
||||||
@@ -478,7 +484,7 @@ describe('ContinueHostAdapter', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isProcessing: false,
|
isProcessing: stateRequests === 2,
|
||||||
messageQueueLength: 0,
|
messageQueueLength: 0,
|
||||||
pendingPermission: null
|
pendingPermission: null
|
||||||
})
|
})
|
||||||
@@ -605,6 +611,90 @@ describe('ContinueHostAdapter', () => {
|
|||||||
expect(existsSync(generatedConfigPath)).toBe(false)
|
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 () => {
|
it('injects scoped knowledge into a temporary copy of a JSONC config', async () => {
|
||||||
const distribution = await createDistribution()
|
const distribution = await createDistribution()
|
||||||
const configPath = join(
|
const configPath = join(
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import type {
|
|||||||
RuntimeNativeSnapshot,
|
RuntimeNativeSnapshot,
|
||||||
RuntimeSettings
|
RuntimeSettings
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
|
import {
|
||||||
|
maximumConversationToolActivities
|
||||||
|
} from '../../shared/assistant-contracts'
|
||||||
import {
|
import {
|
||||||
continueConfigurationPresetSchema,
|
continueConfigurationPresetSchema,
|
||||||
runtimeNativeInventoryLimits,
|
runtimeNativeInventoryLimits,
|
||||||
@@ -66,8 +69,8 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
|
|||||||
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
|
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
|
||||||
const maximumStreamEvents = 5_000
|
const maximumStreamEvents = 5_000
|
||||||
const maximumStreamEventBytes = 2 * 1024 * 1024
|
const maximumStreamEventBytes = 2 * 1024 * 1024
|
||||||
const maximumToolCalls = 100
|
const maximumToolCalls = maximumConversationToolActivities
|
||||||
const maximumExecutionMilliseconds = 10 * 60_000
|
const defaultControlRequestTimeoutMs = 30_000
|
||||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||||
const customMcpName = 'goodbuddy-custom-mcp'
|
const customMcpName = 'goodbuddy-custom-mcp'
|
||||||
export const continueConfigurationRequiredMessage =
|
export const continueConfigurationRequiredMessage =
|
||||||
@@ -246,6 +249,7 @@ export type ContinueHostAdapterDependencies = {
|
|||||||
maximumStreamEvents: number
|
maximumStreamEvents: number
|
||||||
maximumStreamEventBytes: number
|
maximumStreamEventBytes: number
|
||||||
maximumToolCalls: number
|
maximumToolCalls: number
|
||||||
|
controlRequestTimeoutMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContinueHostRunOptions = {
|
export type ContinueHostRunOptions = {
|
||||||
@@ -830,6 +834,7 @@ export class ContinueHostAdapter {
|
|||||||
maximumStreamEvents,
|
maximumStreamEvents,
|
||||||
maximumStreamEventBytes,
|
maximumStreamEventBytes,
|
||||||
maximumToolCalls,
|
maximumToolCalls,
|
||||||
|
controlRequestTimeoutMs: defaultControlRequestTimeoutMs,
|
||||||
...dependencies
|
...dependencies
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1140,16 +1145,32 @@ export class ContinueHostAdapter {
|
|||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {}
|
init: RequestInit = {}
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const response = await fetch(`${origin}${path}`, {
|
const timeoutSignal = AbortSignal.timeout(
|
||||||
...init,
|
Math.max(1, this.dependencies.controlRequestTimeoutMs)
|
||||||
headers: {
|
)
|
||||||
authorization: `Bearer ${token}`,
|
const requestSignal = init.signal
|
||||||
'content-type': 'application/json',
|
? AbortSignal.any([init.signal, timeoutSignal])
|
||||||
...init.headers
|
: timeoutSignal
|
||||||
},
|
let response: Response
|
||||||
redirect: 'error',
|
try {
|
||||||
signal: init.signal
|
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, {
|
const body = await readBoundedResponseText(response, {
|
||||||
maxBytes: maximumStateBytes,
|
maxBytes: maximumStateBytes,
|
||||||
tooLargeMessage: 'Continue 宿主响应超过安全大小限制'
|
tooLargeMessage: 'Continue 宿主响应超过安全大小限制'
|
||||||
@@ -1559,7 +1580,6 @@ export class ContinueHostAdapter {
|
|||||||
let streamEventCount = 0
|
let streamEventCount = 0
|
||||||
let streamEventBytes = 0
|
let streamEventBytes = 0
|
||||||
const observedToolCallIds = new Set<string>()
|
const observedToolCallIds = new Set<string>()
|
||||||
let executionTimeoutSignal: AbortSignal | undefined
|
|
||||||
try {
|
try {
|
||||||
const initialState = await this.waitForStartup(
|
const initialState = await this.waitForStartup(
|
||||||
child,
|
child,
|
||||||
@@ -1569,13 +1589,7 @@ export class ContinueHostAdapter {
|
|||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
const startIndex = initialState.session.history.length
|
const startIndex = initialState.session.history.length
|
||||||
executionTimeoutSignal = AbortSignal.timeout(
|
const executionSignal = signal
|
||||||
maximumExecutionMilliseconds
|
|
||||||
)
|
|
||||||
const executionSignal = AbortSignal.any([
|
|
||||||
signal,
|
|
||||||
executionTimeoutSignal
|
|
||||||
])
|
|
||||||
const message =
|
const message =
|
||||||
runOptions.images && runOptions.images.length > 0
|
runOptions.images && runOptions.images.length > 0
|
||||||
? [
|
? [
|
||||||
@@ -1598,9 +1612,8 @@ export class ContinueHostAdapter {
|
|||||||
signal: executionSignal
|
signal: executionSignal
|
||||||
})
|
})
|
||||||
|
|
||||||
const expiresAt = Date.now() + maximumExecutionMilliseconds
|
|
||||||
const handledPermissionIds = new Set<string>()
|
const handledPermissionIds = new Set<string>()
|
||||||
while (Date.now() < expiresAt) {
|
while (true) {
|
||||||
executionSignal.throwIfAborted()
|
executionSignal.throwIfAborted()
|
||||||
if (childFailure) {
|
if (childFailure) {
|
||||||
throw childFailure
|
throw childFailure
|
||||||
@@ -1817,20 +1830,15 @@ export class ContinueHostAdapter {
|
|||||||
}
|
}
|
||||||
await delay(150, executionSignal)
|
await delay(150, executionSignal)
|
||||||
}
|
}
|
||||||
throw new Error('Continue 宿主执行超时')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ContinueHostRunError) {
|
if (error instanceof ContinueHostRunError) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
const normalizedError =
|
|
||||||
executionTimeoutSignal?.aborted && !signal.aborted
|
|
||||||
? new Error('Continue 宿主执行超时', { cause: error })
|
|
||||||
: error
|
|
||||||
throw new ContinueHostRunError(
|
throw new ContinueHostRunError(
|
||||||
normalizedError instanceof Error
|
error instanceof Error
|
||||||
? normalizedError.message
|
? error.message
|
||||||
: 'Continue 宿主执行失败',
|
: 'Continue 宿主执行失败',
|
||||||
{ cause: normalizedError, tools: observedTools }
|
{ cause: error, tools: observedTools }
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
signal.removeEventListener('abort', abort)
|
signal.removeEventListener('abort', abort)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('createAgentRuntime model compatibility', () => {
|
|||||||
{ deepseekHarnessLauncher: vi.fn() }
|
{ deepseekHarnessLauncher: vi.fn() }
|
||||||
)
|
)
|
||||||
).toThrow(
|
).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
|
const profile = settings?.deepseekHarnessModelProfile
|
||||||
if (!profile || !isDeepSeekHarnessModelProfile(profile)) {
|
if (!profile || !isDeepSeekHarnessModelProfile(profile)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'DeepSeek Harness 需要使用 API Key 的安全 OpenAI 兼容 Chat Completions 模型连接'
|
'DeepSeek Harness 需要使用 API Key 的 OpenAI 兼容 Chat Completions 模型连接'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!profile.apiKey) {
|
if (!profile.apiKey) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function setup(
|
|||||||
nativeTools?: Array<Record<string, unknown>>
|
nativeTools?: Array<Record<string, unknown>>
|
||||||
toolsSupported?: boolean
|
toolsSupported?: boolean
|
||||||
promptTimeoutMs?: number
|
promptTimeoutMs?: number
|
||||||
|
useProductionPromptTimeout?: boolean
|
||||||
maxEventCharacters?: number
|
maxEventCharacters?: number
|
||||||
maxRequestOutputCharacters?: number
|
maxRequestOutputCharacters?: number
|
||||||
supportsImageInput?: boolean
|
supportsImageInput?: boolean
|
||||||
@@ -51,6 +52,7 @@ function setup(
|
|||||||
initializationTimeoutMs?: number
|
initializationTimeoutMs?: number
|
||||||
useDefaultInitializationTimeout?: boolean
|
useDefaultInitializationTimeout?: boolean
|
||||||
launchDelayMs?: number
|
launchDelayMs?: number
|
||||||
|
prepare?: () => Promise<Record<string, unknown>>
|
||||||
extensionPackages?: Array<{
|
extensionPackages?: Array<{
|
||||||
id: string
|
id: string
|
||||||
entrypoint: string
|
entrypoint: string
|
||||||
@@ -170,7 +172,7 @@ function setup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (method === 'goodbuddy/session/prepare') {
|
if (method === 'goodbuddy/session/prepare') {
|
||||||
return { prepared: true }
|
return options.prepare?.() ?? { prepared: true }
|
||||||
}
|
}
|
||||||
if (method === 'goodbuddy/session/release') {
|
if (method === 'goodbuddy/session/release') {
|
||||||
return { released: true }
|
return { released: true }
|
||||||
@@ -253,7 +255,9 @@ function setup(
|
|||||||
initializationTimeoutMs:
|
initializationTimeoutMs:
|
||||||
options.initializationTimeoutMs ?? 100
|
options.initializationTimeoutMs ?? 100
|
||||||
}),
|
}),
|
||||||
promptTimeoutMs: options.promptTimeoutMs ?? 100,
|
...(options.useProductionPromptTimeout
|
||||||
|
? {}
|
||||||
|
: { promptTimeoutMs: options.promptTimeoutMs ?? 100 }),
|
||||||
shutdownTimeoutMs: 10,
|
shutdownTimeoutMs: 10,
|
||||||
maxStderrBytes: 16,
|
maxStderrBytes: 16,
|
||||||
maxEventCharacters: options.maxEventCharacters,
|
maxEventCharacters: options.maxEventCharacters,
|
||||||
@@ -1347,6 +1351,62 @@ describe('DeepSeekHarnessRuntime', () => {
|
|||||||
await harness.runtime.dispose()
|
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 () => {
|
it('fails on bounded stderr overflow without exposing stderr text', async () => {
|
||||||
const harness = setup()
|
const harness = setup()
|
||||||
const running = collect(
|
const running = collect(
|
||||||
@@ -1420,4 +1480,32 @@ describe('DeepSeekHarnessRuntime', () => {
|
|||||||
})
|
})
|
||||||
await expect(harness.runtime.dispose()).resolves.toBeUndefined()
|
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 ACP_PACKAGE_NAME = '@agentclientprotocol/sdk'
|
||||||
const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000
|
const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000
|
||||||
const DEFAULT_PROMPT_TIMEOUT_MS = 10 * 60_000
|
|
||||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000
|
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000
|
||||||
const DEFAULT_MAX_STDERR_BYTES = 64 * 1024
|
const DEFAULT_MAX_STDERR_BYTES = 64 * 1024
|
||||||
const DEFAULT_MAX_EVENT_CHARACTERS = 64 * 1024
|
const DEFAULT_MAX_EVENT_CHARACTERS = 64 * 1024
|
||||||
@@ -486,8 +485,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private get promptTimeoutMs(): number {
|
private get promptTimeoutMs(): number | undefined {
|
||||||
return this.options.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS
|
return this.options.promptTimeoutMs
|
||||||
}
|
}
|
||||||
|
|
||||||
private get shutdownTimeoutMs(): number {
|
private get shutdownTimeoutMs(): number {
|
||||||
@@ -1435,6 +1434,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
} else {
|
} else {
|
||||||
toolNames.delete(callId)
|
toolNames.delete(callId)
|
||||||
}
|
}
|
||||||
|
const input = safeStringify(update.rawInput)
|
||||||
|
const output = safeStringify(update.rawOutput)
|
||||||
return {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
@@ -1442,12 +1443,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
name,
|
name,
|
||||||
state,
|
state,
|
||||||
summary: `DeepSeek Harness 工具:${name}`,
|
summary: `DeepSeek Harness 工具:${name}`,
|
||||||
...(safeStringify(update.rawInput)
|
...(input ? { input } : {}),
|
||||||
? { input: safeStringify(update.rawInput) }
|
...(output ? { output } : {})
|
||||||
: {}),
|
|
||||||
...(safeStringify(update.rawOutput)
|
|
||||||
? { output: safeStringify(update.rawOutput) }
|
|
||||||
: {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
@@ -1505,8 +1502,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
| { stopReason?: string }
|
| { stopReason?: string }
|
||||||
| undefined
|
| undefined
|
||||||
let promptError: unknown
|
let promptError: unknown
|
||||||
const promptController = new AbortController()
|
|
||||||
const cancel = (): void => {
|
const cancel = (): void => {
|
||||||
|
run!.closed = true
|
||||||
|
run!.wake?.()
|
||||||
|
run!.wake = undefined
|
||||||
void state!.agent
|
void state!.agent
|
||||||
.cancel({
|
.cancel({
|
||||||
sessionId: sessionId!
|
sessionId: sessionId!
|
||||||
@@ -1514,33 +1513,47 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
signal.addEventListener('abort', cancel, { once: true })
|
signal.addEventListener('abort', cancel, { once: true })
|
||||||
await withTimeout(
|
if (signal.aborted) {
|
||||||
state.agent.extMethod(GOODBUDDY_PREPARE, {
|
cancel()
|
||||||
sessionId,
|
}
|
||||||
requestId: request.requestId,
|
try {
|
||||||
mode:
|
await withTimeout(
|
||||||
request.workMode === 'execute' ? 'execute' : 'ask'
|
state.agent.extMethod(GOODBUDDY_PREPARE, {
|
||||||
}),
|
sessionId,
|
||||||
this.initializationTimeoutMs,
|
requestId: request.requestId,
|
||||||
'请求准备'
|
mode:
|
||||||
)
|
request.workMode === 'execute' ? 'execute' : 'ask'
|
||||||
const prompt = withTimeout(
|
}),
|
||||||
state.agent.prompt({
|
this.initializationTimeoutMs,
|
||||||
sessionId,
|
'请求准备'
|
||||||
prompt: [
|
)
|
||||||
{
|
signal.throwIfAborted()
|
||||||
type: 'text',
|
} catch (error) {
|
||||||
text: flattenPrompt(request)
|
signal.removeEventListener('abort', cancel)
|
||||||
},
|
throw error
|
||||||
...(request.images ?? []).map((image) => ({
|
}
|
||||||
type: 'image' as const,
|
const promptOperation = state.agent.prompt({
|
||||||
data: image.data,
|
sessionId,
|
||||||
mimeType: image.mediaType
|
prompt: [
|
||||||
}))
|
{
|
||||||
]
|
type: 'text',
|
||||||
}),
|
text: flattenPrompt(request)
|
||||||
this.promptTimeoutMs,
|
},
|
||||||
'请求'
|
...(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) => {
|
.then((value) => {
|
||||||
response = value
|
response = value
|
||||||
@@ -1555,9 +1568,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
while (!completed || run.updates.length > 0) {
|
while (!completed || run.updates.length > 0) {
|
||||||
|
signal.throwIfAborted()
|
||||||
if (this.fatalError) {
|
if (this.fatalError) {
|
||||||
throw this.fatalError
|
throw this.fatalError
|
||||||
}
|
}
|
||||||
|
if (run.closed && !completed) {
|
||||||
|
throw new Error(
|
||||||
|
'DeepSeek Harness 请求在完成前已关闭'
|
||||||
|
)
|
||||||
|
}
|
||||||
if (run.updates.length === 0) {
|
if (run.updates.length === 0) {
|
||||||
await this.waitForUpdate(run)
|
await this.waitForUpdate(run)
|
||||||
continue
|
continue
|
||||||
@@ -1588,11 +1607,21 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
|||||||
sessionId
|
sessionId
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
promptController.abort(
|
if (!completed) {
|
||||||
new Error('DeepSeek Harness 流式消费已结束')
|
cancel()
|
||||||
)
|
}
|
||||||
signal.removeEventListener('abort', 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 {
|
} finally {
|
||||||
signal.removeEventListener('abort', abortTools)
|
signal.removeEventListener('abort', abortTools)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async function fixture() {
|
|||||||
launchOptions: {
|
launchOptions: {
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
baseUrl: 'https://gateway.example/openai/v1',
|
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||||
model: 'qwen-plus',
|
model: 'qwen-plus',
|
||||||
supportsImageInput: false,
|
supportsImageInput: false,
|
||||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||||
@@ -102,7 +102,7 @@ describe('DeepSeek Harness utility launcher', () => {
|
|||||||
expect(utility.messages[0]).toMatchObject({
|
expect(utility.messages[0]).toMatchObject({
|
||||||
type: 'start',
|
type: 'start',
|
||||||
config: {
|
config: {
|
||||||
baseUrl: 'https://gateway.example/openai/v1',
|
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||||
model: 'qwen-plus',
|
model: 'qwen-plus',
|
||||||
supportsImageInput: false,
|
supportsImageInput: false,
|
||||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
|
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
|
||||||
@@ -235,10 +235,9 @@ describe('DeepSeek Harness utility launcher', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'http://gateway.example/v1',
|
'file:///private/config',
|
||||||
'https://user:secret@gateway.example/v1',
|
'ftp://gateway.example/v1'
|
||||||
'https://gateway.example/v1?api-version=2025-01-01'
|
])('rejects unsupported endpoint %s before forking', async (baseUrl) => {
|
||||||
])('rejects unsafe endpoint %s before forking', async (baseUrl) => {
|
|
||||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||||
const fork = vi.fn()
|
const fork = vi.fn()
|
||||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||||
@@ -250,7 +249,7 @@ describe('DeepSeek Harness utility launcher', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
launcher({ ...launchOptions, baseUrl })
|
launcher({ ...launchOptions, baseUrl })
|
||||||
).rejects.toThrow('HTTPS')
|
).rejects.toThrow('HTTP 或 HTTPS')
|
||||||
expect(fork).not.toHaveBeenCalled()
|
expect(fork).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function createDeepSeekHarnessUtilityLauncher(
|
|||||||
}
|
}
|
||||||
if (!isDeepSeekHarnessCompatibleBaseUrl(options.baseUrl)) {
|
if (!isDeepSeekHarnessCompatibleBaseUrl(options.baseUrl)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'DeepSeek Harness 模型地址必须使用 HTTPS 或本机回环 HTTP,且不得包含凭据、查询参数或片段'
|
'DeepSeek Harness 模型地址必须使用 HTTP 或 HTTPS'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ function fakeClient() {
|
|||||||
} as unknown as ReturnType<typeof createOpencodeClient>
|
} as unknown as ReturnType<typeof createOpencodeClient>
|
||||||
}
|
}
|
||||||
|
|
||||||
function stdoutOf(child: SpawnedProcess): PassThrough {
|
|
||||||
return child.stdout as PassThrough
|
|
||||||
}
|
|
||||||
|
|
||||||
function stderrOf(child: SpawnedProcess): PassThrough {
|
function stderrOf(child: SpawnedProcess): PassThrough {
|
||||||
return child.stderr as PassThrough
|
return child.stderr as PassThrough
|
||||||
}
|
}
|
||||||
@@ -91,6 +87,7 @@ function dependencies(
|
|||||||
spawnMock: ReturnType<typeof vi.fn>
|
spawnMock: ReturnType<typeof vi.fn>
|
||||||
detectBinary: ReturnType<typeof vi.fn>
|
detectBinary: ReturnType<typeof vi.fn>
|
||||||
createClient: ReturnType<typeof vi.fn>
|
createClient: ReturnType<typeof vi.fn>
|
||||||
|
checkServerHealth: ReturnType<typeof vi.fn>
|
||||||
} {
|
} {
|
||||||
const spawnMock = vi.fn(() => child)
|
const spawnMock = vi.fn(() => child)
|
||||||
const detectBinary = vi.fn().mockResolvedValue({
|
const detectBinary = vi.fn().mockResolvedValue({
|
||||||
@@ -98,18 +95,21 @@ function dependencies(
|
|||||||
detail: 'OpenCode CLI 已就绪'
|
detail: 'OpenCode CLI 已就绪'
|
||||||
})
|
})
|
||||||
const createClient = vi.fn(() => fakeClient())
|
const createClient = vi.fn(() => fakeClient())
|
||||||
|
const checkServerHealth = vi.fn().mockResolvedValue(true)
|
||||||
return {
|
return {
|
||||||
deps: {
|
deps: {
|
||||||
spawn: spawnMock as unknown as typeof spawn,
|
spawn: spawnMock as unknown as typeof spawn,
|
||||||
detectBinary,
|
detectBinary,
|
||||||
createClient: createClient as unknown as typeof createOpencodeClient,
|
createClient: createClient as unknown as typeof createOpencodeClient,
|
||||||
|
checkServerHealth,
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
startupTimeoutMs: 100,
|
startupTimeoutMs: 100,
|
||||||
...overrides
|
...overrides
|
||||||
},
|
},
|
||||||
spawnMock,
|
spawnMock,
|
||||||
detectBinary,
|
detectBinary,
|
||||||
createClient
|
createClient,
|
||||||
|
checkServerHealth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,19 +257,16 @@ function embeddedRuntime(
|
|||||||
client: ReturnType<typeof createOpencodeClient>,
|
client: ReturnType<typeof createOpencodeClient>,
|
||||||
overrides: Partial<
|
overrides: Partial<
|
||||||
ConstructorParameters<typeof OpenCodeRuntime>[0]
|
ConstructorParameters<typeof OpenCodeRuntime>[0]
|
||||||
> = {}
|
> = {},
|
||||||
|
dependencyOverrides: Partial<OpenCodeRuntimeDependencies> = {}
|
||||||
): OpenCodeRuntime {
|
): OpenCodeRuntime {
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const { deps } = dependencies(child, {
|
const { deps } = dependencies(child, {
|
||||||
|
...dependencyOverrides,
|
||||||
createClient: vi.fn(
|
createClient: vi.fn(
|
||||||
() => client
|
() => client
|
||||||
) as unknown as typeof createOpencodeClient
|
) 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)
|
return new OpenCodeRuntime(options(overrides), deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +298,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
detail: 'OpenCode CLI 已就绪'
|
detail: 'OpenCode CLI 已就绪'
|
||||||
})
|
})
|
||||||
const createClient = vi.fn(() => fakeClient())
|
const createClient = vi.fn(() => fakeClient())
|
||||||
|
const checkServerHealth = vi.fn().mockResolvedValue(true)
|
||||||
const spawnMock = vi.fn((command: string) => {
|
const spawnMock = vi.fn((command: string) => {
|
||||||
if (command === 'taskkill.exe') {
|
if (command === 'taskkill.exe') {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -308,11 +306,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
})
|
})
|
||||||
return killerChild
|
return killerChild
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
|
||||||
stdoutOf(serverChild).write(
|
|
||||||
'opencode server listening securely on http://127.0.0.1:43210\n'
|
|
||||||
)
|
|
||||||
}, 0)
|
|
||||||
return serverChild
|
return serverChild
|
||||||
})
|
})
|
||||||
const configPath = './private/opencode.json'
|
const configPath = './private/opencode.json'
|
||||||
@@ -325,6 +318,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
spawn: spawnMock as unknown as typeof spawn,
|
spawn: spawnMock as unknown as typeof spawn,
|
||||||
detectBinary,
|
detectBinary,
|
||||||
createClient: createClient as unknown as typeof createOpencodeClient,
|
createClient: createClient as unknown as typeof createOpencodeClient,
|
||||||
|
checkServerHealth,
|
||||||
platform: 'win32'
|
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 = (
|
const clientOptions = (
|
||||||
createClient.mock.calls as unknown as Array<
|
createClient.mock.calls as unknown as Array<
|
||||||
[
|
[
|
||||||
@@ -375,12 +377,17 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
expect(clientOptions).toMatchObject({
|
expect(clientOptions).toMatchObject({
|
||||||
baseUrl: 'http://127.0.0.1:43210',
|
baseUrl: serverUrl,
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: expect.stringMatching(/^Basic /u)
|
Authorization: expect.stringMatching(/^Basic /u)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
expect(checkServerHealth).toHaveBeenCalledWith(
|
||||||
|
serverUrl,
|
||||||
|
clientOptions?.headers?.Authorization,
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
)
|
||||||
const spawnOptions = (
|
const spawnOptions = (
|
||||||
spawnMock.mock.calls as unknown as Array<
|
spawnMock.mock.calls as unknown as Array<
|
||||||
[string, string[], { env?: NodeJS.ProcessEnv }]
|
[string, string[], { env?: NodeJS.ProcessEnv }]
|
||||||
@@ -445,11 +452,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
)
|
)
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const { deps, spawnMock } = dependencies(child)
|
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(
|
const runtime = new OpenCodeRuntime(
|
||||||
options({
|
options({
|
||||||
skillPackages: [
|
skillPackages: [
|
||||||
@@ -523,11 +525,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
it('injects an independent model profile without persisting its key', async () => {
|
it('injects an independent model profile without persisting its key', async () => {
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const { deps, spawnMock } = dependencies(child)
|
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(
|
const runtime = new OpenCodeRuntime(
|
||||||
options({
|
options({
|
||||||
modelProfile: {
|
modelProfile: {
|
||||||
@@ -601,11 +598,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
])
|
])
|
||||||
)
|
)
|
||||||
Object.assign(process.env, inheritedCredentials)
|
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(
|
const runtime = new OpenCodeRuntime(
|
||||||
options({
|
options({
|
||||||
modelProfile: {
|
modelProfile: {
|
||||||
@@ -730,7 +722,8 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
authentication: 'api-key',
|
authentication: 'api-key',
|
||||||
apiKey: 'local-probe-key'
|
apiKey: 'local-probe-key'
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
{ startupTimeoutMs: 20_000 }
|
||||||
)
|
)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(
|
const timeout = setTimeout(
|
||||||
@@ -808,11 +801,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const { deps, spawnMock } = dependencies(child)
|
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(
|
const runtime = new OpenCodeRuntime(
|
||||||
options({
|
options({
|
||||||
modelProfile: {
|
modelProfile: {
|
||||||
@@ -899,11 +887,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
|
process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||||
'https://telemetry.invalid'
|
'https://telemetry.invalid'
|
||||||
try {
|
try {
|
||||||
setTimeout(() => {
|
|
||||||
stdoutOf(child).write(
|
|
||||||
'opencode server listening on http://127.0.0.1:3010\n'
|
|
||||||
)
|
|
||||||
}, 0)
|
|
||||||
const runtime = new OpenCodeRuntime(options(), deps)
|
const runtime = new OpenCodeRuntime(options(), deps)
|
||||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||||
available: true
|
available: true
|
||||||
@@ -960,36 +943,39 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it('polls the known loopback URL without waiting for stdout', async () => {
|
||||||
'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) => {
|
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const { deps, createClient } = dependencies(child, {
|
const { deps, checkServerHealth, createClient } = dependencies(child, {
|
||||||
spawn: vi.fn(() => {
|
startupTimeoutMs: 1_000
|
||||||
queueMicrotask(() => {
|
|
||||||
stdoutOf(child).write(`opencode server listening on ${url}\n`)
|
|
||||||
closeChild(child, 7)
|
|
||||||
})
|
|
||||||
return child
|
|
||||||
}) as unknown as typeof spawn
|
|
||||||
})
|
})
|
||||||
|
checkServerHealth
|
||||||
|
.mockResolvedValueOnce(false)
|
||||||
|
.mockResolvedValueOnce(true)
|
||||||
const runtime = new OpenCodeRuntime(options(), deps)
|
const runtime = new OpenCodeRuntime(options(), deps)
|
||||||
|
|
||||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||||
available: false,
|
available: true
|
||||||
detail: 'OpenCode Server 启动前退出(code 7)'
|
})
|
||||||
|
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 () => {
|
it('times out, terminates the process tree, and does not expose stderr', async () => {
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const secret = 'private-config-token'
|
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)
|
stderrOf(child).write(secret)
|
||||||
const runtime = new OpenCodeRuntime(options(), deps)
|
const runtime = new OpenCodeRuntime(options(), deps)
|
||||||
|
|
||||||
@@ -1038,7 +1024,9 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
|
|
||||||
it('terminates startup when the request is aborted', async () => {
|
it('terminates startup when the request is aborted', async () => {
|
||||||
const child = fakeChild()
|
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 runtime = new OpenCodeRuntime(options(), deps)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const stream = runtime.run(
|
const stream = runtime.run(
|
||||||
@@ -1058,6 +1046,25 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
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 () => {
|
it('keeps external baseUrl mode free of binary detection and spawning', async () => {
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const { deps, spawnMock, detectBinary, createClient } = dependencies(child)
|
const { deps, spawnMock, detectBinary, createClient } = dependencies(child)
|
||||||
@@ -1521,11 +1528,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
? questionEvent.value.questionId
|
? questionEvent.value.questionId
|
||||||
: ''
|
: ''
|
||||||
await runtime.respondToQuestion(questionId, [['先写测试']])
|
await runtime.respondToQuestion(questionId, [['先写测试']])
|
||||||
expect(setup.questionReply).toHaveBeenCalledWith({
|
expect(setup.questionReply).toHaveBeenCalledWith(
|
||||||
requestID: 'question-1',
|
{
|
||||||
directory: process.cwd(),
|
requestID: 'question-1',
|
||||||
answers: [['先写测试']]
|
directory: process.cwd(),
|
||||||
})
|
answers: [['先写测试']]
|
||||||
|
},
|
||||||
|
{ signal: expect.any(AbortSignal) }
|
||||||
|
)
|
||||||
await expect(stream.next()).resolves.toMatchObject({
|
await expect(stream.next()).resolves.toMatchObject({
|
||||||
value: { type: 'done' }
|
value: { type: 'done' }
|
||||||
})
|
})
|
||||||
@@ -1614,20 +1624,212 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
runtime.respondToQuestion(secondId, [['second answer']])
|
runtime.respondToQuestion(secondId, [['second answer']])
|
||||||
])
|
])
|
||||||
expect(setup.questionReply).toHaveBeenCalledTimes(2)
|
expect(setup.questionReply).toHaveBeenCalledTimes(2)
|
||||||
expect(setup.questionReply).toHaveBeenNthCalledWith(1, {
|
expect(setup.questionReply).toHaveBeenNthCalledWith(
|
||||||
requestID: 'shared-question',
|
1,
|
||||||
directory: process.cwd(),
|
{
|
||||||
answers: [['first answer']]
|
requestID: 'shared-question',
|
||||||
})
|
directory: process.cwd(),
|
||||||
expect(setup.questionReply).toHaveBeenNthCalledWith(2, {
|
answers: [['first answer']]
|
||||||
requestID: 'shared-question',
|
},
|
||||||
directory: process.cwd(),
|
{ signal: expect.any(AbortSignal) }
|
||||||
answers: [['second answer']]
|
)
|
||||||
})
|
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 Promise.all([first.next(), second.next()])
|
||||||
await runtime.dispose()
|
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 () => {
|
it('aborts an OpenCode run at its total execution deadline', async () => {
|
||||||
const setup = runClient([])
|
const setup = runClient([])
|
||||||
;(
|
;(
|
||||||
@@ -1705,11 +1907,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
executionTimeoutMs: 20
|
executionTimeoutMs: 20
|
||||||
}).deps
|
}).deps
|
||||||
)
|
)
|
||||||
setTimeout(() => {
|
|
||||||
stdoutOf(child).write(
|
|
||||||
'opencode server listening on http://127.0.0.1:4010\n'
|
|
||||||
)
|
|
||||||
}, 0)
|
|
||||||
const stream = runtime.run(
|
const stream = runtime.run(
|
||||||
{
|
{
|
||||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||||
@@ -1886,11 +2083,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
() => setup.client
|
() => setup.client
|
||||||
) as unknown as typeof createOpencodeClient
|
) 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(
|
const runtime = new OpenCodeRuntime(
|
||||||
options({ knowledgeGateway: gateway }),
|
options({ knowledgeGateway: gateway }),
|
||||||
deps
|
deps
|
||||||
@@ -1987,11 +2179,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
() => setup.client
|
() => setup.client
|
||||||
) as unknown as typeof createOpencodeClient
|
) 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(
|
const runtime = new OpenCodeRuntime(
|
||||||
options({
|
options({
|
||||||
knowledgeGateway: {
|
knowledgeGateway: {
|
||||||
@@ -2090,11 +2277,6 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
() => setup.client
|
() => setup.client
|
||||||
) as unknown as typeof createOpencodeClient
|
) 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(
|
const runtime = new OpenCodeRuntime(
|
||||||
options({
|
options({
|
||||||
knowledgeGateway: {
|
knowledgeGateway: {
|
||||||
@@ -2340,11 +2522,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
{ signal: expect.any(AbortSignal) }
|
{ signal: expect.any(AbortSignal) }
|
||||||
)
|
)
|
||||||
expect(permissionReply).toHaveBeenCalledOnce()
|
expect(permissionReply).toHaveBeenCalledOnce()
|
||||||
expect(permissionReply).toHaveBeenCalledWith({
|
expect(permissionReply).toHaveBeenCalledWith(
|
||||||
requestID: 'permission-1',
|
{
|
||||||
directory: process.cwd(),
|
requestID: 'permission-1',
|
||||||
reply: 'once'
|
directory: process.cwd(),
|
||||||
})
|
reply: 'once'
|
||||||
|
},
|
||||||
|
{ signal: expect.any(AbortSignal) }
|
||||||
|
)
|
||||||
expect(events).toContainEqual(
|
expect(events).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'reasoning',
|
type: 'reasoning',
|
||||||
@@ -2436,14 +2621,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
requestID: 'permission-1',
|
requestID: 'permission-1',
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
reply: 'once'
|
reply: 'once'
|
||||||
}
|
},
|
||||||
|
{ signal: expect.any(AbortSignal) }
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
requestID: 'permission-2',
|
requestID: 'permission-2',
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
reply: 'once'
|
reply: 'once'
|
||||||
}
|
},
|
||||||
|
{ signal: expect.any(AbortSignal) }
|
||||||
]
|
]
|
||||||
])
|
])
|
||||||
await runtime.dispose()
|
await runtime.dispose()
|
||||||
@@ -2609,10 +2796,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
await collectRun(runtime)
|
await collectRun(runtime)
|
||||||
await runtime.releaseConversation('conversation-1')
|
await runtime.releaseConversation('conversation-1')
|
||||||
|
|
||||||
expect(session.delete).toHaveBeenCalledWith({
|
expect(session.delete).toHaveBeenCalledWith(
|
||||||
sessionID: 'session-1',
|
{
|
||||||
directory: process.cwd()
|
sessionID: 'session-1',
|
||||||
})
|
directory: process.cwd()
|
||||||
|
},
|
||||||
|
{ signal: expect.any(AbortSignal) }
|
||||||
|
)
|
||||||
await runtime.dispose()
|
await runtime.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2632,11 +2822,14 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
|||||||
await collectRun(runtime, 'execute')
|
await collectRun(runtime, 'execute')
|
||||||
|
|
||||||
expect(permissionReply).toHaveBeenCalledOnce()
|
expect(permissionReply).toHaveBeenCalledOnce()
|
||||||
expect(permissionReply).toHaveBeenCalledWith({
|
expect(permissionReply).toHaveBeenCalledWith(
|
||||||
requestID: 'permission-1',
|
{
|
||||||
directory: process.cwd(),
|
requestID: 'permission-1',
|
||||||
reply: 'reject'
|
directory: process.cwd(),
|
||||||
})
|
reply: 'reject'
|
||||||
|
},
|
||||||
|
{ signal: expect.any(AbortSignal) }
|
||||||
|
)
|
||||||
await runtime.dispose()
|
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)
|
!isDeepSeekHarnessModelProfile(selectedProfile)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'DeepSeek Harness 仅支持使用 API Key 的安全 OpenAI 兼容 Chat Completions 连接'
|
'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3308,6 +3308,20 @@ describe('AssistantDatabase', () => {
|
|||||||
output: '远程输出'.repeat(10_000)
|
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: [
|
contextCompressions: [
|
||||||
{
|
{
|
||||||
state: 'completed' as const,
|
state: 'completed' as const,
|
||||||
@@ -3368,6 +3382,20 @@ describe('AssistantDatabase', () => {
|
|||||||
output: '远程输出'.repeat(10_000)
|
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: [
|
contextCompressions: [
|
||||||
{
|
{
|
||||||
state: 'completed',
|
state: 'completed',
|
||||||
@@ -4278,7 +4306,7 @@ describe('AssistantDatabase', () => {
|
|||||||
database.close()
|
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(
|
const directory = await mkdtemp(
|
||||||
join(tmpdir(), 'goodbuddy-conversation-recovery-')
|
join(tmpdir(), 'goodbuddy-conversation-recovery-')
|
||||||
)
|
)
|
||||||
@@ -4289,6 +4317,8 @@ describe('AssistantDatabase', () => {
|
|||||||
const messageId = '00000000-0000-4000-8000-000000000218'
|
const messageId = '00000000-0000-4000-8000-000000000218'
|
||||||
const cancelledMessageId =
|
const cancelledMessageId =
|
||||||
'00000000-0000-4000-8000-000000000219'
|
'00000000-0000-4000-8000-000000000219'
|
||||||
|
const subagentMessageId =
|
||||||
|
'00000000-0000-4000-8000-000000000220'
|
||||||
const initial = new AssistantDatabase(databasePath)
|
const initial = new AssistantDatabase(databasePath)
|
||||||
initial.initialize('C:\\Workspace')
|
initial.initialize('C:\\Workspace')
|
||||||
initial.replaceConversations([
|
initial.replaceConversations([
|
||||||
@@ -4342,6 +4372,38 @@ describe('AssistantDatabase', () => {
|
|||||||
error: 'runtime parser detail'
|
error: 'runtime parser detail'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: subagentMessageId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '父请求已完成',
|
||||||
|
createdAt: 1_775_000_003_000,
|
||||||
|
state: 'complete',
|
||||||
|
status: '已完成',
|
||||||
|
subagents: [
|
||||||
|
{
|
||||||
|
childTaskId:
|
||||||
|
'00000000-0000-4000-8000-000000000221',
|
||||||
|
expertId:
|
||||||
|
'00000000-0000-4000-8000-000000000222',
|
||||||
|
expertName: 'explorer',
|
||||||
|
routingMode: 'native',
|
||||||
|
runtimeCallId: 'task-1',
|
||||||
|
state: 'running',
|
||||||
|
reason: '检查项目'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
childTaskId:
|
||||||
|
'00000000-0000-4000-8000-000000000223',
|
||||||
|
expertId:
|
||||||
|
'00000000-0000-4000-8000-000000000224',
|
||||||
|
expertName: 'worker',
|
||||||
|
routingMode: 'native',
|
||||||
|
runtimeCallId: 'task-2',
|
||||||
|
state: 'completed',
|
||||||
|
output: '已完成'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4370,6 +4432,21 @@ describe('AssistantDatabase', () => {
|
|||||||
expect.objectContaining({ name: 'failed-tool', state: 'failed' })
|
expect.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({
|
expect(recovered.listConversations()[0]?.messages[1]).toMatchObject({
|
||||||
id: cancelledMessageId,
|
id: cancelledMessageId,
|
||||||
state: 'error',
|
state: 'error',
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ type MessageMetadata = {
|
|||||||
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
||||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||||
|
subagents?: ConversationSnapshot['messages'][number]['subagents']
|
||||||
sources?: string[]
|
sources?: string[]
|
||||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||||
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
|
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(
|
function interruptActiveToolBlocks(
|
||||||
blocks: MessageMetadata['blocks']
|
blocks: MessageMetadata['blocks']
|
||||||
): MessageMetadata['blocks'] {
|
): MessageMetadata['blocks'] {
|
||||||
@@ -1017,6 +1028,9 @@ function toConversationSnapshot(
|
|||||||
tools: interrupted
|
tools: interrupted
|
||||||
? interruptActiveTools(metadata.tools)
|
? interruptActiveTools(metadata.tools)
|
||||||
: metadata.tools,
|
: metadata.tools,
|
||||||
|
subagents: interrupted
|
||||||
|
? interruptActiveSubagents(metadata.subagents)
|
||||||
|
: metadata.subagents,
|
||||||
sources: metadata.sources,
|
sources: metadata.sources,
|
||||||
sourceReferences: metadata.sourceReferences,
|
sourceReferences: metadata.sourceReferences,
|
||||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||||
@@ -1040,6 +1054,7 @@ function serializeConversationMessageMetadata(
|
|||||||
contextCompression: message.contextCompression,
|
contextCompression: message.contextCompression,
|
||||||
contextCompressions: message.contextCompressions,
|
contextCompressions: message.contextCompressions,
|
||||||
tools: message.tools,
|
tools: message.tools,
|
||||||
|
subagents: message.subagents,
|
||||||
sources: message.sources,
|
sources: message.sources,
|
||||||
sourceReferences: message.sourceReferences,
|
sourceReferences: message.sourceReferences,
|
||||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||||
@@ -1417,7 +1432,7 @@ export class AssistantDatabase {
|
|||||||
const metadata = JSON.parse(
|
const metadata = JSON.parse(
|
||||||
message.metadata_json
|
message.metadata_json
|
||||||
) as MessageMetadata
|
) as MessageMetadata
|
||||||
const hasActiveTool = Boolean(
|
const hasActiveActivity = Boolean(
|
||||||
metadata.tools?.some(
|
metadata.tools?.some(
|
||||||
(tool) =>
|
(tool) =>
|
||||||
tool.state === 'pending' || tool.state === 'running'
|
tool.state === 'pending' || tool.state === 'running'
|
||||||
@@ -1427,9 +1442,17 @@ export class AssistantDatabase {
|
|||||||
block.type === 'tool' &&
|
block.type === 'tool' &&
|
||||||
(block.tool.state === 'pending' ||
|
(block.tool.state === 'pending' ||
|
||||||
block.tool.state === 'running')
|
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
|
continue
|
||||||
}
|
}
|
||||||
updateMessage.run(
|
updateMessage.run(
|
||||||
@@ -1441,7 +1464,10 @@ export class AssistantDatabase {
|
|||||||
? interruptedMessageStatus
|
? interruptedMessageStatus
|
||||||
: metadata.status,
|
: metadata.status,
|
||||||
tools: interruptActiveTools(metadata.tools),
|
tools: interruptActiveTools(metadata.tools),
|
||||||
blocks: interruptActiveToolBlocks(metadata.blocks)
|
blocks: interruptActiveToolBlocks(metadata.blocks),
|
||||||
|
subagents: interruptActiveSubagents(
|
||||||
|
metadata.subagents
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
message.id
|
message.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ describe('ChannelSettingsStore', () => {
|
|||||||
const bound = await store.saveWeixinBinding({
|
const bound = await store.saveWeixinBinding({
|
||||||
accountId: 'account-123456',
|
accountId: 'account-123456',
|
||||||
userId: 'user-654321',
|
userId: 'user-654321',
|
||||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
baseUrl: 'http://ilinkai.weixin.qq.com',
|
||||||
token: 'weixin-private-token'
|
token: 'weixin-private-token'
|
||||||
})
|
})
|
||||||
expect(bound.weixin).toMatchObject({
|
expect(bound.weixin).toMatchObject({
|
||||||
|
|||||||
@@ -300,9 +300,13 @@ const weixinBindingSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.max(2_048)
|
.max(2_048)
|
||||||
.refine((value) => new URL(value).protocol === 'https:', {
|
.refine(
|
||||||
message: '微信服务地址必须使用 HTTPS'
|
(value) =>
|
||||||
}),
|
['http:', 'https:'].includes(new URL(value).protocol),
|
||||||
|
{
|
||||||
|
message: '微信服务地址必须使用 HTTP 或 HTTPS'
|
||||||
|
}
|
||||||
|
),
|
||||||
token: z
|
token: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('controlled DeepSeek Harness host', () => {
|
|||||||
dshHome: 'C:\\controlled-dsh-home',
|
dshHome: 'C:\\controlled-dsh-home',
|
||||||
skillPackages: []
|
skillPackages: []
|
||||||
})
|
})
|
||||||
).rejects.toThrow('secure OpenAI-compatible')
|
).rejects.toThrow('HTTP or HTTPS OpenAI-compatible')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('suppresses console payloads instead of contaminating stdout', () => {
|
it('suppresses console payloads instead of contaminating stdout', () => {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function validateHostConfig(
|
|||||||
!isDeepSeekHarnessCompatibleBaseUrl(config.baseUrl)
|
!isDeepSeekHarnessCompatibleBaseUrl(config.baseUrl)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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) {
|
if (!config.credentialRefs.length) {
|
||||||
|
|||||||
@@ -6706,6 +6706,62 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
await harness.dispose()
|
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)(
|
it.each(['opencode', 'continue'] as const)(
|
||||||
'preserves read-only Ask mode at the %s Runtime boundary',
|
'preserves read-only Ask mode at the %s Runtime boundary',
|
||||||
async (runtimeId) => {
|
async (runtimeId) => {
|
||||||
|
|||||||
@@ -3488,6 +3488,13 @@ export function registerIpcHandlers(
|
|||||||
if (publicEvent.type === 'tool') {
|
if (publicEvent.type === 'tool') {
|
||||||
toolStates.set(publicEvent.callId, publicEvent)
|
toolStates.set(publicEvent.callId, publicEvent)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
publicEvent.type === 'subagent' &&
|
||||||
|
publicEvent.routingMode === 'native' &&
|
||||||
|
publicEvent.runtimeCallId
|
||||||
|
) {
|
||||||
|
toolStates.delete(publicEvent.runtimeCallId)
|
||||||
|
}
|
||||||
if (publicEvent.type === 'question') {
|
if (publicEvent.type === 'question') {
|
||||||
pendingAgentQuestions.set(publicEvent.questionId, {
|
pendingAgentQuestions.set(publicEvent.questionId, {
|
||||||
requestId: request.requestId,
|
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 () => {
|
it('persists acknowledgement and does not show the release again', async () => {
|
||||||
const { service, settingsStore } = await createService('0.8.18')
|
const { service, settingsStore } = await createService('0.8.18')
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export class ReleaseNotesService {
|
|||||||
async getPending(): Promise<ReleaseNotesSnapshot> {
|
async getPending(): Promise<ReleaseNotesSnapshot> {
|
||||||
const releases = await this.loadReleases()
|
const releases = await this.loadReleases()
|
||||||
const currentVersion = this.dependencies.currentVersion
|
const currentVersion = this.dependencies.currentVersion
|
||||||
|
if (!releases.some((release) => release.version === currentVersion)) {
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
releases: []
|
||||||
|
}
|
||||||
|
}
|
||||||
const lastSeenVersion =
|
const lastSeenVersion =
|
||||||
await this.dependencies.settingsStore.getLastSeenReleaseNotesVersion()
|
await this.dependencies.settingsStore.getLastSeenReleaseNotesVersion()
|
||||||
const pending = releases.filter((release) => {
|
const pending = releases.filter((release) => {
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ describe('packaged release notes', () => {
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
) as unknown
|
) as unknown
|
||||||
|
const packageSource = JSON.parse(
|
||||||
|
await readFile(join(process.cwd(), 'package.json'), 'utf8')
|
||||||
|
) as { version?: unknown }
|
||||||
const parsed = releaseNotesFileSchema.parse(source)
|
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(parsed.releases).toContainEqual(
|
||||||
expect.objectContaining({ version: '0.8.19' })
|
expect.objectContaining({ version: '0.8.19' })
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import { AgentFrameError } from '../../shared/agent-protocol/frame'
|
|||||||
import {
|
import {
|
||||||
assertRemotePromptAcceptanceMatchesPreparation,
|
assertRemotePromptAcceptanceMatchesPreparation,
|
||||||
remotePromptOperationAcceptanceSchema,
|
remotePromptOperationAcceptanceSchema,
|
||||||
remotePromptOperationPreparationSchema
|
remotePromptOperationPreparationSchema,
|
||||||
|
UNBOUNDED_REMOTE_PROMPT_DEADLINE
|
||||||
} from '../../shared/remote-agent-contracts'
|
} from '../../shared/remote-agent-contracts'
|
||||||
import {
|
import {
|
||||||
modelBridgePolicySchema
|
modelBridgePolicySchema
|
||||||
@@ -66,6 +67,9 @@ const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge'
|
|||||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
|
const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
|
||||||
const DEFAULT_CONTROL_TIMEOUT_MS = 15_000
|
const DEFAULT_CONTROL_TIMEOUT_MS = 15_000
|
||||||
const DEFAULT_MAXIMUM_PENDING_CONTROL_REQUESTS = 32
|
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_PENDING_OUTPUT_WRITES = 256
|
||||||
const MAXIMUM_OUTPUT_WRITE_BYTES =
|
const MAXIMUM_OUTPUT_WRITE_BYTES =
|
||||||
AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes
|
AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes
|
||||||
@@ -1038,11 +1042,16 @@ export class ProtocolRemoteRuntimeChannel
|
|||||||
].some(
|
].some(
|
||||||
(bridge) => bridge.providerDeliveryMayBeUncertain
|
(bridge) => bridge.providerDeliveryMayBeUncertain
|
||||||
)
|
)
|
||||||
|
const recoveryDeadlineExpired =
|
||||||
|
this.#recoveryDeadlineAt !== undefined &&
|
||||||
|
this.#recoveryDeadlineAt !==
|
||||||
|
UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS &&
|
||||||
|
Date.now() >= this.#recoveryDeadlineAt
|
||||||
if (
|
if (
|
||||||
this.#recoveryDeadlineAt === undefined ||
|
this.#recoveryDeadlineAt === undefined ||
|
||||||
this.#recoverySignal === undefined ||
|
this.#recoverySignal === undefined ||
|
||||||
this.#recoverySignal.aborted ||
|
this.#recoverySignal.aborted ||
|
||||||
Date.now() >= this.#recoveryDeadlineAt ||
|
recoveryDeadlineExpired ||
|
||||||
this.#activeBinarySends > 0 ||
|
this.#activeBinarySends > 0 ||
|
||||||
this.#pendingControlRequests > 0 ||
|
this.#pendingControlRequests > 0 ||
|
||||||
providerDeliveryMayBeUncertain ||
|
providerDeliveryMayBeUncertain ||
|
||||||
@@ -1090,11 +1099,13 @@ export class ProtocolRemoteRuntimeChannel
|
|||||||
await this.#flushRecoveryPersistence()
|
await this.#flushRecoveryPersistence()
|
||||||
const deadlineAt = this.#recoveryDeadlineAt!
|
const deadlineAt = this.#recoveryDeadlineAt!
|
||||||
const callerSignal = this.#recoverySignal!
|
const callerSignal = this.#recoverySignal!
|
||||||
const remaining = deadlineAt - Date.now()
|
if (
|
||||||
if (remaining <= 0) {
|
deadlineAt !== UNBOUNDED_REMOTE_PROMPT_DEADLINE_MS &&
|
||||||
|
Date.now() >= deadlineAt
|
||||||
|
) {
|
||||||
throw new ProtocolRemoteRuntimeChannelError(
|
throw new ProtocolRemoteRuntimeChannelError(
|
||||||
'Remote Runtime recovery deadline expired',
|
'Remote Runtime recovery deadline expired',
|
||||||
'aborted'
|
'transport'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const recoveryController = new AbortController()
|
const recoveryController = new AbortController()
|
||||||
@@ -1108,6 +1119,13 @@ export class ProtocolRemoteRuntimeChannel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
callerSignal.addEventListener('abort', abort, { once: true })
|
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(() => {
|
const timeout = setTimeout(() => {
|
||||||
recoveryController.abort(
|
recoveryController.abort(
|
||||||
new DOMException(
|
new DOMException(
|
||||||
@@ -1115,7 +1133,7 @@ export class ProtocolRemoteRuntimeChannel
|
|||||||
'TimeoutError'
|
'TimeoutError'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}, remaining)
|
}, recoveryTimeoutMs)
|
||||||
try {
|
try {
|
||||||
await this.#state.connection.reconnect!(
|
await this.#state.connection.reconnect!(
|
||||||
recoveryController.signal
|
recoveryController.signal
|
||||||
@@ -1151,10 +1169,7 @@ export class ProtocolRemoteRuntimeChannel
|
|||||||
channelId: this.#state.openResult.channelId,
|
channelId: this.#state.openResult.channelId,
|
||||||
channelEpoch: this.#state.openResult.channelEpoch
|
channelEpoch: this.#state.openResult.channelEpoch
|
||||||
},
|
},
|
||||||
Math.min(
|
this.#state.controlTimeoutMs,
|
||||||
this.#state.controlTimeoutMs,
|
|
||||||
Math.max(1, deadlineAt - Date.now())
|
|
||||||
),
|
|
||||||
recoveryController.signal
|
recoveryController.signal
|
||||||
),
|
),
|
||||||
'Remote Runtime resume response is invalid'
|
'Remote Runtime resume response is invalid'
|
||||||
@@ -1205,10 +1220,7 @@ export class ProtocolRemoteRuntimeChannel
|
|||||||
acknowledgedSequence:
|
acknowledgedSequence:
|
||||||
resume.cursors.lastMainAckSequence
|
resume.cursors.lastMainAckSequence
|
||||||
},
|
},
|
||||||
Math.min(
|
this.#state.controlTimeoutMs,
|
||||||
this.#state.controlTimeoutMs,
|
|
||||||
Math.max(1, deadlineAt - Date.now())
|
|
||||||
),
|
|
||||||
recoveryController.signal
|
recoveryController.signal
|
||||||
),
|
),
|
||||||
'Remote Runtime replay response is invalid'
|
'Remote Runtime replay response is invalid'
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
const { store } = await createStore({
|
const { store } = await createStore({
|
||||||
GOODBUDDY_MODEL_API_KEY: apiKey,
|
GOODBUDDY_MODEL_API_KEY: apiKey,
|
||||||
GOODBUDDY_MODEL_BASE_URL:
|
GOODBUDDY_MODEL_BASE_URL:
|
||||||
'https://gateway.example/openai/v1',
|
'http://gateway.example/openai/v1?api-version=1',
|
||||||
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
deepseekHarnessModelProfile: {
|
deepseekHarnessModelProfile: {
|
||||||
id: 'goodbuddy-platform-harness',
|
id: 'goodbuddy-platform-harness',
|
||||||
name: '管理员预置模型',
|
name: '管理员预置模型',
|
||||||
baseUrl: 'https://gateway.example/openai/v1',
|
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
|
||||||
modelName: 'qwen-plus',
|
modelName: 'qwen-plus',
|
||||||
protocol: 'openai-chat-completions',
|
protocol: 'openai-chat-completions',
|
||||||
authentication: 'api-key',
|
authentication: 'api-key',
|
||||||
@@ -486,28 +486,11 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
[
|
||||||
'an insecure public endpoint',
|
'an unsupported endpoint protocol',
|
||||||
{
|
|
||||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
|
||||||
GOODBUDDY_MODEL_BASE_URL: 'http://gateway.example/v1',
|
|
||||||
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'an endpoint with embedded credentials',
|
|
||||||
{
|
{
|
||||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||||
GOODBUDDY_MODEL_BASE_URL:
|
GOODBUDDY_MODEL_BASE_URL:
|
||||||
'https://user:secret@gateway.example/v1',
|
'ftp://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',
|
|
||||||
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2290,7 +2290,7 @@ export class RuntimeSettingsStore {
|
|||||||
}
|
}
|
||||||
if (!isDeepSeekHarnessModelProfile(profile)) {
|
if (!isDeepSeekHarnessModelProfile(profile)) {
|
||||||
throw new Error(
|
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 />)
|
render(<App />)
|
||||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||||
target: { value: '分析复杂问题' }
|
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()
|
||||||
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(
|
expect(
|
||||||
within(statusRegion).getByText('无法读取必要上下文')
|
within(statusRegion).getByText('无法读取必要上下文')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
within(statusRegion).queryByText('第四位专家')
|
within(statusRegion).getByText('第四位专家')
|
||||||
).not.toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
agentListener?.({
|
agentListener?.({
|
||||||
@@ -8599,9 +8606,20 @@ describe('App', () => {
|
|||||||
output: '代码专家的部分结论'
|
output: '代码专家的部分结论'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(within(statusRegion).getByText('已完成')).toBeInTheDocument()
|
expect(within(statusRegion).getAllByText('已完成')).toHaveLength(2)
|
||||||
expect(within(statusRegion).getByText('已取消')).toBeInTheDocument()
|
expect(within(statusRegion).getByText('已取消')).toBeInTheDocument()
|
||||||
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(
|
expect(
|
||||||
within(statusRegion).getByText('研究专家的独立结论')
|
within(statusRegion).getByText('研究专家的独立结论')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
@@ -8627,7 +8645,7 @@ describe('App', () => {
|
|||||||
fireEvent.click(screen.getByText('运行记录'))
|
fireEvent.click(screen.getByText('运行记录'))
|
||||||
expect(await screen.findAllByText('子专家')).toHaveLength(4)
|
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.getAllByText(/指定专家/u).length).toBeGreaterThan(0)
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/研究专家的独立结论/u)
|
screen.getByText(/研究专家的独立结论/u)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
@@ -8636,6 +8654,144 @@ describe('App', () => {
|
|||||||
).toBeInTheDocument()
|
).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 () => {
|
it('offers once, session, permanent, and deny for a tool call', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
|
|||||||
+161
-26
@@ -128,6 +128,7 @@ import {
|
|||||||
conversationMessageBlocksSchema,
|
conversationMessageBlocksSchema,
|
||||||
conversationSubagentActivitySchema,
|
conversationSubagentActivitySchema,
|
||||||
interactiveWorkModes,
|
interactiveWorkModes,
|
||||||
|
maximumConversationSubagentActivities,
|
||||||
normalizeInteractiveWorkMode,
|
normalizeInteractiveWorkMode,
|
||||||
projectChannelLabels
|
projectChannelLabels
|
||||||
} from '../../shared/assistant-contracts'
|
} 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(
|
function terminalizeMessageToolBlocks(
|
||||||
blocks: ConversationMessageBlock[] | undefined,
|
blocks: ConversationMessageBlock[] | undefined,
|
||||||
state: 'failed' | 'cancelled'
|
state: 'failed' | 'cancelled' | 'interrupted'
|
||||||
): ConversationMessageBlock[] | undefined {
|
): ConversationMessageBlock[] | undefined {
|
||||||
return blocks?.map((block) =>
|
return blocks?.map((block) =>
|
||||||
block.type === 'tool' &&
|
block.type === 'tool' &&
|
||||||
@@ -1212,7 +1261,8 @@ function isConversation(value: unknown): value is Conversation {
|
|||||||
))) &&
|
))) &&
|
||||||
(entry.subagents === undefined ||
|
(entry.subagents === undefined ||
|
||||||
(Array.isArray(entry.subagents) &&
|
(Array.isArray(entry.subagents) &&
|
||||||
entry.subagents.length <= 3 &&
|
entry.subagents.length <=
|
||||||
|
maximumConversationSubagentActivities &&
|
||||||
entry.subagents.every(
|
entry.subagents.every(
|
||||||
(subagent) =>
|
(subagent) =>
|
||||||
conversationSubagentActivitySchema.safeParse(subagent)
|
conversationSubagentActivitySchema.safeParse(subagent)
|
||||||
@@ -4183,7 +4233,10 @@ function App(): React.JSX.Element {
|
|||||||
conversationId: run.conversationId,
|
conversationId: run.conversationId,
|
||||||
parentTaskId: event.requestId,
|
parentTaskId: event.requestId,
|
||||||
expertId: event.expertId,
|
expertId: event.expertId,
|
||||||
routingMode: event.routingMode,
|
routingMode:
|
||||||
|
event.routingMode === 'native'
|
||||||
|
? undefined
|
||||||
|
: event.routingMode,
|
||||||
title: event.expertName,
|
title: event.expertName,
|
||||||
instructions:
|
instructions:
|
||||||
event.reason ??
|
event.reason ??
|
||||||
@@ -4205,8 +4258,23 @@ function App(): React.JSX.Element {
|
|||||||
? current.map((task) =>
|
? current.map((task) =>
|
||||||
task.id === event.childTaskId ? childTask : 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({
|
recordActivity({
|
||||||
conversationId: run.conversationId,
|
conversationId: run.conversationId,
|
||||||
requestId: event.requestId,
|
requestId: event.requestId,
|
||||||
@@ -4216,7 +4284,9 @@ function App(): React.JSX.Element {
|
|||||||
detail: [
|
detail: [
|
||||||
event.routingMode === 'smart'
|
event.routingMode === 'smart'
|
||||||
? tRef.current('chat.subagents.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.reason,
|
||||||
event.error
|
event.error
|
||||||
]
|
]
|
||||||
@@ -4239,6 +4309,7 @@ function App(): React.JSX.Element {
|
|||||||
expertId: event.expertId,
|
expertId: event.expertId,
|
||||||
expertName: event.expertName,
|
expertName: event.expertName,
|
||||||
routingMode: event.routingMode,
|
routingMode: event.routingMode,
|
||||||
|
runtimeCallId: event.runtimeCallId,
|
||||||
state: event.state,
|
state: event.state,
|
||||||
reason: event.reason,
|
reason: event.reason,
|
||||||
output: event.output,
|
output: event.output,
|
||||||
@@ -4246,10 +4317,27 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
subagents[index] = subagent
|
subagents[index] = subagent
|
||||||
} else if (subagents.length < 3) {
|
} else if (
|
||||||
|
subagents.length <
|
||||||
|
maximumConversationSubagentActivities
|
||||||
|
) {
|
||||||
subagents.push(subagent)
|
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') {
|
} else if (event.type === 'approval') {
|
||||||
recordActivity({
|
recordActivity({
|
||||||
@@ -4342,6 +4430,12 @@ function App(): React.JSX.Element {
|
|||||||
? 'cancelled'
|
? 'cancelled'
|
||||||
: 'failed'
|
: 'failed'
|
||||||
: 'completed'
|
: 'completed'
|
||||||
|
const incompleteSubagentError = tRef.current(
|
||||||
|
'chat.subagents.incomplete'
|
||||||
|
)
|
||||||
|
const incompleteActivityDetail = tRef.current(
|
||||||
|
'chat.status.activityIncomplete'
|
||||||
|
)
|
||||||
updateRequestActivity(
|
updateRequestActivity(
|
||||||
event.requestId,
|
event.requestId,
|
||||||
terminalStatus,
|
terminalStatus,
|
||||||
@@ -4349,25 +4443,27 @@ function App(): React.JSX.Element {
|
|||||||
? event.message
|
? event.message
|
||||||
: tRef.current('chat.status.taskCompleted')
|
: tRef.current('chat.status.taskCompleted')
|
||||||
)
|
)
|
||||||
if (event.type === 'error') {
|
setActivityRecords((current) =>
|
||||||
setActivityRecords((current) =>
|
current.map((record) =>
|
||||||
current.map((record) =>
|
record.requestId === event.requestId &&
|
||||||
record.requestId === event.requestId &&
|
record.kind !== 'request' &&
|
||||||
record.kind !== 'request' &&
|
(record.status === 'pending' ||
|
||||||
(record.status === 'pending' ||
|
record.status === 'running')
|
||||||
record.status === 'running')
|
? {
|
||||||
? {
|
...record,
|
||||||
...record,
|
status:
|
||||||
status: terminalStatus,
|
event.type === 'done'
|
||||||
detail: `${record.detail}\n${event.message}`.slice(
|
? 'interrupted'
|
||||||
0,
|
: terminalStatus,
|
||||||
4_000
|
detail: `${record.detail}\n${
|
||||||
)
|
event.type === 'done'
|
||||||
}
|
? incompleteActivityDetail
|
||||||
: record
|
: event.message
|
||||||
)
|
}`.slice(0, 4_000)
|
||||||
|
}
|
||||||
|
: record
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
recordActivity({
|
recordActivity({
|
||||||
conversationId: run.conversationId,
|
conversationId: run.conversationId,
|
||||||
requestId: event.requestId,
|
requestId: event.requestId,
|
||||||
@@ -4382,6 +4478,25 @@ function App(): React.JSX.Element {
|
|||||||
: tRef.current('chat.status.runtimeCompleted'),
|
: tRef.current('chat.status.runtimeCompleted'),
|
||||||
status: terminalStatus
|
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) => {
|
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||||
const representedToolError =
|
const representedToolError =
|
||||||
event.type === 'error' &&
|
event.type === 'error' &&
|
||||||
@@ -4394,7 +4509,7 @@ function App(): React.JSX.Element {
|
|||||||
? event.status === 'cancelled'
|
? event.status === 'cancelled'
|
||||||
? ('cancelled' as const)
|
? ('cancelled' as const)
|
||||||
: ('failed' as const)
|
: ('failed' as const)
|
||||||
: undefined
|
: ('interrupted' as const)
|
||||||
const fallbackError =
|
const fallbackError =
|
||||||
event.type === 'error' &&
|
event.type === 'error' &&
|
||||||
!representedToolError &&
|
!representedToolError &&
|
||||||
@@ -4436,6 +4551,26 @@ function App(): React.JSX.Element {
|
|||||||
: tool
|
: tool
|
||||||
)
|
)
|
||||||
: message.tools,
|
: 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
|
blocks: toolTerminalState
|
||||||
? terminalizeMessageToolBlocks(
|
? terminalizeMessageToolBlocks(
|
||||||
appendMessageContentBlock(
|
appendMessageContentBlock(
|
||||||
|
|||||||
@@ -246,26 +246,110 @@ describe('ChatTimeline', () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const region = screen.getByLabelText('子专家状态')
|
const region = screen.getByLabelText('子代理状态')
|
||||||
const summary = screen.getByText('综合结果').parentElement
|
const summary = screen.getByText('综合结果').parentElement
|
||||||
const messageBody = region.parentElement
|
const messageBody = region.parentElement
|
||||||
expect(summary?.parentElement).toBe(messageBody)
|
expect(summary?.parentElement).toBe(messageBody)
|
||||||
expect(
|
expect(
|
||||||
Array.from(messageBody?.children ?? []).indexOf(region)
|
Array.from(messageBody?.children ?? []).indexOf(region)
|
||||||
).toBeLessThan(
|
).toBeGreaterThan(
|
||||||
Array.from(messageBody?.children ?? []).indexOf(summary!)
|
Array.from(messageBody?.children ?? []).indexOf(summary!)
|
||||||
)
|
)
|
||||||
const cards = within(region).getAllByRole('group')
|
const cards = within(region).getAllByRole('group')
|
||||||
expect(cards).toHaveLength(3)
|
expect(cards).toHaveLength(3)
|
||||||
|
const experts = ['研究专家', '代码专家', '安全专家']
|
||||||
for (const [index, output] of [
|
for (const [index, output] of [
|
||||||
'研究专家的独立结论',
|
'研究专家的独立结论',
|
||||||
'代码专家的独立结论',
|
'代码专家的独立结论',
|
||||||
'安全专家的独立结论'
|
'安全专家的独立结论'
|
||||||
].entries()) {
|
].entries()) {
|
||||||
expect(within(cards[index]!).getByText(output)).toBeInTheDocument()
|
expect(
|
||||||
|
within(cards[index]!).queryByText(output)
|
||||||
|
).not.toBeInTheDocument()
|
||||||
expect(cards[index]).not.toHaveAttribute('open')
|
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(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 {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
Library,
|
Library,
|
||||||
ClockFading,
|
ClockFading,
|
||||||
|
LoaderCircle,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
UserRound
|
UserRound,
|
||||||
|
XCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { memo, useEffect, useRef } from 'react'
|
import { memo, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type {
|
import type {
|
||||||
ApprovalDecision,
|
ApprovalDecision,
|
||||||
@@ -78,19 +83,40 @@ export type ImageViewerItem = {
|
|||||||
type MessageBlockRenderItem =
|
type MessageBlockRenderItem =
|
||||||
| {
|
| {
|
||||||
kind: 'block'
|
kind: 'block'
|
||||||
block: Exclude<ConversationMessageBlock, { type: 'tool' }>
|
block: Extract<
|
||||||
|
ConversationMessageBlock,
|
||||||
|
{ type: 'text' | 'reasoning' }
|
||||||
|
>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'tools'
|
kind: 'tools'
|
||||||
id: string
|
id: string
|
||||||
tools: ToolActivity[]
|
tools: ToolActivity[]
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'subagents'
|
||||||
|
id: string
|
||||||
|
childTaskIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
function groupMessageBlocks(
|
function groupMessageBlocks(
|
||||||
blocks: ConversationMessageBlock[]
|
blocks: ConversationMessageBlock[]
|
||||||
): MessageBlockRenderItem[] {
|
): MessageBlockRenderItem[] {
|
||||||
const items: MessageBlockRenderItem[] = []
|
const items: MessageBlockRenderItem[] = []
|
||||||
for (const block of blocks) {
|
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') {
|
if (block.type !== 'tool') {
|
||||||
items.push({ kind: 'block', block })
|
items.push({ kind: 'block', block })
|
||||||
continue
|
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({
|
function SubagentStatusList({
|
||||||
subagents
|
subagents
|
||||||
}: {
|
}: {
|
||||||
@@ -231,44 +341,11 @@ function SubagentStatusList({
|
|||||||
aria-label={t('chat.subagents.region')}
|
aria-label={t('chat.subagents.region')}
|
||||||
className="subagent-status-list"
|
className="subagent-status-list"
|
||||||
>
|
>
|
||||||
{subagents.slice(0, 3).map((subagent) => (
|
{subagents.map((subagent) => (
|
||||||
<details
|
<SubagentStatusCard
|
||||||
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
|
||||||
key={subagent.childTaskId}
|
key={subagent.childTaskId}
|
||||||
>
|
subagent={subagent}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -327,6 +404,20 @@ function ChatMessageRowView({
|
|||||||
const compressionMarkers =
|
const compressionMarkers =
|
||||||
message.contextCompressions ??
|
message.contextCompressions ??
|
||||||
(message.contextCompression ? [message.contextCompression] : [])
|
(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 = (
|
const compressionLabel = (
|
||||||
compression: ConversationContextCompressionMarker
|
compression: ConversationContextCompressionMarker
|
||||||
): string =>
|
): string =>
|
||||||
@@ -468,14 +559,19 @@ function ChatMessageRowView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{message.subagents && message.subagents.length > 0 && (
|
|
||||||
<SubagentStatusList subagents={message.subagents} />
|
|
||||||
)}
|
|
||||||
{message.blocks && message.blocks.length > 0 ? (
|
{message.blocks && message.blocks.length > 0 ? (
|
||||||
<div className="message-blocks">
|
<div className="message-blocks">
|
||||||
{groupMessageBlocks(message.blocks).map((item) =>
|
{groupMessageBlocks(message.blocks).map((item) =>
|
||||||
item.kind === 'tools' ? (
|
item.kind === 'tools' ? (
|
||||||
<ToolExecutionList key={item.id} tools={item.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' ? (
|
) : item.block.type === 'reasoning' ? (
|
||||||
<MessageReasoning
|
<MessageReasoning
|
||||||
content={item.block.content}
|
content={item.block.content}
|
||||||
@@ -510,6 +606,9 @@ function ChatMessageRowView({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{unorderedSubagents && unorderedSubagents.length > 0 && (
|
||||||
|
<SubagentStatusList subagents={unorderedSubagents} />
|
||||||
|
)}
|
||||||
{message.artifactIds?.map((artifactId) => {
|
{message.artifactIds?.map((artifactId) => {
|
||||||
const candidate = artifactById.get(artifactId)
|
const candidate = artifactById.get(artifactId)
|
||||||
const artifact =
|
const artifact =
|
||||||
|
|||||||
@@ -399,6 +399,11 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: /发布笔记/ })
|
screen.getByRole('button', { name: /发布笔记/ })
|
||||||
).toHaveAttribute('aria-pressed', 'true')
|
).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: '待办' }))
|
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||||
const selectedTodoButton = screen
|
const selectedTodoButton = screen
|
||||||
|
|||||||
@@ -1657,12 +1657,21 @@ export function MagicNotesWorkspace({
|
|||||||
{note.preview || t('notes.noPreview')}
|
{note.preview || t('notes.noPreview')}
|
||||||
</span>
|
</span>
|
||||||
<span className="magic-note-list-item__meta">
|
<span className="magic-note-list-item__meta">
|
||||||
{t(
|
<span>
|
||||||
note.entryCount === 1
|
{t(
|
||||||
? 'notes.entryCountOne'
|
note.entryCount === 1
|
||||||
: 'notes.entryCountOther',
|
? 'notes.entryCountOne'
|
||||||
{ count: note.entryCount }
|
: 'notes.entryCountOther',
|
||||||
)}
|
{ count: note.entryCount }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<time dateTime={note.updatedAt}>
|
||||||
|
{t('notes.updatedAt', {
|
||||||
|
date: dateFormatter.format(
|
||||||
|
new Date(note.updatedAt)
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -322,6 +322,8 @@ export const app = {
|
|||||||
taskCompleted: 'Task completed',
|
taskCompleted: 'Task completed',
|
||||||
taskFailed: 'Task failed',
|
taskFailed: 'Task failed',
|
||||||
runtimeCompleted: 'Agent Runtime completed its response',
|
runtimeCompleted: 'Agent Runtime completed its response',
|
||||||
|
activityIncomplete:
|
||||||
|
'The parent request ended before this activity reported completion',
|
||||||
answerSubmitted: 'Answer submitted; OpenCode is continuing',
|
answerSubmitted: 'Answer submitted; OpenCode is continuing',
|
||||||
questionSkipped: 'Question skipped'
|
questionSkipped: 'Question skipped'
|
||||||
},
|
},
|
||||||
@@ -345,12 +347,17 @@ export const app = {
|
|||||||
},
|
},
|
||||||
subagents: {
|
subagents: {
|
||||||
region: 'Subagent status',
|
region: 'Subagent status',
|
||||||
smart: 'Smart routing',
|
badge: 'Subagent',
|
||||||
manual: 'Selected manually',
|
smart: 'GoodBuddy smart routing',
|
||||||
|
manual: 'GoodBuddy selected expert',
|
||||||
|
native: 'Native OpenCode',
|
||||||
fallbackTask: '{{name}} subagent task',
|
fallbackTask: '{{name}} subagent task',
|
||||||
output: 'Expert output',
|
task: 'Task',
|
||||||
|
output: 'Subagent output',
|
||||||
error: 'Execution details',
|
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: {
|
states: {
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
running: 'Running',
|
running: 'Running',
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const magicNotes = {
|
|||||||
noPreview: 'No entries yet',
|
noPreview: 'No entries yet',
|
||||||
entryCountOne: '{{count}} entry',
|
entryCountOne: '{{count}} entry',
|
||||||
entryCountOther: '{{count}} entries',
|
entryCountOther: '{{count}} entries',
|
||||||
|
updatedAt: 'Updated {{date}}',
|
||||||
emptySelectionTitle: 'No note selected',
|
emptySelectionTitle: 'No note selected',
|
||||||
emptySelectionDescription:
|
emptySelectionDescription:
|
||||||
'Select a note on the left, or create a note to start writing.',
|
'Select a note on the left, or create a note to start writing.',
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ export const settings = {
|
|||||||
incompatibleImageProtocol:
|
incompatibleImageProtocol:
|
||||||
'Incompatible (image generation protocol is unsupported)',
|
'Incompatible (image generation protocol is unsupported)',
|
||||||
incompatibleHarnessProtocol:
|
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:
|
secureStorageWarning:
|
||||||
'Secure system key storage is unavailable. Use an environment variable to avoid storing an API Key in plaintext.'
|
'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: '任务执行完成',
|
taskCompleted: '任务执行完成',
|
||||||
taskFailed: '任务执行失败',
|
taskFailed: '任务执行失败',
|
||||||
runtimeCompleted: 'Agent Runtime 已完成响应',
|
runtimeCompleted: 'Agent Runtime 已完成响应',
|
||||||
|
activityIncomplete: '父请求已结束,但该活动未报告完成状态',
|
||||||
answerSubmitted: '回答已提交,OpenCode 正在继续执行',
|
answerSubmitted: '回答已提交,OpenCode 正在继续执行',
|
||||||
questionSkipped: '已跳过问题'
|
questionSkipped: '已跳过问题'
|
||||||
},
|
},
|
||||||
@@ -330,13 +331,17 @@ export const app = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
subagents: {
|
subagents: {
|
||||||
region: '子专家状态',
|
region: '子代理状态',
|
||||||
smart: '智能路由',
|
badge: '子代理',
|
||||||
manual: '手动指定',
|
smart: 'GoodBuddy 智能路由',
|
||||||
|
manual: 'GoodBuddy 指定专家',
|
||||||
|
native: 'OpenCode 原生',
|
||||||
fallbackTask: '{{name}} 子专家任务',
|
fallbackTask: '{{name}} 子专家任务',
|
||||||
output: '专家输出',
|
task: '任务',
|
||||||
|
output: '子代理输出',
|
||||||
error: '执行说明',
|
error: '执行说明',
|
||||||
noOutput: '该专家暂时没有可显示的输出。',
|
noOutput: '子代理暂时没有可显示的输出。',
|
||||||
|
incomplete: '父请求已结束,但子 Agent 未报告完成状态。',
|
||||||
states: {
|
states: {
|
||||||
queued: '等待中',
|
queued: '等待中',
|
||||||
running: '进行中',
|
running: '进行中',
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const magicNotes = {
|
|||||||
noPreview: '还没有记录',
|
noPreview: '还没有记录',
|
||||||
entryCountOne: '{{count}} 条记录',
|
entryCountOne: '{{count}} 条记录',
|
||||||
entryCountOther: '{{count}} 条记录',
|
entryCountOther: '{{count}} 条记录',
|
||||||
|
updatedAt: '更新于 {{date}}',
|
||||||
emptySelectionTitle: '还没有选择笔记',
|
emptySelectionTitle: '还没有选择笔记',
|
||||||
emptySelectionDescription: '从左侧选择笔记,或新建一篇笔记开始记录。',
|
emptySelectionDescription: '从左侧选择笔记,或新建一篇笔记开始记录。',
|
||||||
newEntryLabel: '新记录内容',
|
newEntryLabel: '新记录内容',
|
||||||
|
|||||||
@@ -681,7 +681,7 @@ export const settings = {
|
|||||||
incompatible: '不兼容',
|
incompatible: '不兼容',
|
||||||
incompatibleImageProtocol: '不兼容(不支持图像生成协议)',
|
incompatibleImageProtocol: '不兼容(不支持图像生成协议)',
|
||||||
incompatibleHarnessProtocol:
|
incompatibleHarnessProtocol:
|
||||||
'不兼容(需 Chat Completions、API Key 和安全地址)',
|
'不兼容(需 Chat Completions、API Key 和 HTTP/HTTPS 地址)',
|
||||||
secureStorageWarning:
|
secureStorageWarning:
|
||||||
'当前系统密钥服务不可用。为了避免明文落盘,请使用环境变量提供 API Key。'
|
'当前系统密钥服务不可用。为了避免明文落盘,请使用环境变量提供 API Key。'
|
||||||
},
|
},
|
||||||
|
|||||||
+130
-15
@@ -1609,8 +1609,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.magic-note-list-item__meta {
|
.magic-note-list-item__meta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-caption);
|
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 {
|
.magic-notes-stream-pane {
|
||||||
@@ -4891,7 +4907,8 @@ button > svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-blocks .message-reasoning,
|
.message-blocks .message-reasoning,
|
||||||
.message-blocks .tool-execution-list {
|
.message-blocks .tool-execution-list,
|
||||||
|
.message-blocks .subagent-status-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5243,25 +5260,25 @@ button > svg {
|
|||||||
|
|
||||||
.subagent-status-list {
|
.subagent-status-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin-top: var(--space-2);
|
margin: var(--space-2) 0 var(--space-4);
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card {
|
.subagent-status-card {
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-card);
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-raised);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card > summary {
|
.subagent-status-card > summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-3) var(--space-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5278,38 +5295,130 @@ button > svg {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subagent-status-card--queued {
|
||||||
|
box-shadow: inset 3px 0 0 var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.subagent-status-card--running {
|
.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--failed,
|
||||||
.subagent-status-card--cancelled {
|
.subagent-status-card--cancelled {
|
||||||
border-color: var(--danger-border);
|
border-color: var(--danger-border);
|
||||||
background: var(--danger-subtle);
|
background: var(--danger-subtle);
|
||||||
|
box-shadow: inset 3px 0 0 var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card__identity {
|
.subagent-status-card__identity {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
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);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card summary strong,
|
.subagent-status-card__heading > strong {
|
||||||
.subagent-status-card summary > span:last-child {
|
|
||||||
color: var(--text-primary);
|
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);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-caption);
|
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 {
|
.subagent-status-card__details {
|
||||||
display: grid;
|
display: grid;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-subtle);
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5333,6 +5442,12 @@ button > svg {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes subagent-status-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.approval-card {
|
.approval-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -242,4 +242,49 @@ describe('conversation persistence contracts', () => {
|
|||||||
|
|
||||||
expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
|
|||||||
type: z.literal('tool'),
|
type: z.literal('tool'),
|
||||||
tool: conversationToolActivitySchema
|
tool: conversationToolActivitySchema
|
||||||
})
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
id: assistantIdSchema,
|
||||||
|
type: z.literal('subagent'),
|
||||||
|
childTaskId: assistantIdSchema
|
||||||
|
})
|
||||||
.strict()
|
.strict()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -186,12 +193,16 @@ export type ConversationMessageBlock = z.infer<
|
|||||||
typeof conversationMessageBlockSchema
|
typeof conversationMessageBlockSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export const maximumConversationToolActivities = 100
|
||||||
|
export const maximumConversationSubagentActivities = 100
|
||||||
|
|
||||||
export const conversationSubagentActivitySchema = z
|
export const conversationSubagentActivitySchema = z
|
||||||
.object({
|
.object({
|
||||||
childTaskId: assistantIdSchema,
|
childTaskId: assistantIdSchema,
|
||||||
expertId: assistantIdSchema,
|
expertId: assistantIdSchema,
|
||||||
expertName: z.string().trim().min(1).max(80),
|
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([
|
state: z.enum([
|
||||||
'queued',
|
'queued',
|
||||||
'running',
|
'running',
|
||||||
@@ -240,10 +251,13 @@ export const conversationMessageSchema = z
|
|||||||
.array(conversationContextCompressionMarkerSchema)
|
.array(conversationContextCompressionMarkerSchema)
|
||||||
.max(2)
|
.max(2)
|
||||||
.optional(),
|
.optional(),
|
||||||
tools: z.array(conversationToolActivitySchema).max(100).optional(),
|
tools: z
|
||||||
|
.array(conversationToolActivitySchema)
|
||||||
|
.max(maximumConversationToolActivities)
|
||||||
|
.optional(),
|
||||||
subagents: z
|
subagents: z
|
||||||
.array(conversationSubagentActivitySchema)
|
.array(conversationSubagentActivitySchema)
|
||||||
.max(3)
|
.max(maximumConversationSubagentActivities)
|
||||||
.optional(),
|
.optional(),
|
||||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||||
sourceReferences: z
|
sourceReferences: z
|
||||||
|
|||||||
+6
-18
@@ -21,6 +21,7 @@ import {
|
|||||||
conversationAttachmentSchema,
|
conversationAttachmentSchema,
|
||||||
conversationHistoryMessageSchema,
|
conversationHistoryMessageSchema,
|
||||||
conversationContextCompressionStateSchema,
|
conversationContextCompressionStateSchema,
|
||||||
|
conversationSubagentActivitySchema,
|
||||||
legacyWorkModeSchema,
|
legacyWorkModeSchema,
|
||||||
maximumConversationHistoryCharacters,
|
maximumConversationHistoryCharacters,
|
||||||
maximumConversationHistoryMessages,
|
maximumConversationHistoryMessages,
|
||||||
@@ -830,7 +831,7 @@ export const runtimeSettingsInputSchema = z
|
|||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['deepseekHarnessModelSource'],
|
path: ['deepseekHarnessModelSource'],
|
||||||
message:
|
message:
|
||||||
'DeepSeek Harness 仅支持使用 API Key 的安全 OpenAI 兼容 Chat Completions 连接'
|
'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const compressionSource = settings.contextCompression?.modelSource
|
const compressionSource = settings.contextCompression?.modelSource
|
||||||
@@ -1171,24 +1172,11 @@ export const approvalDecisionSchema = z.enum([
|
|||||||
|
|
||||||
export type ApprovalDecision = z.infer<typeof approvalDecisionSchema>
|
export type ApprovalDecision = z.infer<typeof approvalDecisionSchema>
|
||||||
|
|
||||||
export const subagentEventSchema = z
|
export const subagentEventSchema =
|
||||||
.object({
|
conversationSubagentActivitySchema
|
||||||
|
.extend({
|
||||||
requestId: z.string().uuid(),
|
requestId: z.string().uuid(),
|
||||||
type: z.literal('subagent'),
|
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()
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ describe('DeepSeek Harness model compatibility', () => {
|
|||||||
'https://api.deepseek.com',
|
'https://api.deepseek.com',
|
||||||
'https://gateway.example/openai/v1',
|
'https://gateway.example/openai/v1',
|
||||||
'https://gateway.example:8443/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://127.0.0.1:11434/v1',
|
||||||
'http://localhost:1234/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) => {
|
])('accepts OpenAI-compatible base URL %s', (baseUrl) => {
|
||||||
expect(isDeepSeekHarnessCompatibleBaseUrl(baseUrl)).toBe(true)
|
expect(isDeepSeekHarnessCompatibleBaseUrl(baseUrl)).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
@@ -24,12 +29,10 @@ describe('DeepSeek Harness model compatibility', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'http://gateway.example/v1',
|
|
||||||
'file:///private/config',
|
'file:///private/config',
|
||||||
'https://user:secret@gateway.example/v1',
|
'ftp://gateway.example/v1',
|
||||||
'https://gateway.example/v1?api-version=2025-01-01',
|
'not-a-url'
|
||||||
'https://gateway.example/v1#chat'
|
])('rejects unsupported base URL %s', (baseUrl) => {
|
||||||
])('rejects unsafe or unsupported base URL %s', (baseUrl) => {
|
|
||||||
expect(isDeepSeekHarnessCompatibleBaseUrl(baseUrl)).toBe(false)
|
expect(isDeepSeekHarnessCompatibleBaseUrl(baseUrl)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,35 +4,14 @@ type DeepSeekHarnessModelProfile = {
|
|||||||
authentication: string
|
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(
|
export function isDeepSeekHarnessCompatibleBaseUrl(
|
||||||
value: string
|
value: string
|
||||||
): boolean {
|
): boolean {
|
||||||
try {
|
try {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
if (
|
|
||||||
!url.hostname ||
|
|
||||||
url.username ||
|
|
||||||
url.password ||
|
|
||||||
url.search ||
|
|
||||||
url.hash
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
url.protocol === 'https:' ||
|
Boolean(url.hostname) &&
|
||||||
(url.protocol === 'http:' &&
|
(url.protocol === 'http:' || url.protocol === 'https:')
|
||||||
isLoopbackHostname(url.hostname))
|
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
modelBridgePolicySchema
|
modelBridgePolicySchema
|
||||||
} from './model-bridge-contracts'
|
} from './model-bridge-contracts'
|
||||||
|
|
||||||
|
export const UNBOUNDED_REMOTE_PROMPT_DEADLINE =
|
||||||
|
'9999-12-31T23:59:59.999Z'
|
||||||
|
|
||||||
export const runtimeSessionBindingStateSchema = z.enum([
|
export const runtimeSessionBindingStateSchema = z.enum([
|
||||||
'opening',
|
'opening',
|
||||||
'ready',
|
'ready',
|
||||||
|
|||||||
Reference in New Issue
Block a user