fix: preserve tool details and prevent repeated history growth

Show bounded operation summaries for Continue, DeepSeek Harness and direct-model tools, retaining input details across incremental updates. Use one canonical workspace path for DeepSeek Host startup and ACP sessions so equivalent Windows paths reuse a working Runtime.

Compare persisted subagent tool fields by value, release terminal task caches, and avoid rebuilding caches for duplicate remote events. Upgrade to schema 35 to recompact repeated schema-34 tool blocks while preserving events and provenance. Include regression coverage, validation records and temporary-resource test exclusions.

Validation: 4155 tests passed, 66 skipped; typecheck and lint passed. Full desktop real-model validation covered all three tool-summary paths and both Windows path spellings. Storage validation evidence includes local and remote tool workloads and real database upgrade checks.

Release note: Tool records now show paths, commands and search details across runtimes. Fix DeepSeek Windows workspace failures and repeated subagent history growth; schema 35 reclaims redundant history data while preserving execution records, and older clients cannot reopen the upgraded database.
This commit is contained in:
mesalogo
2026-09-14 00:34:51 +08:00
parent c71da4b6ae
commit 0d7ba55a50
29 changed files with 667 additions and 97 deletions
+2 -1
View File
@@ -116,7 +116,8 @@ otherwise.
The first upgrade automatically converts existing history and reclaims disk
space, with visible progress and quit/retry support. Chats, execution details,
results, and remote event deduplication are preserved; older clients cannot
reopen the upgraded database. See
reopen the upgraded database. The unreleased schema-35 repair also handles
repeated tool blocks written after upgrading to 0.13.1, without deleting events. See
[storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md).
- [x] **Direct model Runtime**: Supports question answering, knowledge
+1 -1
View File
@@ -32,7 +32,7 @@
### Agent Runtime 与模型连接
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。当前未发布的 schema 35 补修还会整理升级到 0.13.1 后重复写入的工具块,不删除事件。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
+1
View File
@@ -631,6 +631,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 普通工具在主会话和子 Agent 过程中使用同一轻量折叠记录:不显示外层“工具执行”标题、计数、背景和边框,保留无障碍区域名称。记录纵向左对齐,头部按内容收宽且不超过消息宽度;展开箭头在左,工具名作为主标题,有额外信息的单行摘要弱化显示。右侧以绿色对勾显示成功完成,以图标显示运行和待执行状态,提供悬停文字和无障碍名称;失败和可恢复状态保留文字。仅重复工具名和执行状态的已知 Runtime 模板摘要在头部与详情中均隐藏;刷新重试、审批等额外提示保留。默认折叠,状态和输出更新不改变用户的展开选择;折叠时露出一行错误,展开详情使用消息可用宽度、浅背景和左侧细线,按错误、结果、独立折叠的输入参数阅读。详情保留原始文本,完整 JSON 输入可缩进显示,复制仍使用收到的原文,并通过应用通知反馈。详情工具栏只保留复制按钮,文本默认自动换行,长内容在固定最大高度内滚动,不提供换行切换和展开全部按钮。
- OpenCode 主会话和子 Agent 的工具记录均保留 Runtime 提供的操作标题作为摘要,显示文件路径、命令或操作描述;本地 SDK 标题去除首尾空白后最多保留 240 字符,没有标题时沿用工具名回退规则。主会话工具失败或中断时保留已收到的标题;远端 ACP 继续在记录名称中显示其提供的标题。
- 工具取消后保留“已取消”文字,避免与失败图标混淆。
- Continue、DeepSeek Harness 和直连模型的工具名后显示单行操作摘要,内容提取、长度与完成态保留规则见[工具操作摘要](./docs/features/assistant-workbar/runtime-interactions.md#工具操作摘要)。
- Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。
- 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。
- 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。
+8
View File
@@ -37,6 +37,9 @@
覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题
必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。
对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省
的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口;
不能只处理尚未迁移的旧库。
### 真实旧库
@@ -52,6 +55,9 @@
源 schema 与目标 schema。确认原件和备份未被改写。
6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则
检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。
7. 用真实模型模拟正常用户任务,允许主代理与子代理按需调用工具、修改独立测试项目、
运行测试和处理失败;只读纯文字模型调用不能证明工具事件增长正常。记录实际提供商
请求数与统计范围,不把 Runtime 用量上报数当成全部 HTTP 请求数。
## 发布与维护
@@ -65,3 +71,5 @@
表示,并回收空间。具体规则见
[执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md),
数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。
- 当前未发布源码:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。
@@ -19,7 +19,9 @@
- `messages.metadata_json` 继续保存供会话显示的完整子任务过程。
- `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。
`progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。
完全相同的过程使用空更新数组。其他事件内容不改变。
完全相同的过程使用空更新数组。工具字段按值比较,缺省字段与 `undefined` 等价,
不因 JSON 省略空字段而重复写入整个工具结果;空字符串与实际输出/错误变化仍保留。
其他事件内容不改变。
- 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、
最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。
- Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整
@@ -27,6 +29,9 @@
- 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验,
相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID
核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。
- 存储层最多缓存 16 个任务;收到任务完成、错误、取消或中断的终态记录后立即移除该任务
的缓存引用,不影响其他会话。若事务随后回滚或仍收到该任务的后续事件,按已提交记录重建。
释放引用不等于进程 RSS 立即下降,垃圾回收和 Runtime 自身会话缓存另按生命周期回收。
## 旧库升级
@@ -35,19 +40,25 @@
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。
转换可以混合读取旧完整记录与已经转换的记录,因此退出、磁盘写入失败后可以重新扫描
并继续,不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
转换可以混合读取旧完整记录与已经转换的记录,也会重新编码 schema 34 中重复的工具块
更新。因此退出、磁盘写入失败后可以重新扫描并继续;已是相同 JSON 的记录不重复更新,
不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
业务初始化随后将数据库升级为 schema 34,并建立子任务事件的部分索引。
当前源码的业务初始化随后将数据库升级为 schema 35;schema 34 用户也会执行本次整理,
成功后后续启动直接跳过。继续复用 schema 34 的子任务事件部分索引和同一差量表示。
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
升级前备份,不要修改 `user_version` 强行绕过版本检查。
## 验证要求
- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。
- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。
同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。具体证据见
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理
与子代理按正常任务需要读取、修改文件和执行测试,不能用“子代理不使用工具”的纯文字
场景代替工具存储验收;记录实际请求数、有效变化、重复工具块及还原结果。具体证据见
[实现与验证进度](./progress.md)。
+91 -1
View File
@@ -1,5 +1,94 @@
# 工作栏实现与验证进度
## 2026-09-14 多 Runtime 工具操作摘要
显示与事件合并规则见[工具操作摘要](./runtime-interactions.md#工具操作摘要)。Continue、
DeepSeek Harness 和直连模型已接入共享摘要函数,完成事件缺少输入时保留已有摘要。
Windows 完整 App 使用当前生产 Main、Preload、Renderer 和隔离配置,模型为
`deepseek-v4-flash`。从真实输入区输入并点击发送读取测试文件的请求,校验码只存于
文件中。三条成功会话都返回正确校验码,工具完成后仍显示路径;原生点击展开可查看
输入与输出,离开后重新打开会话、重新加载 Renderer 后再次打开均保留摘要和详情。
六张对话区截图已实际打开审阅。
| Runtime | 成功模式 | 提交数(含失败重跑) | 模型 HTTP dispatch | 工具执行 |
| --- | --- | ---: | ---: | ---: |
| 直连模型 | Ask | 1 | 3 | 3 |
| Continue | Execute | 2 | 4 | 3 |
| DeepSeek Harness | Execute | 3 | 3 | 2 |
| 合计 | | 6 | 10 | 8 |
计数来自 Main 和真实 Utility Host 的 HTTP dispatch,未注入答案或工具事件。Continue
首轮 Ask 仅提供配置工具,未能读取文件;改用 Execute 成功。DS 前两次因 Windows
工作区路径写法与 Host `realpath` 比较不一致而返回 ACP `-32602`,均未调用模型;
同目录改写路径仍复用缓存 Runtime,改用全新规范路径目录后成功。该路径问题随后由
[DS 工作区路径修复](../deepseek-harness/progress.md#2026-09-14-windows-工作区路径修复)
单独处理,新增验证计数见该记录,不计入上表。驱动的包解析、
DOM 定位及卡片比较器失败已记录,修正比较器后的复验没有追加模型请求。
本机临时 `runtime-summary-live/report.json` 保留逐次结果和产物哈希;源码基线为
`c71da4b6ae1d7228ff968d39c63db205caf61339` 加本次工作树改动,Main SHA-256 为
`2153d72a947ab6f5eab22eab28d7a3f2f86a47740d5223a3e1547f199fa7de84`。
测试进程、隔离配置、数据库、工作区和构建均已清理,正式配置字节未改变;报告和截图
保留在本机临时目录,不作为长期可用的仓库资源。请求使用已配置 HTTPS,沿用生产
证书策略,本轮未验证严格证书校验。未测试 Main 重启或远程 Agent。
专项回归 210 项通过、1 项跳过。完整 `npm test` 首轮 4154 项通过、66 项跳过,另有
6 个 `.agent-resources` 内的本地 `node:test` 文件被 Vitest 误收集导致套件失败。
使用 `npm test -- --exclude "**/.agent-resources/**"` 完整重跑后,353 个文件通过、
9 个跳过,4154 项通过、66 项跳过、0 失败,耗时 646.04 秒。`npm run typecheck`、
`npm run lint` 和 `git diff --check` 通过。
## 2026-09-13–14 schema 35 工具重复写入补修(未发布)
- 0.13.1 的历史库回收成功,但其真实模型验收限制子代理不使用工具,遗漏了生产工具对象
的 `undefined` 可选字段。JSON 重读省略这些键,原深比较误判未变化的工具块。正常开发
任务复现同批 928 条事件的 7,787 次重复工具块写入;仅归一化空字段的对照将负载从
10,467,622 降为 452,391 字节,逐条还原一致。该对照不是产品修复验收。
- 当前修复按共享工具 schema 的字段值比较,不序列化整份工具输出进行比较。空字符串、
输出修订、状态和错误变化仍保留。schema 35 使用同一编码器重整旧完整事件及 schema 34
已存差量,跳过 JSON 未变化的行;不会删除事件、会话、工具结果或来源字段。
- 存储缓存仍最多 16 个任务,终态及时移除缓存引用;回滚后按已提交记录重建,其他任务
不受影响。远程先按来源主键判断是否重放,重复事件不重建已经释放的写入缓存;
`INSERT OR IGNORE`、冲突校验及消息投影事务仍保留。
- Windows 当前源码加便携版内置 OpenCode 完成正常 CSV 项目开发,主/子代理实际读取、
改代码、补测试和执行命令;独立 59 项测试及 CLI 通过。698 条子代理事件共
368,575 字节,重复工具块为 0,与归一化对照字节数完全相同,每条事件还原一致。
- 多项目实测使用两个独立 OpenCode 实例、每项目两个会话,两轮共 8 次 Execute。
第一轮四个开发任务全部完成;第二轮复用会话,主动取消一个正在使用工具的子代理任务,
其余三个完成并通过测试。2,841 条子代理事件共 1,305,340 字节,重复工具块为 0,
全部逐条还原一致。每轮结束后缓存任务数、打开的 SSE 响应流均为 0。
隔离 Main 测试进程在显式 GC 后的堆占用为 12.94 → 12.91 MiB;
这证明本轮未持续积累,不是整个 Electron UI/Runtime 的 RSS 上限或无限负载保证。
- 共享 Linux x64 Host 使用当前桌面源码经真实 SSH、Agent、ACP 和模型 gateway 完成
Ask 与正常工具 Execute。912 条子代理事件共 405,360 字节,重复工具块为 0,
逐条还原及重复重放通过,远程 Python 项目测试通过。修复位于共享桌面层,不修改 Agent
或公开事件协议。两次早期测试驱动错误中断操作,修正瞬态事件处理后才完成上述验收;
不能将早期失败写成产品通过。
- 本轮实际提供商请求为 121 次:本地开发 26,多会话 79,远程账本 16(成功验收 14,
早期驱动中断 2 次结果未知、未交付)。本地计数器只记录 HTTP 状态,远程按测试
request/operation 的 Agent ledger 统计,不记录凭据、正文或请求头。另有先前诊断的
24 次请求,不计入这 121 次。已核对本轮本机和远程测试工作区无残留 Runtime 进程。
- 活动 schema 34 库通过 SQLite online backup 取得一致性备份,并校验独立测试副本。
正常启动升级成功,2,898,984,960 → 607,055,872 字节(2.70 GiB → 578.93 MiB,
回收 79.06%)。完整比较 424,148 条原事件的内容、ID、时间及来源一致。
隔离启动未复制模型配置,既有启动修复调整一条项目/会话 Runtime 选择及一个在途任务、
消息和活动状态,另追加一个状态事件;不是迁移删除或改写历史内容。
- 同一真实备份的另一个副本直接运行实际构建的升级 Worker,在处理 64 条后取消,
重启后完成 316,664 条子代理记录;全量事件再次还原一致。全部非事件表逐表内容一致,
包括 150 个会话、1,915 条消息、914 个任务和 136 个成果;`integrity_check=ok`。
原运行库和校验备份没有被测试改写。schema 35 副本再次进入实际 Worker 时直接完成,
进度事件数为 0,不重复整理。
- 最终专项 99 项、typecheck、lint 及生产开发构建通过。首轮全量产品用例 4,154 通过、
66 跳过,但 6 个临时 Node 测试项目被 Vitest 错误发现;配置已排除 `.agent-resources/`,
独立项目仍单独运行自己的 Node 测试。随后显式双 worker 全量为 4,148 通过、66 跳过、
`App.test.tsx` 6 项时序失败;恢复仓库默认单 worker 后该文件 220 项全部通过,
未修改 UI 代码。最终默认配置全量为 4,151 通过、66 跳过、3 个问答 UI 测试失败,
失败项与双 worker 不同;本轮全量回归尚未全绿。随后仅调整测试等待和 mock 隔离的
尝试仍有不稳定失败,已完整撤回这些额外 UI 测试改动,不把单次重跑通过当作稳定修复。
存储专项、真实工具任务、并发内存和真实数据迁移证据均通过;UI 测试问题仍需单独处理,
本次未提交、未发布,也未替换用户正在运行的 0.13.1。
## 2026-09-13 Desktop 0.13.1 执行记录去重与大库升级
- 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发
@@ -31,7 +120,8 @@
实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求
不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。
- 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。
截至本记录尚未提交或打标签,准确候选提交、main CI 和原生发布结果待发布流程确认。
已发布提交 `4937720` 和不可变标签 `v0.13.1`,main CI 与六平台发布通过;
后续真实工具场景的遗漏及补修见本页 schema 35 记录。
## 2026-09-13 原生浏览器与应用浮层修复
@@ -57,6 +57,21 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
- 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义,
模型桥生成最终配置时同样保留上述快照策略。
## 工具操作摘要
工具记录沿用 [UI 设计系统](../../../UI-DESIGN.md)的折叠布局。Continue、DeepSeek Harness
和直连模型共用 `toolOperationSummary`:优先使用有效原生标题,否则从参数提取命令、
文件路径、搜索条件或 URL,合并空白后最多显示 240 字符。无法识别时保留原有工具名
回退规则;文件正文、整份参数 JSON 不进入标题,参数详情仍可展开查看。
Continue 在宿主截断输入前提取摘要,流式完成与失败事件使用合并后的输入和摘要。
DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省略输入时仍能显示操作内容。
直连模型从原始参数生成一次摘要,在待执行、执行中和完成时复用;审批失败、拒绝和
执行失败、刷新重试仍保留已有提示。
本机 OpenCode 沿用原生操作标题。远端 ACP 独立使用原生标题作为记录名称,不经过上述
本地转换,本次没有修改 Agent、远程桥或协议。
## 消息底部请求状态
- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示
+1
View File
@@ -8,5 +8,6 @@
| 文档 | 权威职责 |
| --- | --- |
| [技术设计](./technical-design.md) | Runtime 产品与技术方案、协议、插件、打包、测试和验收 |
| [实现进度](./progress.md) | Windows 工作区路径修复及验证记录 |
现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。
@@ -0,0 +1,45 @@
# DeepSeek Harness 实现进度
## 2026-09-14 Windows 工作区路径修复
本机 Runtime 的 Host 启动与 `newSession.cwd` 已共用初始化时解析的 `realpath`,
规则见[技术设计](./technical-design.md#52-受控组合不启动用户-profile)。修复此前
`C:/...\workspace` 写法触发 ACP `-32602`,以及随后换成同目录规范写法仍复用
错误 Runtime 的问题。Host 严格比较与 execution-space 缓存规则保持原样。
新增 Windows 真目录回归,连接真实 ACP、Host 和控制面,分别用混合分隔符及规范
路径创建会话,断言共用同一 Runtime、只启动一次 Host、两次均完成。该自动化回归
使用内存模型;外部真实模型验证单独由完整 App 完成。
Windows 完整 App 使用当前源码构建的 Main、Preload、Renderer 和新建隔离配置。
正式配置只读,模型凭据由隔离配置中的 safeStorage 解密;真实 composer 输入请求,
由 `deepseek-v4-flash` 在 Ask 模式读取 `summary-probe.txt`,校验码未放入 prompt。
| 目录写法 | Composer 提交 | 真实模型 HTTP dispatch | 文件读取 | 结果 |
| --- | ---: | ---: | ---: | --- |
| Windows 混合分隔符 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
| 同目录规范路径 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
| 合计 | 2 | 4 | 2 | 两条会话均通过 |
两轮 dispatch 来自同一 DS Utility Host 进程,切换目录写法未新增 Host;ACP 会话
ID 不同。两条工具卡片均显示路径摘要,原生点击展开后可查看输入与输出;重载
Renderer 并重新选择项目后,已保存会话的摘要、详情和完成状态仍通过检查。四张
局部截图已逐张打开审阅。驱动曾返回不可克隆的监听器函数,以及在重载后默认项目
等待工具卡片超时;修正定位后通过,这两次驱动错误没有增加模型请求。
本机临时 `ds-windows-path-live/report.json`、`dispatch.jsonl` 与 `source.json`
记录结果、逐请求计数和构建哈希,原 `runtime-summary-live` 报告未覆盖。
本次 App 正常退出,退出前记录的 5 个所属进程均已停止;隔离 profile、数据库、
工作区、临时构建和私有日志已清理。正式模型配置与启动前逐字节一致,报告和截图
保留在本机临时目录,不作为长期可用的仓库资源。
测试沿用生产 HTTPS 与证书处理,未单独验证严格证书校验或 Main 重启。
专项 7 个测试文件通过,91 项通过、3 项跳过。`npm run typecheck` 与
`npm run lint` 通过。最初两个假时钟测试因异步目录解析尚未结束就推进启动时钟而
超时;调整为等待 launcher 启动后,DS Runtime 的 34 项测试全部通过。
最终全量 `vitest run`(`npm test` 的脚本入口)为 353 个文件通过、9 个跳过,
4155 项通过、66 项跳过、0 失败,耗时 602.00 秒。`git diff --check` 通过。
远端核查:Main 的 SSH 分支创建托管远程 Runtime,最终使用 `AcpRemoteRuntime`;
`create-runtime.ts` 的本机 DS 入口拒绝 SSH execution space。本次未改远端代码,
未调用远端模型,也未进行 Linux Host 实测。
@@ -121,6 +121,12 @@ 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` /
`AcpRemoteRuntime` 单独处理,不经过本机 DS Runtime 的目录解析。
### 5.3 双层内部控制面
Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层:
@@ -307,6 +313,7 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
- `text`:已提交的助手文本分片。
- `reasoning`:可选的有界推理摘要分片。
- `tool`:工具 ID、名称、状态和有界输入/输出摘要。
- 操作标题与增量完成事件的参数保留遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),本机适配器按调用 ID 合并,详情上限保持不变。
- `model-usage`:模型、提供方、输入、输出和缓存 Token。
- `done`:停止原因和 Session ID。
@@ -53,6 +53,8 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
### 3.1 标题与摘要
正常执行状态的操作摘要遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),从原始调用参数提取并在完成后保留;异常状态继续显示原有提示。
```text
进程执行
PowerShell · packages/app · npm test
@@ -44,7 +44,8 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
桌面保存托管 SSH 子任务事件时,共用
[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。
Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号
继续保留。该存储修复不要求单独升级 Agent。
继续保留。工具字段比较与 schema 35 对 schema 34 重复块的整理也在该共享桌面层完成;
不把工具过程改为仅存内存,不要求单独升级 Agent。
```text
Renderer
+6 -1
View File
@@ -84,10 +84,15 @@ Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
转换后回收空间,不靠删聊天或单独修本机数据库解决。
**防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、
消息事务回滚及旧库中断续跑。真实旧库需验证逐条等价和物理文件缩小。
消息事务回滚及旧库中断续跑。工具对象须包含生产路径中的 `undefined` 可选字段,
经过真实 JSON 往返后仍识别为无变化,不能按对象键存在性比较。真实模型验证须包含
子代理正常使用工具、修改文件及运行测试,不能只测禁用工具的文本流。
真实旧库需验证逐条等价和物理文件缩小,已升级 schema 的重复数据同样要覆盖。
**历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB;
本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。
0.13.1 发布后的真实工具任务再次复现:同批 928 条事件有 7,787 次重复工具块写入;
只统一 JSON 空字段后,事件负载从 10,467,622 降为 452,391 字节,逐条还原一致。
## 生产路径复用“测试”函数
+11 -7
View File
@@ -1603,7 +1603,7 @@ describe('ContinueHostAdapter', () => {
expect(existsSync(globalDirectory)).toBe(false)
})
it('uses auto mode and returns audit metadata for agent tools', async () => {
it.each(['completed', 'failed'] as const)('uses auto mode and retains streamed audit metadata on %s', async (toolState) => {
const distribution = await createDistribution()
let launchArgs: string[] = []
const permissionBodies: unknown[] = []
@@ -1681,7 +1681,7 @@ describe('ContinueHostAdapter', () => {
toolCall: {
function: { name: 'Bash' }
},
status: 'done'
status: toolState
}
]
}
@@ -1695,7 +1695,7 @@ describe('ContinueHostAdapter', () => {
type: 'tool',
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
output:
'Tests passed\nAuthorization: Bearer secret-token'
},
@@ -1737,9 +1737,10 @@ describe('ContinueHostAdapter', () => {
{
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
input:
'{"command":"npm test","token":"secret-token"}',
summary: 'npm test',
output:
'Tests passed\nAuthorization: Bearer secret-token'
}
@@ -1754,7 +1755,8 @@ describe('ContinueHostAdapter', () => {
name: 'Bash',
state: 'running',
input:
'{"command":"npm test","token":"secret-token"}'
'{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
}
},
{
@@ -1762,9 +1764,11 @@ describe('ContinueHostAdapter', () => {
tool: {
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
output:
'Tests passed\nAuthorization: Bearer secret-token'
'Tests passed\nAuthorization: Bearer secret-token',
input: '{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
}
},
{ type: 'text', delta: 'TOOLS_OK' }
+18 -1
View File
@@ -1,4 +1,5 @@
import spawn from 'cross-spawn'
import { toolOperationSummary } from './tool-operation-summary'
import { createHash, randomBytes } from 'node:crypto'
import {
copyFile,
@@ -112,6 +113,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']),
input: z.string().max(4_000).optional(),
summary: z.string().max(240).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(1_000).optional()
})
@@ -181,6 +183,7 @@ export type ContinueHostTool = {
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
input?: string
summary?: string
output?: string
error?: string
}
@@ -729,6 +732,10 @@ function extractContinueTools(
callId,
name: name.trim().slice(0, 200),
state: normalizedState,
summary: toolOperationSummary(
(toolFunction as Record<string, unknown>).arguments,
state.title
),
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {})
@@ -749,6 +756,7 @@ function mergeContinueTools(
...previous,
...tool,
input: tool.input ?? previous?.input,
summary: tool.summary ?? previous?.summary,
output: tool.output ?? previous?.output,
error: tool.error ?? previous?.error
})
@@ -976,6 +984,11 @@ export class ContinueHostAdapter {
serverStateMarker,
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
)
patched = replaceExactly(
patched,
'state:"running",input:(()=>',
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
)
patched = replaceExactly(
patched,
serverStateEndpointMarker,
@@ -1682,6 +1695,7 @@ export class ContinueHostAdapter {
callId: event.callId,
name: event.name,
state: event.state,
summary: toolOperationSummary(event.input, event.summary),
...(event.input
? { input: boundedToolDetail(event.input, 4_000) }
: {}),
@@ -1693,7 +1707,10 @@ export class ContinueHostAdapter {
: {})
}
observedTools = mergeContinueTools(observedTools, [tool])
await runOptions.onEvent?.({ type: 'tool', tool })
await runOptions.onEvent?.({
type: 'tool',
tool: observedTools.find((item) => item.callId === tool.callId)!
})
}
const pendingQuestion = state.goodbuddyQuestion
if (
+1 -1
View File
@@ -1005,7 +1005,7 @@ describe('ContinueAgentRuntime', () => {
type: 'tool',
name: 'Bash',
state: 'completed',
summary: 'Continue 工具:Bash',
summary: 'npm test',
input: '{"command":"npm test"}',
output: 'Tests passed'
}),
+2 -1
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import type {
AgentQuestionAnswer,
AgentEvent,
@@ -88,7 +89,7 @@ function toContinueToolEvent(
: terminalize && tool.state !== 'completed'
? 'failed'
: tool.state,
summary: `Continue 工具:${tool.name}`,
summary: toolOperationSummary(tool.input, tool.summary) ?? `Continue 工具:${tool.name}`,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {})
@@ -35,6 +35,8 @@ import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
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'
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
const SKILL_CALL_ID = 'e2e-skill-call'
@@ -383,6 +385,49 @@ function createInProcessLaunch(
}
describe('DeepSeek Harness real ACP control-plane E2E', () => {
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')
const dshHome = join(root, 'home')
await Promise.all([mkdir(workspace), mkdir(dshHome)])
const canonical = await realpath(workspace)
const mixed = `${root.replaceAll('\\', '/')}\\workspace`
const resolver = new ExecutionSpaceResolver()
const inProcess = createInProcessLaunch(dshHome, {
stream: () => textResponse('PATH_OK')
})
const launch = vi.fn(inProcess.launch)
const manager = new SelectedRuntimeManager(async (_selection, space) => {
if (space?.kind !== 'local') throw new Error('local workspace required')
return new DeepSeekHarnessRuntime({
defaultWorkspace: space.rootPath, baseUrl: 'https://api.deepseek.com',
model: 'path-test', launch,
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
})
})
try {
expect(mixed).not.toBe(canonical)
expect(await realpath(mixed)).toBe(canonical)
const first = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(mixed))
for (const [index, spelling] of [mixed, canonical].entries()) {
const runtime = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(spelling))
expect(runtime).toBe(first)
const events = await collect(runtime.run({
requestId: `path-${index}`, conversationId: `path-${index}`,
workMode: 'ask', prompt: 'Return PATH_OK.'
}, AbortSignal.timeout(20_000)))
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(events.filter(event => event.type === 'text').map(event => event.delta).join('')).toBe('PATH_OK')
}
expect(launch).toHaveBeenCalledOnce()
expect(launch.mock.calls[0]?.[0].cwd).toBe(canonical)
} finally {
await manager.dispose()
await rm(root, { recursive: true, force: true })
}
})
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')
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import { realpath } from 'node:fs/promises'
import type { RuntimeEvent } from './runtime'
import {
ModelToolProvider,
@@ -243,7 +244,7 @@ function setup(
})
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: 'C:\\workspace',
defaultWorkspace: process.cwd(),
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
supportsImageInput: options.supportsImageInput,
@@ -474,6 +475,7 @@ describe('DeepSeekHarnessRuntime', () => {
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(10_001)
await expect(status).resolves.toMatchObject({
@@ -503,6 +505,7 @@ describe('DeepSeekHarnessRuntime', () => {
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(12_000)
await expect(status).resolves.toMatchObject({
@@ -674,7 +677,7 @@ describe('DeepSeekHarnessRuntime', () => {
harness.child.stdout
)
expect(harness.launch).toHaveBeenCalledWith({
cwd: 'C:\\workspace',
cwd: await realpath(process.cwd()),
signal: expect.any(AbortSignal),
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
@@ -710,7 +713,12 @@ describe('DeepSeekHarnessRuntime', () => {
await harness.runtime.dispose()
})
it('keeps the original tool name on generic completion updates', async () => {
it.each([
['completed', undefined, 'GoodBuddy'],
['failed', undefined, 'GoodBuddy'],
['completed', 'Search the release notes', 'Search the release notes'],
['failed', 'Search the release notes', 'Search the release notes']
] as const)('keeps tool input and summary on incremental %s updates with title %s', async (status, title, summary) => {
const harness = setup()
const running = collect(
harness.runtime.run(
@@ -725,6 +733,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call',
toolCallId: 'call-web-search',
name: 'web_search',
title,
status: 'pending',
rawInput: { query: 'GoodBuddy' }
})
@@ -732,7 +741,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-web-search',
name: 'tool',
status: 'completed',
status,
rawOutput: 'search result'
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
@@ -743,13 +752,16 @@ describe('DeepSeekHarnessRuntime', () => {
type: 'tool',
callId: 'call-web-search',
name: 'web_search',
state: 'pending'
state: 'pending',
summary
}),
expect.objectContaining({
type: 'tool',
callId: 'call-web-search',
name: 'web_search',
state: 'completed'
state: status,
summary,
input: expect.stringContaining('GoodBuddy')
})
])
)
+24 -9
View File
@@ -1,3 +1,5 @@
import { toolOperationSummary } from './tool-operation-summary'
import { realpath } from 'node:fs/promises'
import type {
AgentRuntimeStatus,
ModelRequestHeaders,
@@ -210,12 +212,16 @@ type ActiveRun = {
toolController: AbortController
authorize?: RuntimeAuthorizer
updates: AcpSessionNotification['update'][]
toolNames: Map<string, string>
toolNames: Map<
string,
{ name: string; input?: string; summary?: string; title?: string }
>
wake?: () => void
closed: boolean
}
type HarnessState = {
workspace: string
child: DeepSeekHarnessChild
connection: AcpConnection
agent: AcpAgent
@@ -778,9 +784,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.launchController = launchController
let child: DeepSeekHarnessChild | undefined
try {
const workspace = await realpath(this.options.defaultWorkspace)
child = await withTimeout(
this.options.launch({
cwd: this.options.defaultWorkspace,
cwd: workspace,
signal: launchController.signal,
baseUrl: this.options.baseUrl,
model: this.options.model,
@@ -977,6 +984,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
throw new Error('DeepSeek Harness ACP 客户端初始化失败')
}
const stateWithoutCapabilities = {
workspace,
child,
connection,
agent
@@ -1306,7 +1314,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
const creation = state.agent
.newSession({
cwd: this.options.defaultWorkspace,
cwd: state.workspace,
mcpServers: []
})
.then((response) => {
@@ -1336,7 +1344,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
private toRuntimeEvent(
requestId: string,
update: AcpSessionNotification['update'],
toolNames: Map<string, string>
toolNames: ActiveRun['toolNames']
): RuntimeEvent | undefined {
if (update.goodBuddyEvent) {
return this.toUsageEvent(
@@ -1378,16 +1386,23 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
'DeepSeek Harness 工具'
).slice(0, 200)
const callId = update.toolCallId.slice(0, 256)
const previous = toolNames.get(callId)
const name =
reportedName === 'tool'
? toolNames.get(callId) ?? reportedName
reportedName === 'tool' || (!update.name && !update.title)
? previous?.name ?? reportedName
: reportedName
const title = toolOperationSummary(
undefined,
update.title === 'tool' || update.title === update.name || update.title === previous?.name
? undefined : update.title
) ?? previous?.title
const input = safeStringify(update.rawInput) ?? previous?.input
const summary = toolOperationSummary(update.rawInput, title) ?? previous?.summary
if (state === 'pending' || state === 'running') {
toolNames.set(callId, name)
toolNames.set(callId, { name, input, summary, title })
} else {
toolNames.delete(callId)
}
const input = safeStringify(update.rawInput)
const output = safeStringify(update.rawOutput)
return {
requestId,
@@ -1395,7 +1410,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
callId,
name,
state,
summary: `DeepSeek Harness 工具:${name}`,
summary: summary ?? `DeepSeek Harness 工具:${name}`,
...(input ? { input } : {}),
...(output ? { output } : {})
}
+10 -5
View File
@@ -2473,7 +2473,10 @@ describe('ModelAgentRuntime', () => {
).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u)
})
it('runs approved direct-model tools and returns their results to OpenAI', async () => {
it.each([false, true])('runs approved tools with summaries from raw arguments (long input: %s)', async (longInput) => {
const args = longInput
? { content: 'x'.repeat(5_000), path: 'README.md' }
: { path: 'README.md' }
const responses = [
{
model: 'qwen3',
@@ -2488,7 +2491,7 @@ describe('ModelAgentRuntime', () => {
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
arguments: JSON.stringify(args)
}
}
]
@@ -2596,7 +2599,7 @@ describe('ModelAgentRuntime', () => {
)
expect(toolProvider.getApproval).toHaveBeenCalledWith(
expect.objectContaining({ name: 'workspace_read_text' }),
{ path: 'README.md' },
args,
expect.any(String),
expect.objectContaining({
conversationId: 'conversation-tools',
@@ -2605,7 +2608,7 @@ describe('ModelAgentRuntime', () => {
)
expect(toolProvider.callTool).toHaveBeenCalledWith(
'workspace_read_text',
{ path: 'README.md' },
args,
expect.any(AbortSignal),
expect.objectContaining({
conversationId: 'conversation-tools',
@@ -2617,11 +2620,13 @@ describe('ModelAgentRuntime', () => {
.filter((event) => event.type === 'tool')
.map((event) => event.state)
).toEqual(['pending', 'running', 'completed'])
expect(events.filter((event) => event.type === 'tool').map((event) => event.summary))
.toEqual(['README.md', 'README.md', 'README.md'])
expect(events).toContainEqual(
expect.objectContaining({
type: 'tool',
state: 'completed',
input: '{\n "path": "README.md"\n}',
input: longInput ? expect.any(String) : '{\n "path": "README.md"\n}',
output:
'tool result\n\n[图片结果 1:image/png]'
})
+5 -3
View File
@@ -1,4 +1,5 @@
import { createHash, randomBytes } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import { dirname } from 'node:path'
import type {
ApprovalDecision,
@@ -3752,6 +3753,7 @@ export class ModelAgentRuntime implements AgentRuntime {
tool?.name !== 'subagent_delegate'
const displayName = tool?.displayName ?? call.name.slice(0, 128)
const input = boundedToolDetail(call.arguments, 4_000)
const summary = toolOperationSummary(call.arguments) ?? `直连模型工具:${displayName}`
roundSummary.push(
[
`TOOL_CALL: ${displayName}`,
@@ -3767,7 +3769,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'pending',
summary: `直连模型工具:${displayName}`,
summary,
input
}
}
@@ -3844,7 +3846,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'running',
summary: `正在执行直连模型工具:${displayName}`,
summary,
input
}
}
@@ -3925,7 +3927,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'completed',
summary: `直连模型工具已完成:${displayName}`,
summary,
input,
output: getToolResultPreview(result.parts)
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { toolOperationSummary } from './tool-operation-summary'
describe('toolOperationSummary', () => {
it.each(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])(
'extracts %s without displaying file contents', (key) => {
expect(toolOperationSummary({ content: 'x'.repeat(10_000), [key]: 'src/main.ts' }))
.toBe('src/main.ts')
}
)
it.each([
[{ command: 'npm test\r\n-- --run', path: '/repo' }, 'npm test -- --run'],
[{ pattern: 'TODO', path: 'src', filePattern: '*.ts' }, 'TODO | src | *.ts'],
[{ query: 'latest release' }, 'latest release'],
[{ glob: '**/*.ts' }, '**/*.ts'],
[{ url: 'https://example.com' }, 'https://example.com']
])('extracts command, search and URL parameters', (args, expected) => {
expect(toolOperationSummary(JSON.stringify(args))).toBe(expected)
})
it.each([undefined, null, [], 12, 'broken JSON', '{"path":', '"text"', { content: 'body' }, { path: {}, command: 42 }])(
'falls back for invalid or irrelevant input %j', (input) => {
expect(toolOperationSummary(input)).toBeUndefined()
}
)
it('prefers a nonempty native title and bounds a single line to 240 characters', () => {
expect(toolOperationSummary({ path: 'file' }, 'Read\n native\tfile')).toBe('Read native file')
expect(toolOperationSummary({ path: 'file' }, ' \n ')).toBe('file')
expect(toolOperationSummary({ command: 'x'.repeat(500) })).toHaveLength(240)
expect(toolOperationSummary({}, 'x'.repeat(500))).toHaveLength(240)
})
it('can run in the Continue host bundle before input preview truncation', () => {
const summarize = new Function(`return (${toolOperationSummary.toString()})`)() as typeof toolOperationSummary
expect(summarize({ content: 'x'.repeat(10_000), filepath: 'report.md' })).toBe('report.md')
})
})
+34
View File
@@ -0,0 +1,34 @@
// Kept self-contained so the Continue host can use it before truncating input.
export function toolOperationSummary(
input: unknown,
title?: unknown
): string | undefined {
const singleLine = (value: unknown): string =>
typeof value === 'string'
? [...value].map((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127 ? ' ' : character
}).join('').replace(/\s+/gu, ' ').trim()
: ''
const nativeTitle = singleLine(title)
if (nativeTitle) return nativeTitle.slice(0, 240)
if (typeof input === 'string') {
try {
input = JSON.parse(input)
} catch {
return undefined
}
}
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined
const args = input as Record<string, unknown>
const first = (keys: string[]): string =>
keys.map((key) => singleLine(args[key])).find(Boolean) ?? ''
const command = first(['command'])
const path = first(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])
const search = first(['query', 'pattern', 'glob'])
const filter = first(['filePattern', 'include'])
const summary = command || (search
? [search, path, filter].filter(Boolean).join(' | ')
: path || first(['url']))
return summary ? summary.slice(0, 240) : undefined
}
+44 -30
View File
@@ -95,9 +95,12 @@ import {
setMagicNoteChecklistCompletion
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
import { SubagentProgressStorage } from './subagent-progress-storage'
import {
SubagentProgressStorage,
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34
export const ASSISTANT_DATABASE_SCHEMA_VERSION = SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
export type RemoteTaskEventInput = {
taskId: string
@@ -5344,34 +5347,9 @@ export class AssistantDatabase {
database: DatabaseSync,
event: ValidatedRemoteTaskEvent
): boolean {
const payloadJson = this.subagentProgress!.serialize(
event.taskId, event.kind, JSON.parse(event.payloadJson)
)
const result = database
.prepare(
`INSERT OR IGNORE INTO task_events
(task_id, run_id, kind, payload_json, created_at,
remote_binding_id, remote_operation_id,
remote_semantic_sequence, remote_event_index)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
event.taskId,
event.kind,
payloadJson,
new Date().toISOString(),
event.bindingId,
event.operationId,
event.semanticSequence,
event.eventIndex
)
if (result.changes === 1) {
this.subagentProgress!.inserted(
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
)
return true
}
const existing = database
const findExisting = (): {
id: number; task_id: string; kind: string; payload_json: string
} | undefined => database
.prepare(
`SELECT id, task_id, kind, payload_json
FROM task_events
@@ -5393,6 +5371,38 @@ export class AssistantDatabase {
payload_json: string
}
| undefined
// Duplicate replay must not recreate a completed task's released write cache.
let existing = findExisting()
if (!existing) {
const payloadJson = this.subagentProgress!.serialize(
event.taskId, event.kind, JSON.parse(event.payloadJson)
)
const result = database
.prepare(
`INSERT OR IGNORE INTO task_events
(task_id, run_id, kind, payload_json, created_at,
remote_binding_id, remote_operation_id,
remote_semantic_sequence, remote_event_index)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
event.taskId,
event.kind,
payloadJson,
new Date().toISOString(),
event.bindingId,
event.operationId,
event.semanticSequence,
event.eventIndex
)
if (result.changes === 1) {
this.subagentProgress!.inserted(
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
)
return true
}
existing = findExisting()
}
if (
existing?.task_id === event.taskId &&
existing.kind === event.kind &&
@@ -9681,6 +9691,10 @@ export class AssistantDatabase {
COMMIT;
`)
}
if (version.user_version < SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION) {
// Startup's worker has already compacted full and schema-34 delta events.
database.exec(`PRAGMA user_version = ${SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION}`)
}
}
private requireDatabase(): DatabaseSync {
@@ -7,11 +7,12 @@ import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { SubagentEvent } from '../../shared/contracts'
import { AssistantDatabase } from './assistant-database'
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
import {
compactSubagentPayload,
restoreSubagentPayload
restoreSubagentPayload,
SubagentProgressStorage
} from './subagent-progress-storage'
const directories: string[] = []
@@ -78,7 +79,10 @@ describe('subagent progress storage', () => {
const event = subagent(fixtureData.taskId)
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) }
tool: {
name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'x'.repeat(200_000), error: undefined
}
}
const textId = randomUUID()
try {
@@ -102,23 +106,116 @@ describe('subagent progress storage', () => {
const state = new Map<string, ConversationMessageBlock[]>()
let restored: unknown
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
expect(restored).toEqual(event)
expect(restored).toStrictEqual(JSON.parse(JSON.stringify(event)))
} finally { inspection.close() }
} finally { database.close() }
})
it('ignores omitted optional tool fields but preserves every actual field change', () => {
const event = subagent(randomUUID())
const block: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
callId: 'read-1', name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'unchanged', error: undefined
}
}
const state = new Map<string, ConversationMessageBlock[]>([
[event.childTaskId, JSON.parse(JSON.stringify([block]))]
])
expect(compactSubagentPayload({ ...event, progress: [block] }, state))
.toMatchObject({ progressUpdates: [] })
for (const change of [
{ callId: 'read-2' }, { name: 'write' }, { summary: 'Updated' },
{ state: 'failed' as const }, { input: '' }, { output: 'changed' },
{ error: 'failed' }, { output: undefined }
]) {
const updated = { ...block, tool: { ...block.tool, ...change } }
const compact = compactSubagentPayload({ ...event, progress: [updated] }, state)
expect(compact).toMatchObject({
progressUpdates: [{ type: 'upsert', block: updated }]
})
}
})
it('releases terminal task caches without clearing peers, and rebuilds after rollback', () => {
const database = new DatabaseSync(':memory:')
database.exec(`CREATE TABLE task_events(
id INTEGER PRIMARY KEY, task_id TEXT, kind TEXT, payload_json TEXT
)`)
const storage = new SubagentProgressStorage(database)
const caches = (storage as unknown as { tasks: Map<string, unknown> }).tasks
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
name: 'read', state: 'completed', summary: 'Read',
output: 'x'.repeat(200_000), error: undefined
}
}
let id = 0
const append = (taskId: string, kind: string, event: unknown) => {
const payload = storage.serialize(taskId, kind, event)
database.prepare('INSERT INTO task_events VALUES (?, ?, ?, ?)')
.run(++id, taskId, kind, payload)
storage.inserted(taskId, kind, id, payload)
return JSON.parse(payload) as { progressUpdates: unknown[] }
}
try {
const peer = subagent(randomUUID())
append(peer.requestId, 'subagent', { ...peer, progress: [tool] })
for (const [kind, payload] of [
['done', {}], ['error', {}],
...['completed', 'failed', 'cancelled', 'interrupted']
.map((status) => ['status', { status }])
] as Array<[string, unknown]>) {
const event = { ...subagent(randomUUID()), progress: [tool] }
append(event.requestId, 'subagent', event)
expect(caches.size).toBe(2)
database.exec('BEGIN')
append(event.requestId, kind, payload)
expect(caches.has(event.requestId)).toBe(false)
expect(caches.has(peer.requestId)).toBe(true)
database.exec('ROLLBACK')
expect(append(event.requestId, 'subagent', event).progressUpdates).toEqual([])
append(event.requestId, kind, payload)
expect(caches.size).toBe(1)
}
for (let index = 0; index < 40; index++) {
const event = subagent(randomUUID())
append(event.requestId, 'subagent', { ...event, progress: [tool] })
expect(caches.size).toBeLessThanOrEqual(16)
}
} finally { database.close() }
})
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
const { database, taskId, path } = await fixture()
const event = subagent(taskId)
const textId = randomUUID()
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'x'.repeat(20_000), error: undefined
}
}
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] }
const first = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed' }] }
const second = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed tail' }] }
try {
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
database.appendTaskEvent(taskId, 'done', {})
const caches = (database as unknown as {
subagentProgress: { tasks: Map<string, unknown> }
}).subagentProgress.tasks
expect(caches.size).toBe(0)
expect(database.appendRemoteTaskEventOnce({
...provenance, semanticSequence: '2', payload: second
})).toBe(false)
expect(caches.size).toBe(0)
} finally { database.close() }
const reopened = new AssistantDatabase(path)
reopened.initialize(tmpdir())
@@ -208,7 +305,7 @@ describe('subagent progress storage', () => {
} finally { inspection.close(); database.close() }
})
it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => {
it.each([33, 34])('converts schema %i rows, resumes and shrinks without losing provenance', async (sourceVersion) => {
const { database, taskId, path } = await fixture()
database.close()
const event = subagent(taskId)
@@ -220,10 +317,12 @@ describe('subagent progress storage', () => {
const legacy = new DatabaseSync(path)
const expected: SubagentEvent[] = []
try {
legacy.exec('PRAGMA user_version = 33; BEGIN')
legacy.exec(`PRAGMA user_version = ${sourceVersion}; BEGIN`)
const insert = legacy.prepare(
`INSERT INTO task_events(task_id, kind, payload_json, created_at)
VALUES (?, 'subagent', ?, ?)`
`INSERT INTO task_events(
task_id, kind, payload_json, created_at, remote_binding_id,
remote_operation_id, remote_semantic_sequence, remote_event_index
) VALUES (?, 'subagent', ?, ?, 'binding', 'operation', ?, 0)`
)
for (let index = 0; index < 150; index++) {
const full = {
@@ -231,9 +330,17 @@ describe('subagent progress storage', () => {
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
}
expected.push(full)
insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z')
const { progress, ...metadata } = full
const stored = sourceVersion === 34 ? {
...metadata,
progressUpdates: progress.map((block) => ({ type: 'upsert', block }))
} : full
insert.run(taskId, JSON.stringify(stored), '2026-09-13T00:00:00Z', String(index + 1))
}
legacy.exec('COMMIT')
legacy.exec(`COMMIT;
CREATE TRIGGER reject_unchanged_payload BEFORE UPDATE OF payload_json ON task_events
WHEN OLD.payload_json = NEW.payload_json
BEGIN SELECT RAISE(ABORT, 'unchanged payload rewritten'); END;`)
} finally { legacy.close() }
const before = (await stat(path)).size
expect(before).toBeGreaterThan(40_000_000)
@@ -248,13 +355,25 @@ describe('subagent progress storage', () => {
try {
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
const rows = inspected.prepare(
`SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id`
).all() as Array<{ payload_json: string; created_at: string }>
`SELECT id, task_id, payload_json, created_at, remote_binding_id,
remote_operation_id, remote_semantic_sequence, remote_event_index
FROM task_events WHERE kind = 'subagent' ORDER BY id`
).all() as Array<{
id: number; task_id: string; payload_json: string; created_at: string
remote_binding_id: string; remote_operation_id: string
remote_semantic_sequence: string; remote_event_index: number
}>
expect(rows).toHaveLength(expected.length)
const blocks = new Map<string, ConversationMessageBlock[]>()
rows.forEach((row, index) => {
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
expect(row.created_at).toBe('2026-09-13T00:00:00Z')
expect(row.task_id).toBe(taskId)
expect(row.remote_binding_id).toBe('binding')
expect(row.remote_operation_id).toBe('operation')
expect(row.remote_semantic_sequence).toBe(String(index + 1))
expect(row.remote_event_index).toBe(0)
if (index > 0) expect(row.id).toBe(rows[index - 1]!.id + 1)
})
} finally { inspected.close() }
const reopened = new AssistantDatabase(path)
@@ -263,5 +382,47 @@ describe('subagent progress storage', () => {
const messages: unknown[] = []
upgradeAssistantStorage(path, (value) => messages.push(value))
expect(messages).toEqual([])
const latest = new DatabaseSync(path)
try {
expect(latest.prepare('PRAGMA user_version').get()).toEqual({
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
})
} finally { latest.close() }
}, 30_000)
it('recompacts mixed full and delta events without changing corrections, resets or child state', () => {
const first = subagent(randomUUID())
const second = { ...subagent(first.requestId), state: 'completed' as const, output: 'Done' }
const textId = randomUUID()
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: { name: 'read', state: 'completed', summary: 'Read', output: 'payload' }
}
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
const stored = [
{ ...first, progress: [tool, text] },
{ ...second, progressUpdates: [{ type: 'upsert', block: text }] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'upsert', block: tool },
{ type: 'append', id: textId, blockType: 'text', delta: ' tail' }
] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'upsert', block: { ...text, content: 'corrected' } }
] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'reset', blocks: [text, tool] }
] },
{ ...second, progressUpdates: [] },
{ ...first, progress: [] },
{ ...first, progress: [text] }
]
const oldState = new Map<string, ConversationMessageBlock[]>()
const newState = new Map<string, ConversationMessageBlock[]>()
for (const input of stored) {
const event = JSON.parse(JSON.stringify(input))
const compact = compactSubagentPayload(event, oldState)
const expected = restoreSubagentPayload(event, oldState)
expect(restoreSubagentPayload(compact, newState)).toStrictEqual(expected)
}
})
})
@@ -4,7 +4,8 @@ import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
import {
compactSubagentPayload,
restoreSubagentPayload
restoreSubagentPayload,
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export function upgradeAssistantStorage(
@@ -21,7 +22,10 @@ export function upgradeAssistantStorage(
const version = database.prepare('PRAGMA user_version').get() as {
user_version: number
}
if (version.user_version < 1 || version.user_version >= 34) return
if (
version.user_version < 1 ||
version.user_version >= SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
) return
checkCancelled()
const progress: AssistantStorageProgress = {
stage: 'scanning', processed: 0, total: 0, bytesBefore
@@ -65,8 +69,9 @@ export function upgradeAssistantStorage(
const compact = compactSubagentPayload(payload, blocks)
// Replay mixed old/new rows on retry. Never delete events or provenance.
restoreSubagentPayload(payload, blocks)
if (compact !== payload) {
update.run(JSON.stringify(compact), row.id)
const compactJson = JSON.stringify(compact)
if (compactJson !== row.payload_json) {
update.run(compactJson, row.id)
}
afterId = row.id
}
@@ -4,9 +4,28 @@ import type { DatabaseSync } from 'node:sqlite'
import {
assistantIdSchema,
conversationMessageBlockSchema,
type ConversationMessageBlock
conversationToolActivitySchema,
type ConversationMessageBlock,
type ConversationToolActivity
} from '../../shared/assistant-contracts'
export const SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION = 35
const toolFields = Object.keys(conversationToolActivitySchema.shape) as
Array<keyof ConversationToolActivity>
function sameProgressBlock(
previous: ConversationMessageBlock,
next: ConversationMessageBlock
): boolean {
if (previous.type === 'tool' && next.type === 'tool') {
// JSON omits undefined optional fields. Compare their values, not key presence.
return previous.id === next.id &&
toolFields.every((field) => previous.tool[field] === next.tool[field])
}
return isDeepStrictEqual(previous, next)
}
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('append'),
@@ -89,7 +108,7 @@ export function diffSubagentProgress(
type: 'append', id: block.id, blockType: block.type, delta
})
}
} else if (!prior || !isDeepStrictEqual(prior, block)) {
} else if (!prior || !sameProgressBlock(prior, block)) {
updates.push({ type: 'upsert', block })
}
}
@@ -114,12 +133,15 @@ export function compactSubagentPayload(
progress: ReadonlyMap<string, ConversationMessageBlock[]>
): unknown {
const event = asSubagent(payload)
if (!event || !Array.isArray(event.progress)) return payload
if (!event || (
!Array.isArray(event.progress) && !Array.isArray(event.progressUpdates)
)) return payload
const previous = progress.get(event.childTaskId) ?? []
const compact = { ...event }
delete compact.progress
compact.progressUpdates = diffSubagentProgress(
progress.get(event.childTaskId) ?? [],
event.progress
previous,
applySubagentProgress(previous, event) ?? []
)
return compact
}
@@ -158,7 +180,17 @@ export class SubagentProgressStorage {
}
inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
if (kind !== 'subagent') return
if (kind !== 'subagent') {
if (
kind === 'done' || kind === 'error' ||
(kind === 'status' && [
'completed', 'failed', 'cancelled', 'interrupted'
].includes((JSON.parse(payloadJson) as { status?: string }).status ?? ''))
) {
this.tasks.delete(taskId)
}
return
}
const task = this.tasks.get(taskId)
if (!task) return
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./src/renderer/src/test-setup.ts'],
exclude: [
'.agent-resources/**',
'shareserver/**',
'**/node_modules/**',
'**/dist/**',