修复历史边界与流式消息去重并更新发布包

This commit is contained in:
shiyue
2026-09-21 00:38:51 +08:00
parent db04e088bc
commit f8f5ef3a0b
14 changed files with 1733 additions and 423 deletions

View File

@@ -6,3 +6,12 @@
- codebase-memory 项目 `home-cc-web` 索引状态为 ready8208 nodes / 19300 edges
- 工作区存在用户已有未提交改动:`lib/ccweb-mcp-server.js``scripts/ccweb-message-reply-unit.js`,本任务不触碰。
- 2026-09-21db04e08 已删除全量 native 替换,但 merge 的数值 baseIndex 回退仍不可靠messagesEquivalent 不支持 codexAppTurnId 与 native turnId 跨来源对齐。
- 前端 prepend/reconcile 仍以相同下标跳过不同 ID缓存也会按下标覆盖不同消息。
- sessions/35394008-4f0c-43c0-a481-b622d9e6b884.json 本机暂未找到,不能假装完成迁移。
- 自动回传使用 buildCrossConversationReplyAutoRunText 的“子对话回传已返回……”包装,需与“来自……”投递包装分别精确识别;两者均不能作为普通用户消息。
- response_item 中存在 AGENTS/environment 等用户角色上下文;有 event_msg.user_message 时以事件为权威,仅从匹配 response 补充 ID。
- done 原来不携带持久化助手身份,结束后的本地 stream 与刷新消息无法去重;现已将最终消息及合并后下标通过 done 传递。
- 新迁移能力通过已认证 WebSocket 在服务端同步检查/备份/写入;单文件提供 --migrate-session-history 入口,默认预览,--apply 写入。

View File

@@ -5,3 +5,18 @@
- 2026-09-20rollout 解析按 `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`
- 2026-09-21收紧 native 合并,仅首条稳定标识/完整时间戳和内容摘要边界可信;废止数值基线切 native跨线程和分段 turn 保守回退。
- 2026-09-21新增结构化截断元数据、修正消息名额及基线覆盖服务端新增 idle 状态的预览/备份/原子迁移入口,脚本默认预览。
- 2026-09-21用户确认目标旧会话在其他机器本轮不操作远端会话数据。
- 2026-09-21历史服务端 9 项、rollout 10 项、前端专项行为回归已通过;历史召回和切会话专项通过。额外补齐流式转持久化的身份绑定,准备全量和浏览器验证。
- 2026-09-21全量 npm run regression 通过;关联 ccweb-message-reply、failed-insert-card、ccweb-list-user-inputs、javascript-session-runtime 单测和语法检查通过。最终 auto-run 过滤后的 history-recall、session-switch-race 复跑通过。
- 2026-09-21使用 /tmp/ccweb-bun.7eHd2F/node_modules/@oven/bun-linux-x64-baseline/bin/bun1.4.2开始构建发布包Firefox 隔离浏览器验证执行中。
- 2026-09-21CentOS 7 baseline 发布包构建完成MCP initialize、单文件 --migrate-session-history 入口、tar 结构均通过烟测SHA-256 为 7c8fe9bcb1be8c860e10578a1fac925c51785f83dd6449cbfa57f09dcc370c9e。
- 2026-09-21无本机目标数据迁移本机列表仍有其他 running 对话,按 AGENTS.md 不重启本机服务。用户本轮要求异机使用的验证、打包与推送。
- 2026-09-21最终全量回归再次通过。Firefox 156.0 / geckodriver 0.37.1 真浏览器验证通过68 条快照、483 条 native加载、运行、刷新、真实 WebSocket 重连、done、done 后刷新六阶段保持顺序且无重复historySource=merged。
- 浏览器证据:/tmp/cc-web-history-browser-KnMvZ7/evidence.json运行中截图 /tmp/cc-web-history-browser-KnMvZ7/refreshed.png 已目视核对。隔离进程已清理。
- 本轮目标在异机,未迁移或重启生产会话;发布包提供 idle 安全迁移工具。准备提交全部源码、测试、计划及发布包并推送 main。

View File

@@ -7,14 +7,14 @@
## 合并契约
- 持久化快照中去掉 `ccwebPersistenceNotice` 后的消息保持原有顺序是当前会话的权威尾部native 只能补充快照之前的消息。
- 快照压缩时保存 `historySnapshotBaseIndex`native 逻辑消息索引)与 `historySnapshotCount`,旧快照缺字段时按稳定 ID/最长公共后缀推断,无法确认边界时保留快照
- 快照压缩时保存 `historySnapshotBaseIndex`cc-web 逻辑消息索引)与 `historySnapshotCount`;只按首条稳定 ID 别名或完整时间戳和内容摘要确认 native 边界,禁止用数值基线直接切 native
- 合并顺序固定为native 更早消息 → cc-web 持久化消息 → 当前运行中的消息;相同稳定 ID 只保留 cc-web 消息ID 冲突且内容不一致时以持久化消息为准并记录告警。
- native 缺失、解析异常、边界无法确认或合并结果不能证明快照尾部完整时,返回 cc-web 快照,不把未经确认的 native 消息插入当前视图。
- `session_info``resume_session_result``session_history_chunk` 均调用同一解析结果;分页只对该结果切片。
## 稳定标识契约
- 用户消息:优先 `clientMessageId`兼容旧字段 `id`;缺失时生成并持久化 `client:<uuid>`
- 消息已有 `id` 始终保留;新增用户消息保留 `clientMessageId`缺失时生成并持久化 UUID
- Codex App 助手消息:使用 `codexAppTurnKey`,其输入为 session/thread/turn 的稳定字段;旧消息缺失时从对应运行状态或时间戳生成一次并保存。
- native rollout 助手消息:使用 `nativeTurnKey`,由 threadId + turnId/turn_context 组成;同一 turn 跨刷新保持一致。
- 跨对话回传:保留 `replyToRequestId`(兼容 `crossConversation.replyRequestId`),同时生成 `reply:<requestId>` 作为稳定消息 ID来源元数据始终保留。
@@ -23,7 +23,7 @@
## Turn 聚合契约
-`turn_id``turn_context.turn_id``turn_context.id` 或 session/thread 上下文组成 turn key缺失时使用相邻事件窗口的稳定 fallback key。
- 同一 turn 内按 rollout 文件顺序合并文本片段、工具调用与工具结果只有遇到下一条用户消息、turn 完成/失败事件或文件结束才 flush。
- 同一 turn 内按 rollout 文件顺序合并文本片段、工具调用与工具结果;只有遇到新 turn、下一条真实用户消息、turn 完成/失败事件或文件结束才 flush。
- tool call/result 绑定同一调用 ID空 turn 不产出气泡;跨对话内部 user 回传不作为普通 user 消息插入。
## 前端契约
@@ -60,3 +60,15 @@
| 错误 | 尝试 | 处理 |
|---|---|---|
## 2026-09-21 反馈修订
- [complete] 收紧历史边界并补齐解析、前端稳定标识
- [complete] 补齐结构化快照元数据及安全迁移入口
- [complete] 运行历史、重连回归与必要浏览器验证
- [complete] 检查旧会话迁移条件及服务重启条件
- [complete] 重建 CentOS 7 发布包并烟测
- [in_progress] 提交全部修改并推送
修订约束:不以 historySnapshotBaseIndex 直接切 native只确认快照首条的可靠边界不要求尚未进入 rollout 的最近消息也匹配。ID 别名优先,完整时间戳与内容摘要次之;未确认则只保留快照。目标旧会话只在 idle 且原文件未变化时备份和原子迁移。用户要求不再审计本轮仅做实现与必要回归。update_plan 工具当前未提供,以本计划和 CSV 同步跟踪。

View File

@@ -22,23 +22,37 @@ function createCodexRolloutStore(deps) {
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 getRolloutTurnId(entry) {
const payload = entry?.payload || {};
const context = payload.turn_context || entry?.turn_context || {};
const value = payload.turn_id
|| payload.internal_chat_message_metadata_passthrough?.turn_id
|| entry?.turn_id
|| context.turn_id
|| context.id
|| (entry?.type === 'turn_context' ? payload.id : null);
return value ? String(value) : null;
}
function extractCcwebSourceConversation(text) {
const match = String(text || '').match(/^来自「([^」]+)」对话ID:\s*([0-9a-fA-F-]{36}))的消息:/);
if (!match) return null;
return { title: match[1], id: match[2].toLowerCase() };
const value = String(text || '').replace(/\r\n/g, '\n');
const match = value.match(/^来自「([^」]+)」对话ID:\s*([0-9a-fA-F-]{36}))的消息:/);
if (match) return { title: match[1], id: match[2].toLowerCase() };
// 自动继续包装必须同时满足固定开头、请求标识、目标会话和正文结构。
// 普通用户只是在正文中提到“子对话回传”不能被当作内部消息过滤。
const returned = value.match(/^子对话回传已返回,但已返回不等于已完成。请先检查返回内容是否完整满足原始请求,再决定继续推进、补问目标对话或向用户汇报。\n\nrequestId([^\n]+)\n目标对话「([^」]+)」ID: ([0-9a-fA-F-]{36}|未知)\n\n原始请求\n[\s\S]*?\n\n返回正文\n/);
if (!returned) return null;
return { title: returned[2], id: returned[3] === '未知' ? null : returned[3].toLowerCase() };
}
function parseCodexRolloutLines(lines) {
const entries = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry && typeof entry === 'object') entries.push(entry);
} catch {}
}
const messages = [];
const pendingToolCalls = new Map();
const meta = {
@@ -53,10 +67,14 @@ function createCodexRolloutStore(deps) {
};
const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
let currentAssistant = null;
let currentTurnId = null;
let currentTurnKey = null;
let currentSegment = null;
let implicitTurnSequence = 0;
let sawRealUserMessage = false;
const fallbackUserMessages = [];
let lastUser = null;
const userMessagesById = new Map();
const assistantsByTurn = new Map();
const originalAssistantIds = new WeakMap();
function rememberSourceConversation(text) {
if (meta.sourceConversationId) return;
@@ -66,42 +84,190 @@ function createCodexRolloutStore(deps) {
meta.sourceConversationTitle = sourceConversation.title;
}
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,
nativeTurnKey: `native:${meta.threadId || 'unknown-thread'}:${currentTurnKey}`,
id: `native:${stableHash(`${meta.threadId || ''}:${currentTurnKey}`)}`,
};
} else if (!currentAssistant.timestamp && ts) {
currentAssistant.timestamp = ts;
function messageIdentity(entry) {
const payload = entry.payload || {};
const passthrough = payload.internal_chat_message_metadata_passthrough || {};
const clientMessageId = payload.clientMessageId || payload.client_user_message_id
|| passthrough.clientMessageId || passthrough.client_user_message_id || null;
const id = payload.id || payload.message_id || entry.id || entry.message_id || null;
return { id: id ? String(id) : null, clientMessageId: clientMessageId ? String(clientMessageId) : null };
}
// 有 user_message 事件时它才是用户输入记录response_item 中还混有
// AGENTS、environment 等运行上下文,只有配对成功的记录才可补充身份字段。
const userEventsByText = new Map();
const responseUsers = [];
const responseForUserEvent = new Map();
let scannedTurnId = null;
let hasUserEvents = false;
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
scannedTurnId = getRolloutTurnId(entry) || scannedTurnId;
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
hasUserEvents = true;
const text = String(entry.payload.message || '').trim();
const candidates = userEventsByText.get(text) || [];
candidates.push({ entry, index, turnId: scannedTurnId });
userEventsByText.set(text, candidates);
} else if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'user') {
responseUsers.push({ entry, index, turnId: scannedTurnId });
}
return currentAssistant;
if (entry.type === 'event_msg' && ['task_complete', 'task_completed', 'turn_complete', 'turn_completed', 'turn_failed', 'turn_aborted'].includes(entry.payload?.type)) {
scannedTurnId = null;
}
}
for (const response of responseUsers) {
const text = extractCodexMessageText(response.entry.payload.content).trim();
let matched = null;
for (const candidate of userEventsByText.get(text) || []) {
if (responseForUserEvent.has(candidate.entry)) continue;
if (response.turnId && candidate.turnId && response.turnId !== candidate.turnId) continue;
if (!matched || Math.abs(candidate.index - response.index) < Math.abs(matched.index - response.index)) matched = candidate;
}
if (matched) responseForUserEvent.set(matched.entry, response.entry);
}
function assignAssistantIdentity(assistant) {
const segmentSuffix = currentSegment ? `:segment:${currentSegment}` : '';
assistant.turnId = currentTurnId;
assistant.nativeThreadId = meta.threadId;
assistant.nativeTurnKey = `native:${meta.threadId || 'unknown-thread'}:${currentTurnKey}${segmentSuffix}`;
assistant.id = originalAssistantIds.get(assistant)
|| `history-assistant:${stableHash(`${meta.threadId || ''}:${currentTurnKey}${segmentSuffix}`)}`;
if (currentSegment) assistant.nativeTurnSegment = currentSegment;
}
function flushAssistant() {
if (!currentAssistant) return;
if ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0) {
if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) {
if (!currentAssistant.turnId && !originalAssistantIds.has(currentAssistant)) {
const content = currentAssistant.content || JSON.stringify(currentAssistant.toolCalls);
currentAssistant.id = `history-assistant:${stableHash(`${meta.threadId || ''}:${currentAssistant.timestamp || ''}:${content}`)}`;
}
messages.push(currentAssistant);
}
currentAssistant = null;
currentTurnKey = null;
pendingToolCalls.clear();
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let entry;
try { entry = JSON.parse(trimmed); } catch { continue; }
function selectTurn(turnId) {
if (!turnId || turnId === currentTurnId) return;
const previousTurnKey = currentTurnKey;
if (currentTurnId) flushAssistant();
currentTurnId = turnId;
currentTurnKey = turnId;
currentSegment = null;
// 老格式可能在首段输出后才提供 turn_context补齐身份而不凭空拆出气泡。
if (currentAssistant) {
assistantsByTurn.delete(previousTurnKey);
assistantsByTurn.set(currentTurnKey, [currentAssistant]);
assignAssistantIdentity(currentAssistant);
}
if (lastUser && !lastUser.assistantStarted && lastUser.turnKey === previousTurnKey) {
lastUser.turnKey = currentTurnKey;
lastUser.message.turnId = currentTurnId;
}
}
function ensureAssistant(entry) {
selectTurn(getRolloutTurnId(entry));
if (!currentTurnKey) currentTurnKey = `implicit:${implicitTurnSequence += 1}`;
if (!currentAssistant) {
currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: entry.timestamp || null };
assignAssistantIdentity(currentAssistant);
const assistants = assistantsByTurn.get(currentTurnKey) || [];
assistants.push(currentAssistant);
assistantsByTurn.set(currentTurnKey, assistants);
}
if (lastUser) lastUser.assistantStarted = true;
return currentAssistant;
}
function rememberAssistantMessageId(assistant, entry) {
const { id } = messageIdentity(entry);
if (!id) return;
if (!originalAssistantIds.has(assistant)) {
originalAssistantIds.set(assistant, id);
assistant.id = id;
}
if (!assistant.nativeMessageIds) assistant.nativeMessageIds = [];
if (!assistant.nativeMessageIds.includes(id)) assistant.nativeMessageIds.push(id);
}
function appendUser(entry, text) {
text = String(text || '').trim();
if (!text) return;
rememberSourceConversation(text);
// 内部回传只是 native 的输入包装,不能冒充用户,也不能打断正在聚合的助手。
if (extractCcwebSourceConversation(text)) return;
const identity = messageIdentity(entry);
const turnId = getRolloutTurnId(entry);
const ts = entry.timestamp || null;
const known = (identity.id && userMessagesById.get(identity.id))
|| (identity.clientMessageId && userMessagesById.get(identity.clientMessageId));
const existing = known;
if (existing) {
if (identity.clientMessageId) existing.message.clientMessageId = identity.clientMessageId;
if (identity.id) {
if (!existing.hasOriginalId) {
existing.message.id = identity.id;
existing.hasOriginalId = true;
}
userMessagesById.set(identity.id, existing);
}
if (identity.clientMessageId) userMessagesById.set(identity.clientMessageId, existing);
return;
}
selectTurn(turnId);
const hadAssistant = (assistantsByTurn.get(currentTurnKey) || []).length > 0;
flushAssistant();
if (!currentTurnId) {
currentTurnKey = `implicit:${implicitTurnSequence += 1}`;
currentSegment = null;
}
const id = identity.id || (identity.clientMessageId ? `client:${identity.clientMessageId}` : null)
|| `history-user:${stableHash(`${meta.threadId || ''}:${currentTurnId || ''}:${ts || ''}:${text}`)}`;
if (hadAssistant && currentTurnId) {
// steer 是真实用户输入:保持前后邻接,同时避免同 turn 的两个助手段互相去重。
for (const assistant of assistantsByTurn.get(currentTurnKey) || []) {
if (!assistant.nativeTurnSegment) assistant.nativeTurnSegment = 'initial';
}
currentSegment = stableHash(id);
}
const message = { role: 'user', content: text, timestamp: ts, id, turnId: currentTurnId, nativeThreadId: meta.threadId };
if (identity.clientMessageId) message.clientMessageId = identity.clientMessageId;
messages.push(message);
lastUser = { message, turnKey: currentTurnKey, assistantStarted: false, hasOriginalId: !!identity.id };
if (identity.id) userMessagesById.set(identity.id, lastUser);
if (identity.clientMessageId) userMessagesById.set(identity.clientMessageId, lastUser);
if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' ');
}
function appendTool(entry, output = false) {
const payload = entry.payload || {};
const assistant = ensureAssistant(entry);
const fallbackName = payload.type.startsWith('custom_') ? 'CustomToolCall'
: payload.type.startsWith('mcp_') ? 'McpToolCall' : 'FunctionCall';
const toolUseId = payload.call_id || payload.id
|| `native-tool:${stableHash(`${assistant.nativeTurnKey}:${payload.type}:${entry.timestamp || ''}:${JSON.stringify(payload)}`)}`;
let tool = pendingToolCalls.get(toolUseId);
if (!tool) {
tool = { name: payload.name || payload.tool || fallbackName, id: toolUseId, input: null, done: false };
assistant.toolCalls.push(tool);
pendingToolCalls.set(toolUseId, tool);
}
if (!output) {
tool.name = payload.name || payload.tool || tool.name;
tool.input = sanitizeToolInput(tool.name, payload.input ?? payload.arguments ?? '');
}
if (output || payload.status === 'completed' || payload.status === 'failed' || payload.result !== undefined) {
tool.done = true;
const result = payload.output ?? payload.result;
if (result !== undefined) tool.result = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 2000);
}
}
for (const entry of entries) {
const ts = entry.timestamp || null;
if (ts) meta.updatedAt = ts;
@@ -113,21 +279,22 @@ 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}`);
if ((entry.type === 'event_msg' && ['task_started', 'turn_started'].includes(entry.payload?.type))
|| entry.type === 'turn_context') {
selectTurn(getRolloutTurnId(entry));
if (!currentTurnKey) currentTurnKey = `implicit:${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();
if (entry.type === 'event_msg' && ['task_complete', 'task_completed', 'turn_complete', 'turn_completed', 'turn_failed', 'turn_aborted'].includes(entry.payload?.type)) {
const turnId = getRolloutTurnId(entry);
// 迟到的上一轮结束事件不能截断当前轮。
if (!turnId || !currentTurnId || turnId === currentTurnId) {
flushAssistant();
currentTurnId = null;
currentTurnKey = null;
currentSegment = null;
}
continue;
}
@@ -147,119 +314,41 @@ function createCodexRolloutStore(deps) {
}
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
const text = String(entry.payload?.message || '').trim();
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,
id: `native-user:${stableHash(`${meta.threadId || ''}:${ts || ''}:${text}`)}`,
});
}
const response = responseForUserEvent.get(entry);
const responseIdentity = response ? messageIdentity(response) : {};
const eventIdentity = messageIdentity(entry);
const enrichedEntry = response ? {
...entry,
payload: {
...entry.payload,
id: eventIdentity.id || responseIdentity.id,
clientMessageId: eventIdentity.clientMessageId || responseIdentity.clientMessageId,
turn_id: getRolloutTurnId(entry) || getRolloutTurnId(response),
},
} : entry;
appendUser(enrichedEntry, entry.payload?.message);
continue;
}
if (entry.type !== 'response_item') continue;
const payload = entry.payload || {};
switch (payload.type) {
case 'message': {
if (payload.role === 'assistant') {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
appendAssistantContent(ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey)), text);
}
} else if (payload.role === 'user' && !sawRealUserMessage) {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
rememberSourceConversation(text);
if (!extractCcwebSourceConversation(text)) {
fallbackUserMessages.push({
role: 'user',
content: text,
timestamp: ts,
id: `native-user:${stableHash(`${meta.threadId || ''}:${ts || ''}:${text}`)}`,
});
}
}
if (payload.type === 'message') {
const text = extractCodexMessageText(payload.content);
if (payload.role === 'assistant' && text.trim()) {
const assistant = ensureAssistant(entry);
rememberAssistantMessageId(assistant, entry);
appendAssistantContent(assistant, text);
} else if (payload.role === 'user' && !hasUserEvents) {
appendUser(entry, text);
}
break;
}
case 'function_call': {
const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey));
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
const tc = {
name: payload.name || 'FunctionCall',
id: toolUseId,
input: sanitizeToolInput(payload.name || 'FunctionCall', payload.arguments || ''),
done: false,
};
assistant.toolCalls.push(tc);
pendingToolCalls.set(toolUseId, tc);
break;
}
case 'function_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: 'FunctionCall', 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;
}
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;
} else if (['function_call', 'custom_tool_call', 'mcp_tool_call', 'mcp_call'].includes(payload.type)) {
appendTool(entry);
} else if (['function_call_output', 'custom_tool_call_output', 'mcp_tool_call_output', 'mcp_call_output'].includes(payload.type)) {
appendTool(entry, true);
}
}
flushAssistant();
if (!sawRealUserMessage && fallbackUserMessages.length > 0) {
const fallback = fallbackUserMessages[0];
if (!meta.title) meta.title = fallback.content.trim().slice(0, 80).replace(/\n/g, ' ');
return { meta, messages: fallbackUserMessages.concat(messages), totalUsage };
}
return { meta, messages, totalUsage };
}
@@ -309,6 +398,7 @@ function createCodexRolloutStore(deps) {
}
return {
getRolloutTurnId,
parseCodexRolloutLines,
getCodexRolloutFiles,
getImportedCodexThreadIds,

View File

@@ -1811,13 +1811,46 @@
|| message.clientMessageId
|| message.replyToRequestId
|| message.crossConversation?.replyToRequestId
|| message.crossConversation?.replyRequestId
|| message.codexAppTurnKey
|| message.nativeTurnKey;
if (explicit) return String(explicit);
const turnId = message.turnId || message.codexAppTurnId;
if (turnId) return `turn:${message.role || ''}:${turnId}`;
const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || '');
return `message:${stableMessageHash(`${message.role || ''}\u001f${message.timestamp || ''}\u001f${content}`)}`;
}
function getMessageRenderFingerprint(message) {
// 工具结果、附件和跨会话来源变化也必须刷新气泡,不能只比较正文。
return stableMessageHash(JSON.stringify(message || {}));
}
function mergeIndexedSessionMessages(snapshot, messages, incomingBase = 0) {
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length);
const entries = new Map();
const positions = snapshot.historyMessagePositions || {};
const upsert = (message, index) => {
const key = getStableMessageKey(message);
const previous = entries.get(key);
entries.set(key, { message, index, order: previous?.order ?? entries.size });
};
existingMessages.forEach((message, index) => {
const storedIndex = positions[getStableMessageKey(message)];
upsert(message, Number.isFinite(storedIndex) ? storedIndex : existingBase + index);
});
messages.forEach((message, index) => upsert(message, incomingBase + index));
// 下标只决定位置。同下标的不同 ID 均保留,同 ID 则更新消息及位置。
const sorted = Array.from(entries.values()).sort((a, b) => a.index - b.index || a.order - b.order);
snapshot.messages = sorted.map((entry) => entry.message);
snapshot.historyMessagePositions = Object.fromEntries(sorted.map((entry) => [getStableMessageKey(entry.message), entry.index]));
snapshot.historyBaseIndex = sorted[0]?.index ?? incomingBase;
snapshot.historyBuffered = snapshot.messages.length;
}
function findRenderedSessionMessageByKey(key) {
const normalizedKey = String(key || '').trim();
if (!normalizedKey) return null;
@@ -2493,10 +2526,10 @@
messageEl.dataset.sessionMessage = 'true';
messageEl.dataset.messageIndex = String(messageIndex);
if (message) {
messageEl.__sessionMessage = 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}`);
messageEl.dataset.messageFingerprint = getMessageRenderFingerprint(message);
}
if (messageEl.classList.contains('assistant')) {
syncAssistantBranchButton(messageEl, messageIndex);
@@ -2673,6 +2706,7 @@
|| payload.taskTracking?.enabled === true,
historyTotal,
historyBaseIndex,
historyMessagePositions: payload.historyMessagePositions ? deepClone(payload.historyMessagePositions) : {},
historyBuffered,
historyCursor,
historyTruncated,
@@ -6755,6 +6789,7 @@
// session_history_chunk 的 prepend 并发,造成消息视觉顺序错乱。
immediate: !!options.immediate || !!snapshot.historyPending,
baseIndex: snapshot.historyBaseIndex || 0,
messagePositions: snapshot.historyMessagePositions,
});
scheduleAdvancedSearchJump();
if (snapshot.isRunning && snapshot.sessionId === currentSessionId) {
@@ -6765,6 +6800,7 @@
// 仅保留 streaming-msg 会导致用户气泡在第一次切换时消失。
reconcileRenderedSessionMessages(snapshot.messages || [], {
baseIndex: snapshot.historyBaseIndex || 0,
messagePositions: snapshot.historyMessagePositions,
preserveScroll: true,
});
generatingSessionId = snapshot.sessionId;
@@ -7047,36 +7083,8 @@
const bufferedSnapshot = getSessionHistoryBuffer(sessionId);
const snapshot = cachedSnapshot || bufferedSnapshot;
if (!snapshot) return;
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? 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();
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;
snapshot.historyBuffered = snapshot.messages.length;
mergeIndexedSessionMessages(snapshot, messages, incomingBase);
if (Number.isFinite(Number(options.total))) snapshot.historyTotal = Math.max(0, Number(options.total));
if (Number.isFinite(Number(options.cursor))) snapshot.historyCursor = Math.max(0, Number(options.cursor));
snapshot.historyTruncated = snapshot.historyCursor > 0 || snapshot.historyBaseIndex > 0;
@@ -7094,36 +7102,8 @@
function mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, options = {}) {
if (!snapshot || !Array.isArray(messages) || messages.length === 0) return;
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? 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();
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;
snapshot.historyBuffered = snapshot.messages.length;
mergeIndexedSessionMessages(snapshot, messages, incomingBase);
if (Number.isFinite(Number(options.total))) {
snapshot.historyTotal = Math.max(0, Number(options.total));
}
@@ -8379,10 +8359,7 @@
? 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}`);
const fingerprint = getMessageRenderFingerprint(msg.message);
if (existingElement.dataset.messageFingerprint !== fingerprint) {
const messageIndex = Number(existingElement.dataset.messageIndex);
const replacement = buildMsgElement(
@@ -8533,7 +8510,11 @@
}
break;
}
finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true });
finishGenerating(msg.sessionId, {
keepRunning: msg.goalActive === true,
assistantMessage: msg.assistantMessage,
assistantMessageIndex: msg.assistantMessageIndex,
});
break;
case 'system_message':
@@ -8625,6 +8606,7 @@
const toolsDiv = document.querySelector('#streaming-msg .msg-tools');
if (toolsDiv) toolsDiv.innerHTML = '';
}
bindStreamingAssistantMessage(msg.assistantMessageId, msg.turnId);
pendingText = msg.text || '';
flushRender();
if (msg.toolCalls && msg.toolCalls.length > 0) {
@@ -8650,19 +8632,22 @@
if (!isCurrentSessionEvent(msg)) break;
clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
if (msg.sessionId === currentSessionId && Array.isArray(msg.messages) && msg.messages.length > 0) {
const renderedMessages = messagesDiv.querySelectorAll('[data-session-message="true"]').length;
const historyTotal = Number.isFinite(Number(msg.historyTotal))
? Math.max(0, Number(msg.historyTotal))
: msg.messages.length;
if (renderedMessages < historyTotal || msg.messages.some((message) => !findRenderedSessionMessageByKey(getStableMessageKey(message)))) {
reconcileRenderedSessionMessages(msg.messages, {
preserveScroll: false,
baseIndex: Number.isFinite(Number(msg.historyBaseIndex))
? Number(msg.historyBaseIndex)
: Math.max(0, historyTotal - msg.messages.length),
});
loadedHistorySessionId = msg.sessionId;
}
const historyBaseIndex = Number.isFinite(Number(msg.historyBaseIndex))
? Number(msg.historyBaseIndex)
: Math.max(0, historyTotal - msg.messages.length);
mergeHistoryChunkIntoCachedSnapshot(msg.sessionId, msg.messages, {
baseIndex: historyBaseIndex,
total: historyTotal,
});
// 重连时同 ID 的正文、工具及来源仍可能更新,数量相同也需要对账。
reconcileRenderedSessionMessages(msg.messages, {
preserveScroll: false,
baseIndex: historyBaseIndex,
});
loadedHistorySessionId = msg.sessionId;
}
setCurrentSessionRunningState(!!msg.isRunning);
if (!msg.isRunning && currentSessionId && msg.sessionId === currentSessionId) {
@@ -8896,6 +8881,44 @@
return true;
}
function bindStreamingAssistantMessage(messageId, turnId) {
const stream = document.getElementById('streaming-msg');
if (!stream) return;
const key = messageId || (turnId ? getStableMessageKey({ role: 'assistant', turnId }) : '');
if (!key) return;
stream.dataset.messageId = String(key);
stream.dataset.messageKey = String(key);
if (turnId) stream.dataset.turnId = String(turnId);
const existing = findRenderedSessionMessageByKey(key);
if (existing && existing !== stream) {
// 快照与运行态属于同一条助手消息,保留正在接收工具和正文的节点。
stream.__sessionMessage = existing.__sessionMessage;
stream.dataset.messageIndex = existing.dataset.messageIndex;
existing.remove();
updateUserOutlinePanel();
}
}
function commitCompletedAssistantMessage(message, requestedIndex) {
if (!message || message.role !== 'assistant') return false;
const stream = document.getElementById('streaming-msg');
const existing = findRenderedSessionMessageByKey(getStableMessageKey(message));
const knownIndex = existing?.dataset.messageIndex ?? stream?.dataset.messageIndex;
const messageIndex = Number.isFinite(requestedIndex)
? requestedIndex
: knownIndex !== undefined && Number.isFinite(Number(knownIndex))
? Number(knownIndex)
: currentSessionMessageCount;
stream?.remove();
reconcileRenderedSessionMessages([message], { baseIndex: messageIndex, preserveScroll: false });
currentSessionMessageCount = Math.max(currentSessionMessageCount, messageIndex + 1);
mergeHistoryChunkIntoCachedSnapshot(currentSessionId, [message], {
baseIndex: messageIndex,
total: currentSessionMessageCount,
});
return true;
}
function finishGenerating(sessionId, options = {}) {
if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
const keepRunning = options.keepRunning === true;
@@ -8919,7 +8942,11 @@
if (typing) typing.remove();
const streamEl = document.getElementById('streaming-msg');
if (streamEl) {
const committedAssistant = commitCompletedAssistantMessage(
options.assistantMessage || streamEl?.__sessionMessage,
options.assistantMessageIndex,
);
if (streamEl && !committedAssistant) {
// 若本轮出现过父目录,把末尾散落的 .tool-call 也一并收入同一父节点
if (hasGrouped) {
const toolsDiv = streamEl.querySelector('.msg-tools');
@@ -9290,7 +9317,10 @@
|| (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 || getStableMessageKey(meta) || createLocalId('message');
const stableKey = Object.keys(meta).length > 0
? getStableMessageKey({ ...meta, role, content, attachments })
: '';
const resolvedMessageId = meta?.messageId || meta?.id || stableKey || 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}`;
@@ -9299,9 +9329,8 @@
}
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}`);
div.dataset.messageKey = stableKey || resolvedMessageId;
div.dataset.messageFingerprint = getMessageRenderFingerprint({ ...meta, role, content, attachments });
}
if (role === 'system') {
@@ -10916,6 +10945,10 @@
renderEpoch++;
const epoch = renderEpoch;
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const messageIndexAt = (index) => {
const storedIndex = options.messagePositions?.[getStableMessageKey(messages[index])];
return Number.isFinite(storedIndex) ? storedIndex : baseIndex + index;
};
closedCollabAgentIds = collectClosedCollabAgentIds(messages);
collabAgentStateCache = new Map();
collabAgentIdsByToolUseId = new Map();
@@ -10932,7 +10965,7 @@
}
if (options.immediate) {
const frag = document.createDocumentFragment();
messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, baseIndex + index)));
messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, messageIndexAt(index))));
messagesDiv.appendChild(frag);
updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
@@ -10955,7 +10988,7 @@
// Render first batch immediately
const frag0 = document.createDocumentFragment();
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], baseIndex + i));
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], messageIndexAt(i)));
messagesDiv.appendChild(frag0);
updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
@@ -10969,57 +11002,22 @@
delay += 16;
setTimeout(() => {
if (renderEpoch !== epoch) return; // session switched, abort stale render
const prevHeight = messagesDiv.scrollHeight;
const prevScrollTop = messagesDiv.scrollTop;
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i));
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
// Compensate scrollTop so visible area stays unchanged
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
updateScrollbar();
const pending = [];
const messagePositions = {};
for (let i = start; i < end; i++) {
const key = getStableMessageKey(messages[i]);
// 延迟批次只补缺失气泡,不能覆盖重连期间已收到的更新。
if (findRenderedSessionMessageByKey(key)) continue;
pending.push(messages[i]);
messagePositions[key] = messageIndexAt(i);
}
prependHistoryMessages(pending, { messagePositions, preserveScroll: true });
}, delay);
}
}
function prependHistoryMessages(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) return;
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
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;
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;
collectClosedCollabAgentIds(uniqueMessages.map((entry) => entry.message)).forEach((id) => closedCollabAgentIds.add(id));
const preserveScroll = options.preserveScroll !== false;
const skipScrollbar = options.skipScrollbar === true;
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
const frag = document.createDocumentFragment();
uniqueMessages.forEach(({ message, index }) => frag.appendChild(buildMsgElement(message, baseIndex + index)));
if (!preserveScroll) {
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
if (!skipScrollbar) updateScrollbar();
return;
}
const prevHeight = messagesDiv.scrollHeight;
const prevScrollTop = messagesDiv.scrollTop;
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
if (!skipScrollbar) updateScrollbar();
reconcileRenderedSessionMessages(messages, options);
}
function normalizeAskUserInput(input) {
@@ -11709,49 +11707,72 @@
function reconcileRenderedSessionMessages(messages, options = {}) {
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;
const stream = document.getElementById('streaming-msg');
let inserted = 0;
const records = [];
const existingByKey = new Map();
let changed = false;
messagesDiv.querySelectorAll('[data-session-message="true"]').forEach((element) => {
if (element === stream) return;
const key = String(element.dataset.messageKey || element.dataset.messageId || '').trim();
if (key && key === stream?.dataset.messageKey) {
stream.__sessionMessage = element.__sessionMessage;
stream.dataset.messageIndex = element.dataset.messageIndex;
element.remove();
changed = true;
return;
}
if (key && existingByKey.has(key)) {
element.remove();
changed = true;
return;
}
const record = { element, index: Number(element.dataset.messageIndex), order: records.length };
records.push(record);
if (key) existingByKey.set(key, record);
});
const anchor = stream || records[records.length - 1]?.element.nextSibling || null;
collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id));
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 storedIndex = options.messagePositions?.[messageKey];
const messageIndex = Number.isFinite(storedIndex) ? storedIndex : baseIndex + offset;
if (messageKey && messageKey === stream?.dataset.messageKey) {
stream.__sessionMessage = message;
stream.dataset.messageIndex = String(messageIndex);
return;
}
let record = existingByKey.get(messageKey);
if (record) {
if (record.element.dataset.messageFingerprint !== getMessageRenderFingerprint(message)) {
const replacement = buildMsgElement(message, messageIndex);
keyed.replaceWith(replacement);
existingByKey.set(messageKey, replacement);
existing.set(messageIndex, replacement);
record.element.replaceWith(replacement);
record.element = replacement;
changed = true;
}
if (record.index !== messageIndex) {
markSessionMessageElement(record.element, messageIndex, message);
record.index = messageIndex;
changed = true;
}
return;
}
if (existing.has(messageIndex)) return;
const element = buildMsgElement(message, messageIndex);
const next = Array.from(existing.entries())
.filter(([index]) => index > messageIndex)
.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;
record = { element: buildMsgElement(message, messageIndex), index: messageIndex, order: records.length };
records.push(record);
if (messageKey) existingByKey.set(messageKey, record);
changed = true;
});
if (inserted === 0) return;
if (!changed) return;
messagesDiv.querySelector('.welcome-msg')?.remove();
// 重新排序仅移动历史节点,保留正在生成的 DOM 与流式工具状态。
records.sort((a, b) => a.index - b.index || a.order - b.order);
records.forEach((record) => messagesDiv.insertBefore(record.element, anchor));
updateUserOutlinePanel();
if (options.preserveScroll !== false) {
messagesDiv.scrollTop = beforeScrollTop + (messagesDiv.scrollHeight - beforeHeight);
}
updateScrollbar();
if (!options.skipScrollbar) updateScrollbar();
}
// --- Custom Scrollbar ---

View File

@@ -114,6 +114,19 @@ ${runCommand}
如果不设置上述变量,默认命令名仍是 \`claude\`\`codex\`
## 迁移旧会话历史
先启动新版服务,再在会话 idle 时预览迁移:
\`\`\`bash
CC_WEB_PASSWORD='登录密码' ${runCommand} --migrate-session-history <会话ID>
CC_WEB_PASSWORD='登录密码' ${runCommand} --migrate-session-history <会话ID> --apply
\`\`\`
默认连接本机 8002 端口,可用 \`PORT\`\`CC_WEB_SERVER_URL=ws://127.0.0.1:8002/ws\` 指定服务。
只有 \`--apply\` 执行写入;服务端拒绝迁移运行中的会话或无法确认历史边界的快照。
原 JSON 保存在 \`sessions/_history-backups/\`,写入后重新加载;无需另装 Node.js。
## 目录说明
- \`public/\`:前端静态资源

View File

@@ -0,0 +1,195 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { createCodexRolloutStore } = require('../lib/codex-rollouts');
const store = createCodexRolloutStore({
codexSessionsDir: '/tmp/cc-web-rollout-unit-unused',
sessionsDir: '/tmp/cc-web-rollout-unit-unused',
normalizeSession: (value) => value,
sanitizeToolInput: (_name, value) => value,
});
const timestamp = '2026-09-21T10:00:00.000Z';
const event = (type, extra = {}) => ({ type: 'event_msg', timestamp, payload: { type, ...extra } });
const item = (type, extra = {}) => ({ type: 'response_item', timestamp, payload: { type, ...extra } });
const message = (role, text, extra = {}) => item('message', {
role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }], ...extra,
});
const parse = (...entries) => store.parseCodexRolloutLines([
{ type: 'session_meta', payload: { id: 'thread-unit' } }, ...entries,
].map((entry) => JSON.stringify(entry)));
test('turn ID 支持 metadata 和 turn_context按明确优先级读取', () => {
assert.equal(store.getRolloutTurnId({ turn_id: 'entry', payload: {
turn_id: 'payload', internal_chat_message_metadata_passthrough: { turn_id: 'metadata' },
} }), 'payload');
assert.equal(store.getRolloutTurnId({ turn_id: 'entry', payload: {
internal_chat_message_metadata_passthrough: { turn_id: 'metadata' },
} }), 'metadata');
assert.equal(store.getRolloutTurnId({ turn_id: 'entry' }), 'entry');
assert.equal(store.getRolloutTurnId({ payload: { turn_context: { id: 'nested-context' } } }), 'nested-context');
assert.equal(store.getRolloutTurnId({ type: 'turn_context', payload: { id: 'context' } }), 'context');
});
test('同一 metadata turn 的几十个文本、重复开始事件和各类工具结果只生成一个助手气泡', () => {
const entries = [event('task_started', { turn_id: 'turn-many' }), event('user_message', { message: '检查页面' })];
for (let index = 0; index < 40; index += 1) {
entries.push(message('assistant', `片段 ${index}`, {
internal_chat_message_metadata_passthrough: { turn_id: 'turn-many' },
}));
if (index === 20) entries.push(event('task_started', { turn_id: 'turn-many' }));
}
for (const type of ['function_call', 'custom_tool_call', 'mcp_tool_call']) {
entries.push(item(type, { call_id: type, name: `${type}_name`, arguments: '{}', turn_id: 'turn-many' }));
entries.push(item(`${type}_output`, { call_id: type, output: `${type} 完成`, turn_id: 'turn-many' }));
}
entries.push(event('turn_completed', { turn_id: 'turn-many' }));
const result = parse(...entries);
assert.deepEqual(result.messages.map((entry) => entry.role), ['user', 'assistant']);
const assistant = result.messages[1];
assert.equal(assistant.content.split('\n\n').length, 40);
assert.equal(assistant.turnId, 'turn-many');
assert.equal(assistant.nativeThreadId, 'thread-unit');
assert.equal(assistant.nativeTurnKey, 'native:thread-unit:turn-many');
assert.equal(assistant.toolCalls.length, 3);
assert(assistant.toolCalls.every((tool) => tool.done && tool.result.endsWith('完成')));
assert.equal(parse(...entries).messages[1].id, assistant.id);
});
test('只有 response_item 用户消息的旧 rollout 保持用户和回复的原始位置', () => {
const result = parse(
message('user', '第一问'), message('assistant', '第一答'),
message('user', '第二问'), message('assistant', '第二答'),
);
assert.deepEqual(result.messages.map((entry) => entry.content), ['第一问', '第一答', '第二问', '第二答']);
assert.notEqual(result.messages[1].id, result.messages[3].id);
assert.notEqual(result.messages[1].nativeTurnKey, result.messages[3].nativeTurnKey);
});
test('event_msg 与 response_item 用户双写只保留一条,并保留原消息 ID 和客户端 ID', () => {
for (const reversed of [false, true]) {
const pair = [
event('user_message', { message: '同一问题', client_user_message_id: 'client-1' }),
message('user', '同一问题', { id: 'original-user', clientMessageId: 'client-1' }),
];
if (reversed) pair.reverse();
const result = parse(event('task_started', { turn_id: 'turn-pair' }), ...pair, message('assistant', '回答', { id: 'original-assistant' }));
assert.equal(result.messages.length, 2);
assert.equal(result.messages[0].id, 'original-user');
assert.equal(result.messages[0].clientMessageId, 'client-1');
assert.equal(result.messages[1].id, 'original-assistant');
assert.equal(result.messages[1].turnId, 'turn-pair');
}
});
test('有权威事件时忽略 AGENTS 和 environment 注入,双写先后两种顺序保持原消息位置', () => {
for (const reversed of [false, true]) {
const pair = [event('user_message', { message: '检查页面' }), message('user', '检查页面', { id: 'original-user' })];
if (reversed) pair.reverse();
const result = parse(
message('user', '# AGENTS.md instructions for /home/cc-web'),
event('task_started', { turn_id: 'turn-real' }),
message('user', '<environment_context>运行上下文</environment_context>'),
...pair,
message('assistant', '开始检查'),
item('function_call', { call_id: 'tool-one', name: 'read_file', arguments: '{}' }),
message('user', '<system_reminder>系统注入</system_reminder>'),
item('function_call_output', { call_id: 'tool-one', output: '内容' }),
message('assistant', '检查完成'),
event('task_complete', { turn_id: 'turn-real' }),
message('user', '<environment_context>下一轮上下文</environment_context>'),
event('task_started', { turn_id: 'turn-next' }),
event('user_message', { message: '下一问', id: 'next-user' }),
message('assistant', '下一答'),
);
assert.deepEqual(result.messages.map((entry) => entry.content), ['检查页面', '开始检查\n\n检查完成', '下一问', '下一答']);
assert.equal(result.messages[0].id, 'original-user');
assert.equal(result.messages[1].toolCalls.length, 1);
assert.equal(result.messages[1].toolCalls[0].result, '内容');
assert.equal(result.meta.title, '检查页面');
}
});
test('内部跨会话回传不生成用户气泡,也不会切开同一助手 turn', () => {
const internal = '来自「来源对话」对话ID: 00000000-0000-0000-0000-000000000001的消息处理完毕';
const result = parse(
event('task_started', { turn_id: 'turn-cross' }), event('user_message', { message: '真正的问题' }), message('user', '真正的问题'),
message('assistant', '前半'),
event('user_message', { message: internal }), message('user', internal),
message('assistant', '后半'), event('task_complete', { turn_id: 'turn-cross' }),
);
assert.deepEqual(result.messages.map((entry) => entry.content), ['真正的问题', '前半\n\n后半']);
assert.equal(result.meta.sourceConversationId, '00000000-0000-0000-0000-000000000001');
assert.equal(result.messages[1].nativeTurnSegment, undefined);
});
test('自动继续的子对话回传包装不生成用户气泡或拆轮,同时保留目标来源', () => {
const returned = '子对话回传已返回,但已返回不等于已完成。请先检查返回内容是否完整满足原始请求,再决定继续推进、补问目标对话或向用户汇报。\n\nrequestIdrequest-unit\n目标对话「实现会话」ID: 00000000-0000-0000-0000-000000000002\n\n原始请求\n检查并修复历史顺序\n\n返回正文\n修复完成测试通过。';
const result = parse(
event('task_started', { turn_id: 'turn-return' }), event('user_message', { message: '请处理问题' }),
message('assistant', '等待实现反馈'), event('user_message', { message: returned }),
message('user', returned), message('assistant', '反馈已核对'),
);
assert.deepEqual(result.messages.map((entry) => entry.content), ['请处理问题', '等待实现反馈\n\n反馈已核对']);
assert.equal(result.messages[1].nativeTurnSegment, undefined);
assert.equal(result.meta.sourceConversationId, '00000000-0000-0000-0000-000000000002');
assert.equal(result.meta.sourceConversationTitle, '实现会话');
});
test('普通用户提到子对话回传或不完整包装仍作为用户消息保留', () => {
const texts = [
'请解释子对话回传已返回,但已返回不等于已完成。是什么意思。',
'子对话回传已返回,但已返回不等于已完成。请帮我确认这句话是否正确。',
'子对话回传已返回,但已返回不等于已完成。请先检查返回内容是否完整满足原始请求,再决定继续推进、补问目标对话或向用户汇报。',
];
const result = parse(...texts.map((text) => event('user_message', { message: text })));
assert.deepEqual(result.messages.map((entry) => entry.content), texts);
});
test('turn_context 的新轮 ID 和明确完成事件正确划分助手轮次', () => {
const result = parse(
{ type: 'turn_context', payload: { id: 'turn-one' } }, message('assistant', '第一轮'),
{ type: 'turn_context', payload: { turn_id: 'turn-two' } }, message('assistant', '第二轮'),
event('turn_completed', { turn_id: 'turn-one' }), message('assistant', '第二轮继续'),
event('turn_completed', { turn_id: 'turn-two' }),
message('assistant', '第三轮', { internal_chat_message_metadata_passthrough: { turn_id: 'turn-three' } }),
);
assert.deepEqual(result.messages.map((entry) => entry.content), ['第一轮', '第二轮\n\n第二轮继续', '第三轮']);
assert.deepEqual(result.messages.map((entry) => entry.turnId), ['turn-one', 'turn-two', 'turn-three']);
assert.equal(new Set(result.messages.map((entry) => entry.id)).size, 3);
});
test('同轮真实追加用户输入保留邻接关系,前后助手段有不同稳定键', () => {
const entries = [
event('task_started', { turn_id: 'turn-steer' }), event('user_message', { message: '开始', id: 'user-start' }),
message('assistant', '当前进展'),
event('user_message', { message: '补充要求', id: 'user-steer' }),
message('user', '补充要求', { id: 'user-steer-copy' }),
message('assistant', '按补充要求继续'),
];
const result = parse(...entries);
assert.deepEqual(result.messages.map((entry) => entry.content), ['开始', '当前进展', '补充要求', '按补充要求继续']);
assert.equal(result.messages[1].nativeTurnSegment, 'initial');
assert(result.messages[3].nativeTurnSegment);
assert.equal(result.messages[1].turnId, result.messages[3].turnId);
assert.notEqual(result.messages[1].id, result.messages[3].id);
assert.notEqual(result.messages[1].nativeTurnKey, result.messages[3].nativeTurnKey);
assert.deepEqual(parse(...entries).messages.map((entry) => entry.id), result.messages.map((entry) => entry.id));
});
test('两个相同来源的重复文本仍作为真实用户输入保留', () => {
const result = parse(
event('task_started', { turn_id: 'turn-repeat' }), event('user_message', { message: '继续', id: 'first' }),
message('assistant', '进展'), event('user_message', { message: '继续', id: 'second' }), message('assistant', '完成'),
);
assert.equal(result.messages.filter((entry) => entry.role === 'user').length, 2);
});
test('迟到的 turn_context 补充身份时不会把已开始的同轮回复拆开', () => {
const result = parse(
message('assistant', '前半'), { type: 'turn_context', payload: { id: 'turn-late' } },
message('assistant', '后半'),
);
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].content, '前半\n\n后半');
assert.equal(result.messages[0].turnId, 'turn-late');
});

View File

@@ -0,0 +1,297 @@
#!/usr/bin/env node
'use strict';
// 使用隔离会话、原生 rollout 和 mock 运行时,在真实 Firefox 中验证历史恢复。
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 { spawn } = require('node:child_process');
const { WebSocket, WebSocketServer } = require('ws');
const { createCodexRolloutStore } = require('../lib/codex-rollouts');
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 QUESTION = '没参考其他的页面么WMSA51 是你这么写的么?';
const ARCHITECTURE = '先按项目规范核对现有页面、任务票和负责会话,再把样式返工交回原实现负责人。';
const BOUNDARY = '这不是单纯的样式问题,而是 T-003 的页面职责和流程边界做错了。';
const STREAM = '当前正在生成的消息:正在核对页面职责与流程边界。';
const SESSION_ID = '11111111-2222-4333-8444-555555555555';
const THREAD_ID = '11111111-2222-4333-8444-666666666666';
const PASSWORD = 'HistoryBrowserFixture!234';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
server.close(() => resolve(port));
});
});
}
async function waitFor(predicate, description, timeout = 12000) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeout) {
try { const result = await predicate(); if (result) return result; } catch (error) { lastError = error; }
await sleep(80);
}
throw new Error(`${description}${lastError ? `${lastError.message}` : ''}`);
}
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode) return;
child.kill('SIGTERM');
await Promise.race([new Promise((resolve) => child.once('exit', resolve)), sleep(1000)]);
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
}
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', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'] },
} } });
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 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}`); }
}
function createFixture(root) {
const home = path.join(root, 'fixture-home');
const sessions = path.join(root, 'sessions');
const nativeDir = path.join(home, '.codex', 'sessions', '2026', '09', '21');
for (const folder of [sessions, nativeDir, path.join(root, 'config'), path.join(root, 'logs')]) fs.mkdirSync(folder, { recursive: true });
const rows = [{ type: 'session_meta', payload: { id: THREAD_ID, cwd: root } }];
function turn(index, user, textParts) {
const stamp = new Date(Date.UTC(2026, 8, 21, 0, 0, index)).toISOString();
const turnId = `browser-turn-${index}`;
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_started', turn_id: turnId } });
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'user_message', message: user, id: `browser-user-${index}` } });
textParts.forEach((text) => rows.push({ type: 'response_item', timestamp: stamp, payload: {
type: 'message', role: 'assistant', internal_chat_message_metadata_passthrough: { turn_id: turnId },
content: [{ type: 'output_text', text }],
} }));
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_complete', turn_id: turnId } });
}
for (let index = 0; index < 72; index++) turn(index, `早期问题 ${index}`, [`早期回答 ${index}`]);
const store = createCodexRolloutStore({ codexSessionsDir: nativeDir, sessionsDir: sessions, normalizeSession: (v) => v, sanitizeToolInput: (_name, v) => v });
const prefix = store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages;
const snapshot = prefix.slice(-64);
snapshot.push(
{ id: 'snapshot-latest-user', clientMessageId: 'snapshot-latest-user', role: 'user', timestamp: '2026-09-21T01:00:00.000Z', content: QUESTION },
{ id: 'snapshot-architecture', role: 'assistant', timestamp: '2026-09-21T01:00:01.000Z', content: ARCHITECTURE },
{ id: 'snapshot-boundary', role: 'assistant', timestamp: '2026-09-21T01:00:02.000Z', content: BOUNDARY },
{ id: 'snapshot-child-reply', role: 'assistant', timestamp: '2026-09-21T01:00:03.000Z', content: '已按现有 WMSA51 页面完成职责核对。', crossConversation: {
reply: true, replyToRequestId: 'fixture-child-request', sourceTitle: '页面实现负责人', sourceSessionId: '77777777-2222-4333-8444-555555555555',
} },
);
turn(73, QUESTION, Array.from({ length: 48 }, (_, index) => `native 不应覆盖当前快照的助手片段 ${index}`));
for (let index = 74; index < 242; index++) turn(index, `native 内部输入 ${index}`, [`native 内部回答 ${index}`]);
rows.push({ type: 'event_msg', timestamp: '2026-09-21T02:00:00.000Z', payload: {
type: 'user_message', message: 'native 内部输入尾部', turn_id: 'browser-native-tail',
} });
const rollout = path.join(nativeDir, `rollout-2026-09-21T00-00-00-${THREAD_ID}.jsonl`);
fs.writeFileSync(rollout, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
const notice = {
role: 'system', content: '历史消息过多cc-web 本地快照只保留最近 68 条……', ccwebPersistenceNotice: true,
snapshotMessageCount: 68, snapshotFirstMessageId: snapshot[0].id, snapshotLastMessageId: snapshot.at(-1).id,
nativeThreadId: THREAD_ID, truncatedAt: '2026-09-21T01:00:04.000Z',
};
fs.writeFileSync(path.join(sessions, `${SESSION_ID}.json`), JSON.stringify({
id: SESSION_ID, title: '历史恢复浏览器夹具', agent: 'codexapp', codexAppThreadId: THREAD_ID, cwd: root,
created: '2026-09-21T00:00:00.000Z', updated: '2026-09-21T01:00:04.000Z', permissionMode: 'yolo',
historySnapshotBaseIndex: 80, historySnapshotCount: 68, messages: [notice, ...snapshot],
}, null, 2));
// 只在隔离子进程中替换 homedir不覆盖真实 HOME / CODEX_HOME也不访问生产 rollout。
const preload = path.join(root, 'isolated-home.cjs');
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(filename, 'utf8');
const original = "const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');";
if (!source.includes(original)) throw new Error('原生历史目录注入点已变更,请更新浏览器夹具');
module._compile(source.replace(original, 'const CODEX_SESSIONS_DIR = ' + ${JSON.stringify(JSON.stringify(path.join(home, '.codex', 'sessions')))} + ';'), filename);
};
`);
const mock = path.join(root, 'mock-codex.js');
const completeFlag = path.join(root, 'complete-turn');
const existingMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
const timerCode = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);';
assert(existingMock.includes(timerCode), 'mock 运行时启动入口已变更');
const browserMock = existingMock.replace(timerCode, `
send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-stream',delta:${JSON.stringify(STREAM)}}});
thread.timer = setInterval(() => {
if (!require('fs').existsSync(${JSON.stringify(completeFlag)})) return;
clearInterval(thread.timer);
send({method:'turn/completed',params:{threadId:thread.id,turn:{id:turnId,status:'completed',items:[]}}});
thread.activeTurnId = null;
thread.timer = null;
}, 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 };
}
async function startRelay(port, targetPort, evidence) {
const relay = http.createServer((request, response) => {
const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method, headers: request.headers }, (result) => {
response.writeHead(result.statusCode, result.headers); result.pipe(response);
});
upstream.on('error', () => response.destroy()); request.pipe(upstream);
});
const sockets = new Set();
const wsServer = new WebSocketServer({ noServer: true });
let connections = 0;
relay.on('upgrade', (request, socket, head) => wsServer.handleUpgrade(request, socket, head, (client) => {
connections++;
sockets.add(client);
const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`);
const 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 (['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,
ids: msg.messages?.map((message) => message.id),
});
} 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) => relay.listen(port, '127.0.0.1', resolve));
return {
connectionCount: () => connections,
disconnect: () => { for (const socket of sockets) socket.terminate(); },
close: async () => { for (const socket of sockets) socket.terminate(); wsServer.close(); relay.closeAllConnections(); await new Promise((resolve) => relay.close(resolve)); },
};
}
async function capture(driver, stage, root, requireStream = true) {
await waitFor(() => driver.execute(`return [...document.querySelectorAll('#messages .msg')].some(node => node.textContent.includes(arguments[0]))`, [requireStream ? STREAM : QUESTION]), `${stage} 消息未恢复`);
const result = await driver.execute(`return {
messages: [...document.querySelectorAll('#messages > .msg')].map(node => ({
role: node.classList.contains('user') ? 'user' : node.classList.contains('assistant') ? 'assistant' : 'system',
id: node.dataset.messageId || '', key: node.dataset.messageKey || '', text: node.innerText,
source: node.querySelector('.cross-conversation-label')?.innerText || '', streaming: node.id === 'streaming-msg',
})), running: document.querySelector('#chat-runtime-state')?.textContent || '',
};`);
const find = (text) => result.messages.filter((message) => message.text.includes(text));
for (const text of [QUESTION, ARCHITECTURE, BOUNDARY]) assert.equal(find(text).length, 1, `${stage}:消息重复或消失:${text}`);
const userIndex = result.messages.findIndex((message) => message.text.includes(QUESTION));
assert.equal(result.messages[userIndex].role, 'user');
assert(result.messages[userIndex + 1].text.includes(ARCHITECTURE), `${stage}:用户与对应回复失去邻接关系`);
assert(result.messages[userIndex + 2].text.includes(BOUNDARY));
assert.equal(result.messages[userIndex + 3].role, 'assistant');
assert.equal(result.messages[userIndex + 3].source, '来自「页面实现负责人」的回复');
assert(!result.messages.some((message) => message.text.includes('native 不应覆盖') || message.text.includes('native 内部输入')));
if (requireStream) assert.equal(find(STREAM).length, 1, `${stage}:当前输出重复或丢失`);
const keys = result.messages.map((message) => message.key).filter(Boolean);
assert.equal(new Set(keys).size, keys.length, `${stage}:稳定 ID 重复渲染`);
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`);
// 等待浏览器提交滚动与消息淡入,截图必须展示真实可见内容。
await sleep(500);
result.visibleTail = await driver.execute(`return [...document.querySelectorAll('#messages > .msg')].slice(-6).map(node => {
const box=node.getBoundingClientRect(), style=getComputedStyle(node);
return {text:node.innerText, top:box.top, bottom:box.bottom, height:box.height, opacity:style.opacity, visibility:style.visibility, display:style.display};
});`);
const visibleQuestion = result.visibleTail.find((message) => message.text.includes(QUESTION));
assert(visibleQuestion && visibleQuestion.top >= 0 && visibleQuestion.height > 0 && Number(visibleQuestion.opacity) > 0.9,
`${stage}:目标问题没有实际显示在视口`);
await driver.screenshot(path.join(root, `${stage}.png`));
return result;
}
async function main() {
assert(fs.existsSync(FIREFOX) && fs.existsSync(GECKODRIVER), '需要本机 Firefox 和 geckodriver可通过 CC_WEB_BROWSER_* 指定');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-history-browser-'));
const fixture = createFixture(root);
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;
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_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);
await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败');
relay = await startRelay(relayPort, serverPort, evidence);
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 = new FirefoxDriver(driverPort);
evidence.browser = await driver.start();
await driver.navigate(`http://127.0.0.1:${relayPort}/`);
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.loaded = await capture(driver, 'loaded', root, false);
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();
evidence.stages.refreshed = await capture(driver, 'refreshed', root);
const previousCount = relay.connectionCount();
relay.disconnect();
await waitFor(() => relay.connectionCount() > previousCount, '真实 WebSocket 断线后没有重连');
await waitFor(() => evidence.protocol.some((message) => message.type === 'resume_session_result'), '没有收到恢复会话结果');
evidence.stages.reconnected = await capture(driver, 'reconnected', root);
fs.writeFileSync(fixture.completeFlag, 'complete\n');
await waitFor(() => driver.execute(`return !document.querySelector('#streaming-msg')`), '完成事件后流式气泡未收束');
evidence.stages.completed = await capture(driver, 'completed', root);
await driver.refresh();
evidence.stages.completedRefreshed = await capture(driver, 'completed-refreshed', root);
assert(evidence.protocol.some((message) => message.historySource === 'merged'), '没有验证到服务端真实 native 前缀合并');
evidence.ok = true;
console.log(JSON.stringify({ ok: true, browser: evidence.browser.browserVersion, root, stages: Object.keys(evidence.stages), nativeCount: fixture.nativeCount, snapshotCount: fixture.snapshotCount }, null, 2));
} 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 {
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);
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });

View File

@@ -0,0 +1,346 @@
#!/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}`);
}
// 仅模拟浏览器节点操作,消息身份、对账、缓存合并及重连分支均执行 app.js 原函数。
class Element {
constructor() {
this.dataset = {};
this.children = [];
this.parentNode = null;
this.scrollTop = 0;
this.id = '';
this.classList = { contains: (name) => this.message?.role === name };
}
get scrollHeight() { return this.children.length * 50; }
get nextSibling() {
if (!this.parentNode) return null;
return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null;
}
set innerHTML(value) {
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
}
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;
const nodes = this.parentNode.children;
nodes.splice(nodes.indexOf(this), 1);
this.parentNode = null;
}
replaceWith(node) {
if (!this.parentNode) return;
this.parentNode.insertBefore(node, this);
this.remove();
}
querySelectorAll(selector) {
return this.children.filter((child) => {
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;
});
}
querySelector() { return null; }
}
function createHarness() {
const names = [
'stableMessageHash', 'getStableMessageKey', 'getMessageRenderFingerprint',
'mergeIndexedSessionMessages', 'dedupeSessionMessages', 'markSessionMessageElement',
'mergeHistoryChunkIntoCachedSnapshot', 'mergeHistoryChunkIntoSessionLoadSnapshot',
'findRenderedSessionMessageByKey', 'renderMessages', 'reconcileRenderedSessionMessages',
'prependHistoryMessages', 'bindStreamingAssistantMessage', 'commitCompletedAssistantMessage',
'finishGenerating',
];
const resumeStart = source.indexOf("case 'resume_session_result':");
const resumeEnd = source.indexOf("case 'error':", resumeStart);
const doneStart = source.indexOf("case 'done':");
const doneEnd = source.indexOf("case 'system_message':", doneStart);
const generatingStart = source.indexOf("case 'resume_generating':");
const messagesDiv = new Element();
const api = new Function('Element', 'messagesDiv', `
let renderEpoch = 0;
let closedCollabAgentIds = new Set();
let collabAgentStateCache = new Map();
let collabAgentIdsByToolUseId = new Map();
let closedCollabAgentIdsByToolUseId = new Map();
let currentSessionId = 'running-session';
let currentSessionMessageCount = 0;
let isGenerating = false;
let generatingSessionId = null;
let pendingText = '';
let toolGroupCount = 0;
let hasGrouped = false;
const currentAgent = 'codexapp';
const activeToolCalls = new Map();
const activeTodoCallTargets = new Map();
const window = { pendingContentBlocks: [] };
const msgInput = { focus() {} };
let loadedHistorySessionId = '';
let currentCwd = '/tmp/history-test';
const historyLoadMore = null;
const sessionCache = new Map();
const buffers = new Map();
const timers = [];
let outlineUpdates = 0;
let scrollbarUpdates = 0;
const document = {
getElementById(id) { return messagesDiv.children.find((node) => node.id === id) || null; },
querySelector() { return null; },
createDocumentFragment() { const node = new Element(); node.fragment = true; return node; },
};
function setTimeout(callback) { timers.push(callback); }
function collectClosedCollabAgentIds() { return new Set(); }
function clearUserMessageIndex() {}
function updateUserOutlinePanel() { outlineUpdates += 1; }
function updateScrollbar() { scrollbarUpdates += 1; }
function renderPendingNotes() {}
function scrollToBottom() {}
function syncAssistantBranchButton() {}
function isCurrentSessionEvent(msg) { return msg.sessionId === currentSessionId; }
function clearPendingSessionResumeRequest() {}
function setCurrentSessionRunningState() {}
function updateNoteModeUI() {}
function migratePendingNotesToSession() {}
function migrateQueuedMessagesToSession() {}
function scheduleQueuedMessageDrain() {}
function syncAssistantLastSectionButton() {}
function updateGenerationControls() {}
function isNearBottom() { return true; }
function flushRender() {
const stream = document.getElementById('streaming-msg');
if (stream) stream.pendingText = pendingText;
}
function startGenerating() {
isGenerating = true;
const stream = new Element();
stream.id = 'streaming-msg';
stream.dataset.messageKey = 'local-stream';
messagesDiv.appendChild(stream);
}
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); }
${names.map(extractFunction).join('\n')}
function buildMsgElement(message, index) {
const element = new Element();
element.message = JSON.parse(JSON.stringify(message));
markSessionMessageElement(element, index, message);
return element;
}
function resume(msg) {
switch (msg.type) { ${source.slice(resumeStart, resumeEnd)} }
}
function done(msg) {
switch (msg.type) { ${source.slice(doneStart, doneEnd)} }
}
function resumeGenerating(msg) {
switch (msg.type) { ${source.slice(generatingStart, resumeStart)} }
}
return {
${names.join(',')}, resume, done, resumeGenerating, messagesDiv, sessionCache, buffers,
setCount(value) { currentSessionMessageCount = value; },
count() { return currentSessionMessageCount; },
flushTimers() { while (timers.length) timers.shift()(); },
updates() { return { outlineUpdates, scrollbarUpdates }; },
};
`)(Element, messagesDiv);
api.ids = () => messagesDiv.children.map((node) => node.message?.id || node.id);
return api;
}
function message(id, role = 'assistant', content = id) {
return { id, role, content, timestamp: '2026-09-21T00:00:00.000Z' };
}
{
const api = createHarness();
assert.equal(api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '初稿' }),
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '完成' }));
assert.notEqual(api.getStableMessageKey({ role: 'user', turnId: 'turn-1' }),
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1' }));
assert.equal(api.getStableMessageKey({ id: '已保存', turnId: 'turn-1' }), '已保存');
const before = message('reply');
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, toolCalls: [{ result: '完成' }] }));
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, crossConversation: { sourceTitle: '子对话' } }));
}
for (const cached of [false, true]) {
const api = createHarness();
const snapshot = { sessionId: 'history', messages: [message('user', 'user'), message('assistant')], historyBaseIndex: 7, historyTotal: 10 };
api.buffers.set('history', snapshot);
const merge = (messages, baseIndex) => cached
? api.mergeHistoryChunkIntoCachedSnapshot('history', messages, { baseIndex, total: 10, cursor: 3 })
: api.mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, { baseIndex, total: 10 });
merge([message('older')], 7);
assert.deepEqual(snapshot.messages.map((item) => item.id), ['user', 'older', 'assistant'], '相同下标不能吞掉不同 ID');
merge([message('older')], 7);
assert.equal(snapshot.messages.length, 3, '重复分页不能重复插入');
assert.equal(snapshot.historyMessagePositions.assistant, 8, '碰撞后的再次分页不能挤动原下标');
const updated = { ...message('assistant', 'assistant', '新正文'), toolCalls: [{ result: '工具结果' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
merge([updated], 3);
assert.deepEqual(snapshot.messages.map((item) => item.id), ['assistant', 'user', 'older'], '同 ID 的新位置必须生效');
assert.deepEqual(snapshot.messages[0], updated, '同 ID 的正文、工具和来源必须更新');
assert.equal(snapshot.historyBaseIndex, 3);
}
{
const api = createHarness();
api.renderMessages([message('user', 'user'), message('assistant'), message('assistant', 'assistant', '最终回复')], { immediate: true, baseIndex: 7 });
assert.deepEqual(api.ids(), ['user', 'assistant'], '初始渲染按稳定 ID 去重');
assert.equal(api.messagesDiv.children[1].message.content, '最终回复');
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
assert.deepEqual(api.ids(), ['user', 'older', 'assistant'], '同下标的分页消息不得丢失');
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
assert.equal(api.messagesDiv.children.length, 3);
const updated = { ...message('assistant', 'assistant', '最终回复'), toolCalls: [{ id: 'tool', result: '已更新' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
const counts = api.updates();
api.prependHistoryMessages([updated], { baseIndex: 3 });
assert.deepEqual(api.ids(), ['assistant', 'user', 'older'], '同 ID 跨下标移动必须重排 DOM');
assert.deepEqual(api.messagesDiv.children[0].message, updated, '正文相同时也更新工具和来源');
assert.equal(api.messagesDiv.children[0].dataset.messageIndex, '3');
assert(api.updates().outlineUpdates > counts.outlineUpdates, '只更新消息也需要刷新目录');
}
{
const api = createHarness();
const initial = Array.from({ length: 35 }, (_, index) => message(`batch-${index}`));
api.renderMessages(initial);
const latest = { ...initial[3], content: '重连期间更新的内容', toolCalls: [{ result: '新结果' }] };
api.reconcileRenderedSessionMessages([latest], { baseIndex: 3 });
api.flushTimers();
assert.deepEqual(api.ids(), initial.map((item) => item.id), '延迟渲染与重连对账交错不能重复插入');
assert.equal(api.messagesDiv.children[3].message.content, latest.content, '延迟批次不能把更新恢复成旧内容');
}
{
const api = createHarness();
const user = message('question', 'user', '没参考其他的页面么WMSA51 是你这么写的么?');
const architecture = message('architecture', 'assistant', '页面职责和流程边界需要返工');
const reply = { ...message('reply'), crossConversation: { reply: true, sourceTitle: '旧来源' } };
api.renderMessages([architecture, reply], { immediate: true, baseIndex: 20 });
const stream = new Element();
stream.id = 'streaming-msg';
stream.pendingText = '当前正在生成的消息';
api.messagesDiv.appendChild(stream);
const refreshedReply = { ...reply, crossConversation: { reply: true, sourceTitle: '子对话负责人' }, toolCalls: [{ result: '验收完成' }] };
const snapshot = { sessionId: 'running-session', messages: [architecture, reply], historyBaseIndex: 20, historyTotal: 23 };
api.buffers.set('running-session', snapshot);
const resume = {
type: 'resume_session_result', sessionId: 'running-session', isRunning: true,
historyBaseIndex: 20, historyTotal: 23, messages: [user, architecture, refreshedReply],
};
api.resume(resume);
api.resume(resume);
assert.deepEqual(api.ids(), ['question', 'architecture', 'reply', 'streaming-msg']);
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '运行中重连必须保留原流式节点');
assert.equal(stream.pendingText, '当前正在生成的消息');
assert.equal(api.messagesDiv.children[2].message.crossConversation.sourceTitle, '子对话负责人');
const changed = { ...refreshedReply, content: '回传内容更新', toolCalls: [{ result: '新结果' }] };
api.resume({ ...resume, messages: [user, architecture, changed] });
assert.equal(api.messagesDiv.children[2].message.content, '回传内容更新', '同数量同 ID 的重连也必须更新');
assert.equal(snapshot.messages.find((item) => item.id === 'reply').toolCalls[0].result, '新结果');
assert.equal(api.messagesDiv.children[0].message.role, 'user');
assert.equal(api.messagesDiv.children[2].message.role, 'assistant');
}
{
const api = createHarness();
const question = message('question', 'user');
const completed = { ...message('turn-reply'), toolCalls: [{ id: 'tool', result: '最终结果' }] };
api.renderMessages([question], { immediate: true });
api.setCount(1);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-1', text: '生成中的回复' });
const stream = api.messagesDiv.children.at(-1);
assert.equal(stream.dataset.messageKey, completed.id, '恢复流式状态必须绑定持久化助手 ID');
assert.equal(stream.dataset.turnId, 'turn-1');
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
assert.deepEqual(api.ids(), ['question', 'turn-reply']);
assert.deepEqual(api.messagesDiv.children[1].message, completed, '完成事件必须采用规范化助手消息');
assert.equal(api.count(), 2);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '重复完成事件不能重复增加助手');
assert.equal(api.count(), 2, '重复完成事件不能增加消息计数');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '完成后的重连不得再次插入助手');
}
{
const api = createHarness();
const question = message('question', 'user');
const completed = message('turn-reply', 'assistant', '快照中的最终回复');
api.renderMessages([question, completed], { immediate: true });
api.setCount(2);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-2', text: '仍在生成' });
const stream = api.messagesDiv.children.at(-1);
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '相同助手 ID 的历史气泡与 stream 只能显示一份');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: true, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '流式过程中重复快照不能再建立同 ID 历史气泡');
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '对账仍须保留运行中的原节点');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '停止运行的恢复结果须把 stream 转成同 ID 最终助手');
assert.equal(api.count(), 2);
}
{
const api = createHarness();
const completed = message('already-saved');
api.renderMessages([completed], { immediate: true });
api.setCount(1);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', text: '尚未绑定 ID 的流式输出' });
assert.equal(api.messagesDiv.children.length, 2);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '规范化最终回复' }, assistantMessageIndex: 0 });
assert.deepEqual(api.ids(), ['already-saved'], '完成事件需移除与已保存消息重复的 stream');
assert.equal(api.messagesDiv.children[0].message.content, '规范化最终回复');
assert.equal(api.count(), 1);
}
{
const api = createHarness();
const completed = message('without-stream');
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 3 });
assert.deepEqual(api.ids(), ['without-stream'], '丢失流式节点时完成事件仍可补回最终消息');
assert.equal(api.count(), 4);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '最终内容更新' }, assistantMessageIndex: 3 });
assert.equal(api.messagesDiv.children[0].message.content, '最终内容更新');
assert.equal(api.count(), 4);
}
console.log('前端历史单测通过:稳定 ID、分页、重连、流式身份绑定与完成去重');

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
'use strict';
// 默认预览;--apply 才会让服务端在 idle 状态下备份并原子迁移。
// CC_WEB_PASSWORD=... node scripts/migrate-session-history.js <会话 ID> [--apply]
const WebSocket = require('ws');
const sessionId = process.argv.slice(2).find((arg) => !arg.startsWith('--'));
if (!sessionId || !/^[0-9a-f-]{36}$/i.test(sessionId)) {
console.error('用法node scripts/migrate-session-history.js <会话 ID> [--apply]');
process.exit(1);
}
const serverUrl = process.env.CC_WEB_SERVER_URL || `ws://127.0.0.1:${process.env.PORT || '8002'}/ws`;
const ws = new WebSocket(serverUrl);
const timer = setTimeout(() => finish({ ok: false, code: 'timeout' }), 15000);
let finished = false;
function finish(result) {
if (finished) return;
finished = true;
clearTimeout(timer);
console.log(JSON.stringify(result, null, 2));
process.exitCode = result.ok ? 0 : 1;
ws.terminate();
}
ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', password: process.env.CC_WEB_PASSWORD || '' })));
ws.on('error', (error) => finish({ ok: false, code: 'connection_failed', message: error.message }));
ws.on('close', () => { if (!finished) finish({ ok: false, code: 'connection_closed' }); });
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(String(raw)); } catch { return; }
if (msg.type === 'auth_result') {
if (!msg.success) return finish({ ok: false, code: 'authentication_failed' });
ws.send(JSON.stringify({ type: 'migrate_session_history', sessionId, apply: process.argv.includes('--apply') }));
} else if (msg.type === 'migrate_session_history_result' || msg.type === 'error') {
finish(msg);
}
});

View File

@@ -2818,6 +2818,7 @@ function assertSessionProjectSnapshotContract() {
return new Date(b.updated || 0) - new Date(a.updated || 0);
}
${normalizeGiteaSourceSource}
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
${mergeSource}
return {
mergeSessionListSnapshot,
@@ -3799,6 +3800,9 @@ function assertSessionRenderEpochRaceContract() {
function scrollToBottom() {}
function updateScrollbar() {}
function buildMsgElement(message) { return { id: message.id }; }
function getStableMessageKey(message) { return message.id; }
function findRenderedSessionMessageByKey(key) { return messagesDiv.nodes.find((node) => node.id === key); }
function prependHistoryMessages(messages) { messagesDiv.insertBefore({ __fragment: true, nodes: messages.map(buildMsgElement) }); }
${beginSessionSwitchSource}
${renderMessagesSource}
return {
@@ -3929,6 +3933,7 @@ function assertSessionRequestIdRaceContract() {
function bufferSessionSnapshot() {}
function scheduleAdvancedSearchJump() {}
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
${historyMergeSource}
function handleHistoryMessage(msg) {
switch (msg.type) {
@@ -4174,6 +4179,7 @@ function assertRecoverCurrentHistoryMergeContract() {
function scheduleAdvancedSearchJump() {}
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
${historyMergeSource}
${finalizeLoadedSessionSource}
function handleHistoryMessage(msg) {
@@ -4273,6 +4279,7 @@ function assertPartialSessionSnapshotCacheContract() {
${bufferSource}
${getBufferSource}
${cacheSource}
${['stableMessageHash', 'getStableMessageKey', 'mergeIndexedSessionMessages'].map((name) => extractFunctionSource(frontendSource, name)).join('\n')}
${mergeSource}
${dispositionSource}
return {
@@ -4562,6 +4569,11 @@ function assertServerSessionHistoryRequestIdContract() {
}
function assertHistoryRecallContract() {
for (const script of ['session-history-unit.js', 'codex-rollouts-unit.js', 'history-frontend-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}`);
}
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
@@ -4576,16 +4588,11 @@ function assertHistoryRecallContract() {
assert(frontendSource.includes('function requestOlderHistory'), 'Frontend should request older history on demand');
assert(frontendSource.includes("type: 'load_history_page'"), 'Frontend should use the paged history WebSocket request');
assert(frontendSource.includes('mergeHistoryChunkIntoCachedSnapshot'), 'Frontend should merge history pages into the session cache');
assert(
/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(!extractFunctionSource(frontendSource, 'reconcileRenderedSessionMessages').includes('if (existing.has(messageIndex)) return'),
'Different IDs must not be dropped just because their indexes collide');
assert(frontendSource.includes('function mergeIndexedSessionMessages'), 'History pages should share ID-based merging');
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'),
@@ -4606,8 +4613,8 @@ function assertHistoryRecallContract() {
/messagesDiv\.innerHTML = '';\s*if \(historyLoadMore\) messagesDiv\.appendChild\(historyLoadMore\)/.test(frontendSource),
'Message rerenders should preserve the in-content history control');
assert(
/messagesDiv\.insertBefore\(frag, historyLoadMore\?\.nextSibling \|\| null\)/.test(frontendSource),
'Older message batches should be inserted below the in-content history control');
extractFunctionSource(frontendSource, 'prependHistoryMessages').includes('reconcileRenderedSessionMessages'),
'Older pages should use stable-ID reconciliation while preserving the in-content history control');
const historyHandlerStart = frontendSource.indexOf("case 'session_history_chunk':");
const historyHandlerEnd = frontendSource.indexOf("case 'session_message':", historyHandlerStart);
const historyHandlerSource = historyHandlerStart >= 0 && historyHandlerEnd > historyHandlerStart
@@ -4717,10 +4724,7 @@ function assertHistoryRecallContract() {
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'
);
assert(frontendSource.includes('getMessageRenderFingerprint'), 'History rendering should fingerprint tool results and source metadata too');
const historyMergeStart = serverSource.indexOf('function stableMessageHash');
const historyMergeEnd = serverSource.indexOf('function sessionHistoryCacheKey', historyMergeStart);
@@ -4832,7 +4836,7 @@ function assertCodexAppGoalLifecycleContract() {
assert(
doneBlock.includes('snapshot.isRunning = msg.goalActive === true')
&& doneBlock.includes('finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true })'),
&& /finishGenerating\(msg.sessionId,\s*\{\s*keepRunning: msg.goalActive === true/.test(doneBlock),
'Frontend done handling should keep current and background Goal sessions running between turns'
);
assert(
@@ -4859,7 +4863,7 @@ function assertCodexAppGoalLifecycleContract() {
);
assert(
completeBlock.includes('const goalActive = isCodexAppGoalActive(sessionId)')
&& completeBlock.includes("{ type: 'done', sessionId, costUsd: null, goalActive }")
&& completeBlock.includes("type: 'done', sessionId, costUsd: null, goalActive")
&& completeBlock.includes('if (goalActive)')
&& completeBlock.includes('broadcastSessionList()'),
'Server turn completion should report active Goal state without sending final background completion'

View File

@@ -0,0 +1,167 @@
#!/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 source = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
function extract(name) {
const start = source.indexOf(`function ${name}(`);
assert(start >= 0, `缺少 ${name}`);
return source.slice(start, source.indexOf('\nfunction ', start + 1));
}
const pureSource = source.slice(source.indexOf('function stableMessageHash('), source.indexOf('function sessionHistoryCacheKey('));
const api = new Function('crypto', `${pureSource}; return { ensureStableMessageId, mergeNativeHistoryWithSnapshot, mergeHistorySegments };`)(crypto);
let checks = 0;
function check(name, fn) { fn(); checks++; console.log(`通过:${name}`); }
const question = '没参考其他的页面么WMSA51 是你这么写的么?';
const notice = { role: 'system', ccwebPersistenceNotice: true, content: '旧截断提示' };
const native = Array.from({ length: 483 }, (_, index) => ({ id: `native-${index}`, role: 'assistant', content: `原生历史 ${index}` }));
const snapshot = Array.from({ length: 68 }, (_, index) => ({ id: `ccweb-${index}`, role: index % 2 ? 'assistant' : 'user', content: `快照历史 ${index}` }));
snapshot[0] = { id: 'ccweb-turn', role: 'assistant', codexAppThreadId: 'thread', codexAppTurnId: 'turn-boundary', content: '权威快照首条' };
native[415] = { id: 'native-turn', role: 'assistant', nativeThreadId: 'thread', turnId: 'turn-boundary', content: '原生首条片段' };
snapshot[64] = { id: 'client-final', role: 'user', content: question };
snapshot[65] = { id: 'architecture', role: 'assistant', content: '先按项目规范核对现有页面、任务票和负责会话。' };
snapshot[66] = { id: 'reply', role: 'assistant', content: '子对话回传', replyToRequestId: 'request', crossConversation: { sourceTitle: '原实现负责人' } };
snapshot[67] = { id: 'current-user', role: 'user', content: '继续返工' };
const session = { codexAppThreadId: 'thread', messages: [notice, ...snapshot], historySnapshotBaseIndex: 900 };
check('68 条快照与 483 条原生历史合并后保留权威尾部', () => {
const result = api.mergeNativeHistoryWithSnapshot(session, snapshot, native);
assert.equal(result.confirmed, true);
assert.equal(result.nativePrefixCount, 415);
assert.deepEqual(result.messages.slice(-68).map(m => m.id), snapshot.map(m => m.id));
const index = result.messages.findIndex(m => m.content === question);
assert.equal(result.messages[index + 1].id, 'architecture');
assert.equal(result.messages[index + 2].crossConversation.sourceTitle, '原实现负责人');
assert.equal(result.messages.at(-1).id, 'current-user');
assert(!result.messages.some(m => m.id === 'native-482'));
});
check('错误数组基线和空快照都不能导入未经确认的 native', () => {
assert.equal(api.mergeNativeHistoryWithSnapshot({ historySnapshotBaseIndex: 10 }, [{ role: 'user', content: '不匹配' }], native).confirmed, false);
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [], native).confirmed, false);
});
check('不同来源 ID 可以按完整时间戳和内容摘要确认边界', () => {
const boundary = { id: 'local', role: 'user', content: '唯一问题', timestamp: '2026-09-20T10:00:00.000Z' };
const nativeBoundary = { ...boundary, id: 'native', timestamp: '2026-09-20T18:00:00+08:00' };
const result = api.mergeNativeHistoryWithSnapshot({}, [boundary, snapshot[65]], [native[0], nativeBoundary, native[2]]);
assert.equal(result.confirmed, true);
assert.deepEqual(result.messages.map(m => m.id), ['native-0', 'local', 'architecture']);
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [boundary], [nativeBoundary, nativeBoundary]).confirmed, false);
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [boundary], [{ ...nativeBoundary, timestamp: '' }]).confirmed, false);
});
check('不同线程和分段 turn 不能错误匹配,已有 ID 不被改写', () => {
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [snapshot[0]], [{ ...native[415], nativeThreadId: 'other' }]).confirmed, false);
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [snapshot[0]], [{ ...native[415], nativeTurnSegment: 'initial' }]).confirmed, false);
const normalized = api.ensureStableMessageId(snapshot[0]);
assert.equal(normalized.id, snapshot[0].id);
assert.equal(normalized.turnId, 'turn-boundary');
assert.equal(api.ensureStableMessageId({ role: 'assistant', turnId: 't' }).id, 'history:assistant::t');
});
check('重复回传按请求标识去重且以快照内容为准', () => {
const merged = api.mergeHistorySegments([{ ...snapshot[66], id: 'native-reply', content: '旧回传' }], [snapshot[66], snapshot[66]]);
assert.equal(merged.length, 1);
assert.equal(merged[0].content, '子对话回传');
});
const persistApi = new Function('crypto', `
${pureSource}
${extract('isPersistenceNoticeMessage')}
const SESSION_PERSIST_MAX_MESSAGES = 45;
function sanitizeMessageForPersist(message) { return JSON.parse(JSON.stringify(message)); }
function sanitizePersistValue(value) { return value; }
function normalizeSession(value) { return value; }
${extract('sanitizeMessagesForPersist')}
${extract('sanitizeSessionForPersist')}
return { sanitizeSessionForPersist };
`)(crypto);
check('截断提示保存结构化字段,旧基线不覆盖新基线', () => {
const persisted = persistApi.sanitizeSessionForPersist(session, { maxMessages: 45 });
const metadata = persisted.messages[0];
assert.equal(metadata.ccwebPersistenceNotice, true);
assert.equal(metadata.snapshotMessageCount, 45);
assert.equal(metadata.snapshotFirstMessageId, snapshot[23].id);
assert.equal(metadata.snapshotLastMessageId, snapshot[67].id);
assert.equal(metadata.nativeThreadId, 'thread');
assert(Number.isFinite(Date.parse(metadata.truncatedAt)));
assert.equal(persisted.historySnapshotBaseIndex, 923);
assert.equal(persisted.historySnapshotCount, 45);
const again = persistApi.sanitizeSessionForPersist(persisted, { maxMessages: 45 });
assert.deepEqual(again, persisted);
assert.equal(api.mergeNativeHistoryWithSnapshot({ ...session, messages: [{ ...notice, snapshotFirstMessageId: 'bad' }, ...snapshot] }, snapshot, native).confirmed, false);
});
check('结构化提示不占保留消息名额,新消息后仍刷新边界', () => {
const persisted = persistApi.sanitizeSessionForPersist({ ...session, messages: [notice, ...snapshot.slice(-45)] }, { maxMessages: 45 });
assert.equal(persisted.messages.length, 46);
persisted.messages.push({ id: 'new-user', role: 'user', content: '运行中新消息' });
const next = persistApi.sanitizeSessionForPersist(persisted, { maxMessages: 45 });
assert.equal(next.messages[0].snapshotMessageCount, 45);
assert.equal(next.messages[0].snapshotLastMessageId, 'new-user');
assert.equal(next.historySnapshotBaseIndex, persisted.historySnapshotBaseIndex + 1);
});
check('三个历史出口使用同一 resolver异常边界保持当前快照', () => {
const resolver = new Function('crypto', `
${pureSource}
${extract('isPersistenceNoticeMessage')}
function loadNativeSessionHistory() { return { messages: ${JSON.stringify(native)} }; }
function plog() {}
${extract('resolveSessionHistory')}
return resolveSessionHistory;
`)(crypto);
assert.equal(resolver(session).source, 'merged');
const failed = resolver({ messages: [notice, { id: 'new', role: 'user', content: question }], historySnapshotBaseIndex: 45 });
assert.equal(failed.available, false);
assert.equal(failed.messages[0].content, question);
for (const fn of ['buildSessionInfoPayload', 'handleLoadHistoryPage', 'handleResumeSession']) assert(extract(fn).includes('resolveSessionHistory'));
});
check('迁移只在 idle 执行并精确保留备份,写后可以重新加载', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-history-unit-'));
try {
const id = '35394008-4f0c-43c0-a481-b622d9e6b884';
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', `
${pureSource}
${extract('isPersistenceNoticeMessage')}
let running = true;
let response;
const SESSION_LOAD_MAX_BYTES = 10000000;
const sessionHistoryCache = new Map();
const sessionSearchIndex = { scheduleUpsert() {} };
function scheduleUsageStatisticsUpsert() {}
function sanitizeId(id) { return id; }
function sessionPath(id) { return path.join(SESSIONS_DIR, id + '.json'); }
function isSessionRunning() { return running; }
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 sessionHistoryCacheKey(session) { return session.id; }
function wsSend(ws, result) { response = result; }
function attachClientRequestId(payload) { return payload; }
function findViewingSessionWs() { return null; }
function plog() {}
${extract('writeFileAtomicSync')}
${extract('handleMigrateSessionHistory')}
return { run(id, apply) { handleMigrateSessionHistory(null, { sessionId: id, apply }); return response; }, idle() { running = false; } };
`)(fs, path, crypto, dir, native);
assert.equal(testApi.run(id, true).code, 'session_running');
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
testApi.idle();
assert.equal(testApi.run(id, false).preview, true);
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
const result = testApi.run(id, true);
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'));
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));
assert.equal(testApi.run(id, true).changed, false);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
console.log(`历史服务端回归通过:${checks}`);

290
server.js
View File

@@ -74,6 +74,11 @@ if (process.argv.includes('--ccweb-mcp-server')) {
return;
}
if (process.argv.includes('--migrate-session-history')) {
require('./scripts/migrate-session-history');
return;
}
if (process.argv.includes('--codex-app-worker')) {
require('./lib/codex-app-worker');
return;
@@ -4438,91 +4443,121 @@ function messageReplyToRequestId(message) {
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 turnId = String(message.turnId || message.codexAppTurnId || '').trim();
const output = turnId && !message.turnId ? { ...message, turnId } : message;
if (String(output.id || '').trim()) return output;
const explicitId = String(output.clientMessageId || output.messageId || '').trim();
const replyToRequestId = messageReplyToRequestId(output);
const threadId = output.nativeThreadId || output.codexAppThreadId || '';
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)}` };
|| (turnId ? `history:${output.role || ''}:${threadId}:${turnId}` : '')
|| (output.codexAppTurnKey ? `codexapp:${output.codexAppTurnKey}` : '')
|| (output.nativeTurnKey ? `native:${output.nativeTurnKey}` : '');
if (stableId) return { ...output, id: stableId };
const content = typeof output.content === 'string' ? output.content : JSON.stringify(output.content || '');
const identity = [output.role || '', output.timestamp || output.createdAt || '', content].join('\u001f');
return { ...output, 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 messageIdentityKeys(message, session = {}) {
if (!message || typeof message !== 'object') return [];
const role = message.role || '';
const keys = [message.id, message.clientMessageId, message.messageId]
.map((value) => String(value || '').trim()).filter(Boolean)
.map((value) => `${role}:id:${value}`);
const replyId = messageReplyToRequestId(message);
if (replyId) keys.push(`${role}:reply:${replyId}`);
if (message.codexAppTurnKey) keys.push(`${role}:codexapp:${message.codexAppTurnKey}`);
if (message.nativeTurnKey) keys.push(`${role}:native:${message.nativeTurnKey}`);
const turnId = String(message.turnId || message.codexAppTurnId || '').trim();
const threadId = message.nativeThreadId || message.codexAppThreadId || session.codexAppThreadId || session.codexThreadId || '';
// 同 turn 的引导输入和分段回复是不同气泡,不能仅靠 turnId 将它们吞并。
if (role === 'assistant' && turnId && !message.nativeTurnSegment) {
keys.push(`${role}:turn:${threadId}:${turnId}`);
}
return keys;
}
function mergeHistorySegments(nativePrefix, snapshot) {
function messageContentKey(message) {
if (!message || !message.role) return '';
const timestamp = String(message.timestamp || message.createdAt || '').trim();
const time = Date.parse(timestamp);
const content = typeof message.content === 'string' ? message.content.trim() : JSON.stringify(message.content || '');
if (!timestamp || !Number.isFinite(time) || !content) return '';
return `${message.role}:${time}:${stableMessageHash(content)}`;
}
function messagesEquivalent(left, right, session = {}) {
if (!left || !right || left.role !== right.role) return false;
const rightKeys = new Set(messageIdentityKeys(right, session));
if (messageIdentityKeys(left, session).some((key) => rightKeys.has(key))) return true;
const contentKey = messageContentKey(left);
return !!contentKey && contentKey === messageContentKey(right);
}
function mergeHistorySegments(nativePrefix, snapshot, session = {}) {
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 snapshotKeys = new Set(tail.flatMap((message) => messageIdentityKeys(message, session)));
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;
const positions = new Map();
const earlier = prefix.filter((message) => !messageIdentityKeys(message, session).some((key) => snapshotKeys.has(key)));
for (const message of [...earlier, ...tail]) {
const keys = messageIdentityKeys(message, session);
const position = keys.map((key) => positions.get(key)).find((value) => value !== undefined);
if (position !== undefined) {
merged[position] = message;
keys.forEach((key) => positions.set(key, position));
} else {
keys.forEach((key) => positions.set(key, merged.length));
merged.push(message);
}
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 persisted = (Array.isArray(persistedMessages) ? persistedMessages : []).map(normalizeHistoryMessage);
const snapshot = mergeHistorySegments([], persisted, session);
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 };
const fallback = { messages: snapshot, confirmed: false };
if (snapshot.length === 0 || native.length === 0) return fallback;
// 从尾部寻找快照与 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 notice = (session?.messages || []).find((message) => message?.ccwebPersistenceNotice === true);
const threadId = session?.codexAppThreadId || session?.codexThreadId || session?.claudeSessionId || '';
if (notice?.nativeThreadId && notice.nativeThreadId !== threadId) return fallback;
if (notice?.snapshotFirstMessageId && notice.snapshotFirstMessageId !== snapshot[0]?.id) return fallback;
if (notice?.snapshotMessageCount !== undefined) {
const count = Number(notice.snapshotMessageCount);
if (!Number.isSafeInteger(count) || count <= 0 || count > persisted.length) return fallback;
if (notice.snapshotLastMessageId && notice.snapshotLastMessageId !== persisted[count - 1]?.id) return fallback;
}
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 };
// 只定位快照首条的可靠边界。快照尾部可能包含尚未写入 rollout 的当前输入和回传。
const firstKeys = new Set(messageIdentityKeys(snapshot[0], session));
let matches = native.map((message, index) => ({ message, index }))
.filter(({ message }) => messageIdentityKeys(message, session).some((key) => firstKeys.has(key)));
if (matches.length === 0) {
const fingerprint = messageContentKey(snapshot[0]);
if (!fingerprint) return fallback;
matches = native.map((message, index) => ({ message, index }))
.filter(({ message }) => messageContentKey(message) === fingerprint);
if (matches.length !== 1) return fallback;
} else if (matches.length > 1) {
// 原生记录重复同一个 ID 可以取最早出现处;多个不同 ID 的别名命中不能猜测。
if (new Set(matches.map(({ message }) => message.id)).size !== 1) return fallback;
}
return { messages: snapshot, confirmed: false };
const boundaryIndex = matches[0].index;
return {
messages: mergeHistorySegments(native.slice(0, boundaryIndex), snapshot, session),
confirmed: true,
nativePrefixCount: boundaryIndex,
};
}
function sessionHistoryCacheKey(session) {
@@ -4594,7 +4629,7 @@ function resolveSessionHistory(session) {
const notice = persisted.some(isPersistenceNoticeMessage);
if (!notice) {
return {
messages: persistedMessages.map(normalizeHistoryMessage),
messages: mergeHistorySegments([], persistedMessages.map(normalizeHistoryMessage), session),
source: 'snapshot',
available: true,
recoverable: false,
@@ -4603,7 +4638,7 @@ function resolveSessionHistory(session) {
const native = loadNativeSessionHistory(session);
const merged = mergeNativeHistoryWithSnapshot(session, persistedMessages, native?.messages);
if (merged.confirmed && merged.messages.length >= persistedMessages.length) {
if (merged.confirmed) {
return {
messages: merged.messages,
source: merged.messages.length > persistedMessages.length ? 'merged' : 'snapshot',
@@ -4618,7 +4653,7 @@ function resolveSessionHistory(session) {
nativeCount: Array.isArray(native.messages) ? native.messages.length : 0,
});
}
return { messages: persistedMessages.map(normalizeHistoryMessage), source: 'snapshot', available: false, recoverable: false };
return { messages: mergeHistorySegments([], persistedMessages.map(normalizeHistoryMessage), session), source: 'snapshot', available: false, recoverable: false };
}
function normalizeAgent(agent) {
@@ -5526,17 +5561,27 @@ function sanitizeMessageForPersist(message, limits = {}) {
return output;
}
function sanitizeMessagesForPersist(messages, limits = {}) {
const list = Array.isArray(messages) ? messages : [];
function sanitizeMessagesForPersist(messages, limits = {}, session = {}) {
const source = Array.isArray(messages) ? messages : [];
const previousNotice = source.find(isPersistenceNoticeMessage);
const list = source.filter((message) => !isPersistenceNoticeMessage(message));
const maxMessages = limits.maxMessages || SESSION_PERSIST_MAX_MESSAGES;
const selected = list.length > maxMessages ? list.slice(-maxMessages) : list;
const output = selected.map((message) => sanitizeMessageForPersist(ensureStableMessageId(message), limits));
if (list.length > selected.length) {
if (previousNotice || list.length > selected.length) {
const truncatedAt = list.length > selected.length
? new Date().toISOString()
: previousNotice.truncatedAt || previousNotice.timestamp || new Date().toISOString();
output.unshift({
role: 'system',
content: `历史消息过多,cc-web 本地快照保留最近 ${selected.length};点击顶部“查看更早消息”可从原始会话记录加载省略的 ${list.length - selected.length} 条旧消息`,
timestamp: new Date().toISOString(),
content: `cc-web 本地快照保留最近 ${selected.length}消息;更早历史仅在确认与原始会话的边界后加载`,
timestamp: truncatedAt,
ccwebPersistenceNotice: true,
snapshotMessageCount: output.length,
snapshotFirstMessageId: output[0]?.id || null,
snapshotLastMessageId: output[output.length - 1]?.id || null,
nativeThreadId: session.codexAppThreadId || session.codexThreadId || session.claudeSessionId || previousNotice?.nativeThreadId || null,
truncatedAt,
});
}
return output;
@@ -5571,29 +5616,10 @@ function sanitizeSessionForPersist(session, limits = {}) {
for (const [key, value] of Object.entries(session || {})) {
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;
}
output.messages = sanitizeMessagesForPersist(value, limits, session);
continue;
}
if (key === 'historySnapshotBaseIndex' || key === 'historySnapshotCount') continue;
output[key] = sanitizePersistValue(value, {
maxString: limits.topLevelMaxChars || 16 * 1024,
maxDepth: 4,
@@ -5602,6 +5628,13 @@ function sanitizeSessionForPersist(session, limits = {}) {
});
}
if (!Object.prototype.hasOwnProperty.call(output, 'messages')) output.messages = [];
const sourceMessages = (Array.isArray(session?.messages) ? session.messages : []).filter((message) => !isPersistenceNoticeMessage(message));
const snapshotMessages = output.messages.filter((message) => !isPersistenceNoticeMessage(message));
const existingBaseIndex = Number(session?.historySnapshotBaseIndex);
// 基线只表示 cc-web 的逻辑序列位置,不能拿它直接切割不同粒度的 native 数组。
output.historySnapshotBaseIndex = (Number.isSafeInteger(existingBaseIndex) && existingBaseIndex > 0 ? existingBaseIndex : 0)
+ Math.max(0, sourceMessages.length - snapshotMessages.length);
output.historySnapshotCount = snapshotMessages.length;
return normalizeSession(output);
}
@@ -9762,6 +9795,9 @@ wss.on('connection', (ws, req) => {
case 'load_history_page':
handleLoadHistoryPage(ws, msg);
break;
case 'migrate_session_history':
handleMigrateSessionHistory(ws, msg);
break;
case 'search_sessions':
handleSearchSessions(ws, msg);
break;
@@ -11313,6 +11349,61 @@ function handleLoadHistoryPage(ws, msg = {}) {
}, msg));
}
function handleMigrateSessionHistory(ws, msg = {}) {
const sessionId = sanitizeId(msg.sessionId || '');
const respond = (result) => wsSend(ws, attachClientRequestId({
type: 'migrate_session_history_result', sessionId, ...result,
}, msg));
// 检查、备份和写入在服务端同一同步调用内完成,避免运行任务保存覆盖迁移。
if (isSessionRunning(sessionId)) return respond({ ok: false, code: 'session_running' });
const filePath = sessionPath(sessionId);
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)) {
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) {
return respond({ ok: false, code: 'session_changed' });
}
const backupDir = path.join(SESSIONS_DIR, '_history-backups');
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);
const reloaded = loadSession(sessionId);
if (!reloaded || reloaded.messages.length !== migrated.messages.length) {
writeFileAtomicSync(filePath, original);
return respond({ ok: false, code: 'history_reload_failed', backupPath });
}
sessionHistoryCache.delete(sessionHistoryCacheKey(session));
sessionSearchIndex.scheduleUpsert(sessionId);
scheduleUsageStatisticsUpsert(sessionId);
respond({ ok: true, changed: true, backupPath, ...details });
const viewingWs = findViewingSessionWs(sessionId);
if (viewingWs) wsSend(viewingWs, buildSessionInfoPayload(reloaded));
} catch (error) {
plog('WARN', 'session_history_migration_failed', { sessionId, error: error?.message || String(error) });
respond({ ok: false, code: 'history_migration_failed' });
}
}
function attachActiveRuntimeToWs(ws, sessionId, source = {}) {
if (activeProcesses.has(sessionId)) {
const entry = activeProcesses.get(sessionId);
@@ -11345,6 +11436,11 @@ function attachActiveRuntimeToWs(ws, sessionId, source = {}) {
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));
@@ -14268,7 +14364,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
}
if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) {
const assistantMessage = {
const assistantMessage = ensureStableMessageId({
role: 'assistant',
content: assistantContent,
toolCalls: assistantToolCalls,
@@ -14277,7 +14373,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
codexAppThreadId: entry.threadId || null,
codexAppTurnId: entry.turnId || null,
interrupted: !!options.interrupted,
};
});
const beforeUserMessage = options.beforeUserMessage;
const beforeUserMessageIndex = beforeUserMessage
? session.messages.findIndex((message) => (
@@ -14355,7 +14451,14 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
entry.errorSent = true;
wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
}
wsSend(entry.ws, { type: 'done', sessionId, costUsd: null, goalActive });
const assistantMessage = session?.messages?.find((message) => message.codexAppTurnKey === turnKey);
const assistantMessageIndex = assistantMessage
? resolveSessionHistory(session).messages.findIndex((message) => message.id === assistantMessage.id)
: -1;
wsSend(entry.ws, {
type: 'done', sessionId, costUsd: null, goalActive,
...(assistantMessage ? { assistantMessage: sanitizeMessageForTransport(assistantMessage), assistantMessageIndex } : {}),
});
sendSessionList(entry.ws);
return;
}
@@ -14546,6 +14649,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
}
persistedUserMessage = {
id: userMessageId,
...(clientMessageId ? { clientMessageId } : {}),
role: 'user',
content: textValue,
attachments: savedAttachments,