fix: reuse runtime processes across project sessions
Multiple projects could spawn duplicate heavyweight runtimes, while settings changes and remote reconnection could cancel or strand native conversations. Share compatible local OpenCode and DeepSeek Harness process owners and Agent-owned ACP connections, retaining workspace routing, per-session modes, model attribution, question ownership, targeted cancellation and idle cleanup. Restore remote pending questions and terminal task projections across reconnect and desktop restart, and preserve accepted ordinary requests during settings replacement while cancelling expert-team parents with their replaced scheduler. Extend regression coverage and Agent type checks. Record measured resource and real-model validation results, including failures and limitations. Also include the conversation media-generation design drafts (not an implemented feature), documentation entry links and dedicated Linux test-directory guidance. Desktop and Agent changes must be delivered together for shared process results. Validation: typecheck and lint passed; staged content and documentation links checked. Prior full regression and live-model evidence are recorded in progress notes; no new full test suite or model calls were run for this commit. Release note: 多项目会话现在复用兼容的 OpenCode 和 DeepSeek Harness 进程,减少重复启动并保持工作区、工具和取消隔离。修复远端问答在断线、重启及配置更新后的恢复与状态同步问题,Desktop 与 Agent 需配套更新。
This commit is contained in:
@@ -339,6 +339,10 @@ validation are not substitutes for this development-time check.
|
||||
- Keep all artifacts in GoodBuddy-owned test directories. Before stopping or
|
||||
changing a remote process, verify that it belongs to the test run; never
|
||||
modify unrelated Host files or processes.
|
||||
- During remote testing, prefer dedicated test directories under `/root/tmp`
|
||||
for temporary files. Avoid creating files under `/tmp` where possible, since
|
||||
it may be memory-backed and consume additional RAM. Set `TMPDIR` to the
|
||||
dedicated test directory when needed so test tools use it as well.
|
||||
- If neither route is reachable, report the real-Host validation as blocked and
|
||||
do not present the Agent work as complete or defer its first real validation
|
||||
to release time.
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务过程差量存储、远程去重、旧库转换、空间回收及升级界面 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、并行与输出、消息请求状态及资源生命周期规则 |
|
||||
| [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md) | 跨 Runtime 资源调研、锁定协议证据、进程与会话拆分、远端模型桥、实施顺序和性能验收 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD
|
||||
中。浏览器多实例涉及跨进程身份和工具路由,单独使用技术设计文档维护。
|
||||
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;产品场景与验收保留在现有 PRD,
|
||||
Runtime 资源状态规则由交互边界文档维护。浏览器多实例和 Runtime 进程复用涉及不同的
|
||||
跨进程身份与工具路由,分别使用技术设计文档维护。
|
||||
|
||||
@@ -949,6 +949,41 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
用户级 GoodBuddy Agent Daemon。依赖 Main 模型网关、审批或本机工具的 Run 不能被描述为
|
||||
可以无人值守继续。
|
||||
|
||||
### 14.1 Runtime 资源复用
|
||||
|
||||
2026-09-14 调研确认:多个项目可能各自保留一个重型 Runtime,状态或原生清单读取也会
|
||||
影响其启动与保留。用户需要多项目/多会话自然并行工作,而不是为避免内存增长关闭工具、
|
||||
子代理或删除历史。以下为新需求,**不表示 Desktop 0.13.2 / Agent 0.11.25 已实现**。
|
||||
|
||||
- **FR-RR1**:支持原生多会话的 Runtime,在同一账号和兼容启动配置下跨项目复用重型
|
||||
进程;不得仅因项目目录或会话不同重复启动。等价的默认和显式选择应得到相同复用结果。
|
||||
不兼容的 Runtime、Host、模型连接或插件配置可以分开,不要求全产品只有一个进程。
|
||||
- **FR-RR2**:仅查看被动状态不启动重型 Runtime。显式连接测试、原生清单和执行请求
|
||||
复用兼容的已启动实例;尚未握手时不能显示为已经验证连接。
|
||||
- **FR-RR3**:共享进程仍保持各会话的目录、历史、模型用量、工具、权限和业务问答归属。
|
||||
同一会话顺序执行,不同会话并行;Ask 保持只读,Execute 不增加第二层审批。
|
||||
- **FR-RR4**:请求结束、会话释放与进程退出分别处理。取消一个普通请求、关闭工作栏
|
||||
或释放一个会话不得误停其他健康会话;无会话的空闲重型实例及时释放。正常续聊保留
|
||||
原生上下文,应用退出、配置替换和真实进程故障仍有明确的终态与有界回收。
|
||||
- **FR-RR5**:托管 SSH 独立实现并验证复用,保留 Agent 对活动 Prompt 的所有权、
|
||||
断线续接、事件去重和模型调用结果不确定性规则。不能以本机通过代替远端交付。
|
||||
- **FR-RR6**:对不具备原生多会话 Host 的 Continue 保留正确的请求隔离;直连模型不
|
||||
新增独立模型进程。优化应有前后性能证据,不以删历史、禁用工具、全局串行化或
|
||||
推迟正常用户操作来换取进程/内存指标。
|
||||
|
||||
产品场景验收:
|
||||
|
||||
| 场景 | Given / When / Then |
|
||||
| --- | --- |
|
||||
| US-RR1,首次与热启动 | 同一有效配置的两个项目,分别查看状态、打开清单并开始四个对话;状态不冷启动,后续共用一个可多会话的重型 Runtime |
|
||||
| US-RR2,并行与取消 | 两个项目有同名不同内容文件,四个会话进行真实开发;目录及成果正确,取消其中一个后其余三个继续完成 |
|
||||
| US-RR3,续聊与释放 | 一轮任务完成后继续原会话,再释放其中一个;原生上下文可续用、其他会话不受影响,数据库完整历史仍可查看 |
|
||||
| US-RR4,边界与远程 | 切换不兼容配置、查看 Continue/直连状态或中断 SSH;不串配置、不伪造多会话能力,Agent 已接受工作按现有归属恢复 |
|
||||
|
||||
资源状态规则由 [Runtime 交互边界](./runtime-interactions.md#runtime-资源生命周期)
|
||||
定义;锁定版本研究、实现选择与逐项验收由
|
||||
[进程复用技术设计](./runtime-process-reuse-technical-design.md)定义。
|
||||
|
||||
## 15. 无障碍与响应式
|
||||
|
||||
- 能力目录、所有面板实例、目标选择器、终端控制和进程操作可用键盘完成。
|
||||
|
||||
@@ -1,5 +1,471 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-14 Runtime 进程复用(实施中)
|
||||
|
||||
对应 FR-RR1–FR-RR6,设计及完整验收清单见
|
||||
[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md)。
|
||||
用户已授权实施,本次复用改动尚未整体提交或发布,不包含在正式 Desktop 0.13.2 /
|
||||
Agent 0.11.25 中。最终验证结果见本节末尾,早期失败及中间状态按时间保留。
|
||||
|
||||
- P0–P2:Main 已组合 `LocalRuntimeRegistry`,OpenCode Server 和 DSH Utility 按兼容
|
||||
配置共享,工作区通过轻量适配器传入 Session。无会话 owner 空闲 60 秒回收;
|
||||
适配器淘汰不结束其他会话。DSH 被动状态不再启动 Host,取消超时先释放目标 Session,
|
||||
同时回收对应 Main 工具资源;配置替换期间的原生问题继续交给原请求的 Runtime。
|
||||
- 首轮 Windows 完整 UI 使用当时构建的生产 Main/Preload/Renderer 和隔离 profile,
|
||||
OpenCode、DSH 各两个项目、四个并行会话,通过输入区和发送按钮完成实际开发。
|
||||
OpenCode 工具数为 7、7、6、8;DSH 为 9、13、11、11。独立运行各项目测试合计
|
||||
60 项通过。两类 Runtime 分别共用一个重型进程,本轮未使用原生子代理。
|
||||
- 首轮 UI 提供方请求共 55 次 HTTP 200:OpenCode 23、DSH 32。其中包含驱动选择器
|
||||
失误引发的一条额外默认项目 Ask,工具为 0,不把它计为开发任务验收。
|
||||
同轮 OpenCode 单进程 Private Commit 约 882.88 MiB,工作集约 629.51 MiB;
|
||||
Electron 自身指标另存,不将两者混为整个 App 的测量结论。后续重复运行和空闲
|
||||
证据见下文,不把这一单点与正式用户库直接比较。
|
||||
- P3:生产 Agent 已接入单 ACP decoder/握手、Session → binding/operation 路由、
|
||||
逐 operation Unix 模型桥及空闲回收。共享 stdio 与 Prompt 输入/期限分离;
|
||||
Session 取消结算可如实保留 `processTree: running`。未增加持久进程池账本。
|
||||
- 共享 Linux x64 Host 已通过现有加密凭据与固定 Host identity 连接。使用当前源码、
|
||||
固定 Node 24.19.0 / OpenCode 1.18.29 构建隔离测试包,走真实 prepare/commit、
|
||||
activate、attach 和 Agent-owned Prompt,不改写已有 Host 安装。
|
||||
- Linux 首轮五条 Prompt 共 22 次已完成且已交付模型调用;两个 Execute 分别在正确
|
||||
项目完成修改,各通过 8 项测试,四个会话只启动一个原生进程。但 Ask 的搜索被原
|
||||
`kind === read` 规则误拒,原生子代理工具因权限请求未转发而等待,首轮验收失败。
|
||||
修正采用原生 Session 工具规则;原生子代理只继承父拒绝规则、不继承允许规则这一点
|
||||
已按固定版本源码核实,子 Session 同样设置当前模式,并保留原生显式限制。
|
||||
- 下一轮测试脚本在 transcript 尚有分页时按已完成状态提前结束,误报缺少项目 marker,
|
||||
随后 cleanup 中止了其他三个请求。这是验证驱动缺陷,产品自身已有 `hasMore` 处理;
|
||||
驱动已修正为读完全部分页、等待全部并行结果。该轮共 14 次 dispatch,
|
||||
其中 11 次完成、3 次结果不确定,失败记录保留,不将其计为通过。
|
||||
- 重复测试包构建曾遇到 Host ENOSPC。只清理本轮已确认可重建的 bundle、Runtime
|
||||
staging 和重复 `.gbagent`,保留测试 home、workspace、SQLite 及报告,继续验证。
|
||||
- 后续两轮 Linux 各 7 条 Prompt、35/34 次 dispatch,四个 Ask/Execute 与原生子代理
|
||||
续接均通过,分别取得 483/467 条子会话事件;取消没有关闭共享 owner。重连驱动先后
|
||||
漏发 `controller/resume`、错读 `agent/capabilities.generation`,导致两次后续验收
|
||||
失败;均已修正,保留失败报告。35 次调用一轮包含 1 次结果不确定,34 次一轮全部完成。
|
||||
- 最终当前源码 Linux 生命周期复验:2 条 Prompt、5 次完成且已交付调用,共享一个
|
||||
owner。目标返回 `cancelled / processTree: running`;另一会话跨真实 SSH 断线、
|
||||
controller resume 和重新 attach 继续执行,3 个工具返回正确项目 marker。
|
||||
全部 binding 释放后等待 65 秒,owner 数为 0;停止隔离 Agent 后仍为 0。
|
||||
- 原生 Compact 单独复验:两项目各先完成 3 工具请求,再并行压缩原生 Session。
|
||||
两次压缩分别归到各自的新 operation,各 1 次已交付调用,前后为同一个 owner;
|
||||
总计 4 条 Prompt、8 次完成且已交付调用。释放会话并等待 65 秒后 owner 为 0,
|
||||
Agent 停止后仍为 0。该结果没有用普通聊天返回“已压缩”代替原生 Compact。
|
||||
- 第二轮 Windows 完整 UI 重开同一隔离 profile,四项目八条续接开发请求全部完成,
|
||||
OpenCode 包含原生子代理,DSH 使用已有原生工具。独立项目测试合计 77 项通过。
|
||||
两个 Runtime 仍各一个重型进程。另有四条实际慢工具请求:前两条 30 秒测试在点击
|
||||
停止前已结束,不作为取消通过;后两条 120 秒测试中目标被正常 Stop 取消,另一项目
|
||||
的测试继续完成,DSH PID 未变化。第二个 App 共 12 条任务,11 完成、1 取消;
|
||||
100 次 HTTP 200(OpenCode 24、DSH 76),无失败请求。
|
||||
- 第二轮任务结束后的 631.573 秒采样共 121 点,提供方请求计数始终为 100,
|
||||
DB 始终 2,928,640 字节、WAL 始终 4,268,352 字节。期间查看了项目和截图,属于
|
||||
**无新任务/模型请求**,不是完全无人操作的 UI。原生 Session 有意保留,不要求其
|
||||
Runtime 退出;首个 App 自行退出时的进程消失不计为空闲计时验收。
|
||||
|
||||
| 进程 | Private Commit 起止(MiB) | 范围(MiB) | 区间 CPU 秒 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode | 826.34 → 825.05 | 824.36–830.82 | 12.03125 |
|
||||
| DSH Utility | 98.73 → 98.73 | 98.73–98.73 | 0 |
|
||||
| Main | 184.16 → 185.05 | 182.90–186.77 | 0.953125 |
|
||||
| Renderer | 142.80 → 136.22 | 135.00–142.80 | 0.6875 |
|
||||
| GPU | 120.48 → 138.88 | 120.48–173.52 | 0.0625 |
|
||||
|
||||
Private Commit 不是独占物理内存,工作集相加会重复计算共享页。这组测量未发现两个
|
||||
Runtime 在该空闲区间持续增长,但不是整个 App 无泄漏证明,也不是旧新版本对照。
|
||||
|
||||
- 当前 Linux 六个 ACP/bridge/owner 测试文件为 82 通过、0 跳过,包含 Windows 跳过的
|
||||
Unix broker 并发及旧路由排队拒绝回归。本机完整回归曾为 4,170 通过、67 跳过;
|
||||
首轮唯一失败来自旧测试把 `failed + running` 当成非法,修正后仍保留 `unknown`
|
||||
的拒绝断言,并新增共享失败/取消接受用例。
|
||||
- 标准 `typecheck` 原先没有把部分 Agent 源码作为检查入口。已增加
|
||||
`tsconfig.agent.json`,并修正暴露的 ACP resume capability、spawn stdio 及几个局部
|
||||
类型问题;扩展类型检查、26 项受影响 Agent 回归及 lint 通过,之后已再次进行上述
|
||||
当前源码 Host 生命周期与压缩验证。
|
||||
- 三个只读审查分别检查复用、质量/兼容来源和效率。修复了被动状态刷新不断重置
|
||||
60 秒空闲计时的问题;状态检查不取得执行 owner,Native Session 映射成为唯一会话
|
||||
保留依据,不再另存重复 conversation Set。最后本机定向回归 98 通过、3 跳过,
|
||||
含真实 OpenCode 与 DSH ACP 组合;扩展类型检查通过。该补修仅涉及本机 registry,
|
||||
不改变 Agent/远程路径。当时全量 `npm test` 为 **4,171 通过、67 跳过、0 失败**
|
||||
(356 文件通过、9 跳过,609.57 秒);`npm run typecheck`、`npm run lint`、
|
||||
`npm run build` 均通过。构建仍报告既有 chunk 大小/混合导入警告,不影响成功结果。
|
||||
- 保留 exclusive raw ACP 是已有公开字节流路径,不是为本分支中间形态保留的兼容层。
|
||||
新的共享取消/失败结果需要配套 Desktop/Agent,不声称旧 Desktop 已支持新结果。
|
||||
原生插件两处祖先遍历可局部提取,但没有已证实故障或性能收益,本次不为此扩展改动。
|
||||
- 本轮真实模型调用合计 **273 次**:本机两轮 UI 155 次,Linux 六轮
|
||||
**118 次 dispatch**(29 条 Prompt,114 次完成、4 次结果不确定)。
|
||||
包含所有失败轮次和误发默认 Ask,不重复计算最新报告别名;本机 HTTP 200 不等于
|
||||
Agent ledger 的完整交付证明。原生确定性协议测试不计入真实提供方调用。
|
||||
|
||||
补充的 owner 成本对照使用同一当前源码、同一 Windows 机器、全新空工作区,分别经
|
||||
生产 factory 创建两个独占实例或两个共享适配器。每种组合重复 3 轮,每轮显式连接后
|
||||
再做 5 次双工作区连接检查;共启动 18 个真实 OpenCode/DSH Utility,全部退出,
|
||||
**真实模型和本机合成模型请求均为 0**。测量前全量测试已结束,没有同时运行测试套件。
|
||||
|
||||
| Runtime / owner 方式 | 进程数 | Private Commit 中位数及范围(MiB) | 双工作区首次 ready 中位数及范围(ms) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode 两个独占实例 | 2 | 1,401.17(1,396.15–1,517.91) | 1,195.00(1,184.14–1,213.49) |
|
||||
| OpenCode 一个共享 owner | 1 | 777.59(667.04–802.78) | 1,135.71(1,130.32–1,140.81) |
|
||||
| DSH 两个独占实例 | 2 | 171.62(170.79–173.50) | 167.06(163.36–167.78) |
|
||||
| DSH 一个共享 owner | 1 | 86.64(86.18–87.29) | 155.31(155.28–157.79) |
|
||||
|
||||
这是**进程所有权方式**的对照,不是旧版 App 对新版 App、等规模历史或真实开发负载
|
||||
对照;没有测模型首字节。DSH 后续连接检查读取已缓存握手,不能当作热 Prompt 延迟。
|
||||
最后一个共享 DSH 在无 Session 时每 5 秒读取状态,持续 65 秒,原 Host 已退出且没有
|
||||
重启,证明状态轮询不会续期空闲 owner。探针早期的类型及 CJS/ESM 打包错误均发生在
|
||||
模型调用前;改为正确 ESM 依赖解析并加入启动前语法检查后完成测量,未修改产品以适应驱动。
|
||||
|
||||
清理时,完整 UI 的“关闭窗口”按现有产品行为隐藏到托盘,并不退出 App。随后只对已无
|
||||
活动任务且重新核对启动命令的隔离 App 进程树进行清理;这不作为正常托盘退出验收。
|
||||
命名浏览器会话已关闭,本机按本轮启动路径筛选的进程数为 0。最终通过固定身份 SSH
|
||||
只读核对本轮 Linux 路径下的 `/proc` 进程和各测试安装 owner 数据库,两者均为 0;
|
||||
保留测试 profile、工作区、数据库和报告,未删除用户历史或停止用户 Runtime。
|
||||
|
||||
此时尚未完成的完整 UI、十项目和问答恢复验收,后续结果见以下继续验收记录。
|
||||
旧新版本全 App 对照、完整共享能力组合及 Continue/直连资源分解仍需独立完成,
|
||||
不能将 P0–P4/V1–V8 整体标为完成。
|
||||
|
||||
本机证据位于忽略目录 `.agent-resources/runtime-reuse-implementation-20260914/`,
|
||||
包括 UI 聚合指标、截图、Host 聚合报告及保留的失败轮次。这些不是 checkout 自带资产,
|
||||
不包含可提交的模型或 SSH 凭据。
|
||||
|
||||
### 继续验收:远端完整 UI 与模式切换
|
||||
|
||||
- 用户要求继续后,已从完整生产 Electron UI 启用远程项目、保存两个 SSH 项目并在
|
||||
各项目创建两条会话。仅在测试 SSH 命令入口将 `HOME` 指向既有隔离安装;项目保存、
|
||||
Host/Agent/Runtime 发现、Main Runtime factory、传输、协议、模型和工具均保持生产实现,
|
||||
未伪造 capability、Prompt、事件或权限结果。
|
||||
- 四条正常开发任务均完成,共用一个原生 owner。分别在各自工作区实现 words/prices,
|
||||
独立运行两项目测试为 12 + 13 = **25 项通过**。实际重叠峰值为 2 条请求,
|
||||
不是四条同时运行;四个会话完成后,共享的原生 owner 仍然存活。
|
||||
- 新增真实模型调用 **21 次**,分别为 5、5、5、6,均 completed 且已交付。
|
||||
与上一段 273 次相加,累计 **294 次**;没有把旧 Agent ledger 的 13 次基线重复计入。
|
||||
- 随后在原价格会话中从 Execute 切到 Ask 并发送原生业务问答请求,实际失败,UI
|
||||
显示“没有可安全附加的远端 Agent 请求,且恢复不会重放任务”。Agent 仍把 workMode
|
||||
固定在 binding 首次启动值,拒绝新模式;此次在模型 dispatch 前失败,新增调用为 0。
|
||||
此问题不能由先前“不同 binding 使用不同模式”的通过结果排除。
|
||||
- 当前修正只允许共享 Session 的后续准备变更模式,独占进程仍保持原契约;
|
||||
`AgentOwnedAcpPrompt.start` 显式接收当前请求模式,原生 Session 路由与 ACP 备用权限
|
||||
决策均使用该模式,不再捕获构造时模式。48 项定向回归和扩展类型检查、lint 通过,
|
||||
包含 Execute → Ask → Execute 保留 Session 与原生父/子问答。
|
||||
后续当前源码 Linux 包和完整 UI 复验已通过,见下一节;48 项是当时的定向结果,
|
||||
不是后续全部修正后的全量结果。
|
||||
- 首轮 UI 通过正常 `app.quit()` 进入生产退出清理,本机测试进程退出;
|
||||
隔离 Agent 随后按 installation identity 停止,`/proc` 与 owner 数据库均为 0。
|
||||
首轮报告、失败任务和工作区保留。再次构建前 Host 仅余约 251 MiB,已明确限定只回收
|
||||
更早停止的测试安装软件及重复测试包,不清理状态、历史或工作区。
|
||||
|
||||
### 继续验收:断线、取消、模式续接与 Desktop 重启
|
||||
|
||||
- 四个原生问题同时等待时切断实际 SSH,复现“ACP channel does not exist”,继而出现
|
||||
重复注册 binary channel。诊断确认 Agent-owned Prompt 可能没有 raw ACP journal 行,
|
||||
replay 却对不存在的行 ACK 0。修正只在已有 raw cursor 时确认 journal,不放宽正序号、
|
||||
channel/epoch 或 installation 检查。真实临时 SQLite 回归先失败再通过;94 项后端/
|
||||
journal/channel 测试、当前源码 Linux 六文件 86 项通过,失败报告保留。
|
||||
- 保留旧 Desktop profile 并更换隔离 Agent 后,旧安装的恢复曾无限重试阻塞项目。
|
||||
attach-only 路径现在在打开 channel 前明确报告安装身份不匹配,不重放 Prompt。
|
||||
同一 Agent 的新连接则保留 Runtime,对已有 binding 旋转 transport generation,
|
||||
新 channel 使用实际连接 generation;不同 daemon 或倒退身份仍按原规则拒绝/替换。
|
||||
- 完整生产 Electron UI 再次同时等待四个问题,实际断线重连后原表单和 Session 均保留,
|
||||
没有新增模型调用或 owner。四会话尚在等待时,第五个新 Session 可以执行文件读写;
|
||||
该 Session 的 Execute → Ask → Execute 三轮均完成,各 2 个工具、3 次模型调用,
|
||||
验证实际写入、只读读取、再次写入,不以模型自述代替文件结果。
|
||||
- 取消一个 120 秒原生命令时,其余三个待答会话仍可各自回答并完成。取消后立即在原
|
||||
对话显式发送新请求也完成,使用新的原生 Session,仍共用原 owner。已确认取消通过
|
||||
专用错误进入终态;未确认的 Stop 保留原恢复逻辑,不把网络中断等同于取消成功。
|
||||
曾出现 Task 已取消但 assistant message 仍 streaming,改用既有数据库终态事务
|
||||
同时更新消息、任务和终态事件。最终真实 UI、Desktop SQLite、Agent 状态一致,
|
||||
streaming message 和 active Task 均为 0,共享进程未被误杀。
|
||||
- Desktop 重启前的首次尝试因测试观察 SSH 挂起,实际上未退出,已明确作废该证据。
|
||||
驱动加有界观察超时和 `finally` 退出后,真实重启复现两个缺陷:live question 没有
|
||||
transcript provenance 却被当作持久事件拒绝;同项目串行恢复让第二个待答 Session
|
||||
无法附加。现由 Main 并行恢复任务,live question 使用已有待答 Map 和活动请求租约,
|
||||
只投影到实时会话快照,不伪造 provenance,不写新的问题日志。
|
||||
- 修正后的真实正常退出/重开恢复两个同时待答的 Session,后一个先回答并完成,
|
||||
前一个独立 Stop。两端终态分别 completed/cancelled,UI 无残留活动或恢复提示,
|
||||
仍为同一个原生 owner。到该轮累计真实提供方调用 **348 次**:
|
||||
273 + 首轮远端 UI 21 + 旧隔离安装诊断 6 + 当前隔离安装 48。
|
||||
48 次全部完成并交付;更早 273 次中的 4 次不确定仍保留,Prompt 取消不等同于
|
||||
模型调用交付不确定。后续审查复验的增量另记,不覆盖这一时间点。
|
||||
- 重启修正后的全量回归为 **4,186 通过、67 跳过、1 失败**,唯一失败是
|
||||
`ActivityPanel` 的 501 条历史显示测试;该文件单独重跑 **16 项全通过**。
|
||||
不将单文件重跑写成全量通过,原全量报告保留。此前 4,171 通过的报告早于这些修正。
|
||||
|
||||
### 继续验收:共享能力与配置退役审查
|
||||
|
||||
- OpenCode 生产 factory、真实二进制测试覆盖 2/10 项目,每项目两 Session、两轮原生
|
||||
文件读取并逐项目原生 Compact,manager 容量设为 1,仍只启动一个 Server。
|
||||
DSH 实际 Host/ACP composition 同样覆盖 2/10 项目、每项目两 Session、两轮原生
|
||||
文件读写和平台 Shell,一个 Host。二者使用确定性模型,新增真实提供方调用为 0;
|
||||
这是原生组合测试,不是十项目完整 Electron 性能测试。
|
||||
- DSH 补充双项目共享组合:原生 Skill、实际 stdio MCP、Host 插件清单、不同尺寸的
|
||||
inline 图片和 Execute → Ask 均通过。两项目使用不同 Main ToolProvider 和正确的
|
||||
conversation context,Ask 不调用先前 MCP,图片原始字节分别匹配。该文件当前
|
||||
**9 通过、3 跳过**;Host 在测试进程内实际运行,不能表述为完整 Utility/UI 能力矩阵。
|
||||
- 完整 61 文件复查分别检查复用、质量/兼容来源和效率。没有新增仅服务分支中间形态的
|
||||
兼容层;raw ACP 独占和 Continue 单请求 Host 保留其已有契约。
|
||||
复查发现重启后的回答/跳过未保存到 remote-authoritative assistant message,以及
|
||||
manager reset 后旧 controller 拒绝所属问题的回答。修正复用原消息 `answeredQuestions`
|
||||
metadata 和既有 question owner,不新增 schema、恢复日志或重复历史。
|
||||
- 真实 UI 复验又确认设置保存入口仍主动取消全部请求,不能以 controller 单测通过
|
||||
宣称生产配置切换完整。Runtime 设置、原生定制和能力更新现在只激活/退役配置,
|
||||
不批量 abort 或清除待答确认;已接受请求继续使用原配置。用户 Stop、清除数据和
|
||||
App 退出的原有取消/分离规则不变。真实 SQLite 的三入口回归覆盖并行恢复、反序回答、
|
||||
跳过、重新打开答案和终态;manager reset 回归覆盖旧问题可回答、新工作仍拒绝。
|
||||
最新 **152 项 IPC/controller/manager 测试、typecheck、lint 和生产构建通过**;
|
||||
这次补修的最终真实 UI 复验及全量回归仍在进行。
|
||||
- 后续真实 UI 的设置保存不再取消原生待答 Prompt;一次连接 readiness 超时后,
|
||||
重新启动 Desktop 附加同一请求并回答 `KeepYes`,原 owner 未更换,任务 completed,
|
||||
原消息的 `answeredQuestions` 已落库。再一次正常退出/重开后,“问题与回答”仍显示
|
||||
正确答案和完成状态,截图已检查。该时点累计真实调用 **352 次**,相对 348 新增
|
||||
当前 Agent 3 次及误选默认项目的本机 Ask 1 次;后者未使用问题工具,不计问答验收。
|
||||
当前 Agent ledger 共 51 次,均 completed 且已交付。正常退出 Desktop 后按已核对的
|
||||
隔离 installation 停止 Agent,所属 `/proc` 进程和 active owner 记录均为 0。
|
||||
- 复查设置更新的专家团队分支发现,既有调度器会取消剩余专家,但父请求可能用替换后的
|
||||
模型继续综合。仅对活动团队父请求保留配置替换取消,普通原生请求不受影响;
|
||||
**156 项 IPC/controller/manager/SubagentService 回归通过**。此分支属于 Main 专家
|
||||
调度,不是 Agent 原生子代理;远端普通 Prompt 的 `teamMode` 为 false,先前验证的
|
||||
SSH 问答路径不改变。
|
||||
- 工作区随后出现其他任务的新提交和 Magic Notes API 修改。整仓类型检查曾因其
|
||||
`DesktopApi.magicNotes.onChanged` 与 App 测试 mock 不匹配失败,没有覆盖这些并行
|
||||
改动。冻结 candidate 的 typecheck、lint、生产构建通过,前后性能对照
|
||||
使用该隔离源码,不滚入后来的功能变更;其已带入的按钮位置调整见下节范围说明。
|
||||
- 随后完成 baseline/candidate 两个独立生产构建、相同 seed 历史、三次冷启动及
|
||||
无新任务十分钟的全 App 对照,结果见下节。驱动校准失败不计性能样本,
|
||||
测量期间未并发全量测试;既有 owner-only 数据不替代此结果。
|
||||
|
||||
### 2026-09-15:前后完整 App 对照与剩余 Runtime 检查
|
||||
|
||||
对照基线为 `de14a6157a6c34f1e3ef22619eca374a50a83706`。通过隔离源码构建 baseline
|
||||
和包含本任务改动的 candidate,使用同一 Windows 机器、同一依赖、同一 seed
|
||||
SQLite(四项目、八会话、320 条等量历史消息)和相同工作区 marker。后续其他工作区
|
||||
提交不滚入该冻结对照;这里的冷启动是新的 App/profile/Runtime 进程,不清 OS 文件缓存。
|
||||
|
||||
截图及源码复核发现,candidate 的 `App.tsx` 同时带入了 `fbba2e3` 的工作栏按钮
|
||||
移到主题切换按钮旁的调整。用户明确要求保留,工作区及冻结 candidate 均未剔除,
|
||||
也不为此补跑。以下结果是**包含该 UI 差异的完整 App 实测对照**,不是严格的
|
||||
纯 Runtime 单变量实验;此前“只含本任务改动”的范围描述不准确,现予以更正。
|
||||
|
||||
- 每组 3 次冷启动,各 55 条请求、110 次确定性 loopback 模型请求。每次冷启动包含
|
||||
八会话一轮工具请求和五条热请求,最后一次另加两轮八会话续接。每轮等待八个真实
|
||||
Native Prompt 同时到达提供方再释放响应,随后实际原生 `read` 读取各项目 marker。
|
||||
全部目录结果正确,无全局 Prompt 串行化。该对照新增真实提供方调用为 **0**;
|
||||
自然开发、写入、Shell 和真实模型验收仍以前文证据为准。
|
||||
- baseline 稳态为 3 个 OpenCode Server、2 个 DSH Host;三轮分别累计启动 3/3/3 个
|
||||
OpenCode 和 4/4/6 个 DSH(包含被动状态临时 Host)。candidate 每轮只启动一个
|
||||
OpenCode、一个 DSH;六次 App 正常退出后,本次登记的全部 Runtime 进程均退出。
|
||||
- 早期驱动的必填模型字段、Windows 子进程继承输出管道、Runtime 未就绪时点击以及
|
||||
读到半份 JSON 报告导致的失败均保留,不纳入以下样本。最终报告原子替换,发送前等待
|
||||
实际启用状态并核对 Main 已接收请求;不重放失败/不确定的真实模型请求。
|
||||
|
||||
| 指标 | baseline,中位数(范围) | candidate,中位数(范围) | 样本口径 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| 全 App 采样峰值 Private Commit,MiB | 3,543.18(3,485.58–3,589.01) | 2,040.11(1,698.68–2,085.82) | 每组 3 个 App,分别取采样峰值 |
|
||||
| Runtime 采样峰值 Private Commit,MiB | 2,843.18(2,831.00–2,854.82) | 1,338.93(1,229.31–1,347.63) | 同上,仅 Runtime 进程 |
|
||||
| bootstrap 到首个模型请求到达,ms | 4,519(4,464–4,529) | 3,573(3,519–4,040) | 每组 3 次,包含 UI 启动与操作,不是独立握手计时 |
|
||||
| OpenCode 热请求总时长,ms | 225(198–286) | 200(187–308) | 每组 9 次 |
|
||||
| OpenCode 首个可见事件,ms | 139(118–201) | 124(102–187) | 文本/推理/工具任一事件,不是模型首字节 |
|
||||
| DSH 热请求总时长,ms | 66.5(54–80) | 83(62–104) | 每组 6 次 |
|
||||
| DSH 首个可见事件,ms | 32(28–39) | 38(30–45) | 同上 |
|
||||
|
||||
这批包含上述 UI 差异的样本中,全 App 采样峰值中位数降低 **42.42%**,不能将全部
|
||||
差值单独归因于 Runtime 复用。DSH 的热请求中位数反而增加 16.5 ms,
|
||||
没有隐去该回退,也没有据此增加未经测量的队列优化。Private Commit 不是独占物理
|
||||
RAM,工作集求和会重复统计共享页;5 秒采样不等于捕获了瞬时峰值。两组使用相同
|
||||
测试观察代码,数据不是未插桩 App 或生产大库的性能承诺。
|
||||
|
||||
最后一次各有 **124 个**无新任务空闲样本,覆盖 **610.588 / 610.544 秒**,最大间隔
|
||||
5.149 / 5.068 秒。两组模型请求均固定为 58,active Task 为 0,DB 固定
|
||||
2,007,040 字节、WAL 固定 4,128,272 字节、事件数固定为 276。
|
||||
|
||||
| 空闲组 | baseline Private Commit 起止,MiB / CPU 秒 | candidate Private Commit 起止,MiB / CPU 秒 |
|
||||
| --- | ---: | ---: |
|
||||
| 全部 OpenCode | 2,433.02 → 2,382.73 / 19.359375 | 1,007.88 → 973.12 / 11.609375 |
|
||||
| 全部 DSH | 180.00 → 179.19 / 0.09375 | 86.92 → 87.62 / 0 |
|
||||
| Main | 211.00 → 200.34 / 4.9375 | 205.33 → 197.70 / 4.4375 |
|
||||
| Renderer | 183.32 → 137.40 / 0.265625 | 157.90 → 127.23 / 0.4375 |
|
||||
|
||||
Session 有意保留;DSH 尾端有 0.70 MiB 增量,不把这一段描述为所有点都下降,
|
||||
也不从十分钟数据宣称整个 App 已无泄漏。聚合报告同时保留工作集、堆、句柄和逐进程 CPU。
|
||||
原始数据位于忽略目录 `matched-app-WBCdJL/{before,after}-{10,11,12}`,汇总为 `summary.json`;
|
||||
这些是本机验证资产,不进入版本库。
|
||||
|
||||
其后才运行剩余 Runtime 检查,避免干扰对照:
|
||||
|
||||
- OpenCode 双项目真实 Server 组合通过原生 Skill、真实 stdio MCP 和不同 inline 图片,
|
||||
两轮八次 MCP 调用均用请求 ID 核对原 grant 归属,读取结果仍匹配各自目录;十项目
|
||||
测试仍通过。DSH 的对应共享组合也通过。四文件合计 **114 通过、4 跳过**,
|
||||
跳过的是 3 个显式启用的真实 DSH 模型测试和 1 个真实图像生成测试,不把这些记为通过。
|
||||
- 直连模型的会话 release/dispose 与实际 Shell 完整输出分页检查通过:删除目标会话
|
||||
缓存并释放对应 ToolProvider;dispose 先释放所有已知会话,再释放 provider。实际命令
|
||||
结束后输出可分页,dispose 后句柄失效。没有新增模型进程池、历史字节 LRU 或持久缓存,
|
||||
此处也没有做直连模型整体 retained-heap 泄漏证明。
|
||||
- Continue 使用实际 1.5.47 包和生产 Utility adapter,3 轮各两个并发请求,另 5 次热请求。
|
||||
11 次本机确定性提供方请求全部返回预期内容,11 个请求级 Utility 均退出,真实模型调用
|
||||
为 0。准备耗时中位数:冷准备 **333.93 ms**(326.06–381.94),同 adapter 缓存
|
||||
**0.0031 ms**(0.0010–0.0054),新 adapter 命中磁盘产物仍 **312.97 ms**
|
||||
(276.08–334.63),各 3 次。11 次原生 HTTP ready 中位数 **936.96 ms**
|
||||
(925.60–959.27),首个提供方请求从 run 起算 **980 ms**(969–1,013),总时长
|
||||
**1,353 ms**(1,314–1,373)。准备在 run 前单独测量,不能把它再说成总时长已包含。
|
||||
跨 adapter 重复准备没有消失;本次保留现有检查与缓存,不为约 0.31 秒的准备成本
|
||||
修改单 Session Host 或新增常驻池。
|
||||
- 最终整仓 `npm test -- --reporter=json --outputFile=<本机证据目录>/full-validation-final.json`
|
||||
为 **4,204 通过、67 跳过、0 失败**,耗时约 **733.80 秒**。包括最后加强的
|
||||
OpenCode 逐请求图片互斥断言、共享能力组合、设置退役及专家父请求回归;
|
||||
先前失败的 `ActivityPanel` 本次在全量中 **16 项通过**。早期失败报告仍保留,
|
||||
不将跳过的真实提供方或平台专用测试计为通过。
|
||||
- 全量结束后再次运行 `npm run typecheck`、`npm run lint` 和 `git diff --check`,
|
||||
全部通过,先前并行 Magic Notes mock 不匹配已不再出现。10 个相关文档的
|
||||
**140 个相对文件链接**均可解析,未独立复验标题锚点。全量测试进程及按本次测试
|
||||
路径筛选的本机验证进程均已退出;Linux 隔离安装的清理证据见前文。
|
||||
- 最终门禁及报告修订没有新增真实提供方调用,累计仍为 **352 次**。按钮位置调整及
|
||||
其他并行工作均保留;本次未执行 commit、push、tag 或发布。性能对照仍使用原冻结
|
||||
样本,不把整仓后来通过的回归结果描述成已重新测量该版本的性能。
|
||||
|
||||
验收边界仍需保留:性能对照是 Windows 四项目确定性读取,不是大库自然开发或十项目
|
||||
完整 UI 性能报告;十项目为实际 Native composition 测试。未测所有第三方插件的组合,
|
||||
未证明整个 App/直连模型无泄漏,macOS/Linux arm64 原生运行仍需可用平台或 CI。
|
||||
|
||||
### 2026-09-15:追加真实模型复验与资源观察
|
||||
|
||||
用户要求继续使用真实模型,并监控数据库及内存。此次将 `e1fe1cf` 与当时工作区改动
|
||||
冻结到独立源码目录,构建真实 Main/Preload/Renderer,使用新的隔离 profile 和四个专用
|
||||
项目,预置 320 条合成历史。按钮位置调整保留;没有把正式用户历史送入测试。
|
||||
原始证据位于忽略目录 `.agent-resources/runtime-live-20260915/run-tQ1xtk/`,
|
||||
不是 checkout 自带资产。本轮只调整测试驱动,没有修改 Runtime 产品源码。
|
||||
|
||||
- 独立生产构建通过。冻结快照的类型检查暴露并行附件功能的 App 测试 mock 缺少
|
||||
`getFilePath` / `importFiles`,未覆盖或撤销那部分工作;这一失败不被早先整仓通过
|
||||
的结果覆盖。初次构建取证脚本误用 Preload 扩展名、同步 esbuild 不支持插件等驱动
|
||||
错误均发生在模型调用前,生产构建与 Runtime 未因此修改。
|
||||
- 显式启用 DSH 的三个真实模型专项,全部通过:自定义 Header、Ask 下 Main 代理的
|
||||
Web Search/Fetch、真实 npm 插件在 Ask 被拒绝且在 Execute 执行。共 **7 次**
|
||||
实际模型请求;该次按测试名选择,仅运行这三项,不把其余九项的过滤跳过算作通过。
|
||||
- 完整 App 从输入区发出四项目八条开发任务,实际读写模块、添加边界测试并执行
|
||||
`node --test`。项目选择器曾因模糊名称匹配到路径中的 `live-2` 而选错项目,
|
||||
驱动在发送前被会话身份检查挡住,改用已验证的精确项目文本;没有重复发送已接受任务。
|
||||
- 第一轮六条任务完成、两条 DSH 以 `pi-ai stream idle timeout after 300000ms`
|
||||
失败。一次未观察到上游响应状态,另一条有 HTTP 200 但任务仍超时,不能把 HTTP 200
|
||||
当作交付成功。保留失败与数据库后,正常退出测试 App,将测试转发层改为独立
|
||||
`undici.request` 流式转发,再显式重跑这两条,均完成。由于同时重启了 App,
|
||||
不能据此断言已经唯一定位到转发器或提供方;本轮未盲目修改 Runtime 来规避失败。
|
||||
- 四项目独立执行测试分别 **16、17、18、19 项通过,共 70 项**。每类 Runtime 的
|
||||
项目请求存在重叠,使用同组 owner;本轮开发请求重叠峰值为 OpenCode 4、DSH 2。
|
||||
两类 Runtime 分批发送,不声称八条同时运行。
|
||||
后续真实原生子代理读取模块及测试,父请求执行测试完成;DSH 两个真实慢命令重叠时,
|
||||
只 Stop 其中一个,另一项目继续完成,DSH Host PID 未变。
|
||||
- 直连与 Continue 从同一完整 App 会话各发出一次无工具短请求,分别持久化
|
||||
`LIVE_DIRECT_OK` / `LIVE_CONTINUE_OK`。当前已配置的图像模型另发出一次生成请求,
|
||||
实际得到白底蓝色圆形图片并可在生产预览中打开;不是配置检查,也没有读取提供方
|
||||
返回的图片 URL。这验证当前配置路径,不冒充运行了绑定指定服务商的可选测试。
|
||||
- 此时 App 共 **16 条 Prompt:13 completed、2 failed、1 cancelled**,
|
||||
**67 次模型请求**;加上 DSH 专项 7 次,本机新增 **74 次真实调用(73 次文本、
|
||||
1 次图像)**。计数包含失败及显式重跑。早期累计 352 次暂增至 426 次,
|
||||
后续 Linux 增量另记;本机 HTTP 计数不等于 Agent ledger 的交付证明。
|
||||
- 真实子代理的 **153 条事件**,实际保存 **70,668 字节**,等价完整快照
|
||||
**495,934 字节**,该事件负载减少 **85.75%**。使用生产 codec 逐条还原及重新编码
|
||||
校验通过;6 次工具块 upsert 中无变化重复写入为 **0**,数据库 `quick_check` 为
|
||||
`ok`。这是同批事件的编码差额,不是整个数据库文件缩小 85.75%,也没有删历史。
|
||||
- 模型结束后连续 **610.857 秒、123 个样本**,最大采样间隔 **5.292 秒**。
|
||||
请求数固定为 16,模型调用数固定为 67,活动任务和 streaming 消息为 0;
|
||||
DB 固定 **1,765,376 字节**、WAL 固定 **4,140,632 字节**,消息数 355、
|
||||
事件数 663、事件 JSON 负载 284,577 字节均不变。
|
||||
- 本轮全 App 采样峰值 Private Commit 为 **1,728.79 MiB**,OpenCode + DSH
|
||||
合计采样峰值 **997.66 MiB**。没有同负载的旧版真实模型样本,不能把它与前文
|
||||
确定性对照相减生成新的“节省百分比”,也不把采样峰值称为瞬时峰值或独占物理 RAM。
|
||||
- App 收到正常 quit 后回收了本轮登记的全部 Runtime,OS 核验最终 Main、OpenCode、
|
||||
DSH 和 Continue Utility 均已消失。退出探针曾因等待 `will-quit` 误报超时:
|
||||
生产实现实际在 `before-quit` 清理后调用 `app.exit(0)`。该驱动失败记录保留,
|
||||
后续探针改从独立父进程观察退出,不修改产品退出路径或伪造原始报告中的 phase。
|
||||
关闭后 WAL 为 0,DB 为 **3,125,248 字节**,属于正常 checkpoint,不是空闲增长;
|
||||
已保存图片内容为 **1,351,526 字节**,不能把图片占用与事件差量节省混为一谈。
|
||||
|
||||
| 空闲进程 | Private Commit 起止(MiB) | 范围(MiB) | 区间 CPU 秒 | 句柄起止 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 全 App | 1,345.77 → 1,345.95 | 逐进程样本保留 | 分进程见下 | 分进程见下 |
|
||||
| OpenCode | 750.42 → 746.00 | 741.62–750.42 | 5.984375 | 333 → 333 |
|
||||
| DSH | 89.86 → 89.86 | 89.86–89.93 | 0.015625 | 364 → 364 |
|
||||
| Main | 196.61 → 200.37 | 193.13–205.09 | 5.703125 | 1,137 → 1,137 |
|
||||
| Renderer | 120.66 → 121.51 | 120.64–122.67 | 0.15625 | 337 → 335 |
|
||||
|
||||
Main 堆使用量为 67.27 → 69.44 MiB;Main 与 Renderer 有小幅增量,不能表述为
|
||||
“所有进程都下降”。采样包含测试观察器累积记录的分配,这一窗口未见数据库增长或
|
||||
Runtime 句柄积累,但不构成整个 App 无泄漏证明。
|
||||
|
||||
当前源码 Linux 复验使用 `/root/tmp` 下的专用目录。旧 `/tmp` 测试 Node/依赖缓存
|
||||
已不存在,初次准备因失效链接失败,未发出模型请求。随后只替换本轮创建的失效链接,
|
||||
核验锁定 Node 24.19.0 的 SHA-256,并安装锁定依赖,完成当前源码 Agent/Runtime
|
||||
组包、隔离安装及 OpenCode 1.18.29 激活,不修改旧安装或无关 Host 文件。
|
||||
|
||||
- 两项目各 Ask/Execute 一会话,共四会话使用同一个进程 owner、一个原生 OpenCode PID。
|
||||
Ask 实际读取,Execute 实际修改和运行测试,目录 marker 均正确。后续原生子代理
|
||||
产生 **497 条**子会话事件,同一 Session 续接完成;两项目独立测试各 **8 项通过**。
|
||||
- 两个真实 90 秒慢命令期间,目标 Prompt 明确结算为
|
||||
`cancelled / processTree: running`。另一会话经真实 attach 传输断开、
|
||||
controller resume 和重新附加后完成,原 Session 与 owner 均保留。
|
||||
本轮走生产 SSH/ACP/Agent/模型桥,不是再次运行完整远程 Electron UI 或 Desktop 重启。
|
||||
- 共 **7 条 Prompt:6 completed、1 cancelled**,**36 次模型请求全部 completed
|
||||
且已交付**,逐 operation 归账。取消 Prompt 不等于模型交付不确定。释放全部 binding,
|
||||
等待 65 秒后 owner 为 0;随后停止本轮隔离 Agent,独立 `/proc` 与 owner 表核验均为 0。
|
||||
- Host 共 71 次资源快照,未出现采样错误;所属进程私有驻留页合计采样峰值
|
||||
**1,033.00 MiB**,其中 OpenCode 峰值 **771.60 MiB**。这是 Linux 私有驻留页,
|
||||
不是 Windows Private Commit;也不是 Linux 十分钟空闲或旧新版本对照。
|
||||
退出后 semantic-prompts、model-calls、runtime-owners 数据库分别为
|
||||
**1,634,304 / 36,864 / 24,576 字节**,WAL 均为 0。
|
||||
- 本轮最终新增 **110 次模型请求:109 次文本、1 次图像**,包括本机失败和显式重跑;
|
||||
加上此前 352 次,累计 **462 次**。其中本机一次未观察到上游响应状态,不能把全部
|
||||
110 次写成成功交付。实际开发项目测试合计 **86 项通过(Windows 70 + Linux 16)**。
|
||||
- 收尾时当前工作区 `npm run typecheck`、`npm run lint` 和差异检查通过。
|
||||
238 个 Runtime/Agent/远程适配器文件与冻结快照的文本内容一致,原始字节差异来自
|
||||
checkout 的 CRLF/LF;Main、Preload、Renderer 入口产物哈希未变化。并行附件测试
|
||||
mock 的缺项已在当前工作区消失,不回写冻结快照,也不掩盖其当时的类型检查失败。
|
||||
10 个相关文档的 140 个相对文件链接有效,标题锚点未独立复验。
|
||||
本轮没有产品源码修复,未再次运行全仓 `npm test`;前文 4,204 项通过的报告
|
||||
早于后来的并行附件/窗口改动,不能将其当成这些改动的完整回归结果。
|
||||
- 本机命名浏览器会话及所有本轮测试进程均已关闭;保留隔离工作区、数据库、生成图片、
|
||||
失败诊断及汇总报告,没有清理正式用户历史,也没有 commit、push、tag 或发布。
|
||||
|
||||
## 2026-09-14 Runtime 资源调研与方案(未实施)
|
||||
|
||||
- 按用户要求完成文档与源码调研,方案见
|
||||
[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md),对应
|
||||
FR-RR1–FR-RR6。当前工作仅文档和本机忽略目录内的隔离探针,未修改 Runtime/Agent
|
||||
源码、停止用户 Runtime、清理用户数据或准备新发布。
|
||||
- 源码基线 `de14a6157a6c34f1e3ef22619eca374a50a83706`,保留此前活动 UI 修复。
|
||||
manager/controller、OpenCode、Continue、DeepSeek、直连、远程 ACP、Agent backend /
|
||||
owner、模型桥和原生插件均已检查。此前同基线生命周期定向回归为 11 文件、
|
||||
346 通过/1 跳过;不是本方案实施后的产品验收。
|
||||
- 已安装 0.13.2 的 09:10:12–09:15:12 五分钟只读采样:数据库约增加 2.72 MiB,
|
||||
3,367 条新增子代理事件约 1.49 MiB、平均约 465 字节,93 个工具 upsert 中未变化的
|
||||
upsert 为 0;App 相关进程 Private Commit 约 3,229–3,866 MiB,CPU 平均约为
|
||||
16 核整机的 3.15%。后续一分钟又完成两个任务,不能作为真正空闲或泄漏结论。
|
||||
- 两个 OpenCode Server 属于当前 App,启动工作区不同。其中一个原生库 0 Session /
|
||||
0 Message、约 527 MiB Private Commit。源码确认按工作区保留实例、清单可启动执行
|
||||
Runtime、无时间型空闲回收;没有独立调用轨迹证明该空 Server 最早由清单启动。
|
||||
- 首轮两个无真实模型探针中 OpenCode 通过,DSH 驱动误用工具内部 `filePath` 而不是
|
||||
原生 schema 的 `file_path`,断言失败。修正驱动参数后两项通过,未调整产品代码。
|
||||
- 随后完整三探针最终通过,Vitest 3 passed / 0 failed,39.20 秒:
|
||||
- 生产 OpenCode launcher 单次 spawn,两个目录四个原生 Session,文件路由与单会话
|
||||
删除后的其他会话存活通过;没有调用生产 `Runtime.run`。
|
||||
- 真实 DSH Host composition、Agent factory、文件和 PowerShell 工具,两个不同 cwd
|
||||
的 Agent 并行读写各自测试文件;生产控制面拒绝第二工作区也得到断言确认。
|
||||
这不是 Electron Utility/ACP 生产多工作区测试。
|
||||
- 一个 Windows 原生 `opencode acp`、一条 ACP connection、两个工作区 Session,
|
||||
并行完成两个合成 Prompt;`chat.headers` 的 sessionID 与两个 HTTP 请求归属一致。
|
||||
尚未经过 Agent backend、生产模型桥、真实工具/子代理或 Linux Host。
|
||||
- 本次研究真实模型调用 **0 次**;最终 ACP 探针向本机确定性服务发出 **2 次 HTTP
|
||||
请求**。测试服务、原生进程和专用工作区在 finally 中关闭/删除,不操作用户运行中的
|
||||
Runtime 或其临时数据库。
|
||||
- 复现命令:`npx vitest run --config .agent-resources/runtime-reuse-research-20260914/vitest.config.ts`。
|
||||
`probe.test.ts`、`opencode-result.json`、`dsh-result.json`、`acp-result.json` 保留在
|
||||
同一忽略目录,仅为本机证据,不作为他人 checkout 后必然存在的测试资产。
|
||||
- 未实施项及生产验收见技术设计 P0–P4/V1–V8;本次未进行新的 Linux Host 验证、
|
||||
真实模型开发任务或修复前后内存对照,不将原生能力探针描述成修复完成。
|
||||
- 文档核验覆盖 10 个改动文档、131 个相对链接和其中 44 个标题锚点,全部通过;
|
||||
FR-RR1–FR-RR6 的定义唯一且有验收引用,`git diff --check` 通过。研究用 OpenCode
|
||||
二进制剩余进程数为 0。产品源码未改动,因此本轮未重跑全仓 test/typecheck/lint。
|
||||
|
||||
本页下方 schema 35 段落保留当时的失败与未发布记录。其后已随 Desktop `0.13.2`
|
||||
(发布提交 `81ae9e0`)正式交付,Agent `0.11.25` 同期为维护发布;新的进程复用方案
|
||||
不包含在这两个已发布版本内。
|
||||
|
||||
## 2026-09-14 多 Runtime 工具操作摘要
|
||||
|
||||
显示与事件合并规则见[工具操作摘要](./runtime-interactions.md#工具操作摘要)。Continue、
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
| DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 |
|
||||
|
||||
托管 SSH OpenCode 的权限、原生问题事件与回复链路由
|
||||
[远程主机技术设计](../remote-host/technical-design.md)定义。本次候选的远端业务问答需要
|
||||
[远程主机技术设计](../remote-host/technical-design.md)定义。该远端业务问答基线需要
|
||||
Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime;请先升级 Desktop,再更新
|
||||
Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口,候选不表示已经发布。
|
||||
Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包含此前问答修复,
|
||||
但不包含本文末尾尚未发布的进程复用改动。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口。
|
||||
|
||||
## 运行与失败
|
||||
|
||||
@@ -30,10 +31,17 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
|
||||
提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。
|
||||
- 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时
|
||||
不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。
|
||||
- 待答队列仅保存在当前桌面会话内存中,不写入会话快照。刷新仍在运行的消息时保留整个
|
||||
- 待答队列仅保存在当前桌面会话内存中,不写入持久会话快照。刷新仍在运行的消息时保留整个
|
||||
本地队列;完成、失败、取消事件或持久化终态快照清除整个队列。迟到的提交结果可以保存
|
||||
已回答记录,但不会恢复表单或覆盖终态。本机 Runtime 不恢复重启前未提交的问题;托管 SSH
|
||||
重新附加同一存活 Agent Prompt 时,由 Agent 当前待答列表补发问题,已回答的历史事件不补发。
|
||||
- 未发布的进程复用源码在 Desktop 重启后由 Main 并行附加同项目的存活任务,将待答
|
||||
列表和 Stop 所需的请求 ID 投影到实时 `activeRequest`,不保存该字段或重新发送任务。
|
||||
回答/跳过成功由 Main 写回原远程助理消息的既有问答历史,Renderer 不覆盖远程正文或终态。
|
||||
Runtime 设置、定制和能力更新让旧请求在原配置上结束,已有问题仍可回答;新请求使用
|
||||
新配置。专家团队例外:既有专家调度器在设置替换时取消子任务,团队父请求也随之停止,
|
||||
不使用替换后的模型汇总旧结果。详细实现见
|
||||
[恢复与终态投影](./runtime-process-reuse-technical-design.md#63-恢复与终态投影)。
|
||||
- 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。
|
||||
- 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。
|
||||
- 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应
|
||||
@@ -90,3 +98,46 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省
|
||||
Runtime 的重试上限、请求重放或取消策略。
|
||||
|
||||
实现与真实 Runtime 验证证据见[进度记录](./progress.md)。
|
||||
|
||||
## Runtime 资源生命周期
|
||||
|
||||
本节对应 [FR-RR1–FR-RR6、US-RR1–US-RR4](./prd.md#141-runtime-资源复用),
|
||||
定义进程复用的行为规则。当前源码已接入本机共享 owner 和 Agent 共享 ACP;
|
||||
完整验收状态以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准,不表示已发布。
|
||||
具体 owner、兼容键和协议实现见[技术设计](./runtime-process-reuse-technical-design.md)。
|
||||
|
||||
资源有三个独立维度:进程是否启动/退役,会话是否仍持有原生上下文,请求是否在途。
|
||||
“任务完成”“当前没有选中该项目”“工作栏关闭”均不能直接推导进程可以销毁。
|
||||
|
||||
| 触发 | 请求与会话行为 | 进程行为 | 对应需求/场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 被动状态读取 | 不取得新执行会话,已有状态可读取 | 不冷启动,不延长无会话 owner 的空闲期限;不为探测替换服务中的进程 | RR2 / US-RR1 |
|
||||
| 显式测试/原生清单 | 取得临时检查引用;清单带明确工作区 | 与执行共用启动 Promise 和 owner;结束只释放检查引用 | RR1、RR2 / US-RR1 |
|
||||
| 首次或并行 Prompt | 取得对应 execution space + conversation 的绑定 | 同兼容组复用,非兼容组分别启动 | RR1、RR3 / US-RR1、RR2 |
|
||||
| 普通请求完成 | 释放请求订阅、临时 token、问题和 callback,保留可续聊 Session | 不因为一个请求完成关闭整个进程 | RR3、RR4 / US-RR3 |
|
||||
| 取消一个请求 | 取消其模型/工具调用并结算,其他会话继续 | 不以 kill 共享进程代替正常会话取消 | RR3、RR4 / US-RR2 |
|
||||
| 关闭面板/切换项目 | 停止非必要 UI 推送;不取消后台任务或抹掉会话所有权 | 不触发隐式关闭 | RR4 / US-RR3 |
|
||||
| 显式释放/删除会话 | 释放该 Session 与会话工具资源;删除历史仍只由原有明确删除操作决定 | 其他引用仍在则保留 | RR4 / US-RR3 |
|
||||
| 无会话、无请求、无检查引用 | 不存在需要保留的原生上下文 | 进入短空闲等待,再关闭;新使用者到来取消等待 | RR4 / US-RR1、RR3 |
|
||||
| 配置替换 | 已接受请求使用旧配置结算,新请求使用新配置 | 旧 owner 排空后退役;不积累只被历史 ID 持有的旧配置进程 | RR1、RR4 / US-RR4 |
|
||||
| 进程故障/不可响应 | 只给该 owner 下受影响请求明确中断;未知结果不自动重放 | 有界终止失效 owner,不伪造逐会话故障隔离 | RR3、RR4 / US-RR2、RR4 |
|
||||
| SSH detach | 保留 Agent 已接受 Prompt 及既有恢复归属 | 关闭传输不关闭 Agent 或活动 Runtime | RR5 / US-RR4 |
|
||||
|
||||
表中 RRn 是 PRD 稳定需求 ID `FR-RRn` 的简写。
|
||||
|
||||
不变量:
|
||||
|
||||
1. 一条请求始终使用接受时的 execution space、工作模式、配置和归属;不能读取后来
|
||||
切换的 UI 选择,也不以进程 cwd 表示所有会话的 cwd。
|
||||
2. 同一会话串行,跨会话不增加全局 Prompt 队列。权限、子代理、问题、用量和最终清理
|
||||
必须沿同一 Session/请求归属,不因共享进程放宽 Ask 或限制 Execute。
|
||||
3. owner 的退出条件依据所有使用者,不依据某个 adapter 的空闲状态。
|
||||
未启动对象的 dispose 不产生冷启动;一个等待者取消不取消其他等待者的初始化。
|
||||
4. 正常会话仍持有原生上下文时,不用任意 TTL 清掉它来声称“空闲内存已回收”。
|
||||
无会话 owner 的短空闲等待为 60 秒;
|
||||
不增加用户配置项。它是进程引用回收,不是聊天历史保留期限。
|
||||
5. 持久历史不因请求结束、进程 idle、配置变化或测试 cleanup 被删除。
|
||||
原生进程确实重启后的续聊沿现有历史重建规则,不新增第二套持久会话快照。
|
||||
|
||||
已明确普通并行、取消、释放、配置替换及断线行为。各项实际通过的场景及尚未完成的
|
||||
组合/性能验证以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准。
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# Runtime 进程复用技术设计
|
||||
|
||||
## 1. 状态与结论
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已接入本机与 Agent 生产源码,Windows / Linux x64 验证结果及边界已记录,未发布 |
|
||||
| 日期 | 2026-09-15 |
|
||||
| 调研源码 | `de14a6157a6c34f1e3ef22619eca374a50a83706`;相关 Runtime 与 Desktop 0.13.2 一致 |
|
||||
| 需求 | [工作栏 PRD §14.1,FR-RR1–FR-RR6](./prd.md#141-runtime-资源复用) |
|
||||
| 行为规则 | [Runtime 资源生命周期](./runtime-interactions.md#runtime-资源生命周期) |
|
||||
| 验证事实 | [实施与验证记录](./progress.md#2026-09-14-runtime-进程复用实施中),原始调研记录保留在同页 |
|
||||
|
||||
**修复对象是进程所有权过细,不是工具太多,也不是历史记录应该删除。** 本机 OpenCode
|
||||
和 DeepSeek Harness 应在兼容启动配置内共享重型进程,工作区与会话上下文仍各自独立。
|
||||
远端 OpenCode 必须同时调整 Agent 进程所有权、ACP 连接和模型桥,不能把桌面修复当作远端
|
||||
修复。Continue 的锁定版本是单会话 Host,不做未经验证的常驻进程池;直连模型没有独立
|
||||
模型进程,应检查对象、历史和工具资源的生命周期。
|
||||
|
||||
“一个进程”指一组兼容启动配置对应一个 Runtime Server/Host,不是把不同 Runtime、
|
||||
不同 SSH 账号、不同插件组合强行塞入一个进程。正常工具命令产生的短期子进程不属于重复
|
||||
Server,也不能通过关闭 Shell、子代理或 MCP 来达成进程数指标。
|
||||
|
||||
## 2. 修复前证据与缺陷
|
||||
|
||||
### 2.1 已测现象和解释边界
|
||||
|
||||
Windows 已安装 0.13.2 的只读采样发现两个属于同一 App 的 OpenCode Server:一个原生
|
||||
数据库为 0 Session / 0 Message,约 527 MiB Private Commit;另一个已有会话并执行请求。
|
||||
两个 Server 的启动工作区不同。源码能解释“清单查询也可启动按工作区缓存的 Runtime”,
|
||||
但没有取得能独立证明空 Server 最初由哪一个 UI 操作启动的调用链记录。
|
||||
|
||||
该缺陷不等于整个 App 内存的唯一来源。五分钟活跃采样的 App 相关进程 Private Commit
|
||||
约 3.15–3.78 GiB,CPU 平均占整机约 3.15%;后续一分钟包含新任务,不是空闲回收试验。
|
||||
数据库五分钟增长约 2.72 MiB,新增子代理负载约 1.49 MiB,重复工具 upsert 为 0。
|
||||
schema 35 的存储修复与本方案独立,不能回退或删历史来改善指标。
|
||||
|
||||
同次源数据检查还发现最近 100 个会话的消息 metadata 合计约 107.75 MiB,
|
||||
`listConversations()` 会读入这些会话的全部消息详情。它是 Renderer 大对象加载的明确
|
||||
检查方向,不是堆快照已经证明的泄漏。进程复用不能代替历史按需加载的独立性能工作。
|
||||
|
||||
### 2.2 调研基线的源码归属
|
||||
|
||||
| 位置 | 已确认行为 | 修复含义 |
|
||||
| --- | --- | --- |
|
||||
| [SelectedRuntimeManager](../../../src/main/agent/selected-runtime-manager.ts) | 缓存键包含原始 selection 与 execution-space identity;默认 8 个缓存、16 个保留实例;只在容量压力下淘汰 | 轻量工作区适配器和重型进程不能继续按同一键、同一数量计费 |
|
||||
| [AgentRuntimeController](../../../src/main/agent/runtime-controller.ts) | `canRetire` 同时要求无请求、无 owned conversation;普通完成不移除会话所有权 | 不能把“已完成请求”和“已释放原生会话”混为一谈 |
|
||||
| [本机 OpenCode](../../../src/main/agent/opencode-runtime.ts) | 对象私有 Server/client/初始化 Promise;已有多会话映射;多个入口使用固定 `defaultWorkspace` | 仅删除 manager 的 workspace 键会把后续操作送进错误目录 |
|
||||
| [DeepSeek Runtime](../../../src/main/agent/deepseek-harness-runtime.ts) | 每对象一个 Utility Host;`getStatus()` 调用 `getState()` 启动 Host | manager 的临时状态实例会额外启动、探测并销毁 Host |
|
||||
| [Harness Host](../../../src/main/deepseek-harness-host.ts)、[控制面](../../../src/main/agent/goodbuddy-harness-control-plane.ts) | 根 Provider 有 cwd 默认值;控制面拒绝与 Host workspace 不同的 `newSession.cwd` | 限制主要在 GoodBuddy 创建入口,不是原生工具缺少会话 cwd |
|
||||
| [Continue Host Adapter](../../../src/main/agent/continue-host-adapter.ts) | 每次 run 启动临时 Host,最终 `/exit` 与进程树回收;准备结果已在 adapter 内缓存 | 不重复添加已有缓存;研究跨 adapter 重复准备,不直接共享活动 Host |
|
||||
| [直连模型](../../../src/main/agent/model-runtime.ts) | Main 内 HTTP;历史 Map 按 50 个会话限制,其他归属及工具资源依赖 release/dispose | 没有可合并的常驻模型子进程,按对象和资源检查 |
|
||||
| [远端 ACP](../../../src/main/agent/acp-remote-runtime.ts)、[Agent backend](../../../src/agent-daemon/runtime-acp-backend.ts) | 会话取得 binding/channel;backend 按 binding 拥有进程并拒绝跨 binding 复用 process identity | 必须拆开会话绑定与进程所有权 |
|
||||
| [Agent-owned Prompt](../../../src/agent-daemon/agent-owned-acp-prompt.ts)、[模型桥](../../../src/agent-daemon/model-bridge-helper.ts) | owner 一个 ACP connection/session/当前 operation;helper 固定模型和工作模式并串行 dispatch | 共享进程还要求会话路由、请求归属和取消隔离 |
|
||||
|
||||
原始 selection 的 `auto`、默认 profile 和显式 profile 可能解析为同一有效配置。当前
|
||||
已确认“先按原始 selection 建键、后解析配置”的顺序,但尚未证明全部 UI 调用会实际生成
|
||||
等价重复实例。实施时从 [runtime-selection](../../../src/main/agent/runtime-selection.ts)
|
||||
和调用者验证,不把可能性记为另一次生产复现。
|
||||
|
||||
### 2.3 锁定依赖与协议基础
|
||||
|
||||
以仓库 lock 和本机已安装代码为准,不用最新网页替代锁定版本:
|
||||
|
||||
| 依赖 | 基线 | 本次核验 |
|
||||
| --- | --- | --- |
|
||||
| OpenCode binary / SDK / plugin | 1.18.29 | HTTP API 接受 directory;ACP 接受多个 Session;`chat.headers` 有 sessionID |
|
||||
| ACP SDK | 0.25.1 | 一条连接关联多个 request ID,session notification/permission 带 sessionId |
|
||||
| DeepSeek Harness 包族 | 0.1.2-rc.1 | Agent factory/scoped context、Session header.cwd、真实文件与 Shell 工具 |
|
||||
| Cordis | 4.0.2 | Host 根组件与 Agent scope 的生命周期 |
|
||||
| Continue CLI | 1.5.47 | 实际受管补丁入口的单 `M.session`、QuizService 和权限状态 |
|
||||
|
||||
已通过的三个隔离探针:
|
||||
|
||||
1. **OpenCode HTTP**:调用当前生产 Server launcher,只有一次 spawn;同一个 SDK client
|
||||
在两个目录创建四个 Session,读取各自的同名文件;删除一个 Session 后其余三个仍可取回。
|
||||
没有调用 `Runtime.run`、模型或活动取消。
|
||||
2. **DeepSeek 工具**:当前 Host composition 和真实 Agent factory 在同一 Node 测试进程中
|
||||
创建两个不同 cwd 的原生 Agent;并行执行真正的 `read`、`write` 和 PowerShell,
|
||||
同名相对路径各自命中正确目录。另确认生产控制面仍拒绝第二工作区。没有启动 Electron
|
||||
Utility、ACP Prompt 或模型流,不能据此声称生产多工作区已经可用。
|
||||
3. **原生 ACP 与模型归属**:Windows 上一个 1.18.29 `opencode acp` 进程、一条 ACP
|
||||
connection、两个工作区 Session;分别选模型并发出并行 Prompt。本机确定性 HTTP
|
||||
服务收到两次请求,原生 `chat.headers` 插件传来的 sessionID 分别匹配两个 Session。
|
||||
不经过已发布 Agent backend/helper,没有测试真实工具、子代理、Linux 或断线恢复。
|
||||
|
||||
三个探针真实模型调用均为 **0**,其中第三个有 **2 次本机合成提供方请求**。
|
||||
它们证明方案有原生基础,不替代后文的真实开发任务验收。
|
||||
|
||||
特别纠正:`dsh-tool-fs` 将 `exec.agent.session.header.cwd` 传给 `fs.resolve`;
|
||||
`dsh-tool-bash` / `dsh-tool-pwsh` 也从 Session cwd 补全相对 workdir。
|
||||
`dsh-fs-local` / 本地 Shell 的根 cwd 是非会话调用的默认值,不是工作区隔离边界。
|
||||
不需要为这个已具备的能力新建一套“每工作区 Filesystem/Shell Provider”。
|
||||
|
||||
官方背景资料:[OpenCode Server](https://opencode.ai/docs/server/)、
|
||||
[DeepSeek Harness](https://www.deepseek.com/harness/en/)、
|
||||
[Continue CLI](https://docs.continue.dev/cli/quickstart)。
|
||||
网页只用于核对产品入口;上述版本结论来自本地锁定代码和探针。
|
||||
|
||||
## 3. 选定的结构
|
||||
|
||||
### 3.1 两层对象,不建立通用进程池框架
|
||||
|
||||
```text
|
||||
SelectedRuntimeManager
|
||||
-> 轻量 Runtime adapter(有效 selection + execution space)
|
||||
-> OpenCode process owner / Harness Host owner(兼容启动配置)
|
||||
-> Session binding(execution space + conversationId)
|
||||
-> 当前 requestId / 原生 sessionId / 工作目录
|
||||
```
|
||||
|
||||
- 保留 Main 已解析、校验的 execution space;adapter 每次将其显式交给共享 owner。
|
||||
目录不是 Renderer 新增的任意启动参数,也不依赖 `process.chdir` 或进程级可变 cwd。
|
||||
- owner 持有唯一启动 Promise、client/connection、子进程、会话映射与关闭逻辑。两个
|
||||
工作区同时冷启动时复用同一 Promise;一个调用者取消等待不取消其他使用者的启动。
|
||||
- adapter 的 `dispose` 只释放其引用,不直接关闭同组进程。会话映射由 owner 持有,避免
|
||||
adapter 缓存淘汰丢掉原生续接。会话操作通过 binding 查目录,不从“当前选中项目”反查。
|
||||
- 设置重建沿用现有 reset/drain:新请求取得新配置,旧请求持有启动时的配置直到结算。
|
||||
旧配置不再有在途使用者后释放旧绑定和进程,不能被历史会话 ID 永久钉住。
|
||||
IPC 的 Runtime 设置、定制及能力更新不再主动取消全部请求或清空审批;controller
|
||||
退役仍允许回答其已拥有的问题,但拒绝新请求和未知问题。
|
||||
既有专家调度器仍在配置替换时取消专家子任务,团队父请求一起停止,防止用新模型
|
||||
汇总旧 generation 的部分结果;不为该单独生命周期新建通用退役框架。
|
||||
- 8/16 的旧上限不应继续限制轻量项目 adapter 数量。若保留重型 owner 的容量保护,
|
||||
应统计实际进程配置组及退役进程;不靠提高常数掩盖“一项目一进程”。
|
||||
- 只用进程内 Map、现有 controller、Promise 和普通定时器。会话历史仍以现有 SQLite /
|
||||
Agent transcript 为准,不增加数据库表、迁移、持久化池快照、恢复日志或新配额。
|
||||
|
||||
### 3.2 兼容键与上下文
|
||||
|
||||
先通过 `applyRuntimeSelection` 等现有解析取得有效配置,再生成进程键;公开的 selection
|
||||
仍按现有语义保存,不能把“跟随默认”永久改写成某个显式 profile。
|
||||
|
||||
| 范围 | 参与进程兼容性 | 留在会话/请求 |
|
||||
| --- | --- | --- |
|
||||
| 本机 OpenCode | 本机账号、二进制身份、有效模型连接/配置、启动环境、原生配置和插件组合 | 目录、conversation/session、Prompt、模式、当前权限和问答、请求级 MCP/知识 token |
|
||||
| 本机 Harness | Host/依赖版本、有效模型连接、图片能力、凭据引用、启动环境、启用插件及配置、启动 Skill 集合 | cwd、conversation/session、请求模式、Main Web/MCP 代理与用量归属 |
|
||||
| SSH OpenCode | 已有 Host/SSH 身份、Agent/Runtime installation identity、模型协议/连接、原生插件与启动配置 | Workspace、binding/session、operation、请求凭据和账本归属 |
|
||||
|
||||
第一轮只合并同一有效模型连接及等价默认选择,不顺便支持任意不同模型连接共享:
|
||||
本机 OpenCode 配置使用固定 provider ID,Harness 用量也读取 Host config,跨 profile
|
||||
混用会引入另一项真实风险。不同 profile ID 不通过比较或序列化明文 API Key 来合并;
|
||||
密钥、Header 和插件配置更新通过现有设置失效路径使旧 owner 退役。
|
||||
|
||||
调研基线的 SSH launch profile 把 workMode 写入原生启动配置。当前实现把工作模式
|
||||
放入 Session/当前 Prompt 路由,并设置原生 Session 工具规则,详见 §6.1;
|
||||
不能仅从兼容键中删除 workMode,也不向用户增加逐工具审批。
|
||||
|
||||
## 4. 本机 OpenCode
|
||||
|
||||
在 [opencode-runtime.ts](../../../src/main/agent/opencode-runtime.ts) 中抽出重型 owner,
|
||||
由 [create-runtime](../../../src/main/agent/create-runtime.ts) 的 Main composition 复用。
|
||||
保留工作区 adapter,逐项替换隐式 `options.defaultWorkspace`:
|
||||
|
||||
1. create/get/delete Session、Prompt、历史初始化和压缩。
|
||||
2. 原生能力、工具、文件、Skill、MCP 查询的 directory。
|
||||
3. SSE 订阅、父子 Session 归属、待答 question 的 directory 与原生 ID。
|
||||
4. 权限更新、abort、问题答复/拒绝、MCP 临时注册及最终清理。
|
||||
|
||||
会话绑定记录规范化绝对目录及原生 sessionId。同一 conversation 的请求继续串行,
|
||||
不同 conversation 并行,不引入覆盖整个 Server 的 Prompt 队列。
|
||||
现有 `snapshot: false` 必须保留。
|
||||
|
||||
`mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner
|
||||
串行协调。注册名称、工具可见性、knowledge token 和清理仍按请求区分,不能用一个全局
|
||||
“当前 MCP 列表”覆盖同目录或跨目录的其他请求。探针未覆盖动态 MCP,这是实施验收项。
|
||||
|
||||
第一轮保留现有按请求消费事件流和结束清理的方式,扩展目录及 Session 过滤即可。
|
||||
只有测出重复 SSE 消费是瓶颈时才另做 owner 级事件分发,不同时引入第二套事件总线。
|
||||
健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server
|
||||
无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。
|
||||
|
||||
## 5. DeepSeek Harness
|
||||
|
||||
### 5.1 共享 Host 的最小改法
|
||||
|
||||
- 将 `HarnessState`、Utility 启动 Promise、ACP connection 和事件路由提升到兼容配置
|
||||
owner;Runtime adapter 保留请求工作区。
|
||||
- 每个新 Session 在 Main 解析 `realpath`,Host 校验绝对路径格式,
|
||||
不再要求它等于 Host 启动工作区;`meta.cwd` 传入已有 Agent factory。
|
||||
不依赖 Windows 大小写字符串猜测,也不取消路径存在性校验。
|
||||
- 保留一个本地 Filesystem、Subprocess 和平台 Shell Provider。Session 使用原生 cwd
|
||||
路由;非 Session 的清单查询显式传入查询工作区,不能回退到第一个项目。
|
||||
- 保留 `setup(agentCtx)` 注册 Skill 和工具视图、每 Session 的 Ask definition 身份校验、
|
||||
`sessionId + requestId` 准备及事件路由。Main 的 Web/MCP callback 按对应请求找
|
||||
ToolProvider,不挂到某个最先创建的 adapter 上。
|
||||
- 第一轮保持同 profile、图片能力和插件组合共享,`queueUsage` 的模型配置因此仍一致。
|
||||
给用量加跨会话断言,不借本次改造扩大跨模型 Host 能力。
|
||||
|
||||
第三方插件的初始化可能依赖配置中的路径,不能因为内置工具支持 cwd 就声称所有插件
|
||||
天然多工作区安全。已启用插件组合属于进程配置;对实际产品支持的插件验证它使用
|
||||
Agent scope/Session cwd。若某插件确实有项目级初始化状态,将该状态显式作为不兼容
|
||||
启动配置,而不是默认给所有工作区各起一套 Provider 或禁止 Execute 工具。
|
||||
|
||||
### 5.2 状态与取消
|
||||
|
||||
被动 `getStatus` 改为已有 owner 快照或本地配置/Host 资源检查,不调用 `getState()`。
|
||||
没有启动过时不能写“握手已验证”,可表示资源已安装、尚未连接。
|
||||
状态检查不取得执行引用,也不清除或重置无会话 owner 的空闲计时;owner 已回收时,
|
||||
临时配置检查对象用完即释放,不将其登记成新的执行 owner。
|
||||
用户显式连接测试与原生清单可启动共享 owner;已有 owner 时只做有界控制检查,最终
|
||||
释放的是检查引用,不能在 `finally` dispose 掉仍服务其他会话的 Host。
|
||||
|
||||
当前取消超时会 terminate 整个 Utility。共享后先取消并释放目标 Session 的 Agent /
|
||||
工具资源,不因一个请求结算较慢就杀掉仍健康服务其他 Session 的 Host。若真实控制面
|
||||
已失去响应,保留整个 Host 的有界终止,向受影响会话分别报告中断;不得把它说成
|
||||
“取消完全不影响其他任务”。同步插件卡死的进程级故障无法在同一 JS 进程中强行隔离,
|
||||
不为此新增通用恢复框架。普通长工具取消与整 Host 故障必须分开测试。
|
||||
|
||||
## 6. SSH OpenCode 与 Agent
|
||||
|
||||
本节定义当前源码的共享进程改造;当前已发布版本仍见
|
||||
[远程技术设计](../remote-host/technical-design.md)。不能删除或绕过其持久 binding、
|
||||
operation、transcript、ACK 和模型 dispatch 结果不确定性规则。
|
||||
|
||||
### 6.1 连接与所有权
|
||||
|
||||
将 `RuntimeAcpBackend` 的进程 owner 与 binding 分开:
|
||||
|
||||
```text
|
||||
Agent 当前 Runtime installation + 兼容配置
|
||||
-> 一个 process owner / 一条 ACP ClientSideConnection
|
||||
-> sessionId -> binding -> 当前 operation / 原始 Prompt Promise
|
||||
```
|
||||
|
||||
- 一条 stdio 流只交给一套 ACP decoder/connection,不能让几个 ClientSideConnection
|
||||
竞争读取同一 stdout。消息和 permission 按 sessionId 分派给 binding 的当前 operation。
|
||||
- `AgentAcpConnection` 统一连接级握手、stdio 解码与退出监听,Prompt
|
||||
生命周期仍留在 binding。同一 Session 串行,其他 Session 不等待它完成。
|
||||
- `closeAcpChannel`、取消和 release 只释放所属 binding/Session;最后一个 binding
|
||||
离开后空闲 60 秒关闭进程。关闭 Session 时等待原生 `closeSession` 和原始 Prompt
|
||||
结算,不等待排在 backend 控制队列后面的完成回调。只有原生控制面不响应时才有界
|
||||
终止共享进程,并向其余请求报告中断,不声称这类故障可以逐 Session 隔离。
|
||||
- terminal 与物理进程状态分开:取消/失败的 Session 也可返回
|
||||
`processTree: running`,表示进程继续服务其他 Session,不能伪报进程树为空。
|
||||
Desktop 与 Agent 须使用配套的新契约;原始字节透传 binding 仍保留独占进程规则。
|
||||
- 共享 stdio owner 不使用单 Prompt 的累积输入和墙钟期限管理整进程;单次写入、
|
||||
待写队列、输出背压继续有界,Prompt 输入与截止时间由 binding 管理。
|
||||
- SSH attach 仍不拥有活动 Prompt,断线不能取消或重发 Agent 已接受的工作。
|
||||
历史 binding 的语义和原生 sessionId 保留;内存进程索引从当前安装和已有绑定得出,
|
||||
不增加第二份持久进程恢复账本。
|
||||
- 同一 PID 的多个 binding 不代表同一权限或同一 operation。Host/installation identity
|
||||
变化仍使用已有失效和退役规则,不把不同 Host、账号或 Runtime generation 合并。
|
||||
|
||||
原生 `chat.message` 在模型请求前设置该 Session 的工具规则:Execute 允许工具;
|
||||
Ask 只开放原生只读、搜索和读取网络内容的工具,不开放 Shell/编辑/写入。
|
||||
OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父
|
||||
Session 的拒绝规则、不继承允许规则,因此子 Session 也按根 Prompt 设置规则,再
|
||||
保留原生子代理的显式限制。不能只把进程启动配置统一设成 `ask`,否则子代理工具会等待。
|
||||
|
||||
### 6.2 模型桥归属
|
||||
|
||||
选用锁定 OpenCode 已有的 **`chat.headers` 会话标记**,扩展当前受管
|
||||
[原生插件](../../../src/agent-daemon/opencode-subagent-plugin.ts),不靠解析正文、猜测
|
||||
“当前最新 Prompt”或重写用户模型名来路由:
|
||||
|
||||
1. 现有 Agent prepare/start 顺序在 helper 的进程内登记 Session/根 Session 与
|
||||
binding、operation;插件从原生 sessionID 及已建立的父子关系取得精确归属。
|
||||
2. 在请求生成时固定所属 operation,将有界内部归属 Header 加到 loopback 模型请求。
|
||||
归属必须覆盖原生子代理、自动/手动压缩,不能等 HTTP 到达后才绑定“目前活动的请求”。
|
||||
3. helper 检查它是否仍属于已登记的 Prompt,选择对应现有 Agent gateway/凭据上下文。
|
||||
转发真实提供方前剥离内部 Header;不将其当作密钥或新增认证体系。
|
||||
4. 当前 helper 的单 socket/全局 dispatchBusy 改成按 operation 隔离的派发状态;
|
||||
每个 operation 的现有轮次、顺序、字节和结果不确定性规则不变,取消 A 只关闭 A 的
|
||||
提供方流,不关闭 B 的 socket、response 或待处理请求。
|
||||
5. Prompt 终态撤销对应映射及凭据;迟到请求不能借用下一轮 operation。保留
|
||||
`snapshot: false`、关闭不消费的 title 请求和现有父子问题转交。
|
||||
|
||||
普通 ACP 双 Session 的 Header 路由已有探针证据;子代理、压缩和终态竞态的实际
|
||||
验证结果以进度记录为准。若锁定版本某条实际调用路径不触发 hook,先定位那条原生路径,
|
||||
不得悄悄用全局串行 Prompt 或一个共享凭据槽冒充并行复用。
|
||||
内部桥字段如需调整,按既有受管 Agent/Runtime 版本配套交付,不移动发布标签,不给
|
||||
旧 Agent 宣称具备新行为。
|
||||
|
||||
### 6.3 恢复与终态投影
|
||||
|
||||
- Agent-owned 语义 Prompt 不一定产生原始 ACP journal 行。binary replay 仍校验现有
|
||||
cursor 边界,但不存在 raw cursor 时不向 journal ACK 0;不虚建记录来满足 replay。
|
||||
- 同一 Agent 断线恢复后,保留的 Session binding 使用新 transport generation,
|
||||
Runtime 可以同时接纳新 Session。安装身份已经更换的 attach-only 请求明确失败,
|
||||
不能无限重试阻塞项目,也不能借此重发旧 Prompt。
|
||||
- Desktop 重启时,同项目的可恢复任务并行附加,不能等待一个待答 Prompt 结束才附加
|
||||
第二个。live `question`/`question-resolved` 不属于有 provenance 的 transcript,
|
||||
使用既有 pending Map,清除时只影响对应 request/question。
|
||||
- `conversations.list` 将恢复租约的 request/message ID 和待答列表投影为可选
|
||||
`activeRequest`,Renderer 合并表单并使 Stop 指向真实请求。保存 header/消息时不保存
|
||||
这个实时字段。回答/跳过成功由 Main 按 task → assistant message 归属更新既有
|
||||
`answeredQuestions`,不写另一份问题日志,不覆盖流式正文、已完成或失败状态。
|
||||
- 明确确认的取消使用现有消息/任务终态事务一并结算,避免 Task cancelled 而消息仍
|
||||
streaming。仍无法确认的 Stop 保持可恢复;连接中断本身不是已取消或已失败的证据。
|
||||
|
||||
## 7. Continue、直连模型与相邻内存问题
|
||||
|
||||
### 7.1 Continue
|
||||
|
||||
保留每请求 Host 的隔离和最终清理。1.5.47 的单 Session、QuizService/权限队列意味着
|
||||
“一个 HTTP Server”不等于可安全承载多会话;这一例外有锁定源码依据。
|
||||
|
||||
`getPreparedHost()` 已缓存本 adapter 的准备 Promise,磁盘也有按内容确认的补丁产物。
|
||||
先分别测量读取/哈希/补丁准备、Utility 启动、HTTP ready、首个提供方请求和退出耗时。
|
||||
若确认跨 adapter 重复准备明显,再将只读准备结果与启动 Promise 按已验证的 CLI
|
||||
identity/补丁版本放到 Main composition 复用;失败仍允许重试。不得复用临时 config、
|
||||
token、global dir、活动 Session 或权限/问题队列。设置替换及窗口状态刷新也纳入测量。
|
||||
|
||||
不在本次给 Continue 加多 Session 补丁或常驻池。将来只有锁定版本真正支持多 Session
|
||||
且生产桥验证通过,才替换这一实现。
|
||||
|
||||
### 7.2 直连模型及历史详情
|
||||
|
||||
- 不新增模型 Utility 或进程池。检查 `conversations`、`knownConversationIds`、
|
||||
summary、ToolProvider 与 DirectModelProcessService 的实际引用与 release 调用链。
|
||||
- 活动请求、会话可续读的有界输出和数据库历史是不同资源。请求结束回收临时闭包/
|
||||
callback;会话释放才结束其可续读输出和工具资源,不在工具刚完成时删掉用户可查看结果。
|
||||
- 当前 50 个会话的 history 限制不是字节上限。先采堆与 retained size,再决定可从
|
||||
SQLite 重建的历史缓存是否使用字节约束和 LRU,不能先增加一套持久历史副本。
|
||||
- Renderer 最近 100 个会话整包 metadata 加载另做列表摘要、当前会话分页、展开后取
|
||||
详情的性能改造;本方案只记录依赖,不将这一尚未设计完成的 UI 改造算进进程修复交付。
|
||||
必须保持完整历史、搜索、重新打开和当前活动事件可用。
|
||||
- 旧 Runtime 磁盘缓存可单独评估版本清理策略,不在本次研究或进程 idle 时删除安装、
|
||||
用户工作区、数据库、备份或历史。
|
||||
|
||||
## 8. 内部实施顺序
|
||||
|
||||
| 阶段 | 具体工作 | 必须产出的证据 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 固化本次原生探针为聚焦回归;补有效 selection/进程计数和状态启动计数 | 修复前能复现重复启动,失败原因不依赖真实用户配置 |
|
||||
| P1 | Main composition、兼容键、owner 与 adapter 生命周期;本机 OpenCode 全入口目录化 | 真实 UI 两项目四会话同一 Server,文件/工具/问答/取消不串线 |
|
||||
| P2 | DeepSeek 共享 Utility、会话 cwd、被动状态、请求级代理及取消收敛 | 实际 Utility/ACP 两项目四会话,原生工具与插件验证,状态刷新无额外 Host |
|
||||
| P3 | Agent process/session owner、ACP 单连接路由、Header 模型桥、权限和终态清理 | 开发中的真实 Linux x64 Host,含多会话工具、断线/重连和结果不确定性 |
|
||||
| P4 | 全 Runtime 资源回归;Continue 冷启动分解与有证据的准备复用;直连对象释放检查 | 相同输入的前后进程/内存/CPU/磁盘/延迟报告;没有残留测试进程 |
|
||||
|
||||
这是依赖顺序,不是发布一半实现的许可。涉及的正常生产 UI 路径、远端专用修复和下节
|
||||
验收全部完成后,才能称为 Runtime 复用修复。Continue 测量若没有显示重复准备成本,
|
||||
明确记录不改;不要为凑齐阶段增加代码。历史 UI 分页是后续独立工作。
|
||||
|
||||
## 9. 验收与性能比较
|
||||
|
||||
### 9.1 必测场景
|
||||
|
||||
| 编号 | 对应需求 | 场景与通过条件 |
|
||||
| --- | --- | --- |
|
||||
| V1 | FR-RR1、FR-RR2 | 全新 profile 仅查看状态、切换设置,OpenCode/DSH 重型启动次数为 0;已有 Host 时刷新不新建;清单与首个真实请求并发只有一次初始化 |
|
||||
| V2 | FR-RR1、FR-RR3 | 同一有效模型连接,两个独立项目各两会话同时自然开发,OpenCode/DSH 各一重型进程;各项目同名不同内容文件、命令 cwd、Git/测试结果都正确 |
|
||||
| V3 | FR-RR3 | 第二轮复用同一批会话;Ask/Execute 混合,Ask 写入被边界拒绝,Execute 不增审批;Skill、MCP、图片能力、用量和业务问答不串归属 |
|
||||
| V4 | FR-RR3、FR-RR4 | 一会话取消真实长工具/子代理时另外三会话完成并通过独立项目测试;分别检查请求 abort、SSE、问题映射和工具句柄释放 |
|
||||
| V5 | FR-RR4 | 关闭面板不取消工作;释放一个会话不关闭共享进程;无会话 owner 空闲后退出;App 退出后本机受管进程无残留,远端按已有 detach 规则保留 Agent-owned 工作 |
|
||||
| V6 | FR-RR1、FR-RR4 | 等价默认/显式选择复用;不同实际连接/插件配置不串用;配置更新期间旧请求完成,旧 generation 不永久积累;超过八个项目不因轻量 adapter 报重型容量满 |
|
||||
| V7 | FR-RR5 | 当前源码 Desktop → SSH → 当前开发 Agent → 原生 Runtime,两个项目/四会话;断线、重连、取消、重复 ACK/事件、迟到模型请求不重复调用或跨 operation 归账 |
|
||||
| V8 | FR-RR6 | Continue 并发请求仍各自正确清理;直连 HTTP 不生出额外模型进程;会话 release 后工具/分页输出资源释放;性能采样不靠删除历史或禁用工具达标 |
|
||||
|
||||
V2/V4 必须是正常开发任务,模型可自然调用读写、Shell、测试和已支持的原生子代理;
|
||||
不是要求模型“不用工具”后回复一行。验证产出应用本身可运行、独立测试通过,模型口头
|
||||
说完成不算完成。每个 Runtime 只验证它已经支持的能力,不伪造 DSH/Continue 子代理。
|
||||
|
||||
### 9.2 采样口径
|
||||
|
||||
- 使用同一源码变更前后、同一机器、隔离 profile 与等规模历史副本;不改写用户运行库。
|
||||
覆盖至少三次冷启动、五次热请求,以及三轮多项目任务和至少十分钟**确无新任务**的
|
||||
空闲观察。分别记录中位数、范围与实际样本数,不从一次采样推断泄漏已消失。
|
||||
- 记录 Main、Renderer、Runtime 和工具子进程的 Private Commit、工作集/RSS、堆、
|
||||
CPU、PID/启动次数、会话数、活动请求、SSE/ACP 订阅与残留句柄。Windows 工作集求和
|
||||
会重复计算共享页,Private Commit 也不等于独占物理 RAM。
|
||||
- 同时报首轮准备、HTTP/ACP ready、首个模型请求、首次可见输出和总耗时。降低进程数
|
||||
但把不同会话全局串行化,不能通过验收。
|
||||
- 数据库/WAL 字节与新增事件负载分开;进程 I/O 包含管道,不能称为物理磁盘读写。
|
||||
继续检查未变化工具 upsert 为 0,旧历史读取/搜索/展开正常。
|
||||
- 核心硬指标是每个兼容组一重型进程、无额外被动启动、无跨会话错误、无结束后持续
|
||||
增加的活动句柄、无重复提供方 dispatch。没有证据前不承诺“固定省 527 MiB”或
|
||||
“整个 App 降到某个 RAM 上限”;原生 Session 仍保留时必须单独说明其常驻成本。
|
||||
|
||||
### 9.3 源码与平台验证
|
||||
|
||||
定向覆盖 manager/controller、各 Runtime lifecycle、Harness Host/control plane、
|
||||
ACP backend/owner、模型桥与插件,随后执行仓库要求的 `npm test`、`npm run typecheck`、
|
||||
`npm run lint`;生产构建改动在开发阶段按仓库规则构建验证。
|
||||
|
||||
凡改变 Agent 或桌面到 Agent 路径,必须在开发期间按
|
||||
[真实 Host 验证规则](../remote-host/technical-design.md#agent-开发期间的真实-host-验证)
|
||||
使用当前源码的共享 Linux x64 Host 验证。Windows 原生 ACP 探针、旧版本 Host、mock、
|
||||
CI 和将来发布测试都不能替代它。Windows PowerShell 与 Linux Bash 的工作目录及取消
|
||||
分别验证;macOS/Linux arm64 生命周期与原生依赖由可用原生平台和 CI 补齐并明确报告。
|
||||
|
||||
当前源码的真实 Linux Host、完整生产 UI 和最终整仓门禁结果均保留在进度记录;
|
||||
各次失败、修正、模型调用数及未测平台也一并记录,不以本机单测或原生协议探针
|
||||
替代完整产品验收。完整 App 性能样本保留用户要求的按钮位置调整,属于包含该 UI
|
||||
差异的实测对照,不是纯 Runtime 单变量实验。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 会话媒体生成
|
||||
|
||||
状态:设计草案,尚未实现。日期:2026-09-15。
|
||||
|
||||
用户添加生图或生视频模型后,可以开启“允许 AI 在会话中调用”。支持工具调用的聊天模型通过 GoodBuddy 使用这些模型,图片和视频返回当前会话。切换聊天模型或 Agent Runtime 时,生成模型配置、已提交的生成操作和历史成果各自保持明确的归属。
|
||||
|
||||
本目录负责普通 LLM 调用媒体模型的产品行为、调用入口和生命周期。现有直连图片协议及连续修改仍由[图片生成](../image-generation/README.md)维护;本设计不表示现有功能已改变。
|
||||
|
||||
## 首期范围
|
||||
|
||||
首期以图片为主:在直连文本 Model Runtime 中调用已有生图协议,支持文生图及协议已有的参考图编辑。提供模型调用开关、全局默认和本次指定模型,复用图片成果展示。切换聊天模型或离开会话后,已提交生成继续执行,结果保存到原会话。
|
||||
|
||||
视频、其他 Runtime 的自动调用、会话级生成模型选择、独立生成表单及跨重启查询恢复留到后续。首期只需 `generate_image` 核心工具,Skill 按实际使用需要补充。需求的阶段划分以[产品需求](./prd.md)为准;下文各设计保留的后续规则不作为首期实现要求。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [产品需求](./prd.md) | 范围、需求编号与产品验收 |
|
||||
| [用户场景](./user-stories.md) | 用户操作及场景验收 |
|
||||
| [行为规则](./logic-design.md) | 选择优先级、切换规则、状态和失败处理 |
|
||||
| [界面设计](./ui-design.md) | 配置入口、会话操作、结果卡片和反馈 |
|
||||
| [技术设计](./technical-design.md) | Skill、工具、Main 服务、存储与 Runtime 接入 |
|
||||
| [进度记录](./progress.md) | 当前代码事实、剩余工作与验证证据 |
|
||||
|
||||
## 术语与依赖
|
||||
|
||||
聊天模型负责理解请求、调用工具和组织回答;生成模型负责图片或视频输出;Agent Runtime 承载聊天模型及工具执行。Skill 提供能力使用说明,工具提供结构化调用入口,两者不能互相替代。
|
||||
|
||||
“生成操作”指一次媒体生成及其结果记录,不自动创建任务中心的 Task,也不引入新的通用 Job 调度系统。领域边界遵循 [Task 与 Job 模型](../task-and-job/task-and-job-model.md)。
|
||||
|
||||
- [模型连接](../model-connections/README.md):连接、凭据、协议及请求定制。
|
||||
- [直连文本 Agent](../direct-model-agent/README.md):文本工具循环与上下文。
|
||||
- [远程主机](../remote-host/README.md):远程 Runtime 与桌面通信。
|
||||
- [统一界面规则](../../../UI-DESIGN.md):控件、反馈和可访问性。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 行为规则
|
||||
|
||||
状态:设计草案。此文是模型选择、切换与生成状态的权威定义。
|
||||
|
||||
## 首期适用规则
|
||||
|
||||
阶段范围以[产品需求](./prd.md)为准。以下 L-1 至 L-5 保留完整目标,首期按这些限定执行:
|
||||
|
||||
- L-1:只在直连文本 Model Runtime 暴露图片工具;未接入的 Runtime 或无工具模型显示限制,沿用已有直连图片工作流,不新增显式生成入口。
|
||||
- L-2:新建图片按“本次明确指定 > 全局默认”选择;编辑按“本次明确指定 > 原成果模型 > 全局默认”选择。只有前一项未设置时才查下一项;选定模型失效时提示改选。
|
||||
- L-3:提交时固定生成模型、连接参数、素材和原会话。首期没有会话级生成选择;新请求在实际提交时解析默认值。切换到未接入的 Runtime 只影响后续自动调用,不取消已提交操作。
|
||||
- L-4:对图片请求复用进行中、已完成、失败和已停止等待的状态表达,保存成功后才完成,不预建视频任务状态机。取消后若仍取得成果,更新原卡片并说明取消未生效。重启后未完成记录显示结果未确认,不查询恢复或自动重提。人工重新生成创建新操作;首期不提供独立保存重试。
|
||||
- L-5:图片成果引用、无视觉模型的文字上下文及会话归属规则全部适用。
|
||||
|
||||
## 能力与调用时点
|
||||
|
||||
L-1(FR-1、FR-3、FR-7、FR-9;US-01、US-08、US-11):会话自动生成需要模型连接允许调用、对应协议可执行、Runtime 能暴露工具、聊天模型支持工具调用,并且请求处于 Execute。Skill 只描述这些真实能力。无工具能力时,显式生成入口直接调用同一服务;Ask 下生成入口不能绕过只读限制。
|
||||
|
||||
请求开始和聊天模型、Runtime、配置切换后重新计算可用能力。运行中的聊天回合保留原有工具协议,Main 在真正提交生成前检查最新可用性。这样已关闭的模型即使残留在该回合的工具列表中,也不能接受新操作。
|
||||
|
||||
## 模型和素材选择
|
||||
|
||||
L-2(FR-2、FR-5;US-02、US-03、US-05、US-07、US-14):先确定生成类型和操作意图,再按下表选模型。图片与视频的默认值独立保存。
|
||||
|
||||
| 操作 | 选择优先级,由高到低 |
|
||||
| --- | --- |
|
||||
| 新建图片或视频 | 用户本次明确指定 > 当前会话明确选择 > 全局默认 |
|
||||
| 编辑历史成果 | 用户本次明确指定 > 当前会话明确选择 > 原成果模型 > 全局默认 |
|
||||
|
||||
只有较高优先级未设置时才查看下一项。已选目标被删除、关闭或不支持所需操作时,要求改选,不能静默跳过。没有默认值时由用户选择,可以同时设为默认;名称歧义必须解析到唯一连接后再提交。LLM 不得无依据地填写模型 ID。
|
||||
|
||||
显式附件或明确点选的成果优先。用户使用“刚才那张”等明确指代且仅有一个匹配对象时才引用历史图;多义指代先澄清。新建不相关图片不附带旧图。图生视频复用素材引用,但按视频模型选择顺序处理,不沿用图片模型。
|
||||
|
||||
编辑能力不足或原图缺失时保留输入并提示。新工具路径不自动将编辑降为纯文生图;用户可以明确选择按文字重新生成。已有直连路径的降级规则仍见[图片技术设计](../image-generation/technical-design.md)。
|
||||
|
||||
## 切换规则
|
||||
|
||||
L-3(FR-4;US-03 至 US-06、US-11):生成服务接受提交时固定所属会话、触发消息、实际生成模型、生成参数与素材引用。用户尚未发送的草稿使用发送时配置;已发送且排队的请求保留会话选择,跟随全局默认的选项在实际提交时解析。该时点以后不再重选模型。
|
||||
|
||||
| 变化 | 未提交操作 | 已提交操作与成果 |
|
||||
| --- | --- | --- |
|
||||
| 切换聊天模型 | 下一回合使用新模型及其可用工具 | 不改变生成模型;原回合按 Runtime 切换机制结束,操作独立继续 |
|
||||
| 切换 Runtime | 下一回合按新 Runtime 的工具接入能力执行 | 不依赖旧 Runtime 进程;不向新进程补发未配对的旧工具响应 |
|
||||
| 切换全局默认 | 跟随默认的操作解析新默认 | 继续原模型 |
|
||||
| 切换会话生成模型 | 后续操作使用新选择 | 继续原模型 |
|
||||
| 关闭或删除生成连接 | 拒绝该连接的新操作,提示改选 | 已发请求尽力完成;若后续查询所需连接已失效,显示无法继续查询 |
|
||||
| 更改地址、凭据或请求参数 | 新操作用新配置 | 当前操作不混用新端点;重启后连接已改变则提示无法继续原操作 |
|
||||
| 离开或重开会话 | 会话选择独立保留 | 只更新所属会话,不能写入当前打开的其他会话 |
|
||||
|
||||
切换聊天模型不会自动取消媒体生成。“停止回复”只结束当前文字回合;生成卡片的“取消生成”单独处理已提交操作。切到 Ask 不撤销先前 Execute 已提交的操作,也不允许新增生成。
|
||||
|
||||
## 操作状态
|
||||
|
||||
L-4(FR-6、FR-8;US-04、US-09、US-10、US-13):状态描述应用实际知道的事实。上游没有百分比时,只显示阶段及已等待时间。
|
||||
|
||||
| 状态 | 进入条件 | 后续处理 |
|
||||
| --- | --- | --- |
|
||||
| 提交中 | 已接受操作,正在发送 | 获得上游响应后生成中或保存中;失败保留原因 |
|
||||
| 生成中 | 上游已接受、尚无最终成果 | 查询状态或等待响应,结果返回后保存中 |
|
||||
| 保存中 | 已获得成果,Main 正在校验和保存 | 本地成果及引用保存成功后才完成 |
|
||||
| 已完成 | 成果可从会话重新读取 | 终态,后续编辑或重试创建新操作 |
|
||||
| 取消中 | 用户请求取消、等待终止结果 | 上游确认停止后已取消;若已完成则展示成果并说明取消未生效 |
|
||||
| 已取消 | 本地已停止等待或上游确认取消 | 区分“已停止等待,上游可能继续生成”与“生成已取消” |
|
||||
| 失败 | 上游明确失败或成果保存失败 | 提供失败原因及适用的重试入口 |
|
||||
| 状态待确认 | 提交响应丢失、断连或应用重启,无法确认上游结果 | 有上游 ID 时查询;不能查询时提示用户,不自动重新提交 |
|
||||
|
||||
同一操作的重复状态通知只能更新同一记录。取消后的晚到结果不恢复旧工具回合,不自动开始下一次生成。若仍能取得成果,可在原卡片标明“取消未生效,已生成”并展示。超时不等于上游取消,不承诺退款。
|
||||
|
||||
重新生成创建独立操作,沿用旧操作的模型和输入,提交前检查可用性;用户改选模型后将新选择显示在新尝试上。保存失败且上游成果仍可读取时,“重试保存”使用原操作 ID,由失败回到保存中并更新原卡片,不再次提交生成。无法确认上游是否接受的请求,不做自动生成重试。
|
||||
|
||||
L-5(FR-5、FR-8;US-07、US-12):历史上下文传成果引用、提示词及实际模型信息。只有具备视觉输入能力的聊天模型才接收适用的图片内容。不能以“有成果引用”推断模型已经看过图片。删除会话沿用已有确认流程,并说明活动生成的取消及不能保证上游停止的情况,删除后晚到结果不能重建会话。
|
||||
|
||||
## 完整性与待定项
|
||||
|
||||
已覆盖首次配置、模型选择、历史编辑、模型与 Runtime 切换、只读模式、失败、取消、重启和成果归属。无产品规则需要在本次文档交付前补充确认。
|
||||
|
||||
视频服务商、协议、取消与查询能力、可接受的文件限制属于实施前的技术选型。远程 Runtime 的工具往返需要原型验证;无法接入时按 L-1 提供显式入口,不把工具接入状态写成已支持。跨设备同步、应用退出后的本地执行不在本次范围。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 产品需求
|
||||
|
||||
状态:设计草案。本文描述目标行为,不作为现有能力说明。
|
||||
|
||||
## 用户问题与范围
|
||||
|
||||
用户希望在文字会话中直接提出“画一张海报”“把背景改成蓝色”“用这张图生成视频”,由聊天助手调用已配置的专用模型。用户不应为了生成媒体而丢失当前聊天入口;切换聊天模型后,也应能继续引用已有成果。
|
||||
|
||||
范围包括模型调用开关、默认与会话选择、文生图、支持协议下的参考图编辑、文生视频及图生视频、结果展示和生成过程管理。视频具体服务商及协议待技术选型;接通真实服务前不能宣称视频可用。
|
||||
|
||||
不包含模型训练、自动采购服务、无工具能力 LLM 的通用文本指令执行器、批量渲染平台、应用退出后仍由桌面后台执行的承诺。直接选择图片模型的已有工作流继续保留。
|
||||
|
||||
## 功能需求
|
||||
|
||||
### 首期交付
|
||||
|
||||
首期只交付图片生成,自动调用先接通直连文本 Model Runtime。支持已有图片协议下的文生图、参考图编辑、历史图片引用与成果保存,不新增图片协议。保留模型调用开关、一个全局默认生图模型及用户本次明确指定;编辑默认沿用原成果模型。首期不持久化会话级生成模型选择。
|
||||
|
||||
切换聊天模型、生成模型或离开会话后,已提交操作仍固定原模型和原会话。切换到尚未接入工具的 Runtime 后,原操作继续,但该 Runtime 暂不能自动发起生图。不支持工具调用时说明限制,用户可使用已有直连图片工作流;首期不增加独立生成表单。
|
||||
|
||||
运行状态、取消、错误说明及人工重新生成属于首期。应用退出后不继续本地生成;重新打开时将未完成操作标明结果未确认,不自动重新提交。跨重启查询恢复和独立“重试保存”入口后续再做。
|
||||
|
||||
### 后续范围
|
||||
|
||||
视频生成与播放、其他本地及远程 Runtime 自动调用、会话级模型选择、独立生成表单、查询恢复及保存重试不纳入首期验收。不为这些能力预建工具、存储字段或界面占位。
|
||||
|
||||
以下 FR 编号保留完整目标;FR-1 至 FR-8 按上述首期范围验收,FR-9 首期只要求准确说明已接通的 Runtime 和使用限制,其余接入后分别验收。
|
||||
|
||||
| 编号 | 要求 | 产品验收 |
|
||||
| --- | --- | --- |
|
||||
| FR-1 | 图片、视频模型分别提供会话调用开关;新增和已有连接默认关闭,由用户主动开启 | 保存后成为对应可用能力,关闭后不能再提交新操作;历史成果保留 |
|
||||
| FR-2 | 支持全局默认、当前会话选择和本次明确指定 | 按[选择规则](./logic-design.md)解析并显示实际模型;不静默改用其他服务 |
|
||||
| FR-3 | 在支持工具调用的聊天模型下,使用自然语言生成媒体 | 从聊天输入到真实服务输出、保存与会话展示完整可用,活动记录可见 |
|
||||
| FR-4 | 支持聊天模型、Runtime 及生成模型切换 | 新请求获得当前能力,已提交操作保持原模型和原会话;切换不触发重新生成 |
|
||||
| FR-5 | 支持历史成果引用与继续编辑 | 重新打开会话后可引用已保存原图;缺失素材或编辑能力不足时有明确处理入口 |
|
||||
| FR-6 | 长耗时生成可独立显示状态、取消与重试 | 离开会话不取消;状态区分上游生成、结果保存、完成及失败,不虚构进度 |
|
||||
| FR-7 | 适配工作模式及能力边界 | Ask 不提交生成;不支持工具调用时仍可用 Execute 下的显式生成入口 |
|
||||
| FR-8 | 图片、视频作为会话成果保存 | 结果可预览和导出,来源模型可追溯;聊天模型不能看图时只接收文字事实及引用 |
|
||||
| FR-9 | 本地和远程 Runtime 有可核验的支持说明 | 自动调用以真实工具往返验收为准;未接通的 Runtime 显示原因及显式生成入口 |
|
||||
|
||||
## 交付标准
|
||||
|
||||
以[用户场景](./user-stories.md)验收产品路径。生图、视频和各 Runtime 分别记录验证结果,不用某一个模型或 Runtime 的成功代替其他路径。配置连接检查成功不等于生成成功,真实生成成果必须在会话中可见且能重新打开。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 进度记录
|
||||
|
||||
## 2026-09-15
|
||||
|
||||
根据用户“首期以图片为主”的意见收敛设计,已同步需求、场景、规则、界面及技术文档的阶段范围。首期接通直连文本图片工具,保留模型切换、原会话归属及历史图片引用;后续范围见[产品需求](./prd.md)。
|
||||
|
||||
本次只修改设计文档,未检查或改变应用实现,2026-09-14 的代码检查记录保留为当日证据,不表示已重新验证。图片首期仍待实施。
|
||||
|
||||
文档验证:7 份文档的一致性审阅未发现首期范围冲突;31 个相对 Markdown 链接均可解析。`deai-writing` 扫描阻断项为 0,31 个复核候选为设计状态、能力职责及行为限制,按语境保留。`git diff --check -- docs/features/conversation-media-generation` 通过;目录当前未跟踪,该命令不覆盖未跟踪文件,内容与链接另行审阅。未运行应用测试或真实生成请求。
|
||||
|
||||
## 2026-09-14
|
||||
|
||||
当前阶段:设计草案已落盘,功能未实现。本次只新增设计文档与相关入口链接。
|
||||
|
||||
代码与文档检查确认以下基础能力:
|
||||
|
||||
| 事实 | 证据 |
|
||||
| --- | --- |
|
||||
| 已有直连图片生成、编辑及历史成果引用 | [图片技术设计](../image-generation/technical-design.md)、`src/main/agent/model-runtime.ts` |
|
||||
| 模型连接及 Main 凭据存储已存在 | `src/shared/contracts.ts`、`src/main/runtime-settings-store.ts` |
|
||||
| 已有 Skill 发现、Runtime 分配及工具分发 | `src/main/capabilities/capability-service.ts`、`src/main/agent/model-tool-provider.ts` |
|
||||
| 会话、消息 metadata 和成果已有 SQLite 存储 | `src/main/assistant/assistant-database.ts`、`src/shared/assistant-contracts.ts` |
|
||||
| 生图尚未成为普通 LLM 的内置生成工具 | 检查 `src/shared/builtin-model-tools.ts` 及工具分发路径 |
|
||||
| 本次检查未发现第一方视频生成协议实现 | 检查模型能力契约、Runtime 及现有功能文档;不据此推断外部 MCP 的能力 |
|
||||
|
||||
剩余工作对应[技术设计实施顺序](./technical-design.md):模型调用设置与选择、图片服务及会话入口、各 Runtime 接入、视频真实服务与成果交付均待实施。视频协议选择和远程工具桥接验证待完成。
|
||||
|
||||
本次未修改应用代码,未运行应用测试或真实生成请求。文档验证记录如下:
|
||||
|
||||
- 相对 Markdown 链接检查:7 份新文档及图片功能入口共 33 个链接,失效链接为 0。
|
||||
- `deai-writing` 扫描:阻断项为 0;人工复核保留设计状态、能力边界和禁止行为等必要限定,以及真实并列的验收项。
|
||||
- 独立一致性审阅发现“重新生成”与“重试保存”的操作身份表述不一致,已在 L-4 和 US-09 中统一:前者创建新操作,后者更新原操作。
|
||||
- `git diff --check` 通过。上述检查只验证文档,不构成功能验收。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 技术设计
|
||||
|
||||
状态:设计草案。拟议字段和工具名用于评审,不表示已存在公开 API。实现 [FR-1 至 FR-9](./prd.md)及[行为规则 L-1 至 L-5](./logic-design.md)。
|
||||
|
||||
## 首期实现边界
|
||||
|
||||
首期调用路径为:直连文本 Model Runtime → `generate_image` → Main 图片生成服务 → 现有图片协议 → 原会话 artifact。提取已有图片请求和保存逻辑,Main 持有活动请求,使聊天模型或 Runtime 切换不释放已提交生成。复用现有成果视图与活动反馈。
|
||||
|
||||
只新增图片会话调用开关和全局默认图片 profile ID;本次指定作为工具参数。生成记录优先放入现有消息 metadata,保存实际模型、输入、状态及成果引用。首期没有会话级生成选择、视频字段、上游任务恢复信息或独立任务表。
|
||||
|
||||
工具说明随每个新回合提供可调用图片模型的脱敏 ID、名称和已支持能力,提交前重新校验配置。先以工具描述承载使用规则;只有实际调用验证表明说明不足时才补充一份共享 Skill,不把 Skill 管理作为交付依赖。首期不注册 `list_media_models`、视频、查询和取消工具,取消通过会话中的操作入口完成。
|
||||
|
||||
工具等待遵循现有超时约束;超出回合等待或发生 Runtime 切换后,Main 仍将结果写回原会话。后续回合读取已保存的操作摘要和成果,不补发旧工具响应。应用重启后未完成记录只提示结果未确认,不建立恢复查询或自动重试机制。
|
||||
|
||||
以下章节描述完整目标中的调用关系和扩展约束。出现视频、会话选择、额外工具、独立生成表单、其他 Runtime 接入和恢复查询时,均属后续设计,不要求首期预建抽象或占位实现。
|
||||
|
||||
## 调用结构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Chat[聊天模型与 Agent Runtime] --> Tool[媒体工具入口]
|
||||
Skill[内置媒体 Skill 使用说明] -.-> Chat
|
||||
UI[会话显式生成入口] --> Main[Main 媒体生成服务]
|
||||
Tool --> Main
|
||||
Main --> Profile[模型连接与凭据存储]
|
||||
Main --> Adapter[图片或视频协议实现]
|
||||
Adapter --> Provider[生成服务商]
|
||||
Main --> Artifact[操作记录与会话成果]
|
||||
Artifact --> Card[原会话生成卡片]
|
||||
```
|
||||
|
||||
Main 持有生成请求、取消句柄和结果保存职责。服务生命周期独立于聊天 Runtime controller,切换或释放 controller 不释放已提交的媒体操作。Renderer 经明确 preload/IPC 方法调用;IPC 使用共享契约校验并保留现有发送方校验。凭据只在 Main 解密,不进入 Skill、工具参数或工具输出。
|
||||
|
||||
从 `ModelAgentRuntime` 提取现有图片请求、校验和成果处理中的可复用部分,使直连图片与媒体工具共用协议实现。直连路径保持原有上下文与编辑降级策略;工具路径依据 L-2 传入明确的素材和降级选项,避免协议重用改变产品语义。
|
||||
|
||||
## Skill 与工具
|
||||
|
||||
提供一份内置媒体生成 Skill,说明新建、编辑、图生视频、模型选择、素材引用和结果使用。开启模型配置仅更新能力目录,不为每个连接复制 Skill。能力目录与工具从同一份有效配置生成;Skill 中不保存可过期的模型名单或凭据。
|
||||
|
||||
拟议工具:`list_media_models` 返回脱敏的可用连接 ID、名称、已支持操作及参数约束;`generate_image`、`generate_video` 提交生成;`get_media_generation` 查询;`cancel_media_generation` 取消。按操作性质接入现有工作模式控制,不因媒体工具增加额外通用审批。
|
||||
|
||||
生成输入包括 `prompt`、可选 `modelProfileId`、显式 `operation`、`sourceArtifactIds` 及协议支持的类型化选项。图片数量、尺寸与质量,以及视频时长、分辨率等按已接入协议校验,不能假定服务商完全兼容。`conversationId`、触发消息及请求身份由运行上下文绑定,不能由 LLM 自由指定目标会话。
|
||||
|
||||
提交后返回操作 ID、实际模型和当前状态;完成结果返回成果 ID 与必要文字信息,不把 Base64 或视频字节塞入工具历史。等待有界,长任务通过查询及卡片更新呈现。原回合仍在执行时,可通过其合法工具响应返回最新结果;回合已结束或 Runtime 已切换时,只更新操作记录与原会话卡片,下一回合读取成果摘要,不自动唤起新 LLM 请求。
|
||||
|
||||
## 配置与持久化
|
||||
|
||||
| 对象 | 最小拟议数据 | 存储职责 |
|
||||
| --- | --- | --- |
|
||||
| 模型连接 | 会话调用开关、适用生成能力 | 复用 settings/profile;现有连接缺少开关时解释为关闭 |
|
||||
| 全局偏好 | 默认图片、视频 profile ID | 复用应用设置;不复制连接及凭据 |
|
||||
| 会话选择 | 图片、视频分别为跟随默认或显式 profile ID | 持久化到会话设置,独立于聊天 Runtime selection |
|
||||
| 生成操作 | ID、原会话/消息 ID、工具调用关联、状态、实际模型 ID 与名称、提示词、有效参数、素材引用、时间、错误、成果 ID | 优先扩展现有会话消息 metadata;实施时评估查询需求后决定是否需要独立表 |
|
||||
| 上游异步信息 | 必要的上游任务 ID、查询所需非敏感参数 | 仅在协议确实支持异步查询时保存;无凭据副本 |
|
||||
| 成果 | 媒体内容及类型、原操作与素材关联 | 复用 artifact 体系;不放入 Skill 或 Runtime 内存作为唯一副本 |
|
||||
|
||||
已提交操作在内存持有当次解析的连接参数;持久记录只保留来源与恢复查询所必需的非敏感信息。应用重启后通过原连接核对是否仍可查询,不增加凭据历史、通用恢复日志或多阶段提交机制。不能确认时采用 L-4 的“状态待确认”。
|
||||
|
||||
操作不自动成为 Task/Job,已有 `runs` 表也不等于通用后台调度已经存在。数据库变更实施前按[迁移规范](../../development/database-migrations.md)评审已发布数据兼容;本次文档不预先引入新表和迁移。
|
||||
|
||||
Main 必须在成果持久化成功后发布完成事件,事件按操作 ID 更新而非重复追加。校验素材来自当前会话可访问的附件或成果,继承现有项目、会话隔离,不允许模型填写任意文件路径代替成果引用。
|
||||
|
||||
## 图片与视频结果
|
||||
|
||||
图片继续遵循[图片技术设计](../image-generation/technical-design.md):校验有界内联图片,不下载 Provider 返回的图片 URL。现有成果内容字符串上限为 5 MiB,这不是原始二进制文件大小,实施时必须正确处理编码开销和超限提示。
|
||||
|
||||
视频不沿用内联图片存储。优先复用 `managed_file` 成果和现有文件导出能力,补充媒体类型、播放所需信息及预览。协议选型必须明确提交、查询、取消、结果读取、认证、文件大小上限、超时和过期时间;最终方案用实际服务的证据补入本文。
|
||||
|
||||
若视频服务只返回下载地址,需在视频协议设计中明确 Main 端的读取来源、认证、重定向及字节限制后实现,不能借用或放宽图片 URL 禁止规则。生成媒体读取不使用应用更新或模型包下载源设置;受管软件包镜像规则也不适用于动态生成的媒体结果。无法保存或播放的上游结果不能标记为交付完成。
|
||||
|
||||
## Runtime 接入
|
||||
|
||||
| 路径 | 设计接入点 | 验证要求 |
|
||||
| --- | --- | --- |
|
||||
| 直连文本 Model Runtime | `builtin-model-tools.ts` 的工具契约及 `model-tool-provider.ts` 分发 | 自然语言请求实际调用生成模型并返回成果 |
|
||||
| 本地 OpenCode、Continue、DeepSeek Harness | 复用各 Runtime 已有工具或 MCP 注入机制,转到同一 Main 服务 | 分别验证发现、调用、查询、取消和切换;不能以 Skill 注入成功代替 |
|
||||
| GoodBuddy Agent 远程路径 | 经现有桌面与 Agent 通信能力将工具请求绑定到原会话,Main 执行生成 | 验证真实 Host 的工具往返、断连和结果归属,凭据不下发远端 |
|
||||
| 不支持工具调用的聊天模型或尚未接入的 Runtime | Renderer 显式入口经 IPC 调用同一服务 | Execute 可完成生成,Ask 不提交,原会话可查看成果 |
|
||||
|
||||
Runtime 进程复用时,新的聊天回合仍需刷新能力目录;不假定修改配置必然重启进程。远程桥接是否可直接复用现有工具分发是实施前的验证项,不能由本地成功推断远程支持。远程断连后桌面仍运行时,可继续管理已提交生成;远程 LLM 停止等待不等于媒体取消。
|
||||
|
||||
## 实施顺序与验证
|
||||
|
||||
1. 首期:实现图片调用开关、全局默认及本次指定,提取图片服务并接通直连文本 `generate_image`。完成文生图、已有协议编辑、成果保存、取消及模型切换后的结果交付。
|
||||
2. 后续按实际需求接通其余本地 Runtime 与远程工具路径,分别验证真实调用及切换。
|
||||
3. 后续评估会话级选择、独立生成入口及恢复能力,确认需要后再增加界面和数据。
|
||||
4. 视频单独选型与验收,实际接通服务后再增加视频工具、异步查询和播放能力。
|
||||
|
||||
首期以图片真实产品路径验收,完成后标记“图片首期完成”,后续能力单独记录。针对首期适用的 L-1 至 L-5 编写选择优先级、配置失效、会话隔离、重复结果、取消及晚到结果测试。验证原直连图片行为不回归,检查进程切换不会重复提交。
|
||||
|
||||
源代码实现后运行 `npm test`、`npm run typecheck`、`npm run lint`。首期真实生成从直连文本聊天输入进入,覆盖[用户场景](./user-stories.md)中的首期验收;记录实际请求数量、模型、Runtime 和结果。后续显式入口需独立验证。涉及 Agent 的变更按[远程主机验证要求](../remote-host/technical-design.md)在真实测试 Host 验证。当前仅写文档,不发起收费生成调用。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 界面设计
|
||||
|
||||
状态:设计草案。遵循[统一界面设计](../../../UI-DESIGN.md),实现 [FR-1 至 FR-9](./prd.md),行为以[行为规则](./logic-design.md)为准。
|
||||
|
||||
## 首期界面
|
||||
|
||||
首期只在图片模型配置提供会话调用开关和“设为默认生图模型”,沿用下文共享控件规范。关闭或删除默认模型后保留失效选择并提示改选,不自动改用其他模型。
|
||||
|
||||
会话以自然语言发起生图,本次指定模型也通过请求表达。不新增会话级模型选择器、独立生成表单或视频入口。未设默认时,助手提示用户本次指定一个可用模型,或到设置中选择默认后重新发送请求;名称有歧义时先澄清。
|
||||
|
||||
复用图片成果展示与引用入口,显示实际模型、进行状态、结果或错误,并支持取消和人工重新生成。首期不增加视频播放和独立“重试保存”按钮。不支持工具调用或 Runtime 未接通时说明限制,提示可切换到支持的聊天路径或使用已有直连图片工作流;Ask 下说明生成需要 Execute,不自动切换模式。
|
||||
|
||||
以下为完整目标的界面设计,按[产品需求的阶段划分](./prd.md)实施;首期不预留后续控件。
|
||||
|
||||
## 模型配置
|
||||
|
||||
在现有模型连接表单中,按已支持的生成能力显示持久开关:
|
||||
|
||||
- 图片模型:“允许 AI 在会话中调用此模型生成图片”。
|
||||
- 视频模型:“允许 AI 在会话中调用此模型生成视频”。
|
||||
|
||||
说明文字:“开启后,支持工具调用的聊天助手可使用此模型。生成结果保存在发起请求的会话中。”开关复用共享 Switch,并提供 `role="switch"`、键盘操作及可见焦点。不要求用户维护 Skill 文件或填写第二套 API 凭据。
|
||||
|
||||
开启后提供“设为默认生图模型”或“设为默认视频模型”操作。列表在对应模型旁显示“可在会话中调用”和默认标识。关闭、删除默认连接时提示会话选择将不可用,保留原选择供改选,不自动指定其他模型。
|
||||
|
||||
## 会话入口
|
||||
|
||||
在现有输入区工具菜单提供“生成图片”“生成视频”,以及对应模型选择。默认项写成“跟随全局设置(模型名称)”;没有默认值则显示“选择生成模型”。选择只影响当前会话,设为全局默认必须使用独立操作。
|
||||
|
||||
自然语言调用与显式入口共用成果卡片。显式入口提供提示词和参考素材选择,按所选模型展示适用参数,不呈现供应商请求字段。输入区显示已引用的素材缩略图或文件名,可移除。
|
||||
|
||||
不支持工具调用时提示“当前聊天模型无法自动调用,可使用生成入口”。Ask 下提示“生成图片或视频需要 Execute 模式”,由用户切换工作模式;不能自动切换。未配置生成模型时给出“添加生成模型”入口。
|
||||
|
||||
## 生成卡片
|
||||
|
||||
卡片就近显示媒体类型、实际模型、当前阶段、成果或错误。参数、提示词和来源素材放在可展开详情中;不显示密钥、认证头或供应商私有下载地址。运行活动使用同一操作标识,与卡片关联。
|
||||
|
||||
图片完成后可预览、下载和“继续修改”;存在可用图生视频能力时提供“生成视频”。视频完成后提供带原生播放控制的预览及下载,不自动播放。点击继续修改会把成果加入输入区,用户填写本次修改要求。
|
||||
|
||||
进行中显示“取消生成”,取消结果按行为规则展示。失败显示有针对性的“重试”“重新选择模型”“重新选择素材”或“重试保存”。卡片错误不重复弹出全局错误通知;模型设置保存等非上下文反馈使用应用通知。
|
||||
|
||||
窄窗口中操作允许换行,媒体宽度不超过消息区;模型长名称可截断并提供完整可访问名称。状态使用文字表达,不能只依靠颜色;阶段变化通过适度的 `aria-live` 提示,避免等待时间逐秒播报。弹出选择器关闭后焦点回到触发控件。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 用户场景
|
||||
|
||||
状态:设计草案。表中 Given、When、Then 分别为前提、操作和预期结果。
|
||||
|
||||
## 首期验收
|
||||
|
||||
原 US 编号保留完整目标。首期在直连文本 Model Runtime 验收 US-01、US-03、US-04、US-07、US-12、US-14,其中切换后的自动编辑要求目标聊天模型支持工具调用。切换到其他 Runtime 时,只验证原生成继续并写回原会话,不要求新 Runtime 能发起生成。
|
||||
|
||||
其余场景的首期预期如下,以[首期行为规则](./logic-design.md)为准:
|
||||
|
||||
| 场景 | 首期验收 |
|
||||
| --- | --- |
|
||||
| US-02 | 未设默认时提示本次指定模型或去设置默认;重新发送后生成,不新增模型选择弹窗 |
|
||||
| US-05 | 当前操作使用 X,修改全局默认为 Y 后,新建图片使用 Y,旧操作保持 X |
|
||||
| US-06 | 默认或本次指定的 X 不可用时提示改选,历史图片保留;没有会话级选择 |
|
||||
| US-08 | 无工具能力时说明限制及已有直连图片工作流;Ask 不提交生成 |
|
||||
| US-09 | 图片生成可取消并如实显示结果,人工重新生成创建新操作;没有独立保存重试 |
|
||||
| US-13 | 应用重启后未完成图片操作显示结果未确认,不自动查询或重新提交 |
|
||||
|
||||
US-10 视频生成、US-11 远程自动调用,以及下表中超出上述范围的预期,留到后续验收。
|
||||
|
||||
## 完整目标场景
|
||||
|
||||
| 编号 | 需求 | Given | When | Then |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| US-01 | FR-1、FR-3 | 已添加生图模型并开启会话调用,已选默认模型 | 在支持工具的文字模型中要求画海报 | 活动中显示调用,完成后原会话出现图片及实际模型 |
|
||||
| US-02 | FR-2 | 有两个可调用生图模型,未选默认 | 请求生成图片 | 提供模型选择入口,选择后继续原请求;不会自行按列表顺序提交 |
|
||||
| US-03 | FR-4、FR-5 | 模型 A 已生成图片 | 切换聊天模型 B,要求修改该图 | B 获得成果引用和可用工具,编辑模型按规则选择 |
|
||||
| US-04 | FR-4、FR-6 | 一个生成操作已经提交 | 切换 Runtime 并打开其他会话 | 原操作继续,结果只写回原会话;新 Runtime 不重复调用 |
|
||||
| US-05 | FR-2、FR-4 | 会话跟随全局默认,当前操作使用模型 X | 将默认改成 Y,再发一个新建图片请求 | 旧操作仍用 X,新操作用 Y,卡片显示各自来源 |
|
||||
| US-06 | FR-4 | 会话明确选择 X | X 被关闭或删除后请求生成 | 显示 X 不可用并允许改选;历史图仍可打开 |
|
||||
| US-07 | FR-5、FR-8 | 已保存生成图并重新打开会话 | 显式引用原图,指定另一个支持编辑的模型 | 新模型接收原图;成果丢失时要求重新选择素材,不能假装已编辑 |
|
||||
| US-08 | FR-7 | 切换到不支持工具调用的文字模型 | 点击会话显式生图入口 | Execute 下可生成并回写;Ask 下说明需要切换 Execute,不能发出生成请求 |
|
||||
| US-09 | FR-6 | 操作正在生成或保存结果 | 点击取消,或失败后选择重试 | 如实显示取消结果;重新生成创建独立操作,重试保存更新原操作,不自动重复收费请求 |
|
||||
| US-10 | FR-3、FR-8 | 已接通支持图生视频的模型 | 从图片成果选择生成视频 | 使用所选图片,视频完成并保存后可播放、导出,重开会话仍可访问 |
|
||||
| US-11 | FR-4、FR-9 | 远程 Runtime 已接入媒体工具 | 在远程会话中生成后切换本地 Runtime | 结果与活动属于原会话,凭据留在 Main,后续请求可引用成果 |
|
||||
| US-12 | FR-5、FR-7 | 新聊天模型没有视觉能力 | 请求分析已生成图片或继续修改 | 说明无法直接查看图片;可根据用户文字调用编辑工具,不编造视觉观察 |
|
||||
| US-13 | FR-6 | 上游视频返回可查询的任务标识 | 退出并重开应用 | 查询已有任务;无法查询的中断操作明确提示,不自动重新提交 |
|
||||
| US-14 | FR-5 | 同一会话有多张成果 | 要求“修改第二张”或“新画一张无关图片” | 前者引用指定成果,后者不自动附带上一张图;指代不清时先澄清 |
|
||||
@@ -11,3 +11,8 @@
|
||||
| [实现进度](./progress.md) | Windows 工作区路径修复及验证记录 |
|
||||
|
||||
现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。
|
||||
|
||||
跨项目共享 Host 和状态检查不冷启动的实现设计,由
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md)
|
||||
统一维护;本 Runtime 的生命周期见[技术设计 §9](./technical-design.md#9-runtime-生命周期),
|
||||
未发布实现的验收状态见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
|
||||
@@ -121,10 +121,9 @@ GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不
|
||||
|
||||
模型名称、服务地址、工作区、Skills、MCP schema 和已启用插件的规范化入口通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。插件配置只来自 GoodBuddy 受管状态,不合并用户 profile 或全局补丁。
|
||||
|
||||
本机 Runtime 初始化时对工作区调用 `realpath`,将结果保存在该 Host 的运行状态中;
|
||||
Host 启动与 ACP `newSession.cwd` 共用这一路径。Host 仍校验目录并严格比较会话路径。
|
||||
Windows 混合分隔符与同目录规范写法可继续通过现有 execution-space identity 复用
|
||||
Runtime,无需更改缓存键或增加重试。远端 SSH 由 `ManagedRemoteAcpRuntime` /
|
||||
Main 在创建 Session 前解析该请求工作区的 `realpath`,把绝对路径传给 ACP
|
||||
`newSession.cwd`。Host 不再要求 Session cwd 等于启动 cwd,原生 Agent header.cwd
|
||||
驱动文件与 Shell 工具;启动 cwd 仅为默认值。远端 SSH 由 `ManagedRemoteAcpRuntime` /
|
||||
`AcpRemoteRuntime` 单独处理,不经过本机 DS Runtime 的目录解析。
|
||||
|
||||
### 5.3 双层内部控制面
|
||||
@@ -323,18 +322,18 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
|
||||
|
||||
### 9.1 进程模型
|
||||
|
||||
- 每个活动的 DeepSeek Harness Runtime 实例拥有一个 `utilityProcess`。
|
||||
- 一个进程可以承载多个 Harness Session。
|
||||
- Main `LocalRuntimeRegistry` 按兼容模型及 Host 配置共享重型实例;
|
||||
每组拥有一个 `utilityProcess`,不同工作区使用轻量适配器及独立 Session cwd。
|
||||
- 同一 GoodBuddy 对话的 Prompt 串行执行。
|
||||
- 不同对话可以并行,但受全局并发上限控制。
|
||||
- Runtime 设置变化时创建新实例,旧实例等待在途请求结束或在宽限期后被取消。
|
||||
- Runtime 设置变化时旧配置停止接收新请求,在途请求结算后释放旧实例。
|
||||
|
||||
### 9.2 会话映射
|
||||
|
||||
Main 保存内存映射:
|
||||
|
||||
```text
|
||||
GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
GoodBuddy conversationId -> Harness sessionId + workspace + process generation
|
||||
```
|
||||
|
||||
- 首次请求创建 Session。
|
||||
@@ -346,7 +345,8 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
### 9.3 取消与有界控制操作
|
||||
|
||||
- 用户取消时立即发送 `session/cancel`。
|
||||
- 取消等待有界,超时后关闭连接并终止整个 Harness 进程。
|
||||
- 取消等待有界,超时先释放目标 Session 的 Agent、工具和 Main 代理资源;
|
||||
只有该控制操作也不响应时才终止整个 Harness 进程。
|
||||
- 初始化、握手、请求准备、权限回传和关闭分别使用独立控制超时。
|
||||
- 生产 Prompt 不设置固定墙钟总时限,由 Harness 自身协议终态或用户取消结束。测试可以
|
||||
显式注入短 Prompt 时限验证取消传播,超时错误与用户取消仍不能被宽泛 catch 抹平。
|
||||
@@ -354,7 +354,10 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
|
||||
### 9.4 释放与退出
|
||||
|
||||
- 原生能力清单由 `SelectedRuntimeManager` 缓存的执行 Runtime 提供,与随后的对话请求共用同一实例,不再为清单单独启动并销毁一次 Host;该实例遵循执行缓存的空闲淘汰、设置变更 reset 和退出 dispose 规则。
|
||||
- 原生清单与执行使用共享 owner,查询携带工作区。轻量适配器被缓存淘汰不结束原生
|
||||
Session;无会话、无请求的 owner 空闲 60 秒退出,再次使用按需启动。
|
||||
- `getStatus()` 只读取已有状态和配置,不启动 Host,也不重置空闲回收计时;显式 `testConnection()` 才启动
|
||||
并验证连接,临时检查适配器的 dispose 不终止其他会话。
|
||||
- 删除或释放对话时调用 `goodbuddy/session/release`。
|
||||
- Runtime dispose 时先拒绝新请求,再取消所有 Session。
|
||||
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
|
||||
@@ -363,6 +366,17 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
- 应用退出时中止正在运行的 npm 插件安装并终止其完整进程树,不能让 lifecycle script 在 GoodBuddy 退出后继续运行。
|
||||
- 应用退出不得因 Harness 清理无限阻塞。
|
||||
|
||||
### 9.5 跨项目复用
|
||||
|
||||
锁定 0.1.2-rc.1 的文件和 Bash/PowerShell 工具已按原生 Session header.cwd 路由,
|
||||
Provider 的根 cwd 只是默认值。不为每工作区复制本地 Provider;
|
||||
Main 的 Web/MCP ToolProvider 按工作区保存,最后一个对应 Session 释放时 dispose。
|
||||
|
||||
共享 Utility、逐 Session cwd、被动状态、Main 工具代理和取消升级统一由
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#5-deepseek-harness)
|
||||
定义,证据及剩余验收见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
本节描述当前未发布源码,不将新行为归入已发布的 Desktop 0.13.2。
|
||||
|
||||
## 10. 权限与主机执行
|
||||
|
||||
### 10.1 模式映射
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
直连模型在 Main 内执行 HTTP,本身没有独立模型进程。跨 Runtime 资源调研中的直连
|
||||
部分仅检查历史缓存、会话引用和工具资源释放,不为它新增进程池;分析与验证范围
|
||||
见 [Runtime 资源技术设计 §7.2](../assistant-workbar/runtime-process-reuse-technical-design.md#72-直连模型及历史详情)。
|
||||
以下当前功能不因此改变,进程工具产生的子进程仍按各自会话生命周期管理。
|
||||
|
||||
```text
|
||||
ModelAgentRuntime
|
||||
└─ ModelToolProvider
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
## 相关功能
|
||||
|
||||
- [会话媒体生成设计](../conversation-media-generation/README.md):规划普通 LLM 通过工具调用图片、视频模型及模型切换行为,尚未实现。
|
||||
- [模型连接](../model-connections/README.md):地址、模型 ID、凭据与请求定制。
|
||||
- [直连文本 Agent](../direct-model-agent/README.md):文本模型的工具和上下文压缩,
|
||||
不等于图片模型支持 Responses 图片工具。
|
||||
|
||||
@@ -11,3 +11,7 @@
|
||||
| [远程环境准备技术设计](./environment-provisioning-technical-design.md) | Host 下载、GoodBuddy 传输、安装事务、更新和恢复边界 |
|
||||
|
||||
现有文档包含产品、功能逻辑和进度事实,后续修改远程功能时再按标准职责拆分。
|
||||
|
||||
跨会话共享 OpenCode 进程、ACP 连接与模型桥的实现设计,见
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。
|
||||
当前实现尚未发布;验收状态见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
|
||||
@@ -319,6 +319,9 @@ Execute 直接启动已签名 Runtime:
|
||||
自动重放。插件随 OpenCode 实例释放临时端口,helper 退出时删除插件文件。
|
||||
以上只适用于当前 Agent 管理的 SSH OpenCode 生产路径,不声明任意 ACP 服务的问答兼容性。
|
||||
真实二进制、SSH 传输和取消验证见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-托管-ssh-原生问答)。
|
||||
未发布进程复用源码补齐 Desktop 重启后的多任务并行附加、实时待答快照及 Main 答案
|
||||
持久化,详见[恢复与终态投影](../assistant-workbar/runtime-process-reuse-technical-design.md#63-恢复与终态投影);
|
||||
不把该待答投影当作 durable transcript 或旧版本已经支持的行为。
|
||||
- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的
|
||||
`<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属
|
||||
`toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.agent.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import type { RuntimeAcpProcessOutput, RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
|
||||
describe('AgentAcpConnection', () => {
|
||||
it('uses one decoder and handshake and routes interleaved Sessions without closing peers', async () => {
|
||||
let output!: (value: RuntimeAcpProcessOutput) => void | Promise<void>
|
||||
let input!: ReadableStreamDefaultController<Uint8Array>
|
||||
const unsubscribe = vi.fn()
|
||||
const process: RuntimeAcpProcessOwner = {
|
||||
identity: { launchId: 'launch', processId: 'process', supervisorIdentityDigest: `sha256:${'a'.repeat(64)}` },
|
||||
beginPrompt: vi.fn(), completePrompt: vi.fn(), stop: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
subscribeOutput: vi.fn(listener => { output = listener; return unsubscribe }),
|
||||
subscribeExit: vi.fn(() => unsubscribe),
|
||||
writeStdin: async data => input.enqueue(data)
|
||||
}
|
||||
const initialize = vi.fn(async () => ({ protocolVersion: PROTOCOL_VERSION }))
|
||||
const server = new AgentSideConnection(() => ({
|
||||
initialize, newSession: async () => ({ sessionId: 'session' }),
|
||||
prompt: async () => ({ stopReason: 'end_turn' as const }),
|
||||
cancel: async () => undefined
|
||||
}), ndJsonStream(new WritableStream({
|
||||
write: async data => { await output({ stream: 'stdout', data }) }
|
||||
}), new ReadableStream({ start: controller => { input = controller } })))
|
||||
const transport = new AgentAcpConnection(process)
|
||||
const one: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({
|
||||
outcome: { outcome: 'selected', optionId: 'allow' }
|
||||
})) }
|
||||
const two: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({
|
||||
outcome: { outcome: 'cancelled' }
|
||||
})) }
|
||||
const release = transport.register('one', one)
|
||||
transport.register('two', two)
|
||||
try {
|
||||
await Promise.all([1, 2].map(() => transport.initialize({ protocolVersion: PROTOCOL_VERSION })))
|
||||
expect(initialize).toHaveBeenCalledOnce()
|
||||
for (const sessionId of ['one', 'two', 'one', 'two']) {
|
||||
await server.sessionUpdate({
|
||||
sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: sessionId } }
|
||||
})
|
||||
}
|
||||
await vi.waitFor(() => expect(two.sessionUpdate).toHaveBeenCalledTimes(2))
|
||||
expect(one.sessionUpdate).toHaveBeenCalledTimes(2)
|
||||
const permission = (sessionId: string) => server.requestPermission({
|
||||
sessionId, options: [{ kind: 'allow_once', optionId: 'allow', name: 'Allow' }],
|
||||
toolCall: { toolCallId: 'tool', title: 'write', kind: 'edit' }
|
||||
})
|
||||
expect(await permission('one')).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
release()
|
||||
expect(await permission('one')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
expect(process.subscribeOutput).toHaveBeenCalledOnce()
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
transport.dispose()
|
||||
input.close()
|
||||
}
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Client,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
|
||||
/** One decoder and handshake per native process, with Session-scoped clients. */
|
||||
export class AgentAcpConnection {
|
||||
readonly connection: ClientSideConnection
|
||||
readonly exited: Promise<never>
|
||||
private initialization?: Promise<InitializeResponse>
|
||||
private readonly sessions = new Map<string, Client>()
|
||||
private readonly unsubscribeOutput: () => void
|
||||
private readonly unsubscribeExit: () => void
|
||||
private closed = false
|
||||
private closeInput?: () => void
|
||||
private bridgeOrigin?: string
|
||||
|
||||
constructor(
|
||||
process: RuntimeAcpProcessOwner,
|
||||
fallback?: () => Client,
|
||||
createConnection?: (client: () => Client, stream: ReturnType<typeof ndJsonStream>) => ClientSideConnection
|
||||
) {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let rejectExit!: (error: Error) => void
|
||||
this.exited = new Promise<never>((_resolve, reject) => { rejectExit = reject })
|
||||
void this.exited.catch(() => undefined)
|
||||
const input = new ReadableStream<Uint8Array>({
|
||||
start(value) { controller = value }
|
||||
})
|
||||
this.closeInput = () => {
|
||||
controller?.close()
|
||||
controller = undefined
|
||||
}
|
||||
this.unsubscribeOutput = process.subscribeOutput((output) => {
|
||||
if (!this.closed && output.stream === 'stdout' && output.data.byteLength > 0) {
|
||||
controller?.enqueue(output.data.slice())
|
||||
}
|
||||
}) ?? (() => undefined)
|
||||
this.unsubscribeExit = process.subscribeExit?.(() => {
|
||||
if (this.closed) return
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectExit(error)
|
||||
}) ?? (() => undefined)
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: async (data) => { await process.writeStdin(data) }
|
||||
})
|
||||
const client = (): Client => ({
|
||||
sessionUpdate: async (notification) => {
|
||||
await (this.sessions.get(notification.sessionId) ?? fallback?.())
|
||||
?.sessionUpdate(notification)
|
||||
},
|
||||
requestPermission: async (request) =>
|
||||
await (this.sessions.get(request.sessionId) ?? fallback?.())
|
||||
?.requestPermission(request) ?? { outcome: { outcome: 'cancelled' } },
|
||||
extMethod: async () => ({}),
|
||||
extNotification: async (method, params) => {
|
||||
if (
|
||||
method === 'goodbuddy/modelBridgeReady' &&
|
||||
typeof params.origin === 'string' &&
|
||||
/^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]{43}$/u.test(params.origin)
|
||||
) this.bridgeOrigin = params.origin
|
||||
}
|
||||
})
|
||||
const stream = ndJsonStream(output, input)
|
||||
this.connection = createConnection?.(client, stream) ?? new ClientSideConnection(client, stream)
|
||||
}
|
||||
|
||||
initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
return this.initialization ??= this.connection.initialize(request)
|
||||
}
|
||||
|
||||
register(sessionId: string, client: Client): () => void {
|
||||
if (this.sessions.has(sessionId)) throw new Error('ACP Session already has an owner')
|
||||
this.sessions.set(sessionId, client)
|
||||
return () => {
|
||||
if (this.sessions.get(sessionId) === client) this.sessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async setModelRoute(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
socketPath?: string,
|
||||
workMode?: 'ask' | 'execute'
|
||||
): Promise<void> {
|
||||
if (!this.bridgeOrigin) throw new Error('Shared model bridge is not ready')
|
||||
const response = await fetch(`${this.bridgeOrigin}/session`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, workMode } : { release: true }) }),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`)
|
||||
}
|
||||
dispose(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.sessions.clear()
|
||||
this.unsubscribeOutput()
|
||||
this.unsubscribeExit()
|
||||
this.closeInput?.()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,64 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentOwnedAcpPrompt', () => {
|
||||
it('applies each prompt mode to the retained session and its permission decisions', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-'))
|
||||
temporary.push(root)
|
||||
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
|
||||
let client!: Client
|
||||
let finish!: (response: PromptResponse) => void
|
||||
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
|
||||
const prepareSession = vi.fn(async () => undefined)
|
||||
const completePrompt = vi.fn(async () => undefined)
|
||||
const owner = new AgentOwnedAcpPrompt({
|
||||
bindingId: 'binding-1', controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace', process: new MemoryProcess(),
|
||||
transcript, prepareSession, completePrompt,
|
||||
createConnection: factory => {
|
||||
client = factory()
|
||||
return {
|
||||
initialize: async () => ({ protocolVersion: 1, agentCapabilities: {} }),
|
||||
newSession,
|
||||
prompt: () => new Promise<PromptResponse>(resolve => { finish = resolve })
|
||||
} as unknown as ClientSideConnection
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (const [index, workMode] of (['execute', 'ask', 'execute'] as const).entries()) {
|
||||
const operationId = `operation-${index}`
|
||||
transcript.prepare({
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`,
|
||||
promptSequence: index
|
||||
})
|
||||
await owner.start({
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
prompt: [{ type: 'text', text: 'Continue with the selected mode' }]
|
||||
}, workMode)
|
||||
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId, workMode)
|
||||
const result = await client.requestPermission!({
|
||||
sessionId: 'session-1',
|
||||
toolCall: { toolCallId: `tool-${index}`, kind: 'execute', title: 'Run a test' },
|
||||
options: [
|
||||
{ kind: 'allow_once', optionId: 'allow', name: 'Allow' },
|
||||
{ kind: 'reject_once', optionId: 'reject', name: 'Reject' }
|
||||
]
|
||||
})
|
||||
expect(result.outcome).toEqual({
|
||||
outcome: 'selected', optionId: workMode === 'execute' ? 'allow' : 'reject'
|
||||
})
|
||||
finish({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => expect(transcript.attach('binding-1', operationId, 'controller-1'))
|
||||
.toMatchObject({ state: 'completed' }))
|
||||
}
|
||||
expect(newSession).toHaveBeenCalledOnce()
|
||||
expect(completePrompt).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
owner.close()
|
||||
transcript.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the original prompt promise through Desktop detach and recovers the final response', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
|
||||
temporary.push(root)
|
||||
@@ -76,7 +134,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
expectedModel,
|
||||
process,
|
||||
transcript,
|
||||
@@ -101,7 +158,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text' as const, text: 'finish independently' }]
|
||||
}
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
state: 'running',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
@@ -141,7 +198,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
limit: 10
|
||||
}).events.map((event) => event.kind)
|
||||
).toEqual(['session-update', 'prompt-terminal'])
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
state: 'completed'
|
||||
})
|
||||
expect(prompt).toHaveBeenCalledOnce()
|
||||
@@ -170,7 +227,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt,
|
||||
@@ -191,7 +247,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'finish independently' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
transcript.attach('binding-1', 'operation-1', 'controller-1')
|
||||
@@ -239,7 +295,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process,
|
||||
transcript,
|
||||
completePrompt,
|
||||
@@ -261,7 +316,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'wait for Runtime exit' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await process.emitExit()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
@@ -315,7 +370,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt: async () => undefined,
|
||||
@@ -338,7 +392,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'run one tool' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await expect(
|
||||
client.requestPermission({
|
||||
sessionId: 'session-1',
|
||||
@@ -388,7 +442,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'ask',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt: async () => undefined,
|
||||
@@ -411,7 +464,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'inspect one file' }]
|
||||
})
|
||||
}, 'ask')
|
||||
const permission = (
|
||||
kind: 'read' | 'edit'
|
||||
): Parameters<Client['requestPermission']>[0] => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
RemoteOwnedPromptStartResult
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import {
|
||||
SemanticPromptStore,
|
||||
SemanticPromptStoreError
|
||||
@@ -25,9 +26,10 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
bindingId: string
|
||||
controllerId: string
|
||||
workspaceDirectory: string
|
||||
workMode: 'ask' | 'execute'
|
||||
expectedModel?: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport?: AgentAcpConnection
|
||||
prepareSession?: (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => Promise<void>
|
||||
transcript: SemanticPromptStore
|
||||
completePrompt: (
|
||||
operationId: string,
|
||||
@@ -44,18 +46,18 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the ACP ClientSideConnection and the original prompt Promise. No
|
||||
* transport-connection signal is part of this lifetime.
|
||||
* Owns a native Session and its original prompt Promise. Desktop connection
|
||||
* signals are not part of this lifetime; the native transport may be shared.
|
||||
*/
|
||||
export class AgentOwnedAcpPrompt {
|
||||
readonly #options: AgentOwnedAcpPromptOptions
|
||||
readonly #connection: ClientSideConnection
|
||||
readonly #input: ReadableStream<Uint8Array>
|
||||
readonly #unsubscribeOutput: () => void
|
||||
readonly #unsubscribeExit: () => void
|
||||
readonly #transport: AgentAcpConnection
|
||||
#unregisterSession?: () => void
|
||||
readonly #processExit: Promise<never>
|
||||
#sessionId?: string
|
||||
#promptPromise?: Promise<void>
|
||||
#promptSettled?: Promise<void>
|
||||
#closed = false
|
||||
#initialized = false
|
||||
readonly #questions = new Map<string, {
|
||||
@@ -64,56 +66,23 @@ export class AgentOwnedAcpPrompt {
|
||||
#acceptQuestions = false
|
||||
#active?: {
|
||||
operationId: string
|
||||
workMode: 'ask' | 'execute'
|
||||
}
|
||||
|
||||
constructor(options: AgentOwnedAcpPromptOptions) {
|
||||
this.#options = options
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let rejectProcessExit!: (error: Error) => void
|
||||
this.#processExit = new Promise<never>((_resolve, reject) => {
|
||||
rejectProcessExit = reject
|
||||
})
|
||||
void this.#processExit.catch(() => undefined)
|
||||
this.#input = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value
|
||||
}
|
||||
})
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: async (chunk) => {
|
||||
await options.process.writeStdin(chunk)
|
||||
}
|
||||
})
|
||||
const unsubscribe = options.process.subscribeOutput((event) => {
|
||||
if (
|
||||
event.stream === 'stdout' &&
|
||||
event.data.byteLength > 0 &&
|
||||
!this.#closed
|
||||
) {
|
||||
controller?.enqueue(event.data.slice())
|
||||
}
|
||||
})
|
||||
this.#unsubscribeOutput = unsubscribe ?? (() => undefined)
|
||||
const unsubscribeExit = options.process.subscribeExit?.(() => {
|
||||
if (this.#closed) {
|
||||
return
|
||||
}
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectProcessExit(error)
|
||||
})
|
||||
this.#unsubscribeExit = unsubscribeExit ?? (() => undefined)
|
||||
const stream = ndJsonStream(output, this.#input)
|
||||
this.#connection =
|
||||
options.createConnection?.(() => this.#client(), stream) ??
|
||||
new ClientSideConnection(() => this.#client(), stream)
|
||||
this.#transport = options.transport ?? new AgentAcpConnection(
|
||||
options.process, () => this.#client(), options.createConnection
|
||||
)
|
||||
this.#connection = this.#transport.connection
|
||||
this.#processExit = this.#transport.exited
|
||||
}
|
||||
|
||||
get sessionId(): string | undefined { return this.#sessionId }
|
||||
|
||||
async start(
|
||||
request: RemoteOwnedPromptStartRequest
|
||||
request: RemoteOwnedPromptStartRequest,
|
||||
workMode: 'ask' | 'execute'
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
if (
|
||||
request.bindingId !== this.#options.bindingId
|
||||
@@ -142,7 +111,7 @@ export class AgentOwnedAcpPrompt {
|
||||
|
||||
if (!this.#initialized) {
|
||||
const initialization = await this.#whileProcessAlive(
|
||||
this.#connection.initialize({
|
||||
this.#transport.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
terminal: false,
|
||||
@@ -170,7 +139,7 @@ export class AgentOwnedAcpPrompt {
|
||||
})
|
||||
)
|
||||
} else if (
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume === true
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume != null
|
||||
) {
|
||||
await this.#whileProcessAlive(
|
||||
this.#connection.resumeSession({
|
||||
@@ -210,6 +179,7 @@ export class AgentOwnedAcpPrompt {
|
||||
throw new Error('ACP Runtime did not select the prepared model')
|
||||
}
|
||||
}
|
||||
this.#unregisterSession = this.#transport.register(this.#sessionId!, this.#client())
|
||||
this.#initialized = true
|
||||
} else if (
|
||||
request.acpSessionId !== undefined &&
|
||||
@@ -217,8 +187,10 @@ export class AgentOwnedAcpPrompt {
|
||||
) {
|
||||
throw new Error('ACP session identity cannot change within a binding')
|
||||
}
|
||||
await this.#options.prepareSession?.(this.#sessionId!, request.operationId, workMode)
|
||||
this.#active = {
|
||||
operationId: request.operationId
|
||||
operationId: request.operationId,
|
||||
workMode
|
||||
}
|
||||
this.#acceptQuestions = true
|
||||
const begun = this.#options.transcript.begin({
|
||||
@@ -229,13 +201,14 @@ export class AgentOwnedAcpPrompt {
|
||||
})
|
||||
// Keep this exact Promise alive on Agent. The caller receives only the
|
||||
// durable operation identity and may disconnect immediately.
|
||||
this.#promptPromise = this.#whileProcessAlive(
|
||||
const promptOperation = this.#whileProcessAlive(
|
||||
this.#connection.prompt({
|
||||
sessionId: this.#sessionId,
|
||||
sessionId: this.#sessionId!,
|
||||
prompt: request.prompt
|
||||
})
|
||||
)
|
||||
.then(
|
||||
this.#promptSettled = promptOperation.then(() => undefined, () => undefined)
|
||||
this.#promptPromise = promptOperation.then(
|
||||
async (response) => {
|
||||
const proposed =
|
||||
response.stopReason === 'cancelled'
|
||||
@@ -305,6 +278,17 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#clear()
|
||||
}
|
||||
|
||||
async releaseSession(): Promise<void> {
|
||||
await this.cancel()
|
||||
if (this.#sessionId && !this.#closed) {
|
||||
await this.#connection.closeSession({ sessionId: this.#sessionId })
|
||||
}
|
||||
// Wait for the original native Prompt, not its completion callback, which
|
||||
// may be queued behind the caller's backend control operation.
|
||||
await this.#promptSettled
|
||||
this.close()
|
||||
}
|
||||
|
||||
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
|
||||
const pending = this.#questions.get(request.questionId)
|
||||
if (this.#closed || !pending || this.#active?.operationId !== request.operationId ||
|
||||
@@ -384,14 +368,15 @@ export class AgentOwnedAcpPrompt {
|
||||
#handlePermission(
|
||||
request: RequestPermissionRequest
|
||||
): RequestPermissionResponse {
|
||||
const active = this.#requireActive()
|
||||
const selected =
|
||||
this.#options.workMode === 'execute'
|
||||
? request.options.find((option) => option.kind === 'allow_always') ??
|
||||
request.options.find((option) => option.kind === 'allow_once')
|
||||
: request.toolCall.kind === 'read'
|
||||
active.workMode === 'execute'
|
||||
? request.options.find((option) => option.kind === 'allow_once') ??
|
||||
request.options.find((option) => option.kind === 'allow_always')
|
||||
: request.toolCall.kind === 'read' || request.toolCall.kind === 'search'
|
||||
? request.options.find((option) => option.kind === 'allow_once')
|
||||
: request.options.find((option) => option.kind === 'reject_always') ??
|
||||
request.options.find((option) => option.kind === 'reject_once')
|
||||
: request.options.find((option) => option.kind === 'reject_once') ??
|
||||
request.options.find((option) => option.kind === 'reject_always')
|
||||
const response: RequestPermissionResponse =
|
||||
selected === undefined
|
||||
? { outcome: { outcome: 'cancelled' } }
|
||||
@@ -403,7 +388,7 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
this.#options.transcript.append({
|
||||
bindingId: this.#options.bindingId,
|
||||
operationId: this.#requireActive().operationId,
|
||||
operationId: active.operationId,
|
||||
kind: 'permission-decision',
|
||||
payload: {
|
||||
sessionId: request.sessionId,
|
||||
@@ -434,15 +419,15 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#closed = true
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
this.#unsubscribeOutput()
|
||||
this.#unsubscribeExit()
|
||||
this.#unregisterSession?.()
|
||||
if (!this.#options.transport) this.#transport.dispose()
|
||||
}
|
||||
|
||||
async #whileProcessAlive<T>(operation: Promise<T>): Promise<T> {
|
||||
return await Promise.race([operation, this.#processExit])
|
||||
}
|
||||
|
||||
#requireActive(): NonNullable<AgentOwnedAcpPrompt['#active']> {
|
||||
#requireActive(): { operationId: string; workMode: 'ask' | 'execute' } {
|
||||
if (this.#active === undefined) {
|
||||
throw new Error('ACP notification has no active prompt')
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ async function runModelBridgeHelper(
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'shared-sessions',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
requireOptions(options, [
|
||||
@@ -212,6 +213,9 @@ async function runModelBridgeHelper(
|
||||
if (workMode !== 'ask' && workMode !== 'execute') {
|
||||
throw new Error('Invalid model bridge work-mode option')
|
||||
}
|
||||
if (options['shared-sessions'] !== undefined && options['shared-sessions'] !== 'true') {
|
||||
throw new Error('Invalid model bridge shared-sessions option')
|
||||
}
|
||||
return await (
|
||||
dependencies.runModelBridgeHelper ??
|
||||
runOpenCodeModelBridgeHelper
|
||||
@@ -221,6 +225,7 @@ async function runModelBridgeHelper(
|
||||
model: options.model!,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
workMode,
|
||||
...(options['shared-sessions'] === 'true' ? { sharedSessions: true } : {}),
|
||||
opencodeEntrypoint: options['opencode-entrypoint']!
|
||||
})
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ function diagnosticErrorName(error: unknown): string {
|
||||
return error instanceof Error ? 'Error' : 'NonError'
|
||||
}
|
||||
|
||||
const SAFE_DIAGNOSTIC_ERROR_CODES = new Set([
|
||||
const SAFE_DIAGNOSTIC_ERROR_CODES: ReadonlySet<string> = new Set([
|
||||
'EACCES',
|
||||
'EADDRINUSE',
|
||||
'ECONNREFUSED',
|
||||
|
||||
@@ -24,6 +24,32 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('direct Linux stdio Runtime ownership', () => {
|
||||
it('keeps shared stdin available across Session completion without a cumulative prompt quota', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(), profile: profile(), sharedSessions: true,
|
||||
identity: { launchId: 'shared-launch', processId: 'shared-process' },
|
||||
installationId: 'installation-1', registry, platform: 'linux',
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16,
|
||||
spawn: () => { queueMicrotask(() => child.emit('spawn')); return child },
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => identity()
|
||||
})
|
||||
try {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
owner.beginPrompt({ deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16 })
|
||||
await owner.writeStdin(Uint8Array.from(Buffer.from('12345678')))
|
||||
await owner.completePrompt()
|
||||
}
|
||||
await owner.writeStdin(Uint8Array.from(Buffer.from('cancel-peer')))
|
||||
} finally {
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('spawns Ask directly without systemd using detached fixed stdio', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
|
||||
@@ -108,6 +108,7 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
bridgeDirectory: string
|
||||
socketPath: string
|
||||
policy: ModelBridgePolicy
|
||||
sharedSessions?: boolean
|
||||
}
|
||||
}
|
||||
identity: { launchId: string; processId: string }
|
||||
@@ -115,6 +116,7 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
registry: RuntimeOwnerRegistry
|
||||
deadlineAt: string
|
||||
maximumInputBytes: number
|
||||
sharedSessions?: boolean
|
||||
signal?: AbortSignal
|
||||
platform?: NodeJS.Platform
|
||||
spawn?: DirectLinuxStdioSpawn
|
||||
@@ -151,6 +153,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
readonly processIdentity: LinuxRuntimeProcessIdentity
|
||||
|
||||
readonly #child: DirectLinuxStdioChild
|
||||
readonly #sharedSessions: boolean
|
||||
readonly #registry: RuntimeOwnerRegistry
|
||||
readonly #now: () => number
|
||||
readonly #readIdentity: typeof readLinuxRuntimeProcessIdentity
|
||||
@@ -194,6 +197,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
listPidNamespaceMembers: typeof listLinuxPidNamespaceMembers
|
||||
sendSignal: typeof process.kill
|
||||
maximumInputBytes: number
|
||||
sharedSessions?: boolean
|
||||
maximumPendingStdinBytes: number
|
||||
maximumStdinWriteBytes: number
|
||||
maximumOutputBytes: number
|
||||
@@ -208,6 +212,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
this.ownerId = input.ownerId
|
||||
this.processIdentity = Object.freeze({ ...input.processIdentity })
|
||||
this.#child = input.child
|
||||
this.#sharedSessions = input.sharedSessions === true
|
||||
this.#registry = input.registry
|
||||
this.#now = input.now
|
||||
this.#readIdentity = input.readProcessIdentity
|
||||
@@ -222,7 +227,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
input.maximumPromptRuntimeMilliseconds
|
||||
this.#maximumOutputQueueChunks = input.maximumOutputQueueChunks
|
||||
this.#maximumListeners = input.maximumListeners
|
||||
this.#scheduleDeadline(input.deadlineAt)
|
||||
if (!this.#sharedSessions) this.#scheduleDeadline(input.deadlineAt)
|
||||
input.child.stdout.on('data', (chunk: Buffer | Uint8Array) => {
|
||||
this.#acceptOutput(chunk)
|
||||
})
|
||||
@@ -245,13 +250,13 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
if (
|
||||
payload.byteLength > this.#maximumStdinWriteBytes ||
|
||||
this.#inputBytes + payload.byteLength > this.#maximumInputBytes ||
|
||||
(!this.#sharedSessions && this.#inputBytes + payload.byteLength > this.#maximumInputBytes) ||
|
||||
this.#pendingInputBytes + payload.byteLength > this.#maximumPendingStdinBytes
|
||||
) {
|
||||
throw capacityError('Runtime stdin quota reached')
|
||||
}
|
||||
const copy = Buffer.from(payload)
|
||||
this.#inputBytes += copy.byteLength
|
||||
if (!this.#sharedSessions) this.#inputBytes += copy.byteLength
|
||||
this.#pendingInputBytes += copy.byteLength
|
||||
const write = this.#writeTail.then(async () => {
|
||||
if (this.#closed || this.#stdinClosed || !this.#promptActive) {
|
||||
@@ -268,6 +273,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
|
||||
beginPrompt(input: { deadlineAt: string; maximumInputBytes: number }): void {
|
||||
if (this.#sharedSessions && !this.#closed && !this.#stdinClosed) return
|
||||
if (this.#closed || this.#stdinClosed || this.#promptActive) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
this.#promptActive ? 'A Runtime prompt is already active' : 'Runtime process is closed',
|
||||
@@ -293,6 +299,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
|
||||
async completePrompt(): Promise<void> {
|
||||
if (this.#sharedSessions) return
|
||||
if (!this.#promptActive) return
|
||||
await this.#writeTail
|
||||
this.#promptActive = false
|
||||
@@ -655,6 +662,7 @@ export async function launchDirectLinuxStdioProcessOwner(
|
||||
options.listPidNamespaceMembers ?? listLinuxPidNamespaceMembers,
|
||||
sendSignal: options.sendSignal ?? process.kill,
|
||||
maximumInputBytes,
|
||||
sharedSessions: options.sharedSessions,
|
||||
maximumPendingStdinBytes,
|
||||
maximumStdinWriteBytes,
|
||||
maximumOutputBytes: manifest.limits.maximumPromptOutputBytes,
|
||||
@@ -975,7 +983,7 @@ function defaultSpawn(
|
||||
args: readonly string[],
|
||||
options: Parameters<DirectLinuxStdioSpawn>[2]
|
||||
): DirectLinuxStdioChild {
|
||||
return nodeSpawn(executable, [...args], options)
|
||||
return nodeSpawn(executable, [...args], { ...options, stdio: [...options.stdio] })
|
||||
}
|
||||
|
||||
async function waitForSpawn(
|
||||
|
||||
@@ -132,24 +132,27 @@ const FORWARDED_HEADER_NAMES = [
|
||||
|
||||
export class ModelBridgeLoopbackProxy {
|
||||
readonly #exchange: ModelBridgeExchange
|
||||
readonly #sharedSessions: boolean
|
||||
readonly #sessionRoutes = new Map<string, { operationId: string; socketPath: string; workMode: 'ask' | 'execute' }>()
|
||||
readonly #routeToken: string
|
||||
readonly #maximumConnections: number
|
||||
readonly #requestTimeoutMs: number
|
||||
readonly #sockets = new Set<Socket>()
|
||||
readonly #dispatchWaiters: DispatchWaiter[] = []
|
||||
readonly #dispatches = new Map<string, DispatchWaiter[]>()
|
||||
#server?: Server
|
||||
#origin?: string
|
||||
#dispatchBusy = false
|
||||
#closing = false
|
||||
#closePromise?: Promise<void>
|
||||
|
||||
constructor(options: {
|
||||
exchange: ModelBridgeExchange
|
||||
sharedSessions?: boolean
|
||||
routeToken?: string
|
||||
maximumConnections?: number
|
||||
requestTimeoutMs?: number
|
||||
}) {
|
||||
this.#exchange = options.exchange
|
||||
this.#sharedSessions = options.sharedSessions === true
|
||||
this.#routeToken = parseModelBridgeRouteToken(
|
||||
options.routeToken ??
|
||||
randomBytes(32).toString('base64url')
|
||||
@@ -259,6 +262,7 @@ export class ModelBridgeLoopbackProxy {
|
||||
)
|
||||
} finally {
|
||||
this.#sockets.clear()
|
||||
this.#sessionRoutes.clear()
|
||||
this.#closing = false
|
||||
}
|
||||
}
|
||||
@@ -280,6 +284,9 @@ export class ModelBridgeLoopbackProxy {
|
||||
let responseFlushed = false
|
||||
let deliveryAcknowledged = false
|
||||
let dispatchHeld = false
|
||||
let dispatchKey = ''
|
||||
let sessionId: string | undefined
|
||||
let sessionRoute: { operationId: string; socketPath: string } | undefined
|
||||
const onAborted = (): void => {
|
||||
cancellation.abort(
|
||||
new ModelBridgeBrokerError('request-cancelled')
|
||||
@@ -303,14 +310,58 @@ export class ModelBridgeLoopbackProxy {
|
||||
timeout?.unref?.()
|
||||
|
||||
try {
|
||||
if (this.#sharedSessions && incoming.url?.startsWith(`/${this.#routeToken}/session`)) {
|
||||
const url = new URL(incoming.url, 'http://localhost')
|
||||
if (url.pathname !== `/${this.#routeToken}/session`) throw new HttpRequestError(404, 'path-not-allowed')
|
||||
if (incoming.method === 'GET') {
|
||||
const route = this.#sessionRoutes.get(url.searchParams.get('sessionId') ?? '')
|
||||
if (!route) throw new HttpRequestError(409, 'session-inactive')
|
||||
outgoing.setHeader('content-type', 'application/json')
|
||||
outgoing.end(JSON.stringify({ operationId: route.operationId, workMode: route.workMode }))
|
||||
return
|
||||
}
|
||||
if (incoming.method !== 'POST') throw new HttpRequestError(405, 'method-not-allowed')
|
||||
const value = JSON.parse((await readBoundedBody(incoming)).toString('utf8')) as {
|
||||
sessionId?: string; operationId?: string; socketPath?: string; release?: boolean; workMode?: 'ask' | 'execute'
|
||||
}
|
||||
if (
|
||||
typeof value.sessionId !== 'string' || value.sessionId.length > 128 ||
|
||||
typeof value.operationId !== 'string' || value.operationId.length > 128
|
||||
) throw new HttpRequestError(400, 'request-invalid')
|
||||
if (value.release) {
|
||||
if (this.#sessionRoutes.get(value.sessionId)?.operationId === value.operationId) {
|
||||
this.#sessionRoutes.delete(value.sessionId)
|
||||
}
|
||||
} else {
|
||||
if (value.workMode !== 'ask' && value.workMode !== 'execute') throw new HttpRequestError(400, 'request-invalid')
|
||||
const socketPath = normalizedAbsolutePath(value.socketPath ?? '', 'Model bridge socket')
|
||||
this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, workMode: value.workMode })
|
||||
}
|
||||
outgoing.end('{}')
|
||||
return
|
||||
}
|
||||
const request = await parseHttpRequest(
|
||||
incoming,
|
||||
this.#routeToken
|
||||
)
|
||||
await this.#acquireDispatch(cancellation.signal)
|
||||
let exchange = this.#exchange
|
||||
if (this.#sharedSessions) {
|
||||
const sessionHeader = incoming.headers['x-goodbuddy-session']
|
||||
const operationId = incoming.headers['x-goodbuddy-operation']
|
||||
sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined
|
||||
const route = sessionId ? this.#sessionRoutes.get(sessionId) : undefined
|
||||
if (!route || route.operationId !== operationId) throw new HttpRequestError(409, 'session-inactive')
|
||||
sessionRoute = route
|
||||
dispatchKey = `${sessionId}\0${operationId}`
|
||||
exchange = createUnixModelBridgeExchange({ socketPath: route.socketPath })
|
||||
}
|
||||
await this.#acquireDispatch(dispatchKey, cancellation.signal)
|
||||
dispatchHeld = true
|
||||
if (sessionId && this.#sessionRoutes.get(sessionId) !== sessionRoute) {
|
||||
throw new HttpRequestError(409, 'session-inactive')
|
||||
}
|
||||
dispatched = normalizeExchangeResult(
|
||||
await raceWithAbort(this.#exchange(request, {
|
||||
await raceWithAbort(exchange(request, {
|
||||
requestId: `model-${randomUUID()}`,
|
||||
signal: cancellation.signal
|
||||
}), cancellation.signal)
|
||||
@@ -351,34 +402,36 @@ export class ModelBridgeLoopbackProxy {
|
||||
incoming.off('aborted', onAborted)
|
||||
outgoing.off('close', onResponseClose)
|
||||
if (dispatchHeld) {
|
||||
this.#releaseDispatch()
|
||||
this.#releaseDispatch(dispatchKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #acquireDispatch(signal: AbortSignal): Promise<void> {
|
||||
async #acquireDispatch(key: string, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (!this.#dispatchBusy) {
|
||||
this.#dispatchBusy = true
|
||||
const queue = this.#dispatches.get(key)
|
||||
if (!queue) {
|
||||
this.#dispatches.set(key, [])
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: DispatchWaiter = { resolve, reject, signal }
|
||||
waiter.abort = (): void => {
|
||||
const index = this.#dispatchWaiters.indexOf(waiter)
|
||||
const index = queue.indexOf(waiter)
|
||||
if (index >= 0) {
|
||||
this.#dispatchWaiters.splice(index, 1)
|
||||
queue.splice(index, 1)
|
||||
}
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', waiter.abort, { once: true })
|
||||
this.#dispatchWaiters.push(waiter)
|
||||
queue.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
#releaseDispatch(): void {
|
||||
while (this.#dispatchWaiters.length > 0) {
|
||||
const waiter = this.#dispatchWaiters.shift()!
|
||||
#releaseDispatch(key: string): void {
|
||||
const queue = this.#dispatches.get(key)
|
||||
while (queue && queue.length > 0) {
|
||||
const waiter = queue.shift()!
|
||||
if (waiter.abort !== undefined) {
|
||||
waiter.signal.removeEventListener('abort', waiter.abort)
|
||||
}
|
||||
@@ -389,7 +442,7 @@ export class ModelBridgeLoopbackProxy {
|
||||
waiter.resolve()
|
||||
return
|
||||
}
|
||||
this.#dispatchBusy = false
|
||||
this.#dispatches.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +452,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
sharedSessions?: boolean
|
||||
opencodeEntrypoint: string
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
spawn?: ModelBridgeHelperSpawn
|
||||
@@ -412,19 +466,19 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
'OpenCode entrypoint'
|
||||
)
|
||||
const exchange = createUnixModelBridgeExchange({ socketPath })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange, sharedSessions: options.sharedSessions })
|
||||
const origin = await proxy.listen()
|
||||
let pluginDirectory: string | undefined
|
||||
try {
|
||||
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
||||
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
loopbackOrigin: origin,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
workMode: options.workMode
|
||||
workMode: options.sharedSessions ? 'ask' : options.workMode
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
@@ -433,6 +487,11 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
plugin: [pathToFileURL(pluginPath).href]
|
||||
})
|
||||
)
|
||||
if (options.sharedSessions) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'goodbuddy/modelBridgeReady', params: { origin }
|
||||
}) + '\n')
|
||||
}
|
||||
const child = (options.spawn ?? defaultHelperSpawn)(
|
||||
opencodeEntrypoint,
|
||||
['acp'],
|
||||
|
||||
@@ -615,6 +615,50 @@ describe('model bridge loopback helper', () => {
|
||||
describe('model bridge Unix broker', () => {
|
||||
const runOnUnix = process.platform === 'win32' ? it.skip : it
|
||||
|
||||
runOnUnix('routes shared Sessions concurrently and rejects queued work after its operation ends', async () => {
|
||||
let unblock!: () => void
|
||||
const blocked = new Promise<void>(resolve => { unblock = resolve })
|
||||
const dispatchOne = vi.fn(async () => { await blocked; return validResponse })
|
||||
const dispatchTwo = vi.fn(async () => validResponse)
|
||||
const brokers = [dispatchOne, dispatchTwo].map(dispatch => new ModelBridgeBrokerServer({
|
||||
scratchDirectory: privateTemporaryDirectory(), dispatch
|
||||
}))
|
||||
const fallback = vi.fn(async () => validResponse)
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange: fallback, sharedSessions: true })
|
||||
await Promise.all(brokers.map(broker => broker.listen()))
|
||||
const origin = await proxy.listen()
|
||||
const route = (sessionId: string, operationId: string, index: number) => fetch(`${origin}/session`, {
|
||||
method: 'POST', body: JSON.stringify({ sessionId, operationId, socketPath: brokers[index]!.socketPath, workMode: 'execute' })
|
||||
})
|
||||
const prompt = (sessionId: string, operationId: string) => fetch(`${origin}/v1/messages`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'x-goodbuddy-session': sessionId, 'x-goodbuddy-operation': operationId },
|
||||
body: '{"model":"fixture"}'
|
||||
})
|
||||
try {
|
||||
expect((await route('one', 'operation-1', 0)).ok).toBe(true)
|
||||
expect((await route('two', 'operation-2', 1)).ok).toBe(true)
|
||||
const first = prompt('one', 'operation-1')
|
||||
await vi.waitFor(() => expect(dispatchOne).toHaveBeenCalledOnce())
|
||||
const queued = prompt('one', 'operation-1')
|
||||
expect((await prompt('two', 'operation-2')).status).toBe(201)
|
||||
await route('one', 'next-operation', 0)
|
||||
unblock()
|
||||
expect((await first).status).toBe(201)
|
||||
expect((await queued).status).toBe(409)
|
||||
expect((await prompt('one', 'operation-1')).status).toBe(409)
|
||||
expect((await prompt('one', 'next-operation')).status).toBe(201)
|
||||
expect(dispatchOne).toHaveBeenCalledTimes(2)
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
for (const dispatch of [dispatchOne, dispatchTwo]) {
|
||||
expect(dispatch.mock.calls[0]?.[0]).not.toHaveProperty('headers.x-goodbuddy-session')
|
||||
}
|
||||
} finally {
|
||||
unblock()
|
||||
await proxy.close()
|
||||
await Promise.all(brokers.map(broker => broker.close()))
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a delayed connection with the default unlimited request timeout', async () => {
|
||||
const socket = new Duplex({
|
||||
read() {},
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
bridgeDirectory: string;
|
||||
socketPath: string;
|
||||
policy: ModelBridgePolicy;
|
||||
sharedSessions?: boolean;
|
||||
};
|
||||
}): OpenCodeLaunchProfile {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest);
|
||||
@@ -90,6 +91,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
"Model bridge socket",
|
||||
),
|
||||
policy: modelBridgePolicySchema.parse(input.modelBridge.policy),
|
||||
sharedSessions: input.modelBridge.sharedSessions === true,
|
||||
};
|
||||
if (
|
||||
modelBridge !== undefined &&
|
||||
@@ -132,6 +134,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
modelBridge.policy.supportsImageInput ? "true" : "false",
|
||||
"--work-mode",
|
||||
input.workMode,
|
||||
...(modelBridge.sharedSessions ? ["--shared-sessions", "true"] : []),
|
||||
"--opencode-entrypoint",
|
||||
executablePath,
|
||||
],
|
||||
|
||||
@@ -5,6 +5,65 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('attributes child and compaction model rounds to the frozen root operation', async () => {
|
||||
const operations = new Map([['one', 'operation-1'], ['two', 'operation-2']])
|
||||
const fetchRoute = vi.fn(async (url: string) => {
|
||||
const session = new URL(url).searchParams.get('sessionId')!
|
||||
return { ok: operations.has(session), json: async () => ({ operationId: operations.get(session), workMode: 'execute' }) }
|
||||
})
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource('http://127.0.0.1:12345/fixture').replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
{ process: { stdout: { write: vi.fn() } }, createServer, randomBytes, fetch: fetchRoute }
|
||||
)
|
||||
const patch = vi.fn()
|
||||
const hooks = await factory({ client: {
|
||||
_client: { get: vi.fn(), post: vi.fn(), patch },
|
||||
session: { get: async ({ path }: { path: { id: string } }) => ({
|
||||
data: {
|
||||
id: path.id, parentID: path.id === 'child' ? 'one' : undefined,
|
||||
permission: path.id === 'child' ? [{ permission: 'task', pattern: '*', action: 'deny' }] : undefined
|
||||
}
|
||||
}) }
|
||||
} })
|
||||
const headers = async (sessionID: string, id: string) => {
|
||||
const output = { headers: {} }
|
||||
await hooks['chat.headers']({ sessionID, message: { id } }, output)
|
||||
return output.headers
|
||||
}
|
||||
try {
|
||||
await hooks['chat.message']({ sessionID: 'one' }, { message: { id: 'first' } })
|
||||
expect(patch).toHaveBeenCalledWith({
|
||||
url: '/session/one', body: { permission: [{ permission: '*', pattern: '*', action: 'allow' }] }, throwOnError: true
|
||||
})
|
||||
expect(await headers('child', 'child-message')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1'
|
||||
})
|
||||
await hooks['chat.message']({ sessionID: 'child' }, { message: { id: 'child-message' } })
|
||||
expect(patch).toHaveBeenLastCalledWith({
|
||||
url: '/session/child', body: { permission: [
|
||||
{ permission: '*', pattern: '*', action: 'allow' },
|
||||
{ permission: 'task', pattern: '*', action: 'deny' }
|
||||
] }, throwOnError: true
|
||||
})
|
||||
expect(await headers('two', 'compact')).toEqual({
|
||||
'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2'
|
||||
})
|
||||
operations.set('one', 'next-operation')
|
||||
expect(await headers('one', 'first')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1'
|
||||
})
|
||||
await hooks.event({ event: { type: 'session.status', properties: { sessionID: 'one', status: { type: 'idle' } } } })
|
||||
expect(await headers('one', 'next-message')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'next-operation'
|
||||
})
|
||||
operations.delete('two')
|
||||
await expect(headers('two', 'stale-new-message')).rejects.toThrow('no longer active')
|
||||
expect(await headers('two', 'compact')).toEqual({
|
||||
'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2'
|
||||
})
|
||||
} finally { await hooks.dispose() }
|
||||
})
|
||||
|
||||
it('authenticates native replies, checks live ownership, rejects stale events and closes its endpoint', async () => {
|
||||
const write = vi.fn()
|
||||
const question = { id: 'q', sessionID: 'grandchild', questions: [
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* OpenCode's ACP adapter omits native questions and child sessions. Native
|
||||
* events use ACP metadata; question replies use a process-local capability.
|
||||
*/
|
||||
export function openCodeSubagentPluginSource(): string {
|
||||
export function openCodeSubagentPluginSource(modelBridgeOrigin?: string): string {
|
||||
return `import { createServer } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes })`
|
||||
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes, modelBridgeOrigin: ${JSON.stringify(modelBridgeOrigin)} })`
|
||||
}
|
||||
|
||||
async function plugin(input?: {
|
||||
@@ -13,17 +13,41 @@ async function plugin(input?: {
|
||||
_client: {
|
||||
get(input: { url: string; throwOnError: true }): Promise<{ data: unknown }>
|
||||
post(input: { url: string; body?: unknown; throwOnError: true }): Promise<unknown>
|
||||
patch(input: { url: string; body: unknown; throwOnError: true }): Promise<unknown>
|
||||
}
|
||||
session: { get(input: { path: { id: string }; throwOnError: true }): Promise<{
|
||||
data: { id: string; parentID?: string }
|
||||
data: { id: string; parentID?: string; permission?: Array<{ permission: string; pattern: string; action: string }> }
|
||||
}> }
|
||||
}
|
||||
}, modules?: {
|
||||
createServer: typeof import('node:http').createServer
|
||||
randomBytes: typeof import('node:crypto').randomBytes
|
||||
modelBridgeOrigin?: string
|
||||
}) {
|
||||
const tasks = new Map<string, { sessionId: string; callId: string }>()
|
||||
const pending = new Map<string, { sessionId: string; root: string }>()
|
||||
const modelMessages = new Map<string, { sessionId: string; operationId: string; workMode: 'ask' | 'execute' }>()
|
||||
const modelRoute = async (sessionId: string, messageId: string) => {
|
||||
const key = `${sessionId}\0${messageId}`
|
||||
const previous = modelMessages.get(key)
|
||||
if (previous) return previous
|
||||
if (!input || !modules?.modelBridgeOrigin) return undefined
|
||||
let root = sessionId
|
||||
const seen = new Set<string>()
|
||||
for (;;) {
|
||||
if (seen.has(root)) throw new Error('OpenCode Session parent cycle')
|
||||
seen.add(root)
|
||||
const session = await input.client.session.get({ path: { id: root }, throwOnError: true })
|
||||
if (!session.data.parentID) break
|
||||
root = session.data.parentID
|
||||
}
|
||||
const response = await fetch(`${modules.modelBridgeOrigin}/session?sessionId=${encodeURIComponent(root)}`)
|
||||
if (!response.ok) throw new Error('GoodBuddy model operation is no longer active')
|
||||
const { operationId, workMode } = await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
|
||||
const route = { sessionId: root, operationId, workMode }
|
||||
modelMessages.set(key, route)
|
||||
return route
|
||||
}
|
||||
let endpoint: string | undefined
|
||||
let closed = false
|
||||
let closeServer: (() => void) | undefined
|
||||
@@ -82,8 +106,44 @@ async function plugin(input?: {
|
||||
closed = true
|
||||
pending.clear()
|
||||
tasks.clear()
|
||||
modelMessages.clear()
|
||||
closeServer?.()
|
||||
},
|
||||
'chat.message': async (
|
||||
request: { sessionID: string },
|
||||
output: { message: { id: string } }
|
||||
) => {
|
||||
const route = await modelRoute(request.sessionID, output.message.id)
|
||||
if (!route) return
|
||||
// Native ACP only forwards permissions for its registered root Sessions.
|
||||
// Use native Session rules so child tools use Execute and Ask's
|
||||
// unavailable tools are omitted instead of interrupting the prompt.
|
||||
const permission = route.workMode === 'execute'
|
||||
? [{ permission: '*', pattern: '*', action: 'allow' }]
|
||||
: [
|
||||
{ permission: '*', pattern: '*', action: 'deny' },
|
||||
...['read', 'glob', 'grep', 'list', 'lsp', 'webfetch', 'websearch', 'codesearch', 'question', 'external_directory']
|
||||
.map(permission => ({ permission, pattern: '*', action: 'allow' }))
|
||||
]
|
||||
if (route.sessionId !== request.sessionID) {
|
||||
const child = await input!.client.session.get({ path: { id: request.sessionID }, throwOnError: true })
|
||||
// OpenCode copies parent denies, not parent allows, into children.
|
||||
// Keep its explicit subagent restrictions after the request rules.
|
||||
permission.push(...(child.data.permission ?? []).filter(rule => rule.permission !== '*'))
|
||||
}
|
||||
await input!.client._client.patch({
|
||||
url: `/session/${encodeURIComponent(request.sessionID)}`, body: { permission }, throwOnError: true
|
||||
})
|
||||
},
|
||||
'chat.headers': async (
|
||||
request: { sessionID: string; message: { id: string } },
|
||||
output: { headers: Record<string, string> }
|
||||
) => {
|
||||
const route = await modelRoute(request.sessionID, request.message.id)
|
||||
if (!route) return
|
||||
output.headers['x-goodbuddy-session'] = route.sessionId
|
||||
output.headers['x-goodbuddy-operation'] = route.operationId
|
||||
},
|
||||
event: async ({ event }: { event: {
|
||||
type: string
|
||||
properties: {
|
||||
@@ -105,6 +165,9 @@ async function plugin(input?: {
|
||||
const properties = event.properties
|
||||
if (closed) return
|
||||
if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) {
|
||||
for (const [key, route] of modelMessages) {
|
||||
if (route.sessionId === properties.sessionID) modelMessages.delete(key)
|
||||
}
|
||||
for (const [id, question] of pending) {
|
||||
if (question.root !== properties.sessionID && question.sessionId !== properties.sessionID) continue
|
||||
pending.delete(id)
|
||||
|
||||
@@ -74,7 +74,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c
|
||||
controllerId: 'controller', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
||||
let complete = false
|
||||
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding', controllerId: 'controller',
|
||||
workspaceDirectory: root, workMode: 'execute', transcript,
|
||||
workspaceDirectory: root, transcript,
|
||||
process: {
|
||||
writeStdin: async (data: Uint8Array) => { child.stdin.write(data) },
|
||||
subscribeOutput: (listener: (event: { stream: string; data: Uint8Array }) => void) => {
|
||||
@@ -88,7 +88,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c
|
||||
})
|
||||
try {
|
||||
await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] })
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] }, 'execute')
|
||||
let questionId: string | undefined
|
||||
await expect.poll(async () => {
|
||||
const page = transcript.page({ bindingId: 'binding', operationId: 'operation',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
type AcpJournalCursor,
|
||||
type RemotePromptOperationPreparation,
|
||||
@@ -7,7 +10,7 @@ import {
|
||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||
import type { ControllerLease } from './controller-registry'
|
||||
import { AgentModelGatewayError } from './agent-model-gateway'
|
||||
import { EventJournalCapacityError } from './event-journal'
|
||||
import { EventJournal, EventJournalCapacityError } from './event-journal'
|
||||
import type { ProtocolMethodContext } from './protocol-server'
|
||||
import type {
|
||||
ModelBridgeBrokerDispatch,
|
||||
@@ -280,6 +283,8 @@ function harness(input: {
|
||||
uniqueProcesses?: boolean
|
||||
outputGate?: Promise<void>
|
||||
agentOwned?: boolean
|
||||
shareOwnedProcesses?: boolean
|
||||
journal?: RuntimeAcpJournal
|
||||
modelGateway?: {
|
||||
dispatch: ReturnType<typeof vi.fn>
|
||||
finalizePrompt: ReturnType<typeof vi.fn>
|
||||
@@ -319,7 +324,9 @@ function harness(input: {
|
||||
}
|
||||
: undefined
|
||||
const backend = new RuntimeAcpBackend({
|
||||
journal,
|
||||
shareOwnedProcesses: input.shareOwnedProcesses,
|
||||
sharedProcessIdleMs: 20,
|
||||
journal: input.journal ?? journal,
|
||||
resolveRuntimeBundle: vi.fn(async () => resolved),
|
||||
loadRegisteredRuntimeBundle: vi.fn(async () => verifiedBundle()),
|
||||
resolveWorkspace: vi.fn(async (preparation) => ({
|
||||
@@ -444,6 +451,66 @@ function harness(input: {
|
||||
}
|
||||
|
||||
describe('RuntimeAcpBackend', () => {
|
||||
it.each([false, true])('allows mode changes only on a shared Session (shared=%s)', async (shared) => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared, workMode: 'execute' })
|
||||
try {
|
||||
await open(fixture)
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation())
|
||||
await invoke(fixture, 'runtime/completePrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1'
|
||||
})
|
||||
const next = fixture.preparation({
|
||||
operationId: 'request-2', requestId: 'request-2', promptSequence: 1, workMode: 'ask'
|
||||
})
|
||||
if (shared) {
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', next)).resolves.toMatchObject({ workMode: 'ask' })
|
||||
await invoke(fixture, 'runtime/completePrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-2', requestId: 'request-2'
|
||||
})
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
operationId: 'request-3', requestId: 'request-3', promptSequence: 2, workMode: 'execute'
|
||||
}))).resolves.toMatchObject({ workMode: 'execute' })
|
||||
} else {
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', next)).rejects.toThrow('work mode cannot change')
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('shares Agent-owned workspaces and modes, retaining peers and reclaiming only an unused process', async () => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
|
||||
const channels: Array<{ bindingId: string; channelId: string; channelEpoch: string }> = []
|
||||
try {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const bindingId = `shared-${index}`
|
||||
const workspaceIdentity = `workspace-${index % 2}`
|
||||
const channel = await invoke(fixture, 'runtime/openAcpChannel', {
|
||||
...fixture.openRequest, bindingId, workspaceIdentity
|
||||
}) as (typeof channels)[number]
|
||||
channels.push({ bindingId, channelId: channel.channelId, channelEpoch: channel.channelEpoch })
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
bindingId, workspaceIdentity, channelEpoch: channel.channelEpoch,
|
||||
workMode: index % 2 ? 'execute' : 'ask',
|
||||
operationId: `operation-${index}`, requestId: `operation-${index}`
|
||||
}))
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
for (const channel of channels.slice(0, 3)) {
|
||||
await invoke(fixture, 'runtime/closeAcpChannel', {
|
||||
...channel, reason: 'released'
|
||||
})
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
await invoke(fixture, 'runtime/closeAcpChannel', { ...channels[3], reason: 'released' })
|
||||
await vi.waitFor(() => expect(fixture.process.stops).toEqual(['binding-closed']))
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects Agent-first unknown blob channels before prompt authority exists', async () => {
|
||||
const fixture = harness()
|
||||
await expect(
|
||||
@@ -1536,6 +1603,71 @@ describe('RuntimeAcpBackend', () => {
|
||||
).rejects.toMatchObject({ code: 'stale-controller' })
|
||||
})
|
||||
|
||||
it('resumes shared semantic bindings with no binary output in the real journal', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-empty-acp-replay-'))
|
||||
const journal = new EventJournal(join(directory, 'events.sqlite'))
|
||||
const fixture = harness({
|
||||
agentOwned: true,
|
||||
shareOwnedProcesses: true,
|
||||
journal
|
||||
})
|
||||
const channels: Array<{
|
||||
bindingId: string
|
||||
channelId: string
|
||||
channelEpoch: string
|
||||
}> = []
|
||||
try {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const bindingId = `empty-${index}`
|
||||
const workspaceIdentity = `workspace-${index % 2}`
|
||||
const channel = await invoke(fixture, 'runtime/openAcpChannel', {
|
||||
...fixture.openRequest, bindingId, workspaceIdentity
|
||||
}) as (typeof channels)[number]
|
||||
channels.push({
|
||||
bindingId,
|
||||
channelId: channel.channelId,
|
||||
channelEpoch: channel.channelEpoch
|
||||
})
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
bindingId, workspaceIdentity,
|
||||
channelEpoch: channel.channelEpoch,
|
||||
operationId: `operation-${index}`,
|
||||
requestId: `operation-${index}`,
|
||||
workMode: index % 2 ? 'execute' : 'ask'
|
||||
}))
|
||||
expect(journal.getAcpCursor(
|
||||
bindingId, channel.channelEpoch, 'runtime-to-main'
|
||||
)).toBeUndefined()
|
||||
}
|
||||
fixture.context.abort.abort()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
const resumed = protocolContext({
|
||||
generation: 2,
|
||||
connectionId: 'connection-2'
|
||||
})
|
||||
for (const channel of channels) {
|
||||
await fixture.backend.methods['runtime/resumeAcpChannel']!(
|
||||
channel, resumed
|
||||
)
|
||||
await expect(fixture.backend.methods['runtime/replayAcpChannel']!(
|
||||
{ ...channel, acknowledgedSequence: '1' }, resumed
|
||||
)).rejects.toThrow('ACK exceeds durable output')
|
||||
await expect(fixture.backend.methods['runtime/replayAcpChannel']!(
|
||||
{ ...channel, acknowledgedSequence: '0' }, resumed
|
||||
)).resolves.toMatchObject({
|
||||
...channel, replayedThroughSequence: '0', live: true
|
||||
})
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
expect(fixture.process.writes).toEqual([])
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
journal.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed on supervised process identity conflict', async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
ProtocolMethodHandler
|
||||
} from './protocol-server'
|
||||
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import type { SemanticPromptStore } from './semantic-prompt-store'
|
||||
import {
|
||||
AgentModelGatewayError,
|
||||
@@ -83,6 +84,7 @@ const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL =
|
||||
const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const SHARED_PROCESS_IDLE_MS = 60_000
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
|
||||
@@ -228,6 +230,7 @@ export type RuntimeAcpProcessLaunch = {
|
||||
workMode: 'ask' | 'execute'
|
||||
deadlineAt: string
|
||||
budget: RemotePromptOperationPreparation['budget']
|
||||
sharedSessions?: boolean
|
||||
modelBridge?: {
|
||||
socketPath: string
|
||||
policy: ModelBridgePolicy
|
||||
@@ -249,6 +252,8 @@ export type RuntimeAcpBackendLimits = {
|
||||
}
|
||||
|
||||
export type RuntimeAcpBackendOptions = {
|
||||
shareOwnedProcesses?: boolean
|
||||
sharedProcessIdleMs?: number
|
||||
journal: RuntimeAcpJournal
|
||||
resolveRuntimeBundle(
|
||||
runtimeId: string,
|
||||
@@ -298,6 +303,7 @@ type PreparedOperation = {
|
||||
budget: RemotePromptOperationPreparation['budget']
|
||||
completion?: z.infer<typeof acpCompletePromptResultSchema>
|
||||
terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted'
|
||||
processTree?: 'running' | 'empty'
|
||||
modelProfile?: AgentPromptModelProfile
|
||||
promptSequence: number
|
||||
ownedPromptStarted?: boolean
|
||||
@@ -314,6 +320,7 @@ type BindingState = {
|
||||
state: 'open' | 'running' | 'stopping' | 'closed' | 'outcome-unknown'
|
||||
workMode?: 'ask' | 'execute'
|
||||
process?: RuntimeAcpProcessOwner
|
||||
sharedProcess?: SharedProcess
|
||||
unsubscribeOutput?: () => void
|
||||
unsubscribeExit?: () => void
|
||||
stopRequested: boolean
|
||||
@@ -338,12 +345,23 @@ type BindingState = {
|
||||
poisoned: boolean
|
||||
}
|
||||
|
||||
type SharedProcess = {
|
||||
key: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport: AgentAcpConnection
|
||||
bindings: Set<BindingState>
|
||||
unsubscribeExit?: () => void
|
||||
idleTimer?: NodeJS.Timeout
|
||||
exited: boolean
|
||||
}
|
||||
|
||||
export class RuntimeAcpBackend {
|
||||
readonly methods: Readonly<Record<string, ProtocolMethodHandler>>
|
||||
readonly #options: RuntimeAcpBackendOptions
|
||||
readonly #limits: RuntimeAcpBackendLimits
|
||||
readonly #bindings = new Map<string, BindingState>()
|
||||
readonly #processIdentityBindings = new Map<string, string>()
|
||||
readonly #sharedProcesses = new Map<string, SharedProcess>()
|
||||
readonly #boundConnectionSignals = new WeakSet<AbortSignal>()
|
||||
#nextChannelEpoch: bigint
|
||||
#lifetimeBindings = 0
|
||||
@@ -636,6 +654,14 @@ export class RuntimeAcpBackend {
|
||||
result.status === 'rejected'
|
||||
)
|
||||
.map((result) => result.reason)
|
||||
const sharedResults = await Promise.allSettled(
|
||||
[...this.#sharedProcesses.values()].map(group =>
|
||||
this.#stopSharedProcess(group, 'binding-closed')
|
||||
)
|
||||
)
|
||||
for (const result of sharedResults) {
|
||||
if (result.status === 'rejected') failures.push(result.reason)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
@@ -930,11 +956,15 @@ export class RuntimeAcpBackend {
|
||||
'identity'
|
||||
)
|
||||
}
|
||||
this.#options.journal.acknowledgeAcpFromMain?.({
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
sequence: request.acknowledgedSequence
|
||||
})
|
||||
// Agent-owned prompts may have semantic events but no binary journal.
|
||||
// In that case the validated zero cursor has nothing to acknowledge.
|
||||
if (initialCursor !== undefined) {
|
||||
this.#options.journal.acknowledgeAcpFromMain?.({
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
sequence: request.acknowledgedSequence
|
||||
})
|
||||
}
|
||||
if (this.#options.journal.replayAcpFrames === undefined) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Durable ACP replay is unavailable',
|
||||
@@ -1011,7 +1041,8 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
if (
|
||||
binding.workMode !== undefined &&
|
||||
binding.workMode !== preparation.workMode
|
||||
binding.workMode !== preparation.workMode &&
|
||||
binding.sharedProcess === undefined
|
||||
) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Runtime process work mode cannot change',
|
||||
@@ -1124,12 +1155,22 @@ export class RuntimeAcpBackend {
|
||||
|
||||
if (binding.process === undefined) {
|
||||
let process: RuntimeAcpProcessOwner
|
||||
const sharedKey =
|
||||
this.#options.shareOwnedProcesses &&
|
||||
preparation.modelProfile && this.#options.modelGateway &&
|
||||
this.#options.semanticPrompts && binding.modelBridgePolicy
|
||||
? canonicalJson([
|
||||
verified.manifest.bundleDigest,
|
||||
verified.manifest.adapterDigest,
|
||||
binding.modelBridgePolicy
|
||||
])
|
||||
: undefined
|
||||
this.#options.diagnostics?.tryRecord('runtime.starting', {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
})
|
||||
try {
|
||||
process = await this.#options.launchProcess({
|
||||
const launch: RuntimeAcpProcessLaunch = {
|
||||
manifest: verified.manifest,
|
||||
bundle: verified,
|
||||
workspace,
|
||||
@@ -1137,6 +1178,7 @@ export class RuntimeAcpBackend {
|
||||
workMode: preparation.workMode,
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
budget: preparation.budget,
|
||||
...(sharedKey === undefined ? {} : { sharedSessions: true }),
|
||||
...(binding.modelBridgePolicy === undefined ||
|
||||
binding.modelBridgeBroker === undefined
|
||||
? {}
|
||||
@@ -1146,7 +1188,15 @@ export class RuntimeAcpBackend {
|
||||
policy: binding.modelBridgePolicy
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (sharedKey !== undefined) {
|
||||
const group = await this.#acquireSharedProcess(sharedKey, launch)
|
||||
binding.sharedProcess = group
|
||||
group.bindings.add(binding)
|
||||
process = group.process
|
||||
} else {
|
||||
process = await this.#options.launchProcess(launch)
|
||||
}
|
||||
assertProcessIdentity(process.identity)
|
||||
} catch (error) {
|
||||
this.#options.diagnostics?.tryRecord('runtime.start.failed', {
|
||||
@@ -1166,7 +1216,7 @@ export class RuntimeAcpBackend {
|
||||
const identityOwner = this.#processIdentityBindings.get(processKey)
|
||||
if (
|
||||
identityOwner !== undefined &&
|
||||
identityOwner !== binding.request.bindingId
|
||||
identityOwner !== (binding.sharedProcess?.key ?? binding.request.bindingId)
|
||||
) {
|
||||
await Promise.resolve(
|
||||
process.stop({
|
||||
@@ -1182,7 +1232,7 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
this.#processIdentityBindings.set(
|
||||
processKey,
|
||||
binding.request.bindingId
|
||||
binding.sharedProcess?.key ?? binding.request.bindingId
|
||||
)
|
||||
binding.process = process
|
||||
binding.workspaceDirectory = workspace.workspaceDirectory
|
||||
@@ -1208,7 +1258,7 @@ export class RuntimeAcpBackend {
|
||||
binding.unsubscribeOutput = unsubscribe
|
||||
}
|
||||
}
|
||||
const unsubscribeExit = process.subscribeExit?.(() => {
|
||||
const unsubscribeExit = binding.sharedProcess ? undefined : process.subscribeExit?.(() => {
|
||||
void this.#enqueueControl(async () => {
|
||||
await this.#handleProcessExit(binding)
|
||||
}).catch(() => undefined)
|
||||
@@ -1230,7 +1280,7 @@ export class RuntimeAcpBackend {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
})
|
||||
} else {
|
||||
} else if (!binding.sharedProcess) {
|
||||
try {
|
||||
await binding.process.beginPrompt({
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
@@ -1246,6 +1296,7 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.workMode = preparation.workMode
|
||||
binding.inputBytes = 0
|
||||
if (!binding.operations.has(preparation.operationId)) {
|
||||
binding.operations.set(preparation.operationId, {
|
||||
@@ -1389,12 +1440,14 @@ export class RuntimeAcpBackend {
|
||||
'conflict'
|
||||
)
|
||||
}
|
||||
if (Buffer.byteLength(canonicalJson(request.prompt)) > prepared.budget.maximumInputBytes) {
|
||||
throw new RuntimeAcpBackendError('Prompt input quota reached', 'capacity')
|
||||
}
|
||||
if (binding.ownedAcp === undefined) {
|
||||
binding.ownedAcp = new AgentOwnedAcpPrompt({
|
||||
bindingId: binding.request.bindingId,
|
||||
controllerId: binding.controllerId,
|
||||
workspaceDirectory: binding.workspaceDirectory,
|
||||
workMode: binding.workMode!,
|
||||
...(binding.modelBridgePolicy === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -1404,6 +1457,14 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}),
|
||||
process: binding.process,
|
||||
...(binding.sharedProcess ? {
|
||||
transport: binding.sharedProcess.transport,
|
||||
prepareSession: async (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => {
|
||||
await binding.sharedProcess!.transport.setModelRoute(
|
||||
sessionId, operationId, binding.modelBridgeBroker!.socketPath, workMode
|
||||
)
|
||||
}
|
||||
} : {}),
|
||||
transcript: this.#options.semanticPrompts,
|
||||
completePrompt: async (operationId, status) => {
|
||||
await this.#enqueueControl(async () => {
|
||||
@@ -1418,7 +1479,7 @@ export class RuntimeAcpBackend {
|
||||
binding.poisoned ? 'outcome-unknown' : status
|
||||
})
|
||||
}
|
||||
const result = await binding.ownedAcp.start(request)
|
||||
const result = await binding.ownedAcp.start(request, prepared.acceptance.workMode)
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
@@ -1532,7 +1593,7 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
await this.#closeModelBridge(binding, true)
|
||||
try {
|
||||
await binding.process.completePrompt()
|
||||
if (!binding.sharedProcess) await binding.process.completePrompt()
|
||||
} catch {
|
||||
binding.state = 'outcome-unknown'
|
||||
prepared.modelProfile = undefined
|
||||
@@ -1556,6 +1617,7 @@ export class RuntimeAcpBackend {
|
||||
: status === 'failed'
|
||||
? 'failed'
|
||||
: 'completed'
|
||||
prepared.processTree = 'running'
|
||||
if (status === 'completed') {
|
||||
prepared.completion = acpCompletePromptResultSchema.parse({
|
||||
bindingId: binding.request.bindingId,
|
||||
@@ -1759,6 +1821,11 @@ export class RuntimeAcpBackend {
|
||||
binding.modelBridgeBroker = undefined
|
||||
binding.modelBridgeClient = undefined
|
||||
let brokerError: unknown
|
||||
if (binding.sharedProcess && binding.ownedAcp?.sessionId && binding.activeOperationId) {
|
||||
await binding.sharedProcess.transport.setModelRoute(
|
||||
binding.ownedAcp.sessionId, binding.activeOperationId
|
||||
).catch(() => undefined)
|
||||
}
|
||||
try {
|
||||
await broker?.close()
|
||||
} catch (error) {
|
||||
@@ -1902,7 +1969,7 @@ export class RuntimeAcpBackend {
|
||||
status: 'terminal',
|
||||
terminalState: prepared.terminalState,
|
||||
processTree:
|
||||
prepared.terminalState === 'completed' ? 'running' : 'empty'
|
||||
prepared.processTree ?? (prepared.terminalState === 'completed' ? 'running' : 'empty')
|
||||
}
|
||||
}
|
||||
if (binding.activeOperationId !== operation.operationId) {
|
||||
@@ -2113,6 +2180,25 @@ export class RuntimeAcpBackend {
|
||||
this.#finishBinding(binding, 'interrupted')
|
||||
return
|
||||
}
|
||||
if (binding.sharedProcess) {
|
||||
const group = binding.sharedProcess
|
||||
const prepared = binding.activeOperationId
|
||||
? binding.operations.get(binding.activeOperationId) : undefined
|
||||
try {
|
||||
await rejectAfter(
|
||||
binding.ownedAcp?.releaseSession() ?? Promise.resolve(),
|
||||
OWNED_PROMPT_CANCEL_GRACE_MS * 3,
|
||||
'Shared Runtime Session release timed out'
|
||||
)
|
||||
if (prepared) prepared.processTree = group.exited ? 'empty' : 'running'
|
||||
this.#finishBinding(binding, reason === 'user-cancelled' ? 'cancelled' : 'interrupted')
|
||||
} catch {
|
||||
// A non-responsive native control plane affects the whole process.
|
||||
// Ordinary Session cancellation never reaches this fallback.
|
||||
await this.#stopSharedProcess(group, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
binding.state = 'stopping'
|
||||
if (!binding.stopRequested) {
|
||||
binding.stopRequested = true
|
||||
@@ -2277,7 +2363,12 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
|
||||
#finishProcess(binding: BindingState): void {
|
||||
if (binding.process !== undefined) {
|
||||
const group = binding.sharedProcess
|
||||
if (group) {
|
||||
group.bindings.delete(binding)
|
||||
binding.sharedProcess = undefined
|
||||
this.#scheduleSharedIdle(group)
|
||||
} else if (binding.process !== undefined) {
|
||||
this.#processIdentityBindings.delete(
|
||||
processIdentityKey(binding.process.identity)
|
||||
)
|
||||
@@ -2305,6 +2396,68 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async #acquireSharedProcess(key: string, launch: RuntimeAcpProcessLaunch): Promise<SharedProcess> {
|
||||
const existing = this.#sharedProcesses.get(key)
|
||||
if (existing && !existing.exited) {
|
||||
if (existing.idleTimer) clearTimeout(existing.idleTimer)
|
||||
existing.idleTimer = undefined
|
||||
return existing
|
||||
}
|
||||
const process = await this.#options.launchProcess(launch)
|
||||
assertProcessIdentity(process.identity)
|
||||
const group: SharedProcess = {
|
||||
key, process, transport: new AgentAcpConnection(process),
|
||||
bindings: new Set(), exited: false
|
||||
}
|
||||
group.unsubscribeExit = process.subscribeExit?.(() => {
|
||||
void this.#enqueueControl(() => this.#handleSharedExit(group)).catch(() => undefined)
|
||||
}) ?? undefined
|
||||
this.#sharedProcesses.set(key, group)
|
||||
return group
|
||||
}
|
||||
|
||||
#scheduleSharedIdle(group: SharedProcess): void {
|
||||
if (group.bindings.size || group.exited || this.#disposed || group.idleTimer) return
|
||||
group.idleTimer = setTimeout(() => {
|
||||
group.idleTimer = undefined
|
||||
void this.#enqueueControl(async () => {
|
||||
if (!group.bindings.size) await this.#stopSharedProcess(group, 'binding-closed')
|
||||
}).catch(() => undefined)
|
||||
}, this.#options.sharedProcessIdleMs ?? SHARED_PROCESS_IDLE_MS)
|
||||
group.idleTimer.unref?.()
|
||||
}
|
||||
|
||||
async #stopSharedProcess(group: SharedProcess, reason: RuntimeAcpProcessStopReason): Promise<void> {
|
||||
if (group.exited) return
|
||||
if (group.idleTimer) clearTimeout(group.idleTimer)
|
||||
group.idleTimer = undefined
|
||||
await group.process.stop({
|
||||
reason, deadlineAt: new Date(this.#now() + 10_000).toISOString()
|
||||
})
|
||||
const result = await group.process.reconcile()
|
||||
if (!sameProcessIdentity(result.identity, group.process.identity) || result.processTree !== 'empty') {
|
||||
throw new RuntimeAcpBackendError('Shared Runtime process stop did not reconcile', 'process')
|
||||
}
|
||||
await this.#handleSharedExit(group)
|
||||
}
|
||||
|
||||
async #handleSharedExit(group: SharedProcess): Promise<void> {
|
||||
if (group.exited) return
|
||||
group.exited = true
|
||||
if (group.idleTimer) clearTimeout(group.idleTimer)
|
||||
group.unsubscribeExit?.()
|
||||
group.transport.dispose()
|
||||
if (this.#sharedProcesses.get(group.key) === group) this.#sharedProcesses.delete(group.key)
|
||||
this.#processIdentityBindings.delete(processIdentityKey(group.process.identity))
|
||||
for (const binding of [...group.bindings]) {
|
||||
if (binding.activeOperationId) {
|
||||
const prepared = binding.operations.get(binding.activeOperationId)
|
||||
if (prepared) prepared.processTree = 'empty'
|
||||
}
|
||||
await this.#handleProcessExit(binding)
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleDeadline(binding: BindingState, deadlineAt: string): void {
|
||||
if (binding.deadlineTimer !== undefined) {
|
||||
clearTimeout(binding.deadlineTimer)
|
||||
|
||||
@@ -245,6 +245,7 @@ export async function createProductionRuntimeProtocol(
|
||||
stateDirectory
|
||||
)
|
||||
backend = new RuntimeAcpBackend({
|
||||
shareOwnedProcesses: true,
|
||||
journal: options.events,
|
||||
resolveRuntimeBundle: (runtimeId, bundleDigest) =>
|
||||
currentRegistry().resolve(
|
||||
@@ -254,7 +255,9 @@ export async function createProductionRuntimeProtocol(
|
||||
),
|
||||
loadRegisteredRuntimeBundle: async (resolved) => {
|
||||
return await loadRegistered(
|
||||
resolved.entry,
|
||||
// This composition's resolver above returns the complete registry
|
||||
// entry; the backend itself only needs its public identity fields.
|
||||
resolved.entry as RuntimeRegistryEntry,
|
||||
resolved.bundleDirectory
|
||||
)
|
||||
},
|
||||
@@ -283,7 +286,8 @@ export async function createProductionRuntimeProtocol(
|
||||
bridgeDirectory:
|
||||
launch.workspace.bridgeDirectory!,
|
||||
socketPath: launch.modelBridge.socketPath,
|
||||
policy: launch.modelBridge.policy
|
||||
policy: launch.modelBridge.policy,
|
||||
sharedSessions: launch.sharedSessions
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -294,7 +298,8 @@ export async function createProductionRuntimeProtocol(
|
||||
installationId,
|
||||
registry: ownerRegistry,
|
||||
deadlineAt: launch.deadlineAt,
|
||||
maximumInputBytes: launch.budget.maximumInputBytes
|
||||
maximumInputBytes: launch.budget.maximumInputBytes,
|
||||
sharedSessions: launch.sharedSessions
|
||||
}),
|
||||
outputSink: options.outputSink,
|
||||
semanticPrompts,
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
AcpRemoteRuntime,
|
||||
RemotePromptCancelledError,
|
||||
RemotePromptRecoveryUnavailableError,
|
||||
type AcpRemoteRuntimeOptions
|
||||
} from './acp-remote-runtime'
|
||||
import type {
|
||||
@@ -2979,6 +2981,42 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the recovered generation for retained and new Sessions and mode changes', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const fresh = ownedChannel()
|
||||
try {
|
||||
await collect(fixture.instance.run(
|
||||
{ ...request, workMode: 'ask' }, new AbortController().signal
|
||||
))
|
||||
Object.defineProperty(fixture.channel, 'generation', { value: 2 })
|
||||
await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-reconnect', workMode: 'execute' },
|
||||
new AbortController().signal
|
||||
))
|
||||
Object.defineProperty(fresh.channel, 'generation', { value: 2 })
|
||||
await collect(fresh.instance.run(
|
||||
{ ...request, requestId: 'new-after-reconnect', conversationId: 'another-conversation' },
|
||||
new AbortController().signal
|
||||
))
|
||||
expect([
|
||||
...fixture.preparePrompt.mock.calls.slice(1),
|
||||
...fresh.preparePrompt.mock.calls
|
||||
].map(([prepared]) => ({
|
||||
controllerGeneration: prepared.controllerGeneration,
|
||||
connectionGeneration: prepared.connectionGeneration
|
||||
}))).toEqual([
|
||||
{ controllerGeneration: 2, connectionGeneration: 2 },
|
||||
{ controllerGeneration: 2, connectionGeneration: 2 }
|
||||
])
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenNthCalledWith(
|
||||
2, expect.objectContaining({ acpSessionId: 'owned-session' })
|
||||
)
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await Promise.all([fixture.instance.dispose(), fresh.instance.dispose()])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not abandon an accepted Agent prompt for a later request', async () => {
|
||||
const fixture = ownedChannel({ waitForCancellation: true })
|
||||
const first = fixture.instance.run(
|
||||
@@ -3115,6 +3153,30 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('definitively ends recovery after the original Agent installation is replaced', async () => {
|
||||
const fixture = ownedChannel({ waitForCancellation: true })
|
||||
const first = fixture.instance.run(request, new AbortController().signal)
|
||||
await first.next()
|
||||
await first.return()
|
||||
const channelFactory = vi.fn(async () => fixture.channel)
|
||||
const replacement = factoryRuntime(channelFactory, fixture.store, {
|
||||
identity: { ...identity, agentInstallationId: 'replacement-agent' },
|
||||
modelBridgePolicy,
|
||||
modelProfile: ownedModelProfile
|
||||
})
|
||||
try {
|
||||
await expect(collect(replacement.run(
|
||||
{ ...request, remoteRecoveryOnly: true },
|
||||
new AbortController().signal
|
||||
))).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
|
||||
expect(channelFactory).not.toHaveBeenCalled()
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await Promise.all([replacement.dispose(), fixture.instance.dispose()])
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles an owned cancellation before the next prompt in the same conversation', async () => {
|
||||
const fixture = ownedChannel()
|
||||
fixture.pageOwnedPromptTranscript.mockImplementationOnce(() => new Promise<never>(() => {}))
|
||||
@@ -3124,7 +3186,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
const pending = stream.next()
|
||||
await vi.waitFor(() => expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce())
|
||||
controller.abort(new Error('cancel pending question'))
|
||||
await expect(pending).rejects.toThrow('cancel pending question')
|
||||
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toBeUndefined()
|
||||
const events = await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-cancel' }, new AbortController().signal
|
||||
@@ -3170,7 +3232,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(settled).toBe(false)
|
||||
|
||||
releaseEscalation()
|
||||
await expect(pending).rejects.toThrow('cancel owned prompt')
|
||||
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -138,6 +138,13 @@ export class RemotePromptRecoveryUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class RemotePromptCancelledError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'RemotePromptCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
type ActivePrompt = {
|
||||
reportedQuestions?: Set<string>
|
||||
subagentProgress?: OpenCodeSubagentProgress
|
||||
@@ -1016,9 +1023,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
false
|
||||
).catch(() => undefined)
|
||||
if (
|
||||
result?.status === 'terminal' &&
|
||||
(result.processTree === 'empty' ||
|
||||
result.terminalState === 'completed')
|
||||
result?.status === 'terminal'
|
||||
) {
|
||||
const terminal = await this.persist(
|
||||
context,
|
||||
@@ -1310,6 +1315,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return runtimeSessionBindingSchema.parse({
|
||||
bindingId,
|
||||
...this.options.identity,
|
||||
controllerGeneration: context.channel.generation,
|
||||
conversationId,
|
||||
runtimeId: this.options.runtimeId,
|
||||
acpSessionId: `opening-${randomUUID()}`,
|
||||
@@ -1353,6 +1359,15 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
'没有可安全附加的远端 Agent 请求,且恢复不会重放任务'
|
||||
)
|
||||
}
|
||||
if (
|
||||
request.remoteRecoveryOnly === true &&
|
||||
binding &&
|
||||
!this.bindingIdentityMatches(binding)
|
||||
) {
|
||||
throw new RemotePromptRecoveryUnavailableError(
|
||||
'原远端 Agent 或 Runtime 已变更,无法恢复原请求且不会重放任务'
|
||||
)
|
||||
}
|
||||
if (
|
||||
request.remoteRecoveryOnly !== true &&
|
||||
this.options.modelProfile !== undefined &&
|
||||
@@ -1741,6 +1756,11 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
current.context,
|
||||
current.binding
|
||||
)
|
||||
current.binding = await this.rotateBindingTransport(
|
||||
current.binding,
|
||||
current.context,
|
||||
signal
|
||||
)
|
||||
current.binding = await this.persist(
|
||||
current.context,
|
||||
current.binding,
|
||||
@@ -2685,9 +2705,10 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
)
|
||||
session.binding = binding
|
||||
if (terminal.state === 'cancelled') {
|
||||
throw signal.aborted
|
||||
? abortReason(signal)
|
||||
: new Error('远端 Runtime 请求已取消')
|
||||
throw new RemotePromptCancelledError(
|
||||
'远端 Runtime 请求已取消',
|
||||
signal.aborted ? { cause: abortReason(signal) } : undefined
|
||||
)
|
||||
}
|
||||
if (terminal.state === 'failed') {
|
||||
throw new Error(
|
||||
@@ -2810,6 +2831,14 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
signal,
|
||||
authorize
|
||||
)
|
||||
} catch (error) {
|
||||
if (signal.aborted && session.binding.state === 'closed') {
|
||||
throw new RemotePromptCancelledError(
|
||||
error instanceof Error ? error.message : '远端 Runtime 请求已取消',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
this.sessionReservations.delete(request.conversationId)
|
||||
this.notifyDrain()
|
||||
|
||||
@@ -134,6 +134,10 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
|
||||
expect(runtime.runtimeId).toBe('deepseek-harness')
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
expect(deepseekHarnessLauncher).not.toHaveBeenCalled()
|
||||
await expect(runtime.testConnection!()).resolves.toMatchObject({
|
||||
available: false
|
||||
})
|
||||
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
type ModelRuntimeOptions
|
||||
} from './model-runtime'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import { OpenCodeRuntime, type OpenCodeRuntimeOptions } from './opencode-runtime'
|
||||
import type { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
type DeepSeekHarnessRuntimeOptions
|
||||
@@ -57,6 +58,7 @@ const noModelTaskTools: ModelToolProviderLike = {
|
||||
}
|
||||
|
||||
export type AgentCapabilityContext = {
|
||||
localRuntimeRegistry?: LocalRuntimeRegistry
|
||||
skillInstructions?: string
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
mcpServers?: ResolvedMcpServer[]
|
||||
@@ -213,8 +215,8 @@ export function createAgentRuntime(
|
||||
if (!capabilities.deepseekHarnessLauncher) {
|
||||
throw new Error('DeepSeek Harness 受控 Host 启动器不可用')
|
||||
}
|
||||
return new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
const options: DeepSeekHarnessRuntimeOptions = {
|
||||
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.modelName,
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
@@ -225,16 +227,26 @@ export function createAgentRuntime(
|
||||
},
|
||||
skillPackages: capabilities.skillPackages,
|
||||
extensionPackages: capabilities.deepseekHarnessExtensions,
|
||||
toolProvider: new ModelToolProvider(
|
||||
getWorkspaceAccess(),
|
||||
capabilities.mcpServers,
|
||||
undefined,
|
||||
capabilities.knowledgeGateway,
|
||||
capabilities.webSearchEnabled === true,
|
||||
{},
|
||||
capabilities.launchEnvironmentProvider
|
||||
createToolProvider: (directory) => new ModelToolProvider(
|
||||
new LocalWorkspaceAccess(directory), capabilities.mcpServers, undefined,
|
||||
capabilities.knowledgeGateway, capabilities.webSearchEnabled === true,
|
||||
{}, capabilities.launchEnvironmentProvider
|
||||
)
|
||||
})
|
||||
}
|
||||
const create = () => new DeepSeekHarnessRuntime(options)
|
||||
if (!capabilities.localRuntimeRegistry) return create()
|
||||
const configuration = { ...options, defaultWorkspace: undefined, createToolProvider: undefined }
|
||||
return capabilities.localRuntimeRegistry.acquire(
|
||||
`deepseek-harness:${profile.id}`,
|
||||
{
|
||||
...configuration,
|
||||
mcpServers: capabilities.mcpServers,
|
||||
webSearchEnabled: capabilities.webSearchEnabled === true,
|
||||
launchEnvironment: capabilities.launchEnvironmentProvider?.()
|
||||
},
|
||||
workspace,
|
||||
create
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'continue') {
|
||||
@@ -293,7 +305,7 @@ export function createAgentRuntime(
|
||||
'OpenCode 独立模型连接仅支持文本对话协议,不支持图像生成协议'
|
||||
)
|
||||
}
|
||||
return new OpenCodeRuntime({
|
||||
const options: OpenCodeRuntimeOptions = {
|
||||
baseUrl,
|
||||
embedded,
|
||||
binaryPath:
|
||||
@@ -311,13 +323,25 @@ export function createAgentRuntime(
|
||||
skillInstructions: capabilities.skillInstructions,
|
||||
skillPackages: capabilities.skillPackages,
|
||||
sharedCacheRoot: capabilities.opencodeSharedCacheRoot,
|
||||
defaultWorkspace: workspace,
|
||||
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
|
||||
knowledgeGateway: capabilities.knowledgeGateway,
|
||||
mcpServers: capabilities.mcpServers,
|
||||
customization: settings?.runtimeCustomization.opencode,
|
||||
launchEnvironmentProvider:
|
||||
capabilities.launchEnvironmentProvider
|
||||
})
|
||||
}
|
||||
const create = () => new OpenCodeRuntime(options)
|
||||
if (!capabilities.localRuntimeRegistry) return create()
|
||||
const configuration = { ...options, defaultWorkspace: undefined }
|
||||
return capabilities.localRuntimeRegistry.acquire(
|
||||
`opencode:${settings?.opencodeModelProfile?.id ?? 'native'}`,
|
||||
{
|
||||
...configuration,
|
||||
launchEnvironment: capabilities.launchEnvironmentProvider?.()
|
||||
},
|
||||
workspace,
|
||||
create
|
||||
)
|
||||
}
|
||||
|
||||
const defaultModelProfile =
|
||||
|
||||
@@ -37,6 +37,10 @@ import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
|
||||
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
|
||||
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import { SelectedRuntimeManager } from './selected-runtime-manager'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import { createAgentRuntime } from './create-runtime'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
@@ -385,6 +389,81 @@ function createInProcessLaunch(
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it.each([2, 10])('shares one production-composed Host across %i projects with independent native file and shell tools', async (projectCount) => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-reuse-')))
|
||||
const directories = Array.from(
|
||||
{ length: projectCount }, (_, index) => join(root, `project-${index}`)
|
||||
)
|
||||
await Promise.all(directories.map(async (directory, index) => {
|
||||
await mkdir(directory)
|
||||
await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`)
|
||||
}))
|
||||
const inProcess = createInProcessLaunch(root, {
|
||||
stream(options) {
|
||||
const id = latestUserText(options)
|
||||
const read = toolResultText(options, `${id}-read`)
|
||||
if (read === undefined) return toolCall(`${id}-read`, 'read', { file_path: 'marker.txt' })
|
||||
const write = toolResultText(options, `${id}-write`)
|
||||
if (write === undefined) return toolCall(`${id}-write`, 'write', {
|
||||
file_path: `${id}.txt`, content: read
|
||||
})
|
||||
const shell = toolResultText(options, `${id}-shell`)
|
||||
if (shell === undefined) return toolCall(`${id}-shell`, process.platform === 'win32' ? 'pwsh' : 'bash', {
|
||||
command: process.platform === 'win32' ? 'Get-Content marker.txt' : 'cat marker.txt',
|
||||
description: 'Read the session workspace fixture'
|
||||
})
|
||||
return textResponse(shell)
|
||||
}
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000009',
|
||||
name: 'Reuse fixture', baseUrl: 'http://127.0.0.1:9',
|
||||
modelName: 'reuse', protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model'
|
||||
}
|
||||
const settings = {
|
||||
...defaultRuntimeSettings, provider: 'deepseek-harness',
|
||||
modelProfiles: [profile], deepseekHarnessModelProfile: profile
|
||||
} as ResolvedRuntimeSettings
|
||||
const manager = new SelectedRuntimeManager(async (_selection, space) =>
|
||||
createAgentRuntime(root, settings, {
|
||||
executionSpace: space, localRuntimeRegistry: registry,
|
||||
deepseekHarnessLauncher: launch
|
||||
}), 1, 1, undefined, undefined, (id) => registry.releaseConversation(id))
|
||||
try {
|
||||
const selection = { provider: 'deepseek-harness' as const }
|
||||
for (let wave = 0; wave < 2; wave++) {
|
||||
const outputs = await Promise.all(directories.flatMap((directory, project) =>
|
||||
[0, 1].map(async (conversation) => {
|
||||
const runtime = await manager.getRuntime(selection, resolver.resolveLocal(directory))
|
||||
const id = `p${project}-c${conversation}-w${wave}`
|
||||
const events = await collect(runtime.run({
|
||||
requestId: id, conversationId: `p${project}-c${conversation}`,
|
||||
prompt: id, workMode: 'execute'
|
||||
}, AbortSignal.timeout(20_000)))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
expect(await readFile(join(directory, `${id}.txt`), 'utf8')).toContain(`PROJECT_${project}`)
|
||||
expect(events.filter((event) => event.type === 'text').map((event) => event.delta).join(''))
|
||||
.toContain(`PROJECT_${project}`)
|
||||
return events
|
||||
})
|
||||
))
|
||||
expect(outputs).toHaveLength(projectCount * 2)
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
}
|
||||
await manager.releaseConversation('p0-c0')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(inProcess.hosts).toHaveLength(1)
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
await registry.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -428,6 +507,103 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native Skills, plugin inventory, MCP calls and images scoped across shared projects', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-shared-capabilities-')))
|
||||
const directories = [join(root, 'one'), join(root, 'two')]
|
||||
await Promise.all(directories.map(directory => mkdir(directory)))
|
||||
const plugin = join(root, 'inventory-plugin.mjs')
|
||||
await writeFile(plugin, [
|
||||
"export const name = 'shared-inventory'",
|
||||
"export const inject = ['skills']",
|
||||
'export function apply(ctx) {',
|
||||
"ctx.skills.register({ name: 'shared-plugin-skill', description: 'Shared plugin fixture', content: '# Shared plugin', source: 'custom' })",
|
||||
'}'
|
||||
].join('\n'))
|
||||
const models = [new FakeGameModel(), new FakeGameModel()]
|
||||
const images = [createCanvas(1, 1).toBuffer('image/png'), createCanvas(2, 1).toBuffer('image/png')]
|
||||
const seenImages = new Map<number, GenerateOptions['messages'][number]['content']>()
|
||||
const inProcess = createInProcessLaunch(root, {
|
||||
stream(options) {
|
||||
const project = latestUserText(options).includes('PROJECT_1') ? 1 : 0
|
||||
seenImages.set(project, options.messages.flatMap(message => message.content).filter(block => block.type === 'image'))
|
||||
return models[project]!.stream(options)
|
||||
}
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const calls = vi.spyOn(ModelToolProvider.prototype, 'callTool')
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000010',
|
||||
name: 'Capabilities fixture', baseUrl: 'http://127.0.0.1:9',
|
||||
modelName: 'capabilities', protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model',
|
||||
supportsImageInput: true
|
||||
}
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const runtimes = directories.map(directory => createAgentRuntime(root, {
|
||||
...defaultRuntimeSettings, provider: 'deepseek-harness',
|
||||
modelProfiles: [profile], deepseekHarnessModelProfile: profile
|
||||
} as ResolvedRuntimeSettings, {
|
||||
executionSpace: resolver.resolveLocal(directory), localRuntimeRegistry: registry,
|
||||
deepseekHarnessLauncher: launch,
|
||||
skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }],
|
||||
deepseekHarnessExtensions: [{ id: 'shared-inventory', entrypoint: plugin, configuration: {} }],
|
||||
mcpServers: [{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '',
|
||||
enabled: true, allowDynamicTools: false, assignments: ['deepseek-harness'],
|
||||
secretConfigured: false, transport: 'stdio',
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')]
|
||||
}]
|
||||
}))
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
await Promise.all(runtimes.map(async (runtime, project) => {
|
||||
const events = await collect(runtime.run({
|
||||
requestId: `capabilities-${project}-${mode}`, conversationId: `capabilities-${project}`,
|
||||
workMode: mode,
|
||||
prompt: `${mode === 'ask' ? 'ASK_BOUNDARY_PROBE' : 'Use Skill and MCP'} PROJECT_${project}`,
|
||||
...(mode === 'execute' ? { images: [{
|
||||
name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!.toString('base64')
|
||||
}] } : {})
|
||||
}, AbortSignal.timeout(30_000), async () => 'once'))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
const model = models[project]!
|
||||
if (mode === 'execute') {
|
||||
expect(model.skillResult).toContain('window.__GOODBUDDY_GAME__')
|
||||
expect(model.blueprint).toMatchObject({ title: 'Prism Relay' })
|
||||
const seen = seenImages.get(project)!
|
||||
expect(seen).toHaveLength(1)
|
||||
const image = seen[0]!
|
||||
if (image.type !== 'image') throw new Error('Expected image block')
|
||||
expect(image.attachment).toMatchObject({ width: project + 1, height: 1 })
|
||||
const stored = await inProcess.hosts[0]!.context.attachments.readImage(image.attachment)
|
||||
expect(Buffer.from(stored.data).equals(images[project]!)).toBe(true)
|
||||
} else {
|
||||
expect(model.askToolNames).not.toContain(model.mcpToolName)
|
||||
expect(model.askToolResult).toContain('Ask 模式不允许执行非只读工具')
|
||||
}
|
||||
expect(await runtime.getNativeSnapshot?.()).toMatchObject({
|
||||
skills: expect.arrayContaining([expect.objectContaining({ id: 'shared-plugin-skill', source: 'plugin' })])
|
||||
})
|
||||
}))
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(calls).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
expect(new Set(calls.mock.contexts).size).toBe(2)
|
||||
expect(calls.mock.calls.map(call => call[3]?.conversationId).sort())
|
||||
.toEqual(['capabilities-0', 'capabilities-1'])
|
||||
await runtimes[0]!.releaseConversation?.('capabilities-0')
|
||||
expect(await runtimes[1]!.getStatus()).toMatchObject({ available: true })
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
calls.mockRestore()
|
||||
await Promise.all(runtimes.map(runtime => runtime.dispose()))
|
||||
await registry.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -780,7 +956,7 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
)
|
||||
|
||||
try {
|
||||
await runtime.getStatus()
|
||||
await runtime.testConnection()
|
||||
expect(inProcess.hosts[0]?.extensionFailures).toEqual([])
|
||||
await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
|
||||
@@ -443,6 +443,16 @@ function toolProvider(
|
||||
}
|
||||
|
||||
describe('DeepSeekHarnessRuntime', () => {
|
||||
it('reports passive readiness without launching a Host or reading a model', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true, detail: expect.stringContaining('首次使用')
|
||||
})
|
||||
expect(harness.launch).not.toHaveBeenCalled()
|
||||
await harness.runtime.dispose()
|
||||
expect(harness.launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes bounded failed-extension cleanup in the startup budget', () => {
|
||||
expect(deepSeekHarnessStartupBudget(11)).toEqual({
|
||||
hostTimeoutMs: 76_000,
|
||||
@@ -474,7 +484,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
|
||||
@@ -504,7 +514,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(12_000)
|
||||
|
||||
@@ -528,7 +538,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
|
||||
await harness.runtime.dispose()
|
||||
@@ -633,7 +643,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
advertisedImageInput: false
|
||||
})
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: expect.stringContaining('图片能力')
|
||||
})
|
||||
@@ -904,11 +914,14 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
])
|
||||
const harness = setup({ toolProvider: provider })
|
||||
await harness.runtime.getStatus()
|
||||
const running = collect(harness.runtime.run(
|
||||
request('catalog', 'execute'), new AbortController().signal
|
||||
))
|
||||
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1))
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
tools: [
|
||||
@@ -938,10 +951,12 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
expect(
|
||||
JSON.stringify(
|
||||
await harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
)
|
||||
).not.toContain('secret')
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1396,8 +1411,9 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('bounds cancellation when the harness prompt never settles', async () => {
|
||||
const harness = setup({ useProductionPromptTimeout: true })
|
||||
it('releases an uncooperative cancelled session without terminating its peer', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ useProductionPromptTimeout: true, toolProvider: provider })
|
||||
const controller = new AbortController()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
@@ -1409,6 +1425,10 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
const peer = collect(harness.runtime.run(
|
||||
request('cancel-peer'), new AbortController().signal
|
||||
))
|
||||
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(2))
|
||||
controller.abort(new Error('cancelled by user'))
|
||||
|
||||
await expect(running).rejects.toThrow('cancelled by user')
|
||||
@@ -1416,7 +1436,14 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/release',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
expect(harness.child.terminate).not.toHaveBeenCalled()
|
||||
expect(provider.releaseConversation).toHaveBeenCalledWith(request('uncooperative-cancel').conversationId)
|
||||
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
|
||||
expect((await peer).at(-1)?.type).toBe('done')
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1447,7 +1474,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
|
||||
it('reports process exit and fully disposes the connection and child', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
harness.exit.resolve({ exitCode: 9 })
|
||||
@@ -1469,7 +1496,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
new Error('method not found')
|
||||
)
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: 'method not found'
|
||||
})
|
||||
|
||||
@@ -204,11 +204,13 @@ export type DeepSeekHarnessRuntimeOptions = {
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
extensionPackages?: ControlledHarnessExtensionPackage[]
|
||||
toolProvider?: ModelToolProviderLike
|
||||
createToolProvider?: (workspace: string) => ModelToolProviderLike
|
||||
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
|
||||
}
|
||||
|
||||
type ActiveRun = {
|
||||
request: AgentExecutionRequest
|
||||
toolProvider?: ModelToolProviderLike
|
||||
toolController: AbortController
|
||||
authorize?: RuntimeAuthorizer
|
||||
updates: AcpSessionNotification['update'][]
|
||||
@@ -454,7 +456,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
private disposed = false
|
||||
private fatalError?: Error
|
||||
private stderrBytes = 0
|
||||
private readonly sessions = new Map<string, string>()
|
||||
private readonly sessions = new Map<string, { id: string; workspace: string }>()
|
||||
private readonly workspaceTools = new Map<string, ModelToolProviderLike>()
|
||||
private readonly proxyToolCatalogs = new Map<string, ModelToolDefinition[]>()
|
||||
private readonly sessionInitializations = new Map<
|
||||
string,
|
||||
@@ -832,10 +835,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
'DeepSeek Harness MCP 工具上下文不可用'
|
||||
)
|
||||
}
|
||||
if (!this.options.toolProvider) {
|
||||
return { tools: [] }
|
||||
}
|
||||
const run = this.activeRuns.get(params.sessionId)
|
||||
if (!run?.toolProvider || run.closed) return { tools: [] }
|
||||
const context = {
|
||||
conversationId:
|
||||
run?.request.conversationId ??
|
||||
@@ -848,7 +849,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
knowledgeCapabilityToken:
|
||||
run?.request.knowledgeCapabilityToken
|
||||
}
|
||||
const tools = await this.options.toolProvider.listTools(
|
||||
const tools = await run.toolProvider.listTools(
|
||||
context,
|
||||
connection.signal
|
||||
)
|
||||
@@ -869,7 +870,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
if (
|
||||
!run ||
|
||||
run.closed ||
|
||||
!this.options.toolProvider ||
|
||||
!run.toolProvider ||
|
||||
typeof name !== 'string' ||
|
||||
!argumentsValue ||
|
||||
typeof argumentsValue !== 'object' ||
|
||||
@@ -935,7 +936,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
if (!isWebTool) {
|
||||
const approval =
|
||||
this.options.toolProvider.getApproval(
|
||||
run.toolProvider.getApproval(
|
||||
tool,
|
||||
argumentsValue as Record<string, unknown>,
|
||||
argumentSummary,
|
||||
@@ -950,7 +951,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = await this.options.toolProvider.callTool(
|
||||
const result = await run.toolProvider.callTool(
|
||||
name,
|
||||
argumentsValue as Record<string, unknown>,
|
||||
run.toolController.signal,
|
||||
@@ -1091,6 +1092,35 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
return {
|
||||
id: 'deepseek-harness',
|
||||
label: 'DeepSeek Harness',
|
||||
available: !this.disposed && !this.fatalError,
|
||||
supportsToolExecution: true,
|
||||
detail: this.disposed
|
||||
? 'DeepSeek Harness Runtime 已关闭'
|
||||
: this.fatalError?.message ??
|
||||
(this.state
|
||||
? `DeepSeek Harness ${this.state.capabilities.harnessVersion} · 当前用户权限`
|
||||
: 'DeepSeek Harness 已配置,将在首次使用时启动')
|
||||
}
|
||||
}
|
||||
|
||||
get hasRetainedSessions(): boolean {
|
||||
return this.sessions.size > 0 || this.sessionInitializations.size > 0
|
||||
}
|
||||
|
||||
private toolsFor(workspace: string): ModelToolProviderLike | undefined {
|
||||
if (!this.options.createToolProvider) return this.options.toolProvider
|
||||
let provider = this.workspaceTools.get(workspace)
|
||||
if (!provider) {
|
||||
provider = this.options.createToolProvider(workspace)
|
||||
this.workspaceTools.set(workspace, provider)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
async testConnection(): Promise<AgentRuntimeStatus> {
|
||||
try {
|
||||
await this.getState()
|
||||
return {
|
||||
@@ -1114,10 +1144,13 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
|
||||
async getNativeSnapshot(workspace?: string): Promise<RuntimeNativeSnapshot> {
|
||||
const state = await this.getState()
|
||||
const response = await withTimeout(
|
||||
state.agent.extMethod(GOODBUDDY_NATIVE_SNAPSHOT, {}),
|
||||
state.agent.extMethod(
|
||||
GOODBUDDY_NATIVE_SNAPSHOT,
|
||||
workspace ? { workspace } : {}
|
||||
),
|
||||
this.initializationTimeoutMs,
|
||||
'原生能力清单'
|
||||
)
|
||||
@@ -1302,11 +1335,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
|
||||
private async getSession(
|
||||
state: HarnessState,
|
||||
conversationId: string
|
||||
conversationId: string,
|
||||
workspace: string
|
||||
): Promise<string> {
|
||||
const current = this.sessions.get(conversationId)
|
||||
if (current?.workspace === workspace) {
|
||||
return current.id
|
||||
}
|
||||
if (current) {
|
||||
return current
|
||||
await this.releaseConversation(conversationId)
|
||||
}
|
||||
const pending = this.sessionInitializations.get(conversationId)
|
||||
if (pending) {
|
||||
@@ -1314,14 +1351,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
const creation = state.agent
|
||||
.newSession({
|
||||
cwd: state.workspace,
|
||||
cwd: workspace,
|
||||
mcpServers: []
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.sessionId) {
|
||||
throw new Error('DeepSeek Harness 未返回 ACP 会话 ID')
|
||||
}
|
||||
this.sessions.set(conversationId, response.sessionId)
|
||||
this.sessions.set(conversationId, { id: response.sessionId, workspace })
|
||||
return response.sessionId
|
||||
})
|
||||
this.sessionInitializations.set(conversationId, creation)
|
||||
@@ -1445,12 +1482,17 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
try {
|
||||
state = await this.getState()
|
||||
signal.throwIfAborted()
|
||||
const workspace = request.executionWorkspace
|
||||
? await realpath(request.executionWorkspace)
|
||||
: state.workspace
|
||||
sessionId = await this.getSession(
|
||||
state,
|
||||
request.conversationId
|
||||
request.conversationId,
|
||||
workspace
|
||||
)
|
||||
run = {
|
||||
request,
|
||||
toolProvider: this.toolsFor(workspace),
|
||||
toolController,
|
||||
authorize,
|
||||
updates: [],
|
||||
@@ -1586,7 +1628,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.shutdownTimeoutMs,
|
||||
'取消请求'
|
||||
).catch(async () => {
|
||||
await this.terminate(state!.child)
|
||||
await withTimeout(
|
||||
state!.agent.extMethod(GOODBUDDY_RELEASE, { sessionId }),
|
||||
this.shutdownTimeoutMs,
|
||||
'释放已取消会话'
|
||||
).catch(async () => {
|
||||
await this.terminate(state!.child)
|
||||
})
|
||||
await this.forgetSession(request.conversationId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1609,25 +1658,39 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const sessionId = this.sessions.get(conversationId)
|
||||
this.sessions.delete(conversationId)
|
||||
if (sessionId) this.proxyToolCatalogs.delete(sessionId)
|
||||
if (!sessionId || !this.state) {
|
||||
await this.options.toolProvider
|
||||
?.releaseConversation(conversationId)
|
||||
.catch(() => undefined)
|
||||
return
|
||||
await this.sessionInitializations.get(conversationId)?.catch(() => undefined)
|
||||
const session = this.sessions.get(conversationId)
|
||||
const sessionId = session?.id
|
||||
if (sessionId && this.state) {
|
||||
await this.state.agent
|
||||
.extMethod(GOODBUDDY_RELEASE, { sessionId })
|
||||
.catch(async () => {
|
||||
await this.state?.agent
|
||||
.cancel({ sessionId })
|
||||
.catch(() => undefined)
|
||||
})
|
||||
}
|
||||
await this.state.agent
|
||||
.extMethod(GOODBUDDY_RELEASE, { sessionId })
|
||||
.catch(async () => {
|
||||
await this.state?.agent
|
||||
.cancel({ sessionId })
|
||||
.catch(() => undefined)
|
||||
})
|
||||
await this.options.toolProvider
|
||||
await this.forgetSession(conversationId)
|
||||
}
|
||||
|
||||
private async forgetSession(conversationId: string): Promise<void> {
|
||||
const session = this.sessions.get(conversationId)
|
||||
const provider = session
|
||||
? this.workspaceTools.get(session.workspace) ?? this.options.toolProvider
|
||||
: this.options.toolProvider
|
||||
this.sessions.delete(conversationId)
|
||||
if (session) this.proxyToolCatalogs.delete(session.id)
|
||||
await provider
|
||||
?.releaseConversation(conversationId)
|
||||
.catch(() => undefined)
|
||||
if (
|
||||
session && provider &&
|
||||
this.workspaceTools.get(session.workspace) === provider &&
|
||||
![...this.sessions.values()].some((other) => other.workspace === session.workspace)
|
||||
) {
|
||||
this.workspaceTools.delete(session.workspace)
|
||||
await provider.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
@@ -1662,8 +1725,16 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.proxyToolCatalogs.clear()
|
||||
this.sessionInitializations.clear()
|
||||
this.conversationTails.clear()
|
||||
const providers = new Set([
|
||||
...this.workspaceTools.values(),
|
||||
...(this.options.toolProvider ? [this.options.toolProvider] : [])
|
||||
])
|
||||
this.workspaceTools.clear()
|
||||
const disposeTools = () => Promise.allSettled(
|
||||
[...providers].map((provider) => provider.dispose())
|
||||
)
|
||||
if (!state) {
|
||||
await this.options.toolProvider?.dispose().catch(() => undefined)
|
||||
await disposeTools()
|
||||
return
|
||||
}
|
||||
await withTimeout(
|
||||
@@ -1671,7 +1742,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.shutdownTimeoutMs,
|
||||
'内部控制面关闭'
|
||||
).catch(() => undefined)
|
||||
await this.options.toolProvider?.dispose().catch(() => undefined)
|
||||
await disposeTools()
|
||||
await this.terminate(state.child)
|
||||
await withTimeout(
|
||||
Promise.allSettled([
|
||||
|
||||
@@ -926,7 +926,9 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
}
|
||||
|
||||
private async nativeSnapshot(): Promise<Record<string, unknown>> {
|
||||
private async nativeSnapshot(
|
||||
workspace = this.config.workspace
|
||||
): Promise<Record<string, unknown>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
@@ -938,7 +940,7 @@ export class GoodBuddyHarnessControlPlane {
|
||||
try {
|
||||
const skills = await Promise.race([
|
||||
this.ctx.skills.list({
|
||||
cwd: this.config.workspace,
|
||||
cwd: workspace,
|
||||
signal: controller.signal
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
@@ -1132,12 +1134,11 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
if (
|
||||
!isAbsolute(params.cwd) ||
|
||||
params.cwd !== this.config.workspace ||
|
||||
params.mcpServers.length > 0
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'the controlled workspace and no MCP servers are required'
|
||||
'an absolute workspace and no MCP servers are required'
|
||||
)
|
||||
}
|
||||
const sessionId = SessionId(randomUUID())
|
||||
@@ -1393,7 +1394,11 @@ export class GoodBuddyHarnessControlPlane {
|
||||
return { released: true }
|
||||
}
|
||||
if (method === GOODBUDDY_NATIVE_SNAPSHOT) {
|
||||
return this.nativeSnapshot()
|
||||
const workspace = params.workspace === undefined
|
||||
? this.config.workspace
|
||||
: requiredString(params, 'workspace')
|
||||
if (!isAbsolute(workspace)) throw RequestError.invalidParams(undefined, 'workspace must be absolute')
|
||||
return this.nativeSnapshot(workspace)
|
||||
}
|
||||
if (method === GOODBUDDY_SHUTDOWN) {
|
||||
await this.dispose()
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import type { AgentExecutionRequest, AgentRuntime, RuntimeEvent } from './runtime'
|
||||
|
||||
function fixture() {
|
||||
const requests: AgentExecutionRequest[] = []
|
||||
const sessions = new Set<string>()
|
||||
const runtime: AgentRuntime & { readonly hasRetainedSessions: boolean } = {
|
||||
runtimeId: 'opencode',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
get hasRetainedSessions() { return sessions.size > 0 },
|
||||
getStatus: vi.fn(async () => ({
|
||||
id: 'opencode' as const, label: 'OpenCode', available: true,
|
||||
supportsToolExecution: true, detail: 'ready'
|
||||
})),
|
||||
async *run(request) {
|
||||
requests.push(request)
|
||||
sessions.add(request.conversationId)
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
},
|
||||
releaseConversation: vi.fn(async (id) => { sessions.delete(id) }),
|
||||
dispose: vi.fn(async () => { sessions.clear() })
|
||||
}
|
||||
return { runtime, requests, sessions }
|
||||
}
|
||||
|
||||
async function run(runtime: AgentRuntime, id: string) {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: id, conversationId: id, prompt: 'continue', workMode: 'execute'
|
||||
}, new AbortController().signal)) events.push(event)
|
||||
return events
|
||||
}
|
||||
|
||||
describe('LocalRuntimeRegistry', () => {
|
||||
it('shares the Runtime but fixes each request to its resolved workspace', async () => {
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const host = fixture()
|
||||
const create = vi.fn(() => host.runtime)
|
||||
const first = registry.acquire('opencode:profile', { model: 'one' }, '/first', create)
|
||||
const second = registry.acquire('opencode:profile', { model: 'one' }, '/second', create)
|
||||
await Promise.all([run(first, 'a'), run(second, 'b')])
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(host.requests.map((request) => request.executionWorkspace).sort()).toEqual(['/first', '/second'])
|
||||
await first.dispose()
|
||||
expect(host.runtime.dispose).not.toHaveBeenCalled()
|
||||
await run(second, 'b')
|
||||
await registry.releaseConversation('a')
|
||||
expect(host.sessions).toEqual(new Set(['b']))
|
||||
await registry.dispose()
|
||||
expect(host.runtime.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes a no-session idle host and recreates it through an existing facade', async () => {
|
||||
vi.useFakeTimers()
|
||||
const registry = new LocalRuntimeRegistry(100)
|
||||
const first = fixture()
|
||||
const second = fixture()
|
||||
const create = vi.fn().mockReturnValueOnce(first.runtime).mockReturnValueOnce(second.runtime)
|
||||
try {
|
||||
const facade = registry.acquire('opencode:profile', {}, '/first', create)
|
||||
await facade.getStatus()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(first.runtime.dispose).toHaveBeenCalledOnce()
|
||||
await run(facade, 'a')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(second.runtime.dispose).not.toHaveBeenCalled()
|
||||
await registry.releaseConversation('a')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(second.runtime.dispose).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await registry.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drains an old configuration without closing a parallel replacement', async () => {
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const old = fixture()
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>((resolve) => { release = resolve })
|
||||
old.runtime.run = async function* (request) {
|
||||
await pending
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
old.runtime.respondToQuestion = vi.fn(async () => undefined)
|
||||
const original = registry.acquire('opencode:profile', { model: 'old' }, '/one', () => old.runtime)
|
||||
const active = run(original, 'a')
|
||||
await Promise.resolve()
|
||||
const next = fixture()
|
||||
const replacement = registry.acquire('opencode:profile', { model: 'new' }, '/two', () => next.runtime)
|
||||
expect(old.runtime.dispose).not.toHaveBeenCalled()
|
||||
await original.respondToQuestion?.('active-question', [])
|
||||
expect(old.runtime.respondToQuestion).toHaveBeenCalledWith('active-question', [])
|
||||
await run(replacement, 'b')
|
||||
release()
|
||||
await active
|
||||
await vi.waitFor(() => expect(old.runtime.dispose).toHaveBeenCalledOnce())
|
||||
expect(next.runtime.dispose).not.toHaveBeenCalled()
|
||||
await registry.dispose()
|
||||
})
|
||||
|
||||
it('does not renew or reacquire an idle owner during passive status polling', async () => {
|
||||
vi.useFakeTimers()
|
||||
const registry = new LocalRuntimeRegistry(100)
|
||||
const host = fixture()
|
||||
const inspection = fixture()
|
||||
const restarted = fixture()
|
||||
const create = vi.fn()
|
||||
.mockReturnValueOnce(host.runtime)
|
||||
.mockReturnValueOnce(inspection.runtime)
|
||||
.mockReturnValueOnce(restarted.runtime)
|
||||
try {
|
||||
const facade = registry.acquire('opencode:profile', {}, '/first', create)
|
||||
for (let poll = 0; poll < 3; poll++) {
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
expect(await facade.getStatus()).toMatchObject({ available: true })
|
||||
}
|
||||
expect(host.runtime.dispose).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(host.runtime.dispose).toHaveBeenCalledOnce()
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
|
||||
await facade.getStatus()
|
||||
expect(inspection.runtime.dispose).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(restarted.runtime.dispose).not.toHaveBeenCalled()
|
||||
|
||||
await run(facade, 'a')
|
||||
expect(create).toHaveBeenCalledTimes(3)
|
||||
expect(restarted.sessions).toEqual(new Set(['a']))
|
||||
} finally {
|
||||
await registry.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type {
|
||||
AgentExecutionRequest,
|
||||
AgentRuntime,
|
||||
RuntimeAuthorizer,
|
||||
RuntimeEvent
|
||||
} from './runtime'
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
RuntimeConversationCompactInput
|
||||
} from '../../shared/contracts'
|
||||
|
||||
const IDLE_RUNTIME_MS = 60_000
|
||||
|
||||
type LocalProcessRuntime = AgentRuntime & {
|
||||
readonly hasRetainedSessions: boolean
|
||||
}
|
||||
|
||||
class LocalRuntimeEntry {
|
||||
private instance?: LocalProcessRuntime
|
||||
private closing?: Promise<void>
|
||||
private active = 0
|
||||
private retired = false
|
||||
private idle?: ReturnType<typeof setTimeout>
|
||||
readonly description: Pick<AgentRuntime,
|
||||
'runtimeId' | 'requiresToolApproval' | 'supportsToolExecution' |
|
||||
'supportsScopedDataTools' | 'capability'>
|
||||
|
||||
constructor(
|
||||
readonly configuration: unknown,
|
||||
private readonly create: () => LocalProcessRuntime,
|
||||
private readonly idleMs: number,
|
||||
private readonly onClosed: () => void
|
||||
) {
|
||||
const runtime = this.instance = create()
|
||||
this.description = {
|
||||
runtimeId: runtime.runtimeId,
|
||||
requiresToolApproval: runtime.requiresToolApproval,
|
||||
supportsToolExecution: runtime.supportsToolExecution,
|
||||
supportsScopedDataTools: runtime.supportsScopedDataTools,
|
||||
capability: runtime.capability
|
||||
}
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
private clearIdle(): void {
|
||||
if (this.idle) clearTimeout(this.idle)
|
||||
this.idle = undefined
|
||||
}
|
||||
|
||||
private scheduleIdle(): void {
|
||||
this.clearIdle()
|
||||
if (this.active > 0) return
|
||||
if (this.retired) {
|
||||
void this.close().finally(this.onClosed).catch(() => undefined)
|
||||
} else if (!this.instance?.hasRetainedSessions) {
|
||||
this.idle = setTimeout(() => {
|
||||
this.idle = undefined
|
||||
void this.close().catch(() => undefined)
|
||||
}, this.idleMs)
|
||||
this.idle.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
private close(): Promise<void> {
|
||||
this.clearIdle()
|
||||
if (this.closing) return this.closing
|
||||
const runtime = this.instance
|
||||
this.instance = undefined
|
||||
const closing = Promise.resolve().then(() => runtime?.dispose()).finally(() => {
|
||||
if (this.closing === closing) this.closing = undefined
|
||||
})
|
||||
this.closing = closing
|
||||
return closing
|
||||
}
|
||||
|
||||
async enter(): Promise<AgentRuntime> {
|
||||
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
|
||||
this.clearIdle()
|
||||
this.active += 1
|
||||
try {
|
||||
await this.closing
|
||||
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
|
||||
return this.instance ??= this.create()
|
||||
} catch (error) {
|
||||
this.leave()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
this.active -= 1
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
async invoke<T>(operation: (runtime: AgentRuntime) => Promise<T>): Promise<T> {
|
||||
const runtime = await this.enter()
|
||||
try {
|
||||
return await operation(runtime)
|
||||
} finally {
|
||||
this.leave()
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
|
||||
if (this.instance) return this.instance.getStatus()
|
||||
// Status inspection must not acquire or extend the execution owner's
|
||||
// lifetime. Both local runtimes can inspect configuration without starting.
|
||||
const inspection = this.create()
|
||||
try {
|
||||
return await inspection.getStatus()
|
||||
} finally {
|
||||
await inspection.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]): Promise<void> {
|
||||
const runtime = this.instance
|
||||
if (!runtime?.respondToQuestion) throw new Error('当前 Runtime 不支持回答交互式问题')
|
||||
// An active request may finish on a retired configuration, including its
|
||||
// outstanding native question. This does not admit a new request.
|
||||
this.clearIdle()
|
||||
this.active += 1
|
||||
try {
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
} finally {
|
||||
this.leave()
|
||||
}
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const runtime = this.instance
|
||||
if (!runtime) return
|
||||
this.clearIdle()
|
||||
this.active += 1
|
||||
try {
|
||||
await runtime.releaseConversation?.(conversationId)
|
||||
} finally {
|
||||
this.leave()
|
||||
}
|
||||
}
|
||||
|
||||
retire(): void {
|
||||
this.retired = true
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.retired = true
|
||||
await this.close()
|
||||
this.onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
/** Main-owned local processes; workspace facades never own their lifetime. */
|
||||
export class LocalRuntimeRegistry {
|
||||
private readonly current = new Map<string, LocalRuntimeEntry>()
|
||||
private readonly entries = new Set<LocalRuntimeEntry>()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly idleMs = IDLE_RUNTIME_MS) {}
|
||||
|
||||
acquire(
|
||||
key: string,
|
||||
configuration: unknown,
|
||||
workspace: string,
|
||||
create: () => LocalProcessRuntime
|
||||
): AgentRuntime {
|
||||
if (this.disposed) throw new Error('Runtime 正在关闭')
|
||||
let entry = this.current.get(key)
|
||||
if (entry && !isDeepStrictEqual(entry.configuration, configuration)) {
|
||||
this.current.delete(key)
|
||||
entry.retire()
|
||||
entry = undefined
|
||||
}
|
||||
if (!entry) {
|
||||
const created = new LocalRuntimeEntry(configuration, create, this.idleMs, () => {
|
||||
this.entries.delete(created)
|
||||
if (this.current.get(key) === created) this.current.delete(key)
|
||||
})
|
||||
this.current.set(key, created)
|
||||
this.entries.add(created)
|
||||
entry = created
|
||||
}
|
||||
return new WorkspaceRuntime(entry, workspace)
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
await Promise.all([...this.entries].map((entry) => entry.releaseConversation(conversationId)))
|
||||
}
|
||||
|
||||
reset(keep?: AgentRuntime): void {
|
||||
const retained = keep instanceof WorkspaceRuntime ? keep.owner : undefined
|
||||
for (const [key, entry] of this.current) {
|
||||
if (entry === retained) continue
|
||||
this.current.delete(key)
|
||||
entry.retire()
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.current.clear()
|
||||
await Promise.all([...this.entries].map((entry) => entry.dispose()))
|
||||
}
|
||||
}
|
||||
|
||||
class WorkspaceRuntime implements AgentRuntime {
|
||||
readonly sharedProcess = true
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly entry: LocalRuntimeEntry,
|
||||
private readonly workspace: string
|
||||
) {}
|
||||
|
||||
get owner(): LocalRuntimeEntry { return this.entry }
|
||||
get runtimeId() { return this.entry.description.runtimeId }
|
||||
get requiresToolApproval() { return this.entry.description.requiresToolApproval }
|
||||
get supportsToolExecution() { return this.entry.description.supportsToolExecution }
|
||||
get supportsScopedDataTools() { return this.entry.description.supportsScopedDataTools }
|
||||
get capability() { return this.entry.description.capability }
|
||||
|
||||
private check(): void {
|
||||
if (this.disposed) throw new Error('Runtime 工作区已释放')
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
this.check()
|
||||
return this.entry.getStatus()
|
||||
}
|
||||
|
||||
testConnection() {
|
||||
this.check()
|
||||
return this.entry.invoke((runtime) => runtime.testConnection?.() ?? runtime.getStatus())
|
||||
}
|
||||
|
||||
getNativeSnapshot() {
|
||||
this.check()
|
||||
return this.entry.invoke((runtime) => {
|
||||
if (!runtime.getNativeSnapshot) throw new Error('当前 Runtime 不支持原生能力清单')
|
||||
return runtime.getNativeSnapshot(this.workspace)
|
||||
})
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
authorize?: RuntimeAuthorizer
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
this.check()
|
||||
signal.throwIfAborted()
|
||||
const runtime = await this.entry.enter()
|
||||
try {
|
||||
yield* runtime.run({ ...request, executionWorkspace: this.workspace }, signal, authorize)
|
||||
} finally {
|
||||
this.entry.leave()
|
||||
}
|
||||
}
|
||||
|
||||
compactConversation(request: RuntimeConversationCompactInput, signal: AbortSignal) {
|
||||
this.check()
|
||||
return this.entry.invoke((runtime) => {
|
||||
if (!runtime.compactConversation) throw new Error('当前 Runtime 不支持手动压缩')
|
||||
return runtime.compactConversation(request, signal)
|
||||
})
|
||||
}
|
||||
|
||||
respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]) {
|
||||
this.check()
|
||||
return this.entry.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
releaseConversation(conversationId: string) {
|
||||
return this.entry.releaseConversation(conversationId)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { createCanvas } from '@napi-rs/canvas'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import { createAgentRuntime } from './create-runtime'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import { SelectedRuntimeManager } from './selected-runtime-manager'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
|
||||
const binaryPath = join(
|
||||
process.cwd(), '.runtime-resources', process.arch,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath)).each([2, 10])('uses one real OpenCode server across %i projects for tools, continuation and compaction', async (projectCount) => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-reuse-')))
|
||||
const directories = Array.from(
|
||||
{ length: projectCount }, (_, index) => join(root, `project-${index}`)
|
||||
)
|
||||
await Promise.all(directories.map(async (directory, index) => {
|
||||
await mkdir(directory)
|
||||
await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`)
|
||||
}))
|
||||
let modelCalls = 0
|
||||
const toolResults: unknown[] = []
|
||||
const capabilityResults: string[] = []
|
||||
const observedImages = new Set<string>()
|
||||
const requestImages = new Map<string, string>()
|
||||
const observedRequestImages = new Map<string, Set<string>>()
|
||||
const images = directories.slice(0, 2).map((_, index) =>
|
||||
createCanvas(index + 1, 1).toBuffer('image/png').toString('base64'))
|
||||
const knowledge = projectCount === 2 ? new KnowledgeService({
|
||||
databasePath: join(root, 'knowledge.sqlite'), managedRoot: join(root, 'knowledge')
|
||||
}) : undefined
|
||||
const gateway = knowledge ? new KnowledgeMcpGateway(knowledge) : undefined
|
||||
await knowledge?.initialize()
|
||||
await gateway?.start()
|
||||
const grants = gateway ? vi.spyOn(gateway, 'grantCustomMcp') : undefined
|
||||
const mcpCalls = gateway ? vi.spyOn(gateway as unknown as {
|
||||
callCustomMcpTool(token: string, binding: unknown, args: Record<string, unknown>, signal: AbortSignal): Promise<unknown>
|
||||
}, 'callCustomMcpTool') : undefined
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let content = ''
|
||||
for await (const chunk of request) content += String(chunk)
|
||||
const body = JSON.parse(content) as {
|
||||
messages: Array<{ role: string; content: unknown }>
|
||||
tools?: Array<{ function: { name: string } }>
|
||||
}
|
||||
modelCalls++
|
||||
let userIndex = -1
|
||||
body.messages.forEach((message, index) => { if (message.role === 'user') userIndex = index })
|
||||
const results = body.messages.slice(userIndex + 1).filter((message) => message.role === 'tool')
|
||||
const result = results.at(-1)
|
||||
if (result) toolResults.push(result.content)
|
||||
const resultText = JSON.stringify(results.map(message => message.content))
|
||||
const marker = resultText.match(/PROJECT_\d+/u)?.[0]
|
||||
const read = body.tools?.some((tool) => tool.function.name === 'read')
|
||||
let tool: { name: string; arguments: Record<string, unknown> } | undefined =
|
||||
read && !result ? { name: 'read', arguments: { filePath: 'marker.txt' } } : undefined
|
||||
if (projectCount === 2 && read) {
|
||||
const requestId = JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1]
|
||||
for (const image of images) {
|
||||
if (JSON.stringify(body.messages).includes(image)) {
|
||||
observedImages.add(image)
|
||||
if (requestId) {
|
||||
const seen = observedRequestImages.get(requestId) ?? new Set<string>()
|
||||
seen.add(image)
|
||||
observedRequestImages.set(requestId, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.length === 1) {
|
||||
tool = { name: 'skill', arguments: { name: 'web-3d-game' } }
|
||||
} else if (results.length === 2) {
|
||||
const name = body.tools?.find(item => item.function.name.endsWith('_create_game_blueprint'))?.function.name
|
||||
if (!name) throw new Error('Shared OpenCode MCP fixture is not exposed')
|
||||
tool = {
|
||||
name, arguments: {
|
||||
theme: 'neon-ruins',
|
||||
seed: JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1],
|
||||
targetCount: 5
|
||||
}
|
||||
}
|
||||
} else if (results.length === 3) {
|
||||
capabilityResults.push(resultText)
|
||||
}
|
||||
}
|
||||
const callTool = tool !== undefined
|
||||
response.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
||||
const delta = callTool
|
||||
? {
|
||||
role: 'assistant',
|
||||
tool_calls: [{
|
||||
index: 0, id: `read-${modelCalls}`, type: 'function',
|
||||
function: { name: tool!.name, arguments: JSON.stringify(tool!.arguments) }
|
||||
}]
|
||||
}
|
||||
: { role: 'assistant', content: marker ?? 'CONTEXT_SUMMARY' }
|
||||
for (const [value, finish] of [[delta, null], [{}, callTool ? 'tool_calls' : 'stop']]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: `reuse-${modelCalls}`, object: 'chat.completion.chunk', created: 1,
|
||||
model: 'reuse', choices: [{ index: 0, delta: value, finish_reason: finish }]
|
||||
})}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>((resolve) => model.listen(0, '127.0.0.1', resolve))
|
||||
const port = (model.address() as { port: number }).port
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000008',
|
||||
name: 'Reuse fixture', modelName: 'reuse', baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
protocol: 'openai-chat-completions' as const, authentication: 'none' as const,
|
||||
supportsImageInput: projectCount === 2
|
||||
}
|
||||
const settings = {
|
||||
...defaultRuntimeSettings, provider: 'opencode', opencodeBinaryPath: binaryPath,
|
||||
modelProfiles: [profile], opencodeModelProfile: profile
|
||||
} as ResolvedRuntimeSettings
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const create = vi.fn(async (_selection, executionSpace) => createAgentRuntime(root, settings, {
|
||||
executionSpace, localRuntimeRegistry: registry, knowledgeGateway: gateway,
|
||||
...(projectCount === 2 ? {
|
||||
skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }],
|
||||
mcpServers: [{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '',
|
||||
enabled: true, allowDynamicTools: false, assignments: ['opencode' as const],
|
||||
secretConfigured: false, transport: 'stdio' as const,
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')]
|
||||
}]
|
||||
} : {})
|
||||
}))
|
||||
const manager = new SelectedRuntimeManager(create, 1, 1, undefined, undefined, (id) => registry.releaseConversation(id))
|
||||
const connections = vi.spyOn(OpenCodeRuntime.prototype, 'testConnection')
|
||||
const launches = vi.spyOn(
|
||||
OpenCodeRuntime.prototype as unknown as { launchEmbedded(): Promise<unknown> },
|
||||
'launchEmbedded'
|
||||
)
|
||||
const nativeSessions = new Set<string>()
|
||||
try {
|
||||
const selection = { provider: 'opencode' as const }
|
||||
const facades = await Promise.all(directories.map((directory) =>
|
||||
manager.getRuntime(selection, resolver.resolveLocal(directory))
|
||||
))
|
||||
for (let wave = 0; wave < 2; wave++) {
|
||||
await Promise.all(facades.flatMap((runtime, project) => [0, 1].map(async (conversation) => {
|
||||
let output = ''
|
||||
const requestId = crypto.randomUUID()
|
||||
if (projectCount === 2 && wave === 0) requestImages.set(requestId, images[project]!)
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: `project-${project}-${conversation}`,
|
||||
prompt: `Read marker.txt for wave ${wave}. REQUEST_${requestId}`, workMode: 'execute',
|
||||
...(projectCount === 2 && wave === 0 ? { images: [{
|
||||
name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!
|
||||
}] } : {})
|
||||
}, AbortSignal.timeout(40_000))) {
|
||||
if (event.type === 'text') output += event.delta
|
||||
if (event.type === 'done' && event.sessionId) nativeSessions.add(event.sessionId)
|
||||
}
|
||||
expect(output, JSON.stringify(toolResults)).toBe(`PROJECT_${project}`)
|
||||
})))
|
||||
}
|
||||
expect(nativeSessions.size).toBe(projectCount * 2)
|
||||
if (projectCount === 2) {
|
||||
expect(observedImages.size).toBe(2)
|
||||
for (const [requestId, image] of requestImages) {
|
||||
expect(observedRequestImages.get(requestId)).toEqual(new Set([image]))
|
||||
}
|
||||
expect(capabilityResults).toHaveLength(8)
|
||||
for (const result of capabilityResults) {
|
||||
expect(result).toContain('window.__GOODBUDDY_GAME__')
|
||||
expect(result).toContain('Prism Relay')
|
||||
}
|
||||
expect(mcpCalls).toHaveBeenCalledTimes(8)
|
||||
for (const [token, , args] of mcpCalls!.mock.calls) {
|
||||
const grantIndex = grants!.mock.results.findIndex(result => result.value === token)
|
||||
expect(grantIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(args.seed).toBe(grants!.mock.calls[grantIndex]![0])
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < facades.length; index++) {
|
||||
await expect(facades[index]!.compactConversation({
|
||||
requestId: crypto.randomUUID(), conversationId: `project-${index}-0`,
|
||||
runtimeSelection: selection, history: [], historyMessageIds: []
|
||||
}, AbortSignal.timeout(30_000))).resolves.toMatchObject({ result: { compacted: true } })
|
||||
}
|
||||
await Promise.all(facades.map((runtime) => runtime.testConnection()))
|
||||
// Every facade invokes the same process-owning Runtime, even beyond capacity 1.
|
||||
expect(new Set(connections.mock.instances).size).toBe(1)
|
||||
expect(launches).toHaveBeenCalledOnce()
|
||||
for (const [index, directory] of directories.entries()) {
|
||||
expect(await readFile(join(directory, 'marker.txt'), 'utf8')).toBe(`PROJECT_${index}`)
|
||||
}
|
||||
await manager.releaseConversation('project-0-0')
|
||||
} finally {
|
||||
connections.mockRestore()
|
||||
launches.mockRestore()
|
||||
mcpCalls?.mockRestore()
|
||||
grants?.mockRestore()
|
||||
await manager.dispose()
|
||||
await registry.dispose()
|
||||
await gateway?.dispose()
|
||||
await knowledge?.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>((resolve) => model.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
@@ -1692,6 +1692,31 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("keeps a shared cold start alive when only its first waiter cancels", async () => {
|
||||
const child = fakeChild();
|
||||
let healthy = false;
|
||||
const { deps, spawnMock } = dependencies(child, {
|
||||
startupTimeoutMs: 2_000,
|
||||
checkServerHealth: vi.fn(async () => healthy),
|
||||
});
|
||||
const runtime = new OpenCodeRuntime(options(), deps);
|
||||
const controller = new AbortController();
|
||||
const pending = runtime.run({
|
||||
requestId: "cancel-initializer", conversationId: "first",
|
||||
prompt: "test", executionWorkspace: resolve("one"),
|
||||
}, controller.signal).next();
|
||||
const rejected = expect(pending).rejects.toThrow("启动已取消");
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
const peer = runtime.testConnection();
|
||||
controller.abort();
|
||||
await rejected;
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
healthy = true;
|
||||
await expect(peer).resolves.toMatchObject({ available: true });
|
||||
expect(spawnMock).toHaveBeenCalledOnce();
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("bounds session setup without imposing a full-run deadline", async () => {
|
||||
const harness = runClient([]);
|
||||
(
|
||||
|
||||
@@ -729,7 +729,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
private clientInitialization?: Promise<OpencodeClient>;
|
||||
private server?: OpenCodeServer;
|
||||
private startingChild?: SpawnedProcess;
|
||||
private readonly sessions = new Map<string, string>();
|
||||
private readonly sessions = new Map<string, { id: string; directory: string }>();
|
||||
private initializationController?: AbortController;
|
||||
private initializationWaiters = 0;
|
||||
private readonly sessionInitializations = new Map<string, Promise<string>>();
|
||||
private readonly pendingQuestions = new Map<
|
||||
string,
|
||||
@@ -761,6 +763,10 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
get hasRetainedSessions(): boolean {
|
||||
return this.sessions.size > 0 || this.sessionInitializations.size > 0;
|
||||
}
|
||||
|
||||
private usesEmbeddedPermissionMediation(): boolean {
|
||||
return this.options.embedded && !this.options.baseUrl;
|
||||
}
|
||||
@@ -1276,19 +1282,38 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (this.client && !this.server) {
|
||||
return this.client;
|
||||
}
|
||||
const existingInitialization = this.clientInitialization;
|
||||
const cached = Boolean(this.client && this.server);
|
||||
// Cached health checks are shared, but each caller owns only its wait.
|
||||
const initialization = this.clientInitialization ??= this.initializeClient(
|
||||
cached ? undefined : signal,
|
||||
).finally(() => {
|
||||
if (this.clientInitialization === initialization) {
|
||||
this.clientInitialization = undefined;
|
||||
this.initializationWaiters += 1;
|
||||
if (!this.clientInitialization) {
|
||||
const controller = this.initializationController = new AbortController();
|
||||
const initialization = this.initializeClient(
|
||||
cached ? undefined : controller.signal,
|
||||
).finally(() => {
|
||||
if (this.clientInitialization === initialization) {
|
||||
this.clientInitialization = undefined;
|
||||
this.initializationController = undefined;
|
||||
}
|
||||
});
|
||||
this.clientInitialization = initialization;
|
||||
}
|
||||
try {
|
||||
return signal
|
||||
? await awaitWithAbort(this.clientInitialization, signal)
|
||||
: await this.clientInitialization;
|
||||
} catch (error) {
|
||||
if (signal?.aborted && !cached && !this.client) {
|
||||
throw new Error("OpenCode Server 启动已取消", { cause: error });
|
||||
}
|
||||
});
|
||||
return signal && (cached || existingInitialization)
|
||||
? await awaitWithAbort(initialization, signal)
|
||||
: await initialization;
|
||||
throw error;
|
||||
} finally {
|
||||
this.initializationWaiters -= 1;
|
||||
if (this.initializationWaiters === 0 && !this.client) {
|
||||
this.initializationController?.abort(
|
||||
new Error("OpenCode 初始化已无等待请求"),
|
||||
);
|
||||
await this.clientInitialization?.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async controlRequest<T>(
|
||||
@@ -1460,6 +1485,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
private async discoverAgents(
|
||||
client: OpencodeClient,
|
||||
signal?: AbortSignal,
|
||||
directory = this.options.defaultWorkspace,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
@@ -1475,7 +1501,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
(controlSignal) =>
|
||||
client.app.agents(
|
||||
{
|
||||
directory: this.options.defaultWorkspace,
|
||||
directory,
|
||||
},
|
||||
{ signal: controlSignal },
|
||||
),
|
||||
@@ -1521,7 +1547,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (!this.supportsNativeCustomization()) {
|
||||
throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent");
|
||||
}
|
||||
const agents = await this.discoverAgents(client, signal);
|
||||
const agents = await this.discoverAgents(
|
||||
client, signal, request.executionWorkspace ?? this.options.defaultWorkspace,
|
||||
);
|
||||
if (
|
||||
!agents.some(
|
||||
(agent) =>
|
||||
@@ -1537,7 +1565,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
return selected;
|
||||
}
|
||||
|
||||
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
|
||||
async getNativeSnapshot(
|
||||
directory = this.options.defaultWorkspace,
|
||||
): Promise<RuntimeNativeSnapshot> {
|
||||
const controlled = this.supportsNativeCustomization();
|
||||
const empty: RuntimeNativeSnapshot = {
|
||||
provider: "opencode",
|
||||
@@ -1572,7 +1602,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const response = await this.controlRequest("连接检查", (signal) =>
|
||||
client.session.list(
|
||||
{
|
||||
directory: this.options.defaultWorkspace,
|
||||
directory,
|
||||
limit: 1,
|
||||
},
|
||||
{ signal },
|
||||
@@ -1602,7 +1632,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
const directory = this.options.defaultWorkspace;
|
||||
const assignedSkillIds = new Set(
|
||||
(this.options.skillPackages ?? []).map((skill) => skill.id),
|
||||
);
|
||||
@@ -1626,7 +1655,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
{ signal },
|
||||
),
|
||||
),
|
||||
this.discoverAgents(client),
|
||||
this.discoverAgents(client, undefined, directory),
|
||||
this.controlRequest("读取工具清单", (signal) =>
|
||||
client.tool.ids({ directory }, { signal }),
|
||||
),
|
||||
@@ -1914,8 +1943,11 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
permission?: PermissionRuleset,
|
||||
): Promise<{ id: string; created: boolean }> {
|
||||
const current = this.sessions.get(request.conversationId);
|
||||
if (current?.directory === directory) {
|
||||
return { id: current.id, created: false };
|
||||
}
|
||||
if (current) {
|
||||
return { id: current, created: false };
|
||||
await this.releaseConversation(request.conversationId);
|
||||
}
|
||||
const pending = this.sessionInitializations.get(request.conversationId);
|
||||
if (pending) {
|
||||
@@ -1959,7 +1991,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const stillCurrent =
|
||||
this.sessionInitializations.get(request.conversationId) === creation;
|
||||
if (signal.aborted || !stillCurrent) {
|
||||
if (this.sessions.get(request.conversationId) !== sessionId) {
|
||||
if (this.sessions.get(request.conversationId)?.id !== sessionId) {
|
||||
void client.session
|
||||
.delete(
|
||||
{
|
||||
@@ -1975,7 +2007,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
throw new Error("OpenCode 会话初始化已失效");
|
||||
}
|
||||
this.sessions.set(request.conversationId, sessionId);
|
||||
this.sessions.set(request.conversationId, { id: sessionId, directory });
|
||||
return sessionId;
|
||||
});
|
||||
this.sessionInitializations.set(request.conversationId, creation);
|
||||
@@ -2072,7 +2104,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
signal: AbortSignal,
|
||||
client: OpencodeClient,
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
const directory = this.options.defaultWorkspace;
|
||||
const directory = resolve(request.executionWorkspace ?? this.options.defaultWorkspace);
|
||||
const runtimeControl =
|
||||
request.runtimeControl?.provider === "opencode"
|
||||
? request.runtimeControl
|
||||
@@ -3065,7 +3097,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
executionSignal = AbortSignal.any([executionSignal, server.signal]);
|
||||
}
|
||||
}
|
||||
const sessionId = this.sessions.get(request.conversationId);
|
||||
const session = this.sessions.get(request.conversationId);
|
||||
const sessionId = session?.id;
|
||||
if (!sessionId || !client) {
|
||||
return {
|
||||
result: {
|
||||
@@ -3076,6 +3109,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
},
|
||||
};
|
||||
}
|
||||
const directory = session!.directory;
|
||||
const context = await this.controlRequest(
|
||||
"读取原生上下文",
|
||||
(controlSignal) =>
|
||||
@@ -3122,7 +3156,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
"订阅 Compact 事件流",
|
||||
(controlSignal) =>
|
||||
client.event.subscribe(
|
||||
{ directory: this.options.defaultWorkspace },
|
||||
{ directory },
|
||||
eventSubscriptionOptions(controlSignal),
|
||||
),
|
||||
subscriptionSignal,
|
||||
@@ -3162,7 +3196,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const compact = await client.session.summarize(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory: this.options.defaultWorkspace,
|
||||
directory,
|
||||
providerID: configuredModel.providerID,
|
||||
modelID: configuredModel.modelID,
|
||||
auto: false,
|
||||
@@ -3220,6 +3254,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.initializationController?.abort(new Error("OpenCode Runtime 已关闭"));
|
||||
this.pendingQuestions.clear();
|
||||
const startingChild = this.startingChild;
|
||||
this.startingChild = undefined;
|
||||
@@ -3238,16 +3273,16 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const sessionId = this.sessions.get(conversationId);
|
||||
const session = this.sessions.get(conversationId);
|
||||
this.sessions.delete(conversationId);
|
||||
if (!sessionId || !this.client) {
|
||||
if (!session || !this.client) {
|
||||
return;
|
||||
}
|
||||
await this.controlRequest("释放会话", (signal) =>
|
||||
this.client!.session.delete(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory: this.options.defaultWorkspace,
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
|
||||
@@ -11,6 +11,39 @@ import {
|
||||
RuntimeReplacementCapacityError
|
||||
} from './runtime-controller'
|
||||
|
||||
it('routes a shared request question to its original Runtime across replacement', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
const previous: AgentRuntime = {
|
||||
sharedProcess: true, requiresToolApproval: false, supportsToolExecution: true,
|
||||
getStatus: vi.fn(), dispose: vi.fn(async () => undefined),
|
||||
respondToQuestion: vi.fn(async () => undefined),
|
||||
async *run(request) {
|
||||
yield { type: 'question', requestId: request.requestId, questionId: 'question-old', questions: [] }
|
||||
await gate
|
||||
yield { type: 'done', requestId: request.requestId }
|
||||
}
|
||||
}
|
||||
const next: AgentRuntime = { ...previous, respondToQuestion: vi.fn(async () => undefined) }
|
||||
const controller = new AgentRuntimeController(previous, 1)
|
||||
const stream = controller.run({
|
||||
requestId: 'old-request', conversationId: 'old-conversation', prompt: 'test', workMode: 'execute'
|
||||
}, new AbortController().signal)
|
||||
try {
|
||||
expect((await stream.next()).value?.type).toBe('question')
|
||||
await controller.replace(next)
|
||||
await controller.respondToQuestion('question-old', [])
|
||||
expect(previous.respondToQuestion).toHaveBeenCalledWith('question-old', [])
|
||||
expect(next.respondToQuestion).not.toHaveBeenCalled()
|
||||
release()
|
||||
expect((await stream.next()).value?.type).toBe('done')
|
||||
} finally {
|
||||
release()
|
||||
await stream.return()
|
||||
await controller.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
class TestRuntime implements AgentRuntime {
|
||||
readonly dispose = vi.fn(async () => {})
|
||||
readonly started: Promise<void>
|
||||
@@ -176,6 +209,26 @@ describe('AgentRuntimeController', () => {
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('lets a shared local request finish while replacing its workspace facade', async () => {
|
||||
const previous = Object.assign(new TestRuntime(true), { sharedProcess: true })
|
||||
const controller = new AgentRuntimeController(previous, 1)
|
||||
const stream = controller.run({
|
||||
requestId: '00000000-0000-4000-8000-000000000071',
|
||||
conversationId: 'shared-replacement', prompt: 'continue', workMode: 'ask'
|
||||
}, new AbortController().signal)
|
||||
const pending = stream.next()
|
||||
await previous.started
|
||||
await controller.replace(new TestRuntime())
|
||||
expect(previous.dispose).not.toHaveBeenCalled()
|
||||
previous.finish()
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
done: false, value: { type: 'text' }
|
||||
})
|
||||
await stream.next()
|
||||
expect(previous.dispose).toHaveBeenCalledOnce()
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('drains remote sessions without interrupting their active prompt', async () => {
|
||||
const previous = new DrainableTestRuntime()
|
||||
const next = new TestRuntime()
|
||||
|
||||
@@ -80,6 +80,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
private retirement?: Promise<void>
|
||||
private disposal?: Promise<void>
|
||||
private readonly ownedConversationIds = new Set<string>()
|
||||
private readonly questionOwners = new Map<string, { slot: RuntimeSlot; requestId: string }>()
|
||||
|
||||
constructor(
|
||||
runtime: AgentRuntime,
|
||||
@@ -147,11 +148,15 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
return this.ownedConversationIds.size
|
||||
}
|
||||
|
||||
get sharedProcess(): boolean {
|
||||
return this.current.runtime.sharedProcess === true
|
||||
}
|
||||
|
||||
get canRetire(): boolean {
|
||||
return (
|
||||
!this.closing &&
|
||||
this.activeRequestCount === 0 &&
|
||||
this.ownedConversationIds.size === 0
|
||||
(this.sharedProcess || this.ownedConversationIds.size === 0)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,7 +274,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
slot.activeRequests += 1
|
||||
try {
|
||||
const status = await operation(slot.runtime)
|
||||
if (slot !== this.current) {
|
||||
if (slot !== this.current && !(slot.runtime.sharedProcess && stage === 'compact')) {
|
||||
throw new Error('Runtime 已切换,请重试')
|
||||
}
|
||||
return status
|
||||
@@ -347,13 +352,16 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
signal,
|
||||
effectiveAuthorize
|
||||
)) {
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
toolDenied = false
|
||||
if (event.type === 'question') {
|
||||
this.questionOwners.set(event.questionId, { slot, requestId: request.requestId })
|
||||
}
|
||||
yield event
|
||||
}
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -368,6 +376,9 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
for (const [questionId, owner] of this.questionOwners) {
|
||||
if (owner.requestId === request.requestId) this.questionOwners.delete(questionId)
|
||||
}
|
||||
slot.activeRequests -= 1
|
||||
if (
|
||||
slot.retiring &&
|
||||
@@ -419,14 +430,16 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void> {
|
||||
if (this.closing) {
|
||||
const owner = this.questionOwners.get(questionId)
|
||||
if (this.closing && !owner) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const runtime = this.current.runtime
|
||||
const runtime = (owner?.slot ?? this.current).runtime
|
||||
if (!runtime.respondToQuestion) {
|
||||
throw new Error('当前 Runtime 不支持回答交互式问题')
|
||||
}
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
this.questionOwners.delete(questionId)
|
||||
}
|
||||
|
||||
private retireSlot(slot: RuntimeSlot): Promise<void> {
|
||||
|
||||
@@ -86,6 +86,9 @@ export type RuntimeConversationCompactOutcome = {
|
||||
}
|
||||
|
||||
export interface AgentRuntime {
|
||||
/** A workspace facade whose sessions are owned by a shared local Runtime. */
|
||||
readonly sharedProcess?: boolean
|
||||
readonly hasRetainedSessions?: boolean
|
||||
readonly runtimeId?: AgentRuntimeStatus['id']
|
||||
readonly requiresToolApproval: boolean
|
||||
readonly supportsToolExecution: boolean
|
||||
@@ -94,7 +97,7 @@ export interface AgentRuntime {
|
||||
readonly capability?: 'chat' | 'image-generation'
|
||||
getStatus(): Promise<AgentRuntimeStatus>
|
||||
testConnection?(): Promise<AgentRuntimeStatus>
|
||||
getNativeSnapshot?(): Promise<RuntimeNativeSnapshot>
|
||||
getNativeSnapshot?(workspace?: string): Promise<RuntimeNativeSnapshot>
|
||||
compactConversation?(
|
||||
request: RuntimeConversationCompactInput,
|
||||
signal: AbortSignal
|
||||
@@ -132,6 +135,8 @@ export type RemoteRecoveredSubagent =
|
||||
ConversationSubagentActivity
|
||||
|
||||
export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
|
||||
/** Resolved Main-side workspace, never taken from the current UI selection. */
|
||||
executionWorkspace?: string
|
||||
workMode?: WorkMode
|
||||
images?: AgentImage[]
|
||||
imageContextNotice?: ConversationMessage['imageContextNotice']
|
||||
|
||||
@@ -510,6 +510,44 @@ describe('SelectedRuntimeManager', () => {
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('answers owned questions after settings reset while rejecting new work', async () => {
|
||||
const active = runtime()
|
||||
active.value.respondToQuestion = vi.fn(async () => undefined)
|
||||
active.value.run = async function* (request) {
|
||||
yield {
|
||||
requestId: request.requestId, type: 'question', questionId: 'retained-question',
|
||||
questions: [{ header: 'Choice', question: 'Continue?', options: [], multiple: false, custom: true }]
|
||||
}
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
const manager = new SelectedRuntimeManager(async () => active.value)
|
||||
const controller = await manager.getRuntime({ provider: 'opencode' }, executionSpace('project-one'))
|
||||
const stream = controller.run({
|
||||
requestId: 'retained-request', conversationId: 'retained-conversation',
|
||||
prompt: 'Ask a question', workMode: 'execute'
|
||||
}, new AbortController().signal)
|
||||
try {
|
||||
expect((await stream.next()).value?.type).toBe('question')
|
||||
await manager.reset()
|
||||
expect(active.dispose).not.toHaveBeenCalled()
|
||||
await expect(controller.respondToQuestion('unknown-question', [['Yes']]))
|
||||
.rejects.toThrow('正在关闭')
|
||||
await expect(controller.respondToQuestion('retained-question', [['Yes']]))
|
||||
.resolves.toBeUndefined()
|
||||
expect(active.value.respondToQuestion).toHaveBeenCalledWith('retained-question', [['Yes']])
|
||||
await expect(controller.run({
|
||||
requestId: 'new-request', conversationId: 'new-conversation',
|
||||
prompt: 'New work', workMode: 'execute'
|
||||
}, new AbortController().signal).next()).rejects.toThrow('正在关闭')
|
||||
expect((await stream.next()).value?.type).toBe('done')
|
||||
await stream.next()
|
||||
await vi.waitFor(() => expect(active.dispose).toHaveBeenCalledOnce())
|
||||
} finally {
|
||||
await stream.return()
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes application exit through cached runtime controllers without ordinary disposal', async () => {
|
||||
const active = runtime()
|
||||
const detachForApplicationExit = vi.fn(() => undefined)
|
||||
@@ -595,7 +633,8 @@ describe('SelectedRuntimeManager', () => {
|
||||
await expect(
|
||||
manager.getRuntime(selection('43'))
|
||||
).rejects.toThrow(/退役容量/iu)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(create).toHaveBeenCalledTimes(3)
|
||||
expect(created[2]!.dispose).toHaveBeenCalledOnce()
|
||||
await manager.dispose()
|
||||
expect(created[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(created[1]!.dispose).toHaveBeenCalledOnce()
|
||||
@@ -607,7 +646,10 @@ describe('SelectedRuntimeManager', () => {
|
||||
finishCreate = resolve
|
||||
})
|
||||
const created = runtime()
|
||||
const create = vi.fn(async () => await pendingCreate)
|
||||
const rejected = runtime()
|
||||
const create = vi.fn()
|
||||
.mockReturnValueOnce(pendingCreate)
|
||||
.mockResolvedValueOnce(rejected.value)
|
||||
const manager = new SelectedRuntimeManager(create, 1, 1)
|
||||
const selection = (suffix: string) => ({
|
||||
provider: 'model' as const,
|
||||
@@ -622,7 +664,8 @@ describe('SelectedRuntimeManager', () => {
|
||||
await expect(
|
||||
manager.getRuntime(selection('52'))
|
||||
).rejects.toThrow(/退役容量/iu)
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(rejected.dispose).toHaveBeenCalledOnce()
|
||||
|
||||
finishCreate(created.value)
|
||||
await expect(firstRuntime).resolves.toEqual(
|
||||
|
||||
@@ -76,7 +76,8 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
DEFAULT_MAXIMUM_RETAINED_RUNTIMES,
|
||||
private readonly observeFailure?:
|
||||
DesktopDiagnosticFailureObserver,
|
||||
private readonly createStatusRuntime = createRuntime
|
||||
private readonly createStatusRuntime = createRuntime,
|
||||
private readonly releaseSharedConversation?: (conversationId: string) => Promise<void>
|
||||
) {
|
||||
if (
|
||||
!Number.isSafeInteger(maximumCachedRuntimes) ||
|
||||
@@ -111,21 +112,6 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
return existing.operation
|
||||
}
|
||||
this.evictIdleEntries(this.maximumCachedRuntimes - 1)
|
||||
if (this.entries.size >= this.maximumCachedRuntimes) {
|
||||
throw new Error(
|
||||
'Agent Runtime 缓存已被活动会话占满,请先关闭不再使用的会话'
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.entries.size +
|
||||
this.retiringControllers.size +
|
||||
this.pendingRetirementControllers >=
|
||||
this.maximumRetainedRuntimes
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话'
|
||||
)
|
||||
}
|
||||
const operation = this.createRuntime(selection, executionSpace).then(
|
||||
async (runtime) => {
|
||||
if (
|
||||
@@ -135,6 +121,21 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
}
|
||||
if (!runtime.sharedProcess) {
|
||||
const currentCount = [...this.entries.values()].filter(
|
||||
(candidate) => candidate !== entry && !candidate.controller?.sharedProcess
|
||||
).length
|
||||
const retiredCount = [...this.retiringControllers].filter(
|
||||
(candidate) => !candidate.sharedProcess
|
||||
).length + this.pendingRetirementControllers
|
||||
if (
|
||||
currentCount >= this.maximumCachedRuntimes ||
|
||||
currentCount + retiredCount >= this.maximumRetainedRuntimes
|
||||
) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话')
|
||||
}
|
||||
}
|
||||
const controller = new AgentRuntimeController(
|
||||
runtime,
|
||||
undefined,
|
||||
@@ -234,6 +235,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
await this.releaseSharedConversation?.(conversationId)
|
||||
const controllers = await Promise.allSettled([
|
||||
...[...this.entries.values()].map(({ operation }) => operation)
|
||||
])
|
||||
|
||||
+259
-20
@@ -12,10 +12,13 @@ import { join } from 'node:path'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import type {
|
||||
AssistantProject,
|
||||
ConversationSnapshot,
|
||||
ConversationQueueItem
|
||||
} from '../shared/assistant-contracts'
|
||||
import {
|
||||
browserTabIdSchema,
|
||||
defaultRuntimeSettings,
|
||||
runtimeSettingsInputSchema,
|
||||
type AgentEvent,
|
||||
type BrowserLiveState,
|
||||
type BrowserTabId
|
||||
@@ -27,6 +30,7 @@ import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { KnowledgeService } from './knowledge/knowledge-service'
|
||||
import { BrowserNavigationStoppedError } from './browser/browser-service'
|
||||
import { RemotePromptCancelledError } from './agent/acp-remote-runtime'
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
sendRemoteEnvironmentUpdateProgress,
|
||||
@@ -4863,7 +4867,7 @@ describe('registerIpcHandlers Runtime customization', () => {
|
||||
settingsStore.updateRuntimeCustomization
|
||||
).toHaveBeenCalledWith(customization)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||
expect(approvalBroker.clear).toHaveBeenCalledOnce()
|
||||
expect(approvalBroker.clear).not.toHaveBeenCalled()
|
||||
const activationError = new Error('customization activation failed')
|
||||
onRuntimeSettingsChanged
|
||||
.mockRejectedValueOnce(activationError)
|
||||
@@ -5062,7 +5066,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
appendRemoteTaskEventOnce: vi.fn<
|
||||
(input: RemoteTaskEventMockInput) => boolean
|
||||
>(() => true),
|
||||
appendRemoteConversationTaskEventOnce: vi.fn(() => true),
|
||||
appendRemoteConversationTaskEventOnce: vi.fn<AssistantDatabase['appendRemoteConversationTaskEventOnce']>(() => true),
|
||||
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
|
||||
listRecoverableRemoteTasks: vi.fn<
|
||||
() => Array<{
|
||||
taskId: string
|
||||
@@ -5078,7 +5083,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
getHighestCommittedRemoteTaskEventSequenceForTask: vi.fn(
|
||||
() => '0'
|
||||
),
|
||||
failRecoverableRemoteTask: vi.fn(),
|
||||
endRecoverableRemoteTask: vi.fn(),
|
||||
recordRemoteTaskQuestionAnswer: vi.fn<AssistantDatabase['recordRemoteTaskQuestionAnswer']>(),
|
||||
getTask: vi.fn<AssistantDatabase['getTask']>(),
|
||||
updateTaskStatus: vi.fn(),
|
||||
createTextArtifact: vi.fn(),
|
||||
createImageArtifact: vi.fn(() => ({
|
||||
@@ -5235,9 +5242,16 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})),
|
||||
updateRuntimeCustomization: vi.fn(
|
||||
async (settings: unknown) => settings
|
||||
)
|
||||
),
|
||||
captureRollback: vi.fn(async () => ({
|
||||
publicSettings: defaultRuntimeSettings,
|
||||
restore: async () => defaultRuntimeSettings
|
||||
})),
|
||||
update: vi.fn(async (settings: unknown) => settings)
|
||||
} as never,
|
||||
(capabilityServiceOverride ?? {}) as never,
|
||||
(capabilityServiceOverride ?? {
|
||||
setWebSearchEnabled: vi.fn(async () => ({}))
|
||||
}) as never,
|
||||
contextManager as never,
|
||||
(knowledgeServiceOverride ?? {
|
||||
database: { listKnowledgeBases: vi.fn(() => []) }
|
||||
@@ -5725,7 +5739,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('keeps a replacement lease owned after the bulk-aborted lease releases', async () => {
|
||||
it('keeps the active lease through settings reload and releases it only after cancellation settles', async () => {
|
||||
const controls = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -5803,7 +5817,25 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
opencode: {},
|
||||
continue: { presets: [] }
|
||||
})
|
||||
expect(controls.get(oldConversationId)?.signal.aborted).toBe(false)
|
||||
|
||||
await expect(harness.handler?.(event, {
|
||||
requestId,
|
||||
conversationId: newConversationId,
|
||||
prompt: '替换请求',
|
||||
workMode: 'ask',
|
||||
knowledgeLibraryIds: []
|
||||
})).rejects.toThrow('请求正在执行')
|
||||
|
||||
harness.cancelHandler?.(event, requestId)
|
||||
expect(controls.get(oldConversationId)?.signal.aborted).toBe(true)
|
||||
controls.get(oldConversationId)?.finish()
|
||||
await vi.waitFor(() =>
|
||||
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
||||
|
||||
await harness.handler?.(event, {
|
||||
requestId,
|
||||
@@ -5815,15 +5847,6 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(controls.has(newConversationId)).toBe(true)
|
||||
)
|
||||
|
||||
controls.get(oldConversationId)?.finish()
|
||||
await vi.waitFor(() =>
|
||||
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
||||
|
||||
harness.cancelHandler?.(event, requestId)
|
||||
expect(controls.get(newConversationId)?.signal.aborted).toBe(true)
|
||||
await expect(
|
||||
@@ -5868,6 +5891,64 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('cancels only an expert-team parent on settings replacement without synthesizing on the replacement model', async () => {
|
||||
let rejectExpert!: (error: Error) => void
|
||||
let parentSignal: AbortSignal | undefined
|
||||
const subagents = {
|
||||
run: vi.fn(async (input: { expert: { name: string }; signal: AbortSignal }) => {
|
||||
parentSignal = input.signal
|
||||
if (input.expert.name === 'First') return { childTaskId: 'first', output: 'Finished analysis' }
|
||||
return new Promise((_, reject) => { rejectExpert = reject })
|
||||
}),
|
||||
synthesize: vi.fn(async () => 'Must not use replacement model'),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
let finishPeer!: () => void
|
||||
let peerSignal: AbortSignal | undefined
|
||||
const runtime = {
|
||||
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
|
||||
async *run(request: { requestId: string }, signal: AbortSignal) {
|
||||
peerSignal = signal
|
||||
await new Promise<void>(resolve => { finishPeer = resolve })
|
||||
yield { requestId: request.requestId, type: 'done' as const }
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime, undefined, 'always', subagents)
|
||||
harness.assistantDatabase.listExperts.mockReturnValue([
|
||||
{ id: '00000000-0000-4000-8000-000000000781', name: 'First', enabled: true },
|
||||
{ id: '00000000-0000-4000-8000-000000000782', name: 'Second', enabled: true }
|
||||
])
|
||||
harness.onRuntimeSettingsChanged.mockImplementation(async () => {
|
||||
rejectExpert(new Error('默认模型设置已更改'))
|
||||
})
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const teamId = '00000000-0000-4000-8000-000000000783'
|
||||
try {
|
||||
await harness.handler?.(event, {
|
||||
requestId: teamId, conversationId: 'expert-team',
|
||||
prompt: 'Analyze with the team', teamMode: true, workMode: 'ask', knowledgeLibraryIds: []
|
||||
})
|
||||
await vi.waitFor(() => expect(subagents.run).toHaveBeenCalledTimes(2))
|
||||
await harness.handler?.(event, {
|
||||
requestId: '00000000-0000-4000-8000-000000000784', conversationId: 'ordinary-peer',
|
||||
prompt: 'Keep working', workMode: 'ask', knowledgeLibraryIds: []
|
||||
})
|
||||
await vi.waitFor(() => expect(peerSignal).toBeDefined())
|
||||
await electronMocks.handlers.get(ipcChannels.runtimeCustomizationUpdate)!(event, {
|
||||
opencode: {}, continue: { presets: [] }
|
||||
})
|
||||
expect(parentSignal?.aborted).toBe(true)
|
||||
expect(peerSignal?.aborted).toBe(false)
|
||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus)
|
||||
.toHaveBeenCalledWith(teamId, 'cancelled', '请求已取消'))
|
||||
expect(subagents.synthesize).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rejectExpert?.(new Error('Test cleanup'))
|
||||
finishPeer?.()
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('detaches an accepted managed SSH run without aborting it or waiting for completion', async () => {
|
||||
let finishRun = (): void => undefined
|
||||
let remoteSignal: AbortSignal | undefined
|
||||
@@ -6555,6 +6636,150 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['runtime', 'customization', 'capabilities'] as const)('restores concurrent live questions and their answer history across %s settings reload', async (settingsKind) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovered-question-history-'))
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize(directory)
|
||||
const project = database.createSshProject({
|
||||
project: {
|
||||
name: 'Recovered questions', description: '', rootPath: '/srv/project',
|
||||
defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' }
|
||||
},
|
||||
executionSpace: {
|
||||
kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850',
|
||||
remoteRootPath: '/srv/project'
|
||||
},
|
||||
assertCurrent: () => {}
|
||||
})
|
||||
const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
||||
const tasks = [1, 2].map(n => ({
|
||||
taskId: id(810 + n), conversationId: id(820 + n), projectId: project.id,
|
||||
currentUserMessageId: id(830 + n), currentAssistantMessageId: id(840 + n),
|
||||
instructions: 'Original question', workMode: 'execute' as const,
|
||||
status: 'running' as const
|
||||
}))
|
||||
for (const task of tasks) {
|
||||
database.saveLocalConversations([{
|
||||
header: {
|
||||
id: task.conversationId, projectId: project.id,
|
||||
title: 'Recovered', updatedAt: Date.now(), runtimeSelection: { provider: 'opencode' }
|
||||
},
|
||||
messages: []
|
||||
}])
|
||||
database.createTask({
|
||||
id: task.taskId, conversationId: task.conversationId, projectId: project.id,
|
||||
title: 'Recovered question', instructions: task.instructions, workMode: task.workMode,
|
||||
remoteRecovery: {
|
||||
recoverable: true, currentUserMessageId: task.currentUserMessageId,
|
||||
currentAssistantMessageId: task.currentAssistantMessageId
|
||||
}
|
||||
})
|
||||
}
|
||||
const ready = new Set<string>()
|
||||
const signals: AbortSignal[] = []
|
||||
const finish = new Map<string, () => void>()
|
||||
const runtime = {
|
||||
runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true,
|
||||
respondToQuestion: vi.fn(async (questionId: string) => { finish.get(questionId)?.() }),
|
||||
async *run(request: { requestId: string; remoteRecoveryOnly?: boolean }, signal: AbortSignal) {
|
||||
signals.push(signal)
|
||||
expect(request.remoteRecoveryOnly).toBe(true)
|
||||
const answered = new Promise<void>(resolve => { finish.set(request.requestId, resolve) })
|
||||
ready.add(request.requestId)
|
||||
yield {
|
||||
requestId: request.requestId, type: 'question' as const, questionId: request.requestId,
|
||||
questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }]
|
||||
}
|
||||
await answered
|
||||
yield {
|
||||
requestId: request.requestId, type: 'done' as const, sessionId: request.requestId,
|
||||
remoteProvenance: {
|
||||
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
|
||||
operationId: request.requestId, semanticSequence: '1', eventIndex: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId, type: 'remote-semantic-checkpoint' as const,
|
||||
remoteProvenance: {
|
||||
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
|
||||
operationId: request.requestId, semanticSequence: '1', eventIndex: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const { harness } = createManagedSshHarness(runtime)
|
||||
harness.assistantDatabase.getProject.mockReturnValue({
|
||||
id: project.id, runtimeSelection: { provider: 'opencode' }, rootPath: '/srv/project',
|
||||
executionSpace: { kind: 'ssh', hostId: id(850), remoteRootPath: '/srv/project' }
|
||||
})
|
||||
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
|
||||
harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database))
|
||||
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
|
||||
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
|
||||
harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database))
|
||||
harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database))
|
||||
harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation(
|
||||
(...args) => database.recordRemoteTaskQuestionAnswer(...args)
|
||||
)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const list = () => electronMocks.handlers.get(ipcChannels.conversationsList)!(event) as ConversationSnapshot[]
|
||||
try {
|
||||
harness.recoveryGetHandler?.(event)
|
||||
await vi.waitFor(() => expect(ready.size).toBe(2))
|
||||
await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true))
|
||||
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
|
||||
const update = settingsKind === 'runtime'
|
||||
? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({
|
||||
...defaultRuntimeSettings, workspacePath: directory, apiKey: { action: 'keep' }
|
||||
})] as const
|
||||
: settingsKind === 'customization'
|
||||
? [ipcChannels.runtimeCustomizationUpdate, { opencode: { defaultAgent: 'build' }, continue: { presets: [] } }] as const
|
||||
: [ipcChannels.capabilitiesToggleWebSearch, false] as const
|
||||
await electronMocks.handlers.get(update[0])!(event, update[1])
|
||||
expect(signals.every(signal => !signal.aborted)).toBe(true)
|
||||
expect(harness.approvalBroker.clear).not.toHaveBeenCalled()
|
||||
for (const [index, task] of [...tasks].reverse().entries()) {
|
||||
const snapshot = list().find(c => c.id === task.conversationId)!
|
||||
expect(snapshot.activeRequest).toMatchObject({
|
||||
requestId: task.taskId, messageId: task.currentAssistantMessageId
|
||||
})
|
||||
await electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!(event, {
|
||||
questionId: task.taskId, answers: index === 0 ? [['Yes']] : []
|
||||
})
|
||||
}
|
||||
await vi.waitFor(() => expect(list().every(c => !c.activeRequest)).toBe(true))
|
||||
expect(runtime.respondToQuestion.mock.calls.map(([questionId]) => questionId))
|
||||
.toEqual([...tasks].reverse().map(task => task.taskId))
|
||||
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledTimes(4)
|
||||
database.close()
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize(directory)
|
||||
try {
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
expect(reopened.getTask(task.taskId).status).toBe('completed')
|
||||
expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({
|
||||
id: task.currentAssistantMessageId, state: 'complete',
|
||||
answeredQuestions: [{
|
||||
questionId: task.taskId, skipped: index === 0,
|
||||
questions: [{
|
||||
header: 'Input', question: 'Continue?', options: [], multiple: false, custom: true,
|
||||
...(index === 1 ? { answer: ['Yes'] } : {})
|
||||
}]
|
||||
}]
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reopened.close()
|
||||
}
|
||||
} finally {
|
||||
for (const resolve of finish.values()) resolve()
|
||||
await harness.dispose()
|
||||
database.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts and drains a local run during Desktop shutdown', async () => {
|
||||
let localSignal: AbortSignal | undefined
|
||||
let finishLocalCleanup = (): void => undefined
|
||||
@@ -6627,7 +6852,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
||||
})
|
||||
|
||||
it('keeps an accepted managed SSH run recoverable after user Stop', async () => {
|
||||
it.each([false, true])('recovers user Stop only while its remote result is uncertain (confirmed=%s)', async (confirmed) => {
|
||||
let remoteSignal: AbortSignal | undefined
|
||||
let statusConsumed = false
|
||||
const remoteRuntime = {
|
||||
@@ -6650,6 +6875,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
if (confirmed) {
|
||||
throw new RemotePromptCancelledError('请求已取消')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
yield { requestId: request.requestId, type: 'done' } as const
|
||||
}
|
||||
@@ -6697,11 +6925,22 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
)
|
||||
|
||||
expect(remoteSignal?.aborted).toBe(true)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
if (confirmed) {
|
||||
await vi.waitFor(() => expect(
|
||||
harness.assistantDatabase.endRecoverableRemoteTask
|
||||
).toHaveBeenCalledWith(requestId, '请求已取消', 'cancelled'))
|
||||
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
|
||||
requestId, 'error', expect.anything()
|
||||
)
|
||||
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
||||
ipcChannels.remoteProjectRecoveryProgress,
|
||||
expect.anything()
|
||||
)
|
||||
} else {
|
||||
await vi.waitFor(() => expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消')
|
||||
)
|
||||
).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消'))
|
||||
}
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
|
||||
+127
-31
@@ -240,7 +240,10 @@ import {
|
||||
} from './agent/runtime-selection'
|
||||
import { safeToolErrorDetail } from './agent/approval-summary'
|
||||
import { ReasoningTagStreamParser } from './agent/reasoning-stream'
|
||||
import { RemotePromptRecoveryUnavailableError } from './agent/acp-remote-runtime'
|
||||
import {
|
||||
RemotePromptCancelledError,
|
||||
RemotePromptRecoveryUnavailableError
|
||||
} from './agent/acp-remote-runtime'
|
||||
import {
|
||||
bundledContinueVersion,
|
||||
bundledDeepSeekHarnessVersion,
|
||||
@@ -1192,7 +1195,7 @@ export function registerIpcHandlers(
|
||||
assistantDatabase: AssistantDatabase,
|
||||
approvalBroker: ToolApprovalBroker,
|
||||
bundledRuntimePaths: BundledRuntimePaths,
|
||||
onRuntimeSettingsChanged: () => Promise<void>,
|
||||
activateRuntimeSettings: () => Promise<void>,
|
||||
onBeforeClearLocalData?: () => Promise<void>,
|
||||
browserControl?: {
|
||||
createTab(
|
||||
@@ -1337,9 +1340,21 @@ export function registerIpcHandlers(
|
||||
controller: AbortController
|
||||
conversationId: string
|
||||
detachOnApplicationExit: boolean
|
||||
teamMode?: boolean
|
||||
recoveredMessageId?: string
|
||||
release(): void
|
||||
}
|
||||
const activeRequests = new Map<string, ActiveRequestLease>()
|
||||
const onRuntimeSettingsChanged = async (): Promise<void> => {
|
||||
// The expert scheduler cancels its work on replacement. Its parent must
|
||||
// not synthesize partial old-generation results with the replacement model.
|
||||
for (const lease of activeRequests.values()) {
|
||||
if (lease.teamMode) {
|
||||
lease.controller.abort(new Error('专家团队使用的模型设置已更改'))
|
||||
}
|
||||
}
|
||||
await activateRuntimeSettings()
|
||||
}
|
||||
const requireTerminalSessionManager = (): TerminalSessionManager => {
|
||||
if (!terminalSessionManager) {
|
||||
throw new Error('终端服务不可用')
|
||||
@@ -1375,7 +1390,7 @@ export function registerIpcHandlers(
|
||||
const activeEventBuffers = new Map<string, { flush(): void }>()
|
||||
const pendingAgentQuestions = new Map<
|
||||
string,
|
||||
{ requestId: string; runtime: AgentRuntime }
|
||||
{ requestId: string; runtime: AgentRuntime; question: Extract<AgentEvent, { type: 'question' }> }
|
||||
>()
|
||||
const heartbeatControllers = new Set<AbortController>()
|
||||
let activeSshDirectoryBrowse: AbortController | undefined
|
||||
@@ -1637,7 +1652,6 @@ export function registerIpcHandlers(
|
||||
): Promise<CapabilitySnapshot> => {
|
||||
const snapshot = await operation
|
||||
if (reconfigureRuntime) {
|
||||
abortActiveRequests('扩展能力设置已更改')
|
||||
await onRuntimeSettingsChanged()
|
||||
}
|
||||
return snapshot
|
||||
@@ -1924,6 +1938,7 @@ export function registerIpcHandlers(
|
||||
controller
|
||||
)
|
||||
lease.detachOnApplicationExit = true
|
||||
lease.recoveredMessageId = task.currentAssistantMessageId
|
||||
let sawTerminal = false
|
||||
try {
|
||||
publishRemoteProjectRecovery({
|
||||
@@ -1973,6 +1988,21 @@ export function registerIpcHandlers(
|
||||
recoveryRequest,
|
||||
controller.signal
|
||||
)) {
|
||||
if (rawEvent.type === 'question' || rawEvent.type === 'question-resolved') {
|
||||
if (rawEvent.type === 'question') {
|
||||
pendingAgentQuestions.set(rawEvent.questionId, {
|
||||
requestId: task.taskId, runtime: recoveredRuntime, question: rawEvent
|
||||
})
|
||||
assistantDatabase.updateTaskStatus(task.taskId, 'waiting_approval')
|
||||
} else {
|
||||
pendingAgentQuestions.delete(rawEvent.questionId)
|
||||
if (![...pendingAgentQuestions.values()].some(pending => pending.requestId === task.taskId)) {
|
||||
assistantDatabase.updateTaskStatus(task.taskId, 'running')
|
||||
}
|
||||
}
|
||||
publishConversationChange()
|
||||
continue
|
||||
}
|
||||
const provenance = remoteSemanticProvenance(rawEvent)
|
||||
if (provenance === undefined) {
|
||||
if (rawEvent.type !== 'status') {
|
||||
@@ -2069,16 +2099,26 @@ export function registerIpcHandlers(
|
||||
return
|
||||
}
|
||||
if (error instanceof RemotePromptRecoveryUnavailableError) {
|
||||
assistantDatabase.failRecoverableRemoteTask(
|
||||
assistantDatabase.endRecoverableRemoteTask(
|
||||
task.taskId,
|
||||
error.message
|
||||
error.message,
|
||||
'failed'
|
||||
)
|
||||
publishConversationChange()
|
||||
return
|
||||
}
|
||||
if (error instanceof RemotePromptCancelledError) {
|
||||
assistantDatabase.endRecoverableRemoteTask(task.taskId, '请求已取消', 'cancelled')
|
||||
publishConversationChange()
|
||||
return
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
for (const [id, pending] of pendingAgentQuestions) {
|
||||
if (pending.requestId === task.taskId) pendingAgentQuestions.delete(id)
|
||||
}
|
||||
lease.release()
|
||||
publishConversationChange()
|
||||
assistantDatabase.completeTaskScheduleRun(task.taskId)
|
||||
}
|
||||
}
|
||||
@@ -2111,9 +2151,11 @@ export function registerIpcHandlers(
|
||||
task.projectId === projectId &&
|
||||
!activeRequests.has(task.taskId)
|
||||
)
|
||||
for (const task of tasks) {
|
||||
await recoverRemoteTask(task, requestId)
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
tasks.map(task => recoverRemoteTask(task, requestId))
|
||||
)
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
// Recovery may terminalize the task without a trailing checkpoint;
|
||||
// let the renderer converge on the persisted terminal state.
|
||||
publishConversationChange()
|
||||
@@ -4137,6 +4179,7 @@ export function registerIpcHandlers(
|
||||
request.conversationId,
|
||||
controller
|
||||
)
|
||||
activeRequestLease.teamMode = request.teamMode === true
|
||||
if (parsedInput.queueItemId) {
|
||||
const dispatchTimeout = queueDispatchTimers.get(
|
||||
parsedInput.queueItemId
|
||||
@@ -4638,7 +4681,8 @@ export function registerIpcHandlers(
|
||||
if (publicEvent.type === 'question') {
|
||||
pendingAgentQuestions.set(publicEvent.questionId, {
|
||||
requestId: request.requestId,
|
||||
runtime: selectedRuntime
|
||||
runtime: selectedRuntime,
|
||||
question: publicEvent
|
||||
})
|
||||
}
|
||||
if (publicEvent.type === 'question-resolved' &&
|
||||
@@ -4723,16 +4767,19 @@ export function registerIpcHandlers(
|
||||
} catch (error) {
|
||||
publishReferences()
|
||||
eventBuffer.flush()
|
||||
const errorMessage = controller.signal.aborted
|
||||
const cancelled =
|
||||
controller.signal.aborted ||
|
||||
error instanceof RemotePromptCancelledError
|
||||
const errorMessage = cancelled
|
||||
? '请求已取消'
|
||||
: safeRuntimeError(error, 'Agent Runtime 执行失败')
|
||||
const agentEvent: AgentEvent =
|
||||
runtimeErrorEvent && !controller.signal.aborted
|
||||
runtimeErrorEvent && !cancelled
|
||||
? runtimeErrorEvent
|
||||
: {
|
||||
requestId: request.requestId,
|
||||
type: 'error',
|
||||
status: controller.signal.aborted
|
||||
status: cancelled
|
||||
? 'cancelled'
|
||||
: 'failed',
|
||||
message: errorMessage
|
||||
@@ -4745,25 +4792,39 @@ export function registerIpcHandlers(
|
||||
const shouldRecoverAcceptedRemoteOperation =
|
||||
remoteConversationRecovery !== undefined &&
|
||||
managedSshOperationAccepted &&
|
||||
!runtimeErrorPersistedRemotely
|
||||
!runtimeErrorPersistedRemotely &&
|
||||
!(error instanceof RemotePromptCancelledError)
|
||||
remoteRecoveryPending =
|
||||
shouldRecoverAcceptedRemoteOperation
|
||||
assistantDatabase.updateTaskStatus(
|
||||
request.requestId,
|
||||
shouldRecoverAcceptedRemoteOperation
|
||||
? 'interrupted'
|
||||
: terminalStatus,
|
||||
errorMessage
|
||||
)
|
||||
if (!runtimeErrorPersistedRemotely) {
|
||||
assistantDatabase.appendTaskEvent(
|
||||
if (
|
||||
remoteConversationRecovery !== undefined &&
|
||||
error instanceof RemotePromptCancelledError &&
|
||||
!runtimeErrorPersistedRemotely
|
||||
) {
|
||||
assistantDatabase.endRecoverableRemoteTask(
|
||||
request.requestId,
|
||||
agentEvent.type,
|
||||
agentEvent
|
||||
errorMessage,
|
||||
'cancelled'
|
||||
)
|
||||
publishConversationChange()
|
||||
} else {
|
||||
assistantDatabase.updateTaskStatus(
|
||||
request.requestId,
|
||||
shouldRecoverAcceptedRemoteOperation
|
||||
? 'interrupted'
|
||||
: terminalStatus,
|
||||
errorMessage
|
||||
)
|
||||
if (!runtimeErrorPersistedRemotely) {
|
||||
assistantDatabase.appendTaskEvent(
|
||||
request.requestId,
|
||||
agentEvent.type,
|
||||
agentEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title: controller.signal.aborted
|
||||
title: cancelled
|
||||
? 'GoodBuddy 任务已取消'
|
||||
: 'GoodBuddy 任务失败',
|
||||
body: '打开任务工作栏查看详情。'
|
||||
@@ -4874,7 +4935,26 @@ export function registerIpcHandlers(
|
||||
response.questionId,
|
||||
response.answers.length > 0 ? response.answers : undefined
|
||||
)
|
||||
const recorded = assistantDatabase.recordRemoteTaskQuestionAnswer(pending.requestId, {
|
||||
questionId: response.questionId,
|
||||
skipped: response.answers.length === 0,
|
||||
questions: pending.question.questions.map((question, index) => ({
|
||||
...question,
|
||||
answer: response.answers[index]
|
||||
}))
|
||||
})
|
||||
pendingAgentQuestions.delete(response.questionId)
|
||||
if (activeRequests.get(pending.requestId)?.recoveredMessageId) {
|
||||
if (
|
||||
![...pendingAgentQuestions.values()].some(value => value.requestId === pending.requestId) &&
|
||||
assistantDatabase.getTask(pending.requestId).status === 'waiting_approval'
|
||||
) {
|
||||
assistantDatabase.updateTaskStatus(pending.requestId, 'running')
|
||||
}
|
||||
}
|
||||
if (recorded) {
|
||||
publishConversationChange()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5083,8 +5163,6 @@ export function registerIpcHandlers(
|
||||
persistCandidate: async () => {
|
||||
const saved =
|
||||
await settingsStore.updateRuntimeCustomization(settings)
|
||||
abortActiveRequests('Runtime 定制设置已更改')
|
||||
approvalBroker.clear()
|
||||
return saved
|
||||
},
|
||||
activate: onRuntimeSettingsChanged,
|
||||
@@ -5153,8 +5231,6 @@ export function registerIpcHandlers(
|
||||
...settings,
|
||||
workspacePath
|
||||
})
|
||||
abortActiveRequests('运行时设置已更改')
|
||||
approvalBroker.clear()
|
||||
return saved
|
||||
},
|
||||
activate: onRuntimeSettingsChanged,
|
||||
@@ -6624,7 +6700,27 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(ipcChannels.conversationsList, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.listConversations()
|
||||
const questions = new Map<string, Extract<AgentEvent, { type: 'question' }>[]>()
|
||||
for (const pending of pendingAgentQuestions.values()) {
|
||||
const entries = questions.get(pending.requestId) ?? []
|
||||
entries.push(pending.question)
|
||||
questions.set(pending.requestId, entries)
|
||||
}
|
||||
const recovered = new Map(
|
||||
[...activeRequests].flatMap(([requestId, lease]) =>
|
||||
lease.recoveredMessageId
|
||||
? [[lease.conversationId, {
|
||||
requestId,
|
||||
messageId: lease.recoveredMessageId,
|
||||
questions: questions.get(requestId) ?? []
|
||||
}] as const]
|
||||
: []
|
||||
)
|
||||
)
|
||||
return assistantDatabase.listConversations().map(conversation => ({
|
||||
...conversation,
|
||||
...(recovered.has(conversation.id) ? { activeRequest: recovered.get(conversation.id) } : {})
|
||||
}))
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
|
||||
@@ -448,6 +448,36 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('retains active Sessions and admits new channels after same-Agent reconnect', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(fixture.options)
|
||||
let releaseGate!: () => void
|
||||
remoteHarness.runGate = new Promise<void>(resolve => { releaseGate = resolve })
|
||||
const first = runtime.run(request('before-reconnect'), new AbortController().signal)
|
||||
await first.next()
|
||||
fixture.connection.client.generation = 6
|
||||
const second = runtime.run(request('after-reconnect'), new AbortController().signal)
|
||||
try {
|
||||
await expect(second.next()).resolves.toMatchObject({
|
||||
value: { type: 'status' }, done: false
|
||||
})
|
||||
expect(remoteHarness.instances).toHaveLength(1)
|
||||
expect(remoteHarness.instances[0]!.dispose).not.toHaveBeenCalled()
|
||||
// This fixture does not implement the open RPC. Reaching it proves the
|
||||
// original factory accepts the recovered transport instead of a stale lease.
|
||||
await expect(remoteHarness.instances[0]!.options.channelFactory(
|
||||
'binding-after-reconnect'
|
||||
)).rejects.toThrow()
|
||||
expect(fixture.connection.client.request.mock.calls.some(
|
||||
([method]) => method === 'runtime/openAcpChannel'
|
||||
)).toBe(true)
|
||||
} finally {
|
||||
releaseGate()
|
||||
await Promise.all([collect(first), collect(second)])
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('abandons connected runtime ownership on application exit without remote cleanup', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
|
||||
@@ -286,11 +286,12 @@ class ManagedRemoteRuntimeResources {
|
||||
bindingId: string
|
||||
) {
|
||||
const transport = await this.prepareTransport()
|
||||
assertSameTransport(
|
||||
expected,
|
||||
transport,
|
||||
'Remote Runtime transport lease is stale'
|
||||
)
|
||||
if (
|
||||
expected.daemonBootId !== transport.daemonBootId ||
|
||||
transport.controllerGeneration < expected.controllerGeneration
|
||||
) {
|
||||
throw new Error('Remote Runtime transport lease is stale')
|
||||
}
|
||||
const channel = await createProtocolRemoteRuntimeChannel({
|
||||
connection: this.connection,
|
||||
openIdentity: {
|
||||
@@ -302,7 +303,7 @@ class ManagedRemoteRuntimeResources {
|
||||
})
|
||||
try {
|
||||
assertSameTransport(
|
||||
expected,
|
||||
transport,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while opening an ACP channel'
|
||||
)
|
||||
@@ -511,19 +512,24 @@ class ManagedRemoteAcpRuntime implements AgentRuntime {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
if (!sameTransport(this.transport, currentTransport)) {
|
||||
if (this.#activeRuns > 0) {
|
||||
throw new Error(
|
||||
'Remote Agent transport changed while another Runtime request was active'
|
||||
)
|
||||
const resumedSameAgent =
|
||||
this.transport.daemonBootId === currentTransport.daemonBootId &&
|
||||
currentTransport.controllerGeneration > this.transport.controllerGeneration
|
||||
if (!resumedSameAgent) {
|
||||
if (this.#activeRuns > 0) {
|
||||
throw new Error(
|
||||
'Remote Agent transport changed while another Runtime request was active'
|
||||
)
|
||||
}
|
||||
await this.remote.dispose()
|
||||
if (this.#disposed) {
|
||||
throw new Error('远端 Runtime 已关闭')
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
this.remote = this.createRemote(currentTransport)
|
||||
}
|
||||
await this.remote.dispose()
|
||||
if (this.#disposed) {
|
||||
throw new Error('远端 Runtime 已关闭')
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
this.remote = this.createRemote(currentTransport)
|
||||
this.transport = currentTransport
|
||||
}
|
||||
this.#activeRuns += 1
|
||||
|
||||
@@ -1296,7 +1296,7 @@ describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
return {
|
||||
status: 'terminal',
|
||||
terminalState: 'failed',
|
||||
processTree: 'running'
|
||||
processTree: 'unknown'
|
||||
}
|
||||
}
|
||||
return defaultResponse(method, params)
|
||||
@@ -1311,6 +1311,17 @@ describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
await fixture.channel.close()
|
||||
})
|
||||
|
||||
it.each(['failed', 'cancelled'] as const)('accepts a %s Session while its shared process serves peers', async terminalState => {
|
||||
const fixture = await openChannel()
|
||||
fixture.client.responder = (method, params) => method === 'runtime/reconcilePrompt'
|
||||
? { status: 'terminal', terminalState, processTree: 'running' }
|
||||
: defaultResponse(method, params)
|
||||
await expect(fixture.channel.reconcilePromptOperation({
|
||||
bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1'
|
||||
})).resolves.toEqual({ status: 'terminal', terminalState, processTree: 'running' })
|
||||
await fixture.channel.close()
|
||||
})
|
||||
|
||||
it('keeps streams alive, seeds exact sequences, and ACKs replay duplicates without re-emitting them', async () => {
|
||||
const fixture = await openChannel()
|
||||
const recoveryAbort = new AbortController()
|
||||
|
||||
@@ -1436,6 +1436,19 @@ function persistedTerminalStateOverridesLocal(
|
||||
return persisted.state !== "streaming" && local.state !== "complete";
|
||||
}
|
||||
|
||||
function withRecoveredQuestions(conversation: Conversation): Conversation {
|
||||
const active = conversation.activeRequest;
|
||||
if (!active) return conversation;
|
||||
return {
|
||||
...conversation,
|
||||
messages: conversation.messages.map(message =>
|
||||
message.id === active.messageId && message.state === "streaming"
|
||||
? { ...message, pendingQuestions: active.questions }
|
||||
: message,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mergePersistedConversations(
|
||||
current: readonly Conversation[],
|
||||
incoming: readonly ConversationSnapshot[],
|
||||
@@ -1454,7 +1467,7 @@ function mergePersistedConversations(
|
||||
const local = currentById.get(conversation.id);
|
||||
if (!local || local.remote) {
|
||||
persistedLocal.set(conversation.id, conversation);
|
||||
return conversation;
|
||||
return withRecoveredQuestions(conversation);
|
||||
}
|
||||
const localMessageById = new Map(
|
||||
local.messages.map((message) => [message.id, message]),
|
||||
@@ -1466,6 +1479,9 @@ function mergePersistedConversations(
|
||||
const messages = [
|
||||
...conversation.messages.map((message) => {
|
||||
const localMessage = localMessageById.get(message.id);
|
||||
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
|
||||
return { ...message, pendingQuestions: undefined };
|
||||
}
|
||||
if (!localIsNewer) {
|
||||
// Pending prompts are live-only and are omitted from persisted messages.
|
||||
if (localMessage?.state === "streaming" && message.state === "streaming") {
|
||||
@@ -1484,10 +1500,10 @@ function mergePersistedConversations(
|
||||
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
|
||||
];
|
||||
const next = localIsNewer
|
||||
? { ...local, messages }
|
||||
? { ...local, messages, activeRequest: conversation.activeRequest }
|
||||
: { ...conversation, messages };
|
||||
persistedLocal.set(conversation.id, conversation);
|
||||
return next;
|
||||
return withRecoveredQuestions(next);
|
||||
});
|
||||
for (const conversation of current) {
|
||||
if (!incomingById.has(conversation.id)) {
|
||||
@@ -5579,7 +5595,7 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
: [];
|
||||
let nextConversations: Conversation[] = [
|
||||
...persistedConversations,
|
||||
...persistedConversations.map(withRecoveredQuestions),
|
||||
...migratedLocalConversations,
|
||||
];
|
||||
let projectConversation = nextConversations.find(
|
||||
@@ -7404,7 +7420,9 @@ function App(): React.JSX.Element {
|
||||
const stop = async (): Promise<void> => {
|
||||
const requestId = [...activeRuns.current.entries()].find(
|
||||
([, run]) => run.conversationId === activeId,
|
||||
)?.[0];
|
||||
)?.[0] ?? conversationsRef.current.find(
|
||||
conversation => conversation.id === activeId,
|
||||
)?.activeRequest?.requestId;
|
||||
if (requestId) {
|
||||
try {
|
||||
await window.goodbuddy.agent.cancel(requestId);
|
||||
@@ -7503,8 +7521,7 @@ function App(): React.JSX.Element {
|
||||
if (!question || question.questionId !== questionId) {
|
||||
return;
|
||||
}
|
||||
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
|
||||
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
|
||||
const taskId = question.requestId;
|
||||
await window.goodbuddy.agent.respondQuestion(questionId, answers);
|
||||
updateMessage(conversationId, messageId, (message) => {
|
||||
const pendingQuestions = message.pendingQuestions?.filter((item) => item.questionId !== questionId);
|
||||
|
||||
@@ -429,20 +429,8 @@ export const acpReconcilePromptResultSchema = z.discriminatedUnion(
|
||||
]),
|
||||
processTree: z.enum(['running', 'empty'])
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
if (
|
||||
result.processTree === 'running' &&
|
||||
result.terminalState !== 'completed'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['processTree'],
|
||||
message:
|
||||
'Only a completed prompt may retain a running process tree'
|
||||
})
|
||||
}
|
||||
}),
|
||||
// A terminal Session can leave a shared Runtime serving other Sessions.
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
status: z.literal('running'),
|
||||
|
||||
@@ -38,6 +38,23 @@ const untouchedProject: AssistantProject = {
|
||||
}
|
||||
|
||||
describe('agent question contracts', () => {
|
||||
it('returns recovered questions only in live snapshots, not saved headers', () => {
|
||||
const id = '00000000-0000-4000-8000-000000000901'
|
||||
const snapshot = {
|
||||
id, title: 'Recovered', updatedAt: 1, messages: [],
|
||||
activeRequest: {
|
||||
requestId: id, messageId: id,
|
||||
questions: [{
|
||||
requestId: id, type: 'question', questionId: 'native',
|
||||
questions: [{ header: 'Input', question: 'Which?', options: [], multiple: false, custom: true }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
expect(conversationSnapshotSchema.parse(snapshot)).toEqual(snapshot)
|
||||
const { messages, ...header } = snapshot
|
||||
expect(localConversationSaveBatchSchema.safeParse([{ header, messages }]).success).toBe(false)
|
||||
})
|
||||
|
||||
it('does not impose GoodBuddy-specific question answer limits', () => {
|
||||
const answers = Array.from({ length: 5 }, (_, questionIndex) =>
|
||||
Array.from(
|
||||
|
||||
@@ -515,6 +515,21 @@ export const conversationBranchSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const liveAgentQuestionSchema = z.object({
|
||||
requestId: z.string().min(1),
|
||||
type: z.literal('question'),
|
||||
questionId: z.string().min(1),
|
||||
childTaskId: z.string().optional(),
|
||||
questions: z.array(z.object({
|
||||
header: z.string(),
|
||||
question: z.string(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string() })),
|
||||
multiple: z.boolean(),
|
||||
custom: z.boolean()
|
||||
}))
|
||||
})
|
||||
export type LiveAgentQuestion = z.infer<typeof liveAgentQuestionSchema>
|
||||
|
||||
export const conversationSnapshotSchema = z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
@@ -537,6 +552,12 @@ export const conversationSnapshotSchema = z
|
||||
branch: conversationBranchSchema.optional(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
updatedAt: z.number().int().nonnegative(),
|
||||
// Main-only live projection. Never part of a saved conversation header.
|
||||
activeRequest: z.object({
|
||||
requestId: assistantIdSchema,
|
||||
messageId: assistantIdSchema,
|
||||
questions: z.array(liveAgentQuestionSchema)
|
||||
}).optional(),
|
||||
messages: z.array(conversationMessageSchema)
|
||||
})
|
||||
.strict()
|
||||
@@ -551,7 +572,8 @@ export const conversationSnapshotsSchema = z
|
||||
export const localConversationHeaderSchema = conversationSnapshotSchema
|
||||
.omit({
|
||||
messages: true,
|
||||
remote: true
|
||||
remote: true,
|
||||
activeRequest: true
|
||||
})
|
||||
|
||||
export type LocalConversationHeader = z.infer<
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src/agent-daemon/**/*.ts"],
|
||||
"exclude": ["src/agent-daemon/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user