Files
cc-web/scripts/history-frontend-unit.js
shiyue 69b6db0f5b 修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
2026-10-10 23:40:16 +08:00

594 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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(tagName = '') {
this.tagName = tagName;
this.dataset = {};
this.children = [];
this.parentNode = null;
this.scrollTop = 0;
this.id = '';
this.classes = new Set();
this.listeners = new Map();
this.classList = {
contains: (name) => this.classes.has(name) || this.message?.role === name,
add: (name) => this.classes.add(name),
remove: (name) => this.classes.delete(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.html = value;
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
}
get innerHTML() { return this.html || ''; }
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); }
addEventListener(type, listener) { this.listeners.set(type, listener); }
click() { if (!this.disabled) this.listeners.get('click')?.(); }
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('.user') && child.message?.role !== 'user') return false;
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' };
}
function createPaginationHarness() {
const rendering = createHarness();
const names = [
'createOutlineHistoryState', 'resetOutlineHistoryState', 'buildOutlineHistoryEntry',
'mergeOutlineHistoryMessages', 'syncOutlineHistoryIndex', 'normalizeOutlineTitleHistory',
'formatUserOutlineDate', 'buildUserOutlineItems', 'buildUserOutlineTimelineItems',
'updateUserOutlinePanel', 'resetHistoryLoadState', 'renderHistoryLoadControl',
'syncHistoryLoadControl', 'requestOlderHistory', 'requestOutlineHistoryPage',
];
const historyStart = source.indexOf("case 'session_history_chunk':");
const historyEnd = source.indexOf("case 'session_message':", historyStart);
const errorStart = source.indexOf("case 'error':", historyStart);
const errorEnd = source.indexOf('const matchesActiveLoadError =', errorStart);
assert(historyStart >= 0 && historyEnd > historyStart && errorStart >= 0 && errorEnd > errorStart);
return new Function('Element', 'rendering', `
let currentSessionId = 'running-session';
const loadedHistorySessionId = currentSessionId;
let currentHistoryState = {};
let currentOutlineHistoryState = createOutlineHistoryState(currentSessionId);
let activeHistoryPageRequest = null;
let activeOutlineHistoryRequest = null;
let activeOutlineTargetRequest = null;
let historyPageRequestSeq = 0;
let outlineHistoryRequestSeq = 0;
const activeSessionLoad = null;
let ws = { readyState: 1 };
let wsAuthenticated = true;
const historyLoadMore = new Element();
const userOutlinePanel = new Element();
const userOutlineBtn = new Element();
const messagesDiv = rendering.messagesDiv;
const userMessageIndex = new Map();
const currentOutlineTitleHistory = [];
const requests = [];
const document = { createElement(tagName) { return new Element(tagName); } };
function send(request) { requests.push(request); }
function shortMessagePreview(value, length) { return String(value).slice(0, length); }
function escapeHtml(value) { return String(value); }
function isBlockingSessionLoad() { return false; }
function scheduleAdvancedSearchJump() {}
function prependHistoryMessages(...args) { rendering.prependHistoryMessages(...args); }
function mergeHistoryChunkIntoCachedSnapshot(...args) { rendering.mergeHistoryChunkIntoCachedSnapshot(...args); }
${names.map(extractFunction).join('\n')}
return {
requests, historyLoadMore, userOutlinePanel, userOutlineBtn,
requestOlderHistory, requestOutlineHistoryPage,
reset() {
resetHistoryLoadState(currentSessionId);
resetOutlineHistoryState(currentSessionId);
updateUserOutlinePanel();
},
ids: rendering.ids,
outlineItems: buildUserOutlineItems,
state() { return { history: currentHistoryState, outline: currentOutlineHistoryState,
pageRequest: activeHistoryPageRequest, outlineRequest: activeOutlineHistoryRequest }; },
connect(authenticated = true, readyState = 1, connected = true) {
wsAuthenticated = authenticated;
ws = connected ? { readyState } : null;
},
select(sessionId) { currentSessionId = sessionId; },
init(snapshot) {
resetHistoryLoadState();
resetOutlineHistoryState();
rendering.buffers.set(snapshot.sessionId, { ...snapshot, messages: [...snapshot.messages] });
rendering.renderMessages(snapshot.messages, { immediate: true, baseIndex: snapshot.historyBaseIndex });
syncHistoryLoadControl(snapshot);
syncOutlineHistoryIndex(snapshot);
updateUserOutlinePanel();
},
receive(msg) {
switch (msg.type) {
${source.slice(historyStart, historyEnd)}
${source.slice(errorStart, errorEnd)}
break;
}
},
};
`)(Element, rendering);
}
const savedMessages = Array.from({ length: 57 }, (_, index) =>
message(`saved-${index}`, index < 45 && index % 2 === 0 ? 'user' : 'assistant'));
function savedSnapshot(available = false, cursor = 45) {
return {
sessionId: 'running-session', messages: savedMessages.slice(cursor),
historyBaseIndex: cursor, historyCursor: cursor, historyTotal: savedMessages.length,
historyAvailable: available,
};
}
function historyChunk(request, cursor) {
return {
type: 'session_history_chunk', sessionId: request.sessionId, requestId: request.requestId,
messages: savedMessages.slice(cursor, request.before), historyBaseIndex: cursor,
historyCursor: cursor, historyTotal: savedMessages.length, historyAvailable: false, remaining: 0,
};
}
function historyButtons(api) {
return api.historyLoadMore.children.filter((node) => node.tagName === 'button');
}
function historyCopy(api) {
return api.historyLoadMore.children.filter((node) => node.tagName === 'span')
.map((node) => node.textContent).join(' ');
}
{
const api = createPaginationHarness();
api.init(savedSnapshot());
api.requestOlderHistory();
assert.equal(api.requests.length, 1, '原始来源不可用时仍须请求已保存的45条正文');
assert.match(historyCopy(api), /45 条已保存的更早消息/);
assert.equal(historyButtons(api)[0].disabled, true, '请求期间保留 loading 和按钮禁用');
assert.equal(historyButtons(api)[0].textContent, '正在加载…');
api.requestOlderHistory();
assert.equal(api.requests.length, 1, '重复点击不能重复请求');
const first = api.requests[0];
api.receive({ ...historyChunk(first, 33), requestId: '过期请求' });
api.receive({ ...historyChunk(first, 33), sessionId: '其他会话' });
assert.equal(api.ids().length, 12, '错误会话和过期请求不能插入正文');
while (api.state().pageRequest) {
const request = api.state().pageRequest;
const response = historyChunk(request, Math.max(0, request.before - 12));
api.receive(response);
api.receive(response);
if (api.state().history.cursor > 0) historyButtons(api)[0].click();
}
assert.deepEqual(api.requests.map((request) => request.before), [45, 33, 21, 9]);
assert.deepEqual(api.ids(), savedMessages.map((item) => item.id), '57条已保存正文保持顺序且不重复');
assert.equal(api.state().history.available, false, '来源状态不能伪装成完整恢复');
assert.equal(api.historyLoadMore.hidden, false, '读完已保存消息仍须保留更早记录待核实提示');
assert.equal(historyButtons(api).length, 0, 'cursor=0时不能保留加载按钮');
assert.doesNotMatch(historyCopy(api), /无法恢复/);
}
{
const api = createPaginationHarness();
api.init(savedSnapshot());
assert.equal(api.outlineItems().length, 0, '首屏没有用户提问,用于验证目录入口');
assert.equal(api.userOutlineBtn.disabled, false, '有已保存旧消息时目录按钮不能因来源状态而禁用');
api.requestOutlineHistoryPage();
assert.equal(api.requests.length, 1, '原始来源不可用时仍须索引已保存的45条');
assert.match(api.userOutlinePanel.innerHTML, /正在加载/);
api.requestOutlineHistoryPage();
assert.equal(api.requests.length, 1, '目录索引请求必须去重');
const first = api.requests[0];
api.receive({ ...historyChunk(first, 33), requestId: '过期目录请求' });
api.receive({ ...historyChunk(first, 33), sessionId: '其他会话' });
assert.equal(api.outlineItems().length, 0, '错误会话和过期响应不能污染目录');
while (api.state().outlineRequest) {
const request = api.state().outlineRequest;
const response = historyChunk(request, Math.max(0, request.before - 12));
api.receive(response);
api.receive(response);
}
assert.deepEqual(api.requests.map((request) => request.before), [45, 33, 21, 9]);
assert(api.requests.every((request) => request.purpose === 'outline'));
assert.deepEqual(api.outlineItems().map((item) => item.id),
savedMessages.filter((item) => item.role === 'user').map((item) => item.id),
'目录自动完成全部用户提问索引,顺序正确且无重复');
assert.equal(api.state().outline.complete, true);
assert.equal(api.state().outline.available, false);
assert.match(api.userOutlinePanel.innerHTML, /更早的记录尚待核实/);
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /无法恢复/);
}
for (const outline of [false, true]) {
const api = createPaginationHarness();
api.init(savedSnapshot());
const requestPage = () => outline ? api.requestOutlineHistoryPage() : api.requestOlderHistory();
const state = () => outline ? api.state().outline : api.state().history;
const active = () => outline ? api.state().outlineRequest : api.state().pageRequest;
api.connect(false);
requestPage();
api.connect(true, 0);
requestPage();
api.connect(true, 1, false);
requestPage();
api.connect();
api.select('其他会话');
requestPage();
assert.equal(api.requests.length, 0, '保留认证、连接状态和会话隔离门禁');
api.select('running-session');
requestPage();
assert.equal(api.requests.length, 1);
const failedRequest = active();
api.receive({ type: 'error', sessionId: '其他会话', requestId: failedRequest.requestId, message: '不得生效' });
assert.equal(state().loading, true, '其他会话错误不能取消本次加载');
api.receive({ type: 'error', sessionId: failedRequest.sessionId, requestId: failedRequest.requestId, message: '读取失败,请重试' });
assert.equal(active(), null);
assert.equal(state().loading, false);
assert.equal(state().cursor, 45, '失败后保留游标供重试');
if (outline) {
assert.match(api.userOutlinePanel.innerHTML, /data-outline-retry/);
assert.equal(api.userOutlineBtn.disabled, false);
requestPage();
} else {
assert.equal(historyButtons(api)[0].disabled, false);
assert.equal(historyButtons(api)[0].textContent, '重试');
historyButtons(api)[0].click();
}
assert.equal(api.requests.length, 2, '失败后能够重试已有记录');
assert.notEqual(active().requestId, failedRequest.requestId);
assert.equal(state().error, '');
assert.equal(state().loading, true);
}
for (const available of [false, true]) {
const api = createPaginationHarness();
api.init(savedSnapshot(available, 0));
api.requestOlderHistory();
api.requestOutlineHistoryPage();
assert.equal(api.requests.length, 0, 'cursor=0时两类请求都必须停止');
assert.equal(historyButtons(api).length, 0);
assert.equal(api.historyLoadMore.hidden, available, '完整历史读完隐藏控件;来源待核实则保留提示');
if (available) {
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/);
} else {
assert.match(historyCopy(api), /更早的记录尚待核实/);
assert.match(api.userOutlinePanel.innerHTML, /更早的记录尚待核实/);
assert.doesNotMatch(historyCopy(api), /不完整|未保存|无法恢复/);
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /不完整|未保存|无法恢复/);
}
}
{
const api = createPaginationHarness();
api.reset();
assert.equal(api.historyLoadMore.hidden, true, '收到历史元数据前不能误报更早记录待核实');
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/);
assert.equal(api.userOutlineBtn.disabled, true, '没有已保存消息或待加载记录时保持空目录禁用');
api.init(savedSnapshot(true));
assert.equal(historyButtons(api)[0].disabled, false);
assert.doesNotMatch(historyCopy(api), /待核实/);
assert.doesNotMatch(api.userOutlinePanel.innerHTML, /待核实/);
}
{
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、重连和流式去重');