修复历史边界与流式消息去重并更新发布包

This commit is contained in:
shiyue
2026-09-21 00:38:51 +08:00
parent db04e088bc
commit f8f5ef3a0b
14 changed files with 1733 additions and 423 deletions

View File

@@ -0,0 +1,346 @@
#!/usr/bin/env node
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
function extractFunction(name) {
const start = source.indexOf(`function ${name}(`);
assert(start >= 0, `缺少前端函数 ${name}`);
let signatureEnd = start;
let parentheses = 0;
for (let i = source.indexOf('(', start); i < source.length; i += 1) {
if (source[i] === '(') parentheses += 1;
if (source[i] === ')' && --parentheses === 0) { signatureEnd = i; break; }
}
let depth = 0;
for (let i = source.indexOf('{', signatureEnd); i < source.length; i += 1) {
if (source[i] === '{') depth += 1;
if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1);
}
throw new Error(`无法提取 ${name}`);
}
// 仅模拟浏览器节点操作,消息身份、对账、缓存合并及重连分支均执行 app.js 原函数。
class Element {
constructor() {
this.dataset = {};
this.children = [];
this.parentNode = null;
this.scrollTop = 0;
this.id = '';
this.classList = { contains: (name) => this.message?.role === name };
}
get scrollHeight() { return this.children.length * 50; }
get nextSibling() {
if (!this.parentNode) return null;
return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null;
}
set innerHTML(value) {
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
}
insertBefore(node, anchor) {
if (node.fragment) {
[...node.children].forEach((child) => this.insertBefore(child, anchor));
return node;
}
if (node === anchor) return node;
node.remove();
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
assert(index >= 0, '插入锚点必须仍在消息容器中');
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
appendChild(node) { return this.insertBefore(node, null); }
removeAttribute(name) { if (name === 'id') this.id = ''; }
remove() {
if (!this.parentNode) return;
const nodes = this.parentNode.children;
nodes.splice(nodes.indexOf(this), 1);
this.parentNode = null;
}
replaceWith(node) {
if (!this.parentNode) return;
this.parentNode.insertBefore(node, this);
this.remove();
}
querySelectorAll(selector) {
return this.children.filter((child) => {
if (selector.includes('[data-session-message="true"]') && child.dataset.sessionMessage !== 'true') return false;
if (selector.includes('[data-message-key]') && !child.dataset.messageKey) return false;
return true;
});
}
querySelector() { return null; }
}
function createHarness() {
const names = [
'stableMessageHash', 'getStableMessageKey', 'getMessageRenderFingerprint',
'mergeIndexedSessionMessages', 'dedupeSessionMessages', 'markSessionMessageElement',
'mergeHistoryChunkIntoCachedSnapshot', 'mergeHistoryChunkIntoSessionLoadSnapshot',
'findRenderedSessionMessageByKey', 'renderMessages', 'reconcileRenderedSessionMessages',
'prependHistoryMessages', 'bindStreamingAssistantMessage', 'commitCompletedAssistantMessage',
'finishGenerating',
];
const resumeStart = source.indexOf("case 'resume_session_result':");
const resumeEnd = source.indexOf("case 'error':", resumeStart);
const doneStart = source.indexOf("case 'done':");
const doneEnd = source.indexOf("case 'system_message':", doneStart);
const generatingStart = source.indexOf("case 'resume_generating':");
const messagesDiv = new Element();
const api = new Function('Element', 'messagesDiv', `
let renderEpoch = 0;
let closedCollabAgentIds = new Set();
let collabAgentStateCache = new Map();
let collabAgentIdsByToolUseId = new Map();
let closedCollabAgentIdsByToolUseId = new Map();
let currentSessionId = 'running-session';
let currentSessionMessageCount = 0;
let isGenerating = false;
let generatingSessionId = null;
let pendingText = '';
let toolGroupCount = 0;
let hasGrouped = false;
const currentAgent = 'codexapp';
const activeToolCalls = new Map();
const activeTodoCallTargets = new Map();
const window = { pendingContentBlocks: [] };
const msgInput = { focus() {} };
let loadedHistorySessionId = '';
let currentCwd = '/tmp/history-test';
const historyLoadMore = null;
const sessionCache = new Map();
const buffers = new Map();
const timers = [];
let outlineUpdates = 0;
let scrollbarUpdates = 0;
const document = {
getElementById(id) { return messagesDiv.children.find((node) => node.id === id) || null; },
querySelector() { return null; },
createDocumentFragment() { const node = new Element(); node.fragment = true; return node; },
};
function setTimeout(callback) { timers.push(callback); }
function collectClosedCollabAgentIds() { return new Set(); }
function clearUserMessageIndex() {}
function updateUserOutlinePanel() { outlineUpdates += 1; }
function updateScrollbar() { scrollbarUpdates += 1; }
function renderPendingNotes() {}
function scrollToBottom() {}
function syncAssistantBranchButton() {}
function isCurrentSessionEvent(msg) { return msg.sessionId === currentSessionId; }
function clearPendingSessionResumeRequest() {}
function setCurrentSessionRunningState() {}
function updateNoteModeUI() {}
function migratePendingNotesToSession() {}
function migrateQueuedMessagesToSession() {}
function scheduleQueuedMessageDrain() {}
function syncAssistantLastSectionButton() {}
function updateGenerationControls() {}
function isNearBottom() { return true; }
function flushRender() {
const stream = document.getElementById('streaming-msg');
if (stream) stream.pendingText = pendingText;
}
function startGenerating() {
isGenerating = true;
const stream = new Element();
stream.id = 'streaming-msg';
stream.dataset.messageKey = 'local-stream';
messagesDiv.appendChild(stream);
}
function getSessionHistoryBuffer(id) { return buffers.get(id) || null; }
function isCompleteSessionSnapshot(snapshot) { return snapshot.complete; }
function cacheSessionSnapshot(snapshot) { sessionCache.set(snapshot.sessionId, { snapshot }); }
function bufferSessionSnapshot(snapshot) { buffers.set(snapshot.sessionId, snapshot); }
${names.map(extractFunction).join('\n')}
function buildMsgElement(message, index) {
const element = new Element();
element.message = JSON.parse(JSON.stringify(message));
markSessionMessageElement(element, index, message);
return element;
}
function resume(msg) {
switch (msg.type) { ${source.slice(resumeStart, resumeEnd)} }
}
function done(msg) {
switch (msg.type) { ${source.slice(doneStart, doneEnd)} }
}
function resumeGenerating(msg) {
switch (msg.type) { ${source.slice(generatingStart, resumeStart)} }
}
return {
${names.join(',')}, resume, done, resumeGenerating, messagesDiv, sessionCache, buffers,
setCount(value) { currentSessionMessageCount = value; },
count() { return currentSessionMessageCount; },
flushTimers() { while (timers.length) timers.shift()(); },
updates() { return { outlineUpdates, scrollbarUpdates }; },
};
`)(Element, messagesDiv);
api.ids = () => messagesDiv.children.map((node) => node.message?.id || node.id);
return api;
}
function message(id, role = 'assistant', content = id) {
return { id, role, content, timestamp: '2026-09-21T00:00:00.000Z' };
}
{
const api = createHarness();
assert.equal(api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '初稿' }),
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '完成' }));
assert.notEqual(api.getStableMessageKey({ role: 'user', turnId: 'turn-1' }),
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1' }));
assert.equal(api.getStableMessageKey({ id: '已保存', turnId: 'turn-1' }), '已保存');
const before = message('reply');
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, toolCalls: [{ result: '完成' }] }));
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, crossConversation: { sourceTitle: '子对话' } }));
}
for (const cached of [false, true]) {
const api = createHarness();
const snapshot = { sessionId: 'history', messages: [message('user', 'user'), message('assistant')], historyBaseIndex: 7, historyTotal: 10 };
api.buffers.set('history', snapshot);
const merge = (messages, baseIndex) => cached
? api.mergeHistoryChunkIntoCachedSnapshot('history', messages, { baseIndex, total: 10, cursor: 3 })
: api.mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, { baseIndex, total: 10 });
merge([message('older')], 7);
assert.deepEqual(snapshot.messages.map((item) => item.id), ['user', 'older', 'assistant'], '相同下标不能吞掉不同 ID');
merge([message('older')], 7);
assert.equal(snapshot.messages.length, 3, '重复分页不能重复插入');
assert.equal(snapshot.historyMessagePositions.assistant, 8, '碰撞后的再次分页不能挤动原下标');
const updated = { ...message('assistant', 'assistant', '新正文'), toolCalls: [{ result: '工具结果' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
merge([updated], 3);
assert.deepEqual(snapshot.messages.map((item) => item.id), ['assistant', 'user', 'older'], '同 ID 的新位置必须生效');
assert.deepEqual(snapshot.messages[0], updated, '同 ID 的正文、工具和来源必须更新');
assert.equal(snapshot.historyBaseIndex, 3);
}
{
const api = createHarness();
api.renderMessages([message('user', 'user'), message('assistant'), message('assistant', 'assistant', '最终回复')], { immediate: true, baseIndex: 7 });
assert.deepEqual(api.ids(), ['user', 'assistant'], '初始渲染按稳定 ID 去重');
assert.equal(api.messagesDiv.children[1].message.content, '最终回复');
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
assert.deepEqual(api.ids(), ['user', 'older', 'assistant'], '同下标的分页消息不得丢失');
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
assert.equal(api.messagesDiv.children.length, 3);
const updated = { ...message('assistant', 'assistant', '最终回复'), toolCalls: [{ id: 'tool', result: '已更新' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
const counts = api.updates();
api.prependHistoryMessages([updated], { baseIndex: 3 });
assert.deepEqual(api.ids(), ['assistant', 'user', 'older'], '同 ID 跨下标移动必须重排 DOM');
assert.deepEqual(api.messagesDiv.children[0].message, updated, '正文相同时也更新工具和来源');
assert.equal(api.messagesDiv.children[0].dataset.messageIndex, '3');
assert(api.updates().outlineUpdates > counts.outlineUpdates, '只更新消息也需要刷新目录');
}
{
const api = createHarness();
const initial = Array.from({ length: 35 }, (_, index) => message(`batch-${index}`));
api.renderMessages(initial);
const latest = { ...initial[3], content: '重连期间更新的内容', toolCalls: [{ result: '新结果' }] };
api.reconcileRenderedSessionMessages([latest], { baseIndex: 3 });
api.flushTimers();
assert.deepEqual(api.ids(), initial.map((item) => item.id), '延迟渲染与重连对账交错不能重复插入');
assert.equal(api.messagesDiv.children[3].message.content, latest.content, '延迟批次不能把更新恢复成旧内容');
}
{
const api = createHarness();
const user = message('question', 'user', '没参考其他的页面么,WMSA51 是你这么写的么?');
const architecture = message('architecture', 'assistant', '页面职责和流程边界需要返工');
const reply = { ...message('reply'), crossConversation: { reply: true, sourceTitle: '旧来源' } };
api.renderMessages([architecture, reply], { immediate: true, baseIndex: 20 });
const stream = new Element();
stream.id = 'streaming-msg';
stream.pendingText = '当前正在生成的消息';
api.messagesDiv.appendChild(stream);
const refreshedReply = { ...reply, crossConversation: { reply: true, sourceTitle: '子对话负责人' }, toolCalls: [{ result: '验收完成' }] };
const snapshot = { sessionId: 'running-session', messages: [architecture, reply], historyBaseIndex: 20, historyTotal: 23 };
api.buffers.set('running-session', snapshot);
const resume = {
type: 'resume_session_result', sessionId: 'running-session', isRunning: true,
historyBaseIndex: 20, historyTotal: 23, messages: [user, architecture, refreshedReply],
};
api.resume(resume);
api.resume(resume);
assert.deepEqual(api.ids(), ['question', 'architecture', 'reply', 'streaming-msg']);
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '运行中重连必须保留原流式节点');
assert.equal(stream.pendingText, '当前正在生成的消息');
assert.equal(api.messagesDiv.children[2].message.crossConversation.sourceTitle, '子对话负责人');
const changed = { ...refreshedReply, content: '回传内容更新', toolCalls: [{ result: '新结果' }] };
api.resume({ ...resume, messages: [user, architecture, changed] });
assert.equal(api.messagesDiv.children[2].message.content, '回传内容更新', '同数量同 ID 的重连也必须更新');
assert.equal(snapshot.messages.find((item) => item.id === 'reply').toolCalls[0].result, '新结果');
assert.equal(api.messagesDiv.children[0].message.role, 'user');
assert.equal(api.messagesDiv.children[2].message.role, 'assistant');
}
{
const api = createHarness();
const question = message('question', 'user');
const completed = { ...message('turn-reply'), toolCalls: [{ id: 'tool', result: '最终结果' }] };
api.renderMessages([question], { immediate: true });
api.setCount(1);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-1', text: '生成中的回复' });
const stream = api.messagesDiv.children.at(-1);
assert.equal(stream.dataset.messageKey, completed.id, '恢复流式状态必须绑定持久化助手 ID');
assert.equal(stream.dataset.turnId, 'turn-1');
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
assert.deepEqual(api.ids(), ['question', 'turn-reply']);
assert.deepEqual(api.messagesDiv.children[1].message, completed, '完成事件必须采用规范化助手消息');
assert.equal(api.count(), 2);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '重复完成事件不能重复增加助手');
assert.equal(api.count(), 2, '重复完成事件不能增加消息计数');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '完成后的重连不得再次插入助手');
}
{
const api = createHarness();
const question = message('question', 'user');
const completed = message('turn-reply', 'assistant', '快照中的最终回复');
api.renderMessages([question, completed], { immediate: true });
api.setCount(2);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-2', text: '仍在生成' });
const stream = api.messagesDiv.children.at(-1);
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '相同助手 ID 的历史气泡与 stream 只能显示一份');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: true, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '流式过程中重复快照不能再建立同 ID 历史气泡');
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '对账仍须保留运行中的原节点');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '停止运行的恢复结果须把 stream 转成同 ID 最终助手');
assert.equal(api.count(), 2);
}
{
const api = createHarness();
const completed = message('already-saved');
api.renderMessages([completed], { immediate: true });
api.setCount(1);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', text: '尚未绑定 ID 的流式输出' });
assert.equal(api.messagesDiv.children.length, 2);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '规范化最终回复' }, assistantMessageIndex: 0 });
assert.deepEqual(api.ids(), ['already-saved'], '完成事件需移除与已保存消息重复的 stream');
assert.equal(api.messagesDiv.children[0].message.content, '规范化最终回复');
assert.equal(api.count(), 1);
}
{
const api = createHarness();
const completed = message('without-stream');
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 3 });
assert.deepEqual(api.ids(), ['without-stream'], '丢失流式节点时完成事件仍可补回最终消息');
assert.equal(api.count(), 4);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '最终内容更新' }, assistantMessageIndex: 3 });
assert.equal(api.messagesDiv.children[0].message.content, '最终内容更新');
assert.equal(api.count(), 4);
}
console.log('前端历史单测通过:稳定 ID、分页、重连、流式身份绑定与完成去重');