feat: improve conversation timeline and agent plans
This commit is contained in:
@@ -1 +1 @@
|
||||
subagent-card-metadata
|
||||
subagent-plan-visibility-fix
|
||||
|
||||
10
.planning/repackage-and-push/findings.md
Normal file
10
.planning/repackage-and-push/findings.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 重新打包并提交推送:发现记录
|
||||
|
||||
- 当前分支为 `main`,跟踪 `origin/main`。
|
||||
- 当前工作区包含已修改文件、规划文件和未跟踪任务文件;用户明确要求全部提交。
|
||||
- `bun` 不在 PATH,需按发布技能复用已有 `@oven/bun-linux-x64-baseline` 可执行文件。
|
||||
- 可用构建器为 `/tmp/ccweb-bun.vyBprA/node_modules/@oven/bun-linux-x64-baseline/bin/bun`,版本 `1.3.14`。
|
||||
- `main` 与 `origin/main` 当前均指向 `4d97446a6f24ef10d6b7d59af1b90fe561b66c78`。
|
||||
- 固定发布产物为 `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz`。
|
||||
- 新发布包 SHA-256 为 `8ff59b222a87cc54c21b80ca285f506682a43b600f6cb1f0fcb4e49fc9006182`,大小 38,977,260 字节。
|
||||
- `.gitignore` 忽略展开的 `dist-exe/*`,但显式保留 `dist-exe/*.tar.gz`。
|
||||
14
.planning/repackage-and-push/progress.md
Normal file
14
.planning/repackage-and-push/progress.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 重新打包并提交推送:进度
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- 已读取 `cc-web-centos7-release`、`planning-with-files`、`todo-list-csv` 技能全文。
|
||||
- 已运行 session catchup,恢复本轮“重新打包、全部提交、推送且不审计”的上下文。
|
||||
- 已确认当前分支 `main` 跟踪 `origin/main`,工作区存在多项修改和未跟踪文件。
|
||||
- 已确认系统 PATH 中没有 Bun,下一步查找本机现有 baseline Bun。
|
||||
- 已建立六步 update_plan 与同名 TODO CSV;按用户明确要求跳过独立计划审查。
|
||||
- 已找到并验证 baseline Bun 1.3.14;当前 `main` 与 `origin/main` 起点一致,阶段 1 完成。
|
||||
- 已用 `BUN_BIN` 指向本机 baseline Bun 执行 `npm run build:single-exe`,成功生成展开目录与 `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz`;阶段 2 完成。
|
||||
- 六项 JavaScript 语法检查、发布二进制 MCP `initialize` smoke、tar 清单和摘要检查全部通过;阶段 3 完成。
|
||||
- 已执行 `git add -A`,16 个路径全部进入暂存区,无未暂存和未跟踪遗漏;阶段 4 完成。
|
||||
- 已创建主提交 `2efb9d2`(`feat: improve conversation timeline and agent plans`);阶段 5 完成,准备推送 `main`。
|
||||
30
.planning/repackage-and-push/task_plan.md
Normal file
30
.planning/repackage-and-push/task_plan.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 重新打包并提交推送
|
||||
|
||||
## 目标
|
||||
|
||||
使用项目既定的 Bun baseline 流程重新生成 CentOS 7 单文件发布包,完成必要的发布包验证,然后将当前工作区全部修改提交并推送到当前分支上游。
|
||||
|
||||
## 阶段
|
||||
|
||||
| 阶段 | 状态 | 验收标准 |
|
||||
|---|---|---|
|
||||
| 1. 确认 Bun baseline 构建器与 Git 推送目标 | complete | 找到现有 baseline Bun,确认当前分支及上游 |
|
||||
| 2. 重新生成 CentOS 7 baseline 发布包 | complete | 生成 `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz` |
|
||||
| 3. 执行发布包与语法验证 | complete | 技能规定的语法、MCP smoke、tar 清单检查通过 |
|
||||
| 4. 暂存当前工作区全部修改 | complete | `git add -A` 后无未暂存或未跟踪修改 |
|
||||
| 5. 创建包含全部修改的提交 | complete | 当前全部修改进入一个新提交 |
|
||||
| 6. 推送当前分支并确认远端 | in_progress | 推送成功,当前分支与上游一致 |
|
||||
|
||||
## 约束
|
||||
|
||||
- 固定目标为 `bun-linux-x64-baseline`,不切换 Docker。
|
||||
- 不安装新 Bun;优先复用本机已有 baseline Bun。
|
||||
- 按用户要求不做额外代码审计,现有全部修改均纳入提交。
|
||||
- 提交前仍执行发布技能规定的轻量验证;这是打包验收而非代码审计。
|
||||
- 不改写历史,不强推。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---|---|
|
||||
| `bun` 不在 PATH | 1 | 按发布技能查找本机已有 baseline Bun |
|
||||
50
.planning/subagent-plan-visibility-fix/findings.md
Normal file
50
.planning/subagent-plan-visibility-fix/findings.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 发现记录
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- 演示子代理线程 `019f9160-6dde-7911-928c-925488ab00f9` 的真实 rollout 中有 4 次 `update_plan`:依次推进三个步骤并最终全部完成。
|
||||
- 当前父会话持久化快照中,`plan_card_demo` 已保存 `planProgress: { completed: 3, total: 3 }`,证明服务端至少在最终快照链路中拿到了计划摘要。
|
||||
- 用户界面始终未看到计划进度,根因应聚焦前端历史消息重建、collab tool 合并或实时 child update 的 DOM 刷新。
|
||||
- 旧的 `subagent-plan-progress` 任务已经实现服务端计划解析、状态持久化与基础卡片渲染,但真实使用仍暴露了覆盖不足的链路。
|
||||
- 当前不重启;先用真实 session 数据构造复现和回归。
|
||||
- codebase-memory 索引状态为 ready(4140 nodes / 8942 edges);关键前端入口仍为 `mergeCollabAgentTools`、`upsertCollabAgentToolCall`、`applyCcwebMcpChildAgentUpdate`。
|
||||
- 历史重建通过 `renderToolCallsWithMergedCollab` 合并全部 collab tool;实时 child update 则先更新缓存和 `activeToolCalls`,随后只调用 `updateToolCall(toolUseId, tool.result, done)`,需要确认它是否用新 `activeToolCalls` 重新构造完整 tool,还是仅以 result 刷新旧 DOM。
|
||||
- `upsertCollabAgentToolCall` 把每个 tool id 保存在 DOM 节点的 `__collabTools` Map 中,然后整体重新合并;若相同 tool 的旧快照和新快照以不同 key 并存,或刷新未走该函数,旧状态可能覆盖计划字段。
|
||||
- `collabAgentStateEntries` 会显式规范化 `planProgress` / `planCurrentStep`,`mergeCollabAgentTaskState` 会 spread 保留这些字段,`createCollabAgentToolElement` 也会为存在的进度创建 `.collab-agent-item-plan`;基础数据/渲染链路从代码上看没有主动丢字段。
|
||||
- `rememberCollabAgentStateFromChild` 通过 `{ ...child }` 把 child 计划字段写入缓存,所以实时事件接收阶段也能保留计划摘要。
|
||||
- 高风险点:`updateToolCall` 的 collab 分支忽略已有 `domElement`,固定在 `getLatestAssistantToolScope()` 返回的最新助手消息中调用 `upsertCollabAgentToolCall`。如果子代理卡片属于较早的消息,实时进度会更新到错误作用域或无法命中原卡片。
|
||||
- 独立计划审查已通过;建议的四个观测点(持久化、历史数据结构、实时 payload、DOM)已纳入定位。
|
||||
- 真实 session 的 `plan_card_demo` 工具呈现出关键形状:`input.agentsStates` 仍是启动时快照(无计划),更新后的 `result` JSON 才包含最终 `planProgress: 3/3`;历史重建必须正确以 result 覆盖 input 才能显示。
|
||||
- 同一工具的最新 result 还合并进了本轮计划审查子代理,说明长会话中的子代理状态持续复用旧 tool id;实时 DOM 更新如果只查最新消息,尤其容易错位。
|
||||
- 现有 `subagent-card-metadata` 回归虽然调用 `applyCcwebMcpChildAgentUpdate`,但测试夹具中的 `updateToolCall()` 是空函数,完全没有验证实时更新是否命中原卡片 DOM;这是目前最明确的覆盖缺口。
|
||||
- `rememberToolCallTarget` 能保存 tool 到 DOM 的引用,但 `applyCcwebMcpChildAgentUpdate` 会用新对象覆盖 `activeToolCalls` 条目,因此历史引用不会被保留。
|
||||
- `buildMsgElement` 重建历史消息时直接用合并后的 tool 创建元素,既不调用 `rememberToolCallTarget`,也没有给元素设置 `data-collab-merged` / `__collabTools`;后续实时更新无法把它识别为可增量合并的原卡片。
|
||||
- 修复需要同时处理两个点:实时更新优先按 tool id 找原卡片作用域;历史重建的 collab 卡片需保存合并标记和原始 tool Map,避免更新时重复插入或错位。
|
||||
- 真实会话顺序为:消息 29 启动演示子代理并承载卡片,消息 30 才是用户反馈“没变化”;因此实时计划阶段原卡片确实位于最近的助手消息中。仅“最新作用域漂移”不足以解释第一次完全看不到,还需核对服务端是否逐次广播计划事件。
|
||||
- 服务端关键路径已确认:`processCcwebMcpChildNotification` 解析计划,`sendCcwebMcpChildAgentUpdate` 同时更新活动工具、持久化工具并向父会话 WebSocket 发送 child update;下一步核验实际调用时机和 payload。
|
||||
- 服务端对每个成功解析的计划通知都会立即返回 `changed: true`,随后直接调用 `sendCcwebMcpChildAgentUpdate`;代码中没有只在完成时发送的节流逻辑。
|
||||
- 广播目标优先取当前活动 turn 的 WebSocket,否则取正在查看该 session 的 WebSocket;若事件已正确路由,前端理论上应逐次收到。
|
||||
- `findCodexAppRouteByRuntime` 能根据 child thread id 从 `ccwebMcpChildThreads` 恢复父会话路由;还需用日志或回归验证真实 plan 通知没有在这一步变成 unrouted。
|
||||
- PM2 当前 ccweb 状态为 online;现有 stdout/stderr 不记录具体 child plan 广播,也没有该演示线程或 plan notification 的可追溯日志,无法靠历史日志区分“未路由”与“前端未渲染”。因此回归必须直接覆盖 payload→DOM 全链路。
|
||||
- 新回归使用真实快照形状:启动态 `input` 无计划,更新态 `result` 才有 `2/3` 与当前步骤;同时要求历史卡片保存 merged 标记和原始 tool Map。
|
||||
- 修复前定向回归按预期失败在“实时子代理更新应先定位原卡片”,证明覆盖点有效。
|
||||
- 历史消息重建现在给合并卡片写入 `data-collab-merged`、child ids 与原始 tool Map;result-only 计划快照可继续接收实时更新。
|
||||
- 实时 `updateToolCall` 先按 tool id / tool Map 在全消息区查原卡片,并优先使用其父作用域;`applyCcwebMcpChildAgentUpdate` 同时保留已有 DOM 引用。
|
||||
- 增强后的定向回归不仅断言代码契约,还实际把历史卡片挂入测试 DOM,验证能按原始 tool id 找回;当前已通过。
|
||||
- 在线 `http://127.0.0.1:8002/app.js` 已包含 `markMergedCollabAgentElement` 和原卡片定位调用,返回 200 且 `Cache-Control: no-store, max-age=0`;静态文件修改无需重启进程。
|
||||
- PM2 中 ccweb 仍为 online,pid 241273,整个修复过程未重启服务。
|
||||
- 用户刷新后提供的真实截图显示:`plan_progress_live_demo` 卡片处于“进行中”,但没有任何进度计数、进度点或当前步骤;这是明确的失败验收,不能再以代理调用成功代替 UI 成功。
|
||||
- 同一子代理 `019fab4a-e95c-7353-8a37-6cdaab89188e` 在 session 快照中同时出现在消息 33(旧 `plan_visibility_plan_review` tool,id `call_XLR...`)与消息 35(自身 tool,id `call_19L...`);两处最终都有 `4/4`,强烈指向实时 plan update 曾路由到旧 tool/card,而用户看到的是当前 tool/card。
|
||||
- 计划最终更新时间 `00:35:45Z` 早于子代理返回时间 `00:37:10Z` 约 85 秒,排除“计划只在完成瞬间落盘、用户来不及看到”的解释。
|
||||
- 当前环境没有可用 Playwright/浏览器控制工具;真实 UI 验收必须以用户截图为准,代码回归只能作为辅助证据。
|
||||
- 服务端根因已定位:`syncCcwebMcpChildAgentsFromCollabItem` 对已有 child 仅在 `spawnToolId` 为空时赋 `item.id`。真实 `subAgentActivity started` 到达新卡片 `call_19L...` 时,child 已错误持有旧卡片 `call_XLR...`,因此不会重绑。
|
||||
- 后续每次 plan notification 都按旧 `child.spawnToolId` 调用 `findCcwebMcpChildTargetToolInMessages`,于是 `4/4` 被合并到消息 33 的旧卡片;用户正在看的消息 35 当前卡片没有计划字段,和截图完全一致。
|
||||
- `findCcwebMcpChildTargetToolInMessages` 在给定 spawn tool id 找不到精确匹配时仍回退到最新 collab tool,会进一步放大跨卡片污染风险;回归需确保有明确 id 时不更新其他卡片。
|
||||
- 计划审查通过;建议明确 child id → tool call id → message/card 映射,并同时断言目标卡片更新、非目标卡片不变,真实验收只接受用户截图或明确确认。
|
||||
- 父 rollout 证实 `call_19LK...` 就是 `plan_progress_live_demo` 的 `spawn_agent` / `sub_agent_activity started` 事件 ID,child id 为 `019fab4a...`;`call_XLR...` 明确属于更早的计划审查代理。两者不是同一卡片。
|
||||
- 当前回归 `assertCodexAppChildToolFallbackContract` 明确断言“缺失的 spawnToolId 应回退到最新 collab tool”,这正是跨卡片污染的错误契约,需要反转为:给定非空 id 未精确命中时不得修改任何其他卡片。
|
||||
- 新双卡片回归覆盖:正确 tool id 只更新当前卡片、旧卡片不出现当前 child、非空未知 id 返回 null 且两张卡片都不变、`subAgentActivity started` 将已有 child 从旧 id 重绑到当前 id。
|
||||
- 修复前回归稳定失败在“非空未知 spawn tool id 不得回退”,修改服务端后已通过。
|
||||
- 全量集成回归揭示嵌套 V2 场景:孙代理的内部 activity id 不在根会话可见 toolCalls 中,原先依赖模糊回退把孙代理合并进父子代理卡片。
|
||||
- 最终路由规则改为:根子代理使用自己的 canonical activity/tool id;嵌套子代理继承最近可见父子代理的 `spawnToolId`,同时保留自己的 `parentThreadId`。这样可以精确命中可见卡片而无需跨卡回退。
|
||||
- 更新后 `subagent-card-routing`、JS 语法、diff check 和全量 `npm run regression` 全部通过;全量回归也重新覆盖了 nested V2 child result。
|
||||
44
.planning/subagent-plan-visibility-fix/progress.md
Normal file
44
.planning/subagent-plan-visibility-fix/progress.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 进度日志
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- 恢复上下文并核对旧计划、真实 rollout 与会话持久化证据。
|
||||
- 确认子代理确实调用 `update_plan`,问题属于 ccweb 显示链路缺陷。
|
||||
- 建立六步修复计划,当前定位真实数据断点。
|
||||
- 使用 codebase-memory 定位并读取历史重建、collab 合并、实时 child update 与 DOM upsert 的关键实现。
|
||||
- 计划审查子代理确认计划可执行,无阻断问题。
|
||||
- 完成关键实现交叉核对,初步锁定实时更新错误选择“最新助手消息作用域”的风险。
|
||||
- 抽取真实 session 工具数据,确认计划摘要仅存在于 result 快照,并发现现有实时更新回归把 DOM 更新函数 stub 掉了。
|
||||
- 进一步确认历史重建元素缺少 collab 增量合并元数据,实时更新也会覆盖已有 DOM 引用。
|
||||
- 对照真实消息顺序后保留前端作用域缺陷,但不把它当作唯一根因;转向核验服务端计划事件逐次广播。
|
||||
- 核对服务端计划处理与发送代码,确认实现意图是每次计划变化都实时广播,不是只持久化最终状态。
|
||||
- 检查 PM2 历史日志;日志粒度不足以还原该事件,决定用真实 payload 驱动的前端回归直接验证。
|
||||
- 阶段 1 完成:真实数据、历史重建、实时 payload 与 DOM 四个观测点已经核验;开始补失败回归。
|
||||
- 新增真实 result-only 快照与历史卡片增量元数据回归;修复前定向回归按预期失败。
|
||||
- 阶段 2 完成,开始实现历史卡片标记与实时原卡片定位。
|
||||
- 完成历史卡片增量元数据与实时原卡片定位修复,并保留 active tool DOM 引用。
|
||||
- 真实 result-only 快照、历史恢复标记、tool id 查找行为回归均已通过;进入语法和定向检查。
|
||||
- `node --check` 检查 `public/app.js`、`scripts/regression.js` 通过。
|
||||
- `subagent-card-metadata` 与 `plan-list-progress` 两组定向回归通过;进入全量回归和差异审查。
|
||||
- 全量 `npm run regression` 在 60 秒限制内通过,`git diff --check` 通过。
|
||||
- 审查相关差异:修改仅围绕历史 collab 卡片元数据、实时目标定位和对应回归;未改服务端协议与 Plan List 渲染逻辑。
|
||||
- 阶段 5 完成;开始验证当前在线服务是否直接提供最新静态资源,从而避免重启。
|
||||
- 在线 `/app.js` 已命中修复代码且禁用缓存,PM2 保持 online;确认无需重启。
|
||||
- 六个阶段全部完成,准备清理本任务临时 TODO CSV 并交付。
|
||||
- `check-complete.sh` 首次因计划采用表格格式报告 0/0;已补充机器可读阶段标记,不影响任务实际完成状态。
|
||||
- 用户真实截图证明功能仍未生效;撤销“已完成”结论并重新打开计划。
|
||||
- 已核对本次演示子代理的 session 快照:同一 child 同时落入消息 33/35 两个不同 collab tool,开始定位 spawn tool id 路由错误。
|
||||
- 读取服务端 child 同步与工具查找实现,确认已有 child 不会在新的 `subAgentActivity started` 事件中重绑 spawn tool id,导致计划持续写入旧卡片。
|
||||
- 独立计划审查通过,阶段 7 继续收敛精确映射并准备失败回归。
|
||||
- 用父 rollout 精确确认 child `019fab4a...` 的正确 tool call id 是 `call_19LK...`,旧 `call_XLR...` 属于另一代理。
|
||||
- 找到现有回归还在保护“错误 id 回退到最新卡片”的旧行为,准备先把契约改成防污染失败回归。
|
||||
- 阶段 7 完成:真实 child、tool call 与消息卡片映射已经闭环;开始编写双卡片防污染回归。
|
||||
- 新增 `subagent-card-routing` 定向回归,修复前按预期失败。
|
||||
- 服务端已改为 canonical `subAgentActivity` 强制重绑当前 tool id,非空 id 只允许精确匹配;定向回归通过,进入全量验证。
|
||||
- 语法、路由定向回归、卡片定向回归和 diff check 通过。
|
||||
- 首次全量回归失败于嵌套 V2 child 可见卡片合并;严格禁回退方案过窄,进入按 child/parent 归属的替代设计。
|
||||
- 实现分层可见 tool id:根子代理重绑自身 tool,嵌套子代理继承父级可见 tool;新增嵌套断言。
|
||||
- 路由定向回归、语法检查、diff check 和全量回归均通过;准备执行服务端生效前的重启安全门禁。
|
||||
- 重启门禁确认只有当前对话 running;执行 PM2 重启后新 PID 178501 online,HTTP 返回 200。
|
||||
- 阶段 9 完成;启动新的真实演示代理,等待用户实际画面确认。
|
||||
- 新演示代理 `plan_progress_route_fix_demo` 已完成 4 步计划并返回;阶段 10 仍保持 in_progress,等待用户确认卡片是否实际出现 `x/4`,不以后台完成代替 UI 验收。
|
||||
78
.planning/subagent-plan-visibility-fix/task_plan.md
Normal file
78
.planning/subagent-plan-visibility-fix/task_plan.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 修复子代理计划进度不可见
|
||||
|
||||
## 目标
|
||||
|
||||
基于真实子代理 rollout 与会话快照,定位 `update_plan` 已执行但父会话子代理卡片不显示进度的断点,补齐回归并修复历史重建与实时更新链路;优先避免不必要的 ccweb 重启。
|
||||
|
||||
## 阶段
|
||||
|
||||
| 阶段 | 状态 | 验收标准 |
|
||||
|---|---|---|
|
||||
| 1. 核验真实通知与会话快照,确定显示链路断点 | complete | 真实数据能稳定复现字段被吞或 DOM 未刷新的具体路径 |
|
||||
| 2. 补充真实快照驱动的失败回归 | complete | 回归在修复前失败,且覆盖已确认断点 |
|
||||
| 3. 修复卡片计划字段的合并与实时更新 | complete | 历史消息和实时 child update 均保留并展示计划摘要 |
|
||||
| 4. 运行定向回归和语法检查 | complete | 相关回归与 JS 语法检查通过 |
|
||||
| 5. 运行全量回归并审查差异 | complete | 全量回归、diff check 通过且无无关改动 |
|
||||
| 6. 验证线上静态资源并确认无需重启 | complete | 当前服务可读取修复资源;仅在确需且满足安全门禁时重启 |
|
||||
| 7. 对齐真实计划事件与卡片工具 ID | complete | 找到 `plan_progress_live_demo` 计划更新实际写入哪张 tool/card,以及为何当前卡片未命中 |
|
||||
| 8. 补充真实工具路由失败回归 | complete | 使用本次消息 33/35 的双工具形状稳定复现错误卡片被更新 |
|
||||
| 9. 修复工具路由并完成回归 | complete | 实时计划仅更新当前子代理所属卡片,定向与全量回归通过 |
|
||||
| 10. 启动新演示并以实际画面验收 | in_progress | 新代理运行时卡片实际出现 `x/4`;未经用户画面确认不宣告完成 |
|
||||
|
||||
## 机器可读阶段状态
|
||||
|
||||
### Phase 1:核验真实链路
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 2:补充失败回归
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 3:修复历史与实时更新
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 4:定向验证
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 5:全量回归与差异审查
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 6:在线资源验证
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 7:对齐真实工具路由
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 8:补充真实路由回归
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 9:修复并验证
|
||||
|
||||
**Status:** complete
|
||||
|
||||
### Phase 10:真实画面验收
|
||||
|
||||
**Status:** in_progress
|
||||
|
||||
## 关键决策
|
||||
|
||||
- 以真实 rollout 中 4 次 `update_plan` 和 session 中最终 `3/3` 为事实基线,不再把问题归因于子代理未调用。
|
||||
- 先验证历史消息重建,再验证实时 `ccweb_mcp_child_agent_update` DOM 更新,避免凭 UI 现象猜测。
|
||||
- 使用现有 child update 事件与卡片结构,不新增协议字段或消息类型。
|
||||
- 保留工作区既有脏改动和用户的其他 TODO CSV,只修改本任务相关区域。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---|---|
|
||||
| 定向回归报错:实时子代理更新未先定位原卡片 | 1 | 预期失败,证明回归已覆盖真实断点;进入实现修复 |
|
||||
| 完成检查器报告 0/0 phases | 1 | 计划原为表格格式,补充机器可读阶段标题和完成状态标记 |
|
||||
| 用户真实截图仍无 `x/4` | 2 | 前一回归只覆盖单工具历史 DOM,未覆盖同一 child 同时落入消息 33/35 两个 tool 的真实路由;重新打开任务 |
|
||||
| 全量回归:Nested V2 child result 未合并到可见卡片 | 1 | 严格禁止非精确回退破坏嵌套子代理;改为按 child/parent 归属回退,不恢复“最新卡片”策略 |
|
||||
@@ -61,3 +61,13 @@
|
||||
- 通用持久化 sanitizer 会保留顶层数组,但可能按通用上限从头截断,因此历史 helper 必须主动只保留最近 100 条。
|
||||
- 审计建议扩展记录 manual/system/derived、unchanged 和 ignored 尝试;本任务不采纳。用户要看的是“标题在哪一步变了”,因此只写入 `ccweb_set_title` 且 `changed === true` 的实际 LLM 标题变化,避免假主题节点与范围膨胀。
|
||||
- MCP 标题历史写入不得修改 `session.updated`,继续保持现有“改标题不改变会话排序”的契约。
|
||||
|
||||
## 2026-07-29:标题前置展示语义
|
||||
|
||||
- 用户确认定位列表是“对话大纲”而不是严格事件审计流;标题应作为章节标题出现在触发该主题的用户消息上方。
|
||||
- 当前后端事件把 `messageIndex` 写成调用 `ccweb_set_title` 时的 `session.messages.length`;正常一轮中最近用户消息索引为 `length - 1`。
|
||||
- 当前前端把消息放在 `messageIndex * 2`,把标题放在 `event.messageIndex * 2 - 1`,所以标题自然落在最近用户消息之后。
|
||||
- 不应直接改变既有 `messageIndex` 的含义,否则旧历史和新历史无法区分;新事件应额外保存零基整数 `anchorMessageIndex`,指向调用时 `session.messages` 中最近的用户消息,且必须小于事件 `messageIndex`。
|
||||
- 展示层优先按精确 `anchorMessageIndex` 归属;锚点消息不可见或旧事件没有锚点时,从定位列表中选择 `event.messageIndex` 之前最近的用户消息作为兼容锚点;仍无候选才使用事件自身位置。
|
||||
- 标题的日期分组也应继承锚点消息时间,避免跨午夜时标题与所属消息被拆到两个日期区段。
|
||||
- 现有 `session_renamed.titleEvent`、`session_info.titleHistory` 和前端 snapshot 都会经过标题历史归一化;只要后端与前端 normalizer 显式保留新字段,实时与重载链路即可共用同一排序逻辑,但必须由回归断言锁定。
|
||||
|
||||
@@ -29,3 +29,26 @@
|
||||
- 最终 diff 审查确认:标题/日期输出均转义,只有消息节点使用按钮与 `data-target`;旧会话默认空历史;MCP unchanged/invalid/manual-lock 均不产生伪事件;缓存版本已同步。
|
||||
- 最终状态:7 个阶段全部完成;保留 scoped planning 记录,删除本任务 TODO CSV。服务重启因另一条活跃会话被明确暂缓。
|
||||
- 下一步:审查计划,并通过 codebase-memory 定位前后端链路。
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- 用户确认落地“标题作为章节标题,显示在触发用户消息上方”的调整。
|
||||
- 已读取 `planning-with-files` 与 `todo-list-csv` 技能全文,运行 session catchup 并恢复上轮上下文。
|
||||
- 已建立“调整标题定位锚点”六步计划及同名 TODO CSV;保留其他任务的 active plan 与未跟踪 CSV。
|
||||
- codebase-memory 索引状态为 ready(4176 nodes / 8942 edges),已定位 `setCurrentConversationTitle()`、`normalizeTitleHistory()`、`buildUserOutlineTimelineItems()` 和 `updateUserOutlinePanel()`。
|
||||
- 根因确认:后端保存调用时消息总数,前端以 `messageIndex * 2 - 1` 排序,导致标题位于触发用户消息之后。
|
||||
- 设计确定:新增 `anchorMessageIndex`,保留 `messageIndex` 原语义;旧事件回退到事件之前最近的用户消息,日期继承锚点消息时间。
|
||||
- 首轮独立计划审查未通过:要求明确锚点字段契约、实时/缓存/重载传输路径及对应回归。
|
||||
- 已补充 `anchorMessageIndex` 的零基索引、合法范围、与 `messageIndex` 的优先级、锚点不可见降级规则,以及实时和重载一致性验收;等待复审。
|
||||
- 第二轮独立计划审查已通过;阶段 1 完成,进入标题前置、旧历史、跨午夜及实时/重载传输失败回归编写。
|
||||
- 已补充专项纯函数与 MCP 集成断言;首次专项回归在“后端应保留合法触发消息锚点”处按预期失败(exit 1)。
|
||||
- 阶段 2 完成,进入后端 `anchorMessageIndex` 归一化、生成、实时返回与重载持久化实现。
|
||||
- 后端已生成最近用户消息零基锚点,并在 normalizer 中仅保留合法整数且早于事件位置的锚点。
|
||||
- 专项回归已越过后端断言,在“标题应位于触发用户消息之前”处按预期失败;阶段 3 完成,进入前端排序实现。
|
||||
- 前端 normalizer 已保留合法 `anchorMessageIndex`;时间线优先精确锚点,旧历史/不可见锚点回退到事件前最近可见用户消息,并继承锚点日期。
|
||||
- `title-history-outline` 专项回归通过;阶段 4 完成,进入语法检查与专项复核。
|
||||
- `node --check`(server/app/regression)与专项回归复核均通过;阶段 5 完成,进入全量回归和最终差异审查。
|
||||
- 最终收紧异常索引边界后,重新运行三项语法检查和 `title-history-outline` 专项回归,全部通过。
|
||||
- 最终全量 `npm run regression` 在 60 秒超时约束内完成,exit 0,输出 `Regression checks passed.`;`git diff --check` 通过。
|
||||
- codebase-memory 最终影响检测 transport closed,未重复调用;本地函数级 diff 确认本次只涉及标题锚点、前端排序及对应回归,其他在途工作区改动保持原样。
|
||||
- 阶段 6 完成,准备清理本次临时 TODO CSV;随后按项目规则复查运行会话并处理此前未完成的服务重启。
|
||||
|
||||
@@ -34,3 +34,30 @@
|
||||
| 后端实现后专项回归因缺少前端 `normalizeOutlineTitleHistory` 失败 | 1 | 后端纯函数契约已通过,进入前端实现 |
|
||||
| `ccweb_list_conversations(status=running)` 60 秒超时 | 1 | 降级检查 PM2、端口、近期会话文件;发现另一条 15:38 收到用户消息的 Codex App 会话,按约束暂缓重启 |
|
||||
| `codebase-memory-mcp detect_changes` 返回 transport closed | 1 | 不重复同一失败,使用已完成的函数级检索、全量回归与本地 diff 审查收口 |
|
||||
| 首轮标题前置计划审查要求细化锚点与传输契约 | 1 | 补充字段名、索引基准、合法范围、优先级、降级规则,以及实时/重载/缓存回归要求后重新审查 |
|
||||
| 标题前置专项回归在后端锚点保留断言失败 | 1 | 符合 TDD 预期,确认现有 `normalizeTitleHistory()` 会丢弃新字段,进入最小后端实现 |
|
||||
| 后端实现后专项回归在标题前置顺序断言失败 | 1 | 后端锚点契约已通过,失败符合预期,进入前端锚点排序实现 |
|
||||
| 最终 `codebase-memory-mcp detect_changes` 返回 transport closed | 1 | 不重复失败调用,改用函数级 diff、专项回归、全量回归与 `git diff --check` 完成影响面审查 |
|
||||
| 首次全量回归调用未保留最终退出码 | 1 | 确认残留测试进程已结束后,改用可追踪 session id 重新运行并取得 exit 0 与通过标记 |
|
||||
|
||||
## 追加迭代:标题前置锚点(2026-07-29)
|
||||
|
||||
| 阶段 | 状态 | 验收标准 |
|
||||
|---|---|---|
|
||||
| 1. 梳理现有标题事件与消息索引契约 | complete | 明确现有标题为何排在触发消息之后,并确定不破坏历史数据的锚点字段 |
|
||||
| 2. 补充标题前置及旧历史兼容回归 | complete | 新标题事件和无锚点旧事件均断言为“日期 → 标题 → 用户消息” |
|
||||
| 3. 持久化标题事件的触发消息锚点 | complete | LLM 实际改名事件记录 `anchorMessageIndex`;实时事件、会话重载、前端归一化与 snapshot 缓存均保留该字段,非法数据被清洗 |
|
||||
| 4. 按锚点重排定位列表展示顺序 | complete | 标题显示在所属用户消息之前,不可点击且不占消息编号;锚点缺失或不可见时按既定回退规则处理 |
|
||||
| 5. 运行语法检查与专项回归 | complete | server/app/regression 语法检查及标题时间线专项回归通过 |
|
||||
| 6. 运行全量回归并审查最终差异 | complete | 全量回归、diff check 和影响面审查通过,临时 TODO CSV 已清理 |
|
||||
|
||||
### 追加约束
|
||||
|
||||
- `messageIndex` 继续表示标题事件实际发生时的消息位置,不复用或改写其历史语义。
|
||||
- 新增可选字段 `anchorMessageIndex`:零基整数,指向改标题时 `session.messages` 中最近一条 `role === 'user'` 的消息;合法值必须 `>= 0` 且 `< messageIndex`,否则归一化时丢弃该字段但保留合法标题事件。
|
||||
- `anchorMessageIndex` 与 `messageIndex` 同时存在时,展示归属优先使用锚点;精确锚点消息未出现在当前定位消息集时,回退到 `messageIndex` 之前最近的可见用户消息;仍无候选时保留事件自身的时间位置。
|
||||
- 旧历史没有 `anchorMessageIndex` 时,从定位列表中选择 `messageIndex` 之前最近的用户消息作为兼容锚点,不改写磁盘历史。
|
||||
- 标题归属到触发消息后,日期也采用该用户消息的本地日期,保证固定顺序为“日期 → 标题 → 用户消息”。
|
||||
- 锚点字段必须贯穿 MCP 返回和持久化、实时 `session_renamed.titleEvent`、重新加载的 `session_info.titleHistory`、前端 `normalizeOutlineTitleHistory()` 与 session snapshot 缓存;实时打开定位列表和重新进入会话都应得到相同顺序。
|
||||
- 回归至少覆盖:新事件即时归属、重新加载后锚点仍存在、旧事件无锚点回退、跨午夜仍按锚点消息日期分组。
|
||||
- 不修改 `.planning/.active_plan` 指向的其他在途任务,不触碰其 TODO CSV。
|
||||
|
||||
Binary file not shown.
@@ -1472,14 +1472,20 @@
|
||||
const title = String(event.title || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
const changedAtMs = Date.parse(String(event.changedAt || ''));
|
||||
const messageIndex = Number(event.messageIndex);
|
||||
const anchorMessageIndex = event.anchorMessageIndex;
|
||||
const source = String(event.source || '').trim().toLowerCase();
|
||||
if (!title || !Number.isFinite(changedAtMs) || !Number.isFinite(messageIndex) || messageIndex < 0 || source !== 'llm') return;
|
||||
normalized.push({
|
||||
const normalizedMessageIndex = Math.trunc(messageIndex);
|
||||
const normalizedEvent = {
|
||||
title,
|
||||
changedAt: new Date(changedAtMs).toISOString(),
|
||||
messageIndex: Math.trunc(messageIndex),
|
||||
messageIndex: normalizedMessageIndex,
|
||||
source: 'llm',
|
||||
});
|
||||
};
|
||||
if (Number.isInteger(anchorMessageIndex) && anchorMessageIndex >= 0 && anchorMessageIndex < normalizedMessageIndex) {
|
||||
normalizedEvent.anchorMessageIndex = anchorMessageIndex;
|
||||
}
|
||||
normalized.push(normalizedEvent);
|
||||
});
|
||||
return normalized.slice(-100);
|
||||
}
|
||||
@@ -1493,10 +1499,11 @@
|
||||
|
||||
function buildUserOutlineTimelineItems(messageItems, titleHistory = []) {
|
||||
const timeline = [];
|
||||
const timelineMessages = [];
|
||||
(Array.isArray(messageItems) ? messageItems : []).forEach((message, order) => {
|
||||
const messageIndex = Number(message.messageIndex);
|
||||
const timestampMs = Date.parse(String(message.timestamp || ''));
|
||||
timeline.push({
|
||||
const timelineMessage = {
|
||||
...message,
|
||||
type: 'message',
|
||||
messageNumber: order + 1,
|
||||
@@ -1504,17 +1511,31 @@
|
||||
sortPosition: Number.isFinite(messageIndex) && messageIndex >= 0 ? messageIndex * 2 : null,
|
||||
sortTime: Number.isFinite(timestampMs) ? timestampMs : null,
|
||||
sortOrder: order,
|
||||
});
|
||||
};
|
||||
timelineMessages.push(timelineMessage);
|
||||
timeline.push(timelineMessage);
|
||||
});
|
||||
normalizeOutlineTitleHistory(titleHistory).forEach((event, order) => {
|
||||
const timestampMs = Date.parse(event.changedAt);
|
||||
const exactAnchor = Number.isInteger(event.anchorMessageIndex)
|
||||
? timelineMessages.find((message) => message.messageIndex === event.anchorMessageIndex) || null
|
||||
: null;
|
||||
const precedingAnchor = timelineMessages.reduce((latest, message) => {
|
||||
if (!Number.isFinite(message.messageIndex) || message.messageIndex >= event.messageIndex) return latest;
|
||||
if (!latest || message.messageIndex > latest.messageIndex) return message;
|
||||
return latest;
|
||||
}, null);
|
||||
const anchor = exactAnchor || precedingAnchor;
|
||||
const anchorMessageIndex = Number.isFinite(anchor?.messageIndex) ? anchor.messageIndex : null;
|
||||
timeline.push({
|
||||
type: 'title',
|
||||
label: event.title,
|
||||
timestamp: event.changedAt,
|
||||
timestamp: anchor?.timestamp || event.changedAt,
|
||||
changedAt: event.changedAt,
|
||||
messageIndex: event.messageIndex,
|
||||
...(anchorMessageIndex !== null ? { anchorMessageIndex } : {}),
|
||||
source: event.source,
|
||||
sortPosition: event.messageIndex * 2 - 1,
|
||||
sortPosition: anchorMessageIndex !== null ? anchorMessageIndex * 2 - 1 : event.messageIndex * 2 - 1,
|
||||
sortTime: Number.isFinite(timestampMs) ? timestampMs : null,
|
||||
sortOrder: order,
|
||||
});
|
||||
@@ -7475,6 +7496,45 @@
|
||||
];
|
||||
}
|
||||
|
||||
function markMergedCollabAgentElement(element, tools, mergedTool = null) {
|
||||
if (!element) return element;
|
||||
const map = tools instanceof Map ? tools : new Map();
|
||||
if (!(tools instanceof Map)) {
|
||||
const list = Array.isArray(tools) ? tools : [];
|
||||
list.forEach((tool, index) => {
|
||||
if (toolKind(tool) !== 'collab_agent_tool_call') return;
|
||||
const key = String(tool?.id || `collab-${index + 1}`);
|
||||
map.set(key, tool);
|
||||
});
|
||||
}
|
||||
const displayTool = mergedTool || mergeCollabAgentTools(Array.from(map.values()));
|
||||
element.dataset.collabMerged = 'true';
|
||||
if (displayTool?.id) element.dataset.toolUseId = String(displayTool.id);
|
||||
element.dataset.childIds = displayTool ? getCollabAgentIdsFromTool(displayTool).join(',') : '';
|
||||
element.__collabTools = map;
|
||||
return element;
|
||||
}
|
||||
|
||||
function collabAgentToolElementMatches(element, toolUseId) {
|
||||
if (!element || !toolUseId) return false;
|
||||
const targetId = String(toolUseId);
|
||||
if (String(element.dataset?.toolUseId || '') === targetId) return true;
|
||||
const map = element.__collabTools;
|
||||
if (!(map instanceof Map)) return false;
|
||||
if (map.has(targetId)) return true;
|
||||
return Array.from(map.keys()).some((key) => String(key) === targetId);
|
||||
}
|
||||
|
||||
function findCollabAgentToolElement(toolUseId, preferredElement = null) {
|
||||
if (preferredElement?.isConnected && collabAgentToolElementMatches(preferredElement, toolUseId)) {
|
||||
return preferredElement;
|
||||
}
|
||||
return findLatestToolCallElement(messagesDiv, (candidate) => (
|
||||
candidate.dataset?.collabMerged === 'true'
|
||||
&& collabAgentToolElementMatches(candidate, toolUseId)
|
||||
));
|
||||
}
|
||||
|
||||
function collabStateTone(statusText) {
|
||||
const normalized = String(statusText || '').toLowerCase();
|
||||
if (!normalized) return 'pending';
|
||||
@@ -7722,10 +7782,14 @@
|
||||
const toolMount = bubble.querySelector(':scope > .cross-conversation-reply-body') || bubble;
|
||||
const FOLD_AT = 3;
|
||||
let grouped = false;
|
||||
const savedCollabTools = m.toolCalls.filter((tc) => toolKind(tc) === 'collab_agent_tool_call');
|
||||
const renderToolCalls = renderToolCallsWithMergedCollab(m.toolCalls);
|
||||
for (const tc of renderToolCalls) {
|
||||
if (isEmptyReasoningTool(tc)) continue;
|
||||
const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
|
||||
if (toolKind(tc) === 'collab_agent_tool_call') {
|
||||
markMergedCollabAgentElement(details, savedCollabTools, tc);
|
||||
}
|
||||
|
||||
// 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
|
||||
const loose = Array.from(toolMount.children).filter(isGroupableToolCall);
|
||||
@@ -8151,18 +8215,14 @@
|
||||
if (!merged) return existing;
|
||||
|
||||
if (existing) {
|
||||
existing.__collabTools = map;
|
||||
existing.dataset.toolUseId = merged.id || existing.dataset.toolUseId || '';
|
||||
existing.dataset.childIds = getCollabAgentIdsFromTool(merged).join(',');
|
||||
markMergedCollabAgentElement(existing, map, merged);
|
||||
existing.replaceChildren(buildToolContentElement(merged));
|
||||
removeDuplicateCollabAgentNodes(toolsDiv);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const el = createToolCallElement(merged.id, merged, !!merged.done);
|
||||
el.dataset.collabMerged = 'true';
|
||||
el.dataset.childIds = getCollabAgentIdsFromTool(merged).join(',');
|
||||
el.__collabTools = map;
|
||||
markMergedCollabAgentElement(el, map, merged);
|
||||
toolsDiv.appendChild(el);
|
||||
removeDuplicateCollabAgentNodes(toolsDiv);
|
||||
return el;
|
||||
@@ -8270,14 +8330,16 @@
|
||||
function updateToolCall(toolUseId, result, done = true) {
|
||||
const tool = activeToolCalls.get(toolUseId) || null;
|
||||
const toolUseIdText = toolUseId ? String(toolUseId) : '';
|
||||
const scope = getLatestAssistantToolScope();
|
||||
if (toolKind(tool) === 'collab_agent_tool_call') {
|
||||
const existingCollabElement = findCollabAgentToolElement(toolUseId, tool?.domElement);
|
||||
const scope = existingCollabElement?.parentElement || getLatestAssistantToolScope();
|
||||
const toolsDiv = scope?.querySelector?.('.msg-tools') || scope?.querySelector?.('.msg-bubble') || scope;
|
||||
const nextTool = { ...tool, result, done };
|
||||
const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, nextTool, done);
|
||||
if (el) rememberToolCallTarget(toolUseId, nextTool, el);
|
||||
return;
|
||||
}
|
||||
const scope = getLatestAssistantToolScope();
|
||||
let el = tool?.domElement && tool.domElement.isConnected ? tool.domElement : null;
|
||||
|
||||
if (!el) {
|
||||
@@ -8363,6 +8425,7 @@
|
||||
});
|
||||
|
||||
if (!isCurrentSessionUpdate) return;
|
||||
const previousTool = activeToolCalls.get(toolUseId) || null;
|
||||
activeToolCalls.set(toolUseId, {
|
||||
id: toolUseId,
|
||||
name: tool.name,
|
||||
@@ -8371,6 +8434,7 @@
|
||||
kind: tool.kind || null,
|
||||
meta: tool.meta || null,
|
||||
done: !!tool.done,
|
||||
domElement: previousTool?.domElement || null,
|
||||
});
|
||||
updateToolCall(toolUseId, tool.result, !!tool.done);
|
||||
}
|
||||
|
||||
@@ -1421,15 +1421,24 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
return {
|
||||
attributes: {},
|
||||
children: [],
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
parentElement: null,
|
||||
isConnected: true,
|
||||
appendChild(child) {
|
||||
if (child && typeof child === 'object') child.parentElement = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
insertBefore(child) {
|
||||
if (child && typeof child === 'object') child.parentElement = this;
|
||||
this.children.unshift(child);
|
||||
return child;
|
||||
},
|
||||
replaceChildren(...children) {
|
||||
this.children = [];
|
||||
children.forEach((child) => this.appendChild(child));
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
},
|
||||
@@ -1451,10 +1460,24 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
},
|
||||
};
|
||||
}
|
||||
const messagesDiv = makeNode();
|
||||
function findLatestToolCallElement(root, matcher) {
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (node.dataset?.toolUseId && matcher(node)) matches.push(node);
|
||||
(Array.isArray(node.children) ? node.children : []).forEach(visit);
|
||||
};
|
||||
visit(root);
|
||||
return matches.length > 0 ? matches[matches.length - 1] : null;
|
||||
}
|
||||
function createCcwebPromptElement() { return makeNode(); }
|
||||
function isEmptyReasoningTool() { return false; }
|
||||
function createToolCallElement(toolUseId, tool, done) {
|
||||
return { ...makeNode(), toolUseId, tool, done };
|
||||
const node = { ...makeNode(), toolUseId, tool, done };
|
||||
node.dataset.toolUseId = String(toolUseId || '');
|
||||
node.dataset.toolKind = toolKind(tool) || '';
|
||||
return node;
|
||||
}
|
||||
function isGroupableToolCall() { return false; }
|
||||
function _refreshGroupSummary() {}
|
||||
@@ -1478,6 +1501,9 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
const el = buildMsgElement({ role: 'assistant', content: '', toolCalls });
|
||||
return el.querySelector('.msg-bubble').children.map((node) => node.tool);
|
||||
},
|
||||
renderMessageElement: (toolCalls) => buildMsgElement({ role: 'assistant', content: '', toolCalls }),
|
||||
mountMessageElement: (element) => messagesDiv.appendChild(element.querySelector('.msg-bubble')),
|
||||
findCollabAgentToolElement,
|
||||
renderCollabAgentToolElement: (tool) => createCollabAgentToolElement(tool),
|
||||
getCachedState: (id) => collabAgentStateCache.get(id),
|
||||
hasCachedState: (id) => collabAgentStateCache.has(id),
|
||||
@@ -1512,6 +1538,7 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
assert(source.includes('function createPlanProgressElementFromProgress(progress'), 'Frontend should expose reusable plan progress rendering for sub-agent cards');
|
||||
assert(source.includes("plan.className = 'collab-agent-item-plan'"), 'Sub-agent cards should render a compact plan progress row');
|
||||
assert(source.includes("currentStep.className = 'collab-agent-item-plan-current'"), 'Sub-agent cards should render the current plan step');
|
||||
assert(source.includes('findCollabAgentToolElement(toolUseId, tool?.domElement)'), 'Live sub-agent updates should locate the original card before falling back to the latest assistant message');
|
||||
assert(source.includes('description.title = descriptionTitle'), 'Task intro node should expose the full task intro or fallback in its title attribute');
|
||||
assert(source.includes('label.textContent = displayTitle;'), 'Rendered card label should use normalized title selection');
|
||||
assert(/\.collab-agent-item-description\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/.test(styleSource), 'Sub-agent task intro should use two-line truncation');
|
||||
@@ -1698,6 +1725,54 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
assert(planCounts.length === 1 && planCounts[0].textContent === '1/3', 'Sub-agent plan summary should always render completed/total');
|
||||
assert(planCurrentSteps.length === 1 && planCurrentSteps[0].textContent === '当前:同步父卡片进度', 'Sub-agent plan summary should render the current in-progress step');
|
||||
|
||||
const persistedPlanSnapshotTool = {
|
||||
id: 'tool-persisted-plan-progress',
|
||||
name: 'subAgentActivity',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: {
|
||||
tool: 'subAgentActivity',
|
||||
receiverThreadIds: ['child-thread-persisted-plan'],
|
||||
agentsStates: {
|
||||
'child-thread-persisted-plan': {
|
||||
title: '真实快照验证代理',
|
||||
status: 'running',
|
||||
},
|
||||
},
|
||||
},
|
||||
result: JSON.stringify({
|
||||
tool: 'subAgentActivity',
|
||||
receiverThreadIds: ['child-thread-persisted-plan'],
|
||||
agentsStates: {
|
||||
'child-thread-persisted-plan': {
|
||||
title: '真实快照验证代理',
|
||||
status: 'running',
|
||||
planProgress: { completed: 2, total: 3 },
|
||||
planCurrentStep: '验证历史卡片增量更新',
|
||||
},
|
||||
},
|
||||
}),
|
||||
done: false,
|
||||
};
|
||||
const persistedMessageElement = collabApi.renderMessageElement([persistedPlanSnapshotTool]);
|
||||
const persistedMessageBubble = persistedMessageElement.querySelector('.msg-bubble');
|
||||
const persistedCard = persistedMessageBubble.children[0];
|
||||
assert(persistedCard?.dataset?.collabMerged === 'true', 'Restored sub-agent cards should be marked as merged cards for later live updates');
|
||||
assert(
|
||||
persistedCard?.__collabTools instanceof Map && persistedCard.__collabTools.has(persistedPlanSnapshotTool.id),
|
||||
'Restored sub-agent cards should retain their original tool snapshots for later result-only plan updates'
|
||||
);
|
||||
collabApi.mountMessageElement(persistedMessageElement);
|
||||
assert(
|
||||
collabApi.findCollabAgentToolElement(persistedPlanSnapshotTool.id) === persistedCard,
|
||||
'Live result-only plan updates should find the restored card by its original tool id'
|
||||
);
|
||||
const persistedPlanElement = collabApi.renderCollabAgentToolElement(persistedCard.tool);
|
||||
const persistedPlanCounts = findNodesByClass(persistedPlanElement, 'plan-progress-count');
|
||||
assert(
|
||||
persistedPlanCounts.length === 1 && persistedPlanCounts[0].textContent === '2/3',
|
||||
'Persisted result-only plan progress should survive history reconstruction'
|
||||
);
|
||||
|
||||
collabApi.rememberCollabAgentState(
|
||||
'unrelated-child',
|
||||
{ title: '无关历史代理', status: 'running' },
|
||||
@@ -2283,11 +2358,20 @@ function assertTitleHistoryOutlineContract() {
|
||||
title: `主题 ${index}`,
|
||||
changedAt: new Date(Date.UTC(2026, 6, 27, 0, index)).toISOString(),
|
||||
messageIndex: index,
|
||||
...(index > 0 ? { anchorMessageIndex: index - 1 } : {}),
|
||||
source: 'llm',
|
||||
})),
|
||||
]);
|
||||
assert(serverHistory.length === 100, 'Server should retain only the latest 100 valid title history events');
|
||||
assert(serverHistory[0].title === '主题 2' && serverHistory.at(-1).title === '主题 101', 'Server title history should discard invalid events and trim from the oldest side');
|
||||
assert(serverHistory[0].anchorMessageIndex === 1 && serverHistory.at(-1).anchorMessageIndex === 100, 'Server title history should preserve valid trigger-message anchors');
|
||||
const invalidAnchorHistory = normalizeServerHistory([
|
||||
{ title: '无效锚点仍保留事件', changedAt: '2026-07-27T00:00:00.000Z', messageIndex: 3, anchorMessageIndex: 3, source: 'llm' },
|
||||
]);
|
||||
assert(
|
||||
invalidAnchorHistory.length === 1 && !Object.prototype.hasOwnProperty.call(invalidAnchorHistory[0], 'anchorMessageIndex'),
|
||||
'Server title history should discard invalid anchors without discarding otherwise valid legacy events'
|
||||
);
|
||||
|
||||
const normalizeOutlineHistorySource = extractFunctionSource(frontendSource, 'normalizeOutlineTitleHistory');
|
||||
const formatOutlineDateSource = extractFunctionSource(frontendSource, 'formatUserOutlineDate');
|
||||
@@ -2299,8 +2383,9 @@ function assertTitleHistoryOutlineContract() {
|
||||
return { normalizeOutlineTitleHistory, formatUserOutlineDate, buildUserOutlineTimelineItems };
|
||||
`)();
|
||||
const beforeMidnight = new Date(2026, 6, 27, 23, 59, 0).toISOString();
|
||||
const titleChangedAt = new Date(2026, 6, 27, 23, 59, 30).toISOString();
|
||||
const titleChangedAt = new Date(2026, 6, 28, 0, 0, 30).toISOString();
|
||||
const afterMidnight = new Date(2026, 6, 28, 0, 1, 0).toISOString();
|
||||
const legacyTitleChangedAt = new Date(2026, 6, 28, 0, 1, 30).toISOString();
|
||||
assert(outlineApi.formatUserOutlineDate(beforeMidnight) === '2026-07-27', 'Outline dates should use the browser local calendar day before midnight');
|
||||
assert(outlineApi.formatUserOutlineDate(afterMidnight) === '2026-07-28', 'Outline dates should roll over at browser-local midnight');
|
||||
|
||||
@@ -2308,15 +2393,18 @@ function assertTitleHistoryOutlineContract() {
|
||||
{ type: 'message', id: 'user-1', targetMessageId: 'hapi-message-user-1', label: '第一步', timestamp: beforeMidnight, messageIndex: 0 },
|
||||
{ type: 'message', id: 'user-2', targetMessageId: 'hapi-message-user-2', label: '第二步', timestamp: afterMidnight, messageIndex: 2 },
|
||||
], [
|
||||
{ title: 'SQL 排查主题', changedAt: titleChangedAt, messageIndex: 1, source: 'llm' },
|
||||
{ title: 'SQL 排查主题', changedAt: titleChangedAt, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' },
|
||||
{ title: '旧历史兼容主题', changedAt: legacyTitleChangedAt, messageIndex: 3, source: 'llm' },
|
||||
]);
|
||||
assert(
|
||||
JSON.stringify(timeline.map((item) => item.type)) === JSON.stringify(['date', 'message', 'title', 'date', 'message']),
|
||||
'Outline timeline should merge date, message and title nodes in conversation order'
|
||||
JSON.stringify(timeline.map((item) => item.type)) === JSON.stringify(['date', 'title', 'message', 'date', 'title', 'message']),
|
||||
'Outline timeline should render each title as a section heading before its triggering user message'
|
||||
);
|
||||
assert(timeline[0].label === '2026-07-27' && timeline[3].label === '2026-07-28', 'Outline should deduplicate dates and show YYYY-MM-DD only');
|
||||
assert(timeline[1].messageNumber === 1 && timeline[4].messageNumber === 2, 'Only selectable message nodes should consume outline numbering');
|
||||
assert(timeline[2].label === 'SQL 排查主题' && !timeline[2].targetMessageId, 'Title history nodes should be read-only timeline metadata');
|
||||
assert(timeline[2].messageNumber === 1 && timeline[5].messageNumber === 2, 'Only selectable message nodes should consume outline numbering');
|
||||
assert(timeline[1].label === 'SQL 排查主题' && timeline[1].anchorMessageIndex === 0, 'Anchored title history should render before its exact triggering message');
|
||||
assert(timeline[4].label === '旧历史兼容主题' && !timeline[4].targetMessageId, 'Legacy title history should fall back to the nearest preceding user message and remain read-only');
|
||||
assert(timeline[0].label !== outlineApi.formatUserOutlineDate(titleChangedAt), 'A title crossing midnight should inherit the triggering message calendar day');
|
||||
|
||||
const updateOutlineSource = extractFunctionSource(frontendSource, 'updateUserOutlinePanel');
|
||||
assert(updateOutlineSource.includes('user-outline-title-event') && updateOutlineSource.includes('user-outline-date'), 'Outline renderer should include dedicated title and date nodes');
|
||||
@@ -3420,7 +3508,7 @@ function extractFunctionSource(source, name) {
|
||||
throw new Error(`Could not parse function body for ${name}`);
|
||||
}
|
||||
|
||||
function assertCodexAppChildToolFallbackContract() {
|
||||
function assertCodexAppChildToolRoutingContract() {
|
||||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const helperStart = source.indexOf('function parseMaybeJsonObject(value)');
|
||||
const helperEnd = source.indexOf('function sendCcwebMcpChildAgentUpdate(sessionId, child)', helperStart);
|
||||
@@ -3452,22 +3540,48 @@ function assertCodexAppChildToolFallbackContract() {
|
||||
};
|
||||
`)();
|
||||
|
||||
const oldTool = {
|
||||
id: 'call-old-reviewer',
|
||||
name: 'subAgentActivity',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: {
|
||||
tool: 'subAgentActivity',
|
||||
receiverThreadIds: ['old-reviewer-thread'],
|
||||
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
|
||||
},
|
||||
result: JSON.stringify({
|
||||
receiverThreadIds: ['old-reviewer-thread'],
|
||||
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
|
||||
}),
|
||||
done: true,
|
||||
};
|
||||
const currentTool = {
|
||||
id: 'call-current-plan-demo',
|
||||
name: 'subAgentActivity',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: {
|
||||
tool: 'subAgentActivity',
|
||||
receiverThreadIds: ['current-plan-thread'],
|
||||
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
|
||||
},
|
||||
result: JSON.stringify({
|
||||
receiverThreadIds: ['current-plan-thread'],
|
||||
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
|
||||
}),
|
||||
done: false,
|
||||
};
|
||||
const session = {
|
||||
id: 'fallback-session',
|
||||
id: 'child-tool-routing-session',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'wait-collab-tool',
|
||||
name: 'wait_agent',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
|
||||
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
|
||||
done: false,
|
||||
},
|
||||
],
|
||||
toolCalls: [oldTool],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [currentTool],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -3487,25 +3601,114 @@ function assertCodexAppChildToolFallbackContract() {
|
||||
};
|
||||
api.setSession(session);
|
||||
const persistedTool = api.updatePersistedCcwebMcpChildTool(session.id, {
|
||||
threadId: 'child-refresh-thread',
|
||||
spawnToolId: 'missing-spawn-tool',
|
||||
label: '刷新路径代理',
|
||||
taskDescription: '验证刷新路径不会丢失子代理卡片。',
|
||||
status: 'closed',
|
||||
candidateResult: '子代理已关闭',
|
||||
closedAt: '2026-07-15T00:00:00.000Z',
|
||||
threadId: 'current-plan-thread',
|
||||
spawnToolId: 'call-current-plan-demo',
|
||||
label: '当前计划代理',
|
||||
taskDescription: '验证计划只进入当前子代理卡片。',
|
||||
status: 'running',
|
||||
planProgress: { completed: 2, total: 4 },
|
||||
planCurrentStep: '更新当前卡片',
|
||||
planUpdatedAt: '2026-07-29T00:35:00.000Z',
|
||||
});
|
||||
assert(persistedTool?.id === 'wait-collab-tool', 'Missing spawnToolId should fall back to the latest collab tool');
|
||||
assert(session.messages[1].toolCalls[0].result === 'ordinary output', 'Fallback must not merge child state into ordinary command_execution tools');
|
||||
assert(persistedTool?.id === 'call-current-plan-demo', 'Child plan updates should select the exact spawn tool id');
|
||||
assert(session.messages[2].toolCalls[0].result === 'ordinary output', 'Child plan routing must not merge into ordinary command_execution tools');
|
||||
const result = JSON.parse(persistedTool.result);
|
||||
assert(result.receiverThreadIds.includes('child-refresh-thread'), 'Persisted fallback collab tool should include child thread id');
|
||||
assert(result.agentsStates?.['child-refresh-thread']?.title === '刷新路径代理', 'Persisted fallback collab tool should store child title');
|
||||
assert(result.agentsStates?.['current-plan-thread']?.planProgress?.completed === 2, 'Exact child card should receive plan progress');
|
||||
assert(result.agentsStates?.['current-plan-thread']?.planCurrentStep === '更新当前卡片', 'Exact child card should receive the current plan step');
|
||||
assert(!JSON.parse(oldTool.result).agentsStates?.['current-plan-thread'], 'Older unrelated child cards must remain untouched');
|
||||
assert(api.getSavedSession()?.id === session.id, 'Exact child-card merge should save the session');
|
||||
|
||||
const currentResultBeforeMissingId = currentTool.result;
|
||||
const missingTool = api.updatePersistedCcwebMcpChildTool(session.id, {
|
||||
threadId: 'missing-route-thread',
|
||||
spawnToolId: 'call-not-present',
|
||||
label: '不应串卡代理',
|
||||
status: 'running',
|
||||
planProgress: { completed: 1, total: 2 },
|
||||
});
|
||||
assert(missingTool === null, 'A non-empty unknown spawn tool id must not fall back to another child card');
|
||||
assert(currentTool.result === currentResultBeforeMissingId, 'Unknown spawn tool ids must not contaminate the latest child card');
|
||||
assert(!JSON.parse(oldTool.result).agentsStates?.['missing-route-thread'], 'Unknown spawn tool ids must not contaminate older child cards');
|
||||
|
||||
const syncSource = extractFunctionSource(source, 'syncCcwebMcpChildAgentsFromCollabItem');
|
||||
const syncApi = new Function(`
|
||||
const ccwebMcpChildThreads = new Map();
|
||||
const sent = [];
|
||||
const path = { basename: (value) => String(value || '').split('/').filter(Boolean).pop() || '' };
|
||||
const SESSION_MESSAGE_CONTENT_MAX_CHARS = 10000;
|
||||
function normalizeCodexAppThreadId(value) { return String(value || '').trim(); }
|
||||
function codexAppCollabToolName(value) { return String(value || '').trim(); }
|
||||
function extractCcwebMcpStringArray(...values) { return values.flat().filter(Boolean).map(String); }
|
||||
function ccwebMcpChildLabel(state, fallback) { return String(state?.label || state?.title || fallback || ''); }
|
||||
function normalizeCcwebMcpChildPlanProgress() { return null; }
|
||||
function extractCcwebMcpChildCandidate() { return ''; }
|
||||
function truncateTextValue(value) { return String(value || ''); }
|
||||
function ccwebMcpChildStatus(value, fallback) {
|
||||
const normalized = String(value || '').toLowerCase();
|
||||
return normalized === 'started' ? 'running' : (normalized || fallback);
|
||||
}
|
||||
function sendCcwebMcpChildAgentUpdate(sessionId, child) {
|
||||
sent.push({ sessionId, child: { ...child } });
|
||||
}
|
||||
${syncSource}
|
||||
return {
|
||||
setChild: (id, child) => ccwebMcpChildThreads.set(id, child),
|
||||
getChild: (id) => ccwebMcpChildThreads.get(id),
|
||||
sync: syncCcwebMcpChildAgentsFromCollabItem,
|
||||
sent,
|
||||
};
|
||||
`)();
|
||||
syncApi.setChild('current-plan-thread', {
|
||||
threadId: 'current-plan-thread',
|
||||
parentSessionId: session.id,
|
||||
parentThreadId: 'parent-thread',
|
||||
spawnToolId: 'call-old-reviewer',
|
||||
label: '当前计划代理',
|
||||
status: 'running',
|
||||
});
|
||||
syncApi.sync({ sessionId: session.id, role: 'parent', entry: { threadId: 'parent-thread' } }, {
|
||||
id: 'call-current-plan-demo',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: 'current-plan-thread',
|
||||
agentPath: '/root/current_plan_demo',
|
||||
});
|
||||
assert(
|
||||
result.agentsStates?.['child-refresh-thread']?.taskDescription === '验证刷新路径不会丢失子代理卡片。',
|
||||
'Persisted fallback collab tool should store child task description'
|
||||
syncApi.getChild('current-plan-thread')?.spawnToolId === 'call-current-plan-demo',
|
||||
'A canonical subAgentActivity started event must rebind an existing child to its current tool id'
|
||||
);
|
||||
assert(
|
||||
syncApi.sent.at(-1)?.child?.spawnToolId === 'call-current-plan-demo',
|
||||
'The first public update after rebind must target the current child card'
|
||||
);
|
||||
|
||||
syncApi.setChild('parent-child-thread', {
|
||||
threadId: 'parent-child-thread',
|
||||
parentSessionId: session.id,
|
||||
parentThreadId: 'parent-thread',
|
||||
spawnToolId: 'call-visible-parent-child',
|
||||
label: '父级子代理',
|
||||
status: 'running',
|
||||
});
|
||||
syncApi.sync({
|
||||
sessionId: session.id,
|
||||
role: 'child',
|
||||
child: syncApi.getChild('parent-child-thread'),
|
||||
}, {
|
||||
id: 'grandchild-internal-activity',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: 'grandchild-thread',
|
||||
agentPath: '/root/parent_child/grandchild',
|
||||
});
|
||||
assert(
|
||||
syncApi.getChild('grandchild-thread')?.spawnToolId === 'call-visible-parent-child',
|
||||
'A nested child should inherit its nearest visible parent card tool id'
|
||||
);
|
||||
assert(
|
||||
syncApi.getChild('grandchild-thread')?.parentThreadId === 'parent-child-thread',
|
||||
'A nested child should still retain its immediate parent thread id'
|
||||
);
|
||||
assert(result.agentsStates?.['child-refresh-thread']?.status === 'closed', 'Persisted fallback collab tool should store child closed status');
|
||||
assert(api.getSavedSession()?.id === session.id, 'Persisted fallback merge should save the session');
|
||||
}
|
||||
|
||||
function assertCodexAppUnroutedNotificationRoutingContract() {
|
||||
@@ -3676,6 +3879,11 @@ async function main() {
|
||||
console.log('Windows startup regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'subagent-card-routing') {
|
||||
assertCodexAppChildToolRoutingContract();
|
||||
console.log('Sub-agent card routing regression checks passed.');
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
}
|
||||
|
||||
@@ -3699,7 +3907,7 @@ async function main() {
|
||||
assertTitleHistoryOutlineContract();
|
||||
assertSessionSwitchResilienceContract();
|
||||
assertSessionSwitchRaceContract();
|
||||
assertCodexAppChildToolFallbackContract();
|
||||
assertCodexAppChildToolRoutingContract();
|
||||
assertMultiAgentV2CompatibilityContract();
|
||||
assertWindowsStartupContract();
|
||||
|
||||
@@ -4093,6 +4301,14 @@ async function main() {
|
||||
assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename');
|
||||
assert(mcpSetTitle.body.titleEvent?.title === 'Concise MCP Title', 'MCP set title should return the persisted title event');
|
||||
assert(mcpSetTitle.body.titleEvent?.messageIndex === storedBeforeMcpTitle.messages.length, 'Title event should anchor after the messages persisted before the rename');
|
||||
const expectedTitleAnchorMessageIndex = (() => {
|
||||
for (let index = storedBeforeMcpTitle.messages.length - 1; index >= 0; index -= 1) {
|
||||
if (storedBeforeMcpTitle.messages[index]?.role === 'user') return index;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
assert(expectedTitleAnchorMessageIndex !== null, 'Title regression fixture should contain a triggering user message');
|
||||
assert(mcpSetTitle.body.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'MCP set title should return the triggering user message anchor');
|
||||
const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_renamed' &&
|
||||
msg.sessionId === codexSession.sessionId &&
|
||||
@@ -4100,6 +4316,7 @@ async function main() {
|
||||
));
|
||||
assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers');
|
||||
assert(mcpTitleRenamed.titleEvent?.title === 'Concise MCP Title', 'MCP set title should push the title event to current viewers');
|
||||
assert(mcpTitleRenamed.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Live session_renamed should preserve the title trigger anchor');
|
||||
const mcpTitleList = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_list' &&
|
||||
msg.sessions.some((session) => (
|
||||
@@ -4116,6 +4333,7 @@ async function main() {
|
||||
assert(storedAfterMcpTitle.titleHistory?.length === 1, 'MCP set title should append exactly one title history event');
|
||||
assert(storedAfterMcpTitle.titleHistory[0].source === 'llm' && storedAfterMcpTitle.titleHistory[0].title === 'Concise MCP Title', 'Persisted title history should identify the LLM title change');
|
||||
assert(!Number.isNaN(Date.parse(storedAfterMcpTitle.titleHistory[0].changedAt)), 'Persisted title history should include a valid change timestamp');
|
||||
assert(storedAfterMcpTitle.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Persisted title history should retain the triggering user message anchor');
|
||||
|
||||
const unchangedMcpTitle = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_set_title',
|
||||
@@ -4134,6 +4352,7 @@ async function main() {
|
||||
msg.requestId === 'reg-title-history-load'
|
||||
));
|
||||
assert(titleHistorySessionInfo.titleHistory?.length === 1, 'session_info should restore persisted title history');
|
||||
assert(titleHistorySessionInfo.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Reloaded session_info should retain the title trigger anchor');
|
||||
|
||||
const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_set_title',
|
||||
|
||||
41
server.js
41
server.js
@@ -2903,18 +2903,32 @@ function normalizeTitleHistory(history) {
|
||||
const title = String(event.title || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
const changedAtMs = Date.parse(String(event.changedAt || ''));
|
||||
const messageIndex = Number(event.messageIndex);
|
||||
const anchorMessageIndex = event.anchorMessageIndex;
|
||||
const source = String(event.source || '').trim().toLowerCase();
|
||||
if (!title || !Number.isFinite(changedAtMs) || !Number.isFinite(messageIndex) || messageIndex < 0 || source !== 'llm') return;
|
||||
normalized.push({
|
||||
const normalizedMessageIndex = Math.trunc(messageIndex);
|
||||
const normalizedEvent = {
|
||||
title,
|
||||
changedAt: new Date(changedAtMs).toISOString(),
|
||||
messageIndex: Math.trunc(messageIndex),
|
||||
messageIndex: normalizedMessageIndex,
|
||||
source: 'llm',
|
||||
});
|
||||
};
|
||||
if (Number.isInteger(anchorMessageIndex) && anchorMessageIndex >= 0 && anchorMessageIndex < normalizedMessageIndex) {
|
||||
normalizedEvent.anchorMessageIndex = anchorMessageIndex;
|
||||
}
|
||||
normalized.push(normalizedEvent);
|
||||
});
|
||||
return normalized.slice(-100);
|
||||
}
|
||||
|
||||
function findLatestUserMessageIndex(messages) {
|
||||
if (!Array.isArray(messages)) return null;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index]?.role === 'user') return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSession(session) {
|
||||
if (!session || typeof session !== 'object') return session;
|
||||
session.agent = normalizeAgent(session.agent);
|
||||
@@ -7320,10 +7334,13 @@ function setCurrentConversationTitle(args = {}, sourceSessionId = '') {
|
||||
const changed = previousTitle !== normalizedTitle;
|
||||
let titleEvent = null;
|
||||
if (changed) {
|
||||
const messageIndex = Array.isArray(session.messages) ? session.messages.length : 0;
|
||||
const anchorMessageIndex = findLatestUserMessageIndex(session.messages);
|
||||
titleEvent = {
|
||||
title: normalizedTitle,
|
||||
changedAt: new Date().toISOString(),
|
||||
messageIndex: Array.isArray(session.messages) ? session.messages.length : 0,
|
||||
messageIndex,
|
||||
...(anchorMessageIndex !== null ? { anchorMessageIndex } : {}),
|
||||
source: 'llm',
|
||||
};
|
||||
session.titleHistory = normalizeTitleHistory([...(session.titleHistory || []), titleEvent]);
|
||||
@@ -8815,18 +8832,20 @@ function findLatestCcwebMcpCollabToolInToolCalls(toolCalls = []) {
|
||||
}
|
||||
|
||||
function findCcwebMcpChildTargetToolInToolCalls(toolCalls = [], spawnToolId = '') {
|
||||
return findExactCcwebMcpChildToolInToolCalls(toolCalls, spawnToolId)
|
||||
|| findLatestCcwebMcpCollabToolInToolCalls(toolCalls);
|
||||
const targetId = String(spawnToolId || '').trim();
|
||||
if (targetId) return findExactCcwebMcpChildToolInToolCalls(toolCalls, targetId);
|
||||
return findLatestCcwebMcpCollabToolInToolCalls(toolCalls);
|
||||
}
|
||||
|
||||
function findCcwebMcpChildTargetToolInMessages(messages = [], spawnToolId = '') {
|
||||
if (!Array.isArray(messages)) return null;
|
||||
const targetId = String(spawnToolId || '').trim();
|
||||
let latestCollabTool = null;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const toolCalls = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : [];
|
||||
const exactTool = findExactCcwebMcpChildToolInToolCalls(toolCalls, spawnToolId);
|
||||
const exactTool = findExactCcwebMcpChildToolInToolCalls(toolCalls, targetId);
|
||||
if (exactTool) return exactTool;
|
||||
if (!latestCollabTool) latestCollabTool = findLatestCcwebMcpCollabToolInToolCalls(toolCalls);
|
||||
if (!targetId && !latestCollabTool) latestCollabTool = findLatestCcwebMcpCollabToolInToolCalls(toolCalls);
|
||||
}
|
||||
return latestCollabTool;
|
||||
}
|
||||
@@ -8959,6 +8978,9 @@ function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) {
|
||||
if (receiverThreadIds.length === 0) return;
|
||||
const activityAgentPath = String(item.agentPath || item.agent_path || '').trim();
|
||||
const activityPrompt = String(item.prompt || item.taskDescription || item.task_description || '').trim();
|
||||
const activityVisibleToolId = isSubAgentActivity
|
||||
? String((routed.role === 'child' ? routed.child?.spawnToolId : '') || item.id || '').trim()
|
||||
: '';
|
||||
const activityState = isSubAgentActivity ? {
|
||||
label: activityAgentPath ? path.basename(activityAgentPath) : '',
|
||||
role: String(item.role || item.agentRole || item.agent_role || '').trim(),
|
||||
@@ -8985,7 +9007,7 @@ function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) {
|
||||
turnId: null,
|
||||
parentSessionId: routed.sessionId,
|
||||
parentThreadId,
|
||||
spawnToolId: isSpawn ? item.id : '',
|
||||
spawnToolId: isSpawn ? (activityVisibleToolId || item.id) : '',
|
||||
label: ccwebMcpChildLabel(state, threadId),
|
||||
role: String(state.role || state.agent || state.agentType || state.agent_type || '').trim(),
|
||||
agentPath: String(state.agentPath || state.agent_path || '').trim(),
|
||||
@@ -9001,6 +9023,7 @@ function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (isSubAgentActivity && activityVisibleToolId) child.spawnToolId = activityVisibleToolId;
|
||||
if (!child.spawnToolId && isSpawn) child.spawnToolId = item.id;
|
||||
if (!child.spawnToolId && item.id) child.spawnToolId = item.id;
|
||||
child.parentSessionId = child.parentSessionId || routed.sessionId;
|
||||
|
||||
5
修复子代理计划实时卡片路由 TO DO list.csv
Normal file
5
修复子代理计划实时卡片路由 TO DO list.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
id,item,status,done_at,notes
|
||||
1,"对齐真实计划事件与卡片工具 ID",DONE,2026-07-29T08:40:34+08:00,"child 019fab4a 的正确卡片是 call_19LK;服务端却保留旧 call_XLR spawnToolId"
|
||||
2,"补充真实工具路由失败回归",DONE,2026-07-29T08:42:13+08:00,"双卡片回归修复前失败于非空未知 tool id 错误回退"
|
||||
3,"修复工具路由并完成回归",DONE,2026-07-29T08:49:55+08:00,"根/嵌套精确路由完成;定向和全量回归通过;PM2 重启后 online、HTTP 200"
|
||||
4,"启动新演示并以实际画面验收",IN_PROGRESS,,
|
||||
|
7
重新打包并提交推送 TO DO list.csv
Normal file
7
重新打包并提交推送 TO DO list.csv
Normal file
@@ -0,0 +1,7 @@
|
||||
id,item,status,done_at,notes
|
||||
1,确认 Bun baseline 构建器与 Git 推送目标,DONE,2026-07-29T16:44:29+08:00,复用 baseline Bun 1.3.14;main 与 origin/main 起点一致
|
||||
2,重新生成 CentOS 7 baseline 发布包,DONE,2026-07-29T16:45:51+08:00,Bun baseline 单文件与 tar.gz 已成功重新生成
|
||||
3,执行发布包与语法验证,DONE,2026-07-29T16:48:07+08:00,六项语法、MCP initialize、tar 清单与摘要均通过
|
||||
4,暂存当前工作区全部修改,DONE,2026-07-29T16:49:29+08:00,git add -A 已暂存当前全部修改
|
||||
5,创建包含全部修改的提交,DONE,2026-07-29T16:50:50+08:00,主提交 2efb9d2 已创建
|
||||
6,推送当前分支并确认远端,IN_PROGRESS,,
|
||||
|
Reference in New Issue
Block a user