修复历史合并跳过快照专有气泡并按 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

View File

@@ -42,6 +42,40 @@ check('错误数组基线和空快照都不能导入未经确认的 native', ()
assert.equal(api.mergeNativeHistoryWithSnapshot({ historySnapshotBaseIndex: 10 }, [{ role: 'user', content: '不匹配' }], native).confirmed, false);
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [], native).confirmed, false);
});
check('快照头部的跨对话回传气泡不阻断 native 边界', () => {
const head = {
id: 'reply:head', role: 'assistant', ccwebDisplayOnly: true, timestamp: '2026-09-20T09:00:00.000Z',
content: '线程「子对话」已返回消息:先按规范核对。', crossConversation: { reply: true, replyToRequestId: 'head' },
};
const result = api.mergeNativeHistoryWithSnapshot({}, [head, snapshot[0], snapshot[65]], native);
assert.equal(result.confirmed, true);
assert.equal(result.nativePrefixCount, 415);
assert.deepEqual(result.messages.slice(-3).map(m => m.id), ['reply:head', 'ccweb-turn', 'architecture']);
assert(!result.messages.some(m => m.id === 'native-482'));
});
check('本地用户回显可以跳过,无法对齐的普通消息仍然放弃确认', () => {
const echo = { id: 'user-echo', role: 'user', content: '继续吧', timestamp: '2026-09-20T09:30:00.000Z' };
const skipped = api.mergeNativeHistoryWithSnapshot({}, [echo, snapshot[0]], native);
assert.equal(skipped.confirmed, true);
assert.equal(skipped.nativePrefixCount, 415);
assert.deepEqual(skipped.messages.slice(-2).map(m => m.id), ['user-echo', 'ccweb-turn']);
const orphan = { id: 'orphan', role: 'assistant', content: '没有 native 对应的助手消息' };
assert.equal(api.mergeNativeHistoryWithSnapshot({}, [orphan, snapshot[0]], native).confirmed, false);
});
check('native 前缀同 ID 片段合并、同轮不同片段各自保留', () => {
const fragments = [
{ id: 'frag-1', role: 'assistant', content: '', toolCalls: [{ name: 'grep', id: 'tool-1' }], nativeThreadId: 'thread', turnId: 'turn-frag' },
{ id: 'frag-1', role: 'assistant', content: '同一轮正文', toolCalls: [{ name: 'run', id: 'tool-2' }], nativeThreadId: 'thread', turnId: 'turn-frag' },
{ id: 'frag-2', role: 'assistant', content: '同轮另一段正文', nativeThreadId: 'thread', turnId: 'turn-frag' },
{ id: 'native-turn', role: 'assistant', nativeThreadId: 'thread', turnId: 'turn-boundary', content: '原生首条片段' },
];
const result = api.mergeNativeHistoryWithSnapshot({}, [snapshot[0], snapshot[65]], fragments);
assert.equal(result.confirmed, true);
assert.equal(result.nativePrefixCount, 3);
assert.deepEqual(result.messages.map(m => m.id), ['frag-1', 'frag-2', 'ccweb-turn', 'architecture']);
assert.equal(result.messages[0].content, '同一轮正文');
assert.deepEqual(result.messages[0].toolCalls.map(t => t.id), ['tool-1', 'tool-2']);
});
check('不同来源 ID 可以按完整时间戳和内容摘要确认边界', () => {
const boundary = { id: 'local', role: 'user', content: '唯一问题', timestamp: '2026-09-20T10:00:00.000Z' };
const nativeBoundary = { ...boundary, id: 'native', timestamp: '2026-09-20T18:00:00+08:00' };