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