fix: stabilize Runtime workflows for 0.12.3
OpenCode status checks could cold-start throwaway servers, unrelated conversations could block each other, and model, Continue, or remote paths could replay or duplicate work under failure and backlog conditions. Settings could also lose edits or overwrite unrelated model fields. This update makes status checks passive, keeps per-conversation ordering while allowing concurrency, preserves native long-running work with bounded display capture and backpressure, narrows retries to known pre-dispatch failures, and aligns remote Ask permissions without reducing Execute. It also keeps Embedding updates narrow, improves settings and browser guidance, removes the unused legacy Main gateway state, and records the validated Desktop 0.12.3 and Agent 0.11.17 release metadata. Release note: 改善 OpenCode 切换、多会话长任务、模型重试、远程 Ask/Execute 与设置保存可靠性,并完成 Desktop 0.12.3 / Agent 0.11.17 发布准备。
This commit is contained in:
@@ -220,6 +220,9 @@ package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host
|
||||
|
||||
GoodBuddy 管理的本机 OpenCode 启动后会轮询已认证的 loopback health 接口,最多等待
|
||||
30 秒。该窗口用于兼容 Linux ARM 等较慢环境;不要改回依赖 stdout 文案或 10 秒固定上限。
|
||||
聊天中的 Runtime 切换只执行路径和模型凭据就绪检查,不为状态显示启动临时 Server。
|
||||
设置中的显式连接测试、原生能力清单读取和首次实际请求仍会启动 OpenCode,并继续使用
|
||||
上述完整健康检查。
|
||||
|
||||
## GoodBuddy Agent 工件
|
||||
|
||||
@@ -308,7 +311,8 @@ integrity,以 `--ignore-scripts` 从官方 npm registry 获取归档;bundle
|
||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership 和
|
||||
`runtime/model-bridge` v1。Agent 通过私有 Unix socket 按需 detached 启动,不依赖
|
||||
systemd、D-Bus、Linger 或 bubblewrap。Ask 与 Execute 都直接启动签名 Runtime;两者的
|
||||
差异由 Agent 工具权限分发边界执行。
|
||||
差异由 OpenCode Ask 权限配置和 Agent 工具权限分发边界共同执行,Execute 不注入该
|
||||
权限覆盖。
|
||||
|
||||
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
||||
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
||||
|
||||
+28
-20
@@ -77,8 +77,11 @@ otherwise.
|
||||
cleanup preserves complete Windows process-tree termination and terminates
|
||||
POSIX process groups when a child uses an independent process group. Chat
|
||||
status checks use a one-shot Runtime probe that is immediately cleaned up
|
||||
rather than entering the model-and-project execution cache. Execution
|
||||
Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
|
||||
rather than entering the model-and-project execution cache. For managed
|
||||
local OpenCode, this passive check validates the selected path and model
|
||||
credential without starting a throwaway server; the explicit settings test,
|
||||
native inventory, and first real use still perform full startup and health
|
||||
checks. Execution Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
|
||||
requests in the same conversation ordered, while different conversations in
|
||||
the same project and different projects can run in parallel. Request-scoped
|
||||
dynamic MCP tools remain isolated through default wildcard disablement and
|
||||
@@ -99,9 +102,10 @@ otherwise.
|
||||
projects, ordinary desktop capabilities, or desktop releases. When enabled,
|
||||
users can manage SSH Hosts with pinned Host Keys, browse bounded remote
|
||||
directories, and create Ask or Execute projects. Both modes start the
|
||||
signed Runtime directly without bubblewrap. Ask permits only read tools at
|
||||
the Agent tool-dispatch boundary, while Execute has all permissions of the
|
||||
selected SSH account. The Agent owns accepted Prompts,
|
||||
signed Runtime directly without bubblewrap. Ask applies OpenCode's Ask
|
||||
permission configuration and permits only native read approvals at the
|
||||
Agent tool-dispatch boundary, while Execute receives no such override and
|
||||
retains all permissions of the selected SSH account. The Agent owns accepted Prompts,
|
||||
provider/tool rounds, Runtime processes, a stable model ledger, and a bounded
|
||||
semantic transcript over a private Unix socket and ACP v5. Work continues on
|
||||
the Host after Desktop exit, network loss, or local-process termination.
|
||||
@@ -137,9 +141,9 @@ otherwise.
|
||||
concurrency, SSH relay loss, cancellation, definite and uncertain provider
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current source lock and
|
||||
Agent source lock is `0.11.16`, while the current Desktop release candidate
|
||||
is `0.12.2`; formal publication status follows the separate Agent and Desktop
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.11.17`, while the current Desktop release candidate is `0.12.3`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels.
|
||||
- [x] **Manual SSH Host environment provisioning source path**: After Host Key,
|
||||
authentication, and system probes succeed, GoodBuddy saves the Host and
|
||||
@@ -318,9 +322,10 @@ otherwise.
|
||||
install, SSL, pip, and venv probe before packaging. The immutable `v0.12.0`
|
||||
attempt passed Windows and macOS but exposed Linux TAR case handling before
|
||||
publication. The immutable `v0.12.1` attempt was cancelled before native
|
||||
packaging while synchronizing the Agent release; the corrected `0.12.2`
|
||||
candidate must pass all six targets. Real Skill/MCP execution, custom interpreter
|
||||
execution, and coordination with affected running processes remain
|
||||
packaging while synchronizing the Agent release. Published `v0.12.2`
|
||||
subsequently passed all six native package jobs and their managed-Python
|
||||
install probes. Real Skill/MCP execution, custom interpreter execution, and
|
||||
coordination with affected running processes remain
|
||||
acceptance work. Six-platform release acceptance must not be claimed before
|
||||
the applicable checks pass.
|
||||
- [x] **On-demand built-in MCP**: Knowledge, Magic Notes, GoodBuddy
|
||||
@@ -523,15 +528,18 @@ otherwise.
|
||||
dual-architecture resource verification are wired. Current Linux x64 source
|
||||
has passed a real-model and tool matrix covering detach, process exit, relay
|
||||
loss, concurrency, cancellation, provider failures, Agent restart, and
|
||||
Desktop SQLite recovery. The final `0.11.14` test-signed package again
|
||||
verified two delivered model rounds, one tool, one terminal state,
|
||||
`latest=ACK`, and no owner/journal residue. The public signing-key registry is
|
||||
provisioned. Direct control-plane source does not wait for a new
|
||||
installer-bearing package, but the current formally signed Linux x64/arm64
|
||||
artifact matrix still requires public verification, along with
|
||||
GitHub/Beijing mirror, dual-architecture, and offline GoodBuddy transfer
|
||||
release acceptance. Until these gates pass, this path must not be described
|
||||
as published or as having passed formal release acceptance.
|
||||
Desktop SQLite recovery. A current-source, test-signed Agent `0.11.17`
|
||||
package on Linux x64 additionally passed two concurrent Ask reads, an
|
||||
Execute write/read, and six real managed-gateway model requests with the
|
||||
configured custom headers and body fields. No provider request was replayed,
|
||||
and scanning the isolated Agent state found no API key or custom request
|
||||
value. The public signing-key registry is provisioned. Direct control-plane
|
||||
source does not wait for a new installer-bearing package, but the current
|
||||
formally signed Linux x64/arm64 artifact matrix still requires public
|
||||
verification, along with GitHub/Beijing mirror, dual-architecture, and
|
||||
offline GoodBuddy transfer release acceptance. Until these gates pass, this
|
||||
path must not be described as published or as having passed formal release
|
||||
acceptance.
|
||||
- [ ] **Multi-cloud remote sandbox Agents** (planned): Manages dedicated Linux
|
||||
sandboxes through cloud-provider APIs and SSH Agents. Credentials remain in
|
||||
Main, and high-risk control-plane operations receive separate confirmation.
|
||||
|
||||
+4
-4
@@ -28,8 +28,8 @@
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 在 Agent 工具分发边界只允许读取工具,Execute 使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.16`,Desktop 发布候选为 `0.12.2`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试、原生能力清单和首次实际使用仍执行完整启动及健康检查;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.17`,Desktop 发布候选为 `0.12.3`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
- [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。
|
||||
- [x] **本机工具执行环境源码链路**:在“能力与工具 > 工具执行环境”中为本机 Skills 与 stdio MCP 选择 GoodBuddy 托管 Node.js、按需安装的托管 Python,或经过真实验证的自定义解释器;提供独立的原生地址/OSS 镜像选择、诊断、安装进度、取消和删除。新的本机 Runtime 与 stdio MCP 获得不可变 PATH 快照,不修改普通终端、系统环境或远程 Host。Windows x64 托管 Node 与原生地址 Python 已通过真实安装验证;托管 Python 归档按目标文件系统验证,Linux 保留大小写不同的合法路径,所有目标仍拒绝完全重复路径和不安全条目。
|
||||
- [ ] **本机工具执行环境发布验收**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。修正后的 `0.12.2` 候选仍须通过全部六个目标。真实 Skill/MCP、自定义解释器和运行中进程协调仍是待完成验收;完成适用检查前不宣称六平台发布验收通过。
|
||||
- [ ] **本机工具执行环境发布验收**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。真实 Skill/MCP、自定义解释器和运行中进程协调仍是待完成验收。
|
||||
- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
|
||||
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍会重新发现并经过现有执行记录与权限边界。
|
||||
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
|
||||
@@ -107,7 +107,7 @@
|
||||
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
|
||||
- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;最终 `0.11.14` 测试签名包再次验证两个已交付模型轮次、一个工具、一个终态、`latest=ACK` 与零 owner/journal 残留。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。
|
||||
- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;当前源码测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。
|
||||
- [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。
|
||||
|
||||
## 规划原则
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.16",
|
||||
"agentVersion": "0.11.17",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档类型 | 功能进度 |
|
||||
| 状态 | 已实施,发布候选 CI 验收中 |
|
||||
| 版本 | 0.5 |
|
||||
| 状态 | 已实施,六平台打包验收已通过;真实 Skills/MCP 与自定义环境验收待完成 |
|
||||
| 版本 | 0.6 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联入口 | [工具执行环境](./README.md) |
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
带入许可证文件。标准六平台打包任务已改用目标架构原生 Runner,并在打包前执行托管 Python
|
||||
的真实安装、SSL、pip 和 venv 探针。不可变的 `v0.12.0` 尝试已通过 Windows 与 macOS,
|
||||
但 Linux x64/ARM64 在发布前暴露 TAR 大小写冲突误判;`v0.12.1` 在同步 Agent 发布时
|
||||
于原生打包前取消。修正后的 `0.12.2` 候选仍需通过全部六个目标才能完成对应跨平台验收。
|
||||
于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python
|
||||
安装、SSL、pip 和 venv 探针。
|
||||
|
||||
## 已完成
|
||||
|
||||
@@ -67,8 +68,7 @@
|
||||
- [x] 发布六个平台/架构的字节完全相同 OSS 镜像对象并公开回读校验。
|
||||
- [x] 确认托管 Python 保持按需下载,Desktop 发行包不额外带入许可证文件。
|
||||
- [x] Windows ARM64、macOS x64/ARM64 原生 CI 安装、SSL、pip、venv 探针。
|
||||
- [ ] Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针;`v0.12.0` 因 TAR 大小写
|
||||
冲突误判失败,`v0.12.1` 未进入原生矩阵,等待 `0.12.2` 修正候选运行结果。
|
||||
- [x] Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针;`v0.12.2` 标签工作流已通过。
|
||||
- [ ] 最低 Windows/macOS/glibc 支持矩阵和 Windows NuGet 签名核验。
|
||||
- [ ] 真实 JavaScript/Python SKILL 成果及 Node/Python stdio MCP 调用。
|
||||
- [ ] 自定义 Node/Python 在产品 UI 中的真实选择和任务执行。
|
||||
@@ -100,6 +100,9 @@
|
||||
- `0.12.2` 协调候选的 Release Notes 校验通过;Agent/Desktop 发布链路聚焦测试
|
||||
`106/106` 通过、1 项平台用例跳过。最终 `npm test` 为 `3478` 通过、`51` 跳过,
|
||||
typecheck 与 lint 通过;仍未运行本地 production build、package 或安装探针。
|
||||
- `v0.12.2` 标签工作流 `33714902060` 随后成功完成 source validation、production
|
||||
bundle、Windows/macOS/Linux x64/arm64 六个原生 package job 及各自托管 Python
|
||||
安装探针,并于 2026-09-03 发布非 draft、非 prerelease 的正式 Release。
|
||||
|
||||
### 2026-09-02 完整实现
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
|
||||
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
|
||||
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
|
||||
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
|
||||
Agent 源码 lock 为 `0.11.16`,Desktop 恢复候选为 `0.12.2`,等待各自独立发布审批。
|
||||
Agent 源码 lock 为 `0.11.17`,Desktop 候选为 `0.12.3`,等待各自独立发布审批。
|
||||
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
|
||||
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
|
||||
|
||||
@@ -187,8 +187,9 @@ Ask 与 Execute 一样直接启动已签名 Runtime,不要求 Host 安装额
|
||||
- `cwd` 为项目 Workspace,并继承 SSH 账号的正常环境;
|
||||
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
|
||||
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
|
||||
- Ask 的只读语义由 Agent 持有的工具权限分发边界执行,不对 Runtime 进程增加文件系统
|
||||
confinement。未来审批模式也在同一工具边界决定是否放行,不改变 Runtime 启动方式。
|
||||
- Ask 启动时同时注入 OpenCode 顶层和 build Agent 的 `permission: "ask"`;Agent
|
||||
工具权限分发边界只批准上述原生读取请求。该双重边界不另加文件系统 confinement,
|
||||
也不改变 Runtime 的直接启动方式。
|
||||
|
||||
### Execute
|
||||
|
||||
@@ -200,6 +201,7 @@ Execute 直接启动已签名 Runtime:
|
||||
launcher 路径;
|
||||
- `cwd` 为项目 Workspace;
|
||||
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
|
||||
- 不注入 OpenCode Ask 权限配置;
|
||||
- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。
|
||||
|
||||
两种模式都保留输入字节上限、用户取消和进程组清理;输出只用有界内存队列与 journal
|
||||
@@ -225,7 +227,9 @@ Execute 直接启动已签名 Runtime:
|
||||
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
||||
- Runtime 事件仍对单项展示字段做有界截断,但不按固定工具调用数或 Desktop 累计展示量
|
||||
中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生 Runtime。
|
||||
Renderer 和 Main 将已接受的全部工具活动保存到本地 SQLite,不再按活动数静默截断。
|
||||
Renderer 和 Main 为每条回复保留最多 500 条工具与子 Agent 活动以及 2,000 个交错
|
||||
展示块;超过后显示本地截断提示,但继续执行 Runtime,也不把展示容量当作 Prompt
|
||||
工具调用上限。已保留活动的后续状态更新仍会正常归并。
|
||||
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
|
||||
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
|
||||
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
|
||||
@@ -350,7 +354,7 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
|
||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||
的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||
当前源码 lock 固定为 `0.11.16`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
|
||||
当前源码 lock 固定为 `0.11.17`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
|
||||
- 2026-08-30 在共享 Linux x64 Host 的隔离测试 HOME 中验证当前 `0.11.13` 源码候选:
|
||||
签名 Runtime 清单的测试墙钟上限为 1 秒,模型桥首轮故意延迟 2.515 秒后 Prompt 仍在
|
||||
5.770 秒正常完成;随后 OpenCode 原生 Task 依次产生 `running`、`completed` 子 Agent
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# GoodBuddy 0.12.3 发布前审查
|
||||
|
||||
## 状态
|
||||
|
||||
发布候选验证完成。代码问题和发布前 UI 逻辑问题已完成主线程复核与聚焦
|
||||
回归;真实模型、当前源码 Agent 的共享 Linux x64 Host E2E、全量测试、
|
||||
类型检查、Lint 和获批双语发布说明校验均已通过。Desktop/Agent 已分别升至
|
||||
`0.12.3`/`0.11.17`。本次按用户选择不构建 LoongArch 实验预览;剩余操作
|
||||
仅为审查暂存内容并提交,未经后续明确授权不 push、tag 或发布。
|
||||
|
||||
## 审查范围
|
||||
|
||||
- 原始审查范围:`f18e4abb3c0e8c9e7c5c870cb50a67e9ed8880fe` 至
|
||||
`4e60a4ca616846fbfc312932456e1a1e7e0a0388`。
|
||||
- 发布比较基线:Desktop `v0.12.2`
|
||||
(`c52732441aba599e643211b3d2175c8ff7027e26`)。
|
||||
- 后续补充:Linux arm64 上本机 OpenCode Runtime 切换性能。
|
||||
- 用户已有的 Desktop Pet 设计提交
|
||||
`374968d50b3f1ae30165a5e12adf7f6213b6c00c` 保持不变,并纳入最终
|
||||
发布范围核对。
|
||||
|
||||
审查分别覆盖 Desktop/UI、文档与构建、远程执行、模型 Runtime、
|
||||
稳定性以及性能/KISS。Subagent 服务曾出现无 Token、Provider 暂不可用、
|
||||
认证失败和本机 `EPERM`,这些基础设施失败未计为审查完成;六个方向最终
|
||||
都获得了结果,所有候选又由主线程沿生产调用链复核和去重。
|
||||
|
||||
## 已验证并修复的问题
|
||||
|
||||
### 1. 默认直连模型遗漏连接级请求设置
|
||||
|
||||
`createAgentRuntime()` 的默认直连聊天路径没有把当前模型连接的
|
||||
`requestHeaders`、`requestBody` 和 `maximumOutputTokens` 交给
|
||||
`ModelAgentRuntime`。设置页显示已保存,但普通聊天不会采用这些值。
|
||||
|
||||
处理:默认直连路径与独立 profile 路径使用相同字段,并通过真实请求
|
||||
mock 验证 Header、Body 和输出上限到达 Provider 请求。
|
||||
|
||||
### 2. 切换 Embedding 连接会清空无关模型字段
|
||||
|
||||
Main 在切换当前 Embedding 连接时重建 `modelProfiles`,遗漏
|
||||
`maximumOutputTokens`、自定义 Header 和自定义 Body。一次向量连接切换
|
||||
会静默清除已保存的 LLM 设置。
|
||||
|
||||
最终处理:不再从完整设置 snapshot 重建输入。切换操作进入
|
||||
`RuntimeSettingsStore` 的串行队列后,只验证并更新
|
||||
`activeEmbeddingConnectionId`;失败回滚也只在当前值仍等于该次选择时恢复
|
||||
这一字段,因此不会丢失模型 profile、API Key keep 语义、Runtime 选择、
|
||||
环境覆盖或并发保存的其他设置。
|
||||
|
||||
### 3. ACP 入站背压过早恢复
|
||||
|
||||
远端 ACP 队列消费一个事件后就恢复入站,即使剩余项目数或字节数仍达到
|
||||
上限,积压时会反复暂停和恢复并继续放大内存压力。
|
||||
|
||||
处理:统一使用 `pendingUpdateLimitReached()`,只有项目数和字节数都降到
|
||||
阈值以下时才恢复输入。
|
||||
|
||||
### 4. Continue 截断后可能重复或乱序显示答案
|
||||
|
||||
Continue Host 的事件流出现 gap 后仍继续转发后续 delta,最终 fallback
|
||||
又可能输出完整答案,导致已显示内容重复或乱序。
|
||||
|
||||
处理:一旦确认事件流溢出就停止转发后续 delta;最终文本只有在它确实以
|
||||
已显示文本为前缀时才追加未显示后缀。
|
||||
|
||||
### 5. 直连模型可能重放已到达 Provider 的请求
|
||||
|
||||
普通 `ECONNRESET`、Header/Body timeout 和 GoodBuddy 自身请求 timeout
|
||||
并不能证明 POST 尚未到达 Provider。自动重试这些错误可能重复生成和计费,
|
||||
且每次尝试会重新开始请求时限。
|
||||
|
||||
处理:自动重试仅保留明确的连接前失败,包括 DNS 临时失败、连接拒绝、
|
||||
网络/主机不可达、connect timeout,以及明确发生在 TLS 建立前的 reset。
|
||||
普通 reset 和请求阶段 timeout 不再自动重放。
|
||||
|
||||
### 6. 远端 Ask 不能只依赖 ACP 权限回调
|
||||
|
||||
OpenCode 1.18.9 的默认 build Agent 最终权限包含通配 `allow`。若 Runtime
|
||||
未产生 `requestPermission`,仅在 ACP 回调中拒绝写工具不能形成完整的
|
||||
Ask 只读边界。
|
||||
|
||||
处理:Ask 启动同时注入顶层和 build Agent 的 `permission: "ask"`;
|
||||
Execute 不注入该覆盖。受管模型桥 helper 增加固定 `--work-mode` 参数,
|
||||
让直接 Runtime 和模型桥 Runtime 使用同一规则。构建、双语功能清单、
|
||||
远程 Host 技术设计及源码注释同步改为这条双重边界,不再描述已撤回的
|
||||
read-only sandbox。
|
||||
|
||||
实际使用隔离配置运行 `opencode debug config` 和
|
||||
`opencode debug agent build --pure` 验证过 OpenCode 1.18.9 的配置优先级。
|
||||
曾评估独立 HOME/XDG 与 `--pure`,但它们会隐藏用户正常环境或插件,已因
|
||||
可能影响功能而撤回。
|
||||
|
||||
### 7. Main 中保留了一套没有生产调用者的旧模型网关
|
||||
|
||||
`RemoteModelGateway`、`ModelCallOperationStore` 及其共享 operation
|
||||
contracts 只被自身测试和旧 Main 网关引用。真实远端调度由 Agent 侧
|
||||
`AgentModelGateway` 承担,保留两套实现会让修复落到错误路径。
|
||||
|
||||
处理:删除无生产构造者的 Main 旧实现及孤立测试;把仍在使用的 resolved
|
||||
profile digest 移入 `managed-model-bridge.ts`。摘要继续绑定请求定制,
|
||||
但明确排除 API Key。
|
||||
|
||||
### 8. Linux arm64 切换 OpenCode 会阻塞在用后即毁的冷启动
|
||||
|
||||
Composer 切换先同步等待一次 `agent.getStatus()`。旧路径会读取已分配
|
||||
Skills/MCP、创建临时配置和全新 XDG 目录、启动 `opencode serve`、等待
|
||||
health、调用 `session.list`,随后立即杀死进程并递归清理。切换成功后
|
||||
原生能力清单还会执行另一次独立冷启动,首次真实请求再启动可复用实例。
|
||||
此前提高 30 秒上限、改用 health 和跳过 `--version` 都没有消除这次
|
||||
阻塞冷启动。
|
||||
|
||||
处理:本机受管 OpenCode 的被动状态检查只解析设置、模型凭据和已选择
|
||||
路径,不读取 Skills/MCP,也不启动 Server。设置中的显式连接测试、原生
|
||||
能力清单和首次真实请求仍执行完整启动、health 与连接检查。状态探针仍
|
||||
为一次性对象,不把空闲 OpenCode Server 留在执行缓存中。
|
||||
|
||||
Windows x64 上仓库现有真实 bundled OpenCode 回环探针显示每次冷启动约
|
||||
1.8 秒;历史 Linux ARM 回归已证明同一阶段可能超过 10 秒。修复后的被动
|
||||
状态单测为毫秒级且断言没有 spawn、client 或 health 调用。
|
||||
|
||||
### 9. OpenCode 被动状态可能验证错误的 fallback 路径
|
||||
|
||||
被动状态初版先让 validator 搜索可用二进制。若用户明确配置的路径已被
|
||||
删除,validator 可能改为找到 bundled 或 PATH 中的另一个候选并报告就绪,
|
||||
但真实启动仍会选择那条失效的配置路径。
|
||||
|
||||
处理:先按真实 launch 的优先级只选择目标路径,再对这个精确路径执行
|
||||
不启动进程的 canonical-file 校验;校验阶段禁止 fallback 到其他候选。
|
||||
|
||||
### 10. 自定义 Header 可能保存 Fetch 无法编码的值
|
||||
|
||||
原 schema 会接受中文等 UTF-16 code unit 大于 255 的 Header 值,但
|
||||
Runtime 构造 Fetch `Headers` 时会因不符合 ByteString 要求抛错。用户会
|
||||
看到设置保存成功,实际请求却在发送前失败。
|
||||
|
||||
处理:保存和 Runtime 共用的 schema 现在同时拒绝控制字符及 code unit
|
||||
大于 255 的值,并增加中文 Header 值回归用例。
|
||||
|
||||
### 11. 设置保存进行中仍可编辑并丢失新输入
|
||||
|
||||
保存按钮会进入 pending 状态,但 textarea 等设置控件仍可修改。旧保存
|
||||
响应返回后会用已保存 snapshot 覆盖这段新输入并清除 dirty 状态。
|
||||
|
||||
处理:设置主体在保存或连接测试期间统一设为 `inert` 和
|
||||
`aria-busy`,避免产生无法归属到当前请求的新草稿,同时保持现有异步反馈。
|
||||
|
||||
### 12. Embedding-only 更新可能写回环境值或覆盖并发保存
|
||||
|
||||
Embedding-only helper 曾从应用环境变量覆盖后的 effective profile 生成
|
||||
写回输入。在环境临时指定 Anthropic/API Key 时,切换 Embedding 连接可能
|
||||
把这些有效值持久化进原本的 OpenAI/no-auth 配置。该 helper 还会在设置
|
||||
串行队列外读取完整 snapshot;若另一项设置保存先入队,旧 snapshot 随后
|
||||
可能把新保存内容整体覆盖,失败回滚也有同样风险。
|
||||
|
||||
处理:删除整份设置重建 helper,改为在 `RuntimeSettingsStore` 队列内只
|
||||
验证并更新 `activeEmbeddingConnectionId`。失败回滚也只恢复这一字段;
|
||||
若期间用户已选择其他连接则不覆盖。测试覆盖环境覆盖移除、并发完整保存
|
||||
和窄回滚后其他设置仍保留。
|
||||
|
||||
### 13. 内置浏览器卡片没有说明仅 Execute 可用
|
||||
|
||||
“能力与工具”中的浏览器卡片只显示通用的“按模式读写”,但后端只在
|
||||
Execute 模式提供 browser tools,容易让 Ask 用户误以为当前模式可用。
|
||||
|
||||
处理:浏览器卡片增加双语“仅 Execute / Execute only”摘要,其他 MCP
|
||||
卡片和后端权限逻辑不变。
|
||||
|
||||
### 14. 长任务活动展示没有实际容量边界
|
||||
|
||||
候选已取消“超过 100 个工具便终止 Runtime”的功能限制,但同时移除了
|
||||
消息中工具、子 Agent 和交错 block 数组的所有保留上限。长任务会让
|
||||
Renderer 每次事件反复复制并线性搜索不断增长的数组,恢复与 SQLite
|
||||
metadata 也会持续变大,和文档所述“只截断本地展示副本”不一致。
|
||||
|
||||
处理:Runtime 继续不受工具调用数限制;每条回复的本地副本最多保留
|
||||
500 条工具/子 Agent 活动和 2,000 个交错展示块。超出后显示持久的双语
|
||||
提示,已保留活动仍可更新状态,原生任务、模型轮次和工具执行均不取消。
|
||||
该上限高于 `v0.12.2` 已发布的 100 条 schema 上限,不破坏已发布数据。
|
||||
|
||||
### 15. 持续 journal 背压会高频轮询 SQLite
|
||||
|
||||
远端 Runtime 输出在 journal 暂时满时每 25 毫秒重新执行一次同步 SQLite
|
||||
append。Desktop 断连或消费者较慢数分钟时,每个受阻 binding 都会每秒
|
||||
触发 40 次无效事务和事件循环唤醒。
|
||||
|
||||
处理:第一次仍立即尝试,失败后从 25 毫秒开始指数退避,最高 1 秒;
|
||||
容量恢复、取消、deadline 和 Runtime 输出暂停语义不变。
|
||||
|
||||
## 候选问题处置
|
||||
|
||||
- Desktop/UI 首轮审查未发现 P0-P3;发布前第二轮按 Runtime 切换、
|
||||
模型/Embedding 设置和 MCP/工具展示三组检查,共确认并修复上述 5 个
|
||||
可复现问题。
|
||||
- 文档与网站校验未发现发布阻断问题。
|
||||
- 不为 Ask 使用独立 HOME/XDG,也不加 `--pure`,以免禁用正常插件或用户
|
||||
环境。
|
||||
- 不让普通状态或原生清单探针永久进入执行缓存,避免恢复“仅查看状态便
|
||||
留下多个空闲 OpenCode 服务”的已修复问题。
|
||||
- 未发现 Main 旧模型网关的生产、文档或导出调用者;Agent 侧真实网关及
|
||||
共享 wire contracts 保留。
|
||||
- 删除 `canonicalModelRequestHeaders()`;其唯一调用结果会立即进入已有
|
||||
`canonicalJson()`,后者已按稳定 code-unit 顺序递归排序对象键。直接传入
|
||||
Headers 对象转换结果可保持摘要不变并移除 locale 排序重复。
|
||||
- OpenCode/ACP 在单次活动 Prompt 内保留增量工具身份,以正确归并后续状态
|
||||
并在终态识别未完成/失败工具;这些 map 随 Prompt 结束释放。本轮只限制
|
||||
Renderer/Main 的持久展示副本,不为 Runtime 增加会中止功能的工具配额。
|
||||
- 未对没有具体失败路径的全局配额、持久恢复或额外安全层建议增加框架。
|
||||
|
||||
## 当前验证证据
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run lint`:通过。
|
||||
- `npm test`:323 个测试文件通过,8 个文件按环境跳过;3,471 项通过,
|
||||
55 项跳过。
|
||||
- 发布修复聚焦矩阵:13 个测试文件,427 项通过,6 项按环境跳过。
|
||||
- OpenCode/SelectedRuntime 聚焦矩阵:79 项通过。
|
||||
- UI 逻辑矩阵:7 个测试文件,319 项通过;其中相关修复矩阵 4 个文件
|
||||
224 项通过,OpenCode 单文件 62 项通过。
|
||||
- 最终审查修复矩阵:8 个测试文件、389 项通过;最后一次受影响子集
|
||||
4 个文件、286 项通过。受影响文件 ESLint 和 `git diff --check` 通过。
|
||||
- 真实 bundled OpenCode 回环 Provider 探针:2 次本机模型路由均通过,
|
||||
每次约 1.8 秒冷启动。
|
||||
- 2026-09-03 共享 Linux x64 Host 使用当前源码 profile 和已登记真实
|
||||
OpenCode 1.18.9 运行 `debug agent build --pure`:Ask 的最后一条
|
||||
`*/*` wildcard 为 `ask`,Execute 为 `allow`;Ask 保留正常 HOME/XDG,
|
||||
Execute 不注入配置覆盖,直接 Runtime 与 model bridge 规则一致。共
|
||||
0 次模型调用,唯一 `/tmp/goodbuddy-e2e-release-profile-*` 目录已清理,
|
||||
共享安装未修改。
|
||||
- 直连 Runtime 自定义 Header+Body 真实最小请求:1 次调用通过;DeepSeek
|
||||
Harness 自定义 Header 真实最小请求:1 次调用通过。两者均由本机 loopback
|
||||
先观察最终请求再转发,未启用工具或附件,也未输出凭据。
|
||||
- 使用当前源码构建的测试签名 Agent `0.11.17` Linux x64 bundle,在共享
|
||||
Host 的唯一 `/tmp/goodbuddy-e2e-release-*` HOME 中完成真实 attach、
|
||||
workspace validation 和 OpenCode 1.18.9 ACP Runtime 验证。两个并发 Ask
|
||||
分别通过原生读取工具返回各自证据文件,Execute 通过原生 Bash 创建并读回
|
||||
`execute-output.txt`;3 个 Runtime Prompt 共发生 6 次真实 Provider
|
||||
请求,每次均观察到连接级自定义 Header+Body,6 个请求 Body digest
|
||||
全部不同,未发生重放。
|
||||
- 同一 Host E2E 扫描隔离 Agent state 的 7 个文件、170,534 字节,未发现
|
||||
API Key、自定义 Header 值或自定义 Body 值。测试 Agent、Provider 代理
|
||||
均已精确停止;独立清理探针确认剩余测试目录和进程均为 0,共享安装未修改。
|
||||
该 bundle 只使用临时测试签名,不是 production 构建或发布产物。
|
||||
- Desktop `0.12.3`、Agent `0.11.17` 的版本一致性脚本通过,
|
||||
`git diff --check` 通过。
|
||||
- 用户已批准 `0.12.3` 精确中英文发布说明并选择不构建 LoongArch 实验预览;
|
||||
`npm run release:notes:verify` 通过。发布说明源码测试不再把某一历史候选
|
||||
恰好超过 500 字符的内容强加给每个后续版本,而是用独立 schema 样例持续
|
||||
验证长场景条目不受任意字符上限限制。
|
||||
- 当前没有可用 Linux arm64 真机,因此本轮未取得 arm64 实机切换耗时;
|
||||
该限制不冒充 x64 结果。修复已由不 spawn 的被动状态单测、Windows x64
|
||||
回环时序和共享 Linux x64 当前源码 Runtime 路径共同覆盖。
|
||||
|
||||
## 提交与发布边界
|
||||
|
||||
1. 提交前审查暂存内容,排除本机 `.diff*.txt`、临时 bundle/probe、凭据、
|
||||
日志和其他用户私有产物。
|
||||
2. 提交后报告精确 commit。只有取得用户后续明确授权,才可 fetch/push 两个
|
||||
`main`、等待 main CI 并创建 `agent-v0.11.17` 或 `v0.12.3` 标签。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.12.3",
|
||||
"releasedAt": "2026-09-04",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.12.3 新增按模型连接定制请求 Header 和 Body,并把内置浏览器扩展到 OpenCode 与 Continue;同时改善多会话并发、长任务、远程 Ask/Execute 和 OpenCode 切换体验。"
|
||||
],
|
||||
"features": [
|
||||
"**模型请求定制。** 每个 LLM 连接现在可以填写有界 JSON 请求头和顶层请求体,用于兼容自建网关、局域网服务和其他需要额外字段的 Provider。直连模型与 Continue 支持 Header 和 Body,本地 OpenCode 与 DeepSeek Harness 使用各自原生支持的 Header,受管 SSH OpenCode 支持两者;认证、协议和 Runtime 自有字段始终优先。",
|
||||
"**Runtime 共享内置浏览器。** 内置浏览器现在可以分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue,并在 Execute 模式使用。用户与 Agent 共享同一个按会话隔离的浏览器页面;关闭 Agent 浏览器能力后,用户仍可在工作栏中手动导航。"
|
||||
],
|
||||
"fixes": [
|
||||
"**多会话和长任务不再被人为中止。** 同一项目中的不同 OpenCode 会话现在可以并行运行,同一会话内仍保持顺序。OpenCode、Continue 和受管 SSH 任务不再因 GoodBuddy 固定的工具、活动、模型调用或累计输出数量而取消;本机展示达到容量时会给出提示,但原生任务继续运行。",
|
||||
"**OpenCode 状态检查和 Runtime 切换更快。** 被动状态检查现在只验证所选路径和模型凭据,不再启动并立即销毁一个 OpenCode Server,减少较慢设备和 Linux ARM Host 上的切换等待。设置中的显式连接测试、原生能力读取和首次实际请求仍执行完整启动与健康检查,失效的自定义路径也不会被其他候选静默替代。",
|
||||
"**模型重试和流式结果更可靠。** 直连模型会在尚未输出内容时,对明确发生在请求发送前的临时连接故障执行可取消重试;可能已经到达 Provider 的请求不会自动重放。Continue 事件流溢出后也不会再把后续增量与最终完整答案重复显示。",
|
||||
"**远程 Ask 与 Execute 启动边界更一致。** 两种模式现在都直接启动已签名 Runtime,不再要求 Host 安装 bubblewrap。Ask 同时使用 OpenCode Ask 权限和 Agent 原生读取审批保持只读;Execute 不注入该限制,继续使用所选 SSH 账号的完整权限。",
|
||||
"**设置保存和能力提示更准确。** 保存或测试期间设置表单会暂时锁定,避免旧响应覆盖新输入;切换 Embedding 连接也不再改写模型凭据、环境覆盖或并发保存的其他字段。无效 Header 会在保存时提示,浏览器卡片会明确标注仅 Execute 可供 Agent 使用。",
|
||||
"**工作栏与终端操作恢复一致。** 终端工具栏粘贴改走受信任的 Main/Preload 剪贴板路径,避免 Electron 拒绝浏览器剪贴板权限后无法粘贴。工作栏页签、关闭按钮和“+”入口恢复圆角样式,终端面板与工具栏继续保持直角。"
|
||||
],
|
||||
"notices": [
|
||||
"**远程项目仍为技术预览。** Host 需要单独发布的 Agent `0.11.17`;Desktop `0.12.3` 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 表示授权所选 SSH 账号可用的完整工具、进程、网络和写入权限。",
|
||||
"**请求定制不是密钥存储。** 自定义 Header 和 Body 会随连接普通设置保存,请勿在其中填写 API Key 或其他密钥。各 Runtime 只应用其原生支持的字段,界面会标明实际生效范围。",
|
||||
"**发布平台。** GoodBuddy 使用 Windows、macOS、Linux 的 `x64`/`arm64` 标准发布矩阵。本次不构建独立的 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.12.3 adds per-connection request headers and body fields and extends the built-in browser to OpenCode and Continue, together with smoother concurrent conversations, long tasks, Remote Ask/Execute, and OpenCode switching."
|
||||
],
|
||||
"features": [
|
||||
"**Custom model requests.** Each LLM connection can now supply bounded JSON headers and top-level body fields for self-hosted gateways, LAN services, and providers that require additional values. Direct models and Continue support both headers and body fields, local OpenCode and DeepSeek Harness use their native header support, and managed SSH OpenCode supports both; authentication, protocol, and Runtime-owned fields always take precedence.",
|
||||
"**Built-in browser shared across Runtimes.** The built-in browser can now be assigned to direct models, GoodBuddy-managed OpenCode, and Continue for use in Execute. The user and Agent share the same conversation-isolated page, while disabling Agent browser access still leaves manual workbar navigation available."
|
||||
],
|
||||
"fixes": [
|
||||
"**Concurrent conversations and long tasks keep running.** Separate OpenCode conversations in one project can now run in parallel while requests within one conversation stay ordered. OpenCode, Continue, and managed SSH tasks are no longer cancelled by GoodBuddy-defined tool, activity, model-call, or accumulated-output counts; reaching local display capacity shows a notice while native execution continues.",
|
||||
"**Faster OpenCode status checks and Runtime switching.** Passive status checks now validate only the selected path and model credential instead of starting and immediately destroying an OpenCode Server, reducing switching delays on slower systems and Linux ARM Hosts. Explicit connection tests, native capability discovery, and first real use still perform full startup and health checks, and an invalid custom path is never silently replaced by another candidate.",
|
||||
"**Safer model retries and streaming results.** Direct models perform cancellable retries before any visible output only for temporary connection failures proven to occur before request dispatch; requests that may have reached the provider are never replayed automatically. Continue also avoids showing later stream deltas again with a complete fallback response after an event-stream overflow.",
|
||||
"**Consistent Remote Ask and Execute startup boundaries.** Both modes now launch the signed Runtime directly without requiring bubblewrap on the Host. Ask stays read-only through both OpenCode Ask permissions and Agent native-read approval, while Execute applies no such override and retains the selected SSH account's full permissions.",
|
||||
"**Reliable settings saves and capability guidance.** Settings are temporarily locked while saving or testing so an older response cannot overwrite newer input, and changing the Embedding connection no longer rewrites model credentials, environment overrides, or unrelated concurrent saves. Invalid headers are rejected when saving, and the browser card now clearly identifies Agent access as Execute-only.",
|
||||
"**Restored workbar and terminal behavior.** Terminal toolbar paste now uses the trusted Main/Preload clipboard path instead of a browser clipboard call that Electron could reject. Workbar tabs, close buttons, and the “+” entry use rounded styling again, while the terminal panel and toolbar remain square."
|
||||
],
|
||||
"notices": [
|
||||
"**Remote Projects remain a technical preview.** Hosts require the separately published Agent `0.11.17`; Desktop `0.12.3` does not embed the Agent or Remote Runtime. Ask remains read-only, while Execute authorizes the full tools, processes, network access, and writable paths available to the selected SSH account.",
|
||||
"**Request customization is not credential storage.** Custom headers and body fields are saved as ordinary connection settings, so do not place API keys or other secrets in them. Each Runtime applies only the fields it supports natively, and the interface identifies the effective scope.",
|
||||
"**Release platforms.** GoodBuddy uses the standard Windows, macOS, and Linux `x64`/`arm64` release matrix. This release does not include a separate experimental LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.12.2",
|
||||
"releasedAt": "2026-09-03",
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '../shared/model-request-policy'
|
||||
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
||||
import {
|
||||
canonicalModelRequestHeaders,
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders
|
||||
} from '../shared/model-request-customization'
|
||||
@@ -573,7 +572,7 @@ function prepareProviderRequest(
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
headers.set('anthropic-version', '2023-06-01')
|
||||
}
|
||||
const canonicalHeaders = canonicalModelRequestHeaders(headers)
|
||||
const canonicalHeaders = Object.fromEntries(headers.entries())
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
if (profile.authentication === 'api-key') {
|
||||
headers.set('x-api-key', profile.apiKey!)
|
||||
|
||||
@@ -58,6 +58,8 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode'
|
||||
],
|
||||
@@ -69,6 +71,7 @@ describe('Agent CLI fixed command contract', () => {
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
supportsImageInput: false,
|
||||
workMode: 'ask',
|
||||
opencodeEntrypoint: '/runtime/bin/opencode'
|
||||
})
|
||||
await expect(
|
||||
@@ -83,6 +86,8 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode',
|
||||
'--api-key',
|
||||
|
||||
@@ -185,6 +185,7 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
requireOptions(options, [
|
||||
@@ -192,6 +193,7 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
const protocol = options.protocol
|
||||
@@ -206,6 +208,10 @@ async function runModelBridgeHelper(
|
||||
if (imageInput !== 'true' && imageInput !== 'false') {
|
||||
throw new Error('Invalid model bridge image-input option')
|
||||
}
|
||||
const workMode = options['work-mode']
|
||||
if (workMode !== 'ask' && workMode !== 'execute') {
|
||||
throw new Error('Invalid model bridge work-mode option')
|
||||
}
|
||||
return await (
|
||||
dependencies.runModelBridgeHelper ??
|
||||
runOpenCodeModelBridgeHelper
|
||||
@@ -214,6 +220,7 @@ async function runModelBridgeHelper(
|
||||
protocol: protocol as ModelBridgeProtocol,
|
||||
model: options.model!,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
workMode,
|
||||
opencodeEntrypoint: options['opencode-entrypoint']!
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,10 +44,14 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
||||
|
||||
export type OpenCodeModelBridgeProviderConfig = {
|
||||
model: string
|
||||
permission?: 'ask'
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
build?: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
provider: Record<
|
||||
string,
|
||||
@@ -389,6 +393,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
protocol: ModelBridgeProtocol
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
opencodeEntrypoint: string
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
spawn?: ModelBridgeHelperSpawn
|
||||
@@ -409,7 +414,8 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
loopbackOrigin: origin,
|
||||
supportsImageInput: options.supportsImageInput
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
workMode: options.workMode
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
@@ -487,6 +493,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
loopbackOrigin: string
|
||||
name?: string
|
||||
supportsImageInput?: boolean
|
||||
workMode?: 'ask' | 'execute'
|
||||
}): OpenCodeModelBridgeProviderConfig {
|
||||
const model = boundedMetadataText(input.model, 'Model name')
|
||||
const name = boundedMetadataText(
|
||||
@@ -500,13 +507,25 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
const baseURL = `${origin}/v1`
|
||||
return {
|
||||
model: openCodeModelBridgeModelId(input.protocol, model),
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
: {}),
|
||||
// GoodBuddy owns conversation titles. A first-prompt title request can
|
||||
// race the real ACP prompt, abandon its Provider response, and correctly
|
||||
// poison the no-replay ledger before the user request can run.
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
},
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
build: {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
provider: {
|
||||
[providerId]: {
|
||||
|
||||
@@ -465,14 +465,19 @@ describe('model bridge loopback helper', () => {
|
||||
name: 'Private model',
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
supportsImageInput: true
|
||||
supportsImageInput: true,
|
||||
workMode: 'ask'
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
model: `${providerId}/private-model`,
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
},
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
@@ -500,6 +505,19 @@ describe('model bridge loopback helper', () => {
|
||||
expect(JSON.stringify(config)).not.toContain('provider.example')
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves Execute provider permissions unchanged', () => {
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
expect(config).not.toHaveProperty('permission')
|
||||
expect(config.agent).not.toHaveProperty('build')
|
||||
})
|
||||
})
|
||||
|
||||
describe('model bridge Unix broker', () => {
|
||||
|
||||
@@ -42,6 +42,16 @@ describe('OpenCode direct launch profile', () => {
|
||||
process.env.HOME ?? fixture.workspaceDirectory,
|
||||
PATH: process.env.PATH || '/usr/bin:/bin'
|
||||
})
|
||||
expect(
|
||||
JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
|
||||
).toMatchObject({
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('runs Execute directly with the SSH account environment', () => {
|
||||
@@ -199,6 +209,8 @@ describe('OpenCode direct launch profile', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
])
|
||||
|
||||
@@ -19,6 +19,15 @@ import {
|
||||
assertAbsoluteManagedPath
|
||||
} from './managed-paths'
|
||||
|
||||
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type OpenCodeLaunchProfile = {
|
||||
executable: string
|
||||
processExecutable: string
|
||||
@@ -143,16 +152,25 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
modelBridge.policy.model,
|
||||
'--supports-image-input',
|
||||
modelBridge.policy.supportsImageInput ? 'true' : 'false',
|
||||
'--work-mode',
|
||||
input.workMode,
|
||||
'--opencode-entrypoint',
|
||||
executablePath
|
||||
]
|
||||
}
|
||||
|
||||
const environment =
|
||||
input.workMode === 'ask'
|
||||
? {
|
||||
...executeEnvironment(process.env, workspaceDirectory),
|
||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG
|
||||
}
|
||||
: executeEnvironment(process.env, workspaceDirectory)
|
||||
return {
|
||||
...runtimeCommand,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
cwd: workspaceDirectory,
|
||||
env: executeEnvironment(process.env, workspaceDirectory),
|
||||
env: environment,
|
||||
workMode: input.workMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,10 @@ class MemoryAcpJournal implements RuntimeAcpJournal {
|
||||
mainAckSequence = '0'
|
||||
failAppend = false
|
||||
capacityBlocked = false
|
||||
appendAttempts = 0
|
||||
|
||||
appendAcpFrame(input: (typeof this.frames)[number]): { created: boolean } {
|
||||
this.appendAttempts += 1
|
||||
if (this.capacityBlocked) {
|
||||
throw new EventJournalCapacityError(
|
||||
'journal capacity',
|
||||
@@ -1251,6 +1253,34 @@ describe('RuntimeAcpBackend', () => {
|
||||
expect(fixture.outputFrames).toEqual(['later'])
|
||||
})
|
||||
|
||||
it('backs off while durable journal capacity remains unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
await invoke(
|
||||
fixture,
|
||||
'runtime/preparePrompt',
|
||||
fixture.preparation()
|
||||
)
|
||||
fixture.journal.capacityBlocked = true
|
||||
const output = fixture.process.emit('stdout', 'later')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(775)
|
||||
expect(fixture.journal.appendAttempts).toBeLessThanOrEqual(6)
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
|
||||
fixture.journal.capacityBlocked = false
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await output
|
||||
|
||||
expect(fixture.outputFrames).toEqual(['later'])
|
||||
await fixture.backend.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops with output quota when the detached durable journal is exhausted', async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
|
||||
@@ -83,7 +83,8 @@ const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
|
||||
|
||||
export type RuntimeAcpBackendErrorCode =
|
||||
| 'capacity'
|
||||
@@ -1976,6 +1977,7 @@ export class RuntimeAcpBackend {
|
||||
try {
|
||||
await this.#enqueueOutput(binding, payload, async () => {
|
||||
const sequence = binding.nextOutputSequence.toString()
|
||||
let retryDelay = ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS
|
||||
while (true) {
|
||||
try {
|
||||
this.#options.journal.appendAcpFrame({
|
||||
@@ -1998,7 +2000,11 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
this.#assertBeforeDeadline(binding)
|
||||
await delay(ACP_JOURNAL_RETRY_MILLISECONDS)
|
||||
await delay(retryDelay)
|
||||
retryDelay = Math.min(
|
||||
ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS,
|
||||
retryDelay * 2
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.nextOutputSequence += 1n
|
||||
|
||||
@@ -2279,7 +2279,19 @@ describe('AcpRemoteRuntime', () => {
|
||||
done: false
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(server.setInboundPaused).toHaveBeenCalledWith(true)
|
||||
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
|
||||
const events = []
|
||||
const firstUpdate = await stream.next()
|
||||
expect(firstUpdate).toMatchObject({
|
||||
value: { type: 'text' },
|
||||
done: false
|
||||
})
|
||||
if (firstUpdate.done) {
|
||||
throw new Error('Expected the first queued Runtime update')
|
||||
}
|
||||
events.push(firstUpdate.value)
|
||||
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
|
||||
while (true) {
|
||||
const next = await stream.next()
|
||||
if (next.done) {
|
||||
|
||||
@@ -332,9 +332,9 @@ function usageEvent(
|
||||
* Generic ACP runtime over an authenticated remote virtual channel.
|
||||
*
|
||||
* The class deliberately does not claim confinement from ACP itself. Ask
|
||||
* permits only one-shot native reads, while the injected channel's read-only
|
||||
* sandbox remains the filesystem boundary. Execute follows the user's full
|
||||
* authorization for the selected SSH account.
|
||||
* permits only one-shot native reads in addition to the OpenCode Ask
|
||||
* permission profile. Execute follows the user's full authorization for the
|
||||
* selected SSH account.
|
||||
*/
|
||||
export class AcpRemoteRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = false
|
||||
@@ -1833,15 +1833,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return
|
||||
}
|
||||
const bytes = utf8JsonBytes(notification.update)
|
||||
const maximumItems = Math.max(
|
||||
1,
|
||||
this.options.maxPendingUpdates ?? DEFAULT_MAX_PENDING_UPDATES
|
||||
)
|
||||
const maximumBytes = Math.max(
|
||||
1,
|
||||
this.options.maxPendingUpdateBytes ??
|
||||
DEFAULT_MAX_PENDING_UPDATE_BYTES
|
||||
)
|
||||
let consume: (() => void) | undefined
|
||||
const consumed = new Promise<void>((resolve) => {
|
||||
consume = resolve
|
||||
@@ -1856,10 +1847,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt.wake = undefined
|
||||
if (
|
||||
!prompt.inboundPaused &&
|
||||
(
|
||||
prompt.updates.length >= maximumItems ||
|
||||
prompt.pendingUpdateBytes >= maximumBytes
|
||||
)
|
||||
this.pendingUpdateLimitReached(prompt)
|
||||
) {
|
||||
prompt.inboundPaused = true
|
||||
await this.awaitOperation(
|
||||
@@ -1881,7 +1869,11 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt.pendingUpdateBytes - queued.bytes
|
||||
)
|
||||
queued.consumed()
|
||||
if (prompt.inboundPaused && !prompt.interruption) {
|
||||
if (
|
||||
prompt.inboundPaused &&
|
||||
!prompt.interruption &&
|
||||
!this.pendingUpdateLimitReached(prompt)
|
||||
) {
|
||||
prompt.inboundPaused = false
|
||||
void this.awaitOperation(
|
||||
prompt.context,
|
||||
@@ -1892,6 +1884,22 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return queued.update
|
||||
}
|
||||
|
||||
private pendingUpdateLimitReached(prompt: ActivePrompt): boolean {
|
||||
const maximumItems = Math.max(
|
||||
1,
|
||||
this.options.maxPendingUpdates ?? DEFAULT_MAX_PENDING_UPDATES
|
||||
)
|
||||
const maximumBytes = Math.max(
|
||||
1,
|
||||
this.options.maxPendingUpdateBytes ??
|
||||
DEFAULT_MAX_PENDING_UPDATE_BYTES
|
||||
)
|
||||
return (
|
||||
prompt.updates.length >= maximumItems ||
|
||||
prompt.pendingUpdateBytes >= maximumBytes
|
||||
)
|
||||
}
|
||||
|
||||
private discardUpdates(prompt: ActivePrompt): void {
|
||||
for (const queued of prompt.updates.splice(0)) {
|
||||
queued.consumed()
|
||||
|
||||
@@ -1346,6 +1346,88 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('stops forwarding later deltas after the patched stream overflows', async () => {
|
||||
const distribution = await createDistribution()
|
||||
let stateRequests = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL | Request) => {
|
||||
if (String(input).endsWith('/state')) {
|
||||
stateRequests += 1
|
||||
return Response.json({
|
||||
session: {
|
||||
history:
|
||||
stateRequests < 4
|
||||
? []
|
||||
: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'prefix missing suffix'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
isProcessing: stateRequests < 4,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null,
|
||||
goodbuddyEvents:
|
||||
stateRequests === 2
|
||||
? [{ type: 'text', delta: 'prefix ' }]
|
||||
: stateRequests === 3
|
||||
? [{ type: 'text', delta: 'suffix' }]
|
||||
: [],
|
||||
goodbuddyEventsOverflow: stateRequests === 3
|
||||
})
|
||||
}
|
||||
return Response.json({})
|
||||
})
|
||||
)
|
||||
const forwarded: unknown[] = []
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: distribution.entryPath,
|
||||
configPath: '',
|
||||
workspace: process.cwd(),
|
||||
cacheRoot: distribution.cacheRoot,
|
||||
trustedBundleHashes: [distribution.sourceHash],
|
||||
launchHost: () => ({
|
||||
exitCode: null,
|
||||
killed: false,
|
||||
stderr: null,
|
||||
once: () => undefined,
|
||||
kill: () => true
|
||||
}),
|
||||
modelProfile: {
|
||||
id: randomUUID(),
|
||||
name: 'Local model',
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
modelName: 'qwen3',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.run(
|
||||
'hello',
|
||||
new AbortController().signal,
|
||||
async () => 'deny',
|
||||
{
|
||||
onEvent: (event) => {
|
||||
forwarded.push(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
text: 'prefix missing suffix',
|
||||
streamedText: true,
|
||||
streamTruncated: true
|
||||
})
|
||||
expect(forwarded).toEqual([
|
||||
{ type: 'text', delta: 'prefix ' }
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards more than 100 unique tool calls', async () => {
|
||||
const distribution = await createDistribution()
|
||||
let stateRequests = 0
|
||||
|
||||
@@ -1647,8 +1647,9 @@ export class ContinueHostAdapter {
|
||||
const nextStreamEventBytes =
|
||||
streamEventBytes + batchStreamEventBytes
|
||||
if (
|
||||
streamTruncated ||
|
||||
nextStreamEventBytes >
|
||||
this.dependencies.maximumStreamEventBytes
|
||||
this.dependencies.maximumStreamEventBytes
|
||||
) {
|
||||
streamTruncated = true
|
||||
streamEvents = []
|
||||
|
||||
@@ -750,8 +750,10 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
if (!result.streamedText || result.streamTruncated) {
|
||||
const remainingText =
|
||||
result.streamTruncated && result.text.startsWith(emittedText)
|
||||
? result.text.slice(emittedText.length)
|
||||
result.streamTruncated
|
||||
? result.text.startsWith(emittedText)
|
||||
? result.text.slice(emittedText.length)
|
||||
: ''
|
||||
: result.text
|
||||
if (remainingText) {
|
||||
yield {
|
||||
|
||||
@@ -160,6 +160,81 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('forwards the selected profile request customization to direct chats', async () => {
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000041',
|
||||
name: 'Tenant model',
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
modelName: 'tenant-model',
|
||||
protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'none' as const,
|
||||
supportsImageInput: false,
|
||||
maximumOutputTokens: 48_000,
|
||||
imageGenerationQuality: 'auto' as const,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'tenant-a'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2
|
||||
}
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
new Response(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
delta: { content: 'OK' },
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
]
|
||||
})}`,
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
''
|
||||
].join('\n'),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
}
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const runtime = createAgentRuntime(
|
||||
process.cwd(),
|
||||
settings({
|
||||
modelProfiles: [profile],
|
||||
defaultModelProfileId: profile.id
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
for await (const _event of runtime.run(
|
||||
{
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ee',
|
||||
conversationId: 'customized-direct-chat',
|
||||
prompt: 'hello'
|
||||
},
|
||||
new AbortController().signal
|
||||
)) {
|
||||
void _event
|
||||
}
|
||||
|
||||
const init = fetcher.mock.calls[0]?.[1]
|
||||
expect(new Headers(init?.headers).get('x-tenant-id')).toBe(
|
||||
'tenant-a'
|
||||
)
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
model: 'tenant-model',
|
||||
temperature: 0.2
|
||||
})
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards the selected profile image capability to direct runtimes', async () => {
|
||||
const visionSettings = settings({
|
||||
supportsImageInput: true
|
||||
|
||||
@@ -353,10 +353,13 @@ export function createAgentRuntime(
|
||||
settings?.modelProtocol ??
|
||||
defaultRuntimeSettings.modelProtocol,
|
||||
authentication: modelAuthentication,
|
||||
requestHeaders: defaultModelProfile?.requestHeaders,
|
||||
requestBody: defaultModelProfile?.requestBody,
|
||||
supportsImageInput:
|
||||
defaultModelProfile?.supportsImageInput ??
|
||||
settings?.supportsImageInput ??
|
||||
defaultRuntimeSettings.supportsImageInput,
|
||||
maximumOutputTokens: defaultModelProfile?.maximumOutputTokens,
|
||||
imageGenerationQuality:
|
||||
defaultModelProfile?.imageGenerationQuality ??
|
||||
settings?.imageGenerationQuality ??
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ModelCallIdentity,
|
||||
PrepareModelCall
|
||||
} from '../../shared/model-call-operation-contracts'
|
||||
import {
|
||||
ModelCallCapacityError,
|
||||
ModelCallConflictError,
|
||||
ModelCallOperationStore,
|
||||
ModelCallStateError
|
||||
} from './model-call-operation-store'
|
||||
|
||||
const directories: string[] = []
|
||||
const digestA = `sha256:${'a'.repeat(64)}`
|
||||
const digestB = `sha256:${'b'.repeat(64)}`
|
||||
const profileDigest = `sha256:${'c'.repeat(64)}`
|
||||
const policy = {
|
||||
modelProfileDigest: profileDigest
|
||||
}
|
||||
const identity: ModelCallIdentity = {
|
||||
callOperationId: 'call-1',
|
||||
requestId: 'request-1',
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0,
|
||||
roundIndex: 0,
|
||||
provider: 'openai',
|
||||
profile: 'work',
|
||||
model: 'gpt-model',
|
||||
protocol: 'responses-v1'
|
||||
}
|
||||
const prepared: PrepareModelCall = {
|
||||
identity,
|
||||
requestDigest: digestA,
|
||||
...policy
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function createPath(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-model-calls-'))
|
||||
directories.push(directory)
|
||||
return join(directory, 'model-calls.sqlite')
|
||||
}
|
||||
|
||||
function operation(callOperationId: string, roundIndex: number): PrepareModelCall {
|
||||
return {
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId,
|
||||
promptOperationId: `prompt-${callOperationId}`,
|
||||
roundIndex: 0
|
||||
},
|
||||
requestDigest: roundIndex % 2 === 0 ? digestA : digestB,
|
||||
...policy
|
||||
}
|
||||
}
|
||||
|
||||
function completeCall(
|
||||
store: ModelCallOperationStore,
|
||||
callOperationId: string
|
||||
): void {
|
||||
store.beginDispatch(callOperationId)
|
||||
store.complete(callOperationId, { status: 'completed' })
|
||||
store.markResponseDelivered(callOperationId)
|
||||
}
|
||||
|
||||
describe('ModelCallOperationStore', () => {
|
||||
it('durably preserves prepared and dispatched crash states in WAL mode', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.prepare(prepared)
|
||||
store.prepare({
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-2',
|
||||
promptOperationId: 'prompt-2',
|
||||
roundIndex: 0
|
||||
},
|
||||
requestDigest: digestB,
|
||||
...policy
|
||||
})
|
||||
expect(store.beginDispatch('call-2').permitted).toBe(true)
|
||||
store.close()
|
||||
|
||||
const check = new DatabaseSync(path)
|
||||
expect(
|
||||
(check.prepare('PRAGMA journal_mode').get() as { journal_mode: string })
|
||||
.journal_mode
|
||||
).toBe('wal')
|
||||
check.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(reopened.get('call-1')?.status).toBe('prepared')
|
||||
expect(reopened.get('call-1')?.dispatchMetadata).toBeUndefined()
|
||||
expect(reopened.get('call-2')?.status).toBe('dispatched')
|
||||
expect(reopened.beginDispatch('call-2').permitted).toBe(false)
|
||||
expect(reopened.listUnresolved().map((record) => record.status)).toEqual([
|
||||
'prepared',
|
||||
'dispatched'
|
||||
])
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('idempotently prepares the same digest and conflicts on changed identity or digest', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
expect(store.prepare(prepared).created).toBe(true)
|
||||
expect(store.prepare(prepared).created).toBe(false)
|
||||
expect(() =>
|
||||
store.prepare({ ...prepared, requestDigest: digestB })
|
||||
).toThrow(ModelCallConflictError)
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: { ...identity, model: 'different-model' }
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('grants only one dispatch permit across concurrent store instances', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path)
|
||||
const second = new ModelCallOperationStore(path)
|
||||
first.prepare(prepared)
|
||||
|
||||
const claims = await Promise.all([
|
||||
Promise.resolve().then(() => first.beginDispatch('call-1')),
|
||||
Promise.resolve().then(() => second.beginDispatch('call-1'))
|
||||
])
|
||||
expect(claims.filter((claim) => claim.permitted)).toHaveLength(1)
|
||||
expect(claims.every((claim) => claim.record.status === 'dispatched')).toBe(
|
||||
true
|
||||
)
|
||||
first.close()
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('blocks replay after an outcome becomes unknown, including after reopen', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.prepare(prepared)
|
||||
store.beginDispatch('call-1', {
|
||||
providerIdempotencyKey: 'provider-key-1'
|
||||
})
|
||||
store.markOutcomeUnknown('call-1', {
|
||||
status: 'outcome-unknown',
|
||||
providerRequestId: 'provider-request-1',
|
||||
reason: {
|
||||
code: 'connection-lost',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
expect(store.beginDispatch('call-1').permitted).toBe(false)
|
||||
store.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(reopened.beginDispatch('call-1').permitted).toBe(false)
|
||||
expect(reopened.listUnresolved()).toHaveLength(1)
|
||||
expect(reopened.listUnresolved()[0]?.status).toBe('outcome-unknown')
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('makes identical completion idempotent and rejects changed evidence', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => 42
|
||||
})
|
||||
store.prepare(prepared)
|
||||
store.beginDispatch('call-1')
|
||||
const evidence = {
|
||||
status: 'completed' as const,
|
||||
providerRequestId: 'request-provider-1',
|
||||
providerResponseId: 'response-provider-1',
|
||||
result: { finishReason: 'stop', outputDigest: digestB },
|
||||
usage: { inputTokens: 10, outputTokens: 5 }
|
||||
}
|
||||
const completed = store.complete('call-1', evidence)
|
||||
expect(store.complete('call-1', evidence)).toEqual(completed)
|
||||
expect(() =>
|
||||
store.complete('call-1', {
|
||||
...evidence,
|
||||
providerResponseId: 'different-response'
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
expect(store.listUnresolved()).toEqual([])
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('strictly rejects invalid transitions and different terminal outcomes', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(prepared)
|
||||
expect(() =>
|
||||
store.complete('call-1', { status: 'completed' })
|
||||
).toThrow(ModelCallStateError)
|
||||
store.beginDispatch('call-1')
|
||||
store.failDefinitive('call-1', {
|
||||
status: 'failed-definitive',
|
||||
error: {
|
||||
code: 'authentication',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
expect(() =>
|
||||
store.complete('call-1', { status: 'completed' })
|
||||
).toThrow(ModelCallStateError)
|
||||
expect(() =>
|
||||
store.markOutcomeUnknown('call-1', {
|
||||
status: 'outcome-unknown',
|
||||
reason: {
|
||||
code: 'uncertain',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallStateError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('guards closed stores and makes close and dispose idempotent', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(prepared)
|
||||
store.close()
|
||||
expect(() => store.get('call-1')).toThrow(ModelCallStateError)
|
||||
expect(() => store.prepare(prepared)).toThrow(ModelCallStateError)
|
||||
expect(() => store.beginDispatch('call-1')).toThrow(ModelCallStateError)
|
||||
expect(() => store.listUnresolved()).toThrow(ModelCallStateError)
|
||||
expect(() => store.close()).not.toThrow()
|
||||
expect(() => store.dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails safely when persisted JSON is corrupt', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.prepare(prepared)
|
||||
store.close()
|
||||
|
||||
const corrupt = new DatabaseSync(path)
|
||||
corrupt.exec(`
|
||||
PRAGMA ignore_check_constraints = ON;
|
||||
UPDATE model_call_operations SET identity_json = '{broken';
|
||||
`)
|
||||
corrupt.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(() => reopened.get('call-1')).toThrow(ModelCallStateError)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('enforces the hard record cap without dispatching prepared calls', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumRecords: 2
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
store.prepare(operation('call-2', 1))
|
||||
|
||||
expect(() => store.prepare(operation('call-3', 2))).toThrow(
|
||||
ModelCallCapacityError
|
||||
)
|
||||
expect(store.get('call-1')?.status).toBe('prepared')
|
||||
expect(store.get('call-2')?.status).toBe('prepared')
|
||||
expect(store.get('call-3')).toBeUndefined()
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('automatically prunes only definitive terminal rows for capacity', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumRecords: 3
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
completeCall(store, 'call-1')
|
||||
store.prepare(operation('call-2', 1))
|
||||
store.beginDispatch('call-2')
|
||||
store.failDefinitive('call-2', {
|
||||
status: 'failed-definitive',
|
||||
error: { code: 'provider-rejected', retryable: false }
|
||||
})
|
||||
store.prepare(operation('call-3', 2))
|
||||
store.beginDispatch('call-3')
|
||||
|
||||
store.prepare(operation('call-4', 3))
|
||||
expect(store.get('call-1')).toBeUndefined()
|
||||
expect(store.get('call-2')?.status).toBe('failed-definitive')
|
||||
expect(store.get('call-3')?.status).toBe('dispatched')
|
||||
expect(store.get('call-4')?.status).toBe('prepared')
|
||||
|
||||
store.prepare(operation('call-5', 4))
|
||||
expect(store.get('call-2')).toBeUndefined()
|
||||
expect(store.get('call-3')?.status).toBe('dispatched')
|
||||
expect(store.get('call-4')?.status).toBe('prepared')
|
||||
expect(store.get('call-5')?.status).toBe('prepared')
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('never prunes dispatched or outcome-unknown rows for capacity', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumRecords: 2
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
store.beginDispatch('call-1')
|
||||
store.prepare(operation('call-2', 1))
|
||||
store.beginDispatch('call-2')
|
||||
store.markOutcomeUnknown('call-2', {
|
||||
status: 'outcome-unknown',
|
||||
reason: { code: 'transport-uncertain', retryable: false }
|
||||
})
|
||||
|
||||
expect(() => store.prepare(operation('call-3', 2))).toThrow(
|
||||
ModelCallCapacityError
|
||||
)
|
||||
expect(store.get('call-1')?.status).toBe('dispatched')
|
||||
expect(store.get('call-2')?.status).toBe('outcome-unknown')
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('reapplies capacity and terminal pruning after reopen', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path, { maximumRecords: 3 })
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
first.prepare(operation(`call-${index}`, index))
|
||||
}
|
||||
completeCall(first, 'call-1')
|
||||
completeCall(first, 'call-2')
|
||||
first.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path, { maximumRecords: 2 })
|
||||
reopened.prepare(operation('call-4', 4))
|
||||
expect(reopened.get('call-1')).toBeUndefined()
|
||||
expect(reopened.get('call-2')).toBeUndefined()
|
||||
expect(reopened.get('call-3')?.status).toBe('prepared')
|
||||
expect(reopened.get('call-4')?.status).toBe('prepared')
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('prunes terminal records deterministically with bounded options', async () => {
|
||||
let now = 0
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => ++now
|
||||
})
|
||||
store.prepare(operation('call-1', 0))
|
||||
completeCall(store, 'call-1')
|
||||
store.prepare(operation('call-2', 1))
|
||||
completeCall(store, 'call-2')
|
||||
store.prepare(operation('call-3', 2))
|
||||
store.beginDispatch('call-3')
|
||||
store.markOutcomeUnknown('call-3', {
|
||||
status: 'outcome-unknown',
|
||||
reason: { code: 'transport-uncertain', retryable: false }
|
||||
})
|
||||
|
||||
expect(store.pruneTerminal(1, 6)).toBe(1)
|
||||
expect(store.get('call-1')).toBeUndefined()
|
||||
expect(store.get('call-2')?.status).toBe('completed')
|
||||
expect(store.get('call-3')?.status).toBe('outcome-unknown')
|
||||
expect(() => store.pruneTerminal(1_001)).toThrow()
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('atomically grants one strictly monotonic next round across stores', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path)
|
||||
const second = new ModelCallOperationStore(path)
|
||||
first.prepare(prepared)
|
||||
completeCall(first, 'call-1')
|
||||
const next = {
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-round-1-a',
|
||||
roundIndex: 1
|
||||
},
|
||||
requestDigest: digestB,
|
||||
...policy
|
||||
}
|
||||
const competing = {
|
||||
...next,
|
||||
identity: {
|
||||
...next.identity,
|
||||
callOperationId: 'call-round-1-b'
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => first.prepare(next)),
|
||||
Promise.resolve().then(() => second.prepare(competing))
|
||||
])
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
|
||||
first.close()
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('retains replay authority after delivered records are pruned and reopened', async () => {
|
||||
const path = await createPath()
|
||||
const first = new ModelCallOperationStore(path)
|
||||
first.prepare(prepared)
|
||||
completeCall(first, 'call-1')
|
||||
first.finalizePrompt({
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0
|
||||
})
|
||||
expect(first.pruneTerminal(1)).toBe(1)
|
||||
expect(first.get('call-1')).toBeUndefined()
|
||||
first.close()
|
||||
|
||||
const reopened = new ModelCallOperationStore(path)
|
||||
expect(() => reopened.prepare(prepared)).toThrow(ModelCallConflictError)
|
||||
expect(
|
||||
reopened.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...prepared.identity,
|
||||
callOperationId: 'call-next-prompt',
|
||||
requestId: 'request-2',
|
||||
promptOperationId: 'prompt-2',
|
||||
promptSequence: 1,
|
||||
roundIndex: 0
|
||||
},
|
||||
requestDigest: digestB
|
||||
}).created
|
||||
).toBe(true)
|
||||
expect(() =>
|
||||
reopened.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...prepared.identity,
|
||||
callOperationId: 'call-skipped-prompt',
|
||||
requestId: 'request-3',
|
||||
promptOperationId: 'prompt-3',
|
||||
promptSequence: 3,
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('compacts finalized prompt authorities below the hard authority cap', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
maximumAuthorities: 1
|
||||
})
|
||||
for (let promptSequence = 0; promptSequence < 3; promptSequence += 1) {
|
||||
const callOperationId = `call-prompt-${promptSequence}`
|
||||
const promptOperationId = `prompt-${promptSequence}`
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId,
|
||||
requestId: `request-${promptSequence}`,
|
||||
promptOperationId,
|
||||
promptSequence,
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
completeCall(store, callOperationId)
|
||||
store.finalizePrompt({
|
||||
bindingId: identity.bindingId,
|
||||
promptOperationId,
|
||||
promptSequence
|
||||
})
|
||||
}
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'replayed-finalized-prompt',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 1,
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('rejects skipped, lower, and profile-swapped rounds', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(prepared)
|
||||
completeCall(store, 'call-1')
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-skipped',
|
||||
roundIndex: 2
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallStateError)
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-profile-swap',
|
||||
roundIndex: 1,
|
||||
profile: 'other'
|
||||
},
|
||||
modelProfileDigest: digestB
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
expect(() =>
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-lower',
|
||||
roundIndex: 0
|
||||
}
|
||||
})
|
||||
).toThrow(ModelCallConflictError)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('permits sequential rounds without a call-count or cumulative-token quota', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
|
||||
const callOperationId = `call-unbounded-${roundIndex}`
|
||||
store.prepare({
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId,
|
||||
requestId: `request-${roundIndex}`,
|
||||
roundIndex
|
||||
}
|
||||
})
|
||||
completeCall(store, callOperationId)
|
||||
}
|
||||
expect(store.get('call-unbounded-11')).toMatchObject({
|
||||
status: 'completed',
|
||||
responseDeliveredAt: expect.any(Number)
|
||||
})
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('requires explicit response delivery before the next round', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => 42
|
||||
})
|
||||
store.prepare(prepared)
|
||||
store.beginDispatch('call-1')
|
||||
store.complete('call-1', { status: 'completed' })
|
||||
const next = {
|
||||
...prepared,
|
||||
identity: {
|
||||
...identity,
|
||||
callOperationId: 'call-2',
|
||||
roundIndex: 1
|
||||
},
|
||||
requestDigest: digestB
|
||||
}
|
||||
expect(() => store.prepare(next)).toThrow(ModelCallStateError)
|
||||
expect(store.pruneTerminal(1)).toBe(0)
|
||||
const pending = store.listStartupRecords().records
|
||||
expect(pending).toMatchObject([{ status: 'completed' }])
|
||||
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
|
||||
const delivered = store.markResponseDelivered('call-1')
|
||||
expect(delivered.responseDeliveredAt).toBe(42)
|
||||
expect(store.markResponseDelivered('call-1')).toEqual(delivered)
|
||||
expect(store.prepare(next).created).toBe(true)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('paginates startup and status-filtered records without starvation', async () => {
|
||||
let now = 0
|
||||
const store = new ModelCallOperationStore(await createPath(), {
|
||||
now: () => ++now
|
||||
})
|
||||
for (const id of ['call-a', 'call-b', 'call-c']) {
|
||||
store.prepare(operation(id, 0))
|
||||
store.beginDispatch(id)
|
||||
}
|
||||
store.complete('call-b', { status: 'completed' })
|
||||
completeCall(store, 'call-c')
|
||||
|
||||
const first = store.listStartupRecords({ limit: 1 })
|
||||
expect(first.records).toHaveLength(1)
|
||||
expect(first.nextCursor).toBeDefined()
|
||||
const second = store.listStartupRecords({
|
||||
limit: 1,
|
||||
cursor: first.nextCursor
|
||||
})
|
||||
expect([
|
||||
...first.records,
|
||||
...second.records
|
||||
].map((record) => record.identity.callOperationId)).toEqual([
|
||||
'call-a',
|
||||
'call-b'
|
||||
])
|
||||
expect(
|
||||
store.list({
|
||||
statuses: ['completed'],
|
||||
delivery: 'pending-response',
|
||||
limit: 10
|
||||
}).records.map((record) => record.identity.callOperationId)
|
||||
).toEqual(['call-b'])
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('includes prepared records in startup reconciliation', async () => {
|
||||
const store = new ModelCallOperationStore(await createPath())
|
||||
store.prepare(operation('call-prepared', 0))
|
||||
|
||||
expect(store.listStartupRecords().records).toMatchObject([
|
||||
{
|
||||
identity: { callOperationId: 'call-prepared' },
|
||||
status: 'prepared'
|
||||
}
|
||||
])
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('uses the verified private SQLite path opener before initialization', async () => {
|
||||
const path = await createPath()
|
||||
const store = new ModelCallOperationStore(path)
|
||||
store.close()
|
||||
const databaseStats = await stat(path)
|
||||
expect(databaseStats.isFile()).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(databaseStats.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
expect(() => new ModelCallOperationStore(':memory:')).toThrow(
|
||||
'SQLite database must be a filesystem path'
|
||||
)
|
||||
|
||||
const invalidPath = join(
|
||||
join(path, '..'),
|
||||
'invalid-capacity-must-not-exist.sqlite'
|
||||
)
|
||||
expect(
|
||||
() =>
|
||||
new ModelCallOperationStore(invalidPath, {
|
||||
maximumRecords: 0
|
||||
})
|
||||
).toThrow()
|
||||
await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1563,8 +1563,7 @@ describe('ModelAgentRuntime', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels immediately during model retry backoff', async () => {
|
||||
const controller = new AbortController()
|
||||
it('does not retry a reset whose dispatch outcome is unknown', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
throw Object.assign(new Error('socket disconnected'), {
|
||||
code: 'ECONNRESET'
|
||||
@@ -1578,6 +1577,44 @@ describe('ModelAgentRuntime', () => {
|
||||
authentication: 'api-key',
|
||||
fetcher
|
||||
})
|
||||
const events: RuntimeEvent[] = []
|
||||
const consume = async (): Promise<void> => {
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
prompt: 'test'
|
||||
},
|
||||
new AbortController().signal
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
await expect(consume()).rejects.toThrow('socket disconnected')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('正在重试')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels immediately during model retry backoff', async () => {
|
||||
const controller = new AbortController()
|
||||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||||
throw Object.assign(new Error('connect timeout'), {
|
||||
code: 'UND_ERR_CONNECT_TIMEOUT'
|
||||
})
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://bigtoken.ai',
|
||||
model: 'sonnet-5',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
fetcher
|
||||
})
|
||||
const stream = runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
@@ -1691,7 +1728,7 @@ describe('ModelAgentRuntime', () => {
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
await assertion
|
||||
expect(fetcher).toHaveBeenCalledTimes(4)
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
@@ -195,22 +195,14 @@ const maxToolArgumentBytes = 128 * 1024
|
||||
const maxToolContextBytes = 1024 * 1024
|
||||
const defaultModelRequestTimeoutMs = 10 * 60_000
|
||||
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
|
||||
const retryableModelNetworkErrorCodes = new Set([
|
||||
const retryablePreDispatchNetworkErrorCodes = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNABORTED',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EHOSTDOWN',
|
||||
'EHOSTUNREACH',
|
||||
'ENETDOWN',
|
||||
'ENETRESET',
|
||||
'ENETUNREACH',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'UND_ERR_BODY_TIMEOUT',
|
||||
'UND_ERR_CONNECT_TIMEOUT',
|
||||
'UND_ERR_HEADERS_TIMEOUT',
|
||||
'UND_ERR_SOCKET'
|
||||
'UND_ERR_CONNECT_TIMEOUT'
|
||||
])
|
||||
|
||||
const noModelTools: ModelToolProviderLike = {
|
||||
@@ -617,7 +609,7 @@ function isRetryableModelRequestError(error: unknown): boolean {
|
||||
const record = current as Record<string, unknown>
|
||||
if (
|
||||
typeof record.code === 'string' &&
|
||||
retryableModelNetworkErrorCodes.has(
|
||||
retryablePreDispatchNetworkErrorCodes.has(
|
||||
record.code.toLocaleUpperCase()
|
||||
)
|
||||
) {
|
||||
@@ -625,8 +617,8 @@ function isRetryableModelRequestError(error: unknown): boolean {
|
||||
}
|
||||
if (typeof record.message === 'string') {
|
||||
if (
|
||||
record.message === '模型接口请求超时' ||
|
||||
/(?:fetch failed|failed to fetch|network(?: request)? (?:error|failed)|socket disconnected|connection (?:closed|reset)|\bECONNRESET\b|\bETIMEDOUT\b)/iu.test(
|
||||
record.code === 'ECONNRESET' &&
|
||||
/before secure TLS connection was established/iu.test(
|
||||
record.message
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -86,6 +86,7 @@ function dependencies(
|
||||
deps: Partial<OpenCodeRuntimeDependencies>
|
||||
spawnMock: ReturnType<typeof vi.fn>
|
||||
detectBinary: ReturnType<typeof vi.fn>
|
||||
validateBinary: ReturnType<typeof vi.fn>
|
||||
createClient: ReturnType<typeof vi.fn>
|
||||
checkServerHealth: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
@@ -94,12 +95,17 @@ function dependencies(
|
||||
path: 'opencode',
|
||||
detail: 'OpenCode CLI 已就绪'
|
||||
})
|
||||
const validateBinary = vi.fn().mockResolvedValue({
|
||||
path: 'opencode',
|
||||
detail: 'OpenCode CLI 已就绪'
|
||||
})
|
||||
const createClient = vi.fn(() => fakeClient())
|
||||
const checkServerHealth = vi.fn().mockResolvedValue(true)
|
||||
return {
|
||||
deps: {
|
||||
spawn: spawnMock as unknown as typeof spawn,
|
||||
detectBinary,
|
||||
validateBinary,
|
||||
createClient: createClient as unknown as typeof createOpencodeClient,
|
||||
checkServerHealth,
|
||||
platform: 'linux',
|
||||
@@ -108,6 +114,7 @@ function dependencies(
|
||||
},
|
||||
spawnMock,
|
||||
detectBinary,
|
||||
validateBinary,
|
||||
createClient,
|
||||
checkServerHealth
|
||||
}
|
||||
@@ -290,6 +297,126 @@ async function collectRun(
|
||||
}
|
||||
|
||||
describe('OpenCodeRuntime embedded launcher', () => {
|
||||
it('checks embedded availability without starting OpenCode', async () => {
|
||||
const child = fakeChild()
|
||||
const {
|
||||
deps,
|
||||
spawnMock,
|
||||
detectBinary,
|
||||
validateBinary,
|
||||
createClient,
|
||||
checkServerHealth
|
||||
} = dependencies(child)
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true,
|
||||
detail: 'OpenCode 已配置,将在首次使用时启动'
|
||||
})
|
||||
expect(detectBinary).toHaveBeenCalledWith(
|
||||
'opencode',
|
||||
'',
|
||||
undefined
|
||||
)
|
||||
expect(validateBinary).toHaveBeenCalledWith(
|
||||
'opencode',
|
||||
'opencode'
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(createClient).not.toHaveBeenCalled()
|
||||
expect(checkServerHealth).not.toHaveBeenCalled()
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('reports an unavailable embedded binary without starting OpenCode', async () => {
|
||||
const child = fakeChild()
|
||||
const {
|
||||
deps,
|
||||
spawnMock,
|
||||
detectBinary,
|
||||
validateBinary,
|
||||
createClient,
|
||||
checkServerHealth
|
||||
} = dependencies(child)
|
||||
detectBinary.mockResolvedValueOnce({
|
||||
path: '/missing/opencode',
|
||||
detail: 'OpenCode CLI 已就绪'
|
||||
})
|
||||
validateBinary.mockResolvedValueOnce({
|
||||
detail: 'OpenCode CLI 未找到'
|
||||
})
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
binaryPath: '/missing/opencode',
|
||||
bundledBinaryPath: '/bundled/opencode'
|
||||
}),
|
||||
deps
|
||||
)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toEqual({
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
available: false,
|
||||
supportsToolExecution: true,
|
||||
detail: 'OpenCode CLI 未找到'
|
||||
})
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(detectBinary).toHaveBeenCalledWith(
|
||||
'opencode',
|
||||
'/missing/opencode',
|
||||
'/bundled/opencode'
|
||||
)
|
||||
expect(validateBinary).toHaveBeenCalledWith(
|
||||
'opencode',
|
||||
'/missing/opencode'
|
||||
)
|
||||
expect(createClient).not.toHaveBeenCalled()
|
||||
expect(checkServerHealth).not.toHaveBeenCalled()
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('reports a missing embedded model credential without starting OpenCode', async () => {
|
||||
const child = fakeChild()
|
||||
const {
|
||||
deps,
|
||||
spawnMock,
|
||||
detectBinary,
|
||||
validateBinary,
|
||||
createClient,
|
||||
checkServerHealth
|
||||
} = dependencies(child)
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
modelProfile: {
|
||||
id: '00000000-0000-4000-8000-000000000011',
|
||||
name: 'Independent model',
|
||||
baseUrl: 'https://model.example',
|
||||
modelName: 'private-model',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key'
|
||||
}
|
||||
}),
|
||||
deps
|
||||
)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toEqual({
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
available: false,
|
||||
supportsToolExecution: true,
|
||||
detail: 'OpenCode 独立模型连接尚未配置 API Key'
|
||||
})
|
||||
expect(detectBinary).not.toHaveBeenCalled()
|
||||
expect(validateBinary).not.toHaveBeenCalled()
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(createClient).not.toHaveBeenCalled()
|
||||
expect(checkServerHealth).not.toHaveBeenCalled()
|
||||
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('uses the detected binary and passes an absolute config path only through env', async () => {
|
||||
const serverChild = fakeChild(314)
|
||||
const killerChild = fakeChild(315)
|
||||
@@ -329,7 +456,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
}
|
||||
)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true,
|
||||
detail:
|
||||
'由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程'
|
||||
@@ -476,7 +603,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
deps
|
||||
)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
|
||||
@@ -553,7 +680,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
deps
|
||||
)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
|
||||
@@ -626,7 +753,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
const environment = (
|
||||
@@ -857,7 +984,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
deps
|
||||
)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
|
||||
@@ -931,7 +1058,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
'https://telemetry.invalid'
|
||||
try {
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
|
||||
@@ -996,7 +1123,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
.mockResolvedValueOnce(true)
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
expect(checkServerHealth).toHaveBeenCalledTimes(2)
|
||||
@@ -1023,7 +1150,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
})
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
let settled = false
|
||||
const statusPromise = runtime.getStatus().finally(() => {
|
||||
const statusPromise = runtime.testConnection().finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
@@ -1053,7 +1180,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
stderrOf(child).write(secret)
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
|
||||
const status = await runtime.getStatus()
|
||||
const status = await runtime.testConnection()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
available: false,
|
||||
@@ -1088,7 +1215,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
})
|
||||
const runtime = new OpenCodeRuntime(options(), deps)
|
||||
|
||||
const status = await runtime.getStatus()
|
||||
const status = await runtime.testConnection()
|
||||
|
||||
expect(status.detail).toBe('OpenCode Server 启动前退出(code 9)')
|
||||
expect(status.detail).not.toContain(secret)
|
||||
|
||||
@@ -477,6 +477,11 @@ export type OpenCodeRuntimeDependencies = {
|
||||
configuredPath: string,
|
||||
bundledPath?: string
|
||||
) => Promise<{ path?: string; detail: string }>
|
||||
validateBinary: (
|
||||
runtime: 'opencode',
|
||||
configuredPath: string,
|
||||
bundledPath?: string
|
||||
) => Promise<{ path?: string; detail: string }>
|
||||
createClient: typeof createOpencodeClient
|
||||
checkServerHealth: (
|
||||
url: string,
|
||||
@@ -695,6 +700,19 @@ async function defaultDetectBinary(
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultValidateBinary(
|
||||
runtime: 'opencode',
|
||||
configuredPath: string
|
||||
): Promise<{ path?: string; detail: string }> {
|
||||
return detectRuntimeBinary({
|
||||
binaryPath: configuredPath,
|
||||
validation: 'canonical-file',
|
||||
allowAutomaticDiscovery: false,
|
||||
binaryNames: [runtime],
|
||||
label: 'OpenCode CLI'
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultCheckServerHealth(
|
||||
url: string,
|
||||
authorization: string,
|
||||
@@ -740,6 +758,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
this.dependencies = {
|
||||
spawn,
|
||||
detectBinary: defaultDetectBinary,
|
||||
validateBinary: defaultValidateBinary,
|
||||
createClient: createOpencodeClient,
|
||||
checkServerHealth: defaultCheckServerHealth,
|
||||
platform: process.platform,
|
||||
@@ -854,15 +873,37 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private async launchEmbedded(signal?: AbortSignal): Promise<OpenCodeServer> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('OpenCode Server 启动已取消')
|
||||
private async inspectEmbeddedConfiguration(
|
||||
validatePath = false
|
||||
): Promise<{
|
||||
path?: string
|
||||
detail: string
|
||||
}> {
|
||||
if (
|
||||
this.options.modelProfile?.authentication === 'api-key' &&
|
||||
!this.options.modelProfile.apiKey
|
||||
) {
|
||||
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
|
||||
}
|
||||
const detection = await this.dependencies.detectBinary(
|
||||
'opencode',
|
||||
this.options.binaryPath,
|
||||
this.options.bundledBinaryPath
|
||||
)
|
||||
if (!validatePath || !detection.path) {
|
||||
return detection
|
||||
}
|
||||
return this.dependencies.validateBinary(
|
||||
'opencode',
|
||||
detection.path
|
||||
)
|
||||
}
|
||||
|
||||
private async launchEmbedded(signal?: AbortSignal): Promise<OpenCodeServer> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('OpenCode Server 启动已取消')
|
||||
}
|
||||
const detection = await this.inspectEmbeddedConfiguration()
|
||||
const binaryPath = detection.path
|
||||
if (!binaryPath) {
|
||||
throw new Error(detection.detail)
|
||||
@@ -875,12 +916,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
throw new Error('OpenCode Server 启动已取消')
|
||||
}
|
||||
|
||||
if (
|
||||
this.options.modelProfile?.authentication === 'api-key' &&
|
||||
!this.options.modelProfile.apiKey
|
||||
) {
|
||||
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
|
||||
}
|
||||
const skillIds = this.getNativeSkillIds()
|
||||
const registration = await this.createSkillRegistration()
|
||||
try {
|
||||
@@ -1186,6 +1221,38 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
if (this.usesEmbeddedPermissionMediation() && !this.client) {
|
||||
try {
|
||||
const detection =
|
||||
await this.inspectEmbeddedConfiguration(true)
|
||||
return {
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
available: Boolean(detection.path),
|
||||
supportsToolExecution: this.supportsToolExecution,
|
||||
detail: detection.path
|
||||
? 'OpenCode 已配置,将在首次使用时启动'
|
||||
: detection.detail
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
available: false,
|
||||
supportsToolExecution: this.supportsToolExecution,
|
||||
detail:
|
||||
error instanceof Error ? error.message : 'OpenCode 不可用'
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.checkConnection()
|
||||
}
|
||||
|
||||
async testConnection(): Promise<AgentRuntimeStatus> {
|
||||
return this.checkConnection()
|
||||
}
|
||||
|
||||
private async checkConnection(): Promise<AgentRuntimeStatus> {
|
||||
try {
|
||||
const client = await this.getClient()
|
||||
const response = await this.controlRequest(
|
||||
|
||||
@@ -1,736 +0,0 @@
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { ModelCallOperationStore } from './model-call-operation-store'
|
||||
import {
|
||||
createModelCallOperationId,
|
||||
createResolvedModelProfileDigest,
|
||||
RemoteModelGateway,
|
||||
type RemoteModelGatewayDispatchContext
|
||||
} from './remote-model-gateway'
|
||||
|
||||
const stores: ModelCallOperationStore[] = []
|
||||
const storePaths = new Map<ModelCallOperationStore, string>()
|
||||
|
||||
const anthropicProfile: ResolvedModelProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Anthropic',
|
||||
baseUrl: 'https://provider.example',
|
||||
modelName: 'claude-test',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
apiKey: 'main-only-secret'
|
||||
}
|
||||
|
||||
const context: RemoteModelGatewayDispatchContext = {
|
||||
requestId: 'request-1',
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0,
|
||||
roundIndex: 0,
|
||||
modelProfileDigest: createResolvedModelProfileDigest(anthropicProfile),
|
||||
modelProfile: anthropicProfile
|
||||
}
|
||||
|
||||
const request = {
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({ model: 'claude-test', messages: [] })
|
||||
).toString('base64')
|
||||
} as const
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const store of stores.splice(0)) {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
async function createStore(): Promise<ModelCallOperationStore> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-gateway-'))
|
||||
const path = join(directory, 'operations.sqlite')
|
||||
const store = new ModelCallOperationStore(path)
|
||||
stores.push(store)
|
||||
storePaths.set(store, path)
|
||||
return store
|
||||
}
|
||||
|
||||
describe('RemoteModelGateway', () => {
|
||||
it('prepares and claims before one authenticated provider fetch', async () => {
|
||||
const store = await createStore()
|
||||
const order: string[] = []
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
prepare.mockImplementation(function (input) {
|
||||
order.push('prepare')
|
||||
return ModelCallOperationStore.prototype.prepare.call(store, input)
|
||||
})
|
||||
const begin = vi.spyOn(store, 'beginDispatch')
|
||||
begin.mockImplementation(function (id, metadata) {
|
||||
order.push('begin')
|
||||
return ModelCallOperationStore.prototype.beginDispatch.call(
|
||||
store,
|
||||
id,
|
||||
metadata
|
||||
)
|
||||
})
|
||||
const complete = vi.spyOn(store, 'complete')
|
||||
let sentHeaders: Headers | undefined
|
||||
let sentBody: Record<string, unknown> | undefined
|
||||
const fetcher = vi.fn(async (url: URL, init?: RequestInit) => {
|
||||
order.push('fetch')
|
||||
expect(url.toString()).toBe(
|
||||
'https://provider.example/v1/messages'
|
||||
)
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(init?.redirect).toBe('error')
|
||||
sentHeaders = new Headers(init?.headers)
|
||||
sentBody = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'message-1',
|
||||
stop_reason: 'end_turn',
|
||||
usage: {
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
cache_read_input_tokens: 3
|
||||
}
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'request-provider-1',
|
||||
authorization: 'must-not-return'
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
const result = await gateway.dispatch(
|
||||
context,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(order).toEqual(['prepare', 'begin', 'fetch'])
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(sentHeaders?.get('x-api-key')).toBe('main-only-secret')
|
||||
expect(sentHeaders?.get('anthropic-version')).toBe('2023-06-01')
|
||||
expect(sentBody).toEqual({
|
||||
model: 'claude-test',
|
||||
messages: []
|
||||
})
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.headers).toEqual({
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'request-provider-1'
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('main-only-secret')
|
||||
const completed = complete.mock.results[0]?.value
|
||||
expect(completed?.status).toBe('completed')
|
||||
expect(completed?.terminalEvidence).toMatchObject({
|
||||
status: 'completed',
|
||||
providerRequestId: 'request-provider-1',
|
||||
providerResponseId: 'message-1',
|
||||
result: {
|
||||
finishReason: 'end_turn',
|
||||
outputDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u)
|
||||
},
|
||||
usage: {
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
cachedInputTokens: 3
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(completed)).not.toContain('main-only-secret')
|
||||
expect(JSON.stringify(completed)).not.toContain('"messages"')
|
||||
})
|
||||
|
||||
it('applies prompt-scoped custom headers and body fields before dispatch', async () => {
|
||||
const store = await createStore()
|
||||
const customizedProfile: ResolvedModelProfile = {
|
||||
...anthropicProfile,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'remote-tenant'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
}
|
||||
}
|
||||
const customizedRequest = {
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
temperature: 0.8
|
||||
})
|
||||
).toString('base64')
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get('x-tenant-id')).toBe(
|
||||
'remote-tenant'
|
||||
)
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
messages: [],
|
||||
metadata: { source: 'goodbuddy' },
|
||||
model: 'claude-test',
|
||||
temperature: 0.8
|
||||
})
|
||||
return new Response('{}')
|
||||
})
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: customizedProfile,
|
||||
modelProfileDigest:
|
||||
createResolvedModelProfileDigest(customizedProfile)
|
||||
},
|
||||
customizedRequest,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
createResolvedModelProfileDigest(customizedProfile)
|
||||
).not.toBe(createResolvedModelProfileDigest(anthropicProfile))
|
||||
})
|
||||
|
||||
it('does not clamp Runtime-selected provider output parameters', async () => {
|
||||
const store = await createStore()
|
||||
let sentBody: Record<string, unknown> | undefined
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async (_url, init) => {
|
||||
sentBody = JSON.parse(String(init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
return new Response('{}')
|
||||
}
|
||||
})
|
||||
const requestWithProviderMaximum = {
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
max_tokens: 1_000_000
|
||||
})
|
||||
).toString('base64')
|
||||
}
|
||||
|
||||
await gateway.dispatch(
|
||||
context,
|
||||
requestWithProviderMaximum,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(sentBody).toMatchObject({ max_tokens: 1_000_000 })
|
||||
})
|
||||
|
||||
it('does not persist endpoints, credentials, or provider bodies', async () => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
new Response(JSON.stringify({ private_response_field: 'secret' }))
|
||||
})
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
store.close()
|
||||
|
||||
const databaseBytes = await readFile(storePaths.get(store)!)
|
||||
const persisted = databaseBytes.toString('utf8')
|
||||
expect(persisted).not.toContain('https://provider.example')
|
||||
expect(persisted).not.toContain('main-only-secret')
|
||||
expect(persisted).not.toContain('"messages"')
|
||||
expect(persisted).not.toContain('private_response_field')
|
||||
})
|
||||
|
||||
it('completes the ledger for a full HTTP 4xx response', async () => {
|
||||
const store = await createStore()
|
||||
const complete = vi.spyOn(store, 'complete')
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
new Response(JSON.stringify({ error: { type: 'invalid_request' } }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await gateway.dispatch(
|
||||
context,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(result.status).toBe(400)
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(complete.mock.results[0]?.value.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('permits only one fetch for duplicate canonical dispatches', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: 'already-dispatched'
|
||||
})
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses profile-independent round identity and rejects a profile swap', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
const swappedProfile = {
|
||||
...anthropicProfile,
|
||||
id: '00000000-0000-4000-8000-000000000002',
|
||||
modelName: 'claude-other'
|
||||
}
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: swappedProfile,
|
||||
modelProfileDigest:
|
||||
createResolvedModelProfileDigest(swappedProfile)
|
||||
},
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({ model: 'claude-other', messages: [] })
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('different identity or evidence')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('validates the Main profile snapshot digest before ledger preparation', async () => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
{ ...context, modelProfileDigest: `sha256:${'f'.repeat(64)}` },
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'model-profile-digest-mismatch' })
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed on a different request for the same bound call', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [{ role: 'user', content: 'different' }]
|
||||
})
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('different identity or evidence')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects illegal paths, headers, and protocol mismatches before prepare', async () => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
for (const invalidRequest of [
|
||||
{ ...request, path: 'https://attacker.example/v1/messages' },
|
||||
{
|
||||
...request,
|
||||
headers: { authorization: 'Bearer remote-secret' }
|
||||
},
|
||||
{ ...request, path: '/v1/responses' },
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({ model: 'untrusted-model' })
|
||||
).toString('base64')
|
||||
},
|
||||
{ ...request, path: '/v1/responses' }
|
||||
]) {
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
invalidRequest as typeof request,
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow()
|
||||
}
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
expect(store.listUnresolved()).toEqual([])
|
||||
})
|
||||
|
||||
it('enforces the trusted profile image-input policy before prepare', async () => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
const imageRequest = {
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'AA=='
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toString('base64')
|
||||
}
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
imageRequest,
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: {
|
||||
...anthropicProfile,
|
||||
supportsImageInput: true
|
||||
},
|
||||
modelProfileDigest: createResolvedModelProfileDigest({
|
||||
...anthropicProfile,
|
||||
supportsImageInput: true
|
||||
})
|
||||
},
|
||||
imageRequest,
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toMatchObject({ status: 200 })
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ background: true },
|
||||
{ store: true },
|
||||
{ service_tier: 'priority' },
|
||||
{ web_search_options: {} },
|
||||
{
|
||||
tools: [
|
||||
{
|
||||
type: 'web_search_20250305',
|
||||
name: 'web_search'
|
||||
}
|
||||
]
|
||||
}
|
||||
])(
|
||||
'rejects independently billed provider capabilities before prepare',
|
||||
async (providerCapability) => {
|
||||
const store = await createStore()
|
||||
const prepare = vi.spyOn(store, 'prepare')
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
...providerCapability
|
||||
})
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('allows ordinary client-defined tools within the token policy', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(
|
||||
context,
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
input_schema: { type: 'object' }
|
||||
}
|
||||
]
|
||||
})
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toMatchObject({ status: 200 })
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'network failure',
|
||||
expectedCode: 'network-error',
|
||||
run: async () => {
|
||||
throw new Error('socket exposed sensitive upstream details')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
expectedCode: 'timeout',
|
||||
run: async (_url: URL, init?: RequestInit) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason),
|
||||
{ once: true }
|
||||
)
|
||||
}),
|
||||
timeoutMs: 5
|
||||
}
|
||||
])(
|
||||
'marks $name after dispatch as outcome unknown',
|
||||
async ({ expectedCode, run, timeoutMs }) => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: run,
|
||||
...(timeoutMs ? { timeoutMs } : {})
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: expectedCode,
|
||||
message: expect.not.stringContaining('sensitive')
|
||||
})
|
||||
expect(store.listUnresolved()).toHaveLength(1)
|
||||
expect(store.listUnresolved()[0]).toMatchObject({
|
||||
status: 'outcome-unknown',
|
||||
terminalEvidence: {
|
||||
status: 'outcome-unknown',
|
||||
reason: { code: expectedCode, retryable: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('marks caller cancellation after dispatch as outcome unknown', async () => {
|
||||
const store = await createStore()
|
||||
const controller = new AbortController()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async (_url, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
controller.abort()
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason),
|
||||
{ once: true }
|
||||
)
|
||||
if (init?.signal?.aborted) {
|
||||
reject(init.signal.reason)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, controller.signal)
|
||||
).rejects.toMatchObject({ code: 'cancelled' })
|
||||
expect(store.listUnresolved()[0]?.status).toBe('outcome-unknown')
|
||||
})
|
||||
|
||||
it('marks an oversized response as outcome unknown', async () => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
new Response(Buffer.alloc(768 * 1024 + 1), {
|
||||
headers: {
|
||||
'content-length': String(768 * 1024 + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({ code: 'response-too-large' })
|
||||
expect(store.listUnresolved()[0]).toMatchObject({
|
||||
status: 'outcome-unknown',
|
||||
terminalEvidence: {
|
||||
reason: {
|
||||
code: 'response-too-large',
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps completion pending until delivery ACK and then permits the next round', async () => {
|
||||
const store = await createStore()
|
||||
const fetcher = vi.fn(async () => new Response('{}'))
|
||||
const gateway = new RemoteModelGateway({ store, fetcher })
|
||||
await gateway.dispatch(context, request, new AbortController().signal)
|
||||
const nextContext = { ...context, roundIndex: 1 }
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(nextContext, request, new AbortController().signal)
|
||||
).rejects.toThrow('not been completed and delivered')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
const pending = store.listStartupRecords().records
|
||||
expect(pending).toMatchObject([{ status: 'completed' }])
|
||||
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
|
||||
|
||||
gateway.markResponseDelivered(context)
|
||||
await gateway.dispatch(
|
||||
nextContext,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('permits sequential responses without a cumulative output allowance', async () => {
|
||||
const store = await createStore()
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () => new Response('{}')
|
||||
})
|
||||
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
|
||||
const round = { ...context, roundIndex }
|
||||
await gateway.dispatch(
|
||||
round,
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
gateway.markResponseDelivered(round)
|
||||
}
|
||||
expect(store.get(
|
||||
createModelCallOperationId({ ...context, roundIndex: 11 })
|
||||
)).toMatchObject({
|
||||
status: 'completed',
|
||||
responseDeliveredAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('marks schema-invalid bridge responses unknown before terminal completion', async () => {
|
||||
const store = await createStore()
|
||||
const response = new Response('{}')
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async () =>
|
||||
({
|
||||
status: 999,
|
||||
headers: response.headers,
|
||||
body: response.body
|
||||
}) as Response
|
||||
})
|
||||
|
||||
await expect(
|
||||
gateway.dispatch(context, request, new AbortController().signal)
|
||||
).rejects.toMatchObject({ code: 'invalid-bridge-response' })
|
||||
expect(store.listStartupRecords().records).toMatchObject([
|
||||
{
|
||||
status: 'outcome-unknown',
|
||||
terminalEvidence: {
|
||||
reason: { code: 'invalid-bridge-response' }
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('injects OpenAI bearer auth without returning or persisting it', async () => {
|
||||
const store = await createStore()
|
||||
const profile: ResolvedModelProfile = {
|
||||
...anthropicProfile,
|
||||
baseUrl: 'https://openai.example/v1',
|
||||
protocol: 'openai-responses'
|
||||
}
|
||||
let authorization: string | null = null
|
||||
const gateway = new RemoteModelGateway({
|
||||
store,
|
||||
fetcher: async (_url, init) => {
|
||||
authorization = new Headers(init?.headers).get('authorization')
|
||||
return new Response('{}')
|
||||
}
|
||||
})
|
||||
|
||||
const result = await gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
modelProfile: profile,
|
||||
modelProfileDigest: createResolvedModelProfileDigest(profile)
|
||||
},
|
||||
{ ...request, path: '/v1/responses' },
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(authorization).toBe('Bearer main-only-secret')
|
||||
expect(JSON.stringify(result)).not.toContain('main-only-secret')
|
||||
})
|
||||
})
|
||||
@@ -1,652 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
modelCallIdentitySchema,
|
||||
modelCallResultMetadataSchema,
|
||||
modelCallUsageMetadataSchema,
|
||||
type ModelCallResultMetadata,
|
||||
type ModelCallUsageMetadata
|
||||
} from '../../shared/model-call-operation-contracts'
|
||||
import {
|
||||
REMOTE_MODEL_GATEWAY_LIMITS,
|
||||
remoteModelGatewayRequestSchema,
|
||||
remoteModelGatewayResponseSchema,
|
||||
type RemoteModelGatewayRequest,
|
||||
type RemoteModelGatewayResponse
|
||||
} from '../../shared/remote-model-gateway-contracts'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
|
||||
import { ModelCallOperationStore } from './model-call-operation-store'
|
||||
import {
|
||||
createOpenAIChatCompletionsUrl,
|
||||
createOpenAIResponsesUrl
|
||||
} from './openai-endpoint'
|
||||
import {
|
||||
BoundedResponseTooLargeError,
|
||||
readBoundedResponseBytes
|
||||
} from './bounded-response'
|
||||
import {
|
||||
assertTextModelRequestPolicy,
|
||||
ModelRequestPolicyError
|
||||
} from '../../shared/model-request-policy'
|
||||
import { modelBridgePathMatchesProtocol } from '../../shared/model-bridge-contracts'
|
||||
import { canonicalJson } from '../../shared/agent-protocol/canonical'
|
||||
import {
|
||||
canonicalModelRequestHeaders,
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders
|
||||
} from '../../shared/model-request-customization'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
const RESPONSE_HEADER_NAMES = [
|
||||
'content-type',
|
||||
'openai-request-id',
|
||||
'request-id',
|
||||
'x-request-id'
|
||||
] as const
|
||||
|
||||
export type RemoteModelGatewayDispatchContext = {
|
||||
requestId: string
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
promptSequence: number
|
||||
roundIndex: number
|
||||
modelProfileDigest: string
|
||||
modelProfile: ResolvedModelProfile
|
||||
}
|
||||
|
||||
export type RemoteModelGatewayOptions = {
|
||||
store: ModelCallOperationStore
|
||||
fetcher?: (
|
||||
input: URL,
|
||||
init: RequestInit
|
||||
) => Promise<Response>
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export class RemoteModelGatewayError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'RemoteModelGatewayError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process-only, one-shot provider forwarder. Remote runtimes choose only
|
||||
* a protocol-specific relative path and a bounded payload. Provider location,
|
||||
* credentials, stable operation identity, and dispatch ownership stay in Main.
|
||||
*/
|
||||
export class RemoteModelGateway {
|
||||
readonly #store: ModelCallOperationStore
|
||||
readonly #fetcher: NonNullable<RemoteModelGatewayOptions['fetcher']>
|
||||
readonly #timeoutMs: number
|
||||
|
||||
constructor(options: RemoteModelGatewayOptions) {
|
||||
this.#store = options.store
|
||||
this.#fetcher = options.fetcher ?? fetch
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
if (
|
||||
!Number.isSafeInteger(timeoutMs) ||
|
||||
timeoutMs < 1 ||
|
||||
timeoutMs > 300_000
|
||||
) {
|
||||
throw new RangeError('Remote model gateway timeout is invalid')
|
||||
}
|
||||
this.#timeoutMs = timeoutMs
|
||||
}
|
||||
|
||||
async dispatch(
|
||||
context: RemoteModelGatewayDispatchContext,
|
||||
request: RemoteModelGatewayRequest,
|
||||
signal: AbortSignal
|
||||
): Promise<RemoteModelGatewayResponse> {
|
||||
signal.throwIfAborted()
|
||||
const prepared = prepareDispatch(context, request)
|
||||
|
||||
this.#store.prepare({
|
||||
identity: prepared.identity,
|
||||
requestDigest: prepared.requestDigest,
|
||||
modelProfileDigest: prepared.modelProfileDigest
|
||||
})
|
||||
const claim = this.#store.beginDispatch(
|
||||
prepared.identity.callOperationId
|
||||
)
|
||||
if (!claim.permitted) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'already-dispatched',
|
||||
'Remote model request was already dispatched'
|
||||
)
|
||||
}
|
||||
|
||||
const timeoutController = new AbortController()
|
||||
const timeoutError = new RemoteModelGatewayError(
|
||||
'timeout',
|
||||
'Remote model request timed out'
|
||||
)
|
||||
const timer = setTimeout(
|
||||
() => timeoutController.abort(timeoutError),
|
||||
this.#timeoutMs
|
||||
)
|
||||
const requestSignal = AbortSignal.any([
|
||||
signal,
|
||||
timeoutController.signal
|
||||
])
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.#fetcher(prepared.url, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: prepared.headers,
|
||||
body: prepared.body,
|
||||
signal: requestSignal
|
||||
})
|
||||
} catch {
|
||||
clearTimeout(timer)
|
||||
const code =
|
||||
timeoutController.signal.aborted
|
||||
? 'timeout'
|
||||
: signal.aborted
|
||||
? 'cancelled'
|
||||
: 'network-error'
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
code
|
||||
)
|
||||
}
|
||||
|
||||
let responseBody: Uint8Array
|
||||
try {
|
||||
responseBody = await readBoundedResponseBytes(response, {
|
||||
maxBytes:
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
|
||||
tooLargeMessage: 'Remote model response is too large',
|
||||
truncatedMessage: 'Remote model response was truncated'
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timer)
|
||||
const code =
|
||||
timeoutController.signal.aborted
|
||||
? 'timeout'
|
||||
: signal.aborted
|
||||
? 'cancelled'
|
||||
: error instanceof BoundedResponseTooLargeError
|
||||
? 'response-too-large'
|
||||
: 'response-read-failed'
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
code
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (timeoutController.signal.aborted || signal.aborted) {
|
||||
const code = timeoutController.signal.aborted
|
||||
? 'timeout'
|
||||
: 'cancelled'
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
code
|
||||
)
|
||||
}
|
||||
|
||||
const responseHeaders = selectResponseHeaders(response.headers)
|
||||
const providerRequestId =
|
||||
safeProviderIdentifier(
|
||||
response.headers.get('x-request-id')
|
||||
) ??
|
||||
safeProviderIdentifier(
|
||||
response.headers.get('openai-request-id')
|
||||
) ??
|
||||
safeProviderIdentifier(response.headers.get('request-id'))
|
||||
let bridgeResponse: RemoteModelGatewayResponse
|
||||
try {
|
||||
bridgeResponse = remoteModelGatewayResponseSchema.parse({
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
bodyBase64: Buffer.from(responseBody).toString('base64')
|
||||
})
|
||||
} catch {
|
||||
return this.#markUnknownAndThrow(
|
||||
prepared.identity.callOperationId,
|
||||
'invalid-bridge-response'
|
||||
)
|
||||
}
|
||||
const parsedMetadata = parseResponseMetadata(responseBody)
|
||||
this.#store.complete(prepared.identity.callOperationId, {
|
||||
status: 'completed',
|
||||
...(providerRequestId ? { providerRequestId } : {}),
|
||||
...(parsedMetadata.providerResponseId
|
||||
? { providerResponseId: parsedMetadata.providerResponseId }
|
||||
: {}),
|
||||
result: {
|
||||
outputDigest: sha256(responseBody),
|
||||
...(parsedMetadata.result ?? {})
|
||||
},
|
||||
...(parsedMetadata.usage ? { usage: parsedMetadata.usage } : {})
|
||||
})
|
||||
|
||||
return bridgeResponse
|
||||
}
|
||||
|
||||
markResponseDelivered(
|
||||
context: Pick<
|
||||
RemoteModelGatewayDispatchContext,
|
||||
'bindingId' | 'promptOperationId' | 'roundIndex'
|
||||
>
|
||||
): void {
|
||||
this.#store.markResponseDelivered(
|
||||
createModelCallOperationId(context)
|
||||
)
|
||||
}
|
||||
|
||||
finalizePrompt(context: {
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
promptSequence: number
|
||||
}): void {
|
||||
this.#store.finalizePrompt(context)
|
||||
}
|
||||
|
||||
#markUnknownAndThrow(
|
||||
callOperationId: string,
|
||||
code: string
|
||||
): never {
|
||||
this.#store.markOutcomeUnknown(callOperationId, {
|
||||
status: 'outcome-unknown',
|
||||
reason: {
|
||||
code,
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
throw new RemoteModelGatewayError(
|
||||
code,
|
||||
remoteModelGatewayErrorMessage(code)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDispatch(
|
||||
context: RemoteModelGatewayDispatchContext,
|
||||
request: RemoteModelGatewayRequest
|
||||
): {
|
||||
identity: ReturnType<typeof modelCallIdentitySchema.parse>
|
||||
requestDigest: string
|
||||
modelProfileDigest: string
|
||||
url: URL
|
||||
headers: Record<string, string>
|
||||
body: Uint8Array
|
||||
} {
|
||||
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
|
||||
const profile = context.modelProfile
|
||||
const modelProfileDigest = createResolvedModelProfileDigest(profile)
|
||||
if (modelProfileDigest !== context.modelProfileDigest) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'model-profile-digest-mismatch',
|
||||
'Trusted model profile snapshot does not match its digest'
|
||||
)
|
||||
}
|
||||
const url = providerUrl(profile, parsedRequest.path)
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password
|
||||
) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'invalid-provider-endpoint',
|
||||
'Configured model provider endpoint is invalid'
|
||||
)
|
||||
}
|
||||
if (profile.authentication === 'api-key' && !profile.apiKey) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'missing-provider-credential',
|
||||
'Configured model provider credential is unavailable'
|
||||
)
|
||||
}
|
||||
const body = normalizeProviderRequestBody(
|
||||
profile,
|
||||
Buffer.from(parsedRequest.bodyBase64, 'base64')
|
||||
)
|
||||
if (
|
||||
body.byteLength >
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
||||
) {
|
||||
throw new RemoteModelGatewayError(
|
||||
'request-too-large',
|
||||
'Customized remote model request is too large'
|
||||
)
|
||||
}
|
||||
const providerHeaders = mergeModelRequestHeaders(
|
||||
profile.requestHeaders ?? {},
|
||||
parsedRequest.headers
|
||||
)
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
providerHeaders.set('anthropic-version', '2023-06-01')
|
||||
}
|
||||
const canonicalHeaders =
|
||||
canonicalModelRequestHeaders(providerHeaders)
|
||||
const normalizedBodyBase64 = body.toString('base64')
|
||||
const canonicalRequest = JSON.stringify({
|
||||
profileId: profile.id,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
model: profile.modelName,
|
||||
url: url.toString(),
|
||||
request: {
|
||||
method: parsedRequest.method,
|
||||
path: parsedRequest.path,
|
||||
headers: canonicalHeaders,
|
||||
bodyBase64: normalizedBodyBase64
|
||||
}
|
||||
})
|
||||
const requestDigest = sha256(canonicalRequest)
|
||||
const callOperationId = createModelCallOperationId(context)
|
||||
const identity = modelCallIdentitySchema.parse({
|
||||
callOperationId,
|
||||
requestId: context.requestId,
|
||||
bindingId: context.bindingId,
|
||||
promptOperationId: context.promptOperationId,
|
||||
promptSequence: context.promptSequence,
|
||||
roundIndex: context.roundIndex,
|
||||
provider:
|
||||
profile.protocol === 'anthropic-messages'
|
||||
? 'anthropic'
|
||||
: 'openai',
|
||||
profile: profile.id,
|
||||
model: profile.modelName,
|
||||
protocol: profile.protocol
|
||||
})
|
||||
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
if (profile.authentication === 'api-key') {
|
||||
providerHeaders.set('x-api-key', profile.apiKey!)
|
||||
}
|
||||
} else if (profile.authentication === 'api-key') {
|
||||
providerHeaders.set('authorization', `Bearer ${profile.apiKey!}`)
|
||||
}
|
||||
|
||||
return {
|
||||
identity,
|
||||
requestDigest,
|
||||
modelProfileDigest,
|
||||
url,
|
||||
headers: Object.fromEntries(providerHeaders.entries()),
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelCallOperationId(
|
||||
context: Pick<
|
||||
RemoteModelGatewayDispatchContext,
|
||||
'bindingId' | 'promptOperationId' | 'roundIndex'
|
||||
>
|
||||
): string {
|
||||
return `model-call-${createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
bindingId: context.bindingId,
|
||||
promptOperationId: context.promptOperationId,
|
||||
roundIndex: context.roundIndex
|
||||
})
|
||||
)
|
||||
.digest('hex')}`
|
||||
}
|
||||
|
||||
export function createResolvedModelProfileDigest(
|
||||
profile: ResolvedModelProfile
|
||||
): string {
|
||||
return sha256(
|
||||
canonicalJson({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
contextWindowTokens: profile.contextWindowTokens ?? null,
|
||||
maximumOutputTokens: profile.maximumOutputTokens ?? null,
|
||||
imageGenerationQuality: profile.imageGenerationQuality ?? null,
|
||||
requestHeaders: Object.fromEntries(
|
||||
Object.entries(profile.requestHeaders ?? {}).map(
|
||||
([name, value]) => [name.toLowerCase(), value]
|
||||
)
|
||||
),
|
||||
requestBody: profile.requestBody ?? {}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeProviderRequestBody(
|
||||
profile: ResolvedModelProfile,
|
||||
body: Buffer
|
||||
): Buffer {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(body)
|
||||
) as unknown
|
||||
} catch {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (
|
||||
profile.protocol !== 'anthropic-messages' &&
|
||||
profile.protocol !== 'openai-chat-completions' &&
|
||||
profile.protocol !== 'openai-responses'
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const request = mergeModelRequestBody(
|
||||
profile.requestBody ?? {},
|
||||
value as Record<string, unknown>
|
||||
)
|
||||
try {
|
||||
assertTextModelRequestPolicy(
|
||||
{
|
||||
protocol: profile.protocol,
|
||||
model: profile.modelName,
|
||||
supportsImageInput: profile.supportsImageInput === true
|
||||
},
|
||||
request
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ModelRequestPolicyError)) {
|
||||
throw error
|
||||
}
|
||||
throw requestPolicyError()
|
||||
}
|
||||
return Buffer.from(canonicalJson(request), 'utf8')
|
||||
}
|
||||
|
||||
function requestPolicyError(): RemoteModelGatewayError {
|
||||
return new RemoteModelGatewayError(
|
||||
'request-policy-mismatch',
|
||||
'Remote model request does not match the trusted model policy'
|
||||
)
|
||||
}
|
||||
|
||||
function providerUrl(
|
||||
profile: ResolvedModelProfile,
|
||||
path: string
|
||||
): URL {
|
||||
if (
|
||||
profile.protocol === 'openai-images-generations' ||
|
||||
!modelBridgePathMatchesProtocol(profile.protocol, path)
|
||||
) {
|
||||
throw protocolPathError()
|
||||
}
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
return createAnthropicMessagesUrl(profile.baseUrl)
|
||||
}
|
||||
if (profile.protocol === 'openai-chat-completions') {
|
||||
return createOpenAIChatCompletionsUrl(profile.baseUrl)
|
||||
}
|
||||
if (profile.protocol === 'openai-responses') {
|
||||
return createOpenAIResponsesUrl(profile.baseUrl)
|
||||
}
|
||||
throw protocolPathError()
|
||||
}
|
||||
|
||||
function protocolPathError(): RemoteModelGatewayError {
|
||||
return new RemoteModelGatewayError(
|
||||
'protocol-path-mismatch',
|
||||
'Remote model request path does not match the configured protocol'
|
||||
)
|
||||
}
|
||||
|
||||
function selectResponseHeaders(
|
||||
headers: Headers
|
||||
): Record<string, string> {
|
||||
const selected: Record<string, string> = {}
|
||||
for (const name of RESPONSE_HEADER_NAMES) {
|
||||
const value = headers.get(name)
|
||||
const encodedBytes =
|
||||
value === null ? 0 : new TextEncoder().encode(value).byteLength
|
||||
if (
|
||||
value !== null &&
|
||||
encodedBytes > 0 &&
|
||||
!/[\r\n]/u.test(value) &&
|
||||
encodedBytes <= REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes
|
||||
) {
|
||||
selected[name] = value
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
function parseResponseMetadata(body: Uint8Array): {
|
||||
providerResponseId?: string
|
||||
result?: ModelCallResultMetadata
|
||||
usage?: ModelCallUsageMetadata
|
||||
} {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(body)) as unknown
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return {}
|
||||
}
|
||||
const providerResponseId = safeProviderIdentifier(parsed.id)
|
||||
const finishReason = safeFinishReason(
|
||||
isRecordArray(parsed.choices) &&
|
||||
isRecord(parsed.choices[0])
|
||||
? parsed.choices[0].finish_reason
|
||||
: parsed.stop_reason
|
||||
)
|
||||
const resultCandidate = {
|
||||
...(finishReason ? { finishReason } : {})
|
||||
}
|
||||
const result = modelCallResultMetadataSchema.safeParse(resultCandidate)
|
||||
const usage = parseUsage(parsed.usage)
|
||||
return {
|
||||
...(providerResponseId ? { providerResponseId } : {}),
|
||||
...(result.success && Object.keys(result.data).length > 0
|
||||
? { result: result.data }
|
||||
: {}),
|
||||
...(usage ? { usage } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsage(value: unknown): ModelCallUsageMetadata | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const promptDetails = isRecord(value.prompt_tokens_details)
|
||||
? value.prompt_tokens_details
|
||||
: undefined
|
||||
const inputDetails = isRecord(value.input_tokens_details)
|
||||
? value.input_tokens_details
|
||||
: undefined
|
||||
const completionDetails = isRecord(value.completion_tokens_details)
|
||||
? value.completion_tokens_details
|
||||
: undefined
|
||||
const outputDetails = isRecord(value.output_tokens_details)
|
||||
? value.output_tokens_details
|
||||
: undefined
|
||||
const candidate = {
|
||||
inputTokens: safeTokenCount(value.input_tokens ?? value.prompt_tokens),
|
||||
outputTokens: safeTokenCount(
|
||||
value.output_tokens ?? value.completion_tokens
|
||||
),
|
||||
cachedInputTokens: safeTokenCount(
|
||||
value.cache_read_input_tokens ??
|
||||
inputDetails?.cached_tokens ??
|
||||
promptDetails?.cached_tokens
|
||||
),
|
||||
reasoningTokens: safeTokenCount(
|
||||
outputDetails?.reasoning_tokens ??
|
||||
completionDetails?.reasoning_tokens
|
||||
)
|
||||
}
|
||||
const compact = Object.fromEntries(
|
||||
Object.entries(candidate).filter((entry) => entry[1] !== undefined)
|
||||
)
|
||||
const result = modelCallUsageMetadataSchema.safeParse(compact)
|
||||
return result.success && Object.keys(result.data).length > 0
|
||||
? result.data
|
||||
: undefined
|
||||
}
|
||||
|
||||
function safeTokenCount(value: unknown): number | undefined {
|
||||
return typeof value === 'number' &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 0
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
|
||||
function safeProviderIdentifier(value: unknown): string | undefined {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.length > 256 ||
|
||||
value.trim() !== value ||
|
||||
new TextEncoder().encode(value).byteLength > 256
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function safeFinishReason(value: unknown): string | undefined {
|
||||
const result =
|
||||
modelCallResultMetadataSchema.shape.finishReason.safeParse(value)
|
||||
return result.success ? result.data : undefined
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
)
|
||||
}
|
||||
|
||||
function isRecordArray(value: unknown): value is Record<string, unknown>[] {
|
||||
return Array.isArray(value) && value.every(isRecord)
|
||||
}
|
||||
|
||||
function sha256(value: string | Uint8Array): string {
|
||||
return `sha256:${createHash('sha256').update(value).digest('hex')}`
|
||||
}
|
||||
|
||||
function remoteModelGatewayErrorMessage(code: string): string {
|
||||
switch (code) {
|
||||
case 'timeout':
|
||||
return 'Remote model request timed out after dispatch'
|
||||
case 'cancelled':
|
||||
return 'Remote model request was cancelled after dispatch'
|
||||
case 'response-too-large':
|
||||
return 'Remote model response exceeded the allowed size'
|
||||
case 'response-read-failed':
|
||||
return 'Remote model response could not be read'
|
||||
case 'invalid-bridge-response':
|
||||
return 'Remote model response could not be safely delivered'
|
||||
default:
|
||||
return 'Remote model request outcome is unknown'
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,36 @@ describe('SelectedRuntimeManager', () => {
|
||||
expect(cached.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses a lightweight factory only for passive status probes', async () => {
|
||||
const probed = runtime()
|
||||
const tested = runtime()
|
||||
const create = vi.fn(async () => tested.value)
|
||||
const createStatus = vi.fn(async () => probed.value)
|
||||
const manager = new SelectedRuntimeManager(
|
||||
create,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
createStatus
|
||||
)
|
||||
const selection = { provider: 'opencode' as const }
|
||||
|
||||
await expect(manager.getStatus(selection)).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
expect(createStatus).toHaveBeenCalledWith(selection)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(probed.dispose).toHaveBeenCalledOnce()
|
||||
|
||||
await expect(manager.testStatus(selection)).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
expect(create).toHaveBeenCalledWith(selection)
|
||||
expect(tested.testConnection).toHaveBeenCalledOnce()
|
||||
expect(tested.dispose).toHaveBeenCalledOnce()
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('disposes a native-inventory runtime without caching it', async () => {
|
||||
const inspected = runtime()
|
||||
const cached = runtime()
|
||||
|
||||
@@ -75,7 +75,8 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
private readonly maximumRetainedRuntimes =
|
||||
DEFAULT_MAXIMUM_RETAINED_RUNTIMES,
|
||||
private readonly observeFailure?:
|
||||
DesktopDiagnosticFailureObserver
|
||||
DesktopDiagnosticFailureObserver,
|
||||
private readonly createStatusRuntime = createRuntime
|
||||
) {
|
||||
if (
|
||||
!Number.isSafeInteger(maximumCachedRuntimes) ||
|
||||
@@ -339,7 +340,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
private async runStatusProbe(
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntimeStatus> {
|
||||
const runtime = await this.createRuntime(selection)
|
||||
const runtime = await this.createStatusRuntime(selection)
|
||||
try {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
conversationMessageSchema,
|
||||
conversationSnapshotSchema,
|
||||
expertCreateSchema,
|
||||
maximumConversationMessageBlocks,
|
||||
maximumConversationRetainedActivities,
|
||||
normalizeInteractiveWorkMode,
|
||||
persistedProjectExecutionSpaceSchema,
|
||||
projectCreateSchema,
|
||||
@@ -260,6 +262,7 @@ type MessageMetadata = {
|
||||
status?: string
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
displayCaptureTruncated?: boolean
|
||||
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
@@ -1154,6 +1157,7 @@ function serializeConversationMessageMetadata(
|
||||
status: message.status,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
displayCaptureTruncated: message.displayCaptureTruncated,
|
||||
contextCompression: message.contextCompression,
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
@@ -1188,6 +1192,9 @@ function appendRecoveredMessageBlock(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (current.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
current.push({
|
||||
id: randomUUID(),
|
||||
type,
|
||||
@@ -1218,6 +1225,9 @@ function upsertRecoveredToolBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'tool' as const, tool }
|
||||
@@ -1257,6 +1267,9 @@ function upsertRecoveredSubagentBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
|
||||
@@ -1289,6 +1302,16 @@ function failedToolRepresentsError(
|
||||
)
|
||||
}
|
||||
|
||||
function canRetainRecoveredActivity(
|
||||
message: ConversationMessage
|
||||
): boolean {
|
||||
return (
|
||||
(message.tools?.length ?? 0) +
|
||||
(message.subagents?.length ?? 0) <
|
||||
maximumConversationRetainedActivities
|
||||
)
|
||||
}
|
||||
|
||||
function reduceRecoveredAgentEvent(
|
||||
message: ConversationMessage,
|
||||
event: RemoteConversationTaskEventInput['event']
|
||||
@@ -1300,10 +1323,18 @@ function reduceRecoveredAgentEvent(
|
||||
maximumRecoveredMessageLength - message.content.length
|
||||
)
|
||||
const delta = event.delta.slice(0, remaining)
|
||||
const blocks = appendRecoveredMessageBlock(
|
||||
message.blocks,
|
||||
'text',
|
||||
delta
|
||||
)
|
||||
next = {
|
||||
...message,
|
||||
content: `${message.content}${delta}`,
|
||||
blocks: appendRecoveredMessageBlock(message.blocks, 'text', delta),
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(delta && message.blocks && blocks === message.blocks),
|
||||
status:
|
||||
event.delta.length > remaining
|
||||
? '回答过长,已在本地截断显示'
|
||||
@@ -1315,14 +1346,18 @@ function reduceRecoveredAgentEvent(
|
||||
0,
|
||||
Math.max(0, maximumRecoveredMessageLength - reasoning.length)
|
||||
)
|
||||
const blocks = appendRecoveredMessageBlock(
|
||||
message.blocks,
|
||||
'reasoning',
|
||||
delta
|
||||
)
|
||||
next = {
|
||||
...message,
|
||||
reasoning: `${reasoning}${delta}`,
|
||||
blocks: appendRecoveredMessageBlock(
|
||||
message.blocks,
|
||||
'reasoning',
|
||||
delta
|
||||
),
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(delta && message.blocks && blocks === message.blocks),
|
||||
status: undefined
|
||||
}
|
||||
} else if (event.type === 'status') {
|
||||
@@ -1370,13 +1405,25 @@ function reduceRecoveredAgentEvent(
|
||||
)
|
||||
if (index >= 0) {
|
||||
tools[index] = tool
|
||||
} else {
|
||||
} else if (!canRetainRecoveredActivity(message)) {
|
||||
next = {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
}
|
||||
if (index < 0 && next === message) {
|
||||
tools.push(tool)
|
||||
}
|
||||
next = {
|
||||
...message,
|
||||
tools,
|
||||
blocks: upsertRecoveredToolBlock(message.blocks, tool)
|
||||
if (next === message) {
|
||||
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
|
||||
next = {
|
||||
...message,
|
||||
tools,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'subagent') {
|
||||
const commonSubagent = {
|
||||
@@ -1401,24 +1448,49 @@ function reduceRecoveredAgentEvent(
|
||||
const index = subagents.findIndex(
|
||||
(candidate) => candidate.childTaskId === event.childTaskId
|
||||
)
|
||||
let retainSubagent = true
|
||||
if (index >= 0) {
|
||||
subagents[index] = subagent
|
||||
} else {
|
||||
subagents.push(subagent)
|
||||
}
|
||||
next = {
|
||||
...message,
|
||||
subagents,
|
||||
tools: event.runtimeCallId
|
||||
? message.tools?.filter(
|
||||
(tool) => tool.callId !== event.runtimeCallId
|
||||
const replacesTool = Boolean(
|
||||
event.runtimeCallId &&
|
||||
message.tools?.some(
|
||||
(tool) => tool.callId === event.runtimeCallId
|
||||
)
|
||||
: message.tools,
|
||||
blocks: upsertRecoveredSubagentBlock(
|
||||
)
|
||||
if (
|
||||
!replacesTool &&
|
||||
!canRetainRecoveredActivity(message)
|
||||
) {
|
||||
retainSubagent = false
|
||||
} else {
|
||||
subagents.push(subagent)
|
||||
}
|
||||
}
|
||||
if (!retainSubagent) {
|
||||
next = {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
} else {
|
||||
const blocks = upsertRecoveredSubagentBlock(
|
||||
message.blocks,
|
||||
event.childTaskId,
|
||||
event.runtimeCallId
|
||||
)
|
||||
next = {
|
||||
...message,
|
||||
subagents,
|
||||
tools: event.runtimeCallId
|
||||
? message.tools?.filter(
|
||||
(tool) => tool.callId !== event.runtimeCallId
|
||||
)
|
||||
: message.tools,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'artifact') {
|
||||
next = {
|
||||
@@ -4600,6 +4672,8 @@ export class AssistantDatabase {
|
||||
status: metadata.status,
|
||||
reasoning: metadata.reasoning,
|
||||
blocks: metadata.blocks,
|
||||
displayCaptureTruncated:
|
||||
metadata.displayCaptureTruncated,
|
||||
contextCompression: metadata.contextCompression,
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
@@ -5002,6 +5076,8 @@ export class AssistantDatabase {
|
||||
status: metadata.status,
|
||||
reasoning: metadata.reasoning,
|
||||
blocks: metadata.blocks,
|
||||
displayCaptureTruncated:
|
||||
metadata.displayCaptureTruncated,
|
||||
contextCompression: metadata.contextCompression,
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
|
||||
+22
-98
@@ -152,10 +152,6 @@ import {
|
||||
import {
|
||||
RemoteEnvironmentOperationStore
|
||||
} from './remote-agent/remote-environment-operation-store'
|
||||
import type {
|
||||
RuntimeSettings,
|
||||
RuntimeSettingsInput
|
||||
} from '../shared/contracts'
|
||||
import {
|
||||
DesktopDiagnostics,
|
||||
type DesktopDiagnosticFailureObserver
|
||||
@@ -364,84 +360,6 @@ async function createEmbeddingProvider(
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeSettingsInputWithEmbeddingSelection(
|
||||
settings: RuntimeSettings,
|
||||
activeEmbeddingConnectionId: string
|
||||
): RuntimeSettingsInput {
|
||||
const configured = settings.configured ?? settings
|
||||
const defaultProfile =
|
||||
settings.modelProfiles.find(
|
||||
(profile) => profile.id === settings.defaultModelProfileId
|
||||
) ?? settings.modelProfiles[0]
|
||||
if (!defaultProfile || !settings.embeddingConnections) {
|
||||
throw new Error('模型连接设置不完整')
|
||||
}
|
||||
return {
|
||||
provider: settings.provider,
|
||||
modelBaseUrl: defaultProfile.baseUrl,
|
||||
modelName: defaultProfile.modelName,
|
||||
modelProtocol: defaultProfile.protocol,
|
||||
modelAuthentication: defaultProfile.authentication,
|
||||
imageGenerationQuality: defaultProfile.imageGenerationQuality,
|
||||
opencodeBaseUrl: configured.opencodeBaseUrl,
|
||||
opencodeEmbedded: settings.opencodeEmbedded,
|
||||
opencodeBinaryPath: configured.opencodeBinaryPath,
|
||||
opencodeConfigPath: configured.opencodeConfigPath,
|
||||
continueBinaryPath: configured.continueBinaryPath,
|
||||
continueConfigPath: configured.continueConfigPath,
|
||||
continueMode: settings.continueMode,
|
||||
subagentSmartRoutingEnabled: settings.subagentSmartRoutingEnabled,
|
||||
knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled,
|
||||
knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl,
|
||||
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
|
||||
knowledgeEmbeddingApiKey: { action: 'keep' },
|
||||
embeddingConnections: settings.embeddingConnections.map(
|
||||
(connection) =>
|
||||
connection.kind === 'builtin'
|
||||
? {
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
kind: connection.kind
|
||||
}
|
||||
: {
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
kind: connection.kind,
|
||||
baseUrl: connection.baseUrl,
|
||||
modelName: connection.modelName,
|
||||
authentication: connection.authentication,
|
||||
apiKey: { action: 'keep' as const }
|
||||
}
|
||||
),
|
||||
activeEmbeddingConnectionId,
|
||||
knowledgeRerankEnabled: settings.knowledgeRerankEnabled ?? false,
|
||||
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint ?? '',
|
||||
knowledgeRerankModel: settings.knowledgeRerankModel ?? '',
|
||||
knowledgeRerankApiKey: { action: 'keep' },
|
||||
contextCompression: settings.contextCompression,
|
||||
workspacePath: configured.workspacePath,
|
||||
apiKey: { action: 'keep' },
|
||||
modelProfiles: settings.modelProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey: { action: 'keep' as const }
|
||||
})),
|
||||
defaultModelProfileId: settings.defaultModelProfileId,
|
||||
opencodeModelSource: configured.opencodeModelSource,
|
||||
continueModelSource: configured.continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
configured.deepseekHarnessModelSource ?? { kind: 'platform' },
|
||||
toolApproval: settings.toolApproval
|
||||
}
|
||||
}
|
||||
|
||||
function createRerankProvider(
|
||||
settings: ResolvedRuntimeSettings
|
||||
): CohereRerankClient | undefined {
|
||||
@@ -1120,6 +1038,22 @@ if (hasSingleInstanceLock) {
|
||||
executionSpace
|
||||
)
|
||||
}
|
||||
const createSelectedStatusRuntime = async (
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntime> => {
|
||||
const resolved = applyRuntimeSelection(
|
||||
await settingsStore.getResolvedSettings(),
|
||||
selection
|
||||
)
|
||||
return resolved.target === 'opencode'
|
||||
? createAgentRuntime(defaultWorkspace, resolved.settings, {
|
||||
bundledRuntimePaths
|
||||
})
|
||||
: createRuntimeWithCapabilities(
|
||||
resolved.settings,
|
||||
resolved.target
|
||||
)
|
||||
}
|
||||
const configuredRuntime = await runStartupPrerequisites({
|
||||
prepareDeepSeekHome: async () => {
|
||||
await mkdir(deepSeekHarnessHome, {
|
||||
@@ -1192,7 +1126,8 @@ if (hasSingleInstanceLock) {
|
||||
createSelectedRuntime,
|
||||
undefined,
|
||||
undefined,
|
||||
observeDesktopFailure
|
||||
observeDesktopFailure,
|
||||
createSelectedStatusRuntime
|
||||
)
|
||||
const contextManager = new ContextManager({
|
||||
parseDocument: documentParsingService.parse
|
||||
@@ -1329,27 +1264,16 @@ if (hasSingleInstanceLock) {
|
||||
const setCurrentEmbeddingConnection = async (
|
||||
connectionId: string
|
||||
): Promise<void> => {
|
||||
const previous = await settingsStore.getPublicSettings()
|
||||
if (previous.activeEmbeddingConnectionId === connectionId) {
|
||||
const selection =
|
||||
await settingsStore.setActiveEmbeddingConnection(connectionId)
|
||||
if (!selection.changed) {
|
||||
return
|
||||
}
|
||||
await settingsStore.update(
|
||||
runtimeSettingsInputWithEmbeddingSelection(
|
||||
previous,
|
||||
connectionId
|
||||
)
|
||||
)
|
||||
try {
|
||||
await reconfigureRuntimes()
|
||||
} catch (activationError) {
|
||||
try {
|
||||
await settingsStore.update(
|
||||
runtimeSettingsInputWithEmbeddingSelection(
|
||||
previous,
|
||||
previous.activeEmbeddingConnectionId ??
|
||||
connectionId
|
||||
)
|
||||
)
|
||||
await selection.restore()
|
||||
await reconfigureRuntimes()
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
|
||||
@@ -35,14 +35,31 @@ describe('packaged release notes', () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
const releaseWithLongScenario = parsed.releases.find(
|
||||
(release) => release.version === packageSource.version
|
||||
)
|
||||
expect(
|
||||
releaseWithLongScenario?.notes['en-US'].features.some(
|
||||
(item) => item.length > 500
|
||||
)
|
||||
).toBe(true)
|
||||
expect(() =>
|
||||
releaseNotesFileSchema.parse({
|
||||
formatVersion: 1,
|
||||
releases: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
releasedAt: '2026-01-01',
|
||||
notes: {
|
||||
'zh-CN': {
|
||||
highlights: [],
|
||||
features: ['详'.repeat(501)],
|
||||
fixes: [],
|
||||
notices: []
|
||||
},
|
||||
'en-US': {
|
||||
highlights: [],
|
||||
features: ['detail '.repeat(100)],
|
||||
fixes: [],
|
||||
notices: []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
).not.toThrow()
|
||||
|
||||
const currentRelease = parsed.releases.find(
|
||||
(release) => release.version === '0.10.1'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { createManagedModelBridge } from './managed-model-bridge'
|
||||
import {
|
||||
createManagedModelBridge,
|
||||
createResolvedModelProfileDigest
|
||||
} from './managed-model-bridge'
|
||||
|
||||
const profile: ResolvedModelProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
@@ -80,4 +83,22 @@ describe('managed Agent prompt model setup', () => {
|
||||
})
|
||||
).toThrow(/usable text model profile/iu)
|
||||
})
|
||||
|
||||
it('binds request customization but not credentials into the profile digest', () => {
|
||||
expect(
|
||||
createResolvedModelProfileDigest({
|
||||
...profile,
|
||||
apiKey: 'different-secret'
|
||||
})
|
||||
).toBe(createResolvedModelProfileDigest(profile))
|
||||
expect(
|
||||
createResolvedModelProfileDigest({
|
||||
...profile,
|
||||
requestBody: {
|
||||
...profile.requestBody,
|
||||
temperature: 0.4
|
||||
}
|
||||
})
|
||||
).not.toBe(createResolvedModelProfileDigest(profile))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { canonicalJson } from '../../shared/agent-protocol/canonical'
|
||||
import {
|
||||
agentPromptModelProfileSchema,
|
||||
modelBridgePolicySchema,
|
||||
type AgentPromptModelProfile,
|
||||
type ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
import { createResolvedModelProfileDigest } from '../agent/remote-model-gateway'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
|
||||
const DEFAULT_MAXIMUM_OUTPUT_TOKENS = 32_000
|
||||
@@ -19,6 +20,32 @@ export type ManagedModelBridge = {
|
||||
profile: AgentPromptModelProfile
|
||||
}
|
||||
|
||||
export function createResolvedModelProfileDigest(
|
||||
profile: ResolvedModelProfile
|
||||
): string {
|
||||
const canonical = canonicalJson({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
contextWindowTokens: profile.contextWindowTokens ?? null,
|
||||
maximumOutputTokens: profile.maximumOutputTokens ?? null,
|
||||
imageGenerationQuality: profile.imageGenerationQuality ?? null,
|
||||
requestHeaders: Object.fromEntries(
|
||||
Object.entries(profile.requestHeaders ?? {}).map(
|
||||
([name, value]) => [name.toLowerCase(), value]
|
||||
)
|
||||
),
|
||||
requestBody: profile.requestBody ?? {}
|
||||
})
|
||||
return `sha256:${createHash('sha256')
|
||||
.update(canonical)
|
||||
.digest('hex')}`
|
||||
}
|
||||
|
||||
export function createManagedModelBridge(options: {
|
||||
profile: ResolvedModelProfile
|
||||
}): ManagedModelBridge {
|
||||
|
||||
@@ -1301,6 +1301,172 @@ describe('RuntimeSettingsStore', () => {
|
||||
).toBe(21)
|
||||
})
|
||||
|
||||
it('changes the active embedding connection without changing model profiles', async () => {
|
||||
const { store } = await createStore()
|
||||
const profileId = '00000000-0000-4000-8000-000000000091'
|
||||
const connectionId = '00000000-0000-4000-8000-000000000092'
|
||||
await store.update(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'Tenant model',
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
modelName: 'tenant-model',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: false,
|
||||
contextWindowTokens: 200_000,
|
||||
maximumOutputTokens: 48_000,
|
||||
imageGenerationQuality: 'auto',
|
||||
requestHeaders: { 'x-tenant-id': 'tenant-a' },
|
||||
requestBody: { temperature: 0.2 },
|
||||
apiKey: { action: 'replace', value: 'tenant-secret' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
embeddingConnections: [
|
||||
{
|
||||
id: builtinEmbeddingConnectionId,
|
||||
name: 'GoodBuddy 内置向量模型',
|
||||
kind: 'builtin'
|
||||
},
|
||||
{
|
||||
id: connectionId,
|
||||
name: '团队向量模型',
|
||||
kind: 'openai-compatible',
|
||||
baseUrl: 'https://team-vectors.example/v1/embeddings',
|
||||
modelName: 'team/embed-v2',
|
||||
authentication: 'none',
|
||||
apiKey: { action: 'keep' }
|
||||
}
|
||||
],
|
||||
activeEmbeddingConnectionId: builtinEmbeddingConnectionId
|
||||
})
|
||||
)
|
||||
const selection =
|
||||
await store.setActiveEmbeddingConnection(connectionId)
|
||||
expect(selection).toMatchObject({
|
||||
changed: true,
|
||||
previousConnectionId: builtinEmbeddingConnectionId
|
||||
})
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
activeEmbeddingConnectionId: connectionId,
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
id: profileId,
|
||||
contextWindowTokens: 200_000,
|
||||
maximumOutputTokens: 48_000,
|
||||
requestHeaders: { 'x-tenant-id': 'tenant-a' },
|
||||
requestBody: { temperature: 0.2 },
|
||||
apiKey: 'tenant-secret'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves configured model fields when embedding changes under an environment override', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const profileId = '00000000-0000-4000-8000-000000000093'
|
||||
await store.update(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'Stored OpenAI model',
|
||||
baseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
supportsImageInput: false,
|
||||
imageGenerationQuality: 'auto',
|
||||
requestHeaders: { 'x-tenant-id': 'tenant-a' },
|
||||
requestBody: { temperature: 0.2 },
|
||||
apiKey: { action: 'keep' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId
|
||||
})
|
||||
)
|
||||
const environmentStore = new RuntimeSettingsStore(
|
||||
filePath,
|
||||
cipher,
|
||||
{ GOODBUDDY_BIGTOKEN_API_KEY: 'environment-key' }
|
||||
)
|
||||
const effective = await environmentStore.getPublicSettings()
|
||||
expect(effective.modelProfiles[0]).toMatchObject({
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
credentialSource: 'environment'
|
||||
})
|
||||
|
||||
await environmentStore.setActiveEmbeddingConnection(
|
||||
legacyEmbeddingConnectionId
|
||||
)
|
||||
|
||||
const withoutEnvironment = new RuntimeSettingsStore(
|
||||
filePath,
|
||||
cipher,
|
||||
{}
|
||||
)
|
||||
await expect(
|
||||
withoutEnvironment.getResolvedSettings()
|
||||
).resolves.toMatchObject({
|
||||
activeEmbeddingConnectionId: legacyEmbeddingConnectionId,
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
id: profileId,
|
||||
baseUrl: 'https://stored.example/v1',
|
||||
modelName: 'stored-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
requestHeaders: { 'x-tenant-id': 'tenant-a' },
|
||||
requestBody: { temperature: 0.2 }
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes embedding selection with full settings updates', async () => {
|
||||
const { store } = await createStore()
|
||||
const settingsUpdate = store.update(
|
||||
settings({ modelName: 'updated-after-selection-read' })
|
||||
)
|
||||
const selectionUpdate =
|
||||
store.setActiveEmbeddingConnection(
|
||||
legacyEmbeddingConnectionId
|
||||
)
|
||||
|
||||
await Promise.all([settingsUpdate, selectionUpdate])
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelName: 'updated-after-selection-read',
|
||||
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back only the embedding selection after later settings updates', async () => {
|
||||
const { store } = await createStore()
|
||||
const selection =
|
||||
await store.setActiveEmbeddingConnection(
|
||||
legacyEmbeddingConnectionId
|
||||
)
|
||||
await store.update(
|
||||
settings({
|
||||
modelName: 'saved-while-embedding-activated',
|
||||
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
|
||||
})
|
||||
)
|
||||
|
||||
await selection.restore()
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelName: 'saved-while-embedding-activated',
|
||||
activeEmbeddingConnectionId: builtinEmbeddingConnectionId
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates version 13 with reranking disabled by default', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
|
||||
@@ -305,6 +305,11 @@ export type RuntimeSettingsRollback = {
|
||||
publicSettings: RuntimeSettings
|
||||
restore(): Promise<RuntimeSettings>
|
||||
}
|
||||
export type ActiveEmbeddingConnectionUpdate = {
|
||||
changed: boolean
|
||||
previousConnectionId: string
|
||||
restore(): Promise<RuntimeSettings>
|
||||
}
|
||||
type Version17StoredSettings = z.infer<
|
||||
typeof version17StoredSettingsSchema
|
||||
>
|
||||
@@ -1998,6 +2003,85 @@ export class RuntimeSettingsStore {
|
||||
return operation
|
||||
}
|
||||
|
||||
setActiveEmbeddingConnection(
|
||||
connectionId: string
|
||||
): Promise<ActiveEmbeddingConnectionUpdate> {
|
||||
let result: ActiveEmbeddingConnectionUpdate | undefined
|
||||
const operation = this.updateQueue.then(async () => {
|
||||
const current = await this.load()
|
||||
const previousConnectionId =
|
||||
current.activeEmbeddingConnectionId
|
||||
const changed = previousConnectionId !== connectionId
|
||||
if (changed) {
|
||||
const next = this.withActiveEmbeddingConnection(
|
||||
current,
|
||||
connectionId
|
||||
)
|
||||
await writeJsonFileAtomically(this.filePath, next)
|
||||
this.settings = next
|
||||
this.loadWarnings = []
|
||||
}
|
||||
result = {
|
||||
changed,
|
||||
previousConnectionId,
|
||||
restore: () =>
|
||||
this.restoreActiveEmbeddingConnection(
|
||||
connectionId,
|
||||
previousConnectionId
|
||||
)
|
||||
}
|
||||
})
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return operation.then(() => result!)
|
||||
}
|
||||
|
||||
private restoreActiveEmbeddingConnection(
|
||||
expectedConnectionId: string,
|
||||
previousConnectionId: string
|
||||
): Promise<RuntimeSettings> {
|
||||
const operation = this.updateQueue.then(async () => {
|
||||
const current = await this.load()
|
||||
if (
|
||||
current.activeEmbeddingConnectionId !== expectedConnectionId
|
||||
) {
|
||||
return this.toPublicSettings(current)
|
||||
}
|
||||
const restored = this.withActiveEmbeddingConnection(
|
||||
current,
|
||||
previousConnectionId
|
||||
)
|
||||
await writeJsonFileAtomically(this.filePath, restored)
|
||||
this.settings = restored
|
||||
this.loadWarnings = []
|
||||
return this.toPublicSettings(restored)
|
||||
})
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return operation
|
||||
}
|
||||
|
||||
private withActiveEmbeddingConnection(
|
||||
current: StoredSettings,
|
||||
connectionId: string
|
||||
): StoredSettings {
|
||||
const connectionExists = current.embeddingConnections.some(
|
||||
(candidate) => candidate.id === connectionId
|
||||
)
|
||||
if (!connectionExists) {
|
||||
throw new Error('当前向量连接不存在')
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
activeEmbeddingConnectionId: connectionId
|
||||
}
|
||||
}
|
||||
|
||||
private async performUpdate(
|
||||
input: RuntimeSettingsInput
|
||||
): Promise<RuntimeSettings> {
|
||||
|
||||
@@ -3694,7 +3694,7 @@ describe('App', () => {
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
|
||||
})
|
||||
|
||||
it('keeps more than 100 tool activities in one response', async () => {
|
||||
it('keeps a bounded display copy without stopping a long tool chain', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
@@ -3708,7 +3708,7 @@ describe('App', () => {
|
||||
}
|
||||
|
||||
act(() => {
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
for (let index = 0; index < 501; index += 1) {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
@@ -3721,7 +3721,12 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('region', { name: '工具执行,共 101 项' })
|
||||
screen.getByRole('region', { name: '工具执行,共 500 项' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
+82
-17
@@ -128,6 +128,8 @@ import {
|
||||
conversationMessageBlocksSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
interactiveWorkModes,
|
||||
maximumConversationMessageBlocks,
|
||||
maximumConversationRetainedActivities,
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -636,6 +638,9 @@ function appendMessageContentBlock(
|
||||
}
|
||||
return current
|
||||
}
|
||||
if (current.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
current.push({
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
@@ -665,6 +670,9 @@ function upsertMessageToolBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
@@ -710,6 +718,9 @@ function upsertMessageSubagentBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
@@ -738,6 +749,14 @@ function terminalizeMessageToolBlocks(
|
||||
)
|
||||
}
|
||||
|
||||
function canRetainMessageActivity(message: Message): boolean {
|
||||
return (
|
||||
(message.tools?.length ?? 0) +
|
||||
(message.subagents?.length ?? 0) <
|
||||
maximumConversationRetainedActivities
|
||||
)
|
||||
}
|
||||
|
||||
function isErrorRepresentedByFailedTool(
|
||||
tools: ToolActivity[] | undefined,
|
||||
errorMessage: string
|
||||
@@ -1373,6 +1392,7 @@ function toConversationMessage(message: Message): ConversationMessage {
|
||||
content: message.content,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
displayCaptureTruncated: message.displayCaptureTruncated,
|
||||
createdAt: message.createdAt,
|
||||
state: message.state,
|
||||
status: message.status,
|
||||
@@ -4316,14 +4336,22 @@ function App(): React.JSX.Element {
|
||||
maxMessageContentLength - message.content.length
|
||||
)
|
||||
const acceptedDelta = event.delta.slice(0, remaining)
|
||||
const blocks = appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'text',
|
||||
acceptedDelta
|
||||
)
|
||||
return {
|
||||
...message,
|
||||
content: `${message.content}${acceptedDelta}`,
|
||||
blocks: appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'text',
|
||||
acceptedDelta
|
||||
),
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(
|
||||
acceptedDelta &&
|
||||
message.blocks &&
|
||||
blocks === message.blocks
|
||||
),
|
||||
status:
|
||||
event.delta.length > remaining
|
||||
? tRef.current('chat.status.responseTruncated')
|
||||
@@ -4340,15 +4368,23 @@ function App(): React.JSX.Element {
|
||||
maxMessageContentLength - currentReasoning.length
|
||||
)
|
||||
)
|
||||
const blocks = appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'reasoning',
|
||||
acceptedDelta
|
||||
)
|
||||
return {
|
||||
...message,
|
||||
reasoning: `${currentReasoning}${acceptedDelta}`,
|
||||
status: undefined,
|
||||
blocks: appendMessageContentBlock(
|
||||
message.blocks,
|
||||
'reasoning',
|
||||
acceptedDelta
|
||||
)
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(
|
||||
acceptedDelta &&
|
||||
message.blocks &&
|
||||
blocks === message.blocks
|
||||
)
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'context-metrics') {
|
||||
@@ -4486,13 +4522,23 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
if (index >= 0) {
|
||||
tools[index] = tool
|
||||
} else {
|
||||
} else if (!canRetainMessageActivity(message)) {
|
||||
return {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
}
|
||||
if (index < 0) {
|
||||
tools.push(tool)
|
||||
}
|
||||
const blocks = upsertMessageToolBlock(message.blocks, tool)
|
||||
return {
|
||||
...message,
|
||||
tools,
|
||||
blocks: upsertMessageToolBlock(message.blocks, tool)
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'subagent') {
|
||||
@@ -4623,9 +4669,29 @@ function App(): React.JSX.Element {
|
||||
if (index >= 0) {
|
||||
subagents[index] = subagent
|
||||
} else {
|
||||
const replacesTool = Boolean(
|
||||
event.runtimeCallId &&
|
||||
message.tools?.some(
|
||||
(tool) => tool.callId === event.runtimeCallId
|
||||
)
|
||||
)
|
||||
if (
|
||||
!replacesTool &&
|
||||
!canRetainMessageActivity(message)
|
||||
) {
|
||||
return {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
}
|
||||
subagents.push(subagent)
|
||||
}
|
||||
const runtimeCallId = event.runtimeCallId
|
||||
const blocks = upsertMessageSubagentBlock(
|
||||
message.blocks,
|
||||
event.childTaskId,
|
||||
runtimeCallId
|
||||
)
|
||||
return {
|
||||
...message,
|
||||
subagents,
|
||||
@@ -4634,11 +4700,10 @@ function App(): React.JSX.Element {
|
||||
(tool) => tool.callId !== runtimeCallId
|
||||
)
|
||||
: message.tools,
|
||||
blocks: upsertMessageSubagentBlock(
|
||||
message.blocks,
|
||||
event.childTaskId,
|
||||
runtimeCallId
|
||||
)
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'approval') {
|
||||
|
||||
@@ -52,6 +52,7 @@ export type Message = {
|
||||
content: string
|
||||
reasoning?: string
|
||||
blocks?: ConversationMessageBlock[]
|
||||
displayCaptureTruncated?: boolean
|
||||
createdAt: number
|
||||
state: 'streaming' | 'complete' | 'error'
|
||||
status?: string
|
||||
@@ -941,6 +942,19 @@ function ChatMessageRowView({
|
||||
value={message.question}
|
||||
/>
|
||||
)}
|
||||
{message.displayCaptureTruncated && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="message__status"
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="message__status-dot"
|
||||
/>
|
||||
{t('chat.status.displayCaptureTruncated')}
|
||||
</div>
|
||||
)}
|
||||
{message.status && (
|
||||
<div
|
||||
aria-atomic="true"
|
||||
|
||||
@@ -620,6 +620,8 @@ export function McpSettingsSection({
|
||||
? t('mcp.browser.unsupported')
|
||||
: !featureAvailable
|
||||
? t('mcp.builtin.serverSummaryDisabled')
|
||||
: server.id === 'builtin-browser'
|
||||
? t('mcp.builtin.serverSummaryExecuteOnly')
|
||||
: server.access === 'mixed'
|
||||
? t('mcp.builtin.serverSummaryMixed')
|
||||
: t('mcp.builtin.serverSummaryReadOnly')}
|
||||
|
||||
@@ -1862,6 +1862,43 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks draft edits while settings are being saved', async () => {
|
||||
let finishSave!: (settings: RuntimeSettings) => void
|
||||
updateRuntime.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<RuntimeSettings>((resolve) => {
|
||||
finishSave = resolve
|
||||
})
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
const settingsBody = screen
|
||||
.getByRole('tabpanel')
|
||||
.closest('.settings-panel__body')
|
||||
await waitFor(() => {
|
||||
expect(settingsBody).toHaveAttribute('inert')
|
||||
expect(settingsBody).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
|
||||
act(() => finishSave(runtimeSettings))
|
||||
await waitFor(() => {
|
||||
expect(settingsBody).not.toHaveAttribute('inert')
|
||||
expect(settingsBody).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
it('submits configured model values while environment values are effective', async () => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
@@ -5123,6 +5160,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
true
|
||||
)
|
||||
)
|
||||
expect(
|
||||
within(browserBuiltinCard!).getByText('仅 Execute')
|
||||
).toBeInTheDocument()
|
||||
const builtinBrowserToggle = screen.getByRole('button', {
|
||||
name: '展开服务器 内置浏览器'
|
||||
})
|
||||
|
||||
@@ -2028,7 +2028,11 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-panel__body">
|
||||
<div
|
||||
aria-busy={saving || testing}
|
||||
className="settings-panel__body"
|
||||
inert={saving || testing ? true : undefined}
|
||||
>
|
||||
<nav
|
||||
aria-label={t('center.categoriesAriaLabel')}
|
||||
aria-orientation="vertical"
|
||||
|
||||
@@ -336,6 +336,8 @@ export const app = {
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
status: {
|
||||
responseTruncated: 'The response was too long and was truncated locally',
|
||||
displayCaptureTruncated:
|
||||
'Later activity details were not retained locally; the Runtime task continued',
|
||||
savingImage: 'Image generated; saving the result',
|
||||
taskCompleted: 'Task completed',
|
||||
taskFailed: 'Task failed',
|
||||
|
||||
@@ -211,6 +211,7 @@ export const integrations = {
|
||||
runtimeAssignmentUnsupportedAriaLabel:
|
||||
'{{name}} cannot be assigned to {{runtime}} because this runtime does not support built-in MCP',
|
||||
unsupportedSuffix: ' (not supported yet)',
|
||||
serverSummaryExecuteOnly: 'Execute only',
|
||||
serverSummaryMixed: 'Read and write by mode',
|
||||
serverSummaryReadOnly: 'Read-only',
|
||||
serverSummaryDisabled: 'Disabled · Enable Magic Notes first',
|
||||
|
||||
@@ -319,6 +319,8 @@ export const app = {
|
||||
scrollToBottom: '到底部',
|
||||
status: {
|
||||
responseTruncated: '回答过长,已在本地截断显示',
|
||||
displayCaptureTruncated:
|
||||
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行',
|
||||
savingImage: '图片已生成,正在保存结果',
|
||||
taskCompleted: '任务执行完成',
|
||||
taskFailed: '任务执行失败',
|
||||
|
||||
@@ -195,6 +195,7 @@ export const integrations = {
|
||||
runtimeAssignmentUnsupportedAriaLabel:
|
||||
'{{name}} 无法分配给 {{runtime}},当前 Runtime 不支持内置 MCP',
|
||||
unsupportedSuffix: '(暂不支持)',
|
||||
serverSummaryExecuteOnly: '仅 Execute',
|
||||
serverSummaryMixed: '按模式读写',
|
||||
serverSummaryReadOnly: '只读',
|
||||
serverSummaryDisabled: '未启用 · 需要开启魔法笔记',
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('project execution space contracts', () => {
|
||||
})
|
||||
|
||||
describe('conversation activity contracts', () => {
|
||||
it('accepts more than 100 tool and subagent activities', () => {
|
||||
it('accepts long activity runs within the retained display bounds', () => {
|
||||
const tools = Array.from({ length: 101 }, (_, index) => ({
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
@@ -203,6 +203,7 @@ describe('conversation activity contracts', () => {
|
||||
})),
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
displayCaptureTruncated: true,
|
||||
tools,
|
||||
subagents
|
||||
})
|
||||
@@ -217,6 +218,37 @@ describe('conversation activity contracts', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects conversation activity and block arrays beyond display bounds', () => {
|
||||
const baseMessage = {
|
||||
id: '00000000-0000-4000-8000-000000000999',
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
createdAt: 1,
|
||||
state: 'complete' as const
|
||||
}
|
||||
expect(
|
||||
conversationMessageSchema.safeParse({
|
||||
...baseMessage,
|
||||
tools: Array.from({ length: 501 }, (_, index) => ({
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
state: 'completed' as const,
|
||||
summary: `Tool ${index + 1}`
|
||||
}))
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
conversationMessageSchema.safeParse({
|
||||
...baseMessage,
|
||||
blocks: Array.from({ length: 2_001 }, (_, index) => ({
|
||||
id: `10000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
|
||||
type: 'text' as const,
|
||||
content: 'x'
|
||||
}))
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation branch contracts', () => {
|
||||
|
||||
@@ -182,9 +182,12 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const conversationMessageBlocksSchema = z.array(
|
||||
conversationMessageBlockSchema
|
||||
)
|
||||
export const maximumConversationRetainedActivities = 500
|
||||
export const maximumConversationMessageBlocks = 2_000
|
||||
|
||||
export const conversationMessageBlocksSchema = z
|
||||
.array(conversationMessageBlockSchema)
|
||||
.max(maximumConversationMessageBlocks)
|
||||
|
||||
export type ConversationToolActivity = z.infer<
|
||||
typeof conversationToolActivitySchema
|
||||
@@ -265,6 +268,7 @@ export const conversationMessageSchema = z
|
||||
content: z.string().max(1_000_000),
|
||||
reasoning: z.string().optional(),
|
||||
blocks: conversationMessageBlocksSchema.optional(),
|
||||
displayCaptureTruncated: z.boolean().optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
state: z.enum(['streaming', 'complete', 'error']),
|
||||
status: z.string().max(4_000).optional(),
|
||||
@@ -274,8 +278,14 @@ export const conversationMessageSchema = z
|
||||
.array(conversationContextCompressionMarkerSchema)
|
||||
.max(2)
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).optional(),
|
||||
subagents: z.array(conversationSubagentActivitySchema).optional(),
|
||||
tools: z
|
||||
.array(conversationToolActivitySchema)
|
||||
.max(maximumConversationRetainedActivities)
|
||||
.optional(),
|
||||
subagents: z
|
||||
.array(conversationSubagentActivitySchema)
|
||||
.max(maximumConversationRetainedActivities)
|
||||
.optional(),
|
||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||
sourceReferences: z
|
||||
.array(
|
||||
|
||||
@@ -607,7 +607,6 @@ export {
|
||||
MODEL_REQUEST_CUSTOMIZATION_LIMITS,
|
||||
modelRequestBodySchema,
|
||||
modelRequestHeadersSchema,
|
||||
canonicalModelRequestHeaders,
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders,
|
||||
type ModelRequestBody,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
completeModelCallEvidenceSchema,
|
||||
failModelCallEvidenceSchema,
|
||||
modelCallDispatchMetadataSchema,
|
||||
modelCallRecordSchema,
|
||||
prepareModelCallSchema
|
||||
} from './model-call-operation-contracts'
|
||||
|
||||
const identity = {
|
||||
callOperationId: 'call-1',
|
||||
requestId: 'request-1',
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0,
|
||||
roundIndex: 0,
|
||||
provider: 'provider',
|
||||
profile: 'default',
|
||||
model: 'model',
|
||||
protocol: 'responses-v1'
|
||||
}
|
||||
const policy = {
|
||||
modelProfileDigest: `sha256:${'b'.repeat(64)}`
|
||||
}
|
||||
|
||||
describe('model call operation contracts', () => {
|
||||
it('rejects secret or payload fields at every persistence boundary', () => {
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
expect(() =>
|
||||
prepareModelCallSchema.parse({
|
||||
identity,
|
||||
requestDigest: digest,
|
||||
...policy,
|
||||
prompt: 'secret prompt'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareModelCallSchema.parse({
|
||||
identity: { ...identity, apiKey: 'secret-key' },
|
||||
requestDigest: digest,
|
||||
...policy
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
modelCallDispatchMetadataSchema.parse({
|
||||
providerIdempotencyKey: 'safe-id',
|
||||
requestBody: { messages: [] }
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
completeModelCallEvidenceSchema.parse({
|
||||
status: 'completed',
|
||||
providerResponseId: 'response-1',
|
||||
responseBody: 'secret response'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
failModelCallEvidenceSchema.parse({
|
||||
status: 'failed-definitive',
|
||||
error: {
|
||||
code: 'authentication',
|
||||
retryable: false,
|
||||
message: 'credential was secret-value'
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('bounds provider identifiers and safe terminal metadata', () => {
|
||||
expect(() =>
|
||||
modelCallDispatchMetadataSchema.parse({
|
||||
providerRequestId: 'x'.repeat(257)
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
completeModelCallEvidenceSchema.parse({
|
||||
status: 'completed',
|
||||
result: { finishReason: 'x'.repeat(129) }
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('tags terminal evidence and enforces record state consistency', () => {
|
||||
const base = {
|
||||
identity,
|
||||
requestDigest: `sha256:${'a'.repeat(64)}`,
|
||||
...policy,
|
||||
preparedAt: 1,
|
||||
updatedAt: 3,
|
||||
dispatchedAt: 2,
|
||||
dispatchMetadata: {}
|
||||
}
|
||||
expect(() =>
|
||||
modelCallRecordSchema.parse({
|
||||
...base,
|
||||
status: 'completed',
|
||||
terminalAt: 3,
|
||||
terminalEvidence: {
|
||||
status: 'failed-definitive',
|
||||
error: { code: 'authentication', retryable: false }
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
modelCallRecordSchema.parse({
|
||||
...base,
|
||||
status: 'prepared'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
modelCallRecordSchema.parse({
|
||||
...base,
|
||||
status: 'dispatched',
|
||||
updatedAt: 1
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects prompt-wide quota fields', () => {
|
||||
const request = {
|
||||
identity,
|
||||
requestDigest: `sha256:${'a'.repeat(64)}`,
|
||||
...policy
|
||||
}
|
||||
expect(prepareModelCallSchema.parse(request)).toEqual(request)
|
||||
expect(() =>
|
||||
prepareModelCallSchema.parse({
|
||||
...request,
|
||||
maximumOutputTokens: 65_537,
|
||||
maximumModelCalls: 100_000,
|
||||
maximumTotalOutputTokens: 1_000_000_000
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,315 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
agentIdentifierSchema,
|
||||
sha256DigestSchema,
|
||||
utf8StringSchema
|
||||
} from './agent-protocol/contracts'
|
||||
|
||||
const countSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
const positiveCountSchema = countSchema.positive()
|
||||
const timestampSchema = countSchema
|
||||
const providerLabelSchema = utf8StringSchema(256, {
|
||||
minimumBytes: 1,
|
||||
label: 'Model provider label'
|
||||
}).refine((value) => value.trim() === value, 'Label must not have outer whitespace')
|
||||
const providerIdentifierSchema = utf8StringSchema(256, {
|
||||
minimumBytes: 1,
|
||||
label: 'Provider identifier'
|
||||
}).refine(
|
||||
(value) => value.trim() === value,
|
||||
'Provider identifier must not have outer whitespace'
|
||||
)
|
||||
|
||||
export const modelCallDigestSchema = sha256DigestSchema
|
||||
|
||||
export const modelCallStatusSchema = z.enum([
|
||||
'prepared',
|
||||
'dispatched',
|
||||
'completed',
|
||||
'failed-definitive',
|
||||
'outcome-unknown'
|
||||
])
|
||||
export type ModelCallStatus = z.infer<typeof modelCallStatusSchema>
|
||||
|
||||
export const modelCallIdentitySchema = z
|
||||
.object({
|
||||
callOperationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema,
|
||||
bindingId: agentIdentifierSchema,
|
||||
promptOperationId: agentIdentifierSchema,
|
||||
promptSequence: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0),
|
||||
roundIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER),
|
||||
provider: providerLabelSchema,
|
||||
profile: providerLabelSchema,
|
||||
model: providerLabelSchema,
|
||||
protocol: providerLabelSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallIdentity = z.infer<typeof modelCallIdentitySchema>
|
||||
|
||||
export const prepareModelCallSchema = z
|
||||
.object({
|
||||
identity: modelCallIdentitySchema,
|
||||
requestDigest: modelCallDigestSchema,
|
||||
modelProfileDigest: modelCallDigestSchema
|
||||
})
|
||||
.strict()
|
||||
export type PrepareModelCall = z.infer<typeof prepareModelCallSchema>
|
||||
|
||||
export const modelCallDispatchMetadataSchema = z
|
||||
.object({
|
||||
providerIdempotencyKey: providerIdentifierSchema.optional(),
|
||||
providerRequestId: providerIdentifierSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallDispatchMetadata = z.infer<
|
||||
typeof modelCallDispatchMetadataSchema
|
||||
>
|
||||
|
||||
export const modelCallUsageMetadataSchema = z
|
||||
.object({
|
||||
inputTokens: countSchema.optional(),
|
||||
outputTokens: countSchema.optional(),
|
||||
cachedInputTokens: countSchema.optional(),
|
||||
reasoningTokens: countSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallUsageMetadata = z.infer<
|
||||
typeof modelCallUsageMetadataSchema
|
||||
>
|
||||
|
||||
export const modelCallResultMetadataSchema = z
|
||||
.object({
|
||||
finishReason: agentIdentifierSchema.optional(),
|
||||
outputDigest: modelCallDigestSchema.optional(),
|
||||
artifactCount: countSchema.max(1_000).optional()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallResultMetadata = z.infer<
|
||||
typeof modelCallResultMetadataSchema
|
||||
>
|
||||
|
||||
export const modelCallErrorMetadataSchema = z
|
||||
.object({
|
||||
code: agentIdentifierSchema,
|
||||
retryable: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallErrorMetadata = z.infer<
|
||||
typeof modelCallErrorMetadataSchema
|
||||
>
|
||||
|
||||
export const completeModelCallEvidenceSchema = z
|
||||
.object({
|
||||
status: z.literal('completed'),
|
||||
providerRequestId: providerIdentifierSchema.optional(),
|
||||
providerResponseId: providerIdentifierSchema.optional(),
|
||||
result: modelCallResultMetadataSchema.optional(),
|
||||
usage: modelCallUsageMetadataSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type CompleteModelCallEvidence = z.infer<
|
||||
typeof completeModelCallEvidenceSchema
|
||||
>
|
||||
|
||||
export const failModelCallEvidenceSchema = z
|
||||
.object({
|
||||
status: z.literal('failed-definitive'),
|
||||
providerRequestId: providerIdentifierSchema.optional(),
|
||||
providerResponseId: providerIdentifierSchema.optional(),
|
||||
error: modelCallErrorMetadataSchema,
|
||||
usage: modelCallUsageMetadataSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type FailModelCallEvidence = z.infer<
|
||||
typeof failModelCallEvidenceSchema
|
||||
>
|
||||
|
||||
export const unknownModelCallEvidenceSchema = z
|
||||
.object({
|
||||
status: z.literal('outcome-unknown'),
|
||||
providerRequestId: providerIdentifierSchema.optional(),
|
||||
reason: modelCallErrorMetadataSchema
|
||||
})
|
||||
.strict()
|
||||
export type UnknownModelCallEvidence = z.infer<
|
||||
typeof unknownModelCallEvidenceSchema
|
||||
>
|
||||
|
||||
export const modelCallTerminalEvidenceSchema = z.discriminatedUnion('status', [
|
||||
completeModelCallEvidenceSchema,
|
||||
failModelCallEvidenceSchema,
|
||||
unknownModelCallEvidenceSchema
|
||||
])
|
||||
export type ModelCallTerminalEvidence = z.infer<
|
||||
typeof modelCallTerminalEvidenceSchema
|
||||
>
|
||||
|
||||
export const modelCallRecordSchema = z
|
||||
.object({
|
||||
identity: modelCallIdentitySchema,
|
||||
requestDigest: modelCallDigestSchema,
|
||||
modelProfileDigest: modelCallDigestSchema,
|
||||
status: modelCallStatusSchema,
|
||||
dispatchMetadata: modelCallDispatchMetadataSchema.optional(),
|
||||
terminalEvidence: modelCallTerminalEvidenceSchema.optional(),
|
||||
preparedAt: timestampSchema,
|
||||
updatedAt: timestampSchema,
|
||||
dispatchedAt: timestampSchema.optional(),
|
||||
terminalAt: timestampSchema.optional(),
|
||||
responseDeliveredAt: timestampSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((record, context) => {
|
||||
if (record.updatedAt < record.preparedAt) {
|
||||
addRecordIssue(context, ['updatedAt'], 'updatedAt precedes preparedAt')
|
||||
}
|
||||
const hasDispatch =
|
||||
record.dispatchMetadata !== undefined && record.dispatchedAt !== undefined
|
||||
if (
|
||||
(record.dispatchMetadata === undefined) !==
|
||||
(record.dispatchedAt === undefined)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['dispatchMetadata'],
|
||||
'dispatch metadata and timestamp must appear together'
|
||||
)
|
||||
}
|
||||
if (
|
||||
record.dispatchedAt !== undefined &&
|
||||
(record.dispatchedAt < record.preparedAt ||
|
||||
record.updatedAt < record.dispatchedAt)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['dispatchedAt'],
|
||||
'dispatchedAt is outside the record timestamp range'
|
||||
)
|
||||
}
|
||||
if (record.status === 'prepared') {
|
||||
if (
|
||||
hasDispatch ||
|
||||
record.terminalEvidence ||
|
||||
record.terminalAt !== undefined ||
|
||||
record.responseDeliveredAt !== undefined
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['status'],
|
||||
'prepared records cannot contain dispatch or terminal state'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!hasDispatch) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['dispatchMetadata'],
|
||||
'dispatched and terminal records require dispatch state'
|
||||
)
|
||||
}
|
||||
if (record.status === 'dispatched') {
|
||||
if (
|
||||
record.terminalEvidence ||
|
||||
record.terminalAt !== undefined ||
|
||||
record.responseDeliveredAt !== undefined
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['status'],
|
||||
'dispatched records cannot contain terminal state'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
record.terminalEvidence?.status !== record.status ||
|
||||
record.terminalAt === undefined
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['terminalEvidence'],
|
||||
'terminal evidence and timestamp must match record status'
|
||||
)
|
||||
}
|
||||
if (
|
||||
record.terminalAt !== undefined &&
|
||||
(record.dispatchedAt === undefined ||
|
||||
record.terminalAt < record.dispatchedAt ||
|
||||
record.updatedAt < record.terminalAt)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['terminalAt'],
|
||||
'terminalAt is outside the record timestamp range'
|
||||
)
|
||||
}
|
||||
if (
|
||||
record.responseDeliveredAt !== undefined &&
|
||||
(record.status !== 'completed' ||
|
||||
record.terminalAt === undefined ||
|
||||
record.responseDeliveredAt < record.terminalAt ||
|
||||
record.updatedAt < record.responseDeliveredAt)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['responseDeliveredAt'],
|
||||
'responseDeliveredAt requires a completed provider response'
|
||||
)
|
||||
}
|
||||
})
|
||||
export type ModelCallRecord = z.infer<typeof modelCallRecordSchema>
|
||||
|
||||
export const modelCallOperationCursorSchema = z
|
||||
.object({
|
||||
updatedAt: timestampSchema,
|
||||
callOperationId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallOperationCursor = z.infer<
|
||||
typeof modelCallOperationCursorSchema
|
||||
>
|
||||
|
||||
export const modelCallDeliveryFilterSchema = z.enum([
|
||||
'any',
|
||||
'pending-response',
|
||||
'delivered'
|
||||
])
|
||||
export type ModelCallDeliveryFilter = z.infer<
|
||||
typeof modelCallDeliveryFilterSchema
|
||||
>
|
||||
|
||||
export const modelCallListOptionsSchema = z
|
||||
.object({
|
||||
statuses: z.array(modelCallStatusSchema).min(1).max(5).optional(),
|
||||
delivery: modelCallDeliveryFilterSchema.default('any'),
|
||||
cursor: modelCallOperationCursorSchema.optional(),
|
||||
limit: positiveCountSchema.max(1_000).default(100)
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallListOptions = z.input<
|
||||
typeof modelCallListOptionsSchema
|
||||
>
|
||||
|
||||
export type ModelCallOperationPage = {
|
||||
records: ModelCallRecord[]
|
||||
nextCursor?: ModelCallOperationCursor
|
||||
}
|
||||
|
||||
function addRecordIssue(
|
||||
context: z.RefinementCtx,
|
||||
path: Array<string | number>,
|
||||
message: string
|
||||
): void {
|
||||
context.addIssue({ code: 'custom', path, message })
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canonicalModelRequestHeaders,
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders,
|
||||
modelRequestBodySchema,
|
||||
@@ -29,6 +28,11 @@ describe('model request customization', () => {
|
||||
'x-tenant': 'two'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
modelRequestHeadersSchema.parse({
|
||||
'x-tenant': '租户甲'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('accepts bounded JSON bodies and rejects reserved top-level fields', () => {
|
||||
@@ -83,12 +87,6 @@ describe('model request customization', () => {
|
||||
'content-type': 'application/json',
|
||||
'x-tenant': 'custom'
|
||||
})
|
||||
expect(canonicalModelRequestHeaders(headers)).toEqual({
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'x-tenant': 'custom'
|
||||
})
|
||||
|
||||
expect(
|
||||
mergeModelRequestBody(
|
||||
{
|
||||
|
||||
@@ -51,10 +51,10 @@ export type ModelRequestJsonValue = CanonicalJsonValue
|
||||
|
||||
export type ModelRequestBody = Record<string, ModelRequestJsonValue>
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
function hasInvalidHeaderValueCharacter(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 31 || code === 127
|
||||
return code <= 31 || code === 127 || code > 255
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ function isModelRequestHeaders(value: unknown): value is ModelRequestHeaders {
|
||||
typeof headerValue !== 'string' ||
|
||||
headerValue.length >
|
||||
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderValueLength ||
|
||||
hasControlCharacter(headerValue)
|
||||
hasInvalidHeaderValueCharacter(headerValue)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -227,16 +227,6 @@ export function mergeModelRequestHeaders(
|
||||
return headers
|
||||
}
|
||||
|
||||
export function canonicalModelRequestHeaders(
|
||||
headers: Headers
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
[...headers.entries()].sort(([left], [right]) =>
|
||||
left.localeCompare(right)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeModelRequestBody(
|
||||
customBody: ModelRequestBody,
|
||||
runtimeBody: Record<string, unknown>
|
||||
|
||||
Reference in New Issue
Block a user