diff --git a/.planning/2026-08-16-goal-bubble-rendering/findings.md b/.planning/2026-08-16-goal-bubble-rendering/findings.md index 2a7f12e..df4ed93 100644 --- a/.planning/2026-08-16-goal-bubble-rendering/findings.md +++ b/.planning/2026-08-16-goal-bubble-rendering/findings.md @@ -71,3 +71,40 @@ - 完成后 `Goal active + 完整目标` 被渲染为居中、接近消息区宽度的大块磨砂面板。 - 长正文包含多行约束与反引号,居中排版和纯文本渲染明显降低可读性。 - 目标视觉应为右侧普通用户气泡;状态反馈应保持短小,不重复正文。 +- 用户在真实桌面界面确认 Goal 已显示为普通用户气泡,但缺少明确的“目标模式”身份标签。 +- `/goal` 由服务端 Slash/RPC 旁路处理,不会先进入模型回合,因此不能依赖模型主动调用 `ccweb_set_title`。 +- 当前会话中直接调用 `ccweb_set_title` 成功,证明 MCP 本身可用;缺口位于 `/goal` 旁路没有标题联动,而不是标题 MCP 故障。 +- 本轮开始时 `public/app.js`、`public/style.css` 与 `server.js` 没有未提交差异;工作树中的 Codex App 重试改动属于其他任务,必须避开。 +- `.planning/.active_plan` 指向另一任务,本轮继续显式维护 `.planning/2026-08-16-goal-bubble-rendering/`,不改全局 active plan。 +- 主题架构要求视觉修复只命中用户指出的消息层级;Goal 标签应使用专用 selector,不能扩大到普通用户气泡或其他卡片。 +- CSS 接入需注意共享暗色 `:is()` 的 specificity,并在最终验收中核对真实 computed style;自动化结构断言不能替代浏览器视觉确认。 +- 修改静态 CSS/JS 后需检查 cache bust 与服务实际加载版本;本次不新增主题资产,也不改 welcome 双路径。 +- Trellis 当前没有 `.trellis/.current-task`,现有任务列表也没有 Goal 标记/标题修复任务;进入代码阶段前需建立任务资料并提供给实现与检查代理。 +- Trellis 要求 Codex 的实现与质量检查由独立子代理执行;当前运行时没有名为 `trellis-implement`/`trellis-check` 的自定义角色,因此后续使用通用 worker,并在任务提示中显式要求读取 Trellis PRD、上下文清单和相关规范。 +- `home-cc-web` 索引为 `ready`,当前 6444 个节点、14582 条边,可直接用于符号级定位。 +- 精确命中 `createMsgElement()`、`buildMsgElement()`、`persistCodexAppGoalDisplayMessage()`、`handleCodexAppGoalSlashCommand()`、`isTitleLockedByUser()` 与 `setCurrentConversationTitle()`。 +- `setCurrentConversationTitle()` 已具备标题历史、持久化、`session_renamed` 推送和 `broadcastSessionList()`,但它将来源写为 `llm`;Goal 自动派生标题需要复用相同广播契约并保留 `derived` 来源语义。 +- 手动重命名路径会设置 `titleSource: 'manual'`,已有 `isTitleLockedByUser()` 可作为禁止 Goal 覆盖用户标题的第一道保护。 +- `buildMsgElement()` 会把完整消息对象作为 `meta` 传给 `createMsgElement()`;因此在 `createMsgElement()` 读取 `meta.ccwebGoalCommand.action === 'set'`,即可让实时与历史 Goal 消息共用同一标签渲染,无需额外历史迁移。 +- `persistCodexAppGoalDisplayMessage()` 当前在消息入栈后统一 `saveSession()`;最小写入方案是在同一保存前尝试派生默认标题,并把 `titleChanged` 返回给 Slash handler 负责发送 `session_renamed` 与列表广播。 +- 普通消息自动标题只在标题严格等于 `New Chat`/`Untitled` 时生效,取用户正文前 60 字并把换行替为空格;Goal 应沿用同一默认标题判定和长度上限,同时压平连续空白,避免多行 objective 生成杂乱标题。 +- `normalizeTitleHistory()` 只接受 `source: 'llm'`;现有普通消息的 `derived` 标题也不写 title history。因此 Goal 自动标题应保持 `titleSource: 'derived'` 并不伪造 LLM 标题历史事件,只推送 `session_renamed` 元数据和会话列表。 +- Trellis 相关上下文应覆盖前端组件/质量规范、后端质量规范和跨层契约指南;本次改动同时涉及 DOM/CSS、WebSocket 事件、会话持久化与集成回归。 +- 暗金荒野主题的真实 ID 是 `wasteland`,不是 `gilded-wasteland`;Goal 标签的主题适配必须限定 `html[data-theme='wasteland']`。 +- `index.html` 的 `app.js` 使用动态 `__CC_WEB_FRONTEND_ASSET_VERSION__`,JS 无需手工版本号;`style.css` 仍使用固定 `20260805-usage-loading-inline`,新增标签样式后应推进样式 cache bust,并同步既有回归契约中的精确版本断言。 +- 现有 Goal 集成回归集中在 `scripts/regression.js` 约 7135 行,已覆盖 display-only 元数据、稳定 request ID、重复请求和刷新持久化,适合在同一场景追加标题持久化/广播断言;前端 DOM 标签需要增加静态契约测试。 +- Goal thread 的 MCP 注入链路已确认:`ensureCodexAppGoalThread()` 调用 `codexAppThreadParams()`,后者通过 `codexAppThreadConfig()` 和 `listRuntimeMcpServerConfigs()` 写入 `mcp_servers.ccweb`;内置配置携带当前 `sourceSessionId`。 +- 用户怀疑“Goal 未注册 ccweb MCP”的根因不是注册缺失:`thread/goal/set` 是 app-server RPC,不是模型 turn/MCP 调用。工具可用性不会强制模型调用 `ccweb_set_title`,所以可靠标题仍应由服务端旁路直接派生。 +- 第一轮独立计划审查发现:仅广播 `session_list` 只会刷新侧栏,当前聊天顶部标题依赖 `session_renamed`。计划已补成“先向 viewers 发送 `session_renamed`,再广播列表”,回归也必须分别断言。 +- 第二轮独立计划复审已通过,无实施阻断项;建议将默认标题判定收敛为 helper,并把自动标题严格限定在 `/goal set` 专用路径。 +- 现有 Goal 集成场景复用了已经发送过多条普通消息的 `codexAppSession`,其标题早已不是默认值;自动标题正向回归需要另建一个尚为 `New Chat` 的 Codex App 会话,不能在原场景上做错误断言。 +- 当前 `wasteland` 用户气泡最大宽度为 `min(42%, 480px)`;标签必须是内容流内的紧凑元素,正文继续依赖现有 `word-break`/`pre-wrap`,不能增加固定宽度或绝对定位造成窄屏溢出。 +- 当前目标文件中只有 `scripts/regression.js` 带并发 Codex App 重试差异;实现必须只在 Goal/缓存断言附近追加,保留约 2446 和 main target 注册处的现有重试代码。 +- 本机可用 `/usr/bin/firefox`,虽未发现 Playwright/Puppeteer;可在服务重启条件满足后用 Firefox headless 做基础桌面/窄屏截图验收。 +- 当前 `127.0.0.1:8002` 返回 200 且 `Cache-Control: no-store`;在线 HTML 仍引用 `style.css?v=20260805-usage-loading-inline` 与动态 app.js 版本,CSS cache bust 更新后需重新检查实际响应。 +- 已完整读取主题验收清单;本次不新增主题/素材,因此资产、主题注册和 welcome 双路径条目不适用,但仍需执行消息 DOM 隔离、`wasteland` computed style、桌面/窄屏、缓存 URL、JS 回归与真实浏览器记录。 +- `sendSessionEventToViewers()` 原样转发 payload,不自动补 `sessionId`;Goal 自动标题事件必须显式携带 `type`、`sessionId`、`title` 和 `publicTitleMetadata()`。 +- `normalizeSession()` 已接受 `titleSource: 'derived'`;`VALID_TITLE_SOURCES` 包含 derived,因此 Goal 旁路可安全复用该来源而不修改清洗逻辑。 +- `truncateTextValue()` 超长时会追加持久化截断标记,不适合标题;Goal 标题应使用独立的 60 个 Unicode code point 截取,避免标题中出现换行标记。 +- 现有 mock 在 `thread/goal/set` 后会发 Goal 后台 turn;包含 `dynamic` 的普通 turn 会执行 `completeMcpToolTurn()`,其结果带 `hasCcwebMcpConfig`,可作为 Goal thread 注册 ccweb MCP 的集成证据。 +- 现有 Codex App 回归会话在发送多条普通消息后才进入旧 Goal 场景;新增默认标题测试应在同一服务器内创建独立的 `codexapp-goal-title` 会话,避免旧会话标题已被普通消息派生。 diff --git a/.planning/2026-08-16-goal-bubble-rendering/progress.md b/.planning/2026-08-16-goal-bubble-rendering/progress.md index c2bbbac..6ccbeaa 100644 --- a/.planning/2026-08-16-goal-bubble-rendering/progress.md +++ b/.planning/2026-08-16-goal-bubble-rendering/progress.md @@ -101,6 +101,48 @@ |-----------|-------|---------|------------| | 2026-08-16 | 仓库内 `todo-list-csv` 技能短路径不存在 | 1 | 使用技能清单中的实际安装路径 | +## Session: 2026-08-18 + +### 阶段 7:执行真实浏览器桌面与窄屏验收 + +- **Status:** complete(剩余验收已转移至阶段 14) +- Actions taken: + - 用户在真实桌面界面确认 Goal 正文已经显示为普通用户气泡。 + - 用户指出气泡缺少“目标模式”标记,并确认 Goal 会话标题没有自动更新。 + - 将新增缺陷纳入同一任务,最终窄屏和主题验收统一放到阶段 14。 + +### 阶段 9:定位 Goal 标记与自动标题链路 + +- **Status:** complete +- Actions taken: + - 执行 session catchup 并恢复上一轮 Goal 实施与验证记录。 + - 核对当前工作树,识别并隔离另一项 Codex App 重试修复的未提交改动。 + - 直接调用当前会话的 `ccweb_set_title` 成功,确认标题 MCP 可用;`/goal` 缺少的是服务端旁路联动。 + - 完整读取主题架构与经验教训,确认只为 Goal 用户消息增加专用标签样式,不扩散到普通气泡。 + - 完整读取 Trellis 工作流并确认当前没有活跃 Trellis task;将在实现前补齐任务资料与独立实现/检查环节。 + - 使用 codebase-memory 确认索引就绪,并定位 Goal 渲染、Slash 处理、标题锁定和标题广播函数。 + - 读取精确函数源码,确认 Goal 元数据可在统一消息渲染入口生成标签,标题可在 display-only 消息保存周期内派生并实时广播。 + - 交叉核对 CSS、缓存版本和现有 Goal 回归位置,确认主题 ID 为 `wasteland`,样式版本需随 CSS 修改推进。 + - 确认 Goal thread 已注入 `mcp_servers.ccweb`;标题未变化源于 `thread/goal/set` 不会自动触发工具,而非 MCP 注册缺失。 + +### 阶段 10:审查增量实施计划并修正缺口 + +- **Status:** complete +- Actions taken: + - 第一轮独立审查指出计划缺少 `session_renamed` 实时事件要求,仅广播列表会让顶部标题保持陈旧。 + - 已补充 `session_renamed` + `session_list` 双事件及其回归断言;第二轮完整复审通过。 + +### 阶段 11:实现目标模式气泡标记 + +- **Status:** in_progress +- Actions taken: + - Trellis 任务 `08-18-goal-mode-title` 已建立、启动并通过上下文校验。 + - 实施前重新核对目标文件差异,只有 `scripts/regression.js` 含另一任务改动,已记录不可覆盖区块。 + - 检查本地运行服务与浏览器能力:服务在线且 no-store,本机有 Firefox 可做后续 headless 验收。 + - 完整读取 `create-cc-web-theme` 验收清单,已将与本次消息层级相关的浏览器、缓存、响应式和隔离项纳入收尾门禁。 + - 进一步核对标题事件与清洗实现:事件 payload 需显式带 sessionId;derived 来源已被会话规范接受;标题截取不能复用带截断标记的持久化 helper。 + - 确认 mock 可验证 Goal thread 的 ccweb MCP 配置,并规划在现有 Codex App 回归中使用独立默认标题会话。 + ## 5-Question Reboot Check | Question | Answer | diff --git a/.planning/2026-08-16-goal-bubble-rendering/task_plan.md b/.planning/2026-08-16-goal-bubble-rendering/task_plan.md index 0eb2ad5..15cd60b 100644 --- a/.planning/2026-08-16-goal-bubble-rendering/task_plan.md +++ b/.planning/2026-08-16-goal-bubble-rendering/task_plan.md @@ -6,7 +6,7 @@ ## Current Phase -阶段 7:执行真实浏览器桌面与窄屏验收 +阶段 11:实现目标模式气泡标记 ## Phases @@ -57,10 +57,10 @@ ### 阶段 7:执行真实浏览器桌面与窄屏验收 -- [ ] 在桌面视口验证长 Goal 为用户气泡 -- [ ] 在窄屏验证换行、溢出和系统提示尺寸 -- [ ] 确认暗金荒野主题未再生成大块 Goal 系统面板 -- **Status:** in_progress +- [x] 用户在桌面真实界面确认长 Goal 已回到普通用户气泡 +- [x] 基于真实反馈记录新缺陷:缺少“目标模式”标记、标题未变化 +- [x] 将窄屏、主题和最终视觉验收转移至阶段 14 +- **Status:** complete ### 阶段 8:审查差异并清理临时任务记录 @@ -68,11 +68,50 @@ - [ ] 完成计划记录并删除本任务 TODO CSV - **Status:** pending +### 阶段 9:定位 Goal 标记与自动标题链路 + +- [x] 使用代码索引确认 Goal 消息渲染、标题派生、保存和广播入口 +- [x] 用源码与当前差异交叉验证修改边界,避开其他任务改动 +- [x] 确认 Goal thread 已注入 `mcp_servers.ccweb`,但 `thread/goal/set` 不会自动触发工具调用 +- **Status:** complete + +### 阶段 10:审查增量实施计划并修正缺口 + +- [x] 由独立计划审查员检查标记、自动标题与回归范围 +- [x] 修正会导致实现偏差或阻塞的计划问题并完成复审 +- **Status:** complete + +### 阶段 11:实现目标模式气泡标记 + +- [ ] 复用 `ccwebGoalCommand` 元数据给 Goal 用户消息增加专用类名和标签 +- [ ] 增加基础样式及暗金荒野主题适配,不改变普通用户气泡 +- **Status:** in_progress + +### 阶段 12:实现 Goal 新会话自动标题 + +- [ ] 仅在会话仍为默认标题时从 Goal objective 派生简短标题 +- [ ] 持久化派生标题并向当前 viewers 发送 `session_renamed`,再广播会话列表,不覆盖手动标题 +- **Status:** pending + +### 阶段 13:补充标记与标题回归测试 + +- [ ] 覆盖 Goal 标签 DOM、普通消息隔离、默认标题派生和手动标题保护 +- [ ] 同时覆盖标题磁盘持久化、`session_renamed` 与 `session_list` 实时更新契约 +- **Status:** pending + +### 阶段 14:运行语法、全量回归与视觉验收 + +- [ ] 执行语法检查、全量回归和差异格式检查 +- [ ] 检查桌面、窄屏及暗金荒野主题下的 Goal 标记视觉 +- [ ] 按验收清单记录真实浏览器能力与剩余限制 +- **Status:** pending + ## Key Questions 1. 设置型 `/goal` 气泡如何持久化而不进入普通模型对话链路? 2. 如何只改变 Goal 命令,不改变其他 Slash 命令的既有行为? 3. 长 Goal 在桌面与窄屏下是否仍保持普通用户气泡布局? +4. `/goal` 绕过模型时,如何可靠更新默认标题且不覆盖用户手动命名? ## Decisions Made @@ -84,6 +123,10 @@ | Goal 气泡使用独立展示消息路径 | 写入 `session.messages` 并发出 `session_message`,但不调用普通消息运行时,兼顾刷新恢复与防止重复模型执行 | | `clientMessageId/requestId` 是命令与消息去重键 | 传输重放时可识别同一次设置,避免重复气泡与重复 Goal RPC | | 校验前失败恢复草稿;RPC 已启动后失败保留气泡且不恢复草稿 | 保持即时提交记录,同时避免用户重试产生两条相同气泡 | +| Goal 标签复用 `ccwebGoalCommand` 元数据 | 历史消息与实时消息共用同一渲染入口,无需迁移持久化数据 | +| `/goal` 自动标题由服务端旁路直接派生 | Goal RPC 不经过模型,不能依赖模型主动调用 `ccweb_set_title` MCP | +| 只更新默认标题且保留 `titleSource: derived` | 避免覆盖手动标题,并允许后续模型/MCP继续优化标题 | +| Goal 标题变化同时发送 `session_renamed` 和 `session_list` | 前者更新当前聊天顶部标题,后者更新侧栏;缺一都会产生局部陈旧 UI | ## Errors Encountered diff --git a/.planning/repackage-and-push/progress.md b/.planning/repackage-and-push/progress.md index f0e7b66..f0b979a 100644 --- a/.planning/repackage-and-push/progress.md +++ b/.planning/repackage-and-push/progress.md @@ -14,3 +14,15 @@ - 已创建主提交 `2efb9d2`(`feat: improve conversation timeline and agent plans`);阶段 5 完成,准备推送 `main`。 - 同步进度后 amend 得到最终主提交 `33c9783`,已成功推送到 `origin/main`(`4d97446..33c9783`)。 - 六个阶段全部完成,准备删除本次临时 TODO CSV,并用收尾提交同步规划状态。 + +## 2026-08-18 + +- 用户要求再次执行 CentOS 7 baseline 单文件打包,并提交、推送当前工作树全部修改。 +- 按用户要求停止额外视觉/代码审计,仅执行发布技能规定的构建与轻量验证。 +- 当前分支为 `main`,远端为 `origin`;工作树中的已跟踪与未跟踪修改均在本次提交范围内。 +- PATH 中没有 Bun;已找到 `/tmp/ccweb-bun.vyBprA/node_modules/@oven/bun-linux-x64-baseline/bin/bun`,进入重新打包阶段。 +- 已用 baseline Bun 1.3.14 完成 `npm run build:single-exe`,重新生成发布目录与 tar.gz,进入发布包验证阶段。 +- 六项 JavaScript 语法检查通过;发布二进制 MCP `initialize` 返回有效 JSON-RPC;tar 清单可读。 +- 新发布包 SHA-256:`f14c108687b84699d6c4f2301363a36235702db51227da719492a4cdd156d48b`。 +- 已确认本次提交范围覆盖当前工作区全部已跟踪与未跟踪修改,进入统一暂存与提交阶段。 +- 已创建包含全部修改的主提交,进入 `origin/main` 推送阶段。 diff --git a/.planning/repackage-and-push/task_plan.md b/.planning/repackage-and-push/task_plan.md index 114e588..ad80d32 100644 --- a/.planning/repackage-and-push/task_plan.md +++ b/.planning/repackage-and-push/task_plan.md @@ -13,7 +13,7 @@ | 3. 执行发布包与语法验证 | complete | 技能规定的语法、MCP smoke、tar 清单检查通过 | | 4. 暂存当前工作区全部修改 | complete | `git add -A` 后无未暂存或未跟踪修改 | | 5. 创建包含全部修改的提交 | complete | 当前全部修改进入一个新提交 | -| 6. 推送当前分支并确认远端 | complete | 推送成功,当前分支与上游一致 | +| 6. 推送当前分支并确认远端 | in_progress | 推送成功,当前分支与上游一致 | ## 约束 @@ -28,3 +28,9 @@ | 错误 | 尝试 | 处理 | |---|---|---| | `bun` 不在 PATH | 1 | 按发布技能查找本机已有 baseline Bun | + +## 当前执行 + +- 日期:2026-08-18 +- 当前阶段:6. 推送当前分支并确认远端 +- 用户要求:再次打包;提交并推送当前全部修改;不做额外审计。 diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md index bcd5533..847c98b 100644 --- a/.trellis/spec/backend/error-handling.md +++ b/.trellis/spec/backend/error-handling.md @@ -32,7 +32,12 @@ Questions to answer: -(To be filled by the team) +### Codex App 过程错误与终态错误 + +- app-server 的 JSON-RPC 方法名为 `error` 时,不能仅凭方法名判定当前 turn 已结束;必须结合消息语义和后续终态事件处理。 +- 纯 `Reconnecting... N/M` 表示 provider 内部重连进度:应向客户端展示,但不得写入最终错误、清理 active turn 或启动新的 cc-web 业务重试。 +- `503 Service Unavailable`、`No available providers`、`service_unavailable_error` 等终态错误必须保留完整错误信息,再交给服务端重试分类。 +- 自动续接必须携带并校验原 `expectedThreadId`;恢复到不同 thread 时应停止,不能用新 thread 掩盖上下文丢失。 --- @@ -48,4 +53,5 @@ Questions to answer: -(To be filled by the team) +- 把每个 app-server `error` 通知都返回为 `done: true`,会在 provider 自身仍重连时提前关闭 turn,并与 cc-web 重试状态机形成交错重试。 +- 使用非锚定正则识别 `Reconnecting... N/M`,可能误吞同时包含最终 503 的组合错误;过程通知识别应匹配整条消息。 diff --git a/.trellis/tasks/08-18-codexapp-retry-resume/check.jsonl b/.trellis/tasks/08-18-codexapp-retry-resume/check.jsonl new file mode 100644 index 0000000..563d0a0 --- /dev/null +++ b/.trellis/tasks/08-18-codexapp-retry-resume/check.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/backend/error-handling.md","reason":"核验错误分类、重试上限与最终失败行为"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验后端测试与代码质量"} +{"file":".trellis/spec/frontend/state-management.md","reason":"核验重连状态、计数和任务终态一致性"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"核验前端测试与代码质量"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"核验跨层事件契约和恢复后的数据流"} diff --git a/.trellis/tasks/08-18-codexapp-retry-resume/implement.jsonl b/.trellis/tasks/08-18-codexapp-retry-resume/implement.jsonl new file mode 100644 index 0000000..e2b2795 --- /dev/null +++ b/.trellis/tasks/08-18-codexapp-retry-resume/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/backend/error-handling.md","reason":"约束临时服务错误、终态错误和重试边界的处理方式"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"约束后端实现与回归测试质量"} +{"file":".trellis/spec/frontend/state-management.md","reason":"约束重连计数和运行终态的前端状态管理"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"约束前端实现与测试质量"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"重试链路跨越 app-server、SSE/WebSocket 与前端状态机,需要校验边界契约"} diff --git a/.trellis/tasks/08-18-codexapp-retry-resume/prd.md b/.trellis/tasks/08-18-codexapp-retry-resume/prd.md new file mode 100644 index 0000000..a37899f --- /dev/null +++ b/.trellis/tasks/08-18-codexapp-retry-resume/prd.md @@ -0,0 +1,27 @@ +# 修复 Codex App 重试中断 + +## 问题 + +Codex App 遇到临时 503 或网络错误时,界面虽然连续显示自动重试与“从中断处继续”,但当前任务仍被终止。第五次附近还会先展示终端 503,再把重连计数重置为 `1/5`,造成状态与真实执行不一致。 + +## 需求 + +1. 可恢复错误进入统一的续跑状态,不提前关闭当前任务。 +2. 同一恢复周期的尝试次数单调递增,不因终端事件或传输重连错误重置。 +3. 每次自动重试必须真正续接原线程/任务,而不只是显示通知。 +4. 恢复成功后清理重试状态并继续流式输出。 +5. 达到重试上限后才进入明确失败终态,并保留可理解的错误原因。 + +## 验收标准 + +- 自动化测试复现“连续临时失败后仍续跑”的场景,并在修复前失败、修复后通过。 +- 自动化测试验证计数不会在同一恢复周期中从 `5/5` 错误回到 `1/5`。 +- 自动化测试验证成功恢复与耗尽重试两种终态。 +- 现有 Codex App 相关回归测试和静态检查通过。 + +## 非目标 + +- 不修改上游 provider 的可用性或调度策略。 +- 不把不可恢复的协议、鉴权或用户输入错误纳入无限重试。 +- 不重做现有视觉主题。 + diff --git a/.trellis/tasks/08-18-codexapp-retry-resume/task.json b/.trellis/tasks/08-18-codexapp-retry-resume/task.json new file mode 100644 index 0000000..0c0427f --- /dev/null +++ b/.trellis/tasks/08-18-codexapp-retry-resume/task.json @@ -0,0 +1,31 @@ +{ + "id": "codexapp-retry-resume", + "name": "codexapp-retry-resume", + "title": "修复 Codex App 重试中断", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-18", + "completedAt": "2026-08-18", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "lib/codex-app-runtime.js", + "scripts/mock-codex-app-server.js", + "scripts/regression.js", + ".trellis/spec/backend/error-handling.md" + ], + "notes": "区分 Codex App 重连过程通知与终端错误;定向及完整回归通过。", + "meta": {} +} diff --git a/.trellis/tasks/08-18-goal-mode-title/check.jsonl b/.trellis/tasks/08-18-goal-mode-title/check.jsonl new file mode 100644 index 0000000..2db10c7 --- /dev/null +++ b/.trellis/tasks/08-18-goal-mode-title/check.jsonl @@ -0,0 +1,7 @@ +{"file":".trellis/spec/frontend/index.md","reason":"核验前端检查清单和组件边界"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"核验标签 DOM、可访问性和普通消息隔离"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"核验缓存版本、前端回归和视觉限制说明"} +{"file":".trellis/spec/backend/index.md","reason":"核验后端会话处理检查清单"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验标题持久化、广播和回归覆盖"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"核验 DOM、WebSocket、持久化和会话列表的一致契约"} +{"file":".trellis/tasks/08-18-goal-mode-title/research/goal-message-title-chain.md","reason":"核验实现是否符合已确认链路与并行工作边界"} diff --git a/.trellis/tasks/08-18-goal-mode-title/implement.jsonl b/.trellis/tasks/08-18-goal-mode-title/implement.jsonl new file mode 100644 index 0000000..4605711 --- /dev/null +++ b/.trellis/tasks/08-18-goal-mode-title/implement.jsonl @@ -0,0 +1,7 @@ +{"file":".trellis/spec/frontend/index.md","reason":"加载前端开发检查清单与组件规范入口"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"约束 Goal 标签 DOM、可访问性与样式隔离"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"约束前端静态契约、回归和缓存版本质量"} +{"file":".trellis/spec/backend/index.md","reason":"加载后端开发检查清单与会话处理规范入口"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"约束会话持久化、标题保护与回归质量"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Goal 改动跨越 DOM、WebSocket 事件、会话持久化和列表广播"} +{"file":".trellis/tasks/08-18-goal-mode-title/research/goal-message-title-chain.md","reason":"提供已验证的 Goal 消息、标题、主题和并行修改边界"} diff --git a/.trellis/tasks/08-18-goal-mode-title/prd.md b/.trellis/tasks/08-18-goal-mode-title/prd.md new file mode 100644 index 0000000..22c01c7 --- /dev/null +++ b/.trellis/tasks/08-18-goal-mode-title/prd.md @@ -0,0 +1,32 @@ +# 修复 Goal 模式标记与自动标题 + +## 问题 + +设置型 `/goal <目标>` 已经以普通用户气泡持久化和展示,但气泡与普通消息完全一致,用户无法识别这是 Goal。与此同时,`/goal` 由服务端 Slash/RPC 旁路处理,不会先创建模型回合,因此模型没有机会主动调用 `ccweb_set_title`,默认标题会一直保留为 `New Chat` 或 `Untitled`。 + +## 需求 + +1. 仅对 `role: user` 且 `ccwebGoalCommand.action === 'set'` 的消息增加 Goal 专用 class,并在正文上方显示固定标签“目标模式”。 +2. 实时 `session_message` 与刷新后的历史消息必须走同一渲染入口;普通用户消息、控制型 `/goal` 命令和其他 display-only 消息不得出现该标签。 +3. 标签保持紧凑、可读,不把用户气泡重新变成大系统面板;提供基础样式和 `wasteland` 暗金荒野主题适配,并保证长正文和窄屏不溢出。 +4. 设置型 `/goal` 持久化展示消息时,仅在当前标题仍为 `New Chat`/`Untitled` 且 `titleSource` 不是 `manual` 时,从 objective 派生标题。 +5. 派生标题压平连续空白、截取最多 60 个字符,写入 `session.title` 与 `titleSource: 'derived'`;不得伪造只接受 `source: 'llm'` 的标题历史事件。 +6. 标题改变后立即保存,并向查看该会话的客户端发送 `session_renamed`,再广播最新 `session_list`;手动标题和任何非默认标题必须保持不变。 +7. 保持 Goal RPC、稳定请求 ID 去重、display-only 持久化、失败草稿语义和控制型 `/goal` 行为不变。 +8. 修改 `style.css` 后推进样式 cache-busting 版本,并同步相关回归断言。 + +## 验收标准 + +- 回归证明 Goal 消息生成专用 class 与“目标模式”标签,普通用户消息不会误命中。 +- 回归证明默认标题会从多行 Goal objective 派生、持久化为 `derived`,并实时发出 `session_renamed`/`session_list`。 +- 回归证明手动标题和已有非默认标题不会被 Goal 覆盖。 +- 重复同一 Goal 命令仍只产生一条展示消息、一次 Goal RPC,且不会重复改标题。 +- `node --check`、相关前端契约测试、`timeout 60s npm run regression` 与 `git diff --check` 通过。 +- 在可用的真实浏览器中检查桌面、窄屏和 `wasteland` 主题;若本机仍无浏览器能力,必须如实记录限制,不得以自动化替代视觉结论。 + +## 非目标 + +- 不让 `/goal` 额外启动普通模型 turn,也不为了改标题调用内部 HTTP/MCP 回路。 +- 不覆盖用户手动标题,不把 Goal 自动标题记作 LLM 标题历史。 +- 不重做普通用户气泡、系统消息、其他主题或 welcome 页面。 +- 不修改当前工作树中 Codex App 重试任务的文件和区块。 diff --git a/.trellis/tasks/08-18-goal-mode-title/research/goal-message-title-chain.md b/.trellis/tasks/08-18-goal-mode-title/research/goal-message-title-chain.md new file mode 100644 index 0000000..f073dc5 --- /dev/null +++ b/.trellis/tasks/08-18-goal-mode-title/research/goal-message-title-chain.md @@ -0,0 +1,22 @@ +# Goal 消息与标题链路 + +## 已确认入口 + +- `public/app.js`:`buildMsgElement()` 把完整消息对象作为 `meta` 传给 `createMsgElement()`,适合直接读取 `ccwebGoalCommand`,同时覆盖实时和历史消息。 +- `server.js`:`persistCodexAppGoalDisplayMessage()` 创建 `role: user`、`ccwebDisplayOnly: true`、带 `ccwebGoalCommand.action: 'set'` 的持久消息,并统一保存会话。 +- `server.js`:`handleCodexAppGoalSlashCommand()` 在普通消息运行时之前处理 `/goal`,因此不会触发模型主动调用 `ccweb_set_title`。 +- `server.js`:`ensureCodexAppGoalThread()` 复用 `codexAppThreadParams()`;`codexAppThreadConfig()` 会从 runtime MCP 配置写入 `mcp_servers.ccweb`。因此底层 Goal thread 已注册 ccweb MCP,缺口是 `thread/goal/set` 本身不是模型工具调用。 +- `server.js`:普通消息只在标题为 `New Chat`/`Untitled` 时取正文前 60 字并设置 `titleSource: 'derived'`。 +- `server.js`:`setCurrentConversationTitle()` 展示了 `session_renamed`、`publicTitleMetadata()` 和 `broadcastSessionList()` 的实时通知契约;该 MCP 路径使用 `titleSource: 'llm'`,不能直接复用其来源语义。 +- `server.js`:`handleRenameSession()` 将用户标题写为 `titleSource: 'manual'`;`isTitleLockedByUser()` 可阻止自动覆盖。 +- `server.js`:`normalizeTitleHistory()` 只保留 `source: 'llm'`,普通 `derived` 标题不进入标题历史。 + +## 视觉与缓存边界 + +- 暗金荒野主题 ID 为 `wasteland`。 +- Goal 标签只应命中专用 Goal user message selector,不能扩大到普通 `.msg.user`。 +- `app.js` 使用动态 `__CC_WEB_FRONTEND_ASSET_VERSION__`;`style.css` 使用固定 query,CSS 变化后需要推进版本并同步回归断言。 + +## 并行工作边界 + +当前工作树另有 Codex App 重试修复,涉及 `lib/codex-app-runtime.js`、`scripts/mock-codex-app-server.js`、`scripts/regression.js` 及 Trellis 规范文件。实现可在 `scripts/regression.js` 的 Goal 场景追加断言,但必须保留并发改动,不得重排或还原整文件。 diff --git a/.trellis/tasks/08-18-goal-mode-title/task.json b/.trellis/tasks/08-18-goal-mode-title/task.json new file mode 100644 index 0000000..1f6a0f4 --- /dev/null +++ b/.trellis/tasks/08-18-goal-mode-title/task.json @@ -0,0 +1,32 @@ +{ + "id": "goal-mode-title", + "name": "goal-mode-title", + "title": "修复 Goal 模式标记与自动标题", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-18", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "public/app.js", + "public/style.css", + "public/index.html", + "server.js", + "scripts/regression.js" + ], + "notes": "Goal 用户气泡增加模式标记;默认标题由服务端旁路派生,手动标题受保护。", + "meta": {} +} diff --git a/.trellis/workspace/shiyue/index.md b/.trellis/workspace/shiyue/index.md index 0d06f07..fa5413b 100644 --- a/.trellis/workspace/shiyue/index.md +++ b/.trellis/workspace/shiyue/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 0 -- **Last Active**: - +- **Total Sessions**: 1 +- **Last Active**: 2026-08-18 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~0 | Active | +| `journal-1.md` | ~37 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 1 | 2026-08-18 | 修复 Codex App 重试中断 | - | `main` | --- @@ -37,4 +38,4 @@ - Sessions are appended to journal files - New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions +- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/shiyue/journal-1.md b/.trellis/workspace/shiyue/journal-1.md index f73ca2f..dd30c63 100644 --- a/.trellis/workspace/shiyue/journal-1.md +++ b/.trellis/workspace/shiyue/journal-1.md @@ -4,3 +4,34 @@ > Started: 2026-04-24 --- + + +## Session 1: 修复 Codex App 重试中断 + +**Date**: 2026-08-18 +**Task**: 修复 Codex App 重试中断 +**Branch**: `main` + +### Summary + +区分 Reconnecting N/M 过程通知与终端 503,保留原 thread 续接重试;定向和完整回归通过。 + +### Main Changes + +(Add details) + +### Git Commits + +(No commits - planning session) + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/Goal 气泡显示 TO DO list.csv b/Goal 气泡显示 TO DO list.csv index 7c5954a..9869b6f 100644 --- a/Goal 气泡显示 TO DO list.csv +++ b/Goal 气泡显示 TO DO list.csv @@ -5,5 +5,11 @@ id,item,status,done_at,notes 4,将 Goal 同步和成功反馈改为短暂精简提示,DONE,2026-08-16T21:36:08+08:00,同步与成功反馈 transient 且成功文案不回显目标 5,补充 Goal 气泡及消息协议回归测试,DONE,2026-08-16T21:36:08+08:00,覆盖消息协议、元数据、去重与背景 turn 负向断言 6,运行语法、专项和全量回归,DONE,2026-08-16T21:36:08+08:00,node --check 与 npm run regression 均通过 -7,执行真实浏览器桌面与窄屏验收,IN_PROGRESS,, +7,执行真实浏览器桌面与窄屏验收,DONE,2026-08-18T18:25:00+08:00,用户已确认普通气泡生效;新增缺陷与剩余视觉验收转移至步骤 9-14 8,审查差异并清理临时任务记录,TODO,, +9,定位 Goal 标记与自动标题链路,DONE,2026-08-18T18:42:00+08:00,已确认渲染/标题/MCP 注入链路及并行修改边界 +10,审查增量实施计划并修正缺口,DONE,2026-08-18T18:49:00+08:00,补齐 session_renamed 后第二轮独立复审通过 +11,实现目标模式气泡标记,IN_PROGRESS,, +12,实现 Goal 新会话自动标题,TODO,, +13,补充标记与标题回归测试,TODO,, +14,运行语法、全量回归与视觉验收,TODO,, 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 de77473..2635ee5 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/codex-app-runtime.js b/lib/codex-app-runtime.js index a784863..eb616c2 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -429,6 +429,10 @@ function createCodexAppRuntime(deps = {}) { return safeStringifyPreview(value, 2000, { maxDepth: 3, maxArray: 10, maxKeys: 20 }); } + function isCodexAppReconnectNotice(value) { + return /^reconnecting(?:\.\.\.)?\s*\d+\s*\/\s*\d+\s*[.!]?$/i.test(codexAppErrorMessage(value).trim()); + } + function sendRuntime(entry, sessionId, payload) { wsSend(entry.ws, { ...payload, sessionId }); } @@ -1020,10 +1024,12 @@ function createCodexAppRuntime(deps = {}) { ? codexAppErrorMessage(params) : (params.message || params.title || ''); if (message) { - if (method === 'error') entry.lastError = message; + const reconnectNotice = method === 'error' && isCodexAppReconnectNotice(params); + if (method === 'error' && !reconnectNotice) entry.lastError = message; if (method === 'error' || shouldShowRuntimeNotice(method, message)) { sendRuntime(entry, sessionId, { type: 'system_message', message }); } + if (reconnectNotice) return { done: false, transient: true }; } return { done: method === 'error' }; } diff --git a/public/app.js b/public/app.js index 986a1c8..563cf9b 100644 --- a/public/app.js +++ b/public/app.js @@ -7979,11 +7979,13 @@ const isCrossConversation = !!meta.crossConversation; const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId); const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply; + const isGoalMessage = role === 'user' && meta?.ccwebGoalCommand?.action === 'set'; const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user'); - div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`; + div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}`; if (role === 'user') { div.id = `hapi-message-${resolvedMessageId}`; div.dataset.messageId = resolvedMessageId; + if (isGoalMessage) div.dataset.goalMode = 'true'; } if (role === 'system') { @@ -8109,6 +8111,13 @@ } if (role === 'user') { + if (isGoalMessage) { + const goalLabel = document.createElement('div'); + goalLabel.className = 'goal-message-label'; + goalLabel.textContent = '目标模式'; + goalLabel.setAttribute('aria-label', '目标模式'); + bubble.appendChild(goalLabel); + } if (content) { const textNode = document.createElement('div'); textNode.className = 'msg-text'; diff --git a/public/index.html b/public/index.html index 7d5b443..3a23a18 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + diff --git a/public/style.css b/public/style.css index d28d8d1..61fd990 100644 --- a/public/style.css +++ b/public/style.css @@ -2991,6 +2991,38 @@ html[data-theme='wasteland'] .advanced-search-panel { border-bottom-right-radius: 4px; padding-right: 42px; } +.msg.user.goal-message .msg-bubble { + display: flex; + flex-direction: column; + align-items: stretch; +} +.goal-message-label { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 5px; + max-width: 100%; + margin: -2px 0 7px; + padding: 2px 7px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 5px; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.86); + font-size: 11px; + font-weight: 700; + line-height: 1.35; + letter-spacing: 0; + white-space: nowrap; +} +.goal-message-label::before { + width: 6px; + height: 6px; + flex: 0 0 6px; + border-radius: 50%; + background: currentColor; + content: ''; + opacity: 0.82; +} .msg-mentions { display: flex; flex-wrap: wrap; @@ -9508,6 +9540,12 @@ html[data-theme='wasteland'] .msg.user .msg-bubble { box-shadow: inset 0 1px rgba(255, 230, 180, 0.03); } +html[data-theme='wasteland'] .msg.user.goal-message .goal-message-label { + border-color: rgba(196, 154, 90, 0.42); + background: rgba(185, 139, 75, 0.14); + color: #e0bd7a; +} + html[data-theme='wasteland'] :is(.msg.assistant, .msg.cross-conversation, .msg.cross-conversation-reply, .msg.system) .msg-bubble { background: rgba(5, 7, 7, 0.1); border-color: rgba(185, 139, 75, 0.16); diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index d5fe99d..4f0e79e 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -917,7 +917,7 @@ function startTurn(params) { message: 'Reconnecting... 1/5', }, }); - thread.activeTurnId = null; + emitCapacityError(thread, turnId); return { turn: { id: turnId, status: 'running', items: [] } }; } } diff --git a/scripts/regression.js b/scripts/regression.js index 0c15b48..10cdc6a 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -671,7 +671,7 @@ function assertFrontendSidebarCollapseContract() { 'Rich themes should provide isolated rail treatments on top of the shared semantic fallback' ); assert( - indexSource.includes('style.css?v=20260805-usage-loading-inline') + indexSource.includes('style.css?v=20260818-goal-mode-label') && indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Sidebar interaction assets should share the reviewed cache-busting version' ); @@ -1143,7 +1143,7 @@ function assertPlanListProgressContract() { assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet'); assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments'); - assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Plan progress CSS should use the current cache-busted URL'); + assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Plan progress CSS should use the current cache-busted URL'); assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Plan progress frontend logic should use the dynamic cache-busted URL'); } @@ -1259,7 +1259,7 @@ function assertFrontendGildedThemeContract() { assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels'); assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper'); assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences'); - assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); + assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Theme bundle app script should use the dynamic cache-busted asset URL'); } @@ -1513,7 +1513,7 @@ function assertFrontendWastelandThemeContract() { assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`); }); - assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); + assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Wasteland registration should share the dynamic cache-busted theme bundle URL'); } @@ -2446,6 +2446,71 @@ function assertCodexAppRuntimeSubAgentActivityContract() { assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name'); } +function assertCodexAppTransientReconnectContract() { + 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 entry = { ws: {}, toolCalls: [], fullText: '' }; + const sessionId = 'codexapp-transient-reconnect-session'; + + for (let attempt = 1; attempt <= 5; attempt += 1) { + const result = runtime.processCodexAppNotification(entry, { + method: 'error', + params: { message: `Reconnecting... ${attempt}/5` }, + }, sessionId); + assert(result?.done === false, 'Codex App reconnect progress must not terminate the active turn'); + assert(!entry.lastError, 'Codex App reconnect progress must not be persisted as the final error'); + } + + const finalResult = runtime.processCodexAppNotification(entry, { + method: 'error', + params: { + error: { + type: 'service_unavailable_error', + code: 'server_is_overloaded', + message: 'No available providers', + }, + }, + }, sessionId); + assert(finalResult?.done === true, 'Codex App service-unavailable error must terminate the failed turn'); + assert(/No available providers/.test(entry.lastError || ''), 'Codex App terminal error should remain available for retry classification'); + + const combinedEntry = { ws: {}, toolCalls: [], fullText: '' }; + const combinedResult = runtime.processCodexAppNotification(combinedEntry, { + method: 'error', + params: { message: 'Reconnecting... 5/5: unexpected status 503 Service Unavailable' }, + }, sessionId); + assert(combinedResult?.done === true, 'Reconnect text combined with a terminal 503 must still terminate the failed turn'); + assert(/503 Service Unavailable/.test(combinedEntry.lastError || ''), 'Combined terminal errors must remain available for retry classification'); + assert(sent.filter((message) => message.type === 'system_message').length === 7, 'Reconnect progress and terminal errors should each be forwarded as system messages'); +} + +function assertGoalModeTitleContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); + const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + + assert(frontendSource.includes("meta?.ccwebGoalCommand?.action === 'set'"), 'Goal messages should be identified from dedicated metadata'); + assert(frontendSource.includes("isGoalMessage ? ' goal-message' : ''"), 'Goal messages should receive a dedicated DOM class'); + assert(frontendSource.includes("goalLabel.textContent = '目标模式'"), 'Goal messages should render the visible mode label'); + assert(frontendSource.includes("div.dataset.goalMode = 'true'"), 'Goal messages should expose a stable goal-mode data attribute'); + assert(styleSource.includes('.msg.user.goal-message .msg-bubble'), 'Goal label styling should remain scoped to user Goal bubbles'); + assert(styleSource.includes('.goal-message-label'), 'Goal mode label should have a dedicated style'); + assert(styleSource.includes("html[data-theme='wasteland'] .msg.user.goal-message .goal-message-label"), 'Wasteland should provide a dedicated Goal label treatment'); + assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Goal label CSS should use the current cache-busted URL'); + assert(serverSource.includes('GOAL_DERIVED_TITLE_MAX_CHARS = 60'), 'Goal-derived titles should keep the 60-character limit'); + assert(serverSource.includes('function isDefaultConversationTitle(session)'), 'Goal title updates should use a default-title guard'); + assert(serverSource.includes('function applyDerivedGoalConversationTitle(session, objective)'), 'Goal title updates should stay in the /goal-specific server path'); + assert(serverSource.includes("type: 'session_renamed'"), 'Goal title updates should notify current viewers'); + assert(serverSource.includes('broadcastSessionList();'), 'Goal title updates should refresh the session list'); + assert(serverSource.includes('mcp_servers.${item.server}'), 'Codex App thread config should continue to inject runtime MCP servers'); +} + function assertFrontendPrimaryCodexAppUiContract() { const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); @@ -4902,7 +4967,7 @@ function assertAdvancedSessionSearchContract() { 'Existing sidebar search clear control contract should remain unchanged'); assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'), 'Advanced search workspace should expose stable panel and result hooks'); - assert(indexSource.includes('style.css?v=20260805-usage-loading-inline') + assert(indexSource.includes('style.css?v=20260818-goal-mode-label') && indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Advanced search CSS and frontend script should share the reviewed cache-bust'); @@ -5019,7 +5084,7 @@ function assertUsageStatisticsContract() { 'Usage dashboard should be a chat-main-local workspace'); assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'), 'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak'); - assert(indexSource.includes('style.css?v=20260805-usage-loading-inline') + assert(indexSource.includes('style.css?v=20260818-goal-mode-label') && indexSource.includes('vendor/echarts.min.js?v=5.6.0') && indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract'); @@ -5878,6 +5943,16 @@ async function main() { console.log('Sub-agent card routing regression checks passed.'); return; } + if (regressionTarget === 'codexapp-retry-runtime') { + assertCodexAppTransientReconnectContract(); + console.log('Codex App retry runtime regression checks passed.'); + return; + } + if (regressionTarget === 'goal-mode-title') { + assertGoalModeTitleContract(); + console.log('Goal mode/title regression checks passed.'); + return; + } throw new Error(`Unknown regression target: ${regressionTarget}`); } @@ -5897,6 +5972,8 @@ async function main() { assertPlanListProgressContract(); assertFrontendSubagentCardMetadataContract(); assertCodexAppRuntimeSubAgentActivityContract(); + assertCodexAppTransientReconnectContract(); + assertGoalModeTitleContract(); assertFrontendPrimaryCodexAppUiContract(); assertSetTitleMcpContract(); assertSessionItemTooltipContract(); @@ -6954,6 +7031,87 @@ async function main() { const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd); assert(codexAppSession.model === 'gpt-5.5(max)', 'Codex App new_session should preserve the max default Codex model'); + const codexAppGoalTitleCwd = path.join(tempRoot, 'codexapp-goal-title'); + mkdirp(codexAppGoalTitleCwd); + ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppGoalTitleCwd, mode: 'yolo' })); + const codexAppGoalTitleSession = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppGoalTitleCwd + )); + assert(codexAppGoalTitleSession.title === 'New Chat', 'A new Goal title fixture should start with the default title'); + const goalTitleObjective = ' 修复 Goal 自动标题\n并保留 ccweb MCP 注册以及窄屏标签 '; + const goalTitleExpected = goalTitleObjective.replace(/\s+/g, ' ').trim().slice(0, 60); + ws.send(JSON.stringify({ + type: 'message', + text: `/goal${goalTitleObjective}`, + sessionId: codexAppGoalTitleSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + requestId: 'regression-goal-title-request', + clientMessageId: 'regression-goal-title-message', + })); + const goalTitleBubble = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && + msg.sessionId === codexAppGoalTitleSession.sessionId && + msg.message?.id === 'regression-goal-title-message' + )); + assert(goalTitleBubble.message.ccwebGoalCommand?.action === 'set', 'Goal title fixture should persist dedicated Goal metadata'); + const goalTitleRenamed = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_renamed' && + msg.sessionId === codexAppGoalTitleSession.sessionId && + msg.title === goalTitleExpected + )); + assert(goalTitleRenamed.titleSource === 'derived', 'Goal default title should be marked as derived'); + const goalTitleList = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + msg.sessions.some((item) => ( + item.id === codexAppGoalTitleSession.sessionId && + item.title === goalTitleExpected && + item.titleSource === 'derived' + )) + )); + assert(goalTitleList.sessions.some((item) => item.id === codexAppGoalTitleSession.sessionId && item.title === goalTitleExpected), 'Goal derived title should refresh the session list'); + await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /正在同步 Goal/.test(msg.message || '')); + await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal active/.test(msg.message || '')); + await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal background output/.test(msg.text || '')); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppGoalTitleSession.sessionId); + const storedGoalTitleSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppGoalTitleSession.sessionId}.json`), 'utf8')); + assert(storedGoalTitleSession.title === goalTitleExpected, 'Goal derived title should persist to the session file'); + assert(storedGoalTitleSession.titleSource === 'derived', 'Persisted Goal title should retain derived source'); + assert(!storedGoalTitleSession.titleHistory?.some((event) => event.source === 'derived'), 'Goal derived title should not fabricate an LLM title history event'); + + ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic goal mcp config probe', sessionId: codexAppGoalTitleSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const goalMcpConfigProbe = await nextMessage(messages, ws, (msg) => ( + msg.type === 'tool_end' && + msg.sessionId === codexAppGoalTitleSession.sessionId && + msg.toolUseId === 'mcp-ccweb-list' + )); + assert(/"hasCcwebMcpConfig": true/.test(goalMcpConfigProbe.result || ''), 'Goal thread should retain ccweb MCP configuration after thread/goal/set'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppGoalTitleSession.sessionId); + + ws.send(JSON.stringify({ type: 'rename_session', sessionId: codexAppGoalTitleSession.sessionId, title: '手动 Goal 标题' })); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_renamed' && + msg.sessionId === codexAppGoalTitleSession.sessionId && + msg.title === '手动 Goal 标题' + )); + ws.send(JSON.stringify({ + type: 'message', + text: '/goal second objective must not overwrite manual title', + sessionId: codexAppGoalTitleSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + requestId: 'regression-goal-manual-request', + clientMessageId: 'regression-goal-manual-message', + })); + await nextMessage(messages, ws, (msg) => msg.type === 'session_message' && msg.message?.id === 'regression-goal-manual-message'); + await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /正在同步 Goal/.test(msg.message || '')); + await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal active/.test(msg.message || '')); + await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal background output/.test(msg.text || '')); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppGoalTitleSession.sessionId); + const storedManualGoalTitleSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppGoalTitleSession.sessionId}.json`), 'utf8')); + assert(storedManualGoalTitleSession.title === '手动 Goal 标题', 'Goal must not overwrite a manually renamed title'); + assert(storedManualGoalTitleSession.titleSource === 'manual', 'Manual Goal title protection should retain manual source'); + 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'); assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill'); diff --git a/server.js b/server.js index c04302d..c70244b 100644 --- a/server.js +++ b/server.js @@ -819,6 +819,8 @@ const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']); const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']); const VALID_TITLE_SOURCES = new Set(['derived', 'llm', 'manual', 'system']); const MCP_CONVERSATION_TITLE_MAX_CHARS = 120; +const GOAL_DERIVED_TITLE_MAX_CHARS = 60; +const DEFAULT_CONVERSATION_TITLE_VALUES = new Set(['new chat', 'untitled']); const MCP_PROMPT_TITLE_MAX_CHARS = 160; const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000; const MCP_PROMPT_QUESTION_MAX_COUNT = 10; @@ -8102,9 +8104,10 @@ function persistCodexAppGoalDisplayMessage(session, command, source = {}) { }, }; session.messages.push(message); + const titleUpdate = applyDerivedGoalConversationTitle(session, command.objective); session.updated = timestamp; saveSession(session); - return { message, created: true, duplicate: false }; + return { message, created: true, duplicate: false, titleUpdate }; } async function ensureCodexAppGoalThread(session) { @@ -8214,6 +8217,15 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) { sessionId: session.id, message: goalDisplayMessage, }); + if (persisted.titleUpdate?.changed) { + sendSessionEventToViewers(session.id, { + type: 'session_renamed', + sessionId: session.id, + title: session.title, + ...publicTitleMetadata(session), + }); + broadcastSessionList(); + } } catch (err) { sendGoalSystemMessage(ws, `Goal failed: ${err?.message || err}`, { sessionId: session.id }, { preserveComposerDraft: true }); return; @@ -8552,6 +8564,31 @@ function isTitleLockedByUser(session) { return String(session?.titleSource || '').trim().toLowerCase() === 'manual'; } +function isDefaultConversationTitle(session) { + if (!session || isTitleLockedByUser(session)) return false; + const normalized = String(session.title || '').replace(/\s+/g, ' ').trim().toLowerCase(); + return !normalized || DEFAULT_CONVERSATION_TITLE_VALUES.has(normalized); +} + +function deriveGoalConversationTitle(objective) { + const normalized = String(objective || '').replace(/\s+/g, ' ').trim(); + return [...normalized].slice(0, GOAL_DERIVED_TITLE_MAX_CHARS).join(''); +} + +function applyDerivedGoalConversationTitle(session, objective) { + if (!isDefaultConversationTitle(session)) { + return { changed: false, title: session?.title || 'Untitled' }; + } + const title = deriveGoalConversationTitle(objective); + if (!title) return { changed: false, title: session?.title || 'Untitled' }; + const previousTitle = session.title || 'Untitled'; + const changed = previousTitle !== title + || String(session.titleSource || '').trim().toLowerCase() !== 'derived'; + session.title = title; + session.titleSource = 'derived'; + return { changed, title, previousTitle }; +} + function publicTitleMetadata(session) { return { titleSource: String(session?.titleSource || '').trim().toLowerCase() || null, diff --git a/重新打包并提交推送 TO DO list.csv b/重新打包并提交推送 TO DO list.csv new file mode 100644 index 0000000..08c3963 --- /dev/null +++ b/重新打包并提交推送 TO DO list.csv @@ -0,0 +1,7 @@ +id,item,status,done_at,notes +1,确认 Bun baseline 构建器与 Git 推送目标,DONE,2026-08-18T21:26:00+08:00,main 分支;origin 远端;已找到本地 baseline Bun +2,重新生成 CentOS 7 baseline 发布包,DONE,2026-08-18T21:30:00+08:00,baseline Bun 1.3.14 构建成功 +3,执行发布包与语法验证,DONE,2026-08-18T21:32:00+08:00,语法检查、MCP initialize、tar 清单均通过;SHA-256 f14c1086... +4,暂存当前工作区全部修改,DONE,2026-08-18T21:35:00+08:00,全部工作区修改统一纳入暂存范围 +5,创建包含全部修改的提交,DONE,2026-08-18T21:37:00+08:00,全部 27 个变更路径已进入统一提交 +6,推送当前分支并确认远端,IN_PROGRESS,,