fix: stabilize session list and Codex imports
This commit is contained in:
@@ -619,8 +619,8 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260727-session-item-tooltip')
|
||||
&& indexSource.includes('app.js?v=20260727-session-item-tooltip'),
|
||||
indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm')
|
||||
&& indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
}
|
||||
@@ -943,8 +943,8 @@ 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=20260727-session-item-tooltip'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
}
|
||||
|
||||
function assertFrontendGildedThemeContract() {
|
||||
@@ -1059,8 +1059,8 @@ 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=20260727-session-item-tooltip'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
}
|
||||
|
||||
function assertFrontendWastelandThemeContract() {
|
||||
@@ -1313,8 +1313,8 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260727-session-item-tooltip'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
function assertFrontendCcwebPromptContract() {
|
||||
@@ -2344,6 +2344,630 @@ function assertSessionItemTooltipContract() {
|
||||
assert(!createItemSource.includes('item.title = sessionCwd'), 'Session card should not fall back to a project-only tooltip');
|
||||
}
|
||||
|
||||
function assertSidebarTitleRefreshStormContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem');
|
||||
const renderListSource = extractFunctionSource(frontendSource, 'renderSessionList');
|
||||
const optionalSources = [
|
||||
'buildSessionListStructureSignature',
|
||||
'refreshSessionListRelativeTimes',
|
||||
].map((name) => maybeExtractFunctionSource(frontendSource, name)).filter(Boolean).join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
class MiniElement {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = String(tagName || 'div').toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this.listeners = {};
|
||||
this.parentElement = null;
|
||||
this.hidden = false;
|
||||
this._className = '';
|
||||
this._textContent = '';
|
||||
this._innerHTML = '';
|
||||
}
|
||||
get className() {
|
||||
return this._className;
|
||||
}
|
||||
set className(value) {
|
||||
this._className = String(value || '');
|
||||
}
|
||||
get classList() {
|
||||
const node = this;
|
||||
return {
|
||||
contains(name) {
|
||||
return node._className.split(/\\s+/).filter(Boolean).includes(name);
|
||||
},
|
||||
add(...names) {
|
||||
const classes = new Set(node._className.split(/\\s+/).filter(Boolean));
|
||||
names.filter(Boolean).forEach((name) => classes.add(name));
|
||||
node._className = [...classes].join(' ');
|
||||
},
|
||||
remove(...names) {
|
||||
const removeSet = new Set(names.filter(Boolean));
|
||||
node._className = node._className.split(/\\s+/).filter(Boolean).filter((name) => !removeSet.has(name)).join(' ');
|
||||
},
|
||||
toggle(name, force) {
|
||||
const hasClass = this.contains(name);
|
||||
const shouldAdd = force === undefined ? !hasClass : !!force;
|
||||
if (shouldAdd) this.add(name);
|
||||
else this.remove(name);
|
||||
return shouldAdd;
|
||||
},
|
||||
};
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
get textContent() {
|
||||
return this._textContent;
|
||||
}
|
||||
set textContent(value) {
|
||||
this._textContent = String(value || '');
|
||||
this.children = [];
|
||||
}
|
||||
get innerHTML() {
|
||||
return this._innerHTML;
|
||||
}
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = String(value || '');
|
||||
this.children = [];
|
||||
this._parseInnerHtml(this._innerHTML);
|
||||
}
|
||||
_parseInnerHtml(html) {
|
||||
const tagRe = /<([a-z][a-z0-9-]*)([^>]*)>/gi;
|
||||
let match;
|
||||
while ((match = tagRe.exec(html))) {
|
||||
const tagName = match[1];
|
||||
const attrSource = match[2] || '';
|
||||
const classMatch = attrSource.match(/class="([^"]*)"/);
|
||||
if (!classMatch) continue;
|
||||
const child = new MiniElement(tagName);
|
||||
child.className = classMatch[1];
|
||||
const titleMatch = attrSource.match(/title="([^"]*)"/);
|
||||
if (titleMatch) child.title = titleMatch[1];
|
||||
const ariaExpandedMatch = attrSource.match(/aria-expanded="([^"]*)"/);
|
||||
if (ariaExpandedMatch) child.setAttribute('aria-expanded', ariaExpandedMatch[1]);
|
||||
const idMatch = attrSource.match(/id="([^"]*)"/);
|
||||
if (idMatch) child.id = idMatch[1];
|
||||
const closeTag = '</' + tagName + '>';
|
||||
const closeIndex = html.indexOf(closeTag, tagRe.lastIndex);
|
||||
if (closeIndex >= 0) {
|
||||
const raw = html.slice(tagRe.lastIndex, closeIndex);
|
||||
child._textContent = raw.replace(/<[^>]+>/g, '').replace(/\\s+/g, ' ').trim();
|
||||
}
|
||||
this.appendChild(child);
|
||||
}
|
||||
}
|
||||
appendChild(child) {
|
||||
if (child && typeof child === 'object') child.parentElement = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes[name];
|
||||
}
|
||||
addEventListener(name, handler) {
|
||||
if (!this.listeners[name]) this.listeners[name] = [];
|
||||
this.listeners[name].push(handler);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
const evt = event || {};
|
||||
evt.target = evt.target || this;
|
||||
evt.stopPropagation = evt.stopPropagation || function stopPropagation() {};
|
||||
(this.listeners[evt.type] || []).forEach((handler) => handler(evt));
|
||||
}
|
||||
matches(selector) {
|
||||
return selector.split(',').some((part) => {
|
||||
const classes = String(part || '').trim().match(/\\.([a-zA-Z0-9_-]+)/g)?.map((item) => item.slice(1)) || [];
|
||||
return classes.length > 0 && classes.every((name) => this.classList.contains(name));
|
||||
});
|
||||
}
|
||||
closest(selector) {
|
||||
let node = this;
|
||||
while (node) {
|
||||
if (node.matches(selector)) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const results = [];
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (node !== this && node.matches(selector)) results.push(node);
|
||||
(Array.isArray(node.children) ? node.children : []).forEach(visit);
|
||||
};
|
||||
visit(this);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
let sessions = [];
|
||||
let currentSessionId = 's1';
|
||||
let sessionSearchQuery = '';
|
||||
let lastSessionListStructureSignature = '';
|
||||
let collapseOlderSessions = false;
|
||||
let failOldSessionLoadMoreOnce = false;
|
||||
let currentAgent = 'codexapp';
|
||||
let currentMode = 'yolo';
|
||||
const AGENT_LABELS = { codexapp: 'Codex' };
|
||||
const collapsedProjectKeys = new Set();
|
||||
const pendingNotesByTarget = new Map();
|
||||
const queuedMessagesByTarget = new Map();
|
||||
const localStorage = { removeItem() {}, setItem() {}, getItem() { return null; } };
|
||||
const sessionList = new MiniElement('div');
|
||||
sessionList.clearCount = 0;
|
||||
Object.defineProperty(sessionList, 'innerHTML', {
|
||||
get() { return this._innerHTML; },
|
||||
set(value) {
|
||||
this._innerHTML = String(value || '');
|
||||
this.children = [];
|
||||
if (value === '') this.clearCount += 1;
|
||||
},
|
||||
});
|
||||
const document = {
|
||||
createElement(tagName) {
|
||||
return new MiniElement(tagName);
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
return sessionList.querySelectorAll(selector);
|
||||
},
|
||||
};
|
||||
const Element = MiniElement;
|
||||
let openedSessionIds = [];
|
||||
|
||||
function syncSessionSearchUi() {}
|
||||
function normalizeAgent(agent) { return AGENT_LABELS[agent] ? agent : 'codexapp'; }
|
||||
function getVisibleSessions() { return sessions; }
|
||||
function normalizeSessionSearchQuery(query) { return String(query || '').trim().toLowerCase(); }
|
||||
function sessionMatchesSearch(session, normalizedQuery) {
|
||||
return !normalizedQuery || String(session.title || '').toLowerCase().includes(normalizedQuery);
|
||||
}
|
||||
function getPathLeaf(input) {
|
||||
const normalized = String(input || '').replace(/\\\\/g, '/').replace(/\\/+$/, '');
|
||||
return normalized.split('/').filter(Boolean).pop() || '';
|
||||
}
|
||||
function getSessionEffectiveCwd(session) { return session?.cwd || ''; }
|
||||
function getSessionProjectName(session) { return session?.projectName || getPathLeaf(getSessionEffectiveCwd(session)); }
|
||||
function buildSessionItemTooltip(projectName, title) {
|
||||
return [projectName ? '项目:' + projectName : '', '标题:' + (title || 'Untitled')].filter(Boolean).join('\\n');
|
||||
}
|
||||
function escapeHtml(value) { return String(value ?? ''); }
|
||||
function timeAgo(value) { return 'time:' + String(value || ''); }
|
||||
function compareSessionUpdatedDesc(a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); }
|
||||
function compareSessionPinnedDesc(a, b) { return new Date(b.pinnedAt || 0) - new Date(a.pinnedAt || 0); }
|
||||
function splitPinnedSessions(sessionItems) {
|
||||
const pinnedSessions = [];
|
||||
const regularSessions = [];
|
||||
for (const session of sessionItems) {
|
||||
(session.pinnedAt ? pinnedSessions : regularSessions).push(session);
|
||||
}
|
||||
pinnedSessions.sort(compareSessionPinnedDesc);
|
||||
regularSessions.sort(compareSessionUpdatedDesc);
|
||||
return { pinnedSessions, regularSessions };
|
||||
}
|
||||
function groupSessionsByProject(sessionItems) {
|
||||
const groups = [];
|
||||
const groupMap = new Map();
|
||||
const ungroupedSessions = [];
|
||||
for (const session of sessionItems) {
|
||||
const name = getSessionProjectName(session);
|
||||
if (!name) {
|
||||
ungroupedSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
if (!groupMap.has(name)) {
|
||||
const group = { name, cwd: getSessionEffectiveCwd(session), sessions: [], latestUpdated: session.updated || '' };
|
||||
groupMap.set(name, group);
|
||||
groups.push(group);
|
||||
}
|
||||
const group = groupMap.get(name);
|
||||
group.sessions.push(session);
|
||||
if (new Date(session.updated || 0) > new Date(group.latestUpdated || 0)) {
|
||||
group.latestUpdated = session.updated || group.latestUpdated;
|
||||
group.cwd = getSessionEffectiveCwd(session) || group.cwd;
|
||||
}
|
||||
}
|
||||
for (const group of groups) group.sessions.sort(compareSessionUpdatedDesc);
|
||||
ungroupedSessions.sort(compareSessionUpdatedDesc);
|
||||
return { groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)), ungroupedSessions };
|
||||
}
|
||||
function getProjectCollapseKey(group) { return normalizeAgent(currentAgent) + ':' + (group?.cwd || group?.name || ''); }
|
||||
function getProjectOldSessionCollapseKey(group) { return 'project:' + getProjectCollapseKey(group); }
|
||||
function getUngroupedOldSessionCollapseKey() { return normalizeAgent(currentAgent) + ':ungrouped'; }
|
||||
function splitCollapsedSessions(sessionItems) {
|
||||
if (!collapseOlderSessions || sessionItems.length < 2) {
|
||||
return { visibleSessions: sessionItems, hiddenSessions: [] };
|
||||
}
|
||||
return { visibleSessions: sessionItems.slice(0, 1), hiddenSessions: sessionItems.slice(1) };
|
||||
}
|
||||
function createOldSessionLoadMoreButton() {
|
||||
if (failOldSessionLoadMoreOnce) {
|
||||
failOldSessionLoadMoreOnce = false;
|
||||
throw new Error('synthetic old-session render failure');
|
||||
}
|
||||
return new MiniElement('button');
|
||||
}
|
||||
function setProjectCollapsed() {}
|
||||
function quickCreateProjectSession() {}
|
||||
function setSessionActionMenuOpen(item, open) { item.classList.toggle('menu-open', open); }
|
||||
function closeSessionActionMenus() {}
|
||||
function copyTextToClipboard() {}
|
||||
function toggleSessionPinned() {}
|
||||
function getLastSessionForAgent() { return ''; }
|
||||
function getAgentSessionStorageKey() { return ''; }
|
||||
function getSessionQueueKey(sessionId) { return sessionId ? 'session:' + sessionId : ''; }
|
||||
function invalidateSessionCache() {}
|
||||
function send() {}
|
||||
function resetChatView() {}
|
||||
const skipDeleteConfirm = true;
|
||||
function showDeleteConfirm() {}
|
||||
function isMobileInputMode() { return false; }
|
||||
function closeSidebar() {}
|
||||
function openSession(sessionId) { openedSessionIds.push(sessionId); }
|
||||
function startEditSessionTitle() {}
|
||||
|
||||
${optionalSources}
|
||||
${createItemSource}
|
||||
${renderListSource}
|
||||
|
||||
function cloneSession(session, overrides = {}) {
|
||||
return { ...session, ...overrides };
|
||||
}
|
||||
function setSessions(nextSessions) {
|
||||
sessions = nextSessions.map((session) => ({ ...session }));
|
||||
}
|
||||
function nodesByClass(className) {
|
||||
return sessionList.querySelectorAll('.' + className);
|
||||
}
|
||||
function timeTextFor(sessionId) {
|
||||
const item = nodesByClass('session-item').find((node) => node.dataset.id === sessionId);
|
||||
return item?.querySelector('.session-item-time')?.textContent || '';
|
||||
}
|
||||
return {
|
||||
renderSessionList,
|
||||
setSessions,
|
||||
setCollapseOlderSessions(value) { collapseOlderSessions = !!value; },
|
||||
failNextOldSessionLoadMore() { failOldSessionLoadMoreOnce = true; },
|
||||
cloneSession,
|
||||
nodeState() {
|
||||
return {
|
||||
clearCount: sessionList.clearCount,
|
||||
groups: nodesByClass('session-project-group'),
|
||||
items: nodesByClass('session-item'),
|
||||
openedSessionIds: [...openedSessionIds],
|
||||
};
|
||||
},
|
||||
timeTextFor,
|
||||
clickFirstSession() {
|
||||
const first = nodesByClass('session-item')[0];
|
||||
first.dispatchEvent({ type: 'click', target: first });
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const baseSessions = [
|
||||
{
|
||||
id: 's1',
|
||||
agent: 'codexapp',
|
||||
title: 'Alpha',
|
||||
updated: '2026-07-30T08:00:00.000Z',
|
||||
cwd: '/work/cc-web',
|
||||
isRunning: false,
|
||||
hasUnread: false,
|
||||
waitingOnChildren: false,
|
||||
readyReplyCount: 0,
|
||||
pendingReplyCount: 0,
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
agent: 'codexapp',
|
||||
title: 'Beta',
|
||||
updated: '2026-07-30T07:00:00.000Z',
|
||||
cwd: '/work/cc-web',
|
||||
isRunning: false,
|
||||
hasUnread: false,
|
||||
waitingOnChildren: false,
|
||||
readyReplyCount: 0,
|
||||
pendingReplyCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
api.setSessions(baseSessions);
|
||||
api.renderSessionList();
|
||||
const initial = api.nodeState();
|
||||
assert(initial.clearCount === 1, 'Initial sidebar render should build the DOM once');
|
||||
assert(initial.groups.length === 1, 'Initial sidebar render should create a project group');
|
||||
assert(initial.items.length === 2, 'Initial sidebar render should create session items');
|
||||
api.clickFirstSession();
|
||||
assert(api.nodeState().openedSessionIds.join(',') === 's1', 'Initial session item click listener should work');
|
||||
|
||||
api.setSessions([
|
||||
api.cloneSession(baseSessions[0], { updated: '2026-07-30T08:00:30.000Z' }),
|
||||
baseSessions[1],
|
||||
]);
|
||||
api.renderSessionList();
|
||||
const afterUpdatedOnly = api.nodeState();
|
||||
assert(afterUpdatedOnly.clearCount === 1, 'Updated-only sidebar snapshots should not clear the list again');
|
||||
assert(afterUpdatedOnly.groups[0] === initial.groups[0], 'Updated-only sidebar snapshots should keep project group node identity');
|
||||
assert(afterUpdatedOnly.items[0] === initial.items[0], 'Updated-only sidebar snapshots should keep session item node identity');
|
||||
assert(api.timeTextFor('s1') === 'time:2026-07-30T08:00:30.000Z', 'Updated-only sidebar snapshots should refresh relative time in place');
|
||||
api.clickFirstSession();
|
||||
assert(api.nodeState().openedSessionIds.join(',') === 's1,s1', 'Updated-only sidebar snapshots should keep existing click listener usable');
|
||||
|
||||
api.setSessions([
|
||||
api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z' }),
|
||||
baseSessions[1],
|
||||
]);
|
||||
api.renderSessionList();
|
||||
const afterTitle = api.nodeState();
|
||||
assert(afterTitle.clearCount === 2, 'Title changes should still rebuild the sidebar structure');
|
||||
assert(afterTitle.items[0] !== afterUpdatedOnly.items[0], 'Title changes should replace the affected session node');
|
||||
|
||||
api.setSessions([
|
||||
api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z', isRunning: true }),
|
||||
baseSessions[1],
|
||||
]);
|
||||
api.renderSessionList();
|
||||
const afterStatus = api.nodeState();
|
||||
assert(afterStatus.clearCount === 3, 'Running status changes should still rebuild the sidebar structure');
|
||||
|
||||
api.setSessions([
|
||||
api.cloneSession(baseSessions[1], { updated: '2026-07-30T09:00:00.000Z' }),
|
||||
api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z', isRunning: true }),
|
||||
]);
|
||||
api.renderSessionList();
|
||||
const afterOrder = api.nodeState();
|
||||
assert(afterOrder.clearCount === 4, 'Order changes should still rebuild the sidebar structure');
|
||||
assert(afterOrder.items[0].dataset.id === 's2', 'Order changes should render the new first session in place');
|
||||
|
||||
api.setCollapseOlderSessions(true);
|
||||
api.renderSessionList();
|
||||
const afterOldSessionCollapse = api.nodeState();
|
||||
assert(afterOldSessionCollapse.clearCount === 5, 'Old-session collapse changes should rebuild without losing its collapse key');
|
||||
assert(afterOldSessionCollapse.items.length === 1, 'Old-session collapse should keep only the recent project session visible');
|
||||
|
||||
api.setSessions([
|
||||
api.cloneSession(baseSessions[0], { id: 'pinned', title: 'Pinned', pinnedAt: '2026-07-30T10:00:00.000Z' }),
|
||||
api.cloneSession(baseSessions[0], { title: 'Alpha regular' }),
|
||||
baseSessions[1],
|
||||
]);
|
||||
api.failNextOldSessionLoadMore();
|
||||
let syntheticRenderFailed = false;
|
||||
try {
|
||||
api.renderSessionList();
|
||||
} catch (err) {
|
||||
syntheticRenderFailed = err?.message === 'synthetic old-session render failure';
|
||||
}
|
||||
assert(syntheticRenderFailed, 'The regression harness should exercise a partial sidebar render failure');
|
||||
api.renderSessionList();
|
||||
const afterRenderRetry = api.nodeState();
|
||||
assert(afterRenderRetry.groups.length === 2, 'A render retry should rebuild both pinned and project groups after a partial failure');
|
||||
assert(afterRenderRetry.items.length === 2, 'A render retry should not accept a partial pinned-only DOM as complete');
|
||||
}
|
||||
|
||||
function assertCcwebMcpChildUpdateCoalescingContract() {
|
||||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const functionNames = [
|
||||
'isFinalCcwebMcpChildStatus',
|
||||
'snapshotCcwebMcpChildForPersist',
|
||||
'flushPendingCcwebMcpChildSession',
|
||||
'updateCcwebMcpChildToolState',
|
||||
'updatePersistedCcwebMcpChildTool',
|
||||
'flushCcwebMcpChildSessionListBroadcast',
|
||||
'scheduleCcwebMcpChildSessionListBroadcast',
|
||||
'sendCcwebMcpChildAgentUpdate',
|
||||
];
|
||||
const helperSource = functionNames.map((name) => extractFunctionSource(source, name)).join('\n');
|
||||
const api = new Function(`
|
||||
const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = 250;
|
||||
const pendingCcwebMcpChildSessionFlushes = new Map();
|
||||
let ccwebMcpChildSessionListBroadcastTimer = null;
|
||||
const activeCodexAppTurns = new Map();
|
||||
const diskSessions = new Map();
|
||||
const scheduledTimers = [];
|
||||
const sentPayloads = [];
|
||||
const targetWs = { readyState: 1 };
|
||||
let loadCount = 0;
|
||||
let saveCount = 0;
|
||||
let broadcastCount = 0;
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
function setTimeout(callback) {
|
||||
const timer = { callback, active: true, unref() {} };
|
||||
scheduledTimers.push(timer);
|
||||
return timer;
|
||||
}
|
||||
function clearTimeout(timer) {
|
||||
if (timer) timer.active = false;
|
||||
}
|
||||
function loadSession(sessionId) {
|
||||
loadCount += 1;
|
||||
const session = diskSessions.get(sessionId);
|
||||
return session ? clone(session) : null;
|
||||
}
|
||||
function saveSession(session) {
|
||||
saveCount += 1;
|
||||
diskSessions.set(session.id, clone(session));
|
||||
return true;
|
||||
}
|
||||
function findViewingSessionWs() {
|
||||
return targetWs;
|
||||
}
|
||||
function findCcwebMcpChildTargetToolInToolCalls(toolCalls, spawnToolId) {
|
||||
return (Array.isArray(toolCalls) ? toolCalls : []).find((tool) => tool?.id === spawnToolId) || null;
|
||||
}
|
||||
function findCcwebMcpChildTargetToolInMessages(messages, spawnToolId) {
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
const tool = findCcwebMcpChildTargetToolInToolCalls(message?.toolCalls, spawnToolId);
|
||||
if (tool) return tool;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function mergeCcwebMcpChildIntoTool(tool, child) {
|
||||
if (!tool || !child) return null;
|
||||
const result = tool.result ? JSON.parse(tool.result) : {};
|
||||
const agentsStates = result.agentsStates || {};
|
||||
agentsStates[child.threadId] = {
|
||||
...(agentsStates[child.threadId] || {}),
|
||||
status: child.status,
|
||||
planCurrentStep: child.planCurrentStep || '',
|
||||
finalMessage: child.finalMessage || '',
|
||||
};
|
||||
tool.result = JSON.stringify({ ...result, status: child.status, agentsStates });
|
||||
tool.done = isFinalCcwebMcpChildStatus(child.status);
|
||||
return tool;
|
||||
}
|
||||
function ccwebMcpChildPublicState(child) {
|
||||
return { ...child };
|
||||
}
|
||||
function wsSend(ws, payload) {
|
||||
sentPayloads.push({ ws, payload: clone(payload) });
|
||||
}
|
||||
function broadcastSessionList() {
|
||||
broadcastCount += 1;
|
||||
}
|
||||
|
||||
${helperSource}
|
||||
|
||||
function flushTimers() {
|
||||
let progressed = true;
|
||||
while (progressed) {
|
||||
progressed = false;
|
||||
for (const timer of scheduledTimers) {
|
||||
if (!timer.active) continue;
|
||||
timer.active = false;
|
||||
timer.callback();
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
seed(session, activeTool) {
|
||||
diskSessions.set(session.id, clone(session));
|
||||
activeCodexAppTurns.set(session.id, { ws: targetWs, toolCalls: [activeTool] });
|
||||
},
|
||||
send: sendCcwebMcpChildAgentUpdate,
|
||||
flushTimers,
|
||||
snapshot() {
|
||||
return {
|
||||
loadCount,
|
||||
saveCount,
|
||||
broadcastCount,
|
||||
payloadCount: sentPayloads.length,
|
||||
diskSessions: clone([...diskSessions.entries()]),
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const persistedTool = {
|
||||
id: 'spawn-child-1',
|
||||
name: 'subAgentActivity',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: '{}',
|
||||
result: JSON.stringify({ agentsStates: {} }),
|
||||
done: false,
|
||||
};
|
||||
api.seed({
|
||||
id: 'parent-session',
|
||||
title: 'Parent',
|
||||
updated: '2026-07-30T08:00:00.000Z',
|
||||
messages: [{ role: 'assistant', content: '', toolCalls: [persistedTool] }],
|
||||
}, { ...persistedTool });
|
||||
|
||||
for (const planCurrentStep of ['分析', '实现', '验证']) {
|
||||
api.send('parent-session', {
|
||||
threadId: 'child-1',
|
||||
spawnToolId: 'spawn-child-1',
|
||||
status: 'running',
|
||||
planCurrentStep,
|
||||
});
|
||||
}
|
||||
const duringBurst = api.snapshot();
|
||||
assert(duringBurst.payloadCount === 3, 'Every child delta should still send a realtime local payload');
|
||||
assert(duringBurst.loadCount === 1, 'A child update burst should reuse one pending parent session snapshot');
|
||||
assert(duringBurst.saveCount === 0, 'A running child update burst should defer parent session persistence');
|
||||
assert(duringBurst.broadcastCount === 0, 'A running child update burst should defer full session-list broadcasts');
|
||||
|
||||
api.flushTimers();
|
||||
const afterBurst = api.snapshot();
|
||||
assert(afterBurst.saveCount === 1, 'A child update burst should persist the parent session once');
|
||||
assert(afterBurst.loadCount <= 2, 'A child update burst should not reload the parent session for every delta');
|
||||
assert(afterBurst.broadcastCount === 1, 'A child update burst should broadcast the full session list once');
|
||||
const storedAfterBurst = new Map(afterBurst.diskSessions).get('parent-session');
|
||||
const storedBurstState = JSON.parse(storedAfterBurst.messages[0].toolCalls[0].result).agentsStates['child-1'];
|
||||
assert(storedBurstState.planCurrentStep === '验证', 'The trailing flush should persist the latest child state');
|
||||
|
||||
api.send('parent-session', {
|
||||
threadId: 'child-1',
|
||||
spawnToolId: 'spawn-child-1',
|
||||
status: 'returned',
|
||||
planCurrentStep: '完成',
|
||||
finalMessage: '最终结果',
|
||||
});
|
||||
const afterFinal = api.snapshot();
|
||||
assert(afterFinal.payloadCount === 4, 'A final child update should still send its realtime local payload');
|
||||
assert(afterFinal.saveCount === 2, 'A final child update should flush persistence immediately');
|
||||
assert(afterFinal.broadcastCount === 2, 'A final child update should flush the session-list broadcast immediately');
|
||||
const storedFinal = new Map(afterFinal.diskSessions).get('parent-session');
|
||||
const storedFinalState = JSON.parse(storedFinal.messages[0].toolCalls[0].result).agentsStates['child-1'];
|
||||
assert(storedFinalState.status === 'returned' && storedFinalState.finalMessage === '最终结果', 'The final child state must not be lost');
|
||||
|
||||
api.flushTimers();
|
||||
const afterCancelledTimers = api.snapshot();
|
||||
assert(afterCancelledTimers.saveCount === 2 && afterCancelledTimers.broadcastCount === 2, 'An immediate final flush should cancel stale trailing work');
|
||||
|
||||
const sharedSpawnTool = {
|
||||
id: 'spawn-shared',
|
||||
name: 'subAgentActivity',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: '{}',
|
||||
result: JSON.stringify({ agentsStates: {} }),
|
||||
done: false,
|
||||
};
|
||||
api.seed({
|
||||
id: 'sibling-parent-session',
|
||||
title: 'Sibling parent',
|
||||
updated: '2026-07-30T08:00:00.000Z',
|
||||
messages: [{ role: 'assistant', content: '', toolCalls: [sharedSpawnTool] }],
|
||||
}, { ...sharedSpawnTool });
|
||||
const beforeSiblingBurst = api.snapshot();
|
||||
api.send('sibling-parent-session', {
|
||||
threadId: 'sibling-a',
|
||||
spawnToolId: 'spawn-shared',
|
||||
status: 'running',
|
||||
planCurrentStep: 'A 验证',
|
||||
});
|
||||
api.send('sibling-parent-session', {
|
||||
threadId: 'sibling-b',
|
||||
spawnToolId: 'spawn-shared',
|
||||
status: 'running',
|
||||
planCurrentStep: 'B 验证',
|
||||
});
|
||||
const duringSiblingBurst = api.snapshot();
|
||||
assert(duringSiblingBurst.payloadCount === beforeSiblingBurst.payloadCount + 2, 'Sibling child deltas should both remain realtime');
|
||||
assert(duringSiblingBurst.saveCount === beforeSiblingBurst.saveCount, 'Sibling child deltas should share the pending parent flush');
|
||||
api.flushTimers();
|
||||
const afterSiblingBurst = api.snapshot();
|
||||
assert(afterSiblingBurst.saveCount === beforeSiblingBurst.saveCount + 1, 'Sibling child deltas should persist in one parent save');
|
||||
assert(afterSiblingBurst.broadcastCount === beforeSiblingBurst.broadcastCount + 1, 'Sibling child deltas should share one full-list broadcast');
|
||||
const storedSiblingSession = new Map(afterSiblingBurst.diskSessions).get('sibling-parent-session');
|
||||
const storedSiblingStates = JSON.parse(storedSiblingSession.messages[0].toolCalls[0].result).agentsStates;
|
||||
assert(storedSiblingStates['sibling-a']?.planCurrentStep === 'A 验证', 'The first sibling sharing a spawn tool must not be dropped');
|
||||
assert(storedSiblingStates['sibling-b']?.planCurrentStep === 'B 验证', 'The second sibling sharing a spawn tool must be persisted');
|
||||
}
|
||||
|
||||
function assertTitleHistoryOutlineContract() {
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
@@ -3508,6 +4132,10 @@ function extractFunctionSource(source, name) {
|
||||
throw new Error(`Could not parse function body for ${name}`);
|
||||
}
|
||||
|
||||
function maybeExtractFunctionSource(source, name) {
|
||||
return source.indexOf(`function ${name}(`) >= 0 ? extractFunctionSource(source, name) : '';
|
||||
}
|
||||
|
||||
function assertCodexAppChildToolRoutingContract() {
|
||||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const helperStart = source.indexOf('function parseMaybeJsonObject(value)');
|
||||
@@ -3516,6 +4144,9 @@ function assertCodexAppChildToolRoutingContract() {
|
||||
const helperSource = source.slice(helperStart, helperEnd);
|
||||
const api = new Function(`
|
||||
const sessions = new Map();
|
||||
const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = 250;
|
||||
const pendingCcwebMcpChildSessionFlushes = new Map();
|
||||
let ccwebMcpChildSessionListBroadcastTimer = null;
|
||||
let savedSession = null;
|
||||
function truncateTextValue(value, maxLength, suffix = '...') {
|
||||
const text = String(value || '');
|
||||
@@ -3531,11 +4162,13 @@ function assertCodexAppChildToolRoutingContract() {
|
||||
function findViewingSessionWs() {
|
||||
return null;
|
||||
}
|
||||
function broadcastSessionList() {}
|
||||
${helperSource}
|
||||
return {
|
||||
setSession: (session) => sessions.set(session.id, session),
|
||||
getSession: (sessionId) => sessions.get(sessionId),
|
||||
getSavedSession: () => savedSession,
|
||||
flushPendingCcwebMcpChildSession,
|
||||
updatePersistedCcwebMcpChildTool,
|
||||
};
|
||||
`)();
|
||||
@@ -3616,6 +4249,7 @@ function assertCodexAppChildToolRoutingContract() {
|
||||
assert(result.agentsStates?.['current-plan-thread']?.planProgress?.completed === 2, 'Exact child card should receive plan progress');
|
||||
assert(result.agentsStates?.['current-plan-thread']?.planCurrentStep === '更新当前卡片', 'Exact child card should receive the current plan step');
|
||||
assert(!JSON.parse(oldTool.result).agentsStates?.['current-plan-thread'], 'Older unrelated child cards must remain untouched');
|
||||
api.flushPendingCcwebMcpChildSession(session.id);
|
||||
assert(api.getSavedSession()?.id === session.id, 'Exact child-card merge should save the session');
|
||||
|
||||
const currentResultBeforeMissingId = currentTool.result;
|
||||
@@ -3874,6 +4508,12 @@ async function main() {
|
||||
console.log('Session item tooltip regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'sidebar-title-refresh-storm') {
|
||||
assertSidebarTitleRefreshStormContract();
|
||||
assertCcwebMcpChildUpdateCoalescingContract();
|
||||
console.log('Sidebar title refresh storm regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'windows-startup') {
|
||||
assertWindowsStartupContract();
|
||||
console.log('Windows startup regression checks passed.');
|
||||
@@ -3904,6 +4544,7 @@ async function main() {
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
assertSetTitleMcpContract();
|
||||
assertSessionItemTooltipContract();
|
||||
assertCcwebMcpChildUpdateCoalescingContract();
|
||||
assertTitleHistoryOutlineContract();
|
||||
assertSessionSwitchResilienceContract();
|
||||
assertSessionSwitchRaceContract();
|
||||
@@ -5502,6 +6143,8 @@ async function main() {
|
||||
const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
|
||||
const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId);
|
||||
assert(importedCodexItem, 'Codex session listing failed');
|
||||
const codexSubagentItem = codexSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId);
|
||||
assert(!codexSubagentItem, 'Codex import list should hide subagent rollout threads');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath }));
|
||||
const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt');
|
||||
@@ -5518,7 +6161,7 @@ async function main() {
|
||||
assert(duplicateSourceItems.length === 1, 'Codex App import list should collapse rollout entries from the same cc-web source conversation');
|
||||
assert(duplicateSourceItems[0].duplicateCount === 2, 'Collapsed Codex App import item should report duplicate rollout count');
|
||||
const objectSourceItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId);
|
||||
assert(objectSourceItem?.source === 'subagent', 'Codex App import list should format object source metadata');
|
||||
assert(!objectSourceItem, 'Codex App import list should hide subagent rollout threads');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'import_codex_session',
|
||||
|
||||
Reference in New Issue
Block a user