chore: rebuild release package
This commit is contained in:
@@ -1 +1 @@
|
||||
codex-app-worker-timeout
|
||||
subagent-card-metadata
|
||||
|
||||
42
.planning/composer-slash-trigger/findings.md
Normal file
42
.planning/composer-slash-trigger/findings.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 调研记录
|
||||
|
||||
## 用户现象
|
||||
|
||||
- 输入形如 `/report/mcps?search` 的文件或路径文本时,界面显示“未知指令”,并阻止消息发送。
|
||||
- `/` 快捷选择目前只在输入框第一个字符生效,而 `@`、`$` 可在输入中间生效。
|
||||
|
||||
## 预期
|
||||
|
||||
- 未知 `/` 文本可以继续提示,但发送动作应继续完成。
|
||||
- `/` 在与 `@`、`$` 相同的合理 token 边界上可触发快捷选择。
|
||||
|
||||
## 仓库工作流
|
||||
|
||||
- Trellis 要求先建立并启动独立任务,再进入实现和质量检查阶段。
|
||||
- 当前前端组件与质量规范仍是占位文档,没有额外项目级实现约束;本次以现有代码风格和回归测试为准。
|
||||
- 前端交互应保持克制:沿用现有提示样式,只修正触发与发送语义,不增加新的界面层级。
|
||||
- Trellis 已为本次修复创建独立任务 `07-16-composer-slash-trigger`,不会混入原有 bootstrap 或子代理卡片任务。
|
||||
- `codebase-memory-mcp` 项目 `home-cc-web` 索引状态为 `ready`,包含 3191 个节点、7729 条边,可直接用于实现定位。
|
||||
|
||||
## 初步代码定位
|
||||
|
||||
- 服务端 `handleSlashCommand` 当前对所有以 `/` 开头的输入进入 `switch`;默认分支只发送“未知指令”系统消息并要求前端恢复草稿,因此原文本不会进入正常消息处理链路。
|
||||
- 前端 `sendMessage` 存在显式 `text.startsWith('/')` 分支,并为失败响应保存/恢复 slash 草稿;现有回归测试把未知 slash 视为“失败且恢复草稿”,正是用户所述的发送拦截行为。
|
||||
- 需要把“是否为内置 slash 命令”的判断与普通 `/路径` 文本分开:内置命令仍走命令处理,未知 slash 可提示后继续正常消息链路。
|
||||
- 快捷触发的具体解析函数尚待继续定位,需重点查看 `composer_suggestions` 请求生成以及光标前 token 匹配逻辑。
|
||||
|
||||
## 快捷触发根因
|
||||
|
||||
- `findActiveComposerToken` 对 `/` 使用独立特判:要求整段输入 `value.startsWith('/')` 且没有换行,因此只允许首字符触发。
|
||||
- 同一函数对 `@`、`$` 使用 `/(^|\s)([@$])([^\s]*)$/`,允许行首或空白后触发;这解释了三种触发符行为不一致。
|
||||
- 最小一致性修复应统一 token 边界解析:`/`、`@`、`$` 都允许行首或空白后触发,token 到光标前不能含空白。这样普通路径中的内部 `/`(如 `src/foo`)不会误开菜单,而“请看 /report”能触发。
|
||||
- `sendMessage` 仍用 `text.startsWith('/')` 判定命令,需新增明确的内置命令识别;未知 `/路径` 应走普通 `submitUserMessage`,服务端再提供提示或明确的非阻断反馈。
|
||||
|
||||
## 最终实现与验证
|
||||
|
||||
- 前端 `isKnownSlashCommandText` 按第一 token、忽略大小写精确匹配 8 个内置命令;未知 `/路径` 进入普通消息/附件流程。
|
||||
- `findActiveComposerToken` 统一使用行首或空白后的 `[/@$]` token 规则,并通过光标、LF、CRLF、路径内部斜杠用例。
|
||||
- 服务端 `handleSlashCommand` 返回是否已处理;未知命令发送无 `preserveComposerDraft` 的提示后,以 `unknownSlash` 标志进入普通 `handleMessage`,旧客户端与 Codex App 运行中 steer 也能正确放行。
|
||||
- 独立质量审查自动比较前后端完整命令集合,并确保每个服务端命令都有显式 `case`。
|
||||
- 主线程复验:定向回归、完整回归、三份 JS 语法检查及 `git diff --check` 全部通过;完整回归约 30 秒。
|
||||
- 并发中的“统一子代理卡片样式”任务也修改了 `public/app.js`、`scripts/regression.js`,最终总 diff 含其内容;本任务未回退或改写该部分,只按精确函数/断言范围核验 slash 改动。
|
||||
20
.planning/composer-slash-trigger/progress.md
Normal file
20
.planning/composer-slash-trigger/progress.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 进度日志
|
||||
|
||||
- 2026-07-16:读取仓库指令及 planning-with-files、todo-list-csv、frontend-skill。
|
||||
- 2026-07-16:确认工作区已有 `.planning/subagent-card-metadata/` 的无关改动,将保持不触碰。
|
||||
- 2026-07-16:建立本任务独立计划与调研记录。
|
||||
- 2026-07-16:独立计划审查已通过。
|
||||
- 2026-07-16:读取 Trellis 工作流与前端规范,确认需要建立 Trellis 任务并按阶段推进。
|
||||
- 2026-07-16:创建并启动 Trellis 任务 `07-16-composer-slash-trigger`。
|
||||
- 2026-07-16:确认 `home-cc-web` 代码索引就绪,无需重新全量索引。
|
||||
- 2026-07-16:定位到服务端 `handleSlashCommand`、前端 `sendMessage` 与 slash 草稿恢复回归契约,确认发送拦截来自“所有 `/` 前缀都按命令处理”。
|
||||
- 2026-07-16:定位 `findActiveComposerToken`,确认 `/` 被首字符特判,而 `@`、`$` 支持行首/空白 token 边界。
|
||||
- 2026-07-16:补齐 Trellis PRD、调研文档以及 implement/check 上下文,明确未知 slash“提示并继续普通发送”的验收标准。
|
||||
- 2026-07-16:Trellis 任务上下文校验通过;定位阶段完成,进入失败回归测试阶段。
|
||||
- 2026-07-16:未知 slash 放行失败回归已先红后绿,覆盖系统提示、普通消息持久化和附件保留。
|
||||
- 2026-07-16:快捷触发回归覆盖 `/` 的行首、空白后、换行后触发,以及 `src/foo` 不误触发,并保持 `@`、`$` 行为。
|
||||
- 2026-07-16:完成三处最小实现:前端已知命令判定、统一 token 解析、服务端未知 slash 提示后回落正常消息链路。
|
||||
- 2026-07-16:Trellis 独立质量审查通过,并补强命令集合一致性、CRLF/光标、Codex App 运行中 steer 回归。
|
||||
- 2026-07-16:主线程复验定向回归、完整回归、语法检查和 diff 检查均通过。
|
||||
- 2026-07-16:完成最终差异核对;未调用服务重启,未触碰并发会话的子代理卡片改动。
|
||||
- 2026-07-16:Trellis 当前任务指针被并发会话切回其他任务,为避免误结束其任务,未运行无参数的 `task.py finish`。
|
||||
31
.planning/composer-slash-trigger/task_plan.md
Normal file
31
.planning/composer-slash-trigger/task_plan.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 输入框快捷指令修复计划
|
||||
|
||||
## 目标
|
||||
|
||||
修复输入框中未知 `/` 文本阻止发送的问题,并让 `/` 与 `@`、`$` 一样能在输入中的合理位置触发快捷选择,同时补齐回归测试。
|
||||
|
||||
## 阶段
|
||||
|
||||
1. [complete] 初始化 Trellis 与代码索引并定位输入解析、快捷选择和发送拦截实现
|
||||
2. [complete] 补充未知斜杠文本仍可发送的失败回归测试
|
||||
3. [complete] 补充斜杠可在非首字符位置触发的失败回归测试
|
||||
4. [complete] 修复发送拦截和斜杠触发边界实现
|
||||
5. [complete] 运行相关测试、类型检查和静态检查
|
||||
6. [complete] 审查差异、记录结果并清理临时清单
|
||||
|
||||
## 约束
|
||||
|
||||
- 保留“未知指令”提示,但提示不能阻止普通文本发送。
|
||||
- `/` 的触发位置语义与现有 `@`、`$` 保持一致,并避免路径内容误触发。
|
||||
- 不扩大到 composer 快捷指令体系之外的 UI 改版。
|
||||
- 保护工作区内与本任务无关的既有改动。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---:|---|
|
||||
| Trellis 新任务未自动生成 `prd.md` | 1 | 按工作流手动创建 PRD,并补齐 research/context 文件 |
|
||||
| `rg` 传入不存在的 `test`、`tests` 路径导致非零退出 | 1 | 已确认本项目回归入口是 `scripts/regression.js`,后续收敛到存在路径 |
|
||||
| 独立审查首次完整回归命中并发中的 `subAgentActivity` 断言失败 | 1 | 未越界修改;等待对应会话收敛后复跑通过,主线程也再次完整复验通过 |
|
||||
| 更新调研文档时使用了不存在的 `## 风险` 锚点 | 1 | 读取文件尾部后改用实际存在的末段锚点追加 |
|
||||
| 收尾时 Trellis 当前任务已被并发会话切回 `07-11-subagent-card-metadata` | 1 | 为避免误结束其他任务,未执行只作用于当前指针的 `task.py finish`;本任务计划与验证已独立闭环 |
|
||||
@@ -21,3 +21,24 @@
|
||||
- 独立复核确认实时缓存修复后,`renderMessages()` 会清空缓存;若历史只有空 close/wait,刷新仍退化为工具 ID。
|
||||
- 服务端 `updatePersistedCcwebMcpChildTool` 只按 `child.spawnToolId` 查找工具;真实会话缺少该 spawn 工具时返回 null,未把 child 状态写入历史。
|
||||
- 后端兜底应只选择最近的 `collab_agent_tool_call`,不得把 child 合并进普通工具。
|
||||
- 2026-07-15 本机 PM2 `ccweb` 已实际重启并在线;其他机器仍显示旧样式属于发布物/部署路径问题,不是本机进程未重启。
|
||||
- CentOS 7 单文件发布必须重新运行 `scripts/build-single-exe.js`;该流程会在打包前复制 `public/` 运行时资源,源码更新不会自动进入旧 tar.gz。
|
||||
- 本机 `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz` 已包含最新静态资源:tar 内 `app.js` / `style.css` 与源码 SHA-256 完全一致,并含 `collab-agent-item-description`、两行 clamp 和关闭状态缓存代码。
|
||||
- 单文件运行时从二进制同目录的 `public/` 加载资源;只替换 `cc-web` 二进制、保留旧 `public/`,或设置 `CC_WEB_APP_DIR/CC_WEB_PUBLIC_DIR` 指向旧目录,都会继续显示旧样式。
|
||||
- 旧历史会话本身若只保存 thread ID、没有 title/taskDescription,即使加载新前端也只能显示 `ID ...` 且没有简介;需要用新部署后新建的子代理验证,不能只看旧历史卡片。
|
||||
- 2026-07-16 确认同一版本存在两套事件链路:完整 `spawnAgent` 会以 `collab_agent_tool_call` 进入 `mergeCollabAgentTools()`;原生 `subAgentActivity` 仅在服务端用于线程恢复,前端不会把它合并成富卡片。
|
||||
- `mergeCollabAgentTools()` 明确过滤 `toolKind(tool) === 'collab_agent_tool_call'`;因此 `subAgentActivity + 空 wait` 会分别退化为原始折叠工具条和 `ID call_...` 卡片。
|
||||
- 修复应在进入渲染前将 `agentThreadId`、`agentPath`、活动 `kind` 归一为结构化子代理状态,并避免原始活动事件重复渲染。
|
||||
- 真实会话中的 `subAgentActivity` 输入与结果都只包含 `kind/agentThreadId/agentPath`,未携带 spawn prompt;因此历史数据能恢复线程 ID、状态与角色标题,但不能凭空恢复完整任务简介。
|
||||
- 同一消息里随后出现的 `wait` 已被转换为 `collab_agent_tool_call`,但 `receiverThreadIds` 和 `agentsStates` 均为空,说明应将前置活动事件作为该 wait 的恢复来源。
|
||||
- 扫描现有会话得到 41 条 `subAgentActivity`:活动类型只有 `started` 与 `interacted`,输入字段稳定为 `type/id/kind/agentThreadId/agentPath`,均无 prompt;`interacted` 应保持运行态。
|
||||
- Phase 8 复审已通过;计划已明确字段映射、原始工具条与 `ID call_...` 去重、baseline 二进制 smoke test 和 tarball 核验。
|
||||
- 前端所有历史、恢复流和实时流都通过 `toolKind()` 分流;让它把 raw `subAgentActivity` 识别为协作显示工具,可同时复用现有合并、富卡片渲染和普通工具过滤逻辑,避免在三个调用点分别硬编码。
|
||||
- `normalizeCollabAgentData()` 是补齐活动字段的最小归一入口:从 `agentThreadId` 构建 receiver/state,从 `agentPath` basename 构建标题/角色,从活动 kind 映射状态,并按真实字段保留 prompt。
|
||||
- 新事件还应在 `lib/codex-app-runtime.js` 的 `itemKind/itemInput/itemResult` 层输出 `collab_agent_tool_call` 结构;这样旧前端也能把新会话视为富卡片,同时保留 `input.type=subAgentActivity` 供服务端恢复线程路由。
|
||||
- 首轮生产实现已让定向行为回归通过;人工审查仍需确认三项最小范围边界:空 wait 对整体头部状态的覆盖、agentPath 同时填充 role 导致标题/页脚重复、通用 tool_end 全量增加 name/input 的必要性。
|
||||
- 独立质量检查确认首轮实现仍有高风险缺口:完成态空 wait 会覆盖整体头部为已返回但 child 仍运行;runtime completed 不带 prompt 时会直接替换 started input,刷新后简介丢失。
|
||||
- 另有两个中风险边界:agentPath basename 回填 role 会导致标题/footer 重复;仅凭 name 匹配 `subAgentActivity` 会把无 agentThreadId 的同名普通工具误归类。
|
||||
- 第二轮已改为子状态优先聚合整体状态、runtime 活动输入结构化合并、agentPath 仅提供标题、activity 匹配必须存在 threadId;新增边界回归均通过,等待原检查代理复核。
|
||||
- 原检查代理复核时把 `item/completed` 误等同于 child completed;真实会话反证是 `input.kind=started` 的活动工具本身 `done=true`,因此 transport item 生命周期不能覆盖 activity kind 的 child 生命周期语义。
|
||||
- 正确契约:completed 通知缺少新 kind 时继承 previous activity kind/status;只有活动 kind 明确为 completed/returned/failed/closed 时才改变 child 终态,同时仍保留 started prompt。
|
||||
|
||||
@@ -22,3 +22,16 @@
|
||||
- 2026-07-15:空 close/wait 卡片重建实现与完整回归通过,进入独立复核。
|
||||
- 2026-07-15:独立复核发现刷新后历史仍丢失;追加服务端持久化兜底修复。
|
||||
- 2026-07-15:服务端在 spawnToolId 缺失时回退最近协作工具,刷新闭环回归及最终独立复核通过。
|
||||
- 2026-07-15:本机 ccweb 已重启在线;开始诊断其他机器发布后仍使用旧样式的问题,优先核对 dist-exe 产物与源码资源时间。
|
||||
- 2026-07-15:确认本机 tar 包静态资源哈希与源码一致;远端老样式应检查是否整体替换 public、运行目录/环境变量,以及是否仅查看旧历史数据。
|
||||
- 2026-07-15:本机 HTTP 返回 app.js/style.css 哈希与源码及 tar 完全一致,Cache-Control 为 no-store;Phase 7 完成。
|
||||
- 2026-07-16:用户确认同一实例不同对话仍有两种样式;恢复 Phase 8,定位 `subAgentActivity` 未进入富卡片合并链路。
|
||||
- 2026-07-16:Phase 8 首次计划审查要求补齐字段映射、重复渲染断言和最终发布包 smoke test,已收紧计划并进入复审。
|
||||
- 2026-07-16:Phase 8 复审通过;完成 41 条真实活动事件字段/状态盘点,进入失败回归编写。
|
||||
- 2026-07-16:新增 raw `subAgentActivity + 空 wait` 行为回归;当前实现按预期失败于 `call_wait_empty` 被误作子代理 ID,进入字段归一实现。
|
||||
- 2026-07-16:完成前端旧历史归一和 runtime 新事件结构化,定向回归通过;进入 Trellis 独立质量检查。
|
||||
- 2026-07-16:Trellis 独立检查未放行;追加空 wait 整体状态、completed prompt 保留、角色去重和同名普通工具隔离修正循环。
|
||||
- 2026-07-16:第二轮测试先行修正完成并通过定向回归;提交原 Trellis 检查代理复核。
|
||||
- 2026-07-16:对复核提出的“item/completed 强制 child completed”意见基于真实 done=true started 活动提出异议;补充协议语义回归后再复核。
|
||||
- 2026-07-16:原 Trellis 检查代理撤回错误意见并最终通过;字段归一与统一渲染完成,进入全量回归。
|
||||
- 2026-07-16:完整 `npm run regression`、服务端/mock 语法和全工作树 diff 检查通过;在 /tmp 安装 baseline Bun,进入发布构建。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
Phase 7
|
||||
Phase 8
|
||||
|
||||
## 阶段
|
||||
|
||||
@@ -42,15 +42,25 @@ Phase 7
|
||||
|
||||
### Phase 7: 修复关闭后子代理卡片消失
|
||||
|
||||
- [ ] 覆盖关闭更新缺失结构化状态的回归场景
|
||||
- [ ] 缓存子代理最后一次结构化状态
|
||||
- [ ] 用已关闭 ID 和状态缓存重建关闭卡片
|
||||
- [ ] 保留已关闭子代理的标题、简介与线程 ID
|
||||
- [ ] 验证关闭状态、数量和卡片持续可见
|
||||
- [ ] 验证普通工具调用不进入子代理合并路径
|
||||
- [ ] 找不到原 spawn 工具时,将 child 状态合并到最近的协作工具历史记录
|
||||
- [ ] 刷新/历史重渲染后仍保留标题、简介、线程 ID 和关闭状态
|
||||
- [ ] 实施前后核对工作树,只改子代理相关代码与测试
|
||||
- [x] 覆盖关闭更新缺失结构化状态的回归场景
|
||||
- [x] 缓存子代理最后一次结构化状态
|
||||
- [x] 用已关闭 ID 和状态缓存重建关闭卡片
|
||||
- [x] 保留已关闭子代理的标题、简介与线程 ID
|
||||
- [x] 验证关闭状态、数量和卡片持续可见
|
||||
- [x] 验证普通工具调用不进入子代理合并路径
|
||||
- [x] 找不到原 spawn 工具时,将 child 状态合并到最近的协作工具历史记录
|
||||
- [x] 刷新/历史重渲染后仍保留标题、简介、线程 ID 和关闭状态
|
||||
- [x] 实施前后核对工作树,只改子代理相关代码与测试
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 8: 统一 subAgentActivity 与原生协作工具卡片
|
||||
|
||||
- [x] 复现并定位两套子代理事件链路
|
||||
- [x] 为活动归一空 wait 复用及去重写失败回归
|
||||
- [x] 实现 agentThreadId agentPath 状态及 prompt 字段归一
|
||||
- [x] 合并活动与协作工具并隐藏原始工具条及 call ID
|
||||
- [x] 运行语法检查和完整自动化回归
|
||||
- [ ] 重建 CentOS 7 发布包并完成二进制与 tar 核验
|
||||
- **Status:** in_progress
|
||||
|
||||
## 完成标准
|
||||
@@ -71,6 +81,12 @@ Phase 7
|
||||
3. 多代理不同 prompt 的标题和简介不串联。
|
||||
4. 无 prompt 时回退短线程 ID。
|
||||
5. 关闭更新即使不携带 `receiverThreadIds/agentsStates`,也保留原子代理卡片并显示“已关闭”。
|
||||
6. 仅有 `subAgentActivity` 与空 `wait` 的历史会话也显示统一富卡片,不再显示原始活动工具条或 `ID call_...`。
|
||||
7. `agentThreadId` 写入 `receiverThreadIds` 并作为 `agentsStates` 键;`agentPath` 的 basename 写入可读标题/角色。
|
||||
8. `started/interacted` 映射为运行态,`completed/returned` 映射为完成态,失败/关闭类活动映射到对应终态。
|
||||
9. 活动或同线程事件能提供 prompt 时写入 `taskDescription`;完全没有 prompt 时不伪造简介。
|
||||
10. 回归分别覆盖完整 `spawnAgent`、仅 `subAgentActivity + 空 wait`、刷新历史、原始工具条去重、无 `ID call_...` 与 prompt 保留。
|
||||
11. 发布包保持 `bun-linux-x64-baseline` 目标,完成 MCP JSON-RPC 初始化 smoke test,并核对 tarball 内运行文件与静态资源。
|
||||
|
||||
## 范围约束
|
||||
|
||||
@@ -95,3 +111,8 @@ Phase 7
|
||||
| 空 wait/close 工具缺少可渲染的子代理状态 | 1 | 缓存 `msg.child` 状态,并用已关闭 ID 重建关闭卡片 |
|
||||
| 测试先行缺少 `rememberCollabAgentState` | 1 | 实现按 threadId 缓存 helper 后回归通过 |
|
||||
| 实时缓存无法覆盖刷新后的历史重渲染 | 1 | 原 spawn 工具缺失时把 child 合并进最近协作工具并持久化 |
|
||||
| 收尾时临时 TODO CSV 已被外部流程移除 | 1 | 确认根目录无 TODO CSV 后不重建,直接闭合持久化计划 |
|
||||
| `task.py list-context` 误传 action 参数 | 1 | 查看 `--help`,确认该命令只接收任务目录,后续按正确形状调用 |
|
||||
| Phase 8 首次计划审查发现字段、去重和发布核验契约不够具体 | 1 | 补充字段级映射、逐项行为断言与 CentOS 7 二进制/tar 验证标准后复审 |
|
||||
| 首轮实现通过定向回归但独立检查发现空 wait 状态冲突与 completed 丢 prompt | 1 | 补充四类边界行为断言,改为子状态聚合整体状态并结构化合并 runtime 活动输入 |
|
||||
| `timeout` 后直接放环境变量导致命令被当成可执行文件 | 1 | 改用 npm `--registry` 单次参数,代理查询与临时 Bun 安装成功 |
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
7. 关闭更新即使只提供 `child.threadId/status`,且工具载荷缺少 `receiverThreadIds/agentsStates`,也必须保留此前子代理卡片,将状态显示为“已关闭”。
|
||||
8. 空的 `wait/close` 协作工具不得把已有子代理数量重置为 0;普通工具调用不得进入子代理状态合并路径。
|
||||
9. 当历史中找不到原 `spawnToolId` 时,服务端应将 child 状态合并到最近的协作工具记录,保证刷新后仍可恢复关闭卡片;不得选择普通工具。
|
||||
10. 原生 `subAgentActivity` 必须归一化进同一子代理卡片:`agentThreadId` 作为线程 ID,`agentPath` 作为角色/标题来源,活动 `kind` 映射为状态;随后空 `wait` 复用这些状态,原始活动工具条不重复显示。
|
||||
11. 若 `subAgentActivity` 没有携带原始 prompt,应保守显示可读角色标题,不伪造任务简介;若同线程的其他事件提供 prompt,则继续按既有优先级保留为简介。
|
||||
|
||||
## 视觉要求
|
||||
|
||||
@@ -36,6 +38,7 @@
|
||||
- 无 prompt 时仍能回退到短线程 ID,不报错。
|
||||
- 关闭空载荷场景保留原标题、简介、线程 ID,并显示关闭状态与非零数量。
|
||||
- 页面刷新和历史重渲染后仍满足上述关闭卡片要求。
|
||||
- 仅包含 `subAgentActivity + 空 wait` 的历史消息显示非零子代理富卡片,标题来自 `agentPath`,且不再同时显示 `subAgentActivity` 原始工具条或 `ID call_...`。
|
||||
- JavaScript 语法检查及项目回归测试通过。
|
||||
|
||||
## 范围
|
||||
|
||||
2
.trellis/tasks/07-16-composer-slash-trigger/check.jsonl
Normal file
2
.trellis/tasks/07-16-composer-slash-trigger/check.jsonl
Normal file
@@ -0,0 +1,2 @@
|
||||
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"按项目质量检查入口审查前端行为与回归覆盖"}
|
||||
{"file":".trellis/tasks/07-16-composer-slash-trigger/research/composer-slash-analysis.md","reason":"核对根因、验收标准和已知风险是否全部覆盖"}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"file":".trellis/spec/frontend/index.md","reason":"遵守项目前端开发入口与质量检查约定"}
|
||||
{"file":".trellis/tasks/07-16-composer-slash-trigger/research/composer-slash-analysis.md","reason":"提供已定位的前后端根因、推荐实现与风险边界"}
|
||||
29
.trellis/tasks/07-16-composer-slash-trigger/prd.md
Normal file
29
.trellis/tasks/07-16-composer-slash-trigger/prd.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 修复输入框快捷指令触发与发送
|
||||
|
||||
## 背景
|
||||
|
||||
输入框目前把所有以 `/` 开头的内容都当成内置命令。用户输入文件路径或其他未知 slash 文本时,系统虽显示“未知指令”,但原消息不会发送。同时,快捷选择中的 `/` 只在整段输入第一个字符生效,与 `@`、`$` 的 token 触发规则不一致。
|
||||
|
||||
## 需求
|
||||
|
||||
1. 已知内置 slash 命令继续走现有命令处理链路,不显示普通用户气泡。
|
||||
2. 未知 `/` 文本仍显示“未知指令”提示,但必须继续按普通消息发送给当前代理,并正常显示用户消息。
|
||||
3. 未知 `/` 文本按普通消息处理附件,不应被“命令消息不支持附件”拦截。
|
||||
4. `/` 快捷选择允许在行首或空白后触发,与 `@`、`$` 保持一致。
|
||||
5. token 内部的 `/`(如 `src/foo`)不应触发快捷选择;换行后的行首或空白后 `/` 应可触发。
|
||||
6. 保持现有 MCP、skill、prompt 建议来源与插入语义不变。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 输入 `/report/mcps?search` 时会看到未知指令提示,同时该文本作为普通消息发送。
|
||||
- 输入 `请查看 /rep` 且光标位于末尾时,显示 `/` 建议菜单。
|
||||
- 输入 `src/foo` 时不显示 `/` 建议菜单。
|
||||
- `@`、`$` 的触发行为保持不变。
|
||||
- `/help`、`/model`、`/mode` 等内置命令行为保持不变。
|
||||
- 回归测试覆盖未知 slash 放行、触发边界和已知命令保留,并全部通过。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不新增手动 MCP 参数表单。
|
||||
- 不重做输入框视觉样式。
|
||||
- 不调整 slash 命令集合或服务器能力注入方式。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Composer slash 行为调研
|
||||
|
||||
## 根因
|
||||
|
||||
- `public/app.js` 的 `findActiveComposerToken` 对 `/` 使用 `value.startsWith('/')` 特判,只允许整段首字符触发;`@`、`$` 则允许行首或空白后触发。
|
||||
- `public/app.js` 的 `sendMessage` 对任意 `text.startsWith('/')` 都进入命令发送分支,未知路径也不会走 `submitUserMessage`。
|
||||
- `server.js` 的 WebSocket 消息分发对任意 slash 前缀调用 `handleSlashCommand`,其默认分支只发未知指令系统消息,随后不调用 `handleMessage`。
|
||||
- `scripts/regression.js` 现有契约断言未知 slash 必须恢复草稿,与新需求相反,需要更新为“提示但继续普通发送”。
|
||||
|
||||
## 推荐实现
|
||||
|
||||
- 前端新增基于 `SLASH_COMMANDS` 第一 token 精确匹配的已知命令判断,仅已知命令进入现有 slash 分支。
|
||||
- 服务端让 `handleSlashCommand` 返回是否已处理;未知分支先发送提示并返回 `false`,WebSocket 分发随后调用正常 `handleMessage`。
|
||||
- `findActiveComposerToken` 统一解析 `(^|\s)([/@$])([^\s]*)$` 的当前行 token 边界,保留既有插入逻辑。
|
||||
- 回归测试同时覆盖源码契约和 WebSocket 真实行为。
|
||||
|
||||
## 风险
|
||||
|
||||
- 必须确保未知 slash 的系统提示不带 `preserveComposerDraft`,否则会在普通发送后错误恢复已发送内容。
|
||||
- 必须确保已知命令仍携带 requestId,失败时仍可恢复草稿。
|
||||
- 服务端必须独立判断,不能只信任前端,避免旧客户端仍拦截未知 slash。
|
||||
26
.trellis/tasks/07-16-composer-slash-trigger/task.json
Normal file
26
.trellis/tasks/07-16-composer-slash-trigger/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "composer-slash-trigger",
|
||||
"name": "composer-slash-trigger",
|
||||
"title": "修复输入框快捷指令触发与发送",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shiyue",
|
||||
"assignee": "shiyue",
|
||||
"createdAt": "2026-07-16",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
Binary file not shown.
@@ -263,6 +263,107 @@ function createCodexAppRuntime(deps = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function cleanRuntimeText(value) {
|
||||
return value == null ? '' : String(value).trim();
|
||||
}
|
||||
|
||||
function runtimeBasename(value) {
|
||||
const text = cleanRuntimeText(value).replace(/\\/g, '/');
|
||||
if (!text) return '';
|
||||
return text.split('/').filter(Boolean).pop() || text;
|
||||
}
|
||||
|
||||
function normalizeSubAgentActivityStatus(value, done = false) {
|
||||
const normalized = cleanRuntimeText(value).toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (/^(started|interacted|interaction|message|delta|progress|updated|update)$/.test(normalized)) return 'running';
|
||||
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 (/^(failed|fail|error|errored|cancelled|canceled|aborted|rejected)$/.test(normalized)) return 'failed';
|
||||
return done ? 'completed' : 'running';
|
||||
}
|
||||
|
||||
function firstReceiverThreadId(input = {}) {
|
||||
return Array.isArray(input.receiverThreadIds) && input.receiverThreadIds.length > 0
|
||||
? cleanRuntimeText(input.receiverThreadIds[0])
|
||||
: '';
|
||||
}
|
||||
|
||||
function normalizeSubAgentActivityItem(item = {}, done = false, previousInput = null) {
|
||||
const source = item && typeof item === 'object' ? item : {};
|
||||
const previous = previousInput && typeof previousInput === 'object' ? previousInput : {};
|
||||
const previousThreadId = firstReceiverThreadId(previous);
|
||||
const previousState = previousThreadId && previous.agentsStates && typeof previous.agentsStates === 'object'
|
||||
? previous.agentsStates[previousThreadId] || {}
|
||||
: {};
|
||||
const activityKind = cleanRuntimeText(
|
||||
source.kind
|
||||
|| source.activityKind
|
||||
|| source.activity_kind
|
||||
|| source.status
|
||||
|| (done ? 'completed' : '')
|
||||
|| previous.kind
|
||||
|| previousState.kind
|
||||
|| ''
|
||||
);
|
||||
const agentThreadId = cleanRuntimeText(
|
||||
source.agentThreadId
|
||||
|| source.agent_thread_id
|
||||
|| source.threadId
|
||||
|| source.thread_id
|
||||
|| source.childThreadId
|
||||
|| source.child_thread_id
|
||||
|| previous.agentThreadId
|
||||
|| previousThreadId
|
||||
|| previousState.agentThreadId
|
||||
|| ''
|
||||
);
|
||||
const agentPath = cleanRuntimeText(
|
||||
source.agentPath
|
||||
|| source.agent_path
|
||||
|| previous.agentPath
|
||||
|| previousState.agentPath
|
||||
|| source.agent
|
||||
|| source.name
|
||||
|| ''
|
||||
);
|
||||
const title = runtimeBasename(agentPath);
|
||||
const prompt = cleanRuntimeText(
|
||||
source.prompt
|
||||
|| source.taskDescription
|
||||
|| source.task_description
|
||||
|| previous.prompt
|
||||
|| previousState.taskDescription
|
||||
|| ''
|
||||
);
|
||||
const status = normalizeSubAgentActivityStatus(activityKind, done);
|
||||
const explicitRole = cleanRuntimeText(source.role || source.agentRole || source.agent_role);
|
||||
const state = {
|
||||
type: 'subAgentActivity',
|
||||
kind: activityKind,
|
||||
agentThreadId,
|
||||
agentPath,
|
||||
label: title || cleanRuntimeText(source.label || source.title || source.name || previousState.label || previousState.title || previousState.name || ''),
|
||||
title: title || cleanRuntimeText(source.title || source.label || source.name || previousState.title || previousState.label || previousState.name || ''),
|
||||
name: title || cleanRuntimeText(source.name || source.label || source.title || previousState.name || previousState.label || previousState.title || ''),
|
||||
role: explicitRole || cleanRuntimeText(previousState.role || ''),
|
||||
status,
|
||||
hasReadableSourceTitle: !!title,
|
||||
};
|
||||
if (prompt) state.taskDescription = truncateEnd(prompt, RUNTIME_TOOL_INPUT_MAX_CHARS);
|
||||
return {
|
||||
type: 'subAgentActivity',
|
||||
kind: activityKind,
|
||||
agentThreadId,
|
||||
agentPath,
|
||||
prompt: prompt ? truncateEnd(prompt, RUNTIME_TOOL_INPUT_MAX_CHARS) : null,
|
||||
tool: 'subAgentActivity',
|
||||
status,
|
||||
receiverThreadIds: agentThreadId ? [agentThreadId] : [],
|
||||
agentsStates: agentThreadId ? { [agentThreadId]: state } : {},
|
||||
};
|
||||
}
|
||||
|
||||
function codexAppErrorMessage(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
@@ -324,6 +425,8 @@ function createCodexAppRuntime(deps = {}) {
|
||||
return 'dynamic_tool_call';
|
||||
case 'collabAgentToolCall':
|
||||
return 'collab_agent_tool_call';
|
||||
case 'subAgentActivity':
|
||||
return 'collab_agent_tool_call';
|
||||
case 'webSearch':
|
||||
return 'web_search';
|
||||
case 'imageView':
|
||||
@@ -350,6 +453,8 @@ function createCodexAppRuntime(deps = {}) {
|
||||
return item.tool || 'DynamicToolCall';
|
||||
case 'collabAgentToolCall':
|
||||
return item.tool || 'CollabAgentToolCall';
|
||||
case 'subAgentActivity':
|
||||
return 'subAgentActivity';
|
||||
case 'webSearch':
|
||||
return 'WebSearch';
|
||||
case 'imageGeneration':
|
||||
@@ -397,6 +502,8 @@ function createCodexAppRuntime(deps = {}) {
|
||||
receiverThreadIds: limitPreviewValue(item.receiverThreadIds || [], { maxString: 512, maxDepth: 3 }),
|
||||
agentsStates: limitPreviewValue(item.agentsStates || {}, { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }),
|
||||
};
|
||||
case 'subAgentActivity':
|
||||
return normalizeSubAgentActivityItem(item);
|
||||
case 'imageGeneration':
|
||||
return {
|
||||
prompt: truncateEnd(item.prompt || item.query || '', RUNTIME_TOOL_INPUT_MAX_CHARS),
|
||||
@@ -467,6 +574,15 @@ function createCodexAppRuntime(deps = {}) {
|
||||
subtitle: reasoningTextFromItem(item).replace(/\s+/g, ' ').slice(0, 120),
|
||||
status: item.status || null,
|
||||
};
|
||||
case 'subAgentActivity': {
|
||||
const activity = normalizeSubAgentActivityItem(item);
|
||||
return {
|
||||
kind: 'collab_agent_tool_call',
|
||||
title: 'subAgentActivity',
|
||||
subtitle: activity.agentPath || activity.agentThreadId || '',
|
||||
status: activity.status,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: itemKind(item),
|
||||
@@ -513,6 +629,8 @@ function createCodexAppRuntime(deps = {}) {
|
||||
receiverThreadIds: item.receiverThreadIds || [],
|
||||
agentsStates: item.agentsStates || {},
|
||||
}, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
case 'subAgentActivity':
|
||||
return safeStringifyPreview(normalizeSubAgentActivityItem(item, true), RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
case 'imageGeneration':
|
||||
return safeStringifyPreview({
|
||||
status: item.status || null,
|
||||
@@ -537,7 +655,11 @@ function createCodexAppRuntime(deps = {}) {
|
||||
toolCall.name = itemName(item);
|
||||
toolCall.kind = kind;
|
||||
toolCall.meta = itemMeta(item) || toolCall.meta || null;
|
||||
if (toolCall.input == null || kind === 'todo_list') toolCall.input = itemInput(item);
|
||||
if (item?.type === 'subAgentActivity') {
|
||||
toolCall.input = normalizeSubAgentActivityItem(item, false, toolCall.input);
|
||||
} else if (toolCall.input == null || kind === 'todo_list') {
|
||||
toolCall.input = itemInput(item);
|
||||
}
|
||||
return toolCall;
|
||||
}
|
||||
|
||||
@@ -815,13 +937,16 @@ function createCodexAppRuntime(deps = {}) {
|
||||
}
|
||||
const toolCall = ensureToolCall(entry, item, sessionId);
|
||||
if (!toolCall) return { done: false };
|
||||
const result = truncateEnd(itemResult(item), RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
const result = item.type === 'subAgentActivity'
|
||||
? safeStringifyPreview(toolCall.input, RUNTIME_TOOL_RESULT_MAX_CHARS)
|
||||
: truncateEnd(itemResult(item), RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
toolCall.done = true;
|
||||
toolCall.result = result;
|
||||
toolCall.meta = itemMeta(item) || toolCall.meta;
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_end',
|
||||
toolUseId: toolCall.id,
|
||||
...(item.type === 'subAgentActivity' ? { name: toolCall.name, input: toolCall.input } : {}),
|
||||
result,
|
||||
kind: toolCall.kind,
|
||||
meta: toolCall.meta,
|
||||
|
||||
163
public/app.js
163
public/app.js
@@ -54,6 +54,12 @@
|
||||
{ cmd: '/help', desc: '显示帮助' },
|
||||
];
|
||||
|
||||
function isKnownSlashCommandText(text) {
|
||||
const [command = ''] = String(text || '').trim().split(/\s+/, 1);
|
||||
const normalized = command.toLowerCase();
|
||||
return SLASH_COMMANDS.some((item) => item.cmd.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
const MODE_LABELS = {
|
||||
default: '默认',
|
||||
plan: 'Plan',
|
||||
@@ -5545,11 +5551,7 @@
|
||||
pendingText = msg.text || '';
|
||||
flushRender();
|
||||
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
const mergedCollabTool = mergeCollabAgentTools(msg.toolCalls);
|
||||
const resumeToolCalls = [
|
||||
...(mergedCollabTool ? [mergedCollabTool] : []),
|
||||
...msg.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'),
|
||||
];
|
||||
const resumeToolCalls = renderToolCallsWithMergedCollab(msg.toolCalls);
|
||||
for (const tc of resumeToolCalls) {
|
||||
activeToolCalls.set(tc.id, {
|
||||
name: tc.name,
|
||||
@@ -6314,9 +6316,34 @@
|
||||
let renderEpoch = 0;
|
||||
|
||||
function toolKind(tool) {
|
||||
if (isSubAgentActivityTool(tool)) return 'collab_agent_tool_call';
|
||||
return tool?.kind || tool?.meta?.kind || '';
|
||||
}
|
||||
|
||||
function isSubAgentActivityTool(tool) {
|
||||
const kind = String(tool?.kind || tool?.meta?.kind || tool?.type || '').trim();
|
||||
const name = String(tool?.name || tool?.tool || '').trim();
|
||||
const inputData = effectiveObject(tool?.input);
|
||||
const resultData = effectiveObject(tool?.result);
|
||||
const payload = {
|
||||
...tool,
|
||||
...inputData,
|
||||
...resultData,
|
||||
};
|
||||
const inputType = String(inputData.type || '').trim();
|
||||
const resultType = String(resultData.type || '').trim();
|
||||
const activitySignal = inputData.kind || inputData.activityKind || inputData.activity_kind || inputData.status
|
||||
|| resultData.kind || resultData.activityKind || resultData.activity_kind || resultData.status;
|
||||
const hasActivitySignal = kind === 'subAgentActivity'
|
||||
|| inputType === 'subAgentActivity'
|
||||
|| resultType === 'subAgentActivity'
|
||||
|| (
|
||||
name === 'subAgentActivity'
|
||||
&& !!activitySignal
|
||||
);
|
||||
return hasActivitySignal && !!getSubAgentActivityThreadId(payload);
|
||||
}
|
||||
|
||||
function normalizeDisplayPath(filePath) {
|
||||
const rawPath = typeof filePath === 'string' ? filePath.trim() : '';
|
||||
if (!rawPath) return '';
|
||||
@@ -6595,9 +6622,86 @@
|
||||
return normalizeCollabAgentAction(value);
|
||||
}
|
||||
|
||||
function collabAgentBasename(value) {
|
||||
const text = cleanCollabAgentText(value).replace(/\\/g, '/');
|
||||
if (!text) return '';
|
||||
return text.split('/').filter(Boolean).pop() || text;
|
||||
}
|
||||
|
||||
function normalizeSubAgentActivityStatus(value, done = false) {
|
||||
const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (/^(started|interacted|interaction|message|delta|progress|updated|update)$/.test(normalized)) return 'running';
|
||||
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 (/^(failed|fail|error|errored|cancelled|canceled|aborted|rejected)$/.test(normalized)) return 'failed';
|
||||
return done ? 'completed' : 'running';
|
||||
}
|
||||
|
||||
function getSubAgentActivityThreadId(activity = {}) {
|
||||
return cleanCollabAgentText(
|
||||
activity.agentThreadId
|
||||
|| activity.agent_thread_id
|
||||
|| activity.threadId
|
||||
|| activity.thread_id
|
||||
|| activity.childThreadId
|
||||
|| activity.child_thread_id
|
||||
|| ''
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSubAgentActivityData(tool, inputData, resultData) {
|
||||
const activity = {
|
||||
...inputData,
|
||||
...resultData,
|
||||
};
|
||||
const threadId = getSubAgentActivityThreadId(activity);
|
||||
if (!threadId) return null;
|
||||
const activityKind = cleanCollabAgentText(activity.kind || activity.activityKind || activity.activity_kind || activity.status || '');
|
||||
const agentTitle = collabAgentBasename(activity.agentPath || activity.agent_path || activity.agent || activity.role || activity.name || '');
|
||||
const prompt = cleanCollabAgentText(activity.prompt || activity.taskDescription || activity.task_description || '');
|
||||
const state = {
|
||||
...activity,
|
||||
label: agentTitle || activity.label || activity.title || activity.name || '',
|
||||
title: agentTitle || activity.title || activity.label || activity.name || '',
|
||||
name: agentTitle || activity.name || activity.label || activity.title || '',
|
||||
role: cleanCollabAgentText(activity.role || activity.agentRole || activity.agent_role || ''),
|
||||
status: normalizeSubAgentActivityStatus(activityKind, !!tool?.done),
|
||||
activityKind,
|
||||
agentPath: activity.agentPath || activity.agent_path || '',
|
||||
hasReadableSourceTitle: !!agentTitle,
|
||||
};
|
||||
if (prompt) state.taskDescription = prompt;
|
||||
return {
|
||||
...activity,
|
||||
type: 'subAgentActivity',
|
||||
tool: 'subAgentActivity',
|
||||
prompt,
|
||||
status: state.status,
|
||||
receiverThreadIds: [threadId],
|
||||
agentsStates: {
|
||||
[threadId]: state,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateCollabAgentStatus(receiverThreadIds, states, fallbackStatus = '', done = false) {
|
||||
const ids = Array.isArray(receiverThreadIds) ? receiverThreadIds : [];
|
||||
const tones = ids.map((id) => collabStateTone(states?.[id]?.status));
|
||||
if (tones.length === 0) return fallbackStatus || (done ? 'completed' : 'running');
|
||||
if (tones.some((tone) => tone === 'running')) return 'running';
|
||||
if (tones.every((tone) => tone === 'closed')) return 'closed';
|
||||
if (tones.some((tone) => tone === 'error')) return 'failed';
|
||||
if (tones.every((tone) => tone === 'done' || tone === 'closed')) return 'completed';
|
||||
if (tones.some((tone) => tone === 'pending')) return 'pending';
|
||||
return fallbackStatus || (done ? 'completed' : 'running');
|
||||
}
|
||||
|
||||
function normalizeCollabAgentData(tool) {
|
||||
const inputData = effectiveObject(tool?.input);
|
||||
const resultData = effectiveObject(tool?.result);
|
||||
const activityData = isSubAgentActivityTool(tool) ? normalizeSubAgentActivityData(tool, inputData, resultData) : null;
|
||||
if (activityData) return activityData;
|
||||
const merged = {
|
||||
...inputData,
|
||||
...resultData,
|
||||
@@ -6953,14 +7057,18 @@
|
||||
const data = normalizeCollabAgentData(tool);
|
||||
const action = getCollabAgentAction(tool, data);
|
||||
const isCloseAction = action === 'close_agent';
|
||||
const fallbackId = tool.id || 'tool-1';
|
||||
receiverThreadIds.push(fallbackId);
|
||||
states[fallbackId] = {
|
||||
...mergeCollabAgentTaskState({}, { label: '子代理' }, data.prompt, fallbackId, 0),
|
||||
status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')),
|
||||
};
|
||||
if (!['wait_agent', 'close_agent'].includes(action)) {
|
||||
const fallbackId = tool.id || 'tool-1';
|
||||
receiverThreadIds.push(fallbackId);
|
||||
states[fallbackId] = {
|
||||
...mergeCollabAgentTaskState({}, { label: '子代理' }, data.prompt, fallbackId, 0),
|
||||
status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (receiverThreadIds.length === 0) return null;
|
||||
|
||||
receiverThreadIds.forEach((id, index) => {
|
||||
const nextStatus = localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending');
|
||||
states[id] = rememberCollabAgentState(
|
||||
@@ -6977,9 +7085,7 @@
|
||||
};
|
||||
});
|
||||
|
||||
const allClosed = receiverThreadIds.length > 0
|
||||
&& receiverThreadIds.every((id) => collabStateTone(states[id]?.status) === 'closed');
|
||||
const mergedStatus = allClosed ? 'closed' : (status || (done ? 'completed' : 'running'));
|
||||
const mergedStatus = aggregateCollabAgentStatus(receiverThreadIds, states, status, done);
|
||||
|
||||
return {
|
||||
id: list[0].id || 'collab-agent-merged',
|
||||
@@ -6996,6 +7102,15 @@
|
||||
};
|
||||
}
|
||||
|
||||
function renderToolCallsWithMergedCollab(toolCalls, options = {}) {
|
||||
const calls = Array.isArray(toolCalls) ? toolCalls : [];
|
||||
const mergedCollabTool = mergeCollabAgentTools(calls, options);
|
||||
return [
|
||||
...(mergedCollabTool ? [mergedCollabTool] : []),
|
||||
...calls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'),
|
||||
];
|
||||
}
|
||||
|
||||
function collabStateTone(statusText) {
|
||||
const normalized = String(statusText || '').toLowerCase();
|
||||
if (!normalized) return 'pending';
|
||||
@@ -7220,11 +7335,7 @@
|
||||
const toolMount = bubble.querySelector(':scope > .cross-conversation-reply-body') || bubble;
|
||||
const FOLD_AT = 3;
|
||||
let grouped = false;
|
||||
const mergedCollabTool = mergeCollabAgentTools(m.toolCalls);
|
||||
const renderToolCalls = [
|
||||
...(mergedCollabTool ? [mergedCollabTool] : []),
|
||||
...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);
|
||||
@@ -8403,17 +8514,9 @@
|
||||
const cursor = typeof msgInput.selectionStart === 'number' ? msgInput.selectionStart : value.length;
|
||||
const before = value.slice(0, cursor);
|
||||
|
||||
if (!before.includes('\n') && value.startsWith('/') && cursor > 0) {
|
||||
const nextWhitespace = value.search(/\s/);
|
||||
const end = nextWhitespace >= 0 ? Math.min(cursor, nextWhitespace) : cursor;
|
||||
if (cursor <= end || nextWhitespace < 0) {
|
||||
return { trigger: '/', query: value.slice(1, cursor), start: 0, end: cursor };
|
||||
}
|
||||
}
|
||||
|
||||
const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
|
||||
const line = before.slice(lineStart);
|
||||
const match = line.match(/(^|\s)([@$])([^\s]*)$/);
|
||||
const match = line.match(/(^|\s)([\/@$])([^\s]*)$/);
|
||||
if (!match) return null;
|
||||
const prefixLength = match[1] ? match[1].length : 0;
|
||||
const start = lineStart + match.index + prefixLength;
|
||||
@@ -8710,7 +8813,7 @@
|
||||
appendError('Codex App 运行中插入暂不支持图片附件,请先移除图片。');
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('/')) {
|
||||
if (isKnownSlashCommandText(text)) {
|
||||
appendError('Codex App 运行中暂不支持 slash 指令插入。');
|
||||
return;
|
||||
}
|
||||
@@ -8742,7 +8845,7 @@
|
||||
}
|
||||
|
||||
// Slash commands: don't show as user bubble
|
||||
if (text.startsWith('/')) {
|
||||
if (isKnownSlashCommandText(text)) {
|
||||
if (pendingAttachments.length > 0) {
|
||||
appendError('命令消息暂不支持附带图片,请先移除图片或发送普通消息。');
|
||||
return;
|
||||
|
||||
@@ -564,7 +564,7 @@ function assertFrontendComposerMcpContract() {
|
||||
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
|
||||
}
|
||||
|
||||
function assertFrontendSlashDraftPreservationContract() {
|
||||
function assertComposerSlashRoutingContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
assert(source.includes('const pendingSlashDraftsByRequestId = new Map();'), 'Frontend should keep request-scoped slash drafts');
|
||||
@@ -576,19 +576,67 @@ function assertFrontendSlashDraftPreservationContract() {
|
||||
assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId');
|
||||
assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error');
|
||||
|
||||
const frontendCommandsMatch = source.match(/const\s+SLASH_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
|
||||
const serverCommandsMatch = serverSource.match(/const\s+COMPOSER_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
|
||||
assert(frontendCommandsMatch && serverCommandsMatch, 'Frontend and server should both declare composer slash command lists');
|
||||
const frontendCommandNames = Array.from(frontendCommandsMatch[1].matchAll(/\bcmd:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
|
||||
const serverCommandNames = Array.from(serverCommandsMatch[1].matchAll(/\bname:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
|
||||
assert(frontendCommandNames.length > 0, 'Frontend slash command list should not be empty');
|
||||
assert(
|
||||
JSON.stringify([...frontendCommandNames].sort()) === JSON.stringify([...serverCommandNames].sort()),
|
||||
'Frontend and server should classify the same complete set of known slash commands'
|
||||
);
|
||||
|
||||
const knownSlashSource = extractFunctionSource(source, 'isKnownSlashCommandText');
|
||||
const knownSlashApi = new Function('SLASH_COMMANDS', `
|
||||
${knownSlashSource}
|
||||
return isKnownSlashCommandText;
|
||||
`)(frontendCommandNames.map((cmd) => ({ cmd })));
|
||||
frontendCommandNames.forEach((command) => {
|
||||
assert(knownSlashApi(`${command.toUpperCase()} argument`), `Known slash command ${command} should match case-insensitively`);
|
||||
});
|
||||
assert(!knownSlashApi('/report/mcps?search') && !knownSlashApi('/help/topic'), 'Unknown slash paths should remain ordinary messages');
|
||||
|
||||
const tokenSource = extractFunctionSource(source, 'findActiveComposerToken');
|
||||
const tokenApi = new Function(`
|
||||
let msgInput = null;
|
||||
${tokenSource}
|
||||
return (value, cursor = value.length) => {
|
||||
msgInput = { value, selectionStart: cursor };
|
||||
return findActiveComposerToken();
|
||||
};
|
||||
`)();
|
||||
assert(tokenApi('/rep')?.trigger === '/', 'Slash suggestions should trigger at the composer start');
|
||||
assert(tokenApi('请查看 /rep')?.query === 'rep', 'Slash suggestions should trigger after whitespace');
|
||||
assert(tokenApi('请查看\n/rep')?.query === 'rep', 'Slash suggestions should trigger at a new line');
|
||||
assert(tokenApi('请查看\r\n/rep')?.query === 'rep', 'Slash suggestions should trigger after a CRLF line boundary');
|
||||
assert(tokenApi('请查看 /rep', 3) === null, 'Slash suggestions should follow the cursor and ignore tokens after it');
|
||||
assert(tokenApi('src/foo') === null, 'Slash suggestions should not trigger inside a path token');
|
||||
assert(tokenApi('请查看 @file')?.trigger === '@' && tokenApi('请使用 $skill')?.trigger === '$', '@ and $ trigger behavior should remain unchanged');
|
||||
|
||||
const sendStart = source.indexOf('function sendMessage()');
|
||||
const slashStart = source.indexOf("if (text.startsWith('/'))", sendStart);
|
||||
const slashStart = source.indexOf('if (isKnownSlashCommandText(text))', sendStart);
|
||||
const rememberStart = source.indexOf('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);', slashStart);
|
||||
const modelPickerStart = source.indexOf("if (text === '/model' || text === '/model ')", slashStart);
|
||||
const modePickerStart = source.indexOf("if (text === '/mode' || text === '/mode ')", slashStart);
|
||||
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should keep slash send branch explicit');
|
||||
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should reserve the slash send branch for known commands');
|
||||
assert(modelPickerStart > slashStart && modelPickerStart < rememberStart, 'Frontend /model picker should stay local and clear normally');
|
||||
assert(modePickerStart > slashStart && modePickerStart < rememberStart, 'Frontend /mode picker should stay local and clear normally');
|
||||
|
||||
assert(serverSource.includes('handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);'), 'Server should pass client request metadata into slash handler');
|
||||
assert(/const\s+handled\s*=\s*handleSlashCommand\(ws, msg\.text\.trim\(\), msg\.sessionId, msg\.agent, msg\);/.test(serverSource), 'Server should inspect slash text and retain the handled result');
|
||||
assert(/if\s*\(!handled\)\s*handleMessage\(ws, msg/.test(serverSource), 'Server should pass unknown slash text into the ordinary message pipeline');
|
||||
assert(serverSource.includes('function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {})'), 'Server slash handler should accept request metadata');
|
||||
assert(serverSource.includes('wsSend(ws, attachClientRequestId(base, source));'), 'Server slash responses should echo requestId');
|
||||
assert(serverSource.includes('preserveComposerDraft: true'), 'Server should explicitly mark slash failures as draft-preserving');
|
||||
assert(/default:\s*\n\s*sendSlashSystemMessage\(`未知指令:[\s\S]*?\n\s*return false;/.test(serverSource), 'Unknown slash hints should not restore the composer and should report an unhandled command');
|
||||
const serverSlashHandler = extractFunctionSource(serverSource, 'handleSlashCommand');
|
||||
const serverHandledCommandNames = Array.from(serverSlashHandler.matchAll(/case\s+['"]([^'"]+)['"]\s*:/g), (match) => match[1]);
|
||||
assert(
|
||||
JSON.stringify([...serverCommandNames].sort()) === JSON.stringify([...serverHandledCommandNames].sort()),
|
||||
'Every server-declared slash command should keep an explicit handler case'
|
||||
);
|
||||
const unknownGuardStart = serverSlashHandler.indexOf('if (!COMPOSER_COMMANDS.some((item) => item.name === cmd))');
|
||||
const runningGuardStart = serverSlashHandler.indexOf('activeCodexAppTurns.has(sessionId)');
|
||||
assert(unknownGuardStart >= 0 && unknownGuardStart < runningGuardStart, 'Unknown slash text should be classified before active Codex App command guards');
|
||||
assert(serverSource.includes('wsSend(ws, attachClientRequestId({') && serverSource.includes('...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {})'), 'Server runtime errors should echo requestId and draft-preservation metadata');
|
||||
}
|
||||
|
||||
@@ -670,6 +718,7 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
const collabMergeEnd = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart);
|
||||
const childUpdateStart = source.indexOf(' function applyCcwebMcpChildAgentUpdate(msg)');
|
||||
const childUpdateEnd = source.indexOf(' function getDeleteConfirmMessage(agent)', childUpdateStart);
|
||||
const buildMsgElementSource = extractFunctionSource(source, 'buildMsgElement');
|
||||
assert(collabMergeStart >= 0 && collabMergeEnd > collabMergeStart, 'Frontend should expose collab merge helpers');
|
||||
assert(childUpdateStart >= 0 && childUpdateEnd > childUpdateStart, 'Frontend should define child-agent update handling before delete helpers');
|
||||
const collabApi = new Function(`
|
||||
@@ -693,16 +742,57 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
updater(cachedSnapshot);
|
||||
}
|
||||
function updateToolCall() {}
|
||||
function makeNode() {
|
||||
return {
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
insertBefore(child) {
|
||||
this.children.unshift(child);
|
||||
return child;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
function createMsgElement() {
|
||||
const bubble = makeNode();
|
||||
return {
|
||||
bubble,
|
||||
querySelector(selector) {
|
||||
return selector === '.msg-bubble' ? bubble : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
function createCcwebPromptElement() { return makeNode(); }
|
||||
function isEmptyReasoningTool() { return false; }
|
||||
function createToolCallElement(toolUseId, tool, done) {
|
||||
return { ...makeNode(), toolUseId, tool, done };
|
||||
}
|
||||
function isGroupableToolCall() { return false; }
|
||||
function _refreshGroupSummary() {}
|
||||
function markSessionMessageElement() {}
|
||||
const document = { createElement: () => makeNode() };
|
||||
function shortChildAgentId(id) {
|
||||
const value = String(id || '');
|
||||
return value.length > 12 ? value.slice(0, 8) : value;
|
||||
}
|
||||
${source.slice(collabMergeStart, collabMergeEnd)}
|
||||
${source.slice(childUpdateStart, childUpdateEnd)}
|
||||
${buildMsgElementSource}
|
||||
return {
|
||||
toolKind,
|
||||
collabStateTone,
|
||||
mergeCollabAgentTools,
|
||||
applyCcwebMcpChildAgentUpdate,
|
||||
rememberCollabAgentState,
|
||||
renderToolCallsForMessage: (toolCalls) => {
|
||||
const el = buildMsgElement({ role: 'assistant', content: '', toolCalls });
|
||||
return el.querySelector('.msg-bubble').children.map((node) => node.tool);
|
||||
},
|
||||
getCachedState: (id) => collabAgentStateCache.get(id),
|
||||
hasCachedState: (id) => collabAgentStateCache.has(id),
|
||||
cacheSize: () => collabAgentStateCache.size,
|
||||
@@ -910,6 +1000,354 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
});
|
||||
assert(collabApi.cacheSize() === cacheSizeBeforeOrdinaryTool, 'Non-collab child updates should not write sub-agent state cache');
|
||||
assert(!collabApi.hasCachedState('ordinary-child'), 'Non-collab tools should not cache child thread state');
|
||||
|
||||
const rawActivityThreadId = 'agent-thread-plan-reviewer-001';
|
||||
const rawActivityPrompt = '请审查 Phase 8 的前端 helper 行为。';
|
||||
const rawSubAgentActivity = (overrides = {}) => {
|
||||
const input = {
|
||||
kind: overrides.activityKind || 'started',
|
||||
agentThreadId: overrides.agentThreadId || rawActivityThreadId,
|
||||
agentPath: overrides.agentPath || '/root/plan_reviewer',
|
||||
...(overrides.prompt === undefined ? { prompt: rawActivityPrompt } : {}),
|
||||
...(overrides.prompt ? { prompt: overrides.prompt } : {}),
|
||||
...(overrides.input || {}),
|
||||
};
|
||||
return {
|
||||
id: overrides.id || `call_activity_${input.kind}`,
|
||||
name: 'subAgentActivity',
|
||||
kind: 'subAgentActivity',
|
||||
input,
|
||||
...(overrides.result !== undefined ? { result: overrides.result } : {}),
|
||||
done: !!overrides.done,
|
||||
};
|
||||
};
|
||||
const emptyWaitTool = {
|
||||
id: 'call_wait_empty',
|
||||
name: 'wait_agent',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
|
||||
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
|
||||
done: false,
|
||||
};
|
||||
const rawActivityMerge = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity(),
|
||||
emptyWaitTool,
|
||||
]);
|
||||
assert(rawActivityMerge, 'Raw subAgentActivity with an empty wait should produce one merged collab card');
|
||||
assert(
|
||||
rawActivityMerge.input.receiverThreadIds.length === 1
|
||||
&& rawActivityMerge.input.receiverThreadIds[0] === rawActivityThreadId,
|
||||
'Raw subAgentActivity agentThreadId should be the sole receiverThreadId and must not fall back to the wait tool call id'
|
||||
);
|
||||
assert(
|
||||
Object.keys(rawActivityMerge.input.agentsStates).length === 1
|
||||
&& Object.prototype.hasOwnProperty.call(rawActivityMerge.input.agentsStates, rawActivityThreadId),
|
||||
'Raw subAgentActivity agentThreadId should be the sole agentsStates key'
|
||||
);
|
||||
const rawActivityState = rawActivityMerge.input.agentsStates[rawActivityThreadId];
|
||||
assert(rawActivityState.label === 'plan_reviewer', 'Raw subAgentActivity agentPath basename should become the readable card title');
|
||||
assert(rawActivityState.role === '', 'Raw subAgentActivity agentPath should not be duplicated into role when no explicit role is present');
|
||||
assert(!/^ID\s+call_/.test(rawActivityState.label || ''), 'Raw subAgentActivity title must not fall back to a tool call id');
|
||||
assert(rawActivityState.status === 'running', 'Raw subAgentActivity started events should keep the child running');
|
||||
assert(rawActivityMerge.input.status === 'running', 'Raw subAgentActivity aggregate status should match its running child state');
|
||||
assert(rawActivityState.taskDescription === rawActivityPrompt, 'Raw subAgentActivity prompt should be preserved as taskDescription');
|
||||
|
||||
const explicitRoleMerge = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_explicit_role',
|
||||
agentThreadId: 'agent-thread-explicit-role',
|
||||
input: { role: 'reviewer' },
|
||||
}),
|
||||
]);
|
||||
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].label === 'plan_reviewer', 'Raw subAgentActivity agentPath basename should remain the readable title when role is explicit');
|
||||
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].role === 'reviewer', 'Raw subAgentActivity should preserve an explicit role');
|
||||
|
||||
const completedEmptyWaitTool = {
|
||||
...emptyWaitTool,
|
||||
id: 'call_wait_completed_empty',
|
||||
result: JSON.stringify({ status: 'completed', receiverThreadIds: [], agentsStates: {} }),
|
||||
done: true,
|
||||
};
|
||||
const runningStartedWithCompletedWait = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_started_done_completed',
|
||||
activityKind: 'started',
|
||||
done: true,
|
||||
input: { status: 'completed' },
|
||||
result: JSON.stringify({ status: 'completed' }),
|
||||
}),
|
||||
completedEmptyWaitTool,
|
||||
]);
|
||||
assert(runningStartedWithCompletedWait.input.receiverThreadIds.length === 1, 'Started raw activity plus completed empty wait should keep one child');
|
||||
assert(runningStartedWithCompletedWait.input.agentsStates[rawActivityThreadId].status === 'running', 'Started raw activity should stay running even when the empty wait is completed');
|
||||
assert(runningStartedWithCompletedWait.input.status === 'running', 'Merged collab status should stay running when the only child is running');
|
||||
|
||||
collabApi.rememberCollabAgentState(
|
||||
'restore-running-child',
|
||||
{ title: '恢复运行代理', status: 'running' },
|
||||
'请保持运行态。',
|
||||
0
|
||||
);
|
||||
const restoredRunningWithCompletedWait = collabApi.mergeCollabAgentTools([
|
||||
completedEmptyWaitTool,
|
||||
], {
|
||||
restoreAgentIds: new Set(['restore-running-child']),
|
||||
});
|
||||
assert(restoredRunningWithCompletedWait.input.agentsStates['restore-running-child'].status === 'running', 'Empty completed wait should not overwrite restored running cache status');
|
||||
assert(restoredRunningWithCompletedWait.input.status === 'running', 'Empty completed wait should not make a restored running child look completed in the aggregate header');
|
||||
|
||||
['started', 'interacted'].forEach((activityKind) => {
|
||||
const threadId = `agent-thread-${activityKind}`;
|
||||
const merged = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: `call_activity_${activityKind}_only`,
|
||||
activityKind,
|
||||
agentThreadId: threadId,
|
||||
prompt: activityKind === 'started' ? `请处理 ${activityKind} 状态。` : '',
|
||||
}),
|
||||
]);
|
||||
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
|
||||
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
|
||||
assert(merged.input.agentsStates[threadId].status === 'running', `Raw ${activityKind} activity should map to running status`);
|
||||
});
|
||||
|
||||
['completed', 'returned'].forEach((activityKind) => {
|
||||
const threadId = `agent-thread-${activityKind}`;
|
||||
const merged = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: `call_activity_${activityKind}_only`,
|
||||
activityKind,
|
||||
agentThreadId: threadId,
|
||||
prompt: `请处理 ${activityKind} 状态。`,
|
||||
done: true,
|
||||
}),
|
||||
]);
|
||||
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
|
||||
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
|
||||
assert(collabApi.collabStateTone(merged.input.agentsStates[threadId].status) === 'done', `Raw ${activityKind} activity should map to a completed tone`);
|
||||
});
|
||||
|
||||
const completedWithoutPrompt = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity(),
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_completed_without_prompt',
|
||||
activityKind: 'completed',
|
||||
prompt: '',
|
||||
done: true,
|
||||
}),
|
||||
]);
|
||||
assert(
|
||||
completedWithoutPrompt.input.agentsStates[rawActivityThreadId].taskDescription === rawActivityPrompt,
|
||||
'Raw subAgentActivity updates for the same thread should retain an earlier prompt as taskDescription'
|
||||
);
|
||||
assert(
|
||||
collabApi.collabStateTone(completedWithoutPrompt.input.agentsStates[rawActivityThreadId].status) === 'done',
|
||||
'Raw subAgentActivity completed updates should finish the same child card'
|
||||
);
|
||||
|
||||
const noPromptThreadId = 'agent-thread-no-prompt';
|
||||
const noPromptActivityMerge = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_no_prompt',
|
||||
agentThreadId: noPromptThreadId,
|
||||
prompt: '',
|
||||
}),
|
||||
]);
|
||||
assert(noPromptActivityMerge.input.agentsStates[noPromptThreadId].label === 'plan_reviewer', 'Raw subAgentActivity without prompt should still use agentPath as title');
|
||||
assert(!noPromptActivityMerge.input.agentsStates[noPromptThreadId].taskDescription, 'Raw subAgentActivity without prompt should not fabricate taskDescription');
|
||||
|
||||
const renderCandidates = collabApi.renderToolCallsForMessage([
|
||||
rawSubAgentActivity(),
|
||||
emptyWaitTool,
|
||||
]);
|
||||
assert(renderCandidates.length === 1, 'Raw subAgentActivity plus empty wait should render only the merged collab card');
|
||||
assert(renderCandidates[0].kind === 'collab_agent_tool_call', 'Raw subAgentActivity should render through the collab display tool');
|
||||
assert(
|
||||
!renderCandidates.some((tool) => tool?.id === 'call_activity_started' && tool?.kind === 'subAgentActivity'),
|
||||
'Raw subAgentActivity should be filtered out of ordinary rendered tool rows'
|
||||
);
|
||||
|
||||
const nameOnlyOrdinaryTool = {
|
||||
id: 'call_name_only_subagent_activity',
|
||||
name: 'subAgentActivity',
|
||||
kind: 'command_execution',
|
||||
input: { command: 'echo should-stay-ordinary' },
|
||||
done: true,
|
||||
};
|
||||
assert(collabApi.toolKind(nameOnlyOrdinaryTool) === 'command_execution', 'Name-only subAgentActivity command tools without agentThreadId should stay ordinary tools');
|
||||
assert(collabApi.mergeCollabAgentTools([nameOnlyOrdinaryTool]) === null, 'Name-only ordinary subAgentActivity tools should not produce a collab card');
|
||||
const nameOnlyRenderCandidates = collabApi.renderToolCallsForMessage([nameOnlyOrdinaryTool]);
|
||||
assert(nameOnlyRenderCandidates.length === 1 && nameOnlyRenderCandidates[0].kind === 'command_execution', 'Name-only ordinary subAgentActivity tools should not be filtered into a collab render candidate');
|
||||
}
|
||||
|
||||
function assertCodexAppRuntimeSubAgentActivityContract() {
|
||||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||||
const sent = [];
|
||||
const runtime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => sent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
const sessionId = 'runtime-subagent-session';
|
||||
const threadId = 'agent-thread-runtime-001';
|
||||
const prompt = '请审查 runtime subAgentActivity 结构。';
|
||||
const entry = {
|
||||
ws: {},
|
||||
toolCalls: [],
|
||||
fullText: '',
|
||||
};
|
||||
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/started',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: threadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
prompt,
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
|
||||
const started = sent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity');
|
||||
assert(started, 'Runtime subAgentActivity item/started should emit tool_start');
|
||||
assert(started.sessionId === sessionId, 'Runtime subAgentActivity tool_start should carry session id');
|
||||
assert(started.name === 'subAgentActivity', 'Runtime subAgentActivity tool_start should preserve activity name');
|
||||
assert(started.kind === 'collab_agent_tool_call', 'Runtime subAgentActivity should surface as a collab agent tool call');
|
||||
assert(started.input?.type === 'subAgentActivity', 'Runtime subAgentActivity input should preserve original type');
|
||||
assert(started.input?.kind === 'started', 'Runtime subAgentActivity input should preserve activity kind');
|
||||
assert(started.input?.agentThreadId === threadId, 'Runtime subAgentActivity input should preserve agentThreadId');
|
||||
assert(started.input?.agentPath === '/root/plan_reviewer', 'Runtime subAgentActivity input should preserve agentPath');
|
||||
assert(started.input?.prompt === prompt, 'Runtime subAgentActivity input should preserve prompt');
|
||||
assert(started.input?.receiverThreadIds?.[0] === threadId, 'Runtime subAgentActivity input should expose receiverThreadIds');
|
||||
assert(started.input?.agentsStates?.[threadId]?.label === 'plan_reviewer', 'Runtime subAgentActivity should derive title from agentPath basename');
|
||||
assert(started.input?.agentsStates?.[threadId]?.role === '', 'Runtime subAgentActivity should not duplicate agentPath title into role');
|
||||
assert(started.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime subAgentActivity should copy prompt to taskDescription');
|
||||
assert(started.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime started subAgentActivity should map to running');
|
||||
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity',
|
||||
type: 'subAgentActivity',
|
||||
agentThreadId: threadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
|
||||
const completed = sent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity');
|
||||
assert(completed, 'Runtime subAgentActivity item/completed should emit tool_end');
|
||||
assert(completed.kind === 'collab_agent_tool_call', 'Runtime completed subAgentActivity should keep collab tool kind');
|
||||
assert(completed.name === 'subAgentActivity', 'Runtime completed subAgentActivity should keep activity name');
|
||||
assert(completed.input?.type === 'subAgentActivity', 'Runtime completed subAgentActivity tool_end should carry input');
|
||||
assert(completed.input?.kind === 'started', 'Runtime lifecycle-completed subAgentActivity input should inherit the started activity kind when completed omits kind');
|
||||
assert(completed.input?.prompt === prompt, 'Runtime completed subAgentActivity input should retain the started prompt when completed omits it');
|
||||
assert(completed.input?.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity input should keep receiverThreadIds');
|
||||
assert(completed.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity input should retain the started taskDescription');
|
||||
assert(completed.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity input should keep child running when completed omits kind');
|
||||
const completedResult = JSON.parse(completed.result);
|
||||
assert(completedResult.type === 'subAgentActivity', 'Runtime completed subAgentActivity result should preserve original type');
|
||||
assert(completedResult.kind === 'started', 'Runtime lifecycle-completed subAgentActivity result should inherit the started activity kind when completed omits kind');
|
||||
assert(completedResult.prompt === prompt, 'Runtime completed subAgentActivity result should retain the started prompt when completed omits it');
|
||||
assert(completedResult.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity result should expose receiverThreadIds');
|
||||
assert(completedResult.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity result should retain the started taskDescription');
|
||||
assert(completedResult.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity result should keep child running when completed omits kind');
|
||||
assert(entry.toolCalls[0]?.kind === 'collab_agent_tool_call', 'Runtime persisted tool call should keep collab tool kind');
|
||||
assert(entry.toolCalls[0]?.input?.type === 'subAgentActivity', 'Runtime persisted tool call should keep subAgentActivity input type for routing recovery');
|
||||
assert(entry.toolCalls[0]?.input?.prompt === prompt, 'Runtime persisted subAgentActivity input should retain the started prompt');
|
||||
assert(entry.toolCalls[0]?.input?.kind === 'started', 'Runtime persisted lifecycle-completed subAgentActivity input should keep the started activity kind');
|
||||
assert(entry.toolCalls[0]?.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime persisted lifecycle-completed subAgentActivity input should keep child running');
|
||||
|
||||
const explicitCompletedSent = [];
|
||||
const explicitCompletedRuntime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => explicitCompletedSent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
const explicitCompletedEntry = { ws: {}, toolCalls: [], fullText: '' };
|
||||
const explicitCompletedThreadId = 'agent-thread-runtime-completed';
|
||||
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
|
||||
method: 'item/started',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity-completed',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: explicitCompletedThreadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
prompt,
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity-completed',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'completed',
|
||||
agentThreadId: explicitCompletedThreadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
const explicitCompleted = explicitCompletedSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity-completed');
|
||||
assert(explicitCompleted.input?.kind === 'completed', 'Runtime explicit completed subAgentActivity input should preserve completed activity kind');
|
||||
assert(explicitCompleted.input?.prompt === prompt, 'Runtime explicit completed subAgentActivity input should retain the started prompt when completed omits it');
|
||||
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity input should retain the started taskDescription');
|
||||
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity input should map child status to completed');
|
||||
const explicitCompletedResult = JSON.parse(explicitCompleted.result);
|
||||
assert(explicitCompletedResult.kind === 'completed', 'Runtime explicit completed subAgentActivity result should preserve completed activity kind');
|
||||
assert(explicitCompletedResult.prompt === prompt, 'Runtime explicit completed subAgentActivity result should retain the started prompt');
|
||||
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity result should retain the started taskDescription');
|
||||
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity result should map child status to completed');
|
||||
|
||||
const explicitRoleSent = [];
|
||||
const explicitRoleRuntime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => explicitRoleSent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
explicitRoleRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
|
||||
method: 'item/started',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity-role',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: 'agent-thread-runtime-role',
|
||||
agentPath: '/root/plan_reviewer',
|
||||
role: 'reviewer',
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
const explicitRoleStarted = explicitRoleSent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity-role');
|
||||
assert(explicitRoleStarted.input?.agentsStates?.['agent-thread-runtime-role']?.role === 'reviewer', 'Runtime subAgentActivity should preserve explicit role');
|
||||
|
||||
const reasoningSent = [];
|
||||
const reasoningRuntime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => reasoningSent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
reasoningRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-reasoning',
|
||||
type: 'reasoning',
|
||||
content: [{ text: '推理完成' }],
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
const reasoningEnd = reasoningSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-reasoning');
|
||||
assert(reasoningEnd, 'Runtime reasoning item/completed should still emit tool_end');
|
||||
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'input'), 'Runtime non-subAgentActivity reasoning tool_end should not gain input');
|
||||
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
|
||||
}
|
||||
|
||||
function assertFrontendPrimaryCodexAppUiContract() {
|
||||
@@ -1268,23 +1706,35 @@ 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();
|
||||
if (regressionTarget) {
|
||||
if (regressionTarget !== 'codexapp-unrouted-routing') {
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
if (regressionTarget === 'composer-slash-routing') {
|
||||
assertComposerSlashRoutingContract();
|
||||
console.log('Composer slash routing regression checks passed.');
|
||||
return;
|
||||
}
|
||||
assertCodexAppUnroutedNotificationRoutingContract();
|
||||
console.log('Codex App unrouted routing regression checks passed.');
|
||||
return;
|
||||
if (regressionTarget === 'codexapp-unrouted-routing') {
|
||||
assertCodexAppUnroutedNotificationRoutingContract();
|
||||
console.log('Codex App unrouted routing regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'subagent-card-metadata') {
|
||||
assertFrontendSubagentCardMetadataContract();
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
console.log('Subagent card metadata regression checks passed.');
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
}
|
||||
|
||||
assertUnlimitedImageAttachmentsContract();
|
||||
assertFrontendGenerationControlsContract();
|
||||
assertFrontendComposerMcpContract();
|
||||
assertFrontendSlashDraftPreservationContract();
|
||||
assertComposerSlashRoutingContract();
|
||||
assertFrontendCcwebPromptContract();
|
||||
assertFrontendMarkdownLinkContract();
|
||||
assertMockCodexAppPromptUserNotTextTriggered();
|
||||
assertFrontendMcpReloadContract();
|
||||
assertFrontendSubagentCardMetadataContract();
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
assertSetTitleMcpContract();
|
||||
assertSessionSwitchResilienceContract();
|
||||
@@ -1562,11 +2012,29 @@ async function main() {
|
||||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
|
||||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/grilling', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-unknown-slash-draft' }));
|
||||
const unknownSlashAttachment = await uploadAttachment(port, token, {
|
||||
filename: 'unknown-slash.png',
|
||||
mime: 'image/png',
|
||||
data: Buffer.from('unknown-slash-image'),
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/report/mcps?search',
|
||||
attachments: [unknownSlashAttachment],
|
||||
sessionId: codexSession.sessionId,
|
||||
mode: 'plan',
|
||||
agent: 'codex',
|
||||
requestId: 'reg-unknown-slash-draft',
|
||||
}));
|
||||
const unknownSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-unknown-slash-draft');
|
||||
assert(unknownSlashDraft.sessionId === codexSession.sessionId, 'Unknown slash draft response should stay scoped to the active session');
|
||||
assert(unknownSlashDraft.preserveComposerDraft === true, 'Unknown slash command should tell the frontend to restore the composer draft');
|
||||
assert(/未知指令: \/grilling/.test(unknownSlashDraft.message || ''), 'Unknown slash command should still show the normal failure hint');
|
||||
assert(!unknownSlashDraft.preserveComposerDraft, 'Unknown slash hints should not restore text that is continuing through ordinary send');
|
||||
assert(/未知指令: \/report\/mcps\?search/.test(unknownSlashDraft.message || ''), 'Unknown slash text should still show the normal hint');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
|
||||
const storedUnknownSlashSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
const storedUnknownSlashMessage = storedUnknownSlashSession.messages.find((message) => message.role === 'user' && message.content === '/report/mcps?search');
|
||||
assert(storedUnknownSlashMessage, 'Unknown slash text should continue through the ordinary message pipeline');
|
||||
assert(storedUnknownSlashMessage.attachments?.some((attachment) => attachment.filename === unknownSlashAttachment.filename), 'Unknown slash text should preserve ordinary message attachments');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/help', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-help-slash-draft' }));
|
||||
const helpSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-help-slash-draft');
|
||||
@@ -2614,6 +3082,38 @@ async function main() {
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||||
await sleep(500);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/runtime/report',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
clientMessageId: 'regression-unknown-slash-steer',
|
||||
}));
|
||||
const runningUnknownSlashHint = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'system_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/未知指令: \/runtime\/report/.test(msg.message || '')
|
||||
));
|
||||
assert(!runningUnknownSlashHint.preserveComposerDraft, 'Running Codex App unknown slash hints should not restore a message that continues through steer');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
msg.clientMessageId === 'regression-unknown-slash-steer' &&
|
||||
msg.status === 'pending'
|
||||
));
|
||||
const runningUnknownSlashDelta = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/steer accepted: \/runtime\/report/.test(msg.text || '')
|
||||
));
|
||||
assert(/\/runtime\/report/.test(runningUnknownSlashDelta.text || ''), 'Running Codex App unknown slash text should continue through turn/steer');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
msg.clientMessageId === 'regression-unknown-slash-steer' &&
|
||||
msg.status === 'inserted'
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: 'runtime steer insert',
|
||||
@@ -2648,6 +3148,7 @@ async function main() {
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === '/runtime/report'), 'Running Codex App unknown slash text should persist as ordinary user history');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');
|
||||
|
||||
|
||||
22
server.js
22
server.js
@@ -6372,7 +6372,8 @@ wss.on('connection', (ws, req) => {
|
||||
switch (msg.type) {
|
||||
case 'message':
|
||||
if (msg.text && msg.text.trim().startsWith('/')) {
|
||||
handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);
|
||||
const handled = handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);
|
||||
if (!handled) handleMessage(ws, msg, { unknownSlash: true });
|
||||
} else {
|
||||
handleMessage(ws, msg);
|
||||
}
|
||||
@@ -7026,13 +7027,18 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
sendSlashResponse({ type: 'system_message', message, ...extra }, options);
|
||||
};
|
||||
|
||||
if (!COMPOSER_COMMANDS.some((item) => item.name === cmd)) {
|
||||
sendSlashSystemMessage(`未知指令: ${cmd}\n输入 /help 查看可用指令`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session && isCodexAppSession(session) && activeCodexAppTurns.has(sessionId)) {
|
||||
sendSlashSystemMessage('Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。', {}, { preserveComposerDraft: true });
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) {
|
||||
sendSlashSystemMessage('Codex App Goal 正在同步,请稍候。', { sessionId }, { preserveComposerDraft: true });
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
@@ -7226,8 +7232,10 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
}
|
||||
|
||||
default:
|
||||
sendSlashSystemMessage(`未知指令: ${cmd}\n输入 /help 查看可用指令`, {}, { preserveComposerDraft: true });
|
||||
sendSlashSystemMessage(`未知指令: ${cmd}\n输入 /help 查看可用指令`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// === Session Handlers ===
|
||||
@@ -8007,7 +8015,7 @@ function handleCcwebMcpChildAgentClose(ws, msg = {}) {
|
||||
// === Runtime Message Handler ===
|
||||
function handleMessage(ws, msg, options = {}) {
|
||||
const { text, sessionId, mode } = msg;
|
||||
const { hideInHistory = false } = options;
|
||||
const { hideInHistory = false, unknownSlash = false } = options;
|
||||
const fail = (code, message) => {
|
||||
wsSend(ws, attachClientRequestId({
|
||||
type: 'error',
|
||||
@@ -8100,7 +8108,7 @@ function handleMessage(ws, msg, options = {}) {
|
||||
runtimeTextValue = decoratorResolution.runtimeText;
|
||||
normalizedRuntimeText = runtimeTextValue.trim();
|
||||
|
||||
if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
|
||||
if (!unknownSlash && normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
|
||||
return fail('command_attachment_unsupported', '命令消息暂不支持同时附带图片。请先发送图片说明,再单独使用 /model 或 /mode。');
|
||||
}
|
||||
|
||||
@@ -10166,7 +10174,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
const normalizedText = textValue.trim();
|
||||
const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
|
||||
if (!normalizedText) return fail('empty_message', '运行中插入内容不能为空。');
|
||||
if (normalizedText.startsWith('/')) return fail('codexapp_running_slash_unsupported', 'Codex App 运行中暂不支持 slash 指令插入。');
|
||||
if (!options.unknownSlash && normalizedText.startsWith('/')) return fail('codexapp_running_slash_unsupported', 'Codex App 运行中暂不支持 slash 指令插入。');
|
||||
if (attachments.length > 0) return fail('codexapp_running_attachment_unsupported', 'Codex App 运行中插入暂不支持图片附件。');
|
||||
if (!entry.threadId || !entry.turnId) return fail('codexapp_turn_not_ready', 'Codex App 当前 turn 尚未准备好,请稍后再插入。');
|
||||
if (!codexAppClient || !codexAppClient.isRunning()) return fail('codexapp_server_not_running', 'Codex app-server 未运行,无法插入。');
|
||||
|
||||
Reference in New Issue
Block a user