1788 lines
117 KiB
JavaScript
1788 lines
117 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const net = require('net');
|
|
const { spawn, spawnSync } = require('child_process');
|
|
const WebSocket = require('ws');
|
|
|
|
const REPO_DIR = path.resolve(__dirname, '..');
|
|
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
|
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
|
|
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
|
|
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
|
|
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
|
|
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;
|
|
|
|
function mkdirp(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getFreePort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.on('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const addr = server.address();
|
|
const port = addr && typeof addr === 'object' ? addr.port : null;
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function sql(dbPath, statement) {
|
|
if (!HAS_SQLITE3) throw new Error('sqlite3 is not available');
|
|
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
|
|
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
async function waitForPort(port, timeoutMs = 10000) {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' });
|
|
if (probe.status === 0) return;
|
|
await sleep(100);
|
|
}
|
|
throw new Error(`Timed out waiting for port ${port}`);
|
|
}
|
|
|
|
async function waitForFile(filePath, timeoutMs = 10000) {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
if (fs.existsSync(filePath)) return;
|
|
await sleep(50);
|
|
}
|
|
throw new Error(`Timed out waiting for file: ${filePath}`);
|
|
}
|
|
|
|
async function waitForJsonCondition(filePath, predicate, timeoutMs = 5000) {
|
|
const started = Date.now();
|
|
let lastError = null;
|
|
while (Date.now() - started < timeoutMs) {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
if (predicate(parsed)) return parsed;
|
|
}
|
|
} catch (err) {
|
|
lastError = err;
|
|
}
|
|
await sleep(50);
|
|
}
|
|
throw new Error(`Timed out waiting for JSON condition: ${filePath}${lastError ? ` (${lastError.message})` : ''}`);
|
|
}
|
|
|
|
async function withServer(env, fn) {
|
|
const child = spawn('/usr/bin/node', [SERVER_PATH], {
|
|
cwd: REPO_DIR,
|
|
env: { ...process.env, ...env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
|
|
try {
|
|
await waitForPort(env.PORT, 10000);
|
|
await fn({ child, stdout: () => stdout, stderr: () => stderr });
|
|
} finally {
|
|
child.kill('SIGTERM');
|
|
await sleep(300);
|
|
if (!child.killed) child.kill('SIGKILL');
|
|
}
|
|
}
|
|
|
|
async function startServer(env) {
|
|
const child = spawn('/usr/bin/node', [SERVER_PATH], {
|
|
cwd: REPO_DIR,
|
|
env: { ...process.env, ...env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
await waitForPort(env.PORT, 10000);
|
|
return {
|
|
child,
|
|
stdout: () => stdout,
|
|
stderr: () => stderr,
|
|
async stop(signal = 'SIGTERM') {
|
|
if (child.exitCode !== null || child.signalCode) return;
|
|
child.kill(signal);
|
|
await sleep(300);
|
|
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
|
|
await sleep(200);
|
|
},
|
|
};
|
|
}
|
|
|
|
function connectWs(port, password) {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
|
const messages = [];
|
|
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) {
|
|
const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12');
|
|
mkdirp(sessionsDir);
|
|
const threadId = 'codex-import-thread';
|
|
const rolloutPath = path.join(sessionsDir, 'rollout-2026-03-12T00-00-00-codex-import-thread.jsonl');
|
|
const rolloutLines = [
|
|
JSON.stringify({
|
|
timestamp: '2026-03-12T00:00:00.000Z',
|
|
type: 'session_meta',
|
|
payload: { id: threadId, cwd: '/tmp/project-b', cli_version: '0.114.0', source: 'exec' },
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: '2026-03-12T00:00:00.100Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }],
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: '2026-03-12T00:00:01.000Z',
|
|
type: 'event_msg',
|
|
payload: { type: 'user_message', message: 'Codex import prompt' },
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: '2026-03-12T00:00:02.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'output_text', text: 'Codex import answer' }],
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: '2026-03-12T00:00:03.000Z',
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'token_count',
|
|
info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } },
|
|
},
|
|
}),
|
|
];
|
|
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
|
|
|
|
let stateDb = null;
|
|
let logsDb = null;
|
|
if (HAS_SQLITE3) {
|
|
stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
|
|
mkdirp(path.dirname(stateDb));
|
|
sql(stateDb, `
|
|
PRAGMA journal_mode = WAL;
|
|
CREATE TABLE IF NOT EXISTS threads (
|
|
id TEXT PRIMARY KEY,
|
|
rollout_path TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
source TEXT NOT NULL,
|
|
model_provider TEXT NOT NULL,
|
|
cwd TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
sandbox_policy TEXT NOT NULL,
|
|
approval_mode TEXT NOT NULL,
|
|
tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
has_user_event INTEGER NOT NULL DEFAULT 0,
|
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
archived_at INTEGER,
|
|
git_sha TEXT,
|
|
git_branch TEXT,
|
|
git_origin_url TEXT,
|
|
cli_version TEXT NOT NULL DEFAULT '',
|
|
first_user_message TEXT NOT NULL DEFAULT '',
|
|
agent_nickname TEXT,
|
|
agent_role TEXT,
|
|
memory_mode TEXT NOT NULL DEFAULT 'enabled'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS stage1_outputs (
|
|
thread_id TEXT PRIMARY KEY,
|
|
source_updated_at INTEGER NOT NULL,
|
|
raw_memory TEXT NOT NULL,
|
|
rollout_summary TEXT NOT NULL,
|
|
generated_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
|
thread_id TEXT NOT NULL,
|
|
position INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
input_schema TEXT NOT NULL,
|
|
PRIMARY KEY(thread_id, position)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
ts_nanos INTEGER NOT NULL,
|
|
level TEXT NOT NULL,
|
|
target TEXT NOT NULL,
|
|
message TEXT,
|
|
module_path TEXT,
|
|
file TEXT,
|
|
line INTEGER,
|
|
thread_id TEXT,
|
|
process_uuid TEXT,
|
|
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
|
|
VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0');
|
|
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
|
`);
|
|
|
|
logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
|
|
sql(logsDb, `
|
|
CREATE TABLE IF NOT EXISTS logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
ts_nanos INTEGER NOT NULL,
|
|
level TEXT NOT NULL,
|
|
target TEXT NOT NULL,
|
|
message TEXT,
|
|
module_path TEXT,
|
|
file TEXT,
|
|
line INTEGER,
|
|
thread_id TEXT,
|
|
process_uuid TEXT,
|
|
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
|
`);
|
|
}
|
|
|
|
return { threadId, rolloutPath, stateDb, logsDb };
|
|
}
|
|
|
|
function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = 'xhigh' } = {}) {
|
|
const codexDir = path.join(homeDir, '.codex');
|
|
mkdirp(codexDir);
|
|
fs.writeFileSync(path.join(codexDir, 'config.toml'), [
|
|
'model_provider = "test"',
|
|
`model = "${model}"`,
|
|
`model_reasoning_effort = "${reasoningEffort}"`,
|
|
'',
|
|
'[projects."/tmp/project-b"]',
|
|
'trust_level = "trusted"',
|
|
'',
|
|
'[mcp_servers.reg-config]',
|
|
'command = "node"',
|
|
'args = ["regression-mcp.js"]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
|
|
function assertFrontendGenerationControlsContract() {
|
|
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
|
const controlsStart = source.indexOf('function updateGenerationControls()');
|
|
const controlsEnd = source.indexOf('\n function updateNoteModeUI()', controlsStart);
|
|
assert(controlsStart >= 0 && controlsEnd > controlsStart, 'Frontend should define updateGenerationControls before updateNoteModeUI');
|
|
|
|
for (const target of ['sendBtn.hidden', 'abortBtn.hidden']) {
|
|
const regex = new RegExp(`${target.replace('.', '\\.')}\\s*=`, 'g');
|
|
let match;
|
|
while ((match = regex.exec(source))) {
|
|
assert(
|
|
match.index > controlsStart && match.index < controlsEnd,
|
|
`${target} should only be assigned in updateGenerationControls`
|
|
);
|
|
}
|
|
}
|
|
|
|
const resumeStart = source.indexOf("case 'resume_generating':");
|
|
const resumeEnd = source.indexOf("case 'error':", resumeStart);
|
|
assert(resumeStart >= 0 && resumeEnd > resumeStart, 'Frontend should keep an explicit resume_generating handler');
|
|
const resumeBlock = source.slice(resumeStart, resumeEnd);
|
|
assert(
|
|
resumeBlock.includes('updateGenerationControls();'),
|
|
'resume_generating should refresh send/abort controls when reusing an existing streaming bubble'
|
|
);
|
|
|
|
const controlsBlock = source.slice(controlsStart, controlsEnd);
|
|
assert(
|
|
/allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock),
|
|
'Codex App should keep the runtime insert send button visible while generating'
|
|
);
|
|
const staleDefaultApprovalWarning = ['默认模式的', '授权申请功能', '暂未实现'].join('');
|
|
assert(
|
|
!source.includes(staleDefaultApprovalWarning),
|
|
'Frontend should not show the stale default-mode approval warning after Codex App approvals are supported'
|
|
);
|
|
}
|
|
|
|
function assertFrontendComposerMcpContract() {
|
|
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
|
const requestStart = source.indexOf('function requestComposerSuggestions()');
|
|
const requestEnd = source.indexOf('\n function handleComposerSuggestions', requestStart);
|
|
assert(requestStart >= 0 && requestEnd > requestStart, 'Frontend should define requestComposerSuggestions before handleComposerSuggestions');
|
|
|
|
const requestBlock = source.slice(requestStart, requestEnd);
|
|
const slashStart = requestBlock.indexOf("if (token.trigger === '/')");
|
|
const slashEnd = requestBlock.indexOf('clearTimeout(composerSuggestionTimer);', slashStart);
|
|
assert(slashStart >= 0 && slashEnd > slashStart, 'Slash composer branch should precede backend debounce');
|
|
const slashBlock = requestBlock.slice(slashStart, slashEnd);
|
|
assert(slashBlock.includes('getLocalSlashSuggestions(token.query)'), 'Slash composer should keep local fallback suggestions');
|
|
assert(!/\breturn\s*;/.test(slashBlock), 'Slash composer should continue to backend suggestions so MCP items can be merged');
|
|
|
|
const menuStart = source.indexOf('function showCmdMenu(token, items)');
|
|
const menuEnd = source.indexOf('\n function requestComposerSuggestions()', menuStart);
|
|
assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
|
|
const menuBlock = source.slice(menuStart, menuEnd);
|
|
assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels');
|
|
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');
|
|
}
|
|
|
|
async function main() {
|
|
assertFrontendGenerationControlsContract();
|
|
assertFrontendComposerMcpContract();
|
|
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
|
const configDir = path.join(tempRoot, 'config');
|
|
const sessionsDir = path.join(tempRoot, 'sessions');
|
|
const logsDir = path.join(tempRoot, 'logs');
|
|
const homeDir = path.join(tempRoot, 'home');
|
|
mkdirp(configDir);
|
|
mkdirp(sessionsDir);
|
|
mkdirp(logsDir);
|
|
mkdirp(homeDir);
|
|
|
|
fs.writeFileSync(path.join(configDir, 'notify.json'), JSON.stringify({
|
|
provider: 'off',
|
|
pushplus: { token: '' },
|
|
telegram: { botToken: '', chatId: '' },
|
|
serverchan: { sendKey: '' },
|
|
feishu: { webhook: '' },
|
|
qqbot: { qmsgKey: '' },
|
|
}, null, 2));
|
|
|
|
const skillDir = path.join(homeDir, '.codex', 'skills', 'regression-skill');
|
|
mkdirp(skillDir);
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
|
'---',
|
|
'name: regression-skill',
|
|
'description: Regression skill for composer suggestions.',
|
|
'---',
|
|
'',
|
|
'# Regression Skill',
|
|
'',
|
|
'Use this only in regression tests.',
|
|
].join('\n'));
|
|
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 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(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');
|
|
|
|
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');
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
|
|
|
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
|
const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) =>
|
|
msg.type === 'ccweb_mcp_child_agent_update' &&
|
|
msg.sessionId === codexAppSession.sessionId &&
|
|
msg.child?.threadId === 'child-thread-a' &&
|
|
msg.child?.status === 'running'
|
|
);
|
|
assert(ccwebMcpChildRunning.toolUseId === 'tool-collab', 'ccweb MCP child update should reference the parent collab tool');
|
|
const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab');
|
|
assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls');
|
|
assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'ccweb MCP collab tool should include child thread ids');
|
|
const ccwebMcpChildReturned = await nextMessage(messages, ws, (msg) =>
|
|
msg.type === 'ccweb_mcp_child_agent_update' &&
|
|
msg.sessionId === codexAppSession.sessionId &&
|
|
msg.child?.threadId === 'child-thread-a' &&
|
|
msg.child?.status === 'returned' &&
|
|
/子代理最终消息/.test(msg.child?.candidateResult || '')
|
|
);
|
|
assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result');
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
|
ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' }));
|
|
const ccwebMcpChildClosed = await nextMessage(messages, ws, (msg) =>
|
|
msg.type === 'ccweb_mcp_child_agent_update' &&
|
|
msg.sessionId === codexAppSession.sessionId &&
|
|
msg.child?.threadId === 'child-thread-a' &&
|
|
msg.child?.status === 'closed'
|
|
);
|
|
assert(/"status": "closed"/.test(ccwebMcpChildClosed.tool?.result || ''), 'ccweb MCP child close should update the parent collab tool state');
|
|
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
|
const hasCollabTool = storedCodexApp.messages
|
|
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
|
.some((tool) => tool.kind === 'collab_agent_tool_call');
|
|
assert(hasCollabTool, 'ccweb MCP collab tool should be persisted into session history');
|
|
const persistedClosedCollabTool = storedCodexApp.messages
|
|
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
|
.reverse()
|
|
.find((tool) => tool.id === 'tool-collab');
|
|
assert(/"status": "closed"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child close should persist closed state');
|
|
|
|
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
|
|
const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
|
|
assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode');
|
|
assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions');
|
|
assert(/"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(150);
|
|
ws.send(JSON.stringify({
|
|
type: 'message',
|
|
text: 'runtime steer insert',
|
|
sessionId: codexAppSession.sessionId,
|
|
mode: 'yolo',
|
|
agent: 'codexapp',
|
|
clientMessageId: 'regression-steer-message',
|
|
}));
|
|
const steerPending = await nextMessage(messages, ws, (msg) =>
|
|
msg.type === 'codex_app_steer_status' &&
|
|
msg.sessionId === codexAppSession.sessionId &&
|
|
msg.clientMessageId === 'regression-steer-message' &&
|
|
msg.status === 'pending'
|
|
);
|
|
assert(/引导中/.test(steerPending.message || ''), 'Codex App steer should expose pending status');
|
|
const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || ''));
|
|
assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer');
|
|
const steerInserted = await nextMessage(messages, ws, (msg) =>
|
|
msg.type === 'codex_app_steer_status' &&
|
|
msg.sessionId === codexAppSession.sessionId &&
|
|
msg.clientMessageId === 'regression-steer-message' &&
|
|
msg.status === 'inserted'
|
|
);
|
|
assert(/已插入/.test(steerInserted.message || ''), 'Codex App steer should expose inserted status');
|
|
const steerSystemMessage = await nextMessage(messages, ws, (msg) =>
|
|
msg.type === 'system_message' &&
|
|
msg.sessionId === codexAppSession.sessionId &&
|
|
/已引导对话: runtime steer insert/.test(msg.message || '')
|
|
);
|
|
assert(steerSystemMessage.transient === true, 'Codex App steer marker should be transient');
|
|
assert(/已引导对话: runtime steer insert/.test(steerSystemMessage.message || ''), 'Codex App steer should show guided conversation marker with preview');
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
|
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
|
assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
|
|
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
|
|
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');
|
|
|
|
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp abort prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
|
const codexAppRunningMcp = await callInternalMcp(port, internalMcpToken, {
|
|
tool: 'ccweb_send_message',
|
|
sourceSessionId: codexSession.sessionId,
|
|
sourceHopCount: 0,
|
|
args: {
|
|
targetConversationId: codexAppSession.sessionId,
|
|
content: 'running codexapp target should reject this',
|
|
},
|
|
});
|
|
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');
|
|
await sleep(150);
|
|
ws.send(JSON.stringify({ type: 'abort' }));
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
|
|
|
const claudeAttachment = await uploadAttachment(port, token, {
|
|
filename: 'claude-test.png',
|
|
mime: 'image/png',
|
|
data: Buffer.from('claude-image'),
|
|
});
|
|
ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' }));
|
|
const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment');
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
|
|
const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
|
|
.trim()
|
|
.split('\n')
|
|
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
|
|
assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
|
|
const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
|
|
assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata');
|
|
assert(storedClaudeSession.claudeSessionId, 'Claude session id should be persisted after first run');
|
|
const claudeSessionIdBeforeMode = storedClaudeSession.claudeSessionId;
|
|
|
|
// Mode switching must not clear Claude runtime session id (resume should keep context).
|
|
ws.send(JSON.stringify({ type: 'set_mode', sessionId: claudeImageSession.sessionId, mode: 'plan' }));
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan');
|
|
const storedClaudeAfterMode = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
|
|
assert(storedClaudeAfterMode.claudeSessionId === claudeSessionIdBeforeMode, 'Claude session id should survive mode switch');
|
|
|
|
ws.send(JSON.stringify({ type: 'message', text: 'second claude prompt', sessionId: claudeImageSession.sessionId, mode: 'plan', agent: 'claude' }));
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
|
|
const claudeSpawns = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
|
|
.trim()
|
|
.split('\n')
|
|
.filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
|
|
const lastClaudeSpawn = claudeSpawns[claudeSpawns.length - 1] || '';
|
|
assert(lastClaudeSpawn.includes(`--resume ${claudeSessionIdBeforeMode}`), 'Claude mode switch should keep --resume session id');
|
|
assert(lastClaudeSpawn.includes('--permission-mode plan'), 'Claude plan mode should set --permission-mode plan');
|
|
|
|
ws.send(JSON.stringify({ type: 'list_native_sessions' }));
|
|
const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions');
|
|
assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed');
|
|
const firstClaude = nativeSessions.groups[0].sessions[0];
|
|
ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir }));
|
|
const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt');
|
|
assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message');
|
|
|
|
ws.send(JSON.stringify({ type: 'list_codex_sessions' }));
|
|
const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
|
|
const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId);
|
|
assert(importedCodexItem, 'Codex session listing failed');
|
|
|
|
ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath }));
|
|
const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt');
|
|
assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions');
|
|
assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed');
|
|
|
|
const importedSessionId = importedCodex.sessionId;
|
|
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId }));
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId));
|
|
|
|
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
|
|
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
|
|
if (codexFixture.stateDb) {
|
|
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
|
|
}
|
|
|
|
ws.close();
|
|
console.log('Regression checks passed.');
|
|
});
|
|
|
|
const recoveryPort = await getFreePort();
|
|
const recoveryEnv = {
|
|
PORT: String(recoveryPort),
|
|
CC_WEB_PASSWORD: password,
|
|
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
|
|
CC_WEB_CONFIG_DIR: configDir,
|
|
CC_WEB_SESSIONS_DIR: sessionsDir,
|
|
CC_WEB_LOGS_DIR: logsDir,
|
|
HOME: homeDir,
|
|
CLAUDE_PATH: MOCK_CLAUDE,
|
|
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
|
};
|
|
|
|
const recoveryServer = await startServer(recoveryEnv);
|
|
let recoverySessionId = null;
|
|
let recoveryStatePath = null;
|
|
try {
|
|
const { ws, messages } = await connectWs(recoveryPort, password);
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
|
const recoverCwd = path.join(tempRoot, 'codexapp-recover-space');
|
|
mkdirp(recoverCwd);
|
|
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: recoverCwd, mode: 'yolo' }));
|
|
const recoverSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === recoverCwd);
|
|
recoverySessionId = recoverSession.sessionId;
|
|
|
|
ws.send(JSON.stringify({ type: 'message', text: 'slow recover codexapp prompt', sessionId: recoverySessionId, mode: 'yolo', agent: 'codexapp' }));
|
|
recoveryStatePath = path.join(sessionsDir, `${recoverySessionId}-run`, 'codexapp-state.json');
|
|
await waitForFile(recoveryStatePath);
|
|
let recoverStateStarted = null;
|
|
const stateStartedAt = Date.now();
|
|
while (Date.now() - stateStartedAt < 5000) {
|
|
recoverStateStarted = JSON.parse(fs.readFileSync(recoveryStatePath, 'utf8'));
|
|
if (/partial before restart/.test(recoverStateStarted.fullText || '')
|
|
&& recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || ''))) {
|
|
break;
|
|
}
|
|
await sleep(50);
|
|
}
|
|
assert(/partial before restart/.test(recoverStateStarted?.fullText || ''), 'Codex App running state should persist partial text before completion');
|
|
assert(recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Codex App running state should persist partial tool output');
|
|
ws.close();
|
|
} finally {
|
|
await recoveryServer.stop('SIGKILL');
|
|
}
|
|
|
|
assert(recoverySessionId, 'Codex App recovery test did not create a session');
|
|
assert(recoveryStatePath && fs.existsSync(recoveryStatePath), 'Codex App recovery state should survive server crash');
|
|
|
|
const restartedRecoveryServer = await startServer(recoveryEnv);
|
|
try {
|
|
const { ws, messages } = await connectWs(recoveryPort, password);
|
|
const recoveredList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === recoverySessionId));
|
|
const recoveredMeta = recoveredList.sessions.find((session) => session.id === recoverySessionId);
|
|
assert(recoveredMeta && !recoveredMeta.isRunning, 'Recovered Codex App partial turn should not stay marked running');
|
|
assert(!fs.existsSync(recoveryStatePath), 'Recovered Codex App state file should be cleaned after startup recovery');
|
|
|
|
ws.send(JSON.stringify({ type: 'load_session', sessionId: recoverySessionId }));
|
|
const recoveredSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === recoverySessionId);
|
|
const recoveredAssistantMessages = (recoveredSessionInfo.messages || []).filter((message) => message.role === 'assistant' && /partial before restart/.test(String(message.content || '')));
|
|
assert(recoveredAssistantMessages.length === 1, 'Recovered Codex App partial assistant output should be persisted exactly once');
|
|
assert(recoveredAssistantMessages[0].codexAppRecoveredPartial === true, 'Recovered Codex App assistant output should be marked partial');
|
|
assert(recoveredAssistantMessages[0].toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Recovered Codex App assistant output should keep tool calls');
|
|
|
|
ws.close();
|
|
} finally {
|
|
await restartedRecoveryServer.stop();
|
|
}
|
|
|
|
const oversizedRecoverySessionId = 'oversized-recovery-session';
|
|
const oversizedRecoveryStateDir = path.join(sessionsDir, `${oversizedRecoverySessionId}-run`);
|
|
const oversizedRecoveryStatePath = path.join(oversizedRecoveryStateDir, 'codexapp-state.json');
|
|
fs.writeFileSync(path.join(sessionsDir, `${oversizedRecoverySessionId}.json`), JSON.stringify({
|
|
id: oversizedRecoverySessionId,
|
|
title: 'Oversized Recovery',
|
|
created: new Date().toISOString(),
|
|
updated: new Date().toISOString(),
|
|
pinnedAt: null,
|
|
agent: 'codexapp',
|
|
claudeSessionId: null,
|
|
codexThreadId: null,
|
|
codexAppThreadId: 'oversized-thread',
|
|
model: 'gpt-5.5(xhigh)',
|
|
permissionMode: 'yolo',
|
|
totalCost: 0,
|
|
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
|
messages: [],
|
|
cwd: homeDir,
|
|
}, null, 2));
|
|
mkdirp(oversizedRecoveryStateDir);
|
|
fs.writeFileSync(oversizedRecoveryStatePath, JSON.stringify({
|
|
version: 1,
|
|
agent: 'codexapp',
|
|
sessionId: oversizedRecoverySessionId,
|
|
threadId: 'oversized-thread',
|
|
turnId: 'oversized-turn',
|
|
turnStatus: 'running',
|
|
fullText: 'x'.repeat(5 * 1024 * 1024),
|
|
toolCalls: [],
|
|
}));
|
|
assert(fs.statSync(oversizedRecoveryStatePath).size > 4 * 1024 * 1024, 'Oversized recovery fixture should exceed the state load guard');
|
|
|
|
const oversizedRecoveryServer = await startServer(recoveryEnv);
|
|
try {
|
|
const { ws, messages } = await connectWs(recoveryPort, password);
|
|
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId));
|
|
assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state');
|
|
ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId }));
|
|
const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId);
|
|
assert((oversizedSessionInfo.messages || []).some((message) => (
|
|
message.role === 'system' &&
|
|
/状态文件异常/.test(String(message.content || '')) &&
|
|
/跳过恢复/.test(String(message.content || ''))
|
|
)), 'Oversized Codex App recovery should add a system notice');
|
|
ws.close();
|
|
} finally {
|
|
await oversizedRecoveryServer.stop();
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err.stack || err.message);
|
|
process.exit(1);
|
|
});
|