修复历史合并跳过快照专有气泡并按 ID 合并片段

- mergeNativeHistoryWithSnapshot 的边界定位改为 locateNativeBoundaryIndex,
  允许跳过快照头部只在 cc-web 存在的气泡(ccwebDisplayOnly 回传显示、
  无 native 身份的本地用户回显),遇到无法对齐的普通消息仍然放弃确认。
- native 前缀不再按 turn 别名折叠,改为 mergeNativePrefixWithSnapshot 按自身 ID
  去重,同 ID 片段用 combineNativeFragments 合并正文与工具调用,避免前端按稳定
  ID 去重丢正文。
- session-history-unit 新增 3 条断言,历史服务端回归 12 项通过。
- 重新构建 dist-exe/cc-web-bun-linux-x64-baseline.tar.gz(bun 1.4.2 baseline)。
This commit is contained in:
shiyue
2026-09-21 10:58:59 +08:00
parent ac00f4e67d
commit 5e9bda7984
5 changed files with 135 additions and 16 deletions

102
server.js
View File

@@ -4538,28 +4538,98 @@ function mergeNativeHistoryWithSnapshot(session, persistedMessages, nativeMessag
if (notice.snapshotLastMessageId && notice.snapshotLastMessageId !== persisted[count - 1]?.id) return fallback;
}
// 只定位快照首条的可靠边界。快照尾部可能包含尚未写入 rollout 的当前输入和回传。
const firstKeys = new Set(messageIdentityKeys(snapshot[0], session));
let matches = native.map((message, index) => ({ message, index }))
.filter(({ message }) => messageIdentityKeys(message, session).some((key) => firstKeys.has(key)));
if (matches.length === 0) {
const fingerprint = messageContentKey(snapshot[0]);
if (!fingerprint) return fallback;
matches = native.map((message, index) => ({ message, index }))
.filter(({ message }) => messageContentKey(message) === fingerprint);
if (matches.length !== 1) return fallback;
} else if (matches.length > 1) {
// 原生记录重复同一个 ID 可以取最早出现处;多个不同 ID 的别名命中不能猜测。
if (new Set(matches.map(({ message }) => message.id)).size !== 1) return fallback;
}
const boundaryIndex = matches[0].index;
// 只定位快照头部的可靠边界。快照尾部可能包含尚未写入 rollout 的当前输入和回传。
// 快照头部可能是 cc-web 专有气泡(跨对话回传显示、本地用户回显),它们不会以同一条
// 记录出现在 rollout 里,因此允许跳过它们继续对齐;遇到无法对齐的普通消息仍然放弃确认。
const boundaryIndex = locateNativeBoundaryIndex(session, snapshot, native);
if (boundaryIndex === null) return fallback;
return {
messages: mergeHistorySegments(native.slice(0, boundaryIndex), snapshot, session),
messages: mergeNativePrefixWithSnapshot(native.slice(0, boundaryIndex), snapshot),
confirmed: true,
nativePrefixCount: boundaryIndex,
};
}
// 跨对话回传显示气泡只存在于 cc-web 快照;本地用户回显由 cc-web 记时,rollout 另有同名记录。
function isSnapshotOnlyMessage(message) {
if (!message || typeof message !== 'object') return false;
if (message.ccwebDisplayOnly === true) return true;
if (message.role !== 'user') return false;
const turnId = String(message.turnId || message.codexAppTurnId || '').trim();
return !message.nativeThreadId && !message.nativeTurnKey && !turnId;
}
// 在 native 中定位与单条快照消息等价的位置;命中不唯一时返回 null,不猜测边界。
function matchNativeBoundaryIndex(session, native, message) {
const keys = new Set(messageIdentityKeys(message, session));
let matches = native.map((candidate, index) => ({ candidate, index }))
.filter(({ candidate }) => messageIdentityKeys(candidate, session).some((key) => keys.has(key)));
if (matches.length === 0) {
const fingerprint = messageContentKey(message);
if (!fingerprint) return null;
matches = native.map((candidate, index) => ({ candidate, index }))
.filter(({ candidate }) => messageContentKey(candidate) === fingerprint);
if (matches.length !== 1) return null;
return matches[0].index;
}
// 原生记录重复同一个 ID 可以取最早出现处;多个不同 ID 的别名命中不能猜测。
if (matches.length > 1 && new Set(matches.map(({ candidate }) => candidate.id)).size !== 1) return null;
return matches[0].index;
}
function locateNativeBoundaryIndex(session, snapshot, native) {
for (let index = 0; index < snapshot.length; index += 1) {
const matched = matchNativeBoundaryIndex(session, native, snapshot[index]);
if (matched !== null) return matched;
if (!isSnapshotOnlyMessage(snapshot[index])) return null;
}
return null;
}
// native 前缀里同一条气泡会被解析器拆成多条记录(同一 turn 段的空文本工具片段),
// 合并成一条才能保住正文;同一 turn 的不同 ID 片段是不同气泡,必须各自保留。
function combineNativeFragments(fragments) {
if (fragments.length === 1) return fragments[0];
const content = fragments
.map((fragment) => String(fragment?.content || ''))
.filter((text) => text.trim())
.join('\n\n');
const toolCalls = [];
const seenToolIds = new Set();
fragments.forEach((fragment) => {
(Array.isArray(fragment?.toolCalls) ? fragment.toolCalls : []).forEach((call) => {
const callId = String(call?.id || '').trim()
|| stableMessageHash(JSON.stringify([call?.name || '', call?.input ?? null]));
if (seenToolIds.has(callId)) return;
seenToolIds.add(callId);
toolCalls.push(call);
});
});
return { ...fragments[0], content, toolCalls };
}
// 快照尾部是权威顺序,native 只补充更早的历史;同 ID 命中快照时以快照为准,
// 前缀内部同 ID 的片段合并成一条,避免前端按稳定 ID 去重时丢掉正文。
function mergeNativePrefixWithSnapshot(nativePrefix, snapshot) {
const prefix = Array.isArray(nativePrefix) ? nativePrefix : [];
const tail = Array.isArray(snapshot) ? snapshot : [];
const tailIds = new Set(tail.map((message) => String(message?.id || '').trim()).filter(Boolean));
const positions = new Map();
const merged = [];
prefix.forEach((message) => {
const id = String(message?.id || '').trim();
if (id && tailIds.has(id)) return;
const position = id ? positions.get(id) : undefined;
if (position !== undefined) {
merged[position] = combineNativeFragments([merged[position], message]);
return;
}
if (id) positions.set(id, merged.length);
merged.push(message);
});
return [...merged, ...tail];
}
function sessionHistoryCacheKey(session) {
if (!session) return '';
if (isCodexLikeSession(session)) return `${getSessionAgent(session)}:${session.codexAppThreadId || session.codexThreadId || ''}`;