594 lines
30 KiB
JavaScript
594 lines
30 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(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、重连和流式去重');
|