Files
cc-web/scripts/regression.js
2026-07-30 18:06:39 +08:00

6342 lines
374 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const path = require('path');
const net = require('net');
const { spawn, spawnSync } = require('child_process');
const WebSocket = require('ws');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const WINDOWS_START_PATH = path.join(REPO_DIR, 'start.bat');
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
const GILDED_THEME_ASSETS = [
{
filename: 'gilded-wasteland.png',
format: 'png',
width: 1000,
height: 1500,
sha256: '11bf3ffed4422c08f6be217c63060ba68d63367e747a3604fc2d7b877e9c5955',
},
].map((asset) => ({
...asset,
path: path.join(REPO_DIR, 'public', 'assets', 'themes', asset.filename),
}));
const WASTELAND_THEME_ASSETS = {
root: path.join(REPO_DIR, 'public', 'assets', 'themes', 'wasteland'),
background: {
filename: 'background.webp',
width: 1672,
height: 941,
sha256: 'ee52b136294a7a31181b67e209253fefe7ace8c2b76885e45621484fecb802cb',
},
manifestFilename: path.join('icons', 'manifest.json'),
iconNames: [
'new-chat', 'search', 'dropdown', 'theme', 'ai', 'status', 'attachment', 'send', 'stop',
'settings', 'user', 'terminal', 'copy', 'refresh', 'chat', 'close', 'check',
],
};
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
const port = addr && typeof addr === 'object' ? addr.port : null;
server.close(() => resolve(port));
});
});
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function imageMarker(filename) {
return new RegExp(`\\[image:${escapeRegExp(filename)}\\]`);
}
function storedAttachmentImageMarker(attachment) {
const id = String(attachment?.id || '').trim();
assert(id, 'Uploaded attachment should include an id for localImage marker checks');
const mime = String(attachment?.mime || '').toLowerCase();
const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png';
return imageMarker(`${id}${ext}`);
}
function assertPersistedMessageAttachment(message, filename, label) {
const attachments = Array.isArray(message?.attachments) ? message.attachments : [];
const attachment = attachments.find((item) => item?.filename === filename);
assert(attachment, `${label} should persist attachment ${filename}`);
assert(attachment.storageState === 'available', `${label} should persist available attachment state`);
assert(!Object.prototype.hasOwnProperty.call(attachment, 'path'), `${label} should not persist local attachment paths`);
}
function sql(dbPath, statement) {
if (!HAS_SQLITE3) throw new Error('sqlite3 is not available');
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
return result.stdout.trim();
}
async function waitForPort(port, timeoutMs = 10000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' });
if (probe.status === 0) return;
await sleep(100);
}
throw new Error(`Timed out waiting for port ${port}`);
}
async function waitForFile(filePath, timeoutMs = 10000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (fs.existsSync(filePath)) return;
await sleep(50);
}
throw new Error(`Timed out waiting for file: ${filePath}`);
}
async function waitForJsonCondition(filePath, predicate, timeoutMs = 5000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
if (fs.existsSync(filePath)) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (predicate(parsed)) return parsed;
}
} catch (err) {
lastError = err;
}
await sleep(50);
}
throw new Error(`Timed out waiting for JSON condition: ${filePath}${lastError ? ` (${lastError.message})` : ''}`);
}
async function withServer(env, fn) {
const child = spawn('/usr/bin/node', [SERVER_PATH], {
cwd: REPO_DIR,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
try {
await waitForPort(env.PORT, 10000);
await fn({ child, stdout: () => stdout, stderr: () => stderr });
} finally {
child.kill('SIGTERM');
await sleep(300);
if (!child.killed) child.kill('SIGKILL');
}
}
async function startServer(env) {
const child = spawn('/usr/bin/node', [SERVER_PATH], {
cwd: REPO_DIR,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
await waitForPort(env.PORT, 10000);
return {
child,
stdout: () => stdout,
stderr: () => stderr,
async stop(signal = 'SIGTERM') {
if (child.exitCode !== null || child.signalCode) return;
child.kill(signal);
await sleep(300);
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
await sleep(200);
},
};
}
function connectWs(port, password, options = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const messages = [];
const receivedMessages = options.trackReceived ? [] : null;
let settled = false;
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'auth', password }));
});
ws.on('message', (buf) => {
const msg = JSON.parse(String(buf));
messages.push(msg);
if (receivedMessages) receivedMessages.push(msg);
if (msg.type === 'auth_result' && msg.success) {
settled = true;
resolve({ ws, messages, receivedMessages, token: msg.token });
}
if (msg.type === 'auth_result' && !msg.success) {
settled = true;
reject(new Error('Auth failed'));
}
});
ws.on('error', (err) => {
if (settled) return;
settled = true;
reject(err);
});
});
}
function assertWsUpgradeRejected(port, pathname) {
return new Promise((resolve, reject) => {
let settled = false;
const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`);
const timer = setTimeout(() => finish(reject, new Error(`WebSocket upgrade was not rejected for ${pathname}`)), 5000);
function finish(done, value) {
if (settled) return;
settled = true;
clearTimeout(timer);
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
try { ws.terminate(); } catch {}
}
done(value);
}
ws.on('open', () => {
finish(reject, new Error(`Unexpected WebSocket connection opened for ${pathname}`));
});
ws.on('unexpected-response', (req, res) => {
res.resume();
if (res.statusCode === 404) {
finish(resolve);
return;
}
finish(reject, new Error(`Expected 404 for ${pathname}, got ${res.statusCode}`));
});
ws.on('error', (err) => {
if (/Unexpected server response: 404/.test(err.message || '')) {
finish(resolve);
return;
}
finish(reject, err);
});
});
}
async function uploadAttachment(port, token, { filename, mime, data }) {
const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': mime,
'X-Filename': encodeURIComponent(filename),
},
body: data,
});
const payload = await response.json();
assert(response.ok && payload.ok, `Attachment upload failed: ${payload.message || response.status}`);
return payload.attachment;
}
async function fetchAuthedJson(port, token, pathname) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const payload = await response.json();
assert(response.ok && payload.ok, `Request failed for ${pathname}: ${payload.message || response.status}`);
return payload;
}
async function postAuthedJson(port, token, pathname, body = {}) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const payload = await response.json();
assert(response.ok && payload.ok, `POST failed for ${pathname}: ${payload.message || response.status}`);
return payload;
}
async function callInternalMcp(port, token, payload) {
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CC-Web-MCP-Token': token,
},
body: JSON.stringify(payload),
});
let body = null;
try {
body = await response.json();
} catch {}
return { status: response.status, body };
}
function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
const callSite = (() => {
const stack = String(new Error().stack || '').split('\n');
return (stack[3] || stack[2] || '').trim();
})();
return new Promise((resolve, reject) => {
const started = Date.now();
const timer = setInterval(() => {
const idx = messages.findIndex(predicate);
if (idx !== -1) {
clearInterval(timer);
const found = messages.splice(idx, 1)[0];
resolve(found);
return;
}
if (Date.now() - started > timeoutMs) {
clearInterval(timer);
const recentTypes = messages.slice(-12).map((m) => m?.type).join(', ');
const pendingTypes = messages.slice(0, 12).map((m) => m?.type).join(', ');
const recentDetails = messages.slice(-6).map((m) => JSON.stringify({
type: m?.type,
sessionId: m?.sessionId,
status: m?.status,
clientMessageId: m?.clientMessageId,
code: m?.code,
text: typeof m?.text === 'string' ? m.text.slice(0, 120) : undefined,
message: typeof m?.message === 'string' ? m.message.slice(0, 120) : undefined,
})).join(' | ');
reject(new Error(`Timed out waiting for expected WebSocket message (wsState=${ws.readyState}, callSite=${callSite}, pendingTypes=[${pendingTypes}], recentTypes=[${recentTypes}], recentDetails=[${recentDetails}])`));
}
}, 50);
});
}
function isSessionCompletionMessage(msg, sessionId) {
return (msg?.type === 'done' || msg?.type === 'background_done') && msg.sessionId === sessionId;
}
function createFakeClaudeHistory(homeDir) {
const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
mkdirp(projectDir);
const sessionId = 'claude-import-test';
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({
type: 'user',
cwd: '/tmp/project-a',
timestamp: '2026-03-12T00:00:00.000Z',
message: { content: 'Claude import prompt' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-03-12T00:00:02.000Z',
message: { content: [{ type: 'text', text: 'Claude import answer' }] },
}),
];
fs.writeFileSync(filePath, `${lines.join('\n')}\n`);
return { sessionId, projectDir: 'tmp-project', filePath };
}
function createFakeCodexHistory(homeDir, options = {}) {
const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12');
mkdirp(sessionsDir);
const threadId = options.threadId || 'codex-import-thread';
const cwd = options.cwd || '/tmp/project-b';
const userText = options.userText || 'Codex import prompt';
const answerText = options.answerText || 'Codex import answer';
const source = options.source || 'exec';
const cliVersion = options.cliVersion || '0.114.0';
const fileStamp = options.fileStamp || '2026-03-12T00-00-00';
const rolloutPath = path.join(sessionsDir, `rollout-${fileStamp}-${threadId}.jsonl`);
const rolloutLines = [
JSON.stringify({
timestamp: '2026-03-12T00:00:00.000Z',
type: 'session_meta',
payload: { id: threadId, cwd, cli_version: cliVersion, source },
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:00.100Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }],
},
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:01.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: userText },
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:02.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: answerText }],
},
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:03.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } },
},
}),
];
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
let stateDb = null;
let logsDb = null;
if (HAS_SQLITE3) {
stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
mkdirp(path.dirname(stateDb));
sql(stateDb, `
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL,
model_provider TEXT NOT NULL,
cwd TEXT NOT NULL,
title TEXT NOT NULL,
sandbox_policy TEXT NOT NULL,
approval_mode TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
agent_nickname TEXT,
agent_role TEXT,
memory_mode TEXT NOT NULL DEFAULT 'enabled'
);
CREATE TABLE IF NOT EXISTS stage1_outputs (
thread_id TEXT PRIMARY KEY,
source_updated_at INTEGER NOT NULL,
raw_memory TEXT NOT NULL,
rollout_summary TEXT NOT NULL,
generated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
input_schema TEXT NOT NULL,
PRIMARY KEY(thread_id, position)
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, '${source}', 'OpenAI', '${cwd.replace(/'/g, "''")}', '${userText.replace(/'/g, "''")}', '{}', 'never', '${cliVersion}');
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
sql(logsDb, `
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
}
return { threadId, rolloutPath, stateDb, logsDb };
}
function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = 'xhigh' } = {}) {
const codexDir = path.join(homeDir, '.codex');
mkdirp(codexDir);
fs.writeFileSync(path.join(codexDir, 'config.toml'), [
'model_provider = "test"',
`model = "${model}"`,
`model_reasoning_effort = "${reasoningEffort}"`,
'',
'[projects."/tmp/project-b"]',
'trust_level = "trusted"',
'',
'[mcp_servers.reg-config]',
'command = "node"',
'args = ["regression-mcp.js"]',
'',
].join('\n'));
}
function assertFrontendSidebarCollapseContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const buttonMarkup = indexSource.match(/<button id="menu-btn"[^>]*>/)?.[0] || '';
assert(buttonMarkup, 'Chat header should retain the sidebar toggle next to the title');
['type="button"', 'aria-controls="sidebar"', 'aria-expanded="true"', 'aria-label="收起左侧菜单"'].forEach((attribute) => {
assert(buttonMarkup.includes(attribute), `Sidebar toggle should expose ${attribute}`);
});
assert(source.includes("const SIDEBAR_COLLAPSE_STORAGE_KEY = 'cc-web-sidebar-collapsed'"), 'Desktop sidebar preference should use a stable storage key');
assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint');
assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities');
assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root');
assert(
indexSource.includes('id="user-outline-panel" class="user-outline-panel"')
&& indexSource.includes('id="ccweb-prompt-outline-panel" class="user-outline-panel ccweb-prompt-outline-panel"'),
'User-message and pending-form locators should share the responsive outline panel layout'
);
const baseOutlinePanelStyleStart = styleSource.indexOf('.user-outline-panel {');
const baseOutlinePanelStyleEnd = styleSource.indexOf('.user-outline-empty', baseOutlinePanelStyleStart);
const baseOutlinePanelStyle = styleSource.slice(baseOutlinePanelStyleStart, baseOutlinePanelStyleEnd);
assert(
/\.user-outline-panel\s*\{[^}]*position:\s*absolute;[^}]*right:\s*0;/.test(baseOutlinePanelStyle),
'Fixed-expanded desktop locators should retain their right-aligned base placement'
);
assert(source.includes('persistSidebarCollapsedPreference(nextCollapsed)'), 'Desktop sidebar toggle should persist the user preference');
assert(
source.includes("menuBtn.setAttribute('aria-expanded', String(expanded))")
&& source.includes("menuBtn.setAttribute('aria-label', label)"),
'Sidebar toggle should synchronize its accessible state and label'
);
assert(
/menuBtn\.addEventListener\('click',[\s\S]*?if \(isSidebarDrawerMode\(\)\)[\s\S]*?openSidebar\(\)[\s\S]*?if \(isSidebarDesktopCollapseMode\(\)\)[\s\S]*?setSidebarCollapsed\(/.test(source),
'Sidebar toggle should preserve the mobile drawer path before using desktop fixed collapse'
);
assert(
source.includes('[sidebarDrawerMedia, sidebarDesktopCollapseMedia].forEach((media) =>')
&& source.includes("media.addEventListener('change', handleSidebarInputModeChange)"),
'Sidebar should reset transient state when either drawer or desktop collapse mode changes'
);
assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail');
const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)');
const desktopCollapseStyleEnd = styleSource.indexOf('@media (prefers-reduced-motion: reduce)', desktopCollapseStyleStart);
assert(
desktopCollapseStyleStart >= 0 && desktopCollapseStyleEnd > desktopCollapseStyleStart,
'Desktop hover behavior should stay isolated from touch and narrow-screen drawers'
);
const desktopCollapseStyle = styleSource.slice(desktopCollapseStyleStart, desktopCollapseStyleEnd);
assert(
styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))')
&& styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'),
'Collapsed sidebar should only consume the rail width without resizing theme-specific sidebars'
);
assert(
styleSource.includes('.app.sidebar-collapsed .sidebar:is(:hover, :focus-within)')
&& styleSource.includes('transform: translateX(0)'),
'Collapsed sidebar should temporarily expand for pointer hover and keyboard focus'
);
assert(
/\.app\.sidebar-collapsed \.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*z-index:\s*120;/.test(desktopCollapseStyle),
'Both collapsed-sidebar locator panels should open toward the chat canvas above the hover preview'
);
const mobileSidebarStyleStart = styleSource.indexOf('@media (max-width: 768px)');
const mobileSidebarStyleEnd = styleSource.indexOf('@media (max-width: 480px)', mobileSidebarStyleStart);
const mobileSidebarStyle = styleSource.slice(mobileSidebarStyleStart, mobileSidebarStyleEnd);
assert(
/\.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(mobileSidebarStyle),
'Closed mobile drawer should keep both locator panels inside the chat canvas'
);
const compactMobileStyleStart = mobileSidebarStyleEnd;
const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart);
const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd);
assert(
/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle),
'Compact-mobile user locator should open left from the second grid column'
);
assert(
/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
'Compact-mobile pending-form locator should open right from the first grid column'
);
assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment');
assert(
source.includes('if (isSidebarDesktopCollapseMode())')
&& source.includes("const expanded = isDrawer\n ? sidebar.classList.contains('open')\n : (isDesktopCollapsible ? !app.classList.contains('sidebar-collapsed') : true)"),
'Unsupported wide-screen pointer modes should stay visually and accessibly expanded'
);
assert(
styleSource.includes("html[data-theme='gilded'] .app.sidebar-collapsed .sidebar::after")
&& styleSource.includes("html[data-theme='wasteland'] .app.sidebar-collapsed .sidebar::after"),
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
);
assert(
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'
);
}
function assertFrontendGenerationControlsContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const controlsStart = source.indexOf('function updateGenerationControls()');
const controlsEnd = source.indexOf('\n function updateNoteModeUI()', controlsStart);
assert(controlsStart >= 0 && controlsEnd > controlsStart, 'Frontend should define updateGenerationControls before updateNoteModeUI');
for (const target of ['sendBtn.hidden', 'abortBtn.hidden']) {
const regex = new RegExp(`${target.replace('.', '\\.')}\\s*=`, 'g');
let match;
while ((match = regex.exec(source))) {
assert(
match.index > controlsStart && match.index < controlsEnd,
`${target} should only be assigned in updateGenerationControls`
);
}
}
const resumeStart = source.indexOf("case 'resume_generating':");
const resumeEnd = source.indexOf("case 'error':", resumeStart);
assert(resumeStart >= 0 && resumeEnd > resumeStart, 'Frontend should keep an explicit resume_generating handler');
const resumeBlock = source.slice(resumeStart, resumeEnd);
assert(
resumeBlock.includes('updateGenerationControls();'),
'resume_generating should refresh send/abort controls when reusing an existing streaming bubble'
);
const controlsBlock = source.slice(controlsStart, controlsEnd);
assert(
/allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock),
'Codex App should keep the runtime insert send button visible while generating'
);
const staleDefaultApprovalWarning = ['默认模式的', '授权申请功能', '暂未实现'].join('');
assert(
!source.includes(staleDefaultApprovalWarning),
'Frontend should not show the stale default-mode approval warning after Codex App approvals are supported'
);
assert(
!source.includes('Codex App 暂不支持导入') && !source.includes('Codex App 模式暂不支持导入'),
'Frontend should not disable Codex App native session import'
);
assert(
source.includes("send({ type: 'list_codex_sessions', agent: importAgent })") &&
source.includes("send({ type: 'import_codex_session', agent: importAgent"),
'Frontend Codex import modal should pass the selected Codex-like agent'
);
assert(
source.includes('function appendImportVisibilityToggle') &&
source.includes('显示已导入会话') &&
source.includes('cc-web 已存在的会话'),
'Frontend import modal should expose a toggle for already imported sessions'
);
assert(
source.includes('(group.sessions || []).filter((sess) => !sess.alreadyImported)') &&
source.includes('codexItems.filter((sess) => !sess.alreadyImported)'),
'Frontend import modal should hide already imported sessions by default'
);
}
function assertFrontendComposerMcpContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const requestStart = source.indexOf('function requestComposerSuggestions()');
const requestEnd = source.indexOf('\n function handleComposerSuggestions', requestStart);
assert(requestStart >= 0 && requestEnd > requestStart, 'Frontend should define requestComposerSuggestions before handleComposerSuggestions');
const requestBlock = source.slice(requestStart, requestEnd);
const slashStart = requestBlock.indexOf("if (token.trigger === '/')");
const slashEnd = requestBlock.indexOf('clearTimeout(composerSuggestionTimer);', slashStart);
assert(slashStart >= 0 && slashEnd > slashStart, 'Slash composer branch should precede backend debounce');
const slashBlock = requestBlock.slice(slashStart, slashEnd);
assert(slashBlock.includes('getLocalSlashSuggestions(token.query)'), 'Slash composer should keep local fallback suggestions');
assert(!/\breturn\s*;/.test(slashBlock), 'Slash composer should continue to backend suggestions so MCP items can be merged');
const menuStart = source.indexOf('function showCmdMenu(token, items)');
const menuEnd = source.indexOf('\n function requestComposerSuggestions()', menuStart);
assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
const menuBlock = source.slice(menuStart, menuEnd);
assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels');
const selectStart = source.indexOf('function selectComposerItemByIndex(index)');
const selectEnd = source.indexOf('\n function selectCmdMenuItem()', selectStart);
assert(selectStart >= 0 && selectEnd > selectStart, 'Frontend should define selectComposerItemByIndex before selectCmdMenuItem');
const selectBlock = source.slice(selectStart, selectEnd);
assert(selectBlock.includes('const insertion = String(item.insertion || item.label || item.name || \'\');'), 'Composer should insert selected item text through the generic insertion path');
assert(selectBlock.includes('const appendSpace = item.appendSpace !== false;'), 'Composer should honor appendSpace for generic MCP insertion');
assert(!source.includes('function showCcwebPromptUserComposerModal'), 'Composer should not open a parameter builder for ccweb_prompt_user');
assert(!source.includes('composer_mcp_tool_submit'), 'Frontend should not submit ccweb_prompt_user from slash composer as structured MCP args');
assert(!serverSource.includes('composer_mcp_tool_submit'), 'Server should not accept slash-composer structured MCP tool submissions');
assert(!source.includes('data-composer-mcp-questions'), 'Frontend should not render a slash-composer MCP argument builder');
assert(!source.includes('data-option-field="recommended"'), 'Frontend should not render slash-composer MCP option editors');
assert(source.includes('function renderComposerMentionsStrip(meta)'), 'Frontend should define composer mention strip renderer');
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
}
function assertComposerSlashRoutingContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
assert(source.includes('const pendingSlashDraftsByRequestId = new Map();'), 'Frontend should keep request-scoped slash drafts');
assert(source.includes('function rememberPendingSlashDraft(requestId, text, sessionId, agent)'), 'Frontend should define slash draft capture');
assert(source.includes('function applyPendingSlashDraftResponse(msg)'), 'Frontend should define slash draft response handling');
assert(source.includes('if (!msg.preserveComposerDraft) return false;'), 'Frontend should restore slash drafts only for explicit failure responses');
assert(source.includes("if (msgInput.value !== '' || pendingAttachments.length > 0) return false;"), 'Frontend should not overwrite user input typed after a slash request');
assert(source.includes('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);'), 'Frontend should remember slash draft before clearing the composer');
assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId');
assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error');
const frontendCommandsMatch = source.match(/const\s+SLASH_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
const serverCommandsMatch = serverSource.match(/const\s+COMPOSER_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
assert(frontendCommandsMatch && serverCommandsMatch, 'Frontend and server should both declare composer slash command lists');
const frontendCommandNames = Array.from(frontendCommandsMatch[1].matchAll(/\bcmd:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
const serverCommandNames = Array.from(serverCommandsMatch[1].matchAll(/\bname:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
assert(frontendCommandNames.length > 0, 'Frontend slash command list should not be empty');
assert(
JSON.stringify([...frontendCommandNames].sort()) === JSON.stringify([...serverCommandNames].sort()),
'Frontend and server should classify the same complete set of known slash commands'
);
const knownSlashSource = extractFunctionSource(source, 'isKnownSlashCommandText');
const knownSlashApi = new Function('SLASH_COMMANDS', `
${knownSlashSource}
return isKnownSlashCommandText;
`)(frontendCommandNames.map((cmd) => ({ cmd })));
frontendCommandNames.forEach((command) => {
assert(knownSlashApi(`${command.toUpperCase()} argument`), `Known slash command ${command} should match case-insensitively`);
});
assert(!knownSlashApi('/report/mcps?search') && !knownSlashApi('/help/topic'), 'Unknown slash paths should remain ordinary messages');
const tokenSource = extractFunctionSource(source, 'findActiveComposerToken');
const tokenApi = new Function(`
let msgInput = null;
${tokenSource}
return (value, cursor = value.length) => {
msgInput = { value, selectionStart: cursor };
return findActiveComposerToken();
};
`)();
assert(tokenApi('/rep')?.trigger === '/', 'Slash suggestions should trigger at the composer start');
assert(tokenApi('请查看 /rep')?.query === 'rep', 'Slash suggestions should trigger after whitespace');
assert(tokenApi('请查看\n/rep')?.query === 'rep', 'Slash suggestions should trigger at a new line');
assert(tokenApi('请查看\r\n/rep')?.query === 'rep', 'Slash suggestions should trigger after a CRLF line boundary');
assert(tokenApi('请查看 /rep', 3) === null, 'Slash suggestions should follow the cursor and ignore tokens after it');
assert(tokenApi('src/foo') === null, 'Slash suggestions should not trigger inside a path token');
assert(tokenApi('请查看 @file')?.trigger === '@' && tokenApi('请使用 $skill')?.trigger === '$', '@ and $ trigger behavior should remain unchanged');
const sendStart = source.indexOf('function sendMessage()');
const slashStart = source.indexOf('if (isKnownSlashCommandText(text))', sendStart);
const rememberStart = source.indexOf('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);', slashStart);
const modelPickerStart = source.indexOf("if (text === '/model' || text === '/model ')", slashStart);
const modePickerStart = source.indexOf("if (text === '/mode' || text === '/mode ')", slashStart);
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should reserve the slash send branch for known commands');
assert(modelPickerStart > slashStart && modelPickerStart < rememberStart, 'Frontend /model picker should stay local and clear normally');
assert(modePickerStart > slashStart && modePickerStart < rememberStart, 'Frontend /mode picker should stay local and clear normally');
assert(/const\s+handled\s*=\s*handleSlashCommand\(ws, msg\.text\.trim\(\), msg\.sessionId, msg\.agent, msg\);/.test(serverSource), 'Server should inspect slash text and retain the handled result');
assert(/if\s*\(!handled\)\s*handleMessage\(ws, msg/.test(serverSource), 'Server should pass unknown slash text into the ordinary message pipeline');
assert(serverSource.includes('function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {})'), 'Server slash handler should accept request metadata');
assert(serverSource.includes('wsSend(ws, attachClientRequestId(base, source));'), 'Server slash responses should echo requestId');
assert(/default:\s*\n\s*sendSlashSystemMessage\(`未知指令:[\s\S]*?\n\s*return false;/.test(serverSource), 'Unknown slash hints should not restore the composer and should report an unhandled command');
const serverSlashHandler = extractFunctionSource(serverSource, 'handleSlashCommand');
const serverHandledCommandNames = Array.from(serverSlashHandler.matchAll(/case\s+['"]([^'"]+)['"]\s*:/g), (match) => match[1]);
assert(
JSON.stringify([...serverCommandNames].sort()) === JSON.stringify([...serverHandledCommandNames].sort()),
'Every server-declared slash command should keep an explicit handler case'
);
const unknownGuardStart = serverSlashHandler.indexOf('if (!COMPOSER_COMMANDS.some((item) => item.name === cmd))');
const runningGuardStart = serverSlashHandler.indexOf('activeCodexAppTurns.has(sessionId)');
assert(unknownGuardStart >= 0 && unknownGuardStart < runningGuardStart, 'Unknown slash text should be classified before active Codex App command guards');
assert(serverSource.includes('wsSend(ws, attachClientRequestId({') && serverSource.includes('...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {})'), 'Server runtime errors should echo requestId and draft-preservation metadata');
}
function assertMockCodexAppPromptUserNotTextTriggered() {
const source = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
assert(!source.includes('codexapp runtime prompt mcp'), 'Mock Codex App should not expose a text-triggered ccweb_prompt_user path');
assert(!source.includes('mcp-ccweb-prompt-user'), 'Regression should not depend on a mock ccweb_prompt_user tool call id');
}
function assertPlanListProgressContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
const normalizeSource = extractFunctionSource(source, 'normalizePlanProgress');
const payloadSource = extractFunctionSource(source, 'planProgressFromPayload');
const resolveSource = extractFunctionSource(source, 'resolveToolPlanProgress');
const progressApi = new Function(`
const toolKind = (tool) => tool?.kind || tool?.meta?.kind || null;
${normalizeSource}
${payloadSource}
${resolveSource}
return { normalizePlanProgress, planProgressFromPayload, resolveToolPlanProgress };
`)();
assert(
JSON.stringify(progressApi.normalizePlanProgress({ completed: 3, total: 5 })) === JSON.stringify({ completed: 3, total: 5 }),
'Plan progress should preserve valid completed and total counts'
);
assert(
JSON.stringify(progressApi.normalizePlanProgress({ completed: 9, total: 5 })) === JSON.stringify({ completed: 5, total: 5 }),
'Plan progress should clamp completed counts to the total'
);
assert(
JSON.stringify(progressApi.planProgressFromPayload({ items: [
{ completed: true },
{ completed: false },
{ completed: true },
] })) === JSON.stringify({ completed: 2, total: 3 }),
'Legacy todo payloads without explicit progress should derive counts from items'
);
assert(
JSON.stringify(progressApi.resolveToolPlanProgress({
kind: 'todo_list',
meta: { progress: { completed: 3, total: 5 } },
})) === JSON.stringify({ completed: 3, total: 5 }),
'Todo tool summaries should resolve progress from runtime metadata'
);
assert(
JSON.stringify(progressApi.resolveToolPlanProgress({
kind: 'todo_list',
meta: { progress: { completed: 7, total: 8 } },
input: { progress: { completed: 7, total: 8 } },
result: JSON.stringify({
type: 'todo_list',
items: Array.from({ length: 8 }, () => ({ completed: true })),
}),
})) === JSON.stringify({ completed: 8, total: 8 }),
'Latest todo result should override stale meta and input progress so the final icon lights up'
);
assert(progressApi.resolveToolPlanProgress({ kind: 'command_execution' }) === null, 'Non-plan tools should not render plan progress');
const sent = [];
const runtime = createCodexAppRuntime({
wsSend: (_ws, payload) => sent.push(payload),
loadSession: () => null,
saveSession: () => {},
});
const entry = { ws: {}, toolCalls: [], fullText: '' };
runtime.processCodexAppNotification(entry, {
method: 'plan/updated',
params: {
id: 'regression-plan-progress',
status: 'inProgress',
plan: [
{ step: '完成数据契约', status: 'completed' },
{ step: '完成 DOM', status: 'completed' },
{ step: '补充回归', status: 'completed' },
{ step: '实现样式', status: 'in_progress' },
{ step: '浏览器验收', status: 'pending' },
],
},
}, 'plan-progress-session');
const update = sent.find((message) => message.type === 'tool_update' && message.toolUseId === 'regression-plan-progress');
assert(update, 'Plan updates should emit a tool_update payload');
assert(update.meta?.progress?.completed === 3 && update.meta?.progress?.total === 5, 'Plan metadata should expose 3/5 progress');
assert(update.input?.progress?.completed === 3 && update.input?.progress?.total === 5, 'Normalized todo input should expose 3/5 progress');
const result = JSON.parse(update.result);
assert(result.progress?.completed === 3 && result.progress?.total === 5, 'Persisted todo result should expose 3/5 progress');
const summarySource = extractFunctionSource(source, 'applyToolSummary');
const progressElementSource = extractFunctionSource(source, 'createPlanProgressElementFromProgress');
assert(summarySource.includes('createPlanProgressElement(tool)'), 'Tool summaries should append the plan progress element beside the title');
assert(progressElementSource.includes("meter.setAttribute('role', 'img')"), 'Plan progress should expose an accessible image role');
assert(progressElementSource.includes("meter.setAttribute('aria-label', progressLabel)"), 'Plan progress should announce completed and total counts');
assert(progressElementSource.includes('Math.min(normalizedProgress.total, 12)'), 'Long plans should cap visible dots to protect the header layout');
assert(progressElementSource.includes("count.textContent = `${normalizedProgress.completed}/${normalizedProgress.total}`"), 'Compacted long plans should keep an exact numeric count');
assert(styleSource.includes('--plan-progress-complete: var(--success);'), 'Plan progress should inherit the active theme success color');
assert(styleSource.includes('--plan-progress-remaining: var(--accent);'), 'Remaining plan progress should inherit the active theme accent color');
assert(/\.plan-progress-dot\s*\{[^}]*width:\s*14px;[^}]*height:\s*14px;[^}]*margin-left:\s*8px;/.test(styleSource), 'Desktop plan progress dots should keep the requested 14px size and 8px left margin');
assert(/\.plan-progress-dot\.is-complete\s*\{[^}]*background:\s*var\(--plan-progress-complete\);/.test(styleSource), 'Completed plan dots should use the semantic completion token');
assert(/\.plan-progress-dot\.is-remaining\s*\{[^}]*background:\s*var\(--plan-progress-remaining\);[^}]*box-shadow:\s*inset 0 0 0 1px[^}]*animation:\s*plan-progress-breathe 1\.8s/.test(styleSource), 'Remaining plan dots should use the semantic color, 1px inner border and breathing animation');
assert(/@keyframes plan-progress-breathe\s*\{[\s\S]*?transform:\s*scale\(0\.9\);[\s\S]*?transform:\s*scale\(1\);[\s\S]*?\}/.test(styleSource), 'Plan progress breathing should use a restrained scale cycle');
assert(/@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.plan-progress-dot\.is-remaining\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;/.test(styleSource), 'Plan progress breathing should respect reduced-motion preferences');
assert(/@media \(max-width:\s*560px\)[\s\S]*?\.plan-progress-dot\s*\{[^}]*width:\s*5px;[^}]*height:\s*5px;/.test(styleSource), 'Plan progress dots should stay compact on narrow screens');
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\s*\{[^}]*width:\s*20px;[^}]*height:\s*20px;[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/.test(styleSource), 'Wasteland plan progress should replace circles with 20px transparent icon canvases');
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\.is-complete\s*\{[^}]*url\('assets\/themes\/wasteland\/icons\/plan-progress\/complete\.png'\);/.test(styleSource), 'Wasteland completed tasks should use the local green status jewel');
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\.is-remaining\s*\{[^}]*url\('assets\/themes\/wasteland\/icons\/plan-progress\/remaining\.png'\);[^}]*box-shadow:\s*none;/.test(styleSource), 'Wasteland remaining tasks should use the local gold loading ring without a square inset border');
assert(/@media \(max-width:\s*560px\)[\s\S]*?html\[data-theme='wasteland'\] \.plan-progress-dot\s*\{[^}]*width:\s*16px;[^}]*height:\s*16px;/.test(styleSource), 'Wasteland progress icons should remain legible and compact on narrow screens');
const isolatedPlanIconRules = Array.from(styleSource.matchAll(/html\[data-theme='([^']+)'\] \.plan-progress-dot\.is-(?:complete|remaining)\s*\{[^}]*icons\/plan-progress\//g));
assert(isolatedPlanIconRules.length === 2 && isolatedPlanIconRules.every((match) => match[1] === 'wasteland'), 'Plan progress image assets should stay isolated to the Wasteland theme');
const planAssetRoot = path.join(WASTELAND_THEME_ASSETS.root, 'icons', 'plan-progress');
const planManifestPath = path.join(planAssetRoot, 'manifest.json');
assert(fs.existsSync(planManifestPath), 'Wasteland plan progress should ship a reproducible asset manifest');
const planManifest = JSON.parse(fs.readFileSync(planManifestPath, 'utf8'));
assert(planManifest.source_sha256 === '482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e', 'Plan progress manifest should retain the reviewed source sheet hash');
const expectedPlanAssets = {
complete: {
semantic: '已完成',
card: 'R2C3 STATUS (ONLINE)',
sha256: '2f75c0dfc105e7fd50b1c90d3ca3b2439306afd50c2a0b5ebdeb674cd592516a',
},
remaining: {
semantic: '未完成',
card: 'R2C4 LOADING',
sha256: '90decca2d4b8204032fa06c0bbde6e9f49afc11bf4fabe21b8307caf5769d672',
},
};
assert(Array.isArray(planManifest.assets) && planManifest.assets.length === 2, 'Plan progress manifest should list exactly two semantic assets');
Object.entries(expectedPlanAssets).forEach(([name, expected]) => {
const entry = planManifest.assets.find((asset) => asset.name === name);
assert(entry?.semantic === expected.semantic && entry?.source_card === expected.card, `${name} progress icon should keep its reviewed semantic and source card`);
assert(entry?.source_variant === '16px', `${name} progress icon should be cut from the native 16px source variant`);
assert(entry?.size?.[0] === 20 && entry?.size?.[1] === 20, `${name} progress icon manifest should retain a 20x20 canvas`);
assert(Array.isArray(entry?.alpha_bbox) && entry.alpha_bbox[2] >= 16 && entry.alpha_bbox[3] >= 16, `${name} progress icon should retain a visible alpha subject`);
const assetPath = path.join(planAssetRoot, `${name}.png`);
assert(fs.existsSync(assetPath), `Wasteland should ship ${name}.png`);
const asset = fs.readFileSync(assetPath);
assert(asset.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${name}.png should remain a PNG`);
assert(asset.readUInt32BE(16) === 20 && asset.readUInt32BE(20) === 20 && asset.readUInt8(25) === 6, `${name}.png should remain a 20x20 RGBA image`);
const actualHash = crypto.createHash('sha256').update(asset).digest('hex');
assert(actualHash === expected.sha256 && entry.sha256 === expected.sha256, `${name}.png should match the reviewed manifest hash`);
});
const extractorPath = path.join(REPO_DIR, '.trellis', 'tasks', '07-17-gilded-wasteland-theme', 'research', 'extract_plan_progress_icons.py');
const extractorSource = fs.readFileSync(extractorPath, 'utf8');
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=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() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const themeVarsStart = styleSource.indexOf("html[data-theme='gilded'] {");
const themeVarsEnd = styleSource.indexOf('\n}', themeVarsStart);
const themeVars = themeVarsStart >= 0 && themeVarsEnd > themeVarsStart
? styleSource.slice(themeVarsStart, themeVarsEnd + 2)
: '';
const themeStyleStart = styleSource.indexOf('/* === Warframe Orokin');
const themeStyleEnd = styleSource.indexOf('/* === 暗金荒野', themeStyleStart);
const themeStyle = themeStyleStart >= 0 && themeStyleEnd > themeStyleStart
? styleSource.slice(themeStyleStart, themeStyleEnd)
: '';
const themeOption = source.match(/\{\s*value: 'gilded',[\s\S]*?\n\s*\},/);
assert(themeOption, 'Frontend should register the gilded theme option');
assert(themeOption[0].includes('hidden: true'), 'Warframe theme should remain registered but hidden until visual sign-off');
assert(source.includes('item.value === theme && !item.hidden'), 'Stored hidden themes should normalize to the default theme');
assert(source.includes('THEME_OPTIONS.filter((theme) => !theme.hidden).map'), 'Theme picker should exclude hidden themes');
assert(indexSource.includes("if (theme === 'gilded')") && indexSource.includes("theme = 'washi'"), 'Page bootstrap should migrate an active hidden Warframe theme before first paint');
assert(themeOption[0].includes("label: 'Warframe Wasteland'"), 'Gilded theme should expose an accurate Warframe picker label');
assert(
themeOption[0].includes("swatches: ['#fbf3e5', '#efe4d2', '#7a3f20', '#147276']"),
'Gilded picker swatches should expose the ivory, parchment, copper and energy theme system'
);
assert(themeOption[0].includes('暖象牙与焦土铜铺开 Orokin 荒野档案'), 'Gilded picker copy should describe the current warm-paper theme');
[source, indexSource].forEach((markupSource, index) => {
const label = index === 0 ? 'Dynamic' : 'Static';
assert(markupSource.includes('class="warframe-hero"') && markupSource.includes('class="welcome-copy"'), `${label} welcome markup should expose the single-image Warframe hero`);
assert(markupSource.includes('class="warframe-hero-art" aria-hidden="true"'), `${label} Warframe hero art should remain decorative`);
assert((markupSource.match(/class="warframe-hero-image"/g) || []).length === 1, `${label} welcome markup should render exactly one Warframe hero image`);
assert(markupSource.includes('src="assets/themes/gilded-wasteland.png"'), `${label} welcome markup should render the reviewed local Warframe artwork`);
assert(
markupSource.includes('<div class="welcome-icon">✿</div>')
&& markupSource.includes('data-welcome-project-copy')
&& markupSource.includes('本次你要构建什么?'),
`${label} welcome markup should preserve the shared project-aware welcome content`
);
assert(!markupSource.includes('warframe-shard'), `${label} welcome markup should not restore the obsolete four-shard composition`);
['warframe-sacrifice.jpg', 'warframe-orokin-figure.jpg', 'warframe-ember-squad.jpg'].forEach((referenceAsset) => {
assert(!markupSource.includes(referenceAsset), `${label} welcome markup should not render reference-only artwork: ${referenceAsset}`);
});
});
[source, indexSource, styleSource].forEach((content, index) => {
const label = ['Dynamic markup', 'Static markup', 'Stylesheet'][index];
['warframe-quick', 'data-warframe-prompt', '分析代码库', '规划下一步', '排查问题'].forEach((forbidden) => {
assert(!content.includes(forbidden), `${label} should not contain theme-specific shortcuts or preset prompts: ${forbidden}`);
});
});
['cdn.displate.com', 'pinterest.com', 'pinimg.com'].forEach((referenceDomain) => {
assert(!source.includes(referenceDomain) && !styleSource.includes(referenceDomain) && !indexSource.includes(referenceDomain), `Reference artwork domain should not be copied into project UI files: ${referenceDomain}`);
});
GILDED_THEME_ASSETS.forEach((expected) => {
assert(fs.existsSync(expected.path), `Gilded theme should ship local artwork: ${expected.filename}`);
const asset = fs.readFileSync(expected.path);
const actualHash = crypto.createHash('sha256').update(asset).digest('hex');
assert(actualHash === expected.sha256, `${expected.filename} should remain byte-identical to the reviewed source`);
assert(asset.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${expected.filename} should remain a PNG`);
assert(asset.readUInt32BE(16) === expected.width && asset.readUInt32BE(20) === expected.height, `${expected.filename} should retain its reviewed ${expected.width}x${expected.height} canvas`);
});
assert(themeVars, 'Stylesheet should define an isolated gilded semantic variable block');
[
'--bg-primary: #fbf3e5',
'--bg-bubble-user: #7a3f20',
'--bg-bubble-assistant: #fff7ea',
'--text-primary: #201812',
'--text-secondary: #4b392a',
'--text-muted: #655446',
'--accent: #7a3f20',
'--warframe-ivory: #fff7ea',
'--warframe-gold: #d5af63',
'--warframe-copper: #7a3f20',
'--warframe-energy: #147276',
].forEach((token) => assert(themeVars.includes(token), `Gilded theme is missing readable surface token: ${token}`));
assert(!/url\(\s*['"]?https?:\/\//i.test(themeVars), 'Gilded semantic variables should never load remote artwork');
assert(themeStyleStart >= 0, 'Stylesheet should include a dedicated gilded component layer');
assert(themeStyle.includes("html:not([data-theme='gilded']) .warframe-hero-art"), 'Non-Warframe themes should hide the single-image hero art');
assert(themeStyle.includes('display: contents;'), 'Non-Warframe themes should preserve the original welcome layout semantics');
assert(themeStyle.includes('.welcome-msg > .warframe-hero') && themeStyle.includes('.warframe-hero-image'), 'Gilded theme should scope the single-image hero to the welcome composition');
assert(!themeStyle.includes('.warframe-shard'), 'Gilded component layer should not restore obsolete four-shard styles');
assert(!themeStyle.includes("url('assets/themes/gilded-wasteland.png')"), 'Warframe artwork should stay a real image node rather than a CSS wallpaper');
assert(!/url\(\s*['"]?https?:\/\//i.test(themeStyle), 'Gilded theme styles should never load remote reference artwork');
const chatMainRule = themeStyle.match(/html\[data-theme='gilded'\] \.chat-main\s*\{([^}]*)\}/);
assert(chatMainRule && !chatMainRule[1].includes('url('), 'Routine chat workspace should not render artwork behind messages');
assert(themeStyle.includes('var(--warframe-ivory)') && themeStyle.includes('rgba(255, 247, 234, 0.94)'), 'Gilded panels should use readable ivory surfaces');
assert(themeStyle.includes(".session-search-input:focus") && themeStyle.includes('rgba(20, 114, 118, 0.14)'), 'Gilded search and composer focus should use the restrained energy focus ring');
assert(themeStyle.includes('.input-wrapper.drag-active') && themeStyle.includes('var(--warframe-energy)'), 'Gilded upload drag state should use the shared energy token');
assert(themeStyle.includes('.new-chat-btn') && themeStyle.includes('var(--warframe-copper)'), 'Gilded primary actions should use the shared copper material');
assert(themeStyle.includes('.msg.user .msg-bubble') && themeStyle.includes('.msg.assistant .msg-bubble'), 'Gilded message surfaces should define both user and assistant readability');
assert(themeStyle.includes('.msg-attachment-card') && themeStyle.includes('.msg-attachment-thumb'), 'Gilded attachment surfaces should remain explicit and readable');
assert(/@keyframes warframeCopyIn\s*\{[\s\S]*?from\s*\{\s*opacity:\s*1;/.test(themeStyle), 'Warframe welcome copy should remain visible from the first animation frame');
assert(/@keyframes warframeHeroImageIn\s*\{[\s\S]*?from\s*\{\s*opacity:\s*0\.72;/.test(themeStyle), 'Warframe hero image should remain visible from the first animation frame');
assert(themeStyle.includes("html[data-theme='gilded'] .sidebar {\n position: fixed;"), 'Gilded mobile sidebar should not consume layout width while closed');
assert(/@media \(max-width:\s*768px\)[\s\S]*?\.welcome-msg > \.warframe-hero\s*\{[\s\S]*?height:\s*clamp\(320px,\s*52svh,\s*430px\);[\s\S]*?\.warframe-hero-image/.test(themeStyle), 'Gilded tablet and mobile layouts should retain the single hero without horizontal overflow');
assert(/@media \(max-width:\s*420px\)[\s\S]*?\.welcome-msg > \.warframe-hero\s*\{[\s\S]*?height:\s*clamp\(340px,\s*52svh,\s*430px\);/.test(themeStyle), 'Gilded narrow-mobile layout should keep a viewport-aware hero height');
const rgb = (hex) => [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
const luminance = (hex) => rgb(hex)
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
const contrast = (foreground, background) => {
const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
return (values[0] + 0.05) / (values[1] + 0.05);
};
assert(contrast('#201812', '#fff7ea') >= 7, 'Gilded primary text should reach AAA contrast on ivory panels');
assert(contrast('#4b392a', '#fff7ea') >= 7, 'Gilded secondary text should reach AAA contrast on ivory panels');
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=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() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const themeVarsStart = styleSource.indexOf("html[data-theme='wasteland'] {");
const themeVarsEnd = styleSource.indexOf('\n}', themeVarsStart);
const themeVars = themeVarsStart >= 0 && themeVarsEnd > themeVarsStart
? styleSource.slice(themeVarsStart, themeVarsEnd + 2)
: '';
const themeStyleStart = styleSource.indexOf('/* === 暗金荒野');
const themeStyle = themeStyleStart >= 0 ? styleSource.slice(themeStyleStart) : '';
const highFidelityStyleStart = styleSource.indexOf('/* === 暗金荒野高保真复刻');
const highFidelityStyle = highFidelityStyleStart >= 0 ? styleSource.slice(highFidelityStyleStart) : '';
const themeOption = source.match(/\{\s*value: 'wasteland',[\s\S]*?\n\s*\},/);
assert(themeOption, 'Frontend should register the wasteland theme option');
assert(!themeOption[0].includes('hidden:'), 'Wasteland theme should be visible in the picker');
assert(themeOption[0].includes("label: '暗金荒野'"), 'Wasteland picker should expose the requested Chinese label');
assert(
themeOption[0].includes("swatches: ['#050707', '#171511', '#c49a5a', '#6f783f']"),
'Wasteland picker should expose the iron, panel, antique-gold and status-green palette'
);
assert(indexSource.includes("if (theme === 'gilded')") && !indexSource.includes("if (theme === 'wasteland')"), 'Wasteland should remain selectable while only hidden gilded values migrate');
assert(themeVars, 'Stylesheet should define an isolated wasteland semantic variable block');
[
'--bg-primary: #090b0a;',
'--bg-bubble-user: #251d16;',
'--bg-bubble-assistant: #101311;',
'--text-primary: #fff;',
'--text-secondary: #eadfc9;',
'--text-muted: #c9bda6;',
'--accent: #c49a5a;',
'--wasteland-iron: #080a09;',
'--wasteland-gold: #c49a5a;',
'--wasteland-green: #6f783f;',
'--wasteland-ember: #a65b3d;',
'--wasteland-cut: polygon(',
].forEach((token) => assert(themeVars.includes(token), `Wasteland theme is missing semantic token: ${token}`));
assert(themeStyleStart >= 0, 'Stylesheet should include a dedicated wasteland component layer');
assert(highFidelityStyleStart >= 0, 'Stylesheet should retain the final high-fidelity wasteland override layer');
const bodyRule = themeStyle.match(/html\[data-theme='wasteland'\] body\s*\{([^}]*)\}/);
assert(bodyRule && bodyRule[1].includes("url('assets/themes/wasteland/background.webp')"), 'Wasteland body should render the reviewed local knight background');
assert(bodyRule && bodyRule[1].includes('background-size: cover') && bodyRule[1].includes('background-attachment: fixed'), 'Wasteland body background should fill and anchor the workspace');
assert(themeStyle.includes('.sidebar') && themeStyle.includes('.chat-header') && themeStyle.includes('.msg-bubble') && themeStyle.includes('.input-wrapper'), 'Wasteland component layer should cover the main workspace surfaces');
[source, indexSource].forEach((markupSource, index) => {
const label = index === 0 ? 'Dynamic' : 'Static';
assert(markupSource.includes('class="wasteland-welcome-art" aria-hidden="true"'), `${label} welcome markup should expose the Wasteland artwork layer`);
assert(markupSource.includes('class="wasteland-welcome-surface"'), `${label} welcome markup should expose a dedicated dark-material surface`);
assert(markupSource.includes('class="wasteland-welcome-frame"') && markupSource.includes('assets/themes/wasteland/frames/welcome-card.png'), `${label} welcome markup should render the extracted local frame image`);
});
assert(
/html\[data-theme='wasteland'\] \.welcome-msg > \.warframe-hero\s*\{[^}]*margin:\s*0 auto;[^}]*aspect-ratio:\s*1095 \/ 875;/.test(themeStyle)
&& themeStyle.includes("url('assets/themes/wasteland/textures/welcome-card-surface.webp')")
&& /html\[data-theme='wasteland'\] \.wasteland-welcome-frame\s*\{[^}]*object-fit:\s*fill;/.test(themeStyle),
'Wasteland welcome card should layer the extracted frame and dark material at the reviewed aspect ratio'
);
const sharedDarkSelectors = Array.from(
styleSource.matchAll(/:is\(([^)]*html\[data-theme='(?:carbon|nocturne|cinder|gilded)'\][^)]*)\)/g),
(match) => match[1]
);
assert(sharedDarkSelectors.length > 0, 'Stylesheet should retain shared dark-theme completion selectors');
sharedDarkSelectors.forEach((selector) => {
['carbon', 'nocturne', 'cinder', 'gilded', 'wasteland'].forEach((theme) => {
assert(selector.includes(`html[data-theme='${theme}']`), `Shared dark selector should include ${theme}: ${selector}`);
});
});
[
['\\.new-chat-btn::before', 'new-chat.png'],
['\\.session-search::before', 'search.png'],
['\\.attach-btn::before', 'attachment.png'],
['\\.send-btn::before', 'send.png'],
['\\.abort-btn::before', 'stop.png'],
['\\.settings-btn::before', 'settings.png'],
].forEach(([selector, filename]) => {
const iconRule = new RegExp(`html\\[data-theme='wasteland'\\]\\s+${selector}\\s*\\{[^}]*url\\(['"]?assets/themes/wasteland/icons/${filename}['"]?\\)`);
assert(iconRule.test(themeStyle), `Wasteland control should use the local ${filename} icon`);
});
assert(
/html\[data-theme='wasteland'\] \.new-chat-btn \.new-chat-glyph\s*\{[^}]*display:\s*inline-flex;/.test(themeStyle),
'Wasteland new-chat control should keep the native plus sign visible'
);
assert(
/html\[data-theme='wasteland'\] \.new-chat-arrow::before\s*\{[^}]*border-top:\s*7px solid #dcb36f;/.test(themeStyle),
'Wasteland new-chat dropdown should render a CSS triangle inside its frame'
);
assert(
/html\[data-theme='wasteland'\] \.session-item-actions\s*\{[^}]*position:\s*absolute;[^}]*right:\s*9px;/.test(themeStyle),
'Wasteland session actions should stay inside the active-session frame'
);
assert(
/html\[data-theme='wasteland'\] \.session-item\.menu-open\s*\{[^}]*overflow:\s*visible;/.test(themeStyle),
'Wasteland session action menu should not be clipped by the session row'
);
assert(
/html\[data-theme='wasteland'\] \.sidebar-footer\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;/.test(themeStyle)
&& /html\[data-theme='wasteland'\] \.brand\s*\{[^}]*align-items:\s*center;[^}]*line-height:\s*1;/.test(themeStyle)
&& /html\[data-theme='wasteland'\] \.brand::before\s*\{[^}]*display:\s*none;/.test(themeStyle),
'Wasteland sidebar footer should center the functional settings control and brand text without a redundant emblem'
);
assert(
/html\[data-theme='wasteland'\] :is\(\.msg\.user, \.msg\.assistant\) \.msg-avatar > :is\(img, svg\)\s*\{[^}]*display:\s*none !important;/.test(themeStyle),
'Wasteland avatar should hide the native agent image instead of stacking it over the themed icon'
);
assert(
/html\[data-theme='wasteland'\] \.input-wrapper\s*\{[^}]*border-image-source:\s*url\('assets\/themes\/wasteland\/frames\/composer\.png'\);[^}]*border-image-slice:\s*10 10 43 10;[^}]*border-image-width:\s*10px 10px 43px 10px;[^}]*border-image-outset:\s*0 0 27px 0;/.test(themeStyle)
&& /html\[data-theme='wasteland'\] \.input-wrapper::before\s*\{[^}]*display:\s*none;/.test(themeStyle)
&& /html\[data-theme='wasteland'\] \.input-wrapper::after\s*\{[^}]*display:\s*none;/.test(themeStyle),
'Wasteland composer should bind its artwork to the real resizable border instead of a fixed-ratio pseudo-element'
);
assert(
/html\[data-theme='wasteland'\] \.input-area\s*\{[^}]*background:\s*transparent !important;[^}]*box-shadow:\s*none;[^}]*backdrop-filter:\s*none;/.test(themeStyle),
'Wasteland input area should expose the page artwork instead of painting a separate footer panel'
);
const welcomeFrame = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'frames', 'welcome-card.png'));
assert(welcomeFrame.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', 'Wasteland welcome frame should remain a PNG');
assert(welcomeFrame.readUInt32BE(16) === 1095 && welcomeFrame.readUInt32BE(20) === 875, 'Wasteland welcome frame should retain its reviewed 1095x875 canvas');
assert(welcomeFrame[24] === 8 && welcomeFrame[25] === 6, 'Wasteland welcome frame should retain an RGBA transparency channel');
const welcomeSurface = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'textures', 'welcome-card-surface.webp'));
assert(welcomeSurface.subarray(0, 4).toString('ascii') === 'RIFF' && welcomeSurface.subarray(8, 12).toString('ascii') === 'WEBP', 'Wasteland welcome surface should remain a WebP texture');
const welcomeSurfaceSyncOffset = welcomeSurface.indexOf(Buffer.from([0x9d, 0x01, 0x2a]));
assert(welcomeSurfaceSyncOffset >= 0, 'Wasteland welcome surface should expose a readable VP8 frame header');
assert(
(welcomeSurface.readUInt16LE(welcomeSurfaceSyncOffset + 3) & 0x3fff) === 600
&& (welcomeSurface.readUInt16LE(welcomeSurfaceSyncOffset + 5) & 0x3fff) === 350,
'Wasteland welcome surface should retain its reviewed 600x350 crop'
);
[
['composer.png', 807, 101],
['composer-action.png', 46, 46],
].forEach(([filename, width, height]) => {
const asset = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'frames', filename));
assert(asset.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${filename} should remain a PNG`);
assert(asset.readUInt32BE(16) === width && asset.readUInt32BE(20) === height, `${filename} should retain its reviewed ${width}x${height} canvas`);
});
[
['note', 36],
['queue', 30],
['send', 30],
['stop', 26],
['ai', 36],
['user', 36],
].forEach(([name, size]) => {
assert(
themeStyle.includes(`url('assets/themes/wasteland/icons/ui/${name}.png')`),
`Wasteland composer should use the themed ${name} icon`
);
const icon = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'icons', 'ui', `${name}.png`));
assert(icon.readUInt32BE(16) === size && icon.readUInt32BE(20) === size, `${name}.png should retain its ${size}x${size} composer canvas`);
});
assert(
/html\[data-theme='wasteland'\] :is\(\.attach-btn, \.note-mode-btn, \.queue-send-btn, \.send-btn, \.abort-btn\) > svg\s*\{[^}]*display:\s*none;/.test(themeStyle),
'Wasteland composer controls should not stack native SVGs over themed icons'
);
assert(
/html\[data-theme='wasteland'\] :is\(\.send-btn, \.abort-btn, \.queue-send-btn, \.note-mode-btn\)\s*\{[^}]*border:\s*0 !important;[^}]*background:\s*transparent !important;[^}]*box-shadow:\s*none !important;/.test(themeStyle)
&& /html\[data-theme='wasteland'\] :is\(\.send-btn, \.abort-btn, \.queue-send-btn, \.note-mode-btn\)::after\s*\{[^}]*display:\s*none;/.test(themeStyle)
&& !themeStyle.includes("url('assets/themes/wasteland/frames/composer-action.png')"),
'Wasteland composer actions should render centered icons without persistent frames or filled backgrounds'
);
assert(
themeStyle.includes('background: rgba(5, 7, 7, 0.08);')
&& themeStyle.includes('background: rgba(226, 214, 190, 0.022) !important;')
&& themeStyle.includes('backdrop-filter: blur(3px) saturate(1.16) brightness(1.28);'),
'Wasteland assistant bubbles should use a nearly colorless brightened frosted-glass surface on dark scenery'
);
assert(
themeStyle.includes("html[data-theme='wasteland'] :is(.tool-call, .tool-group)")
&& themeStyle.includes('background: rgba(5, 7, 7, 0.04) !important;')
&& themeStyle.includes('.tool-call-content.reasoning')
&& themeStyle.includes('.tool-call-content.command')
&& themeStyle.includes('.tool-call-content.file-change')
&& themeStyle.includes('background: rgba(5, 7, 7, 0.035) !important;'),
'Wasteland nested tool surfaces should not remain opaque inside transparent message bubbles'
);
assert(
/html\[data-theme='wasteland'\] \.tool-call\.ccweb-mcp-child-agent-tool-call \.collab-agent-header\s*\{[^}]*min-height:\s*24px;[^}]*padding:\s*4px 8px 0;/.test(themeStyle)
&& /html\[data-theme='wasteland'\] \.tool-call\.ccweb-mcp-child-agent-tool-call \.collab-agent-title-wrap\s*\{[^}]*align-items:\s*center;/.test(themeStyle),
'Wasteland collaboration header should clear the etched corner and center its title tokens'
);
const backgroundPath = path.join(WASTELAND_THEME_ASSETS.root, WASTELAND_THEME_ASSETS.background.filename);
assert(fs.existsSync(backgroundPath), 'Wasteland theme should ship its local WebP background');
const background = fs.readFileSync(backgroundPath);
assert(background.subarray(0, 4).toString('ascii') === 'RIFF' && background.subarray(8, 12).toString('ascii') === 'WEBP', 'Wasteland background should remain a WebP image');
const vp8SyncOffset = background.indexOf(Buffer.from([0x9d, 0x01, 0x2a]));
assert(vp8SyncOffset >= 0 && vp8SyncOffset + 7 <= background.length, 'Wasteland background should expose a readable VP8 frame header');
const backgroundWidth = background.readUInt16LE(vp8SyncOffset + 3) & 0x3fff;
const backgroundHeight = background.readUInt16LE(vp8SyncOffset + 5) & 0x3fff;
assert(
backgroundWidth === WASTELAND_THEME_ASSETS.background.width && backgroundHeight === WASTELAND_THEME_ASSETS.background.height,
`Wasteland background should retain its reviewed ${WASTELAND_THEME_ASSETS.background.width}x${WASTELAND_THEME_ASSETS.background.height} canvas`
);
const backgroundHash = crypto.createHash('sha256').update(background).digest('hex');
assert(backgroundHash === WASTELAND_THEME_ASSETS.background.sha256, 'Wasteland background should remain byte-identical to the reviewed source');
const manifestPath = path.join(WASTELAND_THEME_ASSETS.root, WASTELAND_THEME_ASSETS.manifestFilename);
assert(fs.existsSync(manifestPath), 'Wasteland theme should ship the icon slicing manifest');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const expectedIconNames = [...WASTELAND_THEME_ASSETS.iconNames].sort();
const manifestIconNames = Object.keys(manifest).sort();
assert(manifestIconNames.length === 17, 'Wasteland icon manifest should contain exactly 17 reviewed entries');
assert(JSON.stringify(manifestIconNames) === JSON.stringify(expectedIconNames), 'Wasteland icon manifest should expose the complete reviewed icon set');
const iconDir = path.join(WASTELAND_THEME_ASSETS.root, 'icons');
const actualIconNames = fs.readdirSync(iconDir)
.filter((filename) => filename.endsWith('.png'))
.map((filename) => filename.slice(0, -4))
.sort();
assert(JSON.stringify(actualIconNames) === JSON.stringify(expectedIconNames), 'Wasteland icon directory should contain exactly the 17 manifested PNG files');
expectedIconNames.forEach((name) => {
const entry = manifest[name];
assert(entry.source === '43bd9ed6-2ba6-4c9d-bd9d-25b068cb4711.webp', `${name} manifest entry should identify the reviewed icon board`);
assert(/^R[1-6]C[1-6]$/.test(entry.card) && entry.variant === '32px', `${name} manifest entry should preserve its card and 32px source variant`);
assert(Array.isArray(entry.bbox) && entry.bbox.length === 4 && entry.bbox.every((value) => Number.isInteger(value) && value > 0), `${name} manifest entry should preserve a valid source bounding box`);
assert(entry.output === `icons/${name}.png`, `${name} manifest entry should point to its local PNG output`);
const icon = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, entry.output));
assert(icon.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${name}.png should remain a PNG`);
assert(icon.readUInt32BE(16) === 64 && icon.readUInt32BE(20) === 64, `${name}.png should retain its 64x64 canvas`);
assert(icon[24] === 8 && icon[25] === 6, `${name}.png should remain an 8-bit RGBA asset`);
});
assert(!/url\(\s*['"]?https?:\/\//i.test(`${themeVars}\n${themeStyle}\n${JSON.stringify(manifest)}`), 'Wasteland variables, components and asset manifest should not load remote URLs');
assert(
/@media \(min-width:\s*721px\) and \(max-width:\s*1100px\)[\s\S]*?html\[data-theme='wasteland'\] body\s*\{[^}]*background-position:\s*center,\s*center,\s*75% 50%;/.test(highFidelityStyle),
'Wasteland 721-1100px layout should reveal the right side of the local background'
);
assert(
/@media \(max-width:\s*720px\)[\s\S]*?html\[data-theme='wasteland'\] body\s*\{[^}]*background-position:\s*center,\s*center,\s*75% 50%;/.test(highFidelityStyle),
'Wasteland 720px layout should keep the local background right-aligned'
);
assert(/@media \(max-width:\s*560px\)[\s\S]*?html\[data-theme='wasteland'\] \.input-wrapper\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?min-height:\s*48px;/.test(themeStyle), 'Wasteland 560px layout should keep the composer within the viewport');
assert(/@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?html\[data-theme='wasteland'\] \*[\s\S]*?animation:\s*none !important;[\s\S]*?transition:\s*none !important;/.test(themeStyle), 'Wasteland motion should respect reduced-motion preferences');
const rgb = (hex) => [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
const luminance = (hex) => rgb(hex)
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
const contrast = (foreground, backgroundColor) => {
const values = [luminance(foreground), luminance(backgroundColor)].sort((a, b) => b - a);
return (values[0] + 0.05) / (values[1] + 0.05);
};
['#090b0a', '#101311', '#251d16'].forEach((backgroundColor) => {
assert(contrast('#ffffff', backgroundColor) >= 7, `Wasteland primary text should reach AAA contrast on ${backgroundColor}`);
assert(contrast('#eadfc9', backgroundColor) >= 7, `Wasteland secondary text should reach AAA contrast on ${backgroundColor}`);
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
});
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() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
assert(source.includes('function createCcwebPromptElement(prompt, meta = {})'), 'Frontend should define ccweb prompt renderer');
assert(source.includes("type: 'ccweb_prompt_user_response'"), 'Frontend should send ccweb prompt answers over WebSocket');
assert(source.includes("type: 'ccweb_prompt_user_dismiss'"), 'Frontend should send ccweb prompt dismiss requests over WebSocket');
assert(source.includes("case 'ccweb_prompt_user_update':"), 'Frontend should handle ccweb prompt status updates');
assert(source.includes("case 'ccweb_prompt_user_remove':"), 'Frontend should remove submitted ccweb prompt bubbles');
assert(source.includes('applyCcwebPromptUserUpdate(msg);'), 'Frontend should apply ccweb prompt updates to cached messages and DOM');
assert(source.includes('removeCcwebPromptMessageFromSnapshot'), 'Frontend should remove submitted prompt messages from cached snapshots');
assert(source.includes('renderPendingCcwebPrompts'), 'Frontend should render pending ccweb prompt reminders');
assert(indexSource.includes('id="ccweb-prompt-outline-btn"') && indexSource.includes('class="ccweb-prompt-outline-anchor" hidden'), 'Frontend should expose a hidden ccweb prompt outline button');
assert(source.includes('toggleCcwebPromptOutlinePanel') && source.includes('ccwebPromptOutlineBtn.dataset.count'), 'Frontend should render pending forms behind a compact outline button');
assert(source.includes('dismissCcwebPrompt'), 'Frontend should allow users to ignore pending ccweb prompts');
assert(source.includes('CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY'), 'Frontend should persist ccweb prompt view mode');
assert(source.includes("className = 'ccweb-prompt-tabs'"), 'Frontend should render ccweb prompt tabs for multi-question forms');
assert(source.includes("className = 'pending-ccweb-prompt-dismiss'"), 'Frontend should render a compact dismiss action for pending prompts');
assert(source.includes('card.dataset.viewMode = normalized'), 'Frontend should switch ccweb prompt card view mode');
assert(source.includes('m.ccwebPrompt'), 'Message rebuild should render persisted ccweb prompt messages');
assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea');
assert(source.includes('function clearCcwebPromptSelection(questionEl, question)'), 'ccweb prompt options should expose an explicit no-selection action');
assert(source.includes("clearChoice.textContent = '不选择';"), 'ccweb prompt should render a visible no-selection control');
assert(source.includes("const shouldDeselect = buttons.some"), 'Single-select ccweb prompt options should allow clicking the selected option to deselect');
assert(source.includes("textarea.dataset.ccwebPromptSelectionText"), 'ccweb prompt should clear only option-generated answer text when selections are removed');
}
function assertFrontendMarkdownLinkContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(path.join(REPO_DIR, 'public', 'style.css'), 'utf8');
assert(source.includes('function parseLocalFileLinkHref(rawHref)'), 'Frontend should parse local file hrefs separately from web links');
assert(source.includes("link.dataset.localFileLink = 'true';"), 'Frontend should mark local file links with data-local-file-link');
assert(source.includes('hydrateLocalFileLinks(root);'), 'Rendered markdown should hydrate local file links');
assert(source.includes('openFileBrowserFile(relativePath, { line });'), 'Local file links should open the file browser with line metadata');
assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style');
}
function assertFrontendMcpReloadContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
assert(source.includes('function mcpStartupStatusToastText(status)'), 'Frontend should format MCP startup status toast text');
assert(source.includes("payload.status && typeof payload.status === 'object'"), 'Frontend should preserve plain MCP status summary objects');
assert(source.includes('data.mcpStatus'), 'Frontend reload button should consume reload-mcp mcpStatus payload');
assert(source.includes("case 'mcp_startup_status':"), 'Frontend should handle pushed MCP startup status updates');
assert(source.includes('showMcpStartupStatusToast'), 'Frontend should show explicit MCP startup status toasts');
assert(source.includes('notifyReady: true'), 'Frontend should only show ready MCP startup toasts for explicit reload actions');
assert(source.includes("state === 'ready' && !options.notifyReady"), 'Frontend should suppress background ready MCP startup toasts');
assert(source.includes('MCP 已启动'), 'Frontend should expose a ready toast for ccweb MCP');
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
}
function assertFrontendSubagentCardMetadataContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const metadataStart = source.indexOf(' function cleanCollabAgentText(value)');
const metadataEnd = source.indexOf(' function normalizeCollabAgentAction(value)', metadataStart);
assert(metadataStart >= 0 && metadataEnd > metadataStart, 'Frontend should expose an isolated sub-agent metadata helper block');
const metadataApi = new Function(`
function shortChildAgentId(id) {
const value = String(id || '');
return value.length > 12 ? value.slice(0, 8) : value;
}
${source.slice(metadataStart, metadataEnd)}
return {
isGenericCollabAgentLabel,
readableCollabAgentAutoTitle,
pickCollabAgentTitle,
mergeCollabAgentTaskState,
};
`)();
const collabMergeStart = source.indexOf(' function toolKind(tool)');
const collabMergeEnd = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart);
const collabRenderStart = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart);
const collabRenderEnd = source.indexOf(' function buildToolContentElement', collabRenderStart);
const childUpdateStart = source.indexOf(' function applyCcwebMcpChildAgentUpdate(msg)');
const childUpdateEnd = source.indexOf(' function getDeleteConfirmMessage(agent)', childUpdateStart);
const buildMsgElementSource = extractFunctionSource(source, 'buildMsgElement');
assert(collabMergeStart >= 0 && collabMergeEnd > collabMergeStart, 'Frontend should expose collab merge helpers');
assert(collabRenderStart >= 0 && collabRenderEnd > collabRenderStart, 'Frontend should expose collab card render helpers');
assert(childUpdateStart >= 0 && childUpdateEnd > childUpdateStart, 'Frontend should define child-agent update handling before delete helpers');
const collabApi = new Function(`
let currentCwd = '';
let currentSessionId = 'session-a';
let closedCollabAgentIds = new Set();
let collabAgentStateCache = new Map();
let collabAgentIdsByToolUseId = new Map();
let closedCollabAgentIdsByToolUseId = new Map();
const activeToolCalls = new Map();
let cachedSnapshot = {
messages: [{
toolCalls: [{
id: 'tool-collab',
kind: 'collab_agent_tool_call',
input: {},
}],
}],
};
function updateCachedSession(sessionId, updater) {
updater(cachedSnapshot);
}
function updateToolCall() {}
function makeNode() {
return {
attributes: {},
children: [],
dataset: {},
listeners: {},
parentElement: null,
isConnected: true,
appendChild(child) {
if (child && typeof child === 'object') child.parentElement = this;
this.children.push(child);
return child;
},
insertBefore(child) {
if (child && typeof child === 'object') child.parentElement = this;
this.children.unshift(child);
return child;
},
replaceChildren(...children) {
this.children = [];
children.forEach((child) => this.appendChild(child));
},
setAttribute(name, value) {
this.attributes[name] = String(value);
},
addEventListener(name, handler) {
if (!this.listeners[name]) this.listeners[name] = [];
this.listeners[name].push(handler);
},
querySelector() {
return null;
},
};
}
function createMsgElement() {
const bubble = makeNode();
return {
bubble,
querySelector(selector) {
return selector === '.msg-bubble' ? bubble : null;
},
};
}
const messagesDiv = makeNode();
function findLatestToolCallElement(root, matcher) {
const matches = [];
const visit = (node) => {
if (!node || typeof node !== 'object') return;
if (node.dataset?.toolUseId && matcher(node)) matches.push(node);
(Array.isArray(node.children) ? node.children : []).forEach(visit);
};
visit(root);
return matches.length > 0 ? matches[matches.length - 1] : null;
}
function createCcwebPromptElement() { return makeNode(); }
function isEmptyReasoningTool() { return false; }
function createToolCallElement(toolUseId, tool, done) {
const node = { ...makeNode(), toolUseId, tool, done };
node.dataset.toolUseId = String(toolUseId || '');
node.dataset.toolKind = toolKind(tool) || '';
return node;
}
function isGroupableToolCall() { return false; }
function _refreshGroupSummary() {}
function markSessionMessageElement() {}
const document = { createElement: () => makeNode() };
function shortChildAgentId(id) {
const value = String(id || '');
return value.length > 12 ? value.slice(0, 8) : value;
}
${source.slice(collabMergeStart, collabMergeEnd)}
${source.slice(collabRenderStart, collabRenderEnd)}
${source.slice(childUpdateStart, childUpdateEnd)}
${buildMsgElementSource}
return {
toolKind,
collabStateTone,
mergeCollabAgentTools,
applyCcwebMcpChildAgentUpdate,
rememberCollabAgentState,
renderToolCallsForMessage: (toolCalls) => {
const el = buildMsgElement({ role: 'assistant', content: '', toolCalls });
return el.querySelector('.msg-bubble').children.map((node) => node.tool);
},
renderMessageElement: (toolCalls) => buildMsgElement({ role: 'assistant', content: '', toolCalls }),
mountMessageElement: (element) => messagesDiv.appendChild(element.querySelector('.msg-bubble')),
findCollabAgentToolElement,
renderCollabAgentToolElement: (tool) => createCollabAgentToolElement(tool),
getCachedState: (id) => collabAgentStateCache.get(id),
hasCachedState: (id) => collabAgentStateCache.has(id),
cacheSize: () => collabAgentStateCache.size,
agentIdsForTool: (id) => new Set(collabAgentIdsByToolUseId.get(id) || []),
closedIdsForTool: (id) => new Set(closedCollabAgentIdsByToolUseId.get(id) || []),
};
`)();
const findNodesByClass = (node, className, results = []) => {
if (!node || typeof node !== 'object') return results;
const classes = String(node.className || '').split(/\s+/).filter(Boolean);
if (classes.includes(className)) results.push(node);
(Array.isArray(node.children) ? node.children : []).forEach((child) => findNodesByClass(child, className, results));
return results;
};
assert(source.includes('function pickCollabAgentTitle(state, id, index)'), 'Frontend should pick sub-agent titles through a dedicated helper');
assert(
/const titleCandidates = \[\s*state\.label,\s*state\.title,\s*state\.nickname,\s*state\.name,\s*\]/.test(source),
'Sub-agent title priority should be label -> title -> nickname -> name'
);
assert(source.includes('isGenericCollabAgentLabel(candidate, id)'), 'Sub-agent title picker should skip generic labels and thread IDs');
assert(source.includes('collabAgentTitleFromPrompt(taskDescription)'), 'Generic sub-agent titles should be derived from that agent prompt');
assert(source.includes('return id ? `ID ${shortChildAgentId(id)}`'), 'Sub-agent title picker should fall back to a short thread id without a prompt');
assert(source.includes('function mergeCollabAgentTaskState(previousState = {}, incomingState = {}'), 'Frontend should centralize per-agent task metadata merging');
assert(source.includes('const hasReadableSourceTitle = state.hasReadableSourceTitle == null'), 'Normalized child states should preserve explicit source-title markers before inference');
assert(source.includes('hasReadableSourceTitle,') && source.includes('taskDescription, hasReadableSourceTitle'), 'Normalized child states should use one resolved source-title marker for title picking and returned entries');
assert(/mergeCollabAgentTaskState\(\s*states\[entry\.id\]/.test(source), 'Structured child states should use the tested metadata merge helper');
assert(/mergeCollabAgentTaskState\(\s*states\[id\]/.test(source), 'Receiver-only child states should use the tested metadata merge helper');
assert(source.includes('entry.detail ? `结果: ${entry.detail}` :'), 'Card title should keep runtime result in the container title');
assert(source.includes("description.className = 'collab-agent-item-description'"), 'Sub-agent cards should render a visible task intro node');
assert(source.includes('function createPlanProgressElementFromProgress(progress'), 'Frontend should expose reusable plan progress rendering for sub-agent cards');
assert(source.includes("plan.className = 'collab-agent-item-plan'"), 'Sub-agent cards should render a compact plan progress row');
assert(source.includes("currentStep.className = 'collab-agent-item-plan-current'"), 'Sub-agent cards should render the current plan step');
assert(source.includes('findCollabAgentToolElement(toolUseId, tool?.domElement)'), 'Live sub-agent updates should locate the original card before falling back to the latest assistant message');
assert(source.includes('description.title = descriptionTitle'), 'Task intro node should expose the full task intro or fallback in its title attribute');
assert(source.includes('label.textContent = displayTitle;'), 'Rendered card label should use normalized title selection');
assert(/\.collab-agent-item-description\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/.test(styleSource), 'Sub-agent task intro should use two-line truncation');
assert(/\.collab-agent-item-plan\s*\{[\s\S]*?display:\s*flex;/.test(styleSource), 'Sub-agent plan progress should use a compact flex row');
assert(/\.collab-agent-item-plan \.plan-progress-dot\s*\{[\s\S]*?width:\s*8px;[\s\S]*?height:\s*8px;[\s\S]*?margin-left:\s*0;/.test(styleSource), 'Sub-agent plan dots should remain compact inside narrow cards');
assert(/\.collab-agent-item-plan-current\s*\{[\s\S]*?text-overflow:\s*ellipsis;/.test(styleSource), 'Sub-agent current plan step should truncate safely');
assert(/\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?flex-direction:\s*column;/.test(styleSource), 'Sub-agent cards should be vertically composed and flex-shrink on narrow screens');
assert(/@media \(max-width:\s*640px\)[\s\S]*?\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?\}/.test(styleSource), 'Narrow screens should let sub-agent cards shrink without horizontal overflow');
const uuidV7 = '0190f01d-7b3e-7f03-9a5a-123456789abc';
assert(metadataApi.isGenericCollabAgentLabel(uuidV7, 'different-thread-id'), 'UUID v7 labels should be treated as thread identifiers');
assert(
metadataApi.pickCollabAgentTitle({ label: '子代理', title: '架构审查', name: uuidV7 }, 'child-a', 0) === '架构审查',
'Title selection should skip generic labels and preserve readable field priority'
);
[
['plan_review', '计划审查'],
['plan_reviewer', '计划审查'],
['backend_state_review', '后端状态审查'],
['frontend_state_review', '前端状态审查'],
['trellis_implement', 'Trellis 实现'],
['trellis_check', 'Trellis 检查'],
['custom_worker', 'Custom Worker'],
].forEach(([rawTitle, expectedTitle]) => {
assert(
metadataApi.readableCollabAgentAutoTitle(rawTitle) === expectedTitle,
`Automatic child-agent helper should map ${rawTitle} to ${expectedTitle}`
);
assert(
metadataApi.pickCollabAgentTitle({ label: rawTitle, hasReadableSourceTitle: true }, 'agent-thread-auto-title', 0) === expectedTitle,
`Automatic child-agent task title ${rawTitle} should render as ${expectedTitle}`
);
});
assert(
metadataApi.readableCollabAgentAutoTitle('已有自然语言标题') === '已有自然语言标题',
'Automatic child-agent helper should not rewrite natural-language titles'
);
assert(
metadataApi.pickCollabAgentTitle({ label: 'fix-bug', hasReadableSourceTitle: false }, 'child-prompt-derived', 0) === 'fix-bug',
'Prompt-derived kebab-case titles should remain unchanged'
);
assert(
metadataApi.pickCollabAgentTitle({ label: '子代理', taskDescription: 'frontend_state_review' }, 'child-prompt-title', 0) === 'frontend_state_review',
'Prompt-derived titles should not be auto-translated as protocol task names'
);
assert(
metadataApi.pickCollabAgentTitle({ label: '已有自然语言标题', hasReadableSourceTitle: true }, 'child-natural-title', 0) === '已有自然语言标题',
'Existing natural-language titles should remain unchanged'
);
const promptDerivedAutoNameThreadId = 'agent-thread-prompt-derived-auto-name';
const promptDerivedAutoNameState = metadataApi.mergeCollabAgentTaskState(
{},
{ label: '子代理', status: 'running' },
'frontend_state_review',
promptDerivedAutoNameThreadId,
0
);
assert(promptDerivedAutoNameState.label === 'frontend_state_review', 'Prompt-derived automatic-looking titles should stay raw after metadata merge');
assert(promptDerivedAutoNameState.hasReadableSourceTitle === false, 'Prompt-derived automatic-looking titles should keep a false source-title marker');
const promptDerivedAutoNameTool = {
id: 'tool-prompt-derived-auto-name',
name: 'spawn_agent',
kind: 'collab_agent_tool_call',
input: {
tool: 'spawn_agent',
receiverThreadIds: [promptDerivedAutoNameThreadId],
agentsStates: {
[promptDerivedAutoNameThreadId]: promptDerivedAutoNameState,
},
},
done: false,
};
const promptDerivedAutoNameElement = collabApi.renderCollabAgentToolElement(promptDerivedAutoNameTool);
const promptDerivedAutoNameLabels = findNodesByClass(promptDerivedAutoNameElement, 'collab-agent-item-label');
assert(promptDerivedAutoNameLabels.length === 1, 'Prompt-derived automatic-looking titles should render one child-agent label');
assert(
promptDerivedAutoNameLabels[0].textContent === 'frontend_state_review',
'Prompt-derived automatic-looking titles should stay raw through collabAgentStateEntries and card rendering'
);
const promptDerivedAutoNameDescriptions = findNodesByClass(promptDerivedAutoNameElement, 'collab-agent-item-description');
assert(promptDerivedAutoNameDescriptions.length === 0, 'Task descriptions identical to the resolved card title should not render twice');
const firstSpawn = metadataApi.mergeCollabAgentTaskState(
{},
{ label: '子代理', status: 'running' },
'请审查前端实现。核对标题和简介。',
'child-thread-a',
0
);
const secondSpawn = metadataApi.mergeCollabAgentTaskState(
{},
{ label: '子代理', status: 'running' },
'请验证后端回归。核对状态同步。',
'child-thread-b',
1
);
assert(firstSpawn.taskDescription !== secondSpawn.taskDescription, 'Independent spawns should retain different task introductions');
assert(firstSpawn.label !== secondSpawn.label, 'Independent spawns should derive different titles from their own prompts');
const afterWait = metadataApi.mergeCollabAgentTaskState(firstSpawn, { status: 'completed' }, '', 'child-thread-a', 0);
const afterClose = metadataApi.mergeCollabAgentTaskState(afterWait, { status: 'closed' }, '', 'child-thread-a', 0);
assert(afterWait.taskDescription === firstSpawn.taskDescription, 'Wait updates without a prompt should preserve the original task introduction');
assert(afterClose.taskDescription === firstSpawn.taskDescription, 'Close updates without a prompt should preserve the original task introduction');
const namedSpawn = metadataApi.mergeCollabAgentTaskState({}, { name: '实现代理', status: 'running' }, '', 'child-thread-c', 2);
const namedAfterWait = metadataApi.mergeCollabAgentTaskState(
namedSpawn,
{ label: 'ID child-th', title: '', nickname: '', name: '', status: 'completed' },
'',
'child-thread-c',
2
);
assert(namedAfterWait.label === '实现代理', 'Status updates without a readable title should preserve the existing protocol title');
const namedAfterPromptDerivedUpdate = metadataApi.mergeCollabAgentTaskState(
namedSpawn,
{
label: '整理前端改动并回报状态',
name: 'child-thread-c',
taskDescription: '请整理前端改动并回报状态。',
hasReadableSourceTitle: false,
status: 'completed',
},
'',
'child-thread-c',
2
);
assert(namedAfterPromptDerivedUpdate.label === '实现代理', 'Prompt-derived labels should not replace an existing protocol title');
assert(
namedAfterPromptDerivedUpdate.taskDescription === '请整理前端改动并回报状态。',
'Prompt-derived updates may refresh the task introduction while preserving the protocol title'
);
const noPrompt = metadataApi.mergeCollabAgentTaskState({}, { label: '子代理' }, '', uuidV7, 0);
assert(/^ID\s/.test(noPrompt.label) && noPrompt.label !== uuidV7, 'Missing prompts should fall back to a short thread id');
const spawnedTool = {
id: 'tool-collab',
name: 'spawn_agent',
kind: 'collab_agent_tool_call',
input: {
tool: 'spawn_agent',
prompt: '请实现子代理卡片关闭状态保留。',
receiverThreadIds: ['child-thread-a'],
agentsStates: {
'child-thread-a': {
title: '关闭验证代理',
taskDescription: '请实现子代理卡片关闭状态保留。',
role: 'implementer',
status: 'running',
},
},
},
done: false,
};
const spawnedMerge = collabApi.mergeCollabAgentTools([spawnedTool]);
assert(spawnedMerge.input.receiverThreadIds.length === 1, 'Spawned child should merge into one visible agent');
assert(collabApi.getCachedState('child-thread-a')?.label === '关闭验证代理', 'Structured child state should be cached by thread id');
const planCardTool = {
...spawnedTool,
id: 'tool-plan-progress',
input: {
...spawnedTool.input,
receiverThreadIds: ['child-thread-plan'],
agentsStates: {
'child-thread-plan': {
title: '进度验证代理',
taskDescription: '验证子代理计划简报。',
status: 'running',
planProgress: { completed: 1, total: 3 },
planCurrentStep: '同步父卡片进度',
},
},
},
};
const planCardElement = collabApi.renderCollabAgentToolElement(planCardTool);
const planRows = findNodesByClass(planCardElement, 'collab-agent-item-plan');
const planDots = findNodesByClass(planCardElement, 'plan-progress-dot');
const planCounts = findNodesByClass(planCardElement, 'plan-progress-count');
const planCurrentSteps = findNodesByClass(planCardElement, 'collab-agent-item-plan-current');
assert(planRows.length === 1, 'Sub-agent plan summary should render one compact progress row');
assert(planDots.length === 3, 'Sub-agent plan summary should render one progress dot per short plan item');
assert(planCounts.length === 1 && planCounts[0].textContent === '1/3', 'Sub-agent plan summary should always render completed/total');
assert(planCurrentSteps.length === 1 && planCurrentSteps[0].textContent === '当前:同步父卡片进度', 'Sub-agent plan summary should render the current in-progress step');
const persistedPlanSnapshotTool = {
id: 'tool-persisted-plan-progress',
name: 'subAgentActivity',
kind: 'collab_agent_tool_call',
input: {
tool: 'subAgentActivity',
receiverThreadIds: ['child-thread-persisted-plan'],
agentsStates: {
'child-thread-persisted-plan': {
title: '真实快照验证代理',
status: 'running',
},
},
},
result: JSON.stringify({
tool: 'subAgentActivity',
receiverThreadIds: ['child-thread-persisted-plan'],
agentsStates: {
'child-thread-persisted-plan': {
title: '真实快照验证代理',
status: 'running',
planProgress: { completed: 2, total: 3 },
planCurrentStep: '验证历史卡片增量更新',
},
},
}),
done: false,
};
const persistedMessageElement = collabApi.renderMessageElement([persistedPlanSnapshotTool]);
const persistedMessageBubble = persistedMessageElement.querySelector('.msg-bubble');
const persistedCard = persistedMessageBubble.children[0];
assert(persistedCard?.dataset?.collabMerged === 'true', 'Restored sub-agent cards should be marked as merged cards for later live updates');
assert(
persistedCard?.__collabTools instanceof Map && persistedCard.__collabTools.has(persistedPlanSnapshotTool.id),
'Restored sub-agent cards should retain their original tool snapshots for later result-only plan updates'
);
collabApi.mountMessageElement(persistedMessageElement);
assert(
collabApi.findCollabAgentToolElement(persistedPlanSnapshotTool.id) === persistedCard,
'Live result-only plan updates should find the restored card by its original tool id'
);
const persistedPlanElement = collabApi.renderCollabAgentToolElement(persistedCard.tool);
const persistedPlanCounts = findNodesByClass(persistedPlanElement, 'plan-progress-count');
assert(
persistedPlanCounts.length === 1 && persistedPlanCounts[0].textContent === '2/3',
'Persisted result-only plan progress should survive history reconstruction'
);
collabApi.rememberCollabAgentState(
'unrelated-child',
{ title: '无关历史代理', status: 'running' },
'请处理无关历史任务。',
1
);
collabApi.applyCcwebMcpChildAgentUpdate({
type: 'ccweb_mcp_child_agent_update',
sessionId: 'session-a',
toolUseId: 'tool-collab',
child: {
threadId: 'child-thread-a',
status: 'closed',
},
tool: {
id: 'tool-collab',
name: 'close_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }),
done: true,
},
});
const closedMerge = collabApi.mergeCollabAgentTools([
{
id: 'tool-collab',
name: 'close_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }),
done: true,
},
], {
restoreAgentIds: new Set(['child-thread-a', 'unrelated-child']),
closedAgentIds: collabApi.closedIdsForTool('tool-collab'),
});
assert(closedMerge.input.receiverThreadIds.length === 1, 'Empty close tool should keep exactly the closed child card');
assert(closedMerge.input.receiverThreadIds[0] === 'child-thread-a', 'Empty close tool should restore only the closed child thread id');
assert(!closedMerge.input.agentsStates['unrelated-child'], 'Empty close recovery must not mix unrelated cached children into the card');
assert(closedMerge.input.agentsStates['child-thread-a'].label === '关闭验证代理', 'Empty close recovery should preserve the cached child title');
assert(
closedMerge.input.agentsStates['child-thread-a'].taskDescription === '请实现子代理卡片关闭状态保留。',
'Empty close recovery should preserve the cached child introduction'
);
assert(closedMerge.input.agentsStates['child-thread-a'].status === 'closed', 'Empty close recovery should mark the child closed');
collabApi.rememberCollabAgentState(
'wait-child-a',
{ title: '等待验证代理', status: 'running' },
'请等待子代理返回。',
0
);
const emptyWaitMerge = collabApi.mergeCollabAgentTools([
{
id: 'tool-wait',
name: 'wait_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
done: false,
},
], {
restoreAgentIds: new Set(['wait-child-a']),
});
assert(emptyWaitMerge.input.receiverThreadIds.length === 1, 'Empty wait tool should not reset a cached child card count to zero');
assert(emptyWaitMerge.input.agentsStates['wait-child-a'].label === '等待验证代理', 'Empty wait recovery should preserve the cached child title');
const cacheSizeBeforeOrdinaryTool = collabApi.cacheSize();
const ordinaryMerge = collabApi.mergeCollabAgentTools([
{
id: 'ordinary-tool',
name: 'shell',
kind: 'command_execution',
input: {
receiverThreadIds: ['ordinary-child'],
agentsStates: {
'ordinary-child': { title: '普通工具不应进入子代理缓存', status: 'closed' },
},
},
done: true,
},
], {
restoreAgentIds: new Set(['ordinary-child']),
closedAgentIds: new Set(['ordinary-child']),
});
assert(ordinaryMerge === null, 'Non-collab tools should not enter the collab merge path');
collabApi.applyCcwebMcpChildAgentUpdate({
type: 'ccweb_mcp_child_agent_update',
sessionId: 'session-a',
toolUseId: 'ordinary-tool',
child: { threadId: 'ordinary-child', status: 'closed' },
tool: { id: 'ordinary-tool', name: 'shell', kind: 'command_execution', input: {}, result: '', done: true },
});
assert(collabApi.cacheSize() === cacheSizeBeforeOrdinaryTool, 'Non-collab child updates should not write sub-agent state cache');
assert(!collabApi.hasCachedState('ordinary-child'), 'Non-collab tools should not cache child thread state');
const rawActivityThreadId = 'agent-thread-plan-reviewer-001';
const rawActivityPrompt = '请审查 Phase 8 的前端 helper 行为。';
const rawSubAgentActivity = (overrides = {}) => {
const input = {
kind: overrides.activityKind || 'started',
agentThreadId: overrides.agentThreadId || rawActivityThreadId,
agentPath: overrides.agentPath || '/root/plan_reviewer',
...(overrides.prompt === undefined ? { prompt: rawActivityPrompt } : {}),
...(overrides.prompt ? { prompt: overrides.prompt } : {}),
...(overrides.input || {}),
};
return {
id: overrides.id || `call_activity_${input.kind}`,
name: 'subAgentActivity',
kind: 'subAgentActivity',
input,
...(overrides.result !== undefined ? { result: overrides.result } : {}),
done: !!overrides.done,
};
};
const emptyWaitTool = {
id: 'call_wait_empty',
name: 'wait_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
done: false,
};
const rawActivityMerge = collabApi.mergeCollabAgentTools([
rawSubAgentActivity(),
emptyWaitTool,
]);
assert(rawActivityMerge, 'Raw subAgentActivity with an empty wait should produce one merged collab card');
assert(
rawActivityMerge.input.receiverThreadIds.length === 1
&& rawActivityMerge.input.receiverThreadIds[0] === rawActivityThreadId,
'Raw subAgentActivity agentThreadId should be the sole receiverThreadId and must not fall back to the wait tool call id'
);
assert(
Object.keys(rawActivityMerge.input.agentsStates).length === 1
&& Object.prototype.hasOwnProperty.call(rawActivityMerge.input.agentsStates, rawActivityThreadId),
'Raw subAgentActivity agentThreadId should be the sole agentsStates key'
);
const rawActivityState = rawActivityMerge.input.agentsStates[rawActivityThreadId];
assert(rawActivityState.label === '计划审查', 'Raw subAgentActivity agentPath basename should become a readable card title');
assert(rawActivityState.role === '', 'Raw subAgentActivity agentPath should not be duplicated into role when no explicit role is present');
assert(!/^ID\s+call_/.test(rawActivityState.label || ''), 'Raw subAgentActivity title must not fall back to a tool call id');
assert(rawActivityState.status === 'running', 'Raw subAgentActivity started events should keep the child running');
assert(rawActivityMerge.input.status === 'running', 'Raw subAgentActivity aggregate status should match its running child state');
assert(rawActivityState.taskDescription === rawActivityPrompt, 'Raw subAgentActivity prompt should be preserved as taskDescription');
const rawActivityElement = collabApi.renderCollabAgentToolElement(rawActivityMerge);
const rawActivityDescriptions = findNodesByClass(rawActivityElement, 'collab-agent-item-description');
assert(rawActivityDescriptions.length === 1, 'Raw subAgentActivity with a real prompt should render one visible task description');
assert(rawActivityDescriptions[0].textContent === rawActivityPrompt, 'Real child-agent prompts should remain the visible card description');
assert(rawActivityDescriptions[0].title === rawActivityPrompt, 'Real child-agent prompts should remain the description DOM title');
const explicitRoleMerge = collabApi.mergeCollabAgentTools([
rawSubAgentActivity({
id: 'call_activity_explicit_role',
agentThreadId: 'agent-thread-explicit-role',
input: { role: 'reviewer' },
}),
]);
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].label === '计划审查', 'Raw subAgentActivity agentPath basename should remain the readable title when role is explicit');
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].role === 'reviewer', 'Raw subAgentActivity should preserve an explicit role');
const completedEmptyWaitTool = {
...emptyWaitTool,
id: 'call_wait_completed_empty',
result: JSON.stringify({ status: 'completed', receiverThreadIds: [], agentsStates: {} }),
done: true,
};
const runningStartedWithCompletedWait = collabApi.mergeCollabAgentTools([
rawSubAgentActivity({
id: 'call_activity_started_done_completed',
activityKind: 'started',
done: true,
input: { status: 'completed' },
result: JSON.stringify({ status: 'completed' }),
}),
completedEmptyWaitTool,
]);
assert(runningStartedWithCompletedWait.input.receiverThreadIds.length === 1, 'Started raw activity plus completed empty wait should keep one child');
assert(runningStartedWithCompletedWait.input.agentsStates[rawActivityThreadId].status === 'running', 'Started raw activity should stay running even when the empty wait is completed');
assert(runningStartedWithCompletedWait.input.status === 'running', 'Merged collab status should stay running when the only child is running');
collabApi.rememberCollabAgentState(
'restore-running-child',
{ title: '恢复运行代理', status: 'running' },
'请保持运行态。',
0
);
const restoredRunningWithCompletedWait = collabApi.mergeCollabAgentTools([
completedEmptyWaitTool,
], {
restoreAgentIds: new Set(['restore-running-child']),
});
assert(restoredRunningWithCompletedWait.input.agentsStates['restore-running-child'].status === 'running', 'Empty completed wait should not overwrite restored running cache status');
assert(restoredRunningWithCompletedWait.input.status === 'running', 'Empty completed wait should not make a restored running child look completed in the aggregate header');
['started', 'interacted'].forEach((activityKind) => {
const threadId = `agent-thread-${activityKind}`;
const merged = collabApi.mergeCollabAgentTools([
rawSubAgentActivity({
id: `call_activity_${activityKind}_only`,
activityKind,
agentThreadId: threadId,
prompt: activityKind === 'started' ? `请处理 ${activityKind} 状态。` : '',
}),
]);
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
assert(merged.input.agentsStates[threadId].status === 'running', `Raw ${activityKind} activity should map to running status`);
});
['completed', 'returned'].forEach((activityKind) => {
const threadId = `agent-thread-${activityKind}`;
const merged = collabApi.mergeCollabAgentTools([
rawSubAgentActivity({
id: `call_activity_${activityKind}_only`,
activityKind,
agentThreadId: threadId,
prompt: `请处理 ${activityKind} 状态。`,
done: true,
}),
]);
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
assert(collabApi.collabStateTone(merged.input.agentsStates[threadId].status) === 'done', `Raw ${activityKind} activity should map to a completed tone`);
});
const completedWithoutPrompt = collabApi.mergeCollabAgentTools([
rawSubAgentActivity(),
rawSubAgentActivity({
id: 'call_activity_completed_without_prompt',
activityKind: 'completed',
prompt: '',
done: true,
}),
]);
assert(
completedWithoutPrompt.input.agentsStates[rawActivityThreadId].taskDescription === rawActivityPrompt,
'Raw subAgentActivity updates for the same thread should retain an earlier prompt as taskDescription'
);
assert(
collabApi.collabStateTone(completedWithoutPrompt.input.agentsStates[rawActivityThreadId].status) === 'done',
'Raw subAgentActivity completed updates should finish the same child card'
);
const noPromptThreadId = 'agent-thread-no-prompt';
const noPromptActivityMerge = collabApi.mergeCollabAgentTools([
rawSubAgentActivity({
id: 'call_activity_no_prompt',
agentThreadId: noPromptThreadId,
prompt: '',
}),
]);
assert(noPromptActivityMerge.input.agentsStates[noPromptThreadId].label === '计划审查', 'Raw subAgentActivity without prompt should still use readable agentPath as title');
assert(!noPromptActivityMerge.input.agentsStates[noPromptThreadId].taskDescription, 'Raw subAgentActivity without prompt should not fabricate taskDescription');
const noPromptElement = collabApi.renderCollabAgentToolElement(noPromptActivityMerge);
const noPromptDescriptions = findNodesByClass(noPromptElement, 'collab-agent-item-description');
assert(noPromptDescriptions.length === 0, 'Raw subAgentActivity without prompt should not repeat the card title as a fallback description');
const noPromptItems = findNodesByClass(noPromptElement, 'collab-agent-item');
assert(noPromptItems.length === 1, 'Raw subAgentActivity without prompt should still render one rich child-agent card');
assert(
!String(noPromptItems[0].title || '').includes('子代理任务:'),
'Missing child-agent prompts should not add a duplicate fallback description to the card tooltip'
);
const renderCandidates = collabApi.renderToolCallsForMessage([
rawSubAgentActivity(),
emptyWaitTool,
]);
assert(renderCandidates.length === 1, 'Raw subAgentActivity plus empty wait should render only the merged collab card');
assert(renderCandidates[0].kind === 'collab_agent_tool_call', 'Raw subAgentActivity should render through the collab display tool');
assert(
!renderCandidates.some((tool) => tool?.id === 'call_activity_started' && tool?.kind === 'subAgentActivity'),
'Raw subAgentActivity should be filtered out of ordinary rendered tool rows'
);
const nameOnlyOrdinaryTool = {
id: 'call_name_only_subagent_activity',
name: 'subAgentActivity',
kind: 'command_execution',
input: { command: 'echo should-stay-ordinary' },
done: true,
};
assert(collabApi.toolKind(nameOnlyOrdinaryTool) === 'command_execution', 'Name-only subAgentActivity command tools without agentThreadId should stay ordinary tools');
assert(collabApi.mergeCollabAgentTools([nameOnlyOrdinaryTool]) === null, 'Name-only ordinary subAgentActivity tools should not produce a collab card');
const nameOnlyRenderCandidates = collabApi.renderToolCallsForMessage([nameOnlyOrdinaryTool]);
assert(nameOnlyRenderCandidates.length === 1 && nameOnlyRenderCandidates[0].kind === 'command_execution', 'Name-only ordinary subAgentActivity tools should not be filtered into a collab render candidate');
}
function assertCodexAppRuntimeSubAgentActivityContract() {
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
const sent = [];
const runtime = createCodexAppRuntime({
wsSend: (_ws, payload) => sent.push(payload),
loadSession: () => null,
saveSession: () => {},
});
const sessionId = 'runtime-subagent-session';
const threadId = 'agent-thread-runtime-001';
const prompt = '请审查 runtime subAgentActivity 结构。';
const entry = {
ws: {},
toolCalls: [],
fullText: '',
};
assert(typeof runtime.planUpdateFromNotification === 'function', 'Runtime should expose shared plan notification parsing');
const childPlan = runtime.planUpdateFromNotification({
method: 'turn/plan/updated',
params: {
plan: [
{ step: '解析子代理计划', status: 'completed' },
{ step: '同步父卡片进度', status: 'in_progress' },
{ step: '补充回归验证', status: 'pending' },
],
},
});
assert(childPlan?.progress?.completed === 1 && childPlan?.progress?.total === 3, 'Shared plan parser should summarize child plan progress');
assert(childPlan?.currentStep === '同步父卡片进度', 'Shared plan parser should expose the current in-progress step');
runtime.processCodexAppNotification(entry, {
method: 'item/started',
params: {
item: {
id: 'runtime-activity',
type: 'subAgentActivity',
kind: 'started',
agentThreadId: threadId,
agentPath: '/root/plan_reviewer',
prompt,
},
},
}, sessionId);
const started = sent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity');
assert(started, 'Runtime subAgentActivity item/started should emit tool_start');
assert(started.sessionId === sessionId, 'Runtime subAgentActivity tool_start should carry session id');
assert(started.name === 'subAgentActivity', 'Runtime subAgentActivity tool_start should preserve activity name');
assert(started.kind === 'collab_agent_tool_call', 'Runtime subAgentActivity should surface as a collab agent tool call');
assert(started.input?.type === 'subAgentActivity', 'Runtime subAgentActivity input should preserve original type');
assert(started.input?.kind === 'started', 'Runtime subAgentActivity input should preserve activity kind');
assert(started.input?.agentThreadId === threadId, 'Runtime subAgentActivity input should preserve agentThreadId');
assert(started.input?.agentPath === '/root/plan_reviewer', 'Runtime subAgentActivity input should preserve agentPath');
assert(started.input?.prompt === prompt, 'Runtime subAgentActivity input should preserve prompt');
assert(started.input?.receiverThreadIds?.[0] === threadId, 'Runtime subAgentActivity input should expose receiverThreadIds');
assert(started.input?.agentsStates?.[threadId]?.label === 'plan_reviewer', 'Runtime subAgentActivity should derive title from agentPath basename');
assert(started.input?.agentsStates?.[threadId]?.role === '', 'Runtime subAgentActivity should not duplicate agentPath title into role');
assert(started.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime subAgentActivity should copy prompt to taskDescription');
assert(started.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime started subAgentActivity should map to running');
runtime.processCodexAppNotification(entry, {
method: 'item/completed',
params: {
item: {
id: 'runtime-activity',
type: 'subAgentActivity',
agentThreadId: threadId,
agentPath: '/root/plan_reviewer',
},
},
}, sessionId);
const completed = sent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity');
assert(completed, 'Runtime subAgentActivity item/completed should emit tool_end');
assert(completed.kind === 'collab_agent_tool_call', 'Runtime completed subAgentActivity should keep collab tool kind');
assert(completed.name === 'subAgentActivity', 'Runtime completed subAgentActivity should keep activity name');
assert(completed.input?.type === 'subAgentActivity', 'Runtime completed subAgentActivity tool_end should carry input');
assert(completed.input?.kind === 'started', 'Runtime lifecycle-completed subAgentActivity input should inherit the started activity kind when completed omits kind');
assert(completed.input?.prompt === prompt, 'Runtime completed subAgentActivity input should retain the started prompt when completed omits it');
assert(completed.input?.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity input should keep receiverThreadIds');
assert(completed.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity input should retain the started taskDescription');
assert(completed.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity input should keep child running when completed omits kind');
const completedResult = JSON.parse(completed.result);
assert(completedResult.type === 'subAgentActivity', 'Runtime completed subAgentActivity result should preserve original type');
assert(completedResult.kind === 'started', 'Runtime lifecycle-completed subAgentActivity result should inherit the started activity kind when completed omits kind');
assert(completedResult.prompt === prompt, 'Runtime completed subAgentActivity result should retain the started prompt when completed omits it');
assert(completedResult.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity result should expose receiverThreadIds');
assert(completedResult.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity result should retain the started taskDescription');
assert(completedResult.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity result should keep child running when completed omits kind');
assert(entry.toolCalls[0]?.kind === 'collab_agent_tool_call', 'Runtime persisted tool call should keep collab tool kind');
assert(entry.toolCalls[0]?.input?.type === 'subAgentActivity', 'Runtime persisted tool call should keep subAgentActivity input type for routing recovery');
assert(entry.toolCalls[0]?.input?.prompt === prompt, 'Runtime persisted subAgentActivity input should retain the started prompt');
assert(entry.toolCalls[0]?.input?.kind === 'started', 'Runtime persisted lifecycle-completed subAgentActivity input should keep the started activity kind');
assert(entry.toolCalls[0]?.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime persisted lifecycle-completed subAgentActivity input should keep child running');
const explicitCompletedSent = [];
const explicitCompletedRuntime = createCodexAppRuntime({
wsSend: (_ws, payload) => explicitCompletedSent.push(payload),
loadSession: () => null,
saveSession: () => {},
});
const explicitCompletedEntry = { ws: {}, toolCalls: [], fullText: '' };
const explicitCompletedThreadId = 'agent-thread-runtime-completed';
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
method: 'item/started',
params: {
item: {
id: 'runtime-activity-completed',
type: 'subAgentActivity',
kind: 'started',
agentThreadId: explicitCompletedThreadId,
agentPath: '/root/plan_reviewer',
prompt,
},
},
}, sessionId);
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
method: 'item/completed',
params: {
item: {
id: 'runtime-activity-completed',
type: 'subAgentActivity',
kind: 'completed',
agentThreadId: explicitCompletedThreadId,
agentPath: '/root/plan_reviewer',
},
},
}, sessionId);
const explicitCompleted = explicitCompletedSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity-completed');
assert(explicitCompleted.input?.kind === 'completed', 'Runtime explicit completed subAgentActivity input should preserve completed activity kind');
assert(explicitCompleted.input?.prompt === prompt, 'Runtime explicit completed subAgentActivity input should retain the started prompt when completed omits it');
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity input should retain the started taskDescription');
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity input should map child status to completed');
const explicitCompletedResult = JSON.parse(explicitCompleted.result);
assert(explicitCompletedResult.kind === 'completed', 'Runtime explicit completed subAgentActivity result should preserve completed activity kind');
assert(explicitCompletedResult.prompt === prompt, 'Runtime explicit completed subAgentActivity result should retain the started prompt');
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity result should retain the started taskDescription');
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity result should map child status to completed');
const explicitRoleSent = [];
const explicitRoleRuntime = createCodexAppRuntime({
wsSend: (_ws, payload) => explicitRoleSent.push(payload),
loadSession: () => null,
saveSession: () => {},
});
explicitRoleRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
method: 'item/started',
params: {
item: {
id: 'runtime-activity-role',
type: 'subAgentActivity',
kind: 'started',
agentThreadId: 'agent-thread-runtime-role',
agentPath: '/root/plan_reviewer',
role: 'reviewer',
},
},
}, sessionId);
const explicitRoleStarted = explicitRoleSent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity-role');
assert(explicitRoleStarted.input?.agentsStates?.['agent-thread-runtime-role']?.role === 'reviewer', 'Runtime subAgentActivity should preserve explicit role');
const reasoningSent = [];
const reasoningRuntime = createCodexAppRuntime({
wsSend: (_ws, payload) => reasoningSent.push(payload),
loadSession: () => null,
saveSession: () => {},
});
reasoningRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
method: 'item/completed',
params: {
item: {
id: 'runtime-reasoning',
type: 'reasoning',
content: [{ text: '推理完成' }],
},
},
}, sessionId);
const reasoningEnd = reasoningSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-reasoning');
assert(reasoningEnd, 'Runtime reasoning item/completed should still emit tool_end');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'input'), 'Runtime non-subAgentActivity reasoning tool_end should not gain input');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
}
function assertFrontendPrimaryCodexAppUiContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
assert(!indexSource.includes('id="chat-agent-btn"'), 'Ordinary UI should not render an agent picker button');
assert(!indexSource.includes('id="chat-agent-menu"'), 'Ordinary UI should not render an agent picker menu');
assert(!indexSource.includes('class="chat-agent-picker"'), 'Ordinary UI should not render the agent picker container');
assert(
/\.chat-agent-picker\s*\{[\s\S]*?display:\s*none;/.test(styleSource),
'Stale cached agent picker markup should be hidden by CSS'
);
assert(source.includes("const PRIMARY_UI_AGENT = 'codexapp';"), 'Frontend should define Codex App as the primary UI agent');
assert(source.includes('const DEFAULT_AGENT = PRIMARY_UI_AGENT;'), 'Frontend default agent should point at the primary UI agent');
assert(source.includes('let currentAgent = DEFAULT_AGENT;'), 'Frontend should not initialize currentAgent from stale localStorage');
assert(source.includes("localStorage.setItem('cc-web-agent', currentAgent);"), 'Frontend should overwrite stale cc-web-agent storage with the primary UI agent');
assert(source.includes('currentAgent = normalizeUiAgent(agent);'), 'setCurrentAgent should coerce ordinary UI agent changes back to Codex App');
assert(source.includes('return sessions.filter((s) => isPrimaryUiAgent(s.agent));'), 'Session list should only expose Codex App sessions in ordinary UI');
assert(
/function applySessionSnapshot\(snapshot[\s\S]*?!isPrimaryUiAgent\(snapshotAgent\)[\s\S]*?return false;[\s\S]*?return true;/.test(source),
'Frontend should reject legacy Claude/Codex snapshots from the current view'
);
assert(source.includes('if (!isPrimaryUiAgent(snapshot.agent)) return false;'), 'Cached legacy sessions should not render in ordinary UI');
assert(source.includes('const agent = normalizeUiAgent(options.agent || currentAgent);'), 'Ordinary new-session UI should create Codex App sessions');
assert(source.includes('const targetAgent = normalizeUiAgent(options.agent || currentAgent);'), 'New-session modal should present the Codex App space');
assert(
/function showSettingsPanel\(\)[\s\S]*?setCurrentAgent\(PRIMARY_UI_AGENT\);[\s\S]*?showCodexSettingsPanel\(\);/.test(source),
'Settings button should route to existing Codex settings in ordinary UI'
);
assert(indexSource.includes('id="mode-select"'), 'Permission mode selector should remain visible');
assert(indexSource.includes('<option value="yolo">YOLO</option>'), 'YOLO permission mode should remain available');
assert(indexSource.includes('<option value="default">默认</option>'), 'Default permission mode should remain available');
assert(indexSource.includes('<option value="plan">Plan</option>'), 'Plan permission mode should remain available');
assert(indexSource.includes('Claude / Codex Web Chat'), 'Static login copy should keep existing Claude/Codex wording');
assert(indexSource.includes('<h3 id="warframe-welcome-title" data-welcome-project-copy></h3>'), 'Static welcome should expose an empty project heading for runtime hydration');
assert(indexSource.includes('<p>本次你要构建什么?</p>'), 'Static welcome should show the requested build prompt');
assert(!indexSource.includes('开始与 Claude 对话'), 'Static welcome should not hard-code a legacy Claude label');
assert(
/function getWelcomeProjectName\(cwd = currentCwd\)[\s\S]*?getPathLeaf\(cwd\) \|\| '当前项目'[\s\S]*?function getWelcomeCopy\(cwd = currentCwd\)[\s\S]*?`你正在操作 \$\{getWelcomeProjectName\(cwd\)\}`/.test(source),
'Welcome heading should resolve the current project name from the active cwd'
);
assert(source.includes('<h3 id="warframe-welcome-title" data-welcome-project-copy>${escapeHtml(getWelcomeCopy(cwd))}</h3>'), 'Dynamic welcome heading should render the escaped current project name');
assert(source.includes('<p>本次你要构建什么?</p>'), 'Dynamic welcome markup should show the requested build prompt');
assert(source.includes('syncWelcomeCopy(currentCwd);'), 'Cwd changes should hydrate or refresh the visible project heading');
assert((source.match(/buildWelcomeMarkup\(currentCwd\)/g) || []).length >= 2, 'Empty-session welcome markup should use the active cwd');
const getPathLeafSource = source.match(/function getPathLeaf\(input\) \{[\s\S]*?\n \}/)?.[0];
const getWelcomeProjectNameSource = source.match(/function getWelcomeProjectName\(cwd = currentCwd\) \{[\s\S]*?\n \}/)?.[0];
const getWelcomeCopySource = source.match(/function getWelcomeCopy\(cwd = currentCwd\) \{[\s\S]*?\n \}/)?.[0];
assert(getPathLeafSource && getWelcomeProjectNameSource && getWelcomeCopySource, 'Welcome project-name helpers should remain extractable for behavioral checks');
const welcomeCopyApi = new Function(`${getPathLeafSource}\n${getWelcomeProjectNameSource}\n${getWelcomeCopySource}\nreturn { getWelcomeCopy };`)();
assert(welcomeCopyApi.getWelcomeCopy('/home/cc-web') === '你正在操作 cc-web', 'Welcome heading should display the current cwd leaf as the project name');
assert(welcomeCopyApi.getWelcomeCopy('C:\\work\\demo-project\\') === '你正在操作 demo-project', 'Welcome heading should normalize Windows-style project paths');
assert(welcomeCopyApi.getWelcomeCopy('') === '你正在操作 当前项目', 'Welcome heading should remain readable before a project is selected');
assert(
serverSource.includes("const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);"),
'Server explicit agent support should remain available for API/MCP paths'
);
}
function assertSetTitleMcpContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
assert(serverSource.includes("case 'ccweb_set_title':"), 'Server should route ccweb_set_title through internal MCP');
assert(serverSource.includes('function setCurrentConversationTitle'), 'Server should implement current-conversation title setting');
assert(serverSource.includes('ignored because title is manually locked'), 'ccweb_set_title should report manual title locks as ignored success');
assert(serverSource.includes("titleSource = 'manual'"), 'Manual UI rename should mark titleSource as manual');
assert(serverSource.includes('CCWEB_TITLE_TOOL_INSTRUCTIONS'), 'Server should define model guidance for the title tool');
assert(frontendSource.includes("createdFromKind || ''") && frontendSource.includes("' llm-created'"), 'Frontend should mark MCP-created sessions with llm-created class');
assert(frontendSource.includes('snapshot.titleSource') && frontendSource.includes('snapshot.createdFromKind'), 'Frontend should preserve title metadata in session snapshots');
assert(styleSource.includes('.session-item.llm-created:not(.pinned)::before'), 'LLM-created marker should be hidden when the session is pinned');
}
function assertSessionItemTooltipContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const tooltipSource = extractFunctionSource(frontendSource, 'buildSessionItemTooltip');
const buildSessionItemTooltip = new Function(`${tooltipSource}\nreturn buildSessionItemTooltip;`)();
const fullTitle = '这个标题显示不下,需要在悬停时完整展示';
assert(
buildSessionItemTooltip('cc-web', fullTitle) === `项目cc-web\n标题:${fullTitle}`,
'Session item tooltip should show the project and the complete conversation title on separate lines'
);
assert(
buildSessionItemTooltip('', fullTitle) === `标题:${fullTitle}`,
'Session item tooltip should still expose the complete title when no project is available'
);
const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem');
assert(createItemSource.includes('getSessionProjectName(session)'), 'Session item tooltip should reuse the canonical project-name resolver');
assert(createItemSource.includes('item.title = buildSessionItemTooltip('), 'Session card should apply the combined project/title tooltip');
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');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const normalizeTitleHistorySource = extractFunctionSource(serverSource, 'normalizeTitleHistory');
const normalizeServerHistory = new Function(`${normalizeTitleHistorySource}\nreturn normalizeTitleHistory;`)();
const serverHistory = normalizeServerHistory([
{ title: '无效日期', changedAt: 'not-a-date', messageIndex: 1, source: 'llm' },
{ title: '无效索引', changedAt: '2026-07-27T00:00:00.000Z', messageIndex: -1, source: 'llm' },
...Array.from({ length: 102 }, (_, index) => ({
title: `主题 ${index}`,
changedAt: new Date(Date.UTC(2026, 6, 27, 0, index)).toISOString(),
messageIndex: index,
...(index > 0 ? { anchorMessageIndex: index - 1 } : {}),
source: 'llm',
})),
]);
assert(serverHistory.length === 100, 'Server should retain only the latest 100 valid title history events');
assert(serverHistory[0].title === '主题 2' && serverHistory.at(-1).title === '主题 101', 'Server title history should discard invalid events and trim from the oldest side');
assert(serverHistory[0].anchorMessageIndex === 1 && serverHistory.at(-1).anchorMessageIndex === 100, 'Server title history should preserve valid trigger-message anchors');
const invalidAnchorHistory = normalizeServerHistory([
{ title: '无效锚点仍保留事件', changedAt: '2026-07-27T00:00:00.000Z', messageIndex: 3, anchorMessageIndex: 3, source: 'llm' },
]);
assert(
invalidAnchorHistory.length === 1 && !Object.prototype.hasOwnProperty.call(invalidAnchorHistory[0], 'anchorMessageIndex'),
'Server title history should discard invalid anchors without discarding otherwise valid legacy events'
);
const normalizeOutlineHistorySource = extractFunctionSource(frontendSource, 'normalizeOutlineTitleHistory');
const formatOutlineDateSource = extractFunctionSource(frontendSource, 'formatUserOutlineDate');
const buildOutlineTimelineSource = extractFunctionSource(frontendSource, 'buildUserOutlineTimelineItems');
const outlineApi = new Function(`
${normalizeOutlineHistorySource}
${formatOutlineDateSource}
${buildOutlineTimelineSource}
return { normalizeOutlineTitleHistory, formatUserOutlineDate, buildUserOutlineTimelineItems };
`)();
const beforeMidnight = new Date(2026, 6, 27, 23, 59, 0).toISOString();
const titleChangedAt = new Date(2026, 6, 28, 0, 0, 30).toISOString();
const afterMidnight = new Date(2026, 6, 28, 0, 1, 0).toISOString();
const legacyTitleChangedAt = new Date(2026, 6, 28, 0, 1, 30).toISOString();
assert(outlineApi.formatUserOutlineDate(beforeMidnight) === '2026-07-27', 'Outline dates should use the browser local calendar day before midnight');
assert(outlineApi.formatUserOutlineDate(afterMidnight) === '2026-07-28', 'Outline dates should roll over at browser-local midnight');
const timeline = outlineApi.buildUserOutlineTimelineItems([
{ type: 'message', id: 'user-1', targetMessageId: 'hapi-message-user-1', label: '第一步', timestamp: beforeMidnight, messageIndex: 0 },
{ type: 'message', id: 'user-2', targetMessageId: 'hapi-message-user-2', label: '第二步', timestamp: afterMidnight, messageIndex: 2 },
], [
{ title: 'SQL 排查主题', changedAt: titleChangedAt, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' },
{ title: '旧历史兼容主题', changedAt: legacyTitleChangedAt, messageIndex: 3, source: 'llm' },
]);
assert(
JSON.stringify(timeline.map((item) => item.type)) === JSON.stringify(['date', 'title', 'message', 'date', 'title', 'message']),
'Outline timeline should render each title as a section heading before its triggering user message'
);
assert(timeline[0].label === '2026-07-27' && timeline[3].label === '2026-07-28', 'Outline should deduplicate dates and show YYYY-MM-DD only');
assert(timeline[2].messageNumber === 1 && timeline[5].messageNumber === 2, 'Only selectable message nodes should consume outline numbering');
assert(timeline[1].label === 'SQL 排查主题' && timeline[1].anchorMessageIndex === 0, 'Anchored title history should render before its exact triggering message');
assert(timeline[4].label === '旧历史兼容主题' && !timeline[4].targetMessageId, 'Legacy title history should fall back to the nearest preceding user message and remain read-only');
assert(timeline[0].label !== outlineApi.formatUserOutlineDate(titleChangedAt), 'A title crossing midnight should inherit the triggering message calendar day');
const updateOutlineSource = extractFunctionSource(frontendSource, 'updateUserOutlinePanel');
assert(updateOutlineSource.includes('user-outline-title-event') && updateOutlineSource.includes('user-outline-date'), 'Outline renderer should include dedicated title and date nodes');
assert(updateOutlineSource.includes("item.type === 'message'"), 'Outline renderer should reserve buttons for selectable message nodes');
assert(/closest\('\.user-outline-item'\)/.test(frontendSource), 'Outline click delegation should only target selectable message buttons');
assert(styleSource.includes('.user-outline-title-event') && styleSource.includes('.user-outline-date'), 'Outline title and date nodes should have dedicated styles');
assert(/\.user-outline-date::before[\s\S]*\.user-outline-date::after/.test(styleSource), 'Outline dates should use the requested two-sided divider treatment');
assert(serverSource.includes('titleHistory: refreshedSession.titleHistory'), 'session_info should expose persisted title history to the active conversation only');
assert(frontendSource.includes('titleHistory: normalizeOutlineTitleHistory(payload.titleHistory)'), 'Frontend snapshots should preserve normalized title history');
}
function assertSessionSwitchResilienceContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
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('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');
assert(frontendSource.includes('pendingSessionResumeRequest'), 'Frontend should track lightweight running-session resume requests');
assert(frontendSource.includes('SESSION_RESUME_FALLBACK_MS'), 'Frontend should keep a compatibility fallback for servers without resume_session');
assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload');
assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations');
assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results');
assert(
/case 'resume_session_result':[\s\S]*?if \(!msg\.isRunning && currentSessionId && msg\.sessionId === currentSessionId\) \{[\s\S]*?finishGenerating\(msg\.sessionId \|\| currentSessionId\);[\s\S]*?\}[\s\S]*?break;/.test(frontendSource),
'Frontend idle resume result should finish generation state for the current session'
);
assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view');
const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'");
const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1;
const visibilitySource = visibilityStart >= 0 && visibilityEnd > visibilityStart
? frontendSource.slice(visibilityStart, visibilityEnd)
: '';
assert(visibilitySource.includes('requestSessionResume(currentSessionId'), 'Visibility restore should use lightweight resume for running conversations');
assert(visibilitySource.includes("send({ type: 'list_sessions' });"), 'Visibility restore should only refresh session list for idle conversations');
assert(!visibilitySource.includes("type: 'load_session'"), 'Visibility restore must not force load_session and rerender the current conversation');
assert(!visibilitySource.includes('beginSessionSwitch('), 'Visibility restore must not force a session switch and scroll to bottom');
assert(
/else if \(currentSessionId && \(isGenerating \|\| currentSessionRunning\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource),
'Frontend should queue a lightweight resume request for running conversations when WS closes'
);
assert(
/const flushedSessionResume = flushedSessionSwitch \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource),
'Frontend should resume the current running session after auth without forcing load_session'
);
assert(
frontendSource.includes("send({ type: 'abort', sessionId: currentSessionId })"),
'Frontend abort button should explicitly target the current session'
);
assert(
/case 'background_done':[\s\S]*?if \(isNearBottom\(\)\)[\s\S]*?openSession\(msg\.sessionId,\s*\{ forceSync: true, blocking: false \}\)[\s\S]*?send\(\{ type: 'list_sessions' \}\)/.test(frontendSource),
'Frontend should not auto-rerender the current session on background_done while the user is reading history'
);
assert(
/function startGenerating\(sessionId = currentSessionId, options = \{\}\)[\s\S]*?const shouldFollow = options\.follow !== false[\s\S]*?if \(shouldFollow\)/.test(frontendSource),
'Frontend resume_generating should be able to create a streaming bubble without forcing scroll-to-bottom'
);
assert(
/const preserveStreaming = !!\(options\.preserveStreaming[\s\S]*?\(isGenerating \|\| currentSessionRunning \|\| hasStreamingElement\)\)/.test(frontendSource),
'Frontend should preserve the current running conversation DOM even when isGenerating was stale'
);
assert(
/case 'session_history_chunk':[\s\S]*?activeSessionLoad\?\.recoverCurrent[\s\S]*?break;/.test(frontendSource),
'Frontend should ignore history chunks from recovery fallback to avoid duplicating/redrawing bubbles'
);
assert(
/function flushPendingSessionSwitch\(\)[\s\S]*?return false[\s\S]*?return true/.test(frontendSource),
'Frontend flushPendingSessionSwitch should report whether it sent a load_session request'
);
assert(
/pendingSessionSwitchRequest\s*=\s*\{[\s\S]*?blocking:\s*false[\s\S]*?requestId:\s*activeSessionLoad\.requestId/.test(frontendSource),
'Frontend should retry load_session after WS close without re-blocking the UI'
);
assert(
frontendSource.includes('!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession'),
'Frontend should not let late requestId-bearing session_info switch an idle/welcome view'
);
assert(
frontendSource.includes('matchesActiveLoadError') && frontendSource.includes('errorRequestId === activeSessionLoad.requestId'),
'Frontend should clear active session load errors by requestId'
);
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(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');
assert(/function handleAbort\(ws, msg = \{\}\)/.test(serverSource), 'Server handleAbort should accept the abort request payload');
assert(
/function bindAbortSessionToWs\(sessionId, ws\)[\s\S]*?entry\.ws = ws/.test(serverSource),
'Server abort should rebind the target running session to the current WebSocket before stopping'
);
assert(serverSource.includes('WS_HEARTBEAT_MAX_MISSES'), 'Server should tolerate missed WebSocket pongs before terminating');
assert(serverSource.includes('function markWsActivity'), 'Server should mark WebSocket activity on send/message/pong');
assert(
serverSource.includes('markWsActivity(ws);') && serverSource.includes('markWsActivity(ws);'),
'Server should call markWsActivity from WebSocket send and message paths'
);
assert(
/hasRecentActivity[\s\S]*?_ccWebMissedPongs[\s\S]*?ws_heartbeat_terminate/.test(serverSource),
'Server heartbeat should consider recent activity and log before terminating stale sockets'
);
assert(serverSource.includes('function sanitizeMessagesForTransport'), 'Server should sanitize session messages before WebSocket transport');
assert(
/function splitHistoryMessages\(messages[\s\S]*?const list = sanitizeMessagesForTransport\(messages\)/.test(serverSource),
'Server history split should operate on transport-sanitized messages'
);
assert(
/function wsSend\(ws, data\)[\s\S]*?try\s*\{[\s\S]*?JSON\.stringify\(data\)[\s\S]*?catch/.test(serverSource),
'Server wsSend should guard JSON serialization failures'
);
assert(
runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') &&
runtimeSource.includes('function appendCappedText') &&
runtimeSource.includes('entry.fullText = appendCappedText'),
'Classic runtime should cap accumulated fullText in memory'
);
}
function assertSessionRenderEpochRaceContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const beginSessionSwitchSource = extractFunctionSource(frontendSource, 'beginSessionSwitch');
const renderMessagesSource = extractFunctionSource(frontendSource, 'renderMessages');
const api = new Function(`
let renderEpoch = 0;
let loadedHistorySessionId = 'session-a';
let activeSessionLoad = null;
let currentSessionId = 'session-a';
let closedCollabAgentIds = new Set();
let collabAgentStateCache = new Map();
let collabAgentIdsByToolUseId = new Map();
let closedCollabAgentIdsByToolUseId = new Map();
const scheduled = [];
const messagesDiv = {
nodes: [],
scrollTop: 0,
get scrollHeight() { return this.nodes.length; },
get firstChild() { return this.nodes[0] || null; },
get innerHTML() { return ''; },
set innerHTML(value) { this.nodes = []; },
appendChild(node) {
this.nodes.push(...(node && node.__fragment ? node.nodes : [node]));
},
insertBefore(node) {
this.nodes.unshift(...(node && node.__fragment ? node.nodes : [node]));
},
};
const document = {
createDocumentFragment() {
return {
__fragment: true,
nodes: [],
appendChild(node) { this.nodes.push(node); },
};
},
};
const currentCwd = '/tmp/session-render-race';
function setTimeout(callback) { scheduled.push(callback); return scheduled.length; }
function setSessionLoading(sessionId) {
activeSessionLoad = sessionId ? { sessionId, overlayReleased: false } : null;
}
function requestSessionLoad() {}
function collectClosedCollabAgentIds() { return new Set(); }
function clearUserMessageIndex() {}
function buildWelcomeMarkup() { return '<p>welcome</p>'; }
function updateUserOutlinePanel() {}
function renderPendingNotes() {}
function scrollToBottom() {}
function updateScrollbar() {}
function buildMsgElement(message) { return { id: message.id }; }
${beginSessionSwitchSource}
${renderMessagesSource}
return {
beginSessionSwitch,
renderMessages,
flushTimers() {
while (scheduled.length > 0) scheduled.shift()();
},
renderedIds: () => messagesDiv.nodes.map((node) => node.id),
};
`)();
const sessionAMessages = Array.from({ length: 11 }, (_, index) => ({ id: `a-${index}` }));
api.renderMessages(sessionAMessages);
assert(
api.renderedIds().length === 10 && !api.renderedIds().includes('a-0'),
'Render epoch race fixture should leave the oldest A message in the delayed batch'
);
api.beginSessionSwitch('session-b', { force: true, blocking: false });
api.flushTimers();
assert(
JSON.stringify(api.renderedIds()) === JSON.stringify(sessionAMessages.map((message) => message.id)),
'Beginning a B load must not cancel A delayed batches before the B snapshot is committed'
);
api.renderMessages(sessionAMessages);
api.beginSessionSwitch('session-b', { force: true, blocking: false });
api.renderMessages([{ id: 'b-0' }]);
api.flushTimers();
assert(
JSON.stringify(api.renderedIds()) === JSON.stringify(['b-0']),
'Committing the B render must invalidate A delayed batches so stale messages cannot leak into B'
);
assert(
!/\brenderEpoch\s*(?:\+\+|--|[+\-*/%]?=)/.test(beginSessionSwitchSource),
'beginSessionSwitch should not advance renderEpoch before the replacement snapshot renders'
);
}
function assertSessionRequestIdRaceContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const sessionInfoStart = frontendSource.indexOf("case 'session_info':");
const sessionInfoDecisionStart = frontendSource.indexOf('const messageRequestId', sessionInfoStart);
const sessionInfoDecisionEnd = frontendSource.indexOf('if (!canSwitchToSessionInfo)', sessionInfoDecisionStart);
assert(
sessionInfoStart >= 0 && sessionInfoDecisionStart > sessionInfoStart && sessionInfoDecisionEnd > sessionInfoDecisionStart,
'Frontend should keep an extractable request gate before applying session_info'
);
const sessionInfoDecisionSource = frontendSource.slice(sessionInfoDecisionStart, sessionInfoDecisionEnd);
const decideSessionInfo = new Function('msg', 'snapshot', 'activeLoad', 'pendingNewSession', 'currentSessionId', `
${sessionInfoDecisionSource}
return { canSwitchToSessionInfo, matchesActiveLoad };
`);
const staleCurrentDecision = decideSessionInfo(
{
type: 'session_info',
sessionId: 'session-a',
requestId: 'request-old-a',
messages: [{ id: 'stale-a' }],
historyPending: false,
isRunning: false,
},
{
sessionId: 'session-a',
agent: 'codexapp',
messages: [{ id: 'stale-a' }],
},
{
sessionId: 'session-b',
requestId: 'request-new-b',
snapshot: null,
},
null,
'session-a'
);
const rejectsStaleCurrentSessionInfo = staleCurrentDecision.canSwitchToSessionInfo === false;
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
const finalizeApi = new Function(`
let activeSessionLoad = {
sessionId: 'session-b',
requestId: 'request-new-b',
snapshot: { messages: [{ id: 'b-0' }] },
};
const cached = [];
const finished = [];
function cacheSessionSnapshot(snapshot) { cached.push(snapshot); }
function finishSessionSwitch(sessionId) { finished.push(sessionId); }
${finalizeLoadedSessionSource}
return {
finalizeLoadedSession,
cached,
finished,
reset() { cached.length = 0; finished.length = 0; },
};
`)();
finalizeApi.finalizeLoadedSession('session-b', 'request-old-b');
const rejectsStaleFinalize = finalizeApi.cached.length === 0 && finalizeApi.finished.length === 0;
finalizeApi.reset();
finalizeApi.finalizeLoadedSession('session-b', 'request-new-b');
const acceptsMatchingFinalize = finalizeApi.cached.length === 1 && finalizeApi.finished.length === 1;
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 session_history_chunk handler');
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
const historyApi = new Function(`
let activeSessionLoad = {
sessionId: 'session-b',
requestId: 'request-new-b',
recoverCurrent: false,
snapshot: { messages: [{ id: 'b-recent' }] },
};
let currentSessionId = 'session-b';
let loadedHistorySessionId = 'session-b';
const prepended = [];
const finalized = [];
function cloneMessages(messages) { return messages.slice(); }
function isBlockingSessionLoad() { return false; }
function prependHistoryMessages(messages) { prepended.push(...messages); }
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
function handleHistoryMessage(msg) {
switch (msg.type) {
${historyCaseSource}
}
}
return {
handleHistoryMessage,
prepended,
finalized,
reset() { prepended.length = 0; finalized.length = 0; },
};
`)();
historyApi.handleHistoryMessage({
type: 'session_history_chunk',
sessionId: 'session-b',
requestId: 'request-old-b',
messages: [{ id: 'stale-history' }],
remaining: 0,
historyBaseIndex: 0,
});
const rejectsStaleHistoryChunk = historyApi.prepended.length === 0 && historyApi.finalized.length === 0;
historyApi.reset();
historyApi.handleHistoryMessage({
type: 'session_history_chunk',
sessionId: 'session-b',
requestId: 'request-new-b',
messages: [{ id: 'matching-history' }],
remaining: 0,
historyBaseIndex: 0,
});
const acceptsMatchingHistoryChunk = historyApi.prepended.some((message) => message.id === 'matching-history')
&& historyApi.finalized.some((entry) => entry.requestId === 'request-new-b');
const failures = [];
if (!rejectsStaleCurrentSessionInfo) {
failures.push('A stale requestId-bearing session_info for the current A view must not overwrite the pending B load');
}
if (!rejectsStaleFinalize) {
failures.push('finalizeLoadedSession must reject a stale requestId even when the sessionId still matches');
}
if (!acceptsMatchingFinalize) {
failures.push('finalizeLoadedSession should still commit the matching active load');
}
if (!rejectsStaleHistoryChunk) {
failures.push('session_history_chunk must reject stale requestIds before prepending or finalizing history');
}
if (!acceptsMatchingHistoryChunk) {
failures.push('session_history_chunk should preserve the matching active request path');
}
assert(failures.length === 0, failures.join('; '));
}
function assertBlockingFinishRafRequestRaceContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const isBlockingSessionLoadSource = extractFunctionSource(frontendSource, 'isBlockingSessionLoad');
const clearSessionLoadingSource = extractFunctionSource(frontendSource, 'clearSessionLoading');
const finishSessionSwitchSource = extractFunctionSource(frontendSource, 'finishSessionSwitch');
const api = new Function(`
let activeSessionLoad = {
sessionId: 'session-a',
requestId: 'request-a1',
blocking: true,
};
const rafCallbacks = [];
let scrollCount = 0;
function setSessionLoading(sessionId) {
activeSessionLoad = sessionId ? { sessionId, requestId: 'unexpected', blocking: true } : null;
}
function scrollToBottom() { scrollCount += 1; }
function requestAnimationFrame(callback) { rafCallbacks.push(callback); return rafCallbacks.length; }
${isBlockingSessionLoadSource}
${clearSessionLoadingSource}
${finishSessionSwitchSource}
return {
finishA1() { finishSessionSwitch('session-a', 'request-a1'); },
replaceWithA2() {
activeSessionLoad = {
sessionId: 'session-a',
requestId: 'request-a2',
blocking: true,
};
},
flushRaf() {
while (rafCallbacks.length > 0) rafCallbacks.shift()();
},
activeRequestId: () => activeSessionLoad?.requestId || null,
queuedRafCount: () => rafCallbacks.length,
scrollCount: () => scrollCount,
};
`)();
api.finishA1();
assert(
api.queuedRafCount() === 1 && api.scrollCount() === 1 && api.activeRequestId() === 'request-a1',
'Blocking A1 completion should defer clearing through the real finishSessionSwitch RAF path'
);
api.replaceWithA2();
api.flushRaf();
assert(
api.activeRequestId() === 'request-a2',
'The deferred A1 RAF callback must not clear a newer A2 load for the same sessionId'
);
}
function assertRecoverCurrentHistoryMergeContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
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');
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
const api = new Function(`
let activeSessionLoad = {
sessionId: 'session-recover',
requestId: 'request-recover-new',
recoverCurrent: true,
blocking: false,
snapshot: { messages: [{ id: 'recent' }] },
};
let currentSessionId = 'session-recover';
let loadedHistorySessionId = 'session-recover';
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 cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
${finalizeLoadedSessionSource}
function handleHistoryMessage(msg) {
switch (msg.type) {
${historyCaseSource}
}
}
return {
handleHistoryMessage,
snapshotMessageIds: () => activeSessionLoad.snapshot.messages.map((message) => message.id),
prepended,
cached,
finished,
};
`)();
api.handleHistoryMessage({
type: 'session_history_chunk',
sessionId: 'session-recover',
requestId: 'request-recover-stale',
messages: [{ id: 'stale-history' }],
remaining: 0,
historyBaseIndex: 0,
});
const rejectsStaleChunk = JSON.stringify(api.snapshotMessageIds()) === JSON.stringify(['recent'])
&& api.prepended.length === 0
&& api.cached.length === 0
&& api.finished.length === 0;
api.handleHistoryMessage({
type: 'session_history_chunk',
sessionId: 'session-recover',
requestId: 'request-recover-new',
messages: [{ id: 'history-later' }],
remaining: 1,
historyBaseIndex: 1,
});
const mergesIntermediateChunk = JSON.stringify(api.snapshotMessageIds())
=== JSON.stringify(['history-later', 'recent']);
const keepsRecoveryOffDom = api.prepended.length === 0;
const defersRecoveryFinalize = api.cached.length === 0 && api.finished.length === 0;
api.handleHistoryMessage({
type: 'session_history_chunk',
sessionId: 'session-recover',
requestId: 'request-recover-new',
messages: [{ id: 'history-oldest' }],
remaining: 0,
historyBaseIndex: 0,
});
const cachedSnapshot = api.cached[0] || null;
const cachesCompleteHistory = api.cached.length === 1
&& cachedSnapshot.complete === true
&& JSON.stringify((cachedSnapshot.messages || []).map((message) => message.id))
=== JSON.stringify(['history-oldest', 'history-later', 'recent']);
const finalizesMatchingRecovery = api.finished.length === 1
&& api.finished[0].requestId === 'request-recover-new';
const neverPrependsRecoveryHistory = api.prepended.length === 0;
const failures = [];
if (!rejectsStaleChunk) failures.push('recoverCurrent must still reject stale requestId history chunks');
if (!mergesIntermediateChunk) failures.push('recoverCurrent must merge each accepted history chunk into the active snapshot');
if (!keepsRecoveryOffDom || !neverPrependsRecoveryHistory) failures.push('recoverCurrent history chunks must not prepend the live DOM');
if (!defersRecoveryFinalize) failures.push('recoverCurrent must wait for the last history chunk before caching');
if (!cachesCompleteHistory) failures.push('recoverCurrent finalization must cache recent and historical messages as one complete snapshot');
if (!finalizesMatchingRecovery) failures.push('recoverCurrent finalization must retain the matching requestId');
assert(failures.length === 0, failures.join('; '));
}
function assertServerSessionHistoryRequestIdContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const handleLoadSessionSource = extractFunctionSource(serverSource, 'handleLoadSession');
const api = new Function(`
const sent = [];
const fixture = {
id: 'session-b',
title: 'Session B',
pinnedAt: null,
permissionMode: 'yolo',
model: 'gpt-5.5',
agent: 'codexapp',
hasUnread: false,
cwd: '/tmp/session-b',
totalCost: 0,
totalUsage: null,
updated: '2026-07-18T00:00:00.000Z',
messages: Array.from({ length: 11 }, (_, index) => ({ id: 'b-' + index })),
};
const activeProcesses = new Map();
const activeCodexAppTurns = new Map();
const wsSessionMap = new Map();
function sanitizeId(value) { return String(value || ''); }
function reconcilePendingCrossConversationReplies() {}
function loadSession() { return fixture; }
function wsSend(ws, message) { sent.push(message); }
function attachClientRequestId(message, source) {
return source && source.requestId ? { ...message, requestId: source.requestId } : message;
}
function flushPendingCrossConversationReplies() {}
function getSessionAgent(session) { return session.agent; }
function splitHistoryMessages(messages) {
return {
recentMessages: messages.slice(1),
olderChunks: [messages.slice(0, 1)],
historyRemaining: 0,
historyBuffered: messages.length,
};
}
function crossConversationWaitState() {
return {
waitingOnChildren: false,
pendingReplyCount: 0,
readyReplyCount: 0,
waitingReplyCount: 0,
failedReplyCount: 0,
pendingReplies: [],
};
}
function detachWsFromActiveRuntimes() {}
function saveSession() {}
function publicTitleMetadata() { return {}; }
function sessionModelLabel(session) { return session.model; }
function isSessionRunning() { return false; }
function attachActiveRuntimeToWs() {}
function resolveClaudeSessionLocalMeta() { return null; }
${handleLoadSessionSource}
return {
load(requestId) {
sent.length = 0;
handleLoadSession({}, { type: 'load_session', sessionId: fixture.id, requestId });
return sent.slice();
},
};
`)();
const messages = api.load('request-new-b');
const sessionInfo = messages.find((message) => message.type === 'session_info');
const historyChunks = messages.filter((message) => message.type === 'session_history_chunk');
assert(sessionInfo?.requestId === 'request-new-b', 'Server session_info fixture should echo the load requestId');
assert(historyChunks.length > 0, 'Server load fixture should emit at least one delayed history chunk');
assert(
historyChunks.every((message) => message.requestId === sessionInfo.requestId),
'Every server history chunk must echo the exact requestId carried by its session_info snapshot'
);
}
function assertSessionSwitchRaceContract() {
const checks = [
['render epoch behavior', assertSessionRenderEpochRaceContract],
['frontend requestId behavior', assertSessionRequestIdRaceContract],
['blocking finish RAF request behavior', assertBlockingFinishRafRequestRaceContract],
['recoverCurrent history merge behavior', assertRecoverCurrentHistoryMergeContract],
['server history requestId behavior', assertServerSessionHistoryRequestIdContract],
];
const failures = [];
for (const [label, check] of checks) {
try {
check();
} catch (err) {
failures.push(`${label}: ${err?.message || err}`);
}
}
if (failures.length > 0) {
throw new Error(`Session switch race regression failed:\n- ${failures.join('\n- ')}`);
}
}
function assertCodexAppStaleRunningRecoveryContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
const steerBlock = extractFunctionSource(serverSource, 'handleCodexAppSteerMessage');
assert(
completeBlock.includes('!options.deferPendingCrossConversationFlush') && completeBlock.includes('flushPendingCrossConversationReplies(sessionId)'),
'Stale recovery should be able to defer pending cross-conversation reply flushes'
);
assert(
steerBlock.includes('deferPendingCrossConversationFlush: true'),
'Stale steer recovery should defer pending cross-conversation reply flushes while replacing the turn'
);
assert(
steerBlock.includes('!activeCodexAppTurns.has(sessionId)') && steerBlock.includes('handleCodexAppMessage(ws, refreshedSession'),
'Stale steer recovery should only start the replacement turn while the active turn map is empty'
);
assert(
steerBlock.includes('mcpContext: entry.mcpContext || options.mcpContext || {}'),
'Stale steer recovery should prefer the original active entry MCP context'
);
}
function assertRuntimeImageSendStaticContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const validationBlock = extractFunctionSource(frontendSource, 'getQueuedMessageValidationError');
assert(/attachments/.test(validationBlock), 'Queued message validation should consider attachments for image-only queue items');
const addQueuedBlock = extractFunctionSource(frontendSource, 'addQueuedMessage');
assert(
/attachments/.test(addQueuedBlock) &&
/map\(\s*\(?attachment\)?\s*=>\s*\(\{\s*\.\.\.attachment\s*\}\)\s*\)/.test(addQueuedBlock),
'Queued messages should save an attachment metadata snapshot when they are created'
);
const queueInputBlock = extractFunctionSource(frontendSource, 'queueMessageFromInput');
assert(
!queueInputBlock.includes('排队发送暂不支持图片附件'),
'Queue submit should not actively reject image attachments'
);
assert(
/pendingAttachments\.map\(\s*\(?attachment\)?\s*=>\s*\(\{\s*\.\.\.attachment\s*\}\)\s*\)/.test(queueInputBlock),
'Queue submit should snapshot pending attachments before clearing the composer'
);
assert(
/addQueuedMessage\(\s*text\s*,\s*\{[\s\S]*attachments/.test(queueInputBlock),
'Queue submit should pass the attachment snapshot into addQueuedMessage'
);
assert(
/pendingAttachments\s*=\s*\[\]/.test(queueInputBlock) && /renderPendingAttachments\(\)/.test(queueInputBlock),
'Queue submit should clear the composer attachment tray after enqueueing'
);
const queuedElementBlock = extractFunctionSource(frontendSource, 'createQueuedMessageElement');
assert(
/renderAttachmentPreviews\(\s*message\.attachments/.test(queuedElementBlock),
'Queued message cards should render an attachment summary'
);
const dropQueuedBlock = extractFunctionSource(frontendSource, 'dropQueuedMessage');
const removeQueuedBlock = extractFunctionSource(frontendSource, 'removeQueuedMessage');
assert(
/deleteUploadedAttachment/.test(`${dropQueuedBlock}\n${removeQueuedBlock}`),
'Deleting an unsent queued message should clean up its uploaded attachments'
);
const drainQueuedBlock = extractFunctionSource(frontendSource, 'drainQueuedMessages');
assert(
/submitUserMessage\(\s*text\s*,\s*(attachments|message\??\.attachments)/.test(drainQueuedBlock),
'Queue drain should send the queued attachment snapshot through submitUserMessage'
);
const sendMessageBlock = extractFunctionSource(frontendSource, 'sendMessage');
const runtimeStart = sendMessageBlock.indexOf('if (runtimeInsert)');
const runtimeEnd = sendMessageBlock.indexOf('// Slash commands:', runtimeStart);
assert(runtimeStart >= 0 && runtimeEnd > runtimeStart, 'Frontend should keep a distinct runtime insert branch');
const runtimeInsertBlock = sendMessageBlock.slice(runtimeStart, runtimeEnd);
assert(
!runtimeInsertBlock.includes('Codex App 运行中插入暂不支持图片附件'),
'Runtime insert should not actively reject image attachments'
);
assert(
/pendingAttachments\.map\(\s*\(?attachment\)?\s*=>\s*\(\{\s*\.\.\.attachment\s*\}\)\s*\)/.test(runtimeInsertBlock),
'Runtime insert should snapshot pending attachments'
);
assert(
/createMsgElement\(\s*'user'\s*,\s*text\s*,\s*attachments/.test(runtimeInsertBlock),
'Runtime insert user bubble should render the same attachments that will be sent'
);
assert(
/send\(\s*\{[\s\S]*type:\s*'message'[\s\S]*attachments/.test(runtimeInsertBlock),
'Runtime insert should include attachments in the WebSocket message'
);
assert(
/pendingAttachments\s*=\s*\[\]/.test(runtimeInsertBlock) && /renderPendingAttachments\(\)/.test(runtimeInsertBlock),
'Runtime insert should clear the composer attachment tray after submission'
);
const handleMessageBlock = extractFunctionSource(serverSource, 'handleMessage');
assert(
/handleCodexAppSteerMessage\(\s*ws\s*,\s*msg\s*,\s*\{[\s\S]*resolvedAttachments[\s\S]*savedAttachments/.test(handleMessageBlock),
'handleMessage should pass resolved and saved attachments into the Codex App steer path'
);
const steerBlock = extractFunctionSource(serverSource, 'handleCodexAppSteerMessage');
assert(
!steerBlock.includes('codexapp_running_attachment_unsupported') &&
!/attachments\.length\s*>\s*0\)\s*return fail/.test(steerBlock),
'Server Codex App steer path should not actively reject image attachments'
);
assert(
/attachments:\s*savedAttachments/.test(steerBlock),
'Server Codex App steer path should persist normalized attachment metadata'
);
assert(
/codexAppInputFromMessage\(\s*runtimeTextValue\s*,\s*resolvedAttachments\s*\)/.test(steerBlock),
'turn/steer input should include resolved localImage attachments'
);
assert(
/handleCodexAppMessage\(\s*ws\s*,\s*refreshedSession\s*,\s*runtimeTextValue\s*,\s*resolvedAttachments/.test(steerBlock),
'Stale steer replacement should reuse the resolved attachments'
);
}
async function runCodexAppRuntimeImageSteerRegression() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-runtime-image-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const homeDir = path.join(tempRoot, 'home');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
mkdirp(homeDir);
const port = await getFreePort();
const password = 'RuntimeImage!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: 'RuntimeImageMcp!234',
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const sessionInfo = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
));
const sessionId = sessionInfo.sessionId;
ws.send(JSON.stringify({
type: 'message',
text: 'slow runtime image base prompt',
sessionId,
mode: 'yolo',
agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
), 5000);
await sleep(150);
const missingAttachment = {
id: 'runtime-missing-image',
kind: 'image',
filename: 'runtime-missing-image.png',
mime: 'image/png',
size: 128,
storageState: 'available',
};
ws.send(JSON.stringify({
type: 'message',
text: 'runtime image missing attachment',
attachments: [missingAttachment],
sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-runtime-image-missing',
}));
const missingStatus = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-runtime-image-missing' &&
msg.status === 'failed'
), 5000);
assert(/失败/.test(missingStatus.message || ''), 'Missing image steer should mark its local user bubble as failed');
const missingError = await nextMessage(messages, ws, (msg) => (
msg.type === 'error' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-runtime-image-missing' &&
msg.code === 'attachment_unavailable'
), 5000);
assert(/图片附件已过期或不可用/.test(missingError.message || ''), 'Missing image steer should surface the attachment error');
const activeAttachment = await uploadAttachment(port, token, {
filename: 'runtime-active-steer.png',
mime: 'image/png',
data: Buffer.from('runtime-active-steer-image'),
});
const activeImageMarker = storedAttachmentImageMarker(activeAttachment);
ws.send(JSON.stringify({
type: 'message',
text: 'runtime image steer insert',
attachments: [activeAttachment],
sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-runtime-image-steer',
}));
const activeStatus = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-runtime-image-steer' &&
['pending', 'failed'].includes(msg.status)
), 5000);
assert(activeStatus.status === 'pending', `Codex App image steer should be accepted, got ${activeStatus.status}: ${activeStatus.message || ''}`);
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === sessionId &&
/steer accepted: runtime image steer insert/.test(msg.text || '') &&
activeImageMarker.test(msg.text || '')
), 5000);
await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-runtime-image-steer' &&
msg.status === 'inserted'
), 5000);
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
const stored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${sessionId}.json`), 'utf8'));
assert(
!stored.messages.some((message) => message.role === 'user' && message.content === 'runtime image missing attachment'),
'Failed missing image steer should not persist a user message that never reached the model'
);
const steerUsers = stored.messages.filter((message) => message.role === 'user' && message.content === 'runtime image steer insert');
assert(steerUsers.length === 1, 'Active image steer user message should persist exactly once');
assertPersistedMessageAttachment(steerUsers[0], activeAttachment.filename, 'Active image steer user message');
assert(
stored.messages.some((message) => (
message.role === 'assistant' &&
/runtime image steer insert/.test(String(message.content || '')) &&
activeImageMarker.test(String(message.content || ''))
)),
'Active image steer assistant output should include the localImage marker from the mock'
);
ws.close();
});
}
async function runCodexAppStaleRunningRegression(options = {}) {
const includeRuntimeImage = options.includeRuntimeImage === true;
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-stale-running-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const homeDir = path.join(tempRoot, 'home');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
mkdirp(homeDir);
const port = await getFreePort();
const password = 'StaleRunning!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: 'StaleRunningMcp!234',
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages, receivedMessages, token } = await connectWs(port, password, { trackReceived: true });
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const sessionInfo = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
));
const sessionId = sessionInfo.sessionId;
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp stale running first',
sessionId,
mode: 'yolo',
agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
));
const staleOutput = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === sessionId && /Codex App stale turn output/.test(msg.text || '')
));
assert(/codexapp stale running first/.test(staleOutput.text || ''), 'Stale running fixture should emit the first turn output');
await sleep(150);
const staleAttachment = includeRuntimeImage
? await uploadAttachment(port, token, {
filename: 'runtime-stale-follow-up.png',
mime: 'image/png',
data: Buffer.from('runtime-stale-follow-up-image'),
})
: null;
const staleImageMarker = staleAttachment ? storedAttachmentImageMarker(staleAttachment) : null;
const staleFollowUpPayload = {
type: 'message',
text: 'codexapp stale running follow-up',
sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-stale-running-follow-up',
};
if (staleAttachment) staleFollowUpPayload.attachments = [staleAttachment];
ws.send(JSON.stringify(staleFollowUpPayload));
const stalePending = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-stale-running-follow-up' &&
['pending', 'failed'].includes(msg.status)
), 5000);
assert(stalePending.status === 'pending', `Stale image steer should be accepted before replacement, got ${stalePending.status}: ${stalePending.message || ''}`);
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning)
), 5000);
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
), 5000);
const replacementResume = await nextMessage(messages, ws, (msg) => (
msg.type === 'resume_generating' && msg.sessionId === sessionId
), 5000);
assert(replacementResume.text === '' && Array.isArray(replacementResume.toolCalls) && replacementResume.toolCalls.length === 0, 'Stale replacement should resume generation with an empty streaming payload');
const recoveredStatus = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === sessionId &&
msg.clientMessageId === 'regression-stale-running-follow-up' &&
msg.status === 'inserted'
), 5000);
assert(!/失败/.test(recoveredStatus.message || ''), 'Recovered stale steer should update the UI as a non-failure');
const recoveredHint = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' && msg.sessionId === sessionId && /已自动开始新一轮对话/.test(msg.message || '')
), 5000);
assert(recoveredHint.transient === true, 'Recovered stale steer hint should be transient');
const recoveredDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || '')
), 5000);
if (staleAttachment) {
assert(
staleImageMarker.test(recoveredDelta.text || ''),
'Stale replacement turn should receive the same image attachment'
);
}
const replacementResumeIndex = receivedMessages.findIndex((msg) => msg.type === 'resume_generating' && msg.sessionId === sessionId);
const replacementDeltaIndex = receivedMessages.findIndex((msg) => msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || ''));
assert(replacementResumeIndex >= 0 && replacementResumeIndex < replacementDeltaIndex, 'Stale replacement should restore generating UI before its first text delta');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
const finalList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning)
), 5000);
assert(finalList.sessions.some((session) => session.id === sessionId && !session.isRunning), 'Recovered follow-up should finish idle');
assert(!messages.some((msg) => msg.code === 'codexapp_steer_failed'), 'Recovered stale steer should not emit codexapp_steer_failed');
const stored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${sessionId}.json`), 'utf8'));
const staleFollowUpUsers = stored.messages.filter((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up');
assert(staleFollowUpUsers.length === 1, 'Recovered follow-up user message should persist exactly once');
if (staleAttachment) {
assertPersistedMessageAttachment(staleFollowUpUsers[0], staleAttachment.filename, 'Recovered stale follow-up user message');
}
assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || ''))).length === 1, 'Stale first turn output should persist exactly once');
assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || ''))).length === 1, 'Recovered follow-up output should persist exactly once');
if (staleAttachment) {
assert(
stored.messages.some((message) => (
message.role === 'assistant' &&
/Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || '')) &&
staleImageMarker.test(String(message.content || ''))
)),
'Recovered stale assistant output should include the localImage marker from the mock'
);
}
const staleAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || '')));
const followUpUserIndex = stored.messages.findIndex((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up');
const recoveredAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || '')));
assert(staleAssistantIndex < followUpUserIndex && followUpUserIndex < recoveredAssistantIndex, 'Recovered history should keep stale assistant before follow-up user before recovered assistant');
assert(!fs.existsSync(path.join(sessionsDir, `${sessionId}-run`)), 'Recovered follow-up should clean the Codex App run directory after completion');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
const mismatchSession = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId
));
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp expected turn mismatch first',
sessionId: mismatchSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
}));
await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' && msg.sessionId === mismatchSession.sessionId && /expected turn mismatch fixture/.test(msg.text || '')
), 5000);
await sleep(150);
ws.send(JSON.stringify({
type: 'message',
text: 'codexapp mismatch follow-up',
sessionId: mismatchSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-expected-turn-mismatch',
}));
const mismatchFailed = await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === mismatchSession.sessionId &&
msg.clientMessageId === 'regression-expected-turn-mismatch' &&
msg.status === 'failed'
), 5000);
assert(/失败/.test(mismatchFailed.message || ''), 'Expected turn mismatch should keep the original failed steer status');
const mismatchError = await nextMessage(messages, ws, (msg) => (
msg.type === 'error' && msg.sessionId === mismatchSession.sessionId && msg.code === 'codexapp_steer_failed'
), 5000);
assert(/expectedTurnId does not match active turn/.test(mismatchError.message || ''), 'Expected turn mismatch should not trigger stale-turn recovery');
assert(!messages.some((msg) => msg.type === 'system_message' && msg.sessionId === mismatchSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')), 'Expected turn mismatch should not start a replacement turn');
ws.close();
});
}
async function runRuntimeImageSendTarget() {
const failures = [];
const checks = [
['active steer image regression', () => runCodexAppRuntimeImageSteerRegression()],
['stale replacement image regression', () => runCodexAppStaleRunningRegression({ includeRuntimeImage: true })],
['runtime image static contract', () => assertRuntimeImageSendStaticContract()],
];
for (const [label, check] of checks) {
try {
await check();
} catch (err) {
failures.push(`${label}: ${err?.message || err}`);
}
}
if (failures.length > 0) {
throw new Error(`Runtime image send regression failed:\n- ${failures.join('\n- ')}`);
}
}
function assertUnlimitedImageAttachmentsContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const handlerStart = frontendSource.indexOf('async function handleSelectedImageFiles(fileList)');
const handlerEnd = frontendSource.indexOf('\n function getVisibleSessions()', handlerStart);
assert(
handlerStart >= 0 && handlerEnd > handlerStart,
'Frontend should define handleSelectedImageFiles before getVisibleSessions'
);
const handlerBlock = frontendSource.slice(handlerStart, handlerEnd);
assert(
!/pendingAttachments\.length\s*\+\s*files\.length\s*>\s*\d+/.test(handlerBlock),
'Frontend image attachment picker should not enforce a numeric per-message attachment cap'
);
assert(
!/单条消息最多附带\s*\d+\s*张图片/.test(handlerBlock),
'Frontend image attachment picker should not show a per-message image count limit'
);
assert(
!serverSource.includes('MAX_MESSAGE_ATTACHMENTS'),
'Server should not define or use MAX_MESSAGE_ATTACHMENTS'
);
assert(
!/msg\.attachments\s*\.slice\s*\(\s*0\s*,/.test(serverSource),
'Server should not truncate msg.attachments with slice'
);
}
function extractFunctionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert(start >= 0, `Server should define ${name}`);
let parenDepth = 0;
let signatureEnd = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnd = i;
break;
}
}
}
assert(signatureEnd > start, `Server function ${name} should have a complete signature`);
const open = source.indexOf('{', signatureEnd);
assert(open > start, `Server function ${name} should have a body`);
let depth = 0;
for (let i = open; i < source.length; i += 1) {
const ch = source[i];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
}
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)');
const helperEnd = source.indexOf('function sendCcwebMcpChildAgentUpdate(sessionId, child)', helperStart);
assert(helperStart >= 0 && helperEnd > helperStart, 'Server should keep ccweb MCP child helper block before sendCcwebMcpChildAgentUpdate');
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 || '');
return text.length > maxLength ? text.slice(0, maxLength - suffix.length) + suffix : text;
}
function loadSession(sessionId) {
return sessions.get(sessionId) || null;
}
function saveSession(session) {
savedSession = JSON.parse(JSON.stringify(session));
sessions.set(session.id, session);
}
function findViewingSessionWs() {
return null;
}
function broadcastSessionList() {}
${helperSource}
return {
setSession: (session) => sessions.set(session.id, session),
getSession: (sessionId) => sessions.get(sessionId),
getSavedSession: () => savedSession,
flushPendingCcwebMcpChildSession,
updatePersistedCcwebMcpChildTool,
};
`)();
const oldTool = {
id: 'call-old-reviewer',
name: 'subAgentActivity',
kind: 'collab_agent_tool_call',
input: {
tool: 'subAgentActivity',
receiverThreadIds: ['old-reviewer-thread'],
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
},
result: JSON.stringify({
receiverThreadIds: ['old-reviewer-thread'],
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
}),
done: true,
};
const currentTool = {
id: 'call-current-plan-demo',
name: 'subAgentActivity',
kind: 'collab_agent_tool_call',
input: {
tool: 'subAgentActivity',
receiverThreadIds: ['current-plan-thread'],
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
},
result: JSON.stringify({
receiverThreadIds: ['current-plan-thread'],
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
}),
done: false,
};
const session = {
id: 'child-tool-routing-session',
messages: [
{
role: 'assistant',
content: '',
toolCalls: [oldTool],
},
{
role: 'assistant',
content: '',
toolCalls: [currentTool],
},
{
role: 'assistant',
content: '',
toolCalls: [
{
id: 'ordinary-command-tool',
name: 'shell',
kind: 'command_execution',
input: { command: 'echo should-not-be-selected' },
result: 'ordinary output',
done: true,
},
],
},
],
};
api.setSession(session);
const persistedTool = api.updatePersistedCcwebMcpChildTool(session.id, {
threadId: 'current-plan-thread',
spawnToolId: 'call-current-plan-demo',
label: '当前计划代理',
taskDescription: '验证计划只进入当前子代理卡片。',
status: 'running',
planProgress: { completed: 2, total: 4 },
planCurrentStep: '更新当前卡片',
planUpdatedAt: '2026-07-29T00:35:00.000Z',
});
assert(persistedTool?.id === 'call-current-plan-demo', 'Child plan updates should select the exact spawn tool id');
assert(session.messages[2].toolCalls[0].result === 'ordinary output', 'Child plan routing must not merge into ordinary command_execution tools');
const result = JSON.parse(persistedTool.result);
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;
const missingTool = api.updatePersistedCcwebMcpChildTool(session.id, {
threadId: 'missing-route-thread',
spawnToolId: 'call-not-present',
label: '不应串卡代理',
status: 'running',
planProgress: { completed: 1, total: 2 },
});
assert(missingTool === null, 'A non-empty unknown spawn tool id must not fall back to another child card');
assert(currentTool.result === currentResultBeforeMissingId, 'Unknown spawn tool ids must not contaminate the latest child card');
assert(!JSON.parse(oldTool.result).agentsStates?.['missing-route-thread'], 'Unknown spawn tool ids must not contaminate older child cards');
const syncSource = extractFunctionSource(source, 'syncCcwebMcpChildAgentsFromCollabItem');
const syncApi = new Function(`
const ccwebMcpChildThreads = new Map();
const sent = [];
const path = { basename: (value) => String(value || '').split('/').filter(Boolean).pop() || '' };
const SESSION_MESSAGE_CONTENT_MAX_CHARS = 10000;
function normalizeCodexAppThreadId(value) { return String(value || '').trim(); }
function codexAppCollabToolName(value) { return String(value || '').trim(); }
function extractCcwebMcpStringArray(...values) { return values.flat().filter(Boolean).map(String); }
function ccwebMcpChildLabel(state, fallback) { return String(state?.label || state?.title || fallback || ''); }
function normalizeCcwebMcpChildPlanProgress() { return null; }
function extractCcwebMcpChildCandidate() { return ''; }
function truncateTextValue(value) { return String(value || ''); }
function ccwebMcpChildStatus(value, fallback) {
const normalized = String(value || '').toLowerCase();
return normalized === 'started' ? 'running' : (normalized || fallback);
}
function sendCcwebMcpChildAgentUpdate(sessionId, child) {
sent.push({ sessionId, child: { ...child } });
}
${syncSource}
return {
setChild: (id, child) => ccwebMcpChildThreads.set(id, child),
getChild: (id) => ccwebMcpChildThreads.get(id),
sync: syncCcwebMcpChildAgentsFromCollabItem,
sent,
};
`)();
syncApi.setChild('current-plan-thread', {
threadId: 'current-plan-thread',
parentSessionId: session.id,
parentThreadId: 'parent-thread',
spawnToolId: 'call-old-reviewer',
label: '当前计划代理',
status: 'running',
});
syncApi.sync({ sessionId: session.id, role: 'parent', entry: { threadId: 'parent-thread' } }, {
id: 'call-current-plan-demo',
type: 'subAgentActivity',
kind: 'started',
agentThreadId: 'current-plan-thread',
agentPath: '/root/current_plan_demo',
});
assert(
syncApi.getChild('current-plan-thread')?.spawnToolId === 'call-current-plan-demo',
'A canonical subAgentActivity started event must rebind an existing child to its current tool id'
);
assert(
syncApi.sent.at(-1)?.child?.spawnToolId === 'call-current-plan-demo',
'The first public update after rebind must target the current child card'
);
syncApi.setChild('parent-child-thread', {
threadId: 'parent-child-thread',
parentSessionId: session.id,
parentThreadId: 'parent-thread',
spawnToolId: 'call-visible-parent-child',
label: '父级子代理',
status: 'running',
});
syncApi.sync({
sessionId: session.id,
role: 'child',
child: syncApi.getChild('parent-child-thread'),
}, {
id: 'grandchild-internal-activity',
type: 'subAgentActivity',
kind: 'started',
agentThreadId: 'grandchild-thread',
agentPath: '/root/parent_child/grandchild',
});
assert(
syncApi.getChild('grandchild-thread')?.spawnToolId === 'call-visible-parent-child',
'A nested child should inherit its nearest visible parent card tool id'
);
assert(
syncApi.getChild('grandchild-thread')?.parentThreadId === 'parent-child-thread',
'A nested child should still retain its immediate parent thread id'
);
}
function assertCodexAppUnroutedNotificationRoutingContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
assert(source.includes('const codexAppThreadSessionIndex = new Map();'), 'Server should keep an O(1) Codex App thread -> session index');
assert(source.includes('const codexAppUnknownThreadMisses = new Map();'), 'Server should keep a bounded negative cache for unknown Codex App threads');
assert(source.includes('const codexAppUnroutedNotificationLogTimes = new Map();'), 'Server should throttle unrouted notification logs by thread/method');
assert(source.includes('function recoverCcwebMcpChildThreadsFromPersistedToolCalls'), 'Server should restore child-thread routes from persisted collaboration tool calls');
assert(
/recoverCcwebMcpChildThreadsFromPersistedToolCalls\(\s*sessionId,\s*state,\s*toolCalls\s*\)/.test(source),
'Codex App recovery should rebuild persisted child-thread routes before cleaning run state'
);
assert(source.includes('function updateSessionRuntimeThreadIndex'), 'Server should centralize session thread index maintenance');
assert(
/function saveSession\(session\)[\s\S]*?updateSessionRuntimeThreadIndex\(session\)/.test(source),
'Saving a session should refresh the runtime thread index'
);
assert(
/function handleDeleteSession\(ws, sessionId\)[\s\S]*?removeSessionRuntimeThreadIndex\(sessionId\)/.test(source),
'Deleting a session should remove its runtime thread index entry'
);
const lookupBlock = extractFunctionSource(source, 'findCodexAppSessionByThreadId');
assert(lookupBlock.includes('codexAppThreadSessionIndex.get(targetThreadId)'), 'Thread lookup should consult the O(1) index');
assert(lookupBlock.includes('codexAppUnknownThreadMisses'), 'Thread lookup should use the unknown-thread negative cache');
assert(!lookupBlock.includes('fs.readdirSync(SESSIONS_DIR)'), 'Thread lookup must not synchronously scan all session files on the notification hot path');
const routeBlock = extractFunctionSource(source, 'findCodexAppRouteByRuntime');
const childIndex = routeBlock.indexOf("role: 'child'");
const adoptIndex = routeBlock.indexOf('adoptCodexAppUnroutedTurn');
assert(childIndex >= 0, 'Runtime routing should return child routes');
assert(adoptIndex >= 0, 'Runtime routing should still adopt parent notifications from disk state');
assert(childIndex < adoptIndex, 'Known child threads should route before parent disk adoption');
const notificationBlock = extractFunctionSource(source, 'handleCodexAppNotification');
assert(
notificationBlock.includes('shouldLogCodexAppUnroutedNotification(notification)'),
'Unrouted notification logging should be throttled by a helper'
);
}
function assertMultiAgentV2CompatibilityContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
assert(
/CODEX_REASONING_LEVELS\s*=\s*new Set\(\[[^\]]*'ultra'/.test(serverSource),
'Codex config model parsing should accept the ultra reasoning level'
);
assert(
/low\|medium\|high\|xhigh\|ultra/.test(runtimeSource),
'Codex CLI model suffix parsing should accept ultra'
);
assert(
frontendSource.includes("{ value: 'ultra', label: 'ultra'"),
'Codex model picker should expose ultra reasoning'
);
const instructionsBlock = serverSource.slice(
serverSource.indexOf('const CODEX_APP_COLLABORATION_INSTRUCTIONS'),
serverSource.indexOf('function getLocalCodexConfigTomlPath')
);
assert(instructionsBlock.includes('current runtime tool schema'), 'Sub-agent guidance should defer to the current runtime tool schema');
assert(!instructionsBlock.includes('fork_context'), 'Sub-agent guidance should not hard-code the V1 fork_context field');
assert(!instructionsBlock.includes('additional wait_agent rounds'), 'Sub-agent guidance should not impose obsolete repeated wait_agent calls');
const syncBlock = extractFunctionSource(serverSource, 'syncCcwebMcpChildAgentsFromCollabItem');
assert(syncBlock.includes("itemType === 'subAgentActivity'"), 'Child routing should register canonical V2 subAgentActivity items');
const notificationBlock = extractFunctionSource(serverSource, 'handleCodexAppNotification');
const activitySyncIndex = notificationBlock.indexOf('syncCcwebMcpChildAgentsFromCollabItem(routed, item)');
const childReturnIndex = notificationBlock.indexOf("if (routed.role === 'child')");
assert(activitySyncIndex >= 0 && activitySyncIndex < childReturnIndex, 'Nested subAgentActivity registration should happen before child notification routing returns');
const childNotificationBlock = extractFunctionSource(serverSource, 'processCcwebMcpChildNotification');
assert(childNotificationBlock.includes('codexAppRuntime.planUpdateFromNotification(notification)'), 'Child notification routing should reuse runtime plan parsing');
const recoveryBlock = extractFunctionSource(serverSource, 'recoverCcwebMcpChildThreadsFromPersistedToolCalls');
assert(recoveryBlock.includes('parseMaybeJsonObject(tool?.result)'), 'Child recovery should read the latest persisted collaboration tool result');
assert(recoveryBlock.includes('recoveredState.planProgress'), 'Child recovery should restore persisted plan progress');
assert(frontendSource.includes("closeBtn.textContent = '中断';"), 'Sub-agent action should use interrupt semantics');
assert(frontendSource.includes('entry.agentPath ? `路径: ${entry.agentPath}`'), 'Sub-agent cards should expose the canonical agent path');
}
function assertWindowsStartupContract() {
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
assert(source.includes('cd /d "%~dp0"'), 'Windows startup should switch to its own directory and support other drive letters');
assert(/where node(?:\.exe)? >nul 2>&1/i.test(source), 'Windows startup should verify that Node.js is available');
assert(/process\.versions\.node[\s\S]*?>= 18/.test(source), 'Windows startup should reject Node.js versions below 18');
assert(/where npm\.cmd >nul 2>&1/i.test(source), 'Windows startup should verify that npm.cmd is available');
assert(/call npm\.cmd (?:ci|install)/i.test(source), 'Windows startup should use call when invoking npm.cmd');
assert(/if errorlevel 1[\s\S]*?exit \/b 1/i.test(source), 'Windows startup should stop after an environment or dependency failure');
assert(source.includes('set "APP_EXIT_CODE=%ERRORLEVEL%"'), 'Windows startup should preserve the server exit code');
assert(source.includes('exit /b %APP_EXIT_CODE%'), 'Windows startup should return the server exit code to the caller');
}
async function main() {
const targetIndex = process.argv.indexOf('--target');
const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim();
if (regressionTarget) {
if (regressionTarget === 'composer-slash-routing') {
assertComposerSlashRoutingContract();
console.log('Composer slash routing regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-unrouted-routing') {
assertCodexAppUnroutedNotificationRoutingContract();
console.log('Codex App unrouted routing regression checks passed.');
return;
}
if (regressionTarget === 'subagent-card-metadata') {
assertFrontendSubagentCardMetadataContract();
assertCodexAppRuntimeSubAgentActivityContract();
assertMultiAgentV2CompatibilityContract();
console.log('Subagent card metadata regression checks passed.');
return;
}
if (regressionTarget === 'gilded-theme') {
assertFrontendGildedThemeContract();
console.log('Gilded theme regression checks passed.');
return;
}
if (regressionTarget === 'wasteland-theme') {
assertFrontendWastelandThemeContract();
assertPlanListProgressContract();
console.log('Wasteland theme regression checks passed.');
return;
}
if (regressionTarget === 'sidebar-collapse') {
assertFrontendSidebarCollapseContract();
console.log('Sidebar collapse regression checks passed.');
return;
}
if (regressionTarget === 'plan-list-progress') {
assertPlanListProgressContract();
console.log('Plan List progress regression checks passed.');
return;
}
if (regressionTarget === 'runtime-image-send') {
await runRuntimeImageSendTarget();
console.log('Runtime image send regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-stale-running') {
await runCodexAppStaleRunningRegression();
assertSessionSwitchResilienceContract();
assertCodexAppStaleRunningRecoveryContract();
console.log('Codex App stale running regression checks passed.');
return;
}
if (regressionTarget === 'session-switch-race') {
assertSessionSwitchRaceContract();
console.log('Session switch race regression checks passed.');
return;
}
if (regressionTarget === 'title-history-outline') {
assertSetTitleMcpContract();
assertTitleHistoryOutlineContract();
console.log('Title history outline regression checks passed.');
return;
}
if (regressionTarget === 'session-item-tooltip') {
assertSessionItemTooltipContract();
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.');
return;
}
if (regressionTarget === 'subagent-card-routing') {
assertCodexAppChildToolRoutingContract();
console.log('Sub-agent card routing regression checks passed.');
return;
}
throw new Error(`Unknown regression target: ${regressionTarget}`);
}
assertUnlimitedImageAttachmentsContract();
assertFrontendGildedThemeContract();
assertFrontendWastelandThemeContract();
assertFrontendSidebarCollapseContract();
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
assertComposerSlashRoutingContract();
assertFrontendCcwebPromptContract();
assertFrontendMarkdownLinkContract();
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();
assertPlanListProgressContract();
assertFrontendSubagentCardMetadataContract();
assertCodexAppRuntimeSubAgentActivityContract();
assertFrontendPrimaryCodexAppUiContract();
assertSetTitleMcpContract();
assertSessionItemTooltipContract();
assertCcwebMcpChildUpdateCoalescingContract();
assertTitleHistoryOutlineContract();
assertSessionSwitchResilienceContract();
assertSessionSwitchRaceContract();
assertCodexAppChildToolRoutingContract();
assertMultiAgentV2CompatibilityContract();
assertWindowsStartupContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const homeDir = path.join(tempRoot, 'home');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
mkdirp(homeDir);
fs.writeFileSync(path.join(configDir, 'notify.json'), JSON.stringify({
provider: 'off',
pushplus: { token: '' },
telegram: { botToken: '', chatId: '' },
serverchan: { sendKey: '' },
feishu: { webhook: '' },
qqbot: { qmsgKey: '' },
}, null, 2));
const skillDir = path.join(homeDir, '.codex', 'skills', 'regression-skill');
mkdirp(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: regression-skill',
'description: Regression skill for composer suggestions.',
'---',
'',
'# Regression Skill',
'',
'Use this only in regression tests.',
].join('\n'));
mkdirp(path.join(skillDir, 'agents'));
fs.writeFileSync(path.join(skillDir, 'agents', 'openai.yaml'), [
'interface:',
' display_name: "Regression Docs"',
' short_description: "Regression skill metadata for composer suggestions."',
' brand_color: "#2f6f64"',
' default_prompt: "Use Regression Docs for regression metadata coverage."',
'',
'dependencies:',
' tools:',
' - type: "mcp"',
' value: "openaiDeveloperDocs"',
' description: "Regression docs MCP server"',
' transport: "streamable_http"',
' url: "https://developers.openai.com/mcp"',
].join('\n'));
const codexPromptsDir = path.join(homeDir, '.codex', 'prompts');
mkdirp(path.join(codexPromptsDir, 'nested-tool'));
fs.writeFileSync(path.join(codexPromptsDir, 'quick-note.md'), [
'---',
'title: Quick Note',
'description: Prompt file shortcut from ~/.codex/prompts.',
'---',
'',
'Prompt body from @quick-note.',
].join('\n'));
fs.writeFileSync(path.join(codexPromptsDir, 'nested-tool', 'prompt.md'), [
'---',
'description: Directory-style prompt shortcut.',
'---',
'',
'Prompt body from @nested-tool.',
].join('\n'));
fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({
prompts: [
{
name: 'shipit',
title: 'Ship It',
description: 'Regression prompt template.',
content: 'Regression prompt body from @shipit.',
},
],
}, null, 2));
createFakeClaudeHistory(homeDir);
createFakeCodexConfig(homeDir, { reasoningEffort: 'ultra' });
const codexFixture = createFakeCodexHistory(homeDir);
const codexAppImportFixture = createFakeCodexHistory(homeDir, {
threadId: 'codexapp-import-thread',
cwd: '/tmp/project-c',
userText: 'Codex App import prompt',
answerText: 'Codex App import answer',
source: 'vscode',
fileStamp: '2026-03-12T00-00-10',
});
const duplicateSourceConversationId = '11111111-1111-4111-8111-111111111111';
const duplicateSourceConversationTitle = '你能看下 00a7cbc2-d0c3-457f-a262-aa5a5859fa54 这个对话么, 你来评估下,这个对话中';
createFakeCodexHistory(homeDir, {
threadId: 'codexapp-duplicate-thread-a',
cwd: '/tmp/project-c',
userText: `来自「${duplicateSourceConversationTitle}」对话ID: ${duplicateSourceConversationId})的消息:\n\n旧候选`,
answerText: 'duplicate import answer a',
source: 'vscode',
fileStamp: '2026-03-12T00-00-20',
});
createFakeCodexHistory(homeDir, {
threadId: 'codexapp-duplicate-thread-b',
cwd: '/tmp/project-c',
userText: `来自「${duplicateSourceConversationTitle}」对话ID: ${duplicateSourceConversationId})的消息:\n\n新候选`,
answerText: 'duplicate import answer b',
source: 'vscode',
fileStamp: '2026-03-12T00-00-21',
});
const codexAppObjectSourceFixture = createFakeCodexHistory(homeDir, {
threadId: 'codexapp-object-source-thread',
cwd: '/tmp/project-c',
userText: 'Object source import prompt',
answerText: 'Object source import answer',
source: { subagent: { thread_spawn: { parent_thread_id: 'parent-thread', depth: 1 } } },
fileStamp: '2026-03-12T00-00-22',
});
const port = await getFreePort();
const password = 'Regression!234';
const internalMcpToken = 'RegressionMcp!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS: '100',
}, async () => {
await assertWsUpgradeRejected(port, '/not-ws');
const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
ws.send(JSON.stringify({ type: 'load_session', sessionId: 'missing-session', requestId: 'reg-missing-session' }));
const missingSessionLoad = await nextMessage(messages, ws, (msg) => (
msg.type === 'error' &&
msg.code === 'session_not_found' &&
msg.sessionId === 'missing-session'
));
assert(missingSessionLoad.requestId === 'reg-missing-session', 'Missing load_session error should echo requestId for frontend cleanup');
const pickerRoot = path.join(homeDir, 'picker-root');
mkdirp(path.join(pickerRoot, 'alpha'));
mkdirp(path.join(pickerRoot, 'beta'));
fs.writeFileSync(path.join(pickerRoot, 'note.txt'), 'not a directory');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'plan', requestId: 'reg-new-default' }));
const defaultCodexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat');
assert(defaultCodexSession.requestId === 'reg-new-default', 'new_session session_info should echo requestId');
assert(defaultCodexSession.cwd === homeDir, 'Codex new_session without cwd should default to HOME');
const missingCwd = path.join(tempRoot, 'missing-space', 'nested-project');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: missingCwd, mode: 'plan' }));
const missingCwdError = await nextMessage(messages, ws, (msg) => msg.type === 'error' && msg.code === 'new_session_cwd_missing');
assert(missingCwdError.cwd === missingCwd, 'Missing cwd error should return the requested absolute path');
assert(!fs.existsSync(missingCwd), 'Missing cwd should not be created before explicit confirmation');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: missingCwd, mode: 'plan', createCwd: true }));
const createdCwdSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === missingCwd);
assert(createdCwdSession.cwd === missingCwd, 'Codex new_session should allow creating a missing cwd');
assert(fs.existsSync(missingCwd), 'Missing cwd should be created when createCwd is enabled');
const directoryPayload = await fetchAuthedJson(port, token, `/api/fs/directories?path=${encodeURIComponent(pickerRoot)}`);
assert(directoryPayload.currentPath === pickerRoot, 'Directory picker should return requested absolute path');
assert(directoryPayload.defaultPath === homeDir, 'Directory picker should expose HOME as default path');
assert(directoryPayload.entries.some((entry) => entry.name === 'alpha'), 'Directory picker should list child directories');
assert(directoryPayload.entries.some((entry) => entry.name === 'beta'), 'Directory picker should include all child directories');
assert(!directoryPayload.entries.some((entry) => entry.name === 'note.txt'), 'Directory picker should hide files');
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
mode: 'custom',
activeProfile: 'Regression Profile',
profiles: [{ name: 'Regression Profile', apiKey: 'sk-regression', apiBase: 'https://example.com/v1' }],
enableSearch: true,
retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 },
},
}));
const codexConfigMsg = await nextMessage(messages, ws, (msg) => msg.type === 'codex_config');
assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
assert(codexConfigMsg.config.retry?.mode === 'limited', 'Codex retry mode should round-trip');
assert(codexConfigMsg.config.retry?.intervalSeconds === 1, 'Codex retry interval should round-trip');
assert(codexConfigMsg.config.retry?.maxAttempts === 2, 'Codex retry max attempts should round-trip');
const codexInitCwd = path.join(tempRoot, 'codex-space');
mkdirp(codexInitCwd);
const projectSkillDir = path.join(codexInitCwd, '.agents', 'skills', 'project-skill');
mkdirp(projectSkillDir);
fs.writeFileSync(path.join(projectSkillDir, 'SKILL.md'), [
'---',
'name: project-skill',
'description: Project-scoped skill for composer suggestions.',
'---',
'',
'# Project Skill',
'',
'Use this only in regression tests.',
].join('\n'));
const projectCodexConfigDir = path.join(codexInitCwd, '.codex');
mkdirp(projectCodexConfigDir);
fs.writeFileSync(path.join(projectCodexConfigDir, 'config.toml'), [
'[mcp_servers.reg-project]',
'type = "stdio"',
`command = ${JSON.stringify(process.execPath)}`,
'args = ["regression-mcp.js"]',
'enabled = true',
'',
'[mcp_servers.reg-disabled]',
'type = "stdio"',
`command = ${JSON.stringify(process.execPath)}`,
'enabled = false',
'',
'[mcp_servers.reg-missing]',
'type = "stdio"',
'command = "definitely-missing-mcp-command"',
].join('\n'));
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd);
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
assert(codexSession.model === 'gpt-5.5(ultra)', 'Codex new_session should preserve ultra from ~/.codex/config.toml');
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: true }));
const pinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId);
assert(pinnedAck.pinnedAt, 'Pinning a session should return pinnedAt');
const pinnedList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && s.pinnedAt));
assert(pinnedList.sessions[0].id === codexSession.sessionId, 'Pinned session should sort before regular sessions');
let storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedPinnedSession.pinnedAt === pinnedAck.pinnedAt, 'Pinned state should persist to session JSON');
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: false }));
const unpinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId && !msg.pinnedAt);
assert(unpinnedAck.pinnedAt === null, 'Unpinning a session should clear pinnedAt');
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && !s.pinnedAt));
storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedPinnedSession.pinnedAt === null, 'Unpinned state should persist to session JSON');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash', trigger: '/', query: 'mo', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash');
assert(slashComposer.items.some((item) => item.kind === 'command' && item.name === '/model'), 'Composer slash suggestions should include /model');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_set_title'), 'Composer slash suggestions should include ccweb_set_title MCP tool');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config');
assert(slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-project'), 'Composer slash suggestions should include available project MCP servers from session cwd');
assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-config'), 'Composer slash suggestions should not include MCP servers from unrelated global Codex config');
assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-disabled'), 'Composer slash suggestions should not include disabled project MCP servers');
assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-missing'), 'Composer slash suggestions should not include project MCP servers with unavailable commands');
const storedComposerFixture = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
storedComposerFixture.messages.push({
role: 'assistant',
content: 'Runtime tools include mcp__regRuntime__inspect_schema and mcp:reg-state/query.',
timestamp: new Date().toISOString(),
});
fs.writeFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), JSON.stringify(storedComposerFixture, null, 2));
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-runtime', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashMcpRuntimeComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-runtime');
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');
const unknownSlashAttachment = await uploadAttachment(port, token, {
filename: 'unknown-slash.png',
mime: 'image/png',
data: Buffer.from('unknown-slash-image'),
});
ws.send(JSON.stringify({
type: 'message',
text: '/report/mcps?search',
attachments: [unknownSlashAttachment],
sessionId: codexSession.sessionId,
mode: 'plan',
agent: 'codex',
requestId: 'reg-unknown-slash-draft',
}));
const unknownSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-unknown-slash-draft');
assert(unknownSlashDraft.sessionId === codexSession.sessionId, 'Unknown slash draft response should stay scoped to the active session');
assert(!unknownSlashDraft.preserveComposerDraft, 'Unknown slash hints should not restore text that is continuing through ordinary send');
assert(/未知指令: \/report\/mcps\?search/.test(unknownSlashDraft.message || ''), 'Unknown slash text should still show the normal hint');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
const storedUnknownSlashSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedUnknownSlashMessage = storedUnknownSlashSession.messages.find((message) => message.role === 'user' && message.content === '/report/mcps?search');
assert(storedUnknownSlashMessage, 'Unknown slash text should continue through the ordinary message pipeline');
assert(storedUnknownSlashMessage.attachments?.some((attachment) => attachment.filename === unknownSlashAttachment.filename), 'Unknown slash text should preserve ordinary message attachments');
ws.send(JSON.stringify({ type: 'message', text: '/help', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-help-slash-draft' }));
const helpSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-help-slash-draft');
assert(!helpSlashDraft.preserveComposerDraft, 'Successful slash command responses should clear pending drafts without restoring input');
assert(/可用指令/.test(helpSlashDraft.message || ''), 'Slash /help should keep the normal help output');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
const metadataSkill = skillComposer.items.find((item) => item.kind === 'skill' && item.name === 'regression-skill');
assert(metadataSkill?.title === 'Regression Docs', 'Composer skill suggestions should expose openai.yaml display_name');
assert(/metadata coverage/.test(metadataSkill?.defaultPromptPreview || ''), 'Composer skill suggestions should expose default prompt preview');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-project-skill', trigger: '$', query: 'project', sessionId: codexSession.sessionId, agent: 'codex' }));
const projectSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-project-skill');
assert(projectSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'project-skill'), 'Composer skill suggestions should include project-scoped skill from session cwd');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp');
assert(!skillMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not include MCP tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-declared-mcp', trigger: '$', query: 'openai', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillDeclaredMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-declared-mcp');
assert(!skillDeclaredMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not list declared MCP dependencies from openai.yaml as available tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt');
assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-file', trigger: '@', query: 'quick', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptFileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-file');
assert(promptFileComposer.items.some((item) => item.kind === 'prompt' && item.name === 'quick-note'), 'Composer prompt suggestions should include ~/.codex/prompts/*.md shortcuts');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-dir', trigger: '@', query: 'nested', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptDirComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-dir');
assert(promptDirComposer.items.some((item) => item.kind === 'prompt' && item.name === 'nested-tool'), 'Composer prompt suggestions should include ~/.codex/prompts/<name>/prompt.md shortcuts');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-at-no-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const atNoMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-at-no-mcp');
assert(!atNoMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer @ suggestions should not include MCP tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-file', trigger: '@', query: 'context', sessionId: codexSession.sessionId, agent: 'codex' }));
const fileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file');
assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file');
assert(!fileComposer.items.some((item) => item.kind === 'mcp'), 'Composer file suggestions should not include MCP tools');
ws.send(JSON.stringify({
type: 'message',
text: '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression',
sessionId: codexSession.sessionId,
mode: 'plan',
agent: 'codex',
}));
const composerExpanded = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
/BEGIN CC-WEB PROMPT: quick-note/.test(msg.text || '') &&
/Composer file context body/.test(msg.text || '')
));
assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content');
assert(/Prompt body from @quick-note/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand ~/.codex/prompts prompt shortcuts');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression');
assert(storedComposerMessage, 'Composer message should persist original user text');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'quick-note'), 'Composer message should persist ~/.codex/prompts prompt mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'project-skill'), 'Composer message should persist project-scoped skill mention metadata');
const storedRegressionSkillMention = storedComposerMessage.composerMentions?.find((mention) => mention.kind === 'skill' && mention.name === 'regression-skill');
assert(storedRegressionSkillMention?.title === 'Regression Docs', 'Stored skill mention should persist display title from openai.yaml');
assert(storedRegressionSkillMention?.dependencies?.some((dep) => dep.value === 'openaiDeveloperDocs' && dep.state === 'declared'), 'Stored skill mention should persist MCP dependency metadata');
const mcpList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { agent: 'codex', limit: 20 },
});
assert(mcpList.status === 200 && mcpList.body?.ok, 'MCP conversation list should succeed');
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
const codexTitleSessionPath = path.join(sessionsDir, `${codexSession.sessionId}.json`);
const storedBeforeMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
const mcpSetTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
sourceSessionId: codexSession.sessionId,
args: { title: 'Concise MCP Title' },
});
assert(mcpSetTitle.status === 200 && mcpSetTitle.body?.ok, `MCP set title should succeed: ${JSON.stringify(mcpSetTitle.body)}`);
assert(mcpSetTitle.body.changed === true && mcpSetTitle.body.ignored === false, 'MCP set title should report a real title change');
assert(mcpSetTitle.body.previousTitle === storedBeforeMcpTitle.title, 'MCP set title should return previousTitle');
assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename');
assert(mcpSetTitle.body.titleEvent?.title === 'Concise MCP Title', 'MCP set title should return the persisted title event');
assert(mcpSetTitle.body.titleEvent?.messageIndex === storedBeforeMcpTitle.messages.length, 'Title event should anchor after the messages persisted before the rename');
const expectedTitleAnchorMessageIndex = (() => {
for (let index = storedBeforeMcpTitle.messages.length - 1; index >= 0; index -= 1) {
if (storedBeforeMcpTitle.messages[index]?.role === 'user') return index;
}
return null;
})();
assert(expectedTitleAnchorMessageIndex !== null, 'Title regression fixture should contain a triggering user message');
assert(mcpSetTitle.body.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'MCP set title should return the triggering user message anchor');
const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_renamed' &&
msg.sessionId === codexSession.sessionId &&
msg.title === 'Concise MCP Title'
));
assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers');
assert(mcpTitleRenamed.titleEvent?.title === 'Concise MCP Title', 'MCP set title should push the title event to current viewers');
assert(mcpTitleRenamed.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Live session_renamed should preserve the title trigger anchor');
const mcpTitleList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions.some((session) => (
session.id === codexSession.sessionId &&
session.title === 'Concise MCP Title' &&
session.titleSource === 'llm'
))
));
assert(mcpTitleList.sessions.some((session) => session.id === codexSession.sessionId && session.updated === storedBeforeMcpTitle.updated), 'MCP set title should not bump session updated sorting timestamp');
const storedAfterMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
assert(storedAfterMcpTitle.title === 'Concise MCP Title', 'MCP set title should persist title');
assert(storedAfterMcpTitle.titleSource === 'llm', 'MCP set title should persist llm titleSource');
assert(storedAfterMcpTitle.updated === storedBeforeMcpTitle.updated, 'MCP set title should not modify updated timestamp');
assert(storedAfterMcpTitle.titleHistory?.length === 1, 'MCP set title should append exactly one title history event');
assert(storedAfterMcpTitle.titleHistory[0].source === 'llm' && storedAfterMcpTitle.titleHistory[0].title === 'Concise MCP Title', 'Persisted title history should identify the LLM title change');
assert(!Number.isNaN(Date.parse(storedAfterMcpTitle.titleHistory[0].changedAt)), 'Persisted title history should include a valid change timestamp');
assert(storedAfterMcpTitle.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Persisted title history should retain the triggering user message anchor');
const unchangedMcpTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
sourceSessionId: codexSession.sessionId,
args: { title: 'Concise MCP Title' },
});
assert(unchangedMcpTitle.status === 200 && unchangedMcpTitle.body?.changed === false, 'Repeated MCP title should report unchanged');
assert(!unchangedMcpTitle.body.titleEvent, 'Repeated MCP title should not create a fake title event');
const storedAfterUnchangedMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
assert(storedAfterUnchangedMcpTitle.titleHistory?.length === 1, 'Repeated MCP title should not append title history');
ws.send(JSON.stringify({ type: 'load_session', sessionId: codexSession.sessionId, requestId: 'reg-title-history-load' }));
const titleHistorySessionInfo = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' &&
msg.sessionId === codexSession.sessionId &&
msg.requestId === 'reg-title-history-load'
));
assert(titleHistorySessionInfo.titleHistory?.length === 1, 'session_info should restore persisted title history');
assert(titleHistorySessionInfo.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Reloaded session_info should retain the title trigger anchor');
const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
sourceSessionId: codexSession.sessionId,
args: { title: ' ' },
});
assert(mcpEmptyTitle.status === 400 && mcpEmptyTitle.body?.code === 'invalid_title', 'MCP set title should reject empty titles');
ws.send(JSON.stringify({ type: 'rename_session', sessionId: codexSession.sessionId, title: 'Manual Locked Title' }));
const manualTitleRenamed = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_renamed' &&
msg.sessionId === codexSession.sessionId &&
msg.title === 'Manual Locked Title'
));
assert(manualTitleRenamed.titleSource === 'manual', 'Manual rename should push manual titleSource');
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions.some((session) => (
session.id === codexSession.sessionId &&
session.title === 'Manual Locked Title' &&
session.titleSource === 'manual'
))
));
const mcpLockedTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
sourceSessionId: codexSession.sessionId,
args: { title: 'Ignored LLM Title' },
});
assert(mcpLockedTitle.status === 200 && mcpLockedTitle.body?.ok, 'MCP set title should return ok when manually locked');
assert(mcpLockedTitle.body.changed === false && mcpLockedTitle.body.ignored === true, 'MCP set title should report ignored under manual lock');
assert(mcpLockedTitle.body.reason === 'ignored because title is manually locked', 'MCP set title should explain manual lock ignores');
assert(mcpLockedTitle.body.title === 'Manual Locked Title' && mcpLockedTitle.body.lockedByUser === true, 'MCP set title should preserve manual title under lock');
const storedAfterManualLock = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
assert(storedAfterManualLock.title === 'Manual Locked Title', 'Manual locked title should remain persisted after ignored MCP set title');
assert(storedAfterManualLock.titleSource === 'manual', 'Manual locked titleSource should remain manual after ignored MCP set title');
assert(storedAfterManualLock.titleHistory?.length === 1, 'Ignored MCP title should not append a fake title history event');
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
args: { cwd: 'relative-project', title: 'Relative path should fail' },
});
assert(mcpRelativeCreate.status === 400 && mcpRelativeCreate.body?.code === 'create_conversation_cwd_relative', 'MCP create conversation should reject relative cwd');
const mcpCreate = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
agent: 'claude',
title: 'MCP Created Conversation',
initialMessage: 'mcp created initial prompt',
},
});
assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`);
assert(mcpCreate.body.agent === 'codex', 'MCP create conversation should ignore agent args and inherit the source agent');
assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default');
assert(mcpCreate.body.mode === 'yolo', 'MCP create conversation should default to yolo when mode is omitted');
assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation');
assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
assert(storedMcpCreated.titleSource === 'system', 'MCP created conversation title should not be treated as user manual title');
assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent');
assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode');
assert(storedMcpCreated.createdFrom?.kind === 'mcp', 'MCP created conversation should persist mcp creation kind');
assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata');
assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message');
assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt');
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions.some((session) => (
session.id === mcpCreate.body.conversationId &&
session.createdFromKind === 'mcp'
))
));
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
mkdirp(mcpReplyCreateCwd);
const mcpCreateReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
agent: 'claude',
cwd: mcpReplyCreateCwd,
title: 'MCP Reply Conversation',
initialMessage: 'mcp create request reply',
requestReply: true,
},
});
assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`);
assert(mcpCreateReply.body.agent === 'codex', 'MCP create requestReply should inherit source agent even if args.agent is passed');
assert(mcpCreateReply.body.mode === 'yolo', 'MCP create requestReply should default to yolo when mode is omitted');
assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd');
assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id');
assert(mcpCreateReply.body.replyDelivery === 'auto_run' && mcpCreateReply.body.sourceAutoRun === true, 'MCP create requestReply should declare source auto-run delivery');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId &&
message.crossConversation?.processed === true
))
));
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8'));
const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation');
const storedMcpCreateReplyIndex = storedMcpCreateSource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId);
assert(storedMcpCreateReplyIndex >= 0, 'MCP create requestReply should send a processed display-only reply back to source');
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.processed === true, 'MCP create requestReply should mark the returned message processed');
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.autoRun === true, 'MCP create requestReply should mark the returned message as auto-run');
assert(storedMcpCreateSource.messages.slice(storedMcpCreateReplyIndex + 1).some((message) => (
message.role === 'assistant' &&
/mcp create request reply/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'MCP create requestReply should continue the source session after the child reply');
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
mkdirp(crossTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' }));
const crossTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTargetCwd);
const crossSend = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'cross hello from mcp',
},
});
assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`);
const crossUserBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
msg.message?.crossConversation?.sourceSessionId === codexSession.sessionId &&
msg.message?.content === 'cross hello from mcp'
));
assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8'));
const storedCrossSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
assert(storedCrossMessage.crossConversation.sourceTitle === storedCrossSource.title, 'Cross message should persist source title');
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
const hopAllowed = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 1,
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'cross hop still allowed',
},
});
assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`);
const hopAllowedBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
msg.message?.crossConversation?.messageId === hopAllowed.body.messageId &&
msg.message?.content === 'cross hop still allowed'
));
assert(hopAllowedBubble.message.crossConversation.hopCount === 2, 'Cross message should keep incrementing hop count without blocking');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
const crossReplyTargetCwd = path.join(tempRoot, 'codex-mcp-cross-reply-target');
mkdirp(crossReplyTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossReplyTargetCwd, mode: 'yolo' }));
const crossReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossReplyTargetCwd);
const requestReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_request_reply',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossReplyTargetSession.sessionId,
content: 'cross reply requested',
},
});
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP request reply should declare source auto-run delivery');
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossReplyTargetSession.sessionId &&
msg.message?.crossConversation?.replyRequestId === requestReply.body.requestId &&
msg.message?.crossConversation?.expectsReply === true &&
msg.message?.content === 'cross reply requested'
));
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === requestReply.body.requestId &&
message.crossConversation?.processed === true
))
));
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedReplyRequestMessage = storedReplyTarget.messages.find((message) => message.crossConversation?.replyRequestId === requestReply.body.requestId);
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'Request reply target message should persist waiting metadata');
assert(storedReplyTarget.messages.some((message) => message.role === 'assistant' && /cross reply requested/.test(String(message.content || ''))), 'Request reply target should produce an assistant reply');
const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId);
assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session');
const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex];
assert(storedReplyMessage.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content');
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker');
assert(storedReplyMessage.crossConversation.autoRun === true, 'Returned cross message should mark source auto-run');
assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only');
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
message.role === 'assistant' &&
/Codex mock handled/.test(String(message.content || '')) &&
/cross reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Returned cross message should trigger the source session to run again');
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
mkdirp(busySourceCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busySourceCwd, mode: 'yolo' }));
const busySourceSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busySourceCwd);
ws.send(JSON.stringify({ type: 'message', text: 'very slow cross-session prompt', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === busySourceSession.sessionId && s.isRunning));
const busyReplyTargetCwd = path.join(tempRoot, 'codex-mcp-busy-reply-target');
mkdirp(busyReplyTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busyReplyTargetCwd, mode: 'yolo' }));
const busyReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busyReplyTargetCwd);
const busyRequestReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_request_reply',
sourceSessionId: busySourceSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: busyReplyTargetSession.sessionId,
content: 'busy source reply requested',
},
});
assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`);
assert(busyRequestReply.body.requestId && busyRequestReply.body.status === 'waiting', 'Busy source request reply should return a waiting request id');
assert(busyRequestReply.body.replyDelivery === 'auto_run' && busyRequestReply.body.sourceAutoRun === true, 'Busy source request reply should declare source auto-run delivery');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busyReplyTargetSession.sessionId);
await waitForJsonCondition(path.join(configDir, 'cross-conversation-replies.json'), (state) => (
Array.isArray(state.replies) &&
state.replies.some((reply) => (
reply.requestId === busyRequestReply.body.requestId &&
reply.sourceConversationId === busySourceSession.sessionId &&
reply.status === 'ready' &&
/busy source reply requested/.test(String(reply.replyText || ''))
))
));
let storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(!storedBusySource.messages.some((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId), 'Busy source should not receive display-only reply while it is still running');
const busyPendingList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_pending_replies',
sourceSessionId: busySourceSession.sessionId,
args: { status: 'ready' },
});
assert(busyPendingList.status === 200 && busyPendingList.body?.ok, `MCP pending reply list should succeed: ${JSON.stringify(busyPendingList.body)}`);
assert(busyPendingList.body.waitingOnChildren === true, 'Pending reply list should report waitingOnChildren while ready reply is queued');
assert(busyPendingList.body.readyReplyCount === 1, 'Pending reply list should count ready replies');
assert(busyPendingList.body.replies.some((reply) => reply.requestId === busyRequestReply.body.requestId && reply.status === 'ready'), 'Pending reply list should include the queued ready reply');
const busyPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
sourceSessionId: busySourceSession.sessionId,
args: { requestId: busyRequestReply.body.requestId },
});
assert(busyPendingDetail.status === 200 && busyPendingDetail.body?.ok, `MCP pending reply detail should succeed: ${JSON.stringify(busyPendingDetail.body)}`);
assert(busyPendingDetail.body.status === 'ready', 'Pending reply detail should expose ready status');
assert(/busy source reply requested/.test(String(busyPendingDetail.body.replyText || '')), 'Pending reply detail should expose target assistant output');
const busyConversationList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: busySourceSession.sessionId,
args: { limit: 50 },
});
assert(busyConversationList.status === 200 && busyConversationList.body?.ok, `MCP conversation list with waiting state should succeed: ${JSON.stringify(busyConversationList.body)}`);
assert(busyConversationList.body.waitingOnChildren === true && busyConversationList.body.readyReplyCount === 1, 'MCP list should expose source waiting state');
const busySourceSummary = busyConversationList.body.conversations.find((item) => item.id === busySourceSession.sessionId);
assert(busySourceSummary?.status === 'running', 'MCP list should still mark the busy source as running before it completes');
assert(busySourceSummary?.waitingOnChildren === true && busySourceSummary?.readyReplyCount === 1, 'MCP list should expose queued child replies on the source conversation');
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
await waitForJsonCondition(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId &&
message.crossConversation?.processed === true &&
message.ccwebDisplayOnly === true
))
));
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
const busyReplyIndex = storedBusySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId);
assert(busyReplyIndex > 0, 'Busy source should receive queued display-only reply after its run completes');
assert(storedBusySource.messages[busyReplyIndex - 1]?.role === 'assistant' && /very slow cross-session prompt/.test(String(storedBusySource.messages[busyReplyIndex - 1].content || '')), 'Queued reply should be appended after the source run assistant message');
assert(storedBusySource.messages[busyReplyIndex].crossConversation?.autoRun === true, 'Queued reply should mark source auto-run');
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(storedBusySource.messages.slice(busyReplyIndex + 1).some((message) => (
message.role === 'assistant' &&
/busy source reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Busy source should auto-run after the queued child reply is flushed');
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
sourceSessionId: busySourceSession.sessionId,
args: { requestId: busyRequestReply.body.requestId },
});
assert(returnedPendingDetail.status === 200 && returnedPendingDetail.body?.ok, 'Returned pending reply detail should remain queryable from source history');
assert(returnedPendingDetail.body.status === 'returned' && returnedPendingDetail.body.returned === true, 'Returned pending reply detail should report returned status');
ws.send(JSON.stringify({ type: 'load_session', sessionId: busySourceSession.sessionId, requestId: 'reg-load-busy-source' }));
const loadedBusySource = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === busySourceSession.sessionId);
assert(loadedBusySource.requestId === 'reg-load-busy-source', 'load_session session_info should echo requestId');
assert(loadedBusySource.isRunning === false, 'Busy source should be idle after background run completed');
assert(loadedBusySource.waitingOnChildren === false && loadedBusySource.pendingReplyCount === 0, 'Busy source should clear waiting state after queued reply is flushed');
ws.send(JSON.stringify({ type: 'message', text: 'source remains usable after queued child reply', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busySourceSession.sessionId);
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(storedBusySource.messages.some((message) => message.role === 'user' && message.content === 'source remains usable after queued child reply'), 'Source conversation should accept normal user messages after queued child reply is flushed');
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const mcpSpawnLine = processLogAfterMcp
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8)));
assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config');
assert(mcpSpawnLine.includes('server.js') && mcpSpawnLine.includes('--ccweb-mcp-server'), 'Codex spawn should launch ccweb MCP through server.js in Node mode');
assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token');
const projectMcpSpawnLine = processLogAfterMcp
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(codexSession.sessionId.slice(0, 8)));
assert(projectMcpSpawnLine && projectMcpSpawnLine.includes('mcp_servers.reg-project.command'), 'Codex spawn should inject project MCP config from session cwd');
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions');
assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path');
assert(Array.isArray(cwdSuggestions.paths) && cwdSuggestions.paths.includes(codexInitCwd), 'CWD suggestions should include recently used session directories');
const crossTalkCwd = path.join(tempRoot, 'codex-cross-talk');
mkdirp(crossTalkCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTalkCwd, mode: 'yolo' }));
const crossTalkSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTalkCwd);
ws.send(JSON.stringify({ type: 'message', text: 'slow cross-session prompt', sessionId: crossTalkSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === crossTalkSession.sessionId && s.isRunning));
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'yolo' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat' && msg.sessionId !== crossTalkSession.sessionId);
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === crossTalkSession.sessionId, 8000);
const leakedCrossTalk = messages.find((msg) => (
['text_delta', 'content_blocks', 'tool_start', 'tool_update', 'tool_end', 'usage', 'cost', 'done', 'system_message', 'error'].includes(msg.type) &&
msg.sessionId === crossTalkSession.sessionId
));
assert(!leakedCrossTalk, `Running session leaked stream event into new session: ${leakedCrossTalk ? JSON.stringify(leakedCrossTalk) : ''}`);
ws.send(JSON.stringify({ type: 'message', text: '/init', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
const codexInitStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /AGENTS\.md/.test(msg.message || ''));
assert(/AGENTS\.md/.test(codexInitStart.message || ''), 'Codex /init should announce AGENTS.md generation');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
assert(fs.existsSync(path.join(codexInitCwd, 'AGENTS.md')), 'Codex /init should generate AGENTS.md in the workspace');
ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex');
assert(codexModelChanged.model === 'gpt-5.3-codex', 'Codex /model should accept arbitrary Codex model names');
const codexAttachment = await uploadAttachment(port, token, {
filename: 'codex-test.png',
mime: 'image/png',
data: Buffer.from('codex-image'),
});
ws.send(JSON.stringify({ type: 'message', text: 'first codex prompt', attachments: [codexAttachment], mode: 'yolo', agent: 'codex' }));
const firstMessageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'first codex prompt');
assert(firstMessageSession.agent === 'codex', 'First-message path created wrong agent');
const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning));
assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
// Switching permission mode must not clear Codex thread id (otherwise resume loses context).
const codexSessionPath = path.join(sessionsDir, `${firstMessageSession.sessionId}.json`);
await waitForFile(codexSessionPath, 15000);
const storedAfterFirst = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
const threadIdBeforeMode = storedAfterFirst.codexThreadId;
assert(threadIdBeforeMode, 'Codex thread id should be persisted after first run');
ws.send(JSON.stringify({ type: 'set_mode', sessionId: firstMessageSession.sessionId, mode: 'plan' }));
await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan');
await waitForFile(codexSessionPath, 15000);
const storedAfterMode = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
assert(storedAfterMode.codexThreadId === threadIdBeforeMode, 'Codex thread id should survive mode switch');
ws.send(JSON.stringify({ type: 'message', text: 'second codex prompt', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const spawnLine = processLog
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag');
const parsedSpawnLine = JSON.parse(spawnLine);
assert(parsedSpawnLine.args.includes('model_reasoning_effort="ultra"'), 'Codex exec should pass the ultra reasoning level through model_reasoning_effort');
const allSpawnsForSession = processLog
.trim()
.split('\n')
.filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
const lastSpawn = allSpawnsForSession[allSpawnsForSession.length - 1] || '';
assert(lastSpawn.includes('resume') && lastSpawn.includes(threadIdBeforeMode), 'Codex mode switch should keep resume thread id');
assert(lastSpawn.includes('-s read-only'), 'Codex plan mode should set sandbox read-only');
assert(lastSpawn.includes('-s read-only resume'), 'Codex resume in plan mode must place -s before resume subcommand');
ws.send(JSON.stringify({ type: 'message', text: 'trigger codex capacity retry', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' }));
const capacityRetryNotice = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /自动重试/.test(msg.message || '') && msg.sessionId === firstMessageSession.sessionId, 10000);
assert(/Codex 服务暂时繁忙/.test(capacityRetryNotice.message || ''), 'Codex transient capacity failure should announce automatic retry');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId, 20000);
const storedAfterCapacityRetry = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
const capacityRetryUsers = storedAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === 'trigger codex capacity retry');
assert(capacityRetryUsers.length === 1, 'Codex transient retry should not duplicate the user message');
assert(storedAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /trigger codex capacity retry/.test(String(message.content || ''))), 'Codex transient retry should persist the successful assistant response');
const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8');
assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: firstMessageSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在执行/.test(msg.message || '') && /Codex \/compact/.test(msg.message || ''));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
const compactDoneMsg = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
assert(/已执行 Codex \/compact/.test(compactDoneMsg.message || ''), 'Codex /compact should complete with Codex-specific status message');
const autoCompactCwd = path.join(tempRoot, 'codex-auto-compact');
mkdirp(autoCompactCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: autoCompactCwd, mode: 'yolo' }));
const autoCompactSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === autoCompactCwd);
ws.send(JSON.stringify({ type: 'message', text: 'warm up auto compact', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'trigger codex context limit', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
const autoCompactStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在按 Codex \/compact 自动压缩/.test(msg.message || ''));
assert(/Codex \/compact/.test(autoCompactStart.message || ''), 'Codex auto /compact should announce auto compact start');
const autoCompactDone = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
assert(/已执行 Codex \/compact/.test(autoCompactDone.message || ''), 'Codex auto /compact should finish compact step');
const autoCompactResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /按 Codex 压缩计划继续执行/.test(msg.message || ''));
assert(/继续执行/.test(autoCompactResume.message || ''), 'Codex auto /compact should announce retry');
// Some Codex builds won't echo the original prompt text as a text delta on retry; accept either.
const autoCompactRetry = await nextMessage(messages, ws, (msg) => (
(msg.type === 'text_delta' && /trigger codex context limit/.test(msg.text || '')) ||
(msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId)
), 20000);
if (autoCompactRetry.type === 'text_delta') {
assert(/trigger codex context limit/.test(autoCompactRetry.text || ''), 'Codex auto /compact should replay the failed prompt after compact');
}
const codexAppCwd = path.join(tempRoot, 'codexapp-space');
mkdirp(codexAppCwd);
const codexAppProjectConfigDir = path.join(codexAppCwd, '.codex');
mkdirp(codexAppProjectConfigDir);
fs.writeFileSync(path.join(codexAppProjectConfigDir, 'config.toml'), [
'[mcp_servers.reg-app-project]',
'type = "stdio"',
`command = ${JSON.stringify(process.execPath)}`,
'args = ["regression-app-mcp.js"]',
'enabled = true',
].join('\n'));
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' }));
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
assert(codexAppSession.model === 'gpt-5.5(ultra)', 'Codex App new_session should preserve the ultra default Codex model');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill');
assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-goal-slash', trigger: '/', query: 'go', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppGoalSlashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-goal-slash');
assert(codexAppGoalSlashComposer.items.some((item) => item.kind === 'command' && item.name === '/goal'), 'Codex App composer slash suggestions should include /goal');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration default probe', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppDefaultCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
assert(/"mode":"default"/.test(codexAppDefaultCollab.text || ''), 'Codex App YOLO mode should pass default collaboration mode');
assert(/"hasModel":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include model');
assert(/"hasDeveloperInstructions":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include sub-agent developer instructions');
assert(/"hasSchemaDrivenSubagents":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should use runtime-schema-driven sub-agent guidance');
assert(/"hasLegacyV1Guidance":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should omit legacy V1 fork/wait guidance');
assert(/"reasoningEffort":"ultra"/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should pass ultra reasoning_effort');
assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level');
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
mode: 'custom',
activeProfile: 'Regression Profile Updated',
profiles: [{ name: 'Regression Profile Updated', apiKey: 'sk-regression-updated', apiBase: 'https://updated.example.com/v1' }],
enableSearch: false,
retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 },
},
}));
const codexAppChangedConfig = await nextMessage(messages, ws, (msg) =>
msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated'
);
assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp after config change prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppAfterConfigChange = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/codexapp after config change prompt/.test(msg.text || '')
));
assert(/codexapp after config change prompt/.test(codexAppAfterConfigChange.text || ''), 'Codex App should not reject a new turn after config signature changes');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const codexAppRetryText = 'codexapp capacity retry prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient capacity failure should announce automatic retry');
assert(/第 1\/2 次/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient retry should start at attempt 1');
assert(/从中断处继续/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App retry after a started turn should announce continuation mode');
const codexAppPartialCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/第 2\/2 次/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App transient retry should continue after partial output');
assert(/从中断处继续/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App partial-output retry should stay in continuation mode');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterCapacityRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppCapacityRetryUsers = storedCodexAppAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryText);
assert(codexAppCapacityRetryUsers.length === 1, 'Codex App transient retry should not duplicate the user message');
assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /codexapp capacity retry prompt/.test(String(message.content || ''))), 'Codex App transient retry should persist the successful assistant response');
assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App transient retry should ask the model to continue instead of replaying the original prompt');
const codexAppReconnectRetryText = 'codexapp reconnect retry prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppReconnectRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppReconnectRetryNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect failure should announce automatic retry');
assert(/第 1\/2 次/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App retry counter should reset after the previous retry succeeds');
assert(/从中断处继续/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect retry after a started turn should announce continuation mode');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterReconnectRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppReconnectRetryUsers = storedCodexAppAfterReconnectRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppReconnectRetryText);
assert(codexAppReconnectRetryUsers.length === 1, 'Codex App reconnect retry should not duplicate the user message');
assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /codexapp reconnect retry prompt/.test(String(message.content || ''))), 'Codex App reconnect retry should persist the successful assistant response');
assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App reconnect retry should continue the interrupted turn instead of replaying the original prompt');
const codexAppThreadBeforeMismatch = storedCodexAppAfterReconnectRetry.codexAppThreadId;
assert(codexAppThreadBeforeMismatch, 'Codex App retry mismatch regression needs an existing app-server thread');
const codexAppRetryMismatchText = 'codexapp retry thread mismatch prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryMismatchText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppRetryMismatchNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should first announce automatic retry');
assert(/第 1\/2 次/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App retry counter should reset for the next independent retryable turn');
assert(/从中断处继续/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should also be a continuation retry');
const codexAppRetryMismatchError = await nextMessage(messages, ws, (msg) => (
msg.type === 'error' &&
msg.sessionId === codexAppSession.sessionId &&
/不同线程/.test(msg.message || '') &&
/上下文丢失/.test(msg.message || '')
), 20000);
assert(/已停止/.test(codexAppRetryMismatchError.message || ''), 'Codex App retry should stop when resume returns a different thread');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterRetryMismatch = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexAppAfterRetryMismatch.codexAppThreadId === codexAppThreadBeforeMismatch, 'Codex App retry mismatch must not replace the persisted app-server thread id');
const codexAppRetryMismatchUsers = storedCodexAppAfterRetryMismatch.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryMismatchText);
assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message');
assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread');
ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalSyncing = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /正在同步 Goal/.test(msg.message || ''), 5000);
assert(/正在同步 Goal/.test(codexAppGoalSyncing.message || ''), 'Codex App /goal should immediately show a syncing notice');
const codexAppGoalRunningList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
Array.isArray(msg.sessions) &&
msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
), 5000);
assert(codexAppGoalRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /goal RPC should mark the session running while waiting for app-server');
const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal');
const codexAppGoalBackgroundDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/Goal background output: improve benchmark coverage/.test(msg.text || '')
), 5000);
assert(/Goal background output/.test(codexAppGoalBackgroundDelta.text || ''), 'Codex App /goal background turn should stream through the active session');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 5000);
const codexAppGoalIdleList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
Array.isArray(msg.sessions) &&
msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning)
), 5000);
assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds');
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal');
ws.send(JSON.stringify({ type: 'message', text: '/goal pause', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalPause = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal paused/.test(msg.message || ''));
assert(/Goal paused/.test(codexAppGoalPause.message || ''), 'Codex App /goal pause should pause the current goal');
ws.send(JSON.stringify({ type: 'message', text: '/goal resume', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || ''));
assert(/Goal active/.test(codexAppGoalResume.message || ''), 'Codex App /goal resume should resume the current goal');
ws.send(JSON.stringify({ type: 'message', text: '/goal clear', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalClear = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal cleared/.test(msg.message || ''));
assert(/Goal cleared/.test(codexAppGoalClear.message || ''), 'Codex App /goal clear should clear the current goal');
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalEmpty = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /用法: \/goal <目标描述>/.test(msg.message || ''));
assert(/\/goal <目标描述>/.test(codexAppGoalEmpty.message || ''), 'Codex App /goal should show usage when no goal exists');
const storedCodexAppAfterGoal = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Codex App /goal slash commands should not be persisted as normal user messages');
assert(storedCodexAppAfterGoal.messages.some((message) => (
message.role === 'assistant' &&
/Goal background output: improve benchmark coverage/.test(String(message.content || ''))
)), 'Codex App /goal background turn should be routed and persisted as an assistant message');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/Long threads and multiple compactions/.test(msg.message || '')
));
assert(/Long threads and multiple compactions/.test(codexAppRuntimeWarning.message || ''), 'Codex App should surface the first runtime warning');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
await sleep(150);
const duplicateRuntimeWarnings = messages.filter((msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/Long threads and multiple compactions/.test(msg.message || '')
));
assert(duplicateRuntimeWarnings.length === 0, 'Codex App should suppress duplicate runtime warning banners');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const hasEmptyReasoningTool = storedCodexAppAfterReasoning.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.some((tool) => (tool.kind === 'reasoning' || tool.meta?.kind === 'reasoning') && !String(tool.result || '').trim());
assert(!hasEmptyReasoningTool, 'Codex App should not persist empty reasoning tool calls');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp tool prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
const codexAppTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-cmd');
assert(/codexapp/.test(codexAppTool.result || ''), 'Codex App should stream app-server tool results');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
let storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppThreadId = storedCodexApp.codexAppThreadId;
assert(codexAppThreadId, 'Codex App thread id should be persisted');
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp huge output prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppHugeTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'huge-tool');
assert((codexAppHugeTool.result || '').length <= 33000, 'Codex App huge tool result should be capped before sending to the browser');
assert(/内容过长|huge-output-start/.test(codexAppHugeTool.result || ''), 'Codex App huge tool result should keep a clear truncated preview');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const persistedHugeTool = storedCodexApp.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.find((tool) => tool.id === 'huge-tool');
assert(persistedHugeTool, 'Codex App huge tool call should be persisted as a preview');
assert(String(persistedHugeTool.result || '').length <= 33000, 'Persisted Codex App huge tool result should be capped');
assert(fs.statSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`)).size < 1024 * 1024, 'Codex App huge output should not inflate session JSON beyond 1MB');
const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id');
assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload');
assert(reloadMcpResult.mcpStatus?.server === 'ccweb', 'Codex App MCP reload should return ccweb server startup status');
assert(reloadMcpResult.mcpStatus?.status === 'ready', 'Codex App MCP reload should surface ready startup status from app-server notification');
assert(reloadMcpResult.mcpStatus?.hasStartupStatus === true, 'Codex App MCP reload should distinguish real startupStatus from pending fallback');
const reloadMcpStatusText = JSON.stringify(reloadMcpResult.mcpStatus);
assert(/CC_WEB_MCP_TOKEN=\[redacted\]/.test(reloadMcpStatusText), 'Codex App MCP reload status should redact token-looking values');
assert(!reloadMcpStatusText.includes('mock-secret-token'), 'Codex App MCP reload status should not leak raw token-looking values');
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.status === 'ready', 'Codex App MCP startup status should be persisted on the session');
assert(!JSON.stringify(storedCodexApp.codexAppMcpStartupStatus).includes('mock-secret-token'), 'Persisted MCP startup status should not leak raw token-looking values');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data');
assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config');
assert(/"hasProjectMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass project MCP config from session cwd');
assert(/"ccwebType": "streamable_http"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should default to shared streamable HTTP');
assert(/"ccwebUrl": "http:\/\/127\.0\.0\.1:\d+\/api\/internal\/mcp\/stream\?/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should point to the shared cc-web HTTP endpoint');
assert(/"ccwebBearerTokenEnvVar": "CC_WEB_CODEX_APP_MCP_TOKEN"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should use bearer_token_env_var for the shared endpoint');
assert(!/--ccweb-mcp-server/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should not launch a per-thread stdio bridge by default');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-prompt-user-mcp', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppEmptySlashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-prompt-user-mcp');
const emptySlashPromptUserIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
const firstOtherCcwebMcpIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name !== 'ccweb_prompt_user');
assert(emptySlashPromptUserIndex >= 0, 'Codex App empty slash composer should include ccweb_prompt_user');
assert(firstOtherCcwebMcpIndex < 0 || emptySlashPromptUserIndex < firstOtherCcwebMcpIndex, 'ccweb_prompt_user should be pinned before other ccweb MCP tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp');
const promptUserComposerItem = codexAppPromptUserMcpComposer.items.find((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
assert(promptUserComposerItem, 'Codex App composer should show ccweb_prompt_user when ccweb MCP is runtime-configured');
assert(promptUserComposerItem.itemType === 'tool', 'ccweb_prompt_user composer item should be a normal MCP tool suggestion');
assert(!promptUserComposerItem.action, 'ccweb_prompt_user composer item should not declare a form action');
assert(promptUserComposerItem.insertion === 'mcp:ccweb/ccweb_prompt_user', 'ccweb_prompt_user composer item should insert the MCP mention text');
assert(promptUserComposerItem.appendSpace === true, 'ccweb_prompt_user composer item should append a space like other MCP suggestions');
const promptUserResult = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_prompt_user',
sourceSessionId: codexAppSession.sessionId,
sourceHopCount: 0,
args: {
title: '确认实现方案',
description: '回归测试多问题表单',
questions: [
{
id: 'ui_choice',
title: '交互方式',
question: '用哪种交互方式?',
required: true,
options: [
{ id: 'fineui', label: 'FineUI 弹窗', recommended: true, answerText: '使用 FineUI 弹窗。' },
{ id: 'prompt', label: '浏览器 prompt', answerText: '使用浏览器 prompt。' },
],
answerPlaceholder: '填写方案',
},
{
id: 'button_id',
title: '按钮 ID',
question: '确认按钮 ID 是什么?',
required: true,
options: [
{ id: 'confirm', label: 'btnShortageReleaseConfirm', recommended: true, answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。' },
],
},
],
},
});
assert(promptUserResult.status === 200 && promptUserResult.body?.ok, `MCP prompt user should render: ${JSON.stringify(promptUserResult.body)}`);
assert(promptUserResult.body.status === 'rendered', 'MCP prompt user should return rendered status without waiting for user input');
assert(promptUserResult.body.questionCount === 2, 'MCP prompt user should preserve multiple questions');
const promptRendered = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.ccwebPrompt?.id === promptUserResult.body.promptId
));
assert(promptRendered.message.ccwebPrompt.status === 'pending', 'Prompt message should start pending');
assert(promptRendered.message.ccwebPrompt.questions?.length === 2, 'Prompt message should carry all questions to the UI');
assert(promptRendered.message.ccwebPrompt.questions[0]?.options?.some((option) => option.id === 'fineui' && option.recommended === true), 'Prompt message should preserve recommended options');
const promptDismissResult = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_prompt_user',
sourceSessionId: codexAppSession.sessionId,
sourceHopCount: 0,
args: {
title: '可忽略表单',
questions: [
{
id: 'dismiss_choice',
title: '是否忽略',
question: '这个表单会被忽略删除。',
required: false,
options: [
{ id: 'skip', label: '忽略', answerText: '忽略这个表单。' },
],
},
],
},
});
assert(promptDismissResult.status === 200 && promptDismissResult.body?.ok, 'Dismissable MCP prompt should render');
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.ccwebPrompt?.id === promptDismissResult.body.promptId
));
ws.send(JSON.stringify({
type: 'ccweb_prompt_user_dismiss',
sessionId: codexAppSession.sessionId,
promptId: promptDismissResult.body.promptId,
}));
const promptDismissed = await nextMessage(messages, ws, (msg) => (
msg.type === 'ccweb_prompt_user_remove' &&
msg.sessionId === codexAppSession.sessionId &&
msg.promptId === promptDismissResult.body.promptId
));
assert(promptDismissed.reason === 'dismissed', 'Dismissed prompt remove event should carry dismissed reason');
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(!storedCodexApp.messages.some((message) => message.ccwebPrompt?.id === promptDismissResult.body.promptId), 'Dismissed prompt message should be removed from session history');
ws.send(JSON.stringify({
type: 'ccweb_prompt_user_response',
sessionId: codexAppSession.sessionId,
promptId: promptUserResult.body.promptId,
answers: {
ui_choice: {
selectedOptionIds: ['fineui'],
answerText: '使用 FineUI 弹窗,方便固定按钮 ID。',
},
button_id: {
selectedOptionIds: ['confirm'],
answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。',
},
},
}));
const promptSubmitted = await nextMessage(messages, ws, (msg) => (
msg.type === 'ccweb_prompt_user_remove' &&
msg.sessionId === codexAppSession.sessionId &&
msg.promptId === promptUserResult.body.promptId
));
assert(promptSubmitted.prompt?.status === 'submitted', 'Prompt remove event should carry submitted status');
assert(promptSubmitted.prompt?.answers?.ui_choice?.selectedOptionLabels?.[0] === 'FineUI 弹窗', 'Prompt remove event should include selected option labels');
const promptAnswerMessage = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.role === 'user' &&
/表单答案/.test(msg.message.content || '') &&
/btnShortageReleaseConfirm/.test(msg.message.content || '')
));
assert(/使用 FineUI 弹窗,方便固定按钮 ID。/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should become a normal user message with the free-form answer');
assert(!/我已回答 ccweb 提示的问题/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should not use the old first-person hard-coded prefix');
assert(!/ccweb 提示表单答案/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should not expose the internal ccweb product name in the user message prefix');
const promptAnswerDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/btnShortageReleaseConfirm/.test(msg.text || '')
));
assert(/表单答案/.test(promptAnswerDelta.text || ''), 'Prompt submission should trigger a Codex App turn with the answer text');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const storedPromptMessage = storedCodexApp.messages.find((message) => message.ccwebPrompt?.id === promptUserResult.body.promptId);
assert(!storedPromptMessage, 'Submitted prompt message should be removed from session history');
assert(storedCodexApp.messages.some((message) => message.role === 'user' && /btnShortageReleaseConfirm/.test(String(message.content || ''))), 'Prompt response user message should persist in session history');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'running'
);
assert(ccwebMcpChildRunning.toolUseId === 'tool-collab', 'ccweb MCP child update should reference the parent collab tool');
const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab');
assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls');
assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'ccweb MCP collab tool should include child thread ids');
const ccwebMcpChildReturned = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'returned' &&
/子代理最终消息/.test(msg.child?.candidateResult || '')
);
assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' }));
const ccwebMcpChildInterrupted = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'interrupted'
);
assert(/"status": "interrupted"/.test(ccwebMcpChildInterrupted.tool?.result || ''), 'ccweb MCP child interrupt should update the parent collab tool state');
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const hasCollabTool = storedCodexApp.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.some((tool) => tool.kind === 'collab_agent_tool_call');
assert(hasCollabTool, 'ccweb MCP collab tool should be persisted into session history');
const persistedClosedCollabTool = storedCodexApp.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.reverse()
.find((tool) => tool.id === 'tool-collab');
assert(/"status": "interrupted"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child interrupt should persist interrupted state');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent v2 prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppV2ChildStarted = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-v2' &&
msg.child?.status === 'running'
);
assert(codexAppV2ChildStarted.toolUseId === 'child-activity-v2', 'V2 child update should reference its subAgentActivity item');
assert(codexAppV2ChildStarted.child.agentPath === '/root/v2_parent', 'V2 child update should preserve its canonical agentPath');
const codexAppV2ChildPlan = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-v2' &&
msg.child?.planProgress?.completed === 1 &&
msg.child?.planProgress?.total === 3
);
assert(codexAppV2ChildPlan.child.planCurrentStep === '同步父卡片进度', 'V2 child update should expose the current in-progress plan step');
const codexAppV2ChildPlanToolResult = JSON.parse(codexAppV2ChildPlan.tool?.result || '{}');
assert(
codexAppV2ChildPlanToolResult.agentsStates?.['child-thread-v2']?.planProgress?.completed === 1,
'V2 child plan progress should merge into the visible parent collaboration tool'
);
const codexAppV2GrandchildStarted = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'grandchild-thread-v2' &&
msg.child?.status === 'running'
);
assert(codexAppV2GrandchildStarted.child.parentThreadId === 'child-thread-v2', 'Nested V2 child should retain the immediate child as parentThreadId');
assert(codexAppV2GrandchildStarted.child.agentPath === '/root/v2_parent/v2_grandchild', 'Nested V2 child should preserve its canonical agentPath');
const codexAppV2GrandchildReturned = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'grandchild-thread-v2' &&
msg.child?.status === 'returned' &&
/V2 孙代理最终消息/.test(msg.child?.candidateResult || '')
);
assert(/grandchild-thread-v2/.test(codexAppV2GrandchildReturned.tool?.result || ''), 'Nested V2 child result should merge into a visible collaboration tool');
const codexAppV2ChildReturned = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-v2' &&
msg.child?.status === 'returned' &&
/V2 子代理最终消息/.test(msg.child?.candidateResult || '')
);
assert(codexAppV2ChildReturned.child.parentThreadId, 'V2 child should retain the root parent thread id');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const persistedV2ChildTool = storedCodexApp.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.find((tool) => tool.id === 'child-activity-v2');
const persistedV2ChildResult = JSON.parse(persistedV2ChildTool?.result || '{}');
assert(
persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planProgress?.completed === 1
&& persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planProgress?.total === 3,
'V2 child plan progress should persist in the session collaboration tool for refresh recovery'
);
assert(
persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planCurrentStep === '同步父卡片进度',
'V2 child current plan step should persist for refresh recovery'
);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode');
assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions');
assert(/"hasSchemaDrivenSubagents":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep runtime-schema-driven guidance');
assert(/"hasLegacyV1Guidance":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should omit legacy V1 guidance');
assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level');
assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp guided prompt', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
const guidedRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_user_input_request' && msg.sessionId === codexAppSession.sessionId);
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
ws.send(JSON.stringify({
type: 'codex_app_user_input_response',
action: 'submit',
sessionId: codexAppSession.sessionId,
requestId: guidedRequest.requestId,
answers: { choice: { answers: ['A'] } },
}));
const guidedSubmitted = await nextMessage(messages, ws, (msg) =>
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/已提交.*引导输入/.test(msg.message || '')
);
assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint');
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId);
assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests');
assert(approvalRequest.itemId === 'approval-command-call', 'Codex App approval request should keep item id');
assert(/echo approved/.test(JSON.stringify(approvalRequest.payload || {})), 'Codex App approval request should include command payload');
ws.send(JSON.stringify({
type: 'codex_app_approval_response',
action: 'approve_session',
sessionId: codexAppSession.sessionId,
requestId: approvalRequest.requestId,
}));
const approvalSubmitted = await nextMessage(messages, ws, (msg) =>
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/本会话执行/.test(msg.message || '')
);
assert(/本会话执行/.test(approvalSubmitted.message || ''), 'Codex App should show approval confirmation hint');
const approvalDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /approval decision: acceptForSession/.test(msg.text || ''));
assert(/approval decision: acceptForSession/.test(approvalDelta.text || ''), 'Codex App should continue after approval response');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
await sleep(500);
ws.send(JSON.stringify({
type: 'message',
text: '/runtime/report',
sessionId: codexAppSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-unknown-slash-steer',
}));
const runningUnknownSlashHint = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/未知指令: \/runtime\/report/.test(msg.message || '')
));
assert(!runningUnknownSlashHint.preserveComposerDraft, 'Running Codex App unknown slash hints should not restore a message that continues through steer');
await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === codexAppSession.sessionId &&
msg.clientMessageId === 'regression-unknown-slash-steer' &&
msg.status === 'pending'
));
const runningUnknownSlashDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/steer accepted: \/runtime\/report/.test(msg.text || '')
));
assert(/\/runtime\/report/.test(runningUnknownSlashDelta.text || ''), 'Running Codex App unknown slash text should continue through turn/steer');
await nextMessage(messages, ws, (msg) => (
msg.type === 'codex_app_steer_status' &&
msg.sessionId === codexAppSession.sessionId &&
msg.clientMessageId === 'regression-unknown-slash-steer' &&
msg.status === 'inserted'
));
ws.send(JSON.stringify({
type: 'message',
text: 'runtime steer insert',
sessionId: codexAppSession.sessionId,
mode: 'yolo',
agent: 'codexapp',
clientMessageId: 'regression-steer-message',
}));
const steerPending = await nextMessage(messages, ws, (msg) =>
msg.type === 'codex_app_steer_status' &&
msg.sessionId === codexAppSession.sessionId &&
msg.clientMessageId === 'regression-steer-message' &&
msg.status === 'pending'
);
assert(/引导中/.test(steerPending.message || ''), 'Codex App steer should expose pending status');
const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || ''));
assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer');
const steerInserted = await nextMessage(messages, ws, (msg) =>
msg.type === 'codex_app_steer_status' &&
msg.sessionId === codexAppSession.sessionId &&
msg.clientMessageId === 'regression-steer-message' &&
msg.status === 'inserted'
);
assert(/已插入/.test(steerInserted.message || ''), 'Codex App steer should expose inserted status');
const steerSystemMessage = await nextMessage(messages, ws, (msg) =>
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/已引导对话: runtime steer insert/.test(msg.message || '')
);
assert(steerSystemMessage.transient === true, 'Codex App steer marker should be transient');
assert(/已引导对话: runtime steer insert/.test(steerSystemMessage.message || ''), 'Codex App steer should show guided conversation marker with preview');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === '/runtime/report'), 'Running Codex App unknown slash text should persist as ordinary user history');
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp abort prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
const codexAppRunningMcp = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: codexAppSession.sessionId,
content: 'running codexapp target should reject this',
},
});
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');
await sleep(150);
ws.send(JSON.stringify({ type: 'detach_view' }));
await sleep(50);
ws.send(JSON.stringify({ type: 'abort', sessionId: codexAppSession.sessionId }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const tinyPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64'
);
const claudeAttachments = await Promise.all(Array.from({ length: 5 }, (_, index) => uploadAttachment(port, token, {
filename: `claude-test-${index + 1}.png`,
mime: 'image/png',
data: tinyPng,
})));
ws.send(JSON.stringify({ type: 'message', text: 'describe attachments', attachments: claudeAttachments, mode: 'yolo', agent: 'claude' }));
const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachments');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
const storedClaudeUserMessage = storedClaudeSession.messages?.find((message) => message.role === 'user' && message.content === 'describe attachments');
assert(
Array.isArray(storedClaudeUserMessage?.attachments) && storedClaudeUserMessage.attachments.length === claudeAttachments.length,
'Claude message should persist all attachment metadata'
);
const storedClaudeAttachmentNames = storedClaudeUserMessage.attachments.map((attachment) => attachment.filename);
for (const attachment of claudeAttachments) {
assert(storedClaudeAttachmentNames.includes(attachment.filename), `Claude message should preserve attachment ${attachment.filename}`);
}
assert(storedClaudeSession.claudeSessionId, 'Claude session id should be persisted after first run');
const claudeSessionIdBeforeMode = storedClaudeSession.claudeSessionId;
// Mode switching must not clear Claude runtime session id (resume should keep context).
ws.send(JSON.stringify({ type: 'set_mode', sessionId: claudeImageSession.sessionId, mode: 'plan' }));
await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan');
const storedClaudeAfterMode = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
assert(storedClaudeAfterMode.claudeSessionId === claudeSessionIdBeforeMode, 'Claude session id should survive mode switch');
ws.send(JSON.stringify({ type: 'message', text: 'second claude prompt', sessionId: claudeImageSession.sessionId, mode: 'plan', agent: 'claude' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
const claudeSpawns = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
.trim()
.split('\n')
.filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
const lastClaudeSpawn = claudeSpawns[claudeSpawns.length - 1] || '';
assert(lastClaudeSpawn.includes(`--resume ${claudeSessionIdBeforeMode}`), 'Claude mode switch should keep --resume session id');
assert(lastClaudeSpawn.includes('--permission-mode plan'), 'Claude plan mode should set --permission-mode plan');
ws.send(JSON.stringify({ type: 'list_native_sessions' }));
const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions');
assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed');
const firstClaude = nativeSessions.groups[0].sessions[0];
ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir }));
const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt');
assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message');
ws.send(JSON.stringify({ type: 'list_codex_sessions' }));
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');
assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions');
assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed');
ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
const codexAppImportSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
const codexAppImportItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
assert(codexAppImportItem, 'Codex App session listing failed');
assert(codexAppImportItem.agent === 'codexapp', 'Codex App import listing should echo target agent');
assert(codexAppImportItem.alreadyImported === false, 'Codex App import should not reuse old Codex imported state');
const duplicateSourceItems = codexAppImportSessions.sessions.filter((item) => item.sourceConversationId === duplicateSourceConversationId);
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, 'Codex App import list should hide subagent rollout threads');
ws.send(JSON.stringify({
type: 'import_codex_session',
agent: 'codexapp',
threadId: codexAppImportItem.threadId,
rolloutPath: codexAppImportItem.rolloutPath,
}));
const importedCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt');
assert(importedCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App import parsed wrong first message');
assert(importedCodexApp.totalUsage?.inputTokens === 20, 'Codex App import usage parse failed');
const importedCodexAppPath = path.join(sessionsDir, `${importedCodexApp.sessionId}.json`);
const storedImportedCodexApp = JSON.parse(fs.readFileSync(importedCodexAppPath, 'utf8'));
assert(storedImportedCodexApp.agent === 'codexapp', 'Codex App import should persist codexapp agent');
assert(storedImportedCodexApp.codexAppThreadId === codexAppImportFixture.threadId, 'Codex App import should persist codexAppThreadId');
assert(!storedImportedCodexApp.codexThreadId, 'Codex App import should not persist legacy codexThreadId');
ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
const codexAppImportSessionsAfter = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
const codexAppImportItemAfter = codexAppImportSessionsAfter.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported');
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId }));
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
!msg.sessions.some((s) => s.id === importedCodexApp.sessionId) &&
!fs.existsSync(importedCodexAppPath)
));
assert(!fs.existsSync(importedCodexAppPath), 'Deleting Codex App imported session did not remove cc-web session JSON');
assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery');
ws.send(JSON.stringify({
type: 'import_codex_session',
agent: 'codexapp',
threadId: codexAppImportFixture.threadId,
rolloutPath: codexAppImportFixture.rolloutPath,
}));
const restoredCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt');
assert(restoredCodexApp.sessionId !== importedCodexApp.sessionId, 'Codex App deleted session should be recreated from rollout history');
assert(restoredCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App re-import should restore messages after cc-web deletion');
const importedSessionId = importedCodex.sessionId;
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId));
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
if (codexFixture.stateDb) {
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
}
ws.close();
console.log('Regression checks passed.');
});
const recoveryPort = await getFreePort();
const recoveryEnv = {
PORT: String(recoveryPort),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
};
const recoveryServer = await startServer(recoveryEnv);
let recoverySessionId = null;
let recoveryStatePath = null;
try {
const { ws, messages } = await connectWs(recoveryPort, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const recoverCwd = path.join(tempRoot, 'codexapp-recover-space');
mkdirp(recoverCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: recoverCwd, mode: 'yolo' }));
const recoverSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === recoverCwd);
recoverySessionId = recoverSession.sessionId;
ws.send(JSON.stringify({ type: 'message', text: 'slow recover codexapp prompt', sessionId: recoverySessionId, mode: 'yolo', agent: 'codexapp' }));
recoveryStatePath = path.join(sessionsDir, `${recoverySessionId}-run`, 'codexapp-state.json');
await waitForFile(recoveryStatePath);
let recoverStateStarted = null;
const stateStartedAt = Date.now();
while (Date.now() - stateStartedAt < 5000) {
recoverStateStarted = JSON.parse(fs.readFileSync(recoveryStatePath, 'utf8'));
if (/partial before restart/.test(recoverStateStarted.fullText || '')
&& recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || ''))) {
break;
}
await sleep(50);
}
assert(/partial before restart/.test(recoverStateStarted?.fullText || ''), 'Codex App running state should persist partial text before completion');
assert(recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Codex App running state should persist partial tool output');
ws.close();
} finally {
await recoveryServer.stop('SIGKILL');
}
assert(recoverySessionId, 'Codex App recovery test did not create a session');
assert(recoveryStatePath && fs.existsSync(recoveryStatePath), 'Codex App recovery state should survive server crash');
const restartedRecoveryServer = await startServer(recoveryEnv);
try {
const { ws, messages } = await connectWs(recoveryPort, password);
const recoveredList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === recoverySessionId));
const recoveredMeta = recoveredList.sessions.find((session) => session.id === recoverySessionId);
assert(recoveredMeta && !recoveredMeta.isRunning, 'Recovered Codex App partial turn should not stay marked running');
assert(!fs.existsSync(recoveryStatePath), 'Recovered Codex App state file should be cleaned after startup recovery');
ws.send(JSON.stringify({ type: 'load_session', sessionId: recoverySessionId }));
const recoveredSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === recoverySessionId);
const recoveredAssistantMessages = (recoveredSessionInfo.messages || []).filter((message) => message.role === 'assistant' && /partial before restart/.test(String(message.content || '')));
assert(recoveredAssistantMessages.length === 1, 'Recovered Codex App partial assistant output should be persisted exactly once');
assert(recoveredAssistantMessages[0].codexAppRecoveredPartial === true, 'Recovered Codex App assistant output should be marked partial');
assert(recoveredAssistantMessages[0].toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Recovered Codex App assistant output should keep tool calls');
ws.close();
} finally {
await restartedRecoveryServer.stop();
}
const oversizedRecoverySessionId = 'oversized-recovery-session';
const oversizedRecoveryStateDir = path.join(sessionsDir, `${oversizedRecoverySessionId}-run`);
const oversizedRecoveryStatePath = path.join(oversizedRecoveryStateDir, 'codexapp-state.json');
fs.writeFileSync(path.join(sessionsDir, `${oversizedRecoverySessionId}.json`), JSON.stringify({
id: oversizedRecoverySessionId,
title: 'Oversized Recovery',
created: new Date().toISOString(),
updated: new Date().toISOString(),
pinnedAt: null,
agent: 'codexapp',
claudeSessionId: null,
codexThreadId: null,
codexAppThreadId: 'oversized-thread',
model: 'gpt-5.5(xhigh)',
permissionMode: 'yolo',
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
cwd: homeDir,
}, null, 2));
mkdirp(oversizedRecoveryStateDir);
fs.writeFileSync(oversizedRecoveryStatePath, JSON.stringify({
version: 1,
agent: 'codexapp',
sessionId: oversizedRecoverySessionId,
threadId: 'oversized-thread',
turnId: 'oversized-turn',
turnStatus: 'running',
fullText: 'x'.repeat(5 * 1024 * 1024),
toolCalls: [],
}));
assert(fs.statSync(oversizedRecoveryStatePath).size > 4 * 1024 * 1024, 'Oversized recovery fixture should exceed the state load guard');
const oversizedRecoveryServer = await startServer(recoveryEnv);
try {
const { ws, messages } = await connectWs(recoveryPort, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId));
assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state');
ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId }));
const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId);
assert((oversizedSessionInfo.messages || []).some((message) => (
message.role === 'system' &&
/状态文件异常/.test(String(message.content || '')) &&
/跳过恢复/.test(String(message.content || ''))
)), 'Oversized Codex App recovery should add a system notice');
ws.close();
} finally {
await oversizedRecoveryServer.stop();
}
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});