diff --git a/.planning/2026-10-10-ended-turn-steer-retry/findings.md b/.planning/2026-10-10-ended-turn-steer-retry/findings.md new file mode 100644 index 0000000..b8d7af4 --- /dev/null +++ b/.planning/2026-10-10-ended-turn-steer-retry/findings.md @@ -0,0 +1,32 @@ +# 调研记录 + +## 用户现象 + +- Codex App 的 ccweb 表单答案在点击发送时,上一轮实际上已经结束,但 UI 仍按运行中消息走插入。 +- 失败卡片显示“插入失败”;点击“重试”仍被当成原 turn 插入,无法把答案作为新一轮发送。 + +## 代码证据 + +- `handleCcwebPromptUserResponse` 将答案交给 `handleMessage`;若 `activeCodexAppTurns` 仍有条目,`handleMessage` 无条件转入 `handleCodexAppSteerMessage`。 +- `handleCodexAppSteerMessage` 已能处理 app-server 返回 `no active turn to steer` 的部分竞态,但 `waitForSteerReady` 超时会直接失败。 +- 前端 `retryCodexAppSteerMessage` 在 `!isGenerating || !currentSessionRunning` 时直接报“当前对话已结束,无法在原 turn 中重试插入”,正好阻断用户要求的新 turn 降级。 +- 失败项保留 `clientMessageId`,服务端普通 `handleMessage` 可用该 ID 避免重复持久化;前端需要把临时卡片提升为普通用户消息并启动生成态。 + +## 设计决策 + +- 运行中的 turn 继续使用 steer;只有本地运行态已结束或 steer 明确确认旧 turn 不存在时,才转普通 `handleMessage` 新开 turn。 +- 新 turn 重试沿用原 `clientMessageId`、正文和附件,避免重复消息;转为普通消息后移除失败操作区并提交消息序号。 +- 不把 expectedTurnId 不匹配、网络错误或附件失效等真实失败泛化成新 turn。 + +## 基线验证 + +- `node scripts/failed-insert-card-unit.js` 当前通过,但现有断言明确要求 turn 结束后禁止重试,正是需要改掉的旧行为。 +- `node scripts/regression.js --target codexapp-stale-running` 当前通过,覆盖了 `no active turn to steer` 的旧 turn 替换;尚未覆盖 `waitForSteerReady` 超时和前端失败卡片转普通新 turn。 +- `node --check server.js && node --check public/app.js` 当前通过。 + +## 实现与回归发现 + +- 本机 app-server 二进制包含带 expectedTurnId 的无活动 turn 文案,原先只匹配 `no active turn to steer` 的恢复条件不足。 +- `turn/completed` 可以先于 steer 错误到达;恢复需要允许本地 entry 已清除,并把旧轮助手消息放回新用户消息之前。 +- 已补充普通文字、完成先到、表单答案的端到端回归;截至本阶段均通过。 +- 前端仍需等待明确发送成功回执后提交临时消息,并通过稳定消息 ID 合并普通 `session_message`,才能同时避免永久 pending 和重复气泡。 diff --git a/.planning/2026-10-10-ended-turn-steer-retry/progress.md b/.planning/2026-10-10-ended-turn-steer-retry/progress.md new file mode 100644 index 0000000..6ab74dd --- /dev/null +++ b/.planning/2026-10-10-ended-turn-steer-retry/progress.md @@ -0,0 +1,14 @@ +# 进度记录 + +## 2026-10-10 + +- 已确认 codebase-memory 项目 `home-cc-web` 索引可用。 +- 已定位前端 `retryCodexAppSteerMessage` 的结束会话拦截,以及后端 `handleCodexAppSteerMessage` 的 ready 超时失败分支。 +- 工作区包含其他未提交的会话历史改动,本任务将只修改插入/重试相关代码和回归测试。 +- 基线定向回归和语法检查均通过;现有前端单测中的“turn 已结束时不能重试”断言与用户需求相反,下一步先改为新 turn 重试回归。 +- 已修复无活动 turn 文案识别、完成先到后的旧输出顺序和自动续发,增加对应 mock、表单答案与竞态回归。 +- 最新定向回归:`timeout 60s node scripts/regression.js --target codexapp-stale-running` 通过。 +- 正在完善前端重试回执、普通消息合并及失败后的再次重试;此前乐观提交不应作为最终成功依据。 +- 已完成 `waitForSteerReady` 的 replaced/timeout 分支、entry 已清理时的直接重分发、完成先到竞态的历史重排,以及前端重试按新 turn 发送与 sent 回执提交。 +- 已复核关键差异:`reorderStaleCodexAppTurnMessages` 的 messageIndex 推送、`completeCodexAppSteerMessage` 的索引提交、`updateCodexAppSteerMessage` 返回值调用方,均符合预期。 +- 部署状态:重启前检查到除当前对话外仍有 2 个 running 会话(`826deaa0`、`2f1134fe`),按 AGENTS.md 暂缓重启,等其结束后再执行 `pm2 restart ccweb --update-env`。 diff --git a/.planning/2026-10-10-ended-turn-steer-retry/task_plan.md b/.planning/2026-10-10-ended-turn-steer-retry/task_plan.md new file mode 100644 index 0000000..667aa12 --- /dev/null +++ b/.planning/2026-10-10-ended-turn-steer-retry/task_plan.md @@ -0,0 +1,40 @@ +# 会话结束后插入转新对话计划 + +## 目标 + +当 Codex App 的上一轮已经结束、但页面或服务端仍把本轮视为可插入时,表单答案和普通跟进消息都应自动按新一轮对话处理;失败卡片点击“重试”也应真正发送,不能继续被“仅允许插入原 turn”拦截。 + +## 阶段 + +1. [completed] 复现并定位会话结束后的发送与失败重试链路 +2. [completed] 补充结束会话后重试自动新开对话的回归用例 +3. [completed] 修复前端失败项重试与后端旧轮结束竞态 +4. [completed] 运行定向测试、语法检查和完整回归 +5. [completed] 复核差异并清理临时跟踪文件 + +## 验收标准 + +- 会话结束后点击失败卡片的“重试”会启动普通新 turn,答案只进入一次历史。 +- 发送时本地运行态落后于 app-server 时,不再把用户答案永久留在“插入失败”。 +- 仍在运行的 turn 继续走 `turn/steer`,expectedTurnId 不匹配等真实错误不被错误降级。 +- 普通文本和带附件的表单答案均有回归覆盖,且定向测试、语法检查和完整回归通过。 + +## 约束 + +- 保留工作区中其他会话历史改动,不覆盖现有任务目录或 CSV。 +- 代码定位优先使用 `codebase-memory-mcp`;FastCtx 只用于读取、编辑和验证本地文件。 +- 不重启服务,除非重启前确认除当前会话外没有其他 `running` 会话。 +- 新增注释和文档使用简体中文。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 表单回归读取到旧轮 done,导致提前检查新输出 | 1 | 等待携带新答案助手消息的 done 后再验收历史 | + +## 收尾记录 + +- 完整回归 `npm run regression` exit 0;定向回归与三个单测脚本、语法检查全部通过。 +- 独立后端审查新增 `scripts/codexapp-steer-recovery-unit.js`(5 项通过),覆盖 ready 等待期间旧轮结束、completion 先到、保存失败与错误分类。 +- 部署:重启前检查到除当前对话外仍有 running 会话(`826deaa0`、`2f1134fe`),按约束暂缓重启;待其结束后执行 `pm2 restart ccweb --update-env` 生效。 +- 临时跟踪文件:本任务 CSV 已按 todo-list-csv 流程清理。 diff --git a/.planning/2026-10-10-mermaid-png-download/browser-report.json b/.planning/2026-10-10-mermaid-png-download/browser-report.json new file mode 100644 index 0000000..267c0ca --- /dev/null +++ b/.planning/2026-10-10-mermaid-png-download/browser-report.json @@ -0,0 +1,112 @@ +{ + "evidenceDir": "/tmp/cc-web-mermaid-export-dirEq0", + "cases": [ + { + "name": "flowchart", + "expected": { + "viewBox": "-8 -8 289 56.600006103515625", + "width": "100%", + "height": null, + "foreignObjects": 5, + "labelBoxes": [ + { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "text": "" + }, + { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "text": "" + }, + { + "x": 0, + "y": 0, + "width": 32, + "height": 25.600006103515625, + "text": "开始" + }, + { + "x": 0, + "y": 0, + "width": 64, + "height": 25.600006103515625, + "text": "处理订单" + }, + { + "x": 0, + "y": 0, + "width": 32, + "height": 25.600006103515625, + "text": "完成" + } + ] + }, + "size": { + "width": 578, + "height": 114 + }, + "filename": "mermaid-1.png", + "mime": "image/png" + }, + { + "name": "sequence", + "expected": { + "viewBox": "-50 -10 450 287", + "width": "100%", + "height": null, + "foreignObjects": 0, + "labelBoxes": [] + }, + "size": { + "width": 900, + "height": 574 + }, + "filename": "mermaid-2.png", + "mime": "image/png" + }, + { + "name": "html-label", + "expected": { + "viewBox": "-8 -8 95 172.8000030517578", + "width": "100%", + "height": null, + "foreignObjects": 3, + "labelBoxes": [ + { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "text": "" + }, + { + "x": 0, + "y": 0, + "width": 64, + "height": 51.19999694824219, + "text": "中文标签第二行" + }, + { + "x": 0, + "y": 0, + "width": 32, + "height": 25.600006103515625, + "text": "结束" + } + ] + }, + "size": { + "width": 190, + "height": 346 + }, + "filename": "mermaid-3.png", + "mime": "image/png" + } + ], + "browser": "156.0" +} diff --git a/.planning/2026-10-10-mermaid-png-download/findings.md b/.planning/2026-10-10-mermaid-png-download/findings.md new file mode 100644 index 0000000..9b87203 --- /dev/null +++ b/.planning/2026-10-10-mermaid-png-download/findings.md @@ -0,0 +1,15 @@ +# 定位记录 + +- 用户要求 Mermaid 下载图片,默认 2 倍;使用 PNG 无损格式。 +- 项目使用 Node.js,前端主入口 public/app.js;Mermaid 相关文件还有 public/index.html、public/style.css。 +- codebase-memory-mcp 的 home-cc-web 索引状态为 ready。 +- 工作区已有多项其他任务改动,包括 public/app.js,需要局部修改。 +- 已定位 serializeMermaidSvg、getMermaidSvgText、svgTextToPngBlob 和 ccDownloadMermaidDiagram;已有复制 PNG 的转换能力,应复用。 +- codebase-memory 的 get_code_snippet/trace_path 连接关闭(Transport closed),后续使用已命中的文件范围进行本地阅读。 +- 本机有 Firefox 和 geckodriver,可使用隔离浏览器页面验证真实导出。 +- 导出改为 svgTextToPngBlob(svgText, 2),输出 .png;复制默认保持 1 倍。 +- 独立代码审查补充使用 UTF-8 Data URL 作为 SVG 图片来源,避开部分浏览器对 Blob + foreignObject 的 Canvas 安全限制;下载按钮 title 和 aria-label 明确 PNG、2 倍。 +- 第一轮成功验证(Firefox 156.0,使用倍率实现与 Blob 来源):流程图 578×114、时序图 900×574、中文换行图 190×346,MIME、PNG 文件头和 2 倍尺寸均通过,实际下载文件存在;目视检查中文与换行完整。 +- 因独立审查随后修改了图片来源,正在用最终 Data URL 实现重复必要的真实浏览器验证。 +- 最终 Data URL 实现复验通过,三类图表尺寸与上述结果一致;PNG 中中文与 HTML 换行可见。浏览器报告已保存为本目录 browser-report.json;原始图片保留于 /tmp/cc-web-mermaid-export-dirEq0/。 +- 页面入口通过 computeFrontendAssetVersion 按文件内容计算资源版本,修改前端后刷新页面即可加载最新代码,无需重启服务。 diff --git a/.planning/2026-10-10-mermaid-png-download/progress.md b/.planning/2026-10-10-mermaid-png-download/progress.md new file mode 100644 index 0000000..09fff90 --- /dev/null +++ b/.planning/2026-10-10-mermaid-png-download/progress.md @@ -0,0 +1,13 @@ +# 进度 + +- 2026-10-10:读取仓库规范、前端质量规范和规划技能;检查工作区与索引状态。 +- 已建立与 update_plan 同步的三阶段 CSV,后续先定位导出函数。 +- 定位完成:下载目前直接保存 SVG;svgTextToPngBlob 已用于复制图片,可以扩展倍率后复用。 +- 独立计划审查通过。进入实现阶段,父代理准备真实 Firefox 导出验证。 +- 已编写 /tmp/cc-web-mermaid-export-browser.cjs:使用项目真实 Mermaid 函数与 10.9.1 资源,在隔离 Firefox 页面点击按钮并校验导出文件头、宽高、中文和切换行为。 +- 实现完成:转换函数 scale 默认 1,下载显式 2;下载文件扩展名 .png;保留错误处理与按钮恢复。node --check public/app.js 通过。 +- 独立代码审查完成:转换改用 Data URL,按钮无障碍标签与提示明确格式和倍率。语法与 diff 空白检查通过。 +- 浏览器三类图表首轮全部通过,抽查 PNG 中中文与箭头完整;最终 Data URL 版本正在复验,证据保存于临时隔离目录。 +- 最终 Data URL 版本复验通过:3 类图表下载的 PNG MIME、文件头、固有尺寸 ×2、按钮恢复和源码切换均符合预期;真实下载文件存在,中文换行目视抽查通过。 +- 保存精简浏览器报告 browser-report.json;复核静态资源按内容计算版本,刷新即可生效,未重启服务。 +- 全部步骤完成,清理本任务 CSV,保留规划与验证记录。 diff --git a/.planning/2026-10-10-mermaid-png-download/task_plan.md b/.planning/2026-10-10-mermaid-png-download/task_plan.md new file mode 100644 index 0000000..ba4a682 --- /dev/null +++ b/.planning/2026-10-10-mermaid-png-download/task_plan.md @@ -0,0 +1,34 @@ +# Mermaid 图片下载 + +目标:气泡中的 Mermaid 下载 PNG 图片,默认导出尺寸为图表固有尺寸的 2 倍。 + +## 阶段 + +1. [completed] 定位 Mermaid 下载入口并确认导出约束。 +2. [completed] 将下载改为默认 2 倍 PNG 图片。 +3. [completed] 验证图片格式、尺寸及页面行为。 + +## 约束 + +- 沿用现有下载按钮与渲染流程。 +- 保留中文标签、图表样式和完整边界,导出比例不受页面缩放或设备像素比影响。 +- 工作区已有其他任务改动,public/app.js 只修改 Mermaid 导出相关区域。 +- 不重启服务,除非确有必要且没有其他运行中的会话。 +- 完成后清理本任务临时 CSV。 + +## 错误记录 + +- Codebase Memory 读取源码和调用链时 Transport closed;已完成符号定位,降级为已知范围的本地源码阅读,不重复调用失败工具。 +- Mermaid CDN 测试资源直连 25 秒、代理 30 秒未下载完;已切换可续传的后台下载,浏览器验证将使用本地同版本资源。 +- CDN 不支持 Range,取消续传,改为延长总下载上限的异步下载。 +- 首个实现代理出现 upstream encrypted continuation context 验证失败,未完成修改;已重新派发完整且简明的实现上下文。 +- 首轮浏览器验证总计 60 秒超时(Firefox 启动较慢);增加浏览器验证总时限并增加阶段输出,仅对浏览器测试调整时限。 +- 第二轮 Firefox 成功启动但加载页面超出 25 秒;确认主机负载 31,限制隔离浏览器内容进程数并增加页面加载时限。 + +## 完成记录 + +- 最终实现真实 Firefox 156.0 测试通过:流程图、时序图和中文 HTML 换行标签均成功下载 PNG;尺寸分别为 578×114、900×574、190×346,均为固有宽高的 2 倍。 +- PNG 文件头、MIME、文件名和按钮恢复通过;图形/代码切换正常;抽查导出图片中文、换行与箭头可见且完整。 +- node --check public/app.js、git diff --check -- public/app.js 通过。 +- trellis-update-spec 复核:此次只调整已有前端导出辅助函数和下载入口,没有新增跨层协议;具体导出约定保存在任务 PRD、findings 与 browser-report.json,无需扩充通用规范。 +- 无依赖变更、无服务重启、无提交。 diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index fd06c56..3500c71 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -20,6 +20,7 @@ This directory contains guidelines for backend development. Fill in each file wi | [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill | | [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill | | [Codex MCP 配置输出](./codex-mcp-config.md) | 旧类型配置兼容及 Codex 配置出口 | 已记录 | +| [完整历史保存](./session-history.md) | 完整档案、快照缓存、并发及分页契约 | 已记录 | --- diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index eb8f1af..8777537 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -38,6 +38,14 @@ Questions to answer: - 头部和尾部片段必须独立解析,禁止直接拼接后匹配,避免截断边界组合出文件中不存在的键值。 - 预览读取必须保持明确的字节上限;解析失败时按字段缺失安全降级,不能让单个损坏会话中断整个列表。 +### 完整历史与有界快照 + +- 具体签名、存储格式、失败矩阵及回归断言见 [完整历史保存契约](./session-history.md)。 +- 会话消息数、正文和工具内容的预览上限只能作用于快照缓存;成功保存的完整历史必须由 cc-web 自身持久化,不能依赖原生客户端日志继续存在。 +- 完整档案先原子提交,随后更新有界快照;完整档案失败时不得用裁剪快照覆盖上次状态。档案提交成功而快照失败时,读取和会话列表仍须能找到已提交档案。 +- 可信完整读取后的修改应保留清空、撤回和插入顺序;短预览及旧修订不能覆盖完整消息或丢掉后来追加的记录,正常并发保存不得静默漏记。 +- 旧版截断历史只有在确认原始记录对应边界后才能补入完整档案;新建档案本身不能证明旧历史已完整。 + --- ## Testing Requirements @@ -46,6 +54,7 @@ Questions to answer: - 部分 JSON 解析改动必须使用超过完整解析阈值的真实文件夹具,证明测试确实进入预览分支。 - 回归至少覆盖:嵌套同名键早于顶层键、顶层键位于尾部、字符串伪键与转义、`null`、字段跨截断边界、头尾拼接伪命中。 +- 完整历史回归须覆盖超过快照上限、长正文与工具结果、存储重开且原始日志不可用、短快照重复保存、正常并发修改、主动清空/删除、归档写入失败及快照缓存失败。 --- diff --git a/.trellis/spec/backend/session-history.md b/.trellis/spec/backend/session-history.md new file mode 100644 index 0000000..94c11f3 --- /dev/null +++ b/.trellis/spec/backend/session-history.md @@ -0,0 +1,100 @@ +# 完整历史保存与分页契约 + +## 1. 范围与触发 + +会话保存、读取、原始日志补齐、导入、运行结束及重启恢复涉及本契约。成功保存的正文与工具数据必须能从 cc-web 自身读取;预览大小限制只用于快照缓存和实时工具预览,不能限制完整历史及完成消息的阅读。 + +## 2. 函数签名 + +```js +createSessionHistoryStore({ sessionsDir, fs? }) +store.load(id) // 完整会话或 null;注册可信加载基线 +store.save(session) // { revision, complete, session, rebased };失败抛错 +store.markLegacy(session) // 注册旧快照来源与完整性状态 +store.markComplete(session, { reset? }) // 确认可靠补齐或主动清空;返回状态还原函数 +store.state(session) // 来源、修订与完整性;不暴露消息基线 +store.listIds() +store.revision(id) +store.remove(id) +store.saveTurn(id, state) // 完整 Codex App 运行态先原子提交 +store.loadTurn(id) // 优先于有界运行预览 +store.listTurnIds() +store.removeTurn(id) // 完整消息已提交或主动清空后才清理 +saveSession(session) // 完整档案提交成功为 true,否则 false +loadSession(id) // 优先完整档案;无档案才读取旧快照 +resolveSessionHistory(session) // { messages, source, available, recoverable } +``` + +## 3. 存储与响应契约 + +- `${SESSIONS_DIR}/_history/.json` 是持久提交点,格式为 `{ version: 1, revision, complete, resetRevision, session }`。`session` 包含完整可序列化字段,排除运行态句柄;正文、工具、数组和深度不使用预览限额。 +- `.json` 是有界快照缓存。`historyArchiveRevision` 位于顶层首字段,用来判断缓存是否过期;不得从嵌套同名键推断修订。 +- `CC_WEB_SESSION_PERSIST_MAX_MESSAGES`、`CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS` 等预览配置不能限制完整档案;真实完成和恢复路径也不得先裁剪消息再提交。 +- 可信来源保存在存储实例的 `WeakMap`,不能凭 JSON 的修订字段伪造。当前修订精确保存删除、清空和插入;旧修订根据加载基线合并互不冲突的修改,保持活跃消息及工具对象引用。 +- 归档提交顺序为临时文件写入、文件 `fsync`、原子 `rename`、推进基线,再更新快照。档案失败不得更新快照;档案已提交而快照失败仍返回成功。 +- 列表枚举快照与档案 ID 的并集。首次快照失败后,孤立档案仍须可读、可列出;缓存继续失败时使用内存有界预览。 +- Codex App 运行态写入 `_history/_turns/.json`,包含完整正文、工具及映射;有界运行态文件只作预览。重启枚举完整运行态,恢复消息提交成功后才清理,异常档案保留证据。 +- 用户提问必须保存成功后才启动 runtime 或接受 steer;完成、重启恢复及跨会话返回必须检查保存结果,失败保留可重试内容。CLI 恢复标识须区分回合,同一已提交回合不可因清理失败再次追加。 +- 确认原始边界后的旧前缀必须先进入自身档案。`complete=false` 保留旧缺口状态,不能因创建档案就宣称旧历史完整。 +- 响应的 `historyCursor` 是尚未展示的已有消息数;正数允许分页。`historyAvailable=false` 只说明旧来源待核实,不能阻断正文或用户目录。完整档案响应 `historySource='archive'`、`historyAvailable=true`。 + +## 4. 校验与错误矩阵 + +| 条件 | 行为 | +|---|---| +| 非字母、数字、下划线或连字符 ID | `history_invalid_id`,不写入 | +| 已有档案但传入未可信加载的短预览 | `history_untrusted_source`,保留已有档案 | +| 档案 JSON 或结构异常 | 读取失败,禁止回退短快照覆盖档案 | +| 旧修订缺可靠消息 ID | `history_stale_unidentified`,不覆盖新记录 | +| 旧清空请求遇到后来追加 | `history_stale_clear`,要求重新加载 | +| 清空已提交后旧对象再保存 | 允许无冲突元数据更新;禁止旧历史及线程复活 | +| 会话已删除或档案已移除 | `history_removed`,旧对象不能重新创建 | +| 完整档案写入失败 | `saveSession=false`,保留上次提交;调用方不得宣称保存成功并清理唯一未提交输出 | +| 完整档案成功、快照失败 | `saveSession=true`,记录缓存错误,重开从档案读取 | +| 原始日志缺失或边界未确认 | 仅返回自身已有消息,保留待核实状态,不猜测补入 | + +## 5. 正常、基本与失败场景 + +- 正常:650 条及长正文、125 条工具调用保存后,快照仍有界;存储重开和原始日志移开后,完整内容相等。 +- 基本:短会话重复保存后正文、顺序和稳定 ID 不变;主动删除、清空、撤回不会复活旧消息。 +- 失败:磁盘写入失败只保留上次提交及可重试的未提交内容;旧短预览不能作为新完整历史覆盖已有档案。 + +## 6. 必需回归与断言 + +- `scripts/session-history-store-unit.js`:实际生产保存和读取入口、超限内容、存储重开、两类写失败、孤立档案、并发及活跃引用、删除/清空/撤回、确认前缀先归档。 +- 真实完成与恢复路径使用超限正文和工具,断言归档原样读回;不能仅绕过生产入库函数直接构造消息数组。 +- 真实 runtime 事件覆盖纯工具、纯文本与结构块混合、Claude user/tool_result;恢复覆盖图片内容块、提交失败保留以及重复恢复幂等性。任务看板的克隆适配器需重新可信加载并只更新元数据。 +- `scripts/session-history-unit.js` 与 `scripts/history-frontend-unit.js`:57 条、首屏 12 条、剩余 45 条在来源待核实状态下仍全部可读,顺序正确、无重复,用户目录完成索引。 +- 隔离浏览器:确认旧前缀后移开夹具原始日志,重启测试服务、重新登录,再从 `archive` 分页到最早消息;继续运行、刷新及重连保持内容。 +- 单测设置 60 秒上限;真实会话和生产服务不能代替隔离夹具。 + +## 7. 错误与正确示例 + +```js +// 错误:把预览当成唯一提交,完整正文在写档案前已经裁剪。 +session.messages.push({ content: truncateTextValue(entry.fullText, previewLimit) }); +writeFileAtomicSync(sessionPath(session.id), buildSessionJsonForPersist(session).json); + +// 正确:保存完整值,归档先提交,再生成有界预览缓存。 +session.messages.push({ content: entry.fullText }); +const committed = store.save(session); +try { + const preview = buildSessionJsonForPersist({ ...committed.session, historyArchiveRevision: committed.revision }); + writeFileAtomicSync(sessionPath(session.id), preview.json); +} catch (error) { + // 完整档案已提交,缓存失败不改成历史保存失败。 + plog('WARN', 'session_snapshot_cache_save_failed', { error: error.message }); +} +``` + +实际代码还须处理稳定 ID、可信加载、运行态排除和快照写失败;这些保护由统一保存入口承担。 + +## 8. 同一任务的自动续跑与运行中重连 + +- 自动延续重试交接完整 `fullText`、全部工具、消息及工具去重映射、稳定助手消息ID和逻辑回合标识。模型提示可只保留末尾7000字,气泡与归档不能使用该提示替代完整输出。 +- 清理中断轮前先提交完整运行态;重试等待期仍须被认定为运行中,并可通过 `resume_generating` 读回完整状态。完整保存失败须保留唯一输出,不能继续破坏性交接。 +- 重试使用独立 `runtimeItemScope`,原生item ID跨轮复用不能覆盖较早输出;高频长正文增量可分片发送,拼接结果必须等于原始增量。 +- 正常新用户任务从空正文开始。耗尽、取消、启动失败及最终保存失败均须保留已产生正文,并防止旧回合晚到通知结束新回合。 +- `turn/start`已发送且原生回合ID未知时取消,旧轮须继续持有线程门禁。通知先到也归旧轮;确认身份并精确中断后才能放行新任务,RPC超时不能凭空释放身份。 +- 完整运行态、`done.assistantMessage`和历史分页应提供全部正文及工具输入/结果;不能让有界工具预览在完成或刷新时覆盖完整气泡。 +- 必需回归:`codexapp-retry-history-unit.js`覆盖A/B/C三次续跑、等待期重连、全部工具、存储重开、耗尽、取消、启动交错、保存失败、重启恢复及正常新轮隔离;`history-runtime-unit.js`同时检查完整读取与有界实时预览。 diff --git a/.trellis/spec/frontend/state-management.md b/.trellis/spec/frontend/state-management.md index b4fc966..18ada80 100644 --- a/.trellis/spec/frontend/state-management.md +++ b/.trellis/spec/frontend/state-management.md @@ -40,7 +40,15 @@ Questions to answer: -(To be filled by the team) +- 历史游标表示服务端当前记录中尚未展示的消息数量。正数游标可以分页,与外部原始记录是否可确认恢复分开判断。 +- `historyAvailable=false` 只用于旧历史来源的核实说明,不能阻断已保存消息的正文或用户提问目录请求,也不能据此宣称消息已丢失或无法恢复。 +- 读取完已保存消息后,游标归零应停止请求;确有旧来源待核实时保留准确说明,正常完整历史不显示该说明。 +- `resume_generating`是完整运行态快照,以正文及全部工具权威替换当前流式状态,并复用同一逻辑助手气泡;不盲目拼接不同用户任务。 +- 长气泡折叠只改变显示。完整正文保留在原始消息和DOM中;多次原生回复默认收起较早段,末段可见;单次超长回复可展开完整内容。 +- 折叠状态按会话与稳定消息身份记录;完成、切换和重新渲染不得把被折叠的内容当作已删除内容保存。 +- 复制按钮与定位、分支同排,仅从完整原始正文中复制最后一个原生回复段。按围栏外的 `agent-message-divider`切分,不以最后自然段或DOM的最后一个p元素判断;代码块、列表保留,时间分隔、工具和按钮不进入剪贴板。没有原生分界时复制整条回复。 +- 单测覆盖末段边界、代码围栏伪分界、流式分界尚未传完、文本块、短消息与长消息。隔离浏览器须实际检查展开完整内容、末段复制、刷新与重连,以及手机44px触控范围。 +- 单条长回复收起时,定位按钮先调用折叠开关展开并更新记忆状态,再计算末段坐标;不能定位到高度裁剪区中仍不可见的文本。 --- diff --git a/.trellis/tasks/10-10-mermaid-png-download/check.jsonl b/.trellis/tasks/10-10-mermaid-png-download/check.jsonl new file mode 100644 index 0000000..a7b4c29 --- /dev/null +++ b/.trellis/tasks/10-10-mermaid-png-download/check.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"前端修改验收"} +{"file":".trellis/tasks/10-10-mermaid-png-download/prd.md","reason":"PNG 导出倍率及边界要求"} diff --git a/.trellis/tasks/10-10-mermaid-png-download/implement.jsonl b/.trellis/tasks/10-10-mermaid-png-download/implement.jsonl new file mode 100644 index 0000000..dad71b9 --- /dev/null +++ b/.trellis/tasks/10-10-mermaid-png-download/implement.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"前端改动与验证要求"} +{"file":".planning/2026-10-10-mermaid-png-download/findings.md","reason":"已定位的导出函数及现有工作区边界"} diff --git a/.trellis/tasks/10-10-mermaid-png-download/info.md b/.trellis/tasks/10-10-mermaid-png-download/info.md new file mode 100644 index 0000000..f9439ed --- /dev/null +++ b/.trellis/tasks/10-10-mermaid-png-download/info.md @@ -0,0 +1,7 @@ +# 实现边界 + +工作区 public/app.js 有其他会话尚未提交的历史恢复相关修改,只调整 Mermaid 导出函数附近。 +使用明确任务目录加载上下文,不覆盖其他会话的 .trellis/.current-task 或 .planning/.active_plan。 +优先复用已存在的 svgTextToPngBlob;PNG 导出默认 2 倍,以固有尺寸为基准。 + +Codebase Memory 已定位相关符号,之后 get_code_snippet/trace_path 因 Transport closed 失败,允许改用局部文件阅读。 diff --git a/.trellis/tasks/10-10-mermaid-png-download/prd.md b/.trellis/tasks/10-10-mermaid-png-download/prd.md new file mode 100644 index 0000000..fa9df06 --- /dev/null +++ b/.trellis/tasks/10-10-mermaid-png-download/prd.md @@ -0,0 +1,24 @@ +# Mermaid 默认下载 2 倍 PNG + +## 用户需求 + +气泡已经支持 Mermaid,现有下载得到 SVG;改为下载图片,默认 2 倍率。 + +## 验收标准 + +- 点击现有 Mermaid 下载按钮,得到 MIME 为 image/png、扩展名为 .png 的图片。 +- 输出宽高分别为图表固有宽高的 2 倍,保持纵横比与完整边界。 +- 保留当前图形配色与中文、HTML 标签,不随页面缩放或设备像素比额外放大。 +- 复用现有 SVG 转 PNG 能力;下载异步失败时保留可理解的错误提示并恢复按钮状态。 +- 原有 Mermaid 渲染、源码切换与复制功能正常。 +- 不新增依赖,不重启线上服务,不改动其他进行中任务的代码。 + +## 验证 + +进行 JavaScript 语法检查与真实浏览器点击下载;核验 PNG 文件头和输出尺寸,检查中文标签是否可见。 + +## 相关文件 + +- public/app.js 的 renderMermaidCodeBlock、serializeMermaidSvg、svgTextToPngBlob 和 ccDownloadMermaidDiagram。 +- .trellis/spec/frontend/quality-guidelines.md。 +- .planning/2026-10-10-mermaid-png-download/task_plan.md。 diff --git a/.trellis/tasks/10-10-mermaid-png-download/task.json b/.trellis/tasks/10-10-mermaid-png-download/task.json new file mode 100644 index 0000000..fa4ca6e --- /dev/null +++ b/.trellis/tasks/10-10-mermaid-png-download/task.json @@ -0,0 +1,26 @@ +{ + "id": "mermaid-png-download", + "name": "mermaid-png-download", + "title": "Mermaid 下载默认 2 倍 PNG 图片", + "description": "气泡 Mermaid 下载 PNG 图片,默认宽高为图表固有尺寸的 2 倍。", + "status": "completed", + "dev_type": "frontend", + "scope": "mermaid-export", + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-10-10", + "completedAt": "2026-10-10", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": ["public/app.js"], + "notes": "PNG 下载默认 2 倍;Firefox 156.0 验证流程图、时序图、中文换行标签,格式与尺寸通过;语法和差异空白检查通过。", + "meta": {} +} diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/check.jsonl b/.trellis/tasks/10-10-snapshot-history-pagination/check.jsonl new file mode 100644 index 0000000..45aeac5 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"完整历史保存及失败保护检查"} +{"file":".trellis/spec/backend/session-history.md","reason":"完整归档存储和跨层分页契约"} +{"file":".trellis/spec/frontend/state-management.md","reason":"分页、缓存及会话隔离检查"} +{"file":".trellis/tasks/10-10-snapshot-history-pagination/prd.md","reason":"用户完整历史要求及行为验收"} diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/findings.md b/.trellis/tasks/10-10-snapshot-history-pagination/findings.md new file mode 100644 index 0000000..d32a819 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/findings.md @@ -0,0 +1,15 @@ +# 调查发现 + +- 前端正文与用户目录使用 available=false 阻断分页;该标志来自外部原始日志的可恢复状态。 +- 服务端游标由仍可读取的快照计算;分页接口本身没有阻断已有记录,提示剩余条数不代表已删除条数。 +- 默认 SESSION_PERSIST_MAX_MESSAGES=180;消息正文默认96 KiB;tool输入/输出也有上限。buildSessionJsonForPersist 在10 MiB 快照限制下最多8轮减半,最坏仅保存提示。 +- loadSession 只读受限快照,无 cc-web 独立完整历史存档。saveSession 是运行时、导入及跨会话等37处调用的统一保存入口。 +- 用户最新要求完整历史自包含持久化,因此仅改前端按钮不足以满足要求。 +- 2026-09-21 旧任务曾迁移类似问题,但不能据此推断本次异机数据状态。 +- 仓库开始时干净,home-cc-web 索引ready;未操作真实会话文件及生产服务。 +- 旧版真实保存函数在650条夹具中仅读回180条及提示;长文本/工具/元数据裁剪和未提交原始前缀均有失败回归证据。 +- 新归档提交与快照缓存的事务语义要求优先读档案,包括快照首次写失败后的列表发现。正常运行时旧修订还需与元数据并发更新协调。 +- 隔离浏览器重启验证需要重新登录;此前失败画面为“密码错误”,没有收到重启后的历史协议,不能归因于归档读取。临时验证脚本补充重新登录后再执行相同读取断言。 +- 整合审查发现另一个保存前缺口:Codex App 完成路径先将正文和工具按预览限额裁剪再放入消息数组,仅让归档绕过保存裁剪还不足以覆盖真实长输出。已交实现代理检查完成和重启恢复等入库路径并补生产完成函数超限回归。 +- 并发合并还必须保留活跃消息及工具对象的引用;统一保存需给无 ID 消息原位补稳定 ID,防止持久基线与重开归一化结果不一致。 +- 独立失败路径审计发现实际 CLI 入库遗漏纯工具与混合内容、重启恢复丢内容块、Claude user/tool_result 未解析、用户输入保存失败仍运行、跨会话返回失败仍丢队列以及 CLI 恢复重复追加。均属完整历史链路内缺口;报告 failure-audit.md 保留场景与证据,已交实现补回归。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/implement.jsonl b/.trellis/tasks/10-10-snapshot-history-pagination/implement.jsonl new file mode 100644 index 0000000..bc20fdb --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/index.md","reason":"历史保存和读取约定"} +{"file":".trellis/spec/frontend/index.md","reason":"前端状态与目录分页约定"} +{"file":".trellis/tasks/10-10-snapshot-history-pagination/findings.md","reason":"根因及异机验证边界"} +{"file":".trellis/tasks/10-10-snapshot-history-pagination/research/persistence-design.md","reason":"完整会话归档及保存事务设计"} diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/prd.md b/.trellis/tasks/10-10-snapshot-history-pagination/prd.md new file mode 100644 index 0000000..53915d6 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/prd.md @@ -0,0 +1,31 @@ +# cc-web 独立完整历史持久化与读取 + +## 用户需求 + +用户反馈「还有 45 条更早消息,但原始记录不可用,无法恢复」,说明出现在别的客户端,并明确「无法恢复这种情况是不可能存在的」。正常成功保存的 cc-web 对话必须能从自身存储完整读取,不依赖外部 Codex/Claude 日志继续存在。目标客户端及会话尚未提供,不能使用本机会话替代异机实测。 + +## 已确认根因 + +1. saveSession 经过 buildSessionJsonForPersist,默认只保留最后 180 条,还会裁剪正文、工具内容与数组;超过快照大小后继续缩减,最坏仅写提示。 +2. 当前没有独立完整历史存档;截断历史只能依赖外部原始记录并确认合并边界。 +3. 原始记录不可用时 resolver 仍返回已有快照,但 available=false 导致前端禁止分页;45 等游标数量其实来自仍保存的消息。 + +## 实施要求 + +- 引入 cc-web 自身的完整历史保存,快照大小限制仅用于热数据/预览,不再造成消息或内容永久消失。 +- 完整记录必须先持久化,随后才允许更新受限快照。完整历史落盘失败应明确失败,保持上次数据,不能先裁剪覆盖。 +- 完整归档已提交但受限快照更新失败时,本次历史仍已保存;记录缓存失败,重启从归档读取完整会话和元数据。 +- 支持进程重启、外部原始记录缺失、短快照反复保存、同 ID 消息内容及工具更新,保持顺序和去重;不限制完整档案的消息数和文本长度。 +- 导入和运行时通过统一保存入口覆盖;旧的确认可恢复原始前缀应安全补入自身档案,未确认的历史不得混入。 +- 正文和用户提问目录均可分页已有记录;提示准确说明旧历史来源状态,不再把可读取消息宣称无法恢复。 +- 正常完整记录读完即结束;旧版早已裁掉的记录只能基于存在且可确证的来源补齐,不伪造或掩盖缺口。 +- 不改写本机真实会话、不操作远端、不重启生产服务。记录异机实际验证仍未完成的边界。 + +## 验收 + +- 超过快照条数上限的完整消息,在多次保存、重新加载存储实例/模拟重启、禁用外部原始日志后均保持全部消息和内容。 +- 超过正文与工具结果预览限制的内容可原样读回;覆盖旧快照重新保存不能把档案裁短。 +- 完整归档和快照写入失败、重复保存、同 ID 更新、跨会话隔离均有行为回归,失败不破坏上次数据。 +- 57 条快照/首屏12/游标45/原始日志不可用,剩余45逐页可读,无重复且顺序正确;用户目录完成索引。 +- 原始日志存在但边界不匹配只读取自身记录;确认可恢复的旧前缀会持久化后再提供。 +- 相关单测、全量回归、语法与差异检查通过;实际生产及异机状态如实交付。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/progress.md b/.trellis/tasks/10-10-snapshot-history-pagination/progress.md new file mode 100644 index 0000000..78c6ea2 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/progress.md @@ -0,0 +1,26 @@ +# 执行记录 + +- 2026-10-10:读取规划技能和 Trellis 工作流并核对工作区,用户说明目标在别的客户端。 +- 2026-10-10:定位提示、resolver、保存及分页函数,发现快照被错误阻断和完整历史未独立保存两个缺口。 +- 2026-10-10:用户强调无法恢复不应发生,计划扩大为完整持久化;独立审查确认旧分页方案不足。 +- 2026-10-10:后端保存设计正在独立调查;修订需求、六步计划及同步CSV。 +- 2026-10-10:独立审查批准修订后的完整历史计划;确定完整会话归档作为提交点,后端持久化与前端分页代理按文件边界并行补失败回归并实施。 +- 2026-10-10:codebase-memory 连接中断,后续本地校验改用 FastCtx;已通知实现代理继续,无生产重启。 +- 2026-10-10:前端故障测试在旧代码失败(45条分页请求数0),修复后专项全部通过,正文与目录45→33→21→9→0可读且无重复。 +- 2026-10-10:持久化旧代码回归9项失败、2项通过:650条仅保存180条,长正文/工具与重复短预览覆盖等均复现;完整归档实现中。 +- 2026-10-10:补充事务边界:归档是持久提交点;孤立归档应可被列表发现;可信旧修订必须处理正常并发状态更新,避免忽略返回值导致新消息静默未存。 +- 2026-10-10:继续隔离 Firefox 归档验收。首次扩展夹具在重启后停在登录页,归档文件已存在但无历史协议返回;失败证据为 /tmp/cc-web-history-browser-oTByEh。补入重启后重新登录与选会话逻辑,再验证原始记录移开后的历史读取。所有操作限于临时夹具。 +- 2026-10-10:修正登录夹具后隔离 Firefox 验收通过,证据 /tmp/cc-web-history-browser-0dcW8C。覆盖自身归档重启读取、最早消息分页、继续运行、刷新、真实 WebSocket 重连、完成后再刷新;原始记录在夹具中移开,未操作生产。 +- 2026-10-10:保存前审查发现真实 Codex App 完成函数仍先裁剪正文/工具,已要求后端代理修复实际入库路径并新增对应超限回归;同时检查无 ID 消息和三方合并活跃引用。第4步继续,尚未进入最终全量验收。 +- 2026-10-10:当前原位合并和无 ID 归一化通过主线程独立夹具检查;新增只读失败路径审查代理,重点排查完成/恢复时唯一内容清理。启动全量兼容回归,以提前定位新保存模块与既有功能的交叉影响。 +- 2026-10-10:按 trellis-update-spec 补充完整历史保存契约文档与后端索引;记录提交顺序、可信加载、完整性状态、缓存失败、并发活跃引用、分页字段及真实完成路径断言,纳入独立审查上下文。 +- 2026-10-10:首轮完整 npm run regression 退出1,scripts/regression.js:9173 任务追踪开启失败。/tmp/cc-web-regression-MkIzuk/logs/process.log:135 证实 history_untrusted_source;task-board-service 克隆加载和保存对象,丢失 WeakMap 来源。已交后端代理修复元数据保存适配器并补回归。测试服务已自行结束,未涉及生产。 +- 2026-10-10:只读失败路径审查完成并写入 failure-audit.md,实际CLI/Claude及交付路径新增7类缺口,已交实现代理处理;额外运行时测试代理仅写新专项脚本,前端代理负责把通过的归档重启浏览器验收加入可复现脚本,文件边界不冲突。 +- 2026-10-10:修复 Codex CLI 普通纯文本被误保存为内容块数组的问题;纯文本继续保持字符串形状,结构化/混合内容使用内容块数组,避免既有消费者得到 `[object Object]`。`npm run regression` 复跑通过。 +- 2026-10-10:审查恢复失败路径,修复 Codex App 会话读取失败和 CLI 恢复会话读取失败时清理唯一运行态的问题;异常时保留 `_history/_turns`、run 目录及 output.jsonl,待存储恢复后重试。隔离浏览器归档重启验收与全部专项回归通过。 +- 2026-10-10:生产完成/恢复路径专项补齐 13 项(history-runtime-completion-unit.js,已注册进全量回归),覆盖完成保存失败保留运行态、恢复幂等、steer 保存失败阻断发送、跨会话与子会话冲刷失败保留、看板 650 条不截断;全量回归通过(日志 /tmp/cc-web-regression-completion-2.log)。 +- 2026-10-10:主线程独立复核:界面与链路中已无“无法恢复”文案;完整归档+有界快照双写生效;唯一未闭环项为异机 45 条真实会话未实测与未部署。等待并发会话(会话结束后插入转新对话)测试落定后复跑最终全量回归。 +- 2026-10-10:当前树历史入口 `npm run regression -- --target history-recall` 复跑通过(/tmp/cc-web-history-scoped-regression.log),包括完整存储、runtime、完成/恢复、原始日志及前端分页六组专项。最终全量首次因整体 60 秒上限中止,第二次在未知 slash 的运行中 steer 预期上超时(/tmp/cc-web-history-final-regression-2.log),实际已降级为新 turn;期间共享文件指纹改变,独立只读复核正在确认原因,不把这次全量记为通过。 +- 2026-10-10:主线程修复完成/恢复夹具恢复阶段:改用“真实损坏档案 → 完成保护 → 修复档案 → 同实例恢复补交”模拟读取失败,消除跨 harness 可信来源丢失造成的假失败;`history-runtime-completion-unit.js` 15/15 通过。 +- 2026-10-10:最终全量 `npm run regression` 通过(19:52:25→19:53:59,日志 /tmp/cc-web-regression-final-verify.log,运行前后 7 个关键文件 sha256 一致);`--target history-recall`、`failed-insert-card-unit.js`、`node --check`、`git diff --check` 全部通过。 +- 2026-10-10:隔离 Firefox 归档重启验收在当前工作区复跑通过(/tmp/cc-web-history-browser-zBtU5w,8 阶段全部完成)。交付边界:未部署、未重启生产、异机 45 条未实测(无目标会话数据),同机其他运行会话未被触碰。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/research/completion-regression-result.md b/.trellis/tasks/10-10-snapshot-history-pagination/research/completion-regression-result.md new file mode 100644 index 0000000..1697627 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/research/completion-regression-result.md @@ -0,0 +1,45 @@ +# 完成/恢复生产路径专项回归结果 + +日期:2026-10-10。负责范围:`handleProcessComplete` / `recoverProcesses` / Codex App 运行态 / 保存失败分支的生产函数行为回归,不改生产实现。 + +## 新增脚本 + +`scripts/history-runtime-completion-unit.js`:从 `server.js` 按函数边界抽取真实完成、恢复与失败分支函数,配真实 `lib/session-history-store.js`、真实 `lib/agent-runtime.js` 事件解析和临时目录执行;不复制实现、不读写真实会话、不启动外部进程。已注册进 `scripts/regression.js` 的 `assertHistoryRecallContract` 专项列表。 + +原有 13 项覆盖: + +- CLI 纯文本完成回合保持字符串内容形状,重开可读(防止 `[object Object]` 形状回归)。 +- CLI 混合内容块(含图片块)与 130 条纯工具调用完整归档。 +- CLI 完成保存失败时保留运行目录与 `output.jsonl`,`done` 标记 `historySaved=false`;存储恢复后由 `recoverProcesses` 恰好补齐一次。 +- CLI 清理失败时重复恢复按 `runKey` 去重,不重复入库。 +- Codex App 完整 turn 运行态先于预览保存(60 万字符正文与工具调用不裁剪),恢复恰好入库一次。 +- Codex App 会话暂不可读时保留 `_history/_turns`、run 目录与预览状态,恢复后恰好入库一次。 +- Codex App 损坏运行态文件保留,不误入库。 +- Codex App 完成路径 `saveSession` 失败时先保留完整运行态,恢复后恰好一条正文且不补中断提示。 +- 初次用户消息保存失败时不创建运行目录、不改写磁盘会话。 +- 跨会话回传保存失败保留 `ready` 重试机会,重试恰好一个气泡;崩溃重试按处理标记去重。 +- 子会话冲刷保存失败保留队列与有界重试标记,存储恢复后提交。 +- 任务看板元数据适配器按 ID 重读完整档案,只提交元数据,不接受截断对象覆盖 650 条历史。 +- Codex App steer 保存失败不调用 `turn/steer`。 + +新增两个完成入口读取失败回归(共 15 项): + +- CLI `handleProcessComplete` 的 `loadSession` 返回 `null` 时,真实完整归档保持原文,真实 run 目录与 `output.jsonl` 保留;应发送 `session_load_failed`、`done.historySaved=false`。恢复读取后,真实 `recoverProcesses` 恰好入库一次,成功提交后才清理。 +- Codex App `handleCodexAppTurnComplete` 的 `loadSession` 返回 `null` 时,真实完整归档保持原文,`_history/_turns`、运行态预览与输出文件保留;最新完整 turn 保持正文、工具和 `completed` 状态。恢复读取后,真实 `recoverCodexAppTurnState` 恰好入库一次,不误补中断提示。 + +两个用例通过损坏真实档案文件制造读取不可用(`loadSession` 收敛为 `null`):完成阶段断言归档未被重写、运行态保留;恢复阶段写回原档案后由同一存储实例补交;没有用 `saveSession=false` 替代读取故障,也不依赖跨 harness 桩函数。 + +## 验证结果 + +- 原有 13 项在此前实现上通过;新增读取失败用例的修复前运行为 **13 项通过、2 项失败**,分别真实复现 CLI 运行目录被删除和 Codex App 完整 turn 被删除。日志 `/tmp/ccweb-completion-load-null-before.txt`。 +- 旧实现对照:`timeout 60s node scripts/history-runtime-completion-unit.js --baseline-head`,新增两个用例分别在 CLI 运行目录保留断言和 Codex App 运行目录保留断言上失败;它们进入真实完成函数后才失败,不依赖缺少新函数或新 writer 返回值复现。对照日志 `/tmp/ccweb-completion-load-null-head.txt`;当前共 15 项失败,预期失败清单包含新增两项。 +- 修复后 15 项最终结果:主线程改为“真实损坏档案 → 完成保护 → 修复档案 → 同实例恢复补交”后复跑 `timeout 60s node scripts/history-runtime-completion-unit.js`,**15/15 通过**(2026-10-10,日志 `/tmp/completion-unit-fixed.txt`)。 +- 最终全量回归:`npm run regression`,**Regression checks passed**(19:52:25→19:53:59),日志 `/tmp/cc-web-regression-final-verify.log`;运行前后 `server.js`、`public/app.js`、`scripts/regression.js`、`scripts/mock-codex-app-server.js`、`lib/session-history-store.js`、`lib/agent-runtime.js`、`lib/codex-app-runtime.js` 的 sha256 一致。 +- 此前全量回归:`npm run regression`,**Regression checks passed**;当次运行前后 `server.js` md5 均为 `94fe21059863b9b1c7088c14d4743ed2`,日志 `/tmp/cc-web-regression-completion-2.log`。共享源码后来继续变化,此结果不能代替最终树验收。 +- `git diff --check`、`node --check` 通过。 + +说明:首次全量回归(日志 `/tmp/cc-web-regression-completion.log`)在 `scripts/regression.js:9622` 出现 steer 超时,当时存在并发验收和共享源码修改;后续串行重跑曾通过。现有证据只支持“steer 时序失败”,没有充分证据把诱因归结为并发负载,也不能以一次复跑通过排除其他时序缺陷。 + +## 边界 + +以上均为临时目录与夹具验证;未读取或修改用户在另一客户端看到的真实会话,未重启生产服务,未部署。不能据此声称那 45 条旧消息已经找回。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/research/failure-audit.md b/.trellis/tasks/10-10-snapshot-history-pagination/research/failure-audit.md new file mode 100644 index 0000000..f1cab7c --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/research/failure-audit.md @@ -0,0 +1,73 @@ +# 生产入库与失败路径只读审计 + +审计时间:2026-10-10 16:45—16:50(Asia/Shanghai)。 + +整改复核(2026-10-10):本报告保留的是修复前证据,下述七项独立缺口及 child 冲刷失败问题均已修复。`history-runtime-unit.js` 和 `history-runtime-completion-unit.js` 分别通过 13 项真实生产路径回归;后者还覆盖初次用户输入与 steer 保存失败阻断发送、跨会话返回失败保留重试、运行恢复幂等及子会话冲刷失败保留队列。详情见 `runtime-test-result.md` 与 `completion-regression-result.md`;不代表异机目标会话已恢复。 + +范围:当前 PRD、`research/persistence-result.md`、后端质量规范,以及 Claude/Codex CLI、Codex App、跨会话返回的生产保存路径。实现代理仍在改代码,下述行号对应审计时源码,整合前需再次核验。仅写本报告;未修改实现文件、真实会话、配置,未重启服务。 + +## 独立缺口 + +| 优先级 | 位置 | 触发场景与实际损失 | 建议行为回归 | +| --- | --- | --- | --- | +| P1 | `server.js:9487`、`:9490`、`:9516` | Claude/Codex CLI 回合只有工具,没有正文或 `contentBlocks`。完成分支条件不含 `toolCalls`,因此零入库后删除运行目录,正常成功的工具结果全部消失。恢复分支已接受纯工具,正常完成仍遗漏。 | 使用真实 `handleProcessComplete`,输入空正文和完整工具调用;归档读回全部调用与长结果,只有归档成功才删除运行目录。 | +| P1 | `lib/agent-runtime.js:519`、`:530`、`server.js:9490` | Codex CLI 同一回合先纯文本、再 JSON 内容块、最后纯文本。解析器把纯文本放 `fullText`,JSON 放 `contentBlocks`;完成保存选择 `contentBlocks || fullText`,成功归档只保留中间内容块,丢前后正文。 | 真实解析器依次输入三类 `item.completed`,再用真实完成函数保存,重开完整档案后确认前文、内容块、末尾及顺序均存在。 | +| P1 | `server.js:9699`、`:9702`、`:9707` | Codex CLI 在服务停止期间完成含图片等非文本内容块的回合。恢复判断和正文仅用 `tempEntry.fullText`:仅图片时不保存且删除日志;文本加图片时成功保存正文但丢图片。 | 用隔离 JSONL 夹具覆盖仅图片、文本加图片、混合内容;真实 `recoverProcesses` 后读回完整内容块,保存失败时目录保留。 | +| P1 | `lib/agent-runtime.js:420`、`:433`、`:458` | Claude 流式输出以 `type: "user"`、`message.content[].type: "tool_result"` 返回工具结果。解析器只在 `assistant` 下处理 `tool_result`,没有 `user` 分支,最终工具仍 `done: false` 且无 `result`。正文成功保存也不能补回工具结果。 | 真实 `processClaudeEvent` 依次输入 assistant/tool_use、user/tool_result、assistant/text、result;从自身档案读回完整工具结果。保持用户消息与 runtime 工具结果的语义区别。 | +| P1 | `server.js:12420`、`:12456`、`:12514`;`:14959`、`:14972` | 用户提问初次保存或 Codex App steer 用户消息保存返回 false,调用者仍启动任务/发送 `turn/steer`。若存储在助手完成前恢复,完成函数重新加载旧归档并成功保存助手,用户提问已经缺失;恢复输出 JSONL/完整 turn 状态不能保证包含原用户正文与附件。 | 第一次归档写失败、之后恢复可写;确认不会在未保存提问时启动/接受 runtime,或明确保留且补交相同用户消息。重开后用户目录包含原提问,重复提交不重复。 | +| P1 | `server.js:8330`、`:8344` | 跨会话返回气泡保存失败被忽略,代码仍设 `returned`、删除待返回请求、返回 true,并可启动后续任务。源会话自身缺少返回气泡,请求队列也不再保留这次交付,用户看见的成功状态与保存结果不符。 | 注入源归档保存失败,待返回请求保持可重试状态且不能宣告已交付;恢复存储后重试一次,源完整档案恰好一个返回气泡,保留交叉会话元数据。 | +| P1 | 最新 `server.js:9708`、`:9714`、`:9719`、`:9722` | CLI 正文归档提交成功后、运行目录清理前崩溃,或清理本身失败。下次重启重复解析同一 JSONL,恢复消息没有 CLI turn 标识或提交去重检查,再追加同一完整正文。Codex App 已有 turnKey 去重,CLI 尚无。 | 原子提交成功后模拟清理失败,重开并重复恢复同一运行目录;归档始终恰好一次工具/正文记录,同时正常相同正文的不同回合不能被错误合并。 | + +这七项独立于已经发现的 Codex App 完成前正文/工具裁剪、运行态裁剪和任务看板克隆后丢可信来源。 + +主线程优先要求的用户输入路径于 16:49 再次核验:最新 +`handleMessage` 的 `saveSession(session)` 为 `server.js:12432`, +`handleCodexAppSteerMessage` 的 `saveSession(currentSession)` 为 +`server.js:14969`,两者均忽略返回值并继续 runtime/steer。 +这个分支应该先明确失败,避免出现“助手最终成功入库,但原用户提问缺失”。 + +## 次要失败观察 + +`flushPendingCcwebMcpChildSession`(`server.js:13219`)在保存前删除 +`pendingCcwebMcpChildSessionFlushes`(`:13223`),末尾虽返回 +`saveSession`(`:13237`),最终状态调用者和 timer 调用者不检查结果。 +如果归档瞬时失败且后续没有新 child 事件,最后的工具协作状态不再自动重试。 +建议把队列移除放到保存成功后,并用最终子线程事件加一次失败/成功冲刷回归。 +这里主要损失父工具的最终展示状态;不能据此推断子会话自身完整档案已经丢失。 + +## 已出现的修复与不应重复取消的限额 + +- 当前运行时创建已传 `preserveFullHistory: true`;CLI 注入的 `truncateObj` 为恒等函数。先前 CLI 的 256 KiB 正文尾部保留、2,000 字符工具结果等生产裁剪已被本轮修改绕过。本报告不再将它们作为未修独立缺口。 +- 当前 `writeCodexAppTurnState` 先调用完整 `sessionHistoryStore.saveTurn`,之后才写有界运行预览;加载优先完整 turn 状态,恢复内容不再二次裁剪,恢复归档保存失败提前返回并保留完整状态。完整 turn 档案枚举也已进入重启恢复。 +- 当前 CLI 正文完成保存失败和 Codex App 完成保存失败已经显式报告 `session_save_failed`、`historySaved: false` 并保留恢复材料,正常失败清理问题正在被修复。 +- CLI 重启恢复已移除超过 16 MiB 就跳过输出并删除目录的分支,并在归档保存失败时 `continue` 保留输出目录。 +- `sendRuntimePreview`、会话传输、标题摘要、MCP 参数校验、文件预览是独立用途的有界数据。只要未把其结果回写唯一完整正文/工具内容,不需要取消这些限额。 +- `loadCodexAppTurnState` 仍对旧版有界状态设置加载限制。新增完整 turn 来源已优先读取;旧文件异常处理与完整档案损坏恢复应单独判断,不宜为解决新记录完整保存直接删除所有异常防护。 +- 检索未发现除主线程已通知的任务看板适配器以外的明显 `cloneJson`/`JSON.parse(JSON.stringify(...))` 后调用统一保存的生产入口;一般标题、模式、usage、MCP 状态等保存返回值忽略主要影响元数据确认,不等同于本报告的唯一内容清理路径。 + +## 隔离证据 + +在 `timeout 60s node` 中通过 `vm.runInNewContext` 提取生产完成、恢复、跨会话返回和 child 冲刷函数,结合真实 `createAgentRuntime`。所有 fs、WebSocket、队列和保存均为内存桩;没有真实会话写入。 + +前三组命令分别约 0.03 秒、0.05 秒和 0.03 秒,退出码 0,断言证实: + +1. CLI 纯工具回合零次保存、一次目录清理。 +2. Codex CLI 混合内容成功保存丢前后纯文本。 +3. 仅图片内容块的 CLI 重启恢复零次保存、一次目录删除。 +4. Claude user/tool_result 解析后工具仍未完成且结果为空。 +5. 跨会话源保存返回 false 时,交付函数仍返回 true 且队列请求被删除。 +6. Child 最终状态冲刷保存失败,待冲刷队列仍被删除。 +7. CLI 恢复保存再次失败时不会清理 pending 输出;存储恢复后可成功提交。 +8. CLI 成功提交后模拟清理失败,再执行恢复会产生两条相同助手消息。 + +用户提问与 steer 保存失败项通过生产控制流确认,尚未执行其大依赖入口的完整行为夹具。Claude 的协议场景采用合成标准事件验证解析器;未读取本机或异机真实 runtime 日志,本报告不宣称异机实测已完成。 + +完成失败后的 pending 恢复结论:CLI 路径已经符合“保存失败明确失败且保留运行输出”的行为,后续存储恢复可重新归档;未修缺口是恢复内容块与提交后幂等性。Codex App 通过完整 turn 档案、失败时保留、恢复时检查 `codexAppTurnKey` 的静态链路已经覆盖同类条件,本子审计未再发现另一个正常完成后清理唯一完整 turn 内容的独立分支。磁盘持续不可写时只能如实报告失败,本报告不把物理无法提交误判为成功保存。 + +## 工具调用简报 + +- 时间:2026-10-10 16:35—16:50,Asia/Shanghai。 +- `codebase-memory-mcp`:`list_projects` 与 `index_status(home-cc-web)` 返回 ready;`search_code` 收敛 `server.js` 的 `saveSession`,`search_graph`/`get_code_snippet` 定位 CLI 完成/恢复、运行事件和 Codex App 状态函数。本子线程服务可用,没有重启或重建索引。 +- FastCtx `glob`/`inspect_local_file`:扫描可用技能与按指定路径读取 PRD、保存报告、质量规范和生产局部源码。个人技能中无必要匹配的只读失败审计技能。 +- 本地 `rg`/局部 `sed`:交叉核验修改中的最新源码与行号。实时修改已使部分 codebase-memory 片段落后,结论以最终局部源码和隔离生产函数证据为准。 +- Node VM:只读源码加内存桩验证六个失败场景;每次总超时 60 秒;无外部请求,无敏感内容输出。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/research/frontend-result.md b/.trellis/tasks/10-10-snapshot-history-pagination/research/frontend-result.md new file mode 100644 index 0000000..5802d7e --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/research/frontend-result.md @@ -0,0 +1,88 @@ +# 前端历史分页实现结果 + +记录时间:2026-10-10 16:09(Asia/Shanghai)。 + +## 变更 + +- `public/app.js`:正文与用户提问目录按正数游标请求已有历史,`available=false` 只用于更早记录待核实的说明。 +- 正文显示可读取的已保存消息数量,保留 loading、重试及来源提示。游标归零后移除加载按钮;来源状态为 false 时仍显示「更早的记录尚待核实,已保存的消息可正常查看。」 +- 用户目录首屏没有提问、但仍有历史游标时保持入口可用;读取失败可重试,索引自动逐页完成。 +- 来源尚未返回时,初始和重置状态不展示待核实提示;服务端明确返回的 `historyAvailable=false` 仍保留。普通完整历史没有限制提示。 +- `scripts/history-frontend-unit.js`:新增行为回归,直接执行生产请求、响应处理、正文合并及目录索引函数;只模拟浏览器节点与网络发送。 + +## 失败与通过证据 + +先修改专项测试,尚未修改 `app.js` 时执行: + +```bash +timeout 60s node scripts/history-frontend-unit.js +``` + +退出码为 **1**,失败断言为「原始来源不可用时仍须请求已保存的45条正文」,实际请求数 **0**、期望 **1**。这是旧分页门禁导致的行为失败。 + +完成最小修复后,以下命令全部退出 **0**: + +```bash +timeout 60s node scripts/history-frontend-unit.js +node --check public/app.js +node --check scripts/history-frontend-unit.js +git diff --check -- public/app.js scripts/history-frontend-unit.js +``` + +已覆盖: + +- 57 条已保存消息、首屏 12 条、游标 45、来源不可用:正文按 `45→33→21→9→0` 分页读全,顺序正确,无重复。 +- 首屏全部为助手消息:目录入口仍可点击,并自动索引全部旧用户提问,顺序正确,无重复。 +- 正文和目录分别验证未认证、连接未打开、连接不存在及当前会话不匹配时不发送请求。 +- 正文和目录分别过滤错误会话、过期响应;重复请求去重,重复响应不污染结果。 +- 两类请求失败后释放在途状态、保留游标、显示重试并发送新请求,loading 正常。 +- 游标为零时两类请求均停止;更早记录待核实的提示仍保留且无正文加载按钮,完整历史不显示限制。 +- 原有稳定消息 ID、缓存合并、重连和流式完成去重回归继续通过。 + +## 余下边界 + +- 前端只读取服务端已提供的历史;完整历史持久化及原始日志合并仍由后端实现负责。 +- `available=false` 也可能只是合并边界尚未确认,不能断言原始文件不完整或消息未保存。旧版如果已经裁掉消息且没有可确证来源,前端无法补出;本次保留核实提示,不将旧存量历史宣称为完全恢复。 +- 尚未在用户所指的其他客户端实际验证。本次只执行隔离的专项测试和语法检查,没有操作真实会话、远端或服务。 + +## 隔离浏览器归档验收 + +已将归档重启流程合入 `scripts/history-browser-check.js`。测试只创建临时目录、临时密码和隔离 +`server.js` 子进程;运行中把夹具 rollout 重命名为 `.isolated-hidden`,停止并重新启动隔离 +服务,重新登录并打开夹具会话,未读取或改写真实会话、生产服务或真实 rollout。 + +执行命令: + +```bash +node --check scripts/history-browser-check.js +git diff --check -- scripts/history-browser-check.js +timeout --signal=TERM --kill-after=5s 60s node scripts/history-browser-check.js +``` + +结果全部通过,证据目录为: +`/tmp/cc-web-history-browser-60Uwf0` + +浏览器阶段包含 `loaded`、`archivedAfterRestart`、`archivedPaged`、`running`、 +`refreshed`、`reconnected`、`completed`、`completedRefreshed`。归档阶段的证据为: + +- 重启后 `session_info.historySource` 全部为 `archive`,`historyTotal=148`, + `historyAvailable=true`。 +- 归档分页共 6 个 `session_history_chunk`,每块来源均为 `archive`、总数均为 148, + 协议下标连续覆盖 `0..147`,协议稳定 ID 数为 148。 +- 重启后首屏为 12 条,下标为 `136..147`;点击分页直到最早的「早期问题 0」出现后, + 浏览器实际读回 148 条,稳定 ID 唯一数为 148,顺序与夹具完整 ID 序列一致。 +- 读完完整归档后提示为空,没有「无法恢复」或「待核实」。 +- `/tmp/cc-web-history-browser-60Uwf0/archived-after-restart.png` 记录重启后首屏; + `/tmp/cc-web-history-browser-60Uwf0/archived-paged.png` 记录分页读到最早消息。 +- 重启后仍继续完成原有运行中发送、刷新、WebSocket 重连、完成事件和完成后刷新检查。 + +本轮只修改 `scripts/history-browser-check.js` 和本报告;隔离测试进程、Firefox、 +relay 及隐藏的 rollout 均在 `finally` 中清理或恢复。 + +## 工具调用简报 + +- `codebase-memory-mcp`:`list_projects` / `index_status(project="home-cc-web")` 确认索引 ready;用 `search_code` / `search_graph` 定位,再用 `get_code_snippet` 读取正文分页、目录分页及渲染函数。 +- 随后查询历史控件 CSS 时索引工具返回 `Transport closed`;降级为 FastCtx 精确本地读取和搜索,没有重启服务。 +- `FastCtx`:读取任务文档、规范、技能目录及专项测试;执行范围限定的测试、语法和差异检查。所有单次测试命令最大 60 秒,无重试或退避。 +- 浏览器验收:使用本地 `Firefox/geckodriver`、临时隔离服务和夹具;测试完成后读取 + `/tmp/cc-web-history-browser-60Uwf0/evidence.json` 及截图核对协议总数、稳定 ID 和阶段结果。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/research/persistence-design.md b/.trellis/tasks/10-10-snapshot-history-pagination/research/persistence-design.md new file mode 100644 index 0000000..9b20733 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/research/persistence-design.md @@ -0,0 +1,15 @@ +# cc-web 完整历史持久化设计调查 + +调查日期:2026-10-10。范围:只读检查代码、现有任务和规范,只写入本研究文档。当前 PRD 仍在更新,本文描述完整历史存档设计,不将旧 PRD 的快照分页范围视为最终范围。 + +## 明确推荐 + +新增 cc-web 自有的完整会话归档,将其作为 `saveSession` 的持久化提交点;现有受限 JSON 快照保留为列表、首屏和搜索预览缓存。`loadSession` 优先从已提交归档恢复完整会话,原生 Codex/Claude 日志只作已确认边界的补充来源。完整归档不得调用现有 `sanitizeSessionForPersist`、`sanitizeMessageForPersist` 或其文本、消息、数组裁剪逻辑。 + +最小版本建议使用独立 `lib/session-history-store.js`,同目录临时文件 + 原子 rename 保存一份紧凑 JSON。归档包括完整持久化会话字段,而不只包括 `messages`,以便完整归档写入成功、受限快照失败时,重启仍恢复本次提交的线程绑定、清空状态和消息顺序。没有归档的旧会话先读取现有快照,保留“前缀未知”的事实,不能因为新建归档就宣称旧历史完整。 + +正常业务读取完整归档后,保存的是当前完整消息数组的精确状态,而不是永久 upsert。这样自然保留 `/clear` 的空数组、撤回/删除和在 steer 用户消息之前插入旧 turn 输出的语义。对来源为受限快照、旧修订或导入的消息数组,使用来源与修订信息区分:它们不能凭消息数量或长度覆盖完整归档。导入应按可靠标识确认重叠后合并,保留 cc-web 独有气泡;无法确认时拒绝破坏性替换并保留旧归档。 + +保存顺序:构造完整、可序列化的提交记录 → 写完整归档并原子提交 → 更新内存归档状态 → 尽力写受限快照 → 通知搜索/统计索引。完整归档失败时不覆盖旧快照、不更新成功状态,返回失败。完整归档成功而受限快照失败时,归档已经是本次持久状态,`loadSession` 必须读它;推荐保存结果表示“归档已提交”,缓存失败单独记录并在下次修复,避免上层误以为用户消息未保存而重复执行。 + +本文后续部分继续记录源码证据、接入点、成本和必须回归的边界。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/research/persistence-result.md b/.trellis/tasks/10-10-snapshot-history-pagination/research/persistence-result.md new file mode 100644 index 0000000..434bd94 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/research/persistence-result.md @@ -0,0 +1,52 @@ +# 完整历史持久化实现记录 + +日期:2026-10-10。负责范围:服务端完整归档、保存入口与生命周期、专项回归。 + +## 失败回归证据 + +先新增 `scripts/session-history-store-unit.js`,使用生产保存、裁剪、读取、resolver 函数及临时文件系统。原生日志测试通过真实 JSONL 文件和生产 `createCodexRolloutStore` 解析,未使用消息数组 mock 替代读取。 + +旧实现运行 `timeout 60s node scripts/session-history-store-unit.js` 退出码 1,9 项失败、2 项通过,约 1.2 秒: + +- 650 条保存后重开只读回最后 180 条及提示;快照上限调为 500 仍损失 150 条。 +- 长正文、125 条工具调用、工具参数/结果、长数组和深层元数据未完整保存。 +- 受限短预览再次保存被接受,旧修订也可覆盖后来已提交的追加。 +- 完整档案不存在,原子归档失败保护和缓存失败提交语义不成立。 +- 真实 rollout 确认前缀后 resolver 仅在内存合并,没有建立自身档案。 + +完整原始输出在临时日志 `/tmp/ccweb-persistence-before.txt`;本文件记录可审计结论,未写入任何真实会话或配置。 + +## 已确定设计 + +- 新增 `lib/session-history-store.js`,`sessions/_history/.json` 为完整会话的原子提交点。 +- 归档保存完整 JSON 数据,排除既有运行态句柄,不进入消息/正文/工具/数组预览裁剪。 +- 可信加载来源与归档修订使用内存身份信息,root 快照或任意短预览不能伪造完整加载。 +- 可信当前修订精确替换消息数组;可信旧修订基于加载基线合并本次变更,保留后来已提交的追加和元数据,冲突字段保留已提交值。未经可信加载的短预览拒绝覆盖档案。 +- 完整归档成功、快照失败视作已提交;`loadSession` 无 root JSON 仍优先读归档,列表接入归档孤儿缓存修复。 +- 旧截断状态继续保留。只有现有边界规则确认原生前缀后,才把补齐结果提交档案。 + +## 实现与验证 + +核心模块与 `saveSession` / `loadSession` 接入已完成;统一生命周期已覆盖: + +- `resolveSessionHistory` 使用既有边界规则确认后先提交归档,提交失败只返回原快照。 +- `loadSession` 即使 root JSON 缺失仍读取归档;列表枚举孤立档案,按归档修订修复有界缓存,缓存继续失败时使用内存有界预览展示。 +- 导入统一先读取可信档案;已有会话只有可靠边界确认后才补前缀,保留 cc-web 专有气泡和顺序。 +- `/clear` 建立新的清空修订,旧运行对象不能复活原线程历史或返回输出;删除会话同步删除自身档案。 +- 迁移保留原始快照备份,使用统一完整归档入口,不再直接写无界 root JSON。 + +当前专项检查: + +- `timeout 60s node scripts/session-history-store-unit.js`:完整持久化回归 **21 项通过**。 +- `timeout 60s node scripts/session-history-unit.js`:原服务端历史回归 **12 项通过**。 +- `timeout 60s node scripts/history-frontend-unit.js`:前端历史分页、目录索引、认证隔离和重试通过。 +- `timeout 60s node scripts/history-runtime-unit.js`:生产 runtime 完整采集 **13/13 通过**,覆盖长正文、纯文本/结构块混合、纯工具、Claude tool_result、Codex App 工具数组及有界传输。 +- `timeout 60s node scripts/history-runtime-completion-unit.js`:完成、恢复及保存失败生产路径 **13/13 通过**,已接入全量回归。包含真实保存失败注入、保留唯一运行输出、存储恢复后恰好提交一次、跨会话与 child 冲刷重试,以及任务看板 650 条历史保持完整;详情见 `completion-regression-result.md`。 +- `npm run regression`:**Regression checks passed**。 +- 隔离 Firefox 归档重启验收:`historySource=archive`、148 条消息、6 个分页块、下标连续且无重复;原始日志夹具移开后仍可读。 + +生产路径补充保护:普通纯文本 CLI 回合保留字符串内容形状;结构化或混合回合才使用内容块数组;初次用户消息、steer、跨会话回传均在保存成功后才继续;CLI/Codex App 恢复在会话读取或归档保存失败时保留运行目录和完整运行态,避免清理唯一可恢复记录;CLI 使用 runKey 防止清理失败后的重复入库。 + +旧版本对照:`timeout 60s node scripts/history-runtime-unit.js --baseline-head` 复现预期的 9 项历史缺陷,证明专项断言确实覆盖原问题。核心文件 Node 语法检查与 `git diff --check` 通过。 + +边界:以上均为隔离临时目录、夹具或本地回归;未读取或修改用户在另一客户端看到的真实会话,未重启生产服务,不能据此声称那 45 条旧消息已经找回。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/research/runtime-test-result.md b/.trellis/tasks/10-10-snapshot-history-pagination/research/runtime-test-result.md new file mode 100644 index 0000000..d15c1a4 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/research/runtime-test-result.md @@ -0,0 +1,99 @@ +# 运行时完整采集回归结果 + +本子任务仅新增 `scripts/history-runtime-unit.js` 和本报告;不修改生产实现、 +已有 regression 或其它单测。测试直接加载生产 factory 和事件处理函数, +使用内存 WebSocket 接收器,不读取或写入真实会话,不操作远端或重启服务。 + +## 首次代码状态与失败证据 + +时间:2026-10-10 16:52:53(Asia/Shanghai)。 + +并行实施代理已先完成默认完整采集改造,因此未复现“长正文被裁剪”的修复前 +状态,仅记录当时真实结果: + +| 场景 | 期望 | 首次只读探针实际 | +| --- | --- | --- | +| Claude 长正文 | 1,050,006 字符原样保留 | 1,050,006 字符,完全相等 | +| Codex CLI 长正文 | 1,050,006 字符原样保留 | 1,050,006 字符,完全相等 | +| Codex App 单次长增量 | 1,050,006 字符原样保留 | 1,050,006 字符,完全相等 | +| Claude assistant/tool_use → user/tool_result | 工具 `done=true`,结果为输入原值 | `done=false`,`result` 缺失 | +| Codex CLI 纯正文 → JSON 文本块 → 纯正文 | 保存路径选用的内容包含三段正文 | `fullText` 含三段,`contentBlocks` 只有中间正文 | + +## HEAD 旧版本失败对照 + +命令: + +```bash +timeout 60s node scripts/history-runtime-unit.js --baseline-head +``` + +脚本通过 `git show HEAD:lib/agent-runtime.js`、`git show HEAD:lib/codex-app-runtime.js` +读取真实旧版本源码,用 `node:module` 在内存中编译,不写入工作区。旧版本 +共复现 9 项预期失败(退出码 0 表示“对照成立”): + +| 失败用例 | 旧版本表现 | +| --- | --- | +| Claude CLI 长正文完整采集 | 正文被 256 KiB 上限裁剪 | +| Codex CLI 长正文完整采集 | 正文被 256 KiB 上限裁剪 | +| Codex App 长正文完整采集 | 单次增量被 16 KiB 流上限裁剪 | +| Claude user/tool_result 长结果完整采集 | `user` 事件未处理,工具仍 `done=false` | +| Codex CLI 长工具结果完整采集 | 结果被 2,000 字符上限裁剪 | +| Codex App 超限工具输入、结果与数组完整采集 | entry 内输入/结果被预览限额裁剪 | +| 超出预览条数上限 40 的 130 条工具仍完整采集 | 工具数组只留前 120 条 | +| Codex CLI 正文与 JSON 内容块混合采集 | `contentBlocks` 与 `fullText` 二选一导致丢两端 | +| 生产 WebSocket 正文与工具预览保持有界 | 缺少 `tool_end`(Claude `user` 缺口的连带失败) | + +`Claude CLI JSON 字符串工具参数使用生产解析`、`Codex App 增量与完成事件补尾 +且不重复`、`重连/历史工具列表预览遵守生产条数上限` 在旧版本即已通过,属于 +防回退断言,不计入基线失败。 + +## 当前实现复跑 + +命令: + +```bash +timeout 60s node scripts/history-runtime-unit.js +``` + +当前(2026-10-10 17:24,Asia/Shanghai)实现共 13 个用例,13 个全部通过, +退出码为 0。实施代理已补齐 Claude `user/tool_result` 事件处理,以及 Codex +CLI 混合正文的统一完整内容保存路径。 + +最终输出: + +```text +通过:生产 runtime 完整采集与有界传输 + checkedTextChars=1050006 + checkedToolInputChars=24006 + checkedToolResultChars=48006 + checkedToolCalls=130 + webSocketPreviews=275 + passed=13 +``` + +覆盖项包括:三条长正文完整采集、Claude JSON 字符串工具参数经生产 +`sanitizeToolInput` 解析且不裁剪、Claude `user/tool_result` 长结果、Codex +CLI 长工具输入与长结果、Codex App 超限输入/结果/数组、130 条超限工具的 +entry 完整性与 130 次预览事件、Codex CLI 混合正文、Codex App 增量与完成事件 +补尾去重、正文与工具预览的传输上限、40 条工具预览上限。 + +## 覆盖边界与局限 + +- 生产完成/重启恢复、输入保存失败、跨会话失败、child 队列、CLI 幂等性由 + 主后端代理的 `scripts/session-history-store-unit.js` 覆盖,本脚本不重复。 +- 脚本只覆盖运行时事件到 entry/WebSocket 的链路,不覆盖真实会话落盘、 + 真实 WebSocket 连接、前端渲染与生产服务重启。 +- 预览断言依赖 `server.js` 中的常量与函数源码提取;若这些符号被重命名, + 脚本会以断言失败提示,而不是静默放宽。 + +## 工具调用简报 + +- 时间:2026-10-10 16:45—17:25,Asia/Shanghai。 +- `codebase-memory-mcp`:`list_projects` 与 `index_status(home-cc-web)` 返回 + `ready`;`search_graph`、`search_code` 与 `get_code_snippet` 定位 + `processRuntimeEvent`、`processCodexAppNotification`、`sanitizeToolInput`、 + `safeJsonParse`、`sanitizeMessageForTransport` 等生产入口,未重建索引、 + 未重启服务。 +- FastCtx 文件工具未返回所需能力,降级使用 `rg`、`sed` 做只读校验。 +- Node 回归:内存 WebSocket + 生产 sanitizer,不落盘;`--baseline-head` + 仅内存编译 Git 旧版本模块。 diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/task.json b/.trellis/tasks/10-10-snapshot-history-pagination/task.json new file mode 100644 index 0000000..6ec71fe --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/task.json @@ -0,0 +1,34 @@ +{ + "id": "snapshot-history-pagination", + "name": "snapshot-history-pagination", + "title": "修复完整历史持久化与快照分页", + "description": "cc-web 自身完整历史归档:超过快照上限的对话可从独立归档完整读回,移除“无法恢复”提示,并保证完成/恢复失败时不清理唯一输出。", + "status": "completed", + "dev_type": "bugfix", + "scope": "server,frontend,regression", + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-10-10", + "completedAt": "2026-10-10", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server.js", + "public/app.js", + "lib/session-history-store.js", + "lib/agent-runtime.js", + "lib/codex-app-runtime.js", + "scripts/regression.js", + "scripts/history-runtime-completion-unit.js" + ], + "notes": "未部署、未重启生产;异机 45 条未实测(无目标会话数据),同机其他运行会话未被触碰。最终全量回归与隔离浏览器归档重启验收通过。", + "meta": {} +} diff --git a/.trellis/tasks/10-10-snapshot-history-pagination/task_plan.md b/.trellis/tasks/10-10-snapshot-history-pagination/task_plan.md new file mode 100644 index 0000000..abb07d6 --- /dev/null +++ b/.trellis/tasks/10-10-snapshot-history-pagination/task_plan.md @@ -0,0 +1,33 @@ +# 完整历史持久化修复计划 + +## 范围 + +根据用户最新要求,将最小分页修复扩大为 cc-web 自身可靠保存完整历史。外部原始日志仅作旧历史补充,不能作为新对话完整记录的唯一来源。本轮规划独立保存,避免覆盖旧任务文档。 + +## 步骤 + +1. 定位快照裁剪与历史读取根因 — completed +2. 确定完整历史保存设计与失败保护 — completed +3. 补充独立持久化及分页失败回归 — completed +4. 实现完整归档并修复分页与提示 — completed +5. 验证重启、长对话与外部记录缺失场景 — completed +6. 核对部署边界并交付恢复结论 — completed + +## 决策 + +- 完整档案与有限工作快照分离,完整档案成功后才允许更新受限快照。 +- 采用完整会话字段的独立原子档案作为持久提交点;归档已成功而缓存快照失败时仍按已提交处理,重启优先读归档。 +- 精确保存可信完整数组以支持清空和删除;来源/修订识别用于阻止旧短预览覆盖完整档案。 +- 原始日志合并仍需确认边界;原始日志缺失不阻断已经保存的历史读取。 +- 行为测试覆盖持久化后重开、超过条数和文本限额、重复及失败保护,避免仅修改提示文字。 +- 后端与前端可并行,写入文件边界互不冲突,整合后由独立子代理审查。 + +## 错误记录 + +- 首轮误将目标当作本机会话;用户纠正后不再操作本机数据。 +- 首次生成文档补丁误判 JSONL 种子内容,读取真实内容后重新生成。 +- 旧方案仅修分页,没有保存完整历史;用户澄清及独立审查后扩大范围,旧计划不再视为完成目标。 +- codebase-memory 后续检索返回 Transport closed;已完成关键调用链定位,剩余读取降级为 FastCtx 本地工具,不重启服务。 +- 首次隔离重启浏览器检查漏了重新登录,停在登录页;补入重新登录和选择会话后验收通过,未将夹具失败误判为历史丢失。 +- 首轮全量回归在任务追踪开启失败;日志证明 task-board-service 的 cloneJson 丢失可信加载身份,saveSession 返回 history_untrusted_source。改为服务端可信重载后仅保存任务元数据,继续保留短预览防覆盖。 +- 完成/恢复专项的两条读取失败夹具最初用跨 harness 桩函数注入 null,恢复补交因可信来源身份丢失被拒,形成假失败;改为“真实损坏档案 → 完成保护 → 修复档案 → 同实例恢复补交”后 15/15 通过。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/check.jsonl b/.trellis/tasks/10-10-streaming-retry-history/check.jsonl new file mode 100644 index 0000000..6fedc96 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/check.jsonl @@ -0,0 +1,2 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/backend/session-history.md", "reason": "完整状态与隔离回归验收"} diff --git a/.trellis/tasks/10-10-streaming-retry-history/findings.md b/.trellis/tasks/10-10-streaming-retry-history/findings.md new file mode 100644 index 0000000..e7349aa --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/findings.md @@ -0,0 +1,14 @@ +# 运行中气泡正文续接发现 + +- `handleCodexAppMessage` 每轮新建空 `fullText`、工具及消息映射。 +- `handleCodexAppTurnComplete` 临时错误清理当前运行态后调用 `scheduleCodexCapacityRetry`。 +- 重试仅把正文尾部作为模型提示,新 entry 未接收完整已产生内容。 +- 目标会话日志已发现多次 `continuation: true`,重连正文长度反复变小;快照没有 assistant 消息。 +- 原生 rollout 路径:`/home/hdzx/.codex/sessions/2026/10/10/rollout-2026-10-10T15-10-02-01a124a5-b65e-70d2-b712-06168bae7bd5.jsonl`。旧正文边界待进一步只读核对。 +- 上一完整历史任务已完成但未部署,不能用源代码新增归档能力声称生产已经保存完整历史。 +- 已对真实线程 ID 精确核对,原生记录保留55段、19606字符正文,首段为北京时间15:10:30“前几轮把‘能运行、物体更多’当成了画面质量…”。独立核对副本为 `research/recovered-assistant.md`,未写回真实会话。 +- 用户再次报告22:12后的气泡缺前文,并要求增加长气泡折叠、现有角标/定位/分支旁的末段复制;此需求追加为计划第7步,仍由唯一前端owner处理。 +- 取消启动不能只检查RPC返回后的entry身份:原生通知可能先到。`turn/start`已发出但ID未知时,必须保留旧轮取消门禁,确认原生身份并中断后才能放行新轮。 +- `done`和历史分页用于完整阅读,不能重用工具预览限额覆盖完整resume内容;实时工具预览限额只用于高频增量。 +- 单条超长回复使用高度折叠时,定位末段应先展开;否则定位会落到隐藏区域。多原生回复折叠保留末段可见。 +- 浏览器可复现的验收需真实Markdown与高亮库。当前公共CDN直连阻塞页面初始化,隔离夹具缓存相同版本字节并记录来源和SHA256后执行,避免以桩替代渲染。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/implement.jsonl b/.trellis/tasks/10-10-streaming-retry-history/implement.jsonl new file mode 100644 index 0000000..5a53497 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/implement.jsonl @@ -0,0 +1,3 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/backend/session-history.md", "reason": "完整正文保存与失败保留约定"} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "运行态快照与消息幂等对账"} diff --git a/.trellis/tasks/10-10-streaming-retry-history/prd.md b/.trellis/tasks/10-10-streaming-retry-history/prd.md new file mode 100644 index 0000000..c13d465 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/prd.md @@ -0,0 +1,33 @@ +# 运行中气泡正文续接 + +## 问题与目标 + +用户报告仍在运行的 Codex App 会话 `826deaa0-c3df-42bc-930b-06ee3e05de50` 的助手气泡丢失先前内容。自动延续重试重新创建 `fullText`,中断前正文仅作为有界模型提示传回,未交接到下一轮显示和最终保存状态。 + +目标:同一用户任务的已产生正文及工具在自动重试、等待重试、切换会话、刷新、断线重连和最终保存后保持完整、有序、不重复。不能用模型提示的 7000 字尾部或有界快照代替完整正文。 + +## 实施范围 + +- 审计 `scheduleCodexCapacityRetry`、`handleCodexAppMessage`、完成/失败路径、运行态保存与恢复、`attachActiveRuntimeToWs`。 +- 在清理中断运行态前保留完整状态,安全交接多次重试;重试耗尽、取消及保存失败仍保留已产生正文。 +- 等待重试和重新开轮时向客户端提供完整状态;前端显示同一逻辑任务的正文,避免清空或重复拼接。 +- 隔离 mock 与单元/浏览器回归先证明失败,再验证多次重试、长正文、工具、重连以及归档读回。 +- 只读检查目标会话原生记录,核对旧气泡前文是否仍有可靠来源;记录数量与边界,不猜测、不直接修改运行中会话。 +- 根据用户后续要求增加长气泡折叠:完整正文保留,较早内容默认收起,最后一段回复保持可见,用户可展开全部;不能用截断实现折叠。 +- 在气泡现有“角标、定位、分支”控件旁增加复制按钮,仅复制最后一个原生回复段;不包含此前回复、分隔时间、工具或按钮。没有回复分隔线时复制该气泡完整正文。 + +## 非本次操作 + +- 不重启生产服务,不调用真实模型开始新任务,不修改真实会话和原生 rollout。 +- 不宣称上一任务的异机 45 条或此会话旧正文已恢复到 UI。 +- 保留已有未提交改动,其他会话任务文档及 CSV 不参与本次清理。 + +## 验收标准 + +1. 流出 A → 暂时错误 → 自动续跑流出 B → 再次错误 → 续跑 C,最终正文包含 A/B/C 各一次且顺序不变,用户消息不重复。 +2. 自动重试前、等待期、启动后分别刷新/重连均能读回已有完整正文及工具;长于快照上限的前缀也保留。 +3. 完整运行态先保存;完整保存失败不得清理唯一输出;重试耗尽、取消及启动失败不静默丢正文。 +4. 最终 assistant 保存和存储重开后正文、工具完整。正常新用户轮次不继承上轮气泡。 +5. 专项回归、语法检查、`git diff --check` 通过;全量回归结果记录,若已有无关失败明确区分。 +6. 交付写明生产未重启、目标会话未修改及旧内容的真实证据范围。 +7. 短气泡无多余折叠入口;长气泡、流式生成、完成、刷新及切换中可展开查看完整正文;复制在展开或折叠状态都只包含最新回复段,桌面和手机可操作。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/progress.md b/.trellis/tasks/10-10-streaming-retry-history/progress.md new file mode 100644 index 0000000..3a0e4c8 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/progress.md @@ -0,0 +1,34 @@ +# 运行中气泡正文续接进度 + +- 2026-10-10:继承上一模型的根因与证据,保留工作区已有改动,独立创建本任务文档及六步 CSV。 +- 代码索引服务仍不可用,使用本地 FastCtx 和补充 `rg` 检查。 +- 尚未修改真实会话、原生记录或生产服务。 +- 已确认运行态重连裁剪和重试等待期缺少运行标记;服务端计划持有完整 entry,续跑移交并统一完整 resume 协议。前端现有 snapshot 权威替换语义可复用,补行为回归。 +- 服务端、独立失败回归、前端审计/浏览器、目标会话只读证据分别派发代理,编辑边界互不重叠。计划审查代理两次因资源/加密上下文失败,已新建无历史审查线程。 +- 当前修复前 `server.js` 已备份 `/tmp/ccweb-streaming-retry-server-before.js`,SHA-256 `b3af261a4dc85d0e0f31f54a9970b430e519790d44c9dddf83c879d397061953`。 +- 独立计划审查已通过。设计包含:完整中断 entry 在重试等待期可读,新轮受信任交接、稳定助手消息 ID,保存失败保留;前端权威替换不盲目合并。 +- 旧版真实函数红灯确认:`scripts/codexapp-retry-history-unit.js` 核心断言为“丢失重试前A正文”,正文22万字;修复前完整运行10组9红1绿。证据 `/tmp/ccweb-streaming-retry-before.txt` 与 `/tmp/ccweb-streaming-retry-before-all.txt`。 +- 服务端开始实施;前端原函数128033字、多次resume同气泡、工具重建、刷新/切换/完成等5组单测已通过,不需要盲目拼接正文。 +- 已只读提取目标会话55段原生助手正文,19606字符;只有输出文本,不含工具和思考,生成独立核对副本。读取点132328810字节、2572行,真实快照只有5条用户消息,运行态约260字符;会话仍由生产正常追加。 +- 用户追加折叠和末段复制要求;追加第7步并扩展前端代理范围,折叠不得丢弃正文,复制以最后原生回复段为边界。 +- 服务端定版:`server.js` SHA-256 `243ef3e418323e918cffb0a6138faac00becc23211c1c160db7deddfbfdee381`,`lib/codex-app-runtime.js` `6fe66921ed8998ed1b1478eb804c0ad965ff40fff7a18dd8086e3de42b67c122`。完整交接、等待期attach、取消/耗尽/新用户门禁、稳定身份、晚到事件及分片实现已落地。 +- 首轮历史专项发现旧clear测试harness缺retained map,已补依赖;完成恢复load-null错误标interrupted已修回completed,15项实际保护断言未降低。 +- 机器出现大量无关目录扫描与磁盘争用;部分/tmp单测/Firefox启动超时。迁移隔离夹具到 `/dev/shm` 后18项续接回归和15项完成恢复均在60秒内通过;不是以延长超时或减少断言规避。 +- 折叠与复制前端6组、流式5组通过;单回复可收起420px,多原生回复露末段,完整原文和DOM保留。浏览器重连红灯先确认,绿色验收继续。 +- 接续验收确认完成与恢复专项15/15通过。此前全量回归失败于子代理卡片真实函数夹具缺少 `codexAppRuntime` 依赖;实现文件指纹校验均未变化,已由独立代理补齐夹具依赖,不降低行为断言。 +- 独立审查复现取消启动交错:旧轮A的 `turn/start` 已发送但ID未知,取消后新轮B会接到A先于RPC回包的通知。采用未知身份期间保留A取消门禁,身份确认后精确中断;补生产函数回归。 +- 审查还确认完整resume工具会被done/history的传输预览裁剪到40条。完整读取出口恢复全部工具,实时预览继续有界,并新增工具数量和长输入/结果回归。 +- 浏览器夹具因登录导航销毁evaluate上下文退出,前端代理修复等待逻辑并在60秒上限内重跑;未将夹具失败记为验收通过。 +- 全量夹具依赖修复已通过五个相关专项;最终全量等服务端新增边界修复定版后运行。缓存定向搜索60秒超时、PM2只读状态查询10秒超时,未重复全盘检索或触发重启。 +- 已把自动续跑、完整重连、取消身份门禁、完整读取出口以及折叠/末段复制契约写入后端历史与前端状态规范。 +- 新增取消启动交错与完整工具读取首轮转绿:真实notification/routing/adoption生产函数回归通过,续接专项19组、runtime13组均在60秒内通过;继续补RPC超时晚到身份和服务明确退出两个分支。 +- 隔离Chromium正式浏览器首轮通过,证据 `/dev/shm/ccweb-streaming-retry-browser-69MVbk`:8个阶段A→AB→ABC,最终正文136746字符、同一稳定ID和3项工具,切换、刷新、重连及完成后读取完整。相同版本真实marked/highlight/mermaid经已授权代理下载,夹具从本地缓存加载,生产资源未替换。 +- 浏览器已查看桌面及手机截图,折叠/展开和末段复制断言通过;最后补单条长回复收起时定位先展开的交互,补最终浏览器绿灯。 +- 2026-10-10T23:09:10+08:00只读枚举运行态,目标会话及另一会话仍为running,未重启服务。此次状态核对只读session ID和turnStatus。 +- 服务端二次定版 `c964060ad121bf16edd44caf94226455cf8a434bb8b14b9f5c434a3215d6b404`;取消门禁3场景均通过,完整续接专项21/21、runtime13/13、完成恢复15/15、steer5/5通过。服务端不再修改。 +- 前端单条长回复定位先展开已实现,控件专项8/8通过。全量回归正在 `/dev/shm/ccweb-streaming-final-regression.log` 运行,锚定实现及专项脚本指纹;最终浏览器补验隐藏区定位和折叠开头截图。 +- 最终浏览器退出0,证据 `/dev/shm/ccweb-streaming-retry-browser-G8wkN6`:8个阶段、正文136748字符、稳定气泡ID、3个工具完整;单长回复定位自动展开后末段实际可见。前端指纹前后不变,控件8/8、流式5/5及历史前端回归通过;5张桌面/手机截图已核对。 +- 浏览器报告和协议证据已复制入本任务 `research/frontend-result.md` 与 `research/browser-evidence.json`,避免证据仅存临时目录。最终全量运行时npm启动本身阻塞且未运行脚本,停止本任务npm包装进程,改为直接执行package.json同一入口 `TMPDIR=/dev/shm node scripts/regression.js`;未修改全量脚本语义。 +- 最终全量退出0,输出 `Regression checks passed.`;所有12项实现/专项指纹核对通过。日志及指纹已复制进本任务研究目录,独立审查确认3项P2全部修复,无未解决P1/P2。 +- 已创建 `research/delivery-result.md` 汇总最终行为、验证、截图、目标55段旧正文核对边界及生产后台未重启状态;折叠/复制前端静态资源可刷新加载,旧气泡未补回的边界明确。 +- 七步全部完成,CSV与对话计划同步后按技能流程仅删除本任务已完成清单;任务状态标记completed,生产后台未部署及旧气泡未写回仍在交付记录中明确保留。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/browser-evidence.json b/.trellis/tasks/10-10-streaming-retry-history/research/browser-evidence.json new file mode 100644 index 0000000..e11d4c8 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/browser-evidence.json @@ -0,0 +1,611 @@ +{ + "root": "/dev/shm/ccweb-streaming-retry-browser-G8wkN6", + "fixture": { + "home": "/dev/shm/ccweb-streaming-retry-browser-G8wkN6/fixture-home", + "sessions": "/dev/shm/ccweb-streaming-retry-browser-G8wkN6/sessions", + "preload": "/dev/shm/ccweb-streaming-retry-browser-G8wkN6/isolated-home.cjs", + "mock": "/dev/shm/ccweb-streaming-retry-browser-G8wkN6/mock-codex.js", + "gates": [ + "/dev/shm/ccweb-streaming-retry-browser-G8wkN6/retry-first", + "/dev/shm/ccweb-streaming-retry-browser-G8wkN6/retry-second", + "/dev/shm/ccweb-streaming-retry-browser-G8wkN6/complete-turn" + ], + "baseline": "" + }, + "protocol": [ + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 0, + "retryPending": false, + "isRunning": true, + "tools": 0 + }, + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 128019, + "retryPending": false, + "isRunning": true, + "tools": 1 + }, + { + "type": "system_message", + "sessionId": "12222222-3333-4444-8555-666666666666", + "textChars": 0, + "retryPending": false, + "message": "Our servers are currently overloaded. Please try again later. server_is_overloaded service_unavailable_error error", + "tools": 0 + }, + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 128019, + "retryPending": true, + "isRunning": true, + "tools": 1 + }, + { + "type": "system_message", + "sessionId": "12222222-3333-4444-8555-666666666666", + "textChars": 0, + "retryPending": false, + "message": "Codex 服务暂时繁忙,4 秒后自动重试(第 1/4 次,将从中断处继续)。", + "tools": 0 + }, + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 128019, + "retryPending": true, + "isRunning": true, + "tools": 1 + }, + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 128019, + "retryPending": false, + "isRunning": true, + "tools": 1 + }, + { + "type": "system_message", + "sessionId": "12222222-3333-4444-8555-666666666666", + "textChars": 0, + "retryPending": false, + "message": "Our servers are currently overloaded. Please try again later. server_is_overloaded service_unavailable_error error", + "tools": 0 + }, + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 132154, + "retryPending": true, + "isRunning": true, + "tools": 2 + }, + { + "type": "system_message", + "sessionId": "12222222-3333-4444-8555-666666666666", + "textChars": 0, + "retryPending": false, + "message": "Codex 服务暂时繁忙,4 秒后自动重试(第 2/4 次,将从中断处继续)。", + "tools": 0 + }, + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 132154, + "retryPending": false, + "isRunning": true, + "tools": 2 + }, + { + "type": "done", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "textChars": 136438, + "retryPending": false, + "tools": 3 + }, + { + "type": "resume_generating", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:baaac379-4a63-4255-a11b-b0f5f89fba64", + "textChars": 0, + "retryPending": false, + "isRunning": true, + "tools": 0 + }, + { + "type": "done", + "sessionId": "12222222-3333-4444-8555-666666666666", + "assistantMessageId": "codexapp:12222222-3333-4444-8555-666666666666:request:baaac379-4a63-4255-a11b-b0f5f89fba64", + "textChars": 30, + "retryPending": false, + "tools": 0 + } + ], + "stages": { + "initial-A": { + "textChars": 128017, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 1, + "toolCount": 1, + "labels": [ + "A" + ], + "textSha256": "bd486a68abe65da91c3bc2630087263bd681c3eba5006e6f05441f9c8c81c65c" + }, + "switched-back-A": { + "textChars": 128017, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 1, + "toolCount": 1, + "labels": [ + "A" + ], + "textSha256": "bd486a68abe65da91c3bc2630087263bd681c3eba5006e6f05441f9c8c81c65c" + }, + "retry-wait-refreshed-A": { + "textChars": 128017, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 1, + "toolCount": 1, + "labels": [ + "A" + ], + "textSha256": "bd486a68abe65da91c3bc2630087263bd681c3eba5006e6f05441f9c8c81c65c" + }, + "retry-wait-reconnected-A": { + "textChars": 128017, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 1, + "toolCount": 1, + "labels": [ + "A" + ], + "textSha256": "bd486a68abe65da91c3bc2630087263bd681c3eba5006e6f05441f9c8c81c65c" + }, + "continued-AB": { + "textChars": 132054, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 1, + "toolCount": 2, + "labels": [ + "A", + "B" + ], + "textSha256": "2ab9a7c30740adbbc859d14f2c7e35d4164ccbfe7d94ecccb523cf8f0380fe8c" + }, + "continued-ABC": { + "textChars": 136318, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 1, + "toolCount": 3, + "labels": [ + "A", + "B", + "C" + ], + "textSha256": "443187a014c05522aa9d10d3f0214ec245afb026d0998281b7973a669e1f6597" + }, + "completed-ABC": { + "textChars": 136748, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 0, + "toolCount": 3, + "labels": [ + "A", + "B", + "C" + ], + "textSha256": "6bdd746f39658fa372726977fa454976fbda72ec9dc924cacc90b3771678c6f8" + }, + "completed-refreshed-ABC": { + "textChars": 136748, + "key": "codexapp:12222222-3333-4444-8555-666666666666:request:aaf373f2-2714-4f85-b273-2ac6f07b27d2", + "streamCount": 0, + "toolCount": 3, + "labels": [ + "A", + "B", + "C" + ], + "textSha256": "6bdd746f39658fa372726977fa454976fbda72ec9dc924cacc90b3771678c6f8" + } + }, + "sourceFingerprints": { + "server.js": "c964060ad121bf16edd44caf94226455cf8a434bb8b14b9f5c434a3215d6b404", + "lib/agent-runtime.js": "997e30969c5e2479655bff7c061243b1b3bf36482eee76ea420d7c4f45e82e57", + "lib/codex-app-runtime.js": "6fe66921ed8998ed1b1478eb804c0ad965ff40fff7a18dd8086e3de42b67c122", + "lib/session-history-store.js": "e8daab67b60e30b451a16654d857700fb676dd5f9be33f2490a8db1c2e109b56", + "public/app.js": "433b4e68a9e91440eada679199a86ca7a84cf28b0f979eb3f46044be99fe757e", + "public/style.css": "1dc679c46b8d26947d3489851ec0058e61b35ada23ac4cd2089f38999a773f6c", + "scripts/mock-codex-app-server.js": "b22d0b027e93df45b991957b040cded5af5f0dbf392a210f923e5c31e8fa29ec", + "scripts/streaming-retry-browser-check.js": "02a5a1afc5cb3d7329c10165b14aba1923903c734d32ae8835b7503b0c917a2f" + }, + "kind": "真实 server.js / 浏览器 / 门控 A-B-C / 隔离存储", + "assets": [ + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js", + "file": "marked.min.js", + "bytes": 35418, + "sha256": "cb4cf2efefd2b1f602bf2f27d594fc0a26340d2661b60cddfcaac7a7b9261886" + }, + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js", + "file": "highlight.min.js", + "bytes": 121727, + "sha256": "837a6fa5b0c736b52bbde2b2b6190f305da3fc9ed41681db5321507057b5c846" + }, + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js", + "file": "mermaid.min.js", + "bytes": 3335717, + "sha256": "61b335a46df05a7ce1c98378f60e5f3e77a7fb608a1056997e8a649304a936d6" + }, + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css", + "file": "atom-one-dark.min.css", + "bytes": 856, + "sha256": "4237ffca7ce6aadb438c457e0a675b125c534bbdda5b87f41f3a1495603bcc9b" + } + ], + "browser": { + "browserName": "chromium", + "browserVersion": "149.0.7827.55" + }, + "controls": { + "initialSingle": { + "toggle": true, + "copy": true, + "expanded": "false", + "multiCollapsed": false, + "singleCollapsed": true, + "textChars": 128017, + "contentHeight": 420, + "contentMaxHeight": "420px", + "early": [], + "dividerCount": 0, + "fencedDivider": false, + "actionButtons": [ + { + "className": "msg-last-section-btn", + "top": 681.796875, + "row": "msg-action-row" + }, + { + "className": "msg-copy-reply-btn", + "top": 681.796875, + "row": "msg-action-row" + } + ], + "copyWidth": 28, + "copyHeight": 28, + "toggleHeight": 30, + "width": 1280, + "scrollWidth": 1280, + "copiedChars": 128018 + }, + "initialSingleExpanded": { + "toggle": true, + "copy": true, + "expanded": "true", + "multiCollapsed": false, + "singleCollapsed": false, + "textChars": 128017, + "contentHeight": 64737.25, + "contentMaxHeight": "none", + "early": [], + "dividerCount": 0, + "fencedDivider": false, + "actionButtons": [ + { + "className": "msg-last-section-btn", + "top": 64999.046875, + "row": "msg-action-row" + }, + { + "className": "msg-copy-reply-btn", + "top": 64999.046875, + "row": "msg-action-row" + } + ], + "copyWidth": 28, + "copyHeight": 28, + "toggleHeight": 30, + "width": 1280, + "scrollWidth": 1280, + "copiedChars": 128018, + "keyboard": "Enter" + }, + "multiple": { + "toggle": true, + "copy": true, + "expanded": "false", + "multiCollapsed": true, + "singleCollapsed": false, + "textChars": 132054, + "contentHeight": 2101, + "contentMaxHeight": "none", + "early": [ + { + "textChars": 7, + "visible": false + }, + { + "textChars": 128000, + "visible": false + }, + { + "textChars": 7, + "visible": false + } + ], + "dividerCount": 1, + "fencedDivider": false, + "actionButtons": [ + { + "className": "msg-last-section-btn", + "top": 865.84375, + "row": "msg-action-row" + }, + { + "className": "msg-copy-reply-btn", + "top": 865.84375, + "row": "msg-action-row" + } + ], + "copyWidth": 28, + "copyHeight": 28, + "toggleHeight": 30, + "width": 1280, + "scrollWidth": 1280, + "copiedChars": 4016 + }, + "multipleExpanded": { + "toggle": true, + "copy": true, + "expanded": "true", + "multiCollapsed": false, + "singleCollapsed": false, + "textChars": 132054, + "contentHeight": 66848.25, + "contentMaxHeight": "none", + "early": [ + { + "textChars": 7, + "visible": true + }, + { + "textChars": 128000, + "visible": true + }, + { + "textChars": 7, + "visible": true + } + ], + "dividerCount": 1, + "fencedDivider": false, + "actionButtons": [ + { + "className": "msg-last-section-btn", + "top": 67186.09375, + "row": "msg-action-row" + }, + { + "className": "msg-copy-reply-btn", + "top": 67186.09375, + "row": "msg-action-row" + } + ], + "copyWidth": 28, + "copyHeight": 28, + "toggleHeight": 30, + "width": 1280, + "scrollWidth": 1280, + "copiedChars": 4016, + "keyboard": "Space" + }, + "completed": { + "toggle": true, + "copy": true, + "expanded": "false", + "multiCollapsed": true, + "singleCollapsed": false, + "textChars": 136748, + "contentMaxHeight": "", + "early": [ + { + "textChars": 7, + "visible": false + }, + { + "textChars": 128000, + "visible": false + }, + { + "textChars": 7, + "visible": false + }, + { + "textChars": 19, + "visible": false + }, + { + "textChars": 4014, + "visible": false + } + ], + "dividerCount": 2, + "fencedDivider": true, + "actionButtons": [ + { + "className": "msg-last-section-btn", + "top": 671.75, + "row": "msg-action-row" + }, + { + "className": "msg-copy-reply-btn", + "top": 671.75, + "row": "msg-action-row" + }, + { + "className": "msg-branch-btn", + "top": 671.75, + "row": "msg-action-row" + } + ], + "copyWidth": 28, + "copyHeight": 28, + "toggleHeight": 30, + "width": 1280, + "scrollWidth": 1280, + "copiedChars": 4165 + }, + "completedExpanded": { + "toggle": true, + "copy": true, + "expanded": "true", + "multiCollapsed": false, + "singleCollapsed": false, + "textChars": 136743, + "contentMaxHeight": "", + "early": [ + { + "textChars": 7, + "visible": true + }, + { + "textChars": 128000, + "visible": true + }, + { + "textChars": 7, + "visible": true + }, + { + "textChars": 19, + "visible": true + }, + { + "textChars": 4014, + "visible": true + } + ], + "dividerCount": 2, + "fencedDivider": true, + "actionButtons": [ + { + "className": "msg-last-section-btn", + "top": 666, + "row": "msg-action-row" + }, + { + "className": "msg-branch-btn", + "top": 666, + "row": "msg-action-row" + }, + { + "className": "msg-copy-reply-btn", + "top": 666, + "row": "msg-action-row" + } + ], + "copyWidth": 28, + "copyHeight": 28, + "toggleHeight": 30, + "width": 1280, + "scrollWidth": 1280, + "copiedChars": 4165 + }, + "mobile": { + "toggle": true, + "copy": true, + "expanded": "false", + "multiCollapsed": true, + "singleCollapsed": false, + "textChars": 136748, + "contentMaxHeight": "", + "early": [ + { + "textChars": 7, + "visible": false + }, + { + "textChars": 128000, + "visible": false + }, + { + "textChars": 7, + "visible": false + }, + { + "textChars": 19, + "visible": false + }, + { + "textChars": 4014, + "visible": false + } + ], + "dividerCount": 2, + "fencedDivider": true, + "actionButtons": [ + { + "className": "msg-last-section-btn", + "top": 5243.40625, + "row": "msg-action-row" + }, + { + "className": "msg-branch-btn", + "top": 5243.40625, + "row": "msg-action-row" + }, + { + "className": "msg-copy-reply-btn", + "top": 5243.40625, + "row": "msg-action-row" + } + ], + "copyWidth": 44, + "copyHeight": 44, + "toggleHeight": 44, + "width": 390, + "scrollWidth": 390, + "copiedChars": 4165 + } + }, + "singleReplyLocate": { + "visible": true, + "expanded": true, + "top": 761.25, + "bottom": 786 + }, + "nextUserTurn": { + "assistantBubbles": 2, + "preservedPrevious": true + }, + "ok": true, + "sourceFingerprintsAfter": { + "server.js": "c964060ad121bf16edd44caf94226455cf8a434bb8b14b9f5c434a3215d6b404", + "lib/agent-runtime.js": "997e30969c5e2479655bff7c061243b1b3bf36482eee76ea420d7c4f45e82e57", + "lib/codex-app-runtime.js": "6fe66921ed8998ed1b1478eb804c0ad965ff40fff7a18dd8086e3de42b67c122", + "lib/session-history-store.js": "e8daab67b60e30b451a16654d857700fb676dd5f9be33f2490a8db1c2e109b56", + "public/app.js": "433b4e68a9e91440eada679199a86ca7a84cf28b0f979eb3f46044be99fe757e", + "public/style.css": "1dc679c46b8d26947d3489851ec0058e61b35ada23ac4cd2089f38999a773f6c", + "scripts/mock-codex-app-server.js": "b22d0b027e93df45b991957b040cded5af5f0dbf392a210f923e5c31e8fa29ec", + "scripts/streaming-retry-browser-check.js": "02a5a1afc5cb3d7329c10165b14aba1923903c734d32ae8835b7503b0c917a2f" + }, + "sourcesChangedDuringRun": [] +} \ No newline at end of file diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/delivery-result.md b/.trellis/tasks/10-10-streaming-retry-history/research/delivery-result.md new file mode 100644 index 0000000..3725960 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/delivery-result.md @@ -0,0 +1,44 @@ +# 运行中气泡续接、折叠与末段复制交付 + +代码实现、独立审查、专项、全量回归及正式浏览器验收均通过。生产后端进程未重启,真实目标旧气泡未被写回。 + +## 最终行为 + +- 自动续跑交接完整正文、工具及去重映射,保留稳定助手气泡身份;等待期、刷新、切换、重连、完成和归档读取均保持完整。 +- 长正文增量分片发送,拼接结果等于原文;完成和历史读取不再用正文或工具预览限额覆盖完整内容。 +- 取消未知身份的启动时保留旧轮门禁,确认身份后精确中断;RPC超时晚到通知和服务明确退出均覆盖,旧通知不能结束新轮。 +- 长气泡只折叠显示,完整正文及DOM保留;多原生回复收起较早段,末段可见,单条长回复可展开。定位单条长回复末段时先展开。 +- 复制按钮与定位、分支同排,仅复制最后一个原生回复段的完整Markdown;保留代码、列表、多自然段、缩进和行末空格,过滤分隔时间、工具及按钮。围栏内同名HTML不会误分段。 + +## 验证 + +| 检查 | 结果 | +|---|---| +| `TMPDIR=/dev/shm node scripts/regression.js` | 退出0,`Regression checks passed.`;与package.json的regression入口相同 | +| 完整续接及取消启动交错专项 | 21/21,含通知先于RPC、RPC超时晚到身份、服务明确退出3场景 | +| runtime完整采集及读取 | 13/13,130项工具、24006字符输入、48006字符结果完整;实时预览有界 | +| 完成与重启恢复 | 15/15 | +| steer恢复 | 5/5 | +| 前端续接原函数 | 5/5 | +| 折叠、复制、定位原函数 | 8/8 | +| 正式Chromium隔离浏览器 | 8阶段全通过,最终DOM136748字符、同一稳定气泡ID和3个工具;新用户轮独立 | +| 桌面及390px手机 | 折叠、展开、末段复制、键盘操作、单条长回复定位及44px触控通过,5张截图已查看 | +| 语法及差异检查 | 通过 | +| 实现及专项脚本指纹 | 全量与浏览器前后均未变化 | + +全量输出见 [regression.log](./regression.log),实现指纹见 [verified-sources.sha256](./verified-sources.sha256)。浏览器报告见 [frontend-result.md](./frontend-result.md),协议及DOM证据见 [browser-evidence.json](./browser-evidence.json),独立审查见 [review.md](./review.md)。 + +截图归档:[桌面折叠](./desktop-collapsed-top.png)、[手机末段与按钮](./mobile-latest-reply.png)。完整5张截图位于 `/dev/shm/ccweb-streaming-retry-browser-G8wkN6`;协议及报告已经复制进任务目录。 + +本机磁盘争用使npm包装进程初始化阻塞且尚未运行脚本,停止本任务包装进程后直接执行相同Node入口。所有独立单元测试使用60秒上限,存储与浏览器夹具移到 `/dev/shm`,没有降低断言或以桩替代Markdown渲染。 + +## 目标旧正文及生产边界 + +- 目标会话 `826deaa0-c3df-42bc-930b-06ee3e05de50` 精确匹配原生线程 `01a124a5-b65e-70d2-b712-06168bae7bd5`。抓取点仍有55段原生助手正文、19606字符,已另存 [核对副本](./recovered-assistant.md)。这是可靠可审阅的旧正文,未猜测或写回真实会话。 +- 本次原生读取截至2026-10-10T22:21:45+08:00,不代表运行中的记录不再增长,也不代表全部工具或另一客户端的45条已恢复;详细边界见 [target-session-evidence.md](./target-session-evidence.md)。 +- 2026-10-10T23:09:10+08:00只读检查仍有目标会话和另一会话的运行态为running。按项目重启规则暂缓生产重启,未改变真实会话、rollout或运行态。 +- 前端资源由服务按请求读取,刷新可加载折叠和复制控件;后台防丢修复需后端重启后加载。刷新显示控件不会自动补回旧版运行态已经漏掉的前文,不能声称真实旧气泡已经恢复。 + +## 工具调用简报 + +2026-10-10:FastCtx本地源码/任务读取、隔离测试和补充rg定位;codebase-memory此前连接断开后使用本地降级,不使用graphify。浏览器使用真实相同版本公共渲染库,经已有代理下载并记录来源和SHA256。没有向外发送真实会话内容,没有重启生产。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/desktop-collapsed-top.png b/.trellis/tasks/10-10-streaming-retry-history/research/desktop-collapsed-top.png new file mode 100644 index 0000000..0d9f9d5 Binary files /dev/null and b/.trellis/tasks/10-10-streaming-retry-history/research/desktop-collapsed-top.png differ diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/frontend-result.md b/.trellis/tasks/10-10-streaming-retry-history/research/frontend-result.md new file mode 100644 index 0000000..58ba2f5 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/frontend-result.md @@ -0,0 +1,60 @@ +# 运行中长气泡前端验收 + +本轮隔离验收完成,浏览器脚本退出码 0。证据目录:`/dev/shm/ccweb-streaming-retry-browser-G8wkN6`。 + +## 变更行为 + +- 正文超过 6000 字或达到 24 个文本块时显示折叠入口。多原生回复默认只显示最后回复,较早正文保留在 DOM,展开后完整显示。 +- 无原生分界的单条长回复以 420px 高度收起,展开解除裁剪。既有定位入口先展开,再定位到末段。 +- 复制入口与定位、分支控件放在现有工具行。复制取原始 Markdown 的最后原生回复;无原生分界时取完整回复。保留首行缩进和行末空格,过滤工具文本与真实回复分界。 +- 围栏内同名分界 HTML 不作为原生回复分界。半截分界流式到达时仍复制上一完整回复。 +- 使用原生按钮的 Enter、Space 操作;手机复制、定位、分支和折叠入口均至少 44px。 +- 展开状态按会话和稳定消息 ID 在本页保存,切换/完成保留,刷新重新采用默认折叠。 + +## 验证结果 + +真实 Chromium 149.0.7827.55,真实生产 server.js、app.js、CSS,门控 Codex mock A/B/C;测试使用独立临时会话、配置、日志及原生历史目录。服务正文长度阈值设为 4096,实际第一段 128000 字以上。 + +8 个阶段全部通过:初始 A、切换返回 A、等待重试刷新 A、等待重试重连 A、续跑 AB、续跑 ABC、完成 ABC、完成刷新 ABC。最终 DOM 136748 字、一个稳定助手消息 ID、3 个完整工具结果。A/B/C 首尾各一次、中间长度及顺序完整;下一用户轮次创建独立气泡且保留旧气泡。 + +单条长回复定位检查记录 `expanded:true,visible:true`,裁剪区末段实际出现在视口。单条 Enter 展开、多条 Space 展开、较早 DOM 显隐、复制原始完整末回复(代码、列表、多自然段)、手机无额外水平溢出、editorial/nocturne 主题均通过。 + +前端控件单测 8 组、流式恢复单测 5 组、现有历史前端回归均退出 0。app.js 和全部新脚本语法检查、所改文件 `git diff --check` 均通过。 + +## 截图 + +- `desktop-collapsed-top.png`:桌面折叠,较早两段隐藏,末回复开头、列表与代码完整显示。 +- `desktop-latest-reply.png`:桌面末尾,复制与定位、分支同排,3 个工具结果保留。 +- `completed-earliest-text.png`:展开后最早第一段正文实际可见。 +- `mobile-collapsed-top.png`:390px 手机折叠开头及代码,44px 展开入口。 +- `mobile-latest-reply.png`:手机末尾三控件同排,44px 复制按钮。 + +以上 5 张截图均已实际查看。 + +## 来源和版本 + +浏览器脚本记录 `sourceFingerprints` 与 `sourceFingerprintsAfter`,本轮 `sourcesChangedDuringRun:[]`。 + +```text +server.js c964060ad121bf16edd44caf94226455cf8a434bb8b14b9f5c434a3215d6b404 +public/app.js 433b4e68a9e91440eada679199a86ca7a84cf28b0f979eb3f46044be99fe757e +public/style.css 1dc679c46b8d26947d3489851ec0058e61b35ada23ac4cd2089f38999a773f6c +scripts/assistant-bubble-controls-unit.js f60bb8c1c393503c461885fd2ee7ef7636dacd2a94c3654ddcac470dc449ca63 +scripts/streaming-retry-frontend-unit.js ea085438474e436d3f47f9d3c92afd5bb4fa0ea2b3f72252e52126e902c9f262 +scripts/streaming-retry-browser-check.js 02a5a1afc5cb3d7329c10165b14aba1923903c734d32ae8835b7503b0c917a2f +``` + +外部 CDN 直连与 proxyd 下载超时后,按 proxyd-accelerator 技能降级到已有局域网代理下载相同版本库。marked 12.0.1、highlight.js 11.9.0、mermaid 10.9.1 与样式使用完整真实字节;无渲染桩。缓存目录 `/dev/shm/ccweb-streaming-browser-assets`,来源 URL、字节长度和 SHA256 记录在 evidence.json 的 assets 字段。 + +复跑命令(上限 60 秒): + +```bash +timeout --signal=TERM --kill-after=5s 60s env TMPDIR=/dev/shm CC_WEB_STREAMING_BROWSER_ENGINE=chromium CC_WEB_STREAMING_BROWSER_ASSET_DIR=/dev/shm/ccweb-streaming-browser-assets node scripts/streaming-retry-browser-check.js +``` + +## 生产边界 + +本前端代理仅修改 UI 源码和隔离验证脚本,未写入真实会话、rollout 或运行态,未重启生产进程。父代理已确认生产静态资源按请求读取,刷新可加载新显示控件;完整正文恢复仍依赖后端修复的部署。不能据本隔离验收声称目标真实旧气泡已经恢复。 + +修复前隔离证据 `/tmp/ccweb-streaming-retry-browser-1XV24a`:初始 A 完整,切换返回 A 时 attach 裁剪使尾段丢失。最终本轮相同链路完整通过。该修复前 server 备份取自后端早期增改时,不宣称为完整未修改基线。 + diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/mobile-latest-reply.png b/.trellis/tasks/10-10-streaming-retry-history/research/mobile-latest-reply.png new file mode 100644 index 0000000..45206dd Binary files /dev/null and b/.trellis/tasks/10-10-streaming-retry-history/research/mobile-latest-reply.png differ diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/recovered-assistant.md b/.trellis/tasks/10-10-streaming-retry-history/research/recovered-assistant.md new file mode 100644 index 0000000..feb6585 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/recovered-assistant.md @@ -0,0 +1,754 @@ +# 第五次重写:原生助手正文核对副本 + +会话:`826deaa0-c3df-42bc-930b-06ee3e05de50` +线程:`01a124a5-b65e-70d2-b712-06168bae7bd5` +读取时间:2026-10-10T22:21:45.228316+08:00 + +共 55 段、19606 字符。按原生 response_item assistant message 顺序提取,仅包含输出正文;不包含工具结果或模型思考。此副本没有写回正在运行的会话。原生日志仍在追加,本文件只代表本次读取范围。 + +## 1. 2026-10-10T15:10:30.152000+08:00 + +原生行号 12;消息 `msg_09ddac1c7a8cab52016ac9e4e1795087d09caecb2bd784b09f`;阶段 `commentary`。 + +前几轮把“能运行、物体更多”当成了画面质量,却没解决构图、素材和空间关系。这次我先重读失败复盘,再检查当前实现和你给的参考图,按第五次重写重新制作;交付前会亲自核对浏览器里的初始画面和左右游动效果。 + +我会使用 `frontend-skill` 把控场景构图,使用 `todo-list-csv` 跟踪重写与验收。 + +## 2. 2026-10-10T15:11:35.991000+08:00 + +原生行号 43;消息 `msg_09ddac1c7a8cab52016ac9e52292d887d0b6949d5d7fc24353`;阶段 `commentary`。 + +复盘里最关键的一点是:真实坐标解决了移动,但零散贴图、空洞构图、暗块和池底硬边仍然让画面失败。你这张参考图好看,靠的是连贯的地形、成组的物体、统一光照和明确的前后关系。 + +第五次重写会围绕这些关系制作一段完整的池塘生态:岩岸与植物连成结构,中央保留游泳通道,近景、池底和远景各有层次。新环境素材会使用 `gpt-image` 技能生成,先检查实际场景片段,再扩展地图。 + +## 3. 2026-10-10T15:17:49.908000+08:00 + +原生行号 115;消息 `msg_09ddac1c7a8cab52016ac9e69aa1a487d09a6f233e26415e2d`;阶段 `commentary`。 + +新素材生成遇到了服务并发限流,我会按退避间隔重试,并继续编写不依赖素材的世界与输入模块。旧画面里那种规则网纹池底、零散草簇和硬切水体不会沿用;这一轮会重建连续地形,并用同一套光照下的独立素材组成岸带。 + +## 4. 2026-10-10T15:23:15.945000+08:00 + +原生行号 154;消息 `msg_09ddac1c7a8cab52016ac9e7df396087d0a17fad0901d9e57c`;阶段 `commentary`。 + +池底材质已经生成,运动、输入和界面正在分开重写。场景采用真实起伏的砂地,苔岩、苦草、水榕、沉木和荷茎各有世界坐标;植物只摆动叶尖,根部固定在地形上。 + +环境图集仍在等待生成。拿到后我会先检查抠图边缘和一小段岸带的桌面、手机构图,避免再把有暗底板或风格不一致的素材直接铺满地图。 + +## 5. 2026-10-10T15:26:56.769000+08:00 + +原生行号 187;消息 `msg_09ddac1c7a8cab52016ac9e8bd147887d088f502a5b368e734`;阶段 `commentary`。 + +能。第五次按重写执行:旧入口和整套场景逻辑已经替换,新建世界、地形、玩家运动、相机和输入模块。旧环境素材退出新场景,只保留你允许复用的五帧锦鲤。 + +现在新环境图集遇到生成服务限流,素材和浏览器验收还没完成,我不会把当前中间状态交付成完成版。 + +## 6. 2026-10-10T15:30:25.956000+08:00 + +原生行号 220;消息 `msg_09ddac1c7a8cab52016ac9e98e0dc887d0b3396529c51b4d3b`;阶段 `commentary`。 + +重写的运动、输入和界面模块已经写好,运动与输入的 16 项测试通过了。环境图集的生成请求仍被限流,参考图编辑请求又返回了“无可用图像额度”,所以目前还无法进入完整画面验收。这个素材阻塞我会保留在记录里,不会用旧草石替代后宣称重写完成。 + +## 7. 2026-10-10T15:36:07+08:00 + +原生行号 268;消息 `msg_09ddac1c7a8cab52016ac9eae3734087d08125ba259e43e8f5`;阶段 `commentary`。 + +图像服务的备用请求也没有拿到环境图集,我会停止反复请求。接下来把岩岸、沉木和植物做成独立的细致三维模型,使用已经生成的砂地材质;先在浏览器检查一段场景的曲面、材质、阴影和构图,达到要求后再扩展。 + +旧入口已整体替换,这一轮继续按重写推进。 + +## 8. 2026-10-10T15:42:44.535000+08:00 + +原生行号 315;消息 `msg_09ddac1c7a8cab52016ac9ec7023b087d0be3afca3ea6b7330`;阶段 `commentary`。 + +新的场景核心已改为起伏地形与真实光照,岩石和植物会把阴影投到池底,相机按玩家世界位置跟随。岩岸、分叉沉木和曲面植物模型正在制作。 + +下一步会直接检查浏览器里的层叠苔岩、分叉沉木、阔叶水榕和荷影,分别看桌面与手机的初始构图及左右经过效果。 + +## 9. 2026-10-10T15:49:34.332000+08:00 + +原生行号 370;消息 `msg_0b7ac35162f289ca016ac9ee0b6c20819193506d969b2249b8`;阶段 `commentary`。 + +岩岸和沉木模型已做好:层叠苔岩有侵蚀轮廓与苔衣,沉木有弯根、分叉和木纹,并使用场景中的同一束光照。它们尚待实际画面检查,造型细节本身不能证明好看。 + +曲面植物接入后,我会马上打开浏览器复核,先判断构图和材质,再判断左右移动。 + +## 10. 2026-10-10T15:52:04.826000+08:00 + +原生行号 400;消息 `msg_0b7ac35162f289ca016ac9eea1fb0c8191ba0ba8fdad84ea51`;阶段 `commentary`。 + +第一张岩木检查图暴露了三个具体问题:池底水纹过于规则、石组像一块大扁石、沉木表面出现块状亮暗。它们还不符合要求。 + +我会先修正这些模型和材质本身,再检查植物接入后的完整画面,不靠增加物体数量或特效掩盖。 + +## 11. 2026-10-10T15:58:04.167000+08:00 + +原生行号 469;消息 `msg_0613e249e99c56dc016ac9f00922fc819188f7cfedb4e4a082`;阶段 `commentary`。 + +完整片段的首屏已经打开。现在能看到真实地形和投影,但两岸排得太整齐,远处层次不足,画面仍有“陈列植物和石头”的感觉。 + +我会重新组织这段场景的岸坡和疏密关系;同时降低渲染开销,保留曲面细节,让手机和浏览器里的游动更顺畅。 + +## 12. 2026-10-10T16:05:52.050000+08:00 + +原生行号 523;消息 `msg_097264e4b8d3c1e0016ac9f15517408191afad9bc39b20d186`;阶段 `final_answer`。 + +当前任务是用户重新授权的**第五次池塘场景重写**。用户提供了一张精致三维村落截图,质问此前美术质量,并强调:“你能不能不修改,而是重写”。必须继续完成重写,不能只交计划或中间版本。 + +### 必须遵守的约束 + +- 全部交流、文档、界面与必要注释用简体中文。 +- 已阅读根目录 `AGENTS.md` 和 `docs/池塘场景失败复盘.md`。历史版本失败、历史待办不代表完成。 +- 严禁单张完整图片假地图、旧贴图堆数量、粗糙低多边形、程序占位草石、用坐标或构建代替视觉验收。 +- 世界、玩家、相机独立;左右输入必须造成明显地标移动和不同深度视差,鱼连续五帧摆尾、相机跟随、松手平滑减速。 +- 桌面与手机都要保存并肉眼检查 A 初始、B 无输入 ≥1 秒、C 右游 ≥1.5 秒、D 随后左游 ≥3 秒;隐藏 HUD、远离起点也要检查。 +- 只有**主代理亲自查看最终浏览器截图**,能描述至少两个地标移动、方向相反、近景比远景快、鱼处于安全区,无暗块、草墙、硬边等,才可报告完成。 +- 用户明确要求重写,因此无需再次请求开发授权。 +- 用户提供的 AGENTS 明确要求并行工作,已据此使用三个子代理;通常开发者禁止无授权主动委派,但此处授权充分。 +- 不要每分钟沉默,继续发有意义的中文简短进度。不要拿中间画面宣称通过。 + +### 工作目录与工具 + +- `/home/hdzx/2026/kongjun`,**不是 Git 仓库**。 +- Node 18.19.1、Python 3.12.3;已有 `three ^0.185.1`、`vite ^5.4.21`,无需安装项目依赖。 +- Playwright:`/home/hdzx/node_modules/playwright/index.js`,导入 default 后取 `chromium`。 +- Chromium 实际使用 **SwiftShader 软件渲染**,这是目前性能瓶颈。 +- 使用 `functions.exec` 内 `tools.mcp__fastctx__run/inspect_local_file/run_background/job_output` 等;已探明参数。 + - run:`{command,cwd,timeout_ms}` + - background:`{command,cwd}` + - inspect:`{files:[{path,offset?,limit?}]}`,没有 `max_lines`。 + - 查看图:`const im=await tools.view_image({path}); image(im.image_url);` + - 本地改文件用 `tools.apply_patch`,不能在同一个 patch 同时 Delete/Add 同一路径;若整体替换需分两次调用。 +- 本地开发服务正在运行:FastCtx job **`j-4thcp3`**,`npm run dev -- --port 5173 --strictPort`,地址 `http://localhost:5173/`,host 0.0.0.0。交付需说明仍运行;关闭需明确告知。 + +### 已使用的技能 + +已宣布并阅读: + +- `/home/hdzx/.codex/skills/frontend-skill/SKILL.md` +- `/home/hdzx/.codex/skills/todo-list-csv/SKILL.md` +- `/home/hdzx/.codex/skills/gpt-image/SKILL.md` +- 阅读过 system imagegen,但没有原生 image tool,实际用本机 gpt-image 技能。 +- 计划技能要求独立计划审查,已完成,两次审查都通过,**仅计划通过,不是视觉通过**。 + +### 当前方向与素材阻塞 + +原本准备生成六格独立环境素材图集与砂地材质,图像服务多次 429 并发满;备用 `gpt-image-2`、参考图编辑、降尺寸仍失败,最后报 `no available image quota`。已向用户如实解释、停止重复请求。 + +**成功生成了砂地材质**: + +- 原图:`tmp/rewrite-five/sand-source.png`,1536×1536。 +- 正式纹理:`public/assets/pond-five/sand.webp`。 +- 提示词记录:`docs/第五次素材提示词.md`。 +- 使用技能启动器:`uv run /home/hdzx/.codex/skills/gpt-image/scripts/generate.py`,本机配置自动读取专用凭证,绝不输出密钥。 + +之后**公开说明改为重新制作细致曲面三维正式模型**,并请计划审查员核对:新精细三维模型不违反项目约束,禁止的是旧粗糙低多边形/基础占位模型,最终仍须视觉检查。不是偷偷降级,也没有沿用旧环境贴图。 + +向用户发过异步可选问题:有无已配置其他图像服务或新素材目录,提供路径即可、不需密钥。**尚未收到回答**;当前工作不依赖此答案,继续新三维方向。 + +视觉方向:暖白日光、青碧池水、连续苔岩岸带、水榕、苦草、羽蕨、沉木、荷茎,中央泳道,克制四角界面。用户村落图借鉴空间、统一光照与主次,不复制村落或用它做背景。 + +### 已重写的文件 + +**旧 `src/main.js` 748 行已整体移除并新写**,备份仅作失败参考在 `tmp/rewrite-five/previous-main.js`。旧环境 PNG 都不被新场景引用,只有允许的锦鲤五帧复用。 + +新入口 `src/main.js`: + +- `PondExperience` 调用新 UI、模型、输入、世界。 +- 暂停、H 隐藏 HUD、Esc 恢复、全屏,事件清理、HMR 生命周期。 +- 全局 `window.pond`,`ready` / `model` / `world` / `input` / `getDebugMetrics()`。 +- 每帧 dt **上限 .08 秒**,模型内部又上限 .1。这在软件渲染低 FPS 下丢弃大量真实时间,导致输入 1.7 秒只推进几帧,当前必须优先优化渲染,而不是用更快数字掩盖。 +- destroyed flag 避免异步加载完成后操作已销毁实例。 + +`src/pond/model.js`(子代理制作,149 行): + +- `PondModel`, `damp`, `clamp`。 +- 独立 playerX/cameraX/velocity/time/frame/heading/input/distance。 +- 世界 ±320,玩家 ±280,maxSpeed 9。 +- 加速时间常数 .15s,减速 .2s,相机跟随 7。 +- 五帧持续循环,待机 9 fps、游动 14 fps,转向 heading ±1.1。 +- 帧率无关积分、转向惯性、边界、掉帧处理。 + +`src/pond/input.js`(227 行): + +- 键盘 A/D/方向键集合,按钮长按指针捕获,画布拖动,多触点优先。 +- clear/destroy,blur、visibility、cancel、capture 丢失可靠释放,按钮状态同步。 + +`src/pond/interface.js`(228 行)及全新 `src/styles.css`(509 行): + +- `createInterface(root)` 返回 shell/canvas/buttons/pauseButton/fullscreenButton/hideButton/**hudRestoreButton** 等。 +- 普通界面统一 `.pond-hud`,恢复按钮 `.pond-hud-restore`。 +- 左上“不上钩·池塘漫游”,右上三个线性图标,左下“01 荷影之间”,右下左右按钮;手机两角 54px 按钮。 +- 没有原距离计数、占据场景大标题、调试面板。 +- 加载、错误、setPaused/setLoading/setError/setHudHidden、销毁。 + +`src/pond/terrain.js`(约75行,最近修改): + +- `bedHeight(x,z)` 连续起伏岸坡。 +- PlaneGeometry 760×250、350×150 subdivisions,顶点真实高度,平滑法线。 +- MeshStandardMaterial 用生成砂地材质,**当前 color `0x60b7c8`**、roughness .92。 +- 注入世界坐标水光;**已移除昂贵九格 Voronoi 规则网纹**,改为三组正弦干涉聚焦光,曲线扰动,低强度局部光斑。 +- `terrain.userData.waterTime.value` 更新。 +- 远地面完整延伸,不裁平面制造水平硬边。 + +`src/pond/rock-wood.js`(约480行,子代理最新修正已写完): + +- API `createMossRocks(seed,scale,sandTexture)`、`createDriftwood(...)`,返回根 y=0 Group,每组一合并 mesh。 +- 多块相嵌苔岩,变形曲面、错层侵蚀、裂层、水蚀轮廓;完整弯根、七向分叉、渐细非圆截面与端口。 +- 标准 PBR 注入世界空间石纹、苔衣、木纹、微法线。 +- 第一版实景有一大块扁石和木纹方块,**已根据主代理截图反馈重写轮廓/材质**;最新岩岸约1.3–1.45万三角、沉木约2.33万,仍有昂贵 fragment FBM/noise,可能是当前性能瓶颈之一。 +- 最近看图已经不像白斑大扁石那么差,木纹格状明暗也消失。 +- 若需优化,考虑把片元噪声/细节预烘焙到纹理或顶点,而非继续复杂每像素 FBM。 + +`src/pond/plants.js`(889行,子代理完成): + +- API `createRibbonPlant`, `createBroadleafPlant`, `createFern`, `createLotus`, `animatePlants`. +- 真正曲面叶、弯曲茎、中脉、荷叶缺口/辐射叶脉,不是 Sprite 或草线。 +- 每株2合并mesh,8造型缓存共享几何/材质,顶点shader叶尖摆动,根权重0。 +- 叶片MeshStandardMaterial:DoubleSide、vertexColors,256×512叶脉 map、bumpMap、roughnessMap;不透明。 +- customDepthMaterial/customDistanceMaterial 用相同顶点变形。 +- **当前几何过密**: + - 苦草:叶面 rows52 columns8,中脉28×4,约1–1.5万三角。 + - 水榕:叶面34×16、茎28×7、中脉24×5,约1–1.5万。 + - 蕨:3–5 fronds,每12–14对羽叶,羽面12×6、中脉9×4,约1.9–3.2万。 + - 荷叶:叶面24×88,茎48×8,11中脉16×4,约1.3–1.9万。 +- 下一步可以合理降低曲面采样密度、保持平滑法线与叶脉纹理,用分层精度,切忌变成低多边形/粗糙占位。属性 plantFlex/plantPhase 必须保持。 +- 从屏幕看前景大水榕和蕨较好,苦草显得尖直,可加强弯曲与聚散而非简单加数量。 + +`src/pond/leaves.js`(自写): + +- 独立世界落叶组,曲面尖叶、有中脉颜色、褐金材质,非随机椭圆占位。 +- 通道细节。 + +`src/pond/world.js`(约260行,最新): + +- Three真世界、PerspectiveCamera、DirectionalLight、HemisphereLight、真实阴影。 +- camera desktop `(x,17,23)` 看 `(x,-.5,-6)` FOV46。 +- mobile `(x,20,26)` 看 `(x,-.5,-5)` FOV58,依据宽高比<.85,竖屏重排视角。 +- tone ACES exposure **.88**;hemi `0xb7e9e1 /0x285653 intensity1.65`,sun `0xffedcc intensity2.6`。 +- fog **`0x287578` 从27到88**;背景仅独立水体渐变气氛,无完整世界图片。 +- 阴影1024 PCFSoft,autoUpdate=false;相机移动>.35或世界时间>.45才needsUpdate,减少开销。 +- 当前局部 `localHalfWidth=65`,**尚未 expandWorld**。 +- `buildBanks()` 每section13,随机x±1.25,岸线 meander `sin(x*.073)*2.5+sin(x*.19)*1.2`。 + - far≈-11-bend,一组较大苔岩、苦草、水榕、蕨,间隔沉木。 + - near≈10.3+sin(x*.083+1.1)*1.7,前景石组、水榕、蕨,部分苦草/沉木。 + - 通道小苔石、小水榕、金色落叶。 + - far z≈-31,岩脊/高草;隔section荷叶。 +- 命名地标:**右侧层叠苔岩、左侧分叉沉木、右侧弯根沉木、前景青苔石组、前景阔叶水榕、远处三片荷叶**。 +- player 五帧Sprite,scale1.83×2.75,y≈.25,heading转向,有地面软投影。 +- 最新新增 `buildWaterLight()`:远处低透明斜水光plane supporting atmosphere,真正地形/地标仍主体;init调用、update更新时间。 +- x距离可见裁剪 中近50、远76;Three自己的frustum也有效。 +- `expandWorld()` 清除局部对象再buildBanks(335),当前还没有启用。**共享植物资源不能被逐株处置**;当前该方法收集所有geometry dispose后重新克隆缓存,有资源生命周期隐患,扩展前处理。 +- landmarks方法投影世界对象名/层/屏幕位置,只能辅助,不能代替肉眼。 + +### 文件与计划 + +计划文档 `docs/第五次重写计划.md` 已更新正式三维方向和API阻塞,二次独立审查通过。 + +当前 CSV:**`第五次池塘重写 TO DO list.csv`**,与 update_plan 同步: + +1. 复核失败证据并确定第五次重写构图 DONE +2. 制作统一的岩岸植物与池底素材 DONE(只是制作已结束,不是美术通过) +3. **重建世界玩家相机并验证场景片段 IN_PROGRESS** +4. 扩展连续生态与移动端构图 TODO +5. 完成桌面与手机左右游动视觉验收 TODO +6. 执行构建与回归检查并保存交付证据 TODO + +按 `todo_csv.py advance` 同步,只能全DONE才cleanup,不动历史CSV。 + +`tests/pond-motion.test.mjs` 16项有意义运动/输入测试已全部通过,日志 `tmp/rewrite-five/motion-tests.txt`。命令 `timeout 60s node --test tests/pond-motion.test.mjs`。 + +曾 `npm run build` 通过一次,但当时世界还是未得到图集的旧新draft,**必须对最终三维实现重新build**,不能用此前结果交付。 + +`public/assets/pond-five/manifest.json` 是先前图集阻塞时新建的 ready:false 清单,**当前世界已不使用**,建议删除这个新建的无用文件,避免混淆。 +`scripts/prepare_fifth_assets.py` 原本切品红图集/处理砂地,目前仅 `--sand-only` 运行过,图集流程未用,建议简化成当前有用的砂地制备,清理无用draft逻辑。 +历史draft保存在 tmp,不影响正式场景。 + +### 浏览器验收脚本与当前核心阻塞 + +`scripts/verify_fifth_scene.mjs`: + +- 默认round fragment,baseURL localhost5173。 +- 输出 `tmp/browser-validation/fifth-rewrite//`。 +- 桌面1280×720;手机390×844,手机通过CDP `Input.dispatchTouchEvent` 按真实触摸按钮。 +- A、B静置1100ms、C D持右1700ms截图后释放、随后A持左3200ms截图。 +- 隐藏 `.pond-hud,.pond-hud-restore` 截D无界面。 +- 检查无输入稳定、松手减速、按钮在界内;**右游需playerX>起点+4,左游需<-4**。 +- round `final` 额外右游9秒远离起点、暂停/恢复、blur释放。 +- 原始截图/报告必须主代理亲自看。 +- prepare里 `waitForFunction(()=>window.pond?.ready,{timeout:25000})` 写法参数错:第二参数实际是arg,timeout应第三参数,建议修正。 +- **验收时不要改源码**,Vite HMR会destroy实例导致截图/metrics混轮甚至`window.pond` undefined。上次就被主代理改waterLight打断。必须等文件稳定后完整重跑。 + +已失败/中间轮次: + +1. `fragment` job **j-i77kmj** `timeout60s` 被杀,只有桌面A/B,无运行错误。不能判通过。 +2. `fragment-2` job **j-z5d6ec** `timeout180s`,A/B/C/D截图但在D读metrics时被HMR打断,报告只有A/B/C。不能判通过。 + +主代理已经看过这些中间桌面图,发现: + +- 首版像园艺陈列:石草横向排整齐、空地大、绿色地面过强、远处平、没有池塘的自然生态感。 +- 调整了岸线错落、岩石尺寸与灰青材质、增加远景水体层次、消除规则网格水纹与木纹块。 +- 仍然**没有完成移动端画面检查**。 +- 中间图路径: + - `tmp/browser-validation/fifth-rewrite/fragment/桌面-A-初始.png` + - `.../fragment-2/桌面-A-初始.png` + - `.../fragment-2/桌面-C-右游1_7秒.png` + - 最新单图 `tmp/rewrite-five/scene-gl.png`(已看),还没有最终完整验收。 + +**最急迫问题:渲染极慢,运动肉眼变化不足。** + +刚跑单浏览器诊断(没有在跑的验收job): + +- Chromium renderer:`ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)...))`。加 `--use-angle=gl` 仍然SwiftShader,没有改善。 +- renderer.info 当前 **约1,133,068 triangles / 103 draw calls**。 +- 诊断 3秒等待实际墙钟≈4.8秒,游戏time只从.40到.56:**只推进2帧,约2秒/帧**。 +- fragment-2 A/B/C model时间 .5765/1.1365/1.6965; + C playerX **3.025** cameraX **1.947**,不满足右游+4检查。 +- 软件渲染+过密叶面/阴影/岩片元多层噪声导致极差帧率。不能通过增maxSpeed、用step模拟时间或坐标证明掩盖。 +- 下一步优先降低必要之外的曲面细分、fragment噪声成本、阴影负担、绘制开销,保留正式模型质感。平滑法线+叶脉贴图可在少很多面下保持精致;远景更应降精度。 +- 此后稳定重跑局部桌面/手机A/B/C/D,亲眼检查真实运动及构图,才允许扩展地图。 + +### 代理状态与分工 + +- `/root/plan_review` completed:两次计划通过,无代码改动。 +- `/root/motion_input` completed:model/input/tests + rock-wood首次与视觉修正。最后回传说**最新模型需重截图**,不作视觉通过。 +- `/root/interface` completed:UI/CSS + plants.js。模型、shader阴影通道和共享资源已检查,没有场景视觉判断。 +- 不需要再等旧代理;可 followup 分配明确互不冲突的性能子任务,用户AGENTS允许并行,但最终主代理看图。 + +### 最近给用户的进度(不要重复照搬) + +“完整片段的首屏已经打开。现在能看到真实地形和投影,但两岸排得太整齐,远处层次不足,画面仍有‘陈列植物和石头’的感觉。我会重新组织这段场景的岸坡和疏密关系;同时降低渲染开销,保留曲面细节,让手机和浏览器里的游动更顺畅。” + +### 清晰下一步 + +1. 优先优化当前正式三维渲染,避免几秒一帧;合理细分、低成本石木材质、共享或分层精度、阴影。 +2. 再审构图:成组错落岸带与中央通道,不能只横向齐排物体或堆数;增强真正水下的光照/层次,禁止掩盖粗糙。 +3. 稳定源文件后重新执行局部桌面/手机A/B/C/D及隐藏HUD,亲自看所有图并描述地标移动。任何失败继续修结构,不能说完成。 +4. 局部美术与运动同时成立后启用 `expandWorld()`(处理共享资源生命周期),延续连续生态。手机自身构图与远离起点检查。 +5. 最终完整验收、运动/输入测试、当前构建;中文记录截图路径与视觉地标描述。精简交付,可用一张新首屏图加预览地址。 +6. 全部真正完成才能plan/CSV cleanup;不覆盖历史失败;告知服务状态。若最终仍无法达标,如实说明未完成,不虚报。 + +最终工具调用简报按用户要求简短列 FastCtx/Playwright/gpt-image(含图像服务阻塞与成功砂地)。 + +## 13. 2026-10-10T16:06:07.425000+08:00 + +原生行号 535;消息 `msg_0a6b4ad05296e85a016ac9f1ec88d48191ad1e14f28c74357e`;阶段 `commentary`。 + +可以,这次按重写推进。旧场景入口已经替换,世界、玩家和相机都重新建立了。当前新版本渲染太慢,画面的空间关系也还需要检查;我会先解决这些问题,再亲自核对桌面和手机上的左右游动,不把构建成功当成交付。 + +## 14. 2026-10-10T16:08:19.228000+08:00 + +原生行号 587;消息 `msg_0a6b4ad05296e85a016ac9f2702ee48191b370874fb7b2f379`;阶段 `commentary`。 + +复盘里最关键的教训是:有世界坐标,还不等于画面成立。目前这段新场景的石草仍排得过于整齐,中央砂地也像空坪。接下来会重组岸带的高低、聚散和通道;渲染优化则保留曲面与叶脉细节,减少看不见的计算。 + +## 15. 2026-10-10T16:10:03.188000+08:00 + +原生行号 606;消息 `msg_0a6b4ad05296e85a016ac9f2d7ffa481919f80df0523c24de1`;阶段 `commentary`。 + +对,重点是场景。我刚才把游动验收讲成了主任务,偏了。 + +接下来先解决画面里“石草排成一列、中央像空坪”的问题,重做岩岸、沉木和植物之间的空间关系,让它形成完整的池塘生态。先看场景本身是否有层次、是否细腻,再做移动检查。 + +## 16. 2026-10-10T16:14:14.917000+08:00 + +原生行号 653;消息 `msg_0a6b4ad05296e85a016ac9f3d43cc881918e1c1d35b0a48b95`;阶段 `commentary`。 + +我正在把岩块、根系和植物组织成大小错落的岸坡,让它们互相嵌合,砂道从岸坡之间穿过。接下来先打开没有界面的场景图检查,重点看是否还有重复陈列、空洞和过硬的边缘。 + +## 17. 2026-10-10T16:18:33.118000+08:00 + +原生行号 701;消息 `msg_04f38d076ae83b82016ac9f4d656f881918916a3a79cc969d8`;阶段 `commentary`。 + +刚看了去掉界面的场景图:岩岸有了高低和前后关系,但砂地仍然偏绿,手机远处还出现了明显的水平边界。这两处会先修正;新的岩石和沉木材质也要放回场景里检查,不能只看模型文件。 + +## 18. 2026-10-10T16:23:52.786000+08:00 + +原生行号 760;消息 `msg_0a6b4ad05296e85a016ac9f6178ed08191859cc2de9897a34e`;阶段 `commentary`。 + +静态截图说明,上一轮虽然把岸线错开了,画面仍然像“上方一排石堆、中间空地、底部一簇植物”,没有形成概念图里连续的岸坡和通道。修复顶部边界还不够,我会先改岸坡与砂地的过渡,让通道向远处延伸,再看光线、材质和手机构图是否也跟着成立。 + +## 19. 2026-10-10T16:34:55.067000+08:00 + +原生行号 789;消息 `msg_093f3adb45d3de00016ac9f8acc5cc8191be4425d72833550e`;阶段 `commentary`。 + +继续制作场景。现在要把分离的石堆和大片平地,重组为连续岸坡、向远处延伸的砂道和嵌入岸缝的植物;岩石与沉木的表面质感也会一起重做。 + +## 20. 2026-10-10T16:38:10.040000+08:00 + +原生行号 823;消息 `msg_093f3adb45d3de00016ac9f96f52d4819194a09b11f617d000`;阶段 `commentary`。 + +岸坡会和池底一起成形:较高的苔岩在一侧成为主景,另一侧由低岩、弯根和水草衔接,中央砂道逐渐通向远处。根部周围也会有沉积与碎石过渡,减少物体像摆在平面上的感觉。 + +## 21. 2026-10-10T16:43:17.310000+08:00 + +原生行号 893;消息 `msg_093f3adb45d3de00016ac9faa2206c8191809dca1bd637a54c`;阶段 `commentary`。 + +池底和岸坡已经按同一套地形衔接起来,砂面也改为中性色,让场景光照来染上水色。岩木正在换成预烘焙的裂纹、苔衣和木纹材质;接入后会检查,它们是否仍有光滑石团和弯管的感觉。 + +## 22. 2026-10-10T16:48:53.325000+08:00 + +原生行号 971;消息 `msg_0e3adb3afbbe9d76016ac9fbf31a5081919869007d5deda528`;阶段 `commentary`。 + +新截图里的砂道已经能通向远处,但岩石和长叶的表面仍然偏粗,整张画面的水下层次也不够。这段还不能作为完成版扩展;我会继续处理正式资产的质感和光照。 + +## 23. 2026-10-10T16:50:27.447000+08:00 + +原生行号 995;消息 `msg_0e3adb3afbbe9d76016ac9fc51616081918c9cecfee946217d`;阶段 `commentary`。 + +我找到了一套可免费使用的自然扫描模型,准备替换目前质感不足的岩石、沉木和蕨类。扫描资产保留了真实裂隙、树皮和叶面细节,会放进这次新建的地形与统一光照中,再检查是否协调。 + +## 24. 2026-10-10T17:11:38.912000+08:00 + +原生行号 1066;消息 `msg_04805278b0d0cf45016aca0148197c81919f8f3a498a28c1f0`;阶段 `commentary`。 + +扫描模型已下载到本地。接下来会保留原始贴图、法线和主要轮廓,简化看不见的网格开销,再把苔岩、沉木和蕨放回这段岸坡,检查是否统一、是否真正改善了场景质感。 + +## 25. 2026-10-10T17:24:06.523000+08:00 + +原生行号 1086;消息 `msg_0a6b4ad05296e85a016aca0323570c8191b09064b8b462d6a7`;阶段 `final_answer`。 + +## 当前目标与优先级 + +继续完成第五次池塘场景重写。用户已明确强调:**场景构图和美术质量是核心,左右游动与构建只是后续检查**。不要从头开始,也不要把运动指标或构建结果当作美术验收。 + +项目约束和失败复盘都已读过。单张背景假地图、粗糙低多边形和程序占位环境均被禁止;用户已重新授权重写。 + +## 已完成 + +- 新建世界、地形、玩家、相机、输入和界面模块;运动与输入的 16 项测试通过。 +- 砂地材质已生成并制备。环境图集服务失败的原因已记录,没有拿旧素材顶替。 +- 根据静态截图重写了地形和岸带布局,加入起伏岸坡、砂道、落叶与地标。 +- 最近发现免费 CC0 扫描模型,已下载 Poly Haven 的岩石、沉木和蕨类素材,来源记录在 `docs/第五次素材来源.md`。 +- 岩石和沉木扫描网格已保留贴图、UV 和法线并进行简化,原模型备份在 `tmp/rewrite-five/natural-originals/`。植物、岩木的建模和性能细节分别由已完成的子代理提供;子代理没有做浏览器美术验收。 +- 已保存前几轮静态截图,主代理逐张看过。`岸坡-4` 仍有大片均匀砂地、远处主体不足及资产质感不足,**不能视为通过**。 +- 本地 Vite 服务仍运行于 `http://localhost:5173/`,FastCtx 后台 job 为 `j-4thcp3`。 + +## 当前关键文件状态 + +- `src/pond/terrain.js`:最近整体重写,按世界坐标生成连续岸坡高度、分块地形和砂地材质。 +- `src/pond/world.js`:最近重组岸带布局,已调整桌面与手机视角;尚未接入新下载的扫描模型。 +- `src/pond/rock-wood.js`:子代理最近改为预烘焙岩木贴图和法线,网格细节与性能有所改善;仍需在真实场景中判断观感。 +- `src/pond/plants.js`:子代理完成近、中、远三档曲面植物,降低三角面并保留叶脉和摆动;需要实景检查。 +- `scripts/download_pond_models.py`:下载并校验 Poly Haven 模型。 +- `scripts/prepare_natural_models.mjs`:简化岩石和沉木网格。 +- `scripts/capture_fifth_composition.mjs`:保存桌面、手机静态场景图。 +- `第五次池塘重写 TO DO list.csv`:第 3 项仍为 `IN_PROGRESS`,其余后续步骤为 `TODO`。`update_plan` 与之同步。 + +## 下一步 + +1. 检查扫描模型的 glTF 贴图、透明度、坐标和尺度;简化尚未处理的蕨模型。 +2. 将本地扫描岩石、树干和蕨类接入新世界,替换观感不足的对应程序模型;保留独立世界坐标与现有地形架构。 +3. 重新截取桌面和手机**静态场景**,肉眼检查岸坡是否连续、砂道是否自然、远近层次和材质是否统一。`岸坡-4` 只是修改前的中间证据。 +4. 若静态场景仍有空洞或素材感不统一,继续改场景;通过这一段后再扩展世界。 +5. 场景稳定后,再做左右运动验收、构建与回归;未通过肉眼检查前不得宣称完成。 + +## 关键限制与数据 + +- Chromium 使用 SwiftShader 软件渲染。旧版本测试约 2.3–2.6 帧/秒;扫描模型集成后尚未重新测量。 +- `岸坡-3` 的计划审查指出:岩石像圆滑团块、砂地均一、近中远纵深不足。`岸坡-4` 展示的也是未接入扫描素材的旧场景。 +- 用户无须再次授权重写;之前询问是否有其他图像服务或本地素材目录,没有收到答复。现有本地扫描模型已提供继续工作的素材。 +- 项目所有沟通和必要注释使用简体中文。 + +## 26. 2026-10-10T17:24:15.150000+08:00 + +原生行号 1098;消息 `msg_0a8e0ac49bb39e4e016aca043e1df08197a90ef76a3733bdb8`;阶段 `commentary`。 + +我会从当前已重写的场景和现有素材接着做,先把扫描模型接入后能否改善岸坡构图查清楚。现在重点仍是场景本身:岸线、砂道、前后层次和材质观感;游动验收排在后面。 + +## 27. 2026-10-10T17:25:06.569000+08:00 + +原生行号 1144;消息 `msg_0a8e0ac49bb39e4e016aca04704dc881979cdbf3a0d4897fc4`;阶段 `commentary`。 + +我发现当前“岩岸”主形体仍由程序雕出的岩块组成;本地的扫描岩石、树干与蕨类已经准备好,却还没进入场景。这正是中断前留下的关键缺口。我先确认它们的尺度、贴图和上一张构图图,再把它们作为场景地标接入,避免继续用参数修饰不理想的程序岩石。 + +## 28. 2026-10-10T17:45:17.329000+08:00 + +原生行号 1255;消息 `msg_0a8e0ac49bb39e4e016aca0927e6e0819799ee8c3e8ec197dd`;阶段 `commentary`。 + +扫描素材接入后构建通过,但真实浏览器等待场景就绪超时,说明这一步不能直接往下扩展。我现在先抓浏览器启动错误和载入阶段,确认是扫描资产解析失败还是渲染初始化过慢;修复后再看图,不把超时当作可交付结果。 + +## 29. 2026-10-10T17:54:28.261000+08:00 + +原生行号 1325;消息 `msg_0a8e0ac49bb39e4e016aca0b4c34848197a5c4bdb94f68d432`;阶段 `commentary`。 + +浏览器错误已经定位并修好:不是网络问题,而是沉木离线减面仍为 6490 三角,超过模块的 6000 上限。我已把离线目标降到 5200,实测生成 5190 三角;扫描岩石和沉木现在能正常载入。接下来的静态图显示材质比旧版自然,但岸带仍断成几簇,我会继续改连续地形和两侧岸线,而不是只堆物体。 + +## 30. 2026-10-10T18:23:30.087000+08:00 + +原生行号 1438;消息 `msg_0a8e0ac49bb39e4e016aca1118f06481978e80beb1cae79cbb`;阶段 `commentary`。 + +静态图现在已经从“零散程序石草”变成了有扫描岩木、前中远层次和中央通道的真实场景,但仍有两处需要继续收紧:砂地中央过宽、岸带之间的连续过渡还不够。下一步我会把地形本身做成连续的两侧浅岸和中间砂道,再跑完整桌面/手机截图;这样场景关系由地形承担,不靠继续堆孤立模型。 + +## 31. 2026-10-10T19:16:55.767000+08:00 + +原生行号 1581;消息 `msg_0c20e126305b8151016aca1ea5131887d096897a4e3ef456c6`;阶段 `commentary`。 + +我接着处理当前场景。最新截图里,扫描岩木已正常显示,但岸带仍像几簇摆放的物体,砂道也缺少清楚的边界;这一轮会重组岸坡与植物的连接关系,再看桌面和手机画面。 + +## 32. 2026-10-10T19:24:38.967000+08:00 + +原生行号 1656;消息 `msg_0c20e126305b8151016aca2073331c87d0a04fe0f1ff646443`;阶段 `commentary`。 + +岩岸的体量已经更明确,但最新图仍偏灰绿,水体光照和池底材质之间的关系还不够好。同时,软件渲染下帧率很低,按住方向会显得迟缓。我会保留扫描模型的形体与贴图,合并重复的绘制和阴影开销,并继续检查光照调整后的实际画面。 + +## 33. 2026-10-10T20:06:55.529000+08:00 + +原生行号 1796;消息 `msg_09fe1d34d16f1201016aca2a5d393487d09d509649ced2034e`;阶段 `commentary`。 + +连续世界已经接入,横向游泳通道也已留出,避免鱼经过岸坡时被地形遮住。我会先检查刚保存的第 13 轮桌面和手机截图,确认前景植物、岸脚和中央通道的位置,再做完整的左右经过检查。 + +## 34. 2026-10-10T20:12:18.410000+08:00 + +原生行号 1838;消息 `msg_0f67a8fde91822a2016aca2b9fc28887d0bd36adbdf85046b8`;阶段 `commentary`。 + +独立复核也指出了同样的问题:岩草组之间缺少连续结构,近景叶片的精细度与扫描岩石不一致。我会据此处理岸坡的岩质基底和叶片曲面,让已有物体连成环境;这次不再增加同类石草数量。 + +## 35. 2026-10-10T20:14:24.215000+08:00 + +原生行号 1846;消息 `msg_02811c2566bbfa89016aca2be1707887d0bca7aadd2691eb06`;阶段 `final_answer`。 + +## 当前任务与用户偏好 + +继续完成第五次池塘场景重写,不能重新开始。用户明确强调:**场景构图、美术和空间关系是重点,左右游动只是后续检查。** 已重新授权开发,无须再问是否继续。 + +所有沟通、文档和必要注释使用简体中文。不得用构建成功、测试、坐标或像素差代替视觉验收。现有画面**仍未通过美术验收,不能报告完成**。 + +## 当前进度 + +项目目录:`/home/hdzx/2026/kongjun` + +- 旧入口已替换,世界、地形、玩家、相机、输入、界面均为重写模块。 +- 新砂地素材已生成;图像服务图集请求曾遭限流和额度不足,已停止反复请求。 +- 本地 Poly Haven CC0 扫描岩石、沉木、蕨类已接入,来源见 `docs/第五次素材来源.md`。 +- 岩石每块约 1800 三角;沉木重新减面后为 **5190 三角**,满足模块 6000 上限。 +- 蕨类已下载官方 alpha 并与 diffuse 合成 RGBA,解决原 JPEG 无透明通道的问题。 +- 新场景已扩展到左右约 335 世界单位,玩家范围为 ±280;启动时调用 `world.expandWorld()`。 +- 静态扫描地标按岸段、材质合批。原对象保留独立世界坐标供地标投影。 +- 前景水榕移到更靠近通道的位置,手机可见近景叶片。 +- 横向通道地形已压低,避免鱼经过岸坡时埋进地形。 +- 最新修改为各块扫描岩石分别贴合坡面,修复整组放在坡顶导致侧石悬空;**此修改尚未截图检查**。 + +## 最新视觉证据与判断 + +最近主代理已查看: + +- `tmp/browser-validation/fifth-rewrite/连续场景-13/桌面-纯场景.png` +- `tmp/browser-validation/fifth-rewrite/连续场景-13/手机-纯场景.png` + +第 13 轮可见扫描岩木、前景叶片、中央通道和远处水体,但仍存在较大差距。独立审查代理 `/root/plan_review` 也确认: + +1. 岩草组之间主要靠裸露砂坡连接,缺少连续的岩质岸坡与岸脚沉积。 +2. 长叶草有硬折,大叶与荷叶较单一,与扫描岩石的精细度不一致。 +3. 灰绿砂地占比高,入水光缺少焦点;手机下部仍有大片同质砂面。 + +**不能将第 13 轮判为美术通过。不要继续同一方向增加石草数量或仅调参数。** + +最后给用户的更新是: + +> 独立复核也指出了同样的问题:岩草组之间缺少连续结构,近景叶片的精细度与扫描岩石不一致。我会据此处理岸坡的岩质基底和叶片曲面,让已有物体连成环境;这次不再增加同类石草数量。 + +## 正在运行的子代理 + +用户提供的 AGENTS.md 明确允许并行代理。 + +- `/root/interface`:**刚收到新任务,正在工作**。只允许修改 `src/pond/plants.js`,重做近景苦草、水榕、荷叶曲面,解决硬折和面片感,保留工厂 API、根部位置和叶尖摆动;不用增加叶片数量。主代理负责场景和地形。 +- `/root/plan_review`:已完成第 13 轮独立静态审查,上述三项差距来自其结果。 +- `/root/motion_input`:此前中断,模块和测试已在本地,无须重新派发旧任务。 + +避免与植物代理同时修改 `plants.js`。 + +## 接下来应做 + +1. **把岸坡本身做成连续岩质基底**,连接现有岩组,保留中央泳道;不是再增加孤立模型。 + - 计划使用细腻的可平铺岩石/苔岩材质,在真实起伏地形上与砂地、岸脚沉积混合。 + - **这一材质获取和实现尚未开始。** + - 可通过 Poly Haven 官方 API 获取 CC0 纹理,下载本地并记录来源。已有岩石模型的贴图是扫描图集,不宜直接当无缝地形材质重复铺。 + - `bedHeight()` 目前仍以各岸组的高斯起伏为主,尚未建立真正连续的主岸基底。 +2. 等植物代理完成后,检查实际近景叶片与岩石是否统一。 +3. 重新保存并肉眼查看桌面、手机静态图,包括刚修复的岩石脚点。 +4. 场景成立后才进入完整左右运动验收和远离起点的连续世界检查。 +5. 做最终构建、回归、证据文档与待办收尾。未通过完整视觉验收前保留 CSV,不标任务完成。 + +## 关键文件与实现细节 + +- `src/main.js` + - 初始化后调用 `world.expandWorld()`。 + - 帧时间上限从 0.08 改为 **0.25 秒**。 +- `src/pond/model.js` + - `MAX_FRAME_DT = 0.25`,保留分步积分和相机阻尼。 +- `src/pond/world.js` + - 直接使用 `createNaturalRocks/Wood/Fern`,旧程序岩木回退已移除。 + - 扫描静态网格通过 `mergeGeometries` 合批。 + - 阴影为 `PCFSoftShadowMap`,相机移动超过 0.8 时更新。 + - 桌面相机:位置 `(x,15.5,20.5)`,看向 `(x,-0.8,-7.5)`,FOV 42。 + - 手机相机:位置 `(x,27,21)`,看向 `(x,-0.8,-2.8)`,FOV 66。 + - 远景水体球使用与雾一致的色彩变换,避免清屏色和池底形成水平硬边;只是氛围层,主要环境是实际世界对象。 +- `src/pond/terrain.js` + - 分块真实曲面地形,区块 96×48,采样 64×32。 + - `bedHeight()` 的最后一段通过 `1-exp(-(z/3.5)^2)` 保持横向泳道通透。 + - 曾尝试明显网状焦散,已改为稀疏、较弱、不连续光纹;禁止恢复规则网纹。 +- `src/pond/natural-assets.js` + - 四套 glTF 缓存加载,拆开素材陈列集,保留独立扫描网格。 + - 岩石组合预算 6000,沉木预算 6000。 + - 最近扫描材质改为 Lambert 漫反射,保留颜色、法线和 alpha,减少金属工作流开销;实测观感与之前接近。 +- `scripts/prepare_natural_models.mjs` + - 沉木目标 5200,实测 5190。 +- `scripts/prepare_fern_texture.py` + - 下载、MD5 校验官方 alpha,合成 PNG,更新 glTF。 +- `scripts/capture_fifth_composition.mjs` + - 真实 Chromium 桌面 1280×720、手机 390×844,保存带界面和纯场景图。 +- `scripts/verify_fifth_scene.mjs` + - 原有 A/B/C/D 与触摸验证脚本,**尚未适配慢渲染时截图期间继续按键的问题**。 + +## 验证与性能限制 + +- `node --test tests/pond-motion.test.mjs`:**最新 16 项全通过**。 + - 长掉帧测试已更新为比较 0.25 秒积分,位移上界 2.25。 +- 最近构建是扫描模型初次接入时成功;后续改动尚需最终构建。 +- Chromium 使用 SwiftShader,1280×720 实测约 **1.6 帧/秒**。合批、材质简化仍未明显解决软件渲染瓶颈,不能宣称流畅。 +- 旧运动验收 `static-岩木` 失败: + - C 右游时 `playerX≈5.14` + - D 左游后 `playerX≈0.77` + - 截图期间仍按住方向、渲染掉帧且时间被限制,造成实际输入时长和积分不一致。 +- 下一次运动脚本可在捕获画面时冻结场景,防止截图耗时继续推进输入;实际通过键盘或触摸长按,不允许直接修改坐标制造移动。必要时记录实际长按时长,在慢渲染环境中按住更久,满足至少 1.5 秒/3 秒要求。保持视觉描述为最终证据。 + +## 待办与服务 + +`第五次池塘重写 TO DO list.csv`: + +- 第 1、2 项 DONE。 +- 第 3 项“重建世界玩家相机并验证场景片段”仍 IN_PROGRESS。 +- 第 4、5、6 项 TODO。 +- 最近第 3 项备注已记录扫描素材、全世界岸带、第 13 轮查看和局部悬空修复,明确未验收。 +- `update_plan` 仍与以上状态一致;后续推进需同步 CSV 和计划。 + +Vite 服务仍运行: + +- `http://localhost:5173/` +- FastCtx 后台任务:`j-4thcp3` +- 不要重复启动服务。 +- 本目录没有 Git 仓库,`git status` 已确认不可用,不必重试。 + +技能 `frontend-skill`、`todo-list-csv`、`gpt-image` 已读并宣布使用,不需重新走初始计划和审查流程。本地工具优先 FastCtx;通过 `functions.exec` 调用 `tools.mcp__fastctx__*`。图像使用 `tools.view_image()` 并 `image(result.image_url)` 查看,FastCtx 图像批量读取曾受输出配额限制。 + +## 36. 2026-10-10T20:14:42.196000+08:00 + +原生行号 1858;消息 `msg_chatcmpl-64be11d2-16b1-9e60-8693-00248c935c24_0`;阶段 `None`。 + +继续第五次重写。当前主线任务是把岸坡做成连续的岩质基底,连接现有岩组;我先确认中断前最后修改的实际状态,再继续地形侧的工作,同时等植物子代理完成。 + +## 37. 2026-10-10T20:15:14.231000+08:00 + +原生行号 1874;消息 `msg_chatcmpl-b240511d-a84d-927a-9a57-44b9eeabd64a_0`;阶段 `None`。 + +了解当前状态了。现在的主线是给岸坡做连续岩质基底。我先检查素材目录与下载脚本的既有模式,然后获取可平铺的 CC0 岩石纹理。 + +## 38. 2026-10-10T20:19:09.886000+08:00 + +原生行号 1923;消息 `msg_chatcmpl-f39b0deb-4470-9990-afac-982515d235da_0`;阶段 `None`。 + +当前两张图确认了同一点:砂坡裸露、岩组孤立,岸线没有连续结构。我先为岸坡挑一套 CC0 可平铺岩石纹理,看几个候选的实际观感再定。 + +## 39. 2026-10-10T20:23:09.536000+08:00 + +原生行号 1939;消息 `msg_chatcmpl-7a706be0-ca99-9a74-a209-50273f00299f_0`;阶段 `None`。 + +媳妇 + +## 40. 2026-10-10T20:27:13.252000+08:00 + +原生行号 1959;消息 `msg_chatcmpl-a4c95934-5be4-9784-8881-35d4583ca265_0`;阶段 `None`。 + +预览里 `rock_face_03`(灰褐裂纹岩壁)和 `rock_pitted_mossy`(暖棕多孔砂岩)最贴合水下岸坡。我下载两份 1K 纹理对比,同时把池底沉积用的 `rock_ground_02` 也拿到,作为岸脚的碎石过渡层。 + +## 41. 2026-10-10T20:33:39.477000+08:00 + +原生行号 1999;消息 `msg_070c1213dae1291c016aca30a0911487d0a7eb61396ea3669d`;阶段 `commentary`。 + +第13轮里,岩组之间仍是裸露砂坡,近景长叶也有明显硬折。我会让地形承担岸线的连续结构,并同步修正叶片曲面;下一张图重点检查岩基、岸脚和植物根部是否连在一起。 + +## 42. 2026-10-10T20:36:07.984000+08:00 + +原生行号 2013;消息 `msg_chatcmpl-47c2bc55-f138-9904-94f6-a5cc4d592614_0`;阶段 `None`。 + +现在明确主线:把地形本身改造成连续的岩质岸坡,这是解决"砂坡裸露、岩组孤立"的关键。我先下载并转换三套候选岩石纹理。 + +## 43. 2026-10-10T20:39:38.467000+08:00 + +原生行号 2057;消息 `msg_083dce0e7e88f262016aca3208089c87d0bc2071ecca7a8415`;阶段 `commentary`。 + +岩壁与碎石纹理已下载并校验。我会把裂纹岩面铺在起伏岸坡上,用碎石沉积连接砂地;中央仍保留连续泳道。这样岩组之间会有共同的基底,植物根部也能落在岸缝里。 + +## 44. 2026-10-10T20:42:12.400000+08:00 + +原生行号 2081;消息 `msg_083dce0e7e88f262016aca32a067b087d08597e3404759f57d`;阶段 `commentary`。 + +第14轮里,岩面已经接起来,但地形形成了横贯画面的石坎,挡住了通向远处的视线;手机下方的岩面也太抢眼。下一步会把它切成两侧错开的岸坡,让砂道从中间延伸到蓝色远景。 + +## 45. 2026-10-10T20:45:09.892000+08:00 + +原生行号 2127;消息 `msg_080385a882a3424e016aca33518e8087d0acd32972fec9a7a0`;阶段 `commentary`。 + +第15轮的砂道能穿过两侧岸坡了。剩下的问题主要在素材衔接:上方大岩块仍显得像架在小石上,岸脚植物也过于分离。我会收低石组重心,让根系和叶片贴着岩缝生长,再调整通道与远景的受光关系。 + +## 46. 2026-10-10T20:48:58.825000+08:00 + +原生行号 2167;消息 `msg_080385a882a3424e016aca3437f5c887d0b3f0d6207b6ea6af`;阶段 `commentary`。 + +岸坡已经接入扫描裂纹材质,原有岩组和根系也已重新安置。植物子代理正在修正叶带硬折与叶面形体;完成后我会检查完整画面的构图、光照和材质衔接,再进入游动验收。 + +## 47. 2026-10-10T21:02:27.092000+08:00 + +原生行号 2236;消息 `msg_057c73d0d49fdecb016aca375fce0c87d09d227886487705e1`;阶段 `commentary`。 + +新苦草的弯曲已经连贯,近景水榕也有了卷边和拱度。模型检查还发现,远看时极细叶脉会出现锯齿和黑色星纹;我会先消除这些瑕疵。手机取景已比较过三种视点,接下来采用能同时看见前景叶片、主岩和远处砂道的方案。 + +## 48. 2026-10-10T21:08:46.886000+08:00 + +原生行号 2311;消息 `msg_0cc29963e05e3cdd016aca38dcc81087d0b1da086eb58e4a76`;阶段 `commentary`。 + +第18轮已形成两側岸坡、中央砂道和远处水体,独立审查也确认可以进入运动验收。它同时指出两处美术差距:圆叶偏均匀,主岩暗部偏黑。我先收紧这两处,再检查离开起始区后的场景是否也成立。 + +## 49. 2026-10-10T21:12:24.306000+08:00 + +原生行号 2343;消息 `msg_0cc29963e05e3cdd016aca39b53f7487d0a2a902548530b22c`;阶段 `commentary`。 + +第19轮的圆叶减少了颗粒感,主岩暗部也能看出纹理层次;桌面和手机都保留了前景叶片、中景岩岸与纵深砂道。现在开始真实长按验收,并继续游到起始区之外。截图时会暂停画面,避免截图耗时额外推进游动。 + +## 50. 2026-10-10T21:15:37.285000+08:00 + +原生行号 2391;消息 `msg_0cc29963e05e3cdd016aca3a76fd5887d095c06862392904c6`;阶段 `commentary`。 + +桌面的初始、静止、右游和左游截图已采集,我正在逐张比较岩组、沉木与前景叶片的变化。构建也通过了;最终判断仍要看这些画面,以及接下来的手机和远离起点截图。 + +## 51. 2026-10-10T21:22:25.869000+08:00 + +原生行号 2444;消息 `msg_0c70ec93c61f5142016aca3c0f675487d08f4c87136e3039a5`;阶段 `commentary`。 + +完整检查发现了一个布局问题:手机左游和跨区时,浅色圆叶会遮住鱼身;脚本虽然没有报错,这两张画面仍不能通过。我会把这些植物退到岩岸后侧,保留中央泳道,再重新采集两端的完整截图。 + +## 52. 2026-10-10T21:25:44.712000+08:00 + +原生行号 2473;消息 `msg_0c70ec93c61f5142016aca3cd4db1c87d0b5225bd9e12394e7`;阶段 `commentary`。 + +中景圆叶已移到泳道后侧,修改后的完整验收仍在采集。前一轮跨区图里的岩岸、沉木和砂道保持连续;这轮重点确认鱼经过这些区域时,身体也能保持清楚可见。 + +## 53. 2026-10-10T21:54:16.687000+08:00 + +原生行号 2522;消息 `msg_04f31b56e41cb9e2016aca43869f7c87d0a82e99fd76b8035c`;阶段 `commentary`。 + +接着核对第21轮手机截图。圆叶遮鱼的问题已修正,桌面五个阶段也已看过;现在检查手机左游和跨区画面,再整理交付证据。 + +## 54. 2026-10-10T22:10:55.519000+08:00 + +原生行号 2543;消息 `msg_0cc29963e05e3cdd016aca476d98d887d0af519978e0f99bf6`;阶段 `commentary`。 + +剩下的是第21轮手机与跨区画面的肉眼复核,以及最终证据收尾。先确认中央泳道没有遮鱼,再核对场景连续性。 + +## 55. 2026-10-10T22:12:16.179000+08:00 + +原生行号 2558;消息 `msg_0cc29963e05e3cdd016aca47bb5bec87d08eee5b501af874cf`;阶段 `commentary`。 + +第21轮手机左游和跨区画面里,鱼身保持可见。右游时,右侧层叠苔岩移向左边;反向左游时,左侧岩组和沉木进入中央,前景叶片的位移大于远处荷影。我会把这组画面和软件渲染仍偏慢的限制一起记录。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/regression.log b/.trellis/tasks/10-10-streaming-retry-history/research/regression.log new file mode 100644 index 0000000..d6d8120 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/regression.log @@ -0,0 +1 @@ +Regression checks passed. diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/review.md b/.trellis/tasks/10-10-streaming-retry-history/research/review.md new file mode 100644 index 0000000..d72aa9c --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/review.md @@ -0,0 +1,61 @@ +# 运行中气泡续接独立审查 + +## 审查结论 + +服务端续接、完整运行态保存、取消门禁及前端折叠/复制通过。本次发现 3 项 P2 均已修复并通过原函数回归;当前没有未解决的 P1/P2,审查通过。 + +审查服务端基线为 `/tmp/ccweb-streaming-retry-server-before.js`,仅核对本任务新增差异;未把上一任务完整历史归档改动重复归入本任务。 + +## 发现及处理 + +| 级别 | 问题与触发条件 | 位置 | 当前状态 | +| --- | --- | --- | --- | +| P2 | A 的 `turn/start` 已发出但原生 ID 未知时取消,旧 entry 曾被释放;此时开始同线程 B,A 的 `turn/started` 先于 A RPC 回包到达,会按 thread 匹配到 B,旧通知能够写入或结束 B。 | `server.js` 的 `findCodexAppEntryByRuntime`、`startCodexAppTurn`、`handleCodexAppAbortSession` | 已修。未知 ID 取消保留门禁,通知/RPC 确认身份后只中断精确旧回合;RPC 超时仍保留门禁,app-server 明确退出后提交旧正文并释放。 | +| P2 | 重连可显示完整 105/130 条工具,但完成或历史读取仍经 40 条工具上限,变成 40 条记录加省略标记;`done` 重建气泡会再次缩短工具列表、输入及结果。 | `server.js:5796` 的 `sanitizeMessageForTransport`;`public/app.js:9237` 的完成气泡重建 | 已修。完成/历史单消息读取保留完整 `toolCalls`,实时 `tool_start`/`tool_end` 预览上限保留。 | +| P2 | 单条回复超过折叠阈值且包含多个自然段时,默认 `.assistant-single-collapsed` 以 420px 高度裁剪正文。点击“定位”仍直接选择末文本段并滚动,没有展开,目标段落继续被 `overflow:hidden` 隐藏。 | `public/app.js:2468` 的 `scrollAssistantBubbleToLastSection`;`public/style.css` 的 `.assistant-single-collapsed .assistant-reply-content` | 已修。定位前点击已有展开控件,同步展开状态后再计算末段坐标;原函数测试验证展开先于定位,多原生段气泡保持原收起状态。 | + +## 失败证据与修复验证 + +取消竞态先用真实路由/退休函数 VM 验证:退休 `turnId:null` 的取消 entry 后,建立同线程 `turnId:null` 的 B,路由旧 A 通知得到 `{ retired: 0, routedToNewEntry: true }`。 + +新增真实开轮/路由/通知回归在修复前失败于“旧 A 身份未知时必须阻止同线程 B 开始”,实际 `beforeIdentity.ok=true`。工具完整读取断言在修复前失败于 130 条工具不能按预览上限替代完整列表。两项修复后均转绿。 + +取消回归执行真实 `startCodexAppTurn`、`handleMessage`、`findCodexAppEntryByRuntime`、`findCodexAppRouteByRuntime`、`adoptCodexAppUnroutedTurn`、`handleCodexAppNotification`、`handleCodexAppAbortSession`、`handleCodexAppServerExit` 和完成保存函数;仅模型 RPC、时钟及无关配置/生命周期回调由隔离夹具替代。覆盖: + +- A 通知先于 A RPC 返回,未知身份时禁止 B 开轮;身份确认后中断 A,再开始 B;A 的迟到完成不能结束 B,B 的正文只归档一次且前后文完整。 +- A RPC 超时后仍保留未知身份门禁;晚到 ID 通知触发精确 A 中断,随后 B 正常完成。 +- A 取消且 ID 未知时 app-server 明确退出,先保存 A 既有完整前文,再释放 B 门禁;B 完整完成。 + +## 最终执行结果 + +所有单元/专项命令使用 60 秒上限,存储测试使用 `/dev/shm` 隔离临时目录。此前普通 `/tmp` 存储专项达到 60 秒上限;改用 `/dev/shm` 后完整专项约 12 秒通过,无需放宽超时。 + +| 命令 | 结果 | 退出码 | +| --- | --- | --- | +| `TMPDIR=/dev/shm timeout 60 node scripts/codexapp-retry-history-unit.js` | 21/21,含真实 mock 协议三轮续接 | 0 | +| `TMPDIR=/dev/shm timeout 60 node scripts/history-runtime-unit.js` | 13/13,完整 130 条工具及 24006 字输入、48006 字结果读回;实时预览仍有界 | 0 | +| `timeout 60 node scripts/assistant-bubble-controls-unit.js` | 最终 8/8,末原生段、多自然段/代码、Markdown 空白语义、围栏及未完整分界不误复制,单条长回复定位先展开 | 0 | +| `timeout 60 node scripts/streaming-retry-frontend-unit.js` | 5/5,多次续接、刷新/重连/切换、完成与保存失败气泡 | 0 | +| 两个所改测试文件 `node --check`、限定文件 `git diff --check` | 通过 | 0 | + +## 验证版本 + +| 文件 | SHA-256 | +| --- | --- | +| `scripts/codexapp-retry-history-unit.js` | `8ff35d8e3722fec5fd2ba8fac5ffac94b69e711f5bcddf2150bc10c865e73b54` | +| `scripts/history-runtime-unit.js` | `0e1e549f2ece5337eb7808f235b6cadc4ec9192c7cf9561a77b71eca66c35ae4` | +| `server.js` | `c964060ad121bf16edd44caf94226455cf8a434bb8b14b9f5c434a3215d6b404` | +| `lib/codex-app-runtime.js` | `6fe66921ed8998ed1b1478eb804c0ad965ff40fff7a18dd8086e3de42b67c122` | +| `public/app.js` | `433b4e68a9e91440eada679199a86ca7a84cf28b0f979eb3f46044be99fe757e` | +| `scripts/assistant-bubble-controls-unit.js` | `f60bb8c1c393503c461885fd2ee7ef7636dacd2a94c3654ddcac470dc449ca63` | + +## 验证边界 + +- 本代理只修改上述两个测试文件及本报告,不修改实现、真实会话、原生 rollout、生产服务或其他任务 CSV。 +- 本代理没有执行全量回归或真实浏览器验收;主代理执行全量回归。前端代理已完成正式浏览器验收,退出码 0,证据在 `/dev/shm/ccweb-streaming-retry-browser-G8wkN6`,本代理只读核对报告与实现。 +- 原函数/隔离 mock 回归证明修复实现,不证明生产已部署或目标会话旧前文已补回 UI。 +- 完整正文用于复制;折叠仅控制 DOM 可见性。原生段复制包含该段原始 Markdown、多自然段及代码,不含工具、分隔时间或按钮。 + +## 工具调用简报 + +本地使用 FastCtx 读取任务契约、当前源码及基线差异,执行隔离回归;codebase-memory 起初索引 ready 且成功定位函数,之后 snippet 返回索引不可用,降级到精确本地源码读取。没有对外发送会话内容。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/streaming-contract.md b/.trellis/tasks/10-10-streaming-retry-history/research/streaming-contract.md new file mode 100644 index 0000000..127ef5c --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/streaming-contract.md @@ -0,0 +1,24 @@ +# 运行中正文交接契约 + +## 已核对的丢失点 + +1. 自动延续重试清理中断运行态,新轮 `fullText`、工具及映射为空。原正文仅进入模型提示,提示有界不等于保存。 +2. `attachActiveRuntimeToWs` 使用 `SESSION_MESSAGE_CONTENT_MAX_CHARS` 裁剪流式正文。浏览器收到裁剪快照后权威替换正文,长正文前缀消失。 +3. 重试等待期不在 `isSessionRunning` 和 attach 判断内。刷新会被视为无运行态,之前气泡无法重建。 + +## 契约 + +- 同一用户任务自动续跑须交接完整正文、全部工具、文本/工具去重映射,以及持续一致的逻辑消息身份。线程身份仍按既有 expectedThreadId 校验。 +- 完整运行态在移除旧活动轮次前落盘,等待期仍可读取;新轮接管前不能清理唯一落盘副本。保存失败明确停止危险交接并保留内容。 +- 续跑启动时先提供完整 `resume_generating` 状态,再提供增量事件。等待期和运行期刷新、切换与重连也提供完整状态。 +- 前端以完整 snapshot 替换当前 streaming 正文、工具,复用气泡节点;禁止把不同用户轮次的正文盲目拼接。现有 `resume_generating` 分支的权威替换和工具重建符合该语义,前端代理将用行为回归确认。 +- 模型重试提示可继续只保留尾部,显示和持久化不使用该上限。 +- 重试耗尽、手动取消、自动启动失败及重启恢复仍需保存已有正文;正常新用户轮次从空正文开始。 +- `turn/start`已发出但原生ID未知时取消,旧轮仍持有线程门禁。通知先于RPC回包也只能归入旧轮,身份明确并精确中断后才允许新轮开始;RPC超时不能凭空释放身份。 +- `done`和历史分页提供完整正文及全部工具输入/结果,不得用实时预览限额覆盖完整运行态。 + +## 验证方法 + +- VM 提取真实生产函数并配真实临时存储,先跑现有实现产生正文丢失红灯,再跑修复版本;不得仅以缺 helper 作为旧版失败证据。 +- 长正文超过预览限额,A/B/C 各一次并保持顺序;所有阶段的工具、消息 ID 和用户消息条数一致。 +- 真实浏览器连接隔离服务,检查实际 DOM 首段、工具、气泡数、刷新及重连,测试不接触生产运行会话;记录浏览器类型、资源来源、截图和退出码。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/target-session-evidence.json b/.trellis/tasks/10-10-streaming-retry-history/research/target-session-evidence.json new file mode 100644 index 0000000..9ee106b --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/target-session-evidence.json @@ -0,0 +1,48 @@ +{ + "capturedAt": "2026-10-10T14:21:45.229038+00:00", + "sessionId": "826deaa0-c3df-42bc-930b-06ee3e05de50", + "source": "/home/hdzx/.codex/sessions/2026/10/10/rollout-2026-10-10T15-10-02-01a124a5-b65e-70d2-b712-06168bae7bd5.jsonl", + "meta": { + "id": "01a124a5-b65e-70d2-b712-06168bae7bd5", + "cwd": "/home/hdzx/2026/kongjun", + "timestamp": "2026-10-10T07:10:02.341Z", + "source": "vscode" + }, + "readBytes": 132328810, + "readLines": 2572, + "capturedSourceSha256": "680f6c57cb625071bcae8a8adf9f279b39febd8d7d313e8521528676bda758be", + "canonicalAssistantMessages": 55, + "chars": 19606, + "phaseCounts": { + "commentary": 46, + "final_answer": 3, + "null": 6 + }, + "duplicateIdsSkipped": 0, + "parseErrors": 0, + "first": { + "line": 12, + "time": "2026-10-10T07:10:30.152Z", + "id": "msg_09ddac1c7a8cab52016ac9e4e1795087d09caecb2bd784b09f", + "phase": "commentary" + }, + "last": { + "line": 2558, + "time": "2026-10-10T14:12:16.179Z", + "id": "msg_0cc29963e05e3cdd016aca47bb5bec87d08eee5b501af874cf", + "phase": "commentary" + }, + "snapshot": { + "messageRoles": { + "user": 5 + }, + "sha256": "f0add4951ce997426172f79d2cb94e7f8fa26ba5825307f2e6a2382352c937ea" + }, + "runtime": { + "turnId": "01a12623-52c6-73d1-a2c8-6e6b270bf34e", + "fullTextChars": 260, + "firstText": "剩下的是第21轮手机与跨区画面的肉眼复核,以及最终证据收尾。先确认中央泳道没有遮鱼,再核对场景连续性。\n\n
2026-1", + "capturedSha256": "d4ef0fa09ed9e4890eaab84e61ac091dd4526919e576b8098506664d81c15365" + }, + "historyArchiveExists": false +} diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/target-session-evidence.md b/.trellis/tasks/10-10-streaming-retry-history/research/target-session-evidence.md new file mode 100644 index 0000000..cf31044 --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/target-session-evidence.md @@ -0,0 +1,12 @@ +# 目标会话只读证据 + +- 会话 `826deaa0-c3df-42bc-930b-06ee3e05de50` 的快照与运行态 threadId 均和原生元数据 `01a124a5-b65e-70d2-b712-06168bae7bd5` 精确一致;项目目录也是 `/home/hdzx/2026/kongjun`。 +- 本次读取 132328810 字节、2572 行,提取 55 段助手正文、19606 字符;重复消息 ID 跳过 0 条,解析异常 0 行。只选择 response_item / message / assistant 的输出文本;不把工具内容、重试用户提示或 event 镜像计作助手正文。 +- 首段时间 2026-10-10T15:10:30.152000+08:00、原生第 12 行,开头: + +> 前几轮把“能运行、物体更多”当成了画面质量,却没解决构图、素材和空间关系。这次我先重读失败复盘,再检查当前实现和你给的参考图,按第五次重写重新制作;交付前会亲自核对浏览器里的初始画面和左右游动效果。 + +- 最近段时间 2026-10-10T22:12:16.179000+08:00。读取时 cc-web 快照只有 5 条用户消息、0 条助手消息;运行态正文仅 260 字符。原生助手正文存在而运行态气泡前缀缺失。 +- 正文核对副本:[recovered-assistant.md](./recovered-assistant.md)。这是独立审阅副本,未写回原会话、未替代正在生成的气泡。 +- 原始 rollout、真实 session 和 run 文件均只读;没有重启服务。运行态和 rollout 仍由生产继续追加,哈希只表示本次抓取点,不能宣称以后必然相同。 +- 详细读取边界、源和快照指纹在 [target-session-evidence.json](./target-session-evidence.json)。 diff --git a/.trellis/tasks/10-10-streaming-retry-history/research/verified-sources.sha256 b/.trellis/tasks/10-10-streaming-retry-history/research/verified-sources.sha256 new file mode 100644 index 0000000..6d6dc2c --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/research/verified-sources.sha256 @@ -0,0 +1,12 @@ +c964060ad121bf16edd44caf94226455cf8a434bb8b14b9f5c434a3215d6b404 server.js +6fe66921ed8998ed1b1478eb804c0ad965ff40fff7a18dd8086e3de42b67c122 lib/codex-app-runtime.js +997e30969c5e2479655bff7c061243b1b3bf36482eee76ea420d7c4f45e82e57 lib/agent-runtime.js +e8daab67b60e30b451a16654d857700fb676dd5f9be33f2490a8db1c2e109b56 lib/session-history-store.js +433b4e68a9e91440eada679199a86ca7a84cf28b0f979eb3f46044be99fe757e public/app.js +1dc679c46b8d26947d3489851ec0058e61b35ada23ac4cd2089f38999a773f6c public/style.css +27c10c9350fa3a9fedb0a05c07060eae747d6f3dbc6bf103b4d468f2d24824e7 scripts/regression.js +8ff35d8e3722fec5fd2ba8fac5ffac94b69e711f5bcddf2150bc10c865e73b54 scripts/codexapp-retry-history-unit.js +0e1e549f2ece5337eb7808f235b6cadc4ec9192c7cf9561a77b71eca66c35ae4 scripts/history-runtime-unit.js +1214b57434ec2b68f48da2a5b27ce8ae10eb7353a5ff9a995c321ec012de068b scripts/history-runtime-completion-unit.js +f60bb8c1c393503c461885fd2ee7ef7636dacd2a94c3654ddcac470dc449ca63 scripts/assistant-bubble-controls-unit.js +ea085438474e436d3f47f9d3c92afd5bb4fa0ea2b3f72252e52126e902c9f262 scripts/streaming-retry-frontend-unit.js diff --git a/.trellis/tasks/10-10-streaming-retry-history/task.json b/.trellis/tasks/10-10-streaming-retry-history/task.json new file mode 100644 index 0000000..d5f00da --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/task.json @@ -0,0 +1,26 @@ +{ + "id": "streaming-retry-history", + "name": "streaming-retry-history", + "title": "修复运行中气泡正文续接", + "description": "", + "status": "completed", + "dev_type": "fullstack", + "scope": "会话历史、运行中续接和气泡控件", + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-10-10", + "completedAt": "2026-10-10", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "实现、专项、全量回归、正式桌面手机浏览器及独立审查全部通过。前端静态资源可刷新加载;真实旧气泡未补回,后台防丢修复未重启加载;有其他running会话,按项目规则暂缓生产重启。", + "meta": {} +} diff --git a/.trellis/tasks/10-10-streaming-retry-history/task_plan.md b/.trellis/tasks/10-10-streaming-retry-history/task_plan.md new file mode 100644 index 0000000..510213e --- /dev/null +++ b/.trellis/tasks/10-10-streaming-retry-history/task_plan.md @@ -0,0 +1,28 @@ +# 运行中气泡正文续接计划 + +## 目标 + +修复 Codex App 自动延续重试丢失累积气泡正文,并核对目标会话旧正文的原生来源。 + +| 步骤 | 状态 | 验收 | +|---|---|---| +| 核对目标运行态、原生记录和气泡重建根因 | complete | 已确认自动续跑空状态、重连裁剪、等待期不附加运行态;原生正文核对另由只读审计记录 | +| 确定运行中正文完整保存与补读契约 | complete | 独立计划审查通过;完整 entry 续跑、等待期附加、失败保留及前端权威替换已明确 | +| 补充长任务、重试及重连丢前文的失败回归 | complete | 旧版生产函数22万字正文丢失红灯,10组旧版回归9红1绿 | +| 修复服务端与前端运行中正文链路 | complete | 完整交接18绿、真实完成/恢复15绿、前端5组及折叠复制6组绿 | +| 验证长正文、切换会话、刷新和重连 | complete | 专项、全量、语法、差异与正式浏览器通过;测试期间实现指纹未变 | +| 记录目标会话证据、验收结果及部署边界 | complete | 审查通过,交付报告及生产边界已记录,仅清理本任务已完成 CSV | +| 实现并验收长气泡折叠与末段复制 | complete | 控件8组、桌面/手机折叠展开复制及单长定位均通过;正式浏览器证据已归档 | + +## 约束与并行边界 + +- 服务端实现、前端审计和独立回归夹具可以并行;先建立协议,再实施依赖变更。 +- `server.js` 只由服务端实现代理编辑;前端实现代理只编辑 `public/app.js` 及独立前端测试;主代理负责集成和任务记录。 +- 目标会话仅只读核对;禁止重启生产、清理真实运行态、覆盖其他任务文件。 + +## 已知错误 + +- `codebase-memory-mcp` 的 `list_projects` / `index_status` 返回 `Transport closed`,降级为 FastCtx 阅读和 `rg` 补充检索。 +- 首次全量回归因子代理同步夹具缺少 `codexAppRuntime` 依赖失败,独立代理修复夹具后重新验收。 +- 浏览器登录导航与evaluate竞态导致夹具退出;修复等待流程后重跑,不能据此宣称UI已验收。 +- 验收新增取消启动通知先到的串轮缺陷,以及done/history工具40条限额覆盖完整运行态的问题,均纳入第5步修复与回归。 diff --git a/.trellis/workspace/shiyue/index.md b/.trellis/workspace/shiyue/index.md index fa5413b..caa8185 100644 --- a/.trellis/workspace/shiyue/index.md +++ b/.trellis/workspace/shiyue/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 1 -- **Last Active**: 2026-08-18 +- **Total Sessions**: 2 +- **Last Active**: 2026-10-10 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~37 | Active | +| `journal-1.md` | ~112 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 2 | 2026-10-10 | 运行中气泡完整续接、折叠与末段复制 | - | `main` | | 1 | 2026-08-18 | 修复 Codex App 重试中断 | - | `main` | diff --git a/.trellis/workspace/shiyue/journal-1.md b/.trellis/workspace/shiyue/journal-1.md index dd30c63..a9f306c 100644 --- a/.trellis/workspace/shiyue/journal-1.md +++ b/.trellis/workspace/shiyue/journal-1.md @@ -35,3 +35,78 @@ ### Next Steps - None - task complete + + +## Session 2: 运行中气泡完整续接、折叠与末段复制 + +**Date**: 2026-10-10 +**Task**: 运行中气泡完整续接、折叠与末段复制 +**Branch**: `main` + +### Summary + +完整正文与工具续接、取消身份门禁、折叠和末段复制完成;专项21/13/15/5/8/5及全量通过,正式浏览器8阶段通过。真实旧正文55段核对副本已归档;后台未重启,旧气泡未写回。 + +### Main Changes + +# 运行中气泡续接、折叠与末段复制交付 + +代码实现、独立审查、专项、全量回归及正式浏览器验收均通过。生产后端进程未重启,真实目标旧气泡未被写回。 + +## 最终行为 + +- 自动续跑交接完整正文、工具及去重映射,保留稳定助手气泡身份;等待期、刷新、切换、重连、完成和归档读取均保持完整。 +- 长正文增量分片发送,拼接结果等于原文;完成和历史读取不再用正文或工具预览限额覆盖完整内容。 +- 取消未知身份的启动时保留旧轮门禁,确认身份后精确中断;RPC超时晚到通知和服务明确退出均覆盖,旧通知不能结束新轮。 +- 长气泡只折叠显示,完整正文及DOM保留;多原生回复收起较早段,末段可见,单条长回复可展开。定位单条长回复末段时先展开。 +- 复制按钮与定位、分支同排,仅复制最后一个原生回复段的完整Markdown;保留代码、列表、多自然段、缩进和行末空格,过滤分隔时间、工具及按钮。围栏内同名HTML不会误分段。 + +## 验证 + +| 检查 | 结果 | +|---|---| +| `TMPDIR=/dev/shm node scripts/regression.js` | 退出0,`Regression checks passed.`;与package.json的regression入口相同 | +| 完整续接及取消启动交错专项 | 21/21,含通知先于RPC、RPC超时晚到身份、服务明确退出3场景 | +| runtime完整采集及读取 | 13/13,130项工具、24006字符输入、48006字符结果完整;实时预览有界 | +| 完成与重启恢复 | 15/15 | +| steer恢复 | 5/5 | +| 前端续接原函数 | 5/5 | +| 折叠、复制、定位原函数 | 8/8 | +| 正式Chromium隔离浏览器 | 8阶段全通过,最终DOM136748字符、同一稳定气泡ID和3个工具;新用户轮独立 | +| 桌面及390px手机 | 折叠、展开、末段复制、键盘操作、单条长回复定位及44px触控通过,5张截图已查看 | +| 语法及差异检查 | 通过 | +| 实现及专项脚本指纹 | 全量与浏览器前后均未变化 | + +全量输出见 [regression.log](./regression.log),实现指纹见 [verified-sources.sha256](./verified-sources.sha256)。浏览器报告见 [frontend-result.md](./frontend-result.md),协议及DOM证据见 [browser-evidence.json](./browser-evidence.json),独立审查见 [review.md](./review.md)。 + +截图归档:[桌面折叠](./desktop-collapsed-top.png)、[手机末段与按钮](./mobile-latest-reply.png)。完整5张截图位于 `/dev/shm/ccweb-streaming-retry-browser-G8wkN6`;协议及报告已经复制进任务目录。 + +本机磁盘争用使npm包装进程初始化阻塞且尚未运行脚本,停止本任务包装进程后直接执行相同Node入口。所有独立单元测试使用60秒上限,存储与浏览器夹具移到 `/dev/shm`,没有降低断言或以桩替代Markdown渲染。 + +## 目标旧正文及生产边界 + +- 目标会话 `826deaa0-c3df-42bc-930b-06ee3e05de50` 精确匹配原生线程 `01a124a5-b65e-70d2-b712-06168bae7bd5`。抓取点仍有55段原生助手正文、19606字符,已另存 [核对副本](./recovered-assistant.md)。这是可靠可审阅的旧正文,未猜测或写回真实会话。 +- 本次原生读取截至2026-10-10T22:21:45+08:00,不代表运行中的记录不再增长,也不代表全部工具或另一客户端的45条已恢复;详细边界见 [target-session-evidence.md](./target-session-evidence.md)。 +- 2026-10-10T23:09:10+08:00只读检查仍有目标会话和另一会话的运行态为running。按项目重启规则暂缓生产重启,未改变真实会话、rollout或运行态。 +- 前端资源由服务按请求读取,刷新可加载折叠和复制控件;后台防丢修复需后端重启后加载。刷新显示控件不会自动补回旧版运行态已经漏掉的前文,不能声称真实旧气泡已经恢复。 + +## 工具调用简报 + +2026-10-10:FastCtx本地源码/任务读取、隔离测试和补充rg定位;codebase-memory此前连接断开后使用本地降级,不使用graphify。浏览器使用真实相同版本公共渲染库,经已有代理下载并记录来源和SHA256。没有向外发送真实会话内容,没有重启生产。 + + +### Git Commits + +(No commits - planning session) + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete 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 b5b4ddf..974c1a5 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 4e2291d..7ac1291 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -15,7 +15,6 @@ function createAgentRuntime(deps) { internalMcpToken, nodePath, wsSend, - truncateObj, sanitizeToolInput, loadSession, saveSession, @@ -32,11 +31,6 @@ function createAgentRuntime(deps) { return Math.max(min, Math.min(max, raw)); } - const RUNTIME_FULL_TEXT_MAX_CHARS = readRuntimePositiveIntEnv( - 'CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS', - 256 * 1024, - { min: 4096 }, - ); const CCWEB_MCP_STARTUP_TIMEOUT_SEC = readRuntimePositiveIntEnv( 'CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC', 30, @@ -47,17 +41,8 @@ function createAgentRuntime(deps) { 60, { min: 10, max: 600 }, ); - const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n'; - - function keepTail(value, maxLen) { - const text = String(value || ''); - if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text; - const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_HEAD.length); - return `${RUNTIME_TRUNCATED_HEAD}${text.slice(-keep)}`; - } - - function appendCappedText(current, addition, maxLen = RUNTIME_FULL_TEXT_MAX_CHARS) { - return keepTail(`${String(current || '')}${String(addition || '')}`, maxLen); + function appendFullText(current, addition) { + return `${String(current || '')}${String(addition || '')}`; } function tomlString(value) { @@ -282,7 +267,7 @@ function createAgentRuntime(deps) { function codexToolInput(item) { if (!item) return null; if (item.type === 'command_execution') return { command: item.command || '' }; - return truncateObj(item, 500); + return item; } function codexToolMeta(item) { @@ -331,7 +316,7 @@ function createAgentRuntime(deps) { if (!item) return ''; if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output; if (typeof item.text === 'string' && item.text) return item.text; - return JSON.stringify(truncateObj(item, 1200)); + return JSON.stringify(item); } function formatAgentMessageDividerTime(date = new Date()) { @@ -369,7 +354,7 @@ function createAgentRuntime(deps) { ? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`) : ''; const chunk = separator + nextText; - entry.fullText = appendCappedText(entry.fullText || '', chunk); + entry.fullText = appendFullText(entry.fullText || '', chunk); return chunk; } @@ -405,6 +390,38 @@ function createAgentRuntime(deps) { function processClaudeEvent(entry, event, sessionId) { if (!event || !event.type) return; + function toolResultText(block) { + if (typeof block?.content === 'string') return block.content; + if (Array.isArray(block?.content)) { + return block.content.map((part) => { + if (typeof part === 'string') return part; + if (typeof part?.text === 'string') return part.text; + return JSON.stringify(part); + }).filter(Boolean).join('\n'); + } + if (block?.content === undefined) return ''; + return JSON.stringify(block.content); + } + + function processToolResult(block) { + const toolId = String(block?.tool_use_id || block?.toolUseId || '').trim(); + if (!toolId) return; + const resultText = toolResultText(block); + let tc = entry.toolCalls.find((tool) => tool.id === toolId); + if (!tc) { + tc = { name: block.name || 'ClaudeTool', id: toolId, input: null, done: false }; + entry.toolCalls.push(tc); + } + tc.done = true; + tc.result = resultText; + sendRuntime(entry, sessionId, { + type: 'tool_end', + toolUseId: toolId, + input: tc.input, + result: resultText, + }); + } + switch (event.type) { case 'system': if (event.session_id) { @@ -422,26 +439,14 @@ function createAgentRuntime(deps) { for (const block of content) { if (block.type === 'text' && block.text) { - entry.fullText = appendCappedText(entry.fullText || '', block.text); + entry.fullText = appendFullText(entry.fullText || '', block.text); sendRuntime(entry, sessionId, { type: 'text_delta', text: block.text }); } else if (block.type === 'tool_use') { const toolInput = sanitizeToolInput(block.name, block.input); const tc = { name: block.name, id: block.id, input: toolInput, done: false }; entry.toolCalls.push(tc); sendRuntime(entry, sessionId, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input }); - } else if (block.type === 'tool_result') { - const resultText = typeof block.content === 'string' - ? block.content - : Array.isArray(block.content) - ? block.content.map((c) => c.text || '').join('\n') - : JSON.stringify(block.content); - const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id); - if (tc) { - tc.done = true; - tc.result = resultText.slice(0, 2000); - } - sendRuntime(entry, sessionId, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) }); - } + } else if (block.type === 'tool_result') processToolResult(block); } if (event.session_id) { @@ -454,6 +459,15 @@ function createAgentRuntime(deps) { break; } + case 'user': { + const content = event.message?.content; + if (!Array.isArray(content)) break; + for (const block of content) { + if (block?.type === 'tool_result') processToolResult(block); + } + break; + } + case 'result': { const session = loadSession(sessionId); if (session) { @@ -495,7 +509,7 @@ function createAgentRuntime(deps) { const item = event.item; if (!item || !item.id || item.type === 'agent_message') break; const tc = ensureCodexToolCall(entry, item, sessionId); - const resultText = codexToolResult(item).slice(0, 2000); + const resultText = codexToolResult(item); tc.done = false; tc.result = resultText; @@ -522,12 +536,20 @@ function createAgentRuntime(deps) { } catch {} if (parsedContent && Array.isArray(parsedContent)) { - if (!entry.contentBlocks) entry.contentBlocks = []; + // 只有出现结构化内容时才切换到内容块形状。纯文本回合继续使用 + // 旧的字符串存储格式,避免下游把数组误转成 `[object Object]`。 + // 如果前面已经收到普通文本,需要先把它作为首个文本块补回, + // 这样混合回合仍能保留完整的顺序和正文。 + if (!entry.contentBlocks) { + entry.contentBlocks = []; + if (entry.fullText) entry.contentBlocks.push({ type: 'text', text: entry.fullText }); + } entry.contentBlocks.push(...parsedContent); const textOnly = parsedContent.filter(b => b.type === 'text').map(b => b.text || '').join(''); appendCodexAgentText(entry, textOnly); sendRuntime(entry, sessionId, { type: 'content_blocks', blocks: parsedContent }); } else { + if (entry.contentBlocks) entry.contentBlocks.push({ type: 'text', text: item.text }); const textChunk = appendCodexAgentText(entry, item.text); sendRuntime(entry, sessionId, { type: 'text_delta', text: textChunk }); } @@ -535,13 +557,14 @@ function createAgentRuntime(deps) { break; } const tc = ensureCodexToolCall(entry, item, sessionId); - const resultText = codexToolResult(item).slice(0, 2000); + const resultText = codexToolResult(item); tc.done = true; tc.result = resultText; sendRuntime(entry, sessionId, { type: 'tool_end', toolUseId: item.id, + input: tc.input, result: resultText, kind: tc.kind, meta: tc.meta, diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index ec26817..389713c 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -13,14 +13,9 @@ function readPositiveIntEnv(name, fallback, options = {}) { return Math.max(min, Math.min(max, raw)); } -const RUNTIME_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_FULL_TEXT_MAX_CHARS', 256 * 1024, { min: 4096 }); -const RUNTIME_AGENT_ITEM_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_AGENT_ITEM_MAX_CHARS', 128 * 1024, { min: 4096 }); -const RUNTIME_TOOL_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_DELTA_MAX_CHARS', 64 * 1024, { min: 1024 }); const RUNTIME_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 }); const RUNTIME_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 }); const RUNTIME_STREAM_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STREAM_DELTA_MAX_CHARS', 16 * 1024, { min: 1024 }); -const RUNTIME_MAX_TOOL_CALLS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_MAX_TOOL_CALLS', 120, { min: 1, max: 1000 }); -const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n'; const RUNTIME_TRUNCATED_TAIL = '\n[cc-web: 内容过长,已截断以保护服务稳定性]'; const CODEX_APP_PLAN_ITEM_TYPES = new Set(['plan', 'plan_list', 'planlist', 'todo', 'todo_list', 'todolist', 'task_list']); const CODEX_APP_PLAN_TOOL_NAMES = new Set(['update_plan', 'plan', 'plan_list', 'todo_list', 'updateplan', 'todolist']); @@ -36,7 +31,6 @@ function createCodexAppRuntime(deps = {}) { wsSend, loadSession, saveSession, - truncateObj, } = deps; function limitPreviewValue(value, options = {}, depth = 0, seen = new WeakSet()) { @@ -102,19 +96,13 @@ function createCodexAppRuntime(deps = {}) { return `${text.slice(0, keep)}${RUNTIME_TRUNCATED_TAIL}`; } - function keepTail(value, maxLen) { - const text = String(value || ''); - if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text; - const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_HEAD.length); - return `${RUNTIME_TRUNCATED_HEAD}${text.slice(-keep)}`; + function appendFullText(current, addition) { + return `${String(current || '')}${String(addition || '')}`; } - function appendCappedText(current, addition, maxLen) { - return keepTail(`${String(current || '')}${String(addition || '')}`, maxLen); - } - - function capStreamDelta(text) { - return truncateEnd(text, RUNTIME_STREAM_DELTA_MAX_CHARS); + function stringifyFullValue(value) { + if (typeof value === 'string') return value; + return JSON.stringify(value, null, 2); } function truncate(value, maxLen) { @@ -243,7 +231,7 @@ function createCodexAppRuntime(deps = {}) { if (!entries) return null; const items = entries .map((entry) => { - const text = truncateEnd(planEntryText(entry), RUNTIME_TOOL_INPUT_MAX_CHARS); + const text = planEntryText(entry); if (!text) return null; return { text, @@ -265,7 +253,7 @@ function createCodexAppRuntime(deps = {}) { const status = normalizeIdentifier(entry.status || entry.state); return ['inprogress', 'in_progress', 'active', 'running', 'working'].includes(status); }); - return truncateEnd(planEntryText(activeEntry), RUNTIME_TOOL_INPUT_MAX_CHARS); + return planEntryText(activeEntry); } function isTodoListPlanDone(item, todoList) { @@ -397,13 +385,13 @@ function createCodexAppRuntime(deps = {}) { status, hasReadableSourceTitle: !!title, }; - if (prompt) state.taskDescription = truncateEnd(prompt, RUNTIME_TOOL_INPUT_MAX_CHARS); + if (prompt) state.taskDescription = prompt; return { type: 'subAgentActivity', kind: activityKind, agentThreadId, agentPath, - prompt: prompt ? truncateEnd(prompt, RUNTIME_TOOL_INPUT_MAX_CHARS) : null, + prompt: prompt || null, tool: 'subAgentActivity', status, receiverThreadIds: agentThreadId ? [agentThreadId] : [], @@ -438,7 +426,25 @@ function createCodexAppRuntime(deps = {}) { } function sendRuntime(entry, sessionId, payload) { - wsSend(entry.ws, { ...payload, sessionId }); + const output = { ...payload, sessionId }; + if (output.type === 'text_delta') { + // 分块发送长增量而不是截断,客户端累积正文应与服务端完整正文一致。 + const text = String(output.text || ''); + const chunkSize = Math.min(RUNTIME_STREAM_DELTA_MAX_CHARS, 16 * 1024); + for (let offset = 0; offset < text.length; offset += chunkSize) { + wsSend(entry.ws, { ...output, text: text.slice(offset, offset + chunkSize) }); + } + return; + } + for (const key of ['text', 'message', 'result', 'input', 'blocks', 'meta']) { + if (Object.prototype.hasOwnProperty.call(output, key)) { + output[key] = limitPreviewValue(output[key], { + maxString: key === 'input' ? RUNTIME_TOOL_INPUT_MAX_CHARS : key === 'text' || key === 'message' + ? RUNTIME_STREAM_DELTA_MAX_CHARS : RUNTIME_TOOL_RESULT_MAX_CHARS, + }); + } + } + return wsSend(entry.ws, output); } function formatAgentMessageDividerTime(date = new Date()) { @@ -521,53 +527,43 @@ function createCodexAppRuntime(deps = {}) { if (todoList) return todoList; switch (item.type) { case 'commandExecution': - return { command: truncateEnd(item.command || '', RUNTIME_TOOL_INPUT_MAX_CHARS) }; + return { command: item.command || '' }; case 'mcpToolCall': return { - server: truncateEnd(item.server || '', 256), - tool: truncateEnd(item.tool || '', 256), - arguments: limitPreviewValue(item.arguments ?? null, { - maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, - maxDepth: 5, - maxArray: 80, - maxKeys: 80, - }), + server: item.server || '', + tool: item.tool || '', + arguments: item.arguments ?? null, }; case 'fileChange': - return { changes: limitPreviewValue(item.changes || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }) }; + return { changes: item.changes || [] }; case 'reasoning': return { - content: limitPreviewValue(item.content || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 4 }), - summary: limitPreviewValue(item.summary || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 4 }), + content: item.content || [], + summary: item.summary || [], }; case 'dynamicToolCall': return { - tool: truncateEnd(item.tool || '', 256), - namespace: truncateEnd(item.namespace || '', 256) || null, - arguments: limitPreviewValue(item.arguments ?? null, { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }), + tool: item.tool || '', + namespace: item.namespace || null, + arguments: item.arguments ?? null, }; case 'collabAgentToolCall': return { - tool: truncateEnd(item.tool || '', 256), - prompt: truncateEnd(item.prompt || '', RUNTIME_TOOL_INPUT_MAX_CHARS) || null, - receiverThreadIds: limitPreviewValue(item.receiverThreadIds || [], { maxString: 512, maxDepth: 3 }), - agentsStates: limitPreviewValue(item.agentsStates || {}, { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }), + tool: item.tool || '', + prompt: item.prompt || null, + receiverThreadIds: item.receiverThreadIds || [], + agentsStates: item.agentsStates || {}, }; case 'subAgentActivity': return normalizeSubAgentActivityItem(item); case 'imageGeneration': return { - prompt: truncateEnd(item.prompt || item.query || '', RUNTIME_TOOL_INPUT_MAX_CHARS), + prompt: item.prompt || item.query || '', size: item.size || null, quality: item.quality || null, }; default: - return limitPreviewValue(item, { - maxString: Math.min(500, RUNTIME_TOOL_INPUT_MAX_CHARS), - maxDepth: 4, - maxArray: 30, - maxKeys: 40, - }); + return item; } } @@ -647,14 +643,14 @@ function createCodexAppRuntime(deps = {}) { function stringifyMcpResult(result) { if (!result) return ''; - if (Array.isArray(result.content)) { + if (Array.isArray(result.content) && Object.keys(result).every(key => key === 'content')) { const text = result.content.map((part) => { - if (typeof part?.text === 'string') return truncateEnd(part.text, RUNTIME_TOOL_RESULT_MAX_CHARS); - return safeStringifyPreview(part, RUNTIME_TOOL_RESULT_MAX_CHARS); + if (typeof part?.text === 'string') return part.text; + return stringifyFullValue(part); }).filter(Boolean).join('\n'); - if (text) return truncateEnd(text, RUNTIME_TOOL_RESULT_MAX_CHARS); + if (text) return text; } - return safeStringifyPreview(result, RUNTIME_TOOL_RESULT_MAX_CHARS); + return stringifyFullValue(result); } function itemResult(item) { @@ -663,28 +659,28 @@ function createCodexAppRuntime(deps = {}) { if (todoList) return JSON.stringify(todoList, null, 2); switch (item.type) { case 'commandExecution': - return truncateEnd(item.aggregatedOutput || '', RUNTIME_TOOL_RESULT_MAX_CHARS); + return item.aggregatedOutput || ''; case 'mcpToolCall': return item.error?.message || stringifyMcpResult(item.result); case 'fileChange': - return safeStringifyPreview(item.changes || [], RUNTIME_TOOL_RESULT_MAX_CHARS); + return stringifyFullValue(item.changes || []); case 'reasoning': - return truncateEnd(reasoningTextFromItem(item), RUNTIME_TOOL_RESULT_MAX_CHARS); + return reasoningTextFromItem(item); case 'dynamicToolCall': - return safeStringifyPreview({ + return stringifyFullValue({ success: item.success ?? null, contentItems: item.contentItems || null, - }, RUNTIME_TOOL_RESULT_MAX_CHARS); + }); case 'collabAgentToolCall': - return safeStringifyPreview({ + return stringifyFullValue({ status: item.status || null, receiverThreadIds: item.receiverThreadIds || [], agentsStates: item.agentsStates || {}, - }, RUNTIME_TOOL_RESULT_MAX_CHARS); + }); case 'subAgentActivity': - return safeStringifyPreview(normalizeSubAgentActivityItem(item, true), RUNTIME_TOOL_RESULT_MAX_CHARS); + return stringifyFullValue(normalizeSubAgentActivityItem(item, true)); case 'imageGeneration': - return safeStringifyPreview({ + return stringifyFullValue({ status: item.status || null, images: Array.isArray(item.images) ? item.images.map((image) => ({ path: image.path || image.filePath || null, @@ -692,17 +688,22 @@ function createCodexAppRuntime(deps = {}) { size: image.size || null, })) : null, outputPath: item.outputPath || item.path || null, - }, RUNTIME_TOOL_RESULT_MAX_CHARS); + }); default: - if (typeof item.text === 'string') return truncateEnd(item.text, RUNTIME_TOOL_RESULT_MAX_CHARS); - return safeStringifyPreview(item, Math.min(1200, RUNTIME_TOOL_RESULT_MAX_CHARS)); + if (typeof item.text === 'string') return item.text; + return stringifyFullValue(item); } } + function itemIdForEntry(entry, itemId) { + if (!itemId) return itemId; + return entry?.runtimeItemScope ? `codexapp-item:${entry.runtimeItemScope}:${itemId}` : itemId; + } + function ensureToolCall(entry, item, sessionId) { if (!item?.id) return null; const kind = itemKind(item); - const itemId = isCcwebPlanItem(item) ? 'ccweb-update-plan' : item.id; + const itemId = itemIdForEntry(entry, isCcwebPlanItem(item) ? 'ccweb-update-plan' : item.id); let toolCall = entry.toolCalls.find((tool) => tool.id === itemId); if (toolCall) { toolCall.name = itemName(item); @@ -716,31 +717,6 @@ function createCodexAppRuntime(deps = {}) { return toolCall; } - if (entry.toolCalls.length >= RUNTIME_MAX_TOOL_CALLS) { - let overflowTool = entry.toolCalls.find((tool) => tool.id === 'ccweb-toolcalls-overflow'); - if (!overflowTool) { - overflowTool = { - name: 'cc-web', - id: 'ccweb-toolcalls-overflow', - kind: 'system', - meta: { kind: 'system', title: 'Tool Calls', subtitle: 'too many tool calls', status: 'inProgress' }, - input: null, - done: false, - result: '工具调用数量过多,后续工具调用已折叠显示。', - }; - entry.toolCalls.push(overflowTool); - sendRuntime(entry, sessionId, { - type: 'tool_start', - name: overflowTool.name, - toolUseId: overflowTool.id, - input: overflowTool.input, - kind: overflowTool.kind, - meta: overflowTool.meta, - }); - } - return overflowTool; - } - toolCall = { name: itemName(item), id: itemId, @@ -762,6 +738,7 @@ function createCodexAppRuntime(deps = {}) { } function appendAgentText(entry, itemId, text) { + itemId = itemIdForEntry(entry, itemId); const nextText = String(text || ''); if (!nextText) return ''; if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); @@ -776,35 +753,36 @@ function createCodexAppRuntime(deps = {}) { const separator = agentMessageSeparator(entry, itemId, nextText); const firstText = currentItemText ? nextText : `${pendingPrefix}${nextText}`; const appended = separator + firstText; - entry.agentMessageItems.set(itemId, appendCappedText(currentItemText, firstText, RUNTIME_AGENT_ITEM_MAX_CHARS)); - entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS); - return capStreamDelta(appended); + entry.agentMessageItems.set(itemId, appendFullText(currentItemText, firstText)); + entry.fullText = appendFullText(entry.fullText || '', appended); + return appended; } function appendAgentCompletedText(entry, item) { + const itemId = itemIdForEntry(entry, item?.id); const text = String(item?.text || ''); if (!text) return ''; if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); - const currentItemText = entry.agentMessageItems.get(item.id) || ''; + const currentItemText = entry.agentMessageItems.get(itemId) || ''; if (!entry.agentMessagePendingPrefixes) entry.agentMessagePendingPrefixes = new Map(); - const pendingPrefix = entry.agentMessagePendingPrefixes.get(item.id) || ''; - entry.agentMessagePendingPrefixes.delete(item.id); + const pendingPrefix = entry.agentMessagePendingPrefixes.get(itemId) || ''; + entry.agentMessagePendingPrefixes.delete(itemId); if (!currentItemText && !text.trim()) return ''; if (currentItemText && text.startsWith(currentItemText)) { const remainder = text.slice(currentItemText.length); - entry.agentMessageItems.set(item.id, keepTail(text, RUNTIME_AGENT_ITEM_MAX_CHARS)); - entry.fullText = appendCappedText(entry.fullText || '', remainder, RUNTIME_FULL_TEXT_MAX_CHARS); - return capStreamDelta(remainder); + entry.agentMessageItems.set(itemId, text); + entry.fullText = appendFullText(entry.fullText || '', remainder); + return remainder; } if (currentItemText === text) return ''; const completedText = !currentItemText && pendingPrefix && !text.startsWith(pendingPrefix) ? `${pendingPrefix}${text}` : text; - const separator = agentMessageSeparator(entry, item.id, completedText); + const separator = agentMessageSeparator(entry, itemId, completedText); const appended = separator + completedText; - entry.agentMessageItems.set(item.id, keepTail(completedText, RUNTIME_AGENT_ITEM_MAX_CHARS)); - entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS); - return capStreamDelta(appended); + entry.agentMessageItems.set(itemId, completedText); + entry.fullText = appendFullText(entry.fullText || '', appended); + return appended; } function agentMessageSeparator(entry, itemId, nextText) { @@ -817,12 +795,8 @@ function createCodexAppRuntime(deps = {}) { function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) { if (!itemId) return; + itemId = itemIdForEntry(entry, itemId); let toolCall = entry.toolCalls.find((tool) => tool.id === itemId); - if (!toolCall) { - const targetItemId = entry.toolCalls.length >= RUNTIME_MAX_TOOL_CALLS ? 'ccweb-toolcalls-overflow' : itemId; - toolCall = entry.toolCalls.find((tool) => tool.id === targetItemId); - itemId = targetItemId; - } if (!toolCall) { toolCall = { name: patch.name || 'CodexAppItem', @@ -846,22 +820,16 @@ function createCodexAppRuntime(deps = {}) { if (patch.kind) toolCall.kind = patch.kind; if (patch.meta) toolCall.meta = patch.meta; if (patch.input !== undefined) { - toolCall.input = limitPreviewValue(patch.input, { - maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, - maxDepth: 5, - maxArray: 80, - maxKeys: 80, - }); + toolCall.input = patch.input; } - const safeResult = truncateEnd(result, RUNTIME_TOOL_RESULT_MAX_CHARS); toolCall.done = done; - toolCall.result = safeResult; + toolCall.result = result; sendRuntime(entry, sessionId, { type: done ? 'tool_end' : 'tool_update', toolUseId: itemId, name: toolCall.name, input: toolCall.input, - result: safeResult, + result, kind: toolCall.kind, meta: toolCall.meta, }); @@ -911,10 +879,11 @@ function createCodexAppRuntime(deps = {}) { case 'item/commandExecution/outputDelta': { const itemId = params.itemId; - const current = entry.toolOutputDeltas?.get(itemId) || ''; - const next = appendCappedText(current, params.delta || '', RUNTIME_TOOL_DELTA_MAX_CHARS); + const scopedItemId = itemIdForEntry(entry, itemId); + const current = entry.toolOutputDeltas?.get(scopedItemId) || ''; + const next = appendFullText(current, params.delta || ''); if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map(); - entry.toolOutputDeltas.set(itemId, next); + entry.toolOutputDeltas.set(scopedItemId, next); updateToolResult(entry, sessionId, itemId, next, false, { name: 'CommandExecution', kind: 'command_execution', @@ -966,10 +935,11 @@ function createCodexAppRuntime(deps = {}) { const itemId = params.itemId; const delta = String(params.delta || ''); if (!itemId || !delta) return { done: false }; - const current = entry.toolOutputDeltas?.get(itemId) || ''; - const next = appendCappedText(current, delta, RUNTIME_TOOL_DELTA_MAX_CHARS); + const scopedItemId = itemIdForEntry(entry, itemId); + const current = entry.toolOutputDeltas?.get(scopedItemId) || ''; + const next = appendFullText(current, delta); if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map(); - entry.toolOutputDeltas.set(itemId, next); + entry.toolOutputDeltas.set(scopedItemId, next); updateToolResult(entry, sessionId, itemId, next, false, { name: 'Reasoning', kind: 'reasoning', @@ -987,7 +957,7 @@ function createCodexAppRuntime(deps = {}) { } if (item.type === 'userMessage') return { done: false }; if (item.type === 'reasoning') { - const result = truncateEnd(itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '', RUNTIME_TOOL_RESULT_MAX_CHARS); + const result = itemResult(item) || entry.toolOutputDeltas?.get(itemIdForEntry(entry, item.id)) || ''; if (!result.trim()) return { done: false }; const toolCall = ensureToolCall(entry, item, sessionId); if (!toolCall) return { done: false }; @@ -1006,8 +976,8 @@ function createCodexAppRuntime(deps = {}) { const toolCall = ensureToolCall(entry, item, sessionId); if (!toolCall) return { done: false }; const result = item.type === 'subAgentActivity' - ? safeStringifyPreview(toolCall.input, RUNTIME_TOOL_RESULT_MAX_CHARS) - : truncateEnd(itemResult(item), RUNTIME_TOOL_RESULT_MAX_CHARS); + ? stringifyFullValue(toolCall.input) + : itemResult(item); toolCall.done = true; toolCall.result = result; toolCall.meta = itemMeta(item) || toolCall.meta; @@ -1060,6 +1030,7 @@ function createCodexAppRuntime(deps = {}) { } return { + itemIdForEntry, planUpdateFromNotification, processCodexAppNotification, updateUsage, diff --git a/lib/session-history-store.js b/lib/session-history-store.js new file mode 100644 index 0000000..f92737b --- /dev/null +++ b/lib/session-history-store.js @@ -0,0 +1,338 @@ +'use strict'; + +const nativeFs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const SESSION_RUNTIME_KEYS = new Set([ + 'ws', 'tailer', 'codexAppStateTimer', 'codexAppStateDirty', 'codexAppStateCleaned', + 'toolOutputDeltas', 'agentMessageItems', +]); + +function createSessionHistoryStore(options = {}) { + const fs = options.fs || nativeFs; + const historyDir = path.join(options.sessionsDir, '_history'); + const turnsDir = path.join(historyDir, '_turns'); + // 身份只留在当前存储实例,不能通过 JSON 快照伪造可信完整加载或修订。 + const loadedStates = new WeakMap(); + const removedIds = new Set(); + + function validId(value) { + const id = String(value || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw Object.assign(new Error('会话归档 ID 无效'), { code: 'history_invalid_id' }); + return id; + } + + function filePath(id) { + return path.join(historyDir, `${validId(id)}.json`); + } + + function readRecord(id) { + let json; + try { + json = fs.readFileSync(filePath(id), 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + const record = JSON.parse(json); + if (record?.version !== 1 || typeof record.revision !== 'string' || !record.revision + || record.session?.id !== id || !Array.isArray(record.session.messages) + || typeof record.complete !== 'boolean') { + throw Object.assign(new Error('完整会话归档格式无效'), { code: 'history_invalid_archive' }); + } + return record; + } + + function state(session) { + const value = session && loadedStates.get(session); + if (!value) return null; + const { baseline, ...metadata } = value; + return metadata; + } + + function load(id) { + const record = readRecord(validId(id)); + if (!record) return null; + loadedStates.set(record.session, { + id, revision: record.revision, complete: record.complete, source: 'archive', + resetRevision: record.resetRevision || null, baseline: JSON.stringify(record.session), + }); + return record.session; + } + + function markLegacy(session) { + validId(session?.id); + loadedStates.set(session, { + id: session.id, revision: null, source: 'legacy', + complete: !session.messages?.some(message => message?.ccwebPersistenceNotice === true) + && !(Number(session.historySnapshotBaseIndex) > 0), + }); + return session; + } + + function markComplete(session, options = {}) { + const previous = loadedStates.get(session); + if (!previous || previous.id !== session.id) { + throw Object.assign(new Error('只能确认可信加载会话的历史状态'), { code: 'history_untrusted_source' }); + } + loadedStates.set(session, { ...previous, complete: true, reset: options.reset === true }); + session.historySnapshotBaseIndex = 0; + session.historySnapshotCount = session.messages.length; + return () => loadedStates.set(session, previous); + } + + function serializeSession(session) { + // 完整档案没有文本、消息数、数组长度或深度裁剪;不可序列化时整次提交失败。 + const json = JSON.stringify(session, function replacer(key, value) { + if (this === session && SESSION_RUNTIME_KEYS.has(key)) return undefined; + if (typeof value === 'bigint') return String(value); + if (value instanceof Map) return Object.fromEntries(value); + if (value instanceof Set) return [...value]; + return value; + }); + const persisted = JSON.parse(json); + if (!Array.isArray(persisted.messages)) throw new Error('完整会话消息必须为数组'); + return persisted; + } + + function equal(left, right) { + return JSON.stringify(left) === JSON.stringify(right); + } + + function plainObject(value) { + return value && typeof value === 'object' && !Array.isArray(value); + } + + function identifiedArray(value) { + return Array.isArray(value) && value.every(item => item && typeof item.id === 'string' && item.id); + } + + function mergeIdentifiedArray(baseline, incoming, current) { + const baseById = new Map(baseline.map(item => [item.id, item])); + const incomingById = new Map(incoming.map(item => [item.id, item])); + const currentById = new Map(current.map(item => [item.id, item])); + const output = []; + const seen = new Set(); + for (const item of current) { + if (seen.has(item.id)) continue; + seen.add(item.id); + const base = baseById.get(item.id); + const next = incomingById.get(item.id); + if (base && !next && equal(item, base)) continue; + output.push(next ? mergeValue(base, next, item) : item); + } + // 只补本次相对加载基线新增的气泡;后来已经删除的旧气泡不能复活。 + for (let index = 0; index < incoming.length; index += 1) { + const item = incoming[index]; + if (baseById.has(item.id) || currentById.has(item.id) || output.some(existing => existing.id === item.id)) continue; + let position = -1; + for (let next = index + 1; next < incoming.length; next += 1) { + position = output.findIndex(existing => existing.id === incoming[next].id); + if (position >= 0) break; + } + if (position < 0) { + for (let previous = index - 1; previous >= 0; previous -= 1) { + const anchor = output.findIndex(existing => existing.id === incoming[previous].id); + if (anchor >= 0) { position = anchor + 1; break; } + } + } + output.splice(position < 0 ? output.length : position, 0, item); + } + return output; + } + + function mergeValue(baseline, incoming, current) { + if (equal(incoming, baseline)) return current; + if (equal(current, baseline) || equal(incoming, current)) return incoming; + if ((baseline === undefined || identifiedArray(baseline)) && identifiedArray(incoming) && identifiedArray(current)) { + return mergeIdentifiedArray(baseline || [], incoming, current); + } + if (plainObject(incoming) && plainObject(current) && (baseline === undefined || plainObject(baseline))) { + const output = Object.create(null); + const base = baseline || {}; + for (const key of new Set([...Object.keys(base), ...Object.keys(incoming), ...Object.keys(current)])) { + const own = (value) => Object.prototype.hasOwnProperty.call(value, key) ? value[key] : undefined; + const value = mergeValue(own(base), own(incoming), own(current)); + if (value !== undefined) output[key] = value; + } + return output; + } + // 同字段冲突保留已经提交的值;互不重叠的正文、工具与元数据修改会逐字段合并。 + return current; + } + + function synchronizeValue(target, persisted, root = false) { + if (Array.isArray(target) && Array.isArray(persisted)) { + const identified = identifiedArray(target) && identifiedArray(persisted); + const byId = identified ? new Map(target.map(item => [item.id, item])) : null; + const updated = persisted.map((item, index) => synchronizeValue(byId ? byId.get(item.id) : target[index], item)); + target.length = updated.length; + for (let index = 0; index < updated.length; index += 1) target[index] = updated[index]; + return target; + } + const prototype = target && typeof target === 'object' ? Object.getPrototypeOf(target) : undefined; + if ((prototype === Object.prototype || prototype === null) && plainObject(persisted)) { + for (const key of Object.keys(target)) { + if ((!root || !SESSION_RUNTIME_KEYS.has(key)) && !Object.prototype.hasOwnProperty.call(persisted, key)) delete target[key]; + } + for (const [key, value] of Object.entries(persisted)) { + const existing = Object.prototype.hasOwnProperty.call(target, key) ? target[key] : undefined; + Object.defineProperty(target, key, { + value: synchronizeValue(existing, value), enumerable: true, writable: true, configurable: true, + }); + } + return target; + } + return persisted; + } + + function writeAtomicJson(target, record) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`; + let fd; + try { + fd = fs.openSync(temporary, 'wx', 0o600); + fs.writeFileSync(fd, JSON.stringify(record), 'utf8'); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(temporary, target); + } finally { + if (fd !== undefined) { + try { fs.closeSync(fd); } catch {} + } + try { fs.unlinkSync(temporary); } catch {} + } + } + + function writeRecord(id, record) { + writeAtomicJson(filePath(id), record); + } + + function turnPath(id) { + return path.join(turnsDir, `${validId(id)}.json`); + } + + function saveTurn(id, state) { + writeAtomicJson(turnPath(id), state); + } + + function loadTurn(id) { + try { + const state = JSON.parse(fs.readFileSync(turnPath(id), 'utf8')); + if (state?.sessionId !== id || state.agent !== 'codexapp') throw new Error('完整运行态归档无效'); + return state; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + } + + function removeTurn(id) { + try { fs.unlinkSync(turnPath(id)); } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + + function listTurnIds() { + try { + return fs.readdirSync(turnsDir).filter(name => /^[a-zA-Z0-9_-]+\.json$/.test(name)).map(name => name.slice(0, -5)); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + } + + function save(session) { + const id = validId(session?.id); + const previous = loadedStates.get(session); + if (removedIds.has(id)) throw Object.assign(new Error('已删除会话不能由旧对象重新保存'), { code: 'history_removed' }); + const current = readRecord(id); + if (current) { + if (previous?.source !== 'archive' || previous.id !== id) { + throw Object.assign(new Error('受限预览不能覆盖完整会话归档'), { code: 'history_untrusted_source' }); + } + } else if (previous?.source === 'archive') { + throw Object.assign(new Error('完整会话归档已移除,拒绝旧对象重新创建'), { code: 'history_removed' }); + } + const complete = current?.complete || (previous ? previous.complete + : !session.messages?.some(message => message?.ccwebPersistenceNotice === true) + && !(Number(session.historySnapshotBaseIndex) > 0)); + let persisted = serializeSession(session); + const rebased = !!current && previous.revision !== current.revision; + if (rebased) { + const baseline = JSON.parse(previous.baseline); + if (!identifiedArray(baseline.messages) || !identifiedArray(persisted.messages) || !identifiedArray(current.session.messages)) { + throw Object.assign(new Error('旧修订缺少可靠消息 ID,拒绝覆盖新历史'), { code: 'history_stale_unidentified' }); + } + if (baseline.messages.length > 0 && persisted.messages.length === 0 && !equal(current.session.messages, baseline.messages)) { + throw Object.assign(new Error('清空前历史已经更新,请重新加载'), { code: 'history_stale_clear' }); + } + if ((current.resetRevision || null) !== (previous.resetRevision || null)) { + // 清空提交后旧运行对象只能同步元数据,旧线程的历史和输出不能复活。 + persisted.messages = baseline.messages; + for (const key of ['claudeSessionId', 'codexThreadId', 'codexAppThreadId', 'historySnapshotBaseIndex', 'historySnapshotCount']) { + if (Object.prototype.hasOwnProperty.call(baseline, key)) persisted[key] = baseline[key]; + else delete persisted[key]; + } + } + persisted = mergeValue(baseline, persisted, current.session); + } + if (complete) { + persisted.historySnapshotBaseIndex = 0; + persisted.historySnapshotCount = persisted.messages.length; + } + const revision = crypto.randomUUID(); + const resetRevision = previous?.reset ? revision : current?.resetRevision || null; + const record = { version: 1, revision, complete, resetRevision, session: persisted }; + writeRecord(id, record); + // 只有原子提交后才推进修订,失败重试仍以原修订为基线。 + loadedStates.set(session, { id, revision, complete, resetRevision, source: 'archive', baseline: JSON.stringify(persisted) }); + if (rebased) { + // 流式处理闭包可能持有气泡和工具对象,合并时按 ID 原位协调,保持后续 delta 写入目标。 + synchronizeValue(session, persisted, true); + } + return { revision, complete, session: persisted, rebased }; + } + + function listIds() { + try { + return fs.readdirSync(historyDir).filter(name => /^[a-zA-Z0-9_-]+\.json$/.test(name)) + .map(name => name.slice(0, -5)); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + } + + function revision(id) { + let fd; + try { + fd = fs.openSync(filePath(id), 'r'); + const head = Buffer.alloc(256); + const count = fs.readSync(fd, head, 0, head.length, 0); + const matched = /^\{"version":1,"revision":"([0-9a-f-]{36})",/.exec(head.toString('utf8', 0, count)); + if (!matched) throw new Error('完整会话归档修订无效'); + return matched[1]; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + } + + function remove(id) { + id = validId(id); + try { fs.unlinkSync(filePath(id)); } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + removedIds.add(id); + removeTurn(id); + } + + return { load, save, state, markLegacy, markComplete, listIds, revision, remove, saveTurn, loadTurn, removeTurn, listTurnIds, historyDir }; +} + +module.exports = { createSessionHistoryStore }; diff --git a/public/app.js b/public/app.js index d89322d..c749f69 100644 --- a/public/app.js +++ b/public/app.js @@ -20,11 +20,17 @@ const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500; const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn'; const ASSISTANT_BRANCH_BUTTON_CLASS = 'msg-branch-btn'; + const ASSISTANT_COPY_REPLY_BUTTON_CLASS = 'msg-copy-reply-btn'; + const ASSISTANT_REPLY_COLLAPSE_CHARS = 6000; + const ASSISTANT_REPLY_COLLAPSE_BLOCKS = 24; + const assistantReplyExpansion = new Map(); const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'msg-last-section-focus'; const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 72; const ASSISTANT_LAST_SECTION_SKIP_SELECTOR = [ `.${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`, `.${ASSISTANT_BRANCH_BUTTON_CLASS}`, + `.${ASSISTANT_COPY_REPLY_BUTTON_CLASS}`, + '.msg-replies-toggle', '.msg-action-row', '.msg-tools', '.tool-call', @@ -256,7 +262,7 @@ cursor: 0, remaining: 0, total: 0, - available: false, + available: true, loading: false, error: '', }; @@ -267,7 +273,7 @@ sessionId: null, cursor: 0, total: 0, - available: false, + available: true, loading: false, complete: false, error: '', @@ -1955,7 +1961,7 @@ codexAppSteerRecords.delete(id); } - function commitCodexAppSteerMessage(record) { + function commitCodexAppSteerMessage(record, requestedIndex = null) { if (!record || record.committed) return false; const element = record.element; if (!element || record.sessionId !== currentSessionId) return false; @@ -1963,9 +1969,16 @@ record.committed = true; return false; } - const messageIndex = currentSessionMessageCount; - currentSessionMessageCount += 1; - markSessionMessageElement(element, messageIndex); + const messageIndex = Number.isInteger(requestedIndex) && requestedIndex >= 0 + ? requestedIndex : currentSessionMessageCount; + currentSessionMessageCount = Math.max(currentSessionMessageCount, messageIndex + 1); + markSessionMessageElement(element, messageIndex, { + id: record.id, + role: 'user', + content: record.text, + attachments: cloneCodexAppSteerAttachments(record.attachments), + timestamp: record.timestamp, + }); record.committed = true; updateUserOutlinePanel(); return true; @@ -1988,6 +2001,41 @@ }); } + function completeCodexAppSteerMessage(record, message = null, messageIndex = null) { + if (!record?.element) return false; + const element = record.element; + commitCodexAppSteerMessage(record, messageIndex); + if (Number.isInteger(messageIndex) && messageIndex >= 0) { + markSessionMessageElement(element, messageIndex); + currentSessionMessageCount = Math.max(currentSessionMessageCount, messageIndex + 1); + } + if (message) { + markSessionMessageElement(element, Number(element.dataset.messageIndex), message); + } + const confirmedMessage = message || element.__sessionMessage; + if (confirmedMessage) { + mergeHistoryChunkIntoCachedSnapshot(record.sessionId, [confirmedMessage], { + baseIndex: Number(element.dataset.messageIndex), total: currentSessionMessageCount, + }); + } + element.classList.remove('codex-steer-message', 'codex-steer-pending', 'codex-steer-inserted', 'codex-steer-failed'); + element.removeAttribute('aria-busy'); + element.querySelector('.codex-steer-status')?.remove(); + element.querySelector('.codex-steer-actions')?.remove(); + clearCodexAppSteerRecord(record.id); + return true; + } + + function reconcileCodexAppSteerSessionMessage(sessionId, message, messageIndex = null) { + if (message?.role !== 'user') return false; + const id = String(message.id || message.clientMessageId || '').trim(); + const record = codexAppSteerRecords.get(id); + if (!record || record.sessionId !== sessionId) return false; + // 新轮回显与原失败卡片共用 ID,直接确认原卡片,避免再追加一个用户气泡。 + if (!message.codexAppSteerStatus) return completeCodexAppSteerMessage(record, message, messageIndex); + return setCodexAppSteerStatusElement(record.element, message.codexAppSteerStatus, message.codexAppSteerMessage); + } + function normalizeOutlineTitleHistory(history) { if (!Array.isArray(history)) return []; const normalized = []; @@ -2026,7 +2074,7 @@ sessionId, cursor: 0, total: 0, - available: false, + available: true, loading: false, complete: false, error: '', @@ -2215,13 +2263,12 @@ ? '
正在加载完整消息列表…
' : historyState.error ? '' - : historyState.cursor > 0 && !historyState.available - ? '
更早消息的原始记录不可用
' + : historyState.sessionId && !historyState.available + ? '
更早的记录尚待核实,已保存的消息可正常查看。
' : ''; if (items.length === 0) { userOutlinePanel.innerHTML = historyStatus || '
暂无用户消息
'; - userOutlineBtn.disabled = !historyState.loading - && !(historyState.cursor > 0 && historyState.available); + userOutlineBtn.disabled = historyState.cursor <= 0 && !historyStatus; } else { userOutlinePanel.innerHTML = `${items.map((item) => { if (item.type === 'date') { @@ -2419,6 +2466,11 @@ } function scrollAssistantBubbleToLastSection(bubble) { + const messageEl = bubble?.closest('.msg.assistant'); + if (messageEl?.classList.contains('assistant-single-collapsed')) { + // 单条长回复的末段可能在裁剪区域,先展开再计算定位坐标。 + bubble.querySelector('.msg-replies-toggle')?.click(); + } const target = getAssistantLastSectionTarget(bubble); if (!target) return false; const rect = getRangeRectFromTextPosition(target.node, target.offset) || target.scope.getBoundingClientRect(); @@ -2477,11 +2529,176 @@ if (!bubble) return; let button = bubble.querySelector(`:scope .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`); const hasTarget = !!getAssistantLastSectionTarget(bubble); - if (!button && !hasTarget) return; - if (!button) button = createAssistantLastSectionButton(); - button.hidden = !hasTarget; - button.disabled = !hasTarget; - getMessageActionRow(bubble)?.appendChild(button); + if (button || hasTarget) { + if (!button) button = createAssistantLastSectionButton(); + button.hidden = !hasTarget; + button.disabled = !hasTarget; + getMessageActionRow(bubble)?.appendChild(button); + } + syncAssistantReplyControls(messageEl); + } + + function getAssistantReplySections(content) { + const source = typeof content === 'string' ? content : Array.isArray(content) + ? content.filter((block) => block?.type === 'text').map((block) => block.text || '').join('\n\n') + : ''; + if (!source) return []; + const sections = []; + let start = 0; + let offset = 0; + let fence = null; + // 原生分界独占一行;围栏内的同名 HTML 属于用户可见代码,不能作为回复边界。 + for (const line of source.split('\n')) { + const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/); + if (marker) { + if (!fence) fence = marker[1]; + else if (marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null; + } else if (!fence && /^\s*]*>[\s\S]*<\/div>\s*$/.test(line)) { + const section = source.slice(start, offset).replace(/^(?:[ \t]*\r?\n)+|(?:\r?\n[ \t]*)+$/g, ''); + if (section.trim()) sections.push(section); + start = offset + line.length + 1; + } else if (!fence && /^\s*= source.length) { + // 分界 HTML 尚在流式传输时,复制继续取上一条完整回复。 + const section = source.slice(start, offset).replace(/^(?:[ \t]*\r?\n)+|(?:\r?\n[ \t]*)+$/g, ''); + if (section.trim()) sections.push(section); + return sections; + } + offset += line.length + 1; + } + const last = source.slice(start).replace(/^(?:[ \t]*\r?\n)+|(?:\r?\n[ \t]*)+$/g, ''); + if (last.trim()) sections.push(last); + return sections; + } + + function getAssistantReplySource(messageEl) { + return messageEl?.__assistantContent ?? messageEl?.__sessionMessage?.content ?? ''; + } + + function getAssistantLastReplyText(messageEl) { + const sections = getAssistantReplySections(getAssistantReplySource(messageEl)); + return sections[sections.length - 1] || ''; + } + + function shouldCollapseAssistantReplies(sections, blockCount) { + return sections.length > 0 && (sections.join('\n\n').length > ASSISTANT_REPLY_COLLAPSE_CHARS + || blockCount >= ASSISTANT_REPLY_COLLAPSE_BLOCKS); + } + + function createAssistantReplyCopyButton() { + const button = document.createElement('button'); + button.type = 'button'; + button.className = ASSISTANT_COPY_REPLY_BUTTON_CLASS; + button.title = '复制最后一段回复'; + button.setAttribute('aria-label', '复制最后一段回复'); + button.innerHTML = ``; + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const messageEl = button.closest('.msg.assistant'); + copyTextToClipboard(getAssistantLastReplyText(messageEl), '最后一段回复已复制'); + }); + return button; + } + + function markAssistantEarlierReplies(bubble, divider) { + const earlier = []; + bubble.querySelectorAll('.assistant-earlier-reply').forEach((element) => element.classList.remove('assistant-earlier-reply')); + const visit = (container) => { + for (const child of container.children) { + if (child === divider) break; + if (child.classList.contains('agent-message-divider')) { + child.classList.add('assistant-earlier-reply'); + earlier.push(child); + continue; + } + if (child.matches(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) continue; + if (child.contains(divider)) visit(child); + else if (child.compareDocumentPosition(divider) & Node.DOCUMENT_POSITION_FOLLOWING) { + child.classList.add('assistant-earlier-reply'); + earlier.push(child); + } + } + }; + visit(bubble); + return earlier; + } + + function getAssistantSingleReplyContainer(bubble) { + const existing = bubble.querySelector(':scope > .msg-text, :scope > .assistant-reply-content, :scope > .cross-conversation-reply-body'); + if (existing) { + existing.classList.add('assistant-reply-content'); + return existing; + } + const content = Array.from(bubble.children).filter((element) => !element.matches(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)); + if (content.length === 0) return null; + const container = document.createElement('div'); + container.className = 'assistant-reply-content'; + bubble.insertBefore(container, content[0]); + content.forEach((element) => container.appendChild(element)); + return container; + } + + function syncAssistantReplyControls(messageEl) { + const bubble = messageEl.querySelector(':scope > .msg-bubble'); + if (!bubble) return; + const sections = getAssistantReplySections(getAssistantReplySource(messageEl)); + const hasText = sections.length > 0; + let copy = bubble.querySelector(`.${ASSISTANT_COPY_REPLY_BUTTON_CLASS}`); + if (!copy && hasText) copy = createAssistantReplyCopyButton(); + if (copy) { + copy.hidden = !hasText; + copy.disabled = !hasText; + getMessageActionRow(bubble)?.appendChild(copy); + } + + const dividers = Array.from(bubble.querySelectorAll('.agent-message-divider')) + .filter((element) => !element.closest('pre, code')); + const divider = dividers[dividers.length - 1]; + const canCollapse = shouldCollapseAssistantReplies(sections, collectAssistantTextScopes(bubble).length); + const multipleReplies = sections.length > 1 && !!divider; + let toggle = bubble.querySelector('.msg-replies-toggle'); + if (!canCollapse) { + messageEl.classList.remove('assistant-history-collapsed'); + messageEl.classList.remove('assistant-single-collapsed'); + toggle?.remove(); + bubble.querySelectorAll('.assistant-earlier-reply').forEach((element) => element.classList.remove('assistant-earlier-reply')); + return; + } + if (multipleReplies) { + markAssistantEarlierReplies(bubble, divider); + } else { + if (!getAssistantSingleReplyContainer(bubble)) return; + } + if (!toggle) { + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'msg-replies-toggle'; + toggle.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const expanded = messageEl.classList.contains('assistant-history-collapsed') + || messageEl.classList.contains('assistant-single-collapsed'); + messageEl.__assistantRepliesExpanded = expanded; + const key = `${currentSessionId || ''}:${messageEl.dataset.messageKey || messageEl.dataset.messageId || ''}`; + assistantReplyExpansion.set(key, expanded); + if (assistantReplyExpansion.size > 500) assistantReplyExpansion.delete(assistantReplyExpansion.keys().next().value); + syncAssistantReplyControls(messageEl); + updateScrollbar(); + }); + bubble.insertBefore(toggle, bubble.firstChild); + } + if (!bubble.id) bubble.id = createLocalId('assistant-reply'); + const key = `${currentSessionId || ''}:${messageEl.dataset.messageKey || messageEl.dataset.messageId || ''}`; + const expanded = messageEl.__assistantRepliesExpanded ?? assistantReplyExpansion.get(key) ?? false; + messageEl.classList.toggle('assistant-history-collapsed', multipleReplies && !expanded); + messageEl.classList.toggle('assistant-single-collapsed', !multipleReplies && !expanded); + toggle.setAttribute('aria-controls', bubble.id); + toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + toggle.textContent = multipleReplies + ? expanded ? '收起较早回复' : `展开较早回复(${sections.length - 1} 段)` + : expanded ? '收起长回复' : '展开完整回复'; + toggle.setAttribute('aria-label', toggle.textContent); } function createAssistantBranchButton(messageIndex) { @@ -7081,7 +7298,7 @@ cursor: 0, remaining: 0, total: 0, - available: false, + available: true, loading: false, error: '', }; @@ -7092,28 +7309,35 @@ if (!historyLoadMore) return; const state = currentHistoryState; historyLoadMore.innerHTML = ''; - historyLoadMore.hidden = !state.sessionId || state.cursor <= 0; + historyLoadMore.hidden = !state.sessionId || (state.cursor <= 0 && state.available); if (historyLoadMore.hidden) return; const copy = document.createElement('span'); copy.className = 'history-load-more-copy'; - if (!state.available) { - copy.textContent = `还有 ${state.cursor} 条更早消息,但原始记录不可用,无法恢复。`; + // 来源状态只提示更早记录待核实,已保存消息仍按游标分页。 + const sourceNotice = state.available + ? '' + : '更早的记录尚待核实,已保存的消息可正常查看。'; + if (sourceNotice) { historyLoadMore.classList.add('is-unavailable'); - historyLoadMore.appendChild(copy); - return; + } else { + historyLoadMore.classList.remove('is-unavailable'); } - historyLoadMore.classList.remove('is-unavailable'); - copy.textContent = state.error - ? state.error - : `还有 ${state.cursor} 条更早消息`; + copy.textContent = [ + state.error || (state.cursor > 0 + ? `还有 ${state.cursor} 条已保存的更早消息` + : '已显示全部已保存消息。'), + sourceNotice, + ].filter(Boolean).join(' '); + historyLoadMore.appendChild(copy); + if (state.cursor <= 0) return; const button = document.createElement('button'); button.type = 'button'; button.className = 'history-load-more-button'; button.textContent = state.loading ? '正在加载…' : (state.error ? '重试' : '查看更早消息'); button.disabled = state.loading; button.addEventListener('click', requestOlderHistory); - historyLoadMore.append(copy, button); + historyLoadMore.appendChild(button); } function syncHistoryLoadControl(payload = null) { @@ -7143,7 +7367,7 @@ function requestOlderHistory() { const state = currentHistoryState; - if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0 || !state.available) return; + if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0) return; if (activeHistoryPageRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return; historyPageRequestSeq += 1; const requestId = `history-page-${Date.now()}-${historyPageRequestSeq}`; @@ -7164,7 +7388,7 @@ function requestOutlineHistoryPage() { const state = currentOutlineHistoryState; - if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0 || !state.available) return; + if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0) return; if (activeOutlineHistoryRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return; outlineHistoryRequestSeq += 1; const requestId = `outline-history-${Date.now()}-${outlineHistoryRequestSeq}`; @@ -7552,7 +7776,7 @@
- +
@@ -7787,34 +8011,31 @@ return serializeMermaidSvg(wrapper); } - function svgTextToPngBlob(svgText) { + function svgTextToPngBlob(svgText, scale = 1) { return new Promise((resolve, reject) => { - const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(svgBlob); + // 使用 Data URL,避免 SVG Blob 中的 HTML 标签触发 Canvas 安全限制。 + const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`; const image = new Image(); image.onload = () => { try { const canvas = document.createElement('canvas'); const width = Math.max(1, image.naturalWidth || image.width || 1200); const height = Math.max(1, image.naturalHeight || image.height || 800); - canvas.width = width; - canvas.height = height; + canvas.width = width * scale; + canvas.height = height * scale; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, width, height); - ctx.drawImage(image, 0, 0, width, height); + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { - URL.revokeObjectURL(url); if (blob) resolve(blob); else reject(new Error('PNG 导出失败')); }, 'image/png'); } catch (error) { - URL.revokeObjectURL(url); reject(error); } }; image.onerror = () => { - URL.revokeObjectURL(url); reject(new Error('SVG 图形读取失败')); }; image.src = url; @@ -7893,8 +8114,8 @@ const svgText = await getMermaidSvgText(wrapper); if (!svgText) throw new Error('暂无可下载图形'); const cid = wrapper.dataset.cid || Date.now(); - const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); - downloadBlob(blob, `mermaid-${cid}.svg`); + const blob = await svgTextToPngBlob(svgText, 2); + downloadBlob(blob, `mermaid-${cid}.png`); showToast('图形已下载'); setTemporaryButtonLabel(btn, '已下载', '下载图'); } catch (error) { @@ -8461,11 +8682,19 @@ } } if (msg.sessionId === currentSessionId && msg.message) { + if (reconcileCodexAppSteerSessionMessage(msg.sessionId, msg.message, msg.messageIndex)) { + renderSessionList(); + break; + } const isProvisionalCodexAppSteer = !!msg.message.codexAppSteerStatus; const existingElement = !isProvisionalCodexAppSteer ? findRenderedSessionMessageByKey(getStableMessageKey(msg.message)) : null; if (existingElement) { + if (Number.isInteger(msg.messageIndex) && msg.messageIndex >= 0) { + markSessionMessageElement(existingElement, msg.messageIndex); + currentSessionMessageCount = Math.max(currentSessionMessageCount, msg.messageIndex + 1); + } const fingerprint = getMessageRenderFingerprint(msg.message); if (existingElement.dataset.messageFingerprint !== fingerprint) { const messageIndex = Number(existingElement.dataset.messageIndex); @@ -8478,8 +8707,9 @@ renderSessionList(); break; } - const messageIndex = isProvisionalCodexAppSteer ? null : currentSessionMessageCount; - if (!isProvisionalCodexAppSteer) currentSessionMessageCount += 1; + const messageIndex = isProvisionalCodexAppSteer ? null + : Number.isInteger(msg.messageIndex) && msg.messageIndex >= 0 ? msg.messageIndex : currentSessionMessageCount; + if (!isProvisionalCodexAppSteer) currentSessionMessageCount = Math.max(currentSessionMessageCount, messageIndex + 1); collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id)); const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); @@ -8636,7 +8866,7 @@ case 'codex_app_steer_status': if (!isCurrentSessionEvent(msg)) break; - updateCodexAppSteerMessage(msg.clientMessageId, msg.status, msg.message); + updateCodexAppSteerMessage(msg.clientMessageId, msg.status, msg.message, msg.messageIndex); break; case 'codex_app_user_input_request': @@ -8861,6 +9091,9 @@ break; } if (pendingNewSessionRequest) pendingNewSessionRequest = null; + if (msg.clientMessageId) { + updateCodexAppSteerMessage(msg.clientMessageId, 'failed', msg.message || '发送失败'); + } applyPendingSlashDraftResponse(msg); appendError(msg.message, { transient: msg.transient, @@ -9114,6 +9347,9 @@ if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; + streamEl.__assistantContent = window.pendingContentBlocks?.length > 0 + ? window.pendingContentBlocks + : pendingText; const shouldFollow = isNearBottom(); if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) { @@ -9154,8 +9390,8 @@ setCodexAppSteerStatusElement(record.element, 'failed', record.message || '插入失败'); return false; } - if (!isGenerating || !currentSessionRunning || !isCodexAppAgent(currentAgent)) { - appendError('当前对话已结束,无法在原 turn 中重试插入。'); + if (!isCodexAppAgent(currentAgent)) { + appendError('当前会话不是 Codex App 会话,无法重试。'); setCodexAppSteerStatusElement(record.element, 'failed', record.message || '插入失败'); return false; } @@ -9164,9 +9400,9 @@ setCodexAppSteerStatusElement(record.element, 'failed', record.message || '插入失败'); return false; } - + const startsNewTurn = !isGenerating || !currentSessionRunning; record.inFlight = true; - setCodexAppSteerStatusElement(record.element, 'pending', '引导中...'); + setCodexAppSteerStatusElement(record.element, 'pending', startsNewTurn ? '重新发送中...' : '引导中...'); try { send({ type: 'message', @@ -9176,7 +9412,9 @@ mode: record.mode, agent: record.agent, clientMessageId: id, + codexAppSteer: true, }); + if (startsNewTurn && !isGenerating) startGenerating(); return true; } catch (err) { record.inFlight = false; @@ -9245,7 +9483,7 @@ retryButton.type = 'button'; retryButton.className = 'codex-steer-action codex-steer-retry'; retryButton.textContent = '重试'; - retryButton.setAttribute('aria-label', '重试插入此消息'); + retryButton.setAttribute('aria-label', '重新发送此消息'); retryButton.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); @@ -9271,9 +9509,12 @@ return true; } - function updateCodexAppSteerMessage(clientMessageId, status, message) { + function updateCodexAppSteerMessage(clientMessageId, status, message, messageIndex = null) { const id = String(clientMessageId || '').trim(); if (!id) return false; + const record = codexAppSteerRecords.get(id); + if (!record) return false; + if (status === 'sent') return completeCodexAppSteerMessage(record, null, messageIndex); const indexed = userMessageIndex.get(id); const element = indexed?.element || messagesDiv.querySelector(`[data-message-id="${cssEscape(id)}"]`); return setCodexAppSteerStatusElement(element, status, message); @@ -9419,6 +9660,7 @@ function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); + if (role === 'assistant') div.__assistantContent = content; const isCrossConversation = !!meta.crossConversation; const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId); const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply; @@ -12809,7 +13051,7 @@ } else { updateScrollbar(); } - send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, clientMessageId: messageId }); + send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, clientMessageId: messageId, codexAppSteer: true }); msgInput.value = ''; pendingAttachments = []; renderPendingAttachments(); diff --git a/public/style.css b/public/style.css index 43d3ce3..b6339fc 100644 --- a/public/style.css +++ b/public/style.css @@ -3496,6 +3496,7 @@ html[data-theme='wasteland'] .advanced-search-panel { } .msg-action-row { display: flex; + flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; @@ -3505,7 +3506,8 @@ html[data-theme='wasteland'] .advanced-search-panel { display: none; } .msg-last-section-btn, -.msg-branch-btn { +.msg-branch-btn, +.msg-copy-reply-btn { appearance: none; width: 28px; height: 28px; @@ -3523,16 +3525,19 @@ html[data-theme='wasteland'] .advanced-search-panel { transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease; } .msg-last-section-btn[hidden], -.msg-branch-btn[hidden] { +.msg-branch-btn[hidden], +.msg-copy-reply-btn[hidden] { display: none; } .msg-last-section-btn svg, -.msg-branch-btn svg { +.msg-branch-btn svg, +.msg-copy-reply-btn svg { display: block; flex-shrink: 0; } .msg-last-section-btn:hover, -.msg-branch-btn:hover { +.msg-branch-btn:hover, +.msg-copy-reply-btn:hover { opacity: 1; background: var(--bg-tertiary); border-color: var(--accent); @@ -3540,7 +3545,8 @@ html[data-theme='wasteland'] .advanced-search-panel { transform: translateY(-1px); } .msg-last-section-btn:focus-visible, -.msg-branch-btn:focus-visible { +.msg-branch-btn:focus-visible, +.msg-copy-reply-btn:focus-visible { opacity: 1; outline: 2px solid rgba(91, 126, 161, 0.28); outline-offset: 2px; @@ -3548,6 +3554,48 @@ html[data-theme='wasteland'] .advanced-search-panel { .msg-last-section-focus { animation: lastSectionFocus 1.1s ease; } +.msg.assistant.assistant-history-collapsed .assistant-earlier-reply { + display: none !important; +} +.msg.assistant.assistant-single-collapsed .assistant-reply-content { + max-height: 420px; + overflow: hidden; +} +.msg-replies-toggle { + appearance: none; + display: block; + max-width: 100%; + margin: 0 0 10px; + padding: 5px 9px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-secondary); + font: inherit; + font-size: 12px; + line-height: 1.5; + text-align: left; + cursor: pointer; +} +.msg-replies-toggle:hover { + color: var(--accent); + border-color: var(--accent); +} +.msg-replies-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +@media (max-width: 768px) { + .msg-last-section-btn, + .msg-branch-btn, + .msg-copy-reply-btn { + width: 44px; + height: 44px; + } + .msg-replies-toggle { + min-height: 44px; + } +} @keyframes lastSectionFocus { 0% { background: rgba(91, 126, 161, 0.18); } 100% { background: transparent; } diff --git a/scripts/assistant-bubble-controls-unit.js b/scripts/assistant-bubble-controls-unit.js new file mode 100644 index 0000000..6c94ddf --- /dev/null +++ b/scripts/assistant-bubble-controls-unit.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8'); + +function extractFunction(name) { + const start = source.indexOf(`function ${name}(`); + assert(start >= 0, `缺少前端函数 ${name}`); + let depth = 0, parentheses = 0, signatureEnd = start; + for (let i = source.indexOf('(', start); i < source.length; i += 1) { + if (source[i] === '(') parentheses += 1; + if (source[i] === ')' && --parentheses === 0) { signatureEnd = i; break; } + } + for (let i = source.indexOf('{', signatureEnd); i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1); + } + throw new Error(`无法提取 ${name}`); +} + +const names = ['getAssistantReplySections', 'getAssistantReplySource', 'getAssistantLastReplyText', 'shouldCollapseAssistantReplies']; +const api = new Function(` + const ASSISTANT_REPLY_COLLAPSE_CHARS = 6000; + const ASSISTANT_REPLY_COLLAPSE_BLOCKS = 24; + ${names.map(extractFunction).join('\n')} + return { ${names.join(',')} }; +`)(); +const DIVIDER = '
10:20:30
'; +const FIRST = '# 第一条回复\n\n' + '前文'.repeat(4000); +const LAST = '# 最后一条回复\n\n第一自然段。\n\n第二自然段。\n\n```js\nconst value = 1;\n```'; +let passed = 0; +function test(description, fn) { fn(); passed += 1; console.log('通过:' + description); } + +test('末段按原生回复分界,保留 Markdown、多自然段与代码', () => { + const content = FIRST + '\n\n' + DIVIDER + '\n\n' + LAST; + const message = { __assistantContent: content }; + assert.deepEqual(api.getAssistantReplySections(content), [FIRST, LAST]); + assert.equal(api.getAssistantLastReplyText(message), LAST); + assert(!api.getAssistantLastReplyText(message).includes('10:20:30')); + assert(!api.getAssistantLastReplyText(message).includes('前文')); +}); + +test('无原生分界的单条回复复制全文,长正文也不会按最后 p 截断', () => { + assert.equal(api.getAssistantLastReplyText({ __assistantContent: LAST }), LAST); + assert.equal(api.shouldCollapseAssistantReplies([FIRST], 30), true); +}); + +test('复制保留 Markdown 首行缩进和行末空格,不改变代码及换行语义', () => { + const indented = ' const value = 1;\n return value; '; + assert.equal(api.getAssistantLastReplyText({ __assistantContent: indented }), indented); + assert.equal(api.getAssistantLastReplyText({ __assistantContent: FIRST + '\n\n' + DIVIDER + '\n\n' + indented + '\n\n' }), indented); +}); + +test('代码围栏内展示分界 HTML 不应误判成原生回复边界', () => { + const code = '```html\n' + DIVIDER + '\n```\n\n说明仍属于同一回复。'; + assert.deepEqual(api.getAssistantReplySections(code), [code]); + assert.deepEqual(api.getAssistantReplySections(code + '\n' + DIVIDER + '\n' + LAST), [code, LAST]); + const tilde = '~~~~html\n' + DIVIDER + '\n~~~~\n段落'; + assert.deepEqual(api.getAssistantReplySections(tilde), [tilde]); +}); + +test('分界 HTML 尚未传完时复制上一条正文,不夹带时间或半截标签', () => { + for (const partial of ['
10:20:30']) { + assert.equal(api.getAssistantLastReplyText({ __assistantContent: LAST + '\n\n' + partial }), LAST); + } +}); + +test('历史、流式及文本块采用完整原始内容,复制不掺工具或按钮', () => { + assert.equal(api.getAssistantLastReplyText({ __sessionMessage: { content: FIRST + '\n' + DIVIDER + '\n' + LAST } }), LAST); + assert.equal(api.getAssistantLastReplyText({ __assistantContent: [ + { type: 'text', text: FIRST + '\n' + DIVIDER + '\n' + LAST }, + { type: 'tool_result', text: '不该复制的工具结果' }, + { type: 'image', alt: '不该复制的图片元数据' }, + ] }), LAST); + const stream = { __assistantContent: FIRST + '\n' + DIVIDER + '\n' + LAST }; + stream.__assistantRepliesExpanded = true; + assert.equal(api.getAssistantLastReplyText(stream), LAST); + stream.__assistantContent += '\n\n最新流式追加'; + assert.equal(api.getAssistantLastReplyText(stream), LAST + '\n\n最新流式追加'); +}); + +test('短消息无折叠入口;达到长度或块数后仅折较早原生回复', () => { + assert.equal(api.shouldCollapseAssistantReplies(['短前文', '短末段'], 2), false); + assert.equal(api.shouldCollapseAssistantReplies([FIRST, LAST], 2), true); + assert.equal(api.shouldCollapseAssistantReplies(['短前文', '短末段'], 24), true); + assert.equal(api.shouldCollapseAssistantReplies([], 50), false); +}); + +test('定位先展开裁剪的单条长回复,再读取末段坐标;多回复收起状态保持', () => { + const locate = new Function('initiallyClipped', ` + let clipped = initiallyClipped; + const events = []; + const message = { classList: { contains: (name) => name === 'assistant-single-collapsed' && clipped } }; + const bubble = { closest: () => message, querySelector: () => ({ click() { clipped = false; events.push('展开'); } }) }; + const scope = { classList: { remove() {} }, getBoundingClientRect: () => ({ top: 100 }) }; + const getAssistantLastSectionTarget = () => { events.push(clipped ? '读取隐藏末段' : '读取可见末段'); return { node: {}, offset: 0, scope }; }; + const getRangeRectFromTextPosition = () => ({ top: 100 }); + const messagesDiv = { scrollTop: 0, getBoundingClientRect: () => ({ top: 0 }), scrollTo: (value) => events.push(value.top) }; + const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 12; + const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'focus'; + const requestAnimationFrame = () => {}; + const updateScrollbar = () => {}; + ${extractFunction('scrollAssistantBubbleToLastSection')} + return { result: scrollAssistantBubbleToLastSection(bubble), clipped, events }; + `); + assert.deepEqual(locate(true), { result: true, clipped: false, events: ['展开', '读取可见末段', 88] }); + assert.deepEqual(locate(false), { result: true, clipped: false, events: ['读取可见末段', 88] }); +}); + +console.log(JSON.stringify({ passed, source: 'public/app.js 原函数', collapseChars: 6000, collapseBlocks: 24 })); diff --git a/scripts/codexapp-retry-history-unit.js b/scripts/codexapp-retry-history-unit.js new file mode 100644 index 0000000..39672c1 --- /dev/null +++ b/scripts/codexapp-retry-history-unit.js @@ -0,0 +1,762 @@ +#!/usr/bin/env node +'use strict'; + +// 抽取服务端真实重试、开轮、完成与重连函数,只把模型边界和时钟替换为隔离夹具。 +// 会话及完整运行态均使用真实存储实现,所有读写限制在临时目录。 +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { inspect } = require('node:util'); +const vm = require('node:vm'); +const { createServerHarness } = require('./session-history-store-unit'); +const { createCodexAppServerClient } = require('../lib/codex-app-server-client'); +const { LIFECYCLE_EVENT_TYPES: TASK_BOARD_LIFECYCLE_EVENTS } = require('../lib/task-board-lifecycle'); +const { CLASSIFICATION_EVENT_TYPES: TASK_STATUS_CLASSIFICATION_EVENTS } = require('../lib/task-board-classifier'); + +const REPO_DIR = path.join(__dirname, '..'); +const baselineIndex = process.argv.indexOf('--baseline'); +const sourcePath = baselineIndex >= 0 ? process.argv[baselineIndex + 1] : path.join(REPO_DIR, 'server.js'); +assert(sourcePath, '--baseline 需要提供修复前工作区 server.js 快照路径'); +const serverSource = fs.readFileSync(sourcePath, 'utf8'); +const runtimePath = baselineIndex >= 0 && fs.existsSync(path.join(path.dirname(sourcePath), 'ccweb-streaming-retry-baseline-runtime.js')) + ? path.join(path.dirname(sourcePath), 'ccweb-streaming-retry-baseline-runtime.js') + : path.join(REPO_DIR, 'lib/codex-app-runtime.js'); +const { createCodexAppRuntime } = require(runtimePath); + +function extractFunction(name, optional = false) { + const match = new RegExp(`(?:^|\\n)(?:async )?function ${name}\\(`).exec(serverSource); + if (!match && optional) return ''; + assert(match, `缺少生产函数 ${name}`); + const start = match.index + (serverSource[match.index] === '\n' ? 1 : 0); + const closingLine = /\n\}/g; + closingLine.lastIndex = start; + let end; + while ((end = closingLine.exec(serverSource))) { + const candidate = serverSource.slice(start, end.index + 2); + try { + new vm.Script(`(${candidate})`, { filename: sourcePath }); + return candidate; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + } + throw new Error(`${name} 函数体不完整`); +} + +function extractConst(name) { + const match = serverSource.match(new RegExp(`^const ${name} = .*;$`, 'm')); + assert(match, `缺少生产常量 ${name}`); + return match[0]; +} + +function createApi(dir, options = {}) { + const storeApi = createServerHarness(dir); + const sent = []; + const logs = []; + const starts = []; + const runtimeRef = { current: null }; + const timers = new Map(); + const maps = Object.fromEntries([ + 'activeProcesses', 'activeCodexAppTurns', 'activeCodexAppGoalCommands', 'activeCodexAppCompactions', + 'codexAppGoalStates', 'pendingCompactRetries', 'pendingCodexCapacityRetries', 'retainedCodexAppTurns', + 'retiredCodexAppRuntimeTurns', 'wsSessionMap', 'ccwebMcpChildThreads', + ].map(name => [name, new Map()])); + const realSaveTurn = storeApi.sessionHistoryStore.saveTurn.bind(storeApi.sessionHistoryStore); + storeApi.sessionHistoryStore.saveTurn = (...args) => { + if (options.failTurnSave) { const error = new Error('夹具:完整运行态保存失败'); error.code = 'ENOSPC'; throw error; } + return realSaveTurn(...args); + }; + const requiredNames = [ + 'readPositiveIntEnv', 'sanitizeId', 'runDir', 'writeFileAtomicSync', 'cleanRunDir', + 'textByteLength', 'truncateTextValue', 'sanitizePersistValue', 'sanitizeToolCallsForPersist', + 'mapToPairs', 'codexAppStatePath', 'codexAppTurnKey', 'serializeCodexAppEntry', + 'buildCodexAppStateJson', 'writeCodexAppTurnState', 'persistCodexAppTurnState', + 'cleanupCodexAppTurnState', 'loadCodexAppTurnState', 'hasCodexAppTurnMessage', + 'appendCodexAppRecoveryNotice', 'recoverCodexAppTurnState', + 'hasRuntimeOutput', 'retryTailText', 'retryPreviewValue', 'buildCodexRetryToolSummary', + 'shouldUseCodexAppContinuationRetry', 'buildCodexAppContinuationRetryText', + 'isCodexTransientCapacityError', 'isCodexTransientConnectionError', 'isCodexTransientRetryableError', + 'codexTransientRetryDelayMs', 'cancelCodexCapacityRetry', 'shouldRetryCodexTransientFailure', + 'scheduleCodexCapacityRetry', 'stableMessageHash', 'messageReplyToRequestId', 'ensureStableMessageId', + 'handleMessage', 'handleCodexAppMessage', 'handleCodexAppTurnComplete', 'handleCodexAppTurnFailure', + 'attachActiveRuntimeToWs', 'handleResumeSession', 'isSessionRunning', + 'bindAbortSessionToWs', 'handleCodexAppAbortSession', 'handleAbort', + 'codexAppRuntimeThreadId', 'codexAppRuntimeTurnId', 'findCodexAppEntryByRuntime', + 'isCodexAppAdoptableRuntimeMethod', 'adoptCodexAppUnroutedTurn', 'findCodexAppRouteByRuntime', + 'handleCodexAppNotification', 'handleCodexAppServerExit', + ]; + const constNames = [ + 'CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 'CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', 'CODEX_APP_STATE_MAX_MAP_ENTRIES', + 'CODEX_APP_STATE_MAX_BYTES', 'CODEX_APP_STATE_LOAD_MAX_BYTES', 'CODEX_APP_STATE_FILE', + 'CODEX_APP_STATE_FLUSH_DELAY_MS', 'SESSION_MESSAGE_CONTENT_MAX_CHARS', + 'SESSION_TOOL_INPUT_MAX_CHARS', 'SESSION_TOOL_RESULT_MAX_CHARS', 'SESSION_MAX_TOOL_CALLS_PER_MESSAGE', + 'PERSIST_TRUNCATED_TEXT', + ]; + const optionalNames = [ + 'ensureCodexAppAssistantMessageId', 'buildCodexAppResumePayload', 'retirePendingCodexCapacityRetry', + 'retireCodexAppRuntimeTurn', 'codexAppAssistantMessageIdForEntry', + ]; + const api = new Function('deps', ` + const { fs, path, crypto, dir, storeApi, sent, logs, starts, timers, maps, options, runtimeRef, + TASK_BOARD_LIFECYCLE_EVENTS, TASK_STATUS_CLASSIFICATION_EVENTS } = deps; + const SESSIONS_DIR = dir; + const INITIAL_HISTORY_COUNT = 12; + const DEFAULT_CODEX_CONFIG = { retry: { intervalSeconds: 1 } }; + const { activeProcesses, activeCodexAppTurns, activeCodexAppGoalCommands, activeCodexAppCompactions, + codexAppGoalStates, pendingCompactRetries, pendingCodexCapacityRetries, retainedCodexAppTurns, + retiredCodexAppRuntimeTurns, wsSessionMap, ccwebMcpChildThreads } = maps; + const sessionHistoryStore = storeApi.sessionHistoryStore; + const loadSession = id => options.failSessionLoad ? null : storeApi.loadSession(id); + const saveSession = session => options.failSessionSave ? false : storeApi.saveSession(session); + const wsSend = (ws, payload) => sent.push({ ...payload, _ws: ws }); + const plog = (level, event, data) => logs.push({ level, event, ...data }); + const setTimeout = (callback, delay) => { + const timer = { callback, delay, unref() {} }; timers.set(timer, timer); return timer; + }; + const clearTimeout = timer => timers.delete(timer); + const getCodexRetryConfig = () => ({ mode: 'limited', maxAttempts: 3, intervalSeconds: 1, ...options.retryConfig }); + const crossConversationReplyRequestIds = () => []; + const resolveMessageAttachments = () => []; + const resolveComposerDecorators = text => ({ runtimeText: text, mentions: [] }); + const attachClientRequestId = (payload, source) => ({ ...payload, requestId: source?.requestId }); + const isCodexAppGoalActive = () => false; + const isCodexAppSession = session => session?.agent === 'codexapp'; + const getSessionAgent = session => session?.agent || 'claude'; + const getRuntimeSessionId = session => session?.codexAppThreadId || null; + const getDefaultSessionCwd = () => dir; + const normalizeAgent = agent => agent || 'codexapp'; + const getDefaultCodexModel = () => 'fixture-model'; + const normalizeSession = session => session; + const normalizeGiteaSource = value => value || null; + const reconcilePendingCrossConversationReplies = () => false; + const flushPendingCrossConversationReplies = () => false; + const detachWsFromActiveRuntimes = () => {}; + const sendSessionList = () => {}; + const broadcastSessionList = () => {}; + const dispatchTaskBoardLifecycle = () => Promise.resolve(null); + const enqueueTaskStatusClassification = () => Promise.resolve(null); + const resolvePendingCodexAppUserInputsForSession = () => {}; + const resolvePendingCodexAppApprovalsForSession = () => {}; + const resolvePendingCodexAppElicitationsForSession = () => {}; + const formatRuntimeError = (agent, raw) => String(raw || ''); + const completeCrossConversationReply = () => false; + const recoverCcwebMcpChildThreadsFromPersistedToolCalls = () => {}; + const setRuntimeSessionId = (session, threadId) => { session.codexAppThreadId = threadId; }; + const resolveSessionHistory = storeApi.resolveSessionHistory; + const sanitizeMessageForTransport = value => value; + const sanitizeMessagesForTransport = value => value; + const wss = { clients: [] }; + const buildNotifyContent = async () => ({ title: '', content: '' }); + const sendNotification = () => {}; + const giteaWorkflowWaiters = new Map(); + const settleGiteaWorkflowTurn = () => Promise.resolve(null); + let codexAppClient = options.runtimeClient || null; + let codexAppClientSignature = 'fixture-signature'; + const pauseCodexAppGoalForAbort = () => false; + const cancelCodexAppGoalCommand = () => false; + const cancelCodexAppCompaction = () => false; + const codexAppInputFromMessage = text => text ? [{ type: 'text', text }] : []; + const normalizeCodexAppThreadId = value => String(value || '').trim(); + const getCodexAppClient = () => ({ client: options.runtimeClient }); + const codexAppThreadParams = () => ({}); + const ensureCodexAppMcpReadyForThread = async () => {}; + const codexAppTurnParams = (session, input, threadId, clientUserMessageId) => ({ input, threadId, clientUserMessageId }); + const codexAppRuntime = { processCodexAppNotification: (...args) => runtimeRef.current.processCodexAppNotification(...args) }; + const handleCodexAppGoalNotification = () => false; + const handleCodexAppMcpStartupStatusNotification = () => false; + const shouldLogCodexAppUnroutedNotification = () => false; + const syncCcwebMcpChildAgentsFromCollabItem = () => {}; + const processCcwebMcpChildNotification = () => ({ changed: false }); + const sendCcwebMcpChildAgentUpdate = () => {}; + const findViewingSessionWs = id => [...wsSessionMap].find(([, sessionId]) => sessionId === id)?.[0] || null; + const findCodexAppSessionByThreadId = threadId => { + for (const id of new Set(wsSessionMap.values())) { + const session = loadSession(id); + if (session?.codexAppThreadId === threadId) return { sessionId: id, session }; + } + return null; + }; + // 仅替换真正的模型边界;调度器、hideInHistory 与新 entry 的交接全部执行生产函数。 + let startCodexAppTurn = async (sessionId, input) => { + const entry = activeCodexAppTurns.get(sessionId); + starts.push({ sessionId, input }); + if (options.failStart) throw new Error('夹具:线程启动失败'); + if (options.startTurn) { await options.startTurn(sessionId, input, entry); return; } + entry.turnId = 'fixture-turn-' + starts.length; + persistCodexAppTurnState(sessionId, entry, { immediate: true }); + }; + ${requiredNames.map(name => extractFunction(name)).join('\n')} + ${optionalNames.map(name => extractFunction(name, true)).join('\n')} + ${constNames.map(name => extractConst(name)).join('\n')} + if (options.realStart) startCodexAppTurn = (${extractFunction('startCodexAppTurn')}); + return { + handleMessage, handleCodexAppMessage, handleCodexAppTurnComplete, handleCodexAppTurnFailure, + scheduleCodexCapacityRetry, cancelCodexCapacityRetry, attachActiveRuntimeToWs, handleResumeSession, + handleAbort, writeCodexAppTurnState, loadCodexAppTurnState, persistCodexAppTurnState, + recoverCodexAppTurnState, codexAppTurnKey, isSessionRunning, findCodexAppEntryByRuntime, + handleCodexAppNotification, handleCodexAppServerExit, + }; + `)({ fs, path, crypto, dir, storeApi, sent, logs, starts, timers, maps, options, runtimeRef, + TASK_BOARD_LIFECYCLE_EVENTS, TASK_STATUS_CLASSIFICATION_EVENTS }); + const runtime = createCodexAppRuntime({ wsSend: (ws, payload) => sent.push({ ...payload, _ws: ws }), + loadSession: storeApi.loadSession, saveSession: storeApi.saveSession }); + runtimeRef.current = runtime; + async function fireRetry(sessionId) { + const retry = maps.pendingCodexCapacityRetries.get(sessionId); + assert(retry?.timer, '应已安排自动重试计时器'); + timers.delete(retry.timer); + retry.timer.callback(); + await Promise.resolve(); + await Promise.resolve(); + } + return { ...api, ...maps, runtime, storeApi, sent, logs, starts, timers, fireRetry, options }; +} + +const ERROR = 'Our servers are currently overloaded. Please try again later.'; +const A = 'A正文首段唯一\n' + '甲'.repeat(220000) + '\nA正文尾段唯一'; +const B = 'B正文唯一\n' + '乙'.repeat(15000) + '\nB正文结束唯一'; +const C = 'C正文唯一\n' + '丙'.repeat(15000) + '\nC正文结束唯一'; + +function seed(api, id = 'retry-history', text = '继续长任务') { + const session = { id, title: '重试历史夹具', agent: 'codexapp', codexAppThreadId: 'fixture-thread', + model: 'fixture-model', permissionMode: 'yolo', cwd: '/fixture', messages: [] }; + assert.equal(api.storeApi.saveSession(session), true); + const ws = { readyState: 1, label: 'initial' }; + assert.equal(api.handleMessage(ws, { type: 'message', sessionId: id, agent: 'codexapp', + clientMessageId: 'original-user', text, mode: 'yolo' }).ok, true); + return { id, ws }; +} + +function emit(api, id, text, label, reusedId = false) { + const entry = api.activeCodexAppTurns.get(id); + assert(entry, '应有活动 entry'); + const base = { threadId: entry.threadId, turnId: entry.turnId }; + const agentId = reusedId ? 'agent-fixed' : 'agent-' + label; + const toolId = reusedId ? 'tool-fixed' : 'tool-' + label; + for (let offset = 0; offset < text.length; offset += 8192) { + api.runtime.processCodexAppNotification(entry, { method: 'item/agentMessage/delta', + params: { ...base, itemId: agentId, delta: text.slice(offset, offset + 8192) } }, id); + } + api.runtime.processCodexAppNotification(entry, { method: 'item/completed', + params: { ...base, item: { id: agentId, type: 'agentMessage', text } } }, id); + api.runtime.processCodexAppNotification(entry, { method: 'item/started', + params: { ...base, item: { id: toolId, type: 'commandExecution', + command: 'fixture-' + label + '-' + '输入'.repeat(10000), status: 'inProgress' } } }, id); + api.runtime.processCodexAppNotification(entry, { method: 'item/commandExecution/outputDelta', + params: { ...base, itemId: toolId, delta: '工具' + label + '结果' + '输出'.repeat(20000) } }, id); + api.writeCodexAppTurnState(id, entry); + return entry; +} + +function assertToolOrder(tools, labels) { + assert.equal(tools.length, labels.length, '工具调用数量不能缩水或重复'); + labels.forEach((label, index) => assert(tools[index].result.includes('工具' + label + '结果'), '工具结果必须按开轮顺序保留')); + assert.equal(new Set(tools.map(tool => tool.id)).size, labels.length, '跨轮同原生 ID 必须独立保存'); +} + +function assertOnceInOrder(text, parts) { + let end = -1; + for (const part of parts) { + const at = text.indexOf(part); + assert(at > end, '正文应完整保留并按 A → B → C 排序'); + assert.equal(text.indexOf(part, at + part.length), -1, '同一段正文不能重复'); + end = at + part.length - 1; + } +} + +let passed = 0; +const failures = []; +async function check(name, callback) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-retry-history-')); + try { + await callback(dir); + passed += 1; + console.log(`通过:${name}`); + } catch (error) { + failures.push(name); + console.error(`失败:${name}:${inspect({ name: error.name, message: error.message.slice(0, 600), + stack: error.stack?.split('\n').filter(line => /^\s+at /.test(line)).slice(0, 3), + actual: error.actual, expected: error.expected, + }, { depth: 1, maxArrayLength: 3, maxStringLength: 160 })}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +async function run() { + if (baselineIndex < 0 && !process.argv.includes('--only-handoff')) { + for (const startOutcome of ['response', 'timeout', 'exit']) { + const label = startOutcome === 'response' + ? '取消启动时原生通知先于 RPC 返回,旧 A 不得进入或结束新 B' + : startOutcome === 'timeout' + ? '取消启动 RPC 超时后保留门禁,晚到 ID 仅中断 A 且 B 正常完成' + : '取消启动未知 ID 时 app-server 明确退出,提交 A 前文并释放 B 门禁'; + await check(label, async dir => { + const deferred = () => { + let resolve, reject; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; + }; + const aResponse = deferred(); + const bResponse = deferred(); + const nativeStarts = []; + const nativeInterrupts = []; + let clientRunning = true; + const runtimeClient = { + start: async () => { clientRunning = true; }, + isRunning: () => clientRunning, + request: async (method, params) => { + if (method === 'thread/resume') return { thread: { id: params.threadId } }; + if (method === 'turn/interrupt') { nativeInterrupts.push(params); return {}; } + assert.equal(method, 'turn/start', '夹具只替换线程 RPC 边界'); + nativeStarts.push(params); + return nativeStarts.length === 1 ? aResponse.promise : bResponse.promise; + }, + }; + const api = createApi(dir, { realStart: true, runtimeClient }); + const pump = async () => { for (let count = 0; count < 20; count += 1) await Promise.resolve(); }; + // 路由、增量累积、完整运行态提交和完成函数均来自生产代码。 + const receive = notification => { + const routed = api.findCodexAppEntryByRuntime(notification.params); + api.handleCodexAppNotification(notification); + return !!routed; + }; + const { id, ws } = seed(api, 'cancel-start-race', '启动任务 A'); + await pump(); + assert.equal(nativeStarts.length, 1, 'A 的真实 turn/start RPC 应已发出但尚未返回'); + assert.equal(api.activeCodexAppTurns.get(id).turnId, null); + if (startOutcome === 'exit') { + // 延续任务在新原生 ID 确认前已经拥有上轮正文,退出也不能丢弃它。 + const carriedEntry = api.activeCodexAppTurns.get(id); + carriedEntry.fullText = '已经产生的 A 完整前文'; + api.persistCodexAppTurnState(id, carriedEntry, { immediate: true }); + } + api.handleAbort(ws, { sessionId: id }); + const nextMessage = { sessionId: id, agent: 'codexapp', clientMessageId: 'race-b-user', text: '新任务 B' }; + const beforeIdentity = api.handleMessage(ws, nextMessage); + await pump(); + assert.equal(beforeIdentity.ok, false, '旧 A 身份未知时必须阻止同线程 B 开始'); + assert.equal(nativeStarts.length, 1, '取消启动的 A 获取精确身份前不能发第二个 turn/start'); + if (startOutcome !== 'response') { + aResponse.reject(new Error('夹具:turn/start RPC request timed out')); + await pump(); + assert.equal(api.activeCodexAppTurns.get(id)?.turnId, null, 'RPC 超时不代表旧 A 没有启动,未知 ID 门禁应保留'); + assert.equal(api.handleMessage(ws, nextMessage).ok, false, 'RPC 超时后 B 仍不能绕过未知身份门禁'); + } + if (startOutcome === 'exit') { + clientRunning = false; + api.handleCodexAppServerExit('fixture-signature', { code: 1, stderr: '夹具:app-server 已明确退出' }); + assert.equal(api.activeCodexAppTurns.has(id), false, 'app-server 明确退出后取消 A 不应永久占用门禁'); + assert.equal(createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant')?.content, + '已经产生的 A 完整前文', '释放门禁前须提交 A 既有正文'); + assert.equal(nativeInterrupts.length, 0, '已退出的进程无需请求未知 turn interrupt'); + } else { + receive({ method: 'turn/started', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'inProgress' } } }); + receive({ method: 'item/agentMessage/delta', params: { threadId: 'fixture-thread', turnId: 'native-A', itemId: 'agent-A', delta: '旧 A 的迟到正文' } }); + if (startOutcome === 'response') aResponse.resolve({ turn: { id: 'native-A' } }); + await pump(); + assert(nativeInterrupts.some(params => params.turnId === 'native-A'), '旧 A 身份确认后必须按精确回合身份中断'); + } + assert.equal(api.handleMessage(ws, nextMessage).ok, true, '旧 A 身份处理后 B 可以正常开始'); + await pump(); + assert.equal(nativeStarts.length, 2); + const bEntry = api.activeCodexAppTurns.get(id); + receive({ method: 'turn/started', params: { threadId: 'fixture-thread', turn: { id: 'native-B', status: 'inProgress' } } }); + const bText = 'B 第一段完整正文\n' + '乙'.repeat(9000) + '\nB 最后一段完整正文'; + receive({ method: 'item/agentMessage/delta', params: { threadId: 'fixture-thread', turnId: 'native-B', itemId: 'agent-B', delta: bText } }); + if (startOutcome !== 'exit') { + assert.equal(receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'interrupted' } } }), false); + } + assert.strictEqual(api.activeCodexAppTurns.get(id), bEntry, '旧 A 的迟到完成不能结束 B'); + assert.equal(bEntry.fullText, bText, 'B 正文不能混入旧 A 通知'); + bResponse.resolve({ turn: { id: 'native-B' } }); + await pump(); + receive({ method: 'item/completed', params: { threadId: 'fixture-thread', turnId: 'native-B', item: { id: 'agent-B', type: 'agentMessage', text: bText } } }); + receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-B', status: 'completed' } } }); + const session = createServerHarness(dir).loadSession(id); + const replies = session.messages.filter(message => message.role === 'assistant' && message.content.includes('B 第一段完整正文')); + assert.equal(replies.length, 1, 'B 完成后只能归档一次'); + assert.equal(replies[0].content, bText, 'B 完成与存储重开后前后文完整'); + assert.equal(session.messages.filter(message => message.role === 'user' && message.id === 'race-b-user').length, 1); + assert.equal(api.activeCodexAppTurns.has(id), false); + if (startOutcome !== 'exit') { + assert.equal(receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'interrupted' } } }), false); + } + }); + } + if (process.argv.includes('--only-start-race')) return; + } + + await check('真实自动重试开轮必须继承超过提示和快照上限的 A 正文及工具映射', async dir => { + const api = createApi(dir); + const { id } = seed(api); + const original = emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + await api.fireRetry(id); + const resumed = api.activeCodexAppTurns.get(id); + assert(resumed?.fullText.includes(A), '真实 schedule → handleMessage → handleCodexAppMessage 丢失了重试前 A 正文'); + assert.deepEqual(resumed.toolCalls, original.toolCalls, '工具调用不得丢失或只剩预览'); + assert.deepEqual(Array.from(resumed.toolOutputDeltas), Array.from(original.toolOutputDeltas)); + assert.deepEqual(Array.from(resumed.agentMessageItems), Array.from(original.agentMessageItems)); + assert.equal(resumed.codexRetry.attempt, 1); + assert.equal(api.storeApi.loadSession(id).messages.filter(message => message.role === 'user').length, 1); + }); + if (process.argv.includes('--only-handoff')) return; + + await check('A → B → C 两次重试后最终助手归档重开仍各保留一次且用户不重复', async dir => { + const api = createApi(dir); + const { id } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + await api.fireRetry(id); + emit(api, id, B, 'B'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + await api.fireRetry(id); + const entry = emit(api, id, C, 'C'); + assert.equal(entry.codexRetry.attempt, 2, '重试次数必须单调增长'); + api.handleCodexAppTurnComplete(id); + const reloaded = createServerHarness(dir).loadSession(id); + const assistants = reloaded.messages.filter(message => message.role === 'assistant'); + assert.equal(assistants.length, 1, '一个逻辑任务只保存一个累积助手气泡'); + assertOnceInOrder(assistants[0].content, [A, B, C]); + assertToolOrder(assistants[0].toolCalls, ['A', 'B', 'C']); + for (const tool of assistants[0].toolCalls) assert(tool.result.length > 32768, '最终工具结果必须完整'); + assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 1); + assert.equal(api.storeApi.sessionHistoryStore.loadTurn(id), null, '完成提交后才清理完整运行态'); + }); + + await check('等待重试期间及续跑启动后重连均提供全部已流出的正文与工具', async dir => { + const api = createApi(dir); + const { id } = seed(api); + const first = emit(api, id, A, 'A'); + api.attachActiveRuntimeToWs({ readyState: 1, label: 'before-error' }, id); + const originalId = api.sent.findLast(message => message.type === 'resume_generating')?.assistantMessageId; + assert(originalId, '累积气泡必须有稳定身份'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + assert.equal(api.isSessionRunning(id), true, '等待重试仍属于运行中的逻辑任务'); + api.handleResumeSession({ readyState: 1, label: 'wait-reconnect' }, { sessionId: id, requestId: 'waiting' }); + let resume = api.sent.findLast(message => message.type === 'resume_generating'); + assert.equal(resume?.text, first.fullText, '等待期刷新/重连不能清空或截断正文'); + assert.deepEqual(resume.toolCalls, first.toolCalls); + assert.equal(resume.assistantMessageId, originalId, '等待期必须复用同一累积气泡身份'); + await api.fireRetry(id); + emit(api, id, B, 'B'); + assert.equal(api.attachActiveRuntimeToWs({ readyState: 1, label: 'running-reconnect' }, id), true); + resume = api.sent.findLast(message => message.type === 'resume_generating'); + assertOnceInOrder(resume.text, [A, B]); + assertToolOrder(resume.toolCalls, ['A', 'B']); + assert.equal(resume.assistantMessageId, originalId, '新重试 turn 必须复用累积气泡身份'); + api.handleCodexAppTurnComplete(id); + const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant'); + assert.equal(assistant.id, originalId, '归档及重连必须指向同一个助手气泡'); + }); + + await check('等待期完整运行态存储重开保留 A 正文及两个映射', async dir => { + const api = createApi(dir); + const { id } = seed(api); + const entry = emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id); + assert.equal(state?.fullText, A, '安排重试前不能删除唯一完整运行态'); + assert.deepEqual(state.toolCalls, entry.toolCalls); + assert.deepEqual(state.toolOutputDeltas, Array.from(entry.toolOutputDeltas)); + assert.deepEqual(state.agentMessageItems, Array.from(entry.agentMessageItems)); + }); + + await check('重试耗尽保存所有已产生正文,且不会再安排新轮', async dir => { + const api = createApi(dir, { retryConfig: { maxAttempts: 1 } }); + const { id } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + await api.fireRetry(id); + emit(api, id, B, 'B'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + assert.equal(api.pendingCodexCapacityRetries.has(id), false); + assert.equal(api.activeCodexAppTurns.has(id), false); + const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant'); + assertOnceInOrder(assistant?.content || '', [A, B]); + }); + + await check('用户取消等待中的重试仍提交已产生正文', async dir => { + const api = createApi(dir); + const { id, ws } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + api.handleAbort(ws, { sessionId: id }); + assert.equal(api.pendingCodexCapacityRetries.has(id), false); + const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant'); + assert.equal(assistant?.content, A, '取消等待不得删除尚未归档正文'); + }); + + await check('重试前完整运行态保存失败时保留内存与上次落盘正文', async dir => { + const options = {}; + const api = createApi(dir, options); + const { id } = seed(api); + const entry = emit(api, id, A, 'A'); + entry.fullText += '\n最新未落盘正文'; + options.failTurnSave = true; + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id); + assert.equal(state?.fullText, A, '保存失败不能清理此前完整运行态'); + const retained = api.activeCodexAppTurns.get(id) || api.pendingCodexCapacityRetries.get(id)?.entry || api.retainedCodexAppTurns.get(id); + assert(retained?.fullText.includes('最新未落盘正文'), '保存失败不能丢弃唯一内存正文'); + }); + + await check('续跑启动失败仍归档此前正文', async dir => { + const options = {}; + const api = createApi(dir, options); + const { id } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + options.failStart = true; + await api.fireRetry(id); + const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant'); + assert.equal(assistant?.content, A); + }); + + await check('最终会话保存失败仍留下可重开的 A/B/C 完整运行态', async dir => { + const options = {}; + const api = createApi(dir, options); + const { id } = seed(api); + for (const [text, label] of [[A, 'A'], [B, 'B']]) { + emit(api, id, text, label); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + await api.fireRetry(id); + } + emit(api, id, C, 'C'); + options.failSessionSave = true; + api.handleCodexAppTurnComplete(id); + const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id); + assertOnceInOrder(state?.fullText || '', [A, B, C]); + assertToolOrder(state.toolCalls, ['A', 'B', 'C']); + assert.equal(api.sent.findLast(message => message.type === 'done')?.historySaved, false); + }); + + await check('普通新用户轮次从空正文开始而不继承上一气泡', async dir => { + const api = createApi(dir); + const { id, ws } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnComplete(id); + assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '新任务' }).ok, true); + const next = api.activeCodexAppTurns.get(id); + assert.equal(next.fullText, ''); + assert.deepEqual(next.toolCalls, []); + assert.equal(next.toolOutputDeltas.size, 0); + assert.equal(next.agentMessageItems.size, 0); + }); + + await check('跨轮复用原生 item ID 不会覆盖工具或误拼已完成助手消息', async dir => { + const api = createApi(dir); + const { id } = seed(api); + for (const [text, label] of [[A, 'A'], [B, 'B']]) { + emit(api, id, text, label, true); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + await api.fireRetry(id); + } + const entry = emit(api, id, C, 'C', true); + assertOnceInOrder(entry.fullText, [A, B, C]); + assertToolOrder(entry.toolCalls, ['A', 'B', 'C']); + assert.equal(entry.agentMessageItems.size, 3); + assert.equal(entry.toolOutputDeltas.size, 3); + api.handleCodexAppTurnComplete(id); + const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant'); + assertOnceInOrder(assistant.content, [A, B, C]); + assertToolOrder(assistant.toolCalls, ['A', 'B', 'C']); + }); + + await check('未完成消息的空白前缀及完整映射跨重试落盘且交接不丢', async dir => { + const api = createApi(dir); + const { id } = seed(api); + const oldEntry = emit(api, id, A, 'A'); + api.runtime.processCodexAppNotification(oldEntry, { method: 'item/agentMessage/delta', + params: { threadId: oldEntry.threadId, turnId: oldEntry.turnId, itemId: 'pending-prefix', delta: '\n ' } }, id); + assert.equal(oldEntry.agentMessagePendingPrefixes.size, 1); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id); + assert.deepEqual(state?.agentMessagePendingPrefixes, Array.from(oldEntry.agentMessagePendingPrefixes)); + await api.fireRetry(id); + const resumed = api.activeCodexAppTurns.get(id); + assert.deepEqual(Array.from(resumed.agentMessagePendingPrefixes), Array.from(oldEntry.agentMessagePendingPrefixes)); + assert.deepEqual(Array.from(resumed.agentMessageItems), Array.from(oldEntry.agentMessageItems)); + }); + + await check('最终保存失败后重连能看完整正文,存储恢复前不得启动新用户轮', async dir => { + const options = {}; + const api = createApi(dir, options); + const { id, ws } = seed(api); + const output = emit(api, id, A, 'A'); + output.fullText += '\n最新完成段'; + options.failSessionSave = true; + options.failTurnSave = true; + api.handleCodexAppTurnComplete(id); + assert(api.retainedCodexAppTurns.get(id)?.fullText.includes('最新完成段'), '双保存失败仍须保留最新内存正文'); + api.attachActiveRuntimeToWs({ readyState: 1 }, id); + assert.equal(api.sent.findLast(message => message.type === 'resume_generating')?.text, output.fullText); + assert.equal(api.sent.findLast(message => message.type === 'done')?.historySaved, false); + assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '下一任务' }).ok, false); + assert.equal(api.starts.length, 1); + options.failSessionSave = false; + options.failTurnSave = false; + assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '下一任务' }).ok, true); + const reloaded = createServerHarness(dir).loadSession(id); + assert.equal(reloaded.messages.find(message => message.role === 'assistant')?.content, output.fullText); + assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 2); + assert.equal(api.activeCodexAppTurns.get(id).fullText, ''); + }); + + await check('等待期计时触发时会话暂不可读仍保留 A 的完整运行态及内存', async dir => { + const options = {}; + const api = createApi(dir, options); + const { id } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + options.failSessionLoad = true; + await api.fireRetry(id); + assert.equal(createServerHarness(dir).sessionHistoryStore.loadTurn(id)?.fullText, A); + const held = api.retainedCodexAppTurns.get(id) || api.pendingCodexCapacityRetries.get(id)?.entry; + assert.equal(held?.fullText, A); + assert.equal(api.starts.length, 1, '会话暂时不可读时不能启动空白新线程'); + }); + + await check('等待期收到新用户任务先提交 A,旧重试取消且新气泡从空开始', async dir => { + const api = createApi(dir); + const { id, ws } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + const oldTimer = api.pendingCodexCapacityRetries.get(id)?.timer; + assert(oldTimer); + assert.equal(api.handleMessage(ws, { sessionId: id, text: '新用户任务', clientMessageId: 'new-during-wait' }).ok, true); + assert.equal(api.pendingCodexCapacityRetries.has(id), false); + assert.equal(api.activeCodexAppTurns.get(id).fullText, ''); + const reloaded = createServerHarness(dir).loadSession(id); + assert.deepEqual(reloaded.messages.map(message => message.role), ['user', 'assistant', 'user']); + assert.equal(reloaded.messages[1].content, A); + oldTimer.callback(); + await Promise.resolve(); + assert.equal(api.starts.length, 2, '已取消的计时器不能再启动旧任务'); + }); + + await check('超过映射及工具预览数量的 105 项完整状态跨重试与归档保留', async dir => { + const api = createApi(dir); + const { id } = seed(api); + const first = api.activeCodexAppTurns.get(id); + const base = { threadId: first.threadId, turnId: first.turnId }; + for (let index = 0; index < 105; index += 1) { + api.runtime.processCodexAppNotification(first, { method: 'item/agentMessage/delta', + params: { ...base, itemId: 'agent-many-' + index, delta: '正文段落-' + index } }, id); + api.runtime.processCodexAppNotification(first, { method: 'item/started', + params: { ...base, item: { id: 'tool-many-' + index, type: 'commandExecution', command: 'fixture-' + index } } }, id); + api.runtime.processCodexAppNotification(first, { method: 'item/commandExecution/outputDelta', + params: { ...base, itemId: 'tool-many-' + index, delta: '输出-' + index } }, id); + } + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id); + assert.equal(state?.toolCalls.length, 105); + assert.equal(state.toolOutputDeltas.length, 105); + assert.equal(state.agentMessageItems.length, 105); + await api.fireRetry(id); + const resumed = api.activeCodexAppTurns.get(id); + assert.equal(resumed.toolCalls.length, 105); + assert.equal(resumed.toolOutputDeltas.size, 105); + assert.equal(resumed.agentMessageItems.size, 105); + api.attachActiveRuntimeToWs({ readyState: 1 }, id); + assert.equal(api.sent.findLast(message => message.type === 'resume_generating').toolCalls.length, 105); + api.handleCodexAppTurnComplete(id); + const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant'); + assert.equal(assistant.toolCalls.length, 105); + assert.equal(assistant.content, first.fullText); + }); + + await check('等待期进程重开从完整运行态恢复 A/B,一次归档且重复恢复不再追加', async dir => { + const api = createApi(dir); + const { id } = seed(api); + emit(api, id, A, 'A'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + await api.fireRetry(id); + emit(api, id, B, 'B'); + api.handleCodexAppTurnFailure(id, new Error(ERROR)); + const savedState = createServerHarness(dir).sessionHistoryStore.loadTurn(id); + assertOnceInOrder(savedState?.fullText || '', [A, B]); + const reopened = createApi(dir); + assert.equal(reopened.recoverCodexAppTurnState(id), true); + let session = createServerHarness(dir).loadSession(id); + let assistants = session.messages.filter(message => message.role === 'assistant'); + assert.equal(assistants.length, 1); + assertOnceInOrder(assistants[0].content, [A, B]); + assertToolOrder(assistants[0].toolCalls, ['A', 'B']); + assert.equal(assistants[0].id, savedState.assistantMessageId); + assert.equal(assistants[0].interrupted, true); + assert.equal(session.messages.filter(message => message.role === 'user').length, 1); + // 模拟运行态清理失败后下次重启再次读到同一状态,恢复入口必须幂等。 + reopened.storeApi.sessionHistoryStore.saveTurn(id, savedState); + assert.equal(reopened.recoverCodexAppTurnState(id), true); + session = createServerHarness(dir).loadSession(id); + assistants = session.messages.filter(message => message.role === 'assistant'); + assert.equal(assistants.length, 1); + assert.equal(reopened.storeApi.sessionHistoryStore.loadTurn(id), null); + }); + + if (baselineIndex >= 0 || process.argv.includes('--skip-mock')) return; + await check('真实 mock 协议三轮续接:A/B/C 正文、复用 ID 工具及用户归档完整', async dir => { + const options = {}; + const api = createApi(dir, options); + const client = createCodexAppServerClient({ command: process.execPath, + args: [path.join(__dirname, 'mock-codex-app-server.js'), 'app-server'], cwd: dir, + env: { ...process.env, CODEX_HOME: path.join(dir, 'codex-home') }, + onNotification: notification => { + const entry = api.activeCodexAppTurns.get('mock-history'); + if (!entry) return; + const result = api.runtime.processCodexAppNotification(entry, notification, 'mock-history'); + if (result.done) api.handleCodexAppTurnComplete('mock-history'); + }, + }); + const waitFor = async predicate => { + const deadline = Date.now() + 5000; + while (!predicate()) { + assert(Date.now() < deadline, '隔离 mock 事件等待超过 5 秒'); + await new Promise(resolve => setTimeout(resolve, 10)); + } + }; + try { + await client.start(); + await client.request('thread/resume', { threadId: 'fixture-thread' }); + options.startTurn = async (sessionId, input) => { + await client.request('turn/start', { threadId: 'fixture-thread', input }); + }; + const { id } = seed(api, 'mock-history', 'codexapp retry full history'); + for (let attempt = 1; attempt <= 2; attempt += 1) { + await waitFor(() => api.pendingCodexCapacityRetries.has(id)); + assert.equal(api.pendingCodexCapacityRetries.get(id).attempts, attempt); + api.handleResumeSession({ readyState: 1 }, { sessionId: id }); + const resume = api.sent.findLast(message => message.type === 'resume_generating'); + assert(resume.text.includes('retry-history-A-begin')); + await api.fireRetry(id); + } + await waitFor(() => !api.activeCodexAppTurns.has(id) && !api.pendingCodexCapacityRetries.has(id)); + const reloaded = createServerHarness(dir).loadSession(id); + const assistant = reloaded.messages.find(message => message.role === 'assistant'); + for (const label of ['A', 'B', 'C']) { + assert.equal(assistant.content.split(`retry-history-${label}-begin`).length, 2); + assert.equal(assistant.content.split(`retry-history-${label}-end`).length, 2); + } + assert.equal(assistant.toolCalls.length, 3); + assert.equal(new Set(assistant.toolCalls.map(tool => tool.id)).size, 3); + ['A', 'B', 'C'].forEach((label, index) => { + assert(assistant.toolCalls[index].result.startsWith(`tool-${label}-full-result`)); + assert(assistant.toolCalls[index].result.length > 32768); + }); + assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 1); + } finally { + await client.stop(); + } + }); +} + +run().then(() => { + if (failures.length) { + console.error(`运行中气泡续接回归失败:${failures.length} 项;通过 ${passed} 项`); + process.exitCode = 1; + } else console.log(`运行中气泡续接回归通过:${passed} 项`); +}).catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/codexapp-steer-recovery-unit.js b/scripts/codexapp-steer-recovery-unit.js new file mode 100644 index 0000000..ae72e22 --- /dev/null +++ b/scripts/codexapp-steer-recovery-unit.js @@ -0,0 +1,347 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +// 直接读取服务端函数,避免单测中的业务副本与实际实现逐渐偏离。 +const serverPath = path.join(__dirname, '..', 'server.js'); +const serverSource = fs.readFileSync(serverPath, 'utf8'); +const functionNames = [ + 'codexAppAssistantMessageIdForEntry', + 'ensureCodexAppAssistantMessageId', + 'buildCodexAppResumePayload', + 'retireCodexAppRuntimeTurn', + 'handleMessage', + 'handleCodexAppMessage', + 'handleCodexAppTurnComplete', + 'isCodexAppNoActiveTurnError', + 'rollbackPersistedCodexAppSteerMessage', + 'reorderStaleCodexAppTurnMessages', + 'handleCodexAppSteerMessage', +]; + +function extractFunction(name) { + const match = new RegExp(`^function ${name}\\(`, 'm').exec(serverSource); + assert.ok(match, `服务端应存在实际函数:${name}`); + const closingLine = /\n\}/g; + closingLine.lastIndex = match.index; + let end; + while ((end = closingLine.exec(serverSource))) { + const candidate = serverSource.slice(match.index, end.index + 2); + try { + // 编译成单一表达式,防止误带相邻函数或全局初始化代码。 + new vm.Script(`(${candidate})`, { filename: serverPath }); + return candidate; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + } + assert.fail(`无法完整提取服务端函数:${name}`); +} + +const sourceFunctions = functionNames.map(extractFunction).join('\n'); +const copy = (value) => JSON.parse(JSON.stringify(value)); +const noop = () => {}; +const sessionId = 'steer-recovery-review'; +const answerId = 'form-answer-review'; +const answerText = '表单答案:保留这条消息'; +const oldOutput = '上一轮完整输出,不能被新轮覆盖。'; +const noActiveErrors = [ + 'no active turn to steer', + 'expected active turn id `old-turn-review`: no active turn', +]; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function createHarness({ running = true, failedSaveAttempts = [], steerError = null } = {}) { + const ws = {}; + const timers = new Map(); + const events = []; + const requests = []; + const starts = []; + const saves = []; + const stateWrites = []; + const failedSaves = new Set(failedSaveAttempts); + const requestResult = deferred(); + const oldEntry = { + ws, + threadId: 'thread-review', + turnId: 'old-turn-review', + fullText: oldOutput, + toolCalls: [], + mcpContext: { sourceSessionId: sessionId }, + startedAt: '2026-10-10T00:00:00.000Z', + }; + let storedSession = { + id: sessionId, + agent: 'codexapp', + title: '恢复插入单测', + cwd: '/tmp/ccweb-steer-recovery-unit', + permissionMode: 'yolo', + codexAppThreadId: 'thread-review', + messages: [{ + id: 'original-question', + role: 'user', + content: '上一轮问题', + timestamp: '2026-10-10T00:00:00.000Z', + }], + }; + let retainedState = copy(oldEntry); + const writeState = (entry) => { + retainedState = copy(entry); + stateWrites.push(copy(entry)); + return true; + }; + const context = { + crypto, Date, Promise, Map, + activeCodexAppTurns: new Map([[sessionId, oldEntry]]), + activeCodexAppGoalCommands: new Map(), + activeCodexAppCompactions: new Map(), + codexAppGoalStates: new Map(), + activeProcesses: new Map(), + pendingCodexCapacityRetries: new Map(), + retainedCodexAppTurns: new Map(), + retiredCodexAppRuntimeTurns: new Map(), + giteaWorkflowWaiters: new Map(), + wsSessionMap: new Map(), + // 手动推进轮询定时器,不等待真实时间,也不向磁盘写会话。 + setTimeout(callback, delay) { + const token = { unref: noop }; + timers.set(token, { callback, delay }); + return token; + }, + clearTimeout: (token) => timers.delete(token), + loadSession: (id) => id === sessionId ? copy(storedSession) : null, + saveSession(session) { + saves.push(copy(session)); + if (failedSaves.has(saves.length)) return false; + storedSession = copy(session); + return true; + }, + writeCodexAppTurnState: (id, entry) => writeState(entry), + persistCodexAppTurnState: (id, entry) => writeState(entry), + cleanupCodexAppTurnState(id, entry) { + entry.codexAppStateCleaned = true; + retainedState = null; + }, + codexAppClient: { + isRunning: () => running, + request(method, params, timeout) { + requests.push({ method, params: copy(params), timeout }); + return steerError ? Promise.reject(new Error(steerError)) : requestResult.promise; + }, + }, + startCodexAppTurn(id, input) { + starts.push({ sessionId: id, input: copy(input) }); + return Promise.resolve(); + }, + sanitizeId: (id) => id, + isCodexAppSession: (session) => session?.agent === 'codexapp', + getRuntimeSessionId: (session) => session.codexAppThreadId, + getDefaultSessionCwd: () => '/tmp/ccweb-steer-recovery-unit', + getSessionAgent: (session) => session.agent, + normalizeSession: noop, + normalizeGiteaSource: () => null, + resolveMessageAttachments: (attachments) => copy(attachments), + resolveComposerDecorators: (text) => ({ runtimeText: text, mentions: [] }), + codexAppInputFromMessage: (text, attachments) => [ + ...(text.trim() ? [{ type: 'text', text }] : []), + ...attachments.map((attachment) => ({ type: 'image', id: attachment.id })), + ], + attachClientRequestId: (payload) => payload, + detachWsFromActiveRuntimes: noop, + registerCrossConversationReplyRequest: noop, + unregisterCrossConversationReplyRequest: noop, + deletePendingCrossConversationReply: noop, + resolvePendingCodexAppUserInputsForSession: noop, + resolvePendingCodexAppApprovalsForSession: noop, + resolvePendingCodexAppElicitationsForSession: noop, + formatRuntimeError: (agent, text) => String(text), + codexAppTurnKey: (id, entry) => `${entry.threadId}:${entry.turnId}`, + shouldRetryCodexTransientFailure: () => false, + cancelCodexCapacityRetry: noop, + hasCodexAppTurnMessage: (session, key) => session.messages.some((message) => message.codexAppTurnKey === key), + ensureStableMessageId: (message) => ({ ...message, id: crypto.randomUUID() }), + isCodexAppGoalActive: () => false, + dispatchTaskBoardLifecycle: noop, + TASK_BOARD_LIFECYCLE_EVENTS: { + USER_MESSAGE_RECEIVED: 'user_message_received', + TURN_FAILED: 'turn_failed', + TURN_COMPLETED: 'turn_completed', + }, + TASK_STATUS_CLASSIFICATION_EVENTS: { + USER_MESSAGE_RECEIVED: 'user_message_received', + TURN_COMPLETED: 'turn_completed', + }, + enqueueTaskStatusClassification: () => Promise.resolve(), + crossConversationReplyRequestIds: () => [], + reconcilePendingCrossConversationReplies: noop, + flushPendingCrossConversationReplies: noop, + sanitizeMessageForTransport: copy, + resolveSessionHistory: (session) => session, + plog: noop, + sendSessionList: noop, + previewInlineText: (text) => text, + wsSend: (target, payload) => events.push(copy(payload)), + }; + vm.createContext(context); + vm.runInContext(sourceFunctions, context, { filename: serverPath, timeout: 1000 }); + + return { + context, oldEntry, events, requests, starts, saves, stateWrites, requestResult, + session: () => copy(storedSession), + state: () => retainedState && copy(retainedState), + send(attachments = []) { + return context.handleCodexAppSteerMessage(ws, { + sessionId, clientMessageId: answerId, text: answerText, attachments, + }, { runtimeText: answerText }); + }, + complete: () => context.handleCodexAppTurnComplete(sessionId), + setRunning: (value) => { running = value; }, + async poll() { + const waiting = [...timers.entries()].filter(([, timer]) => timer.delay === 50); + assert.equal(waiting.length, 1, '等待 ready 时应只有一个待执行的轮询'); + const [token, timer] = waiting[0]; + timers.delete(token); + timer.callback(); + await new Promise(setImmediate); + }, + drain: () => new Promise(setImmediate), + }; +} + +function answers(harness) { + return harness.session().messages.filter((message) => message.id === answerId); +} + +function assertSaveFailure(harness) { + assert.equal(harness.starts.length, 0, '保存失败不能启动新轮'); + assert.equal(harness.stateWrites.filter((entry) => entry.fullText === '').length, 0, '保存失败不能写入新轮的空运行态'); + assert.ok(harness.events.some((event) => event.type === 'error' && event.code === 'session_save_failed'), '保存失败应返回明确的可重试错误'); + assert.equal(harness.events.filter((event) => event.type === 'codex_app_steer_status').at(-1)?.status, 'failed', '保存失败不能把答案确认成发送成功'); +} + +async function testWaitingTurnEnded() { + const harness = createHarness({ running: false }); + const attachment = { id: 'answer-image', filename: '答案图片.png', mime: 'image/png', kind: 'image' }; + assert.equal(harness.send([attachment]).ok, true, '等待中的插入请求应被接收'); + assert.equal(answers(harness).length, 0, '等待 ready 时不能预先重复保存答案'); + harness.complete(); + harness.setRunning(true); + await harness.poll(); + + assert.equal(harness.requests.length, 0, '旧轮已结束时应走普通消息分发'); + assert.equal(harness.starts.length, 1, '旧轮结束后应只启动一轮普通发送'); + assert.equal(answers(harness).length, 1, '重分发后答案应只保存一次'); + assert.deepEqual(answers(harness)[0].attachments, [attachment], '转新轮必须保留答案附件'); + assert.deepEqual(harness.session().messages.map((message) => message.role), ['user', 'assistant', 'user'], '历史应先保存旧轮输出,再保存新答案'); + assert.ok(harness.events.some((event) => event.type === 'codex_app_steer_status' && event.status === 'sent'), '转新轮后应确认同一条答案发送成功'); +} + +async function testWaitingTurnReplaced() { + const harness = createHarness({ running: false }); + harness.send(); + harness.complete(); + const replacement = { ...harness.oldEntry, turnId: 'replacement-turn-review', fullText: '', historySaved: undefined }; + harness.context.activeCodexAppTurns.set(sessionId, replacement); + harness.setRunning(true); + await harness.poll(); + + assert.equal(harness.requests.length, 1, '已有新 entry 时应向当前轮发送一次插入请求'); + assert.equal(harness.requests[0].method, 'turn/steer', '已有活动轮应保留插入语义'); + assert.equal(harness.requests[0].params.expectedTurnId, replacement.turnId, '重新分发必须使用新 entry 的 turn id'); + assert.equal(harness.starts.length, 0, '已有新 entry 时不能额外创建新轮'); + assert.equal(answers(harness).length, 1, '重新分发到新 entry 时答案不能重复保存'); + harness.requestResult.resolve({}); + await harness.drain(); + assert.ok(harness.events.some((event) => event.type === 'codex_app_steer_status' && event.status === 'inserted'), '当前轮插入成功后应确认答案'); +} + +async function testEndedTurnSaveFailed() { + const harness = createHarness({ failedSaveAttempts: [2], steerError: noActiveErrors[1] }); + harness.send(); + await harness.drain(); + + assertSaveFailure(harness); + assert.equal(harness.oldEntry.historySaved, false, '旧轮保存失败应记录明确状态'); + assert.equal(harness.state()?.fullText, oldOutput, '旧轮完整输出运行态必须保留'); + assert.equal(harness.state()?.turnStatus, 'completed', '保留的运行态应标记旧轮已经结束'); + assert.equal(answers(harness).length, 1, '旧轮输出保存失败时不能删除已保存的答案'); +} + +async function testCompletedFirstReorderSaveFailed() { + const harness = createHarness({ failedSaveAttempts: [3] }); + harness.send(); + await harness.drain(); + assert.equal(harness.requests.length, 1, '应先发出等待响应的 steer 请求'); + harness.complete(); + assert.equal(harness.oldEntry.historySaved, true, '先到的 completion 应成功保存旧轮输出'); + harness.requestResult.reject(new Error(noActiveErrors[0])); + await harness.drain(); + + assertSaveFailure(harness); + assert.equal(harness.saves.length, 3, '应真实尝试保存旧轮输出与答案的重排顺序'); + assert.equal(answers(harness).length, 1, '历史重排保存失败时应保留一次答案'); + assert.deepEqual(harness.session().messages.map((message) => message.role), ['user', 'user', 'assistant'], '保存失败时不能误认为重排已经提交'); +} + +async function testErrorClassification() { + const harness = createHarness(); + for (const message of noActiveErrors) { + assert.equal(harness.context.isCodexAppNoActiveTurnError(new Error(message)), true, `应识别真实的无活动轮错误:${message}`); + } + const otherErrors = [ + 'expected active turn id `old-turn-review`: active turn id `different-turn`', + 'network connection reset', + 'network error while checking no active turn to steer', + ]; + for (const message of otherErrors) { + assert.equal(harness.context.isCodexAppNoActiveTurnError(new Error(message)), false, `不能把其他错误误判成结束:${message}`); + const failedHarness = createHarness({ steerError: message }); + failedHarness.send(); + await failedHarness.drain(); + assert.equal(failedHarness.starts.length, 0, `真实错误不能自动开新轮:${message}`); + assert.equal(failedHarness.context.activeCodexAppTurns.get(sessionId), failedHarness.oldEntry, '真实错误不能提前收敛仍活动的旧轮'); + assert.equal(answers(failedHarness).length, 0, '未被运行时接收的答案应回滚,避免重试时重复保存'); + assert.ok(failedHarness.events.some((event) => event.type === 'error' && event.code === 'codexapp_steer_failed'), '真实错误应保留插入失败语义'); + } +} + +async function main() { + const tests = [ + ['等待 ready 时旧轮结束后普通发送', testWaitingTurnEnded], + ['等待 ready 时已有新 entry 按当前轮插入', testWaitingTurnReplaced], + ['无活动轮但旧轮输出保存失败时保留运行态', testEndedTurnSaveFailed], + ['completion 先到而重排保存失败时保留答案', testCompletedFirstReorderSaveFailed], + ['无活动轮错误与真实错误区分', testErrorClassification], + ]; + // 独立脚本异常挂起时也会在 60 秒以内退出。 + const deadline = setTimeout(() => { + console.error('codexapp-steer-recovery-unit:单测超过 45 秒,已终止。'); + process.exit(1); + }, 45000); + try { + for (const [name, test] of tests) { + await test(); + console.log(`通过:${name}`); + } + console.log(`codexapp-steer-recovery-unit:${tests.length} 项全部通过。`); + } finally { + clearTimeout(deadline); + } +} + +main().catch((error) => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/failed-insert-card-unit.js b/scripts/failed-insert-card-unit.js index 5c168b9..bb28cbf 100644 --- a/scripts/failed-insert-card-unit.js +++ b/scripts/failed-insert-card-unit.js @@ -203,6 +203,10 @@ class FakeDocument { querySelector(selector) { return this.root.querySelector(selector); } + + getElementById() { + return null; + } } function createHarness() { @@ -222,6 +226,8 @@ function createHarness() { 'commitCodexAppSteerMessage', 'registerCodexAppSteerSessionMessage', 'retryCodexAppSteerMessage', + 'completeCodexAppSteerMessage', + 'reconcileCodexAppSteerSessionMessage', 'deleteCodexAppSteerMessage', 'setCodexAppSteerStatusElement', 'updateCodexAppSteerMessage', @@ -236,17 +242,26 @@ function createHarness() { let isGenerating = true; let currentSessionRunning = true; let currentSessionMessageCount = 4; + let startGeneratingCalls = 0; + let sendError = null; let ws = { readyState: 1, send(payload) { sent.push(JSON.parse(payload)); } }; function cssEscape(value) { return String(value || '').replace(/"/g, '\\\\"'); } function isCodexAppAgent(agent) { return agent === 'codexapp'; } - function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); } + function startGenerating() { startGeneratingCalls += 1; } + function send(data) { + if (sendError) throw new Error(sendError); + if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); + } function appendError(message) { errors.push(String(message || '')); } function updateUserOutlinePanel() { outlineUpdates += 1; } function updateScrollbar() { scrollbarUpdates += 1; } function scrollToBottom() {} - function markSessionMessageElement(element, index) { + function mergeHistoryChunkIntoCachedSnapshot() {} + function markSessionMessageElement(element, index, message) { element.dataset.sessionMessage = 'true'; element.dataset.messageIndex = String(index); + if (message && message.id) element.dataset.messageKey = String(message.id); + if (message) element.__sessionMessage = message; } ${functions} function makeMessage(id) { @@ -267,6 +282,7 @@ function createHarness() { updateCodexAppSteerMessage, setCodexAppSteerStatusElement, retryCodexAppSteerMessage, + reconcileCodexAppSteerSessionMessage, deleteCodexAppSteerMessage, records: codexAppSteerRecords, index: userMessageIndex, @@ -276,9 +292,11 @@ function createHarness() { get outlineUpdates() { return outlineUpdates; }, get scrollbarUpdates() { return scrollbarUpdates; }, get currentSessionMessageCount() { return currentSessionMessageCount; }, + get startGeneratingCalls() { return startGeneratingCalls; }, setSession(id) { currentSessionId = id; }, setGenerating(value) { isGenerating = value; currentSessionRunning = value; }, setWsReady(value) { ws.readyState = value; }, + setSendError(value) { sendError = value; }, }); `; return vm.runInNewContext(script, { document, messagesDiv, sent, errors, outlineUpdates, scrollbarUpdates }); @@ -304,7 +322,7 @@ function assertFailedActionsContract() { assert(retry && remove, 'failed 状态应同时渲染重试和删除按钮'); assert.strictEqual(retry.textContent, '重试'); assert.strictEqual(remove.textContent, '删除'); - assert.strictEqual(retry.getAttribute('aria-label'), '重试插入此消息'); + assert.strictEqual(retry.getAttribute('aria-label'), '重新发送此消息'); assert.strictEqual(remove.getAttribute('aria-label'), '删除失败插入项'); retry.click(); @@ -318,6 +336,7 @@ function assertFailedActionsContract() { mode: 'plan', agent: 'codexapp', clientMessageId: 'retry-target', + codexAppSteer: true, }); assert.strictEqual(element.getAttribute('aria-busy'), 'true', 'pending 状态应暴露 aria-busy=true'); assert.strictEqual(element.querySelector('.codex-steer-actions'), null, 'pending 状态不应展示失败操作'); @@ -329,6 +348,7 @@ function assertFailedActionsContract() { assert.strictEqual(element.dataset.sessionMessage, 'true', '成功后才提交消息索引'); assert.strictEqual(element.dataset.messageIndex, '4', '成功提交应使用当前会话消息序号'); assert.strictEqual(harness.currentSessionMessageCount, 5, '成功提交只应递增一次'); + assert.strictEqual(element.dataset.messageKey, 'retry-target', '已插入消息也要保留稳定键,避免历史回显重复'); harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入'); assert.strictEqual(harness.currentSessionMessageCount, 5, '重复 inserted 不应重复递增'); } @@ -383,12 +403,54 @@ function assertDeleteAndBlockedRetryContract() { blocked.querySelector('.codex-steer-retry').click(); assert.strictEqual(harness.sent.length, 0, '会话不匹配时不能误发重试'); assert(blocked.querySelector('.codex-steer-actions'), '会话不匹配时应保持失败态'); +} - harness.setSession('session-a'); +function assertFinishedTurnRetryAsNewTurnContract() { + const harness = createHarness(); + const element = harness.makeMessage('finished-turn-target'); + harness.registerCodexAppSteerRecord('finished-turn-target', { + element, + text: '结束后重试', + attachments: [{ id: 'img-finished', filename: 'finished.png' }], + sessionId: 'session-a', + mode: 'plan', + agent: 'codexapp', + }); + harness.updateCodexAppSteerMessage('finished-turn-target', 'failed', '插入失败'); + + // 上一轮已经结束:重试应把这条消息当作新一轮对话直接发送。 harness.setGenerating(false); - blocked.querySelector('.codex-steer-retry').click(); - assert.strictEqual(harness.sent.length, 0, 'turn 已结束时不能误发重试'); - assert(blocked.querySelector('.codex-steer-actions'), 'turn 已结束时应保持失败态'); + const retry = element.querySelector('.codex-steer-retry'); + assert(retry, '已结束会话的失败项仍应展示重试按钮'); + retry.click(); + retry.click(); + + assert.strictEqual(harness.sent.length, 1, '已结束会话重试应发送一条普通消息'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), { + type: 'message', + text: '结束后重试', + attachments: [{ id: 'img-finished', filename: 'finished.png' }], + sessionId: 'session-a', + mode: 'plan', + agent: 'codexapp', + clientMessageId: 'finished-turn-target', + codexAppSteer: true, + }); + assert.strictEqual(harness.startGeneratingCalls, 1, '已结束会话重试应开启新一轮生成'); + assert.strictEqual(harness.currentSessionMessageCount, 4, '未收到发送确认前不能把临时卡片计入历史'); + assert.strictEqual(element.querySelector('.codex-steer-status').textContent, '重新发送中...', '重试应明确显示重新发送状态'); + assert.strictEqual(harness.updateCodexAppSteerMessage('finished-turn-target', 'sent', '已转为新对话'), true); + assert.strictEqual(element.querySelector('.codex-steer-actions'), null, '转新对话后不应保留失败操作区'); + assert.strictEqual(element.querySelector('.codex-steer-status'), null, '转新对话后不应保留插入状态行'); + assert.strictEqual(element.classList.contains('codex-steer-failed'), false, '转新对话后不应保留插入失败样式'); + assert.strictEqual(element.dataset.sessionMessage, 'true', '转新对话后应把消息提交为普通会话消息'); + assert.strictEqual(element.dataset.messageIndex, '4', '转新对话后应占用当前消息序号'); + assert.strictEqual(element.dataset.messageKey, 'finished-turn-target', '转新对话后应写入稳定的消息键'); + assert.strictEqual(harness.currentSessionMessageCount, 5, '转新对话只应递增一次消息计数'); + assert.strictEqual(harness.records.has('finished-turn-target'), false, '成功确认后应清理临时记录'); + harness.updateCodexAppSteerMessage('finished-turn-target', 'sent', '已转为新对话'); + assert.strictEqual(harness.currentSessionMessageCount, 5, '重复发送回执不能增加计数'); + assert.strictEqual(harness.errors.length, 0, '转新对话后不应再提示无法重试'); } function assertProgrammaticSessionMessageRetryContract() { @@ -417,6 +479,7 @@ function assertProgrammaticSessionMessageRetryContract() { mode: 'yolo', agent: 'codexapp', clientMessageId: 'programmatic-target', + codexAppSteer: true, }); harness.updateCodexAppSteerMessage('programmatic-target', 'inserted', '已插入'); assert.strictEqual(element.dataset.sessionMessage, 'true', '程序化临时项成功后才提交消息索引'); @@ -424,6 +487,50 @@ function assertProgrammaticSessionMessageRetryContract() { assert.strictEqual(harness.currentSessionMessageCount, 5, '程序化临时项成功后只递增一次消息计数'); } +function assertRetryFailureAndNormalEchoContract() { + const harness = createHarness(); + const element = harness.makeMessage('normal-echo-target'); + harness.registerCodexAppSteerRecord('normal-echo-target', { + element, text: '表单答案', sessionId: 'session-a', + attachments: [{ id: 'img-retry', filename: 'retry.png' }], + }); + harness.updateCodexAppSteerMessage('normal-echo-target', 'failed', '插入失败'); + harness.setGenerating(false); + harness.setSendError('连接发送失败'); + assert.strictEqual(harness.retryCodexAppSteerMessage('normal-echo-target'), false); + assert(element.querySelector('.codex-steer-retry'), '发送异常后仍应允许重试'); + assert.strictEqual(harness.currentSessionMessageCount, 4, '发送异常不能提交消息序号'); + harness.setSendError(null); + assert.strictEqual(harness.retryCodexAppSteerMessage('normal-echo-target'), true); + harness.updateCodexAppSteerMessage('normal-echo-target', 'failed', '保存失败'); + assert(element.querySelector('.codex-steer-retry'), '服务端保存失败后仍应允许再次重试'); + assert.strictEqual(harness.retryCodexAppSteerMessage('normal-echo-target'), true); + + const echoedMessage = { + id: 'normal-echo-target', role: 'user', content: '表单答案', + attachments: [{ id: 'img-retry', filename: 'retry.png' }], + timestamp: '2026-10-10T11:00:00.000Z', + }; + assert.strictEqual(harness.reconcileCodexAppSteerSessionMessage('other-session', echoedMessage), false); + assert.strictEqual(harness.reconcileCodexAppSteerSessionMessage('session-a', echoedMessage), true); + assert.strictEqual(harness.messagesDiv.children.length, 1, '普通发送回显应确认原卡片,不能追加重复气泡'); + assert.strictEqual(harness.currentSessionMessageCount, 5, '普通发送回显只提交一次消息序号'); + assert.strictEqual(element.querySelector('.codex-steer-status'), null, '普通发送回显后应移除插入状态'); + assert.strictEqual(harness.updateCodexAppSteerMessage('normal-echo-target', 'sent', '已转为新对话'), false); + assert.strictEqual(harness.currentSessionMessageCount, 5, '普通回显之后的回执不能再提交一次'); +} + +function assertCompletedFirstMessageIndexContract() { + const harness = createHarness(); + const element = harness.makeMessage('completed-first-index'); + harness.registerCodexAppSteerRecord('completed-first-index', { + element, text: '完成先到时重试', sessionId: 'session-a', + }); + harness.updateCodexAppSteerMessage('completed-first-index', 'sent', '已转为新对话', 3); + assert.strictEqual(element.dataset.messageIndex, '3', '旧轮重排后应使用服务端确认的真实下标'); + assert.strictEqual(harness.currentSessionMessageCount, 4, '旧轮完成已推进计数时,确认消息不能再重复递增'); +} + function assertStaticContracts() { const appSource = fs.readFileSync(APP_PATH, 'utf8'); const styleSource = fs.readFileSync(STYLE_PATH, 'utf8'); @@ -439,5 +546,8 @@ assertStaticContracts(); assertFailedActionsContract(); assertDeleteAndBlockedRetryContract(); assertProgrammaticSessionMessageRetryContract(); +assertFinishedTurnRetryAsNewTurnContract(); +assertRetryFailureAndNormalEchoContract(); +assertCompletedFirstMessageIndexContract(); console.log('Failed insert card frontend unit checks passed.'); diff --git a/scripts/history-browser-check.js b/scripts/history-browser-check.js index 36afd75..00fd1c7 100644 --- a/scripts/history-browser-check.js +++ b/scripts/history-browser-check.js @@ -48,9 +48,13 @@ async function waitFor(predicate, description, timeout = 12000) { } async function stopChild(child) { if (!child || child.exitCode !== null || child.signalCode) return; + const exited = new Promise((resolve) => child.once('exit', resolve)); child.kill('SIGTERM'); - await Promise.race([new Promise((resolve) => child.once('exit', resolve)), sleep(1000)]); - if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL'); + await Promise.race([exited, sleep(1000)]); + if (child.exitCode === null && !child.signalCode) { + child.kill('SIGKILL'); + await exited; + } } class FirefoxDriver { @@ -168,7 +172,11 @@ Module._extensions['.js'] = (module, filename) => { }, 80); `).replace("path.join(__dirname, 'mock-codex.js')", JSON.stringify(path.join(REPO, 'scripts/mock-codex.js'))); fs.writeFileSync(mock, browserMock, { mode: 0o755 }); - return { home, sessions, rollout, preload, mock, completeFlag, snapshotCount: snapshot.length, nativeCount: store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages.length }; + return { + home, sessions, rollout, preload, mock, completeFlag, snapshotCount: snapshot.length, + historyIds: [...prefix, ...snapshot.slice(-4)].map((message) => message.id), + nativeCount: store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages.length, + }; } async function startRelay(port, targetPort, evidence) { @@ -192,8 +200,13 @@ async function startRelay(port, targetPort, evidence) { try { const msg = JSON.parse(String(data)); if (['session_info', 'resume_session_result', 'session_history_chunk'].includes(msg.type)) evidence.protocol.push({ - type: msg.type, historySource: msg.historySource, historyTotal: msg.historyTotal, isRunning: msg.isRunning, + type: msg.type, historySource: msg.historySource, historyTotal: msg.historyTotal, + historyAvailable: msg.historyAvailable, isRunning: msg.isRunning, ids: msg.messages?.map((message) => message.id), + messages: msg.messages?.map((message, index) => ({ + id: message.id, + index: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) + index : null, + })), }); } catch {} if (client.readyState === WebSocket.OPEN) client.send(data, { binary: false }); @@ -265,16 +278,23 @@ async function main() { const evidence = { root, fixture, protocol: [], stages: {}, kind: '真实 server.js / Firefox / 隔离持久化与 native rollout / mock Codex App' }; const [serverPort, relayPort, driverPort] = await Promise.all([freePort(), freePort(), freePort()]); let server, gecko, relay, driver; + let rolloutHidden = false; try { - server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], { - cwd: REPO, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD, - CC_WEB_INTERNAL_MCP_TOKEN: MCP_TOKEN, - CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'), - CODEX_HOME: path.join(fixture.home, '.codex'), - CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0', - }, stdio: ['ignore', 'pipe', 'pipe'], - }); - const serverLog = fs.createWriteStream(path.join(root, 'server.log')); server.stdout.pipe(serverLog); server.stderr.pipe(serverLog); + const launchServer = (logName = 'server.log') => { + const child = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], { + cwd: REPO, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD, + CC_WEB_INTERNAL_MCP_TOKEN: MCP_TOKEN, + CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'), + CODEX_HOME: path.join(fixture.home, '.codex'), + CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0', + }, stdio: ['ignore', 'pipe', 'pipe'], + }); + const log = fs.createWriteStream(path.join(root, logName)); + child.stdout.pipe(log); child.stderr.pipe(log); + child.once('exit', () => log.end()); + return child; + }; + server = launchServer(); await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败'); const mcpResponse = await fetch(`http://127.0.0.1:${serverPort}/api/internal/mcp/stream?sourceSessionId=${SESSION_ID}`, { method: 'POST', @@ -301,6 +321,106 @@ async function main() { }, '登录或会话列表未就绪'); await driver.execute(`document.querySelector('.session-item').click();`); evidence.stages.loaded = await capture(driver, 'loaded', root, false); + const archiveStart = evidence.protocol.length; + fs.renameSync(fixture.rollout, `${fixture.rollout}.isolated-hidden`); + rolloutHidden = true; + await stopChild(server); + server = launchServer('server-restarted.log'); + await waitFor(async () => { + assert.equal(server.exitCode, null, '隔离服务重启后已退出'); + return (await fetch(`http://127.0.0.1:${serverPort}/`)).ok; + }, '归档验证服务重启失败'); + await driver.refresh(); + await waitFor(async () => { + const ready = await driver.execute('return !document.querySelector("#app").hidden && !!document.querySelector(".session-item")'); + if (ready) return true; + await driver.execute('document.querySelector("#login-password").value=arguments[0];document.querySelector("#login-form").requestSubmit();', [PASSWORD]); + return false; + }, '归档验证重启后登录或会话列表未就绪'); + await driver.execute('document.querySelector(".session-item").click();'); + evidence.stages.archivedAfterRestart = await capture(driver, 'archived-after-restart', root, false); + const archivedBeforePaging = await driver.execute(`return { + ids: [...document.querySelectorAll('#messages > .msg')].map((node) => node.dataset.messageKey).filter(Boolean), + indices: [...document.querySelectorAll('#messages > .msg')] + .map((node) => Number(node.dataset.messageIndex)) + .filter((value) => Number.isFinite(value)), + notice: document.querySelector('#history-load-more')?.textContent || '', + };`); + await driver.execute(`const box=document.querySelector('#messages'); box.scrollTop=0;`); + await waitFor(async () => { + const state = await driver.execute(`return { + earliest: [...document.querySelectorAll('#messages .msg')].some((node) => node.textContent.includes('早期问题 0')), + canLoad: !!document.querySelector('.history-load-more-button:not([disabled])'), + };`); + if (state.earliest) return true; + if (state.canLoad) { + await driver.execute(`document.querySelector('.history-load-more-button:not([disabled])').click();`); + } + return false; + }, '原始记录移开并重启后,最早消息无法从自身归档读回', 30000); + const archived = await driver.execute(`return { + ids: [...document.querySelectorAll('#messages > .msg')].map((node) => node.dataset.messageKey).filter(Boolean), + indices: [...document.querySelectorAll('#messages > .msg')] + .map((node) => Number(node.dataset.messageIndex)) + .filter((value) => Number.isFinite(value)), + notice: document.querySelector('#history-load-more')?.textContent || '', + };`); + const archiveProtocol = evidence.protocol.slice(archiveStart); + const archiveInfo = archiveProtocol.filter((message) => message.type === 'session_info'); + const archiveChunks = archiveProtocol.filter((message) => message.type === 'session_history_chunk'); + const archiveMessages = archiveProtocol.flatMap((message) => message.messages || []); + const archiveIds = new Set(archiveMessages.map((message) => message.id).filter(Boolean)); + const archiveIndices = new Set(archiveMessages + .map((message) => message.index) + .filter((index) => Number.isFinite(index))); + assert(archiveInfo.length > 0 && archiveInfo.every((message) => message.historySource === 'archive'), + '归档刷新后的 session_info 必须全部来自 archive'); + assert(archiveInfo.every((message) => message.historyTotal === 148 && message.historyAvailable === true), + '归档响应必须提供完整总数,且 historyAvailable=true'); + assert(archiveChunks.length > 0 && archiveChunks.every((message) => message.historySource === 'archive'), + '归档分页必须全部来自 archive'); + assert(archiveChunks.every((message) => message.historyTotal === 148 && message.historyAvailable === true), + '归档分页总数或可用状态不一致'); + assert.deepEqual( + [...archiveIndices].sort((left, right) => left - right), + Array.from({ length: 148 }, (_, index) => index), + '归档协议消息下标必须连续覆盖 0 到 147', + ); + assert.equal(archiveIds.size, 148, `归档协议应覆盖 148 个稳定 ID,实际 ${archiveIds.size}`); + assert.equal(archived.ids.length, 148, `归档消息总数应为 148,实际 ${archived.ids.length}`); + assert.deepEqual(archived.ids, fixture.historyIds, '归档分页必须保留夹具全部消息稳定 ID 及顺序'); + assert.deepEqual( + archived.indices.slice().sort((left, right) => left - right), + Array.from({ length: archived.indices.length }, (_, index) => index), + '归档可见消息下标必须从 0 连续覆盖到 147', + ); + assert.deepEqual( + archivedBeforePaging.indices.slice().sort((left, right) => left - right), + Array.from({ length: archivedBeforePaging.indices.length }, (_, index) => index + 136), + '归档首屏必须从消息下标 136 开始', + ); + assert.equal(new Set(archived.ids).size, 148, '归档必须保留 148 个唯一稳定 ID'); + assert(!archived.notice.includes('无法恢复') && !archived.notice.includes('待核实'), + '完整归档读完后不应保留来源不可用或待核实提示'); + await driver.execute(`document.querySelector('#messages').scrollTop=0;`); + await waitFor(() => driver.execute(`const node=[...document.querySelectorAll('#messages > .msg')] + .find((message) => message.dataset.messageKey === arguments[0]); + if (!node) return false; + const box=node.getBoundingClientRect(), style=getComputedStyle(node); + return node.textContent.includes('早期问题 0') && box.top >= 0 + && box.bottom <= innerHeight && box.height > 0 && Number(style.opacity) > 0.9;`, + [fixture.historyIds[0]]), '归档最早问题没有实际显示在视口'); + await driver.screenshot(path.join(root, 'archived-paged.png')); + evidence.stages.archivedPaged = archived; + evidence.archived = { + beforePaging: archivedBeforePaging, + afterPaging: archived, + archiveInfoCount: archiveInfo.length, + archiveChunkCount: archiveChunks.length, + protocolStableIdCount: archiveIds.size, + protocolIndexCount: archiveIndices.size, + pagedScreenshot: path.join(root, 'archived-paged.png'), + }; await driver.execute(`const input=document.querySelector('#msg-input');input.value='继续核对页面职责与流程边界';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#send-btn').click();`); evidence.stages.running = await capture(driver, 'running', root); await driver.refresh(); @@ -323,10 +443,14 @@ async function main() { if (driver?.sessionId) await driver.screenshot(path.join(root, 'failure.png')).catch(() => {}); throw new Error(`${error.message}\n证据:${root}`, { cause: error }); } finally { - fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2)); if (driver) await driver.close().catch(() => {}); if (relay) await relay.close(); - await stopChild(gecko); await stopChild(server); + await stopChild(gecko); + await stopChild(server); + if (rolloutHidden && fs.existsSync(`${fixture.rollout}.isolated-hidden`)) { + fs.renameSync(`${fixture.rollout}.isolated-hidden`, fixture.rollout); + } + fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2)); } } diff --git a/scripts/history-frontend-unit.js b/scripts/history-frontend-unit.js index 0c64ef6..0309586 100644 --- a/scripts/history-frontend-unit.js +++ b/scripts/history-frontend-unit.js @@ -26,13 +26,20 @@ function extractFunction(name) { // 仅模拟浏览器节点操作,消息身份、对账、缓存合并及重连分支均执行 app.js 原函数。 class Element { - constructor() { + constructor(tagName = '') { + this.tagName = tagName; this.dataset = {}; this.children = []; this.parentNode = null; this.scrollTop = 0; this.id = ''; - this.classList = { contains: (name) => this.message?.role === name }; + this.classes = new Set(); + this.listeners = new Map(); + this.classList = { + contains: (name) => this.classes.has(name) || this.message?.role === name, + add: (name) => this.classes.add(name), + remove: (name) => this.classes.delete(name), + }; } get scrollHeight() { return this.children.length * 50; } get nextSibling() { @@ -40,9 +47,11 @@ class Element { return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null; } set innerHTML(value) { + this.html = value; this.children.forEach((child) => { child.parentNode = null; }); this.children = []; } + get innerHTML() { return this.html || ''; } insertBefore(node, anchor) { if (node.fragment) { [...node.children].forEach((child) => this.insertBefore(child, anchor)); @@ -57,6 +66,8 @@ class Element { return node; } appendChild(node) { return this.insertBefore(node, null); } + addEventListener(type, listener) { this.listeners.set(type, listener); } + click() { if (!this.disabled) this.listeners.get('click')?.(); } removeAttribute(name) { if (name === 'id') this.id = ''; } remove() { if (!this.parentNode) return; @@ -71,6 +82,7 @@ class Element { } querySelectorAll(selector) { return this.children.filter((child) => { + if (selector.includes('.user') && child.message?.role !== 'user') return false; if (selector.includes('[data-session-message="true"]') && child.dataset.sessionMessage !== 'true') return false; if (selector.includes('[data-message-key]') && !child.dataset.messageKey) return false; return true; @@ -190,6 +202,241 @@ function message(id, role = 'assistant', content = id) { return { id, role, content, timestamp: '2026-09-21T00:00:00.000Z' }; } +function createPaginationHarness() { + const rendering = createHarness(); + const names = [ + 'createOutlineHistoryState', 'resetOutlineHistoryState', 'buildOutlineHistoryEntry', + 'mergeOutlineHistoryMessages', 'syncOutlineHistoryIndex', 'normalizeOutlineTitleHistory', + 'formatUserOutlineDate', 'buildUserOutlineItems', 'buildUserOutlineTimelineItems', + 'updateUserOutlinePanel', 'resetHistoryLoadState', 'renderHistoryLoadControl', + 'syncHistoryLoadControl', 'requestOlderHistory', 'requestOutlineHistoryPage', + ]; + const historyStart = source.indexOf("case 'session_history_chunk':"); + const historyEnd = source.indexOf("case 'session_message':", historyStart); + const errorStart = source.indexOf("case 'error':", historyStart); + const errorEnd = source.indexOf('const matchesActiveLoadError =', errorStart); + assert(historyStart >= 0 && historyEnd > historyStart && errorStart >= 0 && errorEnd > errorStart); + return new Function('Element', 'rendering', ` + let currentSessionId = 'running-session'; + const loadedHistorySessionId = currentSessionId; + let currentHistoryState = {}; + let currentOutlineHistoryState = createOutlineHistoryState(currentSessionId); + let activeHistoryPageRequest = null; + let activeOutlineHistoryRequest = null; + let activeOutlineTargetRequest = null; + let historyPageRequestSeq = 0; + let outlineHistoryRequestSeq = 0; + const activeSessionLoad = null; + let ws = { readyState: 1 }; + let wsAuthenticated = true; + const historyLoadMore = new Element(); + const userOutlinePanel = new Element(); + const userOutlineBtn = new Element(); + const messagesDiv = rendering.messagesDiv; + const userMessageIndex = new Map(); + const currentOutlineTitleHistory = []; + const requests = []; + const document = { createElement(tagName) { return new Element(tagName); } }; + function send(request) { requests.push(request); } + function shortMessagePreview(value, length) { return String(value).slice(0, length); } + function escapeHtml(value) { return String(value); } + function isBlockingSessionLoad() { return false; } + function scheduleAdvancedSearchJump() {} + function prependHistoryMessages(...args) { rendering.prependHistoryMessages(...args); } + function mergeHistoryChunkIntoCachedSnapshot(...args) { rendering.mergeHistoryChunkIntoCachedSnapshot(...args); } + ${names.map(extractFunction).join('\n')} + return { + requests, historyLoadMore, userOutlinePanel, userOutlineBtn, + requestOlderHistory, requestOutlineHistoryPage, + reset() { + resetHistoryLoadState(currentSessionId); + resetOutlineHistoryState(currentSessionId); + updateUserOutlinePanel(); + }, + ids: rendering.ids, + outlineItems: buildUserOutlineItems, + state() { return { history: currentHistoryState, outline: currentOutlineHistoryState, + pageRequest: activeHistoryPageRequest, outlineRequest: activeOutlineHistoryRequest }; }, + connect(authenticated = true, readyState = 1, connected = true) { + wsAuthenticated = authenticated; + ws = connected ? { readyState } : null; + }, + select(sessionId) { currentSessionId = sessionId; }, + init(snapshot) { + resetHistoryLoadState(); + resetOutlineHistoryState(); + rendering.buffers.set(snapshot.sessionId, { ...snapshot, messages: [...snapshot.messages] }); + rendering.renderMessages(snapshot.messages, { immediate: true, baseIndex: snapshot.historyBaseIndex }); + syncHistoryLoadControl(snapshot); + syncOutlineHistoryIndex(snapshot); + updateUserOutlinePanel(); + }, + receive(msg) { + switch (msg.type) { + ${source.slice(historyStart, historyEnd)} + ${source.slice(errorStart, errorEnd)} + break; + } + }, + }; + `)(Element, rendering); +} + +const savedMessages = Array.from({ length: 57 }, (_, index) => + message(`saved-${index}`, index < 45 && index % 2 === 0 ? 'user' : 'assistant')); +function savedSnapshot(available = false, cursor = 45) { + return { + sessionId: 'running-session', messages: savedMessages.slice(cursor), + historyBaseIndex: cursor, historyCursor: cursor, historyTotal: savedMessages.length, + historyAvailable: available, + }; +} +function historyChunk(request, cursor) { + return { + type: 'session_history_chunk', sessionId: request.sessionId, requestId: request.requestId, + messages: savedMessages.slice(cursor, request.before), historyBaseIndex: cursor, + historyCursor: cursor, historyTotal: savedMessages.length, historyAvailable: false, remaining: 0, + }; +} +function historyButtons(api) { + return api.historyLoadMore.children.filter((node) => node.tagName === 'button'); +} +function historyCopy(api) { + return api.historyLoadMore.children.filter((node) => node.tagName === 'span') + .map((node) => node.textContent).join(' '); +} + +{ + const api = createPaginationHarness(); + api.init(savedSnapshot()); + api.requestOlderHistory(); + assert.equal(api.requests.length, 1, '原始来源不可用时仍须请求已保存的45条正文'); + assert.match(historyCopy(api), /45 条已保存的更早消息/); + assert.equal(historyButtons(api)[0].disabled, true, '请求期间保留 loading 和按钮禁用'); + assert.equal(historyButtons(api)[0].textContent, '正在加载…'); + api.requestOlderHistory(); + assert.equal(api.requests.length, 1, '重复点击不能重复请求'); + const first = api.requests[0]; + api.receive({ ...historyChunk(first, 33), requestId: '过期请求' }); + api.receive({ ...historyChunk(first, 33), sessionId: '其他会话' }); + assert.equal(api.ids().length, 12, '错误会话和过期请求不能插入正文'); + while (api.state().pageRequest) { + const request = api.state().pageRequest; + const response = historyChunk(request, Math.max(0, request.before - 12)); + api.receive(response); + api.receive(response); + if (api.state().history.cursor > 0) historyButtons(api)[0].click(); + } + assert.deepEqual(api.requests.map((request) => request.before), [45, 33, 21, 9]); + assert.deepEqual(api.ids(), savedMessages.map((item) => item.id), '57条已保存正文保持顺序且不重复'); + assert.equal(api.state().history.available, false, '来源状态不能伪装成完整恢复'); + assert.equal(api.historyLoadMore.hidden, false, '读完已保存消息仍须保留更早记录待核实提示'); + assert.equal(historyButtons(api).length, 0, 'cursor=0时不能保留加载按钮'); + assert.doesNotMatch(historyCopy(api), /无法恢复/); +} + +{ + const api = createPaginationHarness(); + api.init(savedSnapshot()); + assert.equal(api.outlineItems().length, 0, '首屏没有用户提问,用于验证目录入口'); + assert.equal(api.userOutlineBtn.disabled, false, '有已保存旧消息时目录按钮不能因来源状态而禁用'); + api.requestOutlineHistoryPage(); + assert.equal(api.requests.length, 1, '原始来源不可用时仍须索引已保存的45条'); + assert.match(api.userOutlinePanel.innerHTML, /正在加载/); + api.requestOutlineHistoryPage(); + assert.equal(api.requests.length, 1, '目录索引请求必须去重'); + const first = api.requests[0]; + api.receive({ ...historyChunk(first, 33), requestId: '过期目录请求' }); + api.receive({ ...historyChunk(first, 33), sessionId: '其他会话' }); + assert.equal(api.outlineItems().length, 0, '错误会话和过期响应不能污染目录'); + while (api.state().outlineRequest) { + const request = api.state().outlineRequest; + const response = historyChunk(request, Math.max(0, request.before - 12)); + api.receive(response); + api.receive(response); + } + assert.deepEqual(api.requests.map((request) => request.before), [45, 33, 21, 9]); + assert(api.requests.every((request) => request.purpose === 'outline')); + assert.deepEqual(api.outlineItems().map((item) => item.id), + savedMessages.filter((item) => item.role === 'user').map((item) => item.id), + '目录自动完成全部用户提问索引,顺序正确且无重复'); + assert.equal(api.state().outline.complete, true); + assert.equal(api.state().outline.available, false); + assert.match(api.userOutlinePanel.innerHTML, /更早的记录尚待核实/); + assert.doesNotMatch(api.userOutlinePanel.innerHTML, /无法恢复/); +} + +for (const outline of [false, true]) { + const api = createPaginationHarness(); + api.init(savedSnapshot()); + const requestPage = () => outline ? api.requestOutlineHistoryPage() : api.requestOlderHistory(); + const state = () => outline ? api.state().outline : api.state().history; + const active = () => outline ? api.state().outlineRequest : api.state().pageRequest; + api.connect(false); + requestPage(); + api.connect(true, 0); + requestPage(); + api.connect(true, 1, false); + requestPage(); + api.connect(); + api.select('其他会话'); + requestPage(); + assert.equal(api.requests.length, 0, '保留认证、连接状态和会话隔离门禁'); + api.select('running-session'); + requestPage(); + assert.equal(api.requests.length, 1); + const failedRequest = active(); + api.receive({ type: 'error', sessionId: '其他会话', requestId: failedRequest.requestId, message: '不得生效' }); + assert.equal(state().loading, true, '其他会话错误不能取消本次加载'); + api.receive({ type: 'error', sessionId: failedRequest.sessionId, requestId: failedRequest.requestId, message: '读取失败,请重试' }); + assert.equal(active(), null); + assert.equal(state().loading, false); + assert.equal(state().cursor, 45, '失败后保留游标供重试'); + if (outline) { + assert.match(api.userOutlinePanel.innerHTML, /data-outline-retry/); + assert.equal(api.userOutlineBtn.disabled, false); + requestPage(); + } else { + assert.equal(historyButtons(api)[0].disabled, false); + assert.equal(historyButtons(api)[0].textContent, '重试'); + historyButtons(api)[0].click(); + } + assert.equal(api.requests.length, 2, '失败后能够重试已有记录'); + assert.notEqual(active().requestId, failedRequest.requestId); + assert.equal(state().error, ''); + assert.equal(state().loading, true); +} + +for (const available of [false, true]) { + const api = createPaginationHarness(); + api.init(savedSnapshot(available, 0)); + api.requestOlderHistory(); + api.requestOutlineHistoryPage(); + assert.equal(api.requests.length, 0, 'cursor=0时两类请求都必须停止'); + assert.equal(historyButtons(api).length, 0); + assert.equal(api.historyLoadMore.hidden, available, '完整历史读完隐藏控件;来源待核实则保留提示'); + if (available) { + assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/); + } else { + assert.match(historyCopy(api), /更早的记录尚待核实/); + assert.match(api.userOutlinePanel.innerHTML, /更早的记录尚待核实/); + assert.doesNotMatch(historyCopy(api), /不完整|未保存|无法恢复/); + assert.doesNotMatch(api.userOutlinePanel.innerHTML, /不完整|未保存|无法恢复/); + } +} + +{ + const api = createPaginationHarness(); + api.reset(); + assert.equal(api.historyLoadMore.hidden, true, '收到历史元数据前不能误报更早记录待核实'); + assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/); + assert.equal(api.userOutlineBtn.disabled, true, '没有已保存消息或待加载记录时保持空目录禁用'); + api.init(savedSnapshot(true)); + assert.equal(historyButtons(api)[0].disabled, false); + assert.doesNotMatch(historyCopy(api), /待核实/); + assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/); +} + { const api = createHarness(); assert.equal(api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '初稿' }), @@ -343,4 +590,4 @@ for (const cached of [false, true]) { assert.equal(api.count(), 4); } -console.log('前端历史单测通过:稳定 ID、分页、重连、流式身份绑定与完成去重'); +console.log('前端历史单测通过:已保存历史分页、目录索引、来源提示、认证隔离与重试、稳定 ID、重连和流式去重'); diff --git a/scripts/history-runtime-completion-unit.js b/scripts/history-runtime-completion-unit.js new file mode 100644 index 0000000..ca74b03 --- /dev/null +++ b/scripts/history-runtime-completion-unit.js @@ -0,0 +1,1009 @@ +#!/usr/bin/env node +'use strict'; + +// 该回归抽取 server.js 的真实完成/恢复函数,配真实 session-history-store 与临时目录执行。 +// 只读写临时夹具,不接触真实会话、不启动外部进程、不复用镜像实现。 + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { inspect } = require('node:util'); +const { createAgentRuntime } = require('../lib/agent-runtime'); +const { createServerHarness } = require('./session-history-store-unit'); +const { LIFECYCLE_EVENT_TYPES: TASK_BOARD_LIFECYCLE_EVENTS } = require('../lib/task-board-lifecycle'); +const { CLASSIFICATION_EVENT_TYPES: TASK_STATUS_CLASSIFICATION_EVENTS } = require('../lib/task-board-classifier'); +const { CCWEB_REPLY_MODES } = require('../lib/ccweb-mcp-server'); + +const REPO_DIR = path.join(__dirname, '..'); +const SERVER_PATH = path.join(REPO_DIR, 'server.js'); +let serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + +function extractFunctionSource(source, name) { + const start = source.indexOf(`function ${name}(`); + assert(start >= 0, `server.js 应定义 ${name}`); + let parenDepth = 0; + let signatureEnd = -1; + for (let index = start; index < source.length; index += 1) { + if (source[index] === '(') parenDepth += 1; + if (source[index] === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnd = index; + break; + } + } + } + assert(signatureEnd > start, `${name} 应有完整签名`); + const open = source.indexOf('{', signatureEnd); + assert(open > start, `${name} 应有函数体`); + let depth = 0; + for (let index = open; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] === '}') { + depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + } + throw new Error(`${name} 函数体不完整`); +} + +function extractConstSource(source, name) { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = source.match(new RegExp(`^const ${escapedName} = .*;$`, 'm')); + assert(match, `server.js 应定义单行常量 ${name}`); + return match[0]; +} + +function entryFixture(overrides = {}) { + return { + agent: 'codex', + pid: 999999, + ws: null, + fullText: '', + toolCalls: [], + lastCost: null, + lastUsage: null, + lastError: null, + errorSent: false, + tailer: null, + runKey: null, + retryRequest: null, + ...overrides, + }; +} + +function sessionFixture(id, agent, messages = []) { + const now = new Date(1800000000000).toISOString(); + return { + id, + title: '夹具会话', + titleSource: 'derived', + created: now, + updated: now, + pinnedAt: null, + agent, + claudeSessionId: null, + codexThreadId: null, + codexAppThreadId: null, + model: agent === 'claude' ? null : 'gpt-5-codex', + permissionMode: 'yolo', + totalCost: 0, + totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + messages, + cwd: '', + }; +} + +// —— CLI 完成与恢复 —— + +function createCliCompletionApi(dir, options = {}) { + const runtimeFs = options.fs || fs; + const storeApi = createServerHarness(dir, { fs: options.storeFs || fs }); + const logs = []; + const sent = []; + const dispatches = []; + const activeProcesses = new Map(); + const alivePids = new Set(options.alivePids || []); + const pendingSlashCommands = new Map(); + const pendingCompactRetries = new Map(); + const loadSession = options.loadSession || storeApi.loadSession; + const saveSession = options.saveSession || storeApi.saveSession; + const agentRuntime = createAgentRuntime({ + processEnv: {}, + wsSend: (target, payload) => { sent.push(payload); }, + sanitizeToolInput: value => value, + loadSession, + saveSession, + }); + const api = new Function( + 'fs', 'path', 'crypto', 'dir', 'sessionHistoryStore', 'activeProcesses', 'pendingSlashCommands', + 'pendingCompactRetries', 'wsSend', 'plog', 'loadSession', 'saveSession', 'getSessionAgent', + 'isProcessRunning', 'cleanRunDir', 'processRuntimeEvent', 'FileTailer', 'codexAppStatePath', + 'recoverCodexAppTurnState', 'sendSessionList', 'cancelCodexCapacityRetry', 'scheduleCodexCapacityRetry', + 'shouldRetryCodexTransientFailure', 'crossConversationReplyRequestIds', 'completeCrossConversationReply', + 'flushPendingCrossConversationReplies', 'dispatchTaskBoardLifecycle', 'wss', 'loadNotifyConfig', + 'buildNotifyContent', 'sendNotification', 'TASK_BOARD_LIFECYCLE_EVENTS', ` + const SESSIONS_DIR = dir; + const CODEX_APP_STATE_FILE = 'codexapp-state.json'; + ${[ + 'sanitizeId', + 'runDir', + 'writeFileAtomicSync', + 'hasRuntimeOutput', + 'codexOutputContent', + 'cliRunKey', + 'hasCliRunMessage', + 'isContextLimitError', + 'handleProcessComplete', + 'recoverProcesses', + ].filter(name => serverSource.includes('function ' + name + '(')).map(name => extractFunctionSource(serverSource, name)).join('\n')} + return { + handleProcessComplete, recoverProcesses, hasRuntimeOutput, runDir, sanitizeId, + codexOutputContent: typeof codexOutputContent === 'function' ? codexOutputContent : null, + cliRunKey: typeof cliRunKey === 'function' ? cliRunKey : null, + }; + `, + )( + runtimeFs, + path, + crypto, + dir, + storeApi.sessionHistoryStore, + activeProcesses, + pendingSlashCommands, + pendingCompactRetries, + (target, payload) => { sent.push(payload); }, + (level, event, data) => { logs.push({ level, event, ...data }); }, + loadSession, + saveSession, + session => session?.agent || 'claude', + pid => alivePids.has(pid), + sessionId => { + const runPath = path.join(dir, `${String(sessionId).replace(/[^a-zA-Z0-9\-]/g, '')}-run`); + if (options.failCleanup) { + const error = new Error('模拟清理失败'); + error.code = 'EACCES'; + throw error; + } + if (runtimeFs.existsSync(runPath)) runtimeFs.rmSync(runPath, { recursive: true }); + }, + (target, event, sessionId) => agentRuntime.processRuntimeEvent(target, event, sessionId), + class FileTailer { constructor() {} readNew() {} stop() {} start() {} }, + sessionId => path.join(dir, `${String(sessionId).replace(/[^a-zA-Z0-9\-]/g, '')}-run`, 'codexapp-state.json'), + () => false, + () => {}, + () => true, + () => false, + () => false, + () => [], + () => false, + () => false, + (sessionId, event) => { dispatches.push({ sessionId, ...event }); return Promise.resolve(null); }, + { clients: [] }, + () => ({ provider: 'off' }), + async () => ({ title: '', content: '' }), + () => {}, + TASK_BOARD_LIFECYCLE_EVENTS, + ); + return { ...api, storeApi, logs, sent, dispatches, activeProcesses }; +} + +function writeRunArtifacts(dir, sessionId, { outputLines = [], runKey = null, pid = 999999 } = {}) { + const runPath = path.join(dir, `${sessionId}-run`); + fs.mkdirSync(runPath, { recursive: true }); + if (runKey) fs.writeFileSync(path.join(runPath, 'run-key'), runKey); + fs.writeFileSync(path.join(runPath, 'pid'), String(pid)); + if (outputLines.length > 0) { + fs.writeFileSync(path.join(runPath, 'output.jsonl'), outputLines.map(line => JSON.stringify(line)).join('\n') + '\n'); + } + return runPath; +} + +// —— Codex App 完整运行态 —— + +function createCodexAppStateApi(dir, options = {}) { + const storeApi = createServerHarness(dir); + const logs = []; + const sent = []; + const activeCodexAppTurns = new Map(); + const realLoadSession = storeApi.loadSession; + const realSaveSession = storeApi.saveSession; + const runtimeFs = options.fs || fs; + const api = new Function( + 'fs', 'path', 'crypto', 'dir', 'sessionHistoryStore', 'activeCodexAppTurns', 'plog', + 'loadSession', 'saveSession', 'isCodexAppSession', 'getSessionAgent', 'setRuntimeSessionId', + 'recoverCcwebMcpChildThreadsFromPersistedToolCalls', 'wsSend', 'resolvePendingCodexAppUserInputsForSession', + 'resolvePendingCodexAppApprovalsForSession', 'resolvePendingCodexAppElicitationsForSession', + 'formatRuntimeError', 'shouldRetryCodexTransientFailure', 'cancelCodexCapacityRetry', + 'ensureStableMessageId', 'dispatchTaskBoardLifecycle', 'enqueueTaskStatusClassification', + 'crossConversationReplyRequestIds', 'completeCrossConversationReply', 'flushPendingCrossConversationReplies', + 'isCodexAppGoalActive', 'resolveSessionHistory', 'sanitizeMessageForTransport', 'sendSessionList', + 'broadcastSessionList', 'wss', 'buildNotifyContent', 'sendNotification', 'giteaWorkflowWaiters', + 'settleGiteaWorkflowTurn', 'TASK_BOARD_LIFECYCLE_EVENTS', 'TASK_STATUS_CLASSIFICATION_EVENTS', ` + const SESSIONS_DIR = dir; + const retainedCodexAppTurns = new Map(); + const retiredCodexAppRuntimeTurns = new Map(); + ${[ + 'readPositiveIntEnv', + 'sanitizeId', + 'runDir', + 'writeFileAtomicSync', + 'cleanRunDir', + 'textByteLength', + 'truncateTextValue', + 'sanitizePersistValue', + 'sanitizeToolCallsForPersist', + 'mapToPairs', + 'codexAppStatePath', + 'codexAppTurnKey', + 'codexAppAssistantMessageIdForEntry', + 'ensureCodexAppAssistantMessageId', + 'buildCodexAppResumePayload', + 'retireCodexAppRuntimeTurn', + 'serializeCodexAppEntry', + 'buildCodexAppStateJson', + 'writeCodexAppTurnState', + 'loadCodexAppTurnState', + 'cleanupCodexAppTurnState', + 'hasCodexAppTurnMessage', + 'appendCodexAppRecoveryNotice', + 'recoverCodexAppTurnState', + 'handleCodexAppTurnComplete', + ].map(name => extractFunctionSource(serverSource, name)).join('\n')} + ${[ + 'CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', + 'CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', + 'CODEX_APP_STATE_MAX_MAP_ENTRIES', + 'CODEX_APP_STATE_MAX_BYTES', + 'CODEX_APP_STATE_LOAD_MAX_BYTES', + 'CODEX_APP_STATE_FILE', + 'SESSION_MESSAGE_CONTENT_MAX_CHARS', + 'SESSION_TOOL_INPUT_MAX_CHARS', + 'SESSION_TOOL_RESULT_MAX_CHARS', + 'SESSION_MAX_TOOL_CALLS_PER_MESSAGE', + 'PERSIST_TRUNCATED_TEXT', + ].map(name => extractConstSource(serverSource, name)).join('\n')} + return { + writeCodexAppTurnState, loadCodexAppTurnState, recoverCodexAppTurnState, + cleanupCodexAppTurnState, handleCodexAppTurnComplete, serializeCodexAppEntry, codexAppTurnKey, + }; + `, + )( + runtimeFs, + path, + crypto, + dir, + storeApi.sessionHistoryStore, + activeCodexAppTurns, + (level, event, data) => { logs.push({ level, event, ...data }); }, + options.loadSession || realLoadSession, + options.saveSession || realSaveSession, + session => session?.agent === 'codexapp', + session => session?.agent || 'claude', + (session, runtimeId) => { session.codexAppThreadId = runtimeId; }, + () => {}, + (target, payload) => { sent.push(payload); }, + () => {}, + () => {}, + () => {}, + (agent, raw) => String(raw || ''), + () => false, + () => true, + message => message, + () => Promise.resolve(null), + () => Promise.resolve(null), + () => [], + () => false, + () => false, + () => false, + session => session, + message => message, + () => {}, + () => {}, + { clients: [] }, + async () => ({ title: '', content: '' }), + () => {}, + new Map(), + () => {}, + TASK_BOARD_LIFECYCLE_EVENTS, + TASK_STATUS_CLASSIFICATION_EVENTS, + ); + return { ...api, storeApi, logs, sent, activeCodexAppTurns }; +} + +// —— 初次消息与 steer 保存失败 —— + +function createMessageSaveFailureApi(dir, options = {}) { + const storeApi = createServerHarness(dir); + const sent = []; + const maps = { + activeCodexAppTurns: new Map(), + activeCodexAppGoalCommands: new Map(), + activeCodexAppCompactions: new Map(), + codexAppGoalStates: new Map(), + activeProcesses: new Map(), + pendingCompactRetries: new Map(), + pendingCodexCapacityRetries: new Map(), + retainedCodexAppTurns: new Map(), + wsSessionMap: new Map(), + }; + const api = new Function('fs', 'path', 'crypto', 'dir', 'storeApi', 'maps', 'options', 'sent', ` + const wsSend = (ws, payload) => sent.push(payload); + const attachClientRequestId = (payload, source) => ({ ...payload, requestId: source?.requestId }); + const resolveMessageAttachments = () => []; + const resolveComposerDecorators = text => ({ runtimeText: text, mentions: [] }); + const cancelCodexCapacityRetry = () => true; + const isCodexAppGoalActive = () => false; + const reconcilePendingCrossConversationReplies = () => false; + const flushPendingCrossConversationReplies = () => false; + const normalizeSession = session => session; + const getRuntimeSessionId = () => null; + const isCodexAppSession = session => session?.agent === 'codexapp'; + const getSessionAgent = session => session?.agent || 'claude'; + const getDefaultSessionCwd = () => dir; + const normalizeGiteaSource = value => value || null; + const dispatchTaskBoardLifecycle = () => Promise.resolve(null); + const sendSessionList = () => {}; + const loadSession = storeApi.loadSession; + const saveSession = options.saveSession; + const activeCodexAppTurns = maps.activeCodexAppTurns; + const activeCodexAppGoalCommands = maps.activeCodexAppGoalCommands; + const activeCodexAppCompactions = maps.activeCodexAppCompactions; + const codexAppGoalStates = maps.codexAppGoalStates; + const activeProcesses = maps.activeProcesses; + const pendingCompactRetries = maps.pendingCompactRetries; + const pendingCodexCapacityRetries = maps.pendingCodexCapacityRetries; + const retainedCodexAppTurns = maps.retainedCodexAppTurns; + const wsSessionMap = maps.wsSessionMap; + ${extractFunctionSource(serverSource, 'handleMessage')} + return { handleMessage }; + `)(fs, path, crypto, dir, storeApi, maps, options, sent); + return { ...api, storeApi, sent, maps }; +} + +function createSteerSaveFailureApi(dir) { + const storeApi = createServerHarness(dir); + const sent = []; + const requestCalls = []; + const maps = { activeCodexAppTurns: new Map(), wsSessionMap: new Map() }; + const api = new Function('fs', 'path', 'crypto', 'dir', 'storeApi', 'maps', 'sent', 'requestCalls', ` + const wsSend = (ws, payload) => sent.push(payload); + const sanitizeId = value => String(value || '').replace(/[^a-zA-Z0-9\\-]/g, ''); + const resolveMessageAttachments = () => []; + const resolveComposerDecorators = text => ({ runtimeText: text, mentions: [] }); + const registerCrossConversationReplyRequest = () => {}; + const unregisterCrossConversationReplyRequest = () => {}; + const deletePendingCrossConversationReply = () => {}; + const detachWsFromActiveRuntimes = () => {}; + const codexAppInputFromMessage = text => ({ text }); + const codexAppClient = { + isRunning: () => true, + request: (...args) => { requestCalls.push(args); return Promise.resolve({}); }, + }; + const enqueueTaskStatusClassification = () => Promise.resolve(null); + const previewInlineText = text => String(text || ''); + const isCodexAppSession = session => session?.agent === 'codexapp'; + const sendSessionList = () => {}; + const isCodexAppNoActiveTurnError = () => false; + const rollbackPersistedCodexAppSteerMessage = () => false; + const handleCodexAppTurnComplete = () => {}; + const handleCodexAppMessage = () => null; + const formatRuntimeError = (agent, raw) => String(raw || ''); + const getSessionAgent = session => session?.agent || 'claude'; + const loadSession = storeApi.loadSession; + const saveSession = () => false; + const activeCodexAppTurns = maps.activeCodexAppTurns; + const wsSessionMap = maps.wsSessionMap; + ${extractFunctionSource(serverSource, 'handleCodexAppSteerMessage')} + return { handleCodexAppSteerMessage }; + `)(fs, path, crypto, dir, storeApi, maps, sent, requestCalls); + return { ...api, storeApi, sent, requestCalls, maps }; +} + +// —— 跨会话回传失败重试 —— + +function createCrossConversationApi(dir, options = {}) { + const storeApi = createServerHarness(dir); + const sent = []; + const pending = new Map(); + const api = new Function('fs', 'path', 'crypto', 'dir', 'storeApi', 'pending', 'sent', 'options', 'CCWEB_REPLY_MODES', ` + const wsSend = (ws, payload) => sent.push(payload); + const loadSession = storeApi.loadSession; + const saveSession = session => (options.failSave ? false : storeApi.saveSession(session)); + const isSessionRunning = () => false; + const findViewingSessionWs = () => null; + const broadcastSessionList = () => {}; + const startCrossConversationReplyAutoRun = () => false; + const saveCrossConversationReplies = () => {}; + const pendingCrossConversationReplies = pending; + ${[ + 'readPositiveIntEnv', + 'sanitizeId', + 'truncateTextValue', + 'normalizeCrossConversationReplyState', + 'setPendingCrossConversationReply', + 'updatePendingCrossConversationReply', + 'deletePendingCrossConversationReply', + 'hasProcessedCrossConversationReply', + 'extractCrossConversationReplyText', + 'buildCrossConversationReplyContent', + 'deliverCrossConversationReply', + 'flushPendingCrossConversationReplies', + ].map(name => extractFunctionSource(serverSource, name)).join('\n')} + ${['PERSIST_TRUNCATED_TEXT', 'CROSS_CONVERSATION_MAX_CONTENT_CHARS'].map(name => extractConstSource(serverSource, name)).join('\n')} + return { + setPendingCrossConversationReply, deliverCrossConversationReply, + flushPendingCrossConversationReplies, pendingCrossConversationReplies: pending, + }; + `)(fs, path, crypto, dir, storeApi, pending, sent, options, CCWEB_REPLY_MODES); + return { ...api, storeApi, sent, options }; +} + +// —— 子会话冲刷失败重试 —— + +function createChildFlushApi(dir, options = {}) { + const storeApi = createServerHarness(dir); + const pendingFlushes = new Map(); + const api = new Function('fs', 'path', 'dir', 'storeApi', 'pendingFlushes', 'options', ` + const loadSession = storeApi.loadSession; + const saveSession = session => (options.failSave ? false : storeApi.saveSession(session)); + const findViewingSessionWs = () => null; + const findCcwebMcpChildTargetToolInMessages = (messages, spawnToolId) => { + for (const message of messages || []) { + for (const tool of message?.toolCalls || []) { + if (tool?.id === spawnToolId) return tool; + } + } + return null; + }; + const mergeCcwebMcpChildIntoTool = (tool, child) => { + if (!tool) return null; + tool.result = child.finalMessage || child.candidateResult || ''; + tool.done = true; + return tool; + }; + const pendingCcwebMcpChildSessionFlushes = pendingFlushes; + ${[ + 'isFinalCcwebMcpChildStatus', + 'snapshotCcwebMcpChildForPersist', + 'flushPendingCcwebMcpChildSession', + 'updatePersistedCcwebMcpChildTool', + ].map(name => extractFunctionSource(serverSource, name)).join('\n')} + ${['CODEX_APP_STATE_FLUSH_DELAY_MS', 'CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS'].map(name => extractConstSource(serverSource, name)).join('\n')} + return { + updatePersistedCcwebMcpChildTool, flushPendingCcwebMcpChildSession, + pendingCcwebMcpChildSessionFlushes: pendingFlushes, + }; + `)(fs, path, dir, storeApi, pendingFlushes, options); + return { ...api, storeApi, options }; +} + +// —— 任务看板元数据适配器 —— + +function createTaskBoardAdapterApi(dir) { + const storeApi = createServerHarness(dir); + const api = new Function('fs', 'path', 'dir', 'storeApi', ` + const loadSession = storeApi.loadSession; + const saveSession = storeApi.saveSession; + ${extractFunctionSource(serverSource, 'saveTaskBoardSessionMetadata')} + return { saveTaskBoardSessionMetadata }; + `)(fs, path, dir, storeApi); + return { ...api, storeApi }; +} + +let checks = 0; +const failures = []; + +function check(name, callback) { + let dir = null; + try { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-completion-')); + callback(dir); + checks += 1; + console.log(`通过:${name}`); + } catch (error) { + failures.push(`${name}:${error.message}`); + console.error(`失败:${name}:${inspect(error, { depth: 2, maxArrayLength: 8, maxStringLength: 600 })}`); + } finally { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function checkAsync(name, callback) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-completion-')); + return Promise.resolve() + .then(() => callback(dir)) + .then(() => { + checks += 1; + console.log(`通过:${name}`); + }) + .catch(error => { + failures.push(`${name}:${error.message}`); + console.error(`失败:${name}:${inspect(error, { depth: 2, maxArrayLength: 8, maxStringLength: 600 })}`); + }) + .finally(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); +} + +function runTests() { + check('CLI 完成时会话读取失败保留真实输出且读取恢复后恰好提交一次', dir => { + const sessionId = 'cli-complete-load-failure'; + const seedApi = createServerHarness(dir); + assert.equal(seedApi.saveSession(sessionFixture(sessionId, 'codex', [ + { id: 'user-before-cli', role: 'user', content: '读取异常前已经保存的问题' }, + ])), true); + const archivePath = path.join(dir, '_history', `${sessionId}.json`); + const archiveBefore = fs.readFileSync(archivePath, 'utf8'); + const fullText = 'CLI 完成时必须保留的唯一输出'; + const runPath = writeRunArtifacts(dir, sessionId, { + runKey: `cli:${sessionId}:load-failure`, + outputLines: [{ type: 'item.completed', item: { id: 'cli-load-message', type: 'agent_message', text: fullText } }], + }); + const outputPath = path.join(runPath, 'output.jsonl'); + const outputBefore = fs.readFileSync(outputPath, 'utf8'); + // 用真实损坏档案制造“会话暂不可读”:读取被收敛为 null,且不使用跨实例桩函数, + // 修复档案后由同一 storeApi 承担恢复补交,可信来源身份不会丢失。 + const corruptedArchive = '{损坏的会话归档:CLI 读取失败夹具'; + fs.writeFileSync(archivePath, corruptedArchive); + const api = createCliCompletionApi(dir); + api.activeProcesses.set(sessionId, entryFixture({ + ws: {}, fullText, runKey: `cli:${sessionId}:load-failure`, + })); + api.handleProcessComplete(sessionId, 0, null); + + assert.equal(fs.existsSync(runPath), true, '读取失败不得清理真实 CLI 运行目录'); + assert.equal(fs.readFileSync(outputPath, 'utf8'), outputBefore, '读取失败不得删除或改写唯一 output.jsonl'); + assert.equal(fs.readFileSync(archivePath, 'utf8'), corruptedArchive, '读取失败不得重写完整会话归档'); + assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_load_failed'), '读取失败应回报 session_load_failed'); + assert(api.sent.some(payload => payload.type === 'done' && payload.historySaved === false), '读取失败不得宣称 historySaved=true'); + assert.equal(api.activeProcesses.has(sessionId), false, '已经完成的 CLI 不应仍显示运行'); + assert.equal(seedApi.loadSession(sessionId), null, '损坏档案下会话读取应被收敛为 null'); + + fs.writeFileSync(archivePath, archiveBefore); + api.recoverProcesses(); + const restored = createServerHarness(dir).loadSession(sessionId); + assert.equal(restored.messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '读取恢复后应恰好补一条 CLI 回复'); + assert.equal(restored.messages[0].id, 'user-before-cli', '恢复必须保留先前已经保存的消息'); + assert.equal(fs.existsSync(runPath), false, '恢复提交成功后才可清理运行目录'); + api.recoverProcesses(); + assert.equal(createServerHarness(dir).loadSession(sessionId).messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '再次恢复不得重复提交'); + }); + + check('Codex App 完成时会话读取失败保留完整 turn 且读取恢复后恰好提交一次', dir => { + const sessionId = 'app-complete-load-failure'; + const seedApi = createServerHarness(dir); + assert.equal(seedApi.saveSession(sessionFixture(sessionId, 'codexapp', [ + { id: 'user-before-app', role: 'user', content: '读取异常前已经保存的 App 问题' }, + ])), true); + const archivePath = path.join(dir, '_history', `${sessionId}.json`); + const archiveBefore = fs.readFileSync(archivePath, 'utf8'); + // 同 CLI 夹具:损坏真实档案制造“会话暂不可读”,恢复时先修复档案再补交。 + const corruptedArchive = '{损坏的会话归档:Codex App 读取失败夹具'; + fs.writeFileSync(archivePath, corruptedArchive); + const api = createCodexAppStateApi(dir); + const fullText = 'Codex App 完成时必须保留的唯一输出'; + const toolCalls = [{ id: 'app-load-tool', name: 'exec', input: { command: 'fixture command' }, result: '完整工具结果', done: true }]; + const entry = { + agent: 'codexapp', ws: {}, cwd: dir, threadId: 'thread-complete-load', turnId: 'turn-complete-load', + turnStatus: 'running', startedAt: new Date(1800000000000).toISOString(), fullText, toolCalls, + toolOutputDeltas: new Map([['app-load-tool', '完整工具结果']]), agentMessageItems: new Map([['app-load-message', fullText]]), + lastUsage: null, lastError: null, userAborted: false, retryRequest: null, + }; + api.writeCodexAppTurnState(sessionId, entry); + // 独立建立完整 turn 夹具,使 HEAD 对照也能从真实文件清理行为失败, + // 而不是仅因旧 writer 未实现完整归档或返回值不同而失败。 + seedApi.sessionHistoryStore.saveTurn(sessionId, { + version: 1, agent: 'codexapp', sessionId, cwd: dir, threadId: entry.threadId, turnId: entry.turnId, + turnStatus: 'running', startedAt: entry.startedAt, updatedAt: entry.startedAt, fullText, toolCalls, + toolOutputDeltas: [...entry.toolOutputDeltas], agentMessageItems: [...entry.agentMessageItems], + lastUsage: null, lastError: null, userAborted: false, + }); + const turnPath = path.join(dir, '_history', '_turns', `${sessionId}.json`); + const runPath = path.join(dir, `${sessionId}-run`); + const previewPath = path.join(runPath, 'codexapp-state.json'); + const outputPath = path.join(runPath, 'output.jsonl'); + fs.writeFileSync(outputPath, `${JSON.stringify({ type: 'fixture', text: fullText })}\n`); + const outputBefore = fs.readFileSync(outputPath, 'utf8'); + assert.equal(fs.existsSync(turnPath), true, '夹具必须包含真实完整 turn 文件'); + assert.equal(fs.existsSync(previewPath), true, '夹具必须包含真实运行态预览文件'); + api.activeCodexAppTurns.set(sessionId, entry); + api.handleCodexAppTurnComplete(sessionId); + + assert.equal(fs.existsSync(turnPath), true, '读取失败不得清理真实完整 turn 文件'); + assert.equal(fs.existsSync(runPath), true, '读取失败不得清理 App 运行目录'); + assert.equal(fs.existsSync(previewPath), true, '读取失败不得清理运行态预览'); + assert.equal(fs.readFileSync(outputPath, 'utf8'), outputBefore, '读取失败不得删除或改写唯一输出文件'); + assert.equal(fs.readFileSync(archivePath, 'utf8'), corruptedArchive, '读取失败不得重写完整会话归档'); + const retainedTurn = seedApi.sessionHistoryStore.loadTurn(sessionId); + assert.equal(retainedTurn.fullText, fullText, '完成时最新完整正文应仍在运行态中'); + assert.deepEqual(retainedTurn.toolCalls, toolCalls); + assert.equal(retainedTurn.turnStatus, 'completed', '完成但归档失败的 turn 必须保留完成状态'); + assert.equal(entry.historySaved, false, '会话不可读时 entry 不得宣称已保存'); + assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_load_failed'), '读取失败应回报 session_load_failed'); + assert(api.sent.some(payload => payload.type === 'done' && payload.historySaved === false), '读取失败的 done 应为 historySaved=false'); + assert.equal(api.activeCodexAppTurns.has(sessionId), false, '已经完成的 App turn 不应仍显示运行'); + + fs.writeFileSync(archivePath, archiveBefore); + assert.equal(api.recoverCodexAppTurnState(sessionId), true); + const restored = createServerHarness(dir).loadSession(sessionId); + assert.equal(restored.messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '读取恢复后应恰好补一条 App 回复'); + assert.equal(restored.messages[0].id, 'user-before-app'); + assert.deepEqual(restored.messages.find(message => message.role === 'assistant').toolCalls, toolCalls); + assert.equal(restored.messages.some(message => message.codexAppTurnKey?.endsWith(':notice')), false, '完成 turn 恢复不应伪装成被中断'); + assert.equal(fs.existsSync(turnPath), false, '归档提交成功后才可删除完整 turn'); + assert.equal(fs.existsSync(runPath), false, '归档提交成功后才可清理 App 运行目录'); + api.recoverCodexAppTurnState(sessionId); + assert.equal(createServerHarness(dir).loadSession(sessionId).messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '再次恢复不得重复提交 App 回复'); + }); + + check('CLI 纯文本完成回合保存为字符串形状并可重开读取', dir => { + const api = createCliCompletionApi(dir); + const sessionId = 'cli-text'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codex', [{ id: 'u1', role: 'user', content: '问题' }])); + writeRunArtifacts(dir, sessionId, { runKey: `cli:${sessionId}:11111111-1111-1111-1111-111111111111` }); + api.activeProcesses.set(sessionId, entryFixture({ ws: {}, fullText: '纯文本最终回复', pid: 999999 })); + api.handleProcessComplete(sessionId, 0, null); + + const stored = api.storeApi.loadSession(sessionId); + const assistant = stored.messages.at(-1); + assert.equal(assistant.role, 'assistant'); + assert.equal(typeof assistant.content, 'string', '纯文本回合必须保持旧字符串形状,不能再变成内容块数组'); + assert.equal(assistant.content, '纯文本最终回复'); + assert.equal(assistant.runKey, `cli:${sessionId}:11111111-1111-1111-1111-111111111111`); + assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false, '保存成功后应清理运行目录'); + assert.equal(api.activeProcesses.has(sessionId), false); + }); + + check('CLI 混合内容块与纯工具回合完整归档', dir => { + const api = createCliCompletionApi(dir); + const mixedId = 'cli-mixed'; + api.storeApi.saveSession(sessionFixture(mixedId, 'codex', [])); + const blocks = [ + { type: 'text', text: '前缀正文' }, + { type: 'image', attachmentId: 'att-1', mime: 'image/png' }, + { type: 'text', text: '结构化尾部' }, + ]; + api.activeProcesses.set(mixedId, entryFixture({ + ws: {}, + fullText: '前缀正文结构化尾部', + contentBlocks: blocks, + toolCalls: [{ id: 'tool-1', name: 'exec', input: { command: 'ls' }, result: 'ok', done: true }], + })); + writeRunArtifacts(dir, mixedId, {}); + api.handleProcessComplete(mixedId, 0, null); + const storedMixed = api.storeApi.loadSession(mixedId).messages.at(-1); + assert.deepEqual(storedMixed.content, blocks, '结构化与图片内容块必须原样归档'); + assert.equal(storedMixed.toolCalls.length, 1); + + const toolOnlyId = 'cli-tool-only'; + api.storeApi.saveSession(sessionFixture(toolOnlyId, 'codex', [])); + api.activeProcesses.set(toolOnlyId, entryFixture({ + ws: {}, + fullText: '', + toolCalls: Array.from({ length: 130 }, (_, index) => ({ id: `tool-${index}`, name: 'exec', input: `输入${index}`, result: `结果${index}`, done: true })), + })); + writeRunArtifacts(dir, toolOnlyId, {}); + api.handleProcessComplete(toolOnlyId, 0, null); + const storedToolOnly = api.storeApi.loadSession(toolOnlyId).messages.at(-1); + assert.equal(storedToolOnly.content, '', '纯工具回合正文保持空字符串'); + assert.equal(storedToolOnly.toolCalls.length, 130, '纯工具回合的 130 条工具调用必须完整归档'); + }); + + check('CLI 完成保存失败时保留运行目录与唯一输出', dir => { + const api = createCliCompletionApi(dir); + const sessionId = 'cli-save-failure'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codex', [])); + const runPath = writeRunArtifacts(dir, sessionId, { + outputLines: [{ type: 'item.completed', item: { id: 'm1', type: 'agent_message', text: '尚未落盘的唯一正文' } }], + }); + api.activeProcesses.set(sessionId, entryFixture({ ws: {}, fullText: '尚未落盘的唯一正文' })); + const failingApi = createCliCompletionApi(dir, { saveSession: () => false, fs: fs, storeFs: fs }); + failingApi.activeProcesses.set(sessionId, entryFixture({ ws: {}, fullText: '尚未落盘的唯一正文' })); + failingApi.handleProcessComplete(sessionId, 0, null); + + assert.equal(fs.existsSync(runPath), true, '保存失败不得清理运行目录'); + assert.equal(fs.existsSync(path.join(runPath, 'output.jsonl')), true, '原始输出必须保留'); + assert(api.storeApi.loadSession(sessionId).messages.length === 0, '保存失败时不能假装已入库'); + assert(failingApi.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed'), '应上报保存失败'); + assert(failingApi.sent.some(payload => payload.type === 'done' && payload.historySaved === false), 'done 应标记 historySaved=false'); + + // 存储恢复后由 recoverProcesses 补齐,且重复恢复不重复入库。 + api.recoverProcesses(); + const recovered = api.storeApi.loadSession(sessionId); + assert.equal(recovered.messages.length, 1, '恢复后应恰好补入一条助手消息'); + assert.equal(recovered.messages[0].content, '尚未落盘的唯一正文'); + assert.equal(fs.existsSync(runPath), false, '成功补齐后应清理运行目录'); + api.recoverProcesses(); + assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '重复恢复不得重复入库'); + }); + + check('CLI 清理失败后的重复恢复保持幂等', dir => { + const failingFs = new Proxy(fs, { + get(target, key) { + if (key === 'rmSync') return (targetPath, options) => { + if (String(targetPath).endsWith('-run')) { + const error = new Error('模拟清理失败'); + error.code = 'EACCES'; + throw error; + } + return target.rmSync(targetPath, options); + }; + return target[key]; + }, + }); + const api = createCliCompletionApi(dir, { fs: failingFs }); + const sessionId = 'cli-cleanup-failure'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codex', [])); + const runPath = writeRunArtifacts(dir, sessionId, { + outputLines: [{ type: 'item.completed', item: { id: 'm1', type: 'agent_message', text: '清理失败也要只存一次' } }], + }); + api.recoverProcesses(); + assert.equal(fs.existsSync(runPath), true, '清理失败时运行目录仍在'); + assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '首次恢复应入库一次'); + api.recoverProcesses(); + assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '第二次恢复必须按 runKey 去重'); + fs.rmSync(runPath, { recursive: true, force: true }); + api.recoverProcesses(); + assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '清理完成后仍不得重复入库'); + }); + + check('Codex App 完整 turn 运行态在完成前保存且恢复只入库一次', dir => { + const api = createCodexAppStateApi(dir); + const sessionId = 'codexapp-full'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', [{ id: 'u1', role: 'user', content: '长任务' }])); + const fullText = '长正文'.repeat(150000) + '[尾部标记]'; + const toolCalls = [{ id: 'tool-1', name: 'exec', input: { command: 'ls' }, result: 'ok', done: true }]; + const entry = { + agent: 'codexapp', cwd: dir, threadId: 'thread-1', turnId: 'turn-1', turnStatus: 'running', + startedAt: new Date(1800000000000).toISOString(), fullText, toolCalls, + toolOutputDeltas: new Map([['tool-1', 'delta']]), agentMessageItems: new Map([['m1', 'item']]), + lastUsage: { inputTokens: 1 }, lastError: null, userAborted: false, + }; + assert.equal(api.writeCodexAppTurnState(sessionId, entry), true, '完整 turn 状态应保存成功'); + const completeTurn = api.storeApi.sessionHistoryStore.loadTurn(sessionId); + assert.equal(completeTurn.fullText, fullText, '完整运行态不得被预览上限裁剪'); + assert.deepEqual(completeTurn.toolCalls, toolCalls); + assert.equal(api.loadCodexAppTurnState(sessionId).fullText, fullText, '恢复必须优先读取完整运行态'); + + assert.equal(api.recoverCodexAppTurnState(sessionId), true); + const session = api.storeApi.loadSession(sessionId); + const assistant = session.messages.find(message => message.role === 'assistant'); + assert(assistant, '恢复后应有助手消息'); + assert.equal(assistant.content, fullText); + assert.equal(assistant.toolCalls.length, 1); + assert.equal(session.messages.some(message => message.role === 'system'), true, '未完成 turn 应有中断提示'); + assert.equal(api.storeApi.sessionHistoryStore.loadTurn(sessionId), null, '恢复后应清理完整运行态'); + assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false, '恢复后应清理运行目录'); + + api.recoverCodexAppTurnState(sessionId); + assert.equal(api.storeApi.loadSession(sessionId).messages.filter(message => message.role === 'assistant').length, 1, '重复恢复不得重复入库'); + }); + + check('Codex App 会话暂不可读时保留运行态并在恢复后入库', dir => { + const api = createCodexAppStateApi(dir, { loadSession: () => null }); + const sessionId = 'codexapp-unavailable'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', [])); + const writeApi = createCodexAppStateApi(dir); + const entry = { + agent: 'codexapp', cwd: dir, threadId: 'thread-2', turnId: 'turn-2', turnStatus: 'completed', + startedAt: new Date(1800000000000).toISOString(), fullText: '会话暂不可读时的输出', toolCalls: [], + toolOutputDeltas: new Map(), agentMessageItems: new Map(), lastUsage: null, lastError: null, userAborted: false, + }; + writeApi.writeCodexAppTurnState(sessionId, entry); + assert.equal(api.recoverCodexAppTurnState(sessionId), true, '会话不可读时应保留运行态'); + assert(api.storeApi.sessionHistoryStore.loadTurn(sessionId), '完整运行态不得被清理'); + assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), true, '运行目录不得被清理'); + assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`, 'codexapp-state.json')), true, '预览状态不得被清理'); + + const recoveryApi = createCodexAppStateApi(dir); + assert.equal(recoveryApi.recoverCodexAppTurnState(sessionId), true); + const session = recoveryApi.storeApi.loadSession(sessionId); + assert.equal(session.messages.filter(message => message.role === 'assistant').length, 1, '会话恢复后应恰好入库一次'); + assert.equal(session.messages.find(message => message.role === 'assistant').content, '会话暂不可读时的输出'); + assert.equal(recoveryApi.storeApi.sessionHistoryStore.loadTurn(sessionId), null); + assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false); + }); + + check('Codex App 损坏运行态文件保留且不误入库', dir => { + const api = createCodexAppStateApi(dir); + const sessionId = 'codexapp-corrupt'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', [])); + const runPath = path.join(dir, `${sessionId}-run`); + fs.mkdirSync(runPath, { recursive: true }); + fs.writeFileSync(path.join(runPath, 'codexapp-state.json'), '{ 损坏的 JSON'); + assert.equal(api.recoverCodexAppTurnState(sessionId), true); + assert.equal(fs.existsSync(path.join(runPath, 'codexapp-state.json')), true, '损坏状态文件必须保留供人工排查'); + assert.equal(api.storeApi.loadSession(sessionId).messages.length, 0, '损坏状态不得伪造助手消息'); + }); + + check('Codex App 完成保存失败时先保留完整运行态再可恢复', dir => { + const api = createCodexAppStateApi(dir, { saveSession: () => false }); + const sessionId = 'codexapp-save-failure'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', [{ id: 'u1', role: 'user', content: '任务' }])); + api.activeCodexAppTurns.set(sessionId, { + agent: 'codexapp', ws: {}, threadId: 'thread-3', turnId: 'turn-3', turnStatus: 'running', + startedAt: new Date(1800000000000).toISOString(), fullText: '完成时尚未入库的唯一正文', toolCalls: [], + toolOutputDeltas: new Map(), agentMessageItems: new Map(), lastUsage: null, lastError: null, userAborted: false, + retryRequest: null, + }); + api.handleCodexAppTurnComplete(sessionId); + assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed'), '应上报完整会话保存失败'); + const savedTurn = api.storeApi.sessionHistoryStore.loadTurn(sessionId); + assert(savedTurn, '保存失败时必须留下完整运行态'); + assert.equal(savedTurn.fullText, '完成时尚未入库的唯一正文'); + assert.equal(savedTurn.turnStatus, 'completed'); + assert.equal(api.activeCodexAppTurns.has(sessionId), false); + + const recoveryApi = createCodexAppStateApi(dir); + assert.equal(recoveryApi.recoverCodexAppTurnState(sessionId), true); + const session = recoveryApi.storeApi.loadSession(sessionId); + assert.equal(session.messages.filter(message => message.role === 'assistant' && message.content === '完成时尚未入库的唯一正文').length, 1, '恢复后应恰好一条正文消息'); + assert.equal(session.messages.some(message => message.role === 'system'), false, '已完成 turn 恢复不应补中断提示'); + }); + + check('初次用户消息保存失败时不启动运行且不产生运行目录', dir => { + const api = createMessageSaveFailureApi(dir, { saveSession: () => false }); + const sessionId = 'message-save-failure'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codex', [])); + const result = api.handleMessage({ readyState: 1 }, { text: '新问题', sessionId }); + assert.equal(result?.ok, false); + assert.equal(result?.code, 'session_save_failed'); + assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed'), '应回报用户消息保存失败'); + assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false, '保存失败不得创建运行目录'); + assert.equal(api.storeApi.loadSession(sessionId).messages.length, 0, '磁盘上的会话不得被静默改写'); + }); + + check('跨会话回传保存失败保留 ready 且重试恰好一个气泡', dir => { + const api = createCrossConversationApi(dir); + const sourceId = 'src'; + const targetId = 'tgt'; + api.storeApi.saveSession(sessionFixture(sourceId, 'codex', [])); + api.storeApi.saveSession(sessionFixture(targetId, 'codex', [{ id: 'u1', role: 'user', content: '请求' }])); + const pendingFixture = () => ({ + requestId: 'req-1', messageId: 'm1', sourceConversationId: sourceId, sourceTitle: '来源', + targetConversationId: targetId, targetTitle: '目标', status: 'ready', + createdAt: new Date().toISOString(), hopCount: 1, sourceAutoRun: false, + originalRequest: '原始请求', replyText: '子对话返回正文', completedAt: null, returnedAt: null, + }); + assert(api.setPendingCrossConversationReply('req-1', pendingFixture())); + api.options.failSave = true; + assert.equal(api.deliverCrossConversationReply('req-1'), false, '保存失败时回传不得计为完成'); + const retained = api.pendingCrossConversationReplies.get('req-1'); + assert.equal(retained.status, 'ready', '保存失败后必须退回 ready 以保留重试机会'); + assert.equal(retained.lastError, 'session_save_failed'); + assert.equal(api.storeApi.loadSession(sourceId).messages.length, 0, '保存失败不得留下半条回传气泡'); + + api.options.failSave = false; + assert.equal(api.deliverCrossConversationReply('req-1'), true); + assert.equal(api.pendingCrossConversationReplies.has('req-1'), false); + const delivered = api.storeApi.loadSession(sourceId).messages; + assert.equal(delivered.filter(message => message.crossConversation?.replyToRequestId === 'req-1').length, 1); + assert(delivered[0].content.includes('子对话返回正文')); + + // 模拟“气泡已落盘但等待状态未更新”的崩溃重试:处理标记必须阻止第二个气泡。 + api.setPendingCrossConversationReply('req-1', pendingFixture()); + assert.equal(api.deliverCrossConversationReply('req-1'), true); + api.flushPendingCrossConversationReplies(sourceId); + assert.equal( + api.storeApi.loadSession(sourceId).messages.filter(message => message.crossConversation?.replyToRequestId === 'req-1').length, + 1, + '崩溃重试不得重复回传气泡', + ); + }); + + check('子会话冲刷保存失败时保留队列并在恢复后提交', dir => { + const api = createChildFlushApi(dir, { failSave: true }); + const sessionId = 'child-parent'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codex', [{ + id: 'm1', role: 'assistant', content: '', + toolCalls: [{ id: 'spawn-1', name: 'spawn_agent', input: {}, done: false }], + }])); + const tool = api.updatePersistedCcwebMcpChildTool(sessionId, { + threadId: 'th-1', spawnToolId: 'spawn-1', status: 'running', finalMessage: '子代理结果', + }); + assert(tool, '子会话状态应能合并到目标工具'); + assert(api.pendingCcwebMcpChildSessionFlushes.has(sessionId), '状态更新后应存在待冲刷队列'); + assert.equal(api.storeApi.loadSession(sessionId).messages[0].toolCalls[0].result, undefined, '保存失败不得假装已落盘'); + + assert.equal(api.flushPendingCcwebMcpChildSession(sessionId), false, '保存失败时冲刷必须返回 false'); + assert(api.pendingCcwebMcpChildSessionFlushes.has(sessionId), '保存失败后队列必须保留'); + const pending = api.pendingCcwebMcpChildSessionFlushes.get(sessionId); + assert.equal(pending.retryScheduled, true, '保存失败后应安排一次有界重试'); + if (pending.timer) { + clearTimeout(pending.timer); + pending.timer = null; + } + + api.options.failSave = false; + assert.equal(api.flushPendingCcwebMcpChildSession(sessionId), true, '存储恢复后冲刷应成功'); + assert.equal(api.pendingCcwebMcpChildSessionFlushes.has(sessionId), false); + assert.equal(api.storeApi.loadSession(sessionId).messages[0].toolCalls[0].result, '子代理结果'); + assert.equal(api.flushPendingCcwebMcpChildSession(sessionId), false, '队列已清空后再次冲刷应为空操作'); + }); + + check('任务看板元数据适配器保留完整历史且不接受截断对象', dir => { + const api = createTaskBoardAdapterApi(dir); + const sessionId = 'task-adapter'; + const messages = Array.from({ length: 650 }, (_, index) => ({ + id: `m-${index}`, + role: index % 2 ? 'assistant' : 'user', + content: `完整历史 ${index}`, + timestamp: new Date(1800000000000 + index * 1000).toISOString(), + })); + api.storeApi.saveSession({ + ...sessionFixture(sessionId, 'codexapp', messages), + taskTracking: { enabled: true, statusId: 'unassigned', version: 1 }, + }); + const preview = JSON.parse(fs.readFileSync(path.join(dir, `${sessionId}.json`), 'utf8')); + assert(preview.messages.length <= 181, '磁盘快照本身应有界'); + + // 任务服务只回传它负责的元数据,适配器必须重新读取可信档案后提交。 + assert.equal(api.saveTaskBoardSessionMetadata({ + id: sessionId, + taskTracking: { enabled: true, statusId: 'waiting-release', version: 2 }, + }), true); + const reloaded = createServerHarness(dir).loadSession(sessionId); + assert.equal(reloaded.messages.length, 650, '任务看板保存元数据不得截断完整历史'); + assert.equal(reloaded.messages[0].content, '完整历史 0'); + assert.equal(reloaded.taskTracking.statusId, 'waiting-release'); + assert.equal(reloaded.taskTracking.version, 2); + }); + + return checkAsync('Codex App steer 保存失败时不发送 turn/steer', async dir => { + const api = createSteerSaveFailureApi(dir); + const sessionId = 'steer-save-failure'; + api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', [])); + api.maps.activeCodexAppTurns.set(sessionId, { ws: null, threadId: 'thread-steer', turnId: 'turn-steer' }); + api.handleCodexAppSteerMessage({ readyState: 1 }, { text: '引导内容', sessionId, clientMessageId: 'c1' }); + await new Promise(resolve => setTimeout(resolve, 25)); + assert.equal(api.requestCalls.length, 0, '保存失败不得调用 turn/steer'); + assert(api.sent.some(payload => payload.type === 'codex_app_steer_status' && payload.status === 'failed')); + assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed')); + assert.equal(api.storeApi.loadSession(sessionId).messages.length, 0, '保存失败的引导消息不得出现在磁盘'); + }); +} + +if (require.main === module) { + const baselineHead = process.argv.includes('--baseline-head'); + if (baselineHead) { + serverSource = execFileSync('git', ['show', 'HEAD:server.js'], { cwd: REPO_DIR, encoding: 'utf8' }); + process.on('unhandledRejection', error => { + failures.push(`基线异步失败:${error?.message || error}`); + }); + } + runTests().then(() => { + if (baselineHead) { + const expectedFailures = [ + 'CLI 完成时会话读取失败保留真实输出且读取恢复后恰好提交一次', + 'Codex App 完成时会话读取失败保留完整 turn 且读取恢复后恰好提交一次', + 'CLI 纯文本完成回合保存为字符串形状并可重开读取', + 'CLI 完成保存失败时保留运行目录与唯一输出', + 'Codex App 完整 turn 运行态在完成前保存且恢复只入库一次', + 'Codex App 会话暂不可读时保留运行态并在恢复后入库', + '初次用户消息保存失败时不启动运行且不产生运行目录', + '跨会话回传保存失败保留 ready 且重试恰好一个气泡', + '子会话冲刷保存失败时保留队列并在恢复后提交', + '任务看板元数据适配器保留完整历史且不接受截断对象', + ]; + const failingNames = failures.map(failure => failure.split(':')[0]); + const missing = expectedFailures.filter(name => !failingNames.includes(name)); + if (missing.length > 0) { + console.error(`\n基线复现缺少预期失败项:${missing.join('、')}`); + process.exit(1); + } + console.log(`\n基线对照(HEAD):${failures.length} 项按预期失败,覆盖旧实现的丢失/覆盖/重复缺陷`); + process.exit(0); + } + if (failures.length > 0) { + console.error(`\n${failures.length}/${checks + failures.length} 项失败`); + process.exit(1); + } + console.log(`\n${checks} 项全部通过`); + }); +} + +module.exports = { createCliCompletionApi, createCodexAppStateApi }; diff --git a/scripts/history-runtime-unit.js b/scripts/history-runtime-unit.js new file mode 100644 index 0000000..b5f8de0 --- /dev/null +++ b/scripts/history-runtime-unit.js @@ -0,0 +1,481 @@ +#!/usr/bin/env node +'use strict'; + +// 该回归直接调用生产运行时 factory / 事件入口,确认完整采集不再被预览限额裁剪。 +// WebSocket 仅为内存接收器;本脚本不读写真实会话,也不启动外部进程。 + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const Module = require('node:module'); +const path = require('node:path'); +const { createAgentRuntime } = require('../lib/agent-runtime'); +const { createCodexAppRuntime } = require('../lib/codex-app-runtime'); + +const REPO_DIR = path.join(__dirname, '..'); +const SERVER_PATH = path.join(REPO_DIR, 'server.js'); +const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]'; +const APP_TRUNCATED_TEXT = '[cc-web: 内容过长,已截断以保护服务稳定性]'; +const EXPECTED_BASELINE_FAILURES = [ + 'Claude user/tool_result 长结果完整采集', + 'Codex CLI 正文与 JSON 内容块混合采集', +]; +const invalidEventHandler = () => { + throw new Error('运行时完整采集回归不应调用 saveSession'); +}; + +function createRuntimeHarness(options = {}) { + const sent = []; + const ws = {}; + const previewApi = buildProductionPreviewApi(); + const wsSend = (target, payload) => previewApi.sendRuntimePreview(target, payload); + const agentRuntimeFactory = options.createAgentRuntime || createAgentRuntime; + const codexAppRuntimeFactory = options.createCodexAppRuntime || createCodexAppRuntime; + const cliRuntime = agentRuntimeFactory({ + processEnv: {}, + wsSend, + sanitizeToolInput: previewApi.sanitizeToolInput, + loadSession: () => null, + saveSession: invalidEventHandler, + }); + const codexAppRuntime = codexAppRuntimeFactory({ + wsSend, + loadSession: () => null, + saveSession: invalidEventHandler, + }); + return { cliRuntime, codexAppRuntime, previewApi, ws, sent }; +} + +function entry(agent, ws) { + return { agent, ws, fullText: '', toolCalls: [] }; +} + +function extractFunctionSource(source, name) { + const start = source.indexOf(`function ${name}(`); + assert(start >= 0, `server.js 应定义 ${name}`); + let parenDepth = 0; + let signatureEnd = -1; + for (let index = start; index < source.length; index += 1) { + if (source[index] === '(') parenDepth += 1; + if (source[index] === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnd = index; + break; + } + } + } + assert(signatureEnd > start, `${name} 应有完整签名`); + const open = source.indexOf('{', signatureEnd); + assert(open > start, `${name} 应有函数体`); + let depth = 0; + for (let index = open; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] === '}') { + depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + } + throw new Error(`${name} 函数体不完整`); +} + +function extractConstSource(source, name) { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = source.match(new RegExp(`^const ${escapedName} = .*;$`, 'm')); + assert(match, `server.js 应定义 ${name}`); + return match[0]; +} + +function buildProductionPreviewApi() { + const source = fs.readFileSync(SERVER_PATH, 'utf8'); + const functionNames = [ + 'readPositiveIntEnv', + 'truncateTextValue', + 'sanitizePersistValue', + 'sanitizeToolCallsForPersist', + 'sanitizeMessageForPersist', + 'sanitizeMessageForTransport', + 'safeJsonParse', + 'sanitizeToolInput', + 'sendRuntimePreview', + ]; + const constantNames = [ + 'SESSION_MESSAGE_CONTENT_MAX_CHARS', + 'SESSION_TOOL_INPUT_MAX_CHARS', + 'SESSION_TOOL_RESULT_MAX_CHARS', + 'SESSION_MAX_TOOL_CALLS_PER_MESSAGE', + 'SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS', + 'SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS', + 'SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS', + 'SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE', + 'PERSIST_TRUNCATED_TEXT', + ]; + return new Function('wsSend', ` + const process = { env: {} }; + ${functionNames.map((name) => extractFunctionSource(source, name)).join('\n')} + ${constantNames.map((name) => extractConstSource(source, name)).join('\n')} + return { sendRuntimePreview, sanitizeMessageForTransport, sanitizeToolInput }; + `)((target, payload) => target.received.push(payload)); +} + +function assertPreviewStringIsBounded(value, maxChars, label) { + assert.equal(typeof value, 'string', `${label}预览应保留字符串形状`); + assert(value.length <= maxChars, `${label}预览不应超过 ${maxChars} 个字符`); + assert( + value.includes(PERSIST_TRUNCATED_TEXT) || value.includes(APP_TRUNCATED_TEXT), + `${label}预览应标记被省略的内容,不能伪装成完整 record` + ); +} + +function assertPreviewIsBounded(value, maxChars, label) { + assert(value !== null && typeof value === 'object', `${label}预览应是对象`); + assertPreviewStringIsBounded(value.prompt, maxChars, `${label}正文`); + assert(value.rows.length <= 81, `${label}数组预览应保持有界`); + assert( + value.rows.some((row) => typeof row?.__truncated === 'string'), + `${label}数组预览应标注省略项目数` + ); +} + +function loadGitHeadFactory(relativePath, exportName) { + const source = execFileSync('git', ['show', `HEAD:${relativePath}`], { + cwd: REPO_DIR, + encoding: 'utf8', + }); + const filename = path.join(REPO_DIR, relativePath); + const loadedModule = new Module(filename, module); + loadedModule.filename = filename; + loadedModule.paths = Module._nodeModulePaths(path.dirname(filename)); + loadedModule._compile(source, filename); + assert.equal(typeof loadedModule.exports[exportName], 'function', `HEAD:${relativePath} 应导出 ${exportName}`); + return loadedModule.exports[exportName]; +} + +function assertRuntimeHistoryContract(options = {}) { + const { cliRuntime, codexAppRuntime, previewApi, ws, sent } = createRuntimeHarness(options); + ws.received = sent; + const longText = '长正文'.repeat(350000) + '[正文尾部]'; + const longToolInput = '工具参数'.repeat(6000) + '[参数尾部]'; + const failures = []; + const passed = []; + const check = (name, callback) => { + try { + callback(); + passed.push(name); + console.log(`通过:${name}`); + } catch (error) { + failures.push(`${name}:${error.message}`); + console.error(`失败:${name}:${error.message}`); + } + }; + + const claudeEntry = entry('claude', ws); + check('Claude CLI 长正文完整采集', () => { + cliRuntime.processRuntimeEvent(claudeEntry, { + type: 'assistant', + message: { content: [{ type: 'text', text: longText }] }, + }, 'fixture-full'); + assert.equal(claudeEntry.fullText, longText, 'Claude 1,050,006 字符正文应原样进入生产 runtime 记录'); + assert(!claudeEntry.fullText.includes(PERSIST_TRUNCATED_TEXT), 'Claude 完整正文不应包含裁剪标记'); + }); + + const codexEntry = entry('codex', ws); + check('Codex CLI 长正文完整采集', () => { + cliRuntime.processRuntimeEvent(codexEntry, { + type: 'item.completed', + item: { id: 'codex-message', type: 'agent_message', text: longText }, + }, 'fixture-full'); + assert.equal(codexEntry.fullText, longText, 'Codex 1,050,006 字符正文应原样进入生产 runtime 记录'); + assert(!codexEntry.fullText.includes(PERSIST_TRUNCATED_TEXT), 'Codex 完整正文不应包含裁剪标记'); + }); + + const codexAppEntry = entry('codexapp', ws); + const appTextStartIndex = sent.length; + check('Codex App 长正文完整采集', () => { + codexAppRuntime.processCodexAppNotification(codexAppEntry, { + method: 'item/agentMessage/delta', + params: { itemId: 'codexapp-message', delta: longText }, + }, 'fixture-full'); + assert.equal(codexAppEntry.fullText, longText, 'Codex App 1,050,006 字符增量应原样进入生产记录'); + assert( + !codexAppEntry.fullText.includes(PERSIST_TRUNCATED_TEXT), + 'Codex App 完整正文不应包含裁剪标记' + ); + }); + + const fullToolResult = '完整工具结果'.repeat(4200) + '[结果尾部]'; + const claudeToolEntry = entry('claude', ws); + cliRuntime.processRuntimeEvent(claudeToolEntry, { + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'claude-tool', + name: 'Read', + input: JSON.stringify({ path: '/fixture.txt', prompt: longToolInput }), + }], + }, + }, 'fixture-tool'); + check('Claude CLI JSON 字符串工具参数使用生产解析并完整保留', () => { + const parsedInput = claudeToolEntry.toolCalls.find((tool) => tool.id === 'claude-tool')?.input; + assert.equal(parsedInput?.path, '/fixture.txt', 'Claude 工具 JSON 输入应由生产 sanitizer 解析为对象'); + assert.equal(parsedInput?.prompt, longToolInput, '生产输入解析不应裁剪工具参数长字符串'); + }); + + check('Claude user/tool_result 长结果完整采集', () => { + cliRuntime.processRuntimeEvent(claudeToolEntry, { + type: 'user', + message: { + content: [{ type: 'tool_result', tool_use_id: 'claude-tool', content: fullToolResult }], + }, + }, 'fixture-tool'); + const claudeTool = claudeToolEntry.toolCalls.find((tool) => tool.id === 'claude-tool'); + assert.equal(claudeTool?.done, true, 'Claude user/tool_result 应结束对应工具调用'); + assert.equal(claudeTool?.result, fullToolResult, 'Claude 完整工具结果应在用户事件中进入生产记录'); + }); + + const codexToolEntry = entry('codex', ws); + check('Codex CLI 长工具结果完整采集', () => { + cliRuntime.processRuntimeEvent(codexToolEntry, { + type: 'item.completed', + item: { + id: 'codex-tool', + type: 'command_execution', + command: 'printf fixture', + status: 'completed', + aggregated_output: fullToolResult, + }, + }, 'fixture-tool'); + assert.equal( + codexToolEntry.toolCalls.find((tool) => tool.id === 'codex-tool')?.result, + fullToolResult, + 'Codex CLI 完整聚合输出应在生产记录中原样保留' + ); + }); + + const codexLongInput = 'Codex 命令参数'.repeat(5000) + '[参数尾部]'; + const codexInputEntry = entry('codex', ws); + check('Codex CLI 长工具输入完整采集', () => { + cliRuntime.processRuntimeEvent(codexInputEntry, { + type: 'item.started', + item: { + id: 'codex-long-command', + type: 'command_execution', + command: codexLongInput, + }, + }, 'fixture-tool-input'); + assert.equal( + codexInputEntry.toolCalls[0]?.input?.command, + codexLongInput, + 'Codex CLI 完整工具输入不应受传输预览上限影响' + ); + }); + + const longAppToolResult = '工具返回'.repeat(12000) + '[工具尾部]'; + const codexAppToolEntry = entry('codexapp', ws); + const largeTool = { + id: 'codexapp-large-tool', + type: 'mcpToolCall', + server: 'fixture', + tool: 'long_payload', + arguments: { + prompt: longToolInput, + rows: Array.from({ length: 150 }, (_, index) => ({ index, values: [index, index + 1] })), + }, + result: { content: [{ type: 'text', text: longAppToolResult }] }, + }; + check('Codex App 超限工具输入、结果与数组完整采集', () => { + codexAppRuntime.processCodexAppNotification(codexAppToolEntry, { + method: 'item/completed', + params: { item: largeTool }, + }, 'fixture-tool'); + assert.deepEqual( + codexAppToolEntry.toolCalls[0]?.input.arguments, + largeTool.arguments, + 'Codex App 完整输入对象、长字符串和大数组都应在 entry 原样保留' + ); + assert.equal( + codexAppToolEntry.toolCalls[0]?.result, + longAppToolResult, + 'Codex App 完整工具结果应原样序列化' + ); + const largeToolInputPreview = sent.find((payload) => payload.toolUseId === 'codexapp-large-tool' && payload.type === 'tool_start'); + const largeToolResultPreview = sent.find((payload) => payload.toolUseId === 'codexapp-large-tool' && payload.type === 'tool_end'); + assert(largeToolInputPreview, 'Codex App 启动的大工具调用应产生真实 WebSocket 输入预览'); + assert(largeToolResultPreview, 'Codex App 完成的大工具调用应产生真实 WebSocket 结果预览'); + assertPreviewIsBounded(largeToolInputPreview.input.arguments, 8 * 1024, 'Codex App 工具输入'); + assertPreviewStringIsBounded(largeToolResultPreview.result, 16 * 1024, 'Codex App 工具结果'); + }); + + const manyToolsEntry = entry('codexapp', ws); + const toolLimit = 40; + const toolCount = 130; + check(`超出 WebSocket 预览条数上限 ${toolLimit} 的 ${toolCount} 条工具仍完整采集`, () => { + for (let index = 0; index < toolCount; index += 1) { + codexAppRuntime.processCodexAppNotification(manyToolsEntry, { + method: 'item/completed', + params: { + item: { + id: `codexapp-tool-${index}`, + type: 'mcpToolCall', + server: 'fixture', + tool: 'short_payload', + arguments: { index, text: `输入-${index}` }, + result: { content: [{ type: 'text', text: `结果-${index}` }] }, + }, + }, + }, 'fixture-many-tools'); + } + assert.equal( + manyToolsEntry.toolCalls.length, + toolCount, + `超过传输预览条数上限 ${toolLimit} 的 ${toolCount} 条工具都应保留在 entry` + ); + assert.equal(manyToolsEntry.toolCalls[0]?.id, 'codexapp-tool-0', '工具数组应保留首项及原始顺序'); + assert.equal( + manyToolsEntry.toolCalls[toolCount - 1]?.result, + `结果-${toolCount - 1}`, + '工具数组应保留最后一项完整结果' + ); + assert.equal( + sent.filter((payload) => payload.type === 'tool_end' && /^codexapp-tool-/.test(payload.toolUseId)).length, + toolCount, + '每条工具事件均应发送生产预览,完整采集不可让工具数超限跳过事件' + ); + }); + + const mixedCodexEntry = entry('codex', ws); + check('Codex CLI 正文与 JSON 内容块混合采集', () => { + const mixedParts = [ + ['mixed-1', '开头正文'], + ['mixed-2', JSON.stringify([{ type: 'text', text: '内容块中正文' }])], + ['mixed-3', '末尾正文'], + ]; + for (const [itemId, text] of mixedParts) { + cliRuntime.processRuntimeEvent(mixedCodexEntry, { + type: 'item.completed', + item: { id: itemId, type: 'agent_message', text }, + }, 'fixture-mixed'); + } + assert(mixedCodexEntry.fullText.includes('开头正文'), 'Codex CLI 完整正文应保留 JSON 内容块之前的文本'); + assert(mixedCodexEntry.fullText.includes('末尾正文'), 'Codex CLI 完整正文应保留 JSON 内容块之后的文本'); + assert( + mixedCodexEntry.contentBlocks?.some((block) => block.text === '内容块中正文'), + 'Codex CLI 正文数组应保留完整的 JSON 内容块' + ); + const consumerSelectedContent = mixedCodexEntry.contentBlocks || mixedCodexEntry.fullText; + const selectedText = typeof consumerSelectedContent === 'string' + ? consumerSelectedContent + : JSON.stringify(consumerSelectedContent); + assert( + selectedText.includes('开头正文') && selectedText.includes('末尾正文'), + 'Codex CLI 混合记录按生产完成路径选择 contentBlocks || fullText 时应不丢两端文本' + ); + }); + + const completedAppEntry = entry('codexapp', ws); + check('Codex App 增量与完成事件补尾且不重复', () => { + codexAppRuntime.processCodexAppNotification(completedAppEntry, { + method: 'item/agentMessage/delta', + params: { itemId: 'codexapp-finished-message', delta: '流式正文' }, + }, 'fixture-complete'); + codexAppRuntime.processCodexAppNotification(completedAppEntry, { + method: 'item/completed', + params: { + item: { + id: 'codexapp-finished-message', + type: 'agentMessage', + text: '流式正文与完成事件补尾', + }, + }, + }, 'fixture-complete'); + assert.equal( + completedAppEntry.fullText, + '流式正文与完成事件补尾', + '完成事件只应将尚未收到的尾部追加到完整正文' + ); + }); + + check('生产 WebSocket 工具预览有界且 Codex App 正文分片完整', () => { + const cliWireText = sent.find((payload) => payload.type === 'text_delta' && payload.text?.includes('长正文')); + assert(cliWireText, 'CLI 长正文应发出真实 WebSocket 增量'); + assertPreviewStringIsBounded(cliWireText.text, 16 * 1024, 'CLI 文本增量'); + const claudeWireResult = sent.find((payload) => payload.type === 'tool_end' && payload.toolUseId === 'claude-tool'); + if (claudeWireResult) assertPreviewStringIsBounded(claudeWireResult.result, 16 * 1024, 'Claude 工具结果'); + else assert(failures.includes('Claude user/tool_result 长结果完整采集'), 'Claude 用户工具结果缺少 tool_end 时应由对应事件断言报告'); + const appChunks = sent.slice(appTextStartIndex).filter((payload) => ( + payload.type === 'text_delta' + && payload.sessionId === 'fixture-full' + )); + assert(appChunks.length > 1, 'Codex App 大增量应分片发送'); + assert(appChunks.every((payload) => payload.text.length <= 16 * 1024), 'Codex App 每片正文保持传输大小上限'); + assert.equal(appChunks.map((payload) => payload.text).join(''), longText, 'Codex App 所有分片拼接后完整保留正文'); + }); + + check('完成及历史正文读取保留 130 条工具和完整输入结果', () => { + // 历史与 done 读取完整记录;实时 tool_start/tool_end 的预览上限另有断言。 + const completeTools = manyToolsEntry.toolCalls.map((tool, index) => index === 65 + ? { ...tool, input: largeTool.arguments, result: longAppToolResult } + : tool); + const transportMessage = previewApi.sanitizeMessageForTransport({ + role: 'assistant', + content: longText, + toolCalls: completeTools, + }); + assert.equal(transportMessage.content, longText, '历史与完成是正文读取入口,必须保留完整正文供折叠和复制'); + assert.equal(transportMessage.toolCalls.length, toolCount, '完成和历史不可用工具预览条数上限替代完整列表'); + assert.deepEqual(transportMessage.toolCalls, completeTools, '完整工具数量、顺序、输入对象和结果不得缩水'); + assert.equal(transportMessage.toolCalls[65].input.prompt, longToolInput, '完成及历史工具输入长字符串完整读回'); + assert.equal(transportMessage.toolCalls[65].result, longAppToolResult, '完成及历史工具结果长字符串完整读回'); + assert(!transportMessage.toolCalls.some(tool => tool.id === 'ccweb-toolcalls-truncated'), '完整读取不得注入工具省略标记'); + }); + + if (failures.length > 0 && !options.returnFailures) { + throw new Error(`运行时完整采集回归发现 ${failures.length} 个失败:\n${failures.map((message) => `- ${message}`).join('\n')}`); + } + return { + checkedTextChars: longText.length, + checkedToolInputChars: longToolInput.length, + checkedToolResultChars: longAppToolResult.length, + checkedToolCalls: toolCount, + webSocketPreviews: sent.length, + passed, + failures, + }; +} + +function runBaselineHead() { + const result = assertRuntimeHistoryContract({ + createAgentRuntime: loadGitHeadFactory('lib/agent-runtime.js', 'createAgentRuntime'), + createCodexAppRuntime: loadGitHeadFactory('lib/codex-app-runtime.js', 'createCodexAppRuntime'), + returnFailures: true, + }); + const failedNames = new Set(result.failures.map((failure) => failure.split(':', 1)[0])); + const missingProofs = EXPECTED_BASELINE_FAILURES.filter((name) => !failedNames.has(name)); + assert.equal( + missingProofs.length, + 0, + `HEAD 基线未复现预期缺陷:${missingProofs.join('、') || '未知'}` + ); + console.log(`通过:HEAD 旧版已复现预期失败(${JSON.stringify(result.failures)})`); +} + +function run() { + if (process.argv.includes('--baseline-head')) { + runBaselineHead(); + return; + } + const result = assertRuntimeHistoryContract(); + console.log(`通过:生产 runtime 完整采集与有界传输(${JSON.stringify({ + checkedTextChars: result.checkedTextChars, + checkedToolInputChars: result.checkedToolInputChars, + checkedToolResultChars: result.checkedToolResultChars, + checkedToolCalls: result.checkedToolCalls, + webSocketPreviews: result.webSocketPreviews, + passed: result.passed.length, + })})`); +} + +if (require.main === module) run(); + +module.exports = { assertRuntimeHistoryContract }; diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 5454211..f10576c 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -792,6 +792,40 @@ function emitPartialCapacityOutput(thread, turnId) { }); } +// 多次延续重试夹具:正文首段超过提示及快照上限,每轮故意复用原生 item ID。 +function emitRetryHistoryPart(thread, turnId, label) { + const padding = label === 'A' ? '甲'.repeat(220000) : '乙'.repeat(15000); + const text = `retry-history-${label}-begin\n${padding}\nretry-history-${label}-end`; + for (let offset = 0; offset < text.length; offset += 8192) { + send({ + method: 'item/agentMessage/delta', + params: { threadId: thread.id, turnId, itemId: 'retry-history-agent', delta: text.slice(offset, offset + 8192) }, + }); + } + send({ + method: 'item/completed', + params: { threadId: thread.id, turnId, item: { id: 'retry-history-agent', type: 'agentMessage', text } }, + }); + send({ + method: 'item/started', + params: { threadId: thread.id, turnId, item: { id: 'retry-history-tool', type: 'commandExecution', + command: `fixture-${label}`, status: 'inProgress' } }, + }); + send({ + method: 'item/commandExecution/outputDelta', + params: { threadId: thread.id, turnId, itemId: 'retry-history-tool', delta: `tool-${label}-full-result\n${'输出'.repeat(20000)}` }, + }); + if (label !== 'C') { + emitCapacityError(thread, turnId); + return; + } + send({ + method: 'turn/completed', + params: { threadId: thread.id, turn: { id: turnId, status: 'completed', items: [] } }, + }); + thread.activeTurnId = null; +} + function completeGuidedInputTurn(thread, turnId) { requestClient('item/tool/requestUserInput', { threadId: thread.id, @@ -924,6 +958,20 @@ function startTurn(params) { return { turn: { id: turnId, status: 'running', items: [] } }; } + if (/^codexapp late completion first$/i.test(text)) { + // 保持 turn 处于运行中,直到后续 steer 到达时才结束,用于复现“发送瞬间 turn 结束”的竞态。 + send({ + method: 'item/agentMessage/delta', + params: { + threadId: thread.id, + turnId, + itemId: 'agent-msg', + delta: 'late completion fixture ready\n', + }, + }); + return { turn: { id: turnId, status: 'running', items: [] } }; + } + if (/^codexapp expected turn mismatch first$/i.test(text)) { send({ method: 'item/agentMessage/delta', @@ -953,6 +1001,14 @@ function startTurn(params) { } } + if (/codexapp retry full history/i.test(text)) { + const retryKey = retryScenarioKey(text, 'codexapp retry full history'); + const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1; + thread.capacityRetryAttempts.set(retryKey, attempts); + emitRetryHistoryPart(thread, turnId, attempts === 1 ? 'A' : attempts === 2 ? 'B' : 'C'); + return { turn: { id: turnId, status: 'running', items: [] } }; + } + if (/codexapp capacity retry/i.test(text)) { const retryKey = retryScenarioKey(text, 'codexapp capacity retry'); const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1; @@ -1028,6 +1084,21 @@ function startTurn(params) { return { turn: { id: turnId, status: 'running', items: [] } }; } + if (text === 'slow codexapp prompt') { + // 连续插入回归由最后一条 steer 决定完成时机,不依赖保存耗时小于定时窗口。 + thread.heldSteerFixture = { turnId, text }; + send({ + method: 'item/agentMessage/delta', + params: { + threadId: thread.id, + turnId, + itemId: 'agent-msg', + delta: 'active steer fixture ready\n', + }, + }); + return { turn: { id: turnId, status: 'running', items: [] } }; + } + const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80; if (/recover/i.test(text)) { send({ @@ -1078,11 +1149,33 @@ function interruptTurn(params) { function steerTurn(params) { const thread = ensureThread(params.threadId, params); - if (!thread.activeTurnId) { + const text = textFromInput(params.input); + if (/^codexapp retry after completed(?:\n|$)/.test(text) && thread.activeTurnId) { + return { error: { code: -32603, message: 'temporary steer rejection' } }; + } + if (/codexapp steer after completion/i.test(text) && thread.activeTurnId) { + // 模拟 steer 到达时 turn 恰好结束:先发出 turn/completed,再返回“无活动 turn”。 + completeTurn(thread, thread.activeTurnId, 'codexapp late completion first'); + const expectedTurnId = typeof params.expectedTurnId === 'string' ? params.expectedTurnId.trim() : ''; return { error: { code: -32001, - message: 'no active turn to steer', + message: expectedTurnId + ? `expected active turn id \`${expectedTurnId}\`: no active turn` + : 'no active turn to steer', + }, + }; + } + if (!thread.activeTurnId) { + // 对齐真实 app-server:带 expectedTurnId 时返回 expected active turn id 形式, + // 不带期望 id 时才返回 no active turn to steer。 + const expectedTurnId = typeof params.expectedTurnId === 'string' ? params.expectedTurnId.trim() : ''; + return { + error: { + code: -32001, + message: expectedTurnId + ? `expected active turn id \`${expectedTurnId}\`: no active turn` + : 'no active turn to steer', }, }; } @@ -1094,7 +1187,6 @@ function steerTurn(params) { }, }; } - const text = textFromInput(params.input); if (text) thread.steers.push(text); send({ method: 'item/agentMessage/delta', @@ -1105,6 +1197,11 @@ function steerTurn(params) { delta: `\n[steer accepted: ${text}]`, }, }); + const held = thread.heldSteerFixture; + if (held?.turnId === thread.activeTurnId && text === 'runtime steer insert') { + thread.heldSteerFixture = null; + thread.timer = setTimeout(() => completeTurn(thread, held.turnId, held.text), 80); + } return { result: {} }; } diff --git a/scripts/regression.js b/scripts/regression.js index fab597a..5cc1dfe 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -3051,6 +3051,13 @@ function assertSidebarTitleRefreshStormContract() { const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem'); const renderListSource = extractFunctionSource(frontendSource, 'renderSessionList'); + const dependencySource = [ + 'sessionPassesMcpVisibilityFilter', + 'syncMcpSessionVisibilityToggle', + 'normalizeGiteaSource', + 'giteaResourceLabel', + ] + .map((name) => extractFunctionSource(frontendSource, name)).join('\n'); const optionalSources = [ 'buildSessionListStructureSignature', 'refreshSessionListRelativeTimes', @@ -3196,6 +3203,7 @@ function assertSidebarTitleRefreshStormContract() { let sessions = []; let currentSessionId = 's1'; let sessionSearchQuery = ''; + let showMcpSessions = true; let lastSessionListStructureSignature = ''; let collapseOlderSessions = false; let failOldSessionLoadMoreOnce = false; @@ -3206,6 +3214,7 @@ function assertSidebarTitleRefreshStormContract() { const pendingNotesByTarget = new Map(); const queuedMessagesByTarget = new Map(); const localStorage = { removeItem() {}, setItem() {}, getItem() { return null; } }; + const mcpSessionVisibilityToggle = new MiniElement('button'); const sessionList = new MiniElement('div'); sessionList.clearCount = 0; Object.defineProperty(sessionList, 'innerHTML', { @@ -3319,6 +3328,7 @@ function assertSidebarTitleRefreshStormContract() { function startEditSessionTitle() {} ${optionalSources} + ${dependencySource} ${createItemSource} ${renderListSource} @@ -3904,10 +3914,14 @@ function assertSessionSwitchResilienceContract() { 'Server wsSend should guard JSON serialization failures' ); assert( - runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') && - runtimeSource.includes('function appendCappedText') && - runtimeSource.includes('entry.fullText = appendCappedText'), - 'Classic runtime should cap accumulated fullText in memory' + runtimeSource.includes('function appendFullText') && + runtimeSource.includes('entry.fullText = appendFullText'), + 'Classic runtime should retain complete fullText in the production record' + ); + assert( + !runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') && + !runtimeSource.includes('function appendCappedText'), + 'Classic runtime must not discard the only complete record before archive persistence' ); } @@ -4734,7 +4748,7 @@ function assertServerSessionHistoryRequestIdContract() { } function assertHistoryRecallContract() { - for (const script of ['session-history-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js']) { + for (const script of ['session-history-store-unit.js', 'session-history-unit.js', 'history-runtime-unit.js', 'history-runtime-completion-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js', 'codexapp-retry-history-unit.js', 'streaming-retry-frontend-unit.js', 'assistant-bubble-controls-unit.js']) { const result = spawnSync(process.execPath, [path.join(REPO_DIR, 'scripts', script)], { encoding: 'utf8', timeout: 60000 }); assert(result.status === 0, `${script}: ${result.stderr || result.stdout || result.error}`); } @@ -4772,7 +4786,7 @@ function assertHistoryRecallContract() { /function buildUserOutlineItems[\s\S]*?currentOutlineHistoryState\.entries/.test(frontendSource), 'User-message locator should merge indexed history instead of reading only rendered DOM'); assert( - /userOutlineBtn\.disabled\s*=\s*!historyState\.loading[\s\S]*?historyState\.cursor\s*> 0[\s\S]*?historyState\.available/.test(frontendSource), + /userOutlineBtn\.disabled\s*=\s*historyState\.cursor\s*<=\s*0\s*&&\s*!historyStatus/.test(frontendSource), 'Locator button should remain openable when only older user messages are available'); assert( /messagesDiv\.innerHTML = '';\s*if \(historyLoadMore\) messagesDiv\.appendChild\(historyLoadMore\)/.test(frontendSource), @@ -4978,6 +4992,20 @@ function assertCodexAppStaleRunningRecoveryContract() { steerBlock.includes('mcpContext: entry.mcpContext || options.mcpContext || {}'), 'Stale steer recovery should prefer the original active entry MCP context' ); + const noActiveTurnBlock = extractFunctionSource(serverSource, 'isCodexAppNoActiveTurnError'); + const matchesNoActiveTurn = new Function(`${noActiveTurnBlock}; return isCodexAppNoActiveTurnError;`)(); + assert( + matchesNoActiveTurn(new Error('no active turn to steer')), + 'Legacy no-active-turn text should trigger stale steer recovery' + ); + assert( + matchesNoActiveTurn(new Error('expected active turn id `turn-1`: no active turn')), + 'Real app-server expected-active-turn text should trigger stale steer recovery' + ); + assert( + !matchesNoActiveTurn(new Error('expectedTurnId does not match active turn')), + 'Expected-turn mismatch must not be treated as a finished turn' + ); } function assertCodexAppGoalLifecycleContract() { @@ -5378,7 +5406,7 @@ async function runCodexAppStaleRunningRegression(options = {}) { msg.type === 'codex_app_steer_status' && msg.sessionId === sessionId && msg.clientMessageId === 'regression-stale-running-follow-up' && - msg.status === 'inserted' + msg.status === 'sent' ), 5000); assert(!/失败/.test(recoveredStatus.message || ''), 'Recovered stale steer should update the UI as a non-failure'); const recoveredHint = await nextMessage(messages, ws, (msg) => ( @@ -5428,9 +5456,256 @@ async function runCodexAppStaleRunningRegression(options = {}) { assert(staleAssistantIndex < followUpUserIndex && followUpUserIndex < recoveredAssistantIndex, 'Recovered history should keep stale assistant before follow-up user before recovered assistant'); assert(!fs.existsSync(path.join(sessionsDir, `${sessionId}-run`)), 'Recovered follow-up should clean the Codex App run directory after completion'); + // 竞态场景:发送引导消息的瞬间旧 turn 刚好结束(turn/completed 先到、steer 失败后到), + // 服务端应恢复旧输出顺序并自动开启新一轮对话。 + ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' })); + const lateSession = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId + )); + ws.send(JSON.stringify({ + type: 'message', + text: 'codexapp late completion first', + sessionId: lateSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + })); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /late completion fixture ready/.test(msg.text || '') + ), 5000); + await sleep(150); + ws.send(JSON.stringify({ + type: 'message', + text: 'codexapp steer after completion', + sessionId: lateSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + clientMessageId: 'regression-steer-after-completion', + })); + const latePending = await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && + msg.sessionId === lateSession.sessionId && + msg.clientMessageId === 'regression-steer-after-completion' && + msg.status === 'pending' + ), 5000); + assert(latePending.status === 'pending', 'Steer-after-completion should first enter pending status'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === lateSession.sessionId, 5000); + const lateRecovered = await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && + msg.sessionId === lateSession.sessionId && + msg.clientMessageId === 'regression-steer-after-completion' && + msg.status === 'sent' + ), 5000); + assert(/已转为新对话/.test(lateRecovered.message || ''), 'Steer-after-completion should recover as a replacement turn'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'resume_generating' && msg.sessionId === lateSession.sessionId + ), 5000); + const lateHint = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && msg.sessionId === lateSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '') + ), 5000); + assert(lateHint.transient === true, 'Steer-after-completion hint should be transient'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /Codex App mock handled: codexapp steer after completion/.test(msg.text || '') + ), 5000); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === lateSession.sessionId, 5000); + assert( + !messages.some((msg) => msg.code === 'codexapp_steer_failed' && msg.sessionId === lateSession.sessionId), + 'Steer-after-completion should not emit codexapp_steer_failed' + ); + const lateStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${lateSession.sessionId}.json`), 'utf8')); + assert( + lateStored.messages.filter((message) => message.role === 'user' && message.content === 'codexapp steer after completion').length === 1, + 'Steer-after-completion user message should persist exactly once' + ); + const lateOldAssistantIndex = lateStored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp late completion first/.test(String(message.content || ''))); + const lateUserIndex = lateStored.messages.findIndex((message) => message.role === 'user' && message.id === 'regression-steer-after-completion'); + const lateNewAssistantIndex = lateStored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp steer after completion/.test(String(message.content || ''))); + assert( + lateOldAssistantIndex >= 0 && lateUserIndex >= 0 && lateNewAssistantIndex >= 0, + 'Steer-after-completion should persist both turns and the steer message' + ); + assert( + lateOldAssistantIndex < lateUserIndex && lateUserIndex < lateNewAssistantIndex, + 'Steer-after-completion should keep old assistant before steer user before replacement assistant' + ); + const lateResumeIndex = receivedMessages.findIndex((msg) => msg.type === 'resume_generating' && msg.sessionId === lateSession.sessionId); + const lateDeltaIndex = receivedMessages.findIndex((msg) => msg.type === 'text_delta' && msg.sessionId === lateSession.sessionId && /Codex App mock handled: codexapp steer after completion/.test(msg.text || '')); + assert( + lateResumeIndex >= 0 && lateResumeIndex < lateDeltaIndex, + 'Steer-after-completion should restore generating UI before its replacement output' + ); + + // 表单答案同样应在旧 turn 已结束时自动转新对话,而不是停留在“插入失败”。 + const promptInternalToken = 'StaleRunningMcp!234'; + ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' })); + const promptSession = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir + && msg.sessionId !== sessionId && msg.sessionId !== lateSession.sessionId + )); + ws.send(JSON.stringify({ + type: 'message', + text: 'codexapp stale running first', + sessionId: promptSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + })); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === promptSession.sessionId && /Codex App stale turn output/.test(msg.text || '') + ), 5000); + await sleep(150); + const promptUserResult = await callInternalMcp(port, promptInternalToken, { + tool: 'ccweb_prompt_user', + sourceSessionId: promptSession.sessionId, + sourceHopCount: 0, + args: { + title: 'Stale 表单', + questions: [ + { + id: 'stale_choice', + title: '继续方式', + question: '在旧 turn 已结束时提交表单。', + required: true, + options: [ + { id: 'continue', label: '继续', answerText: '请继续处理 stale 表单。' }, + ], + }, + ], + }, + }); + assert(promptUserResult.status === 200 && promptUserResult.body?.ok, `Stale prompt user should render: ${JSON.stringify(promptUserResult.body)}`); + const promptRendered = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && + msg.sessionId === promptSession.sessionId && + msg.message?.ccwebPrompt?.id === promptUserResult.body.promptId + ), 5000); + assert(promptRendered.message.ccwebPrompt.status === 'pending', 'Stale prompt should start pending'); + ws.send(JSON.stringify({ + type: 'ccweb_prompt_user_response', + sessionId: promptSession.sessionId, + promptId: promptUserResult.body.promptId, + answers: { + stale_choice: { + selectedOptionIds: ['continue'], + answerText: '请继续处理 stale 表单。', + }, + }, + })); + const promptRemoved = await nextMessage(messages, ws, (msg) => ( + msg.type === 'ccweb_prompt_user_remove' && + msg.sessionId === promptSession.sessionId && + msg.promptId === promptUserResult.body.promptId + ), 5000); + assert(promptRemoved.prompt?.status === 'submitted', 'Stale prompt should be marked submitted'); + const promptSteerBubble = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && + msg.sessionId === promptSession.sessionId && + msg.message?.role === 'user' && + msg.message?.codexAppSteerStatus === 'pending' + ), 5000); + assert(/请继续处理 stale 表单/.test(promptSteerBubble.message.content || ''), 'Stale prompt answer bubble should carry the submitted answer'); + const promptRecovered = await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && + msg.sessionId === promptSession.sessionId && + msg.status === 'sent' && /已转为新对话/.test(msg.message || '') + ), 5000); + assert(promptRecovered.status === 'sent', 'Stale prompt answer should recover as a replacement turn'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'resume_generating' && msg.sessionId === promptSession.sessionId + ), 5000); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === promptSession.sessionId && /请继续处理 stale 表单/.test(msg.text || '') + ), 5000); + // 降级收敛旧 entry 时也会发一个 done,这里要等带新答案输出的那个完成回执。 + await nextMessage(messages, ws, (msg) => ( + msg.type === 'done' && msg.sessionId === promptSession.sessionId && + /请继续处理 stale 表单/.test(String(msg.assistantMessage?.content || '')) + ), 5000); + const promptStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${promptSession.sessionId}.json`), 'utf8')); + const promptAnswerUsers = promptStored.messages.filter((message) => ( + message.role === 'user' && /请继续处理 stale 表单/.test(String(message.content || '')) + )); + assert(promptAnswerUsers.length === 1, 'Stale prompt answer should persist exactly once'); + assert( + !promptStored.messages.some((message) => message.ccwebPrompt?.id === promptUserResult.body.promptId), + 'Submitted stale prompt should be removed from history' + ); + const promptStaleAssistantIndex = promptStored.messages.findIndex((message) => ( + message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || '')) + )); + const promptAnswerUserIndex = promptStored.messages.findIndex((message) => ( + message.role === 'user' && /请继续处理 stale 表单/.test(String(message.content || '')) + )); + const promptNewAssistantIndex = promptStored.messages.findIndex((message) => ( + message.role === 'assistant' && /请继续处理 stale 表单/.test(String(message.content || '')) + )); + assert( + promptStaleAssistantIndex >= 0 && promptAnswerUserIndex >= 0 && promptNewAssistantIndex >= 0, + 'Stale prompt recovery should persist old output, answer message and replacement output' + ); + assert( + promptStaleAssistantIndex < promptAnswerUserIndex && promptAnswerUserIndex < promptNewAssistantIndex, + 'Stale prompt recovery should keep old assistant before answer user before replacement assistant' + ); + + // 插入确实失败后,旧轮结束,再用同一条消息与附件重试:普通发送必须有确认回执。 + ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' })); + const retrySession = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir + && ![sessionId, lateSession.sessionId, promptSession.sessionId].includes(msg.sessionId) + )); + ws.send(JSON.stringify({ + type: 'message', text: 'codexapp slow retry first', sessionId: retrySession.sessionId, + mode: 'yolo', agent: 'codexapp', + })); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && msg.sessions.some((session) => session.id === retrySession.sessionId && session.isRunning) + ), 5000); + const retryAttachment = await uploadAttachment(port, token, { + filename: 'finished-retry.png', mime: 'image/png', data: Buffer.from('finished-retry-image'), + }); + const retryPayload = { + type: 'message', text: 'codexapp retry after completed', sessionId: retrySession.sessionId, + mode: 'plan', agent: 'codexapp', clientMessageId: 'regression-finished-steer-retry', + codexAppSteer: true, attachments: [retryAttachment], + }; + ws.send(JSON.stringify(retryPayload)); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && msg.sessionId === retrySession.sessionId + && msg.clientMessageId === retryPayload.clientMessageId && msg.status === 'failed' + ), 5000); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'error' && msg.sessionId === retrySession.sessionId && msg.code === 'codexapp_steer_failed' + ), 5000); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === retrySession.sessionId, 5000); + const beforeRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${retrySession.sessionId}.json`), 'utf8')); + assert(!beforeRetry.messages.some((message) => message.id === retryPayload.clientMessageId), 'Failed steer should not leave a persisted retry message'); + + const retryStartIndex = receivedMessages.length; + ws.send(JSON.stringify(retryPayload)); + const retryEcho = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && msg.sessionId === retrySession.sessionId && msg.message?.id === retryPayload.clientMessageId + ), 5000); + assert(!retryEcho.message.codexAppSteerStatus, 'Finished-turn retry should echo a normal user message'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && msg.sessionId === retrySession.sessionId + && msg.clientMessageId === retryPayload.clientMessageId && msg.status === 'sent' + ), 5000); + await nextMessage(messages, ws, (msg) => msg.type === 'resume_generating' && msg.sessionId === retrySession.sessionId, 5000); + const retryDelta = await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === retrySession.sessionId && /codexapp retry after completed/.test(msg.text || '') + ), 5000); + assert(storedAttachmentImageMarker(retryAttachment).test(retryDelta.text || ''), 'Retry must deliver the original image to the new turn'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === retrySession.sessionId, 5000); + const afterRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${retrySession.sessionId}.json`), 'utf8')); + const retriedUsers = afterRetry.messages.filter((message) => message.role === 'user' && message.id === retryPayload.clientMessageId); + assert(retriedUsers.length === 1, 'Finished-turn retry should persist the original stable message ID exactly once'); + assertPersistedMessageAttachment(retriedUsers[0], retryAttachment.filename, 'Finished-turn retry message'); + assert(afterRetry.permissionMode === 'plan', 'Finished-turn retry should preserve the original mode'); + assert(!receivedMessages.slice(retryStartIndex).some((msg) => msg.type === 'error' && msg.sessionId === retrySession.sessionId), 'Successful retry should not report another insertion failure'); + ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' })); const mismatchSession = await nextMessage(messages, ws, (msg) => ( - msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId + msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir + && ![sessionId, lateSession.sessionId, promptSession.sessionId, retrySession.sessionId].includes(msg.sessionId) )); ws.send(JSON.stringify({ type: 'message', @@ -6130,7 +6405,9 @@ function assertCodexAppChildToolRoutingContract() { assert(!JSON.parse(oldTool.result).agentsStates?.['missing-route-thread'], 'Unknown spawn tool ids must not contaminate older child cards'); const syncSource = extractFunctionSource(source, 'syncCcwebMcpChildAgentsFromCollabItem'); - const syncApi = new Function(` + const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime')); + const codexAppRuntime = createCodexAppRuntime(); + const syncApi = new Function('codexAppRuntime', ` const ccwebMcpChildThreads = new Map(); const sent = []; const path = { basename: (value) => String(value || '').split('/').filter(Boolean).pop() || '' }; @@ -6156,7 +6433,7 @@ function assertCodexAppChildToolRoutingContract() { sync: syncCcwebMcpChildAgentsFromCollabItem, sent, }; - `)(); + `)(codexAppRuntime); syncApi.setChild('current-plan-thread', { threadId: 'current-plan-thread', parentSessionId: session.id, @@ -6222,7 +6499,7 @@ function assertCodexAppUnroutedNotificationRoutingContract() { ); assert(source.includes('function updateSessionRuntimeThreadIndex'), 'Server should centralize session thread index maintenance'); assert( - /function saveSession\(session\)[\s\S]*?updateSessionRuntimeThreadIndex\(session\)/.test(source), + extractFunctionSource(source, 'saveSession').includes('updateSessionRuntimeThreadIndex(session)'), 'Saving a session should refresh the runtime thread index' ); assert( @@ -9524,7 +9801,9 @@ 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); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /active steer fixture ready/.test(msg.text || '') + )); ws.send(JSON.stringify({ type: 'message', text: '/runtime/report', @@ -9902,7 +10181,7 @@ async function main() { try { const { ws, messages } = await connectWs(recoveryPort, password); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId)); - assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state'); + assert(fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be retained as the only recovery evidence'); ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId })); const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId); assert((oversizedSessionInfo.messages || []).some((message) => ( diff --git a/scripts/session-history-store-unit.js b/scripts/session-history-store-unit.js new file mode 100644 index 0000000..b9438e0 --- /dev/null +++ b/scripts/session-history-store-unit.js @@ -0,0 +1,550 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createCodexRolloutStore } = require('../lib/codex-rollouts'); +const serverSource = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8'); +const storePath = path.join(__dirname, '..', 'lib', 'session-history-store.js'); +const sessionHistoryStoreModule = require(storePath); +const { inspect } = require('node:util'); + +function extract(name, optional = false) { + const start = serverSource.indexOf(`function ${name}(`); + if (start < 0 && optional) return ''; + assert(start >= 0, `缺少生产函数 ${name}`); + const end = serverSource.indexOf('\nfunction ', start + 1); + return serverSource.slice(start, end < 0 ? undefined : end); +} + +function createServerHarness(sessionsDir, options = {}) { + const logs = []; + const filesystem = options.fs || fs; + const sessionHistoryStore = sessionHistoryStoreModule.createSessionHistoryStore({ sessionsDir, fs: filesystem }); + const rollouts = createCodexRolloutStore({ + codexSessionsDir: options.nativeDir || path.join(sessionsDir, '_native'), + sessionsDir, + normalizeSession: value => value, + sanitizeToolInput: value => value, + }); + const pureSource = serverSource.slice(serverSource.indexOf('function stableMessageHash('), serverSource.indexOf('function sessionHistoryCacheKey(')); + const names = [ + 'isPersistenceNoticeMessage', 'truncateTextValue', 'sanitizePersistValue', + 'sanitizeToolCallsForPersist', 'sanitizeMessageForPersist', 'sanitizeMessagesForPersist', + 'sanitizeSessionForPersist', 'buildSessionJsonForPersist', 'writeFileAtomicSync', + 'safeReadSessionJson', 'loadSession', 'preserveNewerTaskTrackingForSessionSave', + 'saveSession', 'sessionHistoryCacheKey', 'cacheSessionHistory', 'loadNativeSessionHistory', + 'resolveSessionHistory', + 'listSessionFiles', 'refreshSessionSnapshotCache', 'prepareNativeHistoryImport', + 'handleSlashCommand', 'handleDeleteSession', 'findCcwebPromptMessage', 'removeCcwebPromptMessage', + 'rollbackPersistedCodexAppSteerMessage', 'handleLoadHistoryPage', + ]; + const api = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'sessionHistoryStore', 'rollouts', 'options', 'logs', ` + const SESSION_MAX_JSON_BYTES = options.snapshotMaxBytes || 512 * 1024; + const SESSION_LOAD_MAX_BYTES = 32 * 1024 * 1024; + const SESSION_PERSIST_MAX_MESSAGES = options.snapshotMaxMessages || 180; + const SESSION_MESSAGE_CONTENT_MAX_CHARS = 96 * 1024; + const SESSION_TOOL_INPUT_MAX_CHARS = 16 * 1024; + const SESSION_TOOL_RESULT_MAX_CHARS = 32 * 1024; + const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = 80; + const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大,已截断以保护服务稳定性]'; + const SESSION_HISTORY_CACHE_LIMIT = 12; + const sessionHistoryCache = new Map(); + const sessionSearchIndex = { scheduleUpsert() {}, remove() {} }; + const COMPOSER_COMMANDS = [{ name: '/clear' }]; + const activeProcesses = new Map(); + const activeCodexAppTurns = new Map(); + const retainedCodexAppTurns = new Map(); + const activeCodexAppGoalCommands = new Map(); + const activeCodexAppCompactions = new Map(); + const codexAppGoalStates = new Map(); + const pendingSlashCommands = new Map(); + const pendingCompactRetries = new Map(); + const ccwebMcpChildThreads = new Map(); + const codexAppClient = null; + const responses = []; + const INITIAL_HISTORY_COUNT = 12; + const HISTORY_CHUNK_SIZE = 24; + function scheduleUsageStatisticsUpsert() {} + function syncCodexAppGoalStateFromSession() {} + function updateSessionRuntimeThreadIndex() {} + function removeSessionRuntimeThreadIndex() {} + function removeUsageStatisticsSession() {} + function cancelCodexCapacityRetry() {} + function cancelCodexAppCompaction() {} + function deleteCrossConversationRepliesForSession() {} + function clearRuntimeSessionId(session) { session.claudeSessionId = session.codexThreadId = session.codexAppThreadId = null; } + function cleanRunDir() {} + function collectSessionAttachmentIds() { return []; } + function sendSessionList() {} + function wsSend(ws, payload) { responses.push(payload); } + function attachClientRequestId(payload, source) { return { ...payload, requestId: source?.requestId }; } + function taskTrackingSnapshotForSession() { return null; } + function publicTitleMetadata() { return {}; } + function sessionModelLabel(session) { return session.model; } + function isCodexAppSession(session) { return session.agent === 'codexapp'; } + function normalizeAgent(agent) { return agent; } + function sanitizeMessagesForTransport(messages) { return messages; } + function plog(level, event, data) { logs.push({ level, event, ...data }); } + function sanitizeId(id) { return /^[\\w-]+$/.test(id) ? id : ''; } + function sessionPath(id) { return path.join(SESSIONS_DIR, sanitizeId(id) + '.json'); } + function textByteLength(value) { return Buffer.byteLength(value); } + function normalizeMessageAttachments(value) { return value; } + function normalizeSession(value) { + if (value && Array.isArray(value.messages)) value.messages = value.messages.map(ensureStableMessageId); + return value; + } + function isCodexLikeSession(session) { return session.agent === 'codex' || session.agent === 'codexapp'; } + function getSessionAgent(session) { return session.agent || 'claude'; } + function getCodexRolloutFiles() { return options.nativeFiles || []; } + function resolveClaudeSessionLocalMeta() { return null; } + const parseCodexRolloutFile = rollouts.parseCodexRolloutFile; + ${pureSource} + ${names.map(name => extract(name)).join('\n')} + return { + loadSession, saveSession, resolveSessionHistory, listSessionFiles, refreshSessionSnapshotCache, + prepareNativeHistoryImport, handleSlashCommand, handleDeleteSession, removeCcwebPromptMessage, + rollbackPersistedCodexAppSteerMessage, handleLoadHistoryPage, responses, logs, sessionHistoryStore, + }; + `)(filesystem, path, crypto, sessionsDir, sessionHistoryStore, rollouts, options, logs); + return api; +} + +let checks = 0; +const failures = []; +function check(name, callback) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-full-history-')); + try { + callback(dir); + checks += 1; + console.log(`通过:${name}`); + } catch (error) { + failures.push(`${name}:${error.message}`); + console.error(`失败:${name}:${inspect(error, { depth: 1, maxArrayLength: 5, maxStringLength: 500 })}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function messages(count) { + return Array.from({ length: count }, (_, index) => ({ + id: `message-${index}`, role: index % 2 ? 'assistant' : 'user', content: `完整历史 ${index}`, + timestamp: new Date(1800000000000 + index * 1000).toISOString(), + })); +} +function fixture(id = 'session-a', count = 650) { + return { id, agent: 'codexapp', codexAppThreadId: 'thread-a', title: '完整会话', messages: messages(count) }; +} + +function runTests() { +check('650 条历史在重复保存和存储实例重开后原样读取', dir => { + let api = createServerHarness(dir); + const original = fixture(); + assert.equal(api.saveSession(original), true); + const cached = JSON.parse(fs.readFileSync(path.join(dir, 'session-a.json'), 'utf8')); + assert(cached.messages.length <= 181, '快照仍应有界'); + assert.equal(api.saveSession(original), true); + api = createServerHarness(dir); + const reloaded = api.loadSession(original.id); + assert.deepEqual(reloaded.messages, original.messages); + assert.equal(api.resolveSessionHistory(reloaded).available, true); +}); + +check('可调 500 条快照上限不限制 650 条完整历史', dir => { + const original = fixture(); + const api = createServerHarness(dir, { snapshotMaxMessages: 500 }); + assert.equal(api.saveSession(original), true); + assert.deepEqual(createServerHarness(dir).loadSession(original.id).messages, original.messages); +}); + +check('长正文、工具参数、工具结果、数组和深层元数据完整持久化', dir => { + const original = fixture('long-content', 1); + original.messages[0].content = '完整中文'.repeat(35000); + original.messages[0].toolCalls = Array.from({ length: 125 }, (_, index) => ({ + id: `tool-${index}`, name: 'run', input: '输入'.repeat(12000), result: '输出'.repeat(21000), + rows: Array.from({ length: 150 }, (_, row) => ({ row, values: [row, row + 1] })), + })); + original.metadata = { text: '元数据'.repeat(8000), values: Array.from({ length: 180 }, (_, index) => index), nested: { a: { b: { c: { d: { value: '原值' } } } } } }; + const api = createServerHarness(dir); + assert.equal(api.saveSession(original), true); + const reloaded = createServerHarness(dir).loadSession(original.id); + assert.deepEqual(reloaded.messages, original.messages); + assert.deepEqual(reloaded.metadata, original.metadata); +}); + +check('未可信加载的短快照反复保存不能覆盖完整历史或正文', dir => { + const original = fixture(); + original.messages.at(-1).content = '长正文'.repeat(50000); + const api = createServerHarness(dir); + assert.equal(api.saveSession(original), true); + const preview = JSON.parse(fs.readFileSync(path.join(dir, 'session-a.json'), 'utf8')); + assert.equal(api.saveSession(preview), false); + assert.equal(api.saveSession(preview), false); + assert.deepEqual(createServerHarness(dir).loadSession(original.id).messages, original.messages); +}); + +check('可信同 ID 更新、删除和 steer 前插入精确保存顺序', dir => { + const api = createServerHarness(dir); + assert.equal(api.saveSession(fixture('mutable', 8)), true); + let loaded = api.loadSession('mutable'); + loaded.messages[1].content = '同 ID 最新正文'; + loaded.messages[1].toolCalls = [{ id: 'same-tool', input: '新输入', result: ['新结果', '尾部'] }]; + loaded.messages.splice(2, 1); + loaded.messages.splice(3, 0, { id: 'inserted-assistant', role: 'assistant', content: 'steer 前旧轮结果' }); + assert.equal(api.saveSession(loaded), true); + assert.deepEqual(createServerHarness(dir).loadSession('mutable').messages, loaded.messages); + loaded = api.loadSession('mutable'); + loaded.messages = []; + loaded.codexAppThreadId = null; + assert.equal(api.saveSession(loaded), true); + assert.deepEqual(createServerHarness(dir).loadSession('mutable').messages, []); +}); + +check('可信旧修订合并自身更新且保留后续提交的追加和元数据', dir => { + const api = createServerHarness(dir); + assert.equal(api.saveSession(fixture('versions', 8)), true); + const stale = api.loadSession('versions'); + const current = api.loadSession('versions'); + current.messages.push({ id: 'new-committed', role: 'user', content: '已提交追加' }); + current.codexAppGoal = { objective: '后来保存的目标', status: 'in_progress' }; + assert.equal(api.saveSession(current), true); + stale.messages[0].content = '旧对象修改'; + assert.equal(api.saveSession(stale), true); + const restored = createServerHarness(dir).loadSession('versions'); + assert.equal(restored.messages[0].content, '旧对象修改'); + assert.equal(restored.messages.at(-1).id, 'new-committed'); + assert.deepEqual(restored.codexAppGoal, current.codexAppGoal); + assert.deepEqual(stale.messages, restored.messages); +}); + +check('两个会话档案相互隔离', dir => { + const api = createServerHarness(dir); + const left = fixture('left', 220); + const right = fixture('right', 250); + right.messages[0].content = '另一会话'; + assert.equal(api.saveSession(left), true); + assert.equal(api.saveSession(right), true); + assert.deepEqual(createServerHarness(dir).loadSession('left').messages, left.messages); + assert.deepEqual(createServerHarness(dir).loadSession('right').messages, right.messages); +}); + +check('完整归档写入失败保持上次归档和快照原文', dir => { + let failArchiveRename = false; + const filesystem = new Proxy(fs, { + get(target, key) { + if (key === 'renameSync') return (from, to) => { + if (failArchiveRename && String(to).includes(`${path.sep}_history${path.sep}`)) throw new Error('模拟归档提交失败'); + return target.renameSync(from, to); + }; + return target[key]; + }, + }); + const api = createServerHarness(dir, { fs: filesystem }); + assert.equal(api.saveSession(fixture('atomic', 220)), true); + const loaded = api.loadSession('atomic'); + const oldMessages = JSON.parse(JSON.stringify(loaded.messages)); + const oldSnapshot = fs.readFileSync(path.join(dir, 'atomic.json'), 'utf8'); + loaded.messages.push({ id: 'must-not-commit', role: 'assistant', content: '未提交' }); + failArchiveRename = true; + assert.equal(api.saveSession(loaded), false); + assert.equal(fs.readFileSync(path.join(dir, 'atomic.json'), 'utf8'), oldSnapshot); + assert.deepEqual(createServerHarness(dir).loadSession('atomic').messages, oldMessages); +}); + +check('完整归档成功而快照失败仍返回成功并恢复最新元数据', dir => { + let failSnapshotRename = false; + const filesystem = new Proxy(fs, { + get(target, key) { + if (key === 'renameSync') return (from, to) => { + if (failSnapshotRename && path.dirname(to) === dir) throw new Error('模拟快照缓存失败'); + return target.renameSync(from, to); + }; + return target[key]; + }, + }); + const api = createServerHarness(dir, { fs: filesystem }); + assert.equal(api.saveSession(fixture('cache-failure', 220)), true); + const loaded = api.loadSession('cache-failure'); + loaded.title = '归档中的最新标题'; + loaded.codexAppThreadId = 'latest-thread'; + loaded.messages.push({ id: 'committed', role: 'user', content: '已提交' }); + failSnapshotRename = true; + assert.equal(api.saveSession(loaded), true); + const reloaded = createServerHarness(dir).loadSession('cache-failure'); + assert.deepEqual(reloaded.messages, loaded.messages); + assert.equal(reloaded.title, loaded.title); + assert.equal(reloaded.codexAppThreadId, loaded.codexAppThreadId); +}); + +check('首次快照失败的孤立档案重开后仍可读取并参与列表且缓存恢复有界', dir => { + const filesystem = new Proxy(fs, { + get(target, key) { + if (key === 'renameSync') return (from, to) => { + if (path.dirname(to) === dir) throw new Error('模拟首次快照缓存失败'); + return target.renameSync(from, to); + }; + return target[key]; + }, + }); + const original = fixture('orphan', 650); + assert.equal(createServerHarness(dir, { fs: filesystem }).saveSession(original), true); + assert.equal(fs.existsSync(path.join(dir, 'orphan.json')), false); + const failedCacheApi = createServerHarness(dir, { fs: filesystem }); + assert.deepEqual(failedCacheApi.loadSession('orphan').messages, original.messages); + assert(failedCacheApi.listSessionFiles().includes('orphan.json')); + assert(failedCacheApi.refreshSessionSnapshotCache('orphan').session.messages.length <= 181); + const reopened = createServerHarness(dir); + assert(reopened.listSessionFiles().includes('orphan.json')); + assert(reopened.refreshSessionSnapshotCache('orphan').session.messages.length <= 181); + assert(fs.existsSync(path.join(dir, 'orphan.json'))); +}); + +check('可信旧修订并发追加和插入保留锚点顺序以及特殊 JSON 键', dir => { + const api = createServerHarness(dir); + const original = fixture('concurrent', 4); + original.messages[1].toolCalls = [{ id: 'tool', input: JSON.parse('{"__proto__":{"old":"原值"},"constructor":"构造键","value":1}') }]; + assert.equal(api.saveSession(original), true); + const stale = api.loadSession('concurrent'); + const current = api.loadSession('concurrent'); + current.messages[1].toolCalls[0].input.later = '后续值'; + current.messages.push({ id: 'current-tail', role: 'user', content: '后来提交的尾部' }); + assert.equal(api.saveSession(current), true); + stale.messages[1].toolCalls[0].input.__proto__.updated = '本次值'; + stale.messages.splice(2, 0, { id: 'inserted', role: 'assistant', content: '插入到已知锚点前' }); + stale.messages.push({ id: 'stale-tail', role: 'assistant', content: '旧对象本轮输出' }); + assert.equal(api.saveSession(stale), true); + const restored = api.loadSession('concurrent'); + assert.deepEqual(restored.messages.map(message => message.id), ['message-0', 'message-1', 'inserted', 'message-2', 'message-3', 'stale-tail', 'current-tail']); + const input = restored.messages[1].toolCalls[0].input; + assert.equal(Object.hasOwn(input, '__proto__'), true); + assert.deepEqual(input.__proto__, { old: '原值', updated: '本次值' }); + assert.equal(input.constructor, '构造键'); + assert.equal(input.later, '后续值'); +}); + +check('旧修订不能复活后来清空的历史或已经删除的气泡', dir => { + const api = createServerHarness(dir); + assert.equal(api.saveSession(fixture('clear-race', 4)), true); + const stale = api.loadSession('clear-race'); + const current = api.loadSession('clear-race'); + current.messages = []; + current.codexAppThreadId = null; + api.sessionHistoryStore.markComplete(current, { reset: true }); + assert.equal(api.saveSession(current), true); + stale.messages[0].content = '旧助手最后写入'; + stale.messages.push({ id: 'old-run-result', role: 'assistant', content: '清空后旧轮才返回的输出' }); + stale.model = '新模型设置'; + assert.equal(api.saveSession(stale), true); + const restored = api.loadSession('clear-race'); + assert.deepEqual(restored.messages, []); + assert.equal(restored.codexAppThreadId, null); + assert.equal(restored.model, '新模型设置'); +}); + +check('流式气泡及工具引用在元数据并发保存后继续接收delta并落盘', dir => { + const api = createServerHarness(dir); + assert.equal(api.saveSession(fixture('streaming', 5)), true); + const streaming = api.loadSession('streaming'); + const assistant = { id: 'streaming-assistant', role: 'assistant', content: '第一段', toolCalls: [{ id: 'streaming-tool', name: 'run', result: '工具第一段' }] }; + const toolCall = assistant.toolCalls[0]; + streaming.messages.push(assistant); + assert.equal(api.saveSession(streaming), true); + const metadata = api.loadSession('streaming'); + metadata.codexAppMcpReload = { pending: true }; + assert.equal(api.saveSession(metadata), true); + assistant.content += '第二段'; + toolCall.result += '第二段'; + assert.equal(api.saveSession(streaming), true); + assert.equal(streaming.messages.at(-1), assistant); + assert.equal(streaming.messages.at(-1).toolCalls[0], toolCall); + assistant.content += '第三段'; + toolCall.result += '第三段'; + assert.equal(api.saveSession(streaming), true); + const reopened = createServerHarness(dir).loadSession('streaming'); + assert.equal(reopened.messages.at(-1).content, '第一段第二段第三段'); + assert.equal(reopened.messages.at(-1).toolCalls[0].result, '工具第一段第二段第三段'); + assert.deepEqual(reopened.codexAppMcpReload, metadata.codexAppMcpReload); +}); + +check('完整归档历史按稳定ID去重且同turn不同气泡分别保留', dir => { + const original = fixture('archive-identities', 1); + original.messages = [ + { id: 'duplicate', role: 'assistant', turnId: 'same-turn', content: '旧片段' }, + { id: 'another-segment', role: 'assistant', turnId: 'same-turn', content: '不同气泡' }, + { id: 'duplicate', role: 'assistant', turnId: 'same-turn', content: '最新片段' }, + ]; + const api = createServerHarness(dir); + assert.equal(api.saveSession(original), true); + const result = api.resolveSessionHistory(api.loadSession('archive-identities')); + assert.deepEqual(result.messages.map(message => message.id), ['duplicate', 'another-segment']); + assert.equal(result.messages[0].content, '最新片段'); + assert.equal(result.messages[1].content, '不同气泡'); +}); + +check('档案异常禁止回退预览覆盖,删除档案后旧加载对象不能重新创建', dir => { + const api = createServerHarness(dir); + assert.equal(api.saveSession(fixture('corrupt', 220)), true); + const stale = api.loadSession('corrupt'); + const oldSnapshot = fs.readFileSync(path.join(dir, 'corrupt.json'), 'utf8'); + fs.writeFileSync(path.join(dir, '_history', 'corrupt.json'), '{损坏文件'); + assert.equal(api.loadSession('corrupt'), null); + assert.equal(api.saveSession(stale), false); + assert.equal(fs.readFileSync(path.join(dir, 'corrupt.json'), 'utf8'), oldSnapshot); + assert.equal(api.saveSession(fixture('remove', 220)), true); + const removed = api.loadSession('remove'); + api.sessionHistoryStore.remove('remove'); + fs.unlinkSync(path.join(dir, 'remove.json')); + assert.equal(api.loadSession('remove'), null); + assert.equal(api.saveSession(removed), false); + assert.equal(fs.existsSync(path.join(dir, '_history', 'remove.json')), false); +}); + +check('旧短预览同 ID 覆盖受拒绝,导入经确认边界保留 cc-web 专有气泡', dir => { + const api = createServerHarness(dir); + const original = fixture('import-existing', 10); + original.messages[0].nativeThreadId = 'thread-a'; + original.messages[0].turnId = 'confirmed-boundary'; + original.messages[0].role = 'assistant'; + original.messages.push({ id: 'ccweb-only', role: 'assistant', ccwebDisplayOnly: true, content: '跨会话回传' }); + assert.equal(api.saveSession(original), true); + const untrusted = JSON.parse(JSON.stringify(original)); + untrusted.messages.at(-1).content = '短'; + assert.equal(api.saveSession(untrusted), false); + const native = [{ id: 'earlier', role: 'assistant', content: '更早前缀' }, { ...original.messages[0], id: 'native-boundary' }, { id: 'native-tail', role: 'assistant', content: '原生尾部' }]; + const existing = api.loadSession('import-existing'); + const prepared = api.prepareNativeHistoryImport(existing, { ...original, messages: native }); + assert(prepared); + assert.equal(api.saveSession(prepared), true); + const restored = api.loadSession('import-existing'); + assert.equal(restored.messages[0].id, 'earlier'); + assert.equal(restored.messages.at(-1).id, 'ccweb-only'); + assert(!restored.messages.some(message => message.id === 'native-tail')); + assert.equal(api.prepareNativeHistoryImport(restored, { ...original, messages: [{ id: 'wrong', role: 'assistant', content: '不匹配' }] }), null); +}); + +check('旧快照的真实 rollout 前缀确认后先归档,移除 rollout 后完整读回', dir => { + const id = 'legacy'; + const nativeDir = path.join(dir, '_native'); + fs.mkdirSync(nativeDir); + const rolloutPath = path.join(nativeDir, 'rollout-thread-a.jsonl'); + const entries = [{ type: 'session_meta', payload: { id: 'thread-a', cwd: dir } }]; + for (let index = 0; index < 8; index += 1) { + entries.push({ timestamp: new Date(1800000000000 + index * 1000).toISOString(), type: 'event_msg', payload: { type: 'user_message', id: `user-${index}`, message: `原生日志 ${index}` } }); + } + fs.writeFileSync(rolloutPath, entries.map(entry => JSON.stringify(entry)).join('\n')); + const parsed = createCodexRolloutStore({ codexSessionsDir: nativeDir, sessionsDir: dir, normalizeSession: value => value, sanitizeToolInput: value => value }).parseCodexRolloutFile(rolloutPath); + assert.equal(parsed.messages.length, 8); + const old = { + id, agent: 'codexapp', codexAppThreadId: 'thread-a', historySnapshotBaseIndex: 5, + messages: [{ role: 'system', ccwebPersistenceNotice: true, content: '旧截断' }, ...parsed.messages.slice(5)], + }; + fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(old)); + const api = createServerHarness(dir, { nativeDir, nativeFiles: [rolloutPath] }); + const resolved = api.resolveSessionHistory(api.loadSession(id)); + assert.equal(resolved.messages.length, 8); + assert(fs.existsSync(path.join(dir, '_history', `${id}.json`)), '确认前缀后应提交自身档案'); + fs.unlinkSync(rolloutPath); + const reopened = createServerHarness(dir); + const reloaded = reopened.loadSession(id); + assert.deepEqual(reloaded.messages, resolved.messages); + assert.equal(reopened.resolveSessionHistory(reloaded).available, true); +}); + +check('原生日志不匹配和不存在时只返回已有旧快照并保留缺口状态', dir => { + const old = { ...fixture('unconfirmed', 57), messages: [{ ccwebPersistenceNotice: true, role: 'system', content: '旧截断' }, ...messages(57)] }; + fs.writeFileSync(path.join(dir, 'unconfirmed.json'), JSON.stringify(old)); + const api = createServerHarness(dir); + const loaded = api.loadSession('unconfirmed'); + const history = api.resolveSessionHistory(loaded); + assert.equal(history.messages.length, 57); + assert.equal(history.available, false); + assert.equal(api.saveSession(loaded), true); + assert.equal(createServerHarness(dir).resolveSessionHistory(createServerHarness(dir).loadSession('unconfirmed')).available, false); +}); + +check('归档提交失败的旧前缀恢复只提供原快照而不宣称恢复成功', dir => { + const id = 'recover-failure'; + const original = fixture(id, 3); + original.messages[0].role = 'assistant'; + original.messages[0].turnId = 'boundary'; + original.messages[0].nativeThreadId = 'thread-a'; + const old = { ...original, messages: [{ ccwebPersistenceNotice: true, role: 'system', content: '旧截断' }, ...original.messages] }; + fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(old)); + const nativeDir = path.join(dir, '_native'); + fs.mkdirSync(nativeDir); + const rolloutPath = path.join(nativeDir, 'rollout-thread-a.jsonl'); + fs.writeFileSync(rolloutPath, [ + { type: 'session_meta', payload: { id: 'thread-a' } }, + { type: 'turn_context', payload: { turn_id: 'early' } }, + { type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '前缀' }] } }, + { type: 'turn_context', payload: { turn_id: 'boundary' } }, + { type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '边界' }] } }, + ].map(entry => JSON.stringify(entry)).join('\n')); + const filesystem = new Proxy(fs, { get(target, key) { + if (key === 'renameSync') return (from, to) => { + if (String(to).includes(`${path.sep}_history${path.sep}`)) throw new Error('归档失败'); + return target.renameSync(from, to); + }; + return target[key]; + } }); + const api = createServerHarness(dir, { fs: filesystem, nativeFiles: [rolloutPath], nativeDir }); + const result = api.resolveSessionHistory(api.loadSession(id)); + assert.equal(result.available, false); + assert.equal(result.messages.length, 3); + assert.equal(fs.existsSync(path.join(dir, '_history', `${id}.json`)), false); +}); + +check('生产 clear/delete/prompt删除/steer撤回路径保存精确状态且重开不复活', dir => { + const api = createServerHarness(dir); + const original = fixture('lifecycle', 230); + original.messages.push({ id: 'prompt-message', role: 'assistant', content: '引导问题', ccwebPrompt: { id: 'prompt-id' } }); + original.messages.push({ id: 'steer-user', role: 'user', content: '待撤回 steer' }); + assert.equal(api.saveSession(original), true); + const loaded = api.loadSession('lifecycle'); + assert(api.removeCcwebPromptMessage(loaded, 'prompt-id')); + assert.equal(api.saveSession(loaded), true); + assert.equal(api.rollbackPersistedCodexAppSteerMessage('lifecycle', 'steer-user'), true); + assert.equal(createServerHarness(dir).loadSession('lifecycle').messages.length, 230); + assert.equal(api.handleSlashCommand(null, '/clear', 'lifecycle', 'codexapp'), true); + const cleared = createServerHarness(dir).loadSession('lifecycle'); + assert.deepEqual(cleared.messages, []); + assert.equal(cleared.codexAppThreadId, null); + assert(!api.responses.some(response => response.type === 'error')); + api.handleDeleteSession(null, 'lifecycle'); + assert.equal(createServerHarness(dir).loadSession('lifecycle'), null); + assert.equal(fs.existsSync(path.join(dir, '_history', 'lifecycle.json')), false); +}); + +check('生产分页出口读取57条旧快照:首屏12以外45条完整有序且不重复', dir => { + const original = fixture('paging', 57); + original.messages.unshift({ ccwebPersistenceNotice: true, role: 'system', content: '旧截断提示' }); + fs.writeFileSync(path.join(dir, 'paging.json'), JSON.stringify(original)); + const api = createServerHarness(dir); + const chunks = []; + let cursor = 45; + while (cursor > 0) { + api.handleLoadHistoryPage(null, { sessionId: 'paging', before: cursor, requestId: `page-${cursor}` }); + const page = api.responses.at(-1); + assert.equal(page.type, 'session_history_chunk'); + assert.equal(page.historyTotal, 57); + assert.equal(page.historyAvailable, false); + chunks.unshift(...page.messages); + cursor = page.historyCursor; + } + assert.deepEqual(chunks.map(message => message.id), original.messages.slice(1, 46).map(message => message.id)); + assert.equal(new Set(chunks.map(message => message.id)).size, 45); +}); + +if (failures.length > 0) { + console.error(`完整历史回归失败:${failures.length} 项,已通过 ${checks} 项`); + process.exitCode = 1; +} else { + console.log(`完整历史持久化回归通过:${checks} 项`); +} +} + +if (require.main === module) runTests(); +module.exports = { createServerHarness }; diff --git a/scripts/session-history-unit.js b/scripts/session-history-unit.js index cc5336b..0226f13 100644 --- a/scripts/session-history-unit.js +++ b/scripts/session-history-unit.js @@ -6,6 +6,7 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { createServerHarness } = require('./session-history-store-unit'); const source = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8'); function extract(name) { const start = source.indexOf(`function ${name}(`); @@ -140,6 +141,7 @@ check('三个历史出口使用同一 resolver,异常边界保持当前快照' ${extract('isPersistenceNoticeMessage')} function loadNativeSessionHistory() { return { messages: ${JSON.stringify(native)} }; } function plog() {} + const sessionHistoryStore = { state() { return null; } }; ${extract('resolveSessionHistory')} return resolveSessionHistory; `)(crypto); @@ -157,7 +159,8 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载' const filePath = path.join(dir, `${id}.json`); const original = JSON.stringify({ ...session, id }); fs.writeFileSync(filePath, original); - const testApi = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'nativeMessages', ` + const persistenceAdapter = createServerHarness(dir); + const testApi = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'nativeMessages', 'persistenceAdapter', ` ${pureSource} ${extract('isPersistenceNoticeMessage')} let running = true; @@ -165,6 +168,7 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载' const SESSION_LOAD_MAX_BYTES = 10000000; const sessionHistoryCache = new Map(); const sessionSearchIndex = { scheduleUpsert() {} }; + const sessionHistoryStore = persistenceAdapter.sessionHistoryStore; function scheduleUsageStatisticsUpsert() {} function sanitizeId(id) { return id; } function sessionPath(id) { return path.join(SESSIONS_DIR, id + '.json'); } @@ -172,7 +176,8 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载' function normalizeSession(value) { return value; } function loadNativeSessionHistory() { return { messages: nativeMessages }; } function textByteLength(value) { return Buffer.byteLength(value); } - function loadSession(id) { return JSON.parse(fs.readFileSync(sessionPath(id), 'utf8')); } + function loadSession(id) { return persistenceAdapter.loadSession(id); } + function saveSession(session) { return persistenceAdapter.saveSession(session); } function sessionHistoryCacheKey(session) { return session.id; } function wsSend(ws, result) { response = result; } function attachClientRequestId(payload) { return payload; } @@ -181,7 +186,7 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载' ${extract('writeFileAtomicSync')} ${extract('handleMigrateSessionHistory')} return { run(id, apply) { handleMigrateSessionHistory(null, { sessionId: id, apply }); return response; }, idle() { running = false; } }; - `)(fs, path, crypto, dir, native); + `)(fs, path, crypto, dir, native, persistenceAdapter); assert.equal(testApi.run(id, true).code, 'session_running'); assert.equal(fs.readFileSync(filePath, 'utf8'), original); testApi.idle(); @@ -191,7 +196,7 @@ check('迁移只在 idle 执行并精确保留备份,写后可以重新加载' assert.equal(result.ok, true); assert.equal(result.changed, true); assert.equal(fs.readFileSync(result.backupPath, 'utf8'), original); - const restored = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const restored = createServerHarness(dir).loadSession(id); assert.equal(restored.messages.length, 483); assert(!restored.messages.some(m => m.ccwebPersistenceNotice)); assert.deepEqual(restored.messages.slice(-68).map(m => m.id), snapshot.map(m => m.id)); diff --git a/scripts/streaming-retry-browser-check.js b/scripts/streaming-retry-browser-check.js new file mode 100644 index 0000000..7d46c2a --- /dev/null +++ b/scripts/streaming-retry-browser-check.js @@ -0,0 +1,498 @@ +#!/usr/bin/env node +'use strict'; + +// 真实浏览器与隔离服务验证同一任务的重试气泡;所有会话、日志和门控文件均位于临时目录。 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawn } = require('node:child_process'); +const { WebSocket, WebSocketServer } = require('ws'); + +const REPO = path.resolve(__dirname, '..'); +const FIREFOX = process.env.CC_WEB_BROWSER_FIREFOX || '/snap/firefox/current/usr/lib/firefox/firefox'; +const GECKODRIVER = process.env.CC_WEB_BROWSER_GECKODRIVER || '/snap/firefox/current/usr/lib/firefox/geckodriver'; +const SESSION = '12222222-3333-4444-8555-666666666666'; +const OTHER = '12222222-3333-4444-8555-777777777777'; +const THREAD = '12222222-3333-4444-8555-888888888888'; +const PASSWORD = 'StreamingRetryBrowserFixture!234'; +const QUESTION = 'streaming browser retry fixture'; +const BROWSER_ASSETS = [ + { url: 'https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js', file: 'marked.min.js' }, + { url: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', file: 'highlight.min.js' }, + { url: 'https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js', file: 'mermaid.min.js' }, + { url: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', file: 'atom-one-dark.min.css' }, +]; +const PARTS = { + A: '第一段最早正文\n\n' + '甲'.repeat(128000) + '\n\n第一段完整末尾\n', + B: '\n第二段续跑正文\n' + '乙'.repeat(4000) + '\n第二段完整末尾\n', + C: '\n第三段续跑正文\n\n最后回复的第一自然段。\n\n- 第一条完整列表内容\n- 第二条完整列表内容\n\n```js\nconst retained = true;\n```\n\n```html\n
代码中的假分界
\n```\n\n' + '丙'.repeat(4000) + '\n\n最后回复的第二自然段。\n\n第三段完整末尾\n', +}; +const MARKERS = { + A: ['第一段最早正文', '第一段完整末尾'], + B: ['第二段续跑正文', '第二段完整末尾'], + C: ['第三段续跑正文', '第三段完整末尾'], +}; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function sourceFingerprints() { + return Object.fromEntries(['server.js', 'lib/agent-runtime.js', 'lib/codex-app-runtime.js', 'lib/session-history-store.js', + 'public/app.js', 'public/style.css', 'scripts/mock-codex-app-server.js', 'scripts/streaming-retry-browser-check.js'].map((file) => [ + file, crypto.createHash('sha256').update(fs.readFileSync(path.join(REPO, file))).digest('hex'), + ])); +} + +async function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); }); + }); +} +async function waitFor(predicate, description, timeout = 8000) { + const start = Date.now(); + let lastError; + while (Date.now() - start < timeout) { + try { const value = await predicate(); if (value) return value; } catch (error) { lastError = error; } + await sleep(60); + } + throw new Error(`${description}${lastError ? ':' + lastError.message : ''}`); +} +async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode) return; + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + await Promise.race([exited, sleep(1000)]); + if (child.exitCode === null && !child.signalCode) { child.kill('SIGKILL'); await exited; } +} + +class FirefoxDriver { + constructor(port) { this.port = port; this.sessionId = ''; } + async request(method, endpoint, body) { + const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, { + method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json(); + if (!response.ok || payload.value?.error) throw new Error(JSON.stringify(payload.value || payload)); + return payload.value; + } + async start() { + const value = await this.request('POST', '/session', { capabilities: { alwaysMatch: { + browserName: 'firefox', pageLoadStrategy: 'eager', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'], prefs: { + 'app.update.auto': false, 'browser.newtabpage.enabled': false, 'browser.startup.page': 0, + 'browser.tabs.remote.autostart': false, 'dom.ipc.processCount': 1, 'dom.ipc.processPrelaunch.enabled': false, + 'fission.autostart': false, 'gfx.webrender.software': true, 'browser.shell.checkDefaultBrowser': false, + } }, + } } }); + this.sessionId = value.sessionId; + await this.request('POST', `/session/${this.sessionId}/window/rect`, { width: 1280, height: 1050 }); + return value.capabilities; + } + execute(script, args = []) { return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args }); } + navigate(url) { return this.request('POST', `/session/${this.sessionId}/url`, { url }); } + refresh() { return this.request('POST', `/session/${this.sessionId}/refresh`, {}); } + async pressKey(selector, key) { + await this.execute('document.querySelector(arguments[0]).focus();', [selector]); + const value = key === 'Enter' ? '\uE007' : ' '; + await this.request('POST', `/session/${this.sessionId}/actions`, { actions: [{ + type: 'key', id: 'keyboard', actions: [{ type: 'keyDown', value }, { type: 'keyUp', value }], + }] }); + await this.request('DELETE', `/session/${this.sessionId}/actions`); + } + async screenshot(file) { + const data = await this.request('GET', `/session/${this.sessionId}/screenshot`); + fs.writeFileSync(file, Buffer.from(data, 'base64')); + } + async close() { if (this.sessionId) await this.request('DELETE', `/session/${this.sessionId}`); } +} + +class ChromiumDriver { + constructor() { this.sessionId = 'chromium'; } + async start() { + const { chromium } = require(process.env.CC_WEB_BROWSER_PLAYWRIGHT || '/home/hdzx/node_modules/playwright'); + this.browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }); + this.page = await this.browser.newPage({ viewport: { width: 1280, height: 1050 } }); + return { browserName: 'chromium', browserVersion: this.browser.version() }; + } + execute(script, args = []) { + return this.page.evaluate(({ script, args }) => new Function(script).apply(null, args), { script, args }); + } + navigate(url) { return this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 8000 }); } + refresh() { return this.page.reload({ waitUntil: 'domcontentloaded', timeout: 8000 }); } + pressKey(selector, key) { return this.page.locator(selector).press(key); } + screenshot(file) { return this.page.screenshot({ path: file }); } + request(_method, endpoint, body) { + if (endpoint.endsWith('/window/rect')) return this.page.setViewportSize({ width: body.width, height: body.height }); + throw new Error('Chromium 夹具不支持 ' + endpoint); + } + async close() { await this.browser?.close(); } +} + +function createFixture(root) { + const home = path.join(root, 'fixture-home'), sessions = path.join(root, 'sessions'); + for (const folder of [home, sessions, path.join(home, '.codex', 'sessions'), path.join(root, 'config'), path.join(root, 'logs')]) fs.mkdirSync(folder, { recursive: true }); + const metadata = { agent: 'codexapp', cwd: root, created: '2026-10-10T01:00:00.000Z', updated: '2026-10-10T01:00:00.000Z', permissionMode: 'yolo' }; + fs.writeFileSync(path.join(sessions, SESSION + '.json'), JSON.stringify({ ...metadata, id: SESSION, + title: '重试气泡浏览器夹具', codexAppThreadId: THREAD, messages: [], + })); + fs.writeFileSync(path.join(sessions, OTHER + '.json'), JSON.stringify({ ...metadata, id: OTHER, + title: '切换隔离目标', messages: [{ id: 'other-user', role: 'user', content: '另一个会话的问题' }, + { id: 'other-answer', role: 'assistant', content: '另一个会话的回答' }], + })); + fs.writeFileSync(path.join(root, 'config', 'codex.json'), JSON.stringify({ mode: 'local', retry: { mode: 'limited', intervalSeconds: 4, maxAttempts: 4 } })); + const gates = ['retry-first', 'retry-second', 'complete-turn'].map((name) => path.join(root, name)); + const preload = path.join(root, 'isolated-home.cjs'); + const baseline = process.env.CC_WEB_STREAMING_BROWSER_BASELINE_SERVER || ''; + if (baseline) assert(fs.existsSync(baseline), '指定的基线源码不存在'); + fs.writeFileSync(preload, ` +const fs = require('node:fs'); +const Module = require('node:module'); +const originalLoader = Module._extensions['.js']; +require('node:os').homedir = () => ${JSON.stringify(home)}; +Module._extensions['.js'] = (module, filename) => { + if (filename !== ${JSON.stringify(path.join(REPO, 'server.js'))}) return originalLoader(module, filename); + const source = fs.readFileSync(${JSON.stringify(baseline)} || filename, 'utf8'); + const needle = "const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');"; + if (!source.includes(needle)) throw new Error('原生历史目录注入点已变更'); + module._compile(source.replace(needle, 'const CODEX_SESSIONS_DIR = ' + ${JSON.stringify(JSON.stringify(path.join(home, '.codex', 'sessions')))} + ';'), filename); +}; +`); + const mock = path.join(root, 'mock-codex.js'); + const originalMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8'); + const needle = " const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;"; + assert(originalMock.includes(needle), 'mock 默认启动分支已变更'); + const replacement = ` + if (/streaming browser retry fixture/i.test(text)) { + const attempt = (thread.browserRetryAttempt || 0) + 1; + thread.browserRetryAttempt = attempt; + const label = attempt === 1 ? 'A' : attempt === 2 ? 'B' : 'C'; + const output = ${JSON.stringify(PARTS)}[label]; + for (let offset = 0; offset < output.length; offset += 8192) { + send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-reused-message',delta:output.slice(offset,offset+8192)}}); + } + send({method:'item/completed',params:{threadId:thread.id,turnId,item:{id:'browser-reused-message',type:'agentMessage',text:output}}}); + const item = {id:'browser-reused-tool',type:'commandExecution',command:'echo browser-tool-'+label,status:'inProgress'}; + send({method:'item/started',params:{threadId:thread.id,turnId,item}}); + send({method:'item/commandExecution/outputDelta',params:{threadId:thread.id,turnId,itemId:item.id,delta:'browser-tool-'+label+'-result'}}); + send({method:'item/completed',params:{threadId:thread.id,turnId,item:{...item,status:'completed',exitCode:0,aggregatedOutput:'browser-tool-'+label+'-result'}}}); + thread.timer = setInterval(() => { + if (!require('fs').existsSync(${JSON.stringify(gates)}[Math.min(attempt,3)-1])) return; + clearInterval(thread.timer); thread.timer = null; + if (attempt <= 2) { emitCapacityError(thread,turnId); } + else { + send({method:'turn/completed',params:{threadId:thread.id,turn:{id:turnId,status:'completed',items:[]}}}); + thread.activeTurnId = null; + } + },40); + return {turn:{id:turnId,status:'running',items:[]}}; + } +`; + fs.writeFileSync(mock, originalMock.replace(needle, replacement + needle) + .replace("path.join(__dirname, 'mock-codex.js')", JSON.stringify(path.join(REPO, 'scripts/mock-codex.js'))), { mode: 0o755 }); + return { home, sessions, preload, mock, gates, baseline }; +} + +async function startRelay(port, targetPort, evidence) { + const assetDir = process.env.CC_WEB_STREAMING_BROWSER_ASSET_DIR || ''; + if (assetDir) { + // 使用与 index.html 相同版本的公开库字节,隔离测试无需等待外部 CDN。 + evidence.assets = BROWSER_ASSETS.map((asset) => { + const content = fs.readFileSync(path.join(assetDir, asset.file)); + assert(content.length > 0, '本地浏览器库不能为空:' + asset.file); + return { ...asset, bytes: content.length, sha256: crypto.createHash('sha256').update(content).digest('hex') }; + }); + } + const server = http.createServer((request, response) => { + const asset = assetDir && BROWSER_ASSETS.find((item) => request.url === '/__streaming_fixture_assets/' + item.file); + if (asset) { + response.writeHead(200, { 'content-type': asset.file.endsWith('.css') ? 'text/css' : 'text/javascript' }); + response.end(fs.readFileSync(path.join(assetDir, asset.file))); + return; + } + const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method, + headers: { ...request.headers, ...(assetDir ? { 'accept-encoding': 'identity' } : {}) }, + }, (result) => { + if (assetDir && result.headers['content-type']?.includes('text/html')) { + const chunks = []; + result.on('data', (chunk) => chunks.push(chunk)); + result.on('end', () => { + let html = Buffer.concat(chunks).toString('utf8'); + BROWSER_ASSETS.forEach((item) => { html = html.replaceAll(item.url, '/__streaming_fixture_assets/' + item.file); }); + response.writeHead(result.statusCode, { ...result.headers, 'content-length': Buffer.byteLength(html) }); + response.end(html); + }); + return; + } + response.writeHead(result.statusCode, result.headers); result.pipe(response); + }); + upstream.on('error', () => response.destroy()); request.pipe(upstream); + }); + const sockets = new Set(), wsServer = new WebSocketServer({ noServer: true }); + let connections = 0; + server.on('upgrade', (request, socket, head) => wsServer.handleUpgrade(request, socket, head, (client) => { + connections += 1; sockets.add(client); + const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`), pending = []; + upstream.on('open', () => { pending.splice(0).forEach((data) => upstream.send(data)); }); + client.on('message', (data) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else pending.push(data); }); + upstream.on('message', (data) => { + try { + const msg = JSON.parse(String(data)); + if (['resume_generating', 'done', 'system_message'].includes(msg.type)) evidence.protocol.push({ + type: msg.type, sessionId: msg.sessionId, assistantMessageId: msg.assistantMessageId || msg.assistantMessage?.id, + textChars: (msg.text || msg.assistantMessage?.content || '').length, retryPending: msg.retryPending === true, + isRunning: msg.isRunning, message: msg.message, tools: (msg.toolCalls || msg.assistantMessage?.toolCalls || []).length, + }); + } catch {} + if (client.readyState === WebSocket.OPEN) client.send(data, { binary: false }); + }); + client.on('close', () => { sockets.delete(client); upstream.terminate(); }); + upstream.on('close', () => { if (client.readyState === WebSocket.OPEN) client.close(); }); + upstream.on('error', () => client.terminate()); client.on('error', () => upstream.terminate()); + })); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return { + count: () => connections, + disconnect() { for (const socket of sockets) socket.terminate(); }, + async close() { for (const socket of sockets) socket.terminate(); wsServer.close(); server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); }, + }; +} + +async function capture(driver, stage, labels, evidence, generating = true) { + const result = await waitFor(async () => { + const state = await driver.execute(`return { + current:document.querySelector('#chat-session-id-btn')?.title || '', + streamCount:document.querySelectorAll('#streaming-msg').length, + bubbles:[...document.querySelectorAll('#messages > .msg.assistant')].map(node => ({ + text:node.querySelector('.msg-text')?.textContent || node.querySelector('.msg-bubble')?.textContent || '', + key:node.dataset.messageKey || '',streaming:node.id==='streaming-msg', + tools:[...node.querySelectorAll('.tool-call')].map(tool => tool.textContent), + })), + users:[...document.querySelectorAll('#messages > .msg.user')].map(node => node.textContent), + };`); + const bubble = state.bubbles.find((node) => node.text.includes(MARKERS.A[0])); + if (!bubble) return null; + for (const label of labels) { + for (const marker of MARKERS[label]) if (!bubble.text.includes(marker)) return null; + if (!bubble.tools.some((text) => text.includes('browser-tool-' + label + '-result'))) return null; + } + return { ...state, bubble }; + }, `${stage}:气泡前文或完整尾段丢失`); + assert.equal(result.streamCount, generating ? 1 : 0, `${stage}:流式气泡数量错误`); + assert.equal(result.bubbles.length, 1, `${stage}:同一逻辑任务只能显示一个助手气泡`); + assert.equal(result.users.filter((text) => text.includes(QUESTION)).length, 1, `${stage}:用户问题重复或消失`); + let previousIndex = -1; + for (const label of labels) { + for (const marker of MARKERS[label]) { + assert.equal(result.bubble.text.split(marker).length - 1, 1, `${stage}:正文重复:${marker}`); + const index = result.bubble.text.indexOf(marker); + assert(index > previousIndex, `${stage}:正文顺序错乱`); previousIndex = index; + } + const first = result.bubble.text.indexOf(MARKERS[label][0]) + MARKERS[label][0].length; + const last = result.bubble.text.indexOf(MARKERS[label][1]); + assert(last - first >= PARTS[label].length - 30, `${stage}:正文中段被裁剪`); + } + assert.equal(result.bubble.tools.length, labels.length, `${stage}:工具气泡丢失或重复`); + for (const label of labels) assert(result.bubble.tools.some((text) => text.includes('browser-tool-' + label + '-result')), `${stage}:工具结果 ${label} 丢失`); + const compact = { + textChars: result.bubble.text.length, key: result.bubble.key, streamCount: result.streamCount, + toolCount: result.bubble.tools.length, labels, textSha256: crypto.createHash('sha256').update(result.bubble.text).digest('hex'), + }; + evidence.stages[stage] = compact; + console.log(JSON.stringify({ stage, ...compact })); + return compact; +} + +async function selectSession(driver, sessionId) { + await driver.execute(`const node=document.querySelector('.session-item[data-id="'+arguments[0]+'"]'); + if(!node)throw new Error('会话列表未出现隔离夹具');node.click();`, [sessionId]); +} + +async function checkControls(driver, stage, labels, expanded = false, mobile = false) { + const controls = await driver.execute(`const msg=document.querySelector('#messages > .msg.assistant'); + const toggle=msg?.querySelector('.msg-replies-toggle'),copy=msg?.querySelector('.msg-copy-reply-btn'); + const content=msg?.querySelector('.assistant-reply-content'),box=content?.getBoundingClientRect(); + return {toggle:!!toggle,copy:!!copy,expanded:toggle?.getAttribute('aria-expanded'), + multiCollapsed:msg?.classList.contains('assistant-history-collapsed'),singleCollapsed:msg?.classList.contains('assistant-single-collapsed'), + textChars:content?.textContent.length || msg?.querySelector('.msg-bubble')?.textContent.length, + contentHeight:box?.height,contentMaxHeight:content?getComputedStyle(content).maxHeight:'', + early:[...msg.querySelectorAll('.assistant-earlier-reply')].map(node=>({text:node.textContent,visible:node.getClientRects().length>0})), + dividerCount:msg.querySelectorAll('.agent-message-divider').length, + fencedDivider:!![...msg.querySelectorAll('pre code')].find(node=>node.textContent.includes('代码中的假分界')), + actionButtons:[...msg.querySelectorAll('.msg-action-row > button')].filter(node=>!node.hidden).map(node=>({ + className:node.className,top:node.getBoundingClientRect().top,row:node.parentElement.className})), + copyWidth:copy?.getBoundingClientRect().width,copyHeight:copy?.getBoundingClientRect().height, + toggleHeight:toggle?.getBoundingClientRect().height, + width:innerWidth,scrollWidth:document.documentElement.scrollWidth};`); + assert(controls.toggle && controls.copy, `${stage}:折叠或复制入口丢失`); + assert.equal(controls.expanded, expanded ? 'true' : 'false', `${stage}:折叠 aria 状态错误`); + assert.equal(controls.dividerCount, labels.length - 1, `${stage}:代码围栏内的 HTML 不得生成原生分界`); + if (labels.includes('C')) assert(controls.fencedDivider, `${stage}:代码中的原始 HTML 应完整显示`); + const actionClasses = controls.actionButtons.map((button) => button.className); + assert(actionClasses.includes('msg-copy-reply-btn') && actionClasses.includes('msg-last-section-btn'), `${stage}:复制和定位控件应在同一工具行`); + if (stage.startsWith('completed') || mobile) assert(actionClasses.includes('msg-branch-btn'), `${stage}:完成气泡的分支控件应保留`); + assert(controls.actionButtons.every((button) => Math.abs(button.top - controls.actionButtons[0].top) < 1), `${stage}:复制、定位和分支应同排`); + if (labels.length === 1 && !expanded) { + assert(controls.singleCollapsed, `${stage}:无 divider 的长单回复应有受控折叠`); + assert(controls.contentHeight <= 421 && controls.textChars > 128000, `${stage}:折叠不得删正文`); + } else if (labels.length > 1) { + assert.equal(controls.multiCollapsed, !expanded, `${stage}:多原生回复折叠状态错误`); + assert(controls.early.some((node) => node.text.includes(MARKERS.A[0])), `${stage}:第一段仍须存在于 DOM`); + assert(controls.early.every((node) => node.visible === expanded), `${stage}:早期正文可见状态错误`); + } + if (mobile) { + assert(controls.copyWidth >= 44 && controls.copyHeight >= 44 && controls.toggleHeight >= 44, `${stage}:手机触控面积不足`); + assert(controls.scrollWidth <= controls.width + 1, `${stage}:控件导致手机水平溢出`); + } + await driver.execute(`window.__browserCopiedText=''; + Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async(value)=>{window.__browserCopiedText=value;}}}); + document.execCommand=(name)=>{if(name==='copy'){window.__browserCopiedText=document.activeElement?.value||'';return true;}return false;}; + document.querySelector('#messages > .msg.assistant .msg-copy-reply-btn').click();`); + const copied = await waitFor(() => driver.execute('return window.__browserCopiedText'), `${stage}:复制没有写入剪贴板`); + assert.equal(copied, PARTS[labels[labels.length - 1]].trim(), `${stage}:复制应包含完整最新原生回复 Markdown`); + assert(!copied.includes('browser-tool-'), `${stage}:复制混入工具结果`); + return { ...controls, early: controls.early.map((node) => ({ textChars: node.text.length, visible: node.visible })), copiedChars: copied.length }; +} + +async function toggleControls(driver, key) { + if (key) await driver.pressKey('#messages > .msg.assistant .msg-replies-toggle', key); + else await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').click();`); +} + +async function main() { + const chromium = process.env.CC_WEB_STREAMING_BROWSER_ENGINE === 'chromium'; + if (!chromium) assert(fs.existsSync(FIREFOX) && fs.existsSync(GECKODRIVER), '需要本机 Firefox 与 geckodriver'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-streaming-retry-browser-')); + const fixture = createFixture(root); + const evidence = { root, fixture, protocol: [], stages: {}, sourceFingerprints: sourceFingerprints(), + kind: '真实 server.js / 浏览器 / 门控 A-B-C / 隔离存储' }; + const [serverPort, relayPort, driverPort] = await Promise.all([freePort(), freePort(), freePort()]); + let server, gecko, relay, driver; + try { + server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], { + cwd: REPO, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD, + CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'), + CODEX_HOME: path.join(fixture.home, '.codex'), + CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0', + CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS: '4096', + }, stdio: ['ignore', 'pipe', 'pipe'], + }); + const serverLog = fs.createWriteStream(path.join(root, 'server.log')); server.stdout.pipe(serverLog); server.stderr.pipe(serverLog); + await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败'); + console.log('隔离服务就绪:' + root); + relay = await startRelay(relayPort, serverPort, evidence); + if (!chromium) { + gecko = spawn(GECKODRIVER, ['--port', String(driverPort)], { stdio: ['ignore', 'pipe', 'pipe'] }); + const geckoLog = fs.createWriteStream(path.join(root, 'geckodriver.log')); gecko.stdout.pipe(geckoLog); gecko.stderr.pipe(geckoLog); + await waitFor(async () => (await fetch(`http://127.0.0.1:${driverPort}/status`)).ok, 'geckodriver 启动失败'); + } + driver = chromium ? new ChromiumDriver() : new FirefoxDriver(driverPort); + console.log('开始启动浏览器:' + (chromium ? 'Chromium' : 'Firefox')); + evidence.browser = await driver.start(); + console.log('浏览器会话已就绪'); + await driver.navigate(`http://127.0.0.1:${relayPort}/`); + await waitFor(() => driver.execute(`return document.querySelector('#login-password')?.getClientRects().length > 0`), '登录表单未就绪'); + // 只提交一次,避免认证刷新尚未结束时再次提交造成连续导航。 + await driver.execute(`document.querySelector('#login-password').value=arguments[0];document.querySelector('#login-form').requestSubmit();`, [PASSWORD]); + await waitFor(() => driver.execute(`return !document.querySelector('#app').hidden && !!document.querySelector('.session-item')`), '登录或会话列表未就绪'); + await selectSession(driver, SESSION); + await waitFor(() => driver.execute(`return document.querySelector('#chat-session-id-btn')?.title.includes(arguments[0])`, [SESSION]), '目标会话未加载'); + await driver.execute(`const input=document.querySelector('#msg-input');input.value=arguments[0];input.dispatchEvent(new Event('input',{bubbles:true}));`, [QUESTION]); + await waitFor(() => driver.execute(`return !document.querySelector('#send-btn').disabled`), '初次请求发送控件未就绪'); + await driver.execute(`document.querySelector('#send-btn').click();`); + const first = await capture(driver, 'initial-A', ['A'], evidence); + evidence.controls = { initialSingle: await checkControls(driver, 'initial-A', ['A']) }; + await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-last-section-btn').click();`); + evidence.singleReplyLocate = await waitFor(() => driver.execute(` + const msg=document.querySelector('#messages > .msg.assistant'); + const last=[...msg.querySelectorAll('p')].find(node=>node.textContent.trim()===arguments[0]); + const rect=last?.getBoundingClientRect(),box=document.querySelector('#messages').getBoundingClientRect(); + if(!rect || msg.querySelector('.msg-replies-toggle').getAttribute('aria-expanded')!=='true')return null; + return rect.top>=box.top&&rect.bottom<=box.bottom?{visible:true,expanded:true,top:rect.top,bottom:rect.bottom}:null;`, [MARKERS.A[1]]), '单条长回复的定位必须先展开并显示裁剪区末段'); + await toggleControls(driver); + await toggleControls(driver, 'Enter'); + evidence.controls.initialSingleExpanded = await checkControls(driver, 'initial-A-expanded', ['A'], true); + evidence.controls.initialSingleExpanded.keyboard = 'Enter'; + await toggleControls(driver); + + await selectSession(driver, OTHER); + await waitFor(() => driver.execute(`return document.querySelector('#messages').textContent.includes('另一个会话的回答')`), '隔离会话未加载'); + assert.equal(await driver.execute(`return document.querySelector('#messages').textContent.includes('第一段最早正文')`), false, '长任务正文串入其他会话'); + assert.equal(await driver.execute(`return !!document.querySelector('.msg-replies-toggle')`), false, '短历史回复不应显示折叠入口'); + await selectSession(driver, SESSION); + await capture(driver, 'switched-back-A', ['A'], evidence); + + fs.writeFileSync(fixture.gates[0], 'retry\n'); + await waitFor(() => evidence.protocol.some((msg) => msg.type === 'resume_generating' && msg.retryPending), '等待重试未下发完整运行态'); + await driver.refresh(); + await capture(driver, 'retry-wait-refreshed-A', ['A'], evidence); + const beforeReconnect = relay.count(); relay.disconnect(); + await waitFor(() => relay.count() > beforeReconnect, '等待重试期间 WebSocket 未重连'); + await capture(driver, 'retry-wait-reconnected-A', ['A'], evidence); + const second = await capture(driver, 'continued-AB', ['A', 'B'], evidence); + assert.equal(second.key, first.key, '重试不能更换逻辑助手消息 ID'); + evidence.controls.multiple = await checkControls(driver, 'continued-AB', ['A', 'B']); + await toggleControls(driver, 'Space'); + evidence.controls.multipleExpanded = await checkControls(driver, 'continued-AB-expanded', ['A', 'B'], true); + evidence.controls.multipleExpanded.keyboard = 'Space'; + await toggleControls(driver); + + fs.writeFileSync(fixture.gates[1], 'retry\n'); + const third = await capture(driver, 'continued-ABC', ['A', 'B', 'C'], evidence); + assert.equal(third.key, first.key); + fs.writeFileSync(fixture.gates[2], 'done\n'); + await waitFor(() => driver.execute(`return !document.querySelector('#streaming-msg')`), '完成后没有收束流式气泡'); + await capture(driver, 'completed-ABC', ['A', 'B', 'C'], evidence, false); + await driver.refresh(); + const completed = await capture(driver, 'completed-refreshed-ABC', ['A', 'B', 'C'], evidence, false); + assert.equal(completed.key, first.key); + evidence.controls.completed = await checkControls(driver, 'completed-refreshed-ABC', ['A', 'B', 'C']); + await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').scrollIntoView({block:'start'});`); + await driver.screenshot(path.join(root, 'desktop-collapsed-top.png')); + await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`); + await driver.screenshot(path.join(root, 'desktop-latest-reply.png')); + await toggleControls(driver); + evidence.controls.completedExpanded = await checkControls(driver, 'completed-ABC-expanded', ['A', 'B', 'C'], true); + + await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=0;`); + await waitFor(() => driver.execute(`const node=[...document.querySelectorAll('#messages > .msg.assistant .msg-bubble')].find(node=>node.textContent.includes('第一段最早正文')); + if(!node)return false;const rect=node.getBoundingClientRect();return rect.top>=0&&rect.top .msg.assistant .msg-replies-toggle').scrollIntoView({block:'start'});`); + await driver.screenshot(path.join(root, 'mobile-collapsed-top.png')); + await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`); + await driver.screenshot(path.join(root, 'mobile-latest-reply.png')); + await driver.execute(`const input=document.querySelector('#msg-input');input.value='下一用户轮次';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#send-btn').click();`); + await waitFor(() => driver.execute(`return [...document.querySelectorAll('#messages > .msg.assistant')].some(node=>node.textContent.includes('下一用户轮次'))`), '下一用户轮次未完成'); + const next = await driver.execute(`return [...document.querySelectorAll('#messages > .msg.assistant')].map(node=>node.textContent)`); + assert.equal(next.length, 2, '新问题应有独立助手气泡'); + assert(!next[1].includes(MARKERS.A[0]), '新用户轮次错误继承上轮正文'); + evidence.nextUserTurn = { assistantBubbles: next.length, preservedPrevious: next[0].includes(MARKERS.A[0]) }; + evidence.ok = true; + console.log(JSON.stringify({ ok: true, root, browser: evidence.browser.browserVersion, stages: Object.keys(evidence.stages) })); + } catch (error) { + evidence.ok = false; evidence.error = error.stack; + if (driver?.sessionId) await driver.screenshot(path.join(root, 'failure.png')).catch(() => {}); + throw new Error(`${error.message}\n证据:${root}`, { cause: error }); + } finally { + if (driver) await driver.close().catch(() => {}); + if (relay) await relay.close(); + await stopChild(gecko); await stopChild(server); + evidence.sourceFingerprintsAfter = sourceFingerprints(); + evidence.sourcesChangedDuringRun = Object.keys(evidence.sourceFingerprints) + .filter((file) => evidence.sourceFingerprints[file] !== evidence.sourceFingerprintsAfter[file]); + fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2)); + } +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/streaming-retry-frontend-unit.js b/scripts/streaming-retry-frontend-unit.js new file mode 100644 index 0000000..210c4e4 --- /dev/null +++ b/scripts/streaming-retry-frontend-unit.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8'); + +function extractFunction(name) { + const start = source.indexOf(`function ${name}(`); + assert(start >= 0, `缺少前端函数 ${name}`); + let signatureEnd = start; + let parentheses = 0; + for (let i = source.indexOf('(', start); i < source.length; i += 1) { + if (source[i] === '(') parentheses += 1; + if (source[i] === ')' && --parentheses === 0) { signatureEnd = i; break; } + } + let depth = 0; + for (let i = source.indexOf('{', signatureEnd); i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1); + } + throw new Error(`无法提取 ${name}`); +} + +function extractCases(start, end) { + const first = source.indexOf(`case '${start}':`); + const last = source.indexOf(`case '${end}':`, first); + assert(first >= 0 && last > first, `缺少事件分支 ${start} → ${end}`); + return source.slice(first, last); +} + +// 节点仅模拟 DOM 容器;生成、恢复、历史对账和完成逻辑直接执行生产函数。 +class Element { + constructor(className = '') { + this.className = className; + this.id = ''; + this.dataset = {}; + this.children = []; + this.parentNode = null; + this.scrollTop = 0; + this.classList = { contains: (value) => this.className.split(' ').includes(value) }; + } + get scrollHeight() { return this.children.length * 50; } + get nextSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; } + set innerHTML(value) { + this.html = value; + this.children.forEach((child) => { child.parentNode = null; }); + this.children = []; + } + get innerHTML() { return this.html || ''; } + insertBefore(node, anchor) { + if (node.fragment) { [...node.children].forEach((child) => this.insertBefore(child, anchor)); return node; } + if (node === anchor) return node; + node.remove(); + const index = anchor ? this.children.indexOf(anchor) : this.children.length; + assert(index >= 0, '插入锚点必须属于当前容器'); + this.children.splice(index, 0, node); + node.parentNode = this; + return node; + } + appendChild(node) { return this.insertBefore(node, null); } + removeAttribute(name) { if (name === 'id') this.id = ''; } + remove() { + if (!this.parentNode) return; + this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode = null; + } + replaceWith(node) { if (this.parentNode) { this.parentNode.insertBefore(node, this); this.remove(); } } + matches(selector) { + if (selector.startsWith('.')) return this.classList.contains(selector.slice(1)); + if (selector.startsWith('#')) return this.id === selector.slice(1); + if (selector.includes('[data-session-message="true"]') && this.dataset.sessionMessage !== 'true') return false; + if (selector.includes('[data-message-key]') && !this.dataset.messageKey) return false; + return selector.startsWith('[data-'); + } + querySelectorAll(selector) { + return this.children.flatMap((child) => [child, ...child.querySelectorAll('*')]) + .filter((child) => selector === '*' || child.matches(selector)); + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } +} + +function createHarness() { + const names = [ + 'stableMessageHash', 'getStableMessageKey', 'getMessageRenderFingerprint', + 'mergeIndexedSessionMessages', 'dedupeSessionMessages', 'markSessionMessageElement', + 'findRenderedSessionMessageByKey', 'renderMessages', 'reconcileRenderedSessionMessages', + 'mergeHistoryChunkIntoCachedSnapshot', 'applySessionSnapshot', + 'isCurrentSessionEvent', 'ensureGeneratingForEvent', 'startGenerating', + 'bindStreamingAssistantMessage', 'commitCompletedAssistantMessage', 'finishGenerating', 'flushRender', + ]; + return new Function('Element', ` + const messagesDiv = new Element(); + const document = { + getElementById(id) { return messagesDiv.querySelector('#' + id); }, + querySelector(selector) { + if (selector.startsWith('#streaming-msg ')) { + return document.getElementById('streaming-msg')?.querySelector(selector.slice(15)) || null; + } + return messagesDiv.querySelector(selector); + }, + createElement() { return new Element(); }, + createDocumentFragment() { const node = new Element(); node.fragment = true; return node; }, + }; + const window = { pendingContentBlocks: [] }; + const activeToolCalls = new Map(), activeTodoCallTargets = new Map(); + let currentSessionId = 'task-session', currentSessionMessageCount = 0; + let currentSessionRunning = false, isGenerating = false, generatingSessionId = null, pendingText = ''; + let toolGroupCount = 0, hasGrouped = false, renderEpoch = 0, currentCwd = '/tmp/streaming-test'; + let currentAgent = 'codexapp', currentMode = 'yolo', currentModel = '', loadedHistorySessionId = null; + let currentOutlineTitleHistory = [], closedCollabAgentIds = new Set(); + let collabAgentStateCache = new Map(), collabAgentIdsByToolUseId = new Map(), closedCollabAgentIdsByToolUseId = new Map(); + const historyLoadMore = null, fileBrowserState = null, PRIMARY_UI_AGENT = 'codexapp', MODE_LABELS = {}; + const sessionCache = new Map(), buffers = new Map(), chatTitle = {}, msgInput = { focus() {} }; + function createMsgElement(role) { const node = new Element('msg ' + role); node.appendChild(new Element('msg-bubble')); return node; } + function buildMsgElement(message, index) { + const node = createMsgElement(message.role); + node.message = JSON.parse(JSON.stringify(message)); + node.querySelector('.msg-bubble').innerHTML = message.content; + markSessionMessageElement(node, index, message); + return node; + } + function appendToolCall(id, name, input, done, kind, meta) { + const node = new Element('tool-call'); node.dataset.toolUseId = id; + node.tool = { id, name, input, done, kind, meta }; + document.getElementById('streaming-msg')?.querySelector('.msg-tools')?.appendChild(node); + } + function updateToolCall(id, result, done) { + const node = messagesDiv.querySelectorAll('.tool-call').find((node) => node.dataset.toolUseId === id); + if (node) Object.assign(node.tool, { result, done }); + } + function isEmptyReasoningTool() { return false; } + function renderToolCallsWithMergedCollab(tools) { return tools; } + function setRenderedMarkdown(node, text) { node.innerHTML = text; } + function scheduleRender() { flushRender(); } + function setCurrentSessionRunningState(value) { currentSessionRunning = value; } + function normalizeAgent(value) { return value || 'codexapp'; } + function isPrimaryUiAgent() { return true; } + function setCurrentAgent(value) { currentAgent = value; } + function normalizeOutlineTitleHistory(value) { return value || []; } + function collectClosedCollabAgentIds() { return new Set(); } + function getSessionHistoryBuffer(id) { return buffers.get(id) || null; } + function isCompleteSessionSnapshot(snapshot) { return snapshot.complete; } + function cacheSessionSnapshot(snapshot) { sessionCache.set(snapshot.sessionId, { snapshot }); } + function bufferSessionSnapshot(snapshot) { buffers.set(snapshot.sessionId, snapshot); } + function isNearBottom() { return true; } + ${[ + 'resetChatView', 'renderSessionList', 'closeFileBrowser', 'updateGenerationControls', + 'resetHistoryLoadState', 'resetOutlineHistoryState', 'clearPendingMissingSessionConfirmation', + 'syncHistoryLoadControl', 'syncOutlineHistoryIndex', 'syncTaskTrackingControl', 'setLastSessionForAgent', + 'updateSessionIdBadge', 'migratePendingNotesToSession', 'migrateQueuedMessagesToSession', + 'setStatsDisplay', 'closeUserOutlinePanel', 'closeCcwebPromptOutlinePanel', 'updateCwdBadge', + 'scheduleAdvancedSearchJump', 'highlightActiveSession', 'closeSidebar', 'showToast', + 'updateNoteModeUI', 'renderPendingNotes', 'scrollToBottom', 'updateScrollbar', + 'syncAssistantLastSectionButton', 'syncAssistantBranchButton', 'clearUserMessageIndex', + 'updateUserOutlinePanel', 'clearPendingSessionResumeRequest', 'scheduleQueuedMessageDrain', + 'followOutputIfNeeded', + ].map((name) => 'function ' + name + '() {}').join('\n')} + ${names.map(extractFunction).join('\n')} + function receive(msg) { + switch (msg.type) { + ${extractCases('text_delta', 'content_blocks')} + ${extractCases('tool_start', 'cost')} + ${extractCases('done', 'system_message')} + ${extractCases('resume_generating', 'error')} + } + } + return { + receive, startGenerating, messagesDiv, sessionCache, + snapshot(snapshot, preserve = false) { + sessionCache.set(snapshot.sessionId, { snapshot }); + applySessionSnapshot(snapshot, { preserveStreaming: preserve, immediate: true }); + }, + stream() { return document.getElementById('streaming-msg'); }, + state() { return { currentSessionId, currentSessionMessageCount, isGenerating, + generatingSessionId, pendingText, currentSessionRunning, tools: [...activeToolCalls.entries()] }; }, + }; + `)(Element); +} + +const SESSION = 'task-session'; +const FIRST = '第一段不能丢失\n' + '已产生的长正文。'.repeat(16000) + '\n第一段末尾\n'; +const SECOND = '\n第二次续跑正文\n'; +const THIRD = '\n第三次续跑正文\n'; +const MESSAGE_ID = 'logical-task-assistant'; +const tool = (id, result = id) => ({ id, name: 'exec_command', input: { cmd: id }, result, done: true }); +const question = { id: 'question', role: 'user', content: '长任务', timestamp: '2026-10-10T01:00:00.000Z' }; +const snapshot = (sessionId = SESSION, isRunning = true, messages = [question]) => ({ + sessionId, agent: 'codexapp', isRunning, messages, historyBaseIndex: 0, historyTotal: messages.length, + historyCursor: 0, +}); +const resume = (text, tools = [], extra = {}) => ({ + type: 'resume_generating', sessionId: SESSION, assistantMessageId: MESSAGE_ID, + turnId: 'runtime-turn-1', text, toolCalls: tools, ...extra, +}); +let passed = 0; +function test(description, run) { run(); passed += 1; console.log('通过:' + description); } + +test('多次延续重试复用一个气泡,完整 snapshot 覆盖正文和工具且不重复', () => { + const api = createHarness(); + api.snapshot(snapshot()); + api.receive(resume('')); + const stream = api.stream(); + api.receive({ type: 'text_delta', sessionId: SESSION, text: FIRST }); + api.receive({ type: 'tool_start', sessionId: SESSION, toolUseId: 'first', name: 'exec_command', input: { cmd: 'first' } }); + api.receive({ type: 'tool_end', sessionId: SESSION, toolUseId: 'first', result: 'first' }); + for (const text of [FIRST, FIRST + SECOND, FIRST + SECOND + THIRD]) { + const tools = text.includes(SECOND) ? [tool('first'), tool('second')] : [tool('first')]; + api.receive(resume(text, tools, { retryPending: true, isRunning: true, turnId: 'runtime-turn-2' })); + api.receive(resume(text, tools, { turnId: 'runtime-turn-3' })); + assert.strictEqual(api.stream(), stream, '同一逻辑重试不得另建气泡'); + assert.equal(api.state().pendingText, text); + assert.equal(stream.querySelector('.msg-text').innerHTML, text, '长正文前缀必须仍在 DOM'); + assert.equal(api.messagesDiv.querySelectorAll('#streaming-msg').length, 1); + assert.deepEqual(api.state().tools.map(([id]) => id), tools.map((value) => value.id)); + assert.equal(stream.querySelectorAll('.tool-call').length, tools.length); + } + assert.equal(stream.dataset.messageKey, MESSAGE_ID); + api.receive(resume('服务端规范化后的完整正文', [tool('replacement')])); + assert.equal(api.state().pendingText, '服务端规范化后的完整正文', 'snapshot 必须权威替换而非盲目拼接'); + assert.equal(stream.querySelector('.msg-text').innerHTML, '服务端规范化后的完整正文'); + assert.deepEqual(api.state().tools.map(([id]) => id), ['replacement']); +}); + +test('运行中的历史重复快照保留气泡,切换会话后从完整恢复协议重建', () => { + const api = createHarness(); + api.snapshot(snapshot()); + api.receive(resume(FIRST + SECOND, [tool('first')])); + const stream = api.stream(); + api.snapshot(snapshot(), true); + assert.strictEqual(api.stream(), stream); + assert.equal(api.state().pendingText, FIRST + SECOND); + api.snapshot(snapshot('another-session', false, [{ ...question, id: 'other-question' }])); + assert.equal(api.state().pendingText, ''); + assert.equal(api.stream(), null); + api.receive(resume('不该串入其他会话')); + assert.equal(api.stream(), null, '后台会话协议不得污染当前视图'); + api.snapshot(snapshot()); + api.receive(resume(FIRST + SECOND, [tool('first')])); + assert.equal(api.state().pendingText, FIRST + SECOND); + assert.equal(api.stream().querySelector('.msg-text').innerHTML, FIRST + SECOND); +}); + +test('刷新后的空客户端及断线重连都用完整正文和工具恢复', () => { + for (const existingStream of [false, true]) { + const api = createHarness(); + api.snapshot(snapshot()); + if (existingStream) api.receive(resume('客户端断线前的过期预览', [tool('outdated')])); + api.receive(resume(FIRST + SECOND + THIRD, [tool('first'), tool('second')])); + assert.equal(api.state().pendingText, FIRST + SECOND + THIRD); + assert.equal(api.stream().querySelector('.msg-text').innerHTML, FIRST + SECOND + THIRD); + assert.deepEqual(api.state().tools.map(([id]) => id), ['first', 'second']); + assert.equal(api.messagesDiv.querySelectorAll('#streaming-msg').length, 1); + } +}); + +test('最终 done 采用完整规范消息,重复完成不重复提交,下一用户轮次清空', () => { + const api = createHarness(); + api.snapshot(snapshot()); + api.receive(resume('客户端尚不完整的输出', [tool('outdated')])); + const final = { id: MESSAGE_ID, role: 'assistant', content: FIRST + SECOND + THIRD, toolCalls: [tool('first'), tool('second')] }; + const done = { type: 'done', sessionId: SESSION, assistantMessage: final, assistantMessageIndex: 1 }; + api.receive(done); + api.receive(done); + assert.equal(api.stream(), null); + assert.equal(api.messagesDiv.children.length, 2); + assert.deepEqual(api.messagesDiv.children[1].message, final); + assert.equal(api.state().currentSessionMessageCount, 2); + assert.equal(api.sessionCache.get(SESSION).snapshot.messages[1].content, final.content); + api.startGenerating(SESSION); + assert.equal(api.state().pendingText, ''); + assert.equal(api.state().tools.length, 0); + api.receive({ type: 'text_delta', sessionId: SESSION, text: '新问题的新回答' }); + assert.equal(api.stream().querySelector('.msg-text').innerHTML, '新问题的新回答'); + assert(!api.stream().querySelector('.msg-text').innerHTML.includes('第一段不能丢失')); +}); + +test('停止运行但保存失败的完整运行态可显示,done 后停止生成且保留正文', () => { + const api = createHarness(); + api.snapshot(snapshot()); + api.receive(resume(FIRST + SECOND, [tool('first')], { isRunning: false, historySaved: false })); + const stream = api.stream(); + api.receive({ type: 'done', sessionId: SESSION, historySaved: false }); + assert.equal(api.state().isGenerating, false); + assert.equal(api.state().currentSessionRunning, false); + assert.equal(api.stream(), null); + assert.equal(stream.querySelector('.msg-text').innerHTML, FIRST + SECOND); + assert.equal(stream.querySelectorAll('.tool-call').length, 1); +}); + +console.log(JSON.stringify({ passed, fullTextChars: (FIRST + SECOND + THIRD).length, source: 'public/app.js 原函数与事件分支' })); diff --git a/server.js b/server.js index d7e1a7a..30611b3 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,7 @@ const { extractThreadId: extractGiteaWorkflowThreadId, } = require('./lib/gitea-workflow-codex'); const { createSessionSearchIndex } = require('./lib/session-search-index'); +const { createSessionHistoryStore } = require('./lib/session-history-store'); const { createUsageStatisticsIndex, UsageStatisticsError } = require('./lib/usage-statistics'); const { createGiteaWebhookReceiver, @@ -314,6 +315,7 @@ fs.mkdirSync(SESSIONS_DIR, { recursive: true }); fs.mkdirSync(LOGS_DIR, { recursive: true }); fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); +const sessionHistoryStore = createSessionHistoryStore({ sessionsDir: SESSIONS_DIR }); // Gitea Workflow runner 与 Codex App turn 的等待桥接。 const giteaWorkflowWaiters = new Map(); @@ -1439,6 +1441,10 @@ const activeProcesses = new Map(); // Active Codex app-server turns: sessionId -> { ws, threadId, turnId, fullText, toolCalls } const activeCodexAppTurns = new Map(); +// 完成入库失败时保留最新内存输出,下一次重连或发送可重试提交。 +const retainedCodexAppTurns = new Map(); +// 忽略已结束原生回合的晚到通知,防止延续重试或新轮被旧事件覆盖。 +const retiredCodexAppRuntimeTurns = new Map(); // Gitea 工作流任务等待 Codex App turn 完成:sessionId -> resolver metadata。 // Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled } @@ -2685,8 +2691,7 @@ function collectRecentSessionCwds(limit = 12) { } try { - const files = fs.readdirSync(SESSIONS_DIR) - .filter((name) => name.endsWith('.json')) + const files = listSessionFiles() .map((name) => { const fullPath = path.join(SESSIONS_DIR, name); let updatedAt = 0; @@ -2699,7 +2704,7 @@ function collectRecentSessionCwds(limit = 12) { for (const file of files) { try { - const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file.name), 'utf8'))); + const session = loadSessionMetaFromFile(path.join(SESSIONS_DIR, file.name)); pushPath(session.cwd); if (results.length >= limit) break; } catch {} @@ -4724,6 +4729,22 @@ function mergeNativePrefixWithSnapshot(nativePrefix, snapshot) { return [...merged, ...tail]; } +function normalizeArchivedHistory(messages) { + const positions = new Map(); + const output = []; + for (const value of messages) { + const message = normalizeHistoryMessage(value); + const key = message?.id ? `${message.role || ''}:id:${message.id}` : null; + const position = key ? positions.get(key) : undefined; + if (position !== undefined) output[position] = message; + else { + if (key) positions.set(key, output.length); + output.push(message); + } + } + return output; +} + function sessionHistoryCacheKey(session) { if (!session) return ''; if (isCodexLikeSession(session)) return `${getSessionAgent(session)}:${session.codexAppThreadId || session.codexThreadId || ''}`; @@ -4791,7 +4812,11 @@ function resolveSessionHistory(session) { const persisted = Array.isArray(session?.messages) ? session.messages : []; const persistedMessages = persisted.filter((message) => !isPersistenceNoticeMessage(message)); const notice = persisted.some(isPersistenceNoticeMessage); - if (!notice) { + const historyState = sessionHistoryStore.state(session); + if (historyState?.source === 'archive' && historyState.complete) { + return { messages: normalizeArchivedHistory(persistedMessages), source: 'archive', available: true, recoverable: false }; + } + if (!notice && historyState?.complete !== false) { return { messages: mergeHistorySegments([], persistedMessages.map(normalizeHistoryMessage), session), source: 'snapshot', @@ -4803,6 +4828,19 @@ function resolveSessionHistory(session) { const native = loadNativeSessionHistory(session); const merged = mergeNativeHistoryWithSnapshot(session, persistedMessages, native?.messages); if (merged.confirmed) { + if (session?.id && historyState) { + const previousBaseIndex = session.historySnapshotBaseIndex; + const previousCount = session.historySnapshotCount; + session.messages = merged.messages; + const restoreHistoryState = sessionHistoryStore.markComplete(session); + if (!saveSession(session)) { + session.messages = persisted; + session.historySnapshotBaseIndex = previousBaseIndex; + session.historySnapshotCount = previousCount; + restoreHistoryState(); + return { messages: persistedMessages.map(normalizeHistoryMessage), source: 'snapshot', available: false, recoverable: false }; + } + } return { messages: merged.messages, source: merged.messages.length > persistedMessages.length ? 'merged' : 'snapshot', @@ -4910,13 +4948,14 @@ function normalizeSession(session) { } if (!Object.prototype.hasOwnProperty.call(session, 'messages')) session.messages = []; if (Array.isArray(session.messages)) { - session.messages = session.messages.map((message) => { - if (!message || typeof message !== 'object') return message; + session.messages.forEach((message) => { + if (!message || typeof message !== 'object') return; const normalizedMessage = ensureStableMessageId(message); + if (normalizedMessage.id) message.id = normalizedMessage.id; + if (normalizedMessage.turnId) message.turnId = normalizedMessage.turnId; if (message.attachments) { - return { ...normalizedMessage, attachments: normalizeMessageAttachments(message.attachments) }; + message.attachments = normalizeMessageAttachments(message.attachments); } - return normalizedMessage; }); } return session; @@ -4946,6 +4985,7 @@ function isCodexLikeSession(session) { function isSessionRunning(sessionId) { return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId) + || pendingCodexCapacityRetries.has(sessionId) || activeCodexAppGoalCommands.has(sessionId) || isCodexAppGoalActive(sessionId) || activeCodexAppCompactions.has(sessionId); @@ -5738,7 +5778,9 @@ function sanitizeMessagesForPersist(messages, limits = {}, session = {}) { : previousNotice.truncatedAt || previousNotice.timestamp || new Date().toISOString(); output.unshift({ role: 'system', - content: `cc-web 本地快照保留最近 ${selected.length} 条消息;更早历史仅在确认与原始会话的边界后加载。`, + content: session.historyArchiveRevision + ? `cc-web 快照保留最近 ${selected.length} 条消息;已保存历史可从自身档案加载。` + : `cc-web 本地快照保留最近 ${selected.length} 条消息;更早历史仅在确认与原始会话的边界后加载。`, timestamp: truncatedAt, ccwebPersistenceNotice: true, snapshotMessageCount: output.length, @@ -5752,13 +5794,17 @@ function sanitizeMessagesForPersist(messages, limits = {}, session = {}) { } function sanitizeMessageForTransport(message) { - return sanitizeMessageForPersist(message, { + const output = sanitizeMessageForPersist(message, { contentMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS, toolInputMaxChars: SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS, toolResultMaxChars: SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS, maxToolCalls: SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE, metaMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS, }); + // 历史分页及 done 读取完整消息,快照预览限制不能裁剪正文或工具。 + if (output && Object.prototype.hasOwnProperty.call(message || {}, 'content')) output.content = message.content; + if (output && Array.isArray(message?.toolCalls)) output.toolCalls = message.toolCalls; + return output; } function sanitizeMessagesForTransport(messages) { @@ -5767,7 +5813,7 @@ function sanitizeMessagesForTransport(messages) { } function sanitizeSessionForPersist(session, limits = {}) { - const output = {}; + const output = session.historyArchiveRevision ? { historyArchiveRevision: session.historyArchiveRevision } : {}; const skipKeys = new Set([ 'ws', 'tailer', @@ -5836,7 +5882,9 @@ function buildSessionJsonForPersist(session) { ...session, messages: [{ role: 'system', - content: `当前会话历史过大,cc-web 未将完整历史写入本地快照;如果原始会话记录仍在,可点击顶部“查看更早消息”尝试恢复。`, + content: session.historyArchiveRevision + ? '当前会话的预览过大,cc-web 仅保存简短快照;已保存历史可从自身档案加载。' + : '当前会话历史过大,旧版快照未包含完整历史;更早记录需要核实原始会话边界。', timestamp: new Date().toISOString(), ccwebPersistenceNotice: true, }], @@ -6082,12 +6130,48 @@ function readSessionPreview(filePath, stat) { } } +function listSessionFiles() { + const files = new Set(fs.readdirSync(SESSIONS_DIR).filter(file => file.endsWith('.json'))); + sessionHistoryStore.listIds().forEach(id => files.add(`${id}.json`)); + return [...files]; +} + +function refreshSessionSnapshotCache(id) { + const revision = sessionHistoryStore.revision(id); + if (!revision) return null; + const filePath = sessionPath(id); + let fd; + let cachedRevision; + try { + fd = fs.openSync(filePath, 'r'); + const head = Buffer.alloc(512); + const count = fs.readSync(fd, head, 0, head.length, 0); + cachedRevision = /^\{\s*"historyArchiveRevision"\s*:\s*"([0-9a-f-]{36})"\s*,/.exec(head.toString('utf8', 0, count))?.[1]; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + if (cachedRevision === revision) return null; + const session = loadSession(id); + if (!session) return null; + const result = buildSessionJsonForPersist({ ...session, historyArchiveRevision: revision }); + try { + writeFileAtomicSync(filePath, result.json); + } catch (error) { + plog('WARN', 'session_snapshot_cache_refresh_failed', { sessionId: id.slice(0, 8), error: error?.message || String(error) }); + } + // 首次缓存失败的孤立归档仍须展示;列表只使用刚生成的有界预览。 + return { session: result.persisted, fileBytes: textByteLength(result.json) }; +} + function loadSessionMetaFromFile(filePath) { const fallbackId = path.basename(filePath, '.json'); try { - const stat = fs.statSync(filePath); - if (stat.size <= SESSION_META_FULL_PARSE_MAX_BYTES) { - const session = normalizeSession(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + const refreshed = refreshSessionSnapshotCache(fallbackId); + const stat = refreshed ? { size: refreshed.fileBytes, mtime: new Date() } : fs.statSync(filePath); + if (refreshed || stat.size <= SESSION_META_FULL_PARSE_MAX_BYTES) { + const session = normalizeSession(refreshed?.session || JSON.parse(fs.readFileSync(filePath, 'utf8'))); const cwd = session.cwd || ''; return { id: session.id || fallbackId, @@ -6144,10 +6228,16 @@ function loadSession(id) { if (!normalizedId) return null; try { const filePath = sessionPath(normalizedId); - if (!fs.existsSync(filePath)) return null; - const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId })); - syncCodexAppGoalStateFromSession(session); - updateSessionRuntimeThreadIndex(session); + let session = sessionHistoryStore.load(normalizedId); + if (!session) { + if (!fs.existsSync(filePath)) return null; + session = safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }); + if (!session) return null; + sessionHistoryStore.markLegacy(session); + } + normalizeSession(session); + syncCodexAppGoalStateFromSession(session); + updateSessionRuntimeThreadIndex(session); return session; } catch (err) { plog('WARN', 'session_load_failed', { @@ -6159,10 +6249,12 @@ function loadSession(id) { } function preserveNewerTaskTrackingForSessionSave(session, targetPath) { - if (!fs.existsSync(targetPath)) return; let persisted; try { - persisted = safeReadSessionJson(targetPath, SESSION_LOAD_MAX_BYTES, { sessionId: session.id }); + persisted = sessionHistoryStore.load(session.id); + if (!persisted && fs.existsSync(targetPath)) { + persisted = safeReadSessionJson(targetPath, SESSION_LOAD_MAX_BYTES, { sessionId: session.id }); + } } catch { return; } @@ -6182,21 +6274,31 @@ function preserveNewerTaskTrackingForSessionSave(session, targetPath) { }); } -function saveSession(session) { +function saveSession(session, options = {}) { if (!session?.id) return false; normalizeSession(session); const targetPath = sessionPath(session.id); try { - preserveNewerTaskTrackingForSessionSave(session, targetPath); - const result = buildSessionJsonForPersist(session); - writeFileAtomicSync(targetPath, result.json); - if (result.guarded) { - const lastAttempt = result.attempts[result.attempts.length - 1] || {}; - plog('WARN', 'session_save_guard_applied', { + if (!options.taskBoardMetadata) preserveNewerTaskTrackingForSessionSave(session, targetPath); + const committed = sessionHistoryStore.save(session); + try { + const result = buildSessionJsonForPersist({ ...committed.session, historyArchiveRevision: committed.revision }); + writeFileAtomicSync(targetPath, result.json); + if (result.guarded) { + const lastAttempt = result.attempts[result.attempts.length - 1] || {}; + plog('INFO', 'session_snapshot_guard_applied', { + sessionId: String(session.id || '').slice(0, 8), + fileBytes: lastAttempt.bytes || textByteLength(result.json), + maxBytes: SESSION_MAX_JSON_BYTES, + attempts: result.attempts, + }); + } + } catch (error) { + // 档案已经提交,缓存更新失败不能让上层重复执行本轮消息。 + plog('WARN', 'session_snapshot_cache_save_failed', { sessionId: String(session.id || '').slice(0, 8), - fileBytes: lastAttempt.bytes || textByteLength(result.json), - maxBytes: SESSION_MAX_JSON_BYTES, - attempts: result.attempts, + revision: committed.revision, + error: error?.message || String(error || ''), }); } updateSessionRuntimeThreadIndex(session); @@ -6206,6 +6308,7 @@ function saveSession(session) { } catch (err) { plog('ERROR', 'session_save_failed', { sessionId: String(session.id || '').slice(0, 8), + code: err?.code || null, error: err?.message || String(err || ''), }); return false; @@ -6229,8 +6332,7 @@ function saveTaskBoardStatusConfig(config) { function listTaskBoardSessionIds() { try { - return fs.readdirSync(SESSIONS_DIR) - .filter((name) => name.endsWith('.json')) + return listSessionFiles() .map((name) => path.basename(name, '.json')); } catch (error) { plog('WARN', 'task_board_session_list_failed', { @@ -6240,10 +6342,23 @@ function listTaskBoardSessionIds() { } } +function saveTaskBoardSessionMetadata(session) { + // 任务服务会克隆会话;重新读取可信档案,只提交它负责的元数据。 + const completeSession = loadSession(session?.id); + if (!completeSession) return false; + if (Object.prototype.hasOwnProperty.call(session, 'taskTracking')) { + completeSession.taskTracking = session.taskTracking; + } else { + delete completeSession.taskTracking; + } + // 服务内部迁移失败时会回滚旧版本;它已同步校验版本,允许此处提交受控回滚。 + return saveSession(completeSession, { taskBoardMetadata: true }); +} + function createProductionTaskBoardService() { const dependencies = { loadSession, - saveSession, + saveSession: saveTaskBoardSessionMetadata, listSessions: listTaskBoardSessionIds, loadStatusDefinitions: loadTaskBoardStatusConfig, saveStatusDefinitions: saveTaskBoardStatusConfig, @@ -6643,12 +6758,61 @@ function mapToPairs(value, options = {}) { } function codexAppTurnKey(sessionId, state = {}) { + // 延续重试沿用首轮身份,气泡、运行态与最终入库消息保持同一条记录。 + const logicalTurnKey = String(state?.logicalTurnKey || '').trim(); + if (logicalTurnKey) return logicalTurnKey; const threadId = state.threadId || ''; const turnId = state.turnId || ''; const startedAt = state.startedAt || ''; return `${sanitizeId(sessionId)}:${threadId}:${turnId}:${startedAt}`; } +function codexAppAssistantMessageIdForEntry(entry) { + const turnId = String(entry?.turnId || '').trim(); + if (!turnId) return null; + return `history:assistant:${String(entry?.threadId || '').trim()}:${turnId}`; +} + +// 同一逻辑回合(含自动延续)只能有一个稳定消息身份;延续重试沿用首轮计算值。 +function ensureCodexAppAssistantMessageId(sessionId, entry) { + if (!entry.logicalTurnKey) entry.logicalTurnKey = codexAppTurnKey(sessionId, entry); + if (entry.assistantMessageId) return entry.assistantMessageId; + const derived = codexAppAssistantMessageIdForEntry(entry); + if (derived) { + entry.assistantMessageId = derived; + return derived; + } + entry.assistantMessageId = ensureStableMessageId({ + role: 'assistant', codexAppTurnKey: entry.logicalTurnKey, + }).id; + return entry.assistantMessageId; +} + +// 重连载荷是完整运行态,不适用有界快照限制。工具的显示语义仍由前端过滤。 +function buildCodexAppResumePayload(sessionId, entry, options = {}) { + return { + type: 'resume_generating', + sessionId, + assistantMessageId: entry.assistantMessageId || codexAppAssistantMessageIdForEntry(entry) || ensureCodexAppAssistantMessageId(sessionId, entry), + turnId: entry.turnId || null, + text: entry.fullText || '', + toolCalls: Array.isArray(entry.toolCalls) ? entry.toolCalls : [], + isRunning: options.isRunning !== false, + retryPending: !!options.retryPending, + ...(options.historySaved === false ? { historySaved: false } : {}), + }; +} + +function retireCodexAppRuntimeTurn(entry) { + if (!entry?.threadId || !entry?.turnId) return; + const key = `${entry.threadId}:${entry.turnId}`; + retiredCodexAppRuntimeTurns.set(key, true); + // 回合 ID 不会重复;只保留近期身份,防止长驻进程无限积累。 + if (retiredCodexAppRuntimeTurns.size > 2048) { + retiredCodexAppRuntimeTurns.delete(retiredCodexAppRuntimeTurns.keys().next().value); + } +} + function serializeCodexAppEntry(sessionId, entry, limits = {}) { return { version: 1, @@ -6661,21 +6825,33 @@ function serializeCodexAppEntry(sessionId, entry, limits = {}) { startedAt: entry.startedAt || new Date().toISOString(), updatedAt: new Date().toISOString(), clientUserMessageId: entry.clientUserMessageId || null, - fullText: truncateTextValue(entry.fullText || '', limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS), - toolCalls: sanitizeToolCallsForPersist(Array.isArray(entry.toolCalls) ? entry.toolCalls : [], { + assistantMessageId: entry.assistantMessageId || codexAppAssistantMessageIdForEntry(entry) || null, + logicalTurnKey: entry.logicalTurnKey || null, + runtimeItemScope: entry.runtimeItemScope || '', + fullText: limits.complete ? entry.fullText || '' : truncateTextValue(entry.fullText || '', limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS), + toolCalls: limits.complete ? entry.toolCalls || [] : sanitizeToolCallsForPersist(Array.isArray(entry.toolCalls) ? entry.toolCalls : [], { maxToolCalls: limits.maxToolCalls || CODEX_APP_STATE_MAX_MAP_ENTRIES, toolInputMaxChars: limits.toolInputMaxChars || SESSION_TOOL_INPUT_MAX_CHARS, toolResultMaxChars: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS, contentMaxChars: limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS, }), - toolOutputDeltas: mapToPairs(entry.toolOutputDeltas, { + toolOutputDeltas: limits.complete ? Array.from(entry.toolOutputDeltas || []) : mapToPairs(entry.toolOutputDeltas, { maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES, maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS, }), - agentMessageItems: mapToPairs(entry.agentMessageItems, { + agentMessageItems: limits.complete ? Array.from(entry.agentMessageItems || []) : mapToPairs(entry.agentMessageItems, { maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES, maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS, }), + agentMessagePendingPrefixes: limits.complete ? Array.from(entry.agentMessagePendingPrefixes || []) : mapToPairs(entry.agentMessagePendingPrefixes, { + maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES, + maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS, + }), + codexRetry: entry.codexRetry ? { + attempt: entry.codexRetry.attempt || null, + expectedThreadId: entry.codexRetry.expectedThreadId || null, + useContinuationRetry: !!entry.codexRetry.useContinuationRetry, + } : null, lastUsage: entry.lastUsage || null, lastError: entry.lastError || null, userAborted: !!entry.userAborted, @@ -6725,10 +6901,13 @@ function buildCodexAppStateJson(sessionId, entry) { } function writeCodexAppTurnState(sessionId, entry) { - if (!entry || entry.codexAppStateCleaned) return; + if (!entry || entry.codexAppStateCleaned) return false; + let completeStateSaved = false; try { const dir = runDir(sessionId); fs.mkdirSync(dir, { recursive: true }); + sessionHistoryStore.saveTurn(sessionId, serializeCodexAppEntry(sessionId, entry, { complete: true })); + completeStateSaved = true; const statePath = codexAppStatePath(sessionId); const result = buildCodexAppStateJson(sessionId, entry); writeFileAtomicSync(statePath, result.json); @@ -6744,10 +6923,11 @@ function writeCodexAppTurnState(sessionId, entry) { error: err?.message || String(err || ''), }); } + return completeStateSaved; } function persistCodexAppTurnState(sessionId, entry, options = {}) { - if (!sessionId || !entry || entry.codexAppStateCleaned) return; + if (!sessionId || !entry || entry.codexAppStateCleaned) return false; const immediate = !!options.immediate; if (immediate && entry.codexAppStateTimer) { clearTimeout(entry.codexAppStateTimer); @@ -6755,8 +6935,7 @@ function persistCodexAppTurnState(sessionId, entry, options = {}) { } if (immediate) { entry.codexAppStateDirty = false; - writeCodexAppTurnState(sessionId, entry); - return; + return writeCodexAppTurnState(sessionId, entry); } entry.codexAppStateDirty = true; if (entry.codexAppStateTimer) return; @@ -6775,11 +6954,15 @@ function cleanupCodexAppTurnState(sessionId, entry) { entry.codexAppStateTimer = null; } if (entry) entry.codexAppStateCleaned = true; + if (retainedCodexAppTurns.get(sessionId) === entry) retainedCodexAppTurns.delete(sessionId); + sessionHistoryStore.removeTurn(sessionId); cleanRunDir(sessionId); } function loadCodexAppTurnState(sessionId) { try { + const completeState = sessionHistoryStore.loadTurn(sessionId); + if (completeState) return completeState; const statePath = codexAppStatePath(sessionId); if (!fs.existsSync(statePath)) return null; const stat = fs.statSync(statePath); @@ -6799,13 +6982,18 @@ function loadCodexAppTurnState(sessionId) { sessionId: String(sessionId || '').slice(0, 8), error: err?.message || String(err || ''), }); - return null; + return { __invalid: true, reason: 'read_failed', preserveState: true, agent: 'codexapp' }; } } function appendCodexAppRecoveryNotice(sessionId, content) { const session = loadSession(sessionId); if (!session || !isCodexAppSession(session)) return false; + // 状态文件按设计会保留证据,重启会再次进入恢复分支;提示只保留一条。 + if (Array.isArray(session.messages) + && session.messages.some(message => message?.codexAppRecoveredPartial === true && message?.content === content)) { + return true; + } session.messages.push({ role: 'system', content, @@ -6825,11 +7013,11 @@ function hasCodexAppTurnMessage(session, turnKey) { function recoverCodexAppTurnState(sessionId) { const state = loadCodexAppTurnState(sessionId); if (state?.__invalid) { + if (state.preserveState) return true; appendCodexAppRecoveryNotice( sessionId, - `Codex App 上次运行状态文件异常(${state.reason || 'invalid'}),已跳过恢复并清理运行目录,避免 cc-web 启动时占用过多内存。`, + `Codex App 上次运行状态文件异常(${state.reason || 'invalid'}),已跳过恢复并保留运行状态文件。`, ); - cleanRunDir(sessionId); return true; } if (!state) { @@ -6839,17 +7027,19 @@ function recoverCodexAppTurnState(sessionId) { const session = loadSession(sessionId); if (!session || !isCodexAppSession(session)) { - cleanRunDir(sessionId); + // 会话读取失败或类型暂时不可判定时,不能删除唯一的完整运行态。 + // 可能只是归档/快照短暂不可读;保留 _history/_turns 与 run 目录, + // 等存储恢复后由下一次启动继续恢复。真正删除会话的路径会显式清理它们。 + plog('WARN', 'codex_app_state_recovery_session_unavailable', { + sessionId: sessionId.slice(0, 8), + sessionFound: !!session, + agent: session ? getSessionAgent(session) : null, + }); return true; } - const fullText = truncateTextValue(state.fullText || '', CODEX_APP_STATE_FULL_TEXT_MAX_CHARS); - const toolCalls = sanitizeToolCallsForPersist(Array.isArray(state.toolCalls) ? state.toolCalls : [], { - maxToolCalls: CODEX_APP_STATE_MAX_MAP_ENTRIES, - toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS, - toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS, - contentMaxChars: CODEX_APP_STATE_FULL_TEXT_MAX_CHARS, - }); + const fullText = state.fullText || ''; + const toolCalls = Array.isArray(state.toolCalls) ? state.toolCalls : []; recoverCcwebMcpChildThreadsFromPersistedToolCalls(sessionId, state, toolCalls); const hasRecoverableContent = fullText.trim() || toolCalls.length > 0; const turnKey = codexAppTurnKey(sessionId, state); @@ -6867,6 +7057,7 @@ function recoverCodexAppTurnState(sessionId) { if (hasRecoverableContent && !hasCodexAppTurnMessage(session, turnKey)) { const interrupted = state.turnStatus !== 'completed'; session.messages.push({ + id: ensureCodexAppAssistantMessageId(sessionId, state), role: 'assistant', content: fullText, toolCalls, @@ -6907,8 +7098,9 @@ function recoverCodexAppTurnState(sessionId) { if (changed) { session.updated = new Date().toISOString(); - saveSession(session); + if (!saveSession(session)) return true; } + sessionHistoryStore.removeTurn(sessionId); cleanRunDir(sessionId); return true; } @@ -7162,7 +7354,7 @@ function handleTaskBoardWsMessage(ws, message = {}) { function sendSessionList(ws) { try { - const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json')); + const files = listSessionFiles(); const sessions = []; for (const f of files) { const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, f)); @@ -7367,7 +7559,7 @@ function listConversationSummaries(args = {}, sourceSessionId = '') { const conversations = []; try { - const files = fs.readdirSync(SESSIONS_DIR).filter((name) => name.endsWith('.json')); + const files = listSessionFiles(); for (const file of files) { const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, file)); if (!meta) continue; @@ -8234,7 +8426,14 @@ function deliverCrossConversationReply(requestId) { sourceSession.updated = now; const sourceWs = findViewingSessionWs(sourceSession.id); if (!sourceWs) sourceSession.hasUnread = true; - saveSession(sourceSession); + if (!saveSession(sourceSession)) { + updatePendingCrossConversationReply(requestId, (draft) => { + draft.status = 'ready'; + draft.lastError = 'session_save_failed'; + }); + broadcastSessionList(); + return false; + } if (sourceWs) { wsSend(sourceWs, { type: 'session_message', @@ -8247,6 +8446,7 @@ function deliverCrossConversationReply(requestId) { draft.status = 'returned'; draft.returnedAt = now; draft.replyMessageId = replyMessageId; + draft.lastError = null; }); deletePendingCrossConversationReply(requestId); broadcastSessionList(); @@ -8432,8 +8632,7 @@ function scriptGetChildConversationIdsApi(args = {}) { } try { - const children = fs.readdirSync(SESSIONS_DIR) - .filter((name) => name.endsWith('.json')) + const children = listSessionFiles() .map((name) => loadSessionMetaFromFile(path.join(SESSIONS_DIR, name))) .filter((meta) => ( meta @@ -9090,6 +9289,60 @@ function hasRuntimeOutput(entry) { return Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0; } +function codexOutputContent(entry) { + const contentBlocks = Array.isArray(entry.contentBlocks) ? entry.contentBlocks : []; + if (contentBlocks.length === 0) return entry.fullText || ''; + // 运行事件按顺序记录;纯文本片段仅是预览,不代表完整正文边界。 + return contentBlocks; +} + +function cliRunKey(sessionId, entry = {}) { + if (entry.runKey) return entry.runKey; + const dir = runDir(sessionId); + try { + const markerPath = path.join(dir, 'run-key'); + if (fs.existsSync(markerPath)) { + const key = fs.readFileSync(markerPath, 'utf8').trim(); + if (/^cli:[a-zA-Z0-9:-]+$/.test(key)) { + entry.runKey = key; + return key; + } + plog('WARN', 'cli_run_key_invalid_marker', { sessionId: sessionId.slice(0, 8), key: key.slice(0, 80) }); + } + // 旧运行目录没有 UUID;进程与输出文件的创建身份共同标识这一轮。 + const outputPath = path.join(dir, 'output.jsonl'); + const identityPath = fs.existsSync(outputPath) ? outputPath : dir; + const stat = fs.statSync(identityPath); + const directoryStat = fs.statSync(dir); + const pidPath = path.join(dir, 'pid'); + const pid = fs.existsSync(pidPath) ? fs.readFileSync(pidPath, 'utf8').trim() : entry.pid || ''; + const identity = [sessionId, pid, stat.dev, stat.ino, stat.birthtimeMs || directoryStat.birthtimeMs || directoryStat.ctimeMs].join(':'); + const key = `cli:${sanitizeId(sessionId)}:${crypto.createHash('sha256').update(identity).digest('hex')}`; + try { + writeFileAtomicSync(markerPath, key); + } catch (error) { + plog('WARN', 'cli_run_key_persist_failed', { sessionId: sessionId.slice(0, 8), error: error?.message || String(error) }); + } + entry.runKey = key; + return key; + } catch (error) { + // 运行标识异常不能阻断完成流程;退化标识只用于本轮入库,不影响下一次恢复。 + plog('WARN', 'cli_run_key_fallback', { + sessionId: sessionId.slice(0, 8), + pid: entry.pid || null, + error: error?.message || String(error), + }); + const fallback = `cli:${sanitizeId(sessionId)}:fallback:${entry.pid || 'unknown'}`; + entry.runKey = fallback; + return fallback; + } +} + +function hasCliRunMessage(session, runKey) { + return Array.isArray(session?.messages) + && session.messages.some(message => message?.role === 'assistant' && message.runKey === runKey); +} + function retryTailText(value, maxChars) { const text = String(value || '').trim(); if (!text || text.length <= maxChars) return text; @@ -9169,15 +9422,34 @@ function codexTransientRetryDelayMs(config) { return Math.max(0, (config?.intervalSeconds || DEFAULT_CODEX_CONFIG.retry.intervalSeconds) * 1000); } -function cancelCodexCapacityRetry(sessionId) { +function cancelCodexCapacityRetry(sessionId, options = {}) { const retry = pendingCodexCapacityRetries.get(sessionId); if (!retry) return false; if (retry.timer) clearTimeout(retry.timer); pendingCodexCapacityRetries.delete(sessionId); + // 取消计时不等于删除已产生输出;只有主动删除/清空才丢弃运行态。 + if (retry.entry && !options.discard) retainedCodexAppTurns.set(sessionId, retry.entry); return true; } +function retirePendingCodexCapacityRetry(sessionId, ws = null) { + const retry = pendingCodexCapacityRetries.get(sessionId); + const entry = retry?.entry || retainedCodexAppTurns.get(sessionId); + if (!entry) return cancelCodexCapacityRetry(sessionId); + if (activeCodexAppTurns.has(sessionId) || activeProcesses.has(sessionId)) return false; + cancelCodexCapacityRetry(sessionId); + if (ws) entry.ws = ws; + const interrupted = !!retry || entry.turnStatus !== 'completed' || !!entry.userAborted; + if (interrupted) entry.userAborted = true; + entry.historySaved = false; + activeCodexAppTurns.set(sessionId, entry); + // 新用户轮次、取消和存储重试都必须先提交旧输出,失败则保留内存与运行档案。 + handleCodexAppTurnComplete(sessionId, { interrupted, skipTransientRetry: true }); + return entry.historySaved === true; +} + function shouldRetryCodexTransientFailure(entry, rawError, context = {}) { + if (entry.userAborted || context.skipTransientRetry) return false; if (!['codex', 'codexapp'].includes(entry.agent || 'claude')) return false; if (!rawError || !isCodexTransientRetryableError(rawError)) return false; if (getCodexRetryConfig().mode === 'off') return false; @@ -9233,8 +9505,16 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { retryMode: retryConfig.mode, timer: null, ws: entry.ws || null, + entry: (entry.agent || '') === 'codexapp' ? entry : null, }; + if (retry.entry) { + ensureCodexAppAssistantMessageId(sessionId, entry); + entry.turnStatus = 'retry_waiting'; + // 在释放 active entry 之前先提交完整运行态;保存失败仍由 pending 持有最新内容。 + persistCodexAppTurnState(sessionId, entry, { immediate: true }); + } + retry.timer = setTimeout(() => { const latest = pendingCodexCapacityRetries.get(sessionId); if (!latest || latest.timer !== retry.timer) return; @@ -9242,11 +9522,17 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { const session = loadSession(sessionId); if (!session) { - pendingCodexCapacityRetries.delete(sessionId); + cancelCodexCapacityRetry(sessionId); + if (latest.entry) { + latest.entry.turnStatus = 'interrupted'; + persistCodexAppTurnState(sessionId, latest.entry, { immediate: true }); + wsSend(latest.entry.ws, { type: 'error', sessionId, code: 'session_load_failed', message: '会话暂时无法读取,自动重试已暂停,已产生输出仍保留。' }); + wsSend(latest.entry.ws, { type: 'done', sessionId, historySaved: false }); + } return; } if (activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId)) { - pendingCodexCapacityRetries.delete(sessionId); + cancelCodexCapacityRetry(sessionId); plog('WARN', 'codex_capacity_retry_skipped_busy', { sessionId: sessionId.slice(0, 8), attempt: latest.attempts, @@ -9256,14 +9542,14 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { } pendingCodexCapacityRetries.delete(sessionId); - const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : null; + const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : findViewingSessionWs(sessionId); plog('INFO', 'codex_capacity_retry_start', { sessionId: sessionId.slice(0, 8), attempt: latest.attempts, expectedThreadId: latest.expectedThreadId ? String(latest.expectedThreadId).slice(0, 24) : null, continuation: !!latest.useContinuationRetry, }); - handleMessage(ws, { + const result = handleMessage(ws, { type: 'message', text: latest.text, sessionId, @@ -9283,13 +9569,21 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { originalText: latest.originalText || latest.text || '', originalRuntimeText: latest.originalRuntimeText || latest.runtimeText || '', useContinuationRetry: !!latest.useContinuationRetry, + carryOverEntry: latest.entry || null, } : null, skipPendingCrossConversationFlush: true, }); + if (latest.entry && !result?.ok && !activeCodexAppTurns.has(sessionId)) { + // runtime 未启动(例如存储失败)时依然完成旧输出提交;不让 timer 成为唯一所有者。 + latest.entry.ws = ws; + retainedCodexAppTurns.set(sessionId, latest.entry); + retirePendingCodexCapacityRetry(sessionId, ws); + } }, delayMs); pendingCodexCapacityRetries.set(sessionId, retry); + if (retry.entry) wsSend(entry.ws, buildCodexAppResumePayload(sessionId, entry, { retryPending: true })); plog('WARN', 'codex_capacity_retry_scheduled', { sessionId: sessionId.slice(0, 8), attempt: attempts, @@ -9379,6 +9673,16 @@ function handleProcessComplete(sessionId, exitCode, signal) { // Save result to session const session = loadSession(sessionId); + if (!session) { + // 读取失败不能视为没有要保存的内容,保留唯一输出供读取恢复后补交。 + entry.historySaved = false; + cancelCodexCapacityRetry(sessionId); + activeProcesses.delete(sessionId); + plog('WARN', 'process_complete_session_unavailable', { sessionId: sessionId.slice(0, 8) }); + wsSend(entry.ws, { type: 'error', sessionId, code: 'session_load_failed', message: '会话暂时无法读取,运行输出已保留,修复存储后可恢复。' }); + wsSend(entry.ws, { type: 'done', sessionId, historySaved: false }); + return; + } if (session && shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) { activeProcesses.delete(sessionId); cleanRunDir(sessionId); @@ -9392,16 +9696,22 @@ function handleProcessComplete(sessionId, exitCode, signal) { cancelCodexCapacityRetry(sessionId); } - if (session && (entry.fullText || entry.contentBlocks)) { + if (session && hasRuntimeOutput(entry) && !hasCliRunMessage(session, cliRunKey(sessionId, entry))) { session.messages.push({ role: 'assistant', - content: entry.contentBlocks || entry.fullText, + content: codexOutputContent(entry), toolCalls: entry.toolCalls || [], timestamp: new Date().toISOString(), + runKey: entry.runKey, }); session.updated = new Date().toISOString(); if (!entry.ws) session.hasUnread = true; - saveSession(session); + if (!saveSession(session)) { + activeProcesses.delete(sessionId); + wsSend(entry.ws, { type: 'error', sessionId, code: 'session_save_failed', message: '完整会话保存失败,运行输出已保留,修复存储后可恢复。' }); + wsSend(entry.ws, { type: 'done', sessionId, historySaved: false }); + return; + } } if (pendingSlash?.kind === 'compact' && session) { @@ -9543,7 +9853,11 @@ setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000); // Recover processes that were running before server restart function recoverProcesses() { try { - const entries = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('-run') && fs.statSync(path.join(SESSIONS_DIR, f)).isDirectory()); + const fullTurnIds = new Set(sessionHistoryStore.listTurnIds()); + const entries = [...new Set([ + ...fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('-run') && fs.statSync(path.join(SESSIONS_DIR, f)).isDirectory()), + ...[...fullTurnIds].map(id => `${id}-run`), + ])]; if (entries.length === 0) return; plog('INFO', 'recovery_start', { runDirs: entries.length }); for (const dirName of entries) { @@ -9552,9 +9866,17 @@ function recoverProcesses() { const pidPath = path.join(dir, 'pid'); const outputPath = path.join(dir, 'output.jsonl'); const session = loadSession(sessionId); + if (!session) { + // 读取失败时保留 output.jsonl 和运行目录;它们可能是当前唯一的 + // 可恢复记录,不能因为本次重启恰逢存储异常而被清理。 + plog('WARN', 'recovery_session_load_failed_preserve_output', { + sessionId: sessionId.slice(0, 8), + }); + continue; + } const agent = getSessionAgent(session); - if (fs.existsSync(codexAppStatePath(sessionId))) { + if (fullTurnIds.has(sessionId) || fs.existsSync(codexAppStatePath(sessionId))) { recoverCodexAppTurnState(sessionId); continue; } @@ -9570,6 +9892,7 @@ function recoverProcesses() { console.log(`[recovery] Re-attaching to session ${sessionId} (PID ${pid})`); plog('INFO', 'recovery_alive', { sessionId: sessionId.slice(0, 8), pid, agent }); const entry = { pid, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null }; + entry.runKey = cliRunKey(sessionId, entry); activeProcesses.set(sessionId, entry); if (fs.existsSync(outputPath)) { @@ -9586,27 +9909,6 @@ function recoverProcesses() { console.log(`[recovery] Processing completed output for session ${sessionId}`); plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid, agent }); if (fs.existsSync(outputPath)) { - const outputStat = fs.statSync(outputPath); - if (outputStat.size > RUN_OUTPUT_RECOVERY_MAX_BYTES) { - plog('WARN', 'recovery_output_skipped_oversized', { - sessionId: sessionId.slice(0, 8), - pid, - agent, - fileBytes: outputStat.size, - maxBytes: RUN_OUTPUT_RECOVERY_MAX_BYTES, - }); - if (session) { - session.messages.push({ - role: 'system', - content: '服务重启期间的运行输出文件过大,cc-web 已跳过恢复完整输出,避免启动时占用过多内存。', - timestamp: new Date().toISOString(), - }); - session.updated = new Date().toISOString(); - saveSession(session); - } - try { fs.rmSync(dir, { recursive: true }); } catch {} - continue; - } const tempEntry = { pid: 0, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null }; const content = fs.readFileSync(outputPath, 'utf8'); for (const line of content.split('\n')) { @@ -9616,15 +9918,17 @@ function recoverProcesses() { processRuntimeEvent(tempEntry, event, sessionId); } catch {} } - if (session && tempEntry.fullText) { + const runKey = cliRunKey(sessionId, tempEntry); + if (session && hasRuntimeOutput(tempEntry) && !hasCliRunMessage(session, runKey)) { session.messages.push({ role: 'assistant', - content: tempEntry.fullText, + content: codexOutputContent(tempEntry), toolCalls: tempEntry.toolCalls || [], timestamp: new Date().toISOString(), + runKey, }); session.updated = new Date().toISOString(); - saveSession(session); + if (!saveSession(session)) continue; } } try { fs.rmSync(dir, { recursive: true }); } catch {} @@ -10262,8 +10566,7 @@ function handleSaveModelConfig(ws, newConfig) { if (tpl.haikuModel) modelToTier.set(tpl.haikuModel, 'haiku'); } try { - for (const file of fs.readdirSync(SESSIONS_DIR)) { - if (!file.endsWith('.json')) continue; + for (const file of listSessionFiles()) { const sessionId = file.slice(0, -5); try { const session = loadSession(sessionId); @@ -10937,7 +11240,8 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) { switch (cmd) { case '/clear': { if (session) { - cancelCodexCapacityRetry(sessionId); + cancelCodexCapacityRetry(sessionId, { discard: true }); + retainedCodexAppTurns.delete(sessionId); if (activeProcesses.has(sessionId)) { const entry = activeProcesses.get(sessionId); killProcess(entry.pid); @@ -10949,7 +11253,12 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) { session.titleHistory = []; clearRuntimeSessionId(session); session.updated = new Date().toISOString(); - saveSession(session); + sessionHistoryStore.markComplete(session, { reset: true }); + if (!saveSession(session)) { + sendSlashSystemMessage('会话清除未保存,请检查存储后重试。'); + break; + } + sessionHistoryStore.removeTurn(sessionId); const taskTracking = taskTrackingSnapshotForSession(session); sendSlashResponse({ type: 'session_info', @@ -11557,23 +11866,15 @@ function handleMigrateSessionHistory(ws, msg = {}) { if (!sessionId || !fs.existsSync(filePath)) return respond({ ok: false, code: 'session_not_found' }); try { const original = fs.readFileSync(filePath, 'utf8'); - const session = normalizeSession(JSON.parse(original)); - if (!session.messages.some(isPersistenceNoticeMessage)) { + const session = loadSession(sessionId); + if (!session) return respond({ ok: false, code: 'session_not_found' }); + if (sessionHistoryStore.state(session)?.complete === true || !session.messages.some(isPersistenceNoticeMessage)) { return respond({ ok: true, changed: false, messageCount: session.messages.length }); } const snapshot = session.messages.filter((message) => !isPersistenceNoticeMessage(message)); const native = loadNativeSessionHistory(session); const merged = mergeNativeHistoryWithSnapshot(session, snapshot, native?.messages); if (!merged.confirmed) return respond({ ok: false, code: 'history_boundary_unconfirmed' }); - const migrated = { - ...session, - messages: merged.messages, - historySnapshotBaseIndex: 0, - historySnapshotCount: merged.messages.length, - }; - // 迁移保留快照原文;不再走会立即截断的普通持久化路径。 - const json = JSON.stringify(migrated, null, 2); - if (textByteLength(json) > SESSION_LOAD_MAX_BYTES) return respond({ ok: false, code: 'history_migration_too_large' }); const details = { snapshotCount: snapshot.length, messageCount: merged.messages.length, nativePrefixCount: merged.nativePrefixCount }; if (msg.apply !== true) return respond({ ok: true, preview: true, ...details }); if (isSessionRunning(sessionId) || fs.readFileSync(filePath, 'utf8') !== original) { @@ -11583,10 +11884,11 @@ function handleMigrateSessionHistory(ws, msg = {}) { fs.mkdirSync(backupDir, { recursive: true }); const backupPath = path.join(backupDir, `${sessionId}.${Date.now()}.${crypto.randomUUID()}.json`); fs.writeFileSync(backupPath, original, { flag: 'wx', mode: 0o600 }); - writeFileAtomicSync(filePath, json); + session.messages = merged.messages; + sessionHistoryStore.markComplete(session); + if (!saveSession(session)) return respond({ ok: false, code: 'history_archive_save_failed', backupPath }); const reloaded = loadSession(sessionId); - if (!reloaded || reloaded.messages.length !== migrated.messages.length) { - writeFileAtomicSync(filePath, original); + if (!reloaded || reloaded.messages.length !== merged.messages.length) { return respond({ ok: false, code: 'history_reload_failed', backupPath }); } sessionHistoryCache.delete(sessionHistoryCacheKey(session)); @@ -11614,7 +11916,7 @@ function attachActiveRuntimeToWs(ws, sessionId, source = {}) { wsSend(ws, attachClientRequestId({ type: 'resume_generating', sessionId, - text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS), + text: entry.fullText || '', toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []), }, source)); return true; @@ -11630,17 +11932,40 @@ function attachActiveRuntimeToWs(ws, sessionId, source = {}) { turnId: entry.turnId || null, responseLen: (entry.fullText || '').length, }); - wsSend(ws, attachClientRequestId({ - type: 'resume_generating', - sessionId, - assistantMessageId: ensureStableMessageId({ - role: 'assistant', codexAppThreadId: entry.threadId, codexAppTurnId: entry.turnId, - codexAppTurnKey: codexAppTurnKey(sessionId, entry), - }).id, - turnId: entry.turnId || null, - text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS), - toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []), - }, source)); + wsSend(ws, attachClientRequestId(buildCodexAppResumePayload(sessionId, entry), source)); + return true; + } + + const retainedEntry = retainedCodexAppTurns.get(sessionId); + if (retainedEntry) { + retainedEntry.ws = ws; + retainedEntry.wsDisconnectTime = null; + plog('INFO', 'codex_app_retained_resume_attach', { + sessionId: sessionId.slice(0, 8), + threadId: retainedEntry.threadId || null, + turnId: retainedEntry.turnId || null, + responseLen: (retainedEntry.fullText || '').length, + historySaved: !!retainedEntry.historySaved, + }); + wsSend(ws, attachClientRequestId(buildCodexAppResumePayload(sessionId, retainedEntry, { + isRunning: false, + historySaved: retainedEntry.historySaved === true, + }), source)); + wsSend(ws, attachClientRequestId({ type: 'done', sessionId, historySaved: retainedEntry.historySaved === true }, source)); + return true; + } + + const pendingRetry = pendingCodexCapacityRetries.get(sessionId); + if (pendingRetry?.entry) { + pendingRetry.ws = ws; + pendingRetry.entry.ws = ws; + pendingRetry.entry.wsDisconnectTime = null; + plog('INFO', 'codex_app_retry_wait_resume_attach', { + sessionId: sessionId.slice(0, 8), + attempt: pendingRetry.attempts || 0, + responseLen: (pendingRetry.entry.fullText || '').length, + }); + wsSend(ws, attachClientRequestId(buildCodexAppResumePayload(sessionId, pendingRetry.entry, { retryPending: true }), source)); return true; } @@ -11680,9 +12005,11 @@ function handleResumeSession(ws, msg = {}) { : { messages: session.messages || [], source: 'snapshot', available: true }; const historyMessages = sanitizeMessagesForTransport(history.messages || []); const recentMessages = historyMessages.slice(-INITIAL_HISTORY_COUNT); - const hasActiveRuntime = activeProcesses.has(sessionId) + const hasActiveRuntime = !!(activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId) - || activeCodexAppGoalCommands.has(sessionId); + || pendingCodexCapacityRetries.get(sessionId)?.entry + || retainedCodexAppTurns.has(sessionId) + || activeCodexAppGoalCommands.has(sessionId)); wsSend(ws, attachClientRequestId({ type: 'resume_session_result', sessionId, @@ -11876,7 +12203,8 @@ function deleteCodexLocalSession(session) { function handleDeleteSession(ws, sessionId) { pendingSlashCommands.delete(sessionId); pendingCompactRetries.delete(sessionId); - cancelCodexCapacityRetry(sessionId); + cancelCodexCapacityRetry(sessionId, { discard: true }); + retainedCodexAppTurns.delete(sessionId); cancelCodexAppCompaction(sessionId); codexAppGoalStates.delete(sessionId); removeSessionRuntimeThreadIndex(sessionId); @@ -11916,6 +12244,8 @@ function handleDeleteSession(ws, sessionId) { removeAttachmentById(attachmentId); } if (fs.existsSync(p)) fs.unlinkSync(p); + sessionHistoryStore.remove(sessionId); + sessionHistoryCache.delete(sessionHistoryCacheKey(session)); sessionSearchIndex.remove(sessionId); removeUsageStatisticsSession(sessionId); if (sessionAgent === 'codex') { @@ -11994,6 +12324,19 @@ function handleDisconnect(ws, wsId) { affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, turnId: entry.turnId || null }); } } + for (const [sid, retry] of pendingCodexCapacityRetries) { + if (retry.ws === ws || retry.entry?.ws === ws) { + retry.ws = null; + if (retry.entry) { + retry.entry.ws = null; + retry.entry.wsDisconnectTime = new Date().toISOString(); + } + affectedSessions.push({ sessionId: sid.slice(0, 8), retryPending: true }); + } + } + for (const entry of retainedCodexAppTurns.values()) { + if (entry.ws === ws) entry.ws = null; + } for (const [sid, entry] of activeCodexAppCompactions) { if (entry.ws === ws) { entry.ws = null; @@ -12016,6 +12359,8 @@ function bindAbortSessionToWs(sessionId, ws) { activeCodexAppTurns.get(sessionId), activeCodexAppGoalCommands.get(sessionId), activeCodexAppCompactions.get(sessionId), + pendingCodexCapacityRetries.get(sessionId)?.entry, + retainedCodexAppTurns.get(sessionId), ].filter(Boolean); if (entries.length > 0) detachWsFromActiveRuntimes(ws); wsSessionMap.set(ws, sessionId); @@ -12023,6 +12368,8 @@ function bindAbortSessionToWs(sessionId, ws) { entry.ws = ws; entry.wsDisconnectTime = null; } + const retry = pendingCodexCapacityRetries.get(sessionId); + if (retry) retry.ws = ws; } function pauseCodexAppGoalForAbort(sessionId, ws = null) { @@ -12102,6 +12449,12 @@ function handleAbort(ws, msg = {}) { if (cancelCodexAppCompaction(sessionId, ws)) return; const entry = activeProcesses.get(sessionId); if (!entry) { + if (pendingCodexCapacityRetries.get(sessionId)?.entry || retainedCodexAppTurns.has(sessionId)) { + retirePendingCodexCapacityRetry(sessionId, ws); + wsSend(ws, { type: 'system_message', sessionId, message: '已取消 Codex 自动重试。' }); + sendSessionList(ws); + return; + } if (cancelCodexCapacityRetry(sessionId)) { wsSend(ws, { type: 'system_message', sessionId, message: '已取消 Codex 自动重试。' }); wsSend(ws, { type: 'done', sessionId }); @@ -12183,11 +12536,18 @@ function handleMessage(ws, msg, options = {}) { const { text, sessionId, mode } = msg; const { hideInHistory = false, unknownSlash = false } = options; const fail = (code, message) => { + if (msg.codexAppSteer === true && msg.clientMessageId) { + wsSend(ws, { + type: 'codex_app_steer_status', sessionId, + clientMessageId: msg.clientMessageId, status: 'failed', message: '发送失败', + }); + } wsSend(ws, attachClientRequestId({ type: 'error', code, message, ...(sessionId ? { sessionId } : {}), + ...(msg.clientMessageId ? { clientMessageId: msg.clientMessageId } : {}), ...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {}), }, msg)); return { ok: false, code, message }; @@ -12211,6 +12571,9 @@ function handleMessage(ws, msg, options = {}) { })); if (sessionId && activeCodexAppTurns.has(sessionId)) { + if (activeCodexAppTurns.get(sessionId).userAborted) { + return fail('session_running', 'Codex App 正在确认停止,请稍候后重试。'); + } return handleCodexAppSteerMessage(ws, msg, { ...options, resolvedAttachments, @@ -12226,7 +12589,13 @@ function handleMessage(ws, msg, options = {}) { } if (sessionId && !hideInHistory) { - cancelCodexCapacityRetry(sessionId); + if (pendingCodexCapacityRetries.get(sessionId)?.entry || retainedCodexAppTurns.has(sessionId)) { + if (!retirePendingCodexCapacityRetry(sessionId, ws)) { + return fail('session_save_failed', '此前已产生的输出尚未保存,保留旧输出并暂停新消息,请检查存储后重试。'); + } + } else { + cancelCodexCapacityRetry(sessionId); + } } if (sessionId && activeCodexAppGoalCommands.has(sessionId)) { @@ -12338,7 +12707,9 @@ function handleMessage(ws, msg, options = {}) { } } session.updated = new Date().toISOString(); - saveSession(session); + if (!saveSession(session)) { + return fail('session_save_failed', '用户消息保存失败,未启动本轮运行,请稍后重试。'); + } const currentSessionId = session.id; if (!hideInHistory) { @@ -12366,19 +12737,32 @@ function handleMessage(ws, msg, options = {}) { if (!sessionId) { wsSend(ws, buildSessionInfoPayload(session)); } - if (ws && options.emitUserMessage && persistedUserMessage) { - wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage }); + if (ws && (options.emitUserMessage || msg.codexAppSteer === true) && persistedUserMessage) { + wsSend(ws, { + type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage, + messageIndex: session.messages.findIndex((message) => message.id === persistedUserMessage.id), + }); } sendSessionList(ws); if (isCodexAppSession(session)) { - return handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, { + const result = handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, { mcpContext: options.mcpContext || {}, giteaWorkflow: options.giteaWorkflow || null, giteaMcp: options.giteaMcp || null, crossConversation: options.crossConversation || null, codexRetry: options.codexRetry || null, }); + // 页面可能仍显示运行中;实际已结束时,确认同一条消息已作为新轮输入接收。 + if (result?.ok && msg.codexAppSteer === true) { + wsSend(ws, { + type: 'codex_app_steer_status', sessionId: currentSessionId, + clientMessageId: msg.clientMessageId, status: 'sent', message: '已转为新对话', + messageIndex: session.messages.findIndex((message) => message.id === persistedUserMessage?.id), + }); + wsSend(ws, buildCodexAppResumePayload(currentSessionId, activeCodexAppTurns.get(currentSessionId))); + } + return result; } const runtimeOptions = { @@ -12396,6 +12780,12 @@ function handleMessage(ws, msg, options = {}) { // === Detached process with file-based I/O === const dir = runDir(currentSessionId); fs.mkdirSync(dir, { recursive: true }); + const runKey = `cli:${currentSessionId}:${crypto.randomUUID()}`; + try { + writeFileAtomicSync(path.join(dir, 'run-key'), runKey); + } catch (error) { + return fail('runtime_state_save_failed', `运行标识保存失败,未启动本轮运行:${error?.message || error}`); + } const inputPath = path.join(dir, 'input.txt'); const outputPath = path.join(dir, 'output.jsonl'); @@ -12459,6 +12849,7 @@ function handleMessage(ws, msg, options = {}) { plog('INFO', 'process_spawn', { sessionId: currentSessionId.slice(0, 8), pid: proc.pid, + runKey, agent: getSessionAgent(session), mode: spawnSpec.mode, model: session.model || 'default', @@ -12558,11 +12949,21 @@ function safeJsonParse(input) { } function sanitizeToolInput(toolName, input) { - const parsed = safeJsonParse(input); - if (toolName === 'AskUserQuestion') { - return parsed; + return safeJsonParse(input); +} + +function sendRuntimePreview(ws, payload) { + const preview = { ...payload }; + for (const key of ['text', 'message', 'blocks', 'input', 'result', 'meta']) { + if (Object.prototype.hasOwnProperty.call(preview, key)) { + preview[key] = sanitizePersistValue(preview[key], { + maxString: key === 'input' ? SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS + : key === 'text' || key === 'message' ? 16 * 1024 : SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS, + maxDepth: 5, maxArray: 80, maxKeys: 80, + }); + } } - return truncateObj(parsed, 500); + wsSend(ws, preview); } function redactSpawnArgs(argsText) { @@ -12593,8 +12994,7 @@ const { internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`, internalMcpToken: INTERNAL_MCP_TOKEN, nodePath: process.execPath, - wsSend, - truncateObj, + wsSend: sendRuntimePreview, sanitizeToolInput, loadSession, saveSession, @@ -12604,10 +13004,9 @@ const { }); const codexAppRuntime = createCodexAppRuntime({ - wsSend, + wsSend: sendRuntimePreview, loadSession, saveSession, - truncateObj, }); function detachWsFromActiveRuntimes(ws, options = {}) { @@ -12624,6 +13023,21 @@ function detachWsFromActiveRuntimes(ws, options = {}) { if (disconnectTime) entry.wsDisconnectTime = disconnectTime; } } + for (const retry of pendingCodexCapacityRetries.values()) { + if (retry.ws === ws || retry.entry?.ws === ws) { + retry.ws = null; + if (retry.entry) { + retry.entry.ws = null; + if (disconnectTime) retry.entry.wsDisconnectTime = disconnectTime; + } + } + } + for (const entry of retainedCodexAppTurns.values()) { + if (entry.ws === ws) { + entry.ws = null; + if (disconnectTime) entry.wsDisconnectTime = disconnectTime; + } + } for (const [, entry] of activeCodexAppGoalCommands) { if (entry.ws === ws) { entry.ws = null; @@ -12649,9 +13063,13 @@ function codexAppRuntimeTurnId(params = {}) { function findCodexAppEntryByRuntime(params = {}) { const threadId = codexAppRuntimeThreadId(params); const turnId = codexAppRuntimeTurnId(params); + if (threadId && turnId && retiredCodexAppRuntimeTurns.has(`${threadId}:${turnId}`)) return null; if (threadId) { for (const [sessionId, entry] of activeCodexAppTurns) { - if (entry.threadId === threadId) return { sessionId, entry }; + if (entry.threadId === threadId) { + if (turnId && entry.turnId && turnId !== entry.turnId) return null; + return { sessionId, entry }; + } } } if (turnId) { @@ -12684,8 +13102,7 @@ function findCodexAppSessionByThreadId(threadId) { function scanCodexAppSessionByThreadId(targetThreadId) { try { - for (const file of fs.readdirSync(SESSIONS_DIR)) { - if (!file.endsWith('.json')) continue; + for (const file of listSessionFiles()) { const sessionId = sanitizeId(file.slice(0, -5)); if (!sessionId) continue; const session = loadSession(sessionId); @@ -12729,11 +13146,16 @@ function adoptCodexAppUnroutedTurn(params = {}, method = '') { const threadId = codexAppRuntimeThreadId(params); const turnId = codexAppRuntimeTurnId(params); if (!threadId || !turnId) return null; + if (retiredCodexAppRuntimeTurns.has(`${threadId}:${turnId}`)) return null; const matched = findCodexAppSessionByThreadId(threadId); if (!matched?.session) return null; + if (pendingCodexCapacityRetries.has(matched.sessionId) || retainedCodexAppTurns.has(matched.sessionId)) return null; const existing = activeCodexAppTurns.get(matched.sessionId); - if (existing) return { sessionId: matched.sessionId, entry: existing }; + if (existing) { + if (existing.turnId && existing.turnId !== turnId) return null; + return { sessionId: matched.sessionId, entry: existing }; + } const entry = { ws: findViewingSessionWs(matched.sessionId), @@ -12758,7 +13180,9 @@ function adoptCodexAppUnroutedTurn(params = {}, method = '') { recoveredFromNotification: true, }; activeCodexAppTurns.set(matched.sessionId, entry); + ensureCodexAppAssistantMessageId(matched.sessionId, entry); persistCodexAppTurnState(matched.sessionId, entry, { immediate: true }); + wsSend(entry.ws, buildCodexAppResumePayload(matched.sessionId, entry)); broadcastSessionList(); plog('INFO', 'codex_app_unrouted_turn_adopted', { sessionId: matched.sessionId.slice(0, 8), @@ -13131,11 +13555,14 @@ function flushPendingCcwebMcpChildSession(sessionId) { const pending = pendingCcwebMcpChildSessionFlushes.get(sessionId); if (!pending) return false; if (pending.timer) clearTimeout(pending.timer); - pendingCcwebMcpChildSessionFlushes.delete(sessionId); + pending.timer = null; // 尾随冲刷前重新读取最新会话,避免覆盖这 250ms 内由父 turn 完成等路径写入的数据。 const session = loadSession(sessionId) || pending.session; - if (!session || !Array.isArray(session.messages)) return false; + if (!session || !Array.isArray(session.messages)) { + pendingCcwebMcpChildSessionFlushes.delete(sessionId); + return false; + } let merged = false; for (const child of pending.children.values()) { const targetTool = findCcwebMcpChildTargetToolInMessages(session.messages, child.spawnToolId); @@ -13145,7 +13572,22 @@ function flushPendingCcwebMcpChildSession(sessionId) { if (!session.updated || pending.updated > session.updated) session.updated = pending.updated; if (pending.markUnread) session.hasUnread = true; - return saveSession(session); + if (saveSession(session)) { + pendingCcwebMcpChildSessionFlushes.delete(sessionId); + return true; + } + + // 保存失败时保留最后子线程状态,给下一次事件或一次短延迟重试机会。 + // 只安排一次自动重试,避免磁盘故障时形成无限紧循环。 + if (!pending.retryScheduled) { + pending.retryScheduled = true; + pending.timer = setTimeout(() => { + pending.timer = null; + flushPendingCcwebMcpChildSession(sessionId); + }, Math.max(CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS, 1000)); + if (typeof pending.timer.unref === 'function') pending.timer.unref(); + } + return false; } function updatePersistedCcwebMcpChildTool(sessionId, child) { @@ -13160,6 +13602,7 @@ function updatePersistedCcwebMcpChildTool(sessionId, child) { updated: session.updated || '', markUnread: false, timer: null, + retryScheduled: false, }; pendingCcwebMcpChildSessionFlushes.set(sessionId, pending); } @@ -13249,8 +13692,9 @@ function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) { const activityAgentPath = String(item.agentPath || item.agent_path || '').trim(); const activityPrompt = String(item.prompt || item.taskDescription || item.task_description || '').trim(); const activityVisibleToolId = isSubAgentActivity - ? String((routed.role === 'child' ? routed.child?.spawnToolId : '') || item.id || '').trim() + ? String((routed.role === 'child' ? routed.child?.spawnToolId : '') || codexAppRuntime.itemIdForEntry(routed.entry, item.id) || '').trim() : ''; + const visibleToolId = routed.role === 'child' ? item.id : codexAppRuntime.itemIdForEntry(routed.entry, item.id); const activityState = isSubAgentActivity ? { label: activityAgentPath ? path.basename(activityAgentPath) : '', role: String(item.role || item.agentRole || item.agent_role || '').trim(), @@ -13277,7 +13721,7 @@ function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) { turnId: null, parentSessionId: routed.sessionId, parentThreadId, - spawnToolId: isSpawn ? (activityVisibleToolId || item.id) : '', + spawnToolId: isSpawn ? (activityVisibleToolId || visibleToolId) : '', label: ccwebMcpChildLabel(state, threadId), role: String(state.role || state.agent || state.agentType || state.agent_type || '').trim(), agentPath: String(state.agentPath || state.agent_path || '').trim(), @@ -13294,8 +13738,8 @@ function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) { updatedAt: now, }; if (isSubAgentActivity && activityVisibleToolId) child.spawnToolId = activityVisibleToolId; - if (!child.spawnToolId && isSpawn) child.spawnToolId = item.id; - if (!child.spawnToolId && item.id) child.spawnToolId = item.id; + if (!child.spawnToolId && isSpawn) child.spawnToolId = visibleToolId; + if (!child.spawnToolId && item.id) child.spawnToolId = visibleToolId; child.parentSessionId = child.parentSessionId || routed.sessionId; child.parentThreadId = child.parentThreadId || parentThreadId; child.label = ccwebMcpChildLabel(state, child.label || threadId); @@ -13499,6 +13943,9 @@ function handleCodexAppNotification(notification) { persistCodexAppTurnState(routed.sessionId, routed.entry, { immediate: !!result?.done }); if (result?.done) { handleCodexAppTurnComplete(routed.sessionId); + } else if (routed.entry.userAborted && routed.entry.turnStartPending && routed.entry.turnId) { + // turn/started 可先于 turn/start 回包;确认旧身份后取消,禁止把它交给下一轮。 + handleCodexAppAbortSession(routed.sessionId); } } @@ -14069,6 +14516,9 @@ function handleCodexAppServerExit(signature, info = {}) { }); for (const sessionId of affected) { + const entry = activeCodexAppTurns.get(sessionId); + // app-server 明确退出后不会再启动未确认的旧 turn,可以释放取消门禁。 + if (entry) entry.turnStartPending = false; handleCodexAppTurnFailure(sessionId, new Error(message)); } } @@ -14367,6 +14817,13 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment expiresAt: attachment.expiresAt, storageState: attachment.storageState, })); + const carryOverEntry = codexRetry?.isAutoRetry && options.codexRetry?.carryOverEntry?.agent === 'codexapp' + ? options.codexRetry.carryOverEntry + : null; + if (carryOverEntry?.codexAppStateTimer) { + clearTimeout(carryOverEntry.codexAppStateTimer); + carryOverEntry.codexAppStateTimer = null; + } const entry = { ws, agent: 'codexapp', @@ -14375,17 +14832,22 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment threadId: expectedThreadId, expectedThreadId, turnId: null, - fullText: '', - toolCalls: [], - toolOutputDeltas: new Map(), - agentMessageItems: new Map(), + fullText: carryOverEntry?.fullText || '', + toolCalls: carryOverEntry?.toolCalls || [], + toolOutputDeltas: carryOverEntry?.toolOutputDeltas || new Map(), + agentMessageItems: carryOverEntry?.agentMessageItems || new Map(), + agentMessagePendingPrefixes: carryOverEntry?.agentMessagePendingPrefixes || new Map(), + // 原生 item ID 可跨 turn 复用;每次续跑为新 item 命名空间保留旧工具和映射。 + runtimeItemScope: carryOverEntry ? `retry-${codexRetry.attempt || 1}` : '', + assistantMessageId: carryOverEntry?.assistantMessageId || null, + logicalTurnKey: carryOverEntry?.logicalTurnKey || `${session.id}:request:${crypto.randomUUID()}`, mcpContext: options.mcpContext || {}, giteaWorkflow: options.giteaWorkflow || null, giteaMcp: options.giteaMcp || options.giteaWorkflow?.mcp || null, giteaWorkflowTaskId: options.giteaWorkflow?.taskId || null, giteaWorkflowKind: options.giteaWorkflow?.kind || 'normal', codexRetry, - lastUsage: null, + lastUsage: carryOverEntry?.lastUsage || null, lastError: null, errorSent: false, crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null, @@ -14402,14 +14864,18 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment expectedThreadId, }, clientUserMessageId: crypto.randomUUID(), - startedAt: new Date().toISOString(), + startedAt: carryOverEntry?.startedAt || new Date().toISOString(), }; + ensureCodexAppAssistantMessageId(session.id, entry); + retainedCodexAppTurns.delete(session.id); activeCodexAppTurns.set(session.id, entry); persistCodexAppTurnState(session.id, entry, { immediate: true }); + // 首轮和自动延续都先绑定稳定气泡,再接收任何 delta。 + wsSend(ws, buildCodexAppResumePayload(session.id, entry)); sendSessionList(ws); startCodexAppTurn(session.id, input).catch((err) => { - handleCodexAppTurnFailure(session.id, err); + if (activeCodexAppTurns.get(session.id) === entry) handleCodexAppTurnFailure(session.id, err); }); return { ok: true, sessionId: session.id }; @@ -14424,6 +14890,7 @@ async function startCodexAppTurn(sessionId, input) { if (clientResult.error) throw new Error(clientResult.error); const client = clientResult.client; await client.start(); + if (activeCodexAppTurns.get(sessionId) !== entry || entry.userAborted) return; const currentThreadId = getRuntimeSessionId(session); const expectedThreadId = entry.expectedThreadId @@ -14441,6 +14908,7 @@ async function startCodexAppTurn(sessionId, input) { if (threadId) { const requestedThreadId = threadId; const resumed = await client.request('thread/resume', { ...threadParams, threadId: requestedThreadId }, 60000); + if (activeCodexAppTurns.get(sessionId) !== entry || entry.userAborted) return; const resumedThreadId = resumed?.thread?.id || requestedThreadId; if (expectedThreadId && resumedThreadId !== expectedThreadId) { const expectedShort = String(expectedThreadId).slice(0, 24); @@ -14469,6 +14937,7 @@ async function startCodexAppTurn(sessionId, input) { threadId: sourceThreadId, lastTurnId: sourceTurnId, }, 60000); + if (activeCodexAppTurns.get(sessionId) !== entry || entry.userAborted) return; threadId = forked?.thread?.id || null; if (!threadId) throw new Error('Codex App thread/fork 未返回新 threadId。'); plog('INFO', 'codex_app_thread_forked', { @@ -14479,6 +14948,7 @@ async function startCodexAppTurn(sessionId, input) { }); } else { const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000); + if (activeCodexAppTurns.get(sessionId) !== entry || entry.userAborted) return; threadId = started?.thread?.id || null; } } @@ -14493,9 +14963,52 @@ async function startCodexAppTurn(sessionId, input) { // 每轮消息都先确认当前线程的配置服务器和工具库存;仅收到 startupStatus=ready // 不足以证明模型可调用具体 MCP,失败时保留会话并给出可重试错误。 await ensureCodexAppMcpReadyForThread(session, threadId); + if (activeCodexAppTurns.get(sessionId) !== entry || entry.userAborted) return; - const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000); + entry.turnStartPending = true; + entry.turnStartClient = client; + let turn; + try { + turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000); + } catch (error) { + if (activeCodexAppTurns.get(sessionId) === entry && entry.userAborted) { + // RPC 超时不能证明未启动;未知 ID 时继续保留 A,晚到通知仍归属 A。 + entry.turnStartPending = !entry.turnId && client.isRunning(); + handleCodexAppAbortSession(sessionId); + if (entry.turnStartPending) { + wsSend(entry.ws, { + type: 'error', sessionId, code: 'codexapp_cancel_pending', + message: '停止请求仍在等待旧回合身份确认,已保留输出并暂停新消息;收到旧回合状态后将继续停止。', + }); + } + return; + } + entry.turnStartPending = false; + throw error; + } if (turn?.turn?.id) entry.turnId = turn.turn.id; + if (activeCodexAppTurns.get(sessionId) !== entry || entry.userAborted) { + if (activeCodexAppTurns.get(sessionId) === entry) { + entry.turnStartPending = !entry.turnId && client.isRunning(); + handleCodexAppAbortSession(sessionId); + return; + } + entry.turnStartPending = false; + // 启动请求已发出后取消时,回包中的旧 turn 仍可能在运行;只中断其精确身份。 + const retiredTurnId = turn?.turn?.id || entry.turnId; + if (retiredTurnId) { + entry.turnId = retiredTurnId; + retireCodexAppRuntimeTurn(entry); + void client.request('turn/interrupt', { threadId, turnId: retiredTurnId }, 30000).catch((error) => { + plog('WARN', 'codex_app_retired_turn_interrupt_failed', { + sessionId: sessionId.slice(0, 8), threadId, turnId: retiredTurnId, + error: error?.message || String(error || ''), + }); + }); + } + return; + } + entry.turnStartPending = false; if (entry.giteaWorkflow?.handleTurnStarted && entry.giteaWorkflowTaskId && entry.turnId) { try { await Promise.resolve(entry.giteaWorkflow.handleTurnStarted({ @@ -14522,6 +15035,13 @@ async function startCodexAppTurn(sessionId, input) { function handleCodexAppTurnComplete(sessionId, options = {}) { const entry = activeCodexAppTurns.get(sessionId); if (!entry) return; + if (entry.userAborted && entry.turnStartPending && !entry.turnId && entry.turnStartClient?.isRunning()) { + // 未知身份的启动仍可能在服务端完成,不能因一次 error 通知提前释放线程。 + entry.turnStatus = 'cancelling'; + persistCodexAppTurnState(sessionId, entry, { immediate: true }); + return; + } + ensureCodexAppAssistantMessageId(sessionId, entry); resolvePendingCodexAppUserInputsForSession(sessionId); resolvePendingCodexAppApprovalsForSession(sessionId); resolvePendingCodexAppElicitationsForSession(sessionId); @@ -14534,6 +15054,31 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { const session = loadSession(sessionId); const turnKey = codexAppTurnKey(sessionId, entry); + if (!session) { + // 归档暂时不可读时仍提交最新完整运行态,禁止把跳过保存当作已提交。 + entry.historySaved = false; + entry.turnStatus = options.interrupted || entry.userAborted || rawError ? 'interrupted' : 'completed'; + if (entry.codexAppStateTimer) { + clearTimeout(entry.codexAppStateTimer); + entry.codexAppStateTimer = null; + } + cancelCodexCapacityRetry(sessionId); + const completeStateSaved = writeCodexAppTurnState(sessionId, entry); + activeCodexAppTurns.delete(sessionId); + retainedCodexAppTurns.set(sessionId, entry); + retireCodexAppRuntimeTurn(entry); + plog('WARN', 'codex_app_complete_session_unavailable', { + sessionId: sessionId.slice(0, 8), completeStateSaved, + }); + wsSend(entry.ws, { + type: 'error', sessionId, code: 'session_load_failed', + message: completeStateSaved + ? '会话暂时无法读取,完整运行态已保留,修复存储后可恢复。' + : '会话和最新运行态保存失败,已保留此前运行态;最新输出可能尚未落盘。', + }); + wsSend(entry.ws, { type: 'done', sessionId, historySaved: false }); + return; + } if (entry.giteaWorkflow?.handleTurnCompleted && entry.giteaWorkflowTaskId) { void Promise.resolve(entry.giteaWorkflow.handleTurnCompleted({ taskId: entry.giteaWorkflowTaskId, @@ -14546,18 +15091,13 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { plog('WARN', 'gitea_workflow_turn_completed_hook_failed', { error: error?.message || String(error || '') }); }); } - const assistantContent = truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS); - const assistantToolCalls = sanitizeToolCallsForPersist(entry.toolCalls || [], { - maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE, - toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS, - toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS, - contentMaxChars: SESSION_MESSAGE_CONTENT_MAX_CHARS, - }); + const assistantContent = entry.fullText || ''; + const assistantToolCalls = Array.isArray(entry.toolCalls) ? entry.toolCalls : []; - if (session && shouldRetryCodexTransientFailure(entry, rawError)) { - activeCodexAppTurns.delete(sessionId); - cleanupCodexAppTurnState(sessionId, entry); + if (session && shouldRetryCodexTransientFailure(entry, rawError, { skipTransientRetry: !!options.skipTransientRetry })) { if (scheduleCodexCapacityRetry(sessionId, entry, rawError)) { + retireCodexAppRuntimeTurn(entry); + activeCodexAppTurns.delete(sessionId); sendSessionList(entry.ws); return; } @@ -14567,6 +15107,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) { const assistantMessage = ensureStableMessageId({ + id: entry.assistantMessageId, role: 'assistant', content: assistantContent, toolCalls: assistantToolCalls, @@ -14574,7 +15115,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { codexAppTurnKey: turnKey, codexAppThreadId: entry.threadId || null, codexAppTurnId: entry.turnId || null, - interrupted: !!options.interrupted, + interrupted: !!options.interrupted || !!entry.userAborted || !!rawError, }); const beforeUserMessage = options.beforeUserMessage; const beforeUserMessageIndex = beforeUserMessage @@ -14592,9 +15133,30 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { } session.updated = new Date().toISOString(); if (!entry.ws) session.hasUnread = true; - saveSession(session); + if (!saveSession(session)) { + entry.historySaved = false; + entry.turnStatus = options.interrupted || entry.userAborted || rawError ? 'interrupted' : 'completed'; + if (entry.codexAppStateTimer) { + clearTimeout(entry.codexAppStateTimer); + entry.codexAppStateTimer = null; + } + const completeStateSaved = writeCodexAppTurnState(sessionId, entry); + activeCodexAppTurns.delete(sessionId); + retainedCodexAppTurns.set(sessionId, entry); + retireCodexAppRuntimeTurn(entry); + wsSend(entry.ws, { + type: 'error', sessionId, code: 'session_save_failed', + message: completeStateSaved + ? '完整会话保存失败,完整运行态已保留,修复存储后可恢复。' + : '完整会话和最新运行态保存失败,已保留此前运行态;最新输出可能尚未落盘。', + }); + wsSend(entry.ws, { type: 'done', sessionId, historySaved: false }); + return; + } } + entry.historySaved = true; + retireCodexAppRuntimeTurn(entry); activeCodexAppTurns.delete(sessionId); cleanupCodexAppTurnState(sessionId, entry); const workflowWaiter = giteaWorkflowWaiters.get(sessionId); @@ -14696,7 +15258,9 @@ function handleCodexAppTurnFailure(sessionId, err) { } function isCodexAppNoActiveTurnError(err) { - return /\bno active turn to steer\b/i.test(String(err?.message || err || '')); + // 真实 app-server 在带 expectedTurnId 时返回 "expected active turn id `...`: no active turn", + // 未带期望 id 时返回 "no active turn to steer";两种都表示旧 turn 已经结束。 + return /^(?:no active turn to steer|expected active turn id\b[^\r\n]*:\s*no active turn)\.?$/i.test(String(err?.message || err || '').trim()); } function rollbackPersistedCodexAppSteerMessage(sessionId, userMessageId) { @@ -14712,10 +15276,33 @@ function rollbackPersistedCodexAppSteerMessage(sessionId, userMessageId) { return true; } +// turn/completed 先于 steer 失败到达时,旧 turn 的助手输出可能被追加到引导消息之后; +// 把这条旧输出移回引导消息之前,保证历史顺序仍是“旧输出在前、新消息在后”。 +function reorderStaleCodexAppTurnMessages(sessionId, entry, userMessageId) { + const session = loadSession(sessionId); + if (!session || !Array.isArray(session.messages)) return false; + const staleTurnKey = codexAppTurnKey(sessionId, entry); + const userIndex = session.messages.findIndex((message) => ( + message?.role === 'user' && message.id === userMessageId + )); + const assistantIndex = session.messages.findIndex((message) => ( + message?.role === 'assistant' && message.codexAppTurnKey === staleTurnKey + )); + if (userIndex < 0 || assistantIndex < 0 || assistantIndex < userIndex) return true; + const [assistantMessage] = session.messages.splice(assistantIndex, 1); + session.messages.splice(userIndex, 0, assistantMessage); + session.updated = new Date().toISOString(); + if (!saveSession(session)) return false; + wsSend(entry.ws, { + type: 'session_message', sessionId, message: sanitizeMessageForTransport(assistantMessage), messageIndex: userIndex, + }); + return true; +} + function handleCodexAppSteerMessage(ws, msg, options = {}) { const sessionId = sanitizeId(msg?.sessionId || ''); const entry = activeCodexAppTurns.get(sessionId); - if (!entry) return null; + if (!entry) return handleMessage(ws, { ...msg, codexAppSteer: true }, options); const clientMessageId = String(msg?.clientMessageId || '').trim(); const userMessageId = clientMessageId || crypto.randomUUID(); @@ -14729,6 +15316,10 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { message, }; if (clientMessageId) payload.clientMessageId = clientMessageId; + if (status === 'sent') { + const savedSession = loadSession(sessionId); + payload.messageIndex = savedSession?.messages?.findIndex((message) => message.id === userMessageId); + } wsSend(targetWs, payload); }; @@ -14744,6 +15335,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { return { ok: false, code, message }; }; + if (entry.userAborted) return fail('session_running', 'Codex App 正在确认停止,请稍候后重试。'); const session = loadSession(sessionId); if (!session || !isCodexAppSession(session)) { return fail('session_not_found', 'Codex App 会话不存在。'); @@ -14806,16 +15398,43 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { const waitForSteerReady = () => new Promise((resolve) => { const startedAt = Date.now(); const check = () => { - if (activeCodexAppTurns.get(sessionId) !== entry) return resolve(false); - if (entry.threadId && entry.turnId && codexAppClient?.isRunning()) return resolve(true); - if (Date.now() - startedAt >= 2000) return resolve(false); + if (activeCodexAppTurns.get(sessionId) !== entry) return resolve('replaced'); + if (entry.threadId && entry.turnId && codexAppClient?.isRunning()) return resolve('ready'); + if (Date.now() - startedAt >= 2000) return resolve('timeout'); setTimeout(check, 50); }; check(); }); - waitForSteerReady().then((ready) => { - if (!ready) { + waitForSteerReady().then((readyState) => { + if (entry.userAborted) { + abandonSteeredCrossConversationReply(); + return fail('session_running', 'Codex App 正在确认停止,请稍候后重试。'); + } + if (readyState !== 'ready') { + // 等待期间旧 turn 已被收敛,重新按当前会话状态分发,交给新 turn 或普通发送。 + if (readyState === 'replaced') { + if (entry.historySaved === false) { + abandonSteeredCrossConversationReply(); + return fail('session_save_failed', '上一轮输出保存失败,未启动新对话;请修复存储后重试。'); + } + if (steerReplyRequestId) unregisterCrossConversationReplyRequest(entry, steerReplyRequestId); + const rerun = handleMessage(ws, { ...msg, clientMessageId: userMessageId, codexAppSteer: true }, { + ...options, + mcpContext: entry.mcpContext || options.mcpContext || {}, + skipPendingCrossConversationFlush: true, + }); + if (rerun?.ok) { + plog('INFO', 'codex_app_stale_steer_recovered', { + sessionId: sessionId.slice(0, 8), + clientMessageId: clientMessageId || null, + reason: 'turn_replaced', + }); + return; + } + abandonSteeredCrossConversationReply(); + return; + } abandonSteeredCrossConversationReply(); sendSteerStatus('failed', '插入失败'); wsSend(entry.ws || ws, { @@ -14865,7 +15484,10 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { } currentSession.messages.push(persistedUserMessage); currentSession.updated = new Date().toISOString(); - saveSession(currentSession); + if (!saveSession(currentSession)) { + abandonSteeredCrossConversationReply(); + return fail('session_save_failed', '引导消息保存失败,未发送 steer,请稍后重试。'); + } if (ws && options.emitUserMessage) { wsSend(ws, { type: 'session_message', @@ -14899,47 +15521,67 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { message: `已引导对话: ${previewInlineText(steerPreviewText)}`, }); }).catch((err) => { - if (isCodexAppNoActiveTurnError(err) && activeCodexAppTurns.get(sessionId) === entry) { - unregisterCrossConversationReplyRequest(entry, steerReplyRequestId); - // app-server 已确认旧 turn 不存在:先收敛旧输出,再复用已持久化的消息直接启动新 turn。 - handleCodexAppTurnComplete(sessionId, { - beforeUserMessage: { - timestamp: persistedUserMessage.timestamp, - content: persistedUserMessage.content, - }, - deferPendingCrossConversationFlush: true, - }); - const refreshedSession = loadSession(sessionId); - const restarted = refreshedSession - && isCodexAppSession(refreshedSession) - && !activeCodexAppTurns.has(sessionId) - ? handleCodexAppMessage(ws, refreshedSession, runtimeTextValue, resolvedAttachments, { - mcpContext: entry.mcpContext || options.mcpContext || {}, - crossConversation: options.crossConversation || null, - }) - : null; - if (restarted?.ok) { - void enqueueTaskStatusClassification(sessionId, { - eventType: TASK_STATUS_CLASSIFICATION_EVENTS.USER_MESSAGE_RECEIVED, - eventId: userMessageId, - userMessage: textValue, - }); - wsSend(entry.ws || ws, { - type: 'resume_generating', - sessionId, - text: '', - toolCalls: [], - }); - sendSteerStatus('inserted', '已转为新对话'); - wsSend(entry.ws || ws, { - type: 'system_message', - sessionId, - tone: 'info', - transient: true, - autoDismissMs: 5000, - message: '检测到上一轮已结束,已自动开始新一轮对话。', - }); - return; + if (isCodexAppNoActiveTurnError(err)) { + const staleEntryActive = activeCodexAppTurns.get(sessionId) === entry; + const noActiveEntry = !activeCodexAppTurns.has(sessionId); + if (staleEntryActive || noActiveEntry) { + unregisterCrossConversationReplyRequest(entry, steerReplyRequestId); + if (entry.historySaved === false) { + abandonSteeredCrossConversationReply(); + return fail('session_save_failed', '上一轮输出保存失败,未启动新对话;请修复存储后重试。'); + } + if (staleEntryActive) { + // app-server 已确认旧 turn 不存在,但本地 entry 尚未收敛:先收敛旧输出。 + handleCodexAppTurnComplete(sessionId, { + beforeUserMessage: { + timestamp: persistedUserMessage.timestamp, + content: persistedUserMessage.content, + }, + deferPendingCrossConversationFlush: true, + }); + if (entry.historySaved === false) { + abandonSteeredCrossConversationReply(); + return fail('session_save_failed', '上一轮输出保存失败,未启动新对话;请修复存储后重试。'); + } + } else { + // turn/completed 先于 steer 失败到达:先恢复旧输出与引导消息的顺序再重启。 + if (!reorderStaleCodexAppTurnMessages(sessionId, entry, userMessageId)) { + abandonSteeredCrossConversationReply(); + return fail('session_save_failed', '对话顺序保存失败,未启动新对话;请修复存储后重试。'); + } + } + const refreshedSession = loadSession(sessionId); + const restarted = refreshedSession + && isCodexAppSession(refreshedSession) + && !activeCodexAppTurns.has(sessionId) + ? handleCodexAppMessage(ws, refreshedSession, runtimeTextValue, resolvedAttachments, { + mcpContext: entry.mcpContext || options.mcpContext || {}, + crossConversation: options.crossConversation || null, + }) + : null; + if (restarted?.ok) { + plog('INFO', 'codex_app_stale_steer_recovered', { + sessionId: sessionId.slice(0, 8), + clientMessageId: clientMessageId || null, + reason: staleEntryActive ? 'no_active_turn' : 'turn_completed_first', + }); + void enqueueTaskStatusClassification(sessionId, { + eventType: TASK_STATUS_CLASSIFICATION_EVENTS.USER_MESSAGE_RECEIVED, + eventId: userMessageId, + userMessage: textValue, + }); + wsSend(entry.ws || ws, buildCodexAppResumePayload(sessionId, activeCodexAppTurns.get(sessionId))); + sendSteerStatus('sent', '已转为新对话'); + wsSend(entry.ws || ws, { + type: 'system_message', + sessionId, + tone: 'info', + transient: true, + autoDismissMs: 5000, + message: '检测到上一轮已结束,已自动开始新一轮对话。', + }); + return; + } } } abandonSteeredCrossConversationReply(); @@ -14972,22 +15614,54 @@ function handleCodexAppAbortSession(sessionId, ws = null) { turnId: entry.turnId || null, }); - if (!entry.threadId || !entry.turnId || !codexAppClient || !codexAppClient.isRunning()) { + const client = entry.turnStartClient || codexAppClient; + if (entry.turnStartPending) entry.cancelledDuringStart = true; + if (entry.turnStartPending && !entry.turnId && client?.isRunning()) { + if (entry.turnStatus !== 'cancelling') { + wsSend(entry.ws, { type: 'system_message', sessionId, message: '正在确认旧回合启动状态,确认后将停止。' }); + } + entry.turnStatus = 'cancelling'; + persistCodexAppTurnState(sessionId, entry, { immediate: true }); + return true; + } + if (!entry.threadId || !entry.turnId || !client || !client.isRunning()) { + entry.turnStartPending = false; handleCodexAppTurnComplete(sessionId, { interrupted: true }); return true; } - codexAppClient.request('turn/interrupt', { + if (entry.turnAbortRequested) return true; + entry.turnAbortRequested = true; + client.request('turn/interrupt', { threadId: entry.threadId, turnId: entry.turnId, }, 30000).then(() => { + if (entry.cancelledDuringStart) { + if (activeCodexAppTurns.get(sessionId) === entry) { + handleCodexAppTurnComplete(sessionId, { interrupted: true }); + } + return; + } setTimeout(() => { - if (activeCodexAppTurns.has(sessionId)) { + if (activeCodexAppTurns.get(sessionId) === entry) { handleCodexAppTurnComplete(sessionId, { interrupted: true }); } }, 2000); }).catch((err) => { - handleCodexAppTurnFailure(sessionId, err); + if (activeCodexAppTurns.get(sessionId) !== entry) return; + entry.turnAbortRequested = false; + if (!client.isRunning()) { + entry.turnStartPending = false; + handleCodexAppTurnComplete(sessionId, { interrupted: true }); + return; + } + // 中断失败不代表旧 turn 结束,继续保留门禁与正文,允许再次停止。 + entry.turnStatus = 'cancelling'; + persistCodexAppTurnState(sessionId, entry, { immediate: true }); + wsSend(entry.ws, { + type: 'error', sessionId, code: 'codexapp_cancel_failed', + message: `旧回合停止尚未确认,已保留输出并暂停新消息:${err?.message || String(err || '')}`, + }); }); return true; @@ -15142,9 +15816,10 @@ const { function getImportedSessionIds() { const imported = new Set(); try { - for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) { + for (const f of listSessionFiles()) { try { - const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); + const s = loadSession(path.basename(f, '.json')); + if (!s) continue; if (s.claudeSessionId) imported.add(s.claudeSessionId); } catch {} } @@ -15209,6 +15884,17 @@ function handleListNativeSessions(ws) { wsSend(ws, { type: 'native_sessions', groups }); } +function prepareNativeHistoryImport(existingSession, importedSession) { + if (!existingSession) return normalizeSession(importedSession); + const snapshot = existingSession.messages.filter(message => !isPersistenceNoticeMessage(message)); + const merged = mergeNativeHistoryWithSnapshot(existingSession, snapshot, importedSession.messages); + if (!merged.confirmed) return null; + // cc-web 的气泡和顺序保持权威;原始记录只补经可靠边界确认的更早前缀。 + Object.assign(existingSession, importedSession, { messages: merged.messages }); + sessionHistoryStore.markComplete(existingSession); + return existingSession; +} + function handleImportNativeSession(ws, msg) { const { sessionId, projectDir } = msg; if (!sessionId || !projectDir) { @@ -15228,9 +15914,10 @@ function handleImportNativeSession(ws, msg) { // Find or create cc-web session with this claudeSessionId let existingSession = null; try { - for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) { + for (const f of listSessionFiles()) { try { - const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); + const s = loadSession(path.basename(f, '.json')); + if (!s) continue; if (s.claudeSessionId === sessionId) { existingSession = s; break; } } catch {} } @@ -15256,7 +15943,7 @@ function handleImportNativeSession(ws, msg) { } const id = existingSession ? existingSession.id : crypto.randomUUID(); - const session = { + const importedSession = { id, title, created: existingSession?.created || new Date().toISOString(), @@ -15273,7 +15960,10 @@ function handleImportNativeSession(ws, msg) { messages, cwd: cwd || existingSession?.cwd || null, }; - saveSession(session); + if (existingSession && isSessionRunning(id)) return wsSend(ws, { type: 'error', message: '会话运行中,请结束后重新导入。' }); + const session = prepareNativeHistoryImport(existingSession, importedSession); + if (!session) return wsSend(ws, { type: 'error', message: '原始记录与已有历史的边界尚未确认,未覆盖已保存历史。' }); + if (!saveSession(session)) return wsSend(ws, { type: 'error', message: '完整会话归档保存失败。' }); wsSessionMap.set(ws, id); const transportMessages = sanitizeMessagesForTransport(session.messages); const taskTracking = taskTrackingSnapshotForSession(session); @@ -15422,9 +16112,10 @@ function handleImportCodexSession(ws, msg) { let existingSession = null; try { - for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) { + for (const f of listSessionFiles()) { try { - const s = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'))); + const s = loadSession(path.basename(f, '.json')); + if (!s) continue; const runtimeThreadId = importAgent === 'codexapp' ? s.codexAppThreadId : s.codexThreadId; if (runtimeThreadId === threadId) { existingSession = s; break; } } catch {} @@ -15432,7 +16123,7 @@ function handleImportCodexSession(ws, msg) { } catch {} const id = existingSession ? existingSession.id : crypto.randomUUID(); - const session = { + const importedSession = { id, title: parsed.meta.title || existingSession?.title || threadId.slice(0, 20), created: existingSession?.created || new Date().toISOString(), @@ -15452,7 +16143,10 @@ function handleImportCodexSession(ws, msg) { cwd: parsed.meta.cwd || existingSession?.cwd || null, }; - saveSession(session); + if (existingSession && isSessionRunning(id)) return wsSend(ws, { type: 'error', message: '会话运行中,请结束后重新导入。' }); + const session = prepareNativeHistoryImport(existingSession, importedSession); + if (!session) return wsSend(ws, { type: 'error', message: '原始记录与已有历史的边界尚未确认,未覆盖已保存历史。' }); + if (!saveSession(session)) return wsSend(ws, { type: 'error', message: '完整会话归档保存失败。' }); wsSessionMap.set(ws, id); const transportMessages = sanitizeMessagesForTransport(session.messages); const taskTracking = taskTrackingSnapshotForSession(session);