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