diff --git a/doc/ai-harness/human-harness.md b/doc/ai-harness/human-harness.md index e679af10..a7d99b5b 100644 --- a/doc/ai-harness/human-harness.md +++ b/doc/ai-harness/human-harness.md @@ -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 行、不自动重派。 diff --git a/doc/deploy/release.md b/doc/deploy/release.md index c54c9e72..70664462 100644 --- a/doc/deploy/release.md +++ b/doc/deploy/release.md @@ -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 不能合并、重排或修改,只能新增更高版本。 diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index 22627976..12dd8485 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -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 人机交互阻塞 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 2ff83561..1a09b5fe 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -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。 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index fd2b3004..393b12a5 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -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` diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs index af4031de..579d8259 100644 --- a/scripts/release/prepare.mjs +++ b/scripts/release/prepare.mjs @@ -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' ]) diff --git a/src/main/services/ai/run/subagents.ts b/src/main/services/ai/run/subagents.ts index 66ab99dc..925fccf0 100644 --- a/src/main/services/ai/run/subagents.ts +++ b/src/main/services/ai/run/subagents.ts @@ -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() 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 { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index e40412f7..6f641008 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx b/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx index 0d1e1228..c9cd7865 100644 --- a/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx +++ b/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx @@ -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(null) const [floating, setFloating] = useState(null) const [hoveredId, setHoveredId] = useState(null) - const [notice, setNotice] = useState('') const popupRef = useRef(null) const latestRef = useRef(state) const targetsRef = useRef([]) @@ -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({ 编辑所选内容 ))} - {notice && ( - - )} {popup && createPortal(
{ + 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 } diff --git a/src/renderer/src/components/workbench/AssistantTurnCard.tsx b/src/renderer/src/components/workbench/AssistantTurnCard.tsx index dce10a05..72f2d7b3 100644 --- a/src/renderer/src/components/workbench/AssistantTurnCard.tsx +++ b/src/renderer/src/components/workbench/AssistantTurnCard.tsx @@ -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(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 ( -
- -
- {requesting ? text.trimEnd() : text} - {requesting ? ( - - ) : null} -
-
- ) -} - -const SubagentTaskRow: React.FC<{ child: SubagentTaskView }> = ({ child }) => { - const [open, setOpen] = useState(false) - return ( - } - > - - - ) -} - -const SubagentGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => ( -
- {(group.subagents || []).map((child) => ( - - ))} -
-) - const AssistantProcessGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => { if (group.subagents?.length) return const isRunning = group.status === 'running' @@ -834,227 +731,229 @@ const AssistantTurnCard: React.FC = ({ } return ( -
-
- {timeline.length > 0 || hasStreamingThought || reserveThinkingSlot || turn.startedAt ? ( -
- {turn.startedAt ? ( - turn.runFinished ? ( - - ) : ( -
- - 已处理 {formatRunDuration(turn.startedAt, currentTime)} - -
- ) - ) : null} -
- {timeline.map((entry) => { - if (entry.kind === 'decision') { - return ( - + 用时 {formatRunDuration(turn.startedAt, turn.finishedAt ?? currentTime)} + + {processExpanded ? ( + + ) : ( + + )} + + ) : ( +
+ + 已处理 {formatRunDuration(turn.startedAt, currentTime)} + +
+ ) + ) : null} +
+ {timeline.map((entry) => { + if (entry.kind === 'decision') { + return ( + + ) + } + if (entry.kind === 'activity') { + return ( + + ) + } + return + })} + {streamingRole === 'thought' && streamingMarkdown.trim() ? ( +
+ - ) - } - if (entry.kind === 'activity') { - return ( - - ) - } - return - })} - {streamingRole === 'thought' && streamingMarkdown.trim() ? ( -
- -
- ) : null} - {reserveThinkingSlot ? ( -
- {showThinkingIndicator ? ( - - 让俺想想 - - ) : null} -
- ) : null} -
-
- ) : null} - - {hasFinalAnswer ? ( -
- -
- ) : null} - - {turn.status === 'failed' && turn.error ? ( -
-
失败:{turn.error}
- {turn.errorDetail ? ( -
- - 查看详情 - -
-                  {turn.errorDetail}
-                
-
- ) : null} -
- ) : null} - - {shouldShowAssistantApplyTargets({ - runFinished: turn.runFinished, - applyTargetCount: turn.applyTargets.length - }) ? ( -
-
-
- -
-
-
修改的文件
-
点击文件查看本次修改
-
-
- {turn.applyTargets.length} 个 -
-
-
- {visibleApplyTargets.map((apply) => { - const statusLabel = - apply.status === 'reverted' - ? apply.operation === 'created' - ? '已删除' - : '已回退' - : apply.status === 'failed' - ? '失败' - : null - return ( -
+ ) : null} + {reserveThinkingSlot ? ( +
-
-
- -
- - - {statusLabel ? ( - - {statusLabel} - - ) : null} + {showThinkingIndicator ? ( + + 让俺想想 -
- - ) - })} + ) : null} +
+ ) : null} +
- {turn.applyTargets.length > MAX_VISIBLE_APPLY_TARGETS ? ( - - ) : null} -
- ) : null} + ) : null} + + {hasFinalAnswer ? ( +
+ +
+ ) : null} + + {turn.status === 'failed' && turn.error ? ( +
+
失败:{turn.error}
+ {turn.errorDetail ? ( +
+ + 查看详情 + +
+                    {turn.errorDetail}
+                  
+
+ ) : null} +
+ ) : null} + + {shouldShowAssistantApplyTargets({ + runFinished: turn.runFinished, + applyTargetCount: turn.applyTargets.length + }) ? ( +
+
+
+ +
+
+
修改的文件
+
点击文件查看本次修改
+
+
+ {turn.applyTargets.length} 个 +
+
+
+ {visibleApplyTargets.map((apply) => { + const statusLabel = + apply.status === 'reverted' + ? apply.operation === 'created' + ? '已删除' + : '已回退' + : apply.status === 'failed' + ? '失败' + : null + return ( + + ) + })} +
+ {turn.applyTargets.length > MAX_VISIBLE_APPLY_TARGETS ? ( + + ) : null} +
+ ) : null} +
-
+ ) } diff --git a/src/renderer/src/components/workbench/SubagentGroupView.tsx b/src/renderer/src/components/workbench/SubagentGroupView.tsx new file mode 100644 index 00000000..78b85244 --- /dev/null +++ b/src/renderer/src/components/workbench/SubagentGroupView.tsx @@ -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(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 ( +
+ +
+ {streaming ? text.trimEnd() : text} + {streaming ? ( + + ) : null} +
+
+ ) +} + +const AUTO_CLOUD_HOLD_MS = 2000 +const SubagentCloudContext = createContext< + | { + openChildId?: string + setManualChildId: React.Dispatch> + } + | 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() + const [autoChildId, setAutoChildId] = useState() + // 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 ( + + {children} + + ) +} + +const SubagentGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => { + const cloud = useContext(SubagentCloudContext) + if (!cloud) throw new Error('SubagentGroupView requires SubagentCloudProvider') + const { openChildId, setManualChildId } = cloud + return ( +
+ {(group.subagents || []).map((child) => { + const open = openChildId === child.childRunId + return ( + + setManualChildId((current) => + next ? child.childRunId : current === child.childRunId ? undefined : current + ) + } + title={} + > + + + ) + })} +
+ ) +} + +export default SubagentGroupView diff --git a/src/renderer/src/components/workbench/types.ts b/src/renderer/src/components/workbench/types.ts index a0d5cf67..3204b878 100644 --- a/src/renderer/src/components/workbench/types.ts +++ b/src/renderer/src/components/workbench/types.ts @@ -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[] } diff --git a/src/renderer/src/components/workbench/utils.ts b/src/renderer/src/components/workbench/utils.ts index cfdc3774..f74cbd41 100644 --- a/src/renderer/src/components/workbench/utils.ts +++ b/src/renderer/src/components/workbench/utils.ts @@ -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)) diff --git a/src/shared/types.ts b/src/shared/types.ts index ad6c0260..8c28136f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 } diff --git a/tests/case/ai/human/mock-llm/human-subagent.test.ts b/tests/case/ai/human/mock-llm/human-subagent.test.ts index 1abbf077..edb4067b 100644 --- a/tests/case/ai/human/mock-llm/human-subagent.test.ts +++ b/tests/case/ai/human/mock-llm/human-subagent.test.ts @@ -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() + 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', diff --git a/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts b/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts index 24d6c40d..714e53b1 100644 --- a/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts @@ -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 && diff --git a/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts b/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts index 6af59417..a5f66fe0 100644 --- a/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts +++ b/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts @@ -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 => { + expect( + await cloud.evaluate((element) => { + const lines = element.querySelector('.nc-subagent-thought-lines')! + const outline = element.querySelector('.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('[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( diff --git a/tests/case/workbench/run/subagent/subagent-prefix-cache-retain-live.e2e.test.ts b/tests/case/workbench/run/subagent/subagent-prefix-cache-retain-live.e2e.test.ts new file mode 100644 index 00000000..448d9054 --- /dev/null +++ b/tests/case/workbench/run/subagent/subagent-prefix-cache-retain-live.e2e.test.ts @@ -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 => + 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 => { + 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 + ) +}) diff --git a/tests/fixtures/workspace/docx-selection-layout.docx b/tests/fixtures/workspace/docx-selection-layout.docx new file mode 100644 index 00000000..5a5bdb0e Binary files /dev/null and b/tests/fixtures/workspace/docx-selection-layout.docx differ diff --git a/tests/harness/core/continuation-prefix-cache.ts b/tests/harness/core/continuation-prefix-cache.ts index a448bdb7..666ad9fc 100644 --- a/tests/harness/core/continuation-prefix-cache.ts +++ b/tests/harness/core/continuation-prefix-cache.ts @@ -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(