#!/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 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 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 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('
') && 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=20260717-wasteland-hifi-23'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); assert(indexSource.includes('app.js?v=20260718-theme-bundle-4'), '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 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'); 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 \(max-width:\s*720px\)[\s\S]*?html\[data-theme='wasteland'\] body\s*\{[\s\S]*?background-position:\s*center,\s*center,\s*70% 50%;/.test(themeStyle), 'Wasteland 720px layout should reposition and darken the local background'); 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=20260717-wasteland-hifi-23'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); assert(indexSource.includes('app.js?v=20260718-theme-bundle-4'), '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: [], listeners: {}, appendChild(child) { this.children.push(child); return child; }, insertBefore(child) { this.children.unshift(child); return 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; }, }; } function createCcwebPromptElement() { return makeNode(); } function isEmptyReasoningTool() { return false; } function createToolCallElement(toolUseId, tool, done) { return { ...makeNode(), toolUseId, tool, done }; } 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); }, 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('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\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 === 1, 'Prompt-derived automatic-looking titles should still render a real task description'); assert(promptDerivedAutoNameDescriptions[0].textContent === 'frontend_state_review', 'Prompt-derived real task descriptions should stay raw in the card'); assert(promptDerivedAutoNameDescriptions[0].title === 'frontend_state_review', 'Prompt-derived real task descriptions should stay raw in the DOM title'); 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'); 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 === 1, 'Raw subAgentActivity without prompt should still render one visible fallback description'); assert(noPromptDescriptions[0].textContent === '子代理任务:计划审查', 'Missing child-agent prompts should render an explicit fallback task description'); assert(noPromptDescriptions[0].title === '子代理任务:计划审查', 'Missing child-agent prompts should use the fallback task description as the DOM title'); 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 include the fallback description in the card DOM title' ); 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: '', }; 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(''), 'YOLO permission mode should remain available'); assert(indexSource.includes(''), 'Default permission mode should remain available'); assert(indexSource.includes(''), '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(''), 'Static welcome should expose an empty project heading for runtime hydration'); assert(indexSource.includes('本次你要构建什么?
'), '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('本次你要构建什么?
'), '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 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 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 assertCodexAppChildToolFallbackContract() { 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(); 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; } ${helperSource} return { setSession: (session) => sessions.set(session.id, session), getSession: (sessionId) => sessions.get(sessionId), getSavedSession: () => savedSession, updatePersistedCcwebMcpChildTool, }; `)(); const session = { id: 'fallback-session', messages: [ { role: 'assistant', content: '', toolCalls: [ { id: 'wait-collab-tool', name: 'wait_agent', kind: 'collab_agent_tool_call', input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} }, result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }), done: false, }, ], }, { 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: 'child-refresh-thread', spawnToolId: 'missing-spawn-tool', label: '刷新路径代理', taskDescription: '验证刷新路径不会丢失子代理卡片。', status: 'closed', candidateResult: '子代理已关闭', closedAt: '2026-07-15T00:00:00.000Z', }); assert(persistedTool?.id === 'wait-collab-tool', 'Missing spawnToolId should fall back to the latest collab tool'); assert(session.messages[1].toolCalls[0].result === 'ordinary output', 'Fallback must not merge child state into ordinary command_execution tools'); const result = JSON.parse(persistedTool.result); assert(result.receiverThreadIds.includes('child-refresh-thread'), 'Persisted fallback collab tool should include child thread id'); assert(result.agentsStates?.['child-refresh-thread']?.title === '刷新路径代理', 'Persisted fallback collab tool should store child title'); assert( result.agentsStates?.['child-refresh-thread']?.taskDescription === '验证刷新路径不会丢失子代理卡片。', 'Persisted fallback collab tool should store child task description' ); assert(result.agentsStates?.['child-refresh-thread']?.status === 'closed', 'Persisted fallback collab tool should store child closed status'); assert(api.getSavedSession()?.id === session.id, 'Persisted fallback merge should save the session'); } 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' ); } 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(); 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(); console.log('Wasteland theme 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; } throw new Error(`Unknown regression target: ${regressionTarget}`); } assertUnlimitedImageAttachmentsContract(); assertFrontendGildedThemeContract(); assertFrontendWastelandThemeContract(); assertFrontendGenerationControlsContract(); assertFrontendComposerMcpContract(); assertComposerSlashRoutingContract(); assertFrontendCcwebPromptContract(); assertFrontendMarkdownLinkContract(); assertMockCodexAppPromptUserNotTextTriggered(); assertFrontendMcpReloadContract(); assertFrontendSubagentCardMetadataContract(); assertCodexAppRuntimeSubAgentActivityContract(); assertFrontendPrimaryCodexAppUiContract(); assertSetTitleMcpContract(); assertSessionSwitchResilienceContract(); assertCodexAppChildToolFallbackContract(); 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); 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(xhigh)', 'Codex new_session should read default model 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/