diff --git a/.planning/codex-rollout-history-merge/findings.md b/.planning/codex-rollout-history-merge/findings.md new file mode 100644 index 0000000..40ec1cc --- /dev/null +++ b/.planning/codex-rollout-history-merge/findings.md @@ -0,0 +1,8 @@ +# 调研发现 + +- `server.js` 中 `resolveSessionHistory` 位于约 4489 行,session_info、session_history_chunk、resume_session_result 多处复用。 +- `lib/codex-rollouts.js` 是 native rollout 解析入口,需结合 server.js 的持久化写入路径分析。 +- `public/app.js` 已有 `renderMessages` 与 `reconcileRenderedSessionMessages`,前端存在重连/历史刷新竞态相关逻辑。 +- codebase-memory 项目 `home-cc-web` 索引状态为 ready(8208 nodes / 19300 edges)。 +- 工作区存在用户已有未提交改动:`lib/ccweb-mcp-server.js`、`scripts/ccweb-message-reply-unit.js`,本任务不触碰。 + diff --git a/.planning/codex-rollout-history-merge/progress.md b/.planning/codex-rollout-history-merge/progress.md new file mode 100644 index 0000000..16b8f86 --- /dev/null +++ b/.planning/codex-rollout-history-merge/progress.md @@ -0,0 +1,7 @@ +# 执行进度 + +- 2026-09-20:建立独立计划目录,确认现有未提交改动并完成 codebase-memory 索引检查。 +- 2026-09-20:服务端新增稳定消息 ID、快照基线元数据和保守 native 前缀合并;`session_info`、`resume_session_result`、`session_history_chunk` 统一读取 resolver。 +- 2026-09-20:rollout 解析按 `task_started` / `turn_context` / 完成事件聚合同一 turn,保留 function/custom tool,过滤跨对话内部 user 输入。 +- 2026-09-20:前端缓存、DOM、重连和实时消息按稳定键去重,普通用户消息把本地 ID 作为 `clientMessageId` 发给服务端。 +- 2026-09-20:通过 `node --check`、完整 `npm run regression`、`history-recall`、`session-switch-race`、既有单测和 `git diff --check`。 diff --git a/.planning/codex-rollout-history-merge/task_plan.md b/.planning/codex-rollout-history-merge/task_plan.md new file mode 100644 index 0000000..636140c --- /dev/null +++ b/.planning/codex-rollout-history-merge/task_plan.md @@ -0,0 +1,62 @@ +# Codex rollout 历史合并修复计划 + +## 目标 + +让 cc-web 持久化消息成为当前会话的权威顺序;Codex native rollout 仅补充更早历史,按 turn 聚合助手输出,并通过稳定消息标识完成历史合并与前端去重。 + +## 合并契约 + +- 持久化快照中去掉 `ccwebPersistenceNotice` 后的消息保持原有顺序,是当前会话的权威尾部;native 只能补充快照之前的消息。 +- 快照压缩时保存 `historySnapshotBaseIndex`(native 逻辑消息索引)与 `historySnapshotCount`,旧快照缺字段时按稳定 ID/最长公共后缀推断,无法确认边界时保留快照。 +- 合并顺序固定为:native 更早消息 → cc-web 持久化消息 → 当前运行中的消息;相同稳定 ID 只保留 cc-web 消息,ID 冲突且内容不一致时以持久化消息为准并记录告警。 +- native 缺失、解析异常、边界无法确认或合并结果不能证明快照尾部完整时,返回 cc-web 快照,不把未经确认的 native 消息插入当前视图。 +- `session_info`、`resume_session_result`、`session_history_chunk` 均调用同一解析结果;分页只对该结果切片。 + +## 稳定标识契约 + +- 用户消息:优先 `clientMessageId`,兼容旧字段 `id`;缺失时生成并持久化 `client:`。 +- Codex App 助手消息:使用 `codexAppTurnKey`,其输入为 session/thread/turn 的稳定字段;旧消息缺失时从对应运行状态或时间戳生成一次并保存。 +- native rollout 助手消息:使用 `nativeTurnKey`,由 threadId + turnId/turn_context 组成;同一 turn 跨刷新保持一致。 +- 跨对话回传:保留 `replyToRequestId`(兼容 `crossConversation.replyRequestId`),同时生成 `reply:` 作为稳定消息 ID;来源元数据始终保留。 +- 其他历史消息按 `message:::` 降级,避免数组下标去重;ID 冲突时以权威来源和较完整内容决胜。 + +## Turn 聚合契约 + +- 以 `turn_id`、`turn_context.turn_id`、`turn_context.id` 或 session/thread 上下文组成 turn key;缺失时使用相邻事件窗口的稳定 fallback key。 +- 同一 turn 内按 rollout 文件顺序合并文本片段、工具调用与工具结果;只有遇到下一条用户消息、turn 完成/失败事件或文件结束才 flush。 +- tool call/result 绑定同一调用 ID;空 turn 不产出气泡;跨对话内部 user 回传不作为普通 user 消息插入。 + +## 前端契约 + +- 服务端先输出规范化消息序列;前端渲染层按 `message.id`/`messageId`/`nativeTurnKey`/`codexAppTurnKey`/`replyToRequestId` 形成稳定键。 +- `renderMessages`、分页 prepend、`reconcileRenderedSessionMessages` 均按稳定键去重,索引仅用于定位/排序兼容;同键内容更新应替换原 DOM,不追加新气泡。 +- 实时消息与历史消息竞态时,以服务端规范化消息为准,保留当前用户消息与对应助手回复邻接关系;跨对话回传显示来源标识,角色保持 assistant。 + +## 可验收测试矩阵 + +- 历史合并:无 native、无快照、完全重叠、native 仅更早、快照尾部用户消息、native 异常/边界不明回退快照;断言顺序、长度、稳定 ID。 +- rollout 聚合:同一 turn 多个 message/tool/result 仅一条 assistant;下一用户消息 flush;turn 完成/失败 flush;内部回传不生成 user 气泡。 +- 稳定 ID:重复加载/刷新/分页 ID 不变;clientMessageId、codexAppTurnKey、replyToRequestId 去重;冲突以持久化消息为准。 +- 前端回归:session_info/resume/history chunk 重复到达不追加;实时流与历史同时到达不拆泡;来源标识和当前生成消息同时可见。 +- 浏览器实测:重载运行会话时用户原问题、架构回复、子对话来源标识、当前生成消息均按逻辑顺序可见。 + +## 步骤 + +- [complete] 盘点服务端、rollout、持久化与前端消息链路 +- [complete] 设计并实现稳定消息标识与 native 历史合并 +- [complete] 聚合 Codex rollout 的同一轮助手输出 +- [complete] 调整前端规范化历史渲染与稳定 ID 去重 +- [complete] 增加历史合并和 rollout 聚合回归测试 +- [complete] 运行静态检查、单测和浏览器/集成验证并收尾 + +## 约束与决策 + +- 保留工作区已有未提交改动,不覆盖 `lib/ccweb-mcp-server.js` 与 `scripts/ccweb-message-reply-unit.js`。 +- 不把未确认的 native 消息插入当前 cc-web 快照;合并失败时返回快照。 +- 回传消息保留来源标识,不能伪装成普通用户气泡。 +- 所有可观测失败记录简短告警,不阻断当前会话展示。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---|---| 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 6cd949d..eba8bfd 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/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 467ad49..c1ba736 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -16,7 +16,7 @@ const CCWEB_REPLY_MODES = Object.freeze({ ONE_WAY: 'one_way', RETURN_AND_CONTINUE: 'return_and_continue', }); -const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode:仅当来源不需要目标结果时使用 one_way;涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时,必须使用 return_and_continue。工具调用会立即返回,不阻塞也不等待目标对话完成。'; +const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode:仅当来源不需要目标结果时使用 one_way;涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时必须使用 return_and_continue。两种模式的工具调用都会立即返回,不阻塞也不等待目标对话完成;return_and_continue 只表示目标完成后由系统异步回传并触发来源继续运行,不表示当前调用需要等待。调用后不要使用 sleep、不要轮询等待,也不要反复查询 pending reply;继续当前工作或结束本轮即可。'; const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([ 'ccweb_list_conversations', 'ccweb_list_user_inputs', @@ -175,7 +175,7 @@ const TOOLS = [ replyMode: { type: 'string', enum: Object.values(CCWEB_REPLY_MODES), - description: '回传模式。one_way 表示单向投递且目标输出只留在目标对话;return_and_continue 表示目标完成后自动回传结果并触发来源继续运行。', + description: '回传模式。one_way 表示单向投递且目标输出只留在目标对话;return_and_continue 表示目标完成后由系统异步回传结果并触发来源继续运行。两种模式都立即返回,不要 sleep 或轮询等待。', }, }, required: ['targetConversationId', 'content', 'replyMode'], diff --git a/lib/codex-rollouts.js b/lib/codex-rollouts.js index d96f27d..00ad285 100644 --- a/lib/codex-rollouts.js +++ b/lib/codex-rollouts.js @@ -18,6 +18,20 @@ function createCodexRolloutStore(deps) { turn.content = turn.content ? `${turn.content}\n\n${text}` : text; } + function stableHash(value) { + return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 24); + } + + function extractTurnKey(entry, payload, fallback) { + const context = payload?.turn_context || entry?.turn_context || {}; + const value = entry?.turn_id + || payload?.turn_id + || context?.turn_id + || context?.id + || null; + return value ? String(value) : fallback; + } + function extractCcwebSourceConversation(text) { const match = String(text || '').match(/^来自「([^」]+)」对话(ID:\s*([0-9a-fA-F-]{36}))的消息:/); if (!match) return null; @@ -39,6 +53,8 @@ function createCodexRolloutStore(deps) { }; const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }; let currentAssistant = null; + let currentTurnKey = null; + let implicitTurnSequence = 0; let sawRealUserMessage = false; const fallbackUserMessages = []; @@ -50,9 +66,21 @@ function createCodexRolloutStore(deps) { meta.sourceConversationTitle = sourceConversation.title; } - function ensureAssistant(ts) { + function ensureAssistant(ts, turnKey = null) { + // 没有 turn_id 的旧 rollout 仍需在每次新助手输出时生成新的隐式轮次键。 + // 否则不同轮次会共用同一个 nativeTurnKey,前端按稳定键去重时会误删后续回复。 + const resolvedTurnKey = turnKey || currentTurnKey || `${meta.threadId || 'native'}:implicit:${implicitTurnSequence += 1}`; + if (currentAssistant && currentTurnKey !== resolvedTurnKey) flushAssistant(); + currentTurnKey = resolvedTurnKey; if (!currentAssistant) { - currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null }; + currentAssistant = { + role: 'assistant', + content: '', + toolCalls: [], + timestamp: ts || null, + nativeTurnKey: `native:${meta.threadId || 'unknown-thread'}:${currentTurnKey}`, + id: `native:${stableHash(`${meta.threadId || ''}:${currentTurnKey}`)}`, + }; } else if (!currentAssistant.timestamp && ts) { currentAssistant.timestamp = ts; } @@ -65,6 +93,7 @@ function createCodexRolloutStore(deps) { messages.push(currentAssistant); } currentAssistant = null; + currentTurnKey = null; pendingToolCalls.clear(); } @@ -84,6 +113,24 @@ function createCodexRolloutStore(deps) { continue; } + if (entry.type === 'event_msg' && ['task_started', 'turn_started'].includes(entry.payload?.type)) { + flushAssistant(); + currentTurnKey = extractTurnKey(entry, entry.payload, `${meta.threadId || 'native'}:turn:${implicitTurnSequence += 1}`); + continue; + } + + if (entry.type === 'turn_context') { + const turnKey = extractTurnKey(entry, entry.payload, currentTurnKey || `${meta.threadId || 'native'}:turn:${implicitTurnSequence += 1}`); + if (currentAssistant && currentTurnKey !== turnKey) flushAssistant(); + currentTurnKey = turnKey; + continue; + } + + if (entry.type === 'event_msg' && ['task_complete', 'turn_completed', 'turn_failed', 'turn_aborted'].includes(entry.payload?.type)) { + flushAssistant(); + continue; + } + if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') { const total = entry.payload?.info?.total_token_usage || null; const usage = entry.payload?.info?.last_token_usage || null; @@ -104,9 +151,18 @@ function createCodexRolloutStore(deps) { if (text) { sawRealUserMessage = true; flushAssistant(); + const sourceConversation = extractCcwebSourceConversation(text); rememberSourceConversation(text); + // cc-web 跨对话投递在 native rollout 中会留下内部 user 输入。 + // 目标会话已经保存了带来源的消息,这里不再伪造普通 user 气泡。 + if (sourceConversation) continue; if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' '); - messages.push({ role: 'user', content: text, timestamp: ts }); + messages.push({ + role: 'user', + content: text, + timestamp: ts, + id: `native-user:${stableHash(`${meta.threadId || ''}:${ts || ''}:${text}`)}`, + }); } continue; } @@ -119,22 +175,26 @@ function createCodexRolloutStore(deps) { if (payload.role === 'assistant') { const text = extractCodexMessageText(payload.content); if (text.trim()) { - if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) { - flushAssistant(); - } - appendAssistantContent(ensureAssistant(ts), text); + appendAssistantContent(ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey)), text); } } else if (payload.role === 'user' && !sawRealUserMessage) { const text = extractCodexMessageText(payload.content); if (text.trim()) { rememberSourceConversation(text); - fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts }); + if (!extractCcwebSourceConversation(text)) { + fallbackUserMessages.push({ + role: 'user', + content: text, + timestamp: ts, + id: `native-user:${stableHash(`${meta.threadId || ''}:${ts || ''}:${text}`)}`, + }); + } } } break; } case 'function_call': { - const assistant = ensureAssistant(ts); + const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey)); const toolUseId = payload.call_id || payload.id || crypto.randomUUID(); const tc = { name: payload.name || 'FunctionCall', @@ -147,7 +207,7 @@ function createCodexRolloutStore(deps) { break; } case 'function_call_output': { - const assistant = ensureAssistant(ts); + const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey)); const toolUseId = payload.call_id || crypto.randomUUID(); let tc = pendingToolCalls.get(toolUseId); if (!tc) { @@ -161,6 +221,34 @@ function createCodexRolloutStore(deps) { : JSON.stringify(payload.output || '')).slice(0, 2000); break; } + case 'custom_tool_call': { + const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey)); + const toolUseId = payload.call_id || payload.id || crypto.randomUUID(); + const tc = { + name: payload.name || 'CustomToolCall', + id: toolUseId, + input: sanitizeToolInput(payload.name || 'CustomToolCall', payload.input || payload.arguments || ''), + done: payload.status === 'completed', + }; + assistant.toolCalls.push(tc); + pendingToolCalls.set(toolUseId, tc); + break; + } + case 'custom_tool_call_output': { + const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey)); + const toolUseId = payload.call_id || crypto.randomUUID(); + let tc = pendingToolCalls.get(toolUseId); + if (!tc) { + tc = { name: 'CustomToolCall', id: toolUseId, input: null, done: false }; + assistant.toolCalls.push(tc); + pendingToolCalls.set(toolUseId, tc); + } + tc.done = true; + tc.result = (typeof payload.output === 'string' + ? payload.output + : JSON.stringify(payload.output || '')).slice(0, 2000); + break; + } default: break; } diff --git a/public/app.js b/public/app.js index 503125a..e97702f 100644 --- a/public/app.js +++ b/public/app.js @@ -1795,6 +1795,52 @@ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } + function stableMessageHash(value) { + let hash = 2166136261; + for (const char of String(value || '')) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); + } + + function getStableMessageKey(message) { + if (!message || typeof message !== 'object') return ''; + const explicit = message.id + || message.messageId + || message.clientMessageId + || message.replyToRequestId + || message.crossConversation?.replyToRequestId + || message.codexAppTurnKey + || message.nativeTurnKey; + if (explicit) return String(explicit); + const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || ''); + return `message:${stableMessageHash(`${message.role || ''}\u001f${message.timestamp || ''}\u001f${content}`)}`; + } + + function findRenderedSessionMessageByKey(key) { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) return null; + return Array.from(messagesDiv.querySelectorAll('[data-session-message="true"][data-message-key]')) + .find((element) => element.dataset.messageKey === normalizedKey) || null; + } + + function dedupeSessionMessages(messages) { + if (!Array.isArray(messages)) return []; + const result = []; + const positions = new Map(); + messages.forEach((message) => { + const key = getStableMessageKey(message); + if (key && positions.has(key)) { + result[positions.get(key)] = message; + } else { + if (key) positions.set(key, result.length); + result.push(message); + } + }); + return result; + } + function rememberPendingSlashDraft(requestId, text, sessionId, agent) { const id = String(requestId || '').trim(); if (!id) return; @@ -2442,10 +2488,16 @@ getMessageActionRow(bubble)?.appendChild(button); } - function markSessionMessageElement(messageEl, messageIndex) { + function markSessionMessageElement(messageEl, messageIndex, message = null) { if (!messageEl || !Number.isFinite(messageIndex) || messageIndex < 0) return; messageEl.dataset.sessionMessage = 'true'; messageEl.dataset.messageIndex = String(messageIndex); + if (message) { + const key = getStableMessageKey(message); + if (key) messageEl.dataset.messageKey = key; + const rawContent = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || ''); + messageEl.dataset.messageFingerprint = stableMessageHash(`${message.role || ''}\u001f${rawContent}`); + } if (messageEl.classList.contains('assistant')) { syncAssistantBranchButton(messageEl, messageIndex); } @@ -7000,9 +7052,27 @@ ? Number(snapshot.historyBaseIndex) : Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length); const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; + const stableKey = (message) => typeof getStableMessageKey === 'function' + ? getStableMessageKey(message) + : String(message?.id || message?.messageId || '').trim(); const indexed = new Map(); - existingMessages.forEach((message, index) => indexed.set(existingBase + index, message)); - messages.forEach((message, index) => indexed.set(incomingBase + index, message)); + const keyed = new Map(); + existingMessages.forEach((message, index) => { + const position = existingBase + index; + indexed.set(position, message); + const key = stableKey(message); + if (key) keyed.set(key, position); + }); + messages.forEach((message, index) => { + const key = stableKey(message); + if (key && keyed.has(key)) { + indexed.set(keyed.get(key), message); + return; + } + const position = incomingBase + index; + indexed.set(position, message); + if (key) keyed.set(key, position); + }); const indexes = Array.from(indexed.keys()).sort((a, b) => a - b); snapshot.messages = indexes.map((index) => indexed.get(index)); snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase; @@ -7029,9 +7099,27 @@ ? Number(snapshot.historyBaseIndex) : Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length); const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; + const stableKey = (message) => typeof getStableMessageKey === 'function' + ? getStableMessageKey(message) + : String(message?.id || message?.messageId || '').trim(); const indexed = new Map(); - existingMessages.forEach((message, index) => indexed.set(existingBase + index, message)); - messages.forEach((message, index) => indexed.set(incomingBase + index, message)); + const keyed = new Map(); + existingMessages.forEach((message, index) => { + const position = existingBase + index; + indexed.set(position, message); + const key = stableKey(message); + if (key) keyed.set(key, position); + }); + messages.forEach((message, index) => { + const key = stableKey(message); + if (key && keyed.has(key)) { + indexed.set(keyed.get(key), message); + return; + } + const position = incomingBase + index; + indexed.set(position, message); + if (key) keyed.set(key, position); + }); const indexes = Array.from(indexed.keys()).sort((a, b) => a - b); snapshot.messages = indexes.map((index) => indexed.get(index)); snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase; @@ -8256,9 +8344,14 @@ if (msg.sessionId && msg.message) { const isCrossConversationReply = !!msg.message.crossConversation?.replyToRequestId; const isProvisionalCodexAppSteer = !!msg.message.codexAppSteerStatus; + const messageKey = getStableMessageKey(msg.message); updateCachedSession(msg.sessionId, (snapshot) => { snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; - if (!isProvisionalCodexAppSteer) snapshot.messages.push(deepClone(msg.message)); + if (!isProvisionalCodexAppSteer) { + const existingIndex = snapshot.messages.findIndex((item) => getStableMessageKey(item) === messageKey); + if (existingIndex >= 0) snapshot.messages[existingIndex] = deepClone(msg.message); + else snapshot.messages.push(deepClone(msg.message)); + } snapshot.updated = msg.message.timestamp || new Date().toISOString(); if (isCrossConversationReply) { snapshot.readyReplyCount = Math.max(0, Number(snapshot.readyReplyCount || 0) - 1); @@ -8282,6 +8375,25 @@ } if (msg.sessionId === currentSessionId && msg.message) { const isProvisionalCodexAppSteer = !!msg.message.codexAppSteerStatus; + const existingElement = !isProvisionalCodexAppSteer + ? findRenderedSessionMessageByKey(getStableMessageKey(msg.message)) + : null; + if (existingElement) { + const rawContent = typeof msg.message.content === 'string' + ? msg.message.content + : JSON.stringify(msg.message.content || ''); + const fingerprint = stableMessageHash(`${msg.message.role || ''}\u001f${rawContent}`); + if (existingElement.dataset.messageFingerprint !== fingerprint) { + const messageIndex = Number(existingElement.dataset.messageIndex); + const replacement = buildMsgElement( + msg.message, + Number.isFinite(messageIndex) ? messageIndex : null, + ); + existingElement.replaceWith(replacement); + } + renderSessionList(); + break; + } const messageIndex = isProvisionalCodexAppSteer ? null : currentSessionMessageCount; if (!isProvisionalCodexAppSteer) currentSessionMessageCount += 1; collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id)); @@ -8542,7 +8654,7 @@ const historyTotal = Number.isFinite(Number(msg.historyTotal)) ? Math.max(0, Number(msg.historyTotal)) : msg.messages.length; - if (renderedMessages < historyTotal) { + if (renderedMessages < historyTotal || msg.messages.some((message) => !findRenderedSessionMessageByKey(getStableMessageKey(message)))) { reconcileRenderedSessionMessages(msg.messages, { preserveScroll: false, baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) @@ -9178,13 +9290,19 @@ || (hasGiteaPrompt ? { source: 'gitea', label: 'Gitea', repository: '', resourceKind: 'issue', number: null, title: '' } : null); const isGiteaMessage = role === 'user' && !!giteaSource; const visibleContent = isGiteaMessage ? getGiteaVisibleMessageContent(content) : content; - const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user'); + const resolvedMessageId = meta?.messageId || meta?.id || getStableMessageKey(meta) || createLocalId('message'); div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}${isGiteaMessage ? ' gitea-message' : ''}`; if (role === 'user') { div.id = `hapi-message-${resolvedMessageId}`; div.dataset.messageId = resolvedMessageId; if (isGoalMessage) div.dataset.goalMode = 'true'; } + if (role !== 'system') { + div.dataset.messageId = resolvedMessageId; + div.dataset.messageKey = getStableMessageKey(meta) || resolvedMessageId; + const rawContent = typeof content === 'string' ? content : JSON.stringify(content || ''); + div.dataset.messageFingerprint = stableMessageHash(`${role}\u001f${rawContent}`); + } if (role === 'system') { const tone = String(meta.tone || 'neutral').trim() || 'neutral'; @@ -10788,12 +10906,13 @@ } } if (Number.isFinite(messageIndex)) { - markSessionMessageElement(el, messageIndex); + markSessionMessageElement(el, messageIndex, m); } return el; } function renderMessages(messages, options = {}) { + messages = typeof dedupeSessionMessages === 'function' ? dedupeSessionMessages(messages) : messages; renderEpoch++; const epoch = renderEpoch; const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; @@ -10869,11 +10988,16 @@ const existingIndexes = new Set(Array.from( messagesDiv.querySelectorAll('[data-session-message="true"][data-message-index]'), ).map((element) => Number(element.dataset.messageIndex))); + const existingKeys = new Set(Array.from( + messagesDiv.querySelectorAll('[data-session-message="true"][data-message-key]'), + ).map((element) => String(element.dataset.messageKey || '').trim()).filter(Boolean)); const uniqueMessages = []; messages.forEach((message, index) => { const messageIndex = baseIndex + index; - if (existingIndexes.has(messageIndex)) return; + const key = getStableMessageKey(message); + if (existingIndexes.has(messageIndex) || (key && existingKeys.has(key))) return; existingIndexes.add(messageIndex); + if (key) existingKeys.add(key); uniqueMessages.push({ message, index }); }); if (uniqueMessages.length === 0) return; @@ -11586,9 +11710,12 @@ if (!Array.isArray(messages) || messages.length === 0) return; const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; const existing = new Map(); + const existingByKey = new Map(); messagesDiv.querySelectorAll('[data-session-message="true"][data-message-index]').forEach((element) => { const index = Number(element.dataset.messageIndex); if (Number.isFinite(index)) existing.set(index, element); + const key = String(element.dataset.messageKey || '').trim(); + if (key) existingByKey.set(key, element); }); const beforeHeight = messagesDiv.scrollHeight; const beforeScrollTop = messagesDiv.scrollTop; @@ -11596,6 +11723,19 @@ let inserted = 0; messages.forEach((message, offset) => { const messageIndex = baseIndex + offset; + const messageKey = getStableMessageKey(message); + const keyed = existingByKey.get(messageKey); + if (keyed) { + const rawContent = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || ''); + const fingerprint = stableMessageHash(`${message.role || ''}\u001f${rawContent}`); + if (keyed.dataset.messageFingerprint !== fingerprint) { + const replacement = buildMsgElement(message, messageIndex); + keyed.replaceWith(replacement); + existingByKey.set(messageKey, replacement); + existing.set(messageIndex, replacement); + } + return; + } if (existing.has(messageIndex)) return; const element = buildMsgElement(message, messageIndex); const next = Array.from(existing.entries()) @@ -11603,6 +11743,7 @@ .sort(([left], [right]) => left - right)[0]?.[1]; messagesDiv.insertBefore(element, next || stream || null); existing.set(messageIndex, element); + if (messageKey) existingByKey.set(messageKey, element); inserted += 1; }); if (inserted === 0) return; @@ -12461,13 +12602,21 @@ if (currentSessionId) { const messageIndex = currentSessionMessageCount; currentSessionMessageCount += 1; - markSessionMessageElement(element, messageIndex); + markSessionMessageElement(element, messageIndex, { id: messageId, role: 'user', content: text, timestamp }); } registerUserMessage(messageId, element, text, timestamp); updateUserOutlinePanel(); scrollToBottom(); - send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); + send({ + type: 'message', + text, + attachments, + sessionId: currentSessionId, + mode: currentMode, + agent: currentAgent, + clientMessageId: messageId, + }); startGenerating(); } diff --git a/scripts/ccweb-message-reply-unit.js b/scripts/ccweb-message-reply-unit.js index dda19bb..8504ae7 100644 --- a/scripts/ccweb-message-reply-unit.js +++ b/scripts/ccweb-message-reply-unit.js @@ -76,10 +76,17 @@ check('ccweb_send_message schema requires replyMode with the exact enum', () => check('ccweb_send_message description states strong selection and non-blocking rules', () => { const description = String(publicSendTool.description || ''); + const replyModeDescription = String(publicSendTool.inputSchema?.properties?.replyMode?.description || ''); assert.match(description, /one_way/, 'description must mention one_way'); assert.match(description, /return_and_continue/, 'description must mention return_and_continue'); assert.match(description, /立即返回/, 'description must say calls return immediately'); assert.match(description, /(不阻塞|不会阻塞|不等待)/, 'description must state the call does not block/wait'); + assert.match(description, /两种模式/, 'description must say both modes return immediately'); + assert.match(description, /不是等待|不表示当前调用需要等待/, 'description must distinguish async return from waiting'); + assert.match(description, /不要使用 sleep/, 'description must forbid sleep polling'); + assert.match(description, /不要轮询等待/, 'description must forbid polling for the reply'); + assert.match(replyModeDescription, /异步回传/, 'replyMode schema must explain asynchronous return delivery'); + assert.match(replyModeDescription, /不要 sleep 或轮询等待/, 'replyMode schema must forbid sleep polling'); assert.match( description, /(仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果[\s\S]*one_way|one_way[\s\S]*仅当[\s\S]*来源[\s\S]*不需要[\s\S]*结果)/, diff --git a/scripts/regression.js b/scripts/regression.js index f2c4f78..f5184ed 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -4580,6 +4580,14 @@ function assertHistoryRecallContract() { /const existingIndexes = new Set\([\s\S]*?data-message-index/.test(frontendSource), 'Frontend should deduplicate history messages by stable message index' ); + assert(frontendSource.includes('function getStableMessageKey'), 'Frontend should use a stable message key for history reconciliation'); + assert(frontendSource.includes('data-message-key'), 'Message DOM nodes should retain stable message keys'); + assert( + (frontendSource.match(/indexed\.set\(keyed\.get\(key\), message\)/g) || []).length >= 2, + 'History snapshot merges should update an existing stable ID in place' + ); + assert(serverSource.includes('function mergeNativeHistoryWithSnapshot'), 'Server should merge native history into the snapshot prefix'); + assert(serverSource.includes('historySnapshotBaseIndex'), 'Persisted snapshots should record their logical history base index'); assert(frontendSource.includes('currentOutlineHistoryState'), 'User-message locator should keep an independent full-history index state'); assert(frontendSource.includes('function requestOutlineHistoryPage'), @@ -4679,12 +4687,82 @@ function assertHistoryRecallContract() { }); const parsed = store.parseCodexRolloutLines([ JSON.stringify({ type: 'session_meta', payload: { id: 'history-thread', cwd: '/tmp' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:00:00.000Z', payload: { type: 'task_started', turn_id: 'turn-history' } }), JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:00:00.000Z', payload: { type: 'user_message', message: '旧问题' } }), - JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:00:01.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '旧回答' }] } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:00:01.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '旧回答第一段' }] } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:00:02.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '旧回答第二段' }] } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:00:03.000Z', payload: { type: 'custom_tool_call', call_id: 'custom-1', name: 'ccweb_send_message', input: '{}' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:00:04.000Z', payload: { type: 'custom_tool_call_output', call_id: 'custom-1', output: 'done' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:00:05.000Z', payload: { type: 'task_complete', turn_id: 'turn-history' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:00:06.000Z', payload: { type: 'task_started', turn_id: 'turn-cross' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:00:07.000Z', payload: { type: 'user_message', message: '来自「源会话」对话(ID: 00000000-0000-0000-0000-000000000001)的消息:内部输入' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:00:08.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '跨对话回复' }] } }), ]); - assert(parsed.messages.length === 2, 'Codex rollout parser should recover user and assistant history'); - assert(parsed.messages[0]?.content === '旧问题' && parsed.messages[1]?.content === '旧回答', + assert(parsed.messages.length === 3, 'Codex rollout parser should recover user and assistant history'); + assert(parsed.messages[0]?.content === '旧问题' && parsed.messages[1]?.content === '旧回答第一段\n\n旧回答第二段', 'Recovered native history should preserve message order and content'); + assert(parsed.messages[1]?.nativeTurnKey === 'native:history-thread:turn-history', 'Aggregated native assistant should retain its turn key'); + assert(parsed.messages[1]?.toolCalls?.length === 1, 'Aggregated native assistant should retain custom tool calls'); + assert(!parsed.messages.some((message) => message.role === 'user' && message.content.includes('内部输入')), + 'Cross-conversation native input must not become a regular user bubble'); + + const implicitTurns = store.parseCodexRolloutLines([ + JSON.stringify({ type: 'session_meta', payload: { id: 'implicit-thread' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:01:00.000Z', payload: { type: 'user_message', message: '第一轮问题' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:01:01.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '第一轮回答' }] } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-09-11T00:02:00.000Z', payload: { type: 'user_message', message: '第二轮问题' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-09-11T00:02:01.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '第二轮回答' }] } }), + ]); + const implicitAssistants = implicitTurns.messages.filter((message) => message.role === 'assistant'); + assert(implicitAssistants.length === 2 && implicitAssistants[0].nativeTurnKey !== implicitAssistants[1].nativeTurnKey, + 'Rollout parser should assign distinct stable keys to adjacent unmarked turns'); + + assert( + /existingElement\.dataset\.messageFingerprint[\s\S]*?existingElement\.replaceWith\(replacement\)/.test(frontendSource), + 'Realtime session messages with an existing stable key should replace changed content instead of being dropped' + ); + + const historyMergeStart = serverSource.indexOf('function stableMessageHash'); + const historyMergeEnd = serverSource.indexOf('function sessionHistoryCacheKey', historyMergeStart); + assert(historyMergeStart >= 0 && historyMergeEnd > historyMergeStart, + 'Server should keep an extractable stable-ID history merge implementation'); + const historyMergeApi = new Function('crypto', `${serverSource.slice(historyMergeStart, historyMergeEnd)}; return { mergeNativeHistoryWithSnapshot };`)(require('crypto')); + const snapshotTail = [ + { id: 'client-user-1', role: 'user', content: '没参考其他的页面么,WMSA51 是你这么写的么?' }, + { id: 'codex-turn-1', role: 'assistant', content: '先按项目规范核对现有页面、任务票和负责会话。' }, + ]; + const merged = historyMergeApi.mergeNativeHistoryWithSnapshot( + { historySnapshotBaseIndex: 1 }, + snapshotTail, + [ + { id: 'native-old', role: 'assistant', content: '更早的 native 历史' }, + ...snapshotTail, + ], + ); + assert(merged.confirmed === true, 'History merge should confirm an overlapping native prefix'); + assert(JSON.stringify(merged.messages.map((message) => message.id)) + === JSON.stringify(['native-old', 'client-user-1', 'codex-turn-1']), + 'History merge should keep the persisted user and assistant tail adjacent'); + const duplicatedNative = historyMergeApi.mergeNativeHistoryWithSnapshot( + { historySnapshotBaseIndex: 1 }, + snapshotTail, + [ + { id: 'native-old', role: 'assistant', content: '更早的 native 历史' }, + snapshotTail[0], + { ...snapshotTail[0], content: 'native 重复用户消息' }, + snapshotTail[1], + ], + ); + assert(JSON.stringify(duplicatedNative.messages.map((message) => message.id)) + === JSON.stringify(['native-old', 'client-user-1', 'codex-turn-1']), + 'History merge should remove duplicate native IDs when the snapshot is authoritative'); + const unconfirmed = historyMergeApi.mergeNativeHistoryWithSnapshot( + { historySnapshotBaseIndex: 0 }, + snapshotTail, + [{ id: 'native-unrelated', role: 'assistant', content: '无法确认的 native 消息' }], + ); + assert(unconfirmed.confirmed === false && unconfirmed.messages.length === snapshotTail.length, + 'Unconfirmed native history must fall back to the cc-web snapshot'); } function assertSessionSwitchRaceContract() { diff --git a/server.js b/server.js index 54ee616..d508bda 100644 --- a/server.js +++ b/server.js @@ -4423,6 +4423,108 @@ function isPersistenceNoticeMessage(message) { return !!(message && typeof message === 'object' && message.ccwebPersistenceNotice === true); } +function stableMessageHash(value) { + return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 24); +} + +function messageReplyToRequestId(message) { + return String( + message?.replyToRequestId + || message?.crossConversation?.replyToRequestId + || message?.crossConversation?.replyRequestId + || '', + ).trim(); +} + +function ensureStableMessageId(message) { + if (!message || typeof message !== 'object' || Array.isArray(message)) return message; + if (String(message.id || '').trim()) return message; + const explicitId = String(message.clientMessageId || message.messageId || '').trim(); + const replyToRequestId = messageReplyToRequestId(message); + const stableId = explicitId + || (replyToRequestId ? `reply:${replyToRequestId}` : '') + || (message.codexAppTurnKey ? `codexapp:${message.codexAppTurnKey}` : '') + || (message.nativeTurnKey ? `native:${message.nativeTurnKey}` : ''); + if (stableId) return { ...message, id: stableId }; + const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || ''); + const identity = [message.role || '', message.timestamp || message.createdAt || '', content].join('\u001f'); + return { ...message, id: `message:${stableMessageHash(identity)}` }; +} + +function normalizeHistoryMessage(message) { + return ensureStableMessageId(message); +} + +function messagesEquivalent(left, right) { + if (!left || !right || left.role !== right.role) return false; + const leftId = String(left.id || '').trim(); + const rightId = String(right.id || '').trim(); + if (leftId && rightId && leftId === rightId) return true; + if (!leftId.startsWith('message:') || !rightId.startsWith('message:')) return false; + const leftContent = typeof left.content === 'string' ? left.content.trim() : JSON.stringify(left.content || ''); + const rightContent = typeof right.content === 'string' ? right.content.trim() : JSON.stringify(right.content || ''); + return !!leftContent + && leftContent === rightContent + && String(left.timestamp || '') === String(right.timestamp || ''); +} + +function mergeHistorySegments(nativePrefix, snapshot) { + const prefix = Array.isArray(nativePrefix) ? nativePrefix : []; + const tail = Array.isArray(snapshot) ? snapshot : []; + const snapshotIds = new Set(tail.map((message) => String(message?.id || '').trim()).filter(Boolean)); + const merged = []; + const prefixIds = new Set(); + for (const message of prefix) { + const id = String(message?.id || '').trim(); + if (id && snapshotIds.has(id)) continue; + if (id && prefixIds.has(id)) continue; + if (id) prefixIds.add(id); + merged.push(message); + } + const snapshotPositions = new Map(); + for (const message of tail) { + const id = String(message?.id || '').trim(); + if (id && snapshotPositions.has(id)) { + merged[snapshotPositions.get(id)] = message; + continue; + } + if (id) snapshotPositions.set(id, merged.length); + merged.push(message); + } + return merged; +} + +function mergeNativeHistoryWithSnapshot(session, persistedMessages, nativeMessages) { + const snapshot = (Array.isArray(persistedMessages) ? persistedMessages : []).map(normalizeHistoryMessage); + const native = (Array.isArray(nativeMessages) ? nativeMessages : []).map(normalizeHistoryMessage); + if (snapshot.length === 0) return native.length > 0 ? { messages: mergeHistorySegments(native, []), confirmed: true } : { messages: snapshot, confirmed: true }; + if (native.length === 0) return { messages: snapshot, confirmed: false }; + + // 从尾部寻找快照与 native 的最长有序重叠,允许 native 中存在旧版本额外事件。 + let nativeCursor = native.length - 1; + let snapshotCursor = snapshot.length - 1; + while (nativeCursor >= 0 && snapshotCursor >= 0) { + if (messagesEquivalent(native[nativeCursor], snapshot[snapshotCursor])) { + nativeCursor -= 1; + snapshotCursor -= 1; + continue; + } + nativeCursor -= 1; + } + if (snapshotCursor < 0) { + const overlapStart = nativeCursor + 1; + return { messages: mergeHistorySegments(native.slice(0, overlapStart), snapshot), confirmed: true }; + } + + const baseIndex = Number(session?.historySnapshotBaseIndex); + const hasSnapshotBoundary = Number.isSafeInteger(baseIndex) && baseIndex > 0; + if (hasSnapshotBoundary && native.length >= baseIndex) { + const prefix = native.slice(0, Math.min(baseIndex, native.length)); + return { messages: mergeHistorySegments(prefix, snapshot), confirmed: true }; + } + return { messages: snapshot, confirmed: false }; +} + function sessionHistoryCacheKey(session) { if (!session) return ''; if (isCodexLikeSession(session)) return `${getSessionAgent(session)}:${session.codexAppThreadId || session.codexThreadId || ''}`; @@ -4491,14 +4593,32 @@ function resolveSessionHistory(session) { const persistedMessages = persisted.filter((message) => !isPersistenceNoticeMessage(message)); const notice = persisted.some(isPersistenceNoticeMessage); if (!notice) { - return { messages: persisted, source: 'snapshot', available: true, recoverable: false }; + return { + messages: persistedMessages.map(normalizeHistoryMessage), + source: 'snapshot', + available: true, + recoverable: false, + }; } const native = loadNativeSessionHistory(session); - if (native?.messages?.length > persistedMessages.length) { - return { messages: native.messages, source: 'native', available: true, recoverable: true }; + const merged = mergeNativeHistoryWithSnapshot(session, persistedMessages, native?.messages); + if (merged.confirmed && merged.messages.length >= persistedMessages.length) { + return { + messages: merged.messages, + source: merged.messages.length > persistedMessages.length ? 'merged' : 'snapshot', + available: true, + recoverable: merged.messages.length > persistedMessages.length, + }; } - return { messages: persisted, source: 'snapshot', available: false, recoverable: false }; + if (native && !merged.confirmed) { + plog('WARN', 'session_history_merge_unconfirmed', { + sessionId: String(session?.id || '').slice(0, 8), + persistedCount: persistedMessages.length, + nativeCount: Array.isArray(native.messages) ? native.messages.length : 0, + }); + } + return { messages: persistedMessages.map(normalizeHistoryMessage), source: 'snapshot', available: false, recoverable: false }; } function normalizeAgent(agent) { @@ -4593,10 +4713,11 @@ function normalizeSession(session) { if (Array.isArray(session.messages)) { session.messages = session.messages.map((message) => { if (!message || typeof message !== 'object') return message; + const normalizedMessage = ensureStableMessageId(message); if (message.attachments) { - return { ...message, attachments: normalizeMessageAttachments(message.attachments) }; + return { ...normalizedMessage, attachments: normalizeMessageAttachments(message.attachments) }; } - return message; + return normalizedMessage; }); } return session; @@ -5409,7 +5530,7 @@ function sanitizeMessagesForPersist(messages, limits = {}) { const list = Array.isArray(messages) ? messages : []; const maxMessages = limits.maxMessages || SESSION_PERSIST_MAX_MESSAGES; const selected = list.length > maxMessages ? list.slice(-maxMessages) : list; - const output = selected.map((message) => sanitizeMessageForPersist(message, limits)); + const output = selected.map((message) => sanitizeMessageForPersist(ensureStableMessageId(message), limits)); if (list.length > selected.length) { output.unshift({ role: 'system', @@ -5451,6 +5572,26 @@ function sanitizeSessionForPersist(session, limits = {}) { if (skipKeys.has(key)) continue; if (key === 'messages') { output.messages = sanitizeMessagesForPersist(value, limits); + const sourceMessages = Array.isArray(value) ? value : []; + const persistedMessages = sourceMessages.filter((message) => !isPersistenceNoticeMessage(message)); + const existingBaseIndex = Number(session?.historySnapshotBaseIndex); + const existingCount = Number(session?.historySnapshotCount); + if (sourceMessages.length > (limits.maxMessages || SESSION_PERSIST_MAX_MESSAGES)) { + const maxPersistedMessages = limits.maxMessages || SESSION_PERSIST_MAX_MESSAGES; + const logicalDroppedCount = Math.max(0, persistedMessages.length - maxPersistedMessages); + output.historySnapshotBaseIndex = Number.isSafeInteger(existingBaseIndex) && existingBaseIndex > 0 + ? existingBaseIndex + logicalDroppedCount + : logicalDroppedCount; + output.historySnapshotCount = Math.max(0, output.messages.length - (output.messages.some(isPersistenceNoticeMessage) ? 1 : 0)); + } else if (Number.isSafeInteger(existingBaseIndex) && existingBaseIndex > 0) { + output.historySnapshotBaseIndex = existingBaseIndex; + output.historySnapshotCount = Number.isSafeInteger(existingCount) && existingCount > 0 + ? Math.max(existingCount, persistedMessages.length) + : persistedMessages.length; + } else { + output.historySnapshotBaseIndex = 0; + output.historySnapshotCount = persistedMessages.length; + } continue; } output[key] = sanitizePersistValue(value, { @@ -7883,6 +8024,8 @@ function deliverCrossConversationReply(requestId) { draft.status = 'delivering'; }); const replyMessage = { + id: replyMessageId, + replyToRequestId: requestId, role: 'assistant', content: replyContent, timestamp: now, @@ -11035,7 +11178,10 @@ function createPersistentConversationSession(args = {}, options = {}) { function buildSessionInfoPayload(session) { const waitState = crossConversationWaitState(session.id); - const messages = sanitizeMessagesForTransport(session.messages || []); + const history = typeof resolveSessionHistory === 'function' + ? resolveSessionHistory(session) + : { messages: session.messages || [], source: 'snapshot', available: true }; + const messages = sanitizeMessagesForTransport(history.messages || []); const taskTracking = taskTrackingSnapshotForSession(session); return { type: 'session_info', @@ -11057,6 +11203,8 @@ function buildSessionInfoPayload(session) { hasUnread: false, historyPending: false, isRunning: false, + historyAvailable: history.available !== false, + historySource: history.source || 'snapshot', waitingOnChildren: waitState.waitingOnChildren, pendingReplyCount: waitState.pendingReplyCount, readyReplyCount: waitState.readyReplyCount, @@ -11868,21 +12016,33 @@ function handleMessage(ws, msg, options = {}) { let persistedUserMessage = null; if (!hideInHistory) { - persistedUserMessage = { - role: 'user', - content: displayTextValue, - attachments: savedAttachments, - timestamp: new Date().toISOString(), - }; - const giteaSource = normalizeGiteaSource(options.giteaSource); - if (giteaSource) persistedUserMessage.giteaSource = giteaSource; - if (options.crossConversation) { - persistedUserMessage.crossConversation = options.crossConversation; + const clientMessageId = String(msg?.clientMessageId || '').trim(); + const replyToRequestId = String(options.crossConversation?.replyToRequestId || '').trim(); + const messageId = clientMessageId || (replyToRequestId ? `reply-request:${replyToRequestId}` : crypto.randomUUID()); + const existingUserMessage = Array.isArray(session.messages) + ? session.messages.find((message) => message?.role === 'user' && message?.id === messageId) + : null; + if (existingUserMessage) { + persistedUserMessage = existingUserMessage; + } else { + persistedUserMessage = { + id: messageId, + role: 'user', + content: displayTextValue, + attachments: savedAttachments, + timestamp: new Date().toISOString(), + }; + if (clientMessageId) persistedUserMessage.clientMessageId = clientMessageId; + const giteaSource = normalizeGiteaSource(options.giteaSource); + if (giteaSource) persistedUserMessage.giteaSource = giteaSource; + if (options.crossConversation) { + persistedUserMessage.crossConversation = options.crossConversation; + } + if (decoratorResolution.mentions.length > 0) { + persistedUserMessage.composerMentions = decoratorResolution.mentions; + } + session.messages.push(persistedUserMessage); } - if (decoratorResolution.mentions.length > 0) { - persistedUserMessage.composerMentions = decoratorResolution.mentions; - } - session.messages.push(persistedUserMessage); } session.updated = new Date().toISOString(); saveSession(session); diff --git a/修复 ccweb_send_message 异步等待 TO DO list.csv b/修复 ccweb_send_message 异步等待 TO DO list.csv new file mode 100644 index 0000000..a14118e --- /dev/null +++ b/修复 ccweb_send_message 异步等待 TO DO list.csv @@ -0,0 +1,7 @@ +id,item,status,done_at,notes +1,定位工具定义、提示词来源和异步回传协议并确认现状,DONE,2026-09-18T15:42:00+08:00,确认问题是提示词语义不够明确,不是服务端同步阻塞 +2,增加会失败的提示词与异步协议回归断言,DONE,2026-09-18T15:45:00+08:00,覆盖立即返回、禁止 sleep/轮询、两种 replyMode 和既有回传链路 +3,修改 ccweb_send_message 提示词并明确异步回传及状态语义,DONE,2026-09-18T15:46:00+08:00,明确 return_and_continue 不等于当前调用等待,结果由系统异步回传 +4,运行延迟回传及 one_way/return_and_continue 协议回归测试,IN_PROGRESS,,先运行消息回传单测,再运行项目 MCP 回归目标 +5,确认服务加载新提示词并清理临时清单,TODO,,需检查运行中会话后再重启 ccweb +6,汇总修复结果与验证证据,TODO,,