#!/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、分页、重连、流式身份绑定与完成去重');