diff --git a/.codex/config.toml b/.codex/config.toml index 91e7486..2befcb8 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,5 +1,4 @@ [mcp_servers.codebase-memory-mcp] -type = "stdio" command = "/home/hdzx/.local/bin/codebase-memory-mcp" args = [] enabled = true diff --git a/.planning/multi-agent-v2-audit/findings.md b/.planning/multi-agent-v2-audit/findings.md new file mode 100644 index 0000000..8e8c808 --- /dev/null +++ b/.planning/multi-agent-v2-audit/findings.md @@ -0,0 +1,85 @@ +# 调研发现 + +## 上游 Multi-agent V2 + +- 本机 `codex-cli` 版本为 `0.144.1`。 +- `codex features list` 显示:`multi_agent` 已稳定且启用;`multi_agent_v2` 为 `under development` 且默认关闭;旧 `collaboration_modes` 已移除但配置值仍显示启用。 +- 因此 V2 目前应按实验性、显式开关能力看待,不能假设所有 app-server 客户端都自动获得新行为。 +- 本机二进制进一步暴露了 V2 的配置面:并发线程上限、等待超时上下限/默认值、用量提示、根/子代理提示文案、工具命名空间、隐藏 `spawn_agent` 元数据、仅非 code mode 启用,以及 `custom` / `explicitRequestOnly` / `proactive` 三种 `multiAgentMode`。 +- 二进制中的 app-server 类型仍沿用 `CollabAgentToolCall` 和 `SubAgentActivity`,并新增/保留 `agentNickname`、`agentRole`、`agentPath`、`depth`、`parentThreadId` 等代理身份与层级元数据。 +- 已从 OpenAI 官方 `openai/codex` 仓库的 `rust-v0.144.1` 标签对应提交 `44918ea1` 下载源码归档。V2 不是单一 UI 开关:涉及 feature config、上下文提示、agent control、独立的 `multi_agents_v2` handlers、CSV 批量派工、app-server `MultiAgentMode` 协议字段及线程状态/通知。 +- V2 工具语义发生实质变化:`spawn_agent` 强制 `task_name` 并返回规范任务路径;默认继承全部历史,也可用 `fork_turns=none|all|N`;`fork_context` 在 V2 明确报错。 +- V1 的 `send_input`、`resume_agent`、`close_agent` 被 V2 的 `send_message`(不触发新 turn)、`followup_task`(触发/续跑 turn)、`interrupt_agent`(中断但保留代理)、`list_agents` 取代。 +- V2 `wait_agent` 不再接收目标列表,也不返回代理最终正文;它等待整个代理树的 mailbox 活动,用户 steer 也会提前唤醒。最终内容应经 mailbox/事件链消费。 +- V2 引入规范层级路径(如 `/root/task1/task3`)和嵌套代理树,可按路径前缀列举;这会把 cc-web 从“按 agent id 展示卡片”推进到“代理树 + mailbox 事件”模型。 +- `MultiAgentMode` 有 `custom`、`explicitRequestOnly`、`proactive` 三种策略,并作为 developer 上下文片段注入;默认 Ultra 推理会选 proactive,其余选 explicit-request-only(除非配置自定义提示)。 +- 另有 `spawn_agents_on_csv` 批量任务:按 CSV 每行派生 worker、并发执行、worker 用 `report_agent_job_result` 回报、最后导出结果 CSV。它是独立的批处理能力,不是基础聊天 UI 必须支持的协议。 +- app-server v2 schema 已出现 `multiAgentMode`,但 0.144.1 的 `thread/start` 处理器暂时把入参绑定为 `_multi_agent_mode`,并在 start/resume/fork 响应中固定回 `explicitRequestOnly`;还需确认 `turn/start` 是否才是当前有效入口。 +- 已确认 `thread/start.multiAgentMode` 与 `turn/start.multiAgentMode` 都在 0.144.1 源码中标为 deprecated/ignored,注释明确要求用 Ultra reasoning effort;因此 cc-web 不应实现新的 `multiAgentMode` 控件或依赖其返回值。 +- 相比 `rust-v0.143.0`,V2 工具 schema 基本未变,0.144.1 最重要的协议变化是事件标准化:`wait_agent` 从专用 `collab_waiting_begin/end` 改为 `item/started` + `item/completed` 的 `collabAgentToolCall(tool=wait)`;spawn/message/interrupt 从专用 `sub_agent_activity` event 改为 completed `subAgentActivity` turn item。 +- 这意味着 cc-web 若只监听旧顶层 collab/sub-agent 通知会漏状态;若已有统一 `item/started`/`item/completed` 分发并识别这两种 item,则基本兼容。 +- 官方提交记录对应三项连续迁移:canonical sub-agent activity(#31299)、canonical collab tool call(#31300)、canonical collab wait(#31301);另有 Ultra + 高并发提示(#31621)。 +- OpenAI 最新稳定版实际已是 `0.145.0`(2026-07-21 发布),而非本机的 0.144.1。官方发布说明明确称“Stabilized the opt-in multi-agent V2 experience”,覆盖可配置子代理模型/推理等级/并发、角色恢复和代理导航,并在 #34383 将 V2 标记为 stable。 +- 0.145.0 同时包含:统一多代理设置到 `agents`(#33550)、遵循子代理模型默认值(#33631)、恢复 V2 agent roles(#33657)、父线程持有的子代理线程只读(#33841)、agent picker 存活性/路径选择(#33921/#33922),并移除了 CSV-backed agent jobs(#34413)。因此 0.144.1 的 `spawn_agents_on_csv` 不能作为最新稳定版能力建议。 +- 已获取 0.145.0 官方源码提交 `25af12f7`。V2 的核心工具仍是 `spawn_agent` / `send_message` / `followup_task` / `interrupt_agent` / `list_agents` / `wait_agent`,`fork_turns` 与 mailbox wait 语义保持不变;canonical `subAgentActivity` / `collabAgentToolCall` 事件路线也仍存在。 +- 0.145.0 新增 `[agents]` 统一配置,包含 enabled、最大线程数、最大嵌套深度、默认子代理模型、默认子代理推理强度、interrupt message 等;旧 `features.multi_agent_v2` 仍保留兼容配置和部分 UI/工具参数。 +- 精确配置语义:`multi_agent_v2` 在 0.145.0 为 stable 但 `default_enabled=false`;`agents.enabled` 默认 true 且旧 feature 开关优先。`agents.max_depth` 的 schema 明确写“V2 忽略”,所以 V2 的路径树不能按 V1 深度上限推断。 +- V2 的 spawn `model` / `reasoning_effort` 在 0.145.0 默认不暴露,只有 `features.multi_agent_v2.expose_spawn_agent_model_overrides=true` 才进入工具 schema;`agent_type` 也只有实际配置角色时才暴露。cc-web 不应在固定 developer instructions 中假设这些字段总可用。 +- 0.145.0 允许 full-history fork 叠加 model/reasoning override,只禁止 full-history 同时覆盖 agent_type;这再次证明 cc-web 当前硬编码的 `fork_context`/override 规则已经过时,最佳修复是让模型遵循运行时工具 schema,而不是在 cc-web 重述易漂移规则。 +- 0.145.0 新增 child terminal turn → direct parent 的标准 completion envelope,结果以不触发新 turn 的 inter-agent communication 写入父代理 mailbox。因此即使 cc-web 没抓到 child 的独立 app-server 通知,Codex 核心仍可让 `wait_agent` 唤醒并把结果交给父代理;服务端 child route 缺口应定级为“实时卡片/状态观测缺口”,不是核心代理结果丢失。 +- app-server v2 仍以 canonical TurnItem 生命周期为主,旧 `SubAgentActivity` 顶层 event 只保留给兼容/raw consumers。cc-web 当前使用 `item/completed(subAgentActivity)` 的方向正确。 +- 已将当前用户实际使用的 Codex 从 0.144.1 升级为 0.145.0,路径仍为 `~/.local/bin/codex`;npm 包和 CLI 版本一致。升级后 `multi_agent_v2` 状态为 stable/false,未自动开启实验/opt-in 能力。 +- 升级后仍有两个 2026-07-02/07-18 启动的 app-server 子进程,其 `/proc//exe` 指向已删除的旧安装目录;它们不会热切换到 0.145.0。当前 ccweb 只检测到本轮一个 running 对话,但本轮不重启服务,避免中断正在进行的会话。 + +## cc-web 当前实现 + +- codebase-memory 初始索引只含 10 个源码文件,漏掉 `server.js` 与 `lib/codex-app-runtime.js`;执行 full reindex 后节点数仍未变化。`.cbmignore` 并未排除这些源码,推测是索引器对超大 JS 文件的提取限制。本轮对未索引文件按仓库规则降级到 `rg`/定点源码读取。 +- 前端 `public/app.js` 已识别 `subAgentActivity`,并把它归并到 `collab_agent_tool_call` 子代理卡片;回归脚本覆盖 agentPath、agentThreadId、role、taskDescription、各 activity kind 与普通工具防误判。 +- 后端 `lib/codex-app-runtime.js` 已明确处理 `item/started`、`item/completed`、`collabAgentToolCall`、`subAgentActivity`,并保留 started→completed 的输入合并与持久化。 +- `server.js` 已有 canonical item lifecycle 路由和 collab 状态归并;说明 0.144.1 最关键的事件迁移在 cc-web 中已有针对性实现与回归。 +- `lib/codex-app-runtime.js` 的 lifecycle 处理可兼容 V2:`item/started` 建立工具项,`item/completed` 合并并结束;`subAgentActivity` 会把 agentPath/agentThreadId/prompt/role 规范化成子代理卡片状态,V2 wait 的 `collabAgentToolCall` 也走统一路径。 +- **明确缺口:** `CODEX_APP_COLLABORATION_INSTRUCTIONS` 仍注入 V1 规则:要求使用/推断 `fork_context`,并要求 `wait_agent` 持续等待“最终状态”。V2 会拒绝 `fork_context`,且 wait 只报告 mailbox 活动、不返回最终正文。这些开发者指令会直接诱导 V2 工具调用失败或空转等待,应优先改为版本中立/V2 兼容指令。 +- `codexAppCollabToolName` 与 collab fallback 仍主要识别 V1 动作(spawn/wait/send_input/resume/close),未显式识别 V2 的 `send_message`、`followup_task`、`interrupt_agent`、`list_agents`。其中 `send_message` 被折叠为 `send_input`,其余需评估对历史恢复和状态归并的影响。 +- **明确缺口:Ultra 被 cc-web 丢弃。** 上游 V2 用 `ReasoningEffort::Ultra` 触发 proactive,但 `server.js` 的允许集合、Codex App 模型字符串解析、`lib/agent-runtime.js` 的 CLI 参数解析,以及前端推理强度选项都只到 `xhigh`。即使 `~/.codex/config.toml` 配置 `model_reasoning_effort = "ultra"`,cc-web 也不会把它编码进 session model/turn collaboration settings。 +- mock/regression 目前反而断言了旧的“重复 wait_agent 直到 final”指导,说明修复 V2 指令时必须同步调整 mock 摘要与回归断言,避免测试把旧语义锁死。 +- **明确缺口:V2 spawn 的 child thread 未进入服务端路由表。** `handleCodexAppNotification` 只对 `collabAgentToolCall` 调 `syncCcwebMcpChildAgentsFromCollabItem`;0.144.1 V2 spawn 发的是 completed `subAgentActivity`。runtime/前端虽能画卡片,但 `ccwebMcpChildThreads` 没有登记该 child,随后 child 自己的 agentMessage/turn 通知会成为 unrouted,实时最终消息与状态无法可靠回填。 +- 现有 mock 的多代理场景仍模拟 V1 `collabAgentToolCall(tool=spawn_agent)` 后再发 child turn;没有模拟“parent 收到 V2 subAgentActivity → 注册 child → 路由 child turn”的真实链路。因此当前回归通过不能证明 V2 live routing 完整。 +- **嵌套代理树也未覆盖。** 即便 child 已被登记,`processCcwebMcpChildNotification` 只处理其 agentMessage 与 turn 生命周期,不处理 child 发出的 `subAgentActivity`;V2 grandchild 的创建/消息会被忽略。基础一层代理修复应为 P0,完整嵌套树可作为 P1。 +- `recoverCcwebMcpChildThreadsFromPersistedToolCalls` 能从已持久化的 `subAgentActivity` 恢复 child map,但只在 cc-web 启动时的残留 turn 恢复路径调用;它不能弥补当前活跃 turn 中 V2 child 的即时注册缺口。 +- V2 `SubAgentActivityItem` 本身不含原始 task prompt,cc-web 目前只能用 agentPath basename 生成标题;若要保留任务描述,需要额外关联同 call id 的 spawn 请求或接受“仅路径标题”的降级。这属于体验优化,不是协议阻断。 +- 本机 `~/.codex/config.toml` 当前模型为 `gpt-5.6-sol`、推理强度 `xhigh`,且未配置 `multi_agent_v2`;结合 `codex features list` 可确认当前 V2 实际未启用。因此这些缺口目前不会破坏现有 V1 会话,但会阻断/削弱未来显式启用 V2。 + +## 差距与建议 + +### P0:启用 V2 前必须跟进 + +1. Ultra 全链路:`CODEX_REASONING_LEVELS`、Codex/Codex App 模型字符串解析、CLI `model_reasoning_effort` 参数、前端 Thinking picker 和回归用例全部加入 `ultra`。 +2. 删除 V1 字段级 developer instructions:不再注入 `fork_context` 和“wait 直到 final”的规则;仅保留 cc-web 自有 title 规则,并要求遵循当前运行时工具 schema/描述。 + +### P1:建议同步完成 + +1. parent 收到 `subAgentActivity(kind=started)` 时登记 child thread,随后能路由 child 的 agentMessage/turn 通知并回填实时卡片;同一路径递归处理 child 发出的 grandchild activity。 +2. mock 改成真实 V2 序列:completed-only `subAgentActivity`、空 receiver 的 wait item、child completion/mailbox、嵌套路径;保留 V1 用例做双栈回归。 +3. UI 的“关闭”在 V2 中实际只是 interrupt,不能伪装成销毁;应调整文案/状态或明确隐藏与可恢复语义。代理展示应保留 canonical task path,避免只取 basename 后同名冲突。 + +### 无需跟进 + +- 不新增 `multiAgentMode` 控件:0.145.0 仍标记 deprecated/ignored,proactive 由 Ultra 驱动。 +- 不做 CSV fanout UI:`spawn_agents_on_csv` 已从 0.145.0 移除。 +- 不默认强开 `multi_agent_v2`:它虽 stable 但仍 opt-in/default false;应继续尊重用户 Codex 配置。 +- `[agents]` 配置由 Codex app-server 直接读取,cc-web 暂无需复制一套配置管理 UI。 + +### 已兼容 + +- canonical `item/started` / `item/completed` 分发。 +- `collabAgentToolCall` 与 `subAgentActivity` 的 runtime/前端归并和基础卡片渲染。 +- app-server `collaborationMode.settings` 的 model/reasoning/developer_instructions 形状及线程级 MCP 注入。 + +## 实施结果 + +- Ultra 已贯通本地 Codex 配置、会话模型字符串、Codex CLI `model_reasoning_effort`、Codex App `collaborationMode.settings.reasoning_effort` 与前端 Thinking picker。 +- 固定注入的 V1 `fork_context` / 重复 `wait_agent` 指导已删除,改为只要求遵循当前 runtime tool schema 与工具描述。 +- 服务端会从父级或任意已登记 child 的 `subAgentActivity` 递归登记子线程,保留直接 `parentThreadId`、`spawnToolId`、canonical `agentPath` 与任务描述。 +- 子代理操作与状态改为“中断 / 已中断”,保留后续 turn 恢复可能;前端卡片 tooltip/footer 展示 canonical path,避免同名 basename 混淆。 +- mock 同时保留 V1 `collabAgentToolCall(spawn_agent)` 序列,并新增 V2 completed-only activity、child→grandchild、空 receiver wait 与终态回填序列。 +- 静态检查、定向 subagent 回归和完整 `npm run regression` 均通过;`multi_agent_v2` 仍保持 opt-in/default false,未由 cc-web 强制开启。 diff --git a/.planning/multi-agent-v2-audit/progress.md b/.planning/multi-agent-v2-audit/progress.md new file mode 100644 index 0000000..7647df9 --- /dev/null +++ b/.planning/multi-agent-v2-audit/progress.md @@ -0,0 +1,38 @@ +# 进度记录 + +- 2026-07-23:已读取 `openai-docs` 与 `planning-with-files` 技能说明。 +- 2026-07-23:已确认工作区存在用户既有未跟踪 CSV 文件,后续不触碰。 +- 2026-07-23:开始阶段 1,上游与本机 Codex 证据收集。 +- 2026-07-23:确认本机 Codex 0.144.1 中 `multi_agent_v2` 为默认关闭的开发中功能。 +- 2026-07-23:官方手册 helper 遇到 HEAD 403,记录后切换为更窄的官方/本机取证路径。 +- 2026-07-23:从本机二进制提取到 V2 配置形状、模式枚举以及 app-server 代理元数据线索。 +- 2026-07-23:获取并定位 OpenAI Codex `rust-v0.144.1` 官方源码中的 Multi-agent V2 实现文件。 +- 2026-07-23:完成 V1/V2 工具语义、任务路径、等待模型、策略模式和 CSV 批处理的第一轮源码对比。 +- 2026-07-23:完成 0.143.0→0.144.1 定点差异比对,锁定 Multi-agent V2 的 turn-item 事件迁移。 +- 2026-07-23:阶段 1 完成,进入 cc-web 现有实现定位。 +- 2026-07-23:发现 codebase-memory 对超大 JS 主文件覆盖不完整;full reindex 未改善,按约定对未索引文件降级为定点文本校验。 +- 2026-07-23:初步确认 cc-web 已覆盖 canonical `collabAgentToolCall` / `subAgentActivity` item 生命周期。 +- 2026-07-23:发现 cc-web 注入的子代理开发者指令仍是 V1 语义,与 V2 `fork_turns` / mailbox wait 冲突,列为首要跟进项。 +- 2026-07-23:确认 cc-web 全链路缺少 Ultra 推理强度支持,导致无法通过官方入口启用 proactive Multi-agent V2。 +- 2026-07-23:确认 V2 `subAgentActivity` 仅完成了 UI/runtime 适配,服务端 child 路由注册仍依赖 V1 spawn item;一层实时 child 通知存在漏路由风险,嵌套代理也未覆盖。 +- 2026-07-23:确认已有 child 恢复逻辑只服务于进程重启后的残留状态,不能替代 V2 live routing 注册。 +- 2026-07-23:阶段 2 完成;当前本机 V2 未启用,进入风险分级和最小跟进范围评估。 +- 2026-07-23:发现最新稳定版为 Codex 0.145.0,发布说明正式稳定化 opt-in Multi-agent V2;审计基线从本机 0.144.1 上调到 0.145.0。 +- 2026-07-23:获取 0.145.0 源码并确认 V2 工具/事件主语义未回退,新增重点在统一 agents 配置与角色/模型恢复。 +- 2026-07-23:确认 0.145.0 的 spawn 字段是按配置动态暴露,cc-web 固定注入字段级规则会持续与上游漂移。 +- 2026-07-23:确认 0.145.0 会把 child 终态标准化投递给父代理 mailbox;cc-web 路由缺口主要影响实时 UI,而非核心结果交付。 +- 2026-07-23:升级后回归通过;发现既有 app-server 仍驻留旧二进制。运行对话检查仅有当前对话,本轮未重启 cc-web。 +- 2026-07-23:完成 0.145.0 差距分级:2 项 P0、3 项 P1,并明确 4 类无需跟进项;本轮未修改 cc-web 产品代码。 +- 2026-07-23:用户授权实施适配并在完成后重启;已创建 `Multi-agent V2 适配 TO DO list.csv`,进入测试先行阶段。 +- 2026-07-23:用户明确授权升级 Codex;开始以单次 proxyd npm registry 安装 0.145.0,不重启 cc-web。 +- 2026-07-23:系统级 npm 前缀安装因 EACCES 失败且未产生有效升级;切换到 `--prefix ~/.local` 的用户级安装路径。 +- 2026-07-23:用户级 Codex 0.145.0 安装成功,CLI/包版本/可执行路径均已核验;进入 0.145.0 兼容性最终比对。 +- 2026-07-23:计划审查通过;补齐 Ultra、V1 指导移除、V2 child/grandchild 路由、真实 V2 mock 和中断/path 展示失败回归。 +- 2026-07-23:完成 Ultra 全链路实现,并用进程 spawn 日志验证 Codex CLI 收到 `model_reasoning_effort="ultra"`。 +- 2026-07-23:完成版本中立的子代理 developer instructions,移除 `fork_context` 与重复 wait 旧规则。 +- 2026-07-23:完成 parent/child `subAgentActivity` 递归登记、嵌套通知路由及 canonical agentPath/parentThreadId 同步。 +- 2026-07-23:完成 V2 mock、前端“中断”语义与路径展示;保留 V1 双栈回归。 +- 2026-07-23:6 个改动 JS 文件语法检查、`git diff --check`、定向回归和完整 `npm run regression` 通过。 +- 2026-07-23:重启前通过 ccweb MCP 确认仅当前对话 running、无 pending reply/child;执行 `pm2 restart ccweb --update-env`。 +- 2026-07-23:重启后 ccweb PID 1272312 online,HTTP 8002 返回 200,线上 app.js 含 Ultra/中断语义;新 app-server 使用有效的 Codex 0.145.0 二进制。 +- 2026-07-23:`multi_agent_v2` 保持 stable/false,未擅自开启;发现 7 月 2 日启动的外部旧 app-server 指向 deleted 二进制,因不属于本次 ccweb 进程未终止。 diff --git a/.planning/multi-agent-v2-audit/task_plan.md b/.planning/multi-agent-v2-audit/task_plan.md new file mode 100644 index 0000000..19b6fcb --- /dev/null +++ b/.planning/multi-agent-v2-audit/task_plan.md @@ -0,0 +1,37 @@ +# Multi-agent V2 与 cc-web 兼容性审计 + +## 目标 + +完成 cc-web 对 Codex 0.145.0 Multi-agent V2 的兼容适配,验证后在无其他运行对话时重启服务并验收。 + +## 实施计划 + +- [x] 补充 Multi-agent V2 失败回归用例 +- [x] 实现 Ultra 推理强度全链路支持 +- [x] 替换过时的 V1 子代理指导 +- [x] 实现 subAgentActivity 子代理路由与嵌套状态同步 +- [x] 更新 V2 mock 与关闭/路径展示语义 +- [x] 运行回归和静态检查 +- [x] 检查运行会话并重启 cc-web +- [x] 执行重启后版本与服务验收 + +## 约束 + +- 上游事实优先采用 OpenAI 官方文档或本机安装包的可验证行为。 +- 代码理解优先使用 codebase-memory-mcp,`rg` 仅做文本与行号校验。 +- 保留工作区既有未提交文件,只修改本任务涉及的产品代码和回归。 +- 用户已明确授权用户级全局 Codex 包升级;仅使用单次代理注册表,不改写 npm 持久配置。 +- 重启前必须确认除当前对话外没有其他 running 对话;不启用 `multi_agent_v2` feature flag。 + +## 错误记录 + +- 官方 Codex 手册 helper 对 `HEAD https://developers.openai.com/codex/codex-manual.md` 返回 HTTP 403;后续改用已缓存手册、官方 GET 或本机安装包证据降级。 +- 一次过宽的 `/home/hdzx` 文件系统扫描超过 60 秒无输出,已中止;后续收窄到已知安装路径与有限目录。 +- 浅克隆官方源码目录只留下无提交的 `.git`,未形成可检出的 `HEAD`;后续改为查询精确标签后定点 fetch,避免重复同一失败路径。 +- 一次工具脚本因 JavaScript 对象引号拼写错误未执行;已立即修正,同类命令后续采用更短参数块。 +- 一次 GitHub release JSON 的 `jq` 表达式把数组结果继续索引 `.body`,导致解析报错;已改为分别输出元组和正文,确认最新稳定版为 0.145.0。 +- 一次计划文件补丁把“错误记录”误定位到 findings 文件,校验未通过且未写入;已按文件职责拆分后成功更新。 +- 一次读取 0.145.0 child completion 源码的工具脚本重复出现 JavaScript 对象引号拼写错误;命令未执行,随即修正并成功获取证据。 +- 一次补写 child completion 发现时再次把 task_plan 的错误记录定位到 findings,补丁校验未通过;已拆分为正确文件更新。 +- 一次补写驻留 app-server 发现时误带了空的 skill 文件 hunk,补丁整体校验失败且未写入;已移除无关 hunk 后成功更新。 +- 首次 `npm install -g` 使用 npm 默认系统前缀 `/usr/local`,因 EACCES 在 rename 阶段失败;未使用 sudo,改为对当前实际 Codex 路径对应的 `~/.local` 显式用户前缀安装。 diff --git a/.planning/subagent-plan-progress/findings.md b/.planning/subagent-plan-progress/findings.md new file mode 100644 index 0000000..4d47dd2 --- /dev/null +++ b/.planning/subagent-plan-progress/findings.md @@ -0,0 +1,18 @@ +# 发现记录 + +## 2026-07-24 + +- `processCcwebMcpChildNotification` 当前仅处理子线程 turn 和 agent message,忽略计划通知。 +- `processCodexAppNotification` 已兼容 `plan/updated`、`turn/plan/updated`、`item/plan/updated`、`item/todoList/updated`。 +- `planUpdateItemFromParams`、`normalizeTodoListFromPlanItem`、`isTodoListPlanDone` 可作为纯解析链路复用。 +- 子代理公开状态通过 `ccwebMcpChildPublicState`、`mergeCcwebMcpChildIntoTool` 和现有 child update 事件传给前端,适合承载精简计划字段。 +- 前端已有 `createPlanProgressElement`,可抽出由 `{ completed, total }` 直接渲染的基础函数。 +- 当前工作区已有未提交改动和用户自有 CSV,必须保留且不能误清理。 +- 子代理恢复路径当前只从原始 `subAgentActivity` input 恢复;精简计划应写进工具 `input.agentsStates[threadId]`,并在恢复时读取对应 agent state。 +- `rememberCollabAgentStateFromChild` 会 spread 子代理公开状态,字段可进入缓存;但 `mergeCollabAgentTaskState` 需要确认不会在卡片工具合并时丢失计划字段。 +- 现有 Plan List 进度点只在总数超过 12 时显示计数;子代理卡片需求明确要求始终展示 `completed/total`,基础渲染函数需要可配置计数显示。 +- 样式文件实际为 `public/style.css`,后续 CSS 契约应使用现有 `PUBLIC_STYLE_PATH`。 +- `normalizeIdentifier` 会把 `inProgress` / `in_progress` 都归一为 `inprogress` / `in_progress`;当前完成状态判断已经兼容多种形状。 +- 纯解析 API 适合返回 `{ item, todoList, progress, currentStep, done }`:父线程用 `todoList` 更新工具,子线程只保留 `progress/currentStep`。 +- 现有 Plan List 回归只断言 items 的 `completed` 和 progress,可以在不改变 todo payload 形状的前提下从原始 entries 提取当前步骤。 +- Plan List 的桌面进度点单点实际占用约 22px;在 220px 子卡片内最多 12 点会溢出,因此子卡片保留同一语义和主题色,但将点尺寸收敛为 8px。 diff --git a/.planning/subagent-plan-progress/progress.md b/.planning/subagent-plan-progress/progress.md new file mode 100644 index 0000000..df5ee82 --- /dev/null +++ b/.planning/subagent-plan-progress/progress.md @@ -0,0 +1,25 @@ +# 进度日志 + +## 2026-07-24T06:22:38+08:00 + +- 恢复前一轮上下文并核对工作区状态。 +- 完整读取 `planning-with-files` 与 `todo-list-csv` 技能说明。 +- 已建立 7 步实施计划;当前执行失败回归阶段。 +- 本地审查计划通过:范围和验收项完整,无需调整步骤。 +- 已确认父计划解析、子通知处理、子状态恢复和前端卡片的精确修改点。 +- 新增运行时纯解析 API 和前端简版进度的契约断言。 +- `npm run regression -- --target subagent-card-metadata` 按预期失败:当前缺少 `createPlanProgressElementFromProgress`。 +- 已在 `codex-app-runtime` 暴露 `planUpdateFromNotification`,并将父线程计划通知改为复用该纯解析 API。 +- 纯解析验证通过:三步计划得到 `1/3` 且当前步骤为“同步”。 +- 子通知处理器已识别计划更新,并将 `planProgress` / `planCurrentStep` / `planUpdatedAt` 同步到公开状态和持久化工具结果。 +- 持久化恢复现在优先读取工具 result 中的最新 agent state,可恢复计划摘要。 +- 已抽取 `createPlanProgressElementFromProgress`,子代理卡片复用进度点并始终显示计数。 +- 卡片新增当前步骤单行省略展示;定向 `subagent-card-metadata` 回归通过。 +- 首次全量回归在旧 Plan List DOM 契约处失败:断言仍提取 wrapper,已改为检查抽取后的基础渲染函数。 +- V2 mock 现在由子线程发送三步 `turn/plan/updated`;集成断言验证父卡片收到 `1/3` 和当前步骤。 +- 新增前端行为断言:3 个进度点、`1/3` 计数与当前步骤均正确渲染。 +- 全量 `npm run regression` 在 60 秒限制内通过,包含 V2 计划状态持久化验证。 +- 差异审查发现 Plan List 原 14px 点在子卡片中可能溢出,已添加高优先级 8px 卡片内覆盖,不影响原 Plan List。 +- 当前修订已通过全量回归、`subagent-card-metadata` / `plan-list-progress` 定向回归、全部改动 JS 语法检查和 `git diff --check`。 +- 重启前 ccweb running 列表仅有当前会话,安全门禁通过。 +- `pm2 restart ccweb --update-env` 已执行;命令回传因服务自身重启中断,事后验收确认 ccweb online、8002 返回 200、线上 `app.js` 包含新进度函数。 diff --git a/.planning/subagent-plan-progress/task_plan.md b/.planning/subagent-plan-progress/task_plan.md new file mode 100644 index 0000000..a196396 --- /dev/null +++ b/.planning/subagent-plan-progress/task_plan.md @@ -0,0 +1,30 @@ +# 子代理卡片计划进度 + +## 目标 + +当子代理通过 `update_plan` 更新计划时,把精简进度同步到父会话的子代理卡片;页面刷新后仍可恢复,并在完成回归后安全重启 ccweb。 + +## 阶段 + +| 阶段 | 状态 | 验收标准 | +|---|---|---| +| 1. 补充子代理计划进度失败回归 | complete | 回归能证明当前缺少子代理计划同步与卡片展示 | +| 2. 暴露并复用计划通知解析 | complete | 子代理和父代理使用同一套计划通知解析逻辑 | +| 3. 实现子代理计划状态同步与持久化 | complete | 子代理公开状态、工具状态、恢复状态都包含精简计划 | +| 4. 实现卡片简版进度渲染 | complete | 卡片显示进度点、完成数和当前步骤 | +| 5. 更新 V2 mock 与集成断言 | complete | mock 子代理发送计划通知,集成回归验证父会话收到状态 | +| 6. 运行回归和静态检查 | complete | JS 语法、diff 检查和 60 秒内全量回归通过 | +| 7. 检查运行会话、重启并验收 | complete | 无其他运行会话时重启,PM2、HTTP 和线上资源正常 | + +## 关键决策 + +- 继续复用 `ccweb_mcp_child_agent_update`,不新增 WebSocket 消息类型。 +- 子代理只向父卡片暴露 `{ completed, total }` 与当前 `in_progress` 步骤,不复制完整计划项。 +- 计划事件解析集中在 `codex-app-runtime`,避免父、子线程各维护一套协议兼容逻辑。 +- 保留工作区中用户和上一任务的既有改动,仅在相关区域增量修改。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---|---| +| Plan List 回归仍从 wrapper 提取 DOM 契约,抽取 helper 后断言失败 | 1 | 契约改为检查 `createPlanProgressElementFromProgress` | diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 35ee2ac..99577e2 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index ca76884..bbdb8f9 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -211,7 +211,7 @@ function createAgentRuntime(deps) { // cc-web UI supports "gpt-5.4(high)" style selection, but Codex CLI expects: // - model: "gpt-5.4" // - reasoning effort: config key `model_reasoning_effort = "high"` - const m = raw.match(/^(.*)\((low|medium|high|xhigh)\)\s*$/i); + const m = raw.match(/^(.*)\((low|medium|high|xhigh|ultra)\)\s*$/i); if (m) { const base = String(m[1] || '').trim(); const lvl = String(m[2] || '').trim().toLowerCase(); diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index e74a19d..a784863 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -24,6 +24,12 @@ const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护 const RUNTIME_TRUNCATED_TAIL = '\n[cc-web: 内容过长,已截断以保护服务稳定性]'; const CODEX_APP_PLAN_ITEM_TYPES = new Set(['plan', 'plan_list', 'planlist', 'todo', 'todo_list', 'todolist', 'task_list']); const CODEX_APP_PLAN_TOOL_NAMES = new Set(['update_plan', 'plan', 'plan_list', 'todo_list', 'updateplan', 'todolist']); +const CODEX_APP_PLAN_UPDATE_METHODS = new Set([ + 'plan/updated', + 'turn/plan/updated', + 'item/plan/updated', + 'item/todoList/updated', +]); function createCodexAppRuntime(deps = {}) { const { @@ -208,8 +214,7 @@ function createCodexAppRuntime(deps = {}) { }; } - function normalizeTodoListFromPlanItem(item) { - if (!isPlanLikeItem(item)) return null; + function planEntriesFromItem(item) { const candidates = [ item.arguments, item.input, @@ -225,7 +230,13 @@ function createCodexAppRuntime(deps = {}) { entries = extractPlanEntries(candidate); if (entries) break; } - if (!Array.isArray(entries)) return null; + return Array.isArray(entries) ? entries : null; + } + + function normalizeTodoListFromPlanItem(item) { + if (!isPlanLikeItem(item)) return null; + const entries = planEntriesFromItem(item); + if (!entries) return null; const items = entries .map((entry) => { const text = truncateEnd(planEntryText(entry), RUNTIME_TOOL_INPUT_MAX_CHARS); @@ -244,6 +255,15 @@ function createCodexAppRuntime(deps = {}) { }; } + function currentPlanStep(entries) { + const activeEntry = (Array.isArray(entries) ? entries : []).find((entry) => { + if (!entry || typeof entry !== 'object') return false; + const status = normalizeIdentifier(entry.status || entry.state); + return ['inprogress', 'in_progress', 'active', 'running', 'working'].includes(status); + }); + return truncateEnd(planEntryText(activeEntry), RUNTIME_TOOL_INPUT_MAX_CHARS); + } + function isTodoListPlanDone(item, todoList) { const status = normalizeIdentifier(item?.status || item?.state); if (['completed', 'complete', 'done', 'success', 'succeeded', 'failed', 'error', 'cancelled', 'canceled'].includes(status)) { @@ -272,6 +292,20 @@ function createCodexAppRuntime(deps = {}) { }; } + function planUpdateFromNotification(notification = {}) { + if (!CODEX_APP_PLAN_UPDATE_METHODS.has(notification?.method)) return null; + const item = planUpdateItemFromParams(notification.params || {}); + const todoList = normalizeTodoListFromPlanItem(item); + if (!todoList) return null; + return { + item, + todoList, + progress: todoList.progress, + currentStep: currentPlanStep(planEntriesFromItem(item)), + done: isTodoListPlanDone(item, todoList), + }; + } + function cleanRuntimeText(value) { return value == null ? '' : String(value).trim(); } @@ -891,10 +925,10 @@ function createCodexAppRuntime(deps = {}) { case 'turn/plan/updated': case 'item/plan/updated': case 'item/todoList/updated': { - const item = planUpdateItemFromParams(params); - const todoList = normalizeTodoListFromPlanItem(item); - if (!todoList) return { done: false }; - updateToolResult(entry, sessionId, todoList.id, JSON.stringify(todoList, null, 2), isTodoListPlanDone(item, todoList), { + const planUpdate = planUpdateFromNotification(notification); + if (!planUpdate) return { done: false }; + const { item, todoList } = planUpdate; + updateToolResult(entry, sessionId, todoList.id, JSON.stringify(todoList, null, 2), planUpdate.done, { name: 'PlanList', kind: 'todo_list', input: todoList, @@ -1000,6 +1034,7 @@ function createCodexAppRuntime(deps = {}) { } return { + planUpdateFromNotification, processCodexAppNotification, updateUsage, }; diff --git a/public/app.js b/public/app.js index bded2d0..55b7f1e 100644 --- a/public/app.js +++ b/public/app.js @@ -6528,24 +6528,24 @@ return null; } - function createPlanProgressElement(tool) { - const progress = resolveToolPlanProgress(tool); - if (!progress) return null; + function createPlanProgressElementFromProgress(progress, options = {}) { + const normalizedProgress = normalizePlanProgress(progress); + if (!normalizedProgress) return null; const meter = document.createElement('span'); meter.className = 'plan-progress'; meter.setAttribute('role', 'img'); - const progressLabel = `计划进度:已完成 ${progress.completed} 项,共 ${progress.total} 项`; + const progressLabel = `计划进度:已完成 ${normalizedProgress.completed} 项,共 ${normalizedProgress.total} 项`; meter.setAttribute('aria-label', progressLabel); meter.title = progressLabel; const dots = document.createElement('span'); dots.className = 'plan-progress-dots'; dots.setAttribute('aria-hidden', 'true'); - const visibleDotCount = Math.min(progress.total, 12); - const completedDotCount = progress.total <= visibleDotCount - ? progress.completed - : Math.round((progress.completed / progress.total) * visibleDotCount); + const visibleDotCount = Math.min(normalizedProgress.total, 12); + const completedDotCount = normalizedProgress.total <= visibleDotCount + ? normalizedProgress.completed + : Math.round((normalizedProgress.completed / normalizedProgress.total) * visibleDotCount); for (let index = 0; index < visibleDotCount; index += 1) { const dot = document.createElement('span'); dot.className = `plan-progress-dot ${index < completedDotCount ? 'is-complete' : 'is-remaining'}`; @@ -6553,16 +6553,20 @@ } meter.appendChild(dots); - if (progress.total > visibleDotCount) { + if (options.alwaysShowCount || normalizedProgress.total > visibleDotCount) { const count = document.createElement('span'); count.className = 'plan-progress-count'; count.setAttribute('aria-hidden', 'true'); - count.textContent = `${progress.completed}/${progress.total}`; + count.textContent = `${normalizedProgress.completed}/${normalizedProgress.total}`; meter.appendChild(count); } return meter; } + function createPlanProgressElement(tool) { + return createPlanProgressElementFromProgress(resolveToolPlanProgress(tool)); + } + function toolSubtitle(tool) { if (toolKind(tool) === 'file_change') { return ''; @@ -6863,6 +6867,7 @@ if (/^(returned|return)$/.test(normalized)) return 'returned'; if (/^(completed|complete|done|finished|finish|success|succeeded)$/.test(normalized)) return 'completed'; if (/^(closed|close|closing|stopped|stop)$/.test(normalized)) return 'closed'; + if (normalized === 'interrupted') return 'interrupted'; if (/^(failed|fail|error|errored|cancelled|canceled|aborted|rejected)$/.test(normalized)) return 'failed'; return done ? 'completed' : 'running'; } @@ -6971,8 +6976,12 @@ nickname: cleanCollabAgentText(state.nickname || ''), name: cleanCollabAgentText(state.name || ''), role, + agentPath: cleanCollabAgentText(state.agentPath || state.agent_path || ''), status, detail, + planProgress: normalizePlanProgress(state.planProgress || state.plan_progress), + planCurrentStep: cleanCollabAgentText(state.planCurrentStep || state.plan_current_step || ''), + planUpdatedAt: state.planUpdatedAt || state.plan_updated_at || null, taskDescription, hasReadableSourceTitle, }; @@ -7346,7 +7355,7 @@ function collabStateTone(statusText) { const normalized = String(statusText || '').toLowerCase(); if (!normalized) return 'pending'; - if (/(closed|close)/.test(normalized)) return 'closed'; + if (/(closed|close|interrupted)/.test(normalized)) return 'closed'; if (/(returned|done|completed|success|finished|idle)/.test(normalized)) return 'done'; if (/(fail|error|cancel|aborted|rejected)/.test(normalized)) return 'error'; if (/(running|working|active|inprogress|in_progress|executing)/.test(normalized)) return 'running'; @@ -7357,6 +7366,7 @@ const normalized = String(statusText || '').trim(); if (!normalized) return '等待中'; const lower = normalized.toLowerCase(); + if (/interrupted/.test(lower)) return '已中断'; if (/(closed|close)/.test(lower)) return '已关闭'; if (/(returned)/.test(lower)) return '已返回'; if (/(done|completed|success|finished)/.test(lower)) return '已返回'; @@ -7431,6 +7441,7 @@ displayTitle, descriptionTitle, entry.role ? `角色: ${entry.role}` : '', + entry.agentPath ? `路径: ${entry.agentPath}` : '', entry.detail ? `结果: ${entry.detail}` : '', entry.id ? `ID: ${entry.id}` : '', ].filter(Boolean).join('\n'); @@ -7465,12 +7476,12 @@ const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'collab-agent-close-btn'; - closeBtn.textContent = '关闭'; - closeBtn.title = `关闭子代理\n${entry.id}`; + closeBtn.textContent = '中断'; + closeBtn.title = `中断当前子代理任务\n${entry.id}`; closeBtn.addEventListener('click', (event) => { event.stopPropagation(); closeBtn.disabled = true; - closeBtn.textContent = '关闭中'; + closeBtn.textContent = '中断中'; send({ type: 'ccweb_mcp_child_agent_close', sessionId: currentSessionId, @@ -7487,10 +7498,28 @@ description.title = descriptionTitle; item.appendChild(description); - if (entry.id || entry.role) { + const planProgress = normalizePlanProgress(entry.planProgress); + if (planProgress) { + const plan = document.createElement('div'); + plan.className = 'collab-agent-item-plan'; + const meter = createPlanProgressElementFromProgress(planProgress, { alwaysShowCount: true }); + if (meter) plan.appendChild(meter); + item.appendChild(plan); + + const currentStepText = cleanCollabAgentText(entry.planCurrentStep); + if (currentStepText) { + const currentStep = document.createElement('div'); + currentStep.className = 'collab-agent-item-plan-current'; + currentStep.textContent = `当前:${currentStepText}`; + currentStep.title = currentStepText; + item.appendChild(currentStep); + } + } + + if (entry.id || entry.role || entry.agentPath) { const footer = document.createElement('div'); footer.className = 'collab-agent-item-footer'; - footer.textContent = entry.role || ''; + footer.textContent = [entry.role, entry.agentPath].filter(Boolean).join(' · '); if (!footer.textContent) footer.hidden = true; item.appendChild(footer); } @@ -9019,11 +9048,12 @@ showOptionPicker(`选择 ${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'} 模型`, baseOptions, current.base || '', (baseValue) => { const base = String(baseValue || '').trim(); const thinkingOptions = [ - { value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' }, + { value: '', label: '无 (默认)', desc: '不附加推理强度后缀' }, { value: 'low', label: 'low', desc: '较轻 thinking' }, { value: 'medium', label: 'medium', desc: '中等 thinking' }, { value: 'high', label: 'high', desc: '更强 thinking' }, - { value: 'xhigh', label: 'xhigh', desc: '最强 thinking' }, + { value: 'xhigh', label: 'xhigh', desc: '高强度 thinking' }, + { value: 'ultra', label: 'ultra', desc: '最高强度 thinking' }, ]; showOptionPicker('选择 Thinking 强度', thinkingOptions, current.level || '', (lvl) => { const level = String(lvl || '').trim().toLowerCase(); diff --git a/public/style.css b/public/style.css index 73be8b7..cb8f319 100644 --- a/public/style.css +++ b/public/style.css @@ -5899,6 +5899,38 @@ html[data-theme='coolvibe'] .settings-back:hover { -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.collab-agent-item-plan { + display: flex; + min-width: 0; + align-items: center; + color: var(--text-muted); +} +.collab-agent-item-plan .plan-progress { + min-width: 0; +} +.collab-agent-item-plan .plan-progress-dots { + gap: 4px; +} +.collab-agent-item-plan .plan-progress-dot { + flex: 0 0 8px; + width: 8px; + height: 8px; + margin-left: 0; + background-size: contain; +} +.collab-agent-item-plan .plan-progress-count { + font-size: 10px; + font-weight: 700; +} +.collab-agent-item-plan-current { + min-width: 0; + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} .collab-agent-item-footer { font-size: 11px; color: var(--text-muted); diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index ca11af4..d5fe99d 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -74,12 +74,15 @@ function retryScenarioKey(text, marker) { function collaborationSummary(params = {}) { const collaborationMode = params.collaborationMode; const settings = collaborationMode?.settings || {}; + const developerInstructions = String(settings.developer_instructions || ''); return JSON.stringify({ mode: collaborationMode?.mode || null, hasModel: Boolean(settings.model), - hasDeveloperInstructions: /Codex sub-agent spawning rules/.test(String(settings.developer_instructions || '')), - hasWaitAgentRetryGuidance: /wait_agent[\s\S]*timeout_ms[\s\S]*additional wait_agent rounds/.test(String(settings.developer_instructions || '')), + hasDeveloperInstructions: /Codex sub-agent runtime rules/.test(developerInstructions), + hasSchemaDrivenSubagents: /current runtime tool schema/.test(developerInstructions), + hasLegacyV1Guidance: /fork_context|additional wait_agent rounds/.test(developerInstructions), hasReasoningEffort: Object.prototype.hasOwnProperty.call(settings, 'reasoning_effort'), + reasoningEffort: settings.reasoning_effort || null, hasTopLevelModel: Object.prototype.hasOwnProperty.call(params, 'model'), hasTopLevelEffort: Object.prototype.hasOwnProperty.call(params, 'effort'), }); @@ -167,6 +170,85 @@ function emitChildCollabTurn(threadId, turnId, finalMessage) { childThread.activeTurnId = null; } +function emitNestedChildCollabTurn() { + const threadId = 'child-thread-v2'; + const turnId = 'child-turn-v2'; + const grandchildThreadId = 'grandchild-thread-v2'; + const childThread = ensureThread(threadId); + childThread.activeTurnId = turnId; + send({ + method: 'turn/started', + params: { + threadId, + turn: { id: turnId, status: 'running', items: [] }, + }, + }); + send({ + method: 'turn/plan/updated', + params: { + threadId, + turnId, + plan: [ + { step: '定位子线程路由', status: 'completed' }, + { step: '同步父卡片进度', status: 'in_progress' }, + { step: '完成集成回归', status: 'pending' }, + ], + }, + }); + send({ + method: 'item/agentMessage/delta', + params: { + threadId, + turnId, + itemId: 'child-agent-msg-v2', + delta: 'V2 子代理最终消息:父级子代理路由正常。', + }, + }); + send({ + method: 'item/completed', + params: { + threadId, + turnId, + completedAtMs: Date.now(), + item: { + id: 'grandchild-activity-v2', + type: 'subAgentActivity', + kind: 'started', + agentThreadId: grandchildThreadId, + agentPath: '/root/v2_parent/v2_grandchild', + prompt: '验证 Multi-agent V2 嵌套子代理路由。', + }, + }, + }); + emitChildCollabTurn( + grandchildThreadId, + 'grandchild-turn-v2', + 'V2 孙代理最终消息:嵌套路由正常。' + ); + send({ + method: 'item/completed', + params: { + threadId, + turnId, + completedAtMs: Date.now(), + item: { + id: 'child-agent-msg-v2', + type: 'agentMessage', + content: [{ type: 'text', text: 'V2 子代理最终消息:父级子代理路由正常。' }], + status: 'completed', + }, + }, + }); + send({ + method: 'turn/completed', + params: { + threadId, + turn: { id: turnId, status: 'completed', items: [] }, + }, + }); + childThread.activeTurnId = null; +} + function completeTurn(thread, turnId, text, status = 'completed') { if (thread.activeTurnId !== turnId) return; const suffix = thread.steers.length > 0 ? ` | steer: ${thread.steers.join(' | ')}` : ''; @@ -267,7 +349,57 @@ function completeTurn(thread, turnId, text, status = 'completed') { }); } - if (/subagent/i.test(text)) { + if (/subagent v2/i.test(text)) { + send({ + method: 'item/completed', + params: { + threadId: thread.id, + turnId, + completedAtMs: Date.now(), + item: { + id: 'child-activity-v2', + type: 'subAgentActivity', + kind: 'started', + agentThreadId: 'child-thread-v2', + agentPath: '/root/v2_parent', + prompt: '验证 Multi-agent V2 子代理路由。', + }, + }, + }); + emitNestedChildCollabTurn(); + send({ + method: 'item/started', + params: { + threadId: thread.id, + turnId, + startedAtMs: Date.now(), + item: { + id: 'wait-empty-v2', + type: 'collabAgentToolCall', + tool: 'wait_agent', + receiverThreadIds: [], + agentsStates: {}, + status: 'inProgress', + }, + }, + }); + send({ + method: 'item/completed', + params: { + threadId: thread.id, + turnId, + completedAtMs: Date.now(), + item: { + id: 'wait-empty-v2', + type: 'collabAgentToolCall', + tool: 'wait_agent', + receiverThreadIds: [], + agentsStates: {}, + status: 'completed', + }, + }, + }); + } else if (/subagent/i.test(text)) { send({ method: 'item/started', params: { @@ -331,7 +463,7 @@ function completeTurn(thread, turnId, text, status = 'completed') { emitChildCollabTurn( 'child-thread-a', 'child-turn-a', - '子代理最终消息:结构化渲染和关闭按钮链路已完成。' + '子代理最终消息:结构化渲染和中断按钮链路已完成。' ); emitChildCollabTurn( 'child-thread-b', diff --git a/scripts/regression.js b/scripts/regression.js index 9ca7aff..ca1908d 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -882,12 +882,12 @@ function assertPlanListProgressContract() { assert(result.progress?.completed === 3 && result.progress?.total === 5, 'Persisted todo result should expose 3/5 progress'); const summarySource = extractFunctionSource(source, 'applyToolSummary'); - const progressElementSource = extractFunctionSource(source, 'createPlanProgressElement'); + const progressElementSource = extractFunctionSource(source, 'createPlanProgressElementFromProgress'); assert(summarySource.includes('createPlanProgressElement(tool)'), 'Tool summaries should append the plan progress element beside the title'); assert(progressElementSource.includes("meter.setAttribute('role', 'img')"), 'Plan progress should expose an accessible image role'); assert(progressElementSource.includes("meter.setAttribute('aria-label', progressLabel)"), 'Plan progress should announce completed and total counts'); - assert(progressElementSource.includes('Math.min(progress.total, 12)'), 'Long plans should cap visible dots to protect the header layout'); - assert(progressElementSource.includes("count.textContent = `${progress.completed}/${progress.total}`"), 'Compacted long plans should keep an exact numeric count'); + assert(progressElementSource.includes('Math.min(normalizedProgress.total, 12)'), 'Long plans should cap visible dots to protect the header layout'); + assert(progressElementSource.includes("count.textContent = `${normalizedProgress.completed}/${normalizedProgress.total}`"), 'Compacted long plans should keep an exact numeric count'); assert(styleSource.includes('--plan-progress-complete: var(--success);'), 'Plan progress should inherit the active theme success color'); assert(styleSource.includes('--plan-progress-remaining: var(--accent);'), 'Remaining plan progress should inherit the active theme accent color'); @@ -1508,9 +1508,15 @@ function assertFrontendSubagentCardMetadataContract() { assert(/mergeCollabAgentTaskState\(\s*states\[id\]/.test(source), 'Receiver-only child states should use the tested metadata merge helper'); assert(source.includes('entry.detail ? `结果: ${entry.detail}` :'), 'Card title should keep runtime result in the container title'); assert(source.includes("description.className = 'collab-agent-item-description'"), 'Sub-agent cards should render a visible task intro node'); + 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('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'); + assert(/\.collab-agent-item-plan\s*\{[\s\S]*?display:\s*flex;/.test(styleSource), 'Sub-agent plan progress should use a compact flex row'); + assert(/\.collab-agent-item-plan \.plan-progress-dot\s*\{[\s\S]*?width:\s*8px;[\s\S]*?height:\s*8px;[\s\S]*?margin-left:\s*0;/.test(styleSource), 'Sub-agent plan dots should remain compact inside narrow cards'); + assert(/\.collab-agent-item-plan-current\s*\{[\s\S]*?text-overflow:\s*ellipsis;/.test(styleSource), 'Sub-agent current plan step should truncate safely'); assert(/\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?flex-direction:\s*column;/.test(styleSource), 'Sub-agent cards should be vertically composed and flex-shrink on narrow screens'); assert(/@media \(max-width:\s*640px\)[\s\S]*?\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?\}/.test(styleSource), 'Narrow screens should let sub-agent cards shrink without horizontal overflow'); @@ -1666,6 +1672,33 @@ function assertFrontendSubagentCardMetadataContract() { assert(spawnedMerge.input.receiverThreadIds.length === 1, 'Spawned child should merge into one visible agent'); assert(collabApi.getCachedState('child-thread-a')?.label === '关闭验证代理', 'Structured child state should be cached by thread id'); + const planCardTool = { + ...spawnedTool, + id: 'tool-plan-progress', + input: { + ...spawnedTool.input, + receiverThreadIds: ['child-thread-plan'], + agentsStates: { + 'child-thread-plan': { + title: '进度验证代理', + taskDescription: '验证子代理计划简报。', + status: 'running', + planProgress: { completed: 1, total: 3 }, + planCurrentStep: '同步父卡片进度', + }, + }, + }, + }; + const planCardElement = collabApi.renderCollabAgentToolElement(planCardTool); + const planRows = findNodesByClass(planCardElement, 'collab-agent-item-plan'); + const planDots = findNodesByClass(planCardElement, 'plan-progress-dot'); + const planCounts = findNodesByClass(planCardElement, 'plan-progress-count'); + const planCurrentSteps = findNodesByClass(planCardElement, 'collab-agent-item-plan-current'); + assert(planRows.length === 1, 'Sub-agent plan summary should render one compact progress row'); + assert(planDots.length === 3, 'Sub-agent plan summary should render one progress dot per short plan item'); + 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'); + collabApi.rememberCollabAgentState( 'unrelated-child', { title: '无关历史代理', status: 'running' }, @@ -1974,6 +2007,20 @@ function assertCodexAppRuntimeSubAgentActivityContract() { fullText: '', }; + assert(typeof runtime.planUpdateFromNotification === 'function', 'Runtime should expose shared plan notification parsing'); + const childPlan = runtime.planUpdateFromNotification({ + method: 'turn/plan/updated', + params: { + plan: [ + { step: '解析子代理计划', status: 'completed' }, + { step: '同步父卡片进度', status: 'in_progress' }, + { step: '补充回归验证', status: 'pending' }, + ], + }, + }); + assert(childPlan?.progress?.completed === 1 && childPlan?.progress?.total === 3, 'Shared plan parser should summarize child plan progress'); + assert(childPlan?.currentStep === '同步父卡片进度', 'Shared plan parser should expose the current in-progress step'); + runtime.processCodexAppNotification(entry, { method: 'item/started', params: { @@ -3423,6 +3470,47 @@ function assertCodexAppUnroutedNotificationRoutingContract() { ); } +function assertMultiAgentV2CompatibilityContract() { + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8'); + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + + assert( + /CODEX_REASONING_LEVELS\s*=\s*new Set\(\[[^\]]*'ultra'/.test(serverSource), + 'Codex config model parsing should accept the ultra reasoning level' + ); + assert( + /low\|medium\|high\|xhigh\|ultra/.test(runtimeSource), + 'Codex CLI model suffix parsing should accept ultra' + ); + assert( + frontendSource.includes("{ value: 'ultra', label: 'ultra'"), + 'Codex model picker should expose ultra reasoning' + ); + const instructionsBlock = serverSource.slice( + serverSource.indexOf('const CODEX_APP_COLLABORATION_INSTRUCTIONS'), + serverSource.indexOf('function getLocalCodexConfigTomlPath') + ); + assert(instructionsBlock.includes('current runtime tool schema'), 'Sub-agent guidance should defer to the current runtime tool schema'); + assert(!instructionsBlock.includes('fork_context'), 'Sub-agent guidance should not hard-code the V1 fork_context field'); + assert(!instructionsBlock.includes('additional wait_agent rounds'), 'Sub-agent guidance should not impose obsolete repeated wait_agent calls'); + + const syncBlock = extractFunctionSource(serverSource, 'syncCcwebMcpChildAgentsFromCollabItem'); + assert(syncBlock.includes("itemType === 'subAgentActivity'"), 'Child routing should register canonical V2 subAgentActivity items'); + const notificationBlock = extractFunctionSource(serverSource, 'handleCodexAppNotification'); + const activitySyncIndex = notificationBlock.indexOf('syncCcwebMcpChildAgentsFromCollabItem(routed, item)'); + const childReturnIndex = notificationBlock.indexOf("if (routed.role === 'child')"); + assert(activitySyncIndex >= 0 && activitySyncIndex < childReturnIndex, 'Nested subAgentActivity registration should happen before child notification routing returns'); + const childNotificationBlock = extractFunctionSource(serverSource, 'processCcwebMcpChildNotification'); + assert(childNotificationBlock.includes('codexAppRuntime.planUpdateFromNotification(notification)'), 'Child notification routing should reuse runtime plan parsing'); + const recoveryBlock = extractFunctionSource(serverSource, 'recoverCcwebMcpChildThreadsFromPersistedToolCalls'); + assert(recoveryBlock.includes('parseMaybeJsonObject(tool?.result)'), 'Child recovery should read the latest persisted collaboration tool result'); + assert(recoveryBlock.includes('recoveredState.planProgress'), 'Child recovery should restore persisted plan progress'); + + assert(frontendSource.includes("closeBtn.textContent = '中断';"), 'Sub-agent action should use interrupt semantics'); + assert(frontendSource.includes('entry.agentPath ? `路径: ${entry.agentPath}`'), 'Sub-agent cards should expose the canonical agent path'); +} + async function main() { const targetIndex = process.argv.indexOf('--target'); const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim(); @@ -3440,6 +3528,7 @@ async function main() { if (regressionTarget === 'subagent-card-metadata') { assertFrontendSubagentCardMetadataContract(); assertCodexAppRuntimeSubAgentActivityContract(); + assertMultiAgentV2CompatibilityContract(); console.log('Subagent card metadata regression checks passed.'); return; } @@ -3503,6 +3592,7 @@ async function main() { assertSessionSwitchResilienceContract(); assertSessionSwitchRaceContract(); assertCodexAppChildToolFallbackContract(); + assertMultiAgentV2CompatibilityContract(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); @@ -3580,7 +3670,7 @@ async function main() { }, null, 2)); createFakeClaudeHistory(homeDir); - createFakeCodexConfig(homeDir); + createFakeCodexConfig(homeDir, { reasoningEffort: 'ultra' }); const codexFixture = createFakeCodexHistory(homeDir); const codexAppImportFixture = createFakeCodexHistory(homeDir, { threadId: 'codexapp-import-thread', @@ -3730,7 +3820,7 @@ async function main() { ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' })); const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd); assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode'); - assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml'); + assert(codexSession.model === 'gpt-5.5(ultra)', 'Codex new_session should preserve ultra from ~/.codex/config.toml'); ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: true })); const pinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId); @@ -4319,6 +4409,8 @@ async function main() { .split('\n') .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8))); assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag'); + const parsedSpawnLine = JSON.parse(spawnLine); + assert(parsedSpawnLine.args.includes('model_reasoning_effort="ultra"'), 'Codex exec should pass the ultra reasoning level through model_reasoning_effort'); const allSpawnsForSession = processLog .trim() @@ -4383,7 +4475,7 @@ async function main() { ].join('\n')); ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' })); const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd); - assert(codexAppSession.model === 'gpt-5.5(xhigh)', 'Codex App new_session should read default Codex model'); + assert(codexAppSession.model === 'gpt-5.5(ultra)', 'Codex App new_session should preserve the ultra default Codex model'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' })); const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill'); @@ -4397,7 +4489,9 @@ async function main() { assert(/"mode":"default"/.test(codexAppDefaultCollab.text || ''), 'Codex App YOLO mode should pass default collaboration mode'); assert(/"hasModel":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include model'); assert(/"hasDeveloperInstructions":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include sub-agent developer instructions'); - assert(/"hasWaitAgentRetryGuidance":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include wait_agent retry guidance'); + assert(/"hasSchemaDrivenSubagents":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should use runtime-schema-driven sub-agent guidance'); + assert(/"hasLegacyV1Guidance":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should omit legacy V1 fork/wait guidance'); + assert(/"reasoningEffort":"ultra"/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should pass ultra reasoning_effort'); assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level'); assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); @@ -4775,13 +4869,13 @@ async function main() { assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' })); - const ccwebMcpChildClosed = await nextMessage(messages, ws, (msg) => + const ccwebMcpChildInterrupted = await nextMessage(messages, ws, (msg) => msg.type === 'ccweb_mcp_child_agent_update' && msg.sessionId === codexAppSession.sessionId && msg.child?.threadId === 'child-thread-a' && - msg.child?.status === 'closed' + msg.child?.status === 'interrupted' ); - assert(/"status": "closed"/.test(ccwebMcpChildClosed.tool?.result || ''), 'ccweb MCP child close should update the parent collab tool state'); + assert(/"status": "interrupted"/.test(ccwebMcpChildInterrupted.tool?.result || ''), 'ccweb MCP child interrupt should update the parent collab tool state'); storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const hasCollabTool = storedCodexApp.messages .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) @@ -4791,13 +4885,76 @@ async function main() { .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) .reverse() .find((tool) => tool.id === 'tool-collab'); - assert(/"status": "closed"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child close should persist closed state'); + assert(/"status": "interrupted"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child interrupt should persist interrupted state'); + + ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent v2 prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppV2ChildStarted = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'child-thread-v2' && + msg.child?.status === 'running' + ); + assert(codexAppV2ChildStarted.toolUseId === 'child-activity-v2', 'V2 child update should reference its subAgentActivity item'); + assert(codexAppV2ChildStarted.child.agentPath === '/root/v2_parent', 'V2 child update should preserve its canonical agentPath'); + const codexAppV2ChildPlan = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'child-thread-v2' && + msg.child?.planProgress?.completed === 1 && + msg.child?.planProgress?.total === 3 + ); + assert(codexAppV2ChildPlan.child.planCurrentStep === '同步父卡片进度', 'V2 child update should expose the current in-progress plan step'); + const codexAppV2ChildPlanToolResult = JSON.parse(codexAppV2ChildPlan.tool?.result || '{}'); + assert( + codexAppV2ChildPlanToolResult.agentsStates?.['child-thread-v2']?.planProgress?.completed === 1, + 'V2 child plan progress should merge into the visible parent collaboration tool' + ); + const codexAppV2GrandchildStarted = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'grandchild-thread-v2' && + msg.child?.status === 'running' + ); + assert(codexAppV2GrandchildStarted.child.parentThreadId === 'child-thread-v2', 'Nested V2 child should retain the immediate child as parentThreadId'); + assert(codexAppV2GrandchildStarted.child.agentPath === '/root/v2_parent/v2_grandchild', 'Nested V2 child should preserve its canonical agentPath'); + const codexAppV2GrandchildReturned = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'grandchild-thread-v2' && + msg.child?.status === 'returned' && + /V2 孙代理最终消息/.test(msg.child?.candidateResult || '') + ); + assert(/grandchild-thread-v2/.test(codexAppV2GrandchildReturned.tool?.result || ''), 'Nested V2 child result should merge into a visible collaboration tool'); + const codexAppV2ChildReturned = await nextMessage(messages, ws, (msg) => + msg.type === 'ccweb_mcp_child_agent_update' && + msg.sessionId === codexAppSession.sessionId && + msg.child?.threadId === 'child-thread-v2' && + msg.child?.status === 'returned' && + /V2 子代理最终消息/.test(msg.child?.candidateResult || '') + ); + assert(codexAppV2ChildReturned.child.parentThreadId, 'V2 child should retain the root parent thread id'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + const persistedV2ChildTool = storedCodexApp.messages + .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) + .find((tool) => tool.id === 'child-activity-v2'); + const persistedV2ChildResult = JSON.parse(persistedV2ChildTool?.result || '{}'); + assert( + persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planProgress?.completed === 1 + && persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planProgress?.total === 3, + 'V2 child plan progress should persist in the session collaboration tool for refresh recovery' + ); + assert( + persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planCurrentStep === '同步父卡片进度', + 'V2 child current plan step should persist for refresh recovery' + ); ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' })); const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || '')); assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode'); assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions'); - assert(/"hasWaitAgentRetryGuidance":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep wait_agent retry guidance'); + assert(/"hasSchemaDrivenSubagents":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep runtime-schema-driven guidance'); + assert(/"hasLegacyV1Guidance":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should omit legacy V1 guidance'); assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level'); assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); diff --git a/server.js b/server.js index 28e701e..2621c81 100644 --- a/server.js +++ b/server.js @@ -725,20 +725,16 @@ const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000; // Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。 const FALLBACK_CODEX_MODEL = 'gpt-5.4'; -const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']); +const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'ultra']); const CCWEB_TITLE_TOOL_INSTRUCTIONS = [ 'Use the ccweb title tool sparingly. For a new chat, call "mcp__ccweb__ccweb_set_title" or "ccweb_set_title" once after the user\'s initial request is clear, and set a concise task title.', 'Do not rename the chat for routine progress, substeps, implementation details, or slightly better wording. Rename only when the user\'s primary objective changes substantially and the existing title would be misleading.', ].join('\n'); const CODEX_APP_COLLABORATION_INSTRUCTIONS = [ CCWEB_TITLE_TOOL_INSTRUCTIONS, - 'Codex sub-agent spawning rules:', - '- Treat omitted fork_context the same as fork_context: true: a full-history fork inherits the parent agent type, model, and reasoning effort.', - '- If you call spawn_agent with fork_context omitted or true, do not set agent_type, model, or reasoning_effort.', - '- If you need a specific agent_type, model, or reasoning_effort, set fork_context: false and include only the necessary context in the message.', - '- Do not rely on parent turn reasoning settings for spawned agents; only set reasoning_effort on spawn_agent when the chosen child model supports it.', - '- When calling wait_agent, always pass timeout_ms explicitly. Use 300000ms for normal waits, and use a longer value when the child task is expected to run longer.', - '- If wait_agent returns without a final child-agent status, keep waiting on the same target agents in additional wait_agent rounds until they return, the user interrupts, or the result is no longer needed.', + 'Codex sub-agent runtime rules:', + '- Follow the current runtime tool schema and tool descriptions; do not assume optional fields exist.', + '- Do not hard-code version-specific spawn, wait, or completion behavior in prompts.', ].join('\n'); function getLocalCodexConfigTomlPath() { @@ -7963,14 +7959,14 @@ function closeCcwebMcpChildAgent(sessionId, childThreadId, options = {}) { } const child = ccwebMcpChildThreads.get(normalizedThreadId); if (!child || child.parentSessionId !== normalizedSessionId) { - return { ok: false, code: 'child_agent_not_found', message: '未找到可关闭的 ccweb MCP 子代理。' }; + return { ok: false, code: 'child_agent_not_found', message: '未找到可中断的 ccweb MCP 子代理。' }; } const now = new Date().toISOString(); - child.status = 'closed'; - child.closedAt = now; + child.status = 'interrupted'; + child.interruptedAt = now; child.updatedAt = now; - child.closeReason = options.reason || 'manual'; + child.interruptReason = options.reason || 'manual'; ccwebMcpChildThreads.set(normalizedThreadId, child); if (child.turnId && codexAppClient?.isRunning()) { @@ -8010,7 +8006,7 @@ function handleCcwebMcpChildAgentClose(ws, msg = {}) { tone: 'info', transient: true, autoDismissMs: 4000, - message: `已关闭子代理 ${result.child.label || result.child.threadId}。`, + message: `已中断子代理 ${result.child.label || result.child.threadId}。`, }); } @@ -8562,10 +8558,11 @@ function codexAppCollabToolName(value) { function ccwebMcpChildStatus(value, fallback = 'running') { const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, ''); if (!normalized) return fallback; + if (normalized === 'interrupted') return 'interrupted'; if (/^(closed|close|cleanup|cleaned)$/.test(normalized)) return 'closed'; if (/^(returned|completed|complete|done|success|succeeded|finished)$/.test(normalized)) return 'returned'; if (/^(failed|failure|error|errored)$/.test(normalized)) return 'failed'; - if (/^(cancelled|canceled|aborted|interrupted)$/.test(normalized)) return 'closed'; + if (/^(cancelled|canceled|aborted)$/.test(normalized)) return 'closed'; if (/^(pending|pendinginit|queued|waiting|running|working|active|inprogress|started)$/.test(normalized)) return 'running'; return fallback; } @@ -8633,6 +8630,17 @@ function ccwebMcpRecoveredChildStatus(input = {}, tool = {}) { return 'running'; } +function normalizeCcwebMcpChildPlanProgress(value) { + if (!value || typeof value !== 'object') return null; + const total = Number.parseInt(value.total, 10); + const completed = Number.parseInt(value.completed, 10); + if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(completed)) return null; + return { + completed: Math.max(0, Math.min(completed, total)), + total, + }; +} + function isSubAgentActivityTool(tool = {}, input = {}) { return tool.name === 'subAgentActivity' || tool.kind === 'subAgentActivity' @@ -8650,10 +8658,16 @@ function recoverCcwebMcpChildThreadsFromPersistedToolCalls(sessionId, state = {} for (const tool of toolCalls) { const input = parseMaybeJsonObject(tool?.input) || (tool?.input && typeof tool.input === 'object' ? tool.input : {}); if (!isSubAgentActivityTool(tool, input)) continue; + const result = parseMaybeJsonObject(tool?.result) || (tool?.result && typeof tool.result === 'object' ? tool.result : {}); const threadId = normalizeCodexAppThreadId( input.agentThreadId || input.agent_thread_id || input.threadId || input.thread_id ); if (!threadId) continue; + const resultStates = result.agentsStates || result.agents_states || {}; + const inputStates = input.agentsStates || input.agents_states || {}; + const persistedState = resultStates[threadId] || inputStates[threadId] || {}; + const recoveredState = { ...input, ...persistedState }; + const recoveredPlanProgress = normalizeCcwebMcpChildPlanProgress(recoveredState.planProgress || recoveredState.plan_progress); const existing = ccwebMcpChildThreads.get(threadId); const child = existing || { @@ -8662,12 +8676,17 @@ function recoverCcwebMcpChildThreadsFromPersistedToolCalls(sessionId, state = {} parentSessionId, parentThreadId, spawnToolId: tool.id || '', - label: ccwebMcpRecoveredChildLabel(input, threadId), - role: String(input.role || input.agentRole || input.agent_role || '').trim(), + label: ccwebMcpRecoveredChildLabel(recoveredState, threadId), + role: String(recoveredState.role || recoveredState.agentRole || recoveredState.agent_role || '').trim(), + agentPath: String(recoveredState.agentPath || recoveredState.agent_path || '').trim(), + taskDescription: String(recoveredState.taskDescription || recoveredState.task_description || input.prompt || '').trim(), lastAssistantMessage: '', candidateResult: '', finalMessage: '', status: 'running', + planProgress: recoveredPlanProgress, + planCurrentStep: String(recoveredState.planCurrentStep || recoveredState.plan_current_step || '').trim(), + planUpdatedAt: recoveredState.planUpdatedAt || recoveredState.plan_updated_at || null, summaryAttempts: 0, createdAt: now, updatedAt: now, @@ -8675,9 +8694,18 @@ function recoverCcwebMcpChildThreadsFromPersistedToolCalls(sessionId, state = {} child.parentSessionId = child.parentSessionId || parentSessionId; child.parentThreadId = child.parentThreadId || parentThreadId; child.spawnToolId = child.spawnToolId || tool.id || ''; - child.label = ccwebMcpRecoveredChildLabel(input, child.label || threadId); - child.role = String(input.role || input.agentRole || input.agent_role || child.role || '').trim(); - if (child.status !== 'closed') child.status = ccwebMcpRecoveredChildStatus(input, tool); + child.label = ccwebMcpRecoveredChildLabel(recoveredState, child.label || threadId); + child.role = String(recoveredState.role || recoveredState.agentRole || recoveredState.agent_role || child.role || '').trim(); + child.agentPath = String(recoveredState.agentPath || recoveredState.agent_path || child.agentPath || '').trim(); + child.taskDescription = String(recoveredState.taskDescription || recoveredState.task_description || input.prompt || child.taskDescription || '').trim(); + if (child.status !== 'closed') child.status = ccwebMcpRecoveredChildStatus(recoveredState, tool); + if (recoveredPlanProgress) child.planProgress = recoveredPlanProgress; + if (recoveredState.planCurrentStep !== undefined || recoveredState.plan_current_step !== undefined) { + child.planCurrentStep = String(recoveredState.planCurrentStep || recoveredState.plan_current_step || '').trim(); + } + if (recoveredState.planUpdatedAt || recoveredState.plan_updated_at) { + child.planUpdatedAt = recoveredState.planUpdatedAt || recoveredState.plan_updated_at; + } child.updatedAt = child.updatedAt || now; child.recoveredFromState = true; ccwebMcpChildThreads.set(threadId, child); @@ -8705,7 +8733,12 @@ function ccwebMcpChildPublicState(child = {}) { threadId: child.threadId || '', label: child.label || child.threadId || '子代理', role: child.role || '', + agentPath: child.agentPath || '', + taskDescription: child.taskDescription || '', status: child.status || 'running', + planProgress: normalizeCcwebMcpChildPlanProgress(child.planProgress), + planCurrentStep: child.planCurrentStep || '', + planUpdatedAt: child.planUpdatedAt || null, detail: ccwebMcpChildSummary(child), candidateResult, finalMessage: child.finalMessage || '', @@ -8714,6 +8747,7 @@ function ccwebMcpChildPublicState(child = {}) { createdAt: child.createdAt || null, updatedAt: child.updatedAt || null, returnedAt: child.returnedAt || null, + interruptedAt: child.interruptedAt || null, closedAt: child.closedAt || null, }; } @@ -8792,11 +8826,16 @@ function mergeCcwebMcpChildIntoTool(tool, child) { title: child.title || child.label || previousState.title || '', name: child.label || agentsStates[child.threadId]?.name || child.threadId, role: child.role || agentsStates[child.threadId]?.role || '', + agentPath: child.agentPath || previousState.agentPath || previousState.agent_path || '', status: child.status || 'running', + planProgress: normalizeCcwebMcpChildPlanProgress(child.planProgress), + planCurrentStep: child.planCurrentStep || '', + planUpdatedAt: child.planUpdatedAt || null, taskDescription, summary: ccwebMcpChildSummary(child), candidateResult, finalMessage: child.finalMessage || '', + interruptedAt: child.interruptedAt || null, closedAt: child.closedAt || null, returnedAt: child.returnedAt || null, }; @@ -8869,31 +8908,57 @@ function sendCcwebMcpChildAgentUpdate(sessionId, child) { } function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) { - if (!routed?.sessionId || item?.type !== 'collabAgentToolCall') return; - const toolName = codexAppCollabToolName(item.tool || item.name); - const receiverThreadIds = extractCcwebMcpStringArray(item.receiverThreadIds, item.receiver_thread_ids, item.targets); + const itemType = String(item?.type || '').trim(); + const isSubAgentActivity = itemType === 'subAgentActivity'; + if (!routed?.sessionId || (!isSubAgentActivity && itemType !== 'collabAgentToolCall')) return; + const toolName = isSubAgentActivity ? 'spawn_agent' : codexAppCollabToolName(item.tool || item.name); + const activityThreadId = isSubAgentActivity + ? normalizeCodexAppThreadId(item.agentThreadId || item.agent_thread_id || item.threadId || item.thread_id) + : ''; + const receiverThreadIds = activityThreadId + ? [activityThreadId] + : extractCcwebMcpStringArray(item.receiverThreadIds, item.receiver_thread_ids, item.targets); if (receiverThreadIds.length === 0) return; - const states = item.agentsStates && typeof item.agentsStates === 'object' + const activityAgentPath = String(item.agentPath || item.agent_path || '').trim(); + const activityPrompt = String(item.prompt || item.taskDescription || item.task_description || '').trim(); + const activityState = isSubAgentActivity ? { + label: activityAgentPath ? path.basename(activityAgentPath) : '', + role: String(item.role || item.agentRole || item.agent_role || '').trim(), + status: item.kind || item.status || 'started', + agentPath: activityAgentPath, + taskDescription: activityPrompt, + } : null; + const states = activityState + ? { [activityThreadId]: activityState } + : (item.agentsStates && typeof item.agentsStates === 'object' ? item.agentsStates - : (item.agents_states && typeof item.agents_states === 'object' ? item.agents_states : {}); + : (item.agents_states && typeof item.agents_states === 'object' ? item.agents_states : {})); for (const threadId of receiverThreadIds) { const state = states[threadId] && typeof states[threadId] === 'object' ? states[threadId] : {}; const existing = ccwebMcpChildThreads.get(threadId); const now = new Date().toISOString(); const isSpawn = toolName === 'spawn_agent' || !existing; + const parentThreadId = routed.role === 'child' + ? routed.child?.threadId || '' + : routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || ''; const child = existing || { threadId, turnId: null, parentSessionId: routed.sessionId, - parentThreadId: routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || '', + parentThreadId, spawnToolId: isSpawn ? 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(), + taskDescription: String(state.taskDescription || state.task_description || item.prompt || '').trim(), lastAssistantMessage: '', candidateResult: '', finalMessage: '', status: 'running', + planProgress: null, + planCurrentStep: '', + planUpdatedAt: null, summaryAttempts: 0, createdAt: now, updatedAt: now, @@ -8901,16 +8966,24 @@ function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) { if (!child.spawnToolId && isSpawn) child.spawnToolId = item.id; if (!child.spawnToolId && item.id) child.spawnToolId = item.id; child.parentSessionId = child.parentSessionId || routed.sessionId; - child.parentThreadId = child.parentThreadId || routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || ''; + child.parentThreadId = child.parentThreadId || parentThreadId; child.label = ccwebMcpChildLabel(state, child.label || threadId); child.role = String(state.role || state.agent || state.agentType || state.agent_type || child.role || '').trim(); + child.agentPath = String(state.agentPath || state.agent_path || child.agentPath || '').trim(); + child.taskDescription = String(state.taskDescription || state.task_description || item.prompt || child.taskDescription || '').trim(); + const planProgress = normalizeCcwebMcpChildPlanProgress(state.planProgress || state.plan_progress); + if (planProgress) child.planProgress = planProgress; + if (state.planCurrentStep !== undefined || state.plan_current_step !== undefined) { + child.planCurrentStep = String(state.planCurrentStep || state.plan_current_step || '').trim(); + } + if (state.planUpdatedAt || state.plan_updated_at) child.planUpdatedAt = state.planUpdatedAt || state.plan_updated_at; if (child.status !== 'closed') { const candidate = extractCcwebMcpChildCandidate(state); if (candidate) { child.candidateResult = truncateTextValue(candidate, SESSION_MESSAGE_CONTENT_MAX_CHARS); child.lastAssistantMessage = child.candidateResult; } - const rawStatus = state.status || state.state || item.status; + const rawStatus = state.status || state.state || item.kind || item.status; const fallback = child.status || 'running'; const nextStatus = ccwebMcpChildStatus(rawStatus, fallback); child.status = nextStatus === 'returned' && !child.candidateResult && !candidate ? fallback : nextStatus; @@ -8933,6 +9006,15 @@ function processCcwebMcpChildNotification(child, notification) { return { changed: false, done: false }; } + const planUpdate = codexAppRuntime.planUpdateFromNotification(notification); + if (planUpdate) { + child.planProgress = normalizeCcwebMcpChildPlanProgress(planUpdate.progress); + child.planCurrentStep = planUpdate.currentStep || ''; + child.planUpdatedAt = now; + child.updatedAt = now; + return { changed: true, done: false }; + } + if (method === 'turn/started') { child.status = 'running'; child.updatedAt = now; @@ -9027,6 +9109,12 @@ function handleCodexAppNotification(notification) { return; } + const item = notification?.params?.item || null; + const isChildActivityItem = item?.type === 'collabAgentToolCall' || item?.type === 'subAgentActivity'; + if (routed.role === 'child' && isChildActivityItem) { + syncCcwebMcpChildAgentsFromCollabItem(routed, item); + } + if (routed.role === 'child') { const result = processCcwebMcpChildNotification(routed.child, notification); if (result.changed) sendCcwebMcpChildAgentUpdate(routed.sessionId, routed.child); @@ -9034,8 +9122,7 @@ function handleCodexAppNotification(notification) { } const result = codexAppRuntime.processCodexAppNotification(routed.entry, notification, routed.sessionId); - const item = notification?.params?.item || null; - if (item?.type === 'collabAgentToolCall') { + if (item?.type === 'collabAgentToolCall' || item?.type === 'subAgentActivity') { syncCcwebMcpChildAgentsFromCollabItem(routed, item); } persistCodexAppTurnState(routed.sessionId, routed.entry, { immediate: !!result?.done }); @@ -9677,7 +9764,7 @@ function handleCodexAppServerExit(signature, info = {}) { function codexAppModelSettings(session) { const raw = String(session?.model || getDefaultCodexModel() || '').trim(); - const match = raw.match(/^(.*)\((low|medium|high|xhigh)\)\s*$/i); + const match = raw.match(/^(.*)\((low|medium|high|xhigh|ultra)\)\s*$/i); if (!match) return { model: raw || null, effort: null }; return { model: String(match[1] || '').trim() || null,