修复历史边界与流式消息去重并更新发布包
This commit is contained in:
@@ -6,3 +6,12 @@
|
||||
- codebase-memory 项目 `home-cc-web` 索引状态为 ready(8208 nodes / 19300 edges)。
|
||||
- 工作区存在用户已有未提交改动:`lib/ccweb-mcp-server.js`、`scripts/ccweb-message-reply-unit.js`,本任务不触碰。
|
||||
|
||||
|
||||
- 2026-09-21:db04e08 已删除全量 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 写入。
|
||||
|
||||
@@ -5,3 +5,18 @@
|
||||
- 2026-09-20:rollout 解析按 `task_started` / `turn_context` / 完成事件聚合同一 turn,保留 function/custom tool,过滤跨对话内部 user 输入。
|
||||
- 2026-09-20:前端缓存、DOM、重连和实时消息按稳定键去重,普通用户消息把本地 ID 作为 `clientMessageId` 发给服务端。
|
||||
- 2026-09-20:通过 `node --check`、完整 `npm run regression`、`history-recall`、`session-switch-race`、既有单测和 `git diff --check`。
|
||||
|
||||
- 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/bun(1.4.2)开始构建发布包;Firefox 隔离浏览器验证执行中。
|
||||
|
||||
- 2026-09-21:CentOS 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。
|
||||
|
||||
@@ -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 同步跟踪。
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
335
public/app.js
335
public/app.js
@@ -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 ---
|
||||
|
||||
@@ -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/\`:前端静态资源
|
||||
|
||||
195
scripts/codex-rollouts-unit.js
Normal file
195
scripts/codex-rollouts-unit.js
Normal 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\nrequestId:request-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');
|
||||
});
|
||||
297
scripts/history-browser-check.js
Normal file
297
scripts/history-browser-check.js
Normal 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; });
|
||||
346
scripts/history-frontend-unit.js
Normal file
346
scripts/history-frontend-unit.js
Normal 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、分页、重连、流式身份绑定与完成去重');
|
||||
37
scripts/migrate-session-history.js
Normal file
37
scripts/migrate-session-history.js
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
167
scripts/session-history-unit.js
Normal file
167
scripts/session-history-unit.js
Normal 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
290
server.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user