修复历史合并跳过快照专有气泡并按 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:
102
server.js
102
server.js
@@ -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 || ''}`;
|
||||
|
||||
Reference in New Issue
Block a user