Files
cc-web/scripts/regression.js
2026-07-01 09:29:11 +08:00

2117 lines
141 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env node
const fs = require('fs');
const 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 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 = [];
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 (msg.type === 'auth_result' && msg.success) {
settled = true;
resolve({ ws, messages, 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 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 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');
}
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');
}
async function main() {
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
assertFrontendCcwebPromptContract();
assertFrontendMarkdownLinkContract();
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();
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');
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');
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');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
const metadataSkill = skillComposer.items.find((item) => item.kind === 'skill' && item.name === 'regression-skill');
assert(metadataSkill?.title === 'Regression Docs', 'Composer skill suggestions should expose openai.yaml display_name');
assert(/metadata coverage/.test(metadataSkill?.defaultPromptPreview || ''), 'Composer skill suggestions should expose default prompt preview');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-project-skill', trigger: '$', query: 'project', sessionId: codexSession.sessionId, agent: 'codex' }));
const projectSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-project-skill');
assert(projectSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'project-skill'), 'Composer skill suggestions should include project-scoped skill from session cwd');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp');
assert(!skillMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not include MCP tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-declared-mcp', trigger: '$', query: 'openai', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillDeclaredMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-declared-mcp');
assert(!skillDeclaredMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not list declared MCP dependencies from openai.yaml as available tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt');
assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-file', trigger: '@', query: 'quick', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptFileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-file');
assert(promptFileComposer.items.some((item) => item.kind === 'prompt' && item.name === 'quick-note'), 'Composer prompt suggestions should include ~/.codex/prompts/*.md shortcuts');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-dir', trigger: '@', query: 'nested', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptDirComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-dir');
assert(promptDirComposer.items.some((item) => item.kind === 'prompt' && item.name === 'nested-tool'), 'Composer prompt suggestions should include ~/.codex/prompts/<name>/prompt.md shortcuts');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-at-no-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const atNoMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-at-no-mcp');
assert(!atNoMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer @ suggestions should not include MCP tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-file', trigger: '@', query: 'context', sessionId: codexSession.sessionId, agent: 'codex' }));
const fileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file');
assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file');
assert(!fileComposer.items.some((item) => item.kind === 'mcp'), 'Composer file suggestions should not include MCP tools');
ws.send(JSON.stringify({
type: 'message',
text: '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression',
sessionId: codexSession.sessionId,
mode: 'plan',
agent: 'codex',
}));
const composerExpanded = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
/BEGIN CC-WEB PROMPT: quick-note/.test(msg.text || '') &&
/Composer file context body/.test(msg.text || '')
));
assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content');
assert(/Prompt body from @quick-note/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand ~/.codex/prompts prompt shortcuts');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression');
assert(storedComposerMessage, 'Composer message should persist original user text');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'quick-note'), 'Composer message should persist ~/.codex/prompts prompt mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'project-skill'), 'Composer message should persist project-scoped skill mention metadata');
const storedRegressionSkillMention = storedComposerMessage.composerMentions?.find((mention) => mention.kind === 'skill' && mention.name === 'regression-skill');
assert(storedRegressionSkillMention?.title === 'Regression Docs', 'Stored skill mention should persist display title from openai.yaml');
assert(storedRegressionSkillMention?.dependencies?.some((dep) => dep.value === 'openaiDeveloperDocs' && dep.state === 'declared'), 'Stored skill mention should persist MCP dependency metadata');
const mcpList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { agent: 'codex', limit: 20 },
});
assert(mcpList.status === 200 && mcpList.body?.ok, 'MCP conversation list should succeed');
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
args: { cwd: 'relative-project', title: 'Relative path should fail' },
});
assert(mcpRelativeCreate.status === 400 && mcpRelativeCreate.body?.code === 'create_conversation_cwd_relative', 'MCP create conversation should reject relative cwd');
const mcpCreate = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
agent: 'claude',
title: 'MCP Created Conversation',
initialMessage: 'mcp created initial prompt',
},
});
assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`);
assert(mcpCreate.body.agent === 'codex', 'MCP create conversation should ignore agent args and inherit the source agent');
assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default');
assert(mcpCreate.body.mode === 'yolo', 'MCP create conversation should default to yolo when mode is omitted');
assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation');
assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent');
assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode');
assert(storedMcpCreated.createdFrom?.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: 'claude',
cwd: mcpReplyCreateCwd,
title: 'MCP Reply Conversation',
initialMessage: 'mcp create request reply',
requestReply: true,
},
});
assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`);
assert(mcpCreateReply.body.agent === 'codex', 'MCP create requestReply should inherit source agent even if args.agent is passed');
assert(mcpCreateReply.body.mode === 'yolo', 'MCP create requestReply should default to yolo when mode is omitted');
assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd');
assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id');
assert(mcpCreateReply.body.replyDelivery === 'auto_run' && mcpCreateReply.body.sourceAutoRun === true, 'MCP create requestReply should declare source auto-run delivery');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId &&
message.crossConversation?.processed === true
))
));
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8'));
const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation');
const storedMcpCreateReplyIndex = storedMcpCreateSource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId);
assert(storedMcpCreateReplyIndex >= 0, 'MCP create requestReply should send a processed display-only reply back to source');
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.processed === true, 'MCP create requestReply should mark the returned message processed');
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.autoRun === true, 'MCP create requestReply should mark the returned message as auto-run');
assert(storedMcpCreateSource.messages.slice(storedMcpCreateReplyIndex + 1).some((message) => (
message.role === 'assistant' &&
/mcp create request reply/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'MCP create requestReply should continue the source session after the child reply');
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
mkdirp(crossTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' }));
const crossTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTargetCwd);
const crossSend = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'cross hello from mcp',
},
});
assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`);
const crossUserBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
msg.message?.crossConversation?.sourceSessionId === codexSession.sessionId &&
msg.message?.content === 'cross hello from mcp'
));
assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8'));
const storedCrossSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
assert(storedCrossMessage.crossConversation.sourceTitle === storedCrossSource.title, 'Cross message should persist source title');
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
const hopAllowed = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 1,
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'cross hop still allowed',
},
});
assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`);
const hopAllowedBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
msg.message?.crossConversation?.messageId === hopAllowed.body.messageId &&
msg.message?.content === 'cross hop still allowed'
));
assert(hopAllowedBubble.message.crossConversation.hopCount === 2, 'Cross message should keep incrementing hop count without blocking');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
const crossReplyTargetCwd = path.join(tempRoot, 'codex-mcp-cross-reply-target');
mkdirp(crossReplyTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossReplyTargetCwd, mode: 'yolo' }));
const crossReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossReplyTargetCwd);
const requestReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_request_reply',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossReplyTargetSession.sessionId,
content: 'cross reply requested',
},
});
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP request reply should declare source auto-run delivery');
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossReplyTargetSession.sessionId &&
msg.message?.crossConversation?.replyRequestId === requestReply.body.requestId &&
msg.message?.crossConversation?.expectsReply === true &&
msg.message?.content === 'cross reply requested'
));
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === requestReply.body.requestId &&
message.crossConversation?.processed === true
))
));
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedReplyRequestMessage = storedReplyTarget.messages.find((message) => message.crossConversation?.replyRequestId === requestReply.body.requestId);
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'Request reply target message should persist waiting metadata');
assert(storedReplyTarget.messages.some((message) => message.role === 'assistant' && /cross reply requested/.test(String(message.content || ''))), 'Request reply target should produce an assistant reply');
const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId);
assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session');
const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex];
assert(storedReplyMessage.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content');
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker');
assert(storedReplyMessage.crossConversation.autoRun === true, 'Returned cross message should mark source auto-run');
assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only');
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
message.role === 'assistant' &&
/Codex mock handled/.test(String(message.content || '')) &&
/cross reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Returned cross message should trigger the source session to run again');
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
mkdirp(busySourceCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busySourceCwd, mode: 'yolo' }));
const busySourceSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busySourceCwd);
ws.send(JSON.stringify({ type: 'message', text: 'very slow cross-session prompt', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === busySourceSession.sessionId && s.isRunning));
const busyReplyTargetCwd = path.join(tempRoot, 'codex-mcp-busy-reply-target');
mkdirp(busyReplyTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busyReplyTargetCwd, mode: 'yolo' }));
const busyReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busyReplyTargetCwd);
const busyRequestReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_request_reply',
sourceSessionId: busySourceSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: busyReplyTargetSession.sessionId,
content: 'busy source reply requested',
},
});
assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`);
assert(busyRequestReply.body.requestId && busyRequestReply.body.status === 'waiting', 'Busy source request reply should return a waiting request id');
assert(busyRequestReply.body.replyDelivery === 'auto_run' && busyRequestReply.body.sourceAutoRun === true, 'Busy source request reply should declare source auto-run delivery');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busyReplyTargetSession.sessionId);
await waitForJsonCondition(path.join(configDir, 'cross-conversation-replies.json'), (state) => (
Array.isArray(state.replies) &&
state.replies.some((reply) => (
reply.requestId === busyRequestReply.body.requestId &&
reply.sourceConversationId === busySourceSession.sessionId &&
reply.status === 'ready' &&
/busy source reply requested/.test(String(reply.replyText || ''))
))
));
let storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(!storedBusySource.messages.some((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId), 'Busy source should not receive display-only reply while it is still running');
const busyPendingList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_pending_replies',
sourceSessionId: busySourceSession.sessionId,
args: { status: 'ready' },
});
assert(busyPendingList.status === 200 && busyPendingList.body?.ok, `MCP pending reply list should succeed: ${JSON.stringify(busyPendingList.body)}`);
assert(busyPendingList.body.waitingOnChildren === true, 'Pending reply list should report waitingOnChildren while ready reply is queued');
assert(busyPendingList.body.readyReplyCount === 1, 'Pending reply list should count ready replies');
assert(busyPendingList.body.replies.some((reply) => reply.requestId === busyRequestReply.body.requestId && reply.status === 'ready'), 'Pending reply list should include the queued ready reply');
const busyPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
sourceSessionId: busySourceSession.sessionId,
args: { requestId: busyRequestReply.body.requestId },
});
assert(busyPendingDetail.status === 200 && busyPendingDetail.body?.ok, `MCP pending reply detail should succeed: ${JSON.stringify(busyPendingDetail.body)}`);
assert(busyPendingDetail.body.status === 'ready', 'Pending reply detail should expose ready status');
assert(/busy source reply requested/.test(String(busyPendingDetail.body.replyText || '')), 'Pending reply detail should expose target assistant output');
const busyConversationList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: busySourceSession.sessionId,
args: { limit: 50 },
});
assert(busyConversationList.status === 200 && busyConversationList.body?.ok, `MCP conversation list with waiting state should succeed: ${JSON.stringify(busyConversationList.body)}`);
assert(busyConversationList.body.waitingOnChildren === true && busyConversationList.body.readyReplyCount === 1, 'MCP list should expose source waiting state');
const busySourceSummary = busyConversationList.body.conversations.find((item) => item.id === busySourceSession.sessionId);
assert(busySourceSummary?.status === 'running', 'MCP list should still mark the busy source as running before it completes');
assert(busySourceSummary?.waitingOnChildren === true && busySourceSummary?.readyReplyCount === 1, 'MCP list should expose queued child replies on the source conversation');
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
await waitForJsonCondition(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId &&
message.crossConversation?.processed === true &&
message.ccwebDisplayOnly === true
))
));
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
const busyReplyIndex = storedBusySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId);
assert(busyReplyIndex > 0, 'Busy source should receive queued display-only reply after its run completes');
assert(storedBusySource.messages[busyReplyIndex - 1]?.role === 'assistant' && /very slow cross-session prompt/.test(String(storedBusySource.messages[busyReplyIndex - 1].content || '')), 'Queued reply should be appended after the source run assistant message');
assert(storedBusySource.messages[busyReplyIndex].crossConversation?.autoRun === true, 'Queued reply should mark source auto-run');
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(storedBusySource.messages.slice(busyReplyIndex + 1).some((message) => (
message.role === 'assistant' &&
/busy source reply requested/.test(String(message.content || '')) &&
/子对话/.test(String(message.content || ''))
)), 'Busy source should auto-run after the queued child reply is flushed');
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
sourceSessionId: busySourceSession.sessionId,
args: { requestId: busyRequestReply.body.requestId },
});
assert(returnedPendingDetail.status === 200 && returnedPendingDetail.body?.ok, 'Returned pending reply detail should remain queryable from source history');
assert(returnedPendingDetail.body.status === 'returned' && returnedPendingDetail.body.returned === true, 'Returned pending reply detail should report returned status');
ws.send(JSON.stringify({ type: 'load_session', sessionId: busySourceSession.sessionId, requestId: 'reg-load-busy-source' }));
const loadedBusySource = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === busySourceSession.sessionId);
assert(loadedBusySource.requestId === 'reg-load-busy-source', 'load_session session_info should echo requestId');
assert(loadedBusySource.isRunning === false, 'Busy source should be idle after background run completed');
assert(loadedBusySource.waitingOnChildren === false && loadedBusySource.pendingReplyCount === 0, 'Busy source should clear waiting state after queued reply is flushed');
ws.send(JSON.stringify({ type: 'message', text: 'source remains usable after queued child reply', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busySourceSession.sessionId);
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
assert(storedBusySource.messages.some((message) => message.role === 'user' && message.content === 'source remains usable after queued child reply'), 'Source conversation should accept normal user messages after queued child reply is flushed');
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const mcpSpawnLine = processLogAfterMcp
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8)));
assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config');
assert(mcpSpawnLine.includes('server.js') && mcpSpawnLine.includes('--ccweb-mcp-server'), 'Codex spawn should launch ccweb MCP through server.js in Node mode');
assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token');
const projectMcpSpawnLine = processLogAfterMcp
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(codexSession.sessionId.slice(0, 8)));
assert(projectMcpSpawnLine && projectMcpSpawnLine.includes('mcp_servers.reg-project.command'), 'Codex spawn should inject project MCP config from session cwd');
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions');
assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path');
assert(Array.isArray(cwdSuggestions.paths) && cwdSuggestions.paths.includes(codexInitCwd), 'CWD suggestions should include recently used session directories');
const crossTalkCwd = path.join(tempRoot, 'codex-cross-talk');
mkdirp(crossTalkCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTalkCwd, mode: 'yolo' }));
const crossTalkSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTalkCwd);
ws.send(JSON.stringify({ type: 'message', text: 'slow cross-session prompt', sessionId: crossTalkSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === crossTalkSession.sessionId && s.isRunning));
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'yolo' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat' && msg.sessionId !== crossTalkSession.sessionId);
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === crossTalkSession.sessionId, 8000);
const leakedCrossTalk = messages.find((msg) => (
['text_delta', 'content_blocks', 'tool_start', 'tool_update', 'tool_end', 'usage', 'cost', 'done', 'system_message', 'error'].includes(msg.type) &&
msg.sessionId === crossTalkSession.sessionId
));
assert(!leakedCrossTalk, `Running session leaked stream event into new session: ${leakedCrossTalk ? JSON.stringify(leakedCrossTalk) : ''}`);
ws.send(JSON.stringify({ type: 'message', text: '/init', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
const codexInitStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /AGENTS\.md/.test(msg.message || ''));
assert(/AGENTS\.md/.test(codexInitStart.message || ''), 'Codex /init should announce AGENTS.md generation');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
assert(fs.existsSync(path.join(codexInitCwd, 'AGENTS.md')), 'Codex /init should generate AGENTS.md in the workspace');
ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex');
assert(codexModelChanged.model === 'gpt-5.3-codex', 'Codex /model should accept arbitrary Codex model names');
const codexAttachment = await uploadAttachment(port, token, {
filename: 'codex-test.png',
mime: 'image/png',
data: Buffer.from('codex-image'),
});
ws.send(JSON.stringify({ type: 'message', text: 'first codex prompt', attachments: [codexAttachment], mode: 'yolo', agent: 'codex' }));
const firstMessageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'first codex prompt');
assert(firstMessageSession.agent === 'codex', 'First-message path created wrong agent');
const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning));
assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
// Switching permission mode must not clear Codex thread id (otherwise resume loses context).
const codexSessionPath = path.join(sessionsDir, `${firstMessageSession.sessionId}.json`);
await waitForFile(codexSessionPath, 15000);
const storedAfterFirst = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
const threadIdBeforeMode = storedAfterFirst.codexThreadId;
assert(threadIdBeforeMode, 'Codex thread id should be persisted after first run');
ws.send(JSON.stringify({ type: 'set_mode', sessionId: firstMessageSession.sessionId, mode: 'plan' }));
await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan');
await waitForFile(codexSessionPath, 15000);
const storedAfterMode = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
assert(storedAfterMode.codexThreadId === threadIdBeforeMode, 'Codex thread id should survive mode switch');
ws.send(JSON.stringify({ type: 'message', text: 'second codex prompt', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const spawnLine = processLog
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag');
const allSpawnsForSession = processLog
.trim()
.split('\n')
.filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
const lastSpawn = allSpawnsForSession[allSpawnsForSession.length - 1] || '';
assert(lastSpawn.includes('resume') && lastSpawn.includes(threadIdBeforeMode), 'Codex mode switch should keep resume thread id');
assert(lastSpawn.includes('-s read-only'), 'Codex plan mode should set sandbox read-only');
assert(lastSpawn.includes('-s read-only resume'), 'Codex resume in plan mode must place -s before resume subcommand');
ws.send(JSON.stringify({ type: 'message', text: 'trigger codex capacity retry', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' }));
const capacityRetryNotice = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /自动重试/.test(msg.message || '') && msg.sessionId === firstMessageSession.sessionId, 10000);
assert(/Codex 服务暂时繁忙/.test(capacityRetryNotice.message || ''), 'Codex transient capacity failure should announce automatic retry');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId, 20000);
const storedAfterCapacityRetry = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
const capacityRetryUsers = storedAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === 'trigger codex capacity retry');
assert(capacityRetryUsers.length === 1, 'Codex transient retry should not duplicate the user message');
assert(storedAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /trigger codex capacity retry/.test(String(message.content || ''))), 'Codex transient retry should persist the successful assistant response');
const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8');
assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: firstMessageSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在执行/.test(msg.message || '') && /Codex \/compact/.test(msg.message || ''));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
const compactDoneMsg = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
assert(/已执行 Codex \/compact/.test(compactDoneMsg.message || ''), 'Codex /compact should complete with Codex-specific status message');
const autoCompactCwd = path.join(tempRoot, 'codex-auto-compact');
mkdirp(autoCompactCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: autoCompactCwd, mode: 'yolo' }));
const autoCompactSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === autoCompactCwd);
ws.send(JSON.stringify({ type: 'message', text: 'warm up auto compact', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'trigger codex context limit', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
const autoCompactStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在按 Codex \/compact 自动压缩/.test(msg.message || ''));
assert(/Codex \/compact/.test(autoCompactStart.message || ''), 'Codex auto /compact should announce auto compact start');
const autoCompactDone = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
assert(/已执行 Codex \/compact/.test(autoCompactDone.message || ''), 'Codex auto /compact should finish compact step');
const autoCompactResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /按 Codex 压缩计划继续执行/.test(msg.message || ''));
assert(/继续执行/.test(autoCompactResume.message || ''), 'Codex auto /compact should announce retry');
// Some Codex builds won't echo the original prompt text as a text delta on retry; accept either.
const autoCompactRetry = await nextMessage(messages, ws, (msg) => (
(msg.type === 'text_delta' && /trigger codex context limit/.test(msg.text || '')) ||
(msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId)
), 20000);
if (autoCompactRetry.type === 'text_delta') {
assert(/trigger codex context limit/.test(autoCompactRetry.text || ''), 'Codex auto /compact should replay the failed prompt after compact');
}
const codexAppCwd = path.join(tempRoot, 'codexapp-space');
mkdirp(codexAppCwd);
const codexAppProjectConfigDir = path.join(codexAppCwd, '.codex');
mkdirp(codexAppProjectConfigDir);
fs.writeFileSync(path.join(codexAppProjectConfigDir, 'config.toml'), [
'[mcp_servers.reg-app-project]',
'type = "stdio"',
`command = ${JSON.stringify(process.execPath)}`,
'args = ["regression-app-mcp.js"]',
'enabled = true',
].join('\n'));
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' }));
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
assert(codexAppSession.model === 'gpt-5.5(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(/"hasWaitAgentRetryGuidance":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include wait_agent retry guidance');
assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level');
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
mode: 'custom',
activeProfile: 'Regression Profile Updated',
profiles: [{ name: 'Regression Profile Updated', apiKey: 'sk-regression-updated', apiBase: 'https://updated.example.com/v1' }],
enableSearch: false,
retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 },
},
}));
const codexAppChangedConfig = await nextMessage(messages, ws, (msg) =>
msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated'
);
assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp after config change prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppAfterConfigChange = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/codexapp after config change prompt/.test(msg.text || '')
));
assert(/codexapp after config change prompt/.test(codexAppAfterConfigChange.text || ''), 'Codex App should not reject a new turn after config signature changes');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const codexAppRetryText = 'codexapp capacity retry prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient capacity failure should announce automatic retry');
assert(/第 1\/2 次/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient retry should start at attempt 1');
assert(/从中断处继续/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App retry after a started turn should announce continuation mode');
const codexAppPartialCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/第 2\/2 次/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App transient retry should continue after partial output');
assert(/从中断处继续/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App partial-output retry should stay in continuation mode');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterCapacityRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppCapacityRetryUsers = storedCodexAppAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryText);
assert(codexAppCapacityRetryUsers.length === 1, 'Codex App transient retry should not duplicate the user message');
assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /codexapp capacity retry prompt/.test(String(message.content || ''))), 'Codex App transient retry should persist the successful assistant response');
assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App transient retry should ask the model to continue instead of replaying the original prompt');
const codexAppReconnectRetryText = 'codexapp reconnect retry prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppReconnectRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppReconnectRetryNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect failure should announce automatic retry');
assert(/第 1\/2 次/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App retry counter should reset after the previous retry succeeds');
assert(/从中断处继续/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect retry after a started turn should announce continuation mode');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterReconnectRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppReconnectRetryUsers = storedCodexAppAfterReconnectRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppReconnectRetryText);
assert(codexAppReconnectRetryUsers.length === 1, 'Codex App reconnect retry should not duplicate the user message');
assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /codexapp reconnect retry prompt/.test(String(message.content || ''))), 'Codex App reconnect retry should persist the successful assistant response');
assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App reconnect retry should continue the interrupted turn instead of replaying the original prompt');
const codexAppThreadBeforeMismatch = storedCodexAppAfterReconnectRetry.codexAppThreadId;
assert(codexAppThreadBeforeMismatch, 'Codex App retry mismatch regression needs an existing app-server thread');
const codexAppRetryMismatchText = 'codexapp retry thread mismatch prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryMismatchText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppRetryMismatchNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should first announce automatic retry');
assert(/第 1\/2 次/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App retry counter should reset for the next independent retryable turn');
assert(/从中断处继续/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should also be a continuation retry');
const codexAppRetryMismatchError = await nextMessage(messages, ws, (msg) => (
msg.type === 'error' &&
msg.sessionId === codexAppSession.sessionId &&
/不同线程/.test(msg.message || '') &&
/上下文丢失/.test(msg.message || '')
), 20000);
assert(/已停止/.test(codexAppRetryMismatchError.message || ''), 'Codex App retry should stop when resume returns a different thread');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterRetryMismatch = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexAppAfterRetryMismatch.codexAppThreadId === codexAppThreadBeforeMismatch, 'Codex App retry mismatch must not replace the persisted app-server thread id');
const codexAppRetryMismatchUsers = storedCodexAppAfterRetryMismatch.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryMismatchText);
assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message');
assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread');
ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const 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');
assert(reloadMcpResult.mcpStatus?.server === 'ccweb', 'Codex App MCP reload should return ccweb server startup status');
assert(reloadMcpResult.mcpStatus?.status === 'ready', 'Codex App MCP reload should surface ready startup status from app-server notification');
assert(reloadMcpResult.mcpStatus?.hasStartupStatus === true, 'Codex App MCP reload should distinguish real startupStatus from pending fallback');
const reloadMcpStatusText = JSON.stringify(reloadMcpResult.mcpStatus);
assert(/CC_WEB_MCP_TOKEN=\[redacted\]/.test(reloadMcpStatusText), 'Codex App MCP reload status should redact token-looking values');
assert(!reloadMcpStatusText.includes('mock-secret-token'), 'Codex App MCP reload status should not leak raw token-looking values');
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.status === 'ready', 'Codex App MCP startup status should be persisted on the session');
assert(!JSON.stringify(storedCodexApp.codexAppMcpStartupStatus).includes('mock-secret-token'), 'Persisted MCP startup status should not leak raw token-looking values');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data');
assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config');
assert(/"hasProjectMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass project MCP config from session cwd');
assert(/"ccwebType": "streamable_http"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should default to shared streamable HTTP');
assert(/"ccwebUrl": "http:\/\/127\.0\.0\.1:\d+\/api\/internal\/mcp\/stream\?/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should point to the shared cc-web HTTP endpoint');
assert(/"ccwebBearerTokenEnvVar": "CC_WEB_CODEX_APP_MCP_TOKEN"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should use bearer_token_env_var for the shared endpoint');
assert(!/--ccweb-mcp-server/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should not launch a per-thread stdio bridge by default');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-prompt-user-mcp', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppEmptySlashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-prompt-user-mcp');
const emptySlashPromptUserIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
const firstOtherCcwebMcpIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name !== 'ccweb_prompt_user');
assert(emptySlashPromptUserIndex >= 0, 'Codex App empty slash composer should include ccweb_prompt_user');
assert(firstOtherCcwebMcpIndex < 0 || emptySlashPromptUserIndex < firstOtherCcwebMcpIndex, 'ccweb_prompt_user should be pinned before other ccweb MCP tools');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp');
const promptUserComposerItem = codexAppPromptUserMcpComposer.items.find((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
assert(promptUserComposerItem, 'Codex App composer should show ccweb_prompt_user when ccweb MCP is runtime-configured');
assert(promptUserComposerItem.itemType === 'tool', 'ccweb_prompt_user composer item should be a normal MCP tool suggestion');
assert(!promptUserComposerItem.action, 'ccweb_prompt_user composer item should not declare a form action');
assert(promptUserComposerItem.insertion === 'mcp:ccweb/ccweb_prompt_user', 'ccweb_prompt_user composer item should insert the MCP mention text');
assert(promptUserComposerItem.appendSpace === true, 'ccweb_prompt_user composer item should append a space like other MCP suggestions');
const promptUserResult = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_prompt_user',
sourceSessionId: codexAppSession.sessionId,
sourceHopCount: 0,
args: {
title: '确认实现方案',
description: '回归测试多问题表单',
questions: [
{
id: 'ui_choice',
title: '交互方式',
question: '用哪种交互方式?',
required: true,
options: [
{ id: 'fineui', label: 'FineUI 弹窗', recommended: true, answerText: '使用 FineUI 弹窗。' },
{ id: 'prompt', label: '浏览器 prompt', answerText: '使用浏览器 prompt。' },
],
answerPlaceholder: '填写方案',
},
{
id: 'button_id',
title: '按钮 ID',
question: '确认按钮 ID 是什么?',
required: true,
options: [
{ id: 'confirm', label: 'btnShortageReleaseConfirm', recommended: true, answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。' },
],
},
],
},
});
assert(promptUserResult.status === 200 && promptUserResult.body?.ok, `MCP prompt user should render: ${JSON.stringify(promptUserResult.body)}`);
assert(promptUserResult.body.status === 'rendered', 'MCP prompt user should return rendered status without waiting for user input');
assert(promptUserResult.body.questionCount === 2, 'MCP prompt user should preserve multiple questions');
const promptRendered = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.ccwebPrompt?.id === promptUserResult.body.promptId
));
assert(promptRendered.message.ccwebPrompt.status === 'pending', 'Prompt message should start pending');
assert(promptRendered.message.ccwebPrompt.questions?.length === 2, 'Prompt message should carry all questions to the UI');
assert(promptRendered.message.ccwebPrompt.questions[0]?.options?.some((option) => option.id === 'fineui' && option.recommended === true), 'Prompt message should preserve recommended options');
const promptDismissResult = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_prompt_user',
sourceSessionId: codexAppSession.sessionId,
sourceHopCount: 0,
args: {
title: '可忽略表单',
questions: [
{
id: 'dismiss_choice',
title: '是否忽略',
question: '这个表单会被忽略删除。',
required: false,
options: [
{ id: 'skip', label: '忽略', answerText: '忽略这个表单。' },
],
},
],
},
});
assert(promptDismissResult.status === 200 && promptDismissResult.body?.ok, 'Dismissable MCP prompt should render');
await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.ccwebPrompt?.id === promptDismissResult.body.promptId
));
ws.send(JSON.stringify({
type: 'ccweb_prompt_user_dismiss',
sessionId: codexAppSession.sessionId,
promptId: promptDismissResult.body.promptId,
}));
const promptDismissed = await nextMessage(messages, ws, (msg) => (
msg.type === 'ccweb_prompt_user_remove' &&
msg.sessionId === codexAppSession.sessionId &&
msg.promptId === promptDismissResult.body.promptId
));
assert(promptDismissed.reason === 'dismissed', 'Dismissed prompt remove event should carry dismissed reason');
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(!storedCodexApp.messages.some((message) => message.ccwebPrompt?.id === promptDismissResult.body.promptId), 'Dismissed prompt message should be removed from session history');
ws.send(JSON.stringify({
type: 'ccweb_prompt_user_response',
sessionId: codexAppSession.sessionId,
promptId: promptUserResult.body.promptId,
answers: {
ui_choice: {
selectedOptionIds: ['fineui'],
answerText: '使用 FineUI 弹窗,方便固定按钮 ID。',
},
button_id: {
selectedOptionIds: ['confirm'],
answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。',
},
},
}));
const promptSubmitted = await nextMessage(messages, ws, (msg) => (
msg.type === 'ccweb_prompt_user_remove' &&
msg.sessionId === codexAppSession.sessionId &&
msg.promptId === promptUserResult.body.promptId
));
assert(promptSubmitted.prompt?.status === 'submitted', 'Prompt remove event should carry submitted status');
assert(promptSubmitted.prompt?.answers?.ui_choice?.selectedOptionLabels?.[0] === 'FineUI 弹窗', 'Prompt remove event should include selected option labels');
const promptAnswerMessage = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.role === 'user' &&
/我已回答 ccweb 提示的问题/.test(msg.message.content || '') &&
/btnShortageReleaseConfirm/.test(msg.message.content || '')
));
assert(/使用 FineUI 弹窗,方便固定按钮 ID。/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should become a normal user message with the free-form answer');
const promptAnswerDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/btnShortageReleaseConfirm/.test(msg.text || '')
));
assert(/我已回答 ccweb 提示的问题/.test(promptAnswerDelta.text || ''), 'Prompt submission should trigger a Codex App turn with the answer text');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const storedPromptMessage = storedCodexApp.messages.find((message) => message.ccwebPrompt?.id === promptUserResult.body.promptId);
assert(!storedPromptMessage, 'Submitted prompt message should be removed from session history');
assert(storedCodexApp.messages.some((message) => message.role === 'user' && /btnShortageReleaseConfirm/.test(String(message.content || ''))), 'Prompt response user message should persist in session history');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'running'
);
assert(ccwebMcpChildRunning.toolUseId === 'tool-collab', 'ccweb MCP child update should reference the parent collab tool');
const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab');
assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls');
assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'ccweb MCP collab tool should include child thread ids');
const ccwebMcpChildReturned = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'returned' &&
/子代理最终消息/.test(msg.child?.candidateResult || '')
);
assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' }));
const 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(/"hasWaitAgentRetryGuidance":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep wait_agent retry guidance');
assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level');
assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp guided prompt', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
const guidedRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_user_input_request' && msg.sessionId === codexAppSession.sessionId);
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
ws.send(JSON.stringify({
type: 'codex_app_user_input_response',
action: 'submit',
sessionId: codexAppSession.sessionId,
requestId: guidedRequest.requestId,
answers: { choice: { answers: ['A'] } },
}));
const guidedSubmitted = await nextMessage(messages, ws, (msg) =>
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/已提交.*引导输入/.test(msg.message || '')
);
assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint');
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId);
assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests');
assert(approvalRequest.itemId === 'approval-command-call', 'Codex App approval request should keep item id');
assert(/echo approved/.test(JSON.stringify(approvalRequest.payload || {})), 'Codex App approval request should include command payload');
ws.send(JSON.stringify({
type: 'codex_app_approval_response',
action: 'approve_session',
sessionId: codexAppSession.sessionId,
requestId: approvalRequest.requestId,
}));
const approvalSubmitted = await nextMessage(messages, ws, (msg) =>
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/本会话执行/.test(msg.message || '')
);
assert(/本会话执行/.test(approvalSubmitted.message || ''), 'Codex App should show approval confirmation hint');
const approvalDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /approval decision: acceptForSession/.test(msg.text || ''));
assert(/approval decision: acceptForSession/.test(approvalDelta.text || ''), 'Codex App should continue after approval response');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
await sleep(500);
ws.send(JSON.stringify({
type: 'message',
text: 'runtime 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');
ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
const codexAppImportSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
const codexAppImportItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
assert(codexAppImportItem, 'Codex App session listing failed');
assert(codexAppImportItem.agent === 'codexapp', 'Codex App import listing should echo target agent');
assert(codexAppImportItem.alreadyImported === false, 'Codex App import should not reuse old Codex imported state');
const duplicateSourceItems = codexAppImportSessions.sessions.filter((item) => item.sourceConversationId === duplicateSourceConversationId);
assert(duplicateSourceItems.length === 1, 'Codex App import list should collapse rollout entries from the same cc-web source conversation');
assert(duplicateSourceItems[0].duplicateCount === 2, 'Collapsed Codex App import item should report duplicate rollout count');
const objectSourceItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId);
assert(objectSourceItem?.source === 'subagent', 'Codex App import list should format object source metadata');
ws.send(JSON.stringify({
type: 'import_codex_session',
agent: 'codexapp',
threadId: codexAppImportItem.threadId,
rolloutPath: codexAppImportItem.rolloutPath,
}));
const importedCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt');
assert(importedCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App import parsed wrong first message');
assert(importedCodexApp.totalUsage?.inputTokens === 20, 'Codex App import usage parse failed');
const importedCodexAppPath = path.join(sessionsDir, `${importedCodexApp.sessionId}.json`);
const storedImportedCodexApp = JSON.parse(fs.readFileSync(importedCodexAppPath, 'utf8'));
assert(storedImportedCodexApp.agent === 'codexapp', 'Codex App import should persist codexapp agent');
assert(storedImportedCodexApp.codexAppThreadId === codexAppImportFixture.threadId, 'Codex App import should persist codexAppThreadId');
assert(!storedImportedCodexApp.codexThreadId, 'Codex App import should not persist legacy codexThreadId');
ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
const codexAppImportSessionsAfter = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
const codexAppImportItemAfter = codexAppImportSessionsAfter.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported');
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedCodexApp.sessionId));
assert(!fs.existsSync(importedCodexAppPath), 'Deleting Codex App imported session did not remove cc-web session JSON');
assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery');
ws.send(JSON.stringify({
type: 'import_codex_session',
agent: 'codexapp',
threadId: codexAppImportFixture.threadId,
rolloutPath: codexAppImportFixture.rolloutPath,
}));
const restoredCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt');
assert(restoredCodexApp.sessionId !== importedCodexApp.sessionId, 'Codex App deleted session should be recreated from rollout history');
assert(restoredCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App re-import should restore messages after cc-web deletion');
const importedSessionId = importedCodex.sessionId;
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId));
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
if (codexFixture.stateDb) {
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
}
ws.close();
console.log('Regression checks passed.');
});
const recoveryPort = await getFreePort();
const recoveryEnv = {
PORT: String(recoveryPort),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
};
const recoveryServer = await startServer(recoveryEnv);
let recoverySessionId = null;
let recoveryStatePath = null;
try {
const { ws, messages } = await connectWs(recoveryPort, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const recoverCwd = path.join(tempRoot, 'codexapp-recover-space');
mkdirp(recoverCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: recoverCwd, mode: 'yolo' }));
const recoverSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === recoverCwd);
recoverySessionId = recoverSession.sessionId;
ws.send(JSON.stringify({ type: 'message', text: 'slow recover codexapp prompt', sessionId: recoverySessionId, mode: 'yolo', agent: 'codexapp' }));
recoveryStatePath = path.join(sessionsDir, `${recoverySessionId}-run`, 'codexapp-state.json');
await waitForFile(recoveryStatePath);
let recoverStateStarted = null;
const stateStartedAt = Date.now();
while (Date.now() - stateStartedAt < 5000) {
recoverStateStarted = JSON.parse(fs.readFileSync(recoveryStatePath, 'utf8'));
if (/partial before restart/.test(recoverStateStarted.fullText || '')
&& recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || ''))) {
break;
}
await sleep(50);
}
assert(/partial before restart/.test(recoverStateStarted?.fullText || ''), 'Codex App running state should persist partial text before completion');
assert(recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Codex App running state should persist partial tool output');
ws.close();
} finally {
await recoveryServer.stop('SIGKILL');
}
assert(recoverySessionId, 'Codex App recovery test did not create a session');
assert(recoveryStatePath && fs.existsSync(recoveryStatePath), 'Codex App recovery state should survive server crash');
const restartedRecoveryServer = await startServer(recoveryEnv);
try {
const { ws, messages } = await connectWs(recoveryPort, password);
const recoveredList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === recoverySessionId));
const recoveredMeta = recoveredList.sessions.find((session) => session.id === recoverySessionId);
assert(recoveredMeta && !recoveredMeta.isRunning, 'Recovered Codex App partial turn should not stay marked running');
assert(!fs.existsSync(recoveryStatePath), 'Recovered Codex App state file should be cleaned after startup recovery');
ws.send(JSON.stringify({ type: 'load_session', sessionId: recoverySessionId }));
const recoveredSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === recoverySessionId);
const recoveredAssistantMessages = (recoveredSessionInfo.messages || []).filter((message) => message.role === 'assistant' && /partial before restart/.test(String(message.content || '')));
assert(recoveredAssistantMessages.length === 1, 'Recovered Codex App partial assistant output should be persisted exactly once');
assert(recoveredAssistantMessages[0].codexAppRecoveredPartial === true, 'Recovered Codex App assistant output should be marked partial');
assert(recoveredAssistantMessages[0].toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Recovered Codex App assistant output should keep tool calls');
ws.close();
} finally {
await restartedRecoveryServer.stop();
}
const oversizedRecoverySessionId = 'oversized-recovery-session';
const oversizedRecoveryStateDir = path.join(sessionsDir, `${oversizedRecoverySessionId}-run`);
const oversizedRecoveryStatePath = path.join(oversizedRecoveryStateDir, 'codexapp-state.json');
fs.writeFileSync(path.join(sessionsDir, `${oversizedRecoverySessionId}.json`), JSON.stringify({
id: oversizedRecoverySessionId,
title: 'Oversized Recovery',
created: new Date().toISOString(),
updated: new Date().toISOString(),
pinnedAt: null,
agent: 'codexapp',
claudeSessionId: null,
codexThreadId: null,
codexAppThreadId: 'oversized-thread',
model: 'gpt-5.5(xhigh)',
permissionMode: 'yolo',
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
cwd: homeDir,
}, null, 2));
mkdirp(oversizedRecoveryStateDir);
fs.writeFileSync(oversizedRecoveryStatePath, JSON.stringify({
version: 1,
agent: 'codexapp',
sessionId: oversizedRecoverySessionId,
threadId: 'oversized-thread',
turnId: 'oversized-turn',
turnStatus: 'running',
fullText: 'x'.repeat(5 * 1024 * 1024),
toolCalls: [],
}));
assert(fs.statSync(oversizedRecoveryStatePath).size > 4 * 1024 * 1024, 'Oversized recovery fixture should exceed the state load guard');
const oversizedRecoveryServer = await startServer(recoveryEnv);
try {
const { ws, messages } = await connectWs(recoveryPort, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId));
assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state');
ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId }));
const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId);
assert((oversizedSessionInfo.messages || []).some((message) => (
message.role === 'system' &&
/状态文件异常/.test(String(message.content || '')) &&
/跳过恢复/.test(String(message.content || ''))
)), 'Oversized Codex App recovery should add a system notice');
ws.close();
} finally {
await oversizedRecoveryServer.stop();
}
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});