修复会话切换兼容并重新打包发布

This commit is contained in:
shiyue
2026-09-13 23:59:27 +08:00
parent d9db66ca8f
commit e785e3c5dd
15 changed files with 1224 additions and 70 deletions

View File

@@ -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');