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

@@ -15,3 +15,10 @@
- response_item 中存在 AGENTS/environment 等用户角色上下文;有 event_msg.user_message 时以事件为权威,仅从匹配 response 补充 ID。
- done 原来不携带持久化助手身份,结束后的本地 stream 与刷新消息无法去重;现已将最终消息及合并后下标通过 done 传递。
- 新迁移能力通过已认证 WebSocket 在服务端同步检查/备份/写入;单文件提供 --migrate-session-history 入口,默认预览,--apply 写入。
## 2026-09-21 旧会话迁移复盘
- `sessions/35394008-…json` 的截断提示结构化字段完整,但仍报 `history_boundary_unconfirmed`:快照首条是 cc-web 专有的跨对话回传显示气泡(`ccwebDisplayOnly`),rollout 里不存在对应记录,旧规则只认首条锚点就直接放弃确认。
- mergeHistorySegments 的 turn 别名键会把 native 前缀里同一 turn 的多条气泡折叠成一条,实测丢掉 9 条助手正文(2537 字);native 前缀必须按自身 ID 去重,不能按 turn 去重。
- rollout 解析器会把同一条气泡拆成多条空文本工具片段且共用同一 `history-assistant:<hash>` ID,前端按稳定 ID 去重时只保留最后一条;前缀侧需要先按 ID 合并片段(正文拼接、工具调用并集)再交给前端。
- 迁移后文件不再带截断提示,未升级的旧服务进程也会走 snapshot 分支把 739 条全部展示,historyAvailable 为 true,因此迁移不必等待服务重启。

View File

@@ -22,3 +22,11 @@
- 本轮目标在异机,未迁移或重启生产会话;发布包提供 idle 安全迁移工具。准备提交全部源码、测试、计划及发布包并推送 main。
- 2026-09-21:修复、测试和发布包已提交 f8f5ef3 并推送 origin/main;全部步骤完成,已清理本轮临时 CSV。
## 2026-09-21 旧会话实际迁移
- 渲染侧确认问题:目标会话默认视图只剩最后 12 条气泡,顶部提示「还有 68 条更早消息,但原始记录不可用,无法恢复」,用户输入气泡全部落在无法加载的历史里。
- 边界规则改为允许跳过快照头部的 cc-web 专有气泡(跨对话回传显示、本地用户回显),遇到无法对齐的普通消息仍然放弃确认;native 前缀改用按 ID 合并片段的 mergeNativePrefixWithSnapshot。
- 单测新增 2 项(头部专有气泡跳过、前缀同 ID 片段合并),历史服务端回归 12 项通过;全量 npm run regression 结果见本轮日志。
- 官方迁移入口走运行中的旧进程仍会拒绝,改用等价离线流程(同 resolver、同 idle 前置、同备份目录、同原子写入与回读校验)迁移目标会话:80 条快照 + 1111 条 native 前缀 = 739 条,备份写入 sessions/_history-backups。
- 迁移后校验:739 条消息、0 重复 ID、时间单调、91 条用户气泡、用户原问题与对应助手回复邻接;浏览器实测加载更多可用,用户气泡可见;其他会话文件未被改写。

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

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 || ''}`;