#!/usr/bin/env node const fs = require('fs'); 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 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 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) { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); const messages = []; ws.on('open', () => { ws.send(JSON.stringify({ type: 'auth', password })); }); ws.on('message', (buf) => { const msg = JSON.parse(String(buf)); messages.push(msg); if (msg.type === 'auth_result' && msg.success) resolve({ ws, messages, token: msg.token }); if (msg.type === 'auth_result' && !msg.success) reject(new Error('Auth failed')); }); ws.on('error', reject); }); } 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 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) { const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12'); mkdirp(sessionsDir); const threadId = 'codex-import-thread'; const rolloutPath = path.join(sessionsDir, 'rollout-2026-03-12T00-00-00-codex-import-thread.jsonl'); const rolloutLines = [ JSON.stringify({ timestamp: '2026-03-12T00:00:00.000Z', type: 'session_meta', payload: { id: threadId, cwd: '/tmp/project-b', cli_version: '0.114.0', source: 'exec' }, }), 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: 'Codex import prompt' }, }), JSON.stringify({ timestamp: '2026-03-12T00:00:02.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Codex import answer' }], }, }), 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, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0'); 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' ); } function assertFrontendComposerMcpContract() { const source = fs.readFileSync(PUBLIC_APP_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'); } async function main() { assertFrontendGenerationControlsContract(); assertFrontendComposerMcpContract(); 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')); 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 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, }, async () => { await assertWsUpgradeRejected(port, '/not-ws'); const { ws, messages, token } = await connectWs(port, password); await nextMessage(messages, ws, (msg) => msg.type === 'session_list'); 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' })); const defaultCodexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat'); 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, }, })); 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'); const codexInitCwd = path.join(tempRoot, 'codex-space'); mkdirp(codexInitCwd); 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'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg-config', 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-config'), 'Composer slash suggestions should include MCP servers from Codex config'); 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 include 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 include MCP servers from mcp:server labels'); 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'); 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' && item.name === 'ccweb_list_conversations'), 'Composer skill trigger suggestions should include ccweb MCP 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-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); const promptMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-mcp'); assert(promptMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer prompt trigger suggestions should include ccweb MCP tools'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-file', trigger: '@', query: 'context', sessionId: codexSession.sessionId, agent: 'codex' })); const fileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file'); assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file'); ws.send(JSON.stringify({ type: 'message', text: '@shipit @context.txt $regression-skill run composer regression', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', })); const composerExpanded = await nextMessage(messages, ws, (msg) => ( msg.type === 'text_delta' && /BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') && /Composer file context body/.test(msg.text || '') )); assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId); const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8')); const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @context.txt $regression-skill run composer regression'); assert(storedComposerMessage, 'Composer message should persist original user text'); assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata'); assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata'); assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata'); const mcpList = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_list_conversations', sourceSessionId: codexSession.sessionId, args: { agent: 'codex', limit: 20 }, }); assert(mcpList.status === 200 && mcpList.body?.ok, 'MCP conversation list should succeed'); assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id'); assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary'); const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_create_conversation', sourceSessionId: codexSession.sessionId, args: { cwd: 'relative-project', title: 'Relative path should fail' }, }); assert(mcpRelativeCreate.status === 400 && mcpRelativeCreate.body?.code === 'create_conversation_cwd_relative', 'MCP create conversation should reject relative cwd'); const mcpCreate = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_create_conversation', sourceSessionId: codexSession.sessionId, sourceHopCount: 0, args: { agent: 'codex', title: 'MCP Created Conversation', initialMessage: 'mcp created initial prompt', }, }); assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`); assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default'); assert(mcpCreate.body.mode === 'plan', 'MCP create conversation should inherit source mode by default'); assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation'); assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id'); await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId); const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8')); assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title'); assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata'); assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message'); assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt'); const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply'); mkdirp(mcpReplyCreateCwd); const mcpCreateReply = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_create_conversation', sourceSessionId: codexSession.sessionId, sourceHopCount: 0, args: { agent: 'codex', cwd: mcpReplyCreateCwd, title: 'MCP Reply Conversation', initialMessage: 'mcp create request reply', requestReply: true, }, }); assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`); assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd'); assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id'); await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId); await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => ( Array.isArray(session.messages) && session.messages.some((message) => ( message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId && message.crossConversation?.processed === true )) )); const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8')); const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8')); assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation'); assert(storedMcpCreateSource.messages.some((message) => ( message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId && message.crossConversation?.processed === true && message.crossConversation?.autoRun === false )), 'MCP create requestReply should send a processed display-only reply back to source'); const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target'); mkdirp(crossTargetCwd); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' })); const crossTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTargetCwd); const crossSend = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_send_message', sourceSessionId: codexSession.sessionId, sourceHopCount: 0, args: { targetConversationId: crossTargetSession.sessionId, content: 'cross hello from mcp', }, }); assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`); const crossUserBubble = await nextMessage(messages, ws, (msg) => ( msg.type === 'session_message' && msg.sessionId === crossTargetSession.sessionId && msg.message?.crossConversation?.sourceSessionId === codexSession.sessionId && msg.message?.content === 'cross hello from mcp' )); assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId); const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8')); const storedCrossSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8')); const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId); assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session'); assert(storedCrossMessage.crossConversation.sourceTitle === storedCrossSource.title, 'Cross message should persist source title'); assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent'); const hopAllowed = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_send_message', sourceSessionId: codexSession.sessionId, sourceHopCount: 1, args: { targetConversationId: crossTargetSession.sessionId, content: 'cross hop still allowed', }, }); assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`); const hopAllowedBubble = await nextMessage(messages, ws, (msg) => ( msg.type === 'session_message' && msg.sessionId === crossTargetSession.sessionId && msg.message?.crossConversation?.messageId === hopAllowed.body.messageId && msg.message?.content === 'cross hop still allowed' )); assert(hopAllowedBubble.message.crossConversation.hopCount === 2, 'Cross message should keep incrementing hop count without blocking'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId); const crossReplyTargetCwd = path.join(tempRoot, 'codex-mcp-cross-reply-target'); mkdirp(crossReplyTargetCwd); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossReplyTargetCwd, mode: 'yolo' })); const crossReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossReplyTargetCwd); const requestReply = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_request_reply', sourceSessionId: codexSession.sessionId, sourceHopCount: 0, args: { targetConversationId: crossReplyTargetSession.sessionId, content: 'cross reply requested', }, }); assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`); assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id'); assert(requestReply.body.replyDelivery === 'display_only' && requestReply.body.sourceAutoRun === false, 'MCP request reply should declare display-only delivery without source auto-run'); const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => ( msg.type === 'session_message' && msg.sessionId === crossReplyTargetSession.sessionId && msg.message?.crossConversation?.replyRequestId === requestReply.body.requestId && msg.message?.crossConversation?.expectsReply === true && msg.message?.content === 'cross reply requested' )); assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId); await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => ( Array.isArray(session.messages) && session.messages.some((message) => ( message.crossConversation?.replyToRequestId === requestReply.body.requestId && message.crossConversation?.processed === true )) )); const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8')); const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8')); const storedReplyRequestMessage = storedReplyTarget.messages.find((message) => message.crossConversation?.replyRequestId === requestReply.body.requestId); assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'Request reply target message should persist waiting metadata'); assert(storedReplyTarget.messages.some((message) => message.role === 'assistant' && /cross reply requested/.test(String(message.content || ''))), 'Request reply target should produce an assistant reply'); const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId); assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session'); const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex]; assert(storedReplyMessage.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content'); assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply'); assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker'); assert(storedReplyMessage.crossConversation.autoRun === false, 'Returned cross message should not auto-run the source session again'); assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only'); assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading'); assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output'); assert(!storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => ( message.role === 'assistant' && /Codex mock handled/.test(String(message.content || '')) && /已返回消息/.test(String(message.content || '')) )), 'Returned cross message should not trigger the source session to run again'); const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8'); const mcpSpawnLine = processLogAfterMcp .trim() .split('\n') .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8))); assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config'); assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token'); ws.send(JSON.stringify({ type: 'list_cwd_suggestions' })); const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions'); assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path'); assert(Array.isArray(cwdSuggestions.paths) && cwdSuggestions.paths.includes(codexInitCwd), 'CWD suggestions should include recently used session directories'); const crossTalkCwd = path.join(tempRoot, 'codex-cross-talk'); mkdirp(crossTalkCwd); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTalkCwd, mode: 'yolo' })); const crossTalkSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTalkCwd); ws.send(JSON.stringify({ type: 'message', text: 'slow cross-session prompt', sessionId: crossTalkSession.sessionId, mode: 'yolo', agent: 'codex' })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === crossTalkSession.sessionId && s.isRunning)); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'yolo' })); await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat' && msg.sessionId !== crossTalkSession.sessionId); await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === crossTalkSession.sessionId, 8000); const leakedCrossTalk = messages.find((msg) => ( ['text_delta', 'content_blocks', 'tool_start', 'tool_update', 'tool_end', 'usage', 'cost', 'done', 'system_message', 'error'].includes(msg.type) && msg.sessionId === crossTalkSession.sessionId )); assert(!leakedCrossTalk, `Running session leaked stream event into new session: ${leakedCrossTalk ? JSON.stringify(leakedCrossTalk) : ''}`); ws.send(JSON.stringify({ type: 'message', text: '/init', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' })); const codexInitStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /AGENTS\.md/.test(msg.message || '')); assert(/AGENTS\.md/.test(codexInitStart.message || ''), 'Codex /init should announce AGENTS.md generation'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId); assert(fs.existsSync(path.join(codexInitCwd, 'AGENTS.md')), 'Codex /init should generate AGENTS.md in the workspace'); ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' })); const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex'); assert(codexModelChanged.model === 'gpt-5.3-codex', 'Codex /model should accept arbitrary Codex model names'); const codexAttachment = await uploadAttachment(port, token, { filename: 'codex-test.png', mime: 'image/png', data: Buffer.from('codex-image'), }); ws.send(JSON.stringify({ type: 'message', text: 'first codex prompt', attachments: [codexAttachment], mode: 'yolo', agent: 'codex' })); const firstMessageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'first codex prompt'); assert(firstMessageSession.agent === 'codex', 'First-message path created wrong agent'); const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning)); assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId); // Switching permission mode must not clear Codex thread id (otherwise resume loses context). const codexSessionPath = path.join(sessionsDir, `${firstMessageSession.sessionId}.json`); await waitForFile(codexSessionPath, 15000); const storedAfterFirst = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8')); const threadIdBeforeMode = storedAfterFirst.codexThreadId; assert(threadIdBeforeMode, 'Codex thread id should be persisted after first run'); ws.send(JSON.stringify({ type: 'set_mode', sessionId: firstMessageSession.sessionId, mode: 'plan' })); await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan'); await waitForFile(codexSessionPath, 15000); const storedAfterMode = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8')); assert(storedAfterMode.codexThreadId === threadIdBeforeMode, 'Codex thread id should survive mode switch'); ws.send(JSON.stringify({ type: 'message', text: 'second codex prompt', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' })); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId); const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8'); const spawnLine = processLog .trim() .split('\n') .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8))); assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag'); const allSpawnsForSession = processLog .trim() .split('\n') .filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8))); const lastSpawn = allSpawnsForSession[allSpawnsForSession.length - 1] || ''; assert(lastSpawn.includes('resume') && lastSpawn.includes(threadIdBeforeMode), 'Codex mode switch should keep resume thread id'); assert(lastSpawn.includes('-s read-only'), 'Codex plan mode should set sandbox read-only'); assert(lastSpawn.includes('-s read-only resume'), 'Codex resume in plan mode must place -s before resume subcommand'); const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8'); assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode'); assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url'); ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: firstMessageSession.sessionId, mode: 'yolo', agent: 'codex' })); await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在执行/.test(msg.message || '') && /Codex \/compact/.test(msg.message || '')); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId); const compactDoneMsg = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || '')); assert(/已执行 Codex \/compact/.test(compactDoneMsg.message || ''), 'Codex /compact should complete with Codex-specific status message'); const autoCompactCwd = path.join(tempRoot, 'codex-auto-compact'); mkdirp(autoCompactCwd); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: autoCompactCwd, mode: 'yolo' })); const autoCompactSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === autoCompactCwd); ws.send(JSON.stringify({ type: 'message', text: 'warm up auto compact', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' })); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: 'trigger codex context limit', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' })); const autoCompactStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在按 Codex \/compact 自动压缩/.test(msg.message || '')); assert(/Codex \/compact/.test(autoCompactStart.message || ''), 'Codex auto /compact should announce auto compact start'); const autoCompactDone = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || '')); assert(/已执行 Codex \/compact/.test(autoCompactDone.message || ''), 'Codex auto /compact should finish compact step'); const autoCompactResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /按 Codex 压缩计划继续执行/.test(msg.message || '')); assert(/继续执行/.test(autoCompactResume.message || ''), 'Codex auto /compact should announce retry'); // Some Codex builds won't echo the original prompt text as a text delta on retry; accept either. const autoCompactRetry = await nextMessage(messages, ws, (msg) => ( (msg.type === 'text_delta' && /trigger codex context limit/.test(msg.text || '')) || (msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId) ), 20000); if (autoCompactRetry.type === 'text_delta') { assert(/trigger codex context limit/.test(autoCompactRetry.text || ''), 'Codex auto /compact should replay the failed prompt after compact'); } const codexAppCwd = path.join(tempRoot, 'codexapp-space'); mkdirp(codexAppCwd); ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' })); const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd); assert(codexAppSession.model === 'gpt-5.5(xhigh)', 'Codex App new_session should read default Codex model'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' })); const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill'); assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-goal-slash', trigger: '/', query: 'go', sessionId: codexAppSession.sessionId, agent: 'codexapp' })); const codexAppGoalSlashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-goal-slash'); assert(codexAppGoalSlashComposer.items.some((item) => item.kind === 'command' && item.name === '/goal'), 'Codex App composer slash suggestions should include /goal'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration default probe', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppDefaultCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || '')); assert(/"mode":"default"/.test(codexAppDefaultCollab.text || ''), 'Codex App YOLO mode should pass default collaboration mode'); assert(/"hasModel":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include model'); assert(/"hasDeveloperInstructions":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include sub-agent developer instructions'); assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level'); assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal'); ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal'); ws.send(JSON.stringify({ type: 'message', text: '/goal pause', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalPause = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal paused/.test(msg.message || '')); assert(/Goal paused/.test(codexAppGoalPause.message || ''), 'Codex App /goal pause should pause the current goal'); ws.send(JSON.stringify({ type: 'message', text: '/goal resume', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '')); assert(/Goal active/.test(codexAppGoalResume.message || ''), 'Codex App /goal resume should resume the current goal'); ws.send(JSON.stringify({ type: 'message', text: '/goal clear', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalClear = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal cleared/.test(msg.message || '')); assert(/Goal cleared/.test(codexAppGoalClear.message || ''), 'Codex App /goal clear should clear the current goal'); ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalEmpty = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /用法: \/goal <目标描述>/.test(msg.message || '')); assert(/\/goal <目标描述>/.test(codexAppGoalEmpty.message || ''), 'Codex App /goal should show usage when no goal exists'); const storedCodexAppAfterGoal = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Codex App /goal slash commands should not be persisted as normal user messages'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => ( msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Long threads and multiple compactions/.test(msg.message || '') )); assert(/Long threads and multiple compactions/.test(codexAppRuntimeWarning.message || ''), 'Codex App should surface the first runtime warning'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); await sleep(150); const duplicateRuntimeWarnings = messages.filter((msg) => ( msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Long threads and multiple compactions/.test(msg.message || '') )); assert(duplicateRuntimeWarnings.length === 0, 'Codex App should suppress duplicate runtime warning banners'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const hasEmptyReasoningTool = storedCodexAppAfterReasoning.messages .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) .some((tool) => (tool.kind === 'reasoning' || tool.meta?.kind === 'reasoning') && !String(tool.result || '').trim()); assert(!hasEmptyReasoningTool, 'Codex App should not persist empty reasoning tool calls'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp tool prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning)); const codexAppTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-cmd'); assert(/codexapp/.test(codexAppTool.result || ''), 'Codex App should stream app-server tool results'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); let storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const codexAppThreadId = storedCodexApp.codexAppThreadId; assert(codexAppThreadId, 'Codex App thread id should be persisted'); assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted'); assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp huge output prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppHugeTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'huge-tool'); assert((codexAppHugeTool.result || '').length <= 33000, 'Codex App huge tool result should be capped before sending to the browser'); assert(/内容过长|huge-output-start/.test(codexAppHugeTool.result || ''), 'Codex App huge tool result should keep a clear truncated preview'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const persistedHugeTool = storedCodexApp.messages .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) .find((tool) => tool.id === 'huge-tool'); assert(persistedHugeTool, 'Codex App huge tool call should be persisted as a preview'); assert(String(persistedHugeTool.result || '').length <= 33000, 'Persisted Codex App huge tool result should be capped'); assert(fs.statSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`)).size < 1024 * 1024, 'Codex App huge output should not inflate session JSON beyond 1MB'); const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`); assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id'); assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list'); assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls'); assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data'); assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) => msg.type === 'ccweb_mcp_child_agent_update' && msg.sessionId === codexAppSession.sessionId && msg.child?.threadId === 'child-thread-a' && msg.child?.status === 'running' ); assert(ccwebMcpChildRunning.toolUseId === 'tool-collab', 'ccweb MCP child update should reference the parent collab tool'); const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab'); assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls'); assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'ccweb MCP collab tool should include child thread ids'); const ccwebMcpChildReturned = await nextMessage(messages, ws, (msg) => msg.type === 'ccweb_mcp_child_agent_update' && msg.sessionId === codexAppSession.sessionId && msg.child?.threadId === 'child-thread-a' && msg.child?.status === 'returned' && /子代理最终消息/.test(msg.child?.candidateResult || '') ); assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' })); const ccwebMcpChildClosed = await nextMessage(messages, ws, (msg) => msg.type === 'ccweb_mcp_child_agent_update' && msg.sessionId === codexAppSession.sessionId && msg.child?.threadId === 'child-thread-a' && msg.child?.status === 'closed' ); assert(/"status": "closed"/.test(ccwebMcpChildClosed.tool?.result || ''), 'ccweb MCP child close should update the parent collab tool state'); storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const hasCollabTool = storedCodexApp.messages .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) .some((tool) => tool.kind === 'collab_agent_tool_call'); assert(hasCollabTool, 'ccweb MCP collab tool should be persisted into session history'); const persistedClosedCollabTool = storedCodexApp.messages .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : []) .reverse() .find((tool) => tool.id === 'tool-collab'); assert(/"status": "closed"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child close should persist closed state'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' })); const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || '')); assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode'); assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions'); assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level'); assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: 'codexapp guided prompt', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' })); const guidedRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_user_input_request' && msg.sessionId === codexAppSession.sessionId); assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions'); ws.send(JSON.stringify({ type: 'codex_app_user_input_response', action: 'submit', sessionId: codexAppSession.sessionId, requestId: guidedRequest.requestId, answers: { choice: { answers: ['A'] } }, })); const guidedSubmitted = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已提交.*引导输入/.test(msg.message || '') ); assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint'); const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || '')); assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' })); const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId); assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests'); assert(approvalRequest.itemId === 'approval-command-call', 'Codex App approval request should keep item id'); assert(/echo approved/.test(JSON.stringify(approvalRequest.payload || {})), 'Codex App approval request should include command payload'); ws.send(JSON.stringify({ type: 'codex_app_approval_response', action: 'approve_session', sessionId: codexAppSession.sessionId, requestId: approvalRequest.requestId, })); const approvalSubmitted = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /本会话执行/.test(msg.message || '') ); assert(/本会话执行/.test(approvalSubmitted.message || ''), 'Codex App should show approval confirmation hint'); const approvalDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /approval decision: acceptForSession/.test(msg.text || '')); assert(/approval decision: acceptForSession/.test(approvalDelta.text || ''), 'Codex App should continue after approval response'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning)); await sleep(150); ws.send(JSON.stringify({ type: 'message', text: 'runtime steer insert', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp', clientMessageId: 'regression-steer-message', })); const steerPending = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_steer_status' && msg.sessionId === codexAppSession.sessionId && msg.clientMessageId === 'regression-steer-message' && msg.status === 'pending' ); assert(/引导中/.test(steerPending.message || ''), 'Codex App steer should expose pending status'); const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || '')); assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer'); const steerInserted = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_steer_status' && msg.sessionId === codexAppSession.sessionId && msg.clientMessageId === 'regression-steer-message' && msg.status === 'inserted' ); assert(/已插入/.test(steerInserted.message || ''), 'Codex App steer should expose inserted status'); const steerSystemMessage = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已引导对话: runtime steer insert/.test(msg.message || '') ); assert(steerSystemMessage.transient === true, 'Codex App steer marker should be transient'); assert(/已引导对话: runtime steer insert/.test(steerSystemMessage.message || ''), 'Codex App steer should show guided conversation marker with preview'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread'); assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history'); assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted'); ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp abort prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning)); const codexAppRunningMcp = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_send_message', sourceSessionId: codexSession.sessionId, sourceHopCount: 0, args: { targetConversationId: codexAppSession.sessionId, content: 'running codexapp target should reject this', }, }); assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets'); await sleep(150); ws.send(JSON.stringify({ type: 'abort' })); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); const claudeAttachment = await uploadAttachment(port, token, { filename: 'claude-test.png', mime: 'image/png', data: Buffer.from('claude-image'), }); ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' })); const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId); const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8') .trim() .split('\n') .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8))); assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json'); const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8')); assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata'); assert(storedClaudeSession.claudeSessionId, 'Claude session id should be persisted after first run'); const claudeSessionIdBeforeMode = storedClaudeSession.claudeSessionId; // Mode switching must not clear Claude runtime session id (resume should keep context). ws.send(JSON.stringify({ type: 'set_mode', sessionId: claudeImageSession.sessionId, mode: 'plan' })); await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan'); const storedClaudeAfterMode = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8')); assert(storedClaudeAfterMode.claudeSessionId === claudeSessionIdBeforeMode, 'Claude session id should survive mode switch'); ws.send(JSON.stringify({ type: 'message', text: 'second claude prompt', sessionId: claudeImageSession.sessionId, mode: 'plan', agent: 'claude' })); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId); const claudeSpawns = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8') .trim() .split('\n') .filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8))); const lastClaudeSpawn = claudeSpawns[claudeSpawns.length - 1] || ''; assert(lastClaudeSpawn.includes(`--resume ${claudeSessionIdBeforeMode}`), 'Claude mode switch should keep --resume session id'); assert(lastClaudeSpawn.includes('--permission-mode plan'), 'Claude plan mode should set --permission-mode plan'); ws.send(JSON.stringify({ type: 'list_native_sessions' })); const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions'); assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed'); const firstClaude = nativeSessions.groups[0].sessions[0]; ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir })); const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt'); assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message'); ws.send(JSON.stringify({ type: 'list_codex_sessions' })); const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions'); const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId); assert(importedCodexItem, 'Codex session listing failed'); ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath })); const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt'); assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions'); assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed'); const importedSessionId = importedCodex.sessionId; ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId)); assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON'); assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file'); if (codexFixture.stateDb) { assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row'); } ws.close(); console.log('Regression checks passed.'); }); const recoveryPort = await getFreePort(); const recoveryEnv = { PORT: String(recoveryPort), CC_WEB_PASSWORD: password, CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, HOME: homeDir, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER, }; const recoveryServer = await startServer(recoveryEnv); let recoverySessionId = null; let recoveryStatePath = null; try { const { ws, messages } = await connectWs(recoveryPort, password); await nextMessage(messages, ws, (msg) => msg.type === 'session_list'); const recoverCwd = path.join(tempRoot, 'codexapp-recover-space'); mkdirp(recoverCwd); ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: recoverCwd, mode: 'yolo' })); const recoverSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === recoverCwd); recoverySessionId = recoverSession.sessionId; ws.send(JSON.stringify({ type: 'message', text: 'slow recover codexapp prompt', sessionId: recoverySessionId, mode: 'yolo', agent: 'codexapp' })); recoveryStatePath = path.join(sessionsDir, `${recoverySessionId}-run`, 'codexapp-state.json'); await waitForFile(recoveryStatePath); let recoverStateStarted = null; const stateStartedAt = Date.now(); while (Date.now() - stateStartedAt < 5000) { recoverStateStarted = JSON.parse(fs.readFileSync(recoveryStatePath, 'utf8')); if (/partial before restart/.test(recoverStateStarted.fullText || '') && recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || ''))) { break; } await sleep(50); } assert(/partial before restart/.test(recoverStateStarted?.fullText || ''), 'Codex App running state should persist partial text before completion'); assert(recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Codex App running state should persist partial tool output'); ws.close(); } finally { await recoveryServer.stop('SIGKILL'); } assert(recoverySessionId, 'Codex App recovery test did not create a session'); assert(recoveryStatePath && fs.existsSync(recoveryStatePath), 'Codex App recovery state should survive server crash'); const restartedRecoveryServer = await startServer(recoveryEnv); try { const { ws, messages } = await connectWs(recoveryPort, password); const recoveredList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === recoverySessionId)); const recoveredMeta = recoveredList.sessions.find((session) => session.id === recoverySessionId); assert(recoveredMeta && !recoveredMeta.isRunning, 'Recovered Codex App partial turn should not stay marked running'); assert(!fs.existsSync(recoveryStatePath), 'Recovered Codex App state file should be cleaned after startup recovery'); ws.send(JSON.stringify({ type: 'load_session', sessionId: recoverySessionId })); const recoveredSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === recoverySessionId); const recoveredAssistantMessages = (recoveredSessionInfo.messages || []).filter((message) => message.role === 'assistant' && /partial before restart/.test(String(message.content || ''))); assert(recoveredAssistantMessages.length === 1, 'Recovered Codex App partial assistant output should be persisted exactly once'); assert(recoveredAssistantMessages[0].codexAppRecoveredPartial === true, 'Recovered Codex App assistant output should be marked partial'); assert(recoveredAssistantMessages[0].toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Recovered Codex App assistant output should keep tool calls'); ws.close(); } finally { await restartedRecoveryServer.stop(); } const oversizedRecoverySessionId = 'oversized-recovery-session'; const oversizedRecoveryStateDir = path.join(sessionsDir, `${oversizedRecoverySessionId}-run`); const oversizedRecoveryStatePath = path.join(oversizedRecoveryStateDir, 'codexapp-state.json'); fs.writeFileSync(path.join(sessionsDir, `${oversizedRecoverySessionId}.json`), JSON.stringify({ id: oversizedRecoverySessionId, title: 'Oversized Recovery', created: new Date().toISOString(), updated: new Date().toISOString(), pinnedAt: null, agent: 'codexapp', claudeSessionId: null, codexThreadId: null, codexAppThreadId: 'oversized-thread', model: 'gpt-5.5(xhigh)', permissionMode: 'yolo', totalCost: 0, totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, messages: [], cwd: homeDir, }, null, 2)); mkdirp(oversizedRecoveryStateDir); fs.writeFileSync(oversizedRecoveryStatePath, JSON.stringify({ version: 1, agent: 'codexapp', sessionId: oversizedRecoverySessionId, threadId: 'oversized-thread', turnId: 'oversized-turn', turnStatus: 'running', fullText: 'x'.repeat(5 * 1024 * 1024), toolCalls: [], })); assert(fs.statSync(oversizedRecoveryStatePath).size > 4 * 1024 * 1024, 'Oversized recovery fixture should exceed the state load guard'); const oversizedRecoveryServer = await startServer(recoveryEnv); try { const { ws, messages } = await connectWs(recoveryPort, password); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId)); assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state'); ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId })); const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId); assert((oversizedSessionInfo.messages || []).some((message) => ( message.role === 'system' && /状态文件异常/.test(String(message.content || '')) && /跳过恢复/.test(String(message.content || '')) )), 'Oversized Codex App recovery should add a system notice'); ws.close(); } finally { await oversizedRecoveryServer.stop(); } } main().catch((err) => { console.error(err.stack || err.message); process.exit(1); });