feat: update subagent task view and related logic for streaming support

- Changed `requesting` to `streaming` in `SubagentTaskView` type.
- Updated logic in `reduceAssistantProcessGroups` to handle streaming state and message timing.
- Modified `AiRunEventPayload` to reflect the new streaming property.
- Enhanced tests for human subagent to validate streaming behavior and prefix cache retention.
- Added new test case for subagent prefix cache retention during live execution.
- Introduced assertions for prefix cache reuse in the continuation logic.
- Added a new DOCX fixture for testing document selection scenarios.
This commit is contained in:
hy committed 2026-10-09 10:30:56 +08:00
1 parent fc7c2cb203
commit 5dd6cff103
21 files changed
+1142 -443

No files matched your search

+1 -1
View File
@@ -96,7 +96,7 @@ Conversation 行(`user` / `assistant` / `tool`)进 `cache.messages` 只经 `
### 1.7 一次性子运行
- **执行**:共用 `AiRunService.run`、human graph 和工具链;继承父配置,独立上下文。任务数不限,`SUBAGENT_EXECUTION_CONCURRENCY` 默认 2,其余 FIFO 排队,轮到才创建实例。工具目录、绑定与入口均禁止再次派发、提问及定时任务管理。
- **转录**:`AiTranscript` 的 SQLite / 内存适配共用投影、预算和压缩。`runId` 标识实例,`ownerRunId` 归属主运行,`parentToolCallId` 关联派发;不建子运行或伙伴行。子完整转录结束释放,父 annotation 保留状态、公开行动、完整公开消息和有界结果,不进主模型;全部收口后返回一条 ToolMessage。流式事件仅更新展示态,子过程和终态不改变父状态;I/O 保留父子关联。
- **转录**:`AiTranscript` 的 SQLite / 内存适配共用投影、预算和压缩。`runId` 标识实例,`ownerRunId` 归属主运行,`parentToolCallId` 关联派发;不建子运行或伙伴行。子完整转录结束释放,父 annotation 保留状态、公开行动、完整公开消息和有界结果,不进主模型;全部收口后返回一条 ToolMessage。流式事件仅更新展示态,明确公开文字流的开始、替换和结束,不把工具或审核请求当作文字流;子过程和终态不改变父状态,I/O 保留父子关联。
- **审批**:共用 hand 的规则检查和 AI 审核,各自传入本次任务、命令及关联 ID 和执行环境,命令说明仅留人工审批卡及日志。主任务内 FIFO、一次一个批次,已有问题优先;有待审批时父状态为 `waiting_approval`。先登记等待器再发布;决定、关联日志及下一批快照同事务提交后恢复原 hand。等待保留实例并占名额,不调用模型;拒绝/指导跳过原操作。当前批次存主 `ai_run`,队列和等待器留 Main;取消不伪造用户决定。
- **文件**:派发前释放父已提交文档会话,返回后重读;跨执行租约互斥,冲突交回父运行。修改和产物从产生时归主运行。
- **结束与恢复**:finally 释放上下文及执行资源,保留落盘产物。Renderer 刷新恢复当前审批;应用重启不恢复子实例或旧审批,未完成状态投影为中断。悬空派发只在模型投影补中断反馈,保留真实结果,不写虚构 tool 行、不自动重派。
+1 -1
View File
@@ -26,7 +26,7 @@
1. 校验 `released-migrations.json` 中已锁定 migration 的版本、名称、文件名和 SHA-256。
2. 并行执行 `typecheck:node`、`typecheck:web`,并准备网页抓取 sidecar。
3. 使用 6 个 worker 执行 Unit、update backend 和非 live AI backend(其中由 `requiresBackendPptBrowser` 识别的 PPT 浏览器用例 `pptBrowserFiles` 固定 1 个 worker 串行)。
4. 必跑 Workbench e2e `prefix-cache-retain-live`,验证同一伙伴经真实 UI 短续接、文本读工具历史、跨 run 前缀缓存和 `toolsHash`;没有 live key 必须失败,不能跳过。
4. 必跑 Workbench e2e `prefix-cache-retain-live` 和 `subagent-prefix-cache-retain-live`,分别验证主伙伴跨任务短续接、同一子实例连续调用及主派发收口的前缀稳定和真实缓存;具体门槛按 Workbench 测试计划执行,没有 live key 必须失败,不能跳过。
5. 全部通过后,把当前连续 migration 纳入发布锁,与 `package.json` 版本一并提交,创建 annotated Tag,并原子推送 `main` 和 Tag。
第一个平台执行 `release:start` 时锁定本次新增 migration;另一平台使用同一份已锁源码。已锁 migration 不能合并、重排或修改,只能新增更高版本。
+5 -3
View File
@@ -509,12 +509,13 @@ PPT 普通预览和审核需完整显示中文及全角标点,换行测量与
- 系统行为:右侧真实 DOCX 预览选中文字后保留淡紫色背景高亮,先展示星光图标+「AI 编辑」入口,点击后展开紧凑输入条,placeholder 为「描述编辑内容」。输入条使用 13px 字号、20px 行高,单行高度约 38px,随内容增高至三行后滚动;右侧约 28px 的深色圆形按钮使用对勾图标,新增时提示「添加到输入框」,编辑已有引用时提示「确认修改」。浮层默认在选区下方,以文档预览可见区域做碰撞检测,下方不足时翻到上方,左右超出时调整;滚动、缩放及输入条增高时重新定位。输入获得焦点后原文高亮仍保留,不添加下划线。点击对勾或 Enter 将内容同步到左侧输入区,尚不发送 AI;Shift+Enter 换行,IME 确认不提交;空原文或空想法不提交,Esc/点击外部放弃未确认改动。
- 选区片段由不可编辑的文档图标+文件名+编号胶囊以及可编辑的想法组成;胶囊为浅紫底,与正文基线对齐、保留间距,不展示方括号;长文件名省略,悬浮可看完整名称和原文。想法可含 `@`,两侧编辑同一份内容。选区既可用于修改也可用于提问。
- 仅支持正文连续文字、跨段落与单个表格单元格;跨单元格、页眉页脚和文本框不开放。引用保留全部原文及前后各最多 150 字上下文,保持段落换行;不包含文字格式或图片内容。
- 支持原生连续文字选择,包括跨段落、跨页、跨单元格、整表及正文与表格混选;页眉页脚和文本框中的真实 HTML 文字同样支持。引用只保留实际选中的文字及前后各最多 150 字上下文,保持段落和单元格内换行;表格单元格以 Tab 分隔、行以空行分隔,空单元格及合并单元格的占位保留,合并文字不重复。图片与文字混选时只提取文字,不包含图片、图形像素文字或文字格式。提取、高亮及定位使用同一份预览文字索引。
- 选区没有可读取文字或原文无法可靠定位时,使用系统 toast 自动消失,同一提示合并;文档内不显示提示条。重复/重叠选区继续静默保留当前选中范围。
- 确认后收起输入条,正文末尾换行追加片段,右侧原文只保留紫色背景,不显示编号、不添加下划线、不改变 DOCX 排版。悬停已保存选区时背景加深并显示手形光标,单击直接打开输入条重新编辑,已有引用提供删除图标;拖动或 Shift 选取文字仍按普通文本选择处理。相同或重叠选区只保留当前选中范围,不展示 AI 编辑或提示,不新增引用。点击左侧引用展开对应文件并定位;删除片段或浮层删除同步移除引用与右侧高亮。左侧当前草稿按伙伴和文件分别从 1 连续编号,按正文中选区片段的顺序排列;删除后重新连续编号,全部删除或发送成功后,新建选区从 1 开始。引用以独立 ID 关联,重新编号不改变原文和想法;已发送消息保留发送时的编号。
- 未发送选区及关联想法只存内存,伙伴/文件切换与关闭 Tab 不丢失,重启丢失;其他正文和附件继续保存。提交期间锁定正文和标注编辑,消息被接受或排队成功后清除本批标注,提交失败保留。已发送消息可回显和再次编辑;原文变化导致无法可靠定位时提示,保留引用内容。
- 文档选区不自动收窄知识库范围,未索引选区仍可发送。
- 验收:
- `WB-DOCUMENT-REFERENCE-001-AC1` 正文、跨段落及单个表格单元格的原文和上下文准确;不支持的区域无法保存引用。
- `WB-DOCUMENT-REFERENCE-001-AC1` 跨段落、跨页、跨单元格、整表、正文/表格混选及可读取的页眉页脚/文本框原文和上下文准确;表格行列、空单元格、合并占位与单元格内换行保留;混选图片不阻断文字引用;无可读取文字不新增引用,提示使用系统 toast 且不重复。
- `WB-DOCUMENT-REFERENCE-001-AC2` 选中先展示 AI 编辑,点击后展开小字号紧凑输入条;对勾图标及新增/编辑提示正确,确认后同步到输入区;紫色选区在输入时保留,浮层在预览区域内随滚动/缩放定位并支持上下翻转,缩放不丢失未确认输入;确认、换行、取消、空内容与 IME 行为正确。
- `WB-DOCUMENT-REFERENCE-001-AC3` 图标、文件名和编号胶囊与正文清晰分隔,输入区与消息回显样式一致;引用与想法按输入顺序保留,支持 @,两侧编辑同步。
- `WB-DOCUMENT-REFERENCE-001-AC4` 右侧紫色高亮无编号和附加下划线,悬停反馈可点击,单击打开对应输入条,文本选择不受影响;重复/重叠选区保留选中范围且无入口或提示;定位和删除正确;左侧编号连续,删除全部后从 1 开始,重新编号不串原文和想法,历史消息编号不变。
@@ -527,7 +528,7 @@ PPT 普通预览和审核需完整显示中文及全角标点,换行测量与
- 验收标准:
- `WB-SUBAGENT-001-AC1`:主伙伴可通过内置派发工具创建任意数量的临时子任务,深度一层。执行并发默认 2(由 `SUBAGENT_EXECUTION_CONCURRENCY` 控制),其余 FIFO 排队,轮到后才创建完整运行实例。继承模型、偏好、权限和工作区快照,独立上下文,不复制主聊天。Brain 对可独立完成的任务给出按需协作建议。
- `WB-SUBAGENT-001-AC2`:当前主回答过程区竖排显示子任务,每行只有稳定的随机内置组合头像和名称。排队头像和名称置灰且静止;运行头像动画,等待用户审批及结束后停止;支持减少动态效果偏好。鼠标悬浮或键盘聚焦打开小云,保留云轮廓及尾巴,最大宽 220px、云面最大高 96px。云内始终只有最新一条公开消息:排队显示「我在排队」,等待审批显示「等待你审核操作」且无请求黑球;执行中随实际模型文本流式追加,新消息替换旧消息;工具期间保留附带公开说明,无说明使用现有工具动作文案;完成显示最终交付,失败、停止、交回主伙伴或中断显示对应原因。不显示原始内部推理。请求期间黑色呼吸圆球跟随文字尾部,无文字时只显示圆球;请求结束或单纯工具执行期间不显示圆球。内容换行、不省略;隐藏滚动条但可滚动,打开及新内容到达时自动滚到底部。鼠标可进入云内,完成后沿用整个过程区折叠规则。无原位展开、额外分组标题、完成计数或正常状态文字。
- `WB-SUBAGENT-001-AC2`:当前主回答过程区竖排显示子任务,每行只有稳定的随机内置组合头像和名称。排队头像和名称置灰且静止;运行头像动画,等待用户审批及结束后停止;支持减少动态效果偏好。鼠标悬浮或键盘聚焦打开小云,保留云轮廓及尾巴,最大宽 220px、云面最大高 96px;文字区域避开轮廓凹陷,左右至少 24px 留白,长路径换行且文字和黑球均限制在云面内部。云内始终只有最新一条公开消息:排队显示「我在排队」,等待审批显示「等待你审核操作」且无请求黑球;执行中随实际模型文本流式追加,新消息替换旧消息;工具期间保留附带公开说明,无说明使用现有工具动作文案;完成显示最终交付,失败、停止、交回主伙伴或中断显示对应原因。不显示原始内部推理。最新公开消息第一个字到达即自动显示云,实际文字流期间黑色呼吸圆球跟随文字尾部;文字流结束后黑球消失,停留 2 秒自动隐藏,后续工具执行或审核不延长黑球。一次只自动展示最新开始输出的子任务云;鼠标悬浮、进入云内或键盘聚焦优先且保持显示。新消息替换并重新计时,换行不另起周期;切换伙伴、刷新或展开历史不重播旧自动云。排队、审批和历史交付仍可悬浮查看。内容换行、不省略;隐藏滚动条但可滚动,打开及新内容到达时自动滚到底部。鼠标可进入云内,完成后沿用整个过程区折叠规则。无原位展开、额外分组标题、完成计数或正常状态文字。
- `WB-SUBAGENT-001-AC3`:子任务不创建伙伴、侧栏项、独立气泡或通知;不得再次派发、提问或管理定时任务。需用户补充信息时交回主伙伴;人工审批使用主对话统一审批卡,子实例等待决定后继续。
- `WB-SUBAGENT-001-AC4`:受控文件修改、快照、产物和回退统一归主伙伴和主 run。同路径保留首次基线与最后状态;跨执行文件工作态互斥,冲突交回主伙伴串行处理。子卡片无独立文件修改列表。终端写入沿用不自动记账规则。
- `WB-SUBAGENT-001-AC5`:子任务自行创建和操作独立浏览器标签,列表和 CDP 操作限制在自身执行范围,不同标签可并行。同一页面调用依次执行。后台网页视图隐藏显示且保留有效页面尺寸,不遮挡主界面;后台操作不切换用户伙伴、活动资源或展开面板;控制结束后标签保留为主伙伴资源,登录状态共享。
@@ -535,6 +536,7 @@ PPT 普通预览和审核需完整显示中文及全角标点,换行测量与
- `WB-SUBAGENT-001-AC7`:子任务完成、失败、取消后释放完整上下文和运行资源,保留已落盘文件及主日志中的精简结果。切伙伴不停止,主任务停止取消子任务。真实应用退出后不恢复子实例;重启保留已完成结果、未完成显示中断;用户续聊修复悬空派发反馈并可创建新子任务。
- `WB-SUBAGENT-001-AC8`:主子运行共用审核和审批执行链,以当前执行者的任务作为审核输入;人工审批保留原子实例,允许后执行原操作,拒绝或指导反馈给原子模型。等待占用执行名额,不产生模型请求。主任务内审批 FIFO,一次展示一个批次,不合并不同执行者的批次;有待审批批次时主状态保持 waiting_approval,已有提问仍优先展示,处理后推进审批。来源头像/名称及指导 placeholder 对应执行者;当前批次提交后自动切换并清空草稿。
- `WB-SUBAGENT-001-AC9`:审批来源、请求、决定和取消归主日志,决定和下一批快照同事务保存后才唤醒执行者;重复/过期决定无效。切伙伴及 Renderer 刷新恢复当前审批;停止清除全部等待并保留已有文件;应用退出不恢复子实例或旧审批,续聊修复等待审批任务为中断。
- `WB-SUBAGENT-001-AC10`:同一子实例运行期间,内存消息经公共投影保持可复用前缀;无技能切换或压缩时工具定义及已有消息不改写。派发过程 annotation 不进入主模型,主伙伴接收子结果时保留已有请求前缀。新子实例不继承旧子历史;缓存从各执行者连续调用的服务端实际 usage 取证。
#### WB-BLOCKER-001 人机交互阻塞
+9
View File
@@ -54,6 +54,15 @@ Word、PPT、Excel、普通文本统一由主 run 的文件会话管理。工具
- How:生产 `AiRunService.run`,只脚本化 LLM 决策,其他依赖使用真实服务与存储。
- Oracle:一次提交超过 2 项仍被接受,峰值并发为默认 2,排队任务仅在前序释放后启动,全部结果进入主工具消息;公开文本真实流式投递,实际请求状态成对开关且原始推理不转发;子完整日志不入 SQLite;状态与精简行动 annotation 持久化;受限工具不可绑定和执行。
#### `human-subagent-prefix-stability`
- 测试类型:backend / mock-llm;扩展现有 human-subagent 文件。
- What:子内存投影、连续工具结果追加、新实例的固定 System 和工具前缀,以及公开文字流的开始与结束。
- Why:确定性区分请求前缀改写与供应商缓存低命中,守住临时实例隔离和文字流生命周期。
- How:生产 AiRunService、人类 graph、真实文本文件及工具;只脚本化 LLM 派发、学习及读取决策。保存真实 requestWire,比较稳定工具集合下的相邻主子 brain;两次派发创建新实例。
- Oracle:已有消息逐项保留,工具定义和顺序不变;新子 System/初始工具相同且无旧子历史;主上下文只含派发汇总,没有子过程 annotation。公开文字 delta 开始流、completed/reset/取消结束流,审核请求不能冒充文字流。服务端缓存由 Workbench `subagent-prefix-cache-retain-live` 验证,mock 不伪造命中。
- 最新验证:2026-10-09 生产 AiRunService 通过;raw logs:`tests/logs/ai/human/mock-llm/human-subagent-prefix-stability/20261009-102111-987-e9228cfe`。两轮共四个新子实例,主子连续请求前缀保持、新实例无旧子历史、真实文本读取及公开流开始/结束通过;不把不同子实例的服务端缓存命中作为断言。
#### `human-subagent-file-cancellation`
- 测试类型:backend / mock-llm。
+15 -6
View File
@@ -316,11 +316,11 @@ pnpm test:update:e2e
#### `docx-selection-reference`
- 测试类型:`e2e / live`;suite `conversation`,group `composer`。
- What:真实 DOCX 选区、AI 编辑入口与对勾确认输入条、可点击的紫色高亮、左侧连续编号、混合正文、双向同步、定位与草稿生命周期。
- What:真实 DOCX 任意连续文字选区、表格行列与合并占位、系统 toast、AI 编辑入口与对勾确认输入条、可点击的紫色高亮、左侧连续编号、混合正文、双向同步、定位与草稿生命周期。
- Why:确保原文准确,引用与想法关联,两侧使用同一内容。
- How:通过鼠标点击起点、Shift 点击终点选取跨段落、部分单词及单元格文字,确认先展示 AI 编辑再点击打开输入条;核对对勾提示、实际字号和高度;悬停原文检查手形光标与加深的紫色背景,点击高亮直接打开对应输入条,未选中文字和段落空白无点击反馈;重复/重叠选择保留原生选中范围且无提示或 AI 编辑;滚动到预览底部检查浮层翻转,缩放后核对浮层边界和未确认输入;确认、双向编辑、跳转和删除;分别从浮层和输入区删除中间及全部选区,核对左侧编号、内容关联和右侧高亮清理;切换伙伴和文件,实际发送后新增选区检查从 1 开始,再编辑历史消息并刷新 Renderer 检查持久化草稿。
- Oracle:覆盖 `WB-DOCUMENT-REFERENCE-001-AC1` 的跨段落/单元格原文与跨单元格拒绝,`WB-DOCUMENT-REFERENCE-001-AC2` 的两阶段入口、对勾及新增/编辑提示、小字号紧凑高度、输入后保留紫色高亮、浮层边界/翻转/滚动/缩放、空内容禁用、Enter 确认、Shift+Enter 换行和 Esc/外部点击关闭,`WB-DOCUMENT-REFERENCE-001-AC3` 的图标编号胶囊和两侧编辑同步,`WB-DOCUMENT-REFERENCE-001-AC4` 的右侧无编号、仅高亮文字可悬停和点击、定位、重复/重叠选择静默保留、删除后左侧连续编号与右侧高亮清理、删空及发送后从 1 开始和内容不串,`WB-DOCUMENT-REFERENCE-001-AC5` 的伙伴隔离及内存/持久化边界,`WB-DOCUMENT-REFERENCE-001-AC6` 的接受后清理、历史编号保留和历史编辑。刷新后选区消失,普通文字与 @ 引用保留。
- 待覆盖:页眉页脚/文本框拒绝、IME、想法内 @ 混排、关闭资源 Tab、应用进程重启及附件恢复、外部原文变化后的定位提示、提交期间锁定、提交失败保留、排队成功清理及历史复制。
- How:通过鼠标起点与 Shift 终点选择部分单词、跨段落、跨单元格、整表及正文/表格混选;复用 workspace DOCX,并用 `docx-selection-layout.docx` 覆盖单元格内换行、空单元格、横向/纵向合并、跨页、页眉页脚、图片混选及反向选择。只读 DOM 取证和引用原文核对;确认、高亮、点击重新编辑和定位均走真实 UI。纯图片选区检查系统 toast 合并与自动消失,文档内无提示条;重复/重叠选区保持静默。核对两阶段入口、对勾提示、字号、高度、浮层翻转/缩放和未确认输入;双向编辑、跳转和两侧删除,核对左侧编号、原文与想法关联及右侧高亮清理;切换伙伴和文件,实际发送后新增选区检查从 1 开始,再编辑历史消息并刷新 Renderer 检查持久化草稿。
- Oracle:覆盖 `WB-DOCUMENT-REFERENCE-001-AC1` 的跨段落/跨页/跨单元格/整表/正文混选、页眉页脚、反向选择、表格行列分隔与空/合并占位、单元格内换行、图片混选和无文字 toast,`WB-DOCUMENT-REFERENCE-001-AC2` 的两阶段入口、对勾及新增/编辑提示、小字号紧凑高度、输入后保留紫色高亮、浮层边界/翻转/滚动/缩放、空内容禁用、Enter 确认、Shift+Enter 换行和 Esc/外部点击关闭,`WB-DOCUMENT-REFERENCE-001-AC3` 的图标编号胶囊和两侧编辑同步,`WB-DOCUMENT-REFERENCE-001-AC4` 的右侧无编号、仅高亮文字可悬停和点击、定位、重复/重叠选择静默保留、删除后左侧连续编号与右侧高亮清理、删空及发送后从 1 开始和内容不串,`WB-DOCUMENT-REFERENCE-001-AC5` 的伙伴隔离及内存/持久化边界,`WB-DOCUMENT-REFERENCE-001-AC6` 的接受后清理、历史编号保留和历史编辑。刷新后选区消失,普通文字与 @ 引用保留。
- 待覆盖:文本框文字、IME、想法内 @ 混排、关闭资源 Tab、应用进程重启及附件恢复、外部原文变化后的定位 toast、提交期间锁定、提交失败保留、排队成功清理及历史复制。
### 5.2 `run`
@@ -330,8 +330,17 @@ pnpm test:update:e2e
- What:并行浏览器与 sidecar、子任务头像和悬浮云、主文件回退,以及运行停止与崩溃续聊。
- Why:覆盖独立标签、共享 sidecar、背景 UI 和临时实例资源之间的真实交互风险。
- How:真实 UI 配置模型和完全访问权限、发送、切伙伴、悬浮及回退。三项任务分别提交独立表单、读取 JS 资料和真实搜索;另以独立伙伴分别运行停止、崩溃阶段,保存文件的子任务完成时中断另一个待加载网页,再续聊创建新实例。真实网站为输入,禁用 localhost 抓取快路;observer、只读 IPC 和原生视图检查只取证;harness 仅终止自身应用进程并以同 userData 重启。
- Oracle:对齐 `WB-SUBAGENT-001-AC1`、`WB-SUBAGENT-001-AC2`、`WB-SUBAGENT-001-AC3`、`WB-SUBAGENT-001-AC4`、`WB-SUBAGENT-001-AC5`、`WB-SUBAGENT-001-AC6`、`WB-SUBAGENT-001-AC7`。标签和页面结果不串;后台视图隐藏、viewport 有效、真实截图有效且不遮挡界面;sidecar 完成 JS 抓取与搜索;切换和折叠不抢选择。竖排头像/名称、排队灰态、运行及完成头像状态、最新消息流式云与黑球、隐藏滚动条和自动滚底符合 PRD。修改归主回答且真实 UI 回退恢复磁盘。停止释放控制并保留文件;重启保留完成记录、未完成投影中断;续聊创建新实例并使用新的网页输入。
- 最新验证:2026-10-08 真实 qwen3.8-flash 三阶段全部通过,耗时 1168s;raw logs:`tests/logs/workbench/run/subagent/subagent-delegation-browser-sidecar/20261008-163711-106`。独立表单提交、JS 资料与真实搜索、后台视图/截图、悬浮云、主修改回退、加载中停止、崩溃重启及新实例续聊均通过。人工完成度 90/100:事实和磁盘交付准确;反复核对与报告篇幅使耗时偏高。主 31 次调用,输入/输出 391,531 / 16,943 token;子 67 次调用,输入/输出 528,108 / 24,154 token;缓存读 754,944(82.1%)。
- Oracle:对齐 `WB-SUBAGENT-001-AC1`、`WB-SUBAGENT-001-AC2`、`WB-SUBAGENT-001-AC3`、`WB-SUBAGENT-001-AC4`、`WB-SUBAGENT-001-AC5`、`WB-SUBAGENT-001-AC6`、`WB-SUBAGENT-001-AC7`。标签和页面结果不串;后台视图隐藏、viewport 有效、真实截图有效且不遮挡界面;sidecar 完成 JS 抓取与搜索;切换和折叠不抢选择。竖排头像/名称、排队灰态、运行及完成头像状态、最新消息流式云与黑球、隐藏滚动条和自动滚底符合 PRD。第一字自动开云、文字流结束后黑球消失及 2 秒隐藏;新消息重置周期、同组只有一个自动云、hover/focus 优先且历史不自动重播。长文字与黑球不越过云轮廓安全区域。修改归主回答且真实 UI 回退恢复磁盘。停止释放控制并保留文件;重启保留完成记录、未完成投影中断;续聊创建新实例并使用新的网页输入。
- 最新验证:2026-10-08 真实 qwen3.8-flash 三阶段全部通过,1325s;raw logs:`tests/logs/workbench/run/subagent/subagent-delegation-browser-sidecar/20261008-185724-429`。第一字自动开云,真实文字流结束后 2175ms 隐藏;文字区域四角位于 SVG 云面内且无横向溢出,长交付、黑球、排队灰态、hover/focus 优先、隐藏滚动条和自动滚底通过。独立表单、JS 资料、真实搜索、后台视图/截图、主修改回退、加载中停止、手动悬浮超过两秒、崩溃恢复无旧自动云及新实例续聊均通过。人工完成度 88/100:事实及磁盘交付准确;主伙伴重复子任务核验、额外检查旧慢网页和字节细节,报告偏长。主 32 次调用,输入/输出 388,109 / 18,433;子 65 次,589,748 / 27,217;缓存读 810,496(82.88%)。保留四张云朵截图及重启前后 trace。
#### `subagent-prefix-cache-retain-live`
- 测试类型:e2e / live;suite `run`,group `subagent`;serviceMode `real`;发布准备必跑。
- What:同一个子实例执行过程中连续模型调用的前缀稳定和真实缓存,以及主伙伴派发后接收结果时的缓存。
- Why:子内存消息投影、工具结果追加及派发收口不能改写已发送的请求前缀。
- How:真实 UI 配置模型并发送一次任务,派发两位临时帮手。复用已有 `工作/本周排期更新.txt`、`工作/对外口径确认.txt`、`工作/临时备注.txt`,分别复制到两个临时资料目录,核对排期负责人及对外风险口径,由主伙伴汇总。按各自 runId/callId 收集连续 brain 请求及实际 usage;工具、存储和投影均为生产实现,observer 仅取证。不注入模型输出、不延时或填充提示制造命中。
- Oracle:对齐 `WB-SUBAGENT-001-AC10`。两项实际完成、各自真实读取至少两份资料、关键事实有工具结果依据、输入不变。每个子实例至少三次 brain 调用,稳定工具集合下的相邻请求完整保留已有消息前缀;主派发收口同样保留前缀。每个执行者都有有效 usage 和实际缓存复用,缺少可比较请求或全程未命中明确失败。逐段记录输入规模、缓存 token 和复用率,以既有 85% 为评估参考,不把每段短请求均达 85% 作为硬门槛;前缀改写与服务端低命中分别判断。不比较不同子实例的缓存,不要求新实例首次命中。新实例上下文隔离由 AI 确定性回归覆盖。
- 最新验证:2026-10-09 真实 qwen3.8-flash 通过,75s;raw logs:`tests/logs/workbench/run/subagent/subagent-prefix-cache-retain-live/20261009-101928-843`。一次主任务、两个子实例分别有 3 / 4 次 brain 调用;5 段子连续请求的消息及工具前缀完整保持,复用率 87.16%–99.85%,主派发收口 88.67%。本次未出现低于 85% 的相邻段,不能据此要求所有短请求均达 85%;首次请求不作为复用比较,逐段 usage 继续留证评估。四份临时资料不变,负责人、周四排期、未签署前保持跟进的口径及来源正确。人工完成度 90/100:交付完整,子任务额外目录检查及报告篇幅偏长扣分。主 2 次调用,输入/输出 14,815 / 1,375;子 7 次,40,608 / 2,914;缓存读 39,680。
#### `subagent-approval-flow`
+7 -10
View File
@@ -21,7 +21,7 @@ const LIVE_KEY_ENV_NAMES = [
'AI_LIVE_KEY_MIMO',
'AI_LIVE_KEY_ZHIPU'
]
const PREFIX_CACHE_LIVE_CASE = 'prefix-cache-retain-live'
const PREFIX_CACHE_LIVE_CASES = ['prefix-cache-retain-live', 'subagent-prefix-cache-retain-live']
const args = process.argv.slice(2)
const valueAfter = (name) => {
const index = args.indexOf(name)
@@ -93,11 +93,11 @@ const assertReleaseLiveKeyConfigured = () => {
const anyKey = LIVE_KEY_ENV_NAMES.find((name) => String(process.env[name] || '').trim())
if (anyKey) {
throw new Error(
`发布准备必跑 ${PREFIX_CACHE_LIVE_CASE}:已配置 ${anyKey},但 AI_LIVE_PROVIDER=${provider} 缺少 ${preferredKey}`
`发布准备必跑 ${PREFIX_CACHE_LIVE_CASES.join(', ')}:已配置 ${anyKey},但 AI_LIVE_PROVIDER=${provider} 缺少 ${preferredKey}`
)
}
throw new Error(
`发布准备必跑 ${PREFIX_CACHE_LIVE_CASE}:请在 .env 配置 ${preferredKey}(或设置 AI_LIVE_PROVIDER 并提供对应 AI_LIVE_KEY_*)`
`发布准备必跑 ${PREFIX_CACHE_LIVE_CASES.join(', ')}:请在 .env 配置 ${preferredKey}(或设置 AI_LIVE_PROVIDER 并提供对应 AI_LIVE_KEY_*)`
)
}
@@ -200,19 +200,16 @@ await run(process.execPath, [
process.env.NCURATOR_RELEASE_PREPARE = '1'
assertReleaseLiveKeyConfigured()
process.stdout.write(`[release] running required live gate: ${PREFIX_CACHE_LIVE_CASE}\n`)
process.stdout.write(
`[release] running required live gate: ${PREFIX_CACHE_LIVE_CASES.join(', ')}\n`
)
await run(process.execPath, [
path.join(ROOT, 'scripts', 'test', 'workflow.mjs'),
'--type',
'e2e',
'--domain',
'workbench',
'--suite',
'conversation',
'--group',
'prefix-cache',
'--case',
PREFIX_CACHE_LIVE_CASE,
...PREFIX_CACHE_LIVE_CASES.flatMap((caseId) => ['--case', caseId]),
'--target',
'source'
])
+7 -11
View File
@@ -137,12 +137,11 @@ export const createSubagentDispatcher = (
}
deps.abortSignal?.addEventListener('abort', cancel, { once: true })
if (deps.abortSignal?.aborted || deps.isCancelled()) cancel()
const requests = new Set<string>()
let replaceStream = true
const stream = (update: {
delta?: string
reset?: boolean
requesting?: boolean
streaming?: boolean
}): void => {
// Keep live fragments ordered with durable snapshots without persisting each fragment.
const next = updates.then(() => {
@@ -182,7 +181,7 @@ export const createSubagentDispatcher = (
isCancelled: () => controller.signal.aborted || deps.isCancelled(),
requestApprovalBatch: async (request) => {
base = { ...base, status: 'waiting_approval' }
stream({ requesting: false })
stream({ streaming: false })
await publish(base)
const result = await deps.requestApprovalBatch({
...request,
@@ -199,19 +198,16 @@ export const createSubagentDispatcher = (
sendEvent: (event) => {
// The thought event is public assistant text accompanying this round of tool calls.
// Raw reasoning deltas, skill state and lifecycle remain local.
if (event.type === 'llm_request_state') {
if (event.requesting) requests.add(event.callId)
else requests.delete(event.callId)
stream({ requesting: requests.size > 0 })
} else if (event.type === 'assistant_brain_stream_started') {
if (event.type === 'assistant_brain_stream_started') {
replaceStream = true
} else if (event.type === 'assistant_brain_stream_reset') {
replaceStream = true
stream({ reset: true })
stream({ reset: true, streaming: false })
} else if (event.type === 'assistant_brain_stream_delta') {
stream({ delta: event.text, reset: replaceStream })
stream({ delta: event.text, reset: replaceStream, streaming: true })
replaceStream = false
} else if (event.type === 'assistant_brain_stream_completed') {
stream({ streaming: false })
if (event.text.trim())
void publish({ ...base, message: event.text.slice(0, 12000) })
} else if (
@@ -306,7 +302,7 @@ export const createSubagentDispatcher = (
await publish(update)
return update
} finally {
stream({ requesting: false })
stream({ streaming: false })
try {
await updates
} finally {
+4 -2
View File
@@ -17,7 +17,7 @@
position: relative;
width: min(220px, calc(100vw - 24px));
max-height: 96px;
padding: 12px 16px;
padding: 26px 30px 22px;
isolation: isolate;
color: var(--notion-text-soft, #787774);
font-size: 12px;
@@ -49,8 +49,10 @@
}
.nc-subagent-thought-lines {
max-height: 72px;
max-height: 48px;
min-height: 18px;
min-width: 0;
overflow-x: hidden;
white-space: pre-wrap;
overflow-wrap: anywhere;
scrollbar-width: none;
@@ -1,4 +1,5 @@
import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { App as AntdApp } from 'antd'
import { createPortal } from 'react-dom'
import { HiOutlineCheck, HiOutlineSparkles, HiOutlineTrash } from 'react-icons/hi2'
import type { ChatInlineContentNode, DocumentSelectionNode } from '../../../../shared/types'
@@ -43,10 +44,10 @@ export default function DocxSelectionLayer({
revision: number
}) {
const state = useContext(DocumentSelectionContext)
const { message } = AntdApp.useApp()
const [popup, setPopup] = useState<Popup | null>(null)
const [floating, setFloating] = useState<SelectionFloatingPosition | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
const [notice, setNotice] = useState('')
const popupRef = useRef<HTMLDivElement>(null)
const latestRef = useRef(state)
const targetsRef = useRef<SelectionTarget[]>([])
@@ -56,17 +57,16 @@ export default function DocxSelectionLayer({
useEffect(() => {
latestRef.current = state
}, [state])
const open = (
node: DocumentSelectionNode,
location: SelectionLocation,
mode: Popup['mode']
): void => {
setNotice('')
setHoveredId(null)
setFloating(null)
setPopup({ node, location, mode })
window.getSelection()?.removeAllRanges()
}
const open = useCallback(
(node: DocumentSelectionNode, location: SelectionLocation, mode: Popup['mode']): void => {
message.destroy('docx-selection')
setHoveredId(null)
setFloating(null)
setPopup({ node, location, mode })
window.getSelection()?.removeAllRanges()
},
[message]
)
useEffect(() => {
if (!container || !state?.enabled || !revision) return
@@ -98,7 +98,8 @@ export default function DocxSelectionLayer({
const preview = readPreviewText(container)
const location = captureSelection(container, preview, range)
if (!location) {
setNotice('请选择正文中的连续文本;表格仅支持单个单元格。')
setPopup(null)
void message.warning({ key: 'docx-selection', content: '当前选区没有可读取的文字。' })
return true
}
const nodes = latestRef.current?.selections.filter((node) => node.filePath === filePath) || []
@@ -106,7 +107,7 @@ export default function DocxSelectionLayer({
const existing = locateSelection(preview, node, latestRef.current?.locations.get(node.id))
if (!existing) continue
if (existing.start < location.end && location.start < existing.end) {
setNotice('')
message.destroy('docx-selection')
setPopup(null)
return true
}
@@ -185,7 +186,16 @@ export default function DocxSelectionLayer({
container.style.cursor = cursor
setHoveredId(null)
}
}, [container, filePath, revision, state?.enabled, state?.locked, state?.selections])
}, [
container,
filePath,
revision,
state?.enabled,
state?.locked,
state?.selections,
message,
open
])
useLayoutEffect(() => {
if (!container || !revision || !state?.enabled) return
@@ -291,14 +301,17 @@ export default function DocxSelectionLayer({
const location = locateSelection(preview, node, state.locations.get(node.id))
const range = location && rangeForLocation(preview, location)
if (!range || !location) {
setNotice('原文已变化或位置不唯一,无法定位该选区。')
void message.warning({
key: 'docx-selection',
content: '原文已变化或位置不唯一,无法定位该选区。'
})
return
}
range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'instant' })
const selected = window.getSelection()
selected?.removeAllRanges()
selected?.addRange(range)
}, [container, revision, filePath, state?.navigation, state?.locations])
}, [container, revision, filePath, state?.navigation, state?.locations, message])
useEffect(() => {
if (!popupId) return
@@ -320,9 +333,9 @@ export default function DocxSelectionLayer({
useEffect(() => {
setPopup(null)
setNotice('')
message.destroy('docx-selection')
setHoveredId(null)
}, [filePath, revision, state?.enabled])
}, [filePath, revision, state?.enabled, message])
if (!state?.enabled) return null
const save = (): void => {
@@ -357,15 +370,6 @@ export default function DocxSelectionLayer({
编辑所选内容
</button>
))}
{notice && (
<button
type="button"
className="absolute bottom-3 left-3 right-3 z-20 rounded-md bg-white px-3 py-2 text-sm text-[#787774] shadow"
onClick={() => setNotice('')}
>
{notice}
</button>
)}
{popup &&
createPortal(
<div
@@ -3,30 +3,86 @@ import type { SelectionLocation } from '../workbench/composer/DocumentSelectionC
type TextRun = { node: Text; start: number; end: number }
export type PreviewText = { text: string; runs: TextRun[] }
const unsupported =
'header, footer, svg, math, .docx-header, .docx-footer, .docx-textbox, [data-docx-textbox]'
const blocks = new Set([
'P',
'TABLE',
'ARTICLE',
'HEADER',
'FOOTER',
'SECTION',
'DIV',
'OL',
'UL',
'LI'
])
const isHidden = (element: Element): boolean => {
const style = getComputedStyle(element)
return style.display === 'none' || style.visibility === 'hidden'
}
function tableGrid(table: HTMLTableElement): (HTMLTableCellElement | null)[][] {
const rows = [...table.rows]
const grid: (HTMLTableCellElement | null)[][] = rows.map(() => [])
for (const [rowIndex, row] of rows.entries()) {
if (isHidden(row)) continue
let column = 0
for (const cell of row.cells) {
// DOCX vertical merges render a hidden continuation cell beside the spanning cell.
if (isHidden(cell)) continue
while (grid[rowIndex][column] !== undefined) column += 1
grid[rowIndex][column] = cell
const endRow = Math.min(rows.length, rowIndex + (cell.rowSpan || rows.length - rowIndex))
for (let r = rowIndex; r < endRow; r += 1) {
for (let c = column; c < column + cell.colSpan; c += 1) {
if (r !== rowIndex || c !== column) grid[r][c] = null
}
}
column += cell.colSpan
}
}
const columns = Math.max(0, ...grid.map((row) => row.length))
return grid.map((row) => Array.from({ length: columns }, (_, column) => row[column] || null))
}
export function readPreviewText(container: HTMLElement): PreviewText {
const runs: TextRun[] = []
let text = ''
const paragraphs = container.querySelectorAll('article p')
paragraphs.forEach((paragraph) => {
if (paragraph.closest(unsupported)) return
if (runs.length) text += '\n'
const walker = document.createTreeWalker(
paragraph,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
)
let node = walker.nextNode()
while (node) {
if (node instanceof HTMLBRElement && !node.closest(unsupported)) text += '\n'
if (node instanceof Text && node.data && !node.parentElement?.closest(unsupported)) {
const visit = (node: Node): void => {
if (node instanceof Text) {
if (node.data && node.parentElement instanceof HTMLElement) {
runs.push({ node, start: text.length, end: text.length + node.data.length })
text += node.data
}
node = walker.nextNode()
return
}
})
if (!(node instanceof HTMLElement || node instanceof SVGElement)) return
if (node.matches('style, script, template') || isHidden(node)) return
if (node instanceof HTMLBRElement) {
text += '\n'
return
}
if (node instanceof HTMLTableElement) {
for (const [rowIndex, row] of tableGrid(node).entries()) {
if (rowIndex) text += '\n\n'
for (const [column, cell] of row.entries()) {
if (column) text += '\t'
if (cell) visit(cell)
}
}
return
}
let hasContent = false
for (const child of node.childNodes) {
if (child instanceof Element && isHidden(child)) continue
const block = child instanceof HTMLElement && blocks.has(child.tagName)
if (block && hasContent) text += '\n'
const start = text.length
visit(child)
if (block || text.length > start) hasContent = true
}
}
visit(container.querySelector('.docx-wrapper') || container)
return { text, runs }
}
@@ -35,32 +91,19 @@ export function captureSelection(
preview: PreviewText,
range: Range
): SelectionLocation | null {
const startElement =
range.startContainer instanceof Element
? range.startContainer
: range.startContainer.parentElement
const endElement =
range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement
if (
!startElement ||
!endElement ||
!container.contains(startElement) ||
!container.contains(endElement) ||
!startElement.closest('article') ||
!endElement.closest('article') ||
startElement.closest(unsupported) ||
endElement.closest(unsupported)
)
if (!container.contains(range.startContainer) || !container.contains(range.endContainer))
return null
const startCell = startElement.closest('td, th')
if (startCell !== endElement.closest('td, th')) return null
if (range.cloneContents().querySelector('img, svg, table, header, footer')) return null
const intersected = preview.runs.filter((run) => range.intersectsNode(run.node))
const intersected = preview.runs.flatMap((run) => {
if (!range.intersectsNode(run.node)) return []
const start = range.startContainer === run.node ? range.startOffset : 0
const end = range.endContainer === run.node ? range.endOffset : run.node.length
return end > start ? [{ start: run.start + start, end: run.start + end }] : []
})
const first = intersected[0]
const last = intersected.at(-1)
if (!first || !last) return null
const start = first.start + (range.startContainer === first.node ? range.startOffset : 0)
const end = last.start + (range.endContainer === last.node ? range.endOffset : last.node.length)
const { start } = first
const { end } = last
if (!preview.text.slice(start, end).trim()) return null
return { start, end, previewText: preview.text }
}
@@ -96,11 +139,11 @@ export function locateSelection(
export function rangeForLocation(preview: PreviewText, location: SelectionLocation): Range | null {
const first = preview.runs.find((run) => run.end > location.start)
const last = preview.runs.find((run) => run.end >= location.end && run.start < location.end)
const last = preview.runs.findLast((run) => run.start < location.end && run.end > location.start)
if (!first || !last) return null
const range = document.createRange()
range.setStart(first.node, Math.max(0, location.start - first.start))
range.setEnd(last.node, location.end - last.start)
range.setEnd(last.node, Math.min(last.node.length, location.end - last.start))
return range
}
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Tooltip } from 'antd'
import PartnerAvatar from './PartnerAvatar'
import SubagentGroupView, { SubagentCloudProvider } from './SubagentGroupView'
import { FaSpider } from 'react-icons/fa'
import { PiBrowsers, PiEyes } from 'react-icons/pi'
import { TbMoodConfuzed } from 'react-icons/tb'
@@ -29,8 +28,7 @@ import type {
AssistantProcessIconKind,
AssistantProcessThought,
AssistantRichTextSegment,
AssistantTurnViewModel,
SubagentTaskView
AssistantTurnViewModel
} from './types'
import type { WorkspaceSource } from '../../../../shared/types'
import type { OpenableMarkdownReference } from '../markdownReference'
@@ -41,7 +39,6 @@ import {
import { workbenchSelectors } from '../../testing/selectors'
import {
buildAssistantExecutionSummaryText,
getSubagentAvatar,
resolveAssistantExecutionIcon,
resolveQuestionInteraction,
shouldShowAssistantApplyTargets,
@@ -294,106 +291,6 @@ const AssistantProcessThoughtView: React.FC<{
)
}
const SubagentThought: React.FC<{ child: SubagentTaskView; open: boolean }> = ({ child, open }) => {
const contentRef = useRef<HTMLDivElement>(null)
const active = child.status === 'running'
const text =
child.status === 'queued'
? '我在排队'
: active
? child.message || ''
: child.result ||
{
waiting_approval: '等待你审核操作',
completed: '任务已完成',
failed: '执行失败',
cancelled: '已停止',
blocked: '需要主伙伴继续处理',
interrupted: '执行被中断,未收到完整结果'
}[child.status]
const requesting = active && child.requesting
useLayoutEffect(() => {
if (!open || !contentRef.current) return
const content = contentRef.current
const scrollToLatest = (): void => {
content.scrollTop = content.scrollHeight
}
scrollToLatest()
const frame = requestAnimationFrame(scrollToLatest)
const observer = new ResizeObserver(scrollToLatest)
observer.observe(content)
return () => {
cancelAnimationFrame(frame)
observer.disconnect()
}
}, [open, text, requesting])
return (
<div data-testid="subagent-thought" className="nc-subagent-thought-cloud">
<svg
className="nc-subagent-thought-outline"
viewBox="0 0 264 110"
preserveAspectRatio="none"
aria-hidden="true"
>
<path d="M30 94C9 94 1 74 11 60C0 45 10 25 30 24C36 5 60 2 74 14C86 0 109 0 120 13C139 0 161 5 169 18C189 6 210 15 214 28C240 22 259 38 254 57C270 74 254 97 232 96C220 111 195 108 184 98C166 112 143 109 131 99C113 112 89 107 81 97C65 111 42 107 30 94Z" />
</svg>
<div ref={contentRef} className="nc-subagent-thought-lines">
<span data-testid="subagent-message">{requesting ? text.trimEnd() : text}</span>
{requesting ? (
<span
data-testid="subagent-stream-dot"
className="nc-subagent-stream-dot"
aria-label="正在请求模型"
/>
) : null}
</div>
</div>
)
}
const SubagentTaskRow: React.FC<{ child: SubagentTaskView }> = ({ child }) => {
const [open, setOpen] = useState(false)
return (
<Tooltip
placement="topLeft"
trigger={['hover', 'focus']}
arrow={false}
color="transparent"
overlayClassName="nc-subagent-thought-tooltip"
mouseEnterDelay={0.15}
onOpenChange={setOpen}
title={<SubagentThought child={child} open={open} />}
>
<button
type="button"
data-testid="subagent-task"
data-child-run-id={child.childRunId}
data-status={child.status}
aria-label={child.name}
className={`flex items-center gap-1.5 rounded-lg py-1 pr-1 text-left text-[13px] leading-5 outline-none focus-visible:ring-1 focus-visible:ring-[#9B9A97] ${child.status === 'queued' ? 'text-[#9B9A97]' : 'text-[#37352F]'}`}
>
<span className={child.status === 'queued' ? 'grayscale opacity-40' : undefined}>
<PartnerAvatar
{...getSubagentAvatar(child.childRunId)}
size={32}
animated={child.status === 'running'}
animationSeed={child.childRunId}
/>
</span>
<span className="max-w-[8em] truncate">{child.name}</span>
</button>
</Tooltip>
)
}
const SubagentGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => (
<div data-testid="subagent-group" className="flex flex-col items-start gap-1 py-1">
{(group.subagents || []).map((child) => (
<SubagentTaskRow key={child.childRunId} child={child} />
))}
</div>
)
const AssistantProcessGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => {
if (group.subagents?.length) return <SubagentGroupView group={group} />
const isRunning = group.status === 'running'
@@ -834,227 +731,229 @@ const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
}
return (
<div className="w-full" data-testid={`workbench-assistant-turn-${messageId}`}>
<div className="space-y-4">
{timeline.length > 0 || hasStreamingThought || reserveThinkingSlot || turn.startedAt ? (
<div data-testid={`${workbenchSelectors.assistantProcessPrefix}-${messageId}`}>
{turn.startedAt ? (
turn.runFinished ? (
<button
type="button"
className="mb-2 flex w-full items-center justify-start gap-1.5 border-b border-[#EEEEEC] pb-2 text-left text-[14px] leading-6 text-[#37352F]"
aria-expanded={processExpanded}
aria-label={`${processExpanded ? '收起' : '展开'}过程记录`}
data-testid={`${workbenchSelectors.assistantProcessTogglePrefix}-${messageId}`}
onClick={() => setProcessExpanded((expanded) => !expanded)}
>
<span
data-testid={`${workbenchSelectors.assistantRunDurationPrefix}-${messageId}`}
<SubagentCloudProvider groups={processGroups}>
<div className="w-full" data-testid={`workbench-assistant-turn-${messageId}`}>
<div className="space-y-4">
{timeline.length > 0 || hasStreamingThought || reserveThinkingSlot || turn.startedAt ? (
<div data-testid={`${workbenchSelectors.assistantProcessPrefix}-${messageId}`}>
{turn.startedAt ? (
turn.runFinished ? (
<button
type="button"
className="mb-2 flex w-full items-center justify-start gap-1.5 border-b border-[#EEEEEC] pb-2 text-left text-[14px] leading-6 text-[#37352F]"
aria-expanded={processExpanded}
aria-label={`${processExpanded ? '收起' : '展开'}过程记录`}
data-testid={`${workbenchSelectors.assistantProcessTogglePrefix}-${messageId}`}
onClick={() => setProcessExpanded((expanded) => !expanded)}
>
用时 {formatRunDuration(turn.startedAt, turn.finishedAt ?? currentTime)}
</span>
{processExpanded ? (
<HiChevronDown className="h-4 w-4 shrink-0" />
) : (
<HiChevronRight className="h-4 w-4 shrink-0" />
)}
</button>
) : (
<div className="mb-2 flex items-center border-b border-[#EEEEEC] pb-2 text-[14px] leading-6 text-[#37352F]">
<span
data-testid={`${workbenchSelectors.assistantRunDurationPrefix}-${messageId}`}
>
已处理 {formatRunDuration(turn.startedAt, currentTime)}
</span>
</div>
)
) : null}
<div className={processExpanded ? 'space-y-2' : 'hidden'}>
{timeline.map((entry) => {
if (entry.kind === 'decision') {
return (
<AssistantProcessThoughtView
key={entry.id}
thought={entry.thought}
<span
data-testid={`${workbenchSelectors.assistantRunDurationPrefix}-${messageId}`}
>
用时 {formatRunDuration(turn.startedAt, turn.finishedAt ?? currentTime)}
</span>
{processExpanded ? (
<HiChevronDown className="h-4 w-4 shrink-0" />
) : (
<HiChevronRight className="h-4 w-4 shrink-0" />
)}
</button>
) : (
<div className="mb-2 flex items-center border-b border-[#EEEEEC] pb-2 text-[14px] leading-6 text-[#37352F]">
<span
data-testid={`${workbenchSelectors.assistantRunDurationPrefix}-${messageId}`}
>
已处理 {formatRunDuration(turn.startedAt, currentTime)}
</span>
</div>
)
) : null}
<div className={processExpanded ? 'space-y-2' : 'hidden'}>
{timeline.map((entry) => {
if (entry.kind === 'decision') {
return (
<AssistantProcessThoughtView
key={entry.id}
thought={entry.thought}
onOpenReference={onOpenReference}
/>
)
}
if (entry.kind === 'activity') {
return (
<AssistantActivityGroupView
key={entry.id}
id={entry.id}
groups={entry.groups}
waitingApproval={entry.waitingApproval}
waitingInteraction={entry.waitingInteraction}
approvalReviews={approvalReviews}
onPreviewImage={onPreviewImage}
shimmerActive={
Boolean(entry.waitingApproval || entry.waitingInteraction) ||
shouldActivityHeaderShimmer({
activityCreatedAt: entry.createdAt,
thoughts: processThoughts,
hasNextAiContent: hasNextAiContentForActivity
})
}
/>
)
}
return <AssistantProcessGroupView key={entry.id} group={entry.group} />
})}
{streamingRole === 'thought' && streamingMarkdown.trim() ? (
<div className="min-w-0 px-2 py-0.5 -mx-2 text-[13.5px] leading-[22px] text-[#37352F]">
<MarkdownRenderer
content={streamingMarkdown}
variant="thought"
onOpenReference={onOpenReference}
/>
)
}
if (entry.kind === 'activity') {
return (
<AssistantActivityGroupView
key={entry.id}
id={entry.id}
groups={entry.groups}
waitingApproval={entry.waitingApproval}
waitingInteraction={entry.waitingInteraction}
approvalReviews={approvalReviews}
onPreviewImage={onPreviewImage}
shimmerActive={
Boolean(entry.waitingApproval || entry.waitingInteraction) ||
shouldActivityHeaderShimmer({
activityCreatedAt: entry.createdAt,
thoughts: processThoughts,
hasNextAiContent: hasNextAiContentForActivity
})
}
/>
)
}
return <AssistantProcessGroupView key={entry.id} group={entry.group} />
})}
{streamingRole === 'thought' && streamingMarkdown.trim() ? (
<div className="min-w-0 px-2 py-0.5 -mx-2 text-[13.5px] leading-[22px] text-[#37352F]">
<MarkdownRenderer
content={streamingMarkdown}
variant="thought"
onOpenReference={onOpenReference}
/>
</div>
) : null}
{reserveThinkingSlot ? (
<div
className="flex h-6 items-center gap-1.5 text-[13px] leading-6 text-[#37352F]"
data-testid={`${workbenchSelectors.assistantProcessItemPrefix}-${messageId}-thinking`}
aria-hidden={!showThinkingIndicator}
>
{showThinkingIndicator ? (
<span className="workbench-thinking-shimmer font-medium text-[#37352F]">
让俺想想
</span>
) : null}
</div>
) : null}
</div>
</div>
) : null}
{hasFinalAnswer ? (
<div
className="min-w-0 text-[15px] leading-7 text-[#37352F]"
data-testid={`${workbenchSelectors.assistantFinalAnswerPrefix}-${messageId}`}
>
<MarkdownRenderer
content={visibleAnswerMarkdown}
linkAccess={turn.linkAccess}
onOpenReference={onOpenReference}
/>
</div>
) : null}
{turn.status === 'failed' && turn.error ? (
<div
className="rounded-[8px] border border-[#E7CAC6] bg-[#F8EBE9] px-4 py-3 text-sm leading-6 text-[#B3261E]"
data-testid={`${workbenchSelectors.assistantRunFailurePrefix}-${messageId}`}
>
<div>失败:{turn.error}</div>
{turn.errorDetail ? (
<details className="mt-2">
<summary
className="cursor-pointer select-none text-xs text-[#8A4F48] underline-offset-2 hover:underline"
data-testid={`${workbenchSelectors.assistantRunFailureDetailTogglePrefix}-${messageId}`}
>
查看详情
</summary>
<pre
className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-[6px] bg-[#F3DDD9] px-3 py-2 text-xs leading-5 text-[#7A3B34]"
data-testid={`${workbenchSelectors.assistantRunFailureDetailPrefix}-${messageId}`}
>
{turn.errorDetail}
</pre>
</details>
) : null}
</div>
) : null}
{shouldShowAssistantApplyTargets({
runFinished: turn.runFinished,
applyTargetCount: turn.applyTargets.length
}) ? (
<div className="overflow-hidden rounded-[8px] border border-[#E5E4E0] bg-white">
<div className="flex items-center gap-3 border-b border-[#EEEEEC] px-3.5 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-[#F4F4F2] text-[#787774]">
<HiOutlineDocumentText className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-[14px] font-semibold text-[#37352F]">修改的文件</div>
<div className="mt-0.5 text-xs text-[#787774]">点击文件查看本次修改</div>
</div>
<div
className="ml-auto shrink-0 text-sm font-semibold tabular-nums text-[#37352F]"
data-testid={`${workbenchSelectors.assistantTurnApplyCountPrefix}-${messageId}`}
>
{turn.applyTargets.length} 个
</div>
</div>
<div>
{visibleApplyTargets.map((apply) => {
const statusLabel =
apply.status === 'reverted'
? apply.operation === 'created'
? '已删除'
: '已回退'
: apply.status === 'failed'
? '失败'
: null
return (
<button
key={apply.changeId}
type="button"
onClick={() => onOpenApply(apply.changeId)}
data-testid={`${workbenchSelectors.assistantTurnApplyPrefix}-${apply.changeId}`}
className="w-full border-b border-[#F1F0ED] px-3.5 py-2.5 text-left transition-colors duration-200 last:border-b-0 hover:bg-[#F7F7F5]"
</div>
) : null}
{reserveThinkingSlot ? (
<div
className="flex h-6 items-center gap-1.5 text-[13px] leading-6 text-[#37352F]"
data-testid={`${workbenchSelectors.assistantProcessItemPrefix}-${messageId}-thinking`}
aria-hidden={!showThinkingIndicator}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<WorkspacePathLabel
filePath={apply.sourcePath || apply.title}
workspaces={workspaces}
fallbackWorkspaceName={fallbackWorkspaceName}
className="text-[13px] font-medium"
fileNameClassName="text-[#37352F]"
prefixClassName="text-[#9B9A97]"
separator="/"
/>
</div>
<span className="flex shrink-0 items-center gap-1 pt-0.5">
<ChangeCountBadges
insertCount={apply.insertCount}
deleteCount={apply.deleteCount}
/>
{statusLabel ? (
<span className="inline-flex h-5 items-center rounded-[4px] bg-[#F4F4F2] px-1.5 text-[11px] font-medium text-[#787774]">
{statusLabel}
</span>
) : null}
{showThinkingIndicator ? (
<span className="workbench-thinking-shimmer font-medium text-[#37352F]">
让俺想想
</span>
</div>
</button>
)
})}
) : null}
</div>
) : null}
</div>
</div>
{turn.applyTargets.length > MAX_VISIBLE_APPLY_TARGETS ? (
<button
type="button"
onClick={() => setApplyTargetsExpanded((value) => !value)}
aria-expanded={applyTargetsExpanded}
data-testid={`${workbenchSelectors.assistantTurnApplyCollapsePrefix}-${messageId}`}
className="flex w-full items-center gap-1.5 border-t border-[#F1F0ED] px-3.5 py-2 text-left text-xs font-medium text-[#787774] transition-colors duration-200 hover:bg-[#F7F7F5] hover:text-[#37352F]"
>
<HiChevronDown
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
applyTargetsExpanded ? 'rotate-180' : ''
}`}
/>
{applyTargetsExpanded
? '收起文件'
: `显示剩余 ${turn.applyTargets.length - MAX_VISIBLE_APPLY_TARGETS} 个文件`}
</button>
) : null}
</div>
) : null}
) : null}
{hasFinalAnswer ? (
<div
className="min-w-0 text-[15px] leading-7 text-[#37352F]"
data-testid={`${workbenchSelectors.assistantFinalAnswerPrefix}-${messageId}`}
>
<MarkdownRenderer
content={visibleAnswerMarkdown}
linkAccess={turn.linkAccess}
onOpenReference={onOpenReference}
/>
</div>
) : null}
{turn.status === 'failed' && turn.error ? (
<div
className="rounded-[8px] border border-[#E7CAC6] bg-[#F8EBE9] px-4 py-3 text-sm leading-6 text-[#B3261E]"
data-testid={`${workbenchSelectors.assistantRunFailurePrefix}-${messageId}`}
>
<div>失败:{turn.error}</div>
{turn.errorDetail ? (
<details className="mt-2">
<summary
className="cursor-pointer select-none text-xs text-[#8A4F48] underline-offset-2 hover:underline"
data-testid={`${workbenchSelectors.assistantRunFailureDetailTogglePrefix}-${messageId}`}
>
查看详情
</summary>
<pre
className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-[6px] bg-[#F3DDD9] px-3 py-2 text-xs leading-5 text-[#7A3B34]"
data-testid={`${workbenchSelectors.assistantRunFailureDetailPrefix}-${messageId}`}
>
{turn.errorDetail}
</pre>
</details>
) : null}
</div>
) : null}
{shouldShowAssistantApplyTargets({
runFinished: turn.runFinished,
applyTargetCount: turn.applyTargets.length
}) ? (
<div className="overflow-hidden rounded-[8px] border border-[#E5E4E0] bg-white">
<div className="flex items-center gap-3 border-b border-[#EEEEEC] px-3.5 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-[#F4F4F2] text-[#787774]">
<HiOutlineDocumentText className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-[14px] font-semibold text-[#37352F]">修改的文件</div>
<div className="mt-0.5 text-xs text-[#787774]">点击文件查看本次修改</div>
</div>
<div
className="ml-auto shrink-0 text-sm font-semibold tabular-nums text-[#37352F]"
data-testid={`${workbenchSelectors.assistantTurnApplyCountPrefix}-${messageId}`}
>
{turn.applyTargets.length} 个
</div>
</div>
<div>
{visibleApplyTargets.map((apply) => {
const statusLabel =
apply.status === 'reverted'
? apply.operation === 'created'
? '已删除'
: '已回退'
: apply.status === 'failed'
? '失败'
: null
return (
<button
key={apply.changeId}
type="button"
onClick={() => onOpenApply(apply.changeId)}
data-testid={`${workbenchSelectors.assistantTurnApplyPrefix}-${apply.changeId}`}
className="w-full border-b border-[#F1F0ED] px-3.5 py-2.5 text-left transition-colors duration-200 last:border-b-0 hover:bg-[#F7F7F5]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<WorkspacePathLabel
filePath={apply.sourcePath || apply.title}
workspaces={workspaces}
fallbackWorkspaceName={fallbackWorkspaceName}
className="text-[13px] font-medium"
fileNameClassName="text-[#37352F]"
prefixClassName="text-[#9B9A97]"
separator="/"
/>
</div>
<span className="flex shrink-0 items-center gap-1 pt-0.5">
<ChangeCountBadges
insertCount={apply.insertCount}
deleteCount={apply.deleteCount}
/>
{statusLabel ? (
<span className="inline-flex h-5 items-center rounded-[4px] bg-[#F4F4F2] px-1.5 text-[11px] font-medium text-[#787774]">
{statusLabel}
</span>
) : null}
</span>
</div>
</button>
)
})}
</div>
{turn.applyTargets.length > MAX_VISIBLE_APPLY_TARGETS ? (
<button
type="button"
onClick={() => setApplyTargetsExpanded((value) => !value)}
aria-expanded={applyTargetsExpanded}
data-testid={`${workbenchSelectors.assistantTurnApplyCollapsePrefix}-${messageId}`}
className="flex w-full items-center gap-1.5 border-t border-[#F1F0ED] px-3.5 py-2 text-left text-xs font-medium text-[#787774] transition-colors duration-200 hover:bg-[#F7F7F5] hover:text-[#37352F]"
>
<HiChevronDown
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
applyTargetsExpanded ? 'rotate-180' : ''
}`}
/>
{applyTargetsExpanded
? '收起文件'
: `显示剩余 ${turn.applyTargets.length - MAX_VISIBLE_APPLY_TARGETS} 个文件`}
</button>
) : null}
</div>
) : null}
</div>
</div>
</div>
</SubagentCloudProvider>
)
}
@@ -0,0 +1,172 @@
import React, {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { Tooltip } from 'antd'
import PartnerAvatar from './PartnerAvatar'
import type { AssistantProcessGroup, SubagentTaskView } from './types'
import { getSubagentAvatar } from './utils'
const SubagentThought: React.FC<{ child: SubagentTaskView; open: boolean }> = ({ child, open }) => {
const contentRef = useRef<HTMLDivElement>(null)
const active = child.status === 'running'
const text =
child.status === 'queued'
? '我在排队'
: active
? child.message || ''
: child.result ||
{
waiting_approval: '等待你审核操作',
completed: '任务已完成',
failed: '执行失败',
cancelled: '已停止',
blocked: '需要主伙伴继续处理',
interrupted: '执行被中断,未收到完整结果'
}[child.status]
const streaming = active && child.streaming
useLayoutEffect(() => {
if (!open || !contentRef.current) return
const content = contentRef.current
const scrollToLatest = (): void => {
content.scrollTop = content.scrollHeight
}
scrollToLatest()
const frame = requestAnimationFrame(scrollToLatest)
const observer = new ResizeObserver(scrollToLatest)
observer.observe(content)
return () => {
cancelAnimationFrame(frame)
observer.disconnect()
}
}, [open, text, streaming])
return (
<div
data-testid="subagent-thought"
data-child-run-id={child.childRunId}
data-message-started-at={child.messageStartedAt}
className="nc-subagent-thought-cloud"
>
<svg
className="nc-subagent-thought-outline"
viewBox="0 0 264 110"
preserveAspectRatio="none"
aria-hidden="true"
>
<path d="M30 94C9 94 1 74 11 60C0 45 10 25 30 24C36 5 60 2 74 14C86 0 109 0 120 13C139 0 161 5 169 18C189 6 210 15 214 28C240 22 259 38 254 57C270 74 254 97 232 96C220 111 195 108 184 98C166 112 143 109 131 99C113 112 89 107 81 97C65 111 42 107 30 94Z" />
</svg>
<div ref={contentRef} className="nc-subagent-thought-lines">
<span data-testid="subagent-message">{streaming ? text.trimEnd() : text}</span>
{streaming ? (
<span
data-testid="subagent-stream-dot"
className="nc-subagent-stream-dot"
aria-label="正在输出文字"
/>
) : null}
</div>
</div>
)
}
const AUTO_CLOUD_HOLD_MS = 2000
const SubagentCloudContext = createContext<
| {
openChildId?: string
setManualChildId: React.Dispatch<React.SetStateAction<string | undefined>>
}
| undefined
>(undefined)
export const SubagentCloudProvider: React.FC<{
groups: AssistantProcessGroup[]
children: React.ReactNode
}> = ({ groups, children }) => {
const tasks = useMemo(() => groups.flatMap((group) => group.subagents || []), [groups])
const [manualChildId, setManualChildId] = useState<string>()
const [autoChildId, setAutoChildId] = useState<string>()
// Existing view state on mount is history, including when returning to a running partner.
const seen = useRef(new Map(tasks.map((child) => [child.childRunId, child.messageStartedAt])))
useEffect(() => {
let latest: SubagentTaskView | undefined
for (const child of tasks) {
if (child.messageStartedAt && seen.current.get(child.childRunId) !== child.messageStartedAt) {
if (!latest || child.messageStartedAt >= (latest.messageStartedAt || 0)) latest = child
}
seen.current.set(child.childRunId, child.messageStartedAt)
}
if (latest) setAutoChildId(latest.childRunId)
}, [tasks])
const automatic = tasks.find((child) => child.childRunId === autoChildId)
const streaming = automatic?.status === 'running' && automatic.streaming
useEffect(() => {
if (!autoChildId || streaming) return
const timer = window.setTimeout(() => setAutoChildId(undefined), AUTO_CLOUD_HOLD_MS)
return () => window.clearTimeout(timer)
}, [autoChildId, streaming, automatic?.messageStartedAt])
return (
<SubagentCloudContext.Provider
value={{ openChildId: manualChildId || autoChildId, setManualChildId }}
>
{children}
</SubagentCloudContext.Provider>
)
}
const SubagentGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => {
const cloud = useContext(SubagentCloudContext)
if (!cloud) throw new Error('SubagentGroupView requires SubagentCloudProvider')
const { openChildId, setManualChildId } = cloud
return (
<div data-testid="subagent-group" className="flex flex-col items-start gap-1 py-1">
{(group.subagents || []).map((child) => {
const open = openChildId === child.childRunId
return (
<Tooltip
key={child.childRunId}
open={open}
placement="topLeft"
trigger={['hover', 'focus']}
arrow={false}
color="transparent"
overlayClassName="nc-subagent-thought-tooltip"
mouseEnterDelay={0.15}
onOpenChange={(next) =>
setManualChildId((current) =>
next ? child.childRunId : current === child.childRunId ? undefined : current
)
}
title={<SubagentThought child={child} open={open} />}
>
<button
type="button"
data-testid="subagent-task"
data-child-run-id={child.childRunId}
data-status={child.status}
aria-label={child.name}
className={`flex items-center gap-1.5 rounded-lg py-1 pr-1 text-left text-[13px] leading-5 outline-none focus-visible:ring-1 focus-visible:ring-[#9B9A97] ${child.status === 'queued' ? 'text-[#9B9A97]' : 'text-[#37352F]'}`}
>
<span className={child.status === 'queued' ? 'grayscale opacity-40' : undefined}>
<PartnerAvatar
{...getSubagentAvatar(child.childRunId)}
size={32}
animated={child.status === 'running'}
animationSeed={child.childRunId}
/>
</span>
<span className="max-w-[8em] truncate">{child.name}</span>
</button>
</Tooltip>
)
})}
</div>
)
}
export default SubagentGroupView
@@ -148,7 +148,8 @@ export type AssistantProcessGroupItem = {
}
export type SubagentTaskView = import('../../../../shared/types').SubagentUpdate & {
requesting?: boolean
streaming?: boolean
messageStartedAt?: number
actions: import('../../../../shared/types').SubagentAction[]
}
@@ -676,7 +676,9 @@ export const reduceAssistantProcessGroups = (
return {
...child,
message: (event.reset ? '' : child.message || '') + (event.delta || ''),
requesting: event.requesting ?? child.requesting
streaming: event.streaming ?? child.streaming,
messageStartedAt:
event.reset && event.delta ? event.createdAt : child.messageStartedAt
}
})
}
@@ -712,7 +714,7 @@ export const reduceAssistantProcessGroups = (
message: active
? (event.update.message ?? event.update.action?.text ?? previous?.message)
: event.update.result,
requesting: event.update.status === 'running' ? previous?.requesting : false
streaming: event.update.status === 'running' ? previous?.streaming : false
}
const subagents = group.subagents?.some((item) => item.childRunId === child.childRunId)
? group.subagents.map((item) => (item.childRunId === child.childRunId ? child : item))
+1 -1
View File
@@ -1660,7 +1660,7 @@ type AiRunEventPayload =
childRunId: string
delta?: string
reset?: boolean
requesting?: boolean
streaming?: boolean
createdAt: number
}
| { type: 'subagent_updated'; runId: string; update: SubagentUpdate; createdAt: number }
@@ -25,6 +25,7 @@ import {
} from '../../../../../src/main/services/ai/human/memory/transcript'
import { messagesFromLogEntries } from '../../../../../src/main/services/ai/human/memory/conversationLogMessages'
import { createMemoryTranscript } from '../../../../../src/main/services/ai/run/subagents'
import { collectBrainPrefixCacheReuse } from '../../../../harness/core/continuation-prefix-cache'
import { AiFileChangeService } from '../../../../../src/main/services/ai/fileChanges/AiFileChangeService'
import { terminalSessionManager } from '../../../../../src/main/services/ai/human/tools/terminal/TerminalSessionManager'
import type { ChatMessageLogEntry, SubagentUpdate } from '../../../../../src/shared/types'
@@ -505,8 +506,8 @@ describe('temporary subagents through production AiRunService', () => {
.map((event) => event.delta)
.join('')
).toBe(ended.entry.payload.result)
expect(own.some((event) => event.requesting === true)).toBe(true)
expect(own.at(-1)?.requesting).toBe(false)
expect(own.some((event) => event.streaming === true)).toBe(true)
expect(own.at(-1)?.streaming).toBe(false)
}
expect(
result.records.filter(
@@ -535,6 +536,98 @@ describe('temporary subagents through production AiRunService', () => {
})
})
it('human-subagent-prefix-stability', async () => {
await runBackendCase({
caseId: 'human-subagent-prefix-stability',
domain: 'ai',
suite: 'human',
groups: ['mock-llm'],
harnessMode: 'mock-llm',
fixtures: [],
runMode: 'single',
description: '真实子内存投影连续追加与新实例固定前缀',
execute: async (runtime) => {
const files = ['A', 'B'].map((suffix) =>
path.join(runtime.workspaceRoot, `cache-note-${suffix}.txt`)
)
for (const file of files) await fs.writeFile(file, '固定资料:苍鹭;负责人:林可。')
let parentCalls = 0
const calls = new Map<string, number>()
const runner = await createAiServiceTestRunner(runtime, {
userInput: 'ROOT_CACHE 派发两轮独立核验并汇总',
streamingModeOverride: 'force_stream',
llmScript: {
'human.brain': steps((call) => {
if (!isChild(call)) {
return ++parentCalls <= 2
? delegate([
{ name: '海豹', task: `CHILD_CACHE_A 核验 ${files[0]}` },
{ name: '水獭', task: `CHILD_CACHE_B 核验 ${files[1]}` }
])
: complete('主伙伴只整合独立结果')
}
const task = call.user.match(/CHILD_CACHE_[AB]/)?.[0] || ''
const count = (calls.get(task) || 0) + 1
calls.set(task, count)
return count % 3 === 0
? complete(`PUBLIC_RESULT_${task}`)
: buildToolDecision({
toolName: 'text_file_read',
args: { sourcePath: files[task.endsWith('A') ? 0 : 1] },
summary: `PUBLIC_ACTION_${task}_${count}`
})
})
}
})
const output = await runner.run()
return { output, io: runner.io, events: runner.events }
},
assert: async (result, runtime) => {
expect(result.output.status).toBe('completed')
const brain = result.io.filter((record) => record.caller === 'human.brain')
const root = brain.filter((record) => !record.ownerRunId)
const children = [
...new Set(brain.filter((record) => record.ownerRunId).map((record) => record.runId))
]
expect(root).toHaveLength(3)
expect(children).toHaveLength(4)
const firstCalls = children.map((id) => brain.find((record) => record.runId === id)!)
for (const first of firstCalls) {
expect(first.system).toBe(firstCalls[0].system)
expect(first.requestWire?.toolsHash).toBe(firstCalls[0].requestWire?.toolsHash)
expect(first.requestWire?.messages).toHaveLength(2)
expect(first.user).not.toContain('PUBLIC_RESULT_')
expect(first.user).not.toContain('PUBLIC_ACTION_')
}
for (const runId of [runtime.runId, ...children]) {
const records = brain.filter((record) => record.runId === runId)
expect(records).toHaveLength(3)
for (let index = 1; index < records.length; index += 1) {
const reuse = collectBrainPrefixCacheReuse({
previousLastBrain: records[index - 1],
nextFirstBrain: records[index]
})
expect(reuse.prefixPreserved, JSON.stringify(reuse.requestWireDiff)).toBe(true)
}
}
expect(root.some((record) => record.user.includes('PUBLIC_ACTION_'))).toBe(false)
for (const childRunId of children) {
const own = result.events.filter(
(event) => event.type === 'subagent_stream' && event.childRunId === childRunId
)
expect(own.filter((event) => event.delta && event.reset && event.streaming)).toHaveLength(
3
)
expect(own.at(-1)?.streaming).toBe(false)
}
runtime.assertThat(true, {
name: 'subagent-prefix-stable',
message: '主子真实消息前缀保留,新实例不继承旧子历史;mock 不伪造服务端缓存'
})
}
})
})
it('human-subagent-file-cancellation', async () => {
await runBackendCase({
caseId: 'human-subagent-file-cancellation',
@@ -115,17 +115,20 @@ const selectText = async (
.locator(options.selector || 'article p')
.nth(startParagraph)
.scrollIntoViewIfNeeded()
const bounds = {
start: await textPoint(page, startParagraph, start, options.selector),
end: await textPoint(page, endParagraph, end, options.selector)
}
const startPoint = await textPoint(page, startParagraph, start, options.selector)
// Hold Shift before releasing the first click so saved text remains a selection anchor.
await page.mouse.move(bounds.start.x, bounds.start.y)
await page.mouse.move(startPoint.x, startPoint.y)
await page.mouse.down()
await page.keyboard.down('Shift')
try {
await page.mouse.up()
await page.mouse.click(bounds.end.x, bounds.end.y)
await page
.locator('.docx-content')
.locator(options.selector || 'article p')
.nth(endParagraph)
.scrollIntoViewIfNeeded()
const endPoint = await textPoint(page, endParagraph, end, options.selector)
await page.mouse.click(endPoint.x, endPoint.y)
} finally {
await page.keyboard.up('Shift')
}
@@ -538,11 +541,12 @@ describe('workbench conversation e2e', () => {
await page.keyboard.press('Backspace')
const atomicDeleted =
(await composer.locator('[data-role="document-selection-token"]').count()) === 1
await selectText(page, 0, 0, 1, 4, { selector: 'article td p', popup: false })
await page
.getByText('请选择正文中的连续文本;表格仅支持单个单元格。', { exact: true })
.waitFor()
const crossCellRejected = (await page.getByTestId('docx-selection-popup').count()) === 0
await selectText(page, 0, 0, 1, 4, { selector: 'article td p' })
await page.getByTestId('docx-selection-thought').fill('解释这两个表头。')
await page.getByTestId('docx-selection-save').click()
const crossCellQuote = (await readDraftSelections()).at(-1)?.quote
await clickSavedText(page, 1, 1, 'article td p')
await page.getByRole('button', { name: '删除引用', exact: true }).click()
await selectText(page, 0, 0, 0, 2, { selector: 'article td p' })
await page.getByTestId('docx-selection-thought').fill('临时引用。')
await page.getByTestId('docx-selection-save').click()
@@ -552,6 +556,133 @@ describe('workbench conversation e2e', () => {
.getAttribute('aria-label')
await clickSavedText(page, 0, 0, 'article td p')
await page.getByRole('button', { name: '删除引用', exact: true }).click()
await openWorkbenchWorkspaceFile(runtime, 'docx-selection-layout.docx')
await page.locator('.docx-content article table').waitFor({ state: 'visible' })
const cellSelector = 'article table td p'
const cellTexts = await page
.locator('.docx-content')
.locator(cellSelector)
.allTextContents()
const lastCell = cellTexts.length - 1
const tableText =
'Area\tOwner\tNotes\tStatus\n\nDesign\tAda\tFirst line\nSecond line\nThird paragraph\tReady\n\nBuild\tShared work\t\tPending\n\n\tBea\t\tDone'
const confirmExcerpt = async (thought: string) => {
await page.getByTestId('docx-selection-thought').fill(thought)
await page.getByTestId('docx-selection-save').click()
return (await readDraftSelections()).at(-1)?.quote
}
const removeExcerpt = async (
paragraph: number,
offset: number,
selector = 'article p'
) => {
await clickSavedText(page, paragraph, offset, selector)
await page.getByRole('button', { name: '删除引用', exact: true }).click()
}
await selectText(page, 0, 0, lastCell, cellTexts[lastCell].length, {
selector: cellSelector
})
const wholeTableQuote = await confirmExcerpt('解释所选整张表格。')
await composer.locator('[data-role="document-selection-token"]').last().click()
const locatedTable = await page.evaluate(() => window.getSelection()?.toString() || '')
await clickSavedText(page, 2, 1, cellSelector)
const tableThought = await page.getByTestId('docx-selection-thought').textContent()
await runtime.captureScreenshot({
name: 'selection-whole-table',
message: '整表引用保留多段落单元格、空格及合并占位,点击高亮重新编辑'
})
await page.getByRole('button', { name: '删除引用', exact: true }).click()
await selectText(page, lastCell, cellTexts[lastCell].length, 0, 0, {
selector: cellSelector
})
const reversedTableQuote = await confirmExcerpt('反向选择整表。')
await removeExcerpt(0, 1, cellSelector)
const paragraphIndex = (text: string) =>
page
.locator('.docx-content article p')
.evaluateAll(
(paragraphs, text) =>
paragraphs.findIndex((paragraph) => paragraph.textContent === text),
text
)
const beforeTable = await paragraphIndex('Text before table.')
const afterTable = await paragraphIndex('Text after table.')
await selectText(page, beforeTable, 0, afterTable, 'Text after table.'.length)
const mixedTableQuote = await confirmExcerpt('解释表格及前后正文。')
await removeExcerpt(beforeTable, 2)
await selectText(page, 0, 0, 0, 'Selection header'.length, { selector: 'header p' })
const headerQuote = await confirmExcerpt('解释页眉。')
await removeExcerpt(0, 1, 'header p')
await selectText(page, 0, 0, 0, 'Selection footer'.length, { selector: 'footer p' })
const footerQuote = await confirmExcerpt('解释页脚。')
await removeExcerpt(0, 1, 'footer p')
const firstPage = await paragraphIndex('Page one end.')
const secondPage = await paragraphIndex('Page two start.')
await selectText(page, firstPage, 0, secondPage, 'Page two start.'.length)
const crossPageQuote = await confirmExcerpt('解释跨页选区。')
const pageCount = await page.locator('.docx-content section.docx').count()
await removeExcerpt(secondPage, 1)
const imageBefore = await paragraphIndex('Image before.')
const imageAfter = await paragraphIndex('Image after.')
await selectText(page, imageBefore, 0, imageAfter, 'Image after.'.length)
const imageMixedQuote = await confirmExcerpt('解释图片前后的文字。')
await removeExcerpt(imageBefore, 1)
await selectText(page, imageBefore, 'Image before.'.length, imageAfter, 0, {
popup: false
})
const toast = page
.locator('.ant-message-notice')
.filter({ hasText: '当前选区没有可读取的文字。' })
await toast.waitFor({ state: 'visible' })
const toastCount = await toast.count()
const noInlineNotice =
!(await page.locator('.docx-content').textContent())?.includes(
'当前选区没有可读取的文字。'
) && (await page.getByTestId('docx-selection-popup').count()) === 0
await runtime.captureScreenshot({
name: 'selection-system-toast',
message: '纯图片选区使用统一系统 toast,同一提示合并,文档内无提示条'
})
await toast.waitFor({ state: 'detached', timeout: 10_000 })
await runtime.assertThat(
wholeTableQuote === tableText &&
reversedTableQuote === tableText &&
tableThought === '解释所选整张表格。' &&
locatedTable.includes('First line') &&
locatedTable.includes('Done') &&
mixedTableQuote === 'Text before table.\n' + tableText + '\nText after table.' &&
headerQuote === 'Selection header' &&
footerQuote === 'Selection footer' &&
pageCount === 2 &&
crossPageQuote?.startsWith('Page one end.') === true &&
crossPageQuote.endsWith('Page two start.') &&
imageMixedQuote?.includes('Image before.') === true &&
imageMixedQuote.includes('Image after.') &&
toastCount === 1 &&
noInlineNotice,
{
name: 'docx-selection-table-pages-and-toast',
message:
'真实整表、反向选择、正文混选、合并与空单元格、单元格内换行、页眉页脚、跨页及图片混选正确;无文字使用单个自动消失的系统 toast。',
data: {
wholeTableQuote,
reversedTableQuote,
mixedTableQuote,
headerQuote,
footerQuote,
crossPageQuote,
pageCount,
imageMixedQuote,
tableThought,
locatedTable,
toastCount,
noInlineNotice
}
}
)
await composer.locator('[data-role="document-selection-token"]').click()
await page
.locator('.docx-content article p')
@@ -679,7 +810,7 @@ describe('workbench conversation e2e', () => {
memoryRestored,
cellQuote,
atomicDeleted,
crossCellRejected,
crossCellQuote,
resetLabel,
nextDraftLabel,
frozenHistoryLabel,
@@ -712,7 +843,7 @@ describe('workbench conversation e2e', () => {
result.memoryRestored &&
result.cellQuote === '模式' &&
result.atomicDeleted &&
result.crossCellRejected &&
result.crossCellQuote === '模式\t核心驱动' &&
result.resetLabel === '人工智能探究课堂PRD.docx · 1' &&
result.nextDraftLabel === '客户周会纪要.docx · 1' &&
result.frozenHistoryLabel === result.historyLabel &&
@@ -1,3 +1,4 @@
import type { Locator } from 'playwright'
import http from 'node:http'
import fs from 'node:fs/promises'
import path from 'node:path'
@@ -39,6 +40,30 @@ const updates = async (runtime: E2ETestRuntime, started: number) => {
)
}
const assertCloudTextInside = async (cloud: Locator): Promise<void> => {
expect(
await cloud.evaluate((element) => {
const lines = element.querySelector<HTMLElement>('.nc-subagent-thought-lines')!
const outline = element.querySelector<SVGPathElement>('.nc-subagent-thought-outline path')!
const inverse = outline.getScreenCTM()!.inverse()
const bounds = lines.getBoundingClientRect()
const corners = [
[bounds.left, bounds.top],
[bounds.right, bounds.top],
[bounds.left, bounds.bottom],
[bounds.right, bounds.bottom]
]
return (
corners.every(([x, y]) =>
outline.isPointInFill(new DOMPoint(x, y).matrixTransform(inverse))
) &&
lines.scrollWidth <= lines.clientWidth &&
getComputedStyle(lines).overflowX === 'hidden'
)
})
).toBe(true)
}
// Real website input: forms send server receipts, source facts are rendered by JavaScript.
const createSite = async () => {
const receipts: string[] = []
@@ -191,11 +216,75 @@ describe('subagent browser and sidecar real UI', () => {
)?.includes('选择模型'),
10_000
)
await runtime.page.mouse.move(0, 0)
// Read-only DOM observer measures one real automatic text cycle, including
// visibility after text ends. A newer message may legitimately supersede it.
const autoCycle = runtime.page.evaluate(
() =>
new Promise<{ elapsedMs: number }>((resolve, reject) => {
let cycle: { key: string; endedAt?: number } | undefined
const started = performance.now()
const timer = window.setInterval(() => {
if (performance.now() - started > 120_000) {
window.clearInterval(timer)
reject(new Error('未观察到真实自动云的完整显示周期'))
return
}
const cloud = [
...document.querySelectorAll<HTMLElement>('[data-testid="subagent-thought"]')
].find(
(element) =>
element.getClientRects().length > 0 && !element.closest('.ant-tooltip-hidden')
)
const key = cloud
? `${cloud.dataset.childRunId}:${cloud.dataset.messageStartedAt}`
: undefined
const text = cloud
?.querySelector('[data-testid="subagent-message"]')
?.textContent?.trim()
const streaming = Boolean(
cloud?.querySelector('[data-testid="subagent-stream-dot"]')
)
if (cloud && key && text && streaming) {
cycle = { key }
} else if (cycle && key === cycle.key && !streaming) {
cycle.endedAt ??= performance.now()
} else if (cycle?.endedAt && !cloud) {
window.clearInterval(timer)
resolve({ elapsedMs: performance.now() - cycle.endedAt })
} else if (key !== cycle?.key) {
cycle = undefined
}
}, 25)
})
)
const started = Date.now()
await sendWorkbenchMessage(
runtime.page,
`请一次派发三位临时帮手分头处理,并由你最后整合:第一位在内置浏览器打开 ${site.url},填写 ALPHA_731 并提交,提交后用内置浏览器截图保存到 ${screenshot} 并保留表单页面,另外用网页资料抓取读取 ${site.url}/facts?marker=31415 并核对该数字,最后将 ${config} 中 timeoutMs 改为 3000,其他字段不动;第二位独立在内置浏览器打开同一个 ${site.url},填写 BETA_892 并提交,提交后保留表单页面,另外用网页资料抓取读取 ${site.url}/facts?marker=27182 核对该数字;第三位搜索 Python 官方 csv reader 说明返回官方来源链接。网页帮手各自使用自己的标签。你把两个提交结果、资料数字和官方来源写到 ${report},检查后交付。`
)
// No hover: the real public text stream opens the cloud and shows its trailing dot.
const automaticCloud = runtime.page.locator('[data-testid="subagent-thought"]:visible')
await waitUntil(
async () =>
(await automaticCloud.count()) === 1 &&
(await automaticCloud.getByTestId('subagent-stream-dot').count()) === 1 &&
Boolean((await automaticCloud.getByTestId('subagent-message').textContent())?.trim()),
120_000
)
await assertCloudTextInside(automaticCloud)
await runtime.captureScreenshot({
name: 'subagent-cloud-auto',
message: '未悬浮时最新公开消息自动显示,文字及黑球位于云内'
})
const cycle = await autoCycle
expect(cycle.elapsedMs).toBeGreaterThanOrEqual(1800)
expect(cycle.elapsedMs).toBeLessThan(3500)
runtime.signal({
name: 'subagent-auto-cloud-hold',
message: '真实文字流结束后云停留约两秒自动隐藏',
data: cycle
})
await waitUntil(async () =>
(await updates(runtime, started)).some((event) => event.update.status === 'running')
)
@@ -208,7 +297,11 @@ describe('subagent browser and sidecar real UI', () => {
await queuedRow.hover()
const queuedCloud = runtime.page.locator('[data-testid="subagent-thought"]:visible')
await queuedCloud.waitFor({ state: 'visible' })
expect(await queuedCloud.getAttribute('data-child-run-id')).toBe(
await queuedRow.getAttribute('data-child-run-id')
)
expect(await queuedCloud.getByTestId('subagent-message').textContent()).toBe('我在排队')
await assertCloudTextInside(queuedCloud)
expect(await queuedCloud.getByTestId('subagent-stream-dot').count()).toBe(0)
await runtime.captureScreenshot({
name: 'subagent-cloud-queued',
@@ -236,7 +329,11 @@ describe('subagent browser and sidecar real UI', () => {
.evaluate((group) => getComputedStyle(group).flexDirection)
).toBe('column')
await waitUntil(async () => {
if (await runtime.page.locator('[data-testid="subagent-thought"]:visible').isVisible())
const visible = runtime.page.locator('[data-testid="subagent-thought"]:visible')
if (
(await visible.count()) === 1 &&
(await visible.getAttribute('data-child-run-id')) === previewChildRunId
)
return true
await runtime.page.mouse.move(0, 0)
await childRow.hover()
@@ -261,6 +358,7 @@ describe('subagent browser and sidecar real UI', () => {
.evaluate((dot) => getComputedStyle(dot).animationName)
).toBe('nc-subagent-dot-breathe')
expect(await liveCloud.getByTestId('subagent-message').count()).toBe(1)
await assertCloudTextInside(liveCloud)
await waitUntil(
async () =>
liveCloud
@@ -489,6 +587,7 @@ describe('subagent browser and sidecar real UI', () => {
)
expect(await completedCloud.getByTestId('subagent-message').count()).toBe(1)
expect(await completedCloud.getByTestId('subagent-stream-dot').count()).toBe(0)
await assertCloudTextInside(completedCloud)
expect(
await completedCloud.evaluate((cloud) => {
const lines = cloud.querySelector('.nc-subagent-thought-lines')!
@@ -566,6 +665,13 @@ describe('subagent browser and sidecar real UI', () => {
site.getSlowRequests() > slowBefore &&
(await updates(runtime, start)).some((event) => event.update.status === 'completed')
)
await runtime.page.mouse.move(0, 0)
await getWorkbenchLocator(runtime.page, workbenchSelectors.input).focus()
// The genuine pending navigation leaves a gap after text output; no test delay
// is added to the model or tool. Auto clouds must expire while the tool still runs.
await runtime.page
.locator('[data-testid="subagent-thought"]:visible')
.waitFor({ state: 'hidden', timeout: 10_000 })
const before = await updates(runtime, start)
const completed = before.find((event) => event.update.status === 'completed')!
expect(await fs.readFile(saved, 'utf8')).toContain(crash ? 'SAVED_CRASH' : 'SAVED_STOP')
@@ -584,6 +690,9 @@ describe('subagent browser and sidecar real UI', () => {
.locator('[data-testid="subagent-task"][data-status="interrupted"]')
.last()
.waitFor({ state: 'visible' })
expect(
await runtime.page.locator('[data-testid="subagent-thought"]:visible').count()
).toBe(0)
expect(
await runtime.page
.locator(`[data-child-run-id="${completed.update.childRunId}"]`)
@@ -600,6 +709,19 @@ describe('subagent browser and sidecar real UI', () => {
.map((contents) => contents.id)
)
expect(loadingPages.length).toBeGreaterThan(0)
const pendingChild = runtime.page
.locator('[data-testid="subagent-task"][data-status="running"]')
.last()
await pendingChild.hover()
const pendingCloud = runtime.page.locator('[data-testid="subagent-thought"]:visible')
await pendingCloud.waitFor({ state: 'visible' })
expect(await pendingCloud.getByTestId('subagent-stream-dot').count()).toBe(0)
await assertCloudTextInside(pendingCloud)
// Manual reading survives longer than the automatic two-second hold.
await runtime.page.waitForTimeout(2200)
expect(await pendingCloud.isVisible()).toBe(true)
await runtime.page.mouse.move(0, 0)
await pendingCloud.waitFor({ state: 'hidden' })
await runtime.page.getByRole('button', { name: '停止本次运行', exact: true }).click()
await waitUntil(async () => {
const latest = new Map(
@@ -0,0 +1,206 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { AiLlmIoRecord, AiRunEvent } from '../../../../../src/shared/types'
import {
collectBrainPrefixCacheReuse,
continuationPrefixCacheAssertionData
} from '../../../../harness/core/continuation-prefix-cache'
import { runE2ECase } from '../../../../harness/e2e/run-e2e-case'
import {
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
assertNoWorkbenchFakeSignals,
clampWorkbenchPartnerName,
collectWorkbenchLlmIoRecords,
configureLiveModelThroughSettingsUi,
createPartnerThroughWorkbenchUi,
createTemporaryWorkbenchWorkspace,
loadWorkbenchLiveModelEnv,
prepareWorkbenchWorkspaceThroughUi,
recordWorkbenchAiEvidence,
selectWorkbenchModel,
sendWorkbenchMessage,
waitForWorkbenchAiRunCompletion
} from '../../../../harness/e2e/workbench'
const CASE_ID = 'subagent-prefix-cache-retain-live'
const SOURCES = ['本周排期更新.txt', '对外口径确认.txt', '临时备注.txt']
const TASKS = [
{ directory: '排期核对', files: [SOURCES[0], SOURCES[1]] },
{ directory: '口径核对', files: [SOURCES[2], SOURCES[1]] }
]
const brainCalls = (records: readonly AiLlmIoRecord[], runId: string): AiLlmIoRecord[] =>
records.filter(
(record) =>
record.runId === runId && record.caller === 'human.brain' && record.status === 'completed'
)
const childUpdates = (events: readonly AiRunEvent[]) =>
events.filter(
(event): event is Extract<AiRunEvent, { type: 'subagent_updated' }> =>
event.type === 'subagent_updated'
)
describe('workbench subagent prefix cache e2e', () => {
it(
CASE_ID,
async () => {
await runE2ECase({
caseId: CASE_ID,
domain: 'workbench',
suite: 'run',
groups: ['subagent'],
description: '真实 UI 一次派发,验证同一子实例连续调用及主派发收口的前缀和缓存',
fixtures: SOURCES.map((name) => `tests/fixtures/workspace/工作/${name}`),
serviceMode: 'real',
execution: 'live',
windowMode: 'visible',
runMode: 'single',
execute: async (runtime) => {
const env = loadWorkbenchLiveModelEnv()
const workspace = await createTemporaryWorkbenchWorkspace(CASE_ID, 'empty')
const originals = new Map(
await Promise.all(
SOURCES.map(
async (name) =>
[
name,
await fs.readFile(path.resolve('tests/fixtures/workspace/工作', name), 'utf8')
] as const
)
)
)
const inputs: Array<{ sourcePath: string; original: string }> = []
const taskFiles: string[][] = []
for (const task of TASKS) {
const directory = path.join(workspace.rootPath, task.directory)
await fs.mkdir(directory)
const files = task.files.map((name) => path.join(directory, name))
for (const [index, name] of task.files.entries()) {
const original = originals.get(name)!
await fs.writeFile(files[index], original, 'utf8')
inputs.push({ sourcePath: files[index], original })
}
taskFiles.push(files)
}
await configureLiveModelThroughSettingsUi(runtime.page, env)
const workspaceName = clampWorkbenchPartnerName(`Workbench ${CASE_ID}`)
await prepareWorkbenchWorkspaceThroughUi(runtime.page, {
workspaceName,
rootPath: workspace.rootPath
})
await createPartnerThroughWorkbenchUi(runtime.page, workspaceName)
await selectWorkbenchModel(runtime.page, env.modelName)
const tasks = [
`先阅读 ${taskFiles[0][0]},找出最终复核的时间安排;得到排期信息后,再阅读 ${taskFiles[0][1]} 核对正式负责人称呼。交付时间、负责人及两份来源路径。`,
`先阅读 ${taskFiles[1][0]},识别这份内部随手记对风险状态的判断;得到判断后,再阅读 ${taskFiles[1][1]} 核对能否用于对外同步。交付可用的风险口径、原因及两份来源路径。`
]
const startedAt = Date.now()
await sendWorkbenchMessage(
runtime.page,
`请通过 delegate_to_subagent 一次派发两位临时帮手,分别独立执行任务A:${tasks[0]} 任务B:${tasks[1]} 两位帮手只读自己的资料目录,不修改文件。你只简短汇总他们交付的负责人、排期和对外风险口径,不接管核对。`
)
const evidence = await waitForWorkbenchAiRunCompletion(
runtime,
startedAt,
env.modelName,
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
{ page: runtime.page }
)
recordWorkbenchAiEvidence(runtime, evidence, 0.8)
const runId = evidence.events.find((event) => event.type === 'run_started')?.runId
if (!runId) throw new Error('缺少主运行 ID')
const records = await collectWorkbenchLlmIoRecords(runtime)
const unchanged = await Promise.all(
inputs.map(
async (input) => (await fs.readFile(input.sourcePath, 'utf8')) === input.original
)
)
await runtime.captureScreenshot({
name: 'subagent-cache-completed',
message: '一次派发的两项资料核对和主伙伴汇总'
})
return { runId, events: evidence.events, records, taskFiles, unchanged }
},
assert: async (result, runtime) => {
expect(result.unchanged).toEqual([true, true, true, true])
const assertCache = async (calls: AiLlmIoRecord[]): Promise<void> => {
const pairs = calls.slice(1).map((next, index) =>
collectBrainPrefixCacheReuse({
previousLastBrain: calls[index],
nextFirstBrain: next
})
)
expect(pairs.length).toBeGreaterThan(0)
for (const [index, reuse] of pairs.entries()) {
await runtime.assertThat(reuse.prefixPreserved, {
name: `request-prefix-${calls[0].runId}-${index + 1}`,
message: '同一执行者的连续请求保留已有消息及工具前缀',
data: continuationPrefixCacheAssertionData(reuse)
})
expect(Number.isFinite(reuse.reuseRatio)).toBe(true)
expect(reuse.nextFirstCachedInputTokens).toBeGreaterThanOrEqual(0)
expect(reuse.nextFirstCachedInputTokens!).toBeLessThanOrEqual(
reuse.nextFirstInputTokens!
)
}
await runtime.assertThat(
pairs.some((reuse) => (reuse.nextFirstCachedInputTokens || 0) > 0),
{
name: `run-cache-reuse-${calls[0].runId}`,
message: '同一执行者的连续请求实际复用缓存;逐段比例记录供评估',
data: {
pairs: pairs.map(continuationPrefixCacheAssertionData)
}
}
)
}
const updates = childUpdates(result.events)
const completed = updates.filter((event) => event.update.status === 'completed')
expect(completed).toHaveLength(2)
const results: string[] = []
for (const event of completed) {
const childRunId = event.update.childRunId
const calls = brainCalls(result.records, childRunId)
expect(calls.length).toBeGreaterThanOrEqual(3)
expect(
calls.every(
(call) =>
call.ownerRunId === result.runId &&
call.parentToolCallId === event.update.toolCallId
)
).toBe(true)
const reads = updates.filter(
(item) =>
item.update.childRunId === childRunId &&
item.update.action?.toolName === 'text_file_read' &&
item.update.action.status === 'completed'
)
expect(reads.length).toBeGreaterThanOrEqual(2)
const toolMessages = JSON.stringify(
calls.flatMap(
(call) =>
call.requestWire?.messages.filter((message) => message.role === 'tool') || []
)
)
const files = result.taskFiles.find((sources) => toolMessages.includes(sources[0]))
expect(files).toBeDefined()
for (const file of files!) expect(toolMessages).toContain(file)
expect(toolMessages).toContain('Alice')
expect(toolMessages).toContain('保持跟进')
results.push(event.update.result || '')
await assertCache(calls)
}
expect(results.join('\n')).toContain('Alice')
expect(results.join('\n')).toMatch(/跟进|签署/)
const root = brainCalls(result.records, result.runId)
expect(root.length).toBeGreaterThanOrEqual(2)
await assertCache(root)
expect(root.at(-1)?.rawText).toContain('Alice')
expect(root.at(-1)?.rawText).toMatch(/跟进|签署/)
await assertNoWorkbenchFakeSignals(runtime)
}
})
},
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS
)
})
Binary file not shown.
@@ -4,7 +4,7 @@ import { diffChatToolsRequestWire } from '../../../src/main/services/ai/utils/ll
/**
* 跨 run 续接时,下一 run 首次 brain 的 cachedInputTokens
* 相对上一 run 末次 brain inputTokens 的最低比例。
* 全 live 跨 run case 共用;调低/调高只改这一处,避免各 case 阈值漂移。
* 连续 brain 与跨 run live 共用;调低/调高只改这一处,避免各 case 阈值漂移。
*/
export const CROSS_RUN_PREFIX_CACHE_MIN_RATIO = 0.85
@@ -60,6 +60,17 @@ export const collectContinuationPrefixCacheReuse = (input: {
const previousLastBrain =
lastBrainRecordWithUsage(previousRecords) || lastBrainRecord(previousRecords)
const nextFirstBrain = firstBrainRecord(nextRecords)
return collectBrainPrefixCacheReuse({ previousLastBrain, nextFirstBrain, minRatio })
}
/** Same projection invariant for adjacent brain calls within a run or across runs. */
export const collectBrainPrefixCacheReuse = (input: {
previousLastBrain: AiLlmIoRecord | undefined
nextFirstBrain: AiLlmIoRecord | undefined
minRatio?: number
}): ContinuationPrefixCacheReuse => {
const { previousLastBrain, nextFirstBrain } = input
const minRatio = input.minRatio ?? CROSS_RUN_PREFIX_CACHE_MIN_RATIO
const previousWire = previousLastBrain?.requestWire
const nextWire = nextFirstBrain?.requestWire
const prefixPreserved = Boolean(