#!/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、重连和流式去重');