fix: stabilize session list and Codex imports

This commit is contained in:
shiyue
2026-07-30 18:06:39 +08:00
parent 21b10e3eb4
commit cc600bdf31
13 changed files with 1247 additions and 42 deletions

View File

@@ -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',