修复会话切换兼容并重新打包发布
This commit is contained in:
@@ -671,7 +671,7 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260911-file-suggestion')
|
||||
indexSource.includes('style.css?v=20260913-history-control-position')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
@@ -1190,7 +1190,7 @@ function assertPlanListProgressContract() {
|
||||
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
|
||||
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Plan progress frontend logic should use the dynamic cache-busted URL');
|
||||
}
|
||||
|
||||
@@ -1306,7 +1306,7 @@ function assertFrontendGildedThemeContract() {
|
||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||||
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Theme bundle app script should use the dynamic cache-busted asset URL');
|
||||
}
|
||||
|
||||
@@ -1560,7 +1560,7 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Wasteland registration should share the dynamic cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
@@ -2616,7 +2616,7 @@ function assertGoalModeTitleContract() {
|
||||
assert(styleSource.includes('.msg.user.goal-message .msg-bubble'), 'Goal label styling should remain scoped to user Goal bubbles');
|
||||
assert(styleSource.includes('.goal-message-label'), 'Goal mode label should have a dedicated style');
|
||||
assert(styleSource.includes("html[data-theme='wasteland'] .msg.user.goal-message .goal-message-label"), 'Wasteland should provide a dedicated Goal label treatment');
|
||||
assert(indexSource.includes('style.css?v=20260911-file-suggestion'), 'Goal label CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Goal label CSS should use the current cache-busted URL');
|
||||
assert(serverSource.includes('GOAL_DERIVED_TITLE_MAX_CHARS = 60'), 'Goal-derived titles should keep the 60-character limit');
|
||||
assert(serverSource.includes('function isDefaultConversationTitle(session)'), 'Goal title updates should use a default-title guard');
|
||||
assert(serverSource.includes('function applyDerivedGoalConversationTitle(session, objective)'), 'Goal title updates should stay in the /goal-specific server path');
|
||||
@@ -3525,6 +3525,27 @@ function assertSessionSwitchResilienceContract() {
|
||||
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
|
||||
|
||||
assert(frontendSource.includes('SESSION_LOAD_REQUEST_TIMEOUT_MS'), 'Frontend should define a hard timeout for session load requests');
|
||||
assert(
|
||||
frontendSource.includes('msg.features?.clientHeartbeat === true')
|
||||
&& frontendSource.includes('!wsSupportsClientHeartbeat'),
|
||||
'Frontend should only send application heartbeats when the server advertises support'
|
||||
);
|
||||
assert(
|
||||
frontendSource.includes('let activeAssigned = false')
|
||||
&& frontendSource.includes("el.classList.toggle('active', isActive)"),
|
||||
'Sidebar highlighting should keep at most one active session row'
|
||||
);
|
||||
assert(
|
||||
/Unknown type:\\s\*client_heartbeat/.test(frontendSource),
|
||||
'Frontend should ignore the legacy server heartbeat compatibility error'
|
||||
);
|
||||
assert(
|
||||
frontendSource.includes('WS_CLIENT_HEARTBEAT_INTERVAL_MS')
|
||||
&& frontendSource.includes('function startWsHeartbeat')
|
||||
&& frontendSource.includes("type: 'client_heartbeat'")
|
||||
&& frontendSource.includes("case 'client_heartbeat_ack':"),
|
||||
'Frontend should actively detect an OPEN-but-stale WebSocket before session load timeout'
|
||||
);
|
||||
assert(frontendSource.includes('sessionLoadRequestTimer'), 'Frontend should track the session load request timeout timer');
|
||||
assert(frontendSource.includes('function clearPendingSessionSwitchRequest'), 'Frontend should be able to cancel stale pending session switch requests');
|
||||
assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should schedule cancellation for stuck load_session requests');
|
||||
@@ -3594,6 +3615,14 @@ function assertSessionSwitchResilienceContract() {
|
||||
|
||||
assert(serverSource.includes('SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS'), 'Server should cap session message size for WebSocket transport');
|
||||
assert(serverSource.includes("case 'resume_session':"), 'Server should accept lightweight resume_session requests');
|
||||
assert(
|
||||
/case 'client_heartbeat':\s*wsSend\(ws,\s*\{[\s\S]*?type:\s*'client_heartbeat_ack'/.test(serverSource),
|
||||
'Server should acknowledge the client application heartbeat'
|
||||
);
|
||||
assert(
|
||||
serverSource.includes('clientHeartbeat: true'),
|
||||
'Server auth_result should advertise client heartbeat support'
|
||||
);
|
||||
assert(serverSource.includes('function handleResumeSession'), 'Server should implement lightweight running-session resume');
|
||||
assert(serverSource.includes('function attachActiveRuntimeToWs'), 'Server should share runtime re-attach logic without sending session_info first');
|
||||
assert(/case 'abort':\s*handleAbort\(ws, msg\);/.test(serverSource), 'Server should pass abort request metadata to handleAbort');
|
||||
@@ -3642,6 +3671,7 @@ function assertSessionRenderEpochRaceContract() {
|
||||
let collabAgentStateCache = new Map();
|
||||
let collabAgentIdsByToolUseId = new Map();
|
||||
let closedCollabAgentIdsByToolUseId = new Map();
|
||||
const historyLoadMore = null;
|
||||
const scheduled = [];
|
||||
const messagesDiv = {
|
||||
nodes: [],
|
||||
@@ -3721,6 +3751,7 @@ function assertSessionRenderEpochRaceContract() {
|
||||
|
||||
function assertSessionRequestIdRaceContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const historyMergeSource = extractFunctionSource(frontendSource, 'mergeHistoryChunkIntoSessionLoadSnapshot');
|
||||
const sessionInfoStart = frontendSource.indexOf("case 'session_info':");
|
||||
const sessionInfoDecisionStart = frontendSource.indexOf('const messageRequestId', sessionInfoStart);
|
||||
const sessionInfoDecisionEnd = frontendSource.indexOf('if (!canSwitchToSessionInfo)', sessionInfoDecisionStart);
|
||||
@@ -3795,13 +3826,21 @@ function assertSessionRequestIdRaceContract() {
|
||||
};
|
||||
let currentSessionId = 'session-b';
|
||||
let loadedHistorySessionId = 'session-b';
|
||||
let activeOutlineHistoryRequest = null;
|
||||
let activeOutlineTargetRequest = null;
|
||||
const currentOutlineHistoryState = { sessionId: 'session-b', entries: new Map(), cursor: 0, total: 0, available: true };
|
||||
const prepended = [];
|
||||
const finalized = [];
|
||||
function cloneMessages(messages) { return messages.slice(); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||
function mergeOutlineHistoryMessages() {}
|
||||
function requestOutlineHistoryPage() {}
|
||||
function isCompleteSessionSnapshot() { return false; }
|
||||
function bufferSessionSnapshot() {}
|
||||
function scheduleAdvancedSearchJump() {}
|
||||
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
|
||||
${historyMergeSource}
|
||||
function handleHistoryMessage(msg) {
|
||||
switch (msg.type) {
|
||||
${historyCaseSource}
|
||||
@@ -3909,6 +3948,7 @@ function assertBlockingFinishRafRequestRaceContract() {
|
||||
function assertRecoverCurrentHistoryMergeContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
|
||||
const historyMergeSource = extractFunctionSource(frontendSource, 'mergeHistoryChunkIntoSessionLoadSnapshot');
|
||||
const historyStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||||
const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart);
|
||||
assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable recovery history handler');
|
||||
@@ -3919,19 +3959,36 @@ function assertRecoverCurrentHistoryMergeContract() {
|
||||
requestId: 'request-recover-new',
|
||||
recoverCurrent: true,
|
||||
blocking: false,
|
||||
snapshot: { messages: [{ id: 'recent' }] },
|
||||
snapshot: {
|
||||
messages: [{ id: 'recent' }],
|
||||
historyTotal: 3,
|
||||
historyBuffered: 1,
|
||||
historyCursor: 2,
|
||||
historyBaseIndex: 2,
|
||||
historyTruncated: true,
|
||||
historyPending: true,
|
||||
complete: false,
|
||||
},
|
||||
};
|
||||
let currentSessionId = 'session-recover';
|
||||
let loadedHistorySessionId = 'session-recover';
|
||||
let activeOutlineHistoryRequest = null;
|
||||
let activeOutlineTargetRequest = null;
|
||||
const currentOutlineHistoryState = { sessionId: 'session-recover', entries: new Map(), cursor: 0, total: 0, available: true };
|
||||
const prepended = [];
|
||||
const cached = [];
|
||||
const finished = [];
|
||||
function cloneMessages(messages) { return messages.map((message) => ({ ...message })); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||
function mergeOutlineHistoryMessages() {}
|
||||
function requestOutlineHistoryPage() {}
|
||||
function isCompleteSessionSnapshot() { return false; }
|
||||
function bufferSessionSnapshot() {}
|
||||
function scheduleAdvancedSearchJump() {}
|
||||
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
|
||||
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
|
||||
${historyMergeSource}
|
||||
${finalizeLoadedSessionSource}
|
||||
function handleHistoryMessage(msg) {
|
||||
switch (msg.type) {
|
||||
@@ -4000,6 +4057,240 @@ function assertRecoverCurrentHistoryMergeContract() {
|
||||
assert(failures.length === 0, failures.join('; '));
|
||||
}
|
||||
|
||||
function assertPartialSessionSnapshotCacheContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const normalizeSource = extractFunctionSource(frontendSource, 'normalizeSessionSnapshot');
|
||||
const completeSource = extractFunctionSource(frontendSource, 'isCompleteSessionSnapshot');
|
||||
const bufferSource = extractFunctionSource(frontendSource, 'bufferSessionSnapshot');
|
||||
const getBufferSource = extractFunctionSource(frontendSource, 'getSessionHistoryBuffer');
|
||||
const cacheSource = extractFunctionSource(frontendSource, 'cacheSessionSnapshot');
|
||||
const mergeSource = extractFunctionSource(frontendSource, 'mergeHistoryChunkIntoCachedSnapshot');
|
||||
const dispositionSource = extractFunctionSource(frontendSource, 'getSessionCacheDisposition');
|
||||
assert(completeSource, 'Frontend should expose one session snapshot completeness predicate');
|
||||
const api = new Function(`
|
||||
const SESSION_CACHE_LIMIT = 8;
|
||||
const SESSION_CACHE_MAX_WEIGHT = 1024 * 1024;
|
||||
const sessionCache = new Map();
|
||||
const sessionHistoryBuffers = new Map();
|
||||
function deepClone(value) { return value === undefined ? value : JSON.parse(JSON.stringify(value)); }
|
||||
function cloneMessages(messages) { return Array.isArray(messages) ? deepClone(messages) : []; }
|
||||
function normalizeOutlineTitleHistory(history) { return Array.isArray(history) ? history : []; }
|
||||
function normalizeAgent(agent) { return String(agent || 'codexapp'); }
|
||||
function normalizeGiteaSource(source) { return source || null; }
|
||||
function estimateSessionSnapshotWeight() { return 1; }
|
||||
function getSessionMeta() {
|
||||
return { updated: '2026-09-13T00:00:00.000Z', hasUnread: false, isRunning: false, waitingOnChildren: false };
|
||||
}
|
||||
function pruneSessionCache() {}
|
||||
${normalizeSource}
|
||||
${completeSource}
|
||||
${bufferSource}
|
||||
${getBufferSource}
|
||||
${cacheSource}
|
||||
${mergeSource}
|
||||
${dispositionSource}
|
||||
return {
|
||||
normalizeSessionSnapshot,
|
||||
isCompleteSessionSnapshot,
|
||||
bufferSessionSnapshot,
|
||||
cacheSessionSnapshot,
|
||||
mergeHistoryChunkIntoCachedSnapshot,
|
||||
getSessionCacheDisposition,
|
||||
cacheSize: () => sessionCache.size,
|
||||
bufferSize: () => sessionHistoryBuffers.size,
|
||||
};
|
||||
`)();
|
||||
|
||||
const partial = api.normalizeSessionSnapshot({
|
||||
sessionId: 'partial-history',
|
||||
messages: Array.from({ length: 12 }, (_, index) => ({ id: `recent-${index}` })),
|
||||
historyTotal: 99,
|
||||
historyBuffered: 12,
|
||||
historyCursor: 87,
|
||||
historyBaseIndex: 87,
|
||||
historyTruncated: true,
|
||||
historyPending: false,
|
||||
});
|
||||
assert(partial.complete === false, 'A truncated session_info snapshot must not be marked complete');
|
||||
assert(api.isCompleteSessionSnapshot(partial) === false,
|
||||
'A snapshot with historyCursor>0 must fail the complete-history predicate');
|
||||
api.cacheSessionSnapshot(partial);
|
||||
assert(api.cacheSize() === 0, 'Partial history must never enter the session cache');
|
||||
|
||||
const paged = api.normalizeSessionSnapshot({
|
||||
sessionId: 'paged-history',
|
||||
messages: [{ id: 'paged-recent' }],
|
||||
historyTotal: 3,
|
||||
historyBuffered: 1,
|
||||
historyCursor: 2,
|
||||
historyBaseIndex: 2,
|
||||
historyTruncated: true,
|
||||
historyPending: false,
|
||||
updated: '2026-09-13T00:00:00.000Z',
|
||||
});
|
||||
api.bufferSessionSnapshot(paged);
|
||||
assert(api.getSessionCacheDisposition('paged-history') === 'miss'
|
||||
&& api.bufferSize() === 1,
|
||||
'Partial history should stay out of strong cache while remaining available for pagination');
|
||||
api.mergeHistoryChunkIntoCachedSnapshot('paged-history', [{ id: 'paged-middle' }], {
|
||||
baseIndex: 1,
|
||||
cursor: 1,
|
||||
total: 3,
|
||||
});
|
||||
api.mergeHistoryChunkIntoCachedSnapshot('paged-history', [{ id: 'paged-oldest' }], {
|
||||
baseIndex: 0,
|
||||
cursor: 0,
|
||||
total: 3,
|
||||
});
|
||||
assert(api.getSessionCacheDisposition('paged-history') === 'strong'
|
||||
&& api.cacheSize() === 1
|
||||
&& api.bufferSize() === 0,
|
||||
'The final paged history chunk should promote the merged snapshot to strong cache');
|
||||
|
||||
const forcedPartial = api.normalizeSessionSnapshot({
|
||||
sessionId: 'forced-partial-history',
|
||||
messages: [{ id: 'recent-only' }],
|
||||
historyTotal: 10,
|
||||
historyBuffered: 1,
|
||||
historyCursor: 9,
|
||||
historyBaseIndex: 9,
|
||||
historyPending: false,
|
||||
}, { complete: true });
|
||||
assert(forcedPartial.complete === false,
|
||||
'Callers must not be able to force a partial snapshot to complete');
|
||||
|
||||
const legacyBadCache = {
|
||||
sessionId: 'legacy-bad-cache',
|
||||
complete: true,
|
||||
historyPending: false,
|
||||
historyCursor: 0,
|
||||
historyBaseIndex: 5,
|
||||
historyTruncated: false,
|
||||
historyBuffered: 10,
|
||||
historyTotal: 10,
|
||||
messages: Array.from({ length: 10 }, (_, index) => ({ id: `legacy-${index}` })),
|
||||
};
|
||||
assert(api.isCompleteSessionSnapshot(legacyBadCache) === false,
|
||||
'A legacy cache with non-zero historyBaseIndex must be treated as incomplete');
|
||||
api.cacheSessionSnapshot(legacyBadCache);
|
||||
assert(api.cacheSize() === 1 && api.getSessionCacheDisposition('legacy-bad-cache') === 'miss',
|
||||
'Legacy partial cache entries must not become strong cache hits');
|
||||
|
||||
const cacheSizeBeforeComplete = api.cacheSize();
|
||||
const complete = api.normalizeSessionSnapshot({
|
||||
sessionId: 'complete-history',
|
||||
messages: Array.from({ length: 12 }, (_, index) => ({ id: `message-${index}` })),
|
||||
historyTotal: 12,
|
||||
historyBuffered: 12,
|
||||
historyCursor: 0,
|
||||
historyBaseIndex: 0,
|
||||
historyTruncated: false,
|
||||
historyPending: false,
|
||||
updated: '2026-09-13T00:00:00.000Z',
|
||||
});
|
||||
assert(complete.complete === true, 'A fully buffered session snapshot should be marked complete');
|
||||
api.cacheSessionSnapshot(complete);
|
||||
assert(api.cacheSize() === cacheSizeBeforeComplete + 1,
|
||||
'A complete session snapshot should enter the session cache');
|
||||
assert(api.getSessionCacheDisposition('complete-history') === 'strong',
|
||||
'A complete unchanged snapshot should remain a strong cache hit');
|
||||
}
|
||||
|
||||
function assertSessionSwitchCacheBehaviorContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const completeSource = extractFunctionSource(frontendSource, 'isCompleteSessionSnapshot');
|
||||
const dispositionSource = extractFunctionSource(frontendSource, 'getSessionCacheDisposition');
|
||||
const openSource = extractFunctionSource(frontendSource, 'openSession');
|
||||
const api = new Function(`
|
||||
const sessionCache = new Map();
|
||||
const metas = new Map([
|
||||
['session-a', { id: 'session-a', agent: 'codexapp', updated: 'v1', hasUnread: false, isRunning: false, waitingOnChildren: false }],
|
||||
['session-b', { id: 'session-b', agent: 'codexapp', updated: 'v1', hasUnread: false, isRunning: false, waitingOnChildren: false }],
|
||||
]);
|
||||
let currentSessionId = 'session-b';
|
||||
let activeSessionLoad = null;
|
||||
const loads = [];
|
||||
const shown = [];
|
||||
const taskBoardViewState = { open: false };
|
||||
const usageDashboardState = { open: false };
|
||||
function isCompleteSessionSnapshot(snapshot) {
|
||||
if (!snapshot || snapshot.complete !== true || snapshot.historyPending || snapshot.historyTruncated) return false;
|
||||
const historyTotal = Number.isFinite(Number(snapshot.historyTotal)) ? Math.max(0, Number(snapshot.historyTotal)) : (snapshot.messages || []).length;
|
||||
const historyBuffered = Number.isFinite(Number(snapshot.historyBuffered)) ? Math.max(0, Number(snapshot.historyBuffered)) : (snapshot.messages || []).length;
|
||||
const historyCursor = Number.isFinite(Number(snapshot.historyCursor)) ? Math.max(0, Number(snapshot.historyCursor)) : 0;
|
||||
const historyBaseIndex = Number.isFinite(Number(snapshot.historyBaseIndex)) ? Math.max(0, Number(snapshot.historyBaseIndex)) : 0;
|
||||
return historyCursor === 0 && historyBaseIndex === 0 && historyBuffered >= historyTotal;
|
||||
}
|
||||
function getSessionMeta(sessionId) { return metas.get(sessionId) || null; }
|
||||
function normalizeAgent(agent) { return String(agent || 'codexapp'); }
|
||||
function getSessionCacheDisposition(sessionId) {
|
||||
const entry = sessionCache.get(sessionId);
|
||||
const meta = getSessionMeta(sessionId);
|
||||
if (!entry?.snapshot || !isCompleteSessionSnapshot(entry.snapshot) || !meta) {
|
||||
if (entry && !isCompleteSessionSnapshot(entry.snapshot)) sessionCache.delete(sessionId);
|
||||
return 'miss';
|
||||
}
|
||||
if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) return 'strong';
|
||||
return 'weak';
|
||||
}
|
||||
function closeTaskBoard() {}
|
||||
function closeUsageDashboard() {}
|
||||
function isPrimaryUiAgent() { return true; }
|
||||
function renderSessionList() {}
|
||||
function closeUserOutlinePanel() {}
|
||||
function closeCcwebPromptOutlinePanel() {}
|
||||
function beginSessionSwitch(sessionId) { loads.push(sessionId); }
|
||||
function showCachedSession(sessionId) { shown.push(sessionId); return true; }
|
||||
${openSource}
|
||||
return {
|
||||
openSession,
|
||||
put(id, snapshot) { sessionCache.set(id, { snapshot, version: snapshot.updated || null }); },
|
||||
setCurrent(id) { currentSessionId = id; activeSessionLoad = null; },
|
||||
loads,
|
||||
shown,
|
||||
hasCache(id) { return sessionCache.has(id); },
|
||||
};
|
||||
`)();
|
||||
|
||||
api.openSession('session-a');
|
||||
assert(JSON.stringify(api.loads) === JSON.stringify(['session-a']) && api.shown.length === 0,
|
||||
'A session without a complete cache must issue a new load_session request');
|
||||
|
||||
api.put('session-a', {
|
||||
sessionId: 'session-a',
|
||||
complete: true,
|
||||
historyPending: false,
|
||||
historyCursor: 0,
|
||||
historyBaseIndex: 0,
|
||||
historyTruncated: false,
|
||||
historyBuffered: 2,
|
||||
historyTotal: 2,
|
||||
updated: 'v1',
|
||||
messages: [{ id: 'user-a' }, { id: 'assistant-a' }],
|
||||
});
|
||||
api.setCurrent('session-b');
|
||||
api.openSession('session-a');
|
||||
assert(api.shown.length === 1 && api.loads.length === 1,
|
||||
'A complete unchanged snapshot may be reused without another load');
|
||||
|
||||
api.put('session-a', {
|
||||
sessionId: 'session-a',
|
||||
complete: true,
|
||||
historyPending: false,
|
||||
historyCursor: 0,
|
||||
historyBaseIndex: 5,
|
||||
historyTruncated: false,
|
||||
historyBuffered: 2,
|
||||
historyTotal: 2,
|
||||
updated: 'v1',
|
||||
messages: [{ id: 'recent-only' }],
|
||||
});
|
||||
api.setCurrent('session-b');
|
||||
api.openSession('session-a');
|
||||
assert(api.loads.length === 2 && api.shown.length === 1 && !api.hasCache('session-a'),
|
||||
'A legacy partial cache must be discarded and must not hide user messages on return');
|
||||
}
|
||||
|
||||
function assertServerSessionHistoryRequestIdContract() {
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const handleLoadSessionSource = extractFunctionSource(serverSource, 'handleLoadSession');
|
||||
@@ -4089,6 +4380,11 @@ function assertHistoryRecallContract() {
|
||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
|
||||
assert(indexSource.includes('id="history-load-more"'), 'Chat view should expose a history load-more control');
|
||||
assert(
|
||||
/<div class="messages-wrap">\s*<div id="messages"[^>]*>\s*<div id="history-load-more"/.test(indexSource),
|
||||
'History load-more control should live at the top of the scrolling message content');
|
||||
assert(/href="style\.css\?v=[^"]*history-control-position/.test(indexSource),
|
||||
'History control layout changes should invalidate the cached stylesheet URL');
|
||||
assert(frontendSource.includes('function requestOlderHistory'), 'Frontend should request older history on demand');
|
||||
assert(frontendSource.includes("type: 'load_history_page'"), 'Frontend should use the paged history WebSocket request');
|
||||
assert(frontendSource.includes('mergeHistoryChunkIntoCachedSnapshot'), 'Frontend should merge history pages into the session cache');
|
||||
@@ -4096,6 +4392,52 @@ function assertHistoryRecallContract() {
|
||||
/const existingIndexes = new Set\([\s\S]*?data-message-index/.test(frontendSource),
|
||||
'Frontend should deduplicate history messages by stable message index'
|
||||
);
|
||||
assert(frontendSource.includes('currentOutlineHistoryState'),
|
||||
'User-message locator should keep an independent full-history index state');
|
||||
assert(frontendSource.includes('function requestOutlineHistoryPage'),
|
||||
'User-message locator should be able to fetch omitted history pages on demand');
|
||||
assert(/requestOutlineHistoryPage[\s\S]*?purpose:\s*'outline'/.test(frontendSource),
|
||||
'User-message locator pages should carry a distinct request purpose');
|
||||
assert(/function requestOutlineMessageTarget[\s\S]*?purpose:\s*'outline-target'/.test(frontendSource),
|
||||
'Locator clicks should request only the page containing the selected message');
|
||||
assert(
|
||||
/function buildUserOutlineItems[\s\S]*?currentOutlineHistoryState\.entries/.test(frontendSource),
|
||||
'User-message locator should merge indexed history instead of reading only rendered DOM');
|
||||
assert(
|
||||
/userOutlineBtn\.disabled\s*=\s*!historyState\.loading[\s\S]*?historyState\.cursor\s*> 0[\s\S]*?historyState\.available/.test(frontendSource),
|
||||
'Locator button should remain openable when only older user messages are available');
|
||||
assert(
|
||||
/messagesDiv\.innerHTML = '';\s*if \(historyLoadMore\) messagesDiv\.appendChild\(historyLoadMore\)/.test(frontendSource),
|
||||
'Message rerenders should preserve the in-content history control');
|
||||
assert(
|
||||
/messagesDiv\.insertBefore\(frag, historyLoadMore\?\.nextSibling \|\| null\)/.test(frontendSource),
|
||||
'Older message batches should be inserted below the in-content history control');
|
||||
const historyHandlerStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||||
const historyHandlerEnd = frontendSource.indexOf("case 'session_message':", historyHandlerStart);
|
||||
const historyHandlerSource = historyHandlerStart >= 0 && historyHandlerEnd > historyHandlerStart
|
||||
? frontendSource.slice(historyHandlerStart, historyHandlerEnd)
|
||||
: '';
|
||||
assert(
|
||||
/matchesOutlineTargetLoad[\s\S]*?prependHistoryMessages[\s\S]*?mergeOutlineHistoryMessages/.test(historyHandlerSource),
|
||||
'Locator target pages should render only the requested page and update the independent index');
|
||||
assert(
|
||||
historyHandlerSource.indexOf('if (matchesOutlineTargetLoad)') >= 0
|
||||
&& historyHandlerSource.indexOf('if (matchesOutlineHistoryLoad)') > historyHandlerSource.indexOf('if (matchesOutlineTargetLoad)'),
|
||||
'Locator target-page handling should run before the full locator-page path');
|
||||
const outlineHistoryBranchStart = historyHandlerSource.indexOf('if (matchesOutlineHistoryLoad)');
|
||||
const outlineHistoryBranchEnd = historyHandlerSource.indexOf('if (activeSessionLoad?.recoverCurrent', outlineHistoryBranchStart);
|
||||
const outlineHistoryBranch = outlineHistoryBranchStart >= 0 && outlineHistoryBranchEnd > outlineHistoryBranchStart
|
||||
? historyHandlerSource.slice(outlineHistoryBranchStart, outlineHistoryBranchEnd)
|
||||
: '';
|
||||
assert(
|
||||
outlineHistoryBranch && !outlineHistoryBranch.includes('prependHistoryMessages'),
|
||||
'Full locator history pages must stay in the independent index and out of the chat DOM');
|
||||
assert(
|
||||
/matchesOutlineHistoryLoad[\s\S]*?prependHistoryMessages/.test(frontendSource),
|
||||
'Ordinary history pages should still prepend into the chat when explicitly requested');
|
||||
assert(
|
||||
/data-message-index/.test(frontendSource) && /requestOutlineMessageTarget/.test(frontendSource),
|
||||
'Locator entries without a rendered DOM target should retain their message index for navigation');
|
||||
assert(styleSource.includes('.history-load-more-button'), 'History load-more control should have dedicated styling');
|
||||
assert(serverSource.includes('function loadNativeSessionHistory'), 'Server should be able to recover native session history');
|
||||
assert(serverSource.includes('function resolveSessionHistory'), 'Server should resolve persisted and native history sources');
|
||||
@@ -4108,6 +4450,20 @@ function assertHistoryRecallContract() {
|
||||
'Normal session loads should keep older history on demand instead of prefetching it all'
|
||||
);
|
||||
|
||||
const historyControlStart = styleSource.indexOf('.history-load-more {');
|
||||
const historyControlEnd = styleSource.indexOf('.history-load-more-copy', historyControlStart);
|
||||
assert(historyControlStart >= 0 && historyControlEnd > historyControlStart,
|
||||
'History load-more control should have an isolated base style block');
|
||||
const historyControlStyle = styleSource.slice(historyControlStart, historyControlEnd);
|
||||
assert(!/\bposition\s*:\s*(?:absolute|fixed|sticky)\b/.test(historyControlStyle),
|
||||
'History load-more control should stay in normal document flow instead of floating');
|
||||
assert(!/(?:^|[;\s])(top|right|bottom|left|transform)\s*:/.test(historyControlStyle),
|
||||
'History load-more control should not pin itself to a viewport or overlay position');
|
||||
assert(/\.messages-wrap\s*\{[\s\S]*?display:\s*flex;[\s\S]*?flex-direction:\s*column;/.test(styleSource),
|
||||
'Messages wrapper should reserve a normal-flow row above the scrolling messages');
|
||||
assert(/\.messages\s*\{[\s\S]*?flex:\s*1;[\s\S]*?min-height:\s*0;/.test(styleSource),
|
||||
'Messages should occupy the remaining height beneath the history control');
|
||||
|
||||
const splitStart = serverSource.indexOf('function splitHistoryMessages');
|
||||
const splitEnd = serverSource.indexOf('const IS_WIN', splitStart);
|
||||
assert(splitStart >= 0 && splitEnd > splitStart, 'Server should keep an extractable history splitter');
|
||||
@@ -4149,6 +4505,8 @@ function assertSessionSwitchRaceContract() {
|
||||
['frontend requestId behavior', assertSessionRequestIdRaceContract],
|
||||
['blocking finish RAF request behavior', assertBlockingFinishRafRequestRaceContract],
|
||||
['recoverCurrent history merge behavior', assertRecoverCurrentHistoryMergeContract],
|
||||
['partial session snapshot cache behavior', assertPartialSessionSnapshotCacheContract],
|
||||
['session switch cache behavior', assertSessionSwitchCacheBehaviorContract],
|
||||
['server history requestId behavior', assertServerSessionHistoryRequestIdContract],
|
||||
];
|
||||
const failures = [];
|
||||
@@ -5630,7 +5988,7 @@ function assertAdvancedSessionSearchContract() {
|
||||
'Existing sidebar search clear control contract should remain unchanged');
|
||||
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
|
||||
'Advanced search workspace should expose stable panel and result hooks');
|
||||
assert(indexSource.includes('style.css?v=20260911-file-suggestion')
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Advanced search CSS and frontend script should share the reviewed cache-bust');
|
||||
|
||||
@@ -5747,7 +6105,7 @@ function assertUsageStatisticsContract() {
|
||||
'Usage dashboard should be a chat-main-local workspace');
|
||||
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
|
||||
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
|
||||
assert(indexSource.includes('style.css?v=20260911-file-suggestion')
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position')
|
||||
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
|
||||
|
||||
Reference in New Issue
Block a user