修复 Codex rollout 历史合并并重新打包

This commit is contained in:
shiyue
2026-09-20 19:34:30 +08:00
parent 9242e79520
commit db04e088bc
11 changed files with 615 additions and 49 deletions

View 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` 索引状态为 ready8208 nodes / 19300 edges
- 工作区存在用户已有未提交改动:`lib/ccweb-mcp-server.js``scripts/ccweb-message-reply-unit.js`,本任务不触碰。

View 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-20rollout 解析按 `task_started` / `turn_context` / 完成事件聚合同一 turn保留 function/custom tool过滤跨对话内部 user 输入。
- 2026-09-20前端缓存、DOM、重连和实时消息按稳定键去重普通用户消息把本地 ID 作为 `clientMessageId` 发给服务端。
- 2026-09-20通过 `node --check`、完整 `npm run regression``history-recall``session-switch-race`、既有单测和 `git diff --check`

View 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下一用户消息 flushturn 完成/失败 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 快照;合并失败时返回快照。
- 回传消息保留来源标识,不能伪装成普通用户气泡。
- 所有可观测失败记录简短告警,不阻断当前会话展示。
## 错误记录
| 错误 | 尝试 | 处理 |
|---|---|---|

View File

@@ -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'],

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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]*结果)/,

View File

@@ -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
View File

@@ -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);

View 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,,
1 id item status done_at notes
2 1 定位工具定义、提示词来源和异步回传协议并确认现状 DONE 2026-09-18T15:42:00+08:00 确认问题是提示词语义不够明确,不是服务端同步阻塞
3 2 增加会失败的提示词与异步协议回归断言 DONE 2026-09-18T15:45:00+08:00 覆盖立即返回、禁止 sleep/轮询、两种 replyMode 和既有回传链路
4 3 修改 ccweb_send_message 提示词并明确异步回传及状态语义 DONE 2026-09-18T15:46:00+08:00 明确 return_and_continue 不等于当前调用等待,结果由系统异步回传
5 4 运行延迟回传及 one_way/return_and_continue 协议回归测试 IN_PROGRESS 先运行消息回传单测,再运行项目 MCP 回归目标
6 5 确认服务加载新提示词并清理临时清单 TODO 需检查运行中会话后再重启 ccweb
7 6 汇总修复结果与验证证据 TODO