fix: resolve runtime, review, channel and chat state failures
Keep remote startup cancellable without blocking other sessions and surface local OpenCode submission failures promptly. Preserve supervisor project isolation, pause behavior and valid story destinations. Restore generated-image channel delivery, Telegram reconnection on unchanged Save and DingTalk business-error retries. Preserve virtualized message disclosures using sparse state and release stale supervisor UI requests. Add production-path regressions, Electron checks, isolated SQLite validation and audit evidence. Fix the SDK cleanup test fixture lifetime under garbage collection without weakening its assertions. Validation: 6136 tests passed, 87 skipped; typecheck, lint and diff checks passed. Seven real text-model calls, current-source Linux x64 Host scenarios and large-history Electron comparisons completed. Release note: Fix stuck runtime requests, inconsistent review results and channel delivery failures, and retain expanded details while browsing long conversations. Remote runtime fixes require an Agent build containing these changes.
This commit is contained in:
@@ -1,5 +1,51 @@
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-10-05:展开状态缓存复查
|
||||
|
||||
独立复查发现,首次 A10 修复在每个折叠项挂载时保存默认值;无引用的消息也会因引用 Hook 留下一条记录。下节首次验收只检查了 DOM 上限,没有覆盖这部分缓存。新增回归在该实现上失败:分批挂载 10,000 个未操作的消息作用域后,预期缓存为 0,实际为 10,000;恢复默认状态后也没有释放条目。
|
||||
|
||||
现改为只保存偏离自动默认值的用户选择,恢复默认时立即删除覆盖项。消息更新后,清理逻辑只检查已保存的覆盖项,借用历史面板已有的消息索引定位来源,清除已删除消息、工具/输入/推理块/子代理/引用及已过期的流式默认值对应的记录。清理不在滚动或渲染期执行。正常的虚拟行卸载、子代理收起继续保留有效选择,行卸载期间完成的推理仍按原规则自动收起。
|
||||
|
||||
本次执行结果:
|
||||
|
||||
- `npm test -- --project unit src/renderer/src/message-expansion.test.tsx src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`:4 个文件、112 项通过。其中新增 4 项覆盖 10,000 个未操作作用域零缓存、用户选择跨卸载保留、恢复默认释放、消息及折叠项删除、未挂载推理完成。
|
||||
- `npm test -- --project unit src/renderer/src/App.test.tsx -t 'keeps each conversation history window|isolates history and task strips'`:2 项通过,346 项未选中。
|
||||
- `npm test -- --project integration tests/message-expansion.electron.test.ts`:三轮交替比较后,最终测量探针调整也单独复跑通过。`npm test -- --project integration tests/frosted-glass.electron.test.ts`:1 项通过,继续覆盖浅深主题、原生点击、跳底、锚点和缓存切换。
|
||||
- `npm run typecheck`、`npm run lint` 通过。该组定向验收后,完整集成测试为 6,136 项通过、0 失败、87 跳过,见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查);本组模型调用 0 次。
|
||||
|
||||
Electron 环境与合成消息结构同下节。此次比较对象为首次 A10 的默认值缓存实现和本次稀疏覆盖实现,不是 `126dbc57`。测试通过 `GB_EXPANSION_DENSE_SOURCE` 加载修改前保留的模块副本,其余生产 Renderer 源码相同。fixture 临时观察首次展开状态写入,捕获该 Map 后恢复原 `Map.set`;产品未增加诊断接口。每个数据量先进行 200 次未操作导航,实际见到 1,397 个不同消息行(一轮旧实现为 1,401),再原生展开 13 条消息的推理、工具结果和输入,返回原行确认恢复,用 Space 收起一项,最后删除消息。
|
||||
|
||||
| 历史消息数 | 旧实现:未操作导航后条目 | 新实现:未操作导航后条目 | 用户操作后:旧/新条目 | 删除消息后:旧/新条目 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 5,600 | 0 | 5,708 / 38 | 5,708 / 0 |
|
||||
| 10,000 | 5,604 | 0 | 5,904 / 38 | 5,904 / 0 |
|
||||
|
||||
以上计数在三轮比较中一致。交互阶段两版均最多采样到 15 个消息行、926 个 DOM 元素,原消息的三个展开项均恢复;A09 迟到来源响应检查也继续通过。计数针对缓存条目,不是浏览器堆内存字节数。
|
||||
|
||||
| 消息数 | 旧实现每轮导航中位数(ms) | 新实现每轮导航中位数(ms) | 旧实现每轮 p95(ms) | 新实现每轮 p95(ms) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 38.8 / 38.8 / 38.4 | 38.8 / 39.1 / 38.5 | 40.4 / 39.6 / 46.9 | 39.2 / 46.7 / 40.1 |
|
||||
| 10,000 | 38.7 / 39.1 / 38.9 | 38.8 / 38.9 / 38.6 | 39.7 / 39.5 / 46.9 | 39.4 / 39.3 / 46.6 |
|
||||
|
||||
导航计时仍为目标聚焦后再等待两个动画帧,每轮 14 个样本;此次先扫过历史,不能与下节首次进入的计时直接比较。三轮比较中,200 次未操作导航的总耗时,旧实现为 4,753.5–5,080.5 ms,新实现为 4,820.3–5,228.8 ms。数据证明默认条目和删除后的条目不再积累,未证明提速;未测堆内存峰值、低端机节流或完整 App/生产 IPC。
|
||||
|
||||
## 2026-10-05:虚拟消息行的展开状态
|
||||
|
||||
聊天历史面板通过 `message-expansion.tsx` 按消息、子代理及折叠项的稳定标识保留展开状态。虚拟行卸载后只保留状态值,不保留 DOM;仍沿用最多 8 个近期交互行及焦点、选区、问题的挂载保护。阅读行为见[聊天阅读状态](./ui-design.md#聊天阅读状态)。本次没有修改 Runtime 执行或消息持久化。
|
||||
|
||||
旧代码上的新增组件回归在返回原消息时失败:预期展开,实际收起。最终三文件 108 项通过,覆盖嵌套工具、父子同名调用隔离、手动收起、行卸载期间流式完成、既有焦点/选区及平滑滚动。命令:`npm test -- --project unit src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`。另有会话存储、同步、流式缓冲、窗口模型和侧栏回归 48 项,以及 App 历史加载、重试、热切换、流式隔离和超过 500 条消息的定向回归 5 项通过。
|
||||
|
||||
真实 Electron 使用当前 `ChatHistoryPane`、样式及 Markdown 渲染,数据为 2,000/10,000 条合成助手消息,每条含正文、推理、工具结果与输入。Windows x64、Electron 44.5.1、1280×800、缩放 1;原生鼠标展开 13 条相距较远的消息,确认原行卸载后再返回,并用原生 Space 操作。基线为临时 worktree 中的 `126dbc57`。三轮交替运行 `npm test -- --project integration tests/message-expansion.electron.test.ts`;基线运行另设 `GB_HISTORY_SOURCE` 指向该 worktree。每轮、每种数据量采样 14 次导航;计时从请求消息定位到目标 article 获得焦点后的两个动画帧,含这两个帧的等待,不等同于纯渲染耗时。
|
||||
|
||||
| 消息数 | 基线每轮中位数(ms) | 修复后每轮中位数(ms) | 基线每轮 p95(ms) | 修复后每轮 p95(ms) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 47.0 / 39.2 / 39.3 | 39.4 / 39.0 / 39.1 | 177.6 / 136.4 / 137.4 | 129.7 / 128.8 / 138.7 |
|
||||
| 10,000 | 47.2 / 39.4 / 39.2 | 38.8 / 39.2 / 39.5 | 56.0 / 40.2 / 43.7 | 41.4 / 39.7 / 40.0 |
|
||||
|
||||
两版在两种数据量下的采样最大值均为 15 个消息行、926 个 DOM 元素;基线三轮均丢失原消息的三个展开状态,修复后三轮均恢复。数字只证明本场景的 DOM 数量有界,计时存在波动,不据此宣称提速或整体性能预算达标。该 Electron fixture 替换了数据来源,不是完整 App、生产 IPC 或真实模型验收;模型调用 0 次。
|
||||
|
||||
既有 `npm test -- --project integration tests/frosted-glass.electron.test.ts` 单独复跑通过,覆盖浅深主题、原生点击、跳底、阅读锚点、会话缓存、加载更早消息及 960×540 短窗口。首次与监督者布局测试合并运行在工具的 60 秒期限内未返回结果,不计为通过;`tests/supervisor-layout.electron.test.ts` 随后单独后台运行通过,总耗时 65.26 秒,stderr 为空。最终完整 typecheck、全仓 lint 和修改范围差异检查通过。首次 lint 曾受其他并行修改中的三个 Runtime 错误阻断,后续复跑已通过。未运行完整测试集或低端机节流测量。
|
||||
|
||||
## 2026-10-04:新建本地项目必选目录
|
||||
|
||||
对应 FR-14 的项目创建入口,规则见 [PRD](./prd.md#fr-14-项目活动汇总)及[界面设计](./ui-design.md#10-项目活动汇总)。创建表单已增加中英文必填提示、无障碍关联及空目录提交检查;共享创建 schema 拒绝空白路径,更新 IPC 使用独立 schema,保留历史空目录项目的保存能力。
|
||||
|
||||
@@ -270,6 +270,14 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
导出也适用于消息通道会话。持久化与刷新规则见
|
||||
[会话置顶存储](../assistant-workbar/execution-history-storage.md#会话置顶)。
|
||||
|
||||
### 聊天阅读状态
|
||||
|
||||
在同一历史面板内,滚动到别处再返回时,消息的推理、工具结果、工具输入、引用和子代理详情保留用户的展开或收起选择。子代理收起后重新展开,其内部工具详情也保留选择;不同消息及父子工具分别记忆。状态保留在历史面板中,不依赖把所有已展开的消息留在 DOM 中;面板被缓存淘汰或应用重启后不恢复这些临时阅读选择。
|
||||
|
||||
未操作的折叠项不留下默认状态记录;用户恢复默认选择、消息或对应折叠项被删除后,释放相应记录。正常滚动卸载不视为删除。
|
||||
|
||||
推理仍在流式输出时默认展开、完成时自动收起,流式期间手动收起不因滚动重挂载而重新展开。有焦点、文本选区端点、待回答问题和待审批项的消息继续按既有规则保留挂载;平滑跳到底部及滚动锚定行为不变。
|
||||
|
||||
## 9. 未来插件入口
|
||||
|
||||
FR-15、US-F1/US-F2 的市场 URL、YAML 导入导出及 ShareServer 分发在后续设计中定义。
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-10-05:Runtime 提交失败与启动取消
|
||||
|
||||
本机 OpenCode 提交失败后会结束所属 SSE 等待;远程原生 Session 启动等待期间,
|
||||
取消与其他 binding 的控制请求可继续处理。保留无默认 Prompt 总时限、共享 peer
|
||||
隔离、取消后的会话清理及原有事件顺序。实现规则见
|
||||
[进程复用设计](./runtime-process-reuse-technical-design.md),旧/新回归、当前源码
|
||||
Windows/Linux x64 实测、真实数据检查及本轮 5 次文本模型调用计数统一记录在
|
||||
[统一执行进度](../unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。
|
||||
|
||||
## 2026-10-04:移除成果页,工作区导入与文件时间
|
||||
|
||||
对应 [FR-WF1~FR-WF3、US-WF1~US-WF3](./workspace-files.md)。成果 Tab、目录注册及独立导入
|
||||
|
||||
@@ -17,6 +17,11 @@ Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包
|
||||
|
||||
## 运行与失败
|
||||
|
||||
OpenCode 提交请求失败时直接显示该失败,不等待没有被接受的请求产生结束事件。
|
||||
远程 Runtime 在原生 Session 启动期间也可取消;启动等待不阻塞其他会话的状态读取。
|
||||
取消及清理范围仍限于所属请求,完整规则见
|
||||
[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md)。
|
||||
|
||||
- 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。
|
||||
- 前台同一请求的待答问题按公开 `questionId` 去重,按到达顺序保存在消息的
|
||||
`pendingQuestions` 中。每次显示队首表单;有多个待答请求时显示数量。后续提问和重复
|
||||
|
||||
@@ -168,6 +168,11 @@ Session 和 Prompt 路由删除产品模式,按实际能力和请求归属接
|
||||
使用独立的 ACP 取消路径。
|
||||
现有 `snapshot: false` 必须保留。
|
||||
|
||||
提交 Prompt 的 Promise 与 SSE 消费同时运行。提交返回错误、抛错或控制请求超时后,
|
||||
立即中止本请求的 SSE 订阅并保留提交错误;不能等待服务端为未接受的 Prompt 发送
|
||||
`session.idle`。回收仍先 abort 订阅再结束迭代器,随后完成该 Session 的有界取消,
|
||||
不关闭其他会话的订阅或共享 Server。生产运行不增加整个 Prompt 的默认总时限。
|
||||
|
||||
`mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner
|
||||
串行协调。注册名称、工具可见性、knowledge token 和清理仍按请求区分,不能用一个全局
|
||||
“当前 MCP 列表”覆盖同目录或跨目录的其他请求。探针未覆盖动态 MCP,这是实施验收项。
|
||||
@@ -282,6 +287,16 @@ Agent 当前 Runtime installation + 兼容配置
|
||||
- 同一 PID 的多个 binding 不代表同一权限或同一 operation。Host/installation identity
|
||||
变化仍使用已有失效和退役规则,不把不同 Host、账号或 Runtime generation 合并。
|
||||
|
||||
`runtime/startPrompt` 校验及 owner 登记经过 backend 控制队列,原生握手、Session
|
||||
创建/恢复和模型路由准备在队列外等待。协议接收端也不等待该 RPC 的最终响应才分派
|
||||
后续帧;未完成的响应继续占用已有 request channel,沿用连接通道容量限制及 FIFO
|
||||
写入。其他方法的分派顺序保持不变,Prompt 终态仍经 backend 控制队列提交。
|
||||
同一 binding 的相同启动请求共用 Promise,内容不同的并发启动返回冲突。
|
||||
|
||||
启动等待属于 Session owner,可以被取消、启动 watchdog 或关闭中断。取消后不得
|
||||
发送 Prompt;原生调用迟到返回的 Session 只关闭自身,不能回收仍服务其他 binding
|
||||
的共享进程。原生握手本身仍由进程 owner 共享,一个等待者退出不取消其他等待者。
|
||||
|
||||
原生 `chat.message` 在模型请求前设置该 Session 的工具与模型路由,不再按产品模式
|
||||
过滤 Shell、编辑或写入;请求级端点与子会话归属仍独立。
|
||||
OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
|
||||
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
|
||||
|
||||
## 2026-10-05 后端阶段审查 A03/A04/A05
|
||||
|
||||
对应 SL-5、SL-8、FR-S6、FR-S10。生产候选读取现在将批次项目和跨项目设置传到只读 Worker;最终候选读取返回后检查暂停/取消;故事归属和拆分在原写事务内拒绝已移除或合并的目标。存储、停止与重试规则见[技术设计](./technical-design.md#单次执行与取消)。没有 schema 变更,原始消息、历史结果及人工确认字段保留。
|
||||
|
||||
先加回归、后改实现的结果:8 个新用例中旧代码失败 7 个,取消用例原本通过。失败覆盖跨项目开/关两种生产候选、最终候选读取期间暂停、归属期间移除/合并目标、拆分前移除功能/子线索。旧暂停用例实际返回 `completed` 并发布结果;旧归属用例写入隐藏目标且把事件标为已处理。修复后暂停不发布结果或 checkpoint,继续仍复用原批次;失效故事批次回滚且事件可重试。生产身份测试补充真实项目事件关联,保留 UUID 别名、确认字段及失败后继续的断言。
|
||||
|
||||
验证命令与结果:
|
||||
|
||||
- `npm test -- src/main/assistant/supervision-production.test.ts src/main/assistant/supervision-worker.test.ts src/main/assistant/supervision-stories.test.ts src/main/assistant/supervision-review.test.ts src/main/assistant/supervision-timeline.test.ts src/main/assistant/supervision-experiences.test.ts src/main/assistant/supervisor-service.test.ts src/main/readonly-query-reader.test.ts`:8 个文件、96 项通过。Worker 回归每轮使用两个项目、422 条来源,并检查候选隔离、实体数量、外键及暂停后的完整发布。
|
||||
- 本次修改的 Main 源码、测试和验证脚本定向 ESLint、`git diff --check` 通过。`npm run typecheck` 首轮发现本轮 fixture 缺默认设置字段,修正后曾被并行 `tests/message-expansion.electron.test.ts:39` 类型错误阻塞;该文件由其负责方修正后,Main/Agent/Web 三组最终全部通过。本轮未修改该文件。
|
||||
- 定向验收时 `npm run lint` 曾被并行 OpenCode 修改中的三项错误阻塞;负责方修正后,最终全仓 lint 与 typecheck 均通过。两份后端文档扫描阻断项为 0;人工复核保留取消与事务顺序的边界说明。完整集成结果见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查)。
|
||||
- `node --import jiti/register scripts/supervision-audit-validation.ts scale <临时父目录>`:真实文件 SQLite,10 个项目、1,000 个实体、10,000 个事件、10,000 条事件实体关联、10 条来源,库文件 10,964,992 字节。三轮交替查询中,每轮十次旧相关子查询合计 3,517/5,302/4,190 ms;新 Worker 查询合计 39.18/34.01/36.04 ms,包含传输。逐项目返回的 100 个候选 ID 及顺序与旧 SQL 完全一致;跨项目读取每轮 0.95~1.79 ms。120 个事件的单批归属事务为 6.63 ms。计数不变,`foreign_key_check` 为空,`integrity_check` 为 `ok`。
|
||||
- 查询测量是同一合成库内的 SQL 对照,非完整 App 基线;同时有其他本地验证任务运行。原相关查询在修复传参后首次实测暴露每十次 3.60~4.65 秒耗时,随后改为实体集合查询。没有新增索引或迁移,也不据此宣称整窗性能改善。
|
||||
|
||||
真实模型检查沿用 `scripts/supervision-production-live.ts` 的环境文件读取与 `ModelAgentRuntime` 无工具模式,由新增合成验证脚本执行。`node --import jiti/register scripts/supervision-audit-validation.ts live <临时父目录> .env.deepseek.local` 只发送一条 147 字符合成消息,真实生产工厂和文件 Worker 完成提取、保存及故事归属。外部请求准确为 **2 次**:提取 1 次、故事归属 1 次;无变化重跑新增请求 0 次。原文片段精确重建,外键与完整性检查通过,总耗时 3.84 秒。脚本上限为 3 次 HTTP 请求、每次模型阶段 45 秒、1,800 输出 token;凭据仅在进程内读取,终端与指标文件只输出计数和验证信息。本机继承的 TLS 环境设置未由本次修改。
|
||||
|
||||
报告位于系统临时 `opencode/supervision-audit-Ggn5uW/metrics.json`(规模检查)和 `opencode/supervision-audit-WNu08s/metrics.json`(模型检查)。只写隔离合成库,未读取或修改用户库,未运行完整 App/Electron 回归或整包构建;没有提交。修改位于桌面监督服务及其 SQLite Worker,GoodBuddy Agent 与远程 Runtime 控制路径未改变。
|
||||
|
||||
## 2026-10-04 螺旋工具栏与事件颜色修正
|
||||
|
||||
接续未提交的回顾布局调整。平铺与螺旋共用标题工具栏样式,项目层级菜单放在面包屑旁,画布内的视角入口和左下角圈数说明已移除。默认斜俯视,拖动及键盘操作保留;两种图谱均以共享 InlineHelp 的 i 图标打开图例。共用历史回顾栏、下钻返回、详情选择与中窄窗口详情面板保留。当前规则见 [UI 设计](./ui-design.md#故事线图谱)。
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
|
||||
本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。
|
||||
|
||||
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。
|
||||
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。当前监督阶段使用共享时间线 checkpoint;候选按下文的批次项目与跨项目设置筛选。memory 继续作为背景读取,结果快照不支持 `as_of` 历史重建。
|
||||
|
||||
生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和无工具 Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。
|
||||
|
||||
模型实体 `id` 只在单次输出内有效。Main 从同一 scope 的故事线提供最多 100 个未撤销实体候选,生产提示只暴露 `candidateRef`(如 `known_1`)、名称和说明。模型显式选择候选后,Main 转换为严格 UUID 类型的内部 `persistedId`。服务校验候选集成员和重复映射,保存事务再次按实体主键校验故事线归属及撤销状态。没有有效候选引用时分配新 UUID,不按名称或裸模型 ID 合并。来源仍按每次结果分配 UUID,保留原始 `source_id` 和 locator。
|
||||
模型实体 `id` 只在单次输出内有效。生产服务将批次的 `projectId` 和 `crossProject` 经 `supervisionCandidates` 传到只读 Worker,再由 `listSupervisionCandidates` 查询共享时间线。跨项目关闭时,只提供该项目当前事件关联的未撤销实体;开启时允许其他项目的实体。候选仍最多 100 个,按更新时间和 ID 排序。查询先取得项目事件关联的实体集合,再筛选候选,避免对每个实体重复扫描事件。省略批次参数的旧读取入口保留精确 scope 查询。
|
||||
|
||||
生产提示只暴露 `candidateRef`(如 `known_1`)、名称和说明。模型显式选择候选后,Main 转换为严格 UUID 类型的内部 `persistedId`。服务校验候选集成员和重复映射,保存事务再次按实体主键校验存在性及撤销状态。没有有效候选引用时分配新 UUID,不按名称或裸模型 ID 合并。来源仍按每次结果分配 UUID,保留原始 `source_id` 和 locator。
|
||||
|
||||
新实体省略 `candidateRef`,不要求模型生成数据库 ID。未知非空 `candidateRef`、冲突身份和候选集之外的 UUID 均拒绝。旧输出中的 `persistedId` 仅在 UUID 合法且属于候选集时复用;空值、占位符和格式错误值不能证明已有身份,保留为独立新实体。局部 ID 或同名不会触发合并。
|
||||
|
||||
@@ -71,6 +73,10 @@ schema 47 的四张批次相关表使用外键随监督运行清理,未完成
|
||||
|
||||
`supervision:execution` 返回进程内 `{ active, runId?, stopping? }`,配置读取期间可有 `active: true` 而尚无 runId。`supervision:cancel({ runId })` 先在既有 `supervision_runs` 写入 `cancelled`、清除错误并保存取消时间,再向父 controller 传播 abort;重复取消保留原时间。该接口等待执行 Promise 结束,包含并行批次的 `allSettled` 和 Runtime `releaseConversation`,之后才释放准入。暂停只请求 abort,执行停止后保存 `paused`;取消优先于暂停。迟到模型输出不得保存叶子、导航或完整结果,成功批次保留,自动 checkpoint 不推进。发布是同步 SQLite 事务,已完成或无变化的运行拒绝取消,不撤销已发布结果;本次无 schema 迁移。
|
||||
|
||||
发布前重读已保存批次所需候选后,再检查父 signal。此时收到暂停或取消,走原有停止分支;完整叶子和导航保留,结果及 checkpoint 尚未提交。该检查与同步保存之间没有新的异步等待。
|
||||
|
||||
故事归属和子线索拆分在既有写事务内,按主键检查模型选中的目标、父故事和跨项目功能仍为 `current`。模型等待期间若用户移除或合并目标,整批写入回滚,包含新建故事和已处理标记;重新整理使用当前候选。拆分同样检查原功能及目标子线索。已发布回顾、用户调整和此前成功批次保留,不增加版本表或恢复日志。
|
||||
|
||||
Preload 暴露类型化 `execution`、`cancel`,取消输入使用 UUID schema 并校验可信 sender。活动 UI 用实时执行状态覆盖持久记录的停止显示;工作回顾只在发起前查询执行状态,Main 仍作最终准入判断。`resume` IPC 直到运行结束才返回,Renderer 发出请求后释放提交锁并独立处理结果,确保继续中的回顾仍可暂停或取消。
|
||||
|
||||
`App` 将既有通知回调经 `HeartbeatCenter` 传给 `SupervisorWorkspace`。回顾操作用 `supervisor-review` 去重键发送本地化 Toast,结果读取失败仍写入 `loadError` 并就地重试;实体、关系操作和来源读取失败也使用通知。工作回顾不再为状态条轮询执行状态,历史读取不发送操作通知。请求锁覆盖发起前检查和整次运行;页面刷新只使旧结果选择失效,不丢弃运行结束反馈。卸载后忽略迟到响应,检查阶段尚未提交的回顾不再发起,已提交的后台运行不取消。活动查询、原始错误持久化和 Main 生命周期保持原路径。
|
||||
@@ -98,7 +104,7 @@ IPC `supervision:suggestions`、`supervision:suggestion-action`、`supervision:r
|
||||
|
||||
只有实际送入模型、通过结果校验且成功落库的完整片段推进位置。心跳报告及其进度、监督结果及其进度分别在各自 SQLite 事务提交。监督服务串行执行,后续自动请求在前次保存后重新收集。报告成功而监督失败时,下一次自动检查跳过已成功的报告输入,但重试监督输入;无变化不创建报告、来源或图谱事件。
|
||||
|
||||
已有同 scope 故事线的 scope 表示和实体 ID 保留;比较项目集合时忽略排序。模型接收最近摘要和同 scope 最多 100 个未撤销实体的候选引用、名称、说明,显式选择候选才复用,身份转换按上文执行。不同范围不合并,已有重复实体不自动修复。知识引用继续以独立来源保存 locator;当前记忆和旧摘要不作为新事件。
|
||||
已有故事线的 scope 表示和实体 ID 保留;比较项目集合时忽略排序。生产候选按上文的项目归属与跨项目设置读取,显式选择候选才复用;已有重复实体不自动修复。监督 checkpoint 的共享时间线规则见[故事线模型](./storyline-model-design.md)。知识引用继续以独立来源保存 locator;当前记忆和旧摘要不作为新事件。
|
||||
|
||||
迁移在事务中扩展心跳计划及运行表的状态 CHECK,保留列、索引、ID、计划、报告与引用;消息版本列使用默认值,无需遍历或重写旧正文。进度初始为空,首次自动运行从配置窗口开始。报告保留期限覆盖 `no_change` 运行,但不删除来源进度;删除报告不触发重新消费。
|
||||
|
||||
@@ -326,7 +332,7 @@ IPC 返回图谱查询结果时使用分页和有界字段;详情中的来源
|
||||
## 当前剩余差距
|
||||
|
||||
- 实体与关系 revoke 保存状态;界面没有 undo 或恢复入口。
|
||||
- 跨 run identity 依赖同 scope 候选 UUID 的显式引用,不自动合并历史重复实体;模型漏选候选时会创建新身份。
|
||||
- 跨 run identity 依赖候选 UUID 的显式引用,候选范围受批次项目及跨项目设置控制;不自动合并历史重复实体,模型漏选候选时会创建新身份。
|
||||
- 结果图谱使用真实事件实体连线,侧栏已按固定目标过滤并支持结果直达图谱;逐事件状态回放和 Experiment 尚未实现。
|
||||
- 用户暂停已接到排队/在途 signal,并保留已保存批次;源版本变更后的局部重算、完整模型配置冻结及其他限制见分块调度第 0 节。
|
||||
- 监督知识实体与知识库正文条目仍是不同对象,知识正文只能通过现有条目定位和预览接口处理。
|
||||
|
||||
@@ -96,6 +96,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
选择历史结果后,图谱读取该结果的成员和内容,历史菜单显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
|
||||
|
||||
切换语言或手动回顾完成引起刷新时,同时解除被替代的来源读取、实体和关系操作的等待状态。新图谱读取完成后,历史选择、刷新和详情操作恢复可用;旧请求迟到时既不显示旧内容,也不能解除后来发起的请求的等待状态。
|
||||
|
||||
手动“回顾当前进展”新建回顾,活动页“继续”恢复原 run;运行中再次发起按上文显示提示,停止清理结束后才能开始新回顾。失败保留上次结果、本次范围和成功批次;已取消回顾不能继续。手动操作不推进自动位置,删除报告也不重置自动位置。
|
||||
|
||||
## 会话侧栏
|
||||
|
||||
@@ -365,6 +365,12 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创
|
||||
同次验证复现原生 ACP 冷启动超过 15 秒控制超时。`runtime/startPrompt` 的控制等待
|
||||
现至少为 30 秒,其余控制请求沿用原时限;Runtime 外层仍保留启动超时和取消处理。
|
||||
|
||||
2026-10-05 修复启动等待阻塞控制队列的问题:原生启动等待同时释放 backend 全局控制
|
||||
队列和协议接收分派,取消、watchdog 及其他 binding 的控制请求可继续处理。相同启动
|
||||
请求只执行一次,迟到返回的已取消 Session 单独关闭;不增加 Prompt 总时限或新协议
|
||||
阶段。实现规则见[共享进程与 Session 所有权](../assistant-workbar/runtime-process-reuse-technical-design.md#61-连接与所有权),
|
||||
本次源码 Host 验证及调用计数见[统一执行进度](../unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。
|
||||
|
||||
## ACP 与断线
|
||||
|
||||
- 管控面只负责有界 JSON-RPC:连接、查询、启动、取消、关闭和重连。数据面只负责
|
||||
|
||||
@@ -35,6 +35,26 @@ npm test
|
||||
|
||||
集成集合及 301 项复跑的完整命令未随计数提供,这里不推测其文件清单。真实本地代理 fixture 见 [`tests/telegram-proxy.electron.test.ts`](../../../tests/telegram-proxy.electron.test.ts);其本地代理结果不证明外部 Telegram Bot 可用。探针操作见 [API 联调](./api-testing.md),探针成功也不包含模型验收。
|
||||
|
||||
## 2026-10-05 通道审计修复验证
|
||||
|
||||
A06 的图片结果回归使用现有 IPC executor 图片事件 fixture,接入生产 `ChannelService` 和内存 SQLite 发件箱。修复前回复状态为 `failed`;修复后状态为 `completed`,图片附件字节保持一致,桌面会话仍记录成果 ID。未调用付费图片模型。
|
||||
|
||||
A07 的 Renderer 回归覆盖推送状态为 `error`、`stopped`、`running`、`starting`、`disabled` 的无修改保存。前两种状态在修复前没有提交配置,修复后能够提交;其余状态保持不提交。扩展的 Electron 场景通过真实设置 UI、Preload、IPC、Manager 和驱动验证 409 后无修改保存恢复轮询,以及正常连接时再次保存不重启。Telegram API 在网络边界使用替身;本次运行有 2 次本地 HTTP 模型调用、0 次外部请求。
|
||||
|
||||
A08 的钉钉回归使用生产传输、服务和 SQLite 发件箱,HTTP 请求只发送到 loopback fixture。首次 HTTP 200 携带非零 `errcode`,修复前被当作成功;修复后记录一次失败并保留上下文,第二次成功响应完成投递,executor 只执行一次。另有空响应、缺少错误码、JSON null、无效 JSON 和超限响应回归。
|
||||
|
||||
本轮聚焦验证:6 个 channel / Renderer suite 共 106 项通过;IPC 图片、文件与 Telegram 场景 3 项通过;Electron 场景 1 项通过。修复前选择性回归为 9 项失败、3 项通过。命令如下:
|
||||
|
||||
```powershell
|
||||
npm test -- src/main/channels/dingtalk-channel-driver.test.ts src/renderer/src/ChannelSettingsSection.test.tsx src/main/channels/channel-manager.test.ts src/main/channels/channel-service.test.ts src/main/channels/telegram-channel-driver.test.ts src/main/channels/sqlite-channel-state.test.ts
|
||||
npm test -- src/main/ipc.test.ts -t "returns a generated image only|creates a bounded result file|routes Telegram settings"
|
||||
npm test -- tests/telegram-channel.electron.test.ts
|
||||
```
|
||||
|
||||
这些结果验证本地生产连接关系,不替代真实平台收发或图片模型验收。本轮未改 Agent、Runtime 协议或执行策略,未做性能改善声明。
|
||||
|
||||
补充检查:钉钉错误码断言加入后,单独复跑 9 项通过;修改的 TS 文件 ESLint、Renderer typecheck 和 `git diff --check` 通过。早期全仓检查受到并行修改中的类型与 lint 错误阻塞,负责方修正后最终检查均通过;完整测试为 6,136 项通过、0 失败、87 跳过。汇总见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查)。
|
||||
|
||||
## Main 性能对比
|
||||
|
||||
基线为 `78afec77758f3c175f8e2b80e5be4327e13b4689` 的临时 detached worktree,当前为包含 Telegram 改动的工作区。按基线、当前交替执行 `npm run perf:main` 各 3 次。环境为 Windows x64、Intel Core Ultra X7 358H、Electron 44.5.1、Node 24.21.0;每轮使用隔离数据库,包含 1000 个会话、20008 条生成消息、24000 条活动记录,每个场景预热后测量 15 次,无外部模型调用。
|
||||
@@ -62,7 +82,7 @@ worker 读取、活动启动、增量更新和同步首页查询在六轮中均
|
||||
## 待完成
|
||||
|
||||
- 在可完成操作系统信任确认的环境中复跑显式启用的代理测试,记录最终结果;保留单次成功和后续导入超时两项证据,不把默认跳过视为通过。
|
||||
- 跟踪全量测试中的 Runtime 清理与帮助弹层间歇性失败;两者均有原样复跑通过证据,未为此修改无关产品逻辑。
|
||||
- Runtime 清理用例的 GC 生命周期问题已定位并修正测试夹具,完整集成测试通过,见[GC 复查](../unified-execution/progress.md#sdk-cleanup-timeout-复查)。历史帮助弹层间歇性失败在本轮完整测试中未出现。
|
||||
- 提供有效测试凭据后运行生产实时探针,记录连接及固定回复结果;本次缺少凭据的阻塞不以历史 Node 成功替代。
|
||||
- 从真实设置页保存并启用专用 Bot,在手机发送授权请求,经过实际模型产生回复并核对桌面历史;再按 PRD 验证连续上下文、隔离、无害工具任务和恢复场景。当前没有真实 Bot 与模型完整链路的通过记录。
|
||||
- Main 测量未覆盖迁移和完整 UI;后续验收需按[性能原则](../../architecture/performance-principles.md)区分实测路径与未测路径。
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
|
||||
结果先进入现有发件箱,发送失败由服务有限重试,不重新调用模型;失败计数达到 5 或遇到永久错误后停止补发。发送前校验目标会话中的 Bot ID,拒绝把旧 Bot 结果交给新 Bot。附件只附加桌面查看提示。
|
||||
|
||||
生成图片后,executor 先把 `artifactIds` 写入桌面会话,再向 `ChannelService` 返回通道契约中的状态、正文、错误和附件。图片字节保留在附件中;桌面成果 ID 不进入严格校验的通道结果,避免成功任务被误报为“无效结果”。该转换位于本地和远程 Runtime 事件处理之后,不改变 Runtime 或 Agent 协议。
|
||||
|
||||
驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送的 429 最多等待重试 3 次,仍失败则交回发件箱处理。跨重启补发与不确定网络结果允许产生重复段落。
|
||||
|
||||
停用会中止轮询、发送等待、活动执行与排队工作,等待活动操作结束后报告停止。清除凭据时设置页同时提交 `enabled=false`;底层拒绝启用但无 Token 的配置。Token 替换先验证,失败不停止旧服务。项目与历史不因停用或清除凭据删除。
|
||||
@@ -37,4 +39,6 @@ Manager 通过 `{ channel, status }` 增量 IPC 推送状态,忽略旧服务
|
||||
|
||||
Renderer 的 `channel-status-store.ts` 订阅事件,selector 只向状态区域提供对应通道数据。首次快照不会覆盖已收到的较新事件;状态更新不重新加载整份设置,不重置 Token 或白名单草稿。配置页复用共享页签、开关、项目设置字段和通知控件。
|
||||
|
||||
保存时读取状态 store 的最新值。Telegram 已启用、配置可编辑且状态为 `error` 或 `stopped` 时,即使字段未变,也提交当前配置以重启连接;`running`、`starting` 状态下的无修改保存不重启轮询。环境变量配置仍为只读,测试连接不改变轮询状态。
|
||||
|
||||
源码入口:[`telegram-channel-driver.ts`](../../../src/main/channels/telegram-channel-driver.ts)、[`channel-manager.ts`](../../../src/main/channels/channel-manager.ts)、[`channel-service.ts`](../../../src/main/channels/channel-service.ts)、[`channel-settings-store.ts`](../../../src/main/channels/channel-settings-store.ts)、[`channel-status-store.ts`](../../../src/renderer/src/channel-status-store.ts)、[`ChannelSettingsSection.tsx`](../../../src/renderer/src/ChannelSettingsSection.tsx)。
|
||||
|
||||
@@ -1,5 +1,100 @@
|
||||
# 进度与证据
|
||||
|
||||
## 2026-10-05 Runtime 生命周期审查修复
|
||||
|
||||
A01(远程启动阻塞控制队列)和 A02(本机 OpenCode 提交失败后等待静默 SSE)已修复。
|
||||
远程启动等待释放 backend 控制队列及协议接收分派,取消、watchdog、关闭和其他
|
||||
binding 可以继续处理;Session owner 合并相同启动、拒绝冲突,并关闭取消后迟到的
|
||||
原生 Session。本机提交错误立即唤醒所属 SSE 消费,保留原错误和既有 Session 取消。
|
||||
两条实现均未增加整个 Prompt 的默认总时限;共享进程、终态提交和写帧顺序规则见
|
||||
[Runtime 进程复用设计](../assistant-workbar/runtime-process-reuse-technical-design.md)。
|
||||
|
||||
### 确定性复现
|
||||
|
||||
持久回归中,本机拒绝、错误响应、10 ms 提交超时三种情况在旧代码经过 100 ms
|
||||
假时钟后仍未结算;远程共享进程中的第二个 Session 启动被挂起时,旧代码经过
|
||||
200 ms 后,取消/截止时间和同进程另一 binding 的查询均未结算。上述五项先运行旧
|
||||
实现,全部按这些断言失败。真实 Host 首轮还定位到协议 dispatcher 串行等待启动
|
||||
响应,新增帧级回归在修复前只分派 `start`,没有分派后续 `cancel` 和 `peer`。
|
||||
|
||||
修复后的九项新增回归通过,另覆盖启动 watchdog、默认关闭时限、相同启动重放及
|
||||
冲突。共享 peer 保持 running;迟到 Session 收到自身的 `session/close`,取消的
|
||||
启动不发送 Prompt。原始审查探针曾在 131 秒假时钟后观察到启动、取消、peer 查询
|
||||
仍等待,默认 10 秒 dispose 报超时且 stop 次数为 0,现有关闭与 watchdog 回归不再
|
||||
等待原生初始化响应。各假时钟值是确定性边界,不是系统性能测量。
|
||||
|
||||
### 当前源码实机验证
|
||||
|
||||
复用已有 lifecycle harness,用 esbuild 重建当前 Desktop driver、Agent daemon、
|
||||
CLI/helper;凭据从 GoodBuddy 已有加密配置的临时副本加载,SSH 校验已固定 Host
|
||||
identity。共享 Linux x64 经 `192.168.0.23` 连接,只使用 `/root/tmp/gb-lifecycle-*`
|
||||
专用目录和所属进程,未替换原 Host 安装或修改无关数据。
|
||||
|
||||
| 场景 | 实际结果 | 真实文本提供商调用 |
|
||||
| --- | --- | ---: |
|
||||
| 首轮 Host 启动挂起 | backend 修复后,协议 dispatcher 仍阻塞;测试退出后所属进程为 0 | 0 |
|
||||
| OpenCode 启动取消及工具、detach、controller 恢复、测试 Agent 提升、重放 | 启动响应被挂起时控制查询 48 ms、取消 216 ms;工具与恢复完成;两个提供商响应均 HTTP 200 | 2 |
|
||||
| Continue 同场景 | 控制查询 60 ms、取消 258 ms;工具与恢复完成;两个提供商响应均 HTTP 200 | 2 |
|
||||
| Windows 当前源码 OpenCode 普通文本 | 真实原生二进制经有界 loopback 转发访问已配置模型,返回 `LOCAL_RUNTIME_OK`,0 工具调用;总耗时 4,727 ms | 1 |
|
||||
|
||||
本轮合计 **5 次真实文本提供商调用、0 次图片生成调用**。两次本机脚本准备失败发生在
|
||||
SDK 动态加载阶段,均为 0 次模型调用,随后修正测试 bundle 并通过。所有成功场景均
|
||||
使用最终相关生产源码。Host 启动取消阶段各为 0 次模型调用,最终所属残留进程均为 0,
|
||||
专用目录由 harness 清理;本机 Runtime 和凭据临时副本也已清理。
|
||||
|
||||
真实数据检查使用本轮原生工具和模型产生的事件:OpenCode 45 条写入、23 个 checkpoint,
|
||||
Continue 16 条写入、8 个 checkpoint,经生产 `AssistantDatabase` 和
|
||||
`RemoteEventBatcher` 写入隔离 SQLite,再重放确认全部去重。未迁移、改写用户生产
|
||||
历史库,也未以构造事件代替本轮 Host transcript。
|
||||
|
||||
原始脚本和日志位于 `C:/Users/jiang/AppData/Local/Temp/opencode`:
|
||||
`a01-runtime-host-build.cjs`、`a01-runtime-host-run.cjs`、`a01-runtime-host/`,以及
|
||||
`a02-local-model.ts`、`a02-local-model-run.cjs` 和 `a02-local-model-1791142126078.log`。
|
||||
Host 成功日志为 `opencode-1791141713375.log`、`continue-1791141785721.log`。
|
||||
延迟为各场景一次实测,不宣称吞吐、p95 或完整桌面性能提升。
|
||||
|
||||
### 检查命令与边界
|
||||
|
||||
```text
|
||||
npx vitest run src/agent-daemon --testTimeout=30000 --hookTimeout=60000
|
||||
npx vitest run src/main/remote-agent src/main/agent/acp-remote-runtime.test.ts src/main/agent/runtime-controller.test.ts src/main/agent/local-runtime-registry.test.ts --testTimeout=30000
|
||||
npx vitest run src/main/agent/opencode-runtime.test.ts
|
||||
npx vitest run src/main/agent/opencode-runtime-lifecycle.test.ts --testTimeout=30000 --hookTimeout=60000
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
四组测试分别为 323 通过/33 跳过、393 通过、156 通过、4 通过,共 876 项通过;
|
||||
typecheck、全仓 lint 和修改范围 `git diff --check` 通过。初次合并运行中,既有
|
||||
OpenCode `cleanup-timeout` 用例失败一次;该用例独立复跑及随后完整 OpenCode 文件
|
||||
复跑通过,未改动该用例或相关产品清理逻辑。较早 Main 类型检查的并发监督者 fixture
|
||||
错误在最终全仓检查时已消失。
|
||||
|
||||
验证覆盖当前源码、真实 Windows OpenCode 二进制和共享 Linux x64 Host,不含签名包
|
||||
安装验收、完整 UI 点击、macOS/Linux arm64 或用户生产大库性能测试。首轮定向验证
|
||||
未运行全仓 `npm test`,后续完整检查见下节;远程更新仍须部署包含本次源码的 Agent,
|
||||
未提交或发布。
|
||||
|
||||
### SDK cleanup-timeout 复查
|
||||
|
||||
后续全仓验证为 6,130 通过、1 失败、87 跳过,唯一失败是 OpenCode SDK
|
||||
`cleanup-timeout` 用例。复查通过独立 Node 子进程的十轮显式 GC 和受控取消复现:
|
||||
旧 fetch 替身只保存 `request.signal`,返回永不结算的 Promise,没有在途 Request
|
||||
所有者或 abort 处理。Request 及 Undici 内部 AbortController 被回收后,外层超时
|
||||
signal 已中止,保存的 Request signal 仍未中止。真实 loopback fetch 在同样 GC
|
||||
条件下保留 Request,取消后 SDK 返回错误,未观察到产品清理提前完成。
|
||||
|
||||
修正仅涉及测试:挂起的 fetch 替身在 abort 时以 `request.signal.reason` 拒绝,
|
||||
闭包保持 Request 存活至取消。原有 `signals.every(signal => signal.aborted)`、调用
|
||||
顺序、错误脱敏及超时均保留。新增 GC 回归在旧替身上稳定失败,修正后通过;九项
|
||||
SDK 定向用例、Main 类型检查及该文件 ESLint 通过。本次跟进新增外部模型调用 0 次,
|
||||
生产代码与远程 Agent 路径均未改动,无需重新执行 Host 模型验证。
|
||||
|
||||
修正后的完整 `npm test` 于 04:00:22 开始,852.63 秒完成,退出码 0:518 文件通过、
|
||||
15 文件跳过;6,136 项通过、0 失败、87 跳过。计数包含并发工作树中的其他修复。
|
||||
原始记录为 `C:/Users/jiang/AppData/Local/Temp/opencode/a02-cleanup-final-20261005.log`
|
||||
及同名 `.json`;GC/真实 loopback 对照脚本为该目录的 `a02-cleanup-gc-probe.mjs`。
|
||||
|
||||
## 2026-10-04 调研与设计
|
||||
|
||||
调研阶段,用户确认彻底移除 Ask / Execute,包括界面、原生工具与 MCP 限制,并允许升级清理历史模式字段。该阶段先交付文档,随后用户确认删除安全设置并授权实施;实现结果见下文。
|
||||
|
||||
@@ -298,6 +298,8 @@ Agent Runtime。OpenCode/Continue 的通道项目只持久化 `provider`,旧
|
||||
|
||||
现有发送者白名单和群聊提及设置继续有效。
|
||||
|
||||
钉钉回复以接口响应中的 `errcode=0` 确认投递,HTTP 200 本身不代表成功。业务错误或无效响应保留回复上下文和发件箱结果,由现有有限重试补发,不重新执行任务。响应读取上限为 64 KiB,包含没有 `Content-Length` 的分块响应;错误提示只保留数值错误码,不回显平台响应中的敏感字段。
|
||||
|
||||
## 8. 远程会话需求
|
||||
|
||||
### 8.1 会话创建与复用
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 阶段审计修复与验收
|
||||
|
||||
本轮十项审计发现均已修复,最终完整测试、类型检查和 lint 通过。修改基于 `126dbc57` 后的工作树,未提交或发布。数据验证使用隔离的合成 SQLite 和本轮真实 Runtime 产生的事件,没有改写 portable 原始用户数据。
|
||||
|
||||
## 修复清单
|
||||
|
||||
| 编号 | 问题 | 必须覆盖的失败场景 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| A01 | 远程 ACP 启动占用全局控制队列 | 延迟初始化时其他绑定查询、取消、看门狗和关闭仍可完成 | 已修复;回归与真实 Host 通过 |
|
||||
| A02 | 本地 OpenCode 提交失败后等待静默 SSE | 拒绝、错误响应、提交超时均及时结束并保留错误,正常流不变 | 已修复;回归与真实本机模型通过 |
|
||||
| A03 | 监督候选生产链路遗漏项目参数 | 真实生产装配与 Worker 路径尊重 projectId/crossProject | 已修复;生产 Worker、SQLite 与真实模型通过 |
|
||||
| A04 | 最终候选查询期间暂停仍发布 | 暂停后不保存结果,正常完成仍保存 | 已修复;暂停/继续与取消回归通过 |
|
||||
| A05 | 在途故事分配写入已移除故事 | 移除候选后不得产生隐藏关联或消耗待处理事件 | 已修复;移除/合并/拆分回归通过 |
|
||||
| A06 | 图片生成结果被通道严格契约拒绝 | 含 artifactIds 的执行结果通过生产通道交付,桌面产物保留 | 已修复;生产服务与 SQLite 发件箱通过 |
|
||||
| A07 | Telegram 原样保存不能恢复轮询 | 终止错误解除后原样保存重启,正常运行保存不重复启动 | 已修复;组件与 Electron 生产入口通过 |
|
||||
| A08 | 钉钉 HTTP 200 业务错误被算作送达 | 非零 errcode 保留失败及重试上下文,成功回复正常交付 | 已修复;loopback HTTP 与 SQLite 重试通过 |
|
||||
| A09 | 监督视图请求失效后永久忙碌 | 刷新期间旧请求结束不覆盖新状态,控件恢复可用 | 已修复;组件与 Electron 通过 |
|
||||
| A10 | 虚拟消息行淘汰丢失展开状态 | 超过八行交互后滚出再滚回保持展开,DOM 仍有界 | 已修复;组件、Electron 与缓存保留测量通过 |
|
||||
|
||||
## 验收要求
|
||||
|
||||
- 每项记录旧实现失败、新实现通过的测试证据及命令。
|
||||
- 运行时改动检查本地与远程路径,Agent 改动在共享 Linux x64 Host 验证当前源码。
|
||||
- 真实模型请求保持有界,记录实际请求数;模拟 Provider 和真实 Provider 分开报告。
|
||||
- 数据验证使用真实 SQLite 合成数据或 portable 数据隔离副本,记录规模及一致性检查,不记录用户正文或凭据。
|
||||
- 性能检查遵守 `docs/architecture/performance-principles.md`,测量与推断分开;不删除动画、虚拟化、取消或正常功能来取得通过。
|
||||
- 最后运行完整测试、类型检查与 lint,并同步所属功能文档。
|
||||
|
||||
## 验证记录
|
||||
|
||||
### 复现与修复
|
||||
|
||||
| 范围 | 修复前证据 | 修复及验证 |
|
||||
| --- | --- | --- |
|
||||
| A01/A02 | 静默 SSE 三种提交失败不结束;挂起启动阻塞取消和其他绑定;协议分派只收到 start | 启动等待释放 backend 和 socket 分派队列;相同启动合并、冲突拒绝;迟到 Session 单独关闭。本机提交失败取消所属订阅并保留原错误。九项新增回归通过 |
|
||||
| A03/A04/A05 | 八个新增用例中七个失败,既有取消场景原本通过;关闭跨项目仍收到其他项目候选,暂停后仍发布,已移除故事仍收到新关联 | 贯通批次参数;发布前检查取消;原事务中检查目标当前状态。96 项监督/Worker 定向测试通过,无 schema 迁移 |
|
||||
| A06/A07/A08 | 选择性回归九项失败、三项通过;图片结果失败、原样保存不调用重启、业务错误被记作送达 | 通道边界映射公开结果;异常轮询状态允许原样保存重启;有界解析钉钉业务响应。106 项通道测试、3 项 IPC 测试及 Telegram Electron 通过 |
|
||||
| A09/A10 | 控件保持禁用;超过八行交互后的展开状态丢失,Electron 原生交互也复现 | 失效请求释放等待状态;展开选择由历史面板保存,虚拟行卸载后可恢复。112 项相关组件回归及真实 Electron 通过 |
|
||||
|
||||
独立复审没有发现运行时修复引入的高置信度回归,但发现首次 A10 实现保存了未操作的默认展开值。跟进回归复现浏览 10,000 个作用域留下 10,000 条记录,现改为仅保存用户覆盖项并清理已删除来源;该修正包含在最终全仓测试中。
|
||||
|
||||
### 真实模型与 Host
|
||||
|
||||
本轮准确合计 **7 次外部文本模型请求、0 次图片模型请求**。另有 Telegram Electron 中的 2 次 loopback 模型请求,不计为外部请求。没有向真实消息通道收件人发送测试消息。
|
||||
|
||||
| 路径 | 真实外部请求 | 结果 |
|
||||
| --- | ---: | --- |
|
||||
| Linux x64 OpenCode | 2 | 当前源码工具执行、断开、恢复、测试 Agent 提升和事件重放通过;启动挂起时查询 48 ms、取消 216 ms |
|
||||
| Linux x64 Continue | 2 | 同类生产路径通过;启动挂起时查询 60 ms、取消 258 ms |
|
||||
| Windows 本地 OpenCode | 1 | 真实二进制正常返回固定文本,0 工具调用,总耗时 4,727 ms |
|
||||
| 监督生产服务与文件 Worker | 2 | 147 字符合成消息完成提取和故事归属,总耗时 3.84 秒;无变化重跑新增请求为 0 |
|
||||
|
||||
Host 使用现有固定身份和加密凭据,测试资源位于 `/root/tmp/gb-lifecycle-*`。结束时所属进程为零,没有替换原 Host 安装。延迟是单次场景观察,不是 p95 或吞吐结论。完整命令、脚本及日志索引见[运行时验证](../features/unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。
|
||||
|
||||
### 数据与性能
|
||||
|
||||
- SQLite 合成库包含 10 个项目、1,000 个实体、10,000 个事件和 10,000 条事件实体关联。候选 ID 与排序逐项一致,数据计数不变、外键检查为空、完整性为 `ok`。真实 Host 事件另外通过生产数据库与批处理器写入:OpenCode 45 条/23 个 checkpoint,Continue 16 条/8 个 checkpoint,重放去重通过。
|
||||
- 激活项目隔离后,测量发现原相关子查询较慢,改用实体集合查询。三轮各十次项目查询,旧 SQL 合计 3,517/5,302/4,190 ms;新 Worker 路径含传输为 39.18/34.01/36.04 ms。120 事件归属事务为 6.63 ms。此结果仅适用于该合成库 SQL 对照,见[监督后端证据](../features/conversation-supervision/progress.md#2026-10-05-后端阶段审查-a03a04a05)。
|
||||
- Windows Electron 使用 2,000/10,000 条合成消息,原版与首次修复各三轮交替运行;后续缓存修正又做三轮对照。最大采样挂载量均为 15 个消息行、926 个 DOM 元素,展开、键盘、焦点、选区和滚动回归通过。缓存修正后的导航中位数为 38.5~39.1 ms,含聚焦后的两帧等待,不能作为纯渲染耗时或整体预算结论。
|
||||
- 未操作导航后缓存条目为零;13 条消息交互后保留 38 条用户覆盖项,删除消息后归零。该测量统计缓存条目,没有测量堆字节。方法、每轮数据及原有滚动回归见[前端验证](../features/application-tool-navigation/progress.md#2026-10-05展开状态缓存复查)。
|
||||
|
||||
### 最终检查
|
||||
|
||||
| 命令 | 最终结果 |
|
||||
| --- | --- |
|
||||
| `npm test` | 518 文件通过、15 文件跳过;6,136 项通过、0 失败、87 跳过;852.63 秒 |
|
||||
| `npm run typecheck` | Main、Agent、Renderer 全部通过 |
|
||||
| `npm run lint` | 通过 |
|
||||
| `git diff --check` | 通过 |
|
||||
|
||||
首轮完整测试的唯一失败为既有 `cleanup-timeout`。独立 GC 探针确认 mock 返回永不结算 Promise,却未保留待完成的 Request;GC 后 Undici 取消转发失效。测试夹具现保持 Request 存活至取消,并按真实 fetch 行为拒绝。原断言、调用顺序和一秒清理预算均保留,新增强制 GC 回归先在旧 mock 上失败,再在修正后通过。生产清理代码未因此调整。详见[GC 复查](../features/unified-execution/progress.md#sdk-cleanup-timeout-复查)。
|
||||
|
||||
最终全仓原始日志为 `C:/Users/jiang/AppData/Local/Temp/opencode/a02-cleanup-final-20261005.log`,退出码及时间记录为同名 `.json`。通道网络替身、发件箱及图片元数据验证见[通道记录](../features/telegram-channel/progress.md#2026-10-05-通道审计修复验证)。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本轮未做真实 Telegram/钉钉平台收发或付费图片生成,A06 验证的是生成完成后的生产交付契约。未做 macOS、Linux arm64、低端机节流、完整 App 性能预算或签名包安装验收,也未用用户生产大库证明性能。上述已测场景未观察到功能或性能退化,不能推广为所有设备和数据规模的保证。远程修复生效需要部署包含当前源码的 Agent;本轮没有提交、打包发布或升级用户安装。
|
||||
@@ -0,0 +1,143 @@
|
||||
// Synthetic SQLite / bounded live validation for A03-A05. Never opens a user database.
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { parseEnv } from 'node:util'
|
||||
import { build } from 'esbuild'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { AssistantDatabase } from '../src/main/assistant/assistant-database'
|
||||
import { createProductionSupervisorService } from '../src/main/assistant/supervision-production'
|
||||
import { SupervisionModelPool } from '../src/main/assistant/supervision-model-pool'
|
||||
import { ModelAgentRuntime } from '../src/main/agent/model-runtime'
|
||||
import type { SupervisionRunRequest } from '../src/shared/supervision-contracts'
|
||||
import { supervisionReviewSettingsSchema } from '../src/shared/supervision-review-contracts'
|
||||
|
||||
async function main() {
|
||||
const [mode, parent, envPath] = process.argv.slice(2)
|
||||
assert((mode === 'scale' || mode === 'live') && parent)
|
||||
const rel = relative(process.cwd(), resolve(parent))
|
||||
assert(rel.startsWith('..') || isAbsolute(rel), 'Output must be outside repository')
|
||||
const directory = await mkdtemp(join(resolve(parent), 'supervision-audit-'))
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const worker = join(directory, 'readonly-query-worker.cjs')
|
||||
await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: worker,
|
||||
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
|
||||
const db = new AssistantDatabase(path)
|
||||
db.initialize(directory)
|
||||
db.enableReadonlyWorker(worker)
|
||||
const sql = (db as unknown as { requireDatabase(): DatabaseSync }).requireDatabase()
|
||||
const pool = new SupervisionModelPool()
|
||||
const report: Record<string, unknown> = { mode, directory, externalCalls: 0 }
|
||||
let calls = 0
|
||||
const started = performance.now()
|
||||
const request: SupervisionRunRequest = { trigger: 'manual', scope: { kind: 'global' },
|
||||
timeRange: { from: '2026-10-01T00:00:00.000Z', to: '2026-10-02T00:00:00.000Z' } }
|
||||
const empty = { summary: 'Synthetic audit', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
try {
|
||||
if (mode === 'scale') {
|
||||
const projects = [db.listProjects()[0]!, ...Array.from({ length: 9 }, (_, i) => db.createProject({ name: `Synthetic ${i}`, description: '', rootPath: directory }))]
|
||||
for (const project of projects) {
|
||||
db.saveSupervisionResult({ request, evidence: [{ id: 's', sourceType: 'conversation', sourceId: randomUUID(),
|
||||
title: 'Synthetic ownership', content: 'Synthetic source', occurredAt: request.timeRange.from, locator: { projectId: project.id } }],
|
||||
output: { ...empty,
|
||||
entities: Array.from({ length: 100 }, (_, i) => ({ id: `e${i}`, label: `${project.id}:${i}`, description: 'Synthetic knowledge', sourceReferenceIds: ['s'] })),
|
||||
events: Array.from({ length: 1000 }, (_, i) => ({ title: `Event ${i}`, description: 'Synthetic decision', occurredAt: request.timeRange.from,
|
||||
eventType: 'discussion' as const, entityIds: [`e${i % 100}`], sourceReferenceIds: ['s'] })) } })
|
||||
}
|
||||
report.seedMs = performance.now() - started
|
||||
const counts = () => ['supervision_entities', 'supervision_events', 'supervision_event_entities', 'supervision_sources'].map(table =>
|
||||
[table, Number(sql.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()!.n)])
|
||||
const before = counts()
|
||||
await db.supervisionCandidates(request, { projectId: projects[0]!.id, crossProject: false })
|
||||
const legacy = sql.prepare(`SELECT e.id FROM supervision_entities e WHERE e.confirmation_state != 'revoked' AND EXISTS (
|
||||
SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id
|
||||
WHERE ee.entity_id = e.id AND ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?)
|
||||
ORDER BY e.updated_at DESC, e.id LIMIT 100`)
|
||||
const rounds: Array<{ legacyMs: number; isolatedMs: number; crossProjectMs: number }> = []
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const legacyStart = performance.now()
|
||||
const expected = new Map(projects.map(project => [project.id, legacy.all(project.id).map(row => row.id)]))
|
||||
const legacyMs = performance.now() - legacyStart
|
||||
const start = performance.now()
|
||||
for (const project of projects) {
|
||||
const candidates = await db.supervisionCandidates(request, { projectId: project.id, crossProject: false })
|
||||
assert.equal(candidates.length, 100)
|
||||
assert(candidates.every(item => item.label.startsWith(project.id + ':')))
|
||||
assert.deepEqual(candidates.map(item => item.id), expected.get(project.id))
|
||||
}
|
||||
const isolatedMs = performance.now() - start
|
||||
const crossStart = performance.now()
|
||||
const candidates = await db.supervisionCandidates(request, { projectId: projects[0]!.id, crossProject: true })
|
||||
assert.equal(candidates.length, 100)
|
||||
rounds.push({ legacyMs, isolatedMs, crossProjectMs: performance.now() - crossStart })
|
||||
}
|
||||
assert.deepEqual(counts(), before)
|
||||
report.rows = Object.fromEntries(before)
|
||||
report.rounds = rounds
|
||||
report.isolatedQueriesPerRound = projects.length
|
||||
// Exercise the new destination check with the largest admitted assignment chunk.
|
||||
sql.exec('INSERT INTO supervision_story_assigned SELECT id FROM supervision_events')
|
||||
const project = projects[0]!
|
||||
const events = sql.prepare('SELECT id, project_id, started_at, event_type, title, description FROM supervision_events WHERE project_id = ? LIMIT 120').all(project.id)
|
||||
const stories = db.supervisionStories()
|
||||
const applyStart = performance.now()
|
||||
stories.apply(project.id, events as Parameters<typeof stories.apply>[1], [], {
|
||||
stories: [{ key: 'new_1', level: 'feature', name: 'Synthetic feature', description: '' }],
|
||||
assignments: events.map((_, i) => ({ event: `e_${i + 1}`, story: 'new_1' })), concluded: [], links: []
|
||||
}, { crossProject: false, threadEvents: 500 })
|
||||
report.assignment120Ms = performance.now() - applyStart
|
||||
assert.equal(stories.list({ kind: 'projects', projectIds: [project.id] })[0]!.events.length, 120)
|
||||
} else {
|
||||
assert(envPath, 'Environment file required')
|
||||
const env = parseEnv(await readFile(envPath, 'utf8'))
|
||||
assert(env.DEEPSEEK_API_KEY && env.DEEPSEEK_BASE_URL && env.DEEPSEEK_MODEL)
|
||||
const content = 'We decided to use SQLite transactions to save review facts and coverage together. This prevents partial publication. The implementation is pending.'
|
||||
db.saveLocalConversations([{ header: { id: randomUUID(), projectId: db.listProjects()[0]!.id, title: 'Synthetic review decision', updatedAt: Date.parse(request.timeRange.from) },
|
||||
messages: [{ id: randomUUID(), role: 'user', state: 'complete', content, createdAt: Date.parse(request.timeRange.from) }] }])
|
||||
const runtime = new ModelAgentRuntime({ protocol: 'openai-chat-completions', authentication: 'api-key',
|
||||
baseUrl: env.DEEPSEEK_BASE_URL, model: env.DEEPSEEK_MODEL, apiKey: env.DEEPSEEK_API_KEY, defaultWorkspace: directory,
|
||||
maximumOutputTokens: 1800, requestBody: { thinking: { type: 'disabled' }, response_format: { type: 'json_object' } },
|
||||
toolProvider: { listTools: async () => [], getApproval: () => { throw new Error('Tools disabled') },
|
||||
callTool: async () => { throw new Error('Tools disabled') }, releaseConversation: async () => undefined, dispose: async () => undefined },
|
||||
fetcher: async (input, init) => {
|
||||
assert(calls < 3, 'Three external request cap')
|
||||
assert.equal(JSON.parse(String(init?.body)).tools?.length ?? 0, 0)
|
||||
calls++
|
||||
return fetch(input, init)
|
||||
} })
|
||||
const service = createProductionSupervisorService(db, async () => ({ supervisorOrganizeTimeoutSeconds: 45,
|
||||
supervisionReview: supervisionReviewSettingsSchema.parse({ crossProject: false, experienceMinEvents: 200 }) }), async () => runtime, pool)
|
||||
const result = await service.run(request)
|
||||
assert.equal(result.status, 'completed')
|
||||
assert.equal(result.coverage?.stories?.status, 'completed')
|
||||
const evidence = db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence)
|
||||
assert.equal(evidence.map(item => item.content).join(''), content)
|
||||
const before = calls
|
||||
assert.equal((await service.run(request)).status, 'no_change')
|
||||
assert.equal(calls, before)
|
||||
report.messages = 1
|
||||
report.characters = content.length
|
||||
report.status = result.status
|
||||
report.storyCalls = result.coverage?.stories?.calls
|
||||
report.noChangeAdditionalCalls = calls - before
|
||||
}
|
||||
assert.deepEqual(sql.prepare('PRAGMA foreign_key_check').all(), [])
|
||||
assert.equal(sql.prepare('PRAGMA integrity_check').get()!.integrity_check, 'ok')
|
||||
report.integrity = 'ok'
|
||||
report.passed = true
|
||||
} catch (error) {
|
||||
report.passed = false
|
||||
report.errorType = error instanceof Error ? error.name : 'Error'
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
report.externalCalls = calls
|
||||
report.elapsedMs = performance.now() - started
|
||||
db.close()
|
||||
pool.dispose()
|
||||
report.databaseBytes = (await stat(path)).size
|
||||
await writeFile(join(directory, 'metrics.json'), JSON.stringify(report, null, 2))
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
}
|
||||
}
|
||||
void main().catch(() => { console.error('Synthetic supervision validation preflight failed'); process.exitCode = 1 })
|
||||
@@ -23,6 +23,33 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentOwnedAcpPrompt', () => {
|
||||
it('coalesces identical starts and rejects conflicting starts while initialization is pending', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-start-'))
|
||||
temporary.push(root)
|
||||
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
|
||||
transcript.prepare({ bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1',
|
||||
controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
||||
let initialize!: (value: { protocolVersion: number }) => void
|
||||
let finish!: (value: PromptResponse) => void
|
||||
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
|
||||
const prompt = vi.fn(() => new Promise<PromptResponse>(resolve => { finish = resolve }))
|
||||
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding-1', controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace', process: new MemoryProcess(), transcript, completePrompt: async () => undefined,
|
||||
createConnection: () => ({ initialize: () => new Promise(resolve => { initialize = resolve }), newSession, prompt }) as unknown as ClientSideConnection })
|
||||
const request = { bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text' as const, text: 'test' }] }
|
||||
try {
|
||||
const first = owner.start(request)
|
||||
const replay = owner.start(request)
|
||||
await expect(owner.start({ ...request, prompt: [{ type: 'text', text: 'different' }] })).rejects.toMatchObject({ code: 'conflict' })
|
||||
initialize({ protocolVersion: 1 })
|
||||
expect(await first).toEqual(await replay)
|
||||
expect(newSession).toHaveBeenCalledOnce()
|
||||
expect(prompt).toHaveBeenCalledOnce()
|
||||
finish({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => expect(transcript.attach('binding-1', 'operation-1', 'controller-1').state).toBe('completed'))
|
||||
} finally { owner.close(); transcript.close() }
|
||||
})
|
||||
|
||||
it('refreshes prompt tools on the retained session and authorizes execution', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-'))
|
||||
temporary.push(root)
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import { raceWithAbort } from './async-utils'
|
||||
import {
|
||||
SemanticPromptStore,
|
||||
SemanticPromptStoreError
|
||||
@@ -63,6 +64,11 @@ export class AgentOwnedAcpPrompt {
|
||||
#promptSettled?: Promise<void>
|
||||
#closed = false
|
||||
#initialized = false
|
||||
#starting?: {
|
||||
digest: string
|
||||
controller: AbortController
|
||||
promise: Promise<RemoteOwnedPromptStartResult>
|
||||
}
|
||||
#hasMcpServers = false
|
||||
readonly #questions = new Map<string, {
|
||||
endpoint: string; operationId: string; questionCount: number; notification: SessionNotification
|
||||
@@ -86,6 +92,7 @@ export class AgentOwnedAcpPrompt {
|
||||
async start(
|
||||
request: RemoteOwnedPromptStartRequest
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
if (this.#closed) throw new Error('ACP Session is closed')
|
||||
if (
|
||||
request.bindingId !== this.#options.bindingId
|
||||
) {
|
||||
@@ -95,6 +102,12 @@ export class AgentOwnedAcpPrompt {
|
||||
)
|
||||
}
|
||||
const startDigest = digest(canonicalJson(request))
|
||||
if (this.#starting) {
|
||||
if (this.#starting.digest !== startDigest) {
|
||||
throw new SemanticPromptStoreError('Another ACP prompt is starting', 'conflict')
|
||||
}
|
||||
return this.#starting.promise
|
||||
}
|
||||
const existing = this.#options.transcript.findStarted({
|
||||
bindingId: request.bindingId,
|
||||
operationId: request.operationId,
|
||||
@@ -111,9 +124,39 @@ export class AgentOwnedAcpPrompt {
|
||||
)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = this.#start(request, startDigest, controller.signal)
|
||||
this.#starting = { digest: startDigest, controller, promise }
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.#starting = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async #start(
|
||||
request: RemoteOwnedPromptStartRequest,
|
||||
startDigest: string,
|
||||
signal: AbortSignal
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
const whileStarting = async <T>(operation: Promise<T>): Promise<T> => {
|
||||
const result = await raceWithAbort(this.#whileProcessAlive(operation), signal)
|
||||
signal.throwIfAborted()
|
||||
return result
|
||||
}
|
||||
const retainSession = async (operation: Promise<string>): Promise<string> =>
|
||||
whileStarting(operation.then(sessionId => {
|
||||
if (signal.aborted) {
|
||||
// A native session can be created after its owner was cancelled.
|
||||
void this.#connection.closeSession({ sessionId }).catch(() => undefined)
|
||||
} else {
|
||||
this.#sessionId = sessionId
|
||||
}
|
||||
return sessionId
|
||||
}))
|
||||
const mcpServers = this.#options.mcpServers?.() ?? []
|
||||
if (!this.#initialized) {
|
||||
const initialization = await this.#whileProcessAlive(
|
||||
const initialization = await whileStarting(
|
||||
this.#transport.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
@@ -134,38 +177,36 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
if (request.acpSessionId !== undefined && this.#sessionId === undefined) {
|
||||
if (initialization.agentCapabilities?.loadSession === true) {
|
||||
await this.#whileProcessAlive(
|
||||
this.#sessionId = await retainSession(
|
||||
this.#connection.loadSession({
|
||||
sessionId: request.acpSessionId,
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
})
|
||||
}).then(() => request.acpSessionId!)
|
||||
)
|
||||
} else if (
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume != null
|
||||
) {
|
||||
await this.#whileProcessAlive(
|
||||
this.#sessionId = await retainSession(
|
||||
this.#connection.resumeSession({
|
||||
sessionId: request.acpSessionId,
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
})
|
||||
}).then(() => request.acpSessionId!)
|
||||
)
|
||||
} else {
|
||||
throw new Error('ACP Runtime cannot resume the requested session')
|
||||
}
|
||||
this.#sessionId = request.acpSessionId
|
||||
} else if (this.#sessionId === undefined) {
|
||||
const created = await this.#whileProcessAlive(
|
||||
this.#sessionId = await retainSession(
|
||||
this.#connection.newSession({
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
})
|
||||
}).then(created => created.sessionId)
|
||||
)
|
||||
this.#sessionId = created.sessionId
|
||||
}
|
||||
if (this.#options.expectedModel !== undefined) {
|
||||
const configured = await this.#whileProcessAlive(
|
||||
const configured = await whileStarting(
|
||||
this.#connection.setSessionConfigOption({
|
||||
sessionId: this.#sessionId!,
|
||||
configId: 'model',
|
||||
@@ -190,13 +231,14 @@ export class AgentOwnedAcpPrompt {
|
||||
) {
|
||||
throw new Error('ACP session identity cannot change within a binding')
|
||||
} else if (mcpServers.length > 0 || this.#hasMcpServers) {
|
||||
await this.#whileProcessAlive(this.#connection.resumeSession({
|
||||
await whileStarting(this.#connection.resumeSession({
|
||||
sessionId: this.#sessionId!, cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
}))
|
||||
}
|
||||
this.#hasMcpServers = mcpServers.length > 0
|
||||
await this.#options.prepareSession?.(this.#sessionId!, request.operationId)
|
||||
await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId)))
|
||||
signal.throwIfAborted()
|
||||
this.#active = {
|
||||
operationId: request.operationId
|
||||
}
|
||||
@@ -251,6 +293,7 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.#starting?.controller.abort(new Error('ACP prompt startup cancelled'))
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
if (this.#sessionId !== undefined && !this.#closed) {
|
||||
@@ -396,6 +439,7 @@ export class AgentOwnedAcpPrompt {
|
||||
return
|
||||
}
|
||||
this.#closed = true
|
||||
this.#starting?.controller.abort(new Error('ACP Session is closed'))
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
this.#unregisterSession?.()
|
||||
|
||||
@@ -44,6 +44,30 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentProtocolServer connection bounds', () => {
|
||||
it('dispatches cancellation and peer requests before a native startup response', async () => {
|
||||
const gate = deferred<void>()
|
||||
const calls: string[] = []
|
||||
const harness = createHarness({ methods: {
|
||||
'runtime/startPrompt': async () => { calls.push('start'); await gate.promise; return { started: true } },
|
||||
'runtime/escalateCancellation': async () => { calls.push('cancel'); return { stopped: true } },
|
||||
'runtime/getAcpCursors': async () => { calls.push('peer'); return {} }
|
||||
} })
|
||||
try {
|
||||
harness.socket.receive(Buffer.concat([
|
||||
controlRequest(harness.controller, 'start', '1', 1, 'runtime/startPrompt'),
|
||||
controlRequest(harness.controller, 'cancel', '1', 1, 'runtime/escalateCancellation'),
|
||||
controlRequest(harness.controller, 'peer', '1', 1, 'runtime/getAcpCursors')
|
||||
].map(Buffer.from)))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(calls).toEqual(['start', 'cancel', 'peer'])
|
||||
expect(harness.socket.writes).toHaveLength(4)
|
||||
gate.resolve()
|
||||
await waitFor(() => harness.socket.writes.length === 6)
|
||||
expect(harness.socket.maximumConcurrentWrites).toBe(1)
|
||||
expect(harness.socket.destroyCalled).toBe(false)
|
||||
} finally { gate.resolve(); harness.close() }
|
||||
})
|
||||
|
||||
it('accepts a frame fragmented into single-byte socket chunks', async () => {
|
||||
const harness = createHarness()
|
||||
const frame = controlRequest(
|
||||
|
||||
@@ -866,11 +866,19 @@ class ProtocolConnection {
|
||||
if (!('id' in request)) {
|
||||
await this.#invokeNotification(request, channel)
|
||||
} else {
|
||||
await this.#invokeRequest(request, channel)
|
||||
await this.#sendChannelClose(channel)
|
||||
this.#channels.close(channel.channelId)
|
||||
this.#channelKinds.delete(channel.channelId)
|
||||
return
|
||||
const completion = this.#invokeRequest(request, channel).then(async () => {
|
||||
if (this.#closed) return
|
||||
await this.#sendChannelClose(channel)
|
||||
this.#channels.close(channel.channelId)
|
||||
this.#channelKinds.delete(channel.channelId)
|
||||
})
|
||||
if (request.method === 'runtime/startPrompt') {
|
||||
// Startup holds its admitted request channel (and its existing capacity
|
||||
// slot), but must leave the socket free for cancellation and peer work.
|
||||
void completion.catch(error => this.#close(protocolFailureCategory('dispatch', error)))
|
||||
} else {
|
||||
await completion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -923,6 +931,7 @@ class ProtocolConnection {
|
||||
controllerTakeoverProven: this.#takeoverProven,
|
||||
signal: this.#abortController.signal
|
||||
})
|
||||
if (this.#closed) return
|
||||
if (request.method === 'runtime/resumeAcpChannel') {
|
||||
this.#bindResumedAcpChannel(result)
|
||||
}
|
||||
@@ -941,6 +950,7 @@ class ProtocolConnection {
|
||||
result: result ?? null
|
||||
})
|
||||
} catch (error) {
|
||||
if (this.#closed) return
|
||||
const serviceCode = typedErrorCode(error)
|
||||
await this.#sendJson(channel, {
|
||||
jsonrpc: '2.0',
|
||||
|
||||
@@ -518,6 +518,84 @@ function harness(input: {
|
||||
}
|
||||
|
||||
describe('RuntimeAcpBackend', () => {
|
||||
it.each(['watchdog', 'dispose'])('interrupts stalled native initialization through %s', async action => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
|
||||
try {
|
||||
await open(fixture)
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
|
||||
let settled = false
|
||||
const starting = invoke(fixture, 'runtime/startPrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1', prompt: [{ type: 'text', text: 'test' }]
|
||||
}).catch(() => undefined).finally(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fixture.process.writes).toHaveLength(1)
|
||||
let disposalError: unknown
|
||||
const disposal = action === 'dispose' ? fixture.backend.dispose().catch((error: unknown) => { disposalError = error }) : undefined
|
||||
await vi.advanceTimersByTimeAsync(action === 'watchdog' ? 121000 : 10000)
|
||||
const observed = { settled, stops: fixture.process.stops.length, disposalError }
|
||||
const initialize = JSON.parse(Buffer.from(fixture.process.writes[0]!).toString())
|
||||
await fixture.process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: initialize.id, error: { code: -32603, message: 'release fixture' } })}\n`)
|
||||
await Promise.all([starting, disposal])
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(observed).toEqual({ settled: true, stops: 1, disposalError: undefined })
|
||||
expect(fixture.process.writes.map(payload => JSON.parse(Buffer.from(payload).toString()).method)).toEqual(['initialize'])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
await fixture.backend.dispose().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['cancel', 'deadline'])('keeps a shared peer responsive during native startup and %s', async reason => {
|
||||
const fixture = await ownedHarness(true)
|
||||
let releaseStartup: (() => Promise<void>) | undefined
|
||||
try {
|
||||
await fixture.start()
|
||||
const process = fixture.processes[0]!
|
||||
const originalWrite = process.onWrite!
|
||||
process.onWrite = payload => {
|
||||
const request = JSON.parse(Buffer.from(payload).toString())
|
||||
if (request.method === 'session/new') {
|
||||
releaseStartup = () => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'late-session' } })}\n`)
|
||||
} else originalWrite(payload)
|
||||
}
|
||||
const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string }
|
||||
vi.useFakeTimers()
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch,
|
||||
operationId: 'starting-request', requestId: 'starting-request',
|
||||
deadlineAt: reason === 'deadline' ? new Date(1100).toISOString() : UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
|
||||
let startSettled = false
|
||||
const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] })
|
||||
.catch(() => undefined).finally(() => { startSettled = true })
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(releaseStartup).toBeDefined()
|
||||
let peerSettled = false
|
||||
const peer = invoke(fixture, 'runtime/getAcpCursors', { bindingId: 'binding-1' }).then(() => { peerSettled = true })
|
||||
let cancelled: Promise<unknown> | undefined
|
||||
if (reason === 'cancel') cancelled = invoke(fixture, 'runtime/escalateCancellation', {
|
||||
bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'opening', reason: 'requested'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
const observed = { peerSettled, startSettled, stops: process.stops.length }
|
||||
// Release the fake native response even on the old implementation.
|
||||
await releaseStartup!()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await Promise.all([starting, peer, cancelled])
|
||||
expect(observed).toEqual({ peerSettled: true, startSettled: true, stops: 0 })
|
||||
expect(fixture.transcript.attach('binding-1', 'request-1', fixture.context.controller.controllerId).state).toBe('running')
|
||||
const writes = process.writes.map(payload => JSON.parse(Buffer.from(payload).toString()))
|
||||
expect(writes.filter(request => request.method === 'session/prompt')).toHaveLength(1)
|
||||
expect(writes).toContainEqual(expect.objectContaining({ method: 'session/close', params: { sessionId: 'late-session' } }))
|
||||
await fixture.prompts[0]!()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
} finally {
|
||||
await releaseStartup?.()
|
||||
await fixture.prompts[0]?.()
|
||||
vi.useRealTimers()
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('commits completion before idle cleanup and preserves it on cleanup failure (shared=%s)', async shared => {
|
||||
for (const failure of ['stop', 'reconcile'] as const) {
|
||||
const fixture = await ownedHarness(shared)
|
||||
|
||||
@@ -440,7 +440,8 @@ export class RuntimeAcpBackend {
|
||||
'runtime/preparePrompt': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#prepare(params, context)),
|
||||
'runtime/startPrompt': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)),
|
||||
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context))
|
||||
.then(({ started }) => started),
|
||||
'runtime/attachPrompt': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)),
|
||||
'runtime/respondToQuestion': (params, context) =>
|
||||
@@ -1441,7 +1442,7 @@ export class RuntimeAcpBackend {
|
||||
return completion
|
||||
}
|
||||
|
||||
async #startOwnedPrompt(
|
||||
#startOwnedPrompt(
|
||||
params: unknown,
|
||||
context: ProtocolMethodContext
|
||||
) {
|
||||
@@ -1464,7 +1465,7 @@ export class RuntimeAcpBackend {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
return existing
|
||||
return { started: Promise.resolve(existing) }
|
||||
}
|
||||
if (
|
||||
this.#options.semanticPrompts === undefined ||
|
||||
@@ -1523,13 +1524,20 @@ export class RuntimeAcpBackend {
|
||||
binding.poisoned ? 'outcome-unknown' : status
|
||||
})
|
||||
}
|
||||
const result = await binding.ownedAcp.start(request)
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
return result
|
||||
const owner = binding.ownedAcp
|
||||
// Native initialization must not hold the global control queue: cancellation,
|
||||
// watchdogs and other bindings still need to make progress while it waits.
|
||||
const started = owner.start(request).then(result => {
|
||||
if (binding.ownedAcp === owner && binding.activeOperationId === request.operationId) {
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
return { started }
|
||||
}
|
||||
|
||||
#attachOwnedPrompt(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import {
|
||||
@@ -263,6 +264,16 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function stalledFetch(request: Request): Promise<Response> {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
// Like a live fetch, retain the Request until cancellation. Keeping only its
|
||||
// signal lets Undici's weakly referenced controller disappear during GC.
|
||||
const abort = (): void => reject(request.signal.reason);
|
||||
if (request.signal.aborted) abort();
|
||||
else request.signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function embeddedRuntime(
|
||||
client: ReturnType<typeof createOpencodeClient>,
|
||||
overrides: Partial<ConstructorParameters<typeof OpenCodeRuntime>[0]> = {},
|
||||
@@ -3323,6 +3334,35 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a stalled SDK fetch until abort even under garbage collection", () => {
|
||||
const output = execFileSync(process.execPath, ["--expose-gc", "--input-type=module", "-e", `
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import { setImmediate as turn } from 'node:timers/promises';
|
||||
const stalledFetch = ${stalledFetch.toString()};
|
||||
const source = new AbortController();
|
||||
let requestSignal, requestRef, result;
|
||||
const sdk = createOpencodeClient({ baseUrl: 'http://127.0.0.1:1', throwOnError: false,
|
||||
fetch: request => {
|
||||
requestSignal = request.signal;
|
||||
requestRef = new WeakRef(request);
|
||||
return stalledFetch(request);
|
||||
}
|
||||
});
|
||||
const operation = sdk.mcp.disconnect({ name: 'gc-fixture' }, { signal: source.signal });
|
||||
operation.then(value => { result = value; });
|
||||
while (!requestSignal) await turn();
|
||||
for (let i = 0; i < 10; i++) { await turn(); gc(); await turn(); }
|
||||
const requestRetained = Boolean(requestRef.deref());
|
||||
source.abort(new Error('controlled deadline'));
|
||||
await turn();
|
||||
console.log(JSON.stringify({ requestRetained, sourceAborted: source.signal.aborted,
|
||||
requestAborted: requestSignal.aborted, sdkSettledWithError: Boolean(result?.error) }));
|
||||
`], { encoding: "utf8", timeout: 4_000 });
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
requestRetained: true, sourceAborted: true, requestAborted: true, sdkSettledWithError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
|
||||
"handles installed SDK fetch results: %s", async (scenario) => {
|
||||
const setup = runClient([
|
||||
@@ -3344,7 +3384,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
order.push("disconnect");
|
||||
signals.push(request.signal);
|
||||
if (scenario === "cleanup-transport") throw failure;
|
||||
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
|
||||
if (scenario === "cleanup-timeout") return stalledFetch(request);
|
||||
return Response.json(scenario !== "cleanup-false");
|
||||
}
|
||||
order.push("add");
|
||||
@@ -4680,6 +4720,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it.each(["rejection", "error-result", "timeout"])("settles failed submission with silent SSE: %s", async (failure) => {
|
||||
vi.useFakeTimers();
|
||||
const setup = runClient([]);
|
||||
const controller = new AbortController();
|
||||
let readerClosed = false;
|
||||
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
|
||||
stream: (async function* () {
|
||||
try {
|
||||
yield* [];
|
||||
await new Promise<void>((resolve) => {
|
||||
if (options?.signal?.aborted) resolve();
|
||||
else options?.signal?.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
} finally { readerClosed = true; }
|
||||
})(),
|
||||
}) as never);
|
||||
vi.mocked(setup.session.promptAsync).mockImplementation(async () => {
|
||||
if (failure === "timeout") return await new Promise(() => {});
|
||||
if (failure === "error-result") return { error: { data: { message: "submission rejected" } } } as never;
|
||||
throw new Error("submission rejected");
|
||||
});
|
||||
const runtime = new OpenCodeRuntime(options({ embedded: false, baseUrl: "http://127.0.0.1:4096" }), {
|
||||
createClient: () => setup.client, controlRequestTimeoutMs: 10,
|
||||
});
|
||||
let settled = false;
|
||||
let error: unknown;
|
||||
const pending = (async () => {
|
||||
for await (const event of runtime.run({ requestId: "silent", conversationId: "silent", prompt: "test" }, controller.signal)) { void event; }
|
||||
})().catch((failure: unknown) => { error = failure; }).finally(() => { settled = true; });
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(settled).toBe(true);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe(failure === "timeout" ? "OpenCode 提交请求超时" : "submission rejected");
|
||||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||||
expect(setup.session.abort).toHaveBeenCalledOnce();
|
||||
expect(readerClosed).toBe(true);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await pending;
|
||||
await runtime.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a rejected async prompt instead of reporting success", async () => {
|
||||
const { client, session } = runClient([
|
||||
{
|
||||
|
||||
@@ -2559,6 +2559,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const submittedChecklistCalls = new Set<string>();
|
||||
const pendingChecklistUpdates: RuntimeChecklist[] = [];
|
||||
let runFailed = false;
|
||||
let submissionFailure: { error: unknown } | undefined;
|
||||
try {
|
||||
const promptText = promptWithUntrustedConversationHistory(
|
||||
request,
|
||||
@@ -2643,7 +2644,15 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
),
|
||||
signal,
|
||||
);
|
||||
prompt.catch(() => undefined);
|
||||
void prompt.then((result) => {
|
||||
if (result.error) {
|
||||
throw new Error(opencodeErrorMessage(result.error, "OpenCode 提交请求失败"));
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
submissionFailure = { error };
|
||||
// Wake a silent SSE reader without cancelling any peer subscription.
|
||||
subscriptionController.abort(error);
|
||||
});
|
||||
|
||||
const repliedPermissionIds = new Set<string>();
|
||||
const ownedSessions = new Set([sessionId]);
|
||||
@@ -3174,7 +3183,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
throw submissionFailure?.error ?? error;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortSession);
|
||||
// Keep the conversation locked until its session-wide abort settles.
|
||||
|
||||
@@ -12378,11 +12378,11 @@ export class AssistantDatabase {
|
||||
return { summary: this.reviewSummary(request.scope, 'supervisor'), background: this.reviewBackground(request.scope) }
|
||||
}
|
||||
|
||||
async supervisionCandidates(request: SupervisionRunRequest): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
|
||||
async supervisionCandidates(request: SupervisionRunRequest, batch?: { projectId: string; crossProject: boolean }): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('reviewCandidates', [request])
|
||||
if (reader) return reader.call('reviewCandidates', [request, batch])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.listSupervisionCandidates(request)
|
||||
return this.listSupervisionCandidates(request, batch)
|
||||
}
|
||||
|
||||
supervisionSuggestions(): SupervisionSuggestionStore {
|
||||
|
||||
@@ -211,7 +211,17 @@ function fixture(content = 'Atlas and Beacon are separate projects.') {
|
||||
const service = (responseKiB = 1024) => createProductionSupervisorService(db, async () => ({ supervisorModelConcurrency: 1,
|
||||
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, responseKiB } }),
|
||||
async () => ({ runtimeId: 'model', capability: 'chat', run } as AgentRuntime), pool)
|
||||
return { db, request, respond, service }
|
||||
// Project ownership comes from timeline events, not the scope that first saved an entity.
|
||||
const associateCandidates = () => {
|
||||
const sql = (db as unknown as { requireDatabase(): DatabaseSync }).requireDatabase()
|
||||
const id = randomUUID()
|
||||
sql.prepare(`INSERT INTO supervision_events (id, story_line_id, result_id, occurred_at, title, description, event_type, project_id)
|
||||
SELECT ?, story_lines.id, supervision_results.id, ?, 'Seed ownership', '', 'discussion', ?
|
||||
FROM story_lines, supervision_results LIMIT 1`).run(id, request.timeRange.from, db.listProjects()[0]!.id)
|
||||
sql.prepare('INSERT INTO supervision_event_entities (event_id, entity_id, change_type) SELECT ?, id, ? FROM supervision_entities').run(id, 'associated')
|
||||
sql.prepare('INSERT INTO supervision_story_assigned VALUES (?)').run(id)
|
||||
}
|
||||
return { db, request, respond, service, associateCandidates }
|
||||
}
|
||||
|
||||
it('records supervisor model usage under a hidden supervision task', async () => {
|
||||
@@ -237,6 +247,7 @@ it('production factory normalizes two absent identities, then reuses exact candi
|
||||
const first = await f.service().run(f.request)
|
||||
expect(first.output.entities).toHaveLength(2)
|
||||
for (const entity of first.output.entities) expect(entity).not.toHaveProperty('persistedId')
|
||||
f.associateCandidates()
|
||||
const candidates = f.db.listSupervisionCandidates(f.request)
|
||||
expect(candidates).toHaveLength(2)
|
||||
expect(f.respond.mock.calls[0]![0]).toContain('KNOWN ENTITIES is empty. Every entity is new; omit candidateRef')
|
||||
@@ -256,6 +267,7 @@ it('retains candidate mappings across leaf batches, failed merge and resume with
|
||||
const f = fixture('Atlas and Beacon. '.repeat(100))
|
||||
f.db.saveSupervisionResult({ request: f.request, evidence: [], output: { ...empty,
|
||||
entities: ['Atlas', 'Beacon'].map(label => ({ id: label, label, description: label, sourceReferenceIds: [] })) } })
|
||||
f.associateCandidates()
|
||||
const candidates = f.db.listSupervisionCandidates(f.request)
|
||||
const original = f.respond.getMockImplementation()!
|
||||
let failMerge = true
|
||||
@@ -346,6 +358,7 @@ it('reuses actual legacy text-key shapes through strict UUID aliases and preserv
|
||||
sql.prepare(`INSERT INTO supervision_entities (id, story_line_id, canonical_label, description, confirmation_state, updated_at, source_reference_ids_json)
|
||||
VALUES (?, ?, ?, 'Human description', 'confirmed', ?, '[]')`).run(id, String(story.id), id, new Date().toISOString())
|
||||
}
|
||||
f.associateCandidates()
|
||||
const candidates = f.db.listSupervisionCandidates(f.request)
|
||||
const legacy = candidates.filter(candidate => candidate.storageId)
|
||||
expect(legacy).toHaveLength(2)
|
||||
|
||||
@@ -126,7 +126,7 @@ export function createProductionSupervisorService(
|
||||
background: request => database.reviewBackground(request.scope),
|
||||
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
|
||||
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
|
||||
candidates: request => database.supervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
|
||||
candidates: (request, batch) => database.supervisionCandidates(request, batch), save: async result => database.saveSupervisionResult(result)
|
||||
}, { database: () => database.supervisionReviewStore(),
|
||||
initialize: (runId, state, signal) => database.initializeSupervisionReview(runId, state, signal),
|
||||
resume: (runId, signal) => database.resumeSupervisionReview(runId, signal),
|
||||
|
||||
@@ -247,3 +247,37 @@ it('runs after publication inside the review and records failure without failing
|
||||
expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Timeline'])
|
||||
await expect(service.run(request)).resolves.toMatchObject({ status: 'no_change', coverage: { stories: { status: 'completed', calls: 0 } } })
|
||||
})
|
||||
|
||||
it.each(['remove', 'merge'] as const)('rejects an assignment whose selected story was changed by %s while awaiting the model', async action => {
|
||||
const f = await fixture()
|
||||
f.publish(f.a.id, ['First', 'Second'])
|
||||
await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Alpha' }, { key: 'new_2', level: 'feature', name: 'Beta' }],
|
||||
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_2' }] }), { kind: 'global' }, options)
|
||||
const [alpha, beta] = f.stories.list({ kind: 'global' })
|
||||
f.publish(f.a.id, ['New evidence'], 22)
|
||||
const model = async () => {
|
||||
f.stories.act(action === 'remove' ? { action, storyId: alpha!.id } : { action, storyId: alpha!.id, intoId: beta!.id })
|
||||
return JSON.stringify({ stories: [{ key: 'new_1', level: 'feature', name: 'Must roll back' }], assignments: [{ event: 'e_1', story: 'story_1' }] })
|
||||
}
|
||||
await expect(assignStories(f.stories, model, { kind: 'global' }, options)).rejects.toThrow('Story changed')
|
||||
expect(f.stories.pending({ kind: 'global' })).toHaveLength(1)
|
||||
expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Beta'])
|
||||
expect(f.sql.prepare("SELECT COUNT(*) AS n FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE s.status != 'current'").get()!.n).toBe(0)
|
||||
await assignStories(f.stories, answer({ assignments: [{ event: 'e_1', story: 'story_1' }] }), { kind: 'global' }, { ...options, threadEvents: 100 })
|
||||
expect(f.stories.pending({ kind: 'global' })).toEqual([])
|
||||
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['feature', 'thread'] as const)('rejects a split after its %s was removed without leaving new threads', async removed => {
|
||||
const f = await fixture()
|
||||
f.publish(f.a.id, ['One', 'Two', 'Three', 'Four'])
|
||||
await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Feature' }, { key: 'new_2', level: 'thread', name: 'Thread', parent: 'new_1' }],
|
||||
assignments: [1, 2, 3, 4].map(n => ({ event: `e_${n}`, story: n === 4 ? 'new_2' : 'new_1' })) }), { kind: 'global' }, { ...options, threadEvents: 4 })
|
||||
const [feature, thread] = f.stories.list({ kind: 'global' })
|
||||
const input = f.stories.splitInput(feature!.id, 8000)
|
||||
f.stories.act({ action: 'remove', storyId: removed === 'feature' ? feature!.id : thread!.id })
|
||||
const before = f.stories.list({ kind: 'global' })
|
||||
expect(() => f.stories.split(input, { threads: [], moves: [{ event: 'e_1', thread: 'thread_1' }] }, 4)).toThrow('Story changed')
|
||||
expect(f.stories.list({ kind: 'global' })).toEqual(before)
|
||||
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -236,6 +236,7 @@ export class SupervisionStoryStore {
|
||||
VALUES (?, ?, ?, 'thread', ?, ?, ?, ?)`).run(id, String(input.feature.project_id), featureId, thread.name, thread.description, now, now)
|
||||
ids.set(thread.key, id); existing.set(name, id); created++
|
||||
}
|
||||
this.assertCurrent([featureId, ...output.moves.flatMap(item => ids.get(item.thread) ?? [])])
|
||||
const move = this.db.prepare('UPDATE supervision_event_stories SET story_id = ? WHERE event_id = ? AND story_id = ? AND is_primary = 1 AND user_set = 0')
|
||||
for (const item of output.moves) {
|
||||
const id = ids.get(item.thread)
|
||||
@@ -252,6 +253,14 @@ export class SupervisionStoryStore {
|
||||
.map(row => `${row.parent}|${nameKey(String(row.name))}`))
|
||||
}
|
||||
|
||||
/** Check model-selected destinations under the caller's write transaction. */
|
||||
private assertCurrent(ids: string[]): void {
|
||||
const unique = [...new Set(ids)]
|
||||
const count = this.db.prepare(`SELECT COUNT(*) AS n FROM supervision_stories
|
||||
WHERE id IN (SELECT value FROM json_each(?)) AND status = 'current'`).get(JSON.stringify(unique))!.n
|
||||
if (Number(count) !== unique.length) throw new Error('Story changed while organizing; retry with current stories')
|
||||
}
|
||||
|
||||
/** Applies one validated model answer for one project chunk in a single transaction. */
|
||||
apply(projectId: string, events: PendingEvent[], candidates: Row[], output: z.infer<typeof outputSchema>, options: Pick<StoryAssignmentOptions, 'crossProject' | 'threadEvents'>, others: Row[] = []): Omit<StoryAssignmentResult, 'calls' | 'split'> {
|
||||
const eventRefs = new Map(events.map((event, index) => [`e_${index + 1}`, event]))
|
||||
@@ -336,6 +345,12 @@ export class SupervisionStoryStore {
|
||||
ids.set(story.key, id)
|
||||
created++
|
||||
}
|
||||
const selected = [
|
||||
...output.assignments.flatMap(item => [item.story, ...(item.related ?? [])]),
|
||||
...output.stories.map(item => item.parent), ...output.concluded.map(item => item.story),
|
||||
...output.links.flatMap(item => [item.cross, item.feature])
|
||||
]
|
||||
this.assertCurrent(selected.flatMap(ref => ref ? ids.get(ref) ?? (otherRefs.has(ref) ? String(otherRefs.get(ref)!.id) : []) : []))
|
||||
const link = this.db.prepare('INSERT OR IGNORE INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, ?)')
|
||||
const mark = this.db.prepare('INSERT OR IGNORE INTO supervision_story_assigned (event_id) VALUES (?)')
|
||||
let assigned = 0, unassigned = 0
|
||||
|
||||
@@ -73,9 +73,9 @@ export function supersedeReextractedEvents(db: DatabaseSync, resultId: string):
|
||||
*/
|
||||
export function timelineCandidates(db: DatabaseSync, projectId: string, crossProject: boolean): Array<{ id: string; label: string; description: string }> {
|
||||
return db.prepare(`SELECT e.id, e.canonical_label AS label, e.description FROM supervision_entities e
|
||||
WHERE e.confirmation_state != 'revoked' AND (? = 1 OR EXISTS (
|
||||
SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id
|
||||
WHERE ee.entity_id = e.id AND ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?))
|
||||
WHERE e.confirmation_state != 'revoked' AND (? = 1 OR e.id IN (
|
||||
SELECT ee.entity_id FROM supervision_events ev JOIN supervision_event_entities ee ON ee.event_id = ev.id
|
||||
WHERE ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?))
|
||||
ORDER BY e.updated_at DESC, e.id LIMIT 100`).all(crossProject ? 1 : 0, projectId) as Array<{ id: string; label: string; description: string }>
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,76 @@ it('publishes full worker-selected coverage atomically and skips it on the next
|
||||
expect(calls).toBe(before)
|
||||
})
|
||||
|
||||
it.each([false, true])('production worker preserves batch candidate isolation with crossProject=%s', async crossProject => {
|
||||
const f = await fixture()
|
||||
const empty = { summary: 'Seed', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
const projects = f.db.listProjects()
|
||||
for (const project of projects) f.db.saveSupervisionResult({
|
||||
request: { ...f.state.request, scope: project.id === f.project.id ? { kind: 'global' } : { kind: 'projects', projectIds: [project.id] } },
|
||||
evidence: [{ id: 's', sourceType: 'conversation', sourceId: randomUUID(), title: 'Seed', content: 'Seed',
|
||||
occurredAt: f.state.request.timeRange.from, locator: { projectId: project.id } }],
|
||||
output: { ...empty, entities: [{ id: 'entity', label: project.id, description: 'Owned knowledge', sourceReferenceIds: ['s'] }],
|
||||
events: [{ title: 'Seed', description: '', occurredAt: f.state.request.timeRange.from, eventType: 'discussion', entityIds: ['entity'], sourceReferenceIds: ['s'] }] }
|
||||
})
|
||||
f.sql.exec('INSERT INTO supervision_story_assigned SELECT id FROM supervision_events')
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanup.push(async () => pool.dispose())
|
||||
const seen = new Set<string>()
|
||||
const runtime = { async *run(input: { prompt: string }) {
|
||||
if (!input.prompt.includes('These inputs are navigation summaries')) {
|
||||
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as Array<{ locator: { projectId: string } }>
|
||||
const projectId = evidence[0]!.locator.projectId
|
||||
const candidates = JSON.parse(input.prompt.split('KNOWN ENTITIES:\n\n')[1]!.split('\n\nPREVIOUS SUMMARY')[0]!) as Array<{ label: string }>
|
||||
expect(candidates.map(item => item.label).sort()).toEqual((crossProject ? projects.map(p => p.id) : [projectId]).sort())
|
||||
seen.add(projectId)
|
||||
}
|
||||
yield { type: 'text', delta: JSON.stringify(empty) }
|
||||
yield { type: 'done' }
|
||||
}, async dispose() {} } as unknown as AgentRuntime
|
||||
const service = createProductionSupervisorService(f.db, async () => ({ supervisionReview: supervisionReviewSettingsSchema.parse({ crossProject }) }), async () => runtime, pool)
|
||||
await expect(service.run(f.state.request)).resolves.toMatchObject({ status: 'completed', coverage: { sources: 422 } })
|
||||
expect([...seen].sort()).toEqual(projects.map(p => p.id).sort())
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_entities').get()!.n).toBe(2)
|
||||
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['paused', 'cancelled'] as const)('honors %s during the final production candidate read before publishing', async action => {
|
||||
const f = await fixture()
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanup.push(async () => pool.dispose())
|
||||
const runtime = { async *run() {
|
||||
yield { type: 'text', delta: JSON.stringify({ summary: 'Saved leaf', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
|
||||
yield { type: 'done' }
|
||||
}, async dispose() {} } as unknown as AgentRuntime
|
||||
const service = createProductionSupervisorService(f.db, async () => ({}), async () => runtime, pool)
|
||||
const candidates = f.db.supervisionCandidates.bind(f.db)
|
||||
let stopped = false
|
||||
let cancellation: Promise<void> | undefined
|
||||
vi.spyOn(f.db, 'supervisionCandidates').mockImplementation(async (...args) => {
|
||||
const result = await candidates(...args)
|
||||
const runId = service.execution().runId!
|
||||
if (!stopped && f.db.supervisionReviewStore().progress(runId).phase === 'saving') {
|
||||
stopped = true
|
||||
if (action === 'paused') service.pause(runId)
|
||||
else cancellation = service.cancel(runId)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const result = await service.run(f.state.request)
|
||||
await cancellation
|
||||
expect(stopped).toBe(true)
|
||||
expect(result.status).toBe(action)
|
||||
expect(f.db.listSupervisionResults()).toEqual([])
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
|
||||
const saved = f.db.supervisionReviewStore().batches(result.runId!, 1000)
|
||||
expect(saved.length).toBeGreaterThan(0)
|
||||
if (action === 'paused') {
|
||||
await expect(service.resume(result.runId!)).resolves.toMatchObject({ status: 'completed' })
|
||||
expect(f.db.supervisionReviewStore().batches(result.runId!, 1000)).toEqual(saved)
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(422)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['crash', 'close'] as const)('settles writer %s before releasing pending work and can rebuild after reopening', async action => {
|
||||
const f = await fixture()
|
||||
const id = f.db.startSupervisionRun(f.state.request)
|
||||
|
||||
@@ -487,6 +487,7 @@ export class SupervisorService {
|
||||
for (const batch of saved) savedProjects.add(batch.projectId)
|
||||
}
|
||||
for (const projectId of savedProjects) await candidatesFor(projectId)
|
||||
controller.signal.throwIfAborted()
|
||||
const result: StoredSupervisionResult = { runId, request, candidates: [...offered.values()],
|
||||
evidence: progress.batches === 1 ? first!.evidence : [], output: root!.output,
|
||||
status: 'completed', coverage: { ...db.progress(runId), complete: true } }
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createServer } from 'node:http'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ChannelService } from './channel-service'
|
||||
import { SqliteChannelOutbox } from './sqlite-channel-state'
|
||||
import {
|
||||
DingTalkChannelDriver,
|
||||
createOfficialDingTalkTransportFactory
|
||||
@@ -46,6 +50,65 @@ function envelope(
|
||||
}
|
||||
|
||||
describe('DingTalkChannelDriver', () => {
|
||||
it('retries an HTTP 200 business failure through the production transport and SQLite outbox', async () => {
|
||||
const requests: string[] = []
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += String(chunk)
|
||||
requests.push(body)
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify(requests.length === 1
|
||||
? { errcode: 130101, errmsg: 'synthetic rate limit' }
|
||||
: { errcode: 0, errmsg: 'ok' }))
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as { port: number }
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize('C:\\Workspace')
|
||||
const outbox = new SqliteChannelOutbox(database)
|
||||
let receive!: (message: { headers: { messageId: string }; data: string }) => void
|
||||
const driver = new DingTalkChannelDriver({
|
||||
clientId: 'client-id', clientSecret: 'synthetic-secret', allowedSenderIds: ['user-1'],
|
||||
transportFactory: createOfficialDingTalkTransportFactory({
|
||||
clientFactory: async () => ({
|
||||
registerCallbackListener: (_topic, listener) => { receive = listener },
|
||||
socketCallBackResponse: () => undefined,
|
||||
connect: async () => undefined, disconnect: () => undefined
|
||||
}),
|
||||
fetchImpl: (_input, init) => fetch(`http://127.0.0.1:${address.port}/reply`, init)
|
||||
})
|
||||
})
|
||||
const executor = vi.fn(async () => ({ status: 'completed', output: 'synthetic reply' }))
|
||||
const failure = vi.fn()
|
||||
const service = new ChannelService(driver, executor, {
|
||||
allowedSenderIds: ['user-1'], allowGroupMessages: true, outbox, onDeliveryFailure: failure
|
||||
})
|
||||
try {
|
||||
await service.start()
|
||||
receive(envelope())
|
||||
await vi.waitFor(() => expect(failure).toHaveBeenCalledOnce())
|
||||
expect(failure.mock.calls[0]?.[0]).toMatchObject({ message: '钉钉回复请求失败 (130101)' })
|
||||
expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'failed', attempts: 1 })
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(2), { timeout: 3000 })
|
||||
await vi.waitFor(() => expect(outbox.listUndelivered()).toEqual([]))
|
||||
expect(requests[1]).toBe(requests[0])
|
||||
expect(executor).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await service.stop()
|
||||
database.close()
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['', '{}', 'null', 'not JSON', JSON.stringify({ errcode: 0, padding: 'x'.repeat(65_536) })])(
|
||||
'rejects invalid or oversized successful HTTP responses (%#)', async (body) => {
|
||||
const transport = await createOfficialDingTalkTransportFactory({
|
||||
fetchImpl: async () => new Response(body)
|
||||
}).create({ clientId: 'synthetic', clientSecret: 'synthetic' })
|
||||
await expect(transport.replyText(SESSION_WEBHOOK, 'reply')).rejects.toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it('adapts group text and consumes only the issued reply context', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const factory: DingTalkTransportFactory = {
|
||||
@@ -133,7 +196,7 @@ describe('DingTalkChannelDriver', () => {
|
||||
connect: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }))
|
||||
const fetchImpl = vi.fn(async () => Response.json({ errcode: 0, errmsg: 'ok' }))
|
||||
const factory = createOfficialDingTalkTransportFactory({
|
||||
clientFactory: async (credentials) => {
|
||||
expect(credentials).toEqual({
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ChannelResultMessage
|
||||
} from '../../shared/channel-contracts'
|
||||
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
|
||||
import { readBoundedResponseText } from '../../shared/node/bounded-response'
|
||||
import {
|
||||
DingTalkDriver,
|
||||
type DingTalkStreamEnvelope,
|
||||
@@ -133,6 +134,7 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport {
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort(new Error('钉钉回复超时'))
|
||||
}, REPLY_TIMEOUT_MS)
|
||||
let result: unknown
|
||||
try {
|
||||
const response = await this.fetchImpl(sessionWebhook, {
|
||||
method: 'POST',
|
||||
@@ -144,22 +146,26 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport {
|
||||
redirect: 'error',
|
||||
signal: controller.signal
|
||||
})
|
||||
const responseLength = Number(
|
||||
response.headers.get('content-length') ?? '0'
|
||||
)
|
||||
if (
|
||||
!response.ok ||
|
||||
!Number.isFinite(responseLength) ||
|
||||
responseLength > MAXIMUM_RESPONSE_BYTES
|
||||
) {
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel()
|
||||
throw new Error('钉钉回复请求失败')
|
||||
}
|
||||
await response.body?.cancel()
|
||||
result = JSON.parse(await readBoundedResponseText(response, {
|
||||
maxBytes: MAXIMUM_RESPONSE_BYTES,
|
||||
tooLargeMessage: '钉钉回复响应过大',
|
||||
truncatedMessage: '钉钉回复响应不完整'
|
||||
}))
|
||||
} catch {
|
||||
throw new Error('钉钉回复请求失败')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (typeof result !== 'object' || result === null || !('errcode' in result) || result.errcode !== 0) {
|
||||
const code = typeof result === 'object' && result !== null && 'errcode' in result &&
|
||||
typeof result.errcode === 'number' && Number.isSafeInteger(result.errcode)
|
||||
? ` (${result.errcode})` : ''
|
||||
throw new Error(`钉钉回复请求失败${code}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,12 +251,8 @@ export class DingTalkChannelDriver implements ChannelDriver {
|
||||
throw new Error('钉钉回复上下文无效或已过期')
|
||||
}
|
||||
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
await this.driver.reply(record.context, resultText(message))
|
||||
} catch {
|
||||
throw new Error('钉钉消息回复失败')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
await this.driver.reply(record.context, resultText(message))
|
||||
if (!message.attachments?.length) {
|
||||
this.replyContexts.delete(message.eventId)
|
||||
}
|
||||
|
||||
+46
-27
@@ -36,6 +36,9 @@ import type {
|
||||
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { ChannelSettingsStore } from './channels/channel-settings-store'
|
||||
import { ChannelService } from './channels/channel-service'
|
||||
import { SqliteChannelOutbox } from './channels/sqlite-channel-state'
|
||||
import type { ChannelDriver, ChannelInboundHandler } from './channels/channel-driver'
|
||||
import { SubagentService, createSubagentRuntime } from './assistant/subagent-service'
|
||||
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
@@ -12583,8 +12586,20 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
throw new Error('Expected channel executor')
|
||||
}
|
||||
|
||||
await expect(
|
||||
executor(
|
||||
const outboxDatabase = new AssistantDatabase(':memory:')
|
||||
outboxDatabase.initialize('C:\\Workspace')
|
||||
const outbox = new SqliteChannelOutbox(outboxDatabase)
|
||||
let inbound!: ChannelInboundHandler
|
||||
const send = vi.fn<ChannelDriver['send']>(async () => undefined)
|
||||
const service = new ChannelService({
|
||||
channel: 'wecom',
|
||||
start: (handler) => { inbound = handler },
|
||||
send,
|
||||
stop: () => undefined
|
||||
}, executor, { allowedSenderIds: ['user-1'], outbox })
|
||||
try {
|
||||
await service.start()
|
||||
await inbound(
|
||||
{
|
||||
channel: 'wecom',
|
||||
eventId: 'event-generated-image',
|
||||
@@ -12594,34 +12609,38 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
text: '生成结果图',
|
||||
mentioned: false
|
||||
},
|
||||
new AbortController().signal
|
||||
() => undefined
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'completed',
|
||||
attachments: [
|
||||
{
|
||||
name: '结果图.png',
|
||||
mimeType: 'image/png',
|
||||
size: image.byteLength,
|
||||
kind: 'image',
|
||||
dataBase64: image.toString('base64')
|
||||
}
|
||||
],
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000499'
|
||||
]
|
||||
})
|
||||
expect(
|
||||
harness.assistantDatabase.appendRemoteConversationMessage
|
||||
).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000499'
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
|
||||
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
attachments: [
|
||||
{
|
||||
name: '结果图.png',
|
||||
mimeType: 'image/png',
|
||||
size: image.byteLength,
|
||||
kind: 'image',
|
||||
dataBase64: image.toString('base64')
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
await harness.dispose()
|
||||
expect(send.mock.calls[0]?.[0]).not.toHaveProperty('artifactIds')
|
||||
expect(outbox.listUndelivered()).toEqual([])
|
||||
expect(
|
||||
harness.assistantDatabase.appendRemoteConversationMessage
|
||||
).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000499'
|
||||
]
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
await service.stop()
|
||||
outboxDatabase.close()
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a bounded result file only when the remote user explicitly requests one', async () => {
|
||||
|
||||
+2
-1
@@ -3056,7 +3056,8 @@ export function registerIpcHandlers(
|
||||
status:
|
||||
result.status === 'completed' ? 'completed' : 'failed'
|
||||
})
|
||||
return result
|
||||
const { status, output, error, attachments } = result
|
||||
return { status, output, error, attachments }
|
||||
} finally {
|
||||
releaseRemoteContexts()
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ if (kind === 'checkpoint') {
|
||||
const scope = (request as ReviewState['request']).scope
|
||||
return database.readSnapshot(() => ({ summary: database.reviewSummary(scope, 'supervisor'), background: database.reviewBackground(scope) }))
|
||||
},
|
||||
reviewCandidates: ([request]) => database.listSupervisionCandidates(request as ReviewState['request'])
|
||||
reviewCandidates: ([request, batch]) => database.listSupervisionCandidates(request as ReviewState['request'],
|
||||
batch as Parameters<AssistantDatabase['listSupervisionCandidates']>[1])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,39 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ChannelSettingsSection', () => {
|
||||
it.each(['error', 'stopped', 'running', 'starting', 'disabled'] as const)(
|
||||
'only reapplies unchanged enabled Telegram settings when pushed state is %s and needs recovery',
|
||||
async (state) => {
|
||||
const configured: ChannelSettingsSnapshot = {
|
||||
...snapshot,
|
||||
telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted', status: { state: 'running' } }
|
||||
}
|
||||
let receive!: (change: ChannelRuntimeStatusChange) => void
|
||||
const apply = vi.fn(async () => configured)
|
||||
const onNotify = vi.fn()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: { ...bindingApi(), getSnapshot: async () => configured, apply,
|
||||
onStatusChanged: (listener: typeof receive) => { receive = listener; return () => undefined } },
|
||||
settings: settingsApi(), projects: { update: vi.fn(async () => projects[0]) }
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
renderChannelSettings({ initialChannel: 'telegram', onNotify })
|
||||
await screen.findByLabelText('TelegramBot Token')
|
||||
act(() => receive({ channel: 'telegram', status: { state } }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalled())
|
||||
if (state === 'error' || state === 'stopped') {
|
||||
expect(apply).toHaveBeenCalledWith({ telegram: {
|
||||
enabled: true, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
|
||||
} })
|
||||
} else {
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps pushed channel status newer than loading and updates reconnect errors without resetting drafts', async () => {
|
||||
let receive!: (change: ChannelRuntimeStatusChange) => void
|
||||
const unsubscribe = vi.fn()
|
||||
|
||||
@@ -1002,6 +1002,9 @@ export function ChannelSettingsSection({
|
||||
)
|
||||
return
|
||||
}
|
||||
const telegramState = (statusStore.getState().telegram ?? snapshot.telegram.status).state
|
||||
const restartTelegram = drafts.telegram.enabled &&
|
||||
(telegramState === 'error' || telegramState === 'stopped')
|
||||
const input: ChannelSettingsApply = {
|
||||
...(weixinEnabled === snapshot.weixin.enabled
|
||||
? {}
|
||||
@@ -1015,7 +1018,7 @@ export function ChannelSettingsSection({
|
||||
? { dingtalk: inputFor('dingtalk', drafts.dingtalk) }
|
||||
: {}),
|
||||
...(!snapshot.telegram.readOnly &&
|
||||
channelDraftChanged('telegram', drafts.telegram, snapshot)
|
||||
(channelDraftChanged('telegram', drafts.telegram, snapshot) || restartTelegram)
|
||||
? { telegram: inputFor('telegram', drafts.telegram) }
|
||||
: {})
|
||||
}
|
||||
|
||||
@@ -182,6 +182,91 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ChatHistoryPane windowing', () => {
|
||||
it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => {
|
||||
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
|
||||
const tool = { callId: 'read', name: 'read', summary: 'Read file', state: 'completed' as const, input: '{"path":"file"}', output: 'File contents' }
|
||||
Object.assign(messages[0]!, { tools: [tool], subagents: [{ childTaskId: 'child', expertId: 'expert', expertName: 'Expert',
|
||||
routingMode: 'native', state: 'completed', progress: [{ id: 'child-tool', type: 'tool', tool }] }] })
|
||||
const { container, chat } = setup(messages)
|
||||
measure()
|
||||
const visit = async (index: number) => {
|
||||
act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) })
|
||||
await frame()
|
||||
measure()
|
||||
}
|
||||
const disclosure = (index: number) => container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index}"] details`)!
|
||||
await visit(0)
|
||||
const original = disclosure(0)
|
||||
act(() => { original.open = true; fireEvent(original, new Event('toggle')) })
|
||||
const toggle = (selector: string, open: boolean) => {
|
||||
const element = container.querySelector<HTMLDetailsElement>(`[data-message-id="m0"] ${selector}`)!
|
||||
act(() => { element.open = open; fireEvent(element, new Event('toggle')) })
|
||||
}
|
||||
toggle('.subagent-status-card', true)
|
||||
toggle('.subagent-status-card .tool-execution', true)
|
||||
toggle('.subagent-status-card .tool-execution__input', true)
|
||||
// Collapsing a subagent unmounts its children even before row eviction.
|
||||
toggle('.subagent-status-card', false)
|
||||
toggle('.subagent-status-card', true)
|
||||
expect(container.querySelector<HTMLDetailsElement>('.subagent-status-card .tool-execution__input')!.open).toBe(true)
|
||||
for (let index = 1; index <= 12; index++) {
|
||||
await visit(index * 100)
|
||||
const details = disclosure(index * 100)
|
||||
act(() => { details.open = true; fireEvent(details, new Event('toggle')) })
|
||||
expect(renderedIds(container).length).toBeLessThanOrEqual(maxRenderedMessageCount + 8)
|
||||
}
|
||||
expect(original.isConnected).toBe(false)
|
||||
await visit(0)
|
||||
expect(disclosure(0)).not.toBe(original)
|
||||
expect(disclosure(0).open).toBe(true)
|
||||
expect(container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .subagent-status-card')!.open).toBe(true)
|
||||
expect(container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .subagent-status-card .tool-execution__input')!.open).toBe(true)
|
||||
// The parent and child deliberately share a tool call ID, but not state.
|
||||
expect([...container.querySelectorAll<HTMLDetailsElement>('[data-message-id="m0"] .tool-execution')]
|
||||
.find(element => !element.closest('.subagent-status-card'))!.open).toBe(false)
|
||||
act(() => { disclosure(0).open = false; fireEvent(disclosure(0), new Event('toggle')) })
|
||||
await visit(1500)
|
||||
await visit(0)
|
||||
expect(disclosure(0).open).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['legacy', 'blocks'] as const)('preserves %s streaming reasoning defaults and manual choices through eviction and completion', async representation => {
|
||||
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
|
||||
messages[0] = { ...messages[0]!, state: 'streaming' }
|
||||
if (representation === 'blocks') Object.assign(messages[0]!, { blocks: [{ id: 'reason', type: 'reasoning', content: 'Reasoning' }] })
|
||||
const { container, chat, props, rerender } = setup(messages)
|
||||
measure()
|
||||
const visit = async (index: number) => {
|
||||
act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) })
|
||||
await frame(); measure()
|
||||
}
|
||||
const details = () => container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .message-reasoning')!
|
||||
await visit(0)
|
||||
expect(details().open).toBe(true)
|
||||
act(() => { details().open = false; fireEvent(details(), new Event('toggle')) })
|
||||
for (let index = 1; index <= 10; index++) {
|
||||
await visit(index * 100)
|
||||
const other = container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index * 100}"] details`)!
|
||||
act(() => { other.open = true; fireEvent(other, new Event('toggle')) })
|
||||
}
|
||||
expect(details()).toBeNull()
|
||||
await visit(0)
|
||||
expect(details().open).toBe(false)
|
||||
act(() => { details().open = true; fireEvent(details(), new Event('toggle')) })
|
||||
for (let index = 1; index <= 10; index++) {
|
||||
await visit(index * 100)
|
||||
const other = container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index * 100}"] details`)!
|
||||
act(() => { other.open = true; fireEvent(other, new Event('toggle')) })
|
||||
}
|
||||
expect(details()).toBeNull()
|
||||
// Completion keeps the existing auto-close semantics rather than restoring
|
||||
// the streaming expansion on the next mount.
|
||||
const completed = messages.map((message, index) => index === 0 ? { ...message, state: 'complete' as const } : message)
|
||||
rerender(<ChatHistoryPane {...props} conversation={conversationOf(completed)} />)
|
||||
await visit(0)
|
||||
expect(details().open).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', async () => {
|
||||
const messages = makeMessages(2_000)
|
||||
const { container, chat } = setup(messages)
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
|
||||
import { isUnusedConversation, type Conversation } from "./chat-conversation";
|
||||
import { MessageExpansionProvider } from "./message-expansion";
|
||||
import {
|
||||
buildSegments,
|
||||
maxRenderedMessageCount,
|
||||
@@ -246,8 +247,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
// Rows kept mounted outside the range: the focused row, the first message
|
||||
// after the final "load earlier" step.
|
||||
const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]);
|
||||
// Rows the user changed (toggled a <details>, typed into a question card):
|
||||
// their local state would be lost if they unmounted. Bounded, oldest out.
|
||||
// Keep recent interactions warm (copy feedback, previews, typed answers).
|
||||
// Disclosure choices also survive eviction in MessageExpansionProvider.
|
||||
const [interactedMessageIds, setInteractedMessageIds] = useState<readonly string[]>([]);
|
||||
// Rows holding the start and end of a non-empty text selection.
|
||||
const [selectionMessageIds, setSelectionMessageIds] = useState<readonly string[]>([]);
|
||||
@@ -544,9 +545,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
|
||||
|
||||
// Rows the user interacts with keep their local state (open details, typed
|
||||
// question answers, "copied" feedback, Mermaid toggles) as they did when
|
||||
// every loaded row stayed mounted.
|
||||
// Keep a bounded set of recently interacted rows mounted. Disclosure state
|
||||
// lives outside the rows, while pending questions are independently pinned.
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!active || !list) return;
|
||||
@@ -1065,7 +1065,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<MessageExpansionProvider messages={messages} messageIndexes={messageIndexById}><div
|
||||
aria-hidden={active ? undefined : "true"}
|
||||
className="chat-history-pane"
|
||||
data-active={active ? "true" : "false"}
|
||||
@@ -1153,6 +1153,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
<ArrowDown aria-hidden="true" size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div></MessageExpansionProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AttachmentResultButton } from './AttachmentResultButton'
|
||||
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
|
||||
import { MessageExpansionScope, useMessageExpansion } from './message-expansion'
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
@@ -167,14 +168,17 @@ function formatAttachmentSize(size: number): string {
|
||||
}
|
||||
|
||||
function MessageReasoning({
|
||||
expansionId = 'reasoning',
|
||||
content,
|
||||
streaming
|
||||
}: {
|
||||
expansionId?: string
|
||||
content: string
|
||||
streaming: boolean
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [expanded, setExpanded] = useMessageExpansion(expansionId, streaming)
|
||||
|
||||
useEffect(() => {
|
||||
if (!streaming || !contentRef.current) {
|
||||
@@ -187,7 +191,7 @@ function MessageReasoning({
|
||||
}, [content, streaming])
|
||||
|
||||
return (
|
||||
<details className="message-reasoning" open={streaming}>
|
||||
<details className="message-reasoning" open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}>
|
||||
<summary>
|
||||
{streaming
|
||||
? t('chat.reasoning.streaming')
|
||||
@@ -239,12 +243,15 @@ const ToolDetail = memo(function ToolDetail({ label, content, formatJson = false
|
||||
)
|
||||
})
|
||||
|
||||
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pick<
|
||||
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, ...tool }: Pick<
|
||||
ToolActivity, 'name' | 'summary' | 'state' | 'input' | 'output' | 'error'
|
||||
> & {
|
||||
expansionId: string
|
||||
onCopy: CopyContent
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [expanded, setExpanded] = useMessageExpansion(`tool:${expansionId}`)
|
||||
const [inputExpanded, setInputExpanded] = useMessageExpansion(`tool-input:${expansionId}`)
|
||||
const summary = tool.summary.trim()
|
||||
// Only suppress known boilerplate; recovery hints and custom summaries remain visible.
|
||||
const summaryIdentity = summary.replace(
|
||||
@@ -258,6 +265,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic
|
||||
return (
|
||||
<li>
|
||||
<details
|
||||
open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}
|
||||
className={`tool-execution tool-execution--${tool.state}`}
|
||||
>
|
||||
<summary>
|
||||
@@ -290,7 +298,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic
|
||||
: tool.state === 'completed' ? 'chat.tools.emptyOutput' : 'chat.tools.noDetails')}</p>
|
||||
)}
|
||||
{tool.input && (
|
||||
<details className="tool-execution__input">
|
||||
<details className="tool-execution__input" open={inputExpanded} onToggle={event => setInputExpanded(event.currentTarget.open)}>
|
||||
<summary>{t('chat.tools.input')}</summary>
|
||||
<ToolDetail label={t('chat.tools.input')} content={tool.input} formatJson onCopy={onCopy} />
|
||||
</details>
|
||||
@@ -322,6 +330,7 @@ function ToolExecutionList({
|
||||
{tools.map((tool) => (
|
||||
<ToolExecutionRow
|
||||
key={tool.callId ?? tool.name}
|
||||
expansionId={tool.callId ?? tool.name}
|
||||
name={tool.name}
|
||||
summary={tool.summary}
|
||||
state={tool.state}
|
||||
@@ -348,7 +357,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
onCopy: CopyContent
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expanded, setExpanded] = useMessageExpansion('subagent')
|
||||
const StateIcon =
|
||||
subagent.state === 'completed'
|
||||
? CheckCircle2
|
||||
@@ -389,6 +398,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
return (
|
||||
<details
|
||||
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
||||
open={expanded}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary>
|
||||
@@ -450,6 +460,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
: item.block.type === 'reasoning' ? (
|
||||
<MessageReasoning
|
||||
key={item.block.id}
|
||||
expansionId={`reasoning:${item.block.id}`}
|
||||
content={item.block.content}
|
||||
streaming={false}
|
||||
/>
|
||||
@@ -513,13 +524,12 @@ function SubagentStatusList({
|
||||
className="subagent-status-list"
|
||||
>
|
||||
{subagents.map((subagent) => (
|
||||
<SubagentStatusCard
|
||||
key={subagent.childTaskId}
|
||||
<MessageExpansionScope key={subagent.childTaskId} id={`subagent:${subagent.childTaskId}`}><SubagentStatusCard
|
||||
renderHtml={renderHtml}
|
||||
subagent={subagent}
|
||||
questionFormId={pendingQuestions?.some((question) => question.childTaskId === subagent.childTaskId) ? questionFormId : undefined}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
/></MessageExpansionScope>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
@@ -581,6 +591,7 @@ function ChatMessageRowView({
|
||||
}: ChatMessageRowProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [citationsExpanded, setCitationsExpanded] = useMessageExpansion('citations')
|
||||
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
useEffect(() => () => clearTimeout(copyResetTimer.current), [])
|
||||
|
||||
@@ -867,6 +878,7 @@ function ChatMessageRowView({
|
||||
/>
|
||||
) : item.block.type === 'reasoning' ? (
|
||||
<MessageReasoning
|
||||
expansionId={`reasoning:${item.block.id}`}
|
||||
content={item.block.content}
|
||||
key={item.block.id}
|
||||
streaming={message.state === 'streaming'}
|
||||
@@ -1054,7 +1066,7 @@ function ChatMessageRowView({
|
||||
)}
|
||||
{message.sourceReferences &&
|
||||
message.sourceReferences.length > 0 && (
|
||||
<details className="message-citations">
|
||||
<details className="message-citations" open={citationsExpanded} onToggle={event => setCitationsExpanded(event.currentTarget.open)}>
|
||||
<summary>
|
||||
{t('chat.citations.view', {
|
||||
count: message.sourceReferences.length
|
||||
@@ -1272,7 +1284,7 @@ function ChatMessageRowView({
|
||||
export const ChatMessageRow = memo(function ChatMessageRow(
|
||||
props: ChatMessageRowProps
|
||||
): React.JSX.Element {
|
||||
return <ChatMessageRowView {...props} message={useLiveMessage(props.message)} />
|
||||
return <MessageExpansionScope id={props.message.id}><ChatMessageRowView {...props} message={useLiveMessage(props.message)} /></MessageExpansionScope>
|
||||
})
|
||||
|
||||
type ChatMessageWindowRowProps = ChatMessageRowProps & {
|
||||
|
||||
@@ -15,6 +15,66 @@ const selectHistory = (id: string) => {
|
||||
}
|
||||
|
||||
describe('SupervisorWorkspace', () => {
|
||||
it.each(['locale', 'review'] as const)('releases pending source state on %s refresh without letting its late response clear a newer request', async trigger => {
|
||||
let finishReview!: () => void
|
||||
const run = vi.fn(() => new Promise<void>(resolve => { finishReview = resolve }))
|
||||
let finishOld!: (value: unknown) => void
|
||||
let finishNew!: (value: unknown) => void
|
||||
const source = vi.fn()
|
||||
.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve }))
|
||||
.mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve }))
|
||||
const graph = vi.fn(async () => ({ storyLine: null,
|
||||
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
|
||||
entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }],
|
||||
eventSources: [{ event_id: 'event', source_id: 'source' }] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph, source, run } } as never
|
||||
render(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('button', { name: /Source/ })
|
||||
if (trigger === 'review') {
|
||||
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
}
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Source/ }))
|
||||
await waitFor(() => expect(source).toHaveBeenCalledTimes(1))
|
||||
await act(async () => { if (trigger === 'locale') await changeUiLocale('en-US'); else finishReview() })
|
||||
const refreshLabel = trigger === 'locale' ? 'Refresh' : '刷新'
|
||||
await waitFor(() => {
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('button', { name: /Source/ })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /Source/ }))
|
||||
await act(async () => finishOld({ title: 'Source', content: 'Obsolete source', occurredAt: result.createdAt }))
|
||||
expect(screen.queryByText('Obsolete source')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Source/ })).toBeDisabled()
|
||||
await act(async () => finishNew({ title: 'Source', content: 'Current source', occurredAt: result.createdAt }))
|
||||
expect(screen.getByText('Current source')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled()
|
||||
})
|
||||
|
||||
it.each(['entity', 'relation'] as const)('releases a pending %s action when locale refresh invalidates it', async kind => {
|
||||
let finish!: () => void
|
||||
const action = vi.fn(() => new Promise<void>(resolve => { finish = resolve }))
|
||||
const graph = vi.fn(async () => ({ storyLine: null,
|
||||
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
|
||||
entities: [{ id: 'entity', canonical_label: 'Entity', description: '', confirmation_state: 'automatic' }],
|
||||
relations: [{ id: 'relation', from_entity_id: 'entity', to_entity_id: 'entity', relation_type: 'related', reason: '', confirmation_state: 'automatic' }],
|
||||
sources: [], eventEntities: [], eventSources: [] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph, entityAction: action, relationAction: action } } as never
|
||||
render(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled())
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '图谱选择' }), { target: { value: kind } })
|
||||
fireEvent.click(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Entity/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: i18nResources['zh-CN'].heartbeat.supervisor.confirm }))
|
||||
await waitFor(() => expect(action).toHaveBeenCalledOnce())
|
||||
await act(async () => { await changeUiLocale('en-US') })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled())
|
||||
await act(async () => finish())
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('submits custom days and keeps reanalysis confirmation consistent in %s', async locale => {
|
||||
await changeUiLocale(locale)
|
||||
const copy = i18nResources[locale].heartbeat.supervisor
|
||||
|
||||
@@ -158,6 +158,7 @@ export function SupervisorWorkspace({
|
||||
const refresh = useCallback(async (requestedId = selectedResult.current) => {
|
||||
if (!api) return
|
||||
const generation = ++loadGeneration.current
|
||||
setPending(undefined)
|
||||
selectedResult.current = requestedId
|
||||
setLoading(true)
|
||||
setGraph(emptyGraph)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from './ChatTimeline'
|
||||
import { MessageExpansionProvider, MessageExpansionScope, useMessageExpansion } from './message-expansion'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
function Disclosure({ id, automaticOpen = false }: { id: string; automaticOpen?: boolean }) {
|
||||
const [open, setOpen] = useMessageExpansion(id, automaticOpen)
|
||||
return <button aria-label={id} aria-pressed={open} onClick={() => setOpen(!open)} />
|
||||
}
|
||||
|
||||
function captureMaps() {
|
||||
const maps = new Set<Map<unknown, unknown>>()
|
||||
const original = Map.prototype.set
|
||||
vi.spyOn(Map.prototype, 'set').mockImplementation(function (this: Map<unknown, unknown>, key, value) {
|
||||
if (value && typeof value.open === 'boolean' && typeof value.automaticOpen === 'boolean') maps.add(this)
|
||||
return original.call(this, key, value)
|
||||
})
|
||||
return () => [...maps].reduce((total, map) => total + map.size, 0)
|
||||
}
|
||||
|
||||
describe('message expansion retention', () => {
|
||||
it('keeps streaming defaults sparse and releases an unmounted manual collapse on completion', async () => {
|
||||
const entries = captureMaps()
|
||||
const message: Message = { id: 'm', content: '', reasoning: 'Reason', state: 'streaming', role: 'assistant', createdAt: 1 }
|
||||
const window = (current: Message, visible: boolean) => <MessageExpansionProvider messages={[current]} messageIndexes={new Map([['m', 0]])}>
|
||||
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning" automaticOpen={current.state === 'streaming'} /></MessageExpansionScope>}
|
||||
</MessageExpansionProvider>
|
||||
const view = render(window(message, true))
|
||||
expect(entries()).toBe(0)
|
||||
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(entries()).toBe(1)
|
||||
view.rerender(window(message, false))
|
||||
view.rerender(window(message, true))
|
||||
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
|
||||
view.rerender(window(message, false))
|
||||
const completed = { ...message, state: 'complete' as const }
|
||||
await act(async () => view.rerender(window(completed, false)))
|
||||
expect(entries()).toBe(0)
|
||||
view.rerender(window(completed, true))
|
||||
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('removes obsolete child and block identities without treating row eviction as deletion', async () => {
|
||||
const entries = captureMaps()
|
||||
const message: Message = { id: 'm', content: '', state: 'complete', role: 'assistant', createdAt: 1,
|
||||
blocks: [{ id: 'reason', type: 'reasoning', content: 'Reason' }],
|
||||
subagents: [{ childTaskId: 'child', expertId: 'expert', expertName: 'Expert', routingMode: 'native', state: 'completed' }] }
|
||||
const window = (current: Message, visible: boolean) => <MessageExpansionProvider messages={[current]} messageIndexes={new Map([['m', 0]])}>
|
||||
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning:reason" />
|
||||
<MessageExpansionScope id="subagent:child"><Disclosure id="subagent" /></MessageExpansionScope>
|
||||
</MessageExpansionScope>}
|
||||
</MessageExpansionProvider>
|
||||
const view = render(window(message, true))
|
||||
fireEvent.click(view.getByRole('button', { name: 'reasoning:reason' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'subagent' }))
|
||||
view.rerender(window(message, false))
|
||||
expect(entries()).toBe(2)
|
||||
await act(async () => view.rerender(window({ ...message, blocks: [], subagents: [] }, false)))
|
||||
expect(entries()).toBe(0)
|
||||
})
|
||||
|
||||
it('visits 10,000 untouched rows without retaining defaults, including absent citations', () => {
|
||||
const entries = captureMaps()
|
||||
const messages: Message[] = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
id: `m${index}`, content: 'Plain message', role: 'assistant', state: 'complete', createdAt: index,
|
||||
}))
|
||||
const messageIndexes = new Map(messages.map((message, index) => [message.id, index]))
|
||||
const window = (start: number) => <MessageExpansionProvider messages={messages} messageIndexes={messageIndexes}>
|
||||
{messages.slice(start, start + 80).map(message => <MessageExpansionScope key={message.id} id={message.id}>
|
||||
<Disclosure id="citations" />
|
||||
</MessageExpansionScope>)}
|
||||
</MessageExpansionProvider>
|
||||
const view = render(window(0))
|
||||
for (let start = 80; start < messages.length; start += 80) view.rerender(window(start))
|
||||
expect(view.container.querySelectorAll('button').length).toBeLessThanOrEqual(80)
|
||||
expect(entries()).toBe(0)
|
||||
})
|
||||
|
||||
it('retains only overrides across eviction and cleans removed messages and disclosures', async () => {
|
||||
const entries = captureMaps()
|
||||
const messages: Message[] = [{ id: 'm', content: '', role: 'assistant', state: 'complete', createdAt: 1,
|
||||
reasoning: 'Reason', tools: [{ callId: 'read', name: 'read', summary: '', state: 'completed', input: 'input' }] }]
|
||||
const window = (items: Message[], visible: boolean) => <MessageExpansionProvider messages={items}
|
||||
messageIndexes={new Map(items.map((message, index) => [message.id, index]))}>
|
||||
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning" /><Disclosure id="tool:read" /><Disclosure id="tool-input:read" /></MessageExpansionScope>}
|
||||
</MessageExpansionProvider>
|
||||
const view = render(window(messages, true))
|
||||
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'tool:read' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'tool-input:read' }))
|
||||
expect(entries()).toBe(3)
|
||||
view.rerender(window(messages, false))
|
||||
expect(entries()).toBe(3)
|
||||
view.rerender(window(messages, true))
|
||||
expect(view.getByRole('button', { name: 'reasoning' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
|
||||
expect(entries()).toBe(2)
|
||||
view.rerender(window(messages, false))
|
||||
await act(async () => view.rerender(window([{ ...messages[0]!, tools: undefined }], false)))
|
||||
expect(entries()).toBe(0)
|
||||
view.rerender(window(messages, true))
|
||||
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
|
||||
expect(entries()).toBe(1)
|
||||
await act(async () => view.rerender(window([], false)))
|
||||
expect(entries()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { Message } from './ChatTimeline'
|
||||
|
||||
type Expansion = { open: boolean; automaticOpen: boolean }
|
||||
type Override = Expansion & { messageId: string; scopeId?: string; id: string }
|
||||
const ExpansionContext = createContext<{
|
||||
states: Map<string, Override>; path: string; messageId?: string; scopeId?: string
|
||||
} | undefined>(undefined)
|
||||
|
||||
/** Only called for saved overrides, after message updates, never on scroll. */
|
||||
function currentDefault(message: Message, entry: Override): boolean | undefined {
|
||||
const child = entry.scopeId ? message.subagents?.find(item => `subagent:${item.childTaskId}` === entry.scopeId) : undefined
|
||||
if (entry.scopeId && !child) return undefined
|
||||
const blocks = child ? child.progress : message.blocks
|
||||
const automaticOpen = !child && message.state === 'streaming'
|
||||
if (entry.id === 'subagent') return child ? false : undefined
|
||||
if (entry.id === 'citations') return message.sourceReferences?.length ? false : undefined
|
||||
if (entry.id === 'reasoning') return !blocks?.length && message.reasoning ? automaticOpen : undefined
|
||||
if (entry.id.startsWith('reasoning:')) {
|
||||
return blocks?.some(block => block.type === 'reasoning' && `reasoning:${block.id}` === entry.id) ? automaticOpen : undefined
|
||||
}
|
||||
const input = entry.id.startsWith('tool-input:')
|
||||
const toolId = entry.id.slice(input ? 'tool-input:'.length : 'tool:'.length)
|
||||
const matches = (tool: NonNullable<Message['tools']>[number]) =>
|
||||
(tool.callId ?? tool.name) === toolId && (!input || Boolean(tool.input))
|
||||
const exists = blocks?.length ? blocks.some(block => block.type === 'tool' && matches(block.tool))
|
||||
: !child && message.tools?.some(matches)
|
||||
return exists ? false : undefined
|
||||
}
|
||||
|
||||
/** Owned by the history pane, not by rows that scrolling can unmount. */
|
||||
export function MessageExpansionProvider({ children, messages, messageIndexes }: {
|
||||
children: ReactNode; messages: readonly Message[]; messageIndexes: ReadonlyMap<string, number>
|
||||
}) {
|
||||
const [value] = useState(() => ({ states: new Map<string, Override>(), path: '' }))
|
||||
useEffect(() => {
|
||||
for (const [key, entry] of value.states) {
|
||||
const index = messageIndexes.get(entry.messageId)
|
||||
const message = index === undefined ? undefined : messages[index]
|
||||
if (!message || currentDefault(message, entry) !== entry.automaticOpen) value.states.delete(key)
|
||||
}
|
||||
}, [messages, messageIndexes, value])
|
||||
return <ExpansionContext value={value}>{children}</ExpansionContext>
|
||||
}
|
||||
|
||||
export function MessageExpansionScope({ id, children }: { id: string; children: ReactNode }) {
|
||||
const parent = useContext(ExpansionContext)
|
||||
const value = useMemo(() => parent && ({ states: parent.states, path: `${parent.path}/${JSON.stringify(id)}`,
|
||||
messageId: parent.messageId ?? id, scopeId: parent.messageId ? id : undefined }), [parent, id])
|
||||
return <ExpansionContext value={value}>{children}</ExpansionContext>
|
||||
}
|
||||
|
||||
export function useMessageExpansion(id: string, automaticOpen = false): [boolean, (open: boolean) => void] {
|
||||
const context = useContext(ExpansionContext)
|
||||
const key = `${context?.path}/${JSON.stringify(id)}`
|
||||
const [state, setState] = useState(() => {
|
||||
const saved = context?.states.get(key)
|
||||
return saved?.automaticOpen === automaticOpen ? saved : { open: automaticOpen, automaticOpen }
|
||||
})
|
||||
// Reasoning still opens while streaming and closes on completion, including
|
||||
// when that transition happened while its row was outside the window.
|
||||
if (state.automaticOpen !== automaticOpen) setState({ open: automaticOpen, automaticOpen })
|
||||
return [state.open, open => {
|
||||
const next = { open, automaticOpen }
|
||||
if (open === automaticOpen) context?.states.delete(key)
|
||||
else if (context?.messageId) context.states.set(key, { ...next, messageId: context.messageId, scopeId: context.scopeId, id })
|
||||
setState(next)
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('preserves virtualized disclosures with native Electron input and bounded large-history DOM', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-expansion-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
const source = process.env.GB_HISTORY_SOURCE
|
||||
const denseSource = process.env.GB_EXPANSION_DENSE_SOURCE
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
resolve: { dedupe: Object.keys({ ...require('../package.json').dependencies, ...require('../package.json').devDependencies }) },
|
||||
plugins: [react(), { name: 'message-expansion-fixture', enforce: 'pre',
|
||||
resolveId(id, importer) {
|
||||
if (denseSource && id.endsWith('/message-expansion')) return this.resolve(denseSource, importer, { skipSelf: true })
|
||||
if (source && importer?.endsWith('/tests/support/message-expansion-regression.tsx') && id.startsWith('../../src/')) {
|
||||
return this.resolve(resolve(source, id.slice(6)), importer, { skipSelf: true })
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/expansion.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(await server.transformIndexHtml('/expansion.html', '<!doctype html><html><body><div id="root"></div><script type="module" src="/tests/support/message-expansion-regression.tsx"></script></body></html>'))
|
||||
})
|
||||
}
|
||||
}],
|
||||
server: { host: '127.0.0.1', port: 0, watch: null, fs: { allow: [resolve('.'), ...(source ? [source] : []), ...(denseSource ? [dirname(denseSource)] : [])] } }
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const driver = join(directory, 'driver.mjs')
|
||||
await copyFile(resolve('tests/support/message-expansion-driver.mjs'), driver)
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GB_EXPANSION_DIRECTORY: directory,
|
||||
GB_EXPANSION_URL: server.resolvedUrls!.local[0] + 'expansion.html' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(require('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
const timer = setTimeout(() => child.kill(), 90000)
|
||||
try {
|
||||
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
|
||||
process.stdout.write(output.split(/\r?\n/).filter(line => line.startsWith('MessageExpansion')).join('\n') + '\n')
|
||||
expect(code, output).toBe(0)
|
||||
} finally { clearTimeout(timer) }
|
||||
} finally {
|
||||
await server.close()
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}, 120000)
|
||||
@@ -0,0 +1,128 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
|
||||
app.setPath('userData', join(process.env.GB_EXPANSION_DIRECTORY, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||
const errors = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
const evaluate = source => win.webContents.executeJavaScript(source)
|
||||
const waitFor = source => evaluate(`new Promise((resolve, reject) => {
|
||||
const deadline = performance.now() + 10000;
|
||||
const check = () => { if (${source}) resolve(true); else if (performance.now() > deadline) reject(new Error(${JSON.stringify(source)})); else requestAnimationFrame(check); }; check(); })`)
|
||||
const frames = () => evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))')
|
||||
const click = async selector => {
|
||||
await evaluate(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'center'})`)
|
||||
await frames()
|
||||
const point = await evaluate(`(() => { const e = document.querySelector(${JSON.stringify(selector)}), r = e.getBoundingClientRect();
|
||||
const x = r.x + r.width / 2, y = r.y + r.height / 2;
|
||||
if (!e.contains(document.elementFromPoint(x,y))) throw new Error('Obscured ' + ${JSON.stringify(selector)});
|
||||
return {x: Math.round(x), y: Math.round(y)}; })()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
|
||||
}
|
||||
for (const count of [2000, 10000]) {
|
||||
await win.loadURL(process.env.GB_EXPANSION_URL + '?count=' + count)
|
||||
win.focus()
|
||||
win.webContents.focus()
|
||||
await waitFor('document.hasFocus() && document.querySelector("[data-message-id=m0] details")')
|
||||
await evaluate('document.fonts.ready.then(() => true)')
|
||||
const untouched = await evaluate(`(async () => {
|
||||
const start = performance.now(), visited = new Set(); let maxRows = 0;
|
||||
for (let index = 0; index < ${count}; index += Math.floor(${count} / 200)) {
|
||||
window.expansionNavigate(index);
|
||||
await new Promise((resolve, reject) => {
|
||||
const deadline = performance.now() + 5000;
|
||||
const check = () => {
|
||||
const row = document.querySelector('[data-message-id=m' + index + ']');
|
||||
if (row?.contains(document.activeElement) && document.activeElement.matches('article')) resolve();
|
||||
else if (performance.now() > deadline) reject(new Error('Sweep navigation timeout'));
|
||||
else requestAnimationFrame(check);
|
||||
}; requestAnimationFrame(check);
|
||||
});
|
||||
const rows = [...document.querySelectorAll('.message-window-row')];
|
||||
maxRows = Math.max(maxRows, rows.length); rows.forEach(row => visited.add(row.dataset.messageId));
|
||||
}
|
||||
return { visited: visited.size, entries: window.expansionEntryCount(), elapsedMs: performance.now() - start, maxRows };
|
||||
})()`)
|
||||
assert.ok(untouched.visited >= 1000, JSON.stringify(untouched))
|
||||
assert.ok(untouched.maxRows <= 70, JSON.stringify(untouched))
|
||||
if (process.env.GB_EXPANSION_DENSE_SOURCE) assert.ok(untouched.entries >= untouched.visited, JSON.stringify(untouched))
|
||||
else if (!process.env.GB_HISTORY_SOURCE) assert.equal(untouched.entries, 0)
|
||||
const times = []
|
||||
let maxRows = 0
|
||||
let maxNodes = 0
|
||||
const visit = async index => {
|
||||
const elapsed = await evaluate(`new Promise((resolve, reject) => {
|
||||
const start = performance.now(); window.expansionNavigate(${index});
|
||||
const check = () => {
|
||||
const row = document.querySelector('[data-message-id=m${index}]');
|
||||
if (row?.contains(document.activeElement) && document.activeElement.matches('article')) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now() - start)));
|
||||
} else if (performance.now() - start > 10000) reject(new Error('Navigation timeout ${index}'));
|
||||
else requestAnimationFrame(check);
|
||||
}; requestAnimationFrame(check);
|
||||
})`)
|
||||
times.push(elapsed)
|
||||
const size = await evaluate('({rows:document.querySelectorAll(".message-window-row").length,nodes:document.querySelectorAll("*").length})')
|
||||
maxRows = Math.max(maxRows, size.rows)
|
||||
maxNodes = Math.max(maxNodes, size.nodes)
|
||||
assert.ok(size.rows <= 70, JSON.stringify(size))
|
||||
}
|
||||
for (let n = 0; n < 13; n++) {
|
||||
const index = n * Math.floor(count / 15)
|
||||
await visit(index)
|
||||
const row = `[data-message-id=m${index}]`
|
||||
await click(row + ' .tool-execution > summary')
|
||||
await waitFor(`document.querySelector('${row} .tool-execution').open`)
|
||||
await click(row + ' .tool-execution__input > summary')
|
||||
await waitFor(`document.querySelector('${row} .tool-execution__input').open`)
|
||||
await click(row + ' .message-reasoning > summary')
|
||||
await waitFor(`document.querySelector('${row} .message-reasoning').open`)
|
||||
}
|
||||
assert.equal(await evaluate('!!document.querySelector("[data-message-id=m0]")'), false, 'original row must actually unmount')
|
||||
await visit(0)
|
||||
const restored = await evaluate('[...document.querySelectorAll("[data-message-id=m0] details")].map(e=>e.open)')
|
||||
assert.deepEqual(restored, process.env.GB_HISTORY_SOURCE ? [false, false, false] : [true, true, true])
|
||||
// Native keyboard toggling still closes a restored disclosure.
|
||||
const summary = '[data-message-id=m0] .message-reasoning > summary'
|
||||
await evaluate(`document.querySelector('${summary}').focus()`)
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Space' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Space' })
|
||||
await waitFor(`document.querySelector('[data-message-id=m0] .message-reasoning').open === ${Boolean(process.env.GB_HISTORY_SOURCE)}`)
|
||||
assert.equal(await evaluate('document.querySelector("textarea").value'), 'Preserved draft')
|
||||
if (!process.env.GB_EXPANSION_DENSE_SOURCE && !process.env.GB_HISTORY_SOURCE) await waitFor('window.expansionEntryCount() === 38')
|
||||
const changedEntries = await evaluate('window.expansionEntryCount()')
|
||||
await evaluate('window.expansionDeleteMessages()')
|
||||
await waitFor('document.querySelectorAll(".message-window-row").length === 0')
|
||||
await frames()
|
||||
if (!process.env.GB_EXPANSION_DENSE_SOURCE && !process.env.GB_HISTORY_SOURCE) await waitFor('window.expansionEntryCount() === 0')
|
||||
const afterDeletion = await evaluate('window.expansionEntryCount()')
|
||||
if (!process.env.GB_EXPANSION_DENSE_SOURCE && !process.env.GB_HISTORY_SOURCE) assert.equal(afterDeletion, 0)
|
||||
times.sort((a,b)=>a-b)
|
||||
console.log('MessageExpansion ' + JSON.stringify({ version: process.env.GB_HISTORY_SOURCE ? 'baseline' : process.env.GB_EXPANSION_DENSE_SOURCE ? 'dense' : 'sparse', count,
|
||||
samples: times.length, navigationTwoFramesP95Ms: times[Math.ceil(times.length * .95)-1],
|
||||
navigationTwoFramesMedianMs: times[Math.floor(times.length/2)], maxRows, maxNodes, restored, untouched, changedEntries, afterDeletion,
|
||||
electron: process.versions.electron, platform: process.platform, arch: process.arch }))
|
||||
}
|
||||
await win.loadURL(process.env.GB_EXPANSION_URL + '?supervisor=1')
|
||||
await waitFor('document.querySelector(".supervisor-workspace__detail .link-button")')
|
||||
await click('.supervisor-workspace__detail .link-button')
|
||||
await waitFor('typeof window.finishSource === "function"')
|
||||
await click('#change-language')
|
||||
await waitFor('document.documentElement.lang === "en-US" && document.querySelector(".supervisor-workspace__detail .link-button")')
|
||||
await evaluate('window.finishSource()')
|
||||
await frames()
|
||||
const stuck = await evaluate('document.querySelector(".supervisor-workspace__detail .link-button").disabled')
|
||||
assert.equal(stuck, Boolean(process.env.GB_HISTORY_SOURCE))
|
||||
assert.equal(await evaluate('document.body.innerText.includes("Stale content")'), false)
|
||||
console.log('MessageExpansion ' + JSON.stringify({ scenario: 'supervisor-invalidation', version: process.env.GB_HISTORY_SOURCE ? 'baseline' : 'fixed', stuck }))
|
||||
assert.deepEqual(errors, [])
|
||||
win.destroy()
|
||||
app.quit()
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChatHistoryPane } from '../../src/renderer/src/ChatHistoryPane'
|
||||
import type { Conversation } from '../../src/renderer/src/chat-conversation'
|
||||
import { PageShell } from '../../src/renderer/src/WorkspacePrimitives'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import { changeUiLocale } from '../../src/renderer/src/i18n'
|
||||
import { SupervisorWorkspace } from '../../src/renderer/src/SupervisorWorkspace'
|
||||
import '../../src/renderer/src/supervisor-workspace.css'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const count = Number(new URLSearchParams(location.search).get('count') ?? 2000)
|
||||
const conversation: Conversation = { id: 'synthetic', title: 'Synthetic history', updatedAt: 1,
|
||||
messages: Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`, role: 'assistant', state: 'complete', createdAt: index + 1,
|
||||
content: `Synthetic message ${index}. ${'A paragraph of rendered history. '.repeat(8)}`,
|
||||
reasoning: `Reasoning ${index}. ${'A reasoning paragraph. '.repeat(8)}`,
|
||||
tools: [{ callId: `tool${index}`, name: 'read', summary: `Read synthetic file ${index}`, state: 'completed',
|
||||
input: '{"path":"synthetic.txt"}', output: 'Synthetic output\n'.repeat(8) }]
|
||||
})) }
|
||||
const noop = (): void => {}
|
||||
const complete = async (): Promise<void> => {}
|
||||
const artifacts = new Map()
|
||||
|
||||
// Fixture-only retention probe. Restore Map.set as soon as the expansion map
|
||||
// first writes; no production diagnostic API or persistent instrumentation.
|
||||
const expansionMaps = new Set<Map<unknown, unknown>>()
|
||||
const originalSet = Map.prototype.set
|
||||
Map.prototype.set = function (key, value) {
|
||||
if (value && typeof value.open === 'boolean' && typeof value.automaticOpen === 'boolean') {
|
||||
expansionMaps.add(this)
|
||||
Map.prototype.set = originalSet
|
||||
}
|
||||
return originalSet.call(this, key, value)
|
||||
}
|
||||
Object.assign(window, { expansionEntryCount: () => [...expansionMaps].reduce((total, map) => total + map.size, 0) })
|
||||
|
||||
function Fixture() {
|
||||
const [current, setCurrent] = useState(conversation)
|
||||
const [navigation, setNavigation] = useState({ conversationId: conversation.id, messageId: 'm0', requestId: 1 })
|
||||
Object.assign(window, {
|
||||
expansionNavigate: (index: number) => setNavigation(current => ({ ...current, messageId: `m${index}`, requestId: current.requestId + 1 })),
|
||||
expansionDeleteMessages: () => setCurrent({ ...conversation, messages: [] })
|
||||
})
|
||||
return <div className="app-shell"><div className="app-frame"><header className="topbar">Synthetic expansion validation</header>
|
||||
<div className="app-content"><main className="workspace"><PageShell variant="reading"><div className="chat-scroll-region">
|
||||
<ChatHistoryPane conversation={current} active artifactById={artifacts} locale="en-US"
|
||||
conversationHtmlRenderingEnabled={false} noteMessageNavigation={navigation}
|
||||
onOpenImageModelSettings={noop} onReselectImageSources={noop} onEditImage={noop}
|
||||
onCopyMessage={async () => true} onDownloadImage={noop} onOpenCitationContext={complete}
|
||||
onOpenCitationSource={complete} onOpenImage={noop} onRespondQuestion={complete} onRetry={noop}
|
||||
onScrollSnapshotChange={noop} onSetInput={noop} quickActions={[]} visibleMessageCount={count}
|
||||
onVisibleMessageCountChange={noop} />
|
||||
</div><footer className="composer-wrap"><textarea aria-label="Composer" defaultValue="Preserved draft" /></footer></PageShell></main></div>
|
||||
</div></div>
|
||||
}
|
||||
const supervisor = new URLSearchParams(location.search).has('supervisor')
|
||||
if (supervisor) {
|
||||
const at = '2026-10-01T00:00:00.000Z'
|
||||
Object.assign(window, { goodbuddy: { supervision: {
|
||||
overview: async () => [{ id: 'result', storyLineId: 'story', sourceId: null, summary: 'Synthetic review', changeDigest: '',
|
||||
createdAt: at, scope: { kind: 'global' }, timeRange: { from: at, to: at }, openItems: [] }],
|
||||
graph: async () => ({ storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: at }],
|
||||
entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: 'Synthetic source', occurred_at: at }],
|
||||
eventSources: [{ event_id: 'event', source_id: 'source' }] }),
|
||||
source: () => new Promise(resolve => Object.assign(window, { finishSource: () => resolve({ title: 'Synthetic source', content: 'Stale content', occurredAt: at }) }))
|
||||
} } })
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<UiLocaleProvider>{supervisor
|
||||
? <><button id="change-language" onClick={() => void changeUiLocale('en-US')}>Change language</button><SupervisorWorkspace tab="graph" /></>
|
||||
: <Fixture />}</UiLocaleProvider>)
|
||||
@@ -41,6 +41,7 @@ void app.whenReady().then(async () => {
|
||||
const updates: object[] = []
|
||||
let pollsAborted = 0
|
||||
let pollingUnavailable = false
|
||||
let pollingConflict = false
|
||||
const originalNetFetch = net.fetch
|
||||
// This is the only Telegram substitution: production driver construction and IPC stay intact.
|
||||
net.fetch = async (input, init) => {
|
||||
@@ -61,6 +62,9 @@ void app.whenReady().then(async () => {
|
||||
assert.deepEqual(body.allowed_updates, ['message'])
|
||||
try { await delay(50, undefined, { signal: init?.signal ?? undefined }) }
|
||||
catch (error) { pollsAborted++; throw error }
|
||||
if (pollingConflict) {
|
||||
return new Response(JSON.stringify({ ok: false, error_code: 409 }), { status: 409 })
|
||||
}
|
||||
if (pollingUnavailable) {
|
||||
return new Response(JSON.stringify({ ok: false, error_code: 503 }), { status: 503 })
|
||||
}
|
||||
@@ -174,6 +178,16 @@ void app.whenReady().then(async () => {
|
||||
assert.equal(database.getProject(conversation.projectId!).channel, 'telegram')
|
||||
|
||||
await wait(() => run(`!document.querySelector('button.primary-button').disabled && document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connected'`), 'saved connection visible')
|
||||
pollingConflict = true
|
||||
await wait(() => run(`document.querySelector('.channel-settings-card [role="alert"]')?.textContent.includes('polling conflict')`), 'terminal polling conflict visible')
|
||||
pollingConflict = false
|
||||
const validationsBeforeRecovery = calls.filter(call => call.method === 'getMe').length
|
||||
await click('button.primary-button')
|
||||
await wait(() => calls.filter(call => call.method === 'getMe').length === validationsBeforeRecovery + 1, 'unchanged Save restarts conflicted poller')
|
||||
await wait(() => run(`!document.querySelector('button.primary-button').disabled && document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connected'`), 'recovered connection visible')
|
||||
await click('button.primary-button')
|
||||
await wait(() => run(`!document.querySelector('button.primary-button').disabled`), 'healthy unchanged Save completed')
|
||||
assert.equal(calls.filter(call => call.method === 'getMe').length, validationsBeforeRecovery + 1, 'Healthy Save must not restart polling')
|
||||
await fill('input[aria-label="Telegram Bot Token"]', 'unsaved-token-placeholder')
|
||||
await fill('textarea[aria-label="Telegram allowed sender IDs"]', '700001\n700003')
|
||||
pollingUnavailable = true
|
||||
@@ -223,7 +237,7 @@ void app.whenReady().then(async () => {
|
||||
assert.deepEqual(blocked, [], 'No unexpected external requests')
|
||||
console.log(JSON.stringify({ telegramSmoke: 'passed', uiConfigSaveTest: true, productionPreloadIpc: true,
|
||||
productionExecutor: true, desktopConversationPersisted: true, reloadRestored: true,
|
||||
disableCancelsModel: true, liveStatusPreservesDrafts: true, clearTokenDisablesAndPreservesHistory: true,
|
||||
disableCancelsModel: true, liveStatusPreservesDrafts: true, unchangedSaveRecoversConflict: true, clearTokenDisablesAndPreservesHistory: true,
|
||||
modelCalls: payloads.length, telegramReplies: replies.length,
|
||||
pollsAborted, externalNetworkCalls: 0 }))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user