6342 lines
374 KiB
JavaScript
6342 lines
374 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const net = require('net');
|
||
const { spawn, spawnSync } = require('child_process');
|
||
const WebSocket = require('ws');
|
||
|
||
const REPO_DIR = path.resolve(__dirname, '..');
|
||
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
||
const WINDOWS_START_PATH = path.join(REPO_DIR, 'start.bat');
|
||
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
|
||
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
|
||
const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
|
||
const GILDED_THEME_ASSETS = [
|
||
{
|
||
filename: 'gilded-wasteland.png',
|
||
format: 'png',
|
||
width: 1000,
|
||
height: 1500,
|
||
sha256: '11bf3ffed4422c08f6be217c63060ba68d63367e747a3604fc2d7b877e9c5955',
|
||
},
|
||
].map((asset) => ({
|
||
...asset,
|
||
path: path.join(REPO_DIR, 'public', 'assets', 'themes', asset.filename),
|
||
}));
|
||
const WASTELAND_THEME_ASSETS = {
|
||
root: path.join(REPO_DIR, 'public', 'assets', 'themes', 'wasteland'),
|
||
background: {
|
||
filename: 'background.webp',
|
||
width: 1672,
|
||
height: 941,
|
||
sha256: 'ee52b136294a7a31181b67e209253fefe7ace8c2b76885e45621484fecb802cb',
|
||
},
|
||
manifestFilename: path.join('icons', 'manifest.json'),
|
||
iconNames: [
|
||
'new-chat', 'search', 'dropdown', 'theme', 'ai', 'status', 'attachment', 'send', 'stop',
|
||
'settings', 'user', 'terminal', 'copy', 'refresh', 'chat', 'close', 'check',
|
||
],
|
||
};
|
||
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
|
||
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
|
||
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
|
||
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;
|
||
|
||
function mkdirp(dir) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function getFreePort() {
|
||
return new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.on('error', reject);
|
||
server.listen(0, '127.0.0.1', () => {
|
||
const addr = server.address();
|
||
const port = addr && typeof addr === 'object' ? addr.port : null;
|
||
server.close(() => resolve(port));
|
||
});
|
||
});
|
||
}
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) {
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
function escapeRegExp(value) {
|
||
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
function imageMarker(filename) {
|
||
return new RegExp(`\\[image:${escapeRegExp(filename)}\\]`);
|
||
}
|
||
|
||
function storedAttachmentImageMarker(attachment) {
|
||
const id = String(attachment?.id || '').trim();
|
||
assert(id, 'Uploaded attachment should include an id for localImage marker checks');
|
||
const mime = String(attachment?.mime || '').toLowerCase();
|
||
const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png';
|
||
return imageMarker(`${id}${ext}`);
|
||
}
|
||
|
||
function assertPersistedMessageAttachment(message, filename, label) {
|
||
const attachments = Array.isArray(message?.attachments) ? message.attachments : [];
|
||
const attachment = attachments.find((item) => item?.filename === filename);
|
||
assert(attachment, `${label} should persist attachment ${filename}`);
|
||
assert(attachment.storageState === 'available', `${label} should persist available attachment state`);
|
||
assert(!Object.prototype.hasOwnProperty.call(attachment, 'path'), `${label} should not persist local attachment paths`);
|
||
}
|
||
|
||
function sql(dbPath, statement) {
|
||
if (!HAS_SQLITE3) throw new Error('sqlite3 is not available');
|
||
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
|
||
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
|
||
return result.stdout.trim();
|
||
}
|
||
|
||
async function waitForPort(port, timeoutMs = 10000) {
|
||
const started = Date.now();
|
||
while (Date.now() - started < timeoutMs) {
|
||
const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' });
|
||
if (probe.status === 0) return;
|
||
await sleep(100);
|
||
}
|
||
throw new Error(`Timed out waiting for port ${port}`);
|
||
}
|
||
|
||
async function waitForFile(filePath, timeoutMs = 10000) {
|
||
const started = Date.now();
|
||
while (Date.now() - started < timeoutMs) {
|
||
if (fs.existsSync(filePath)) return;
|
||
await sleep(50);
|
||
}
|
||
throw new Error(`Timed out waiting for file: ${filePath}`);
|
||
}
|
||
|
||
async function waitForJsonCondition(filePath, predicate, timeoutMs = 5000) {
|
||
const started = Date.now();
|
||
let lastError = null;
|
||
while (Date.now() - started < timeoutMs) {
|
||
try {
|
||
if (fs.existsSync(filePath)) {
|
||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
if (predicate(parsed)) return parsed;
|
||
}
|
||
} catch (err) {
|
||
lastError = err;
|
||
}
|
||
await sleep(50);
|
||
}
|
||
throw new Error(`Timed out waiting for JSON condition: ${filePath}${lastError ? ` (${lastError.message})` : ''}`);
|
||
}
|
||
|
||
async function withServer(env, fn) {
|
||
const child = spawn('/usr/bin/node', [SERVER_PATH], {
|
||
cwd: REPO_DIR,
|
||
env: { ...process.env, ...env },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||
|
||
try {
|
||
await waitForPort(env.PORT, 10000);
|
||
await fn({ child, stdout: () => stdout, stderr: () => stderr });
|
||
} finally {
|
||
child.kill('SIGTERM');
|
||
await sleep(300);
|
||
if (!child.killed) child.kill('SIGKILL');
|
||
}
|
||
}
|
||
|
||
async function startServer(env) {
|
||
const child = spawn('/usr/bin/node', [SERVER_PATH], {
|
||
cwd: REPO_DIR,
|
||
env: { ...process.env, ...env },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||
await waitForPort(env.PORT, 10000);
|
||
return {
|
||
child,
|
||
stdout: () => stdout,
|
||
stderr: () => stderr,
|
||
async stop(signal = 'SIGTERM') {
|
||
if (child.exitCode !== null || child.signalCode) return;
|
||
child.kill(signal);
|
||
await sleep(300);
|
||
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
|
||
await sleep(200);
|
||
},
|
||
};
|
||
}
|
||
|
||
function connectWs(port, password, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||
const messages = [];
|
||
const receivedMessages = options.trackReceived ? [] : null;
|
||
let settled = false;
|
||
|
||
ws.on('open', () => {
|
||
ws.send(JSON.stringify({ type: 'auth', password }));
|
||
});
|
||
ws.on('message', (buf) => {
|
||
const msg = JSON.parse(String(buf));
|
||
messages.push(msg);
|
||
if (receivedMessages) receivedMessages.push(msg);
|
||
if (msg.type === 'auth_result' && msg.success) {
|
||
settled = true;
|
||
resolve({ ws, messages, receivedMessages, token: msg.token });
|
||
}
|
||
if (msg.type === 'auth_result' && !msg.success) {
|
||
settled = true;
|
||
reject(new Error('Auth failed'));
|
||
}
|
||
});
|
||
ws.on('error', (err) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
reject(err);
|
||
});
|
||
});
|
||
}
|
||
|
||
function assertWsUpgradeRejected(port, pathname) {
|
||
return new Promise((resolve, reject) => {
|
||
let settled = false;
|
||
const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`);
|
||
const timer = setTimeout(() => finish(reject, new Error(`WebSocket upgrade was not rejected for ${pathname}`)), 5000);
|
||
|
||
function finish(done, value) {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||
try { ws.terminate(); } catch {}
|
||
}
|
||
done(value);
|
||
}
|
||
|
||
ws.on('open', () => {
|
||
finish(reject, new Error(`Unexpected WebSocket connection opened for ${pathname}`));
|
||
});
|
||
ws.on('unexpected-response', (req, res) => {
|
||
res.resume();
|
||
if (res.statusCode === 404) {
|
||
finish(resolve);
|
||
return;
|
||
}
|
||
finish(reject, new Error(`Expected 404 for ${pathname}, got ${res.statusCode}`));
|
||
});
|
||
ws.on('error', (err) => {
|
||
if (/Unexpected server response: 404/.test(err.message || '')) {
|
||
finish(resolve);
|
||
return;
|
||
}
|
||
finish(reject, err);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function uploadAttachment(port, token, { filename, mime, data }) {
|
||
const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': mime,
|
||
'X-Filename': encodeURIComponent(filename),
|
||
},
|
||
body: data,
|
||
});
|
||
const payload = await response.json();
|
||
assert(response.ok && payload.ok, `Attachment upload failed: ${payload.message || response.status}`);
|
||
return payload.attachment;
|
||
}
|
||
|
||
async function fetchAuthedJson(port, token, pathname) {
|
||
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
const payload = await response.json();
|
||
assert(response.ok && payload.ok, `Request failed for ${pathname}: ${payload.message || response.status}`);
|
||
return payload;
|
||
}
|
||
|
||
async function postAuthedJson(port, token, pathname, body = {}) {
|
||
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const payload = await response.json();
|
||
assert(response.ok && payload.ok, `POST failed for ${pathname}: ${payload.message || response.status}`);
|
||
return payload;
|
||
}
|
||
|
||
async function callInternalMcp(port, token, payload) {
|
||
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CC-Web-MCP-Token': token,
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
let body = null;
|
||
try {
|
||
body = await response.json();
|
||
} catch {}
|
||
return { status: response.status, body };
|
||
}
|
||
|
||
function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
|
||
const callSite = (() => {
|
||
const stack = String(new Error().stack || '').split('\n');
|
||
return (stack[3] || stack[2] || '').trim();
|
||
})();
|
||
return new Promise((resolve, reject) => {
|
||
const started = Date.now();
|
||
const timer = setInterval(() => {
|
||
const idx = messages.findIndex(predicate);
|
||
if (idx !== -1) {
|
||
clearInterval(timer);
|
||
const found = messages.splice(idx, 1)[0];
|
||
resolve(found);
|
||
return;
|
||
}
|
||
if (Date.now() - started > timeoutMs) {
|
||
clearInterval(timer);
|
||
const recentTypes = messages.slice(-12).map((m) => m?.type).join(', ');
|
||
const pendingTypes = messages.slice(0, 12).map((m) => m?.type).join(', ');
|
||
const recentDetails = messages.slice(-6).map((m) => JSON.stringify({
|
||
type: m?.type,
|
||
sessionId: m?.sessionId,
|
||
status: m?.status,
|
||
clientMessageId: m?.clientMessageId,
|
||
code: m?.code,
|
||
text: typeof m?.text === 'string' ? m.text.slice(0, 120) : undefined,
|
||
message: typeof m?.message === 'string' ? m.message.slice(0, 120) : undefined,
|
||
})).join(' | ');
|
||
reject(new Error(`Timed out waiting for expected WebSocket message (wsState=${ws.readyState}, callSite=${callSite}, pendingTypes=[${pendingTypes}], recentTypes=[${recentTypes}], recentDetails=[${recentDetails}])`));
|
||
}
|
||
}, 50);
|
||
});
|
||
}
|
||
|
||
function isSessionCompletionMessage(msg, sessionId) {
|
||
return (msg?.type === 'done' || msg?.type === 'background_done') && msg.sessionId === sessionId;
|
||
}
|
||
|
||
function createFakeClaudeHistory(homeDir) {
|
||
const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
|
||
mkdirp(projectDir);
|
||
const sessionId = 'claude-import-test';
|
||
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
|
||
const lines = [
|
||
JSON.stringify({
|
||
type: 'user',
|
||
cwd: '/tmp/project-a',
|
||
timestamp: '2026-03-12T00:00:00.000Z',
|
||
message: { content: 'Claude import prompt' },
|
||
}),
|
||
JSON.stringify({
|
||
type: 'assistant',
|
||
timestamp: '2026-03-12T00:00:02.000Z',
|
||
message: { content: [{ type: 'text', text: 'Claude import answer' }] },
|
||
}),
|
||
];
|
||
fs.writeFileSync(filePath, `${lines.join('\n')}\n`);
|
||
return { sessionId, projectDir: 'tmp-project', filePath };
|
||
}
|
||
|
||
function createFakeCodexHistory(homeDir, options = {}) {
|
||
const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12');
|
||
mkdirp(sessionsDir);
|
||
const threadId = options.threadId || 'codex-import-thread';
|
||
const cwd = options.cwd || '/tmp/project-b';
|
||
const userText = options.userText || 'Codex import prompt';
|
||
const answerText = options.answerText || 'Codex import answer';
|
||
const source = options.source || 'exec';
|
||
const cliVersion = options.cliVersion || '0.114.0';
|
||
const fileStamp = options.fileStamp || '2026-03-12T00-00-00';
|
||
const rolloutPath = path.join(sessionsDir, `rollout-${fileStamp}-${threadId}.jsonl`);
|
||
const rolloutLines = [
|
||
JSON.stringify({
|
||
timestamp: '2026-03-12T00:00:00.000Z',
|
||
type: 'session_meta',
|
||
payload: { id: threadId, cwd, cli_version: cliVersion, source },
|
||
}),
|
||
JSON.stringify({
|
||
timestamp: '2026-03-12T00:00:00.100Z',
|
||
type: 'response_item',
|
||
payload: {
|
||
type: 'message',
|
||
role: 'user',
|
||
content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }],
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
timestamp: '2026-03-12T00:00:01.000Z',
|
||
type: 'event_msg',
|
||
payload: { type: 'user_message', message: userText },
|
||
}),
|
||
JSON.stringify({
|
||
timestamp: '2026-03-12T00:00:02.000Z',
|
||
type: 'response_item',
|
||
payload: {
|
||
type: 'message',
|
||
role: 'assistant',
|
||
content: [{ type: 'output_text', text: answerText }],
|
||
},
|
||
}),
|
||
JSON.stringify({
|
||
timestamp: '2026-03-12T00:00:03.000Z',
|
||
type: 'event_msg',
|
||
payload: {
|
||
type: 'token_count',
|
||
info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } },
|
||
},
|
||
}),
|
||
];
|
||
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
|
||
|
||
let stateDb = null;
|
||
let logsDb = null;
|
||
if (HAS_SQLITE3) {
|
||
stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
|
||
mkdirp(path.dirname(stateDb));
|
||
sql(stateDb, `
|
||
PRAGMA journal_mode = WAL;
|
||
CREATE TABLE IF NOT EXISTS threads (
|
||
id TEXT PRIMARY KEY,
|
||
rollout_path TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
updated_at INTEGER NOT NULL,
|
||
source TEXT NOT NULL,
|
||
model_provider TEXT NOT NULL,
|
||
cwd TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
sandbox_policy TEXT NOT NULL,
|
||
approval_mode TEXT NOT NULL,
|
||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||
archived INTEGER NOT NULL DEFAULT 0,
|
||
archived_at INTEGER,
|
||
git_sha TEXT,
|
||
git_branch TEXT,
|
||
git_origin_url TEXT,
|
||
cli_version TEXT NOT NULL DEFAULT '',
|
||
first_user_message TEXT NOT NULL DEFAULT '',
|
||
agent_nickname TEXT,
|
||
agent_role TEXT,
|
||
memory_mode TEXT NOT NULL DEFAULT 'enabled'
|
||
);
|
||
CREATE TABLE IF NOT EXISTS stage1_outputs (
|
||
thread_id TEXT PRIMARY KEY,
|
||
source_updated_at INTEGER NOT NULL,
|
||
raw_memory TEXT NOT NULL,
|
||
rollout_summary TEXT NOT NULL,
|
||
generated_at INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
||
thread_id TEXT NOT NULL,
|
||
position INTEGER NOT NULL,
|
||
name TEXT NOT NULL,
|
||
description TEXT NOT NULL,
|
||
input_schema TEXT NOT NULL,
|
||
PRIMARY KEY(thread_id, position)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts INTEGER NOT NULL,
|
||
ts_nanos INTEGER NOT NULL,
|
||
level TEXT NOT NULL,
|
||
target TEXT NOT NULL,
|
||
message TEXT,
|
||
module_path TEXT,
|
||
file TEXT,
|
||
line INTEGER,
|
||
thread_id TEXT,
|
||
process_uuid TEXT,
|
||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
|
||
VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, '${source}', 'OpenAI', '${cwd.replace(/'/g, "''")}', '${userText.replace(/'/g, "''")}', '{}', 'never', '${cliVersion}');
|
||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
||
`);
|
||
|
||
logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
|
||
sql(logsDb, `
|
||
CREATE TABLE IF NOT EXISTS logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts INTEGER NOT NULL,
|
||
ts_nanos INTEGER NOT NULL,
|
||
level TEXT NOT NULL,
|
||
target TEXT NOT NULL,
|
||
message TEXT,
|
||
module_path TEXT,
|
||
file TEXT,
|
||
line INTEGER,
|
||
thread_id TEXT,
|
||
process_uuid TEXT,
|
||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
||
`);
|
||
}
|
||
|
||
return { threadId, rolloutPath, stateDb, logsDb };
|
||
}
|
||
|
||
function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = 'xhigh' } = {}) {
|
||
const codexDir = path.join(homeDir, '.codex');
|
||
mkdirp(codexDir);
|
||
fs.writeFileSync(path.join(codexDir, 'config.toml'), [
|
||
'model_provider = "test"',
|
||
`model = "${model}"`,
|
||
`model_reasoning_effort = "${reasoningEffort}"`,
|
||
'',
|
||
'[projects."/tmp/project-b"]',
|
||
'trust_level = "trusted"',
|
||
'',
|
||
'[mcp_servers.reg-config]',
|
||
'command = "node"',
|
||
'args = ["regression-mcp.js"]',
|
||
'',
|
||
].join('\n'));
|
||
}
|
||
|
||
function assertFrontendSidebarCollapseContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
const buttonMarkup = indexSource.match(/<button id="menu-btn"[^>]*>/)?.[0] || '';
|
||
|
||
assert(buttonMarkup, 'Chat header should retain the sidebar toggle next to the title');
|
||
['type="button"', 'aria-controls="sidebar"', 'aria-expanded="true"', 'aria-label="收起左侧菜单"'].forEach((attribute) => {
|
||
assert(buttonMarkup.includes(attribute), `Sidebar toggle should expose ${attribute}`);
|
||
});
|
||
assert(source.includes("const SIDEBAR_COLLAPSE_STORAGE_KEY = 'cc-web-sidebar-collapsed'"), 'Desktop sidebar preference should use a stable storage key');
|
||
assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint');
|
||
assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities');
|
||
assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root');
|
||
assert(
|
||
indexSource.includes('id="user-outline-panel" class="user-outline-panel"')
|
||
&& indexSource.includes('id="ccweb-prompt-outline-panel" class="user-outline-panel ccweb-prompt-outline-panel"'),
|
||
'User-message and pending-form locators should share the responsive outline panel layout'
|
||
);
|
||
const baseOutlinePanelStyleStart = styleSource.indexOf('.user-outline-panel {');
|
||
const baseOutlinePanelStyleEnd = styleSource.indexOf('.user-outline-empty', baseOutlinePanelStyleStart);
|
||
const baseOutlinePanelStyle = styleSource.slice(baseOutlinePanelStyleStart, baseOutlinePanelStyleEnd);
|
||
assert(
|
||
/\.user-outline-panel\s*\{[^}]*position:\s*absolute;[^}]*right:\s*0;/.test(baseOutlinePanelStyle),
|
||
'Fixed-expanded desktop locators should retain their right-aligned base placement'
|
||
);
|
||
assert(source.includes('persistSidebarCollapsedPreference(nextCollapsed)'), 'Desktop sidebar toggle should persist the user preference');
|
||
assert(
|
||
source.includes("menuBtn.setAttribute('aria-expanded', String(expanded))")
|
||
&& source.includes("menuBtn.setAttribute('aria-label', label)"),
|
||
'Sidebar toggle should synchronize its accessible state and label'
|
||
);
|
||
assert(
|
||
/menuBtn\.addEventListener\('click',[\s\S]*?if \(isSidebarDrawerMode\(\)\)[\s\S]*?openSidebar\(\)[\s\S]*?if \(isSidebarDesktopCollapseMode\(\)\)[\s\S]*?setSidebarCollapsed\(/.test(source),
|
||
'Sidebar toggle should preserve the mobile drawer path before using desktop fixed collapse'
|
||
);
|
||
assert(
|
||
source.includes('[sidebarDrawerMedia, sidebarDesktopCollapseMedia].forEach((media) =>')
|
||
&& source.includes("media.addEventListener('change', handleSidebarInputModeChange)"),
|
||
'Sidebar should reset transient state when either drawer or desktop collapse mode changes'
|
||
);
|
||
|
||
assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail');
|
||
const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)');
|
||
const desktopCollapseStyleEnd = styleSource.indexOf('@media (prefers-reduced-motion: reduce)', desktopCollapseStyleStart);
|
||
assert(
|
||
desktopCollapseStyleStart >= 0 && desktopCollapseStyleEnd > desktopCollapseStyleStart,
|
||
'Desktop hover behavior should stay isolated from touch and narrow-screen drawers'
|
||
);
|
||
const desktopCollapseStyle = styleSource.slice(desktopCollapseStyleStart, desktopCollapseStyleEnd);
|
||
assert(
|
||
styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))')
|
||
&& styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'),
|
||
'Collapsed sidebar should only consume the rail width without resizing theme-specific sidebars'
|
||
);
|
||
assert(
|
||
styleSource.includes('.app.sidebar-collapsed .sidebar:is(:hover, :focus-within)')
|
||
&& styleSource.includes('transform: translateX(0)'),
|
||
'Collapsed sidebar should temporarily expand for pointer hover and keyboard focus'
|
||
);
|
||
assert(
|
||
/\.app\.sidebar-collapsed \.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*z-index:\s*120;/.test(desktopCollapseStyle),
|
||
'Both collapsed-sidebar locator panels should open toward the chat canvas above the hover preview'
|
||
);
|
||
const mobileSidebarStyleStart = styleSource.indexOf('@media (max-width: 768px)');
|
||
const mobileSidebarStyleEnd = styleSource.indexOf('@media (max-width: 480px)', mobileSidebarStyleStart);
|
||
const mobileSidebarStyle = styleSource.slice(mobileSidebarStyleStart, mobileSidebarStyleEnd);
|
||
assert(
|
||
/\.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(mobileSidebarStyle),
|
||
'Closed mobile drawer should keep both locator panels inside the chat canvas'
|
||
);
|
||
const compactMobileStyleStart = mobileSidebarStyleEnd;
|
||
const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart);
|
||
const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd);
|
||
assert(
|
||
/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle),
|
||
'Compact-mobile user locator should open left from the second grid column'
|
||
);
|
||
assert(
|
||
/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
|
||
'Compact-mobile pending-form locator should open right from the first grid column'
|
||
);
|
||
assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment');
|
||
assert(
|
||
source.includes('if (isSidebarDesktopCollapseMode())')
|
||
&& source.includes("const expanded = isDrawer\n ? sidebar.classList.contains('open')\n : (isDesktopCollapsible ? !app.classList.contains('sidebar-collapsed') : true)"),
|
||
'Unsupported wide-screen pointer modes should stay visually and accessibly expanded'
|
||
);
|
||
assert(
|
||
styleSource.includes("html[data-theme='gilded'] .app.sidebar-collapsed .sidebar::after")
|
||
&& styleSource.includes("html[data-theme='wasteland'] .app.sidebar-collapsed .sidebar::after"),
|
||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||
);
|
||
assert(
|
||
indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm')
|
||
&& indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'),
|
||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||
);
|
||
}
|
||
|
||
function assertFrontendGenerationControlsContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const controlsStart = source.indexOf('function updateGenerationControls()');
|
||
const controlsEnd = source.indexOf('\n function updateNoteModeUI()', controlsStart);
|
||
assert(controlsStart >= 0 && controlsEnd > controlsStart, 'Frontend should define updateGenerationControls before updateNoteModeUI');
|
||
|
||
for (const target of ['sendBtn.hidden', 'abortBtn.hidden']) {
|
||
const regex = new RegExp(`${target.replace('.', '\\.')}\\s*=`, 'g');
|
||
let match;
|
||
while ((match = regex.exec(source))) {
|
||
assert(
|
||
match.index > controlsStart && match.index < controlsEnd,
|
||
`${target} should only be assigned in updateGenerationControls`
|
||
);
|
||
}
|
||
}
|
||
|
||
const resumeStart = source.indexOf("case 'resume_generating':");
|
||
const resumeEnd = source.indexOf("case 'error':", resumeStart);
|
||
assert(resumeStart >= 0 && resumeEnd > resumeStart, 'Frontend should keep an explicit resume_generating handler');
|
||
const resumeBlock = source.slice(resumeStart, resumeEnd);
|
||
assert(
|
||
resumeBlock.includes('updateGenerationControls();'),
|
||
'resume_generating should refresh send/abort controls when reusing an existing streaming bubble'
|
||
);
|
||
|
||
const controlsBlock = source.slice(controlsStart, controlsEnd);
|
||
assert(
|
||
/allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock),
|
||
'Codex App should keep the runtime insert send button visible while generating'
|
||
);
|
||
const staleDefaultApprovalWarning = ['默认模式的', '授权申请功能', '暂未实现'].join('');
|
||
assert(
|
||
!source.includes(staleDefaultApprovalWarning),
|
||
'Frontend should not show the stale default-mode approval warning after Codex App approvals are supported'
|
||
);
|
||
assert(
|
||
!source.includes('Codex App 暂不支持导入') && !source.includes('Codex App 模式暂不支持导入'),
|
||
'Frontend should not disable Codex App native session import'
|
||
);
|
||
assert(
|
||
source.includes("send({ type: 'list_codex_sessions', agent: importAgent })") &&
|
||
source.includes("send({ type: 'import_codex_session', agent: importAgent"),
|
||
'Frontend Codex import modal should pass the selected Codex-like agent'
|
||
);
|
||
assert(
|
||
source.includes('function appendImportVisibilityToggle') &&
|
||
source.includes('显示已导入会话') &&
|
||
source.includes('cc-web 已存在的会话'),
|
||
'Frontend import modal should expose a toggle for already imported sessions'
|
||
);
|
||
assert(
|
||
source.includes('(group.sessions || []).filter((sess) => !sess.alreadyImported)') &&
|
||
source.includes('codexItems.filter((sess) => !sess.alreadyImported)'),
|
||
'Frontend import modal should hide already imported sessions by default'
|
||
);
|
||
}
|
||
|
||
function assertFrontendComposerMcpContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const requestStart = source.indexOf('function requestComposerSuggestions()');
|
||
const requestEnd = source.indexOf('\n function handleComposerSuggestions', requestStart);
|
||
assert(requestStart >= 0 && requestEnd > requestStart, 'Frontend should define requestComposerSuggestions before handleComposerSuggestions');
|
||
|
||
const requestBlock = source.slice(requestStart, requestEnd);
|
||
const slashStart = requestBlock.indexOf("if (token.trigger === '/')");
|
||
const slashEnd = requestBlock.indexOf('clearTimeout(composerSuggestionTimer);', slashStart);
|
||
assert(slashStart >= 0 && slashEnd > slashStart, 'Slash composer branch should precede backend debounce');
|
||
const slashBlock = requestBlock.slice(slashStart, slashEnd);
|
||
assert(slashBlock.includes('getLocalSlashSuggestions(token.query)'), 'Slash composer should keep local fallback suggestions');
|
||
assert(!/\breturn\s*;/.test(slashBlock), 'Slash composer should continue to backend suggestions so MCP items can be merged');
|
||
|
||
const menuStart = source.indexOf('function showCmdMenu(token, items)');
|
||
const menuEnd = source.indexOf('\n function requestComposerSuggestions()', menuStart);
|
||
assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
|
||
const menuBlock = source.slice(menuStart, menuEnd);
|
||
assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels');
|
||
const selectStart = source.indexOf('function selectComposerItemByIndex(index)');
|
||
const selectEnd = source.indexOf('\n function selectCmdMenuItem()', selectStart);
|
||
assert(selectStart >= 0 && selectEnd > selectStart, 'Frontend should define selectComposerItemByIndex before selectCmdMenuItem');
|
||
const selectBlock = source.slice(selectStart, selectEnd);
|
||
assert(selectBlock.includes('const insertion = String(item.insertion || item.label || item.name || \'\');'), 'Composer should insert selected item text through the generic insertion path');
|
||
assert(selectBlock.includes('const appendSpace = item.appendSpace !== false;'), 'Composer should honor appendSpace for generic MCP insertion');
|
||
assert(!source.includes('function showCcwebPromptUserComposerModal'), 'Composer should not open a parameter builder for ccweb_prompt_user');
|
||
assert(!source.includes('composer_mcp_tool_submit'), 'Frontend should not submit ccweb_prompt_user from slash composer as structured MCP args');
|
||
assert(!serverSource.includes('composer_mcp_tool_submit'), 'Server should not accept slash-composer structured MCP tool submissions');
|
||
assert(!source.includes('data-composer-mcp-questions'), 'Frontend should not render a slash-composer MCP argument builder');
|
||
assert(!source.includes('data-option-field="recommended"'), 'Frontend should not render slash-composer MCP option editors');
|
||
assert(source.includes('function renderComposerMentionsStrip(meta)'), 'Frontend should define composer mention strip renderer');
|
||
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
|
||
}
|
||
|
||
function assertComposerSlashRoutingContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
assert(source.includes('const pendingSlashDraftsByRequestId = new Map();'), 'Frontend should keep request-scoped slash drafts');
|
||
assert(source.includes('function rememberPendingSlashDraft(requestId, text, sessionId, agent)'), 'Frontend should define slash draft capture');
|
||
assert(source.includes('function applyPendingSlashDraftResponse(msg)'), 'Frontend should define slash draft response handling');
|
||
assert(source.includes('if (!msg.preserveComposerDraft) return false;'), 'Frontend should restore slash drafts only for explicit failure responses');
|
||
assert(source.includes("if (msgInput.value !== '' || pendingAttachments.length > 0) return false;"), 'Frontend should not overwrite user input typed after a slash request');
|
||
assert(source.includes('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);'), 'Frontend should remember slash draft before clearing the composer');
|
||
assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId');
|
||
assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error');
|
||
|
||
const frontendCommandsMatch = source.match(/const\s+SLASH_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
|
||
const serverCommandsMatch = serverSource.match(/const\s+COMPOSER_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
|
||
assert(frontendCommandsMatch && serverCommandsMatch, 'Frontend and server should both declare composer slash command lists');
|
||
const frontendCommandNames = Array.from(frontendCommandsMatch[1].matchAll(/\bcmd:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
|
||
const serverCommandNames = Array.from(serverCommandsMatch[1].matchAll(/\bname:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
|
||
assert(frontendCommandNames.length > 0, 'Frontend slash command list should not be empty');
|
||
assert(
|
||
JSON.stringify([...frontendCommandNames].sort()) === JSON.stringify([...serverCommandNames].sort()),
|
||
'Frontend and server should classify the same complete set of known slash commands'
|
||
);
|
||
|
||
const knownSlashSource = extractFunctionSource(source, 'isKnownSlashCommandText');
|
||
const knownSlashApi = new Function('SLASH_COMMANDS', `
|
||
${knownSlashSource}
|
||
return isKnownSlashCommandText;
|
||
`)(frontendCommandNames.map((cmd) => ({ cmd })));
|
||
frontendCommandNames.forEach((command) => {
|
||
assert(knownSlashApi(`${command.toUpperCase()} argument`), `Known slash command ${command} should match case-insensitively`);
|
||
});
|
||
assert(!knownSlashApi('/report/mcps?search') && !knownSlashApi('/help/topic'), 'Unknown slash paths should remain ordinary messages');
|
||
|
||
const tokenSource = extractFunctionSource(source, 'findActiveComposerToken');
|
||
const tokenApi = new Function(`
|
||
let msgInput = null;
|
||
${tokenSource}
|
||
return (value, cursor = value.length) => {
|
||
msgInput = { value, selectionStart: cursor };
|
||
return findActiveComposerToken();
|
||
};
|
||
`)();
|
||
assert(tokenApi('/rep')?.trigger === '/', 'Slash suggestions should trigger at the composer start');
|
||
assert(tokenApi('请查看 /rep')?.query === 'rep', 'Slash suggestions should trigger after whitespace');
|
||
assert(tokenApi('请查看\n/rep')?.query === 'rep', 'Slash suggestions should trigger at a new line');
|
||
assert(tokenApi('请查看\r\n/rep')?.query === 'rep', 'Slash suggestions should trigger after a CRLF line boundary');
|
||
assert(tokenApi('请查看 /rep', 3) === null, 'Slash suggestions should follow the cursor and ignore tokens after it');
|
||
assert(tokenApi('src/foo') === null, 'Slash suggestions should not trigger inside a path token');
|
||
assert(tokenApi('请查看 @file')?.trigger === '@' && tokenApi('请使用 $skill')?.trigger === '$', '@ and $ trigger behavior should remain unchanged');
|
||
|
||
const sendStart = source.indexOf('function sendMessage()');
|
||
const slashStart = source.indexOf('if (isKnownSlashCommandText(text))', sendStart);
|
||
const rememberStart = source.indexOf('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);', slashStart);
|
||
const modelPickerStart = source.indexOf("if (text === '/model' || text === '/model ')", slashStart);
|
||
const modePickerStart = source.indexOf("if (text === '/mode' || text === '/mode ')", slashStart);
|
||
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should reserve the slash send branch for known commands');
|
||
assert(modelPickerStart > slashStart && modelPickerStart < rememberStart, 'Frontend /model picker should stay local and clear normally');
|
||
assert(modePickerStart > slashStart && modePickerStart < rememberStart, 'Frontend /mode picker should stay local and clear normally');
|
||
|
||
assert(/const\s+handled\s*=\s*handleSlashCommand\(ws, msg\.text\.trim\(\), msg\.sessionId, msg\.agent, msg\);/.test(serverSource), 'Server should inspect slash text and retain the handled result');
|
||
assert(/if\s*\(!handled\)\s*handleMessage\(ws, msg/.test(serverSource), 'Server should pass unknown slash text into the ordinary message pipeline');
|
||
assert(serverSource.includes('function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {})'), 'Server slash handler should accept request metadata');
|
||
assert(serverSource.includes('wsSend(ws, attachClientRequestId(base, source));'), 'Server slash responses should echo requestId');
|
||
assert(/default:\s*\n\s*sendSlashSystemMessage\(`未知指令:[\s\S]*?\n\s*return false;/.test(serverSource), 'Unknown slash hints should not restore the composer and should report an unhandled command');
|
||
const serverSlashHandler = extractFunctionSource(serverSource, 'handleSlashCommand');
|
||
const serverHandledCommandNames = Array.from(serverSlashHandler.matchAll(/case\s+['"]([^'"]+)['"]\s*:/g), (match) => match[1]);
|
||
assert(
|
||
JSON.stringify([...serverCommandNames].sort()) === JSON.stringify([...serverHandledCommandNames].sort()),
|
||
'Every server-declared slash command should keep an explicit handler case'
|
||
);
|
||
const unknownGuardStart = serverSlashHandler.indexOf('if (!COMPOSER_COMMANDS.some((item) => item.name === cmd))');
|
||
const runningGuardStart = serverSlashHandler.indexOf('activeCodexAppTurns.has(sessionId)');
|
||
assert(unknownGuardStart >= 0 && unknownGuardStart < runningGuardStart, 'Unknown slash text should be classified before active Codex App command guards');
|
||
assert(serverSource.includes('wsSend(ws, attachClientRequestId({') && serverSource.includes('...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {})'), 'Server runtime errors should echo requestId and draft-preservation metadata');
|
||
}
|
||
|
||
function assertMockCodexAppPromptUserNotTextTriggered() {
|
||
const source = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
|
||
assert(!source.includes('codexapp runtime prompt mcp'), 'Mock Codex App should not expose a text-triggered ccweb_prompt_user path');
|
||
assert(!source.includes('mcp-ccweb-prompt-user'), 'Regression should not depend on a mock ccweb_prompt_user tool call id');
|
||
}
|
||
|
||
function assertPlanListProgressContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||
|
||
const normalizeSource = extractFunctionSource(source, 'normalizePlanProgress');
|
||
const payloadSource = extractFunctionSource(source, 'planProgressFromPayload');
|
||
const resolveSource = extractFunctionSource(source, 'resolveToolPlanProgress');
|
||
const progressApi = new Function(`
|
||
const toolKind = (tool) => tool?.kind || tool?.meta?.kind || null;
|
||
${normalizeSource}
|
||
${payloadSource}
|
||
${resolveSource}
|
||
return { normalizePlanProgress, planProgressFromPayload, resolveToolPlanProgress };
|
||
`)();
|
||
|
||
assert(
|
||
JSON.stringify(progressApi.normalizePlanProgress({ completed: 3, total: 5 })) === JSON.stringify({ completed: 3, total: 5 }),
|
||
'Plan progress should preserve valid completed and total counts'
|
||
);
|
||
assert(
|
||
JSON.stringify(progressApi.normalizePlanProgress({ completed: 9, total: 5 })) === JSON.stringify({ completed: 5, total: 5 }),
|
||
'Plan progress should clamp completed counts to the total'
|
||
);
|
||
assert(
|
||
JSON.stringify(progressApi.planProgressFromPayload({ items: [
|
||
{ completed: true },
|
||
{ completed: false },
|
||
{ completed: true },
|
||
] })) === JSON.stringify({ completed: 2, total: 3 }),
|
||
'Legacy todo payloads without explicit progress should derive counts from items'
|
||
);
|
||
assert(
|
||
JSON.stringify(progressApi.resolveToolPlanProgress({
|
||
kind: 'todo_list',
|
||
meta: { progress: { completed: 3, total: 5 } },
|
||
})) === JSON.stringify({ completed: 3, total: 5 }),
|
||
'Todo tool summaries should resolve progress from runtime metadata'
|
||
);
|
||
assert(
|
||
JSON.stringify(progressApi.resolveToolPlanProgress({
|
||
kind: 'todo_list',
|
||
meta: { progress: { completed: 7, total: 8 } },
|
||
input: { progress: { completed: 7, total: 8 } },
|
||
result: JSON.stringify({
|
||
type: 'todo_list',
|
||
items: Array.from({ length: 8 }, () => ({ completed: true })),
|
||
}),
|
||
})) === JSON.stringify({ completed: 8, total: 8 }),
|
||
'Latest todo result should override stale meta and input progress so the final icon lights up'
|
||
);
|
||
assert(progressApi.resolveToolPlanProgress({ kind: 'command_execution' }) === null, 'Non-plan tools should not render plan progress');
|
||
|
||
const sent = [];
|
||
const runtime = createCodexAppRuntime({
|
||
wsSend: (_ws, payload) => sent.push(payload),
|
||
loadSession: () => null,
|
||
saveSession: () => {},
|
||
});
|
||
const entry = { ws: {}, toolCalls: [], fullText: '' };
|
||
runtime.processCodexAppNotification(entry, {
|
||
method: 'plan/updated',
|
||
params: {
|
||
id: 'regression-plan-progress',
|
||
status: 'inProgress',
|
||
plan: [
|
||
{ step: '完成数据契约', status: 'completed' },
|
||
{ step: '完成 DOM', status: 'completed' },
|
||
{ step: '补充回归', status: 'completed' },
|
||
{ step: '实现样式', status: 'in_progress' },
|
||
{ step: '浏览器验收', status: 'pending' },
|
||
],
|
||
},
|
||
}, 'plan-progress-session');
|
||
const update = sent.find((message) => message.type === 'tool_update' && message.toolUseId === 'regression-plan-progress');
|
||
assert(update, 'Plan updates should emit a tool_update payload');
|
||
assert(update.meta?.progress?.completed === 3 && update.meta?.progress?.total === 5, 'Plan metadata should expose 3/5 progress');
|
||
assert(update.input?.progress?.completed === 3 && update.input?.progress?.total === 5, 'Normalized todo input should expose 3/5 progress');
|
||
const result = JSON.parse(update.result);
|
||
assert(result.progress?.completed === 3 && result.progress?.total === 5, 'Persisted todo result should expose 3/5 progress');
|
||
|
||
const summarySource = extractFunctionSource(source, 'applyToolSummary');
|
||
const progressElementSource = extractFunctionSource(source, 'createPlanProgressElementFromProgress');
|
||
assert(summarySource.includes('createPlanProgressElement(tool)'), 'Tool summaries should append the plan progress element beside the title');
|
||
assert(progressElementSource.includes("meter.setAttribute('role', 'img')"), 'Plan progress should expose an accessible image role');
|
||
assert(progressElementSource.includes("meter.setAttribute('aria-label', progressLabel)"), 'Plan progress should announce completed and total counts');
|
||
assert(progressElementSource.includes('Math.min(normalizedProgress.total, 12)'), 'Long plans should cap visible dots to protect the header layout');
|
||
assert(progressElementSource.includes("count.textContent = `${normalizedProgress.completed}/${normalizedProgress.total}`"), 'Compacted long plans should keep an exact numeric count');
|
||
|
||
assert(styleSource.includes('--plan-progress-complete: var(--success);'), 'Plan progress should inherit the active theme success color');
|
||
assert(styleSource.includes('--plan-progress-remaining: var(--accent);'), 'Remaining plan progress should inherit the active theme accent color');
|
||
assert(/\.plan-progress-dot\s*\{[^}]*width:\s*14px;[^}]*height:\s*14px;[^}]*margin-left:\s*8px;/.test(styleSource), 'Desktop plan progress dots should keep the requested 14px size and 8px left margin');
|
||
assert(/\.plan-progress-dot\.is-complete\s*\{[^}]*background:\s*var\(--plan-progress-complete\);/.test(styleSource), 'Completed plan dots should use the semantic completion token');
|
||
assert(/\.plan-progress-dot\.is-remaining\s*\{[^}]*background:\s*var\(--plan-progress-remaining\);[^}]*box-shadow:\s*inset 0 0 0 1px[^}]*animation:\s*plan-progress-breathe 1\.8s/.test(styleSource), 'Remaining plan dots should use the semantic color, 1px inner border and breathing animation');
|
||
assert(/@keyframes plan-progress-breathe\s*\{[\s\S]*?transform:\s*scale\(0\.9\);[\s\S]*?transform:\s*scale\(1\);[\s\S]*?\}/.test(styleSource), 'Plan progress breathing should use a restrained scale cycle');
|
||
assert(/@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.plan-progress-dot\.is-remaining\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;/.test(styleSource), 'Plan progress breathing should respect reduced-motion preferences');
|
||
assert(/@media \(max-width:\s*560px\)[\s\S]*?\.plan-progress-dot\s*\{[^}]*width:\s*5px;[^}]*height:\s*5px;/.test(styleSource), 'Plan progress dots should stay compact on narrow screens');
|
||
|
||
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\s*\{[^}]*width:\s*20px;[^}]*height:\s*20px;[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/.test(styleSource), 'Wasteland plan progress should replace circles with 20px transparent icon canvases');
|
||
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\.is-complete\s*\{[^}]*url\('assets\/themes\/wasteland\/icons\/plan-progress\/complete\.png'\);/.test(styleSource), 'Wasteland completed tasks should use the local green status jewel');
|
||
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\.is-remaining\s*\{[^}]*url\('assets\/themes\/wasteland\/icons\/plan-progress\/remaining\.png'\);[^}]*box-shadow:\s*none;/.test(styleSource), 'Wasteland remaining tasks should use the local gold loading ring without a square inset border');
|
||
assert(/@media \(max-width:\s*560px\)[\s\S]*?html\[data-theme='wasteland'\] \.plan-progress-dot\s*\{[^}]*width:\s*16px;[^}]*height:\s*16px;/.test(styleSource), 'Wasteland progress icons should remain legible and compact on narrow screens');
|
||
const isolatedPlanIconRules = Array.from(styleSource.matchAll(/html\[data-theme='([^']+)'\] \.plan-progress-dot\.is-(?:complete|remaining)\s*\{[^}]*icons\/plan-progress\//g));
|
||
assert(isolatedPlanIconRules.length === 2 && isolatedPlanIconRules.every((match) => match[1] === 'wasteland'), 'Plan progress image assets should stay isolated to the Wasteland theme');
|
||
|
||
const planAssetRoot = path.join(WASTELAND_THEME_ASSETS.root, 'icons', 'plan-progress');
|
||
const planManifestPath = path.join(planAssetRoot, 'manifest.json');
|
||
assert(fs.existsSync(planManifestPath), 'Wasteland plan progress should ship a reproducible asset manifest');
|
||
const planManifest = JSON.parse(fs.readFileSync(planManifestPath, 'utf8'));
|
||
assert(planManifest.source_sha256 === '482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e', 'Plan progress manifest should retain the reviewed source sheet hash');
|
||
const expectedPlanAssets = {
|
||
complete: {
|
||
semantic: '已完成',
|
||
card: 'R2C3 STATUS (ONLINE)',
|
||
sha256: '2f75c0dfc105e7fd50b1c90d3ca3b2439306afd50c2a0b5ebdeb674cd592516a',
|
||
},
|
||
remaining: {
|
||
semantic: '未完成',
|
||
card: 'R2C4 LOADING',
|
||
sha256: '90decca2d4b8204032fa06c0bbde6e9f49afc11bf4fabe21b8307caf5769d672',
|
||
},
|
||
};
|
||
assert(Array.isArray(planManifest.assets) && planManifest.assets.length === 2, 'Plan progress manifest should list exactly two semantic assets');
|
||
Object.entries(expectedPlanAssets).forEach(([name, expected]) => {
|
||
const entry = planManifest.assets.find((asset) => asset.name === name);
|
||
assert(entry?.semantic === expected.semantic && entry?.source_card === expected.card, `${name} progress icon should keep its reviewed semantic and source card`);
|
||
assert(entry?.source_variant === '16px', `${name} progress icon should be cut from the native 16px source variant`);
|
||
assert(entry?.size?.[0] === 20 && entry?.size?.[1] === 20, `${name} progress icon manifest should retain a 20x20 canvas`);
|
||
assert(Array.isArray(entry?.alpha_bbox) && entry.alpha_bbox[2] >= 16 && entry.alpha_bbox[3] >= 16, `${name} progress icon should retain a visible alpha subject`);
|
||
const assetPath = path.join(planAssetRoot, `${name}.png`);
|
||
assert(fs.existsSync(assetPath), `Wasteland should ship ${name}.png`);
|
||
const asset = fs.readFileSync(assetPath);
|
||
assert(asset.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${name}.png should remain a PNG`);
|
||
assert(asset.readUInt32BE(16) === 20 && asset.readUInt32BE(20) === 20 && asset.readUInt8(25) === 6, `${name}.png should remain a 20x20 RGBA image`);
|
||
const actualHash = crypto.createHash('sha256').update(asset).digest('hex');
|
||
assert(actualHash === expected.sha256 && entry.sha256 === expected.sha256, `${name}.png should match the reviewed manifest hash`);
|
||
});
|
||
const extractorPath = path.join(REPO_DIR, '.trellis', 'tasks', '07-17-gilded-wasteland-theme', 'research', 'extract_plan_progress_icons.py');
|
||
const extractorSource = fs.readFileSync(extractorPath, 'utf8');
|
||
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
|
||
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
|
||
|
||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Plan progress CSS should use the current cache-busted URL');
|
||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||
}
|
||
|
||
function assertFrontendGildedThemeContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||
const themeVarsStart = styleSource.indexOf("html[data-theme='gilded'] {");
|
||
const themeVarsEnd = styleSource.indexOf('\n}', themeVarsStart);
|
||
const themeVars = themeVarsStart >= 0 && themeVarsEnd > themeVarsStart
|
||
? styleSource.slice(themeVarsStart, themeVarsEnd + 2)
|
||
: '';
|
||
const themeStyleStart = styleSource.indexOf('/* === Warframe Orokin');
|
||
const themeStyleEnd = styleSource.indexOf('/* === 暗金荒野', themeStyleStart);
|
||
const themeStyle = themeStyleStart >= 0 && themeStyleEnd > themeStyleStart
|
||
? styleSource.slice(themeStyleStart, themeStyleEnd)
|
||
: '';
|
||
const themeOption = source.match(/\{\s*value: 'gilded',[\s\S]*?\n\s*\},/);
|
||
|
||
assert(themeOption, 'Frontend should register the gilded theme option');
|
||
assert(themeOption[0].includes('hidden: true'), 'Warframe theme should remain registered but hidden until visual sign-off');
|
||
assert(source.includes('item.value === theme && !item.hidden'), 'Stored hidden themes should normalize to the default theme');
|
||
assert(source.includes('THEME_OPTIONS.filter((theme) => !theme.hidden).map'), 'Theme picker should exclude hidden themes');
|
||
assert(indexSource.includes("if (theme === 'gilded')") && indexSource.includes("theme = 'washi'"), 'Page bootstrap should migrate an active hidden Warframe theme before first paint');
|
||
assert(themeOption[0].includes("label: 'Warframe Wasteland'"), 'Gilded theme should expose an accurate Warframe picker label');
|
||
assert(
|
||
themeOption[0].includes("swatches: ['#fbf3e5', '#efe4d2', '#7a3f20', '#147276']"),
|
||
'Gilded picker swatches should expose the ivory, parchment, copper and energy theme system'
|
||
);
|
||
assert(themeOption[0].includes('暖象牙与焦土铜铺开 Orokin 荒野档案'), 'Gilded picker copy should describe the current warm-paper theme');
|
||
[source, indexSource].forEach((markupSource, index) => {
|
||
const label = index === 0 ? 'Dynamic' : 'Static';
|
||
assert(markupSource.includes('class="warframe-hero"') && markupSource.includes('class="welcome-copy"'), `${label} welcome markup should expose the single-image Warframe hero`);
|
||
assert(markupSource.includes('class="warframe-hero-art" aria-hidden="true"'), `${label} Warframe hero art should remain decorative`);
|
||
assert((markupSource.match(/class="warframe-hero-image"/g) || []).length === 1, `${label} welcome markup should render exactly one Warframe hero image`);
|
||
assert(markupSource.includes('src="assets/themes/gilded-wasteland.png"'), `${label} welcome markup should render the reviewed local Warframe artwork`);
|
||
assert(
|
||
markupSource.includes('<div class="welcome-icon">✿</div>')
|
||
&& markupSource.includes('data-welcome-project-copy')
|
||
&& markupSource.includes('本次你要构建什么?'),
|
||
`${label} welcome markup should preserve the shared project-aware welcome content`
|
||
);
|
||
assert(!markupSource.includes('warframe-shard'), `${label} welcome markup should not restore the obsolete four-shard composition`);
|
||
['warframe-sacrifice.jpg', 'warframe-orokin-figure.jpg', 'warframe-ember-squad.jpg'].forEach((referenceAsset) => {
|
||
assert(!markupSource.includes(referenceAsset), `${label} welcome markup should not render reference-only artwork: ${referenceAsset}`);
|
||
});
|
||
});
|
||
[source, indexSource, styleSource].forEach((content, index) => {
|
||
const label = ['Dynamic markup', 'Static markup', 'Stylesheet'][index];
|
||
['warframe-quick', 'data-warframe-prompt', '分析代码库', '规划下一步', '排查问题'].forEach((forbidden) => {
|
||
assert(!content.includes(forbidden), `${label} should not contain theme-specific shortcuts or preset prompts: ${forbidden}`);
|
||
});
|
||
});
|
||
['cdn.displate.com', 'pinterest.com', 'pinimg.com'].forEach((referenceDomain) => {
|
||
assert(!source.includes(referenceDomain) && !styleSource.includes(referenceDomain) && !indexSource.includes(referenceDomain), `Reference artwork domain should not be copied into project UI files: ${referenceDomain}`);
|
||
});
|
||
|
||
GILDED_THEME_ASSETS.forEach((expected) => {
|
||
assert(fs.existsSync(expected.path), `Gilded theme should ship local artwork: ${expected.filename}`);
|
||
const asset = fs.readFileSync(expected.path);
|
||
const actualHash = crypto.createHash('sha256').update(asset).digest('hex');
|
||
assert(actualHash === expected.sha256, `${expected.filename} should remain byte-identical to the reviewed source`);
|
||
assert(asset.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${expected.filename} should remain a PNG`);
|
||
assert(asset.readUInt32BE(16) === expected.width && asset.readUInt32BE(20) === expected.height, `${expected.filename} should retain its reviewed ${expected.width}x${expected.height} canvas`);
|
||
});
|
||
|
||
assert(themeVars, 'Stylesheet should define an isolated gilded semantic variable block');
|
||
[
|
||
'--bg-primary: #fbf3e5',
|
||
'--bg-bubble-user: #7a3f20',
|
||
'--bg-bubble-assistant: #fff7ea',
|
||
'--text-primary: #201812',
|
||
'--text-secondary: #4b392a',
|
||
'--text-muted: #655446',
|
||
'--accent: #7a3f20',
|
||
'--warframe-ivory: #fff7ea',
|
||
'--warframe-gold: #d5af63',
|
||
'--warframe-copper: #7a3f20',
|
||
'--warframe-energy: #147276',
|
||
].forEach((token) => assert(themeVars.includes(token), `Gilded theme is missing readable surface token: ${token}`));
|
||
assert(!/url\(\s*['"]?https?:\/\//i.test(themeVars), 'Gilded semantic variables should never load remote artwork');
|
||
assert(themeStyleStart >= 0, 'Stylesheet should include a dedicated gilded component layer');
|
||
assert(themeStyle.includes("html:not([data-theme='gilded']) .warframe-hero-art"), 'Non-Warframe themes should hide the single-image hero art');
|
||
assert(themeStyle.includes('display: contents;'), 'Non-Warframe themes should preserve the original welcome layout semantics');
|
||
assert(themeStyle.includes('.welcome-msg > .warframe-hero') && themeStyle.includes('.warframe-hero-image'), 'Gilded theme should scope the single-image hero to the welcome composition');
|
||
assert(!themeStyle.includes('.warframe-shard'), 'Gilded component layer should not restore obsolete four-shard styles');
|
||
assert(!themeStyle.includes("url('assets/themes/gilded-wasteland.png')"), 'Warframe artwork should stay a real image node rather than a CSS wallpaper');
|
||
assert(!/url\(\s*['"]?https?:\/\//i.test(themeStyle), 'Gilded theme styles should never load remote reference artwork');
|
||
const chatMainRule = themeStyle.match(/html\[data-theme='gilded'\] \.chat-main\s*\{([^}]*)\}/);
|
||
assert(chatMainRule && !chatMainRule[1].includes('url('), 'Routine chat workspace should not render artwork behind messages');
|
||
assert(themeStyle.includes('var(--warframe-ivory)') && themeStyle.includes('rgba(255, 247, 234, 0.94)'), 'Gilded panels should use readable ivory surfaces');
|
||
assert(themeStyle.includes(".session-search-input:focus") && themeStyle.includes('rgba(20, 114, 118, 0.14)'), 'Gilded search and composer focus should use the restrained energy focus ring');
|
||
assert(themeStyle.includes('.input-wrapper.drag-active') && themeStyle.includes('var(--warframe-energy)'), 'Gilded upload drag state should use the shared energy token');
|
||
assert(themeStyle.includes('.new-chat-btn') && themeStyle.includes('var(--warframe-copper)'), 'Gilded primary actions should use the shared copper material');
|
||
assert(themeStyle.includes('.msg.user .msg-bubble') && themeStyle.includes('.msg.assistant .msg-bubble'), 'Gilded message surfaces should define both user and assistant readability');
|
||
assert(themeStyle.includes('.msg-attachment-card') && themeStyle.includes('.msg-attachment-thumb'), 'Gilded attachment surfaces should remain explicit and readable');
|
||
assert(/@keyframes warframeCopyIn\s*\{[\s\S]*?from\s*\{\s*opacity:\s*1;/.test(themeStyle), 'Warframe welcome copy should remain visible from the first animation frame');
|
||
assert(/@keyframes warframeHeroImageIn\s*\{[\s\S]*?from\s*\{\s*opacity:\s*0\.72;/.test(themeStyle), 'Warframe hero image should remain visible from the first animation frame');
|
||
assert(themeStyle.includes("html[data-theme='gilded'] .sidebar {\n position: fixed;"), 'Gilded mobile sidebar should not consume layout width while closed');
|
||
assert(/@media \(max-width:\s*768px\)[\s\S]*?\.welcome-msg > \.warframe-hero\s*\{[\s\S]*?height:\s*clamp\(320px,\s*52svh,\s*430px\);[\s\S]*?\.warframe-hero-image/.test(themeStyle), 'Gilded tablet and mobile layouts should retain the single hero without horizontal overflow');
|
||
assert(/@media \(max-width:\s*420px\)[\s\S]*?\.welcome-msg > \.warframe-hero\s*\{[\s\S]*?height:\s*clamp\(340px,\s*52svh,\s*430px\);/.test(themeStyle), 'Gilded narrow-mobile layout should keep a viewport-aware hero height');
|
||
|
||
const rgb = (hex) => [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
|
||
const luminance = (hex) => rgb(hex)
|
||
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
|
||
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
|
||
const contrast = (foreground, background) => {
|
||
const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
|
||
return (values[0] + 0.05) / (values[1] + 0.05);
|
||
};
|
||
assert(contrast('#201812', '#fff7ea') >= 7, 'Gilded primary text should reach AAA contrast on ivory panels');
|
||
assert(contrast('#4b392a', '#fff7ea') >= 7, 'Gilded secondary text should reach AAA contrast on ivory panels');
|
||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||
}
|
||
|
||
function assertFrontendWastelandThemeContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||
const themeVarsStart = styleSource.indexOf("html[data-theme='wasteland'] {");
|
||
const themeVarsEnd = styleSource.indexOf('\n}', themeVarsStart);
|
||
const themeVars = themeVarsStart >= 0 && themeVarsEnd > themeVarsStart
|
||
? styleSource.slice(themeVarsStart, themeVarsEnd + 2)
|
||
: '';
|
||
const themeStyleStart = styleSource.indexOf('/* === 暗金荒野');
|
||
const themeStyle = themeStyleStart >= 0 ? styleSource.slice(themeStyleStart) : '';
|
||
const highFidelityStyleStart = styleSource.indexOf('/* === 暗金荒野高保真复刻');
|
||
const highFidelityStyle = highFidelityStyleStart >= 0 ? styleSource.slice(highFidelityStyleStart) : '';
|
||
const themeOption = source.match(/\{\s*value: 'wasteland',[\s\S]*?\n\s*\},/);
|
||
|
||
assert(themeOption, 'Frontend should register the wasteland theme option');
|
||
assert(!themeOption[0].includes('hidden:'), 'Wasteland theme should be visible in the picker');
|
||
assert(themeOption[0].includes("label: '暗金荒野'"), 'Wasteland picker should expose the requested Chinese label');
|
||
assert(
|
||
themeOption[0].includes("swatches: ['#050707', '#171511', '#c49a5a', '#6f783f']"),
|
||
'Wasteland picker should expose the iron, panel, antique-gold and status-green palette'
|
||
);
|
||
assert(indexSource.includes("if (theme === 'gilded')") && !indexSource.includes("if (theme === 'wasteland')"), 'Wasteland should remain selectable while only hidden gilded values migrate');
|
||
|
||
assert(themeVars, 'Stylesheet should define an isolated wasteland semantic variable block');
|
||
[
|
||
'--bg-primary: #090b0a;',
|
||
'--bg-bubble-user: #251d16;',
|
||
'--bg-bubble-assistant: #101311;',
|
||
'--text-primary: #fff;',
|
||
'--text-secondary: #eadfc9;',
|
||
'--text-muted: #c9bda6;',
|
||
'--accent: #c49a5a;',
|
||
'--wasteland-iron: #080a09;',
|
||
'--wasteland-gold: #c49a5a;',
|
||
'--wasteland-green: #6f783f;',
|
||
'--wasteland-ember: #a65b3d;',
|
||
'--wasteland-cut: polygon(',
|
||
].forEach((token) => assert(themeVars.includes(token), `Wasteland theme is missing semantic token: ${token}`));
|
||
assert(themeStyleStart >= 0, 'Stylesheet should include a dedicated wasteland component layer');
|
||
assert(highFidelityStyleStart >= 0, 'Stylesheet should retain the final high-fidelity wasteland override layer');
|
||
|
||
const bodyRule = themeStyle.match(/html\[data-theme='wasteland'\] body\s*\{([^}]*)\}/);
|
||
assert(bodyRule && bodyRule[1].includes("url('assets/themes/wasteland/background.webp')"), 'Wasteland body should render the reviewed local knight background');
|
||
assert(bodyRule && bodyRule[1].includes('background-size: cover') && bodyRule[1].includes('background-attachment: fixed'), 'Wasteland body background should fill and anchor the workspace');
|
||
assert(themeStyle.includes('.sidebar') && themeStyle.includes('.chat-header') && themeStyle.includes('.msg-bubble') && themeStyle.includes('.input-wrapper'), 'Wasteland component layer should cover the main workspace surfaces');
|
||
[source, indexSource].forEach((markupSource, index) => {
|
||
const label = index === 0 ? 'Dynamic' : 'Static';
|
||
assert(markupSource.includes('class="wasteland-welcome-art" aria-hidden="true"'), `${label} welcome markup should expose the Wasteland artwork layer`);
|
||
assert(markupSource.includes('class="wasteland-welcome-surface"'), `${label} welcome markup should expose a dedicated dark-material surface`);
|
||
assert(markupSource.includes('class="wasteland-welcome-frame"') && markupSource.includes('assets/themes/wasteland/frames/welcome-card.png'), `${label} welcome markup should render the extracted local frame image`);
|
||
});
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.welcome-msg > \.warframe-hero\s*\{[^}]*margin:\s*0 auto;[^}]*aspect-ratio:\s*1095 \/ 875;/.test(themeStyle)
|
||
&& themeStyle.includes("url('assets/themes/wasteland/textures/welcome-card-surface.webp')")
|
||
&& /html\[data-theme='wasteland'\] \.wasteland-welcome-frame\s*\{[^}]*object-fit:\s*fill;/.test(themeStyle),
|
||
'Wasteland welcome card should layer the extracted frame and dark material at the reviewed aspect ratio'
|
||
);
|
||
|
||
const sharedDarkSelectors = Array.from(
|
||
styleSource.matchAll(/:is\(([^)]*html\[data-theme='(?:carbon|nocturne|cinder|gilded)'\][^)]*)\)/g),
|
||
(match) => match[1]
|
||
);
|
||
assert(sharedDarkSelectors.length > 0, 'Stylesheet should retain shared dark-theme completion selectors');
|
||
sharedDarkSelectors.forEach((selector) => {
|
||
['carbon', 'nocturne', 'cinder', 'gilded', 'wasteland'].forEach((theme) => {
|
||
assert(selector.includes(`html[data-theme='${theme}']`), `Shared dark selector should include ${theme}: ${selector}`);
|
||
});
|
||
});
|
||
|
||
[
|
||
['\\.new-chat-btn::before', 'new-chat.png'],
|
||
['\\.session-search::before', 'search.png'],
|
||
['\\.attach-btn::before', 'attachment.png'],
|
||
['\\.send-btn::before', 'send.png'],
|
||
['\\.abort-btn::before', 'stop.png'],
|
||
['\\.settings-btn::before', 'settings.png'],
|
||
].forEach(([selector, filename]) => {
|
||
const iconRule = new RegExp(`html\\[data-theme='wasteland'\\]\\s+${selector}\\s*\\{[^}]*url\\(['"]?assets/themes/wasteland/icons/${filename}['"]?\\)`);
|
||
assert(iconRule.test(themeStyle), `Wasteland control should use the local ${filename} icon`);
|
||
});
|
||
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.new-chat-btn \.new-chat-glyph\s*\{[^}]*display:\s*inline-flex;/.test(themeStyle),
|
||
'Wasteland new-chat control should keep the native plus sign visible'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.new-chat-arrow::before\s*\{[^}]*border-top:\s*7px solid #dcb36f;/.test(themeStyle),
|
||
'Wasteland new-chat dropdown should render a CSS triangle inside its frame'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.session-item-actions\s*\{[^}]*position:\s*absolute;[^}]*right:\s*9px;/.test(themeStyle),
|
||
'Wasteland session actions should stay inside the active-session frame'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.session-item\.menu-open\s*\{[^}]*overflow:\s*visible;/.test(themeStyle),
|
||
'Wasteland session action menu should not be clipped by the session row'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.sidebar-footer\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;/.test(themeStyle)
|
||
&& /html\[data-theme='wasteland'\] \.brand\s*\{[^}]*align-items:\s*center;[^}]*line-height:\s*1;/.test(themeStyle)
|
||
&& /html\[data-theme='wasteland'\] \.brand::before\s*\{[^}]*display:\s*none;/.test(themeStyle),
|
||
'Wasteland sidebar footer should center the functional settings control and brand text without a redundant emblem'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] :is\(\.msg\.user, \.msg\.assistant\) \.msg-avatar > :is\(img, svg\)\s*\{[^}]*display:\s*none !important;/.test(themeStyle),
|
||
'Wasteland avatar should hide the native agent image instead of stacking it over the themed icon'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.input-wrapper\s*\{[^}]*border-image-source:\s*url\('assets\/themes\/wasteland\/frames\/composer\.png'\);[^}]*border-image-slice:\s*10 10 43 10;[^}]*border-image-width:\s*10px 10px 43px 10px;[^}]*border-image-outset:\s*0 0 27px 0;/.test(themeStyle)
|
||
&& /html\[data-theme='wasteland'\] \.input-wrapper::before\s*\{[^}]*display:\s*none;/.test(themeStyle)
|
||
&& /html\[data-theme='wasteland'\] \.input-wrapper::after\s*\{[^}]*display:\s*none;/.test(themeStyle),
|
||
'Wasteland composer should bind its artwork to the real resizable border instead of a fixed-ratio pseudo-element'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.input-area\s*\{[^}]*background:\s*transparent !important;[^}]*box-shadow:\s*none;[^}]*backdrop-filter:\s*none;/.test(themeStyle),
|
||
'Wasteland input area should expose the page artwork instead of painting a separate footer panel'
|
||
);
|
||
const welcomeFrame = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'frames', 'welcome-card.png'));
|
||
assert(welcomeFrame.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', 'Wasteland welcome frame should remain a PNG');
|
||
assert(welcomeFrame.readUInt32BE(16) === 1095 && welcomeFrame.readUInt32BE(20) === 875, 'Wasteland welcome frame should retain its reviewed 1095x875 canvas');
|
||
assert(welcomeFrame[24] === 8 && welcomeFrame[25] === 6, 'Wasteland welcome frame should retain an RGBA transparency channel');
|
||
const welcomeSurface = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'textures', 'welcome-card-surface.webp'));
|
||
assert(welcomeSurface.subarray(0, 4).toString('ascii') === 'RIFF' && welcomeSurface.subarray(8, 12).toString('ascii') === 'WEBP', 'Wasteland welcome surface should remain a WebP texture');
|
||
const welcomeSurfaceSyncOffset = welcomeSurface.indexOf(Buffer.from([0x9d, 0x01, 0x2a]));
|
||
assert(welcomeSurfaceSyncOffset >= 0, 'Wasteland welcome surface should expose a readable VP8 frame header');
|
||
assert(
|
||
(welcomeSurface.readUInt16LE(welcomeSurfaceSyncOffset + 3) & 0x3fff) === 600
|
||
&& (welcomeSurface.readUInt16LE(welcomeSurfaceSyncOffset + 5) & 0x3fff) === 350,
|
||
'Wasteland welcome surface should retain its reviewed 600x350 crop'
|
||
);
|
||
[
|
||
['composer.png', 807, 101],
|
||
['composer-action.png', 46, 46],
|
||
].forEach(([filename, width, height]) => {
|
||
const asset = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'frames', filename));
|
||
assert(asset.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${filename} should remain a PNG`);
|
||
assert(asset.readUInt32BE(16) === width && asset.readUInt32BE(20) === height, `${filename} should retain its reviewed ${width}x${height} canvas`);
|
||
});
|
||
[
|
||
['note', 36],
|
||
['queue', 30],
|
||
['send', 30],
|
||
['stop', 26],
|
||
['ai', 36],
|
||
['user', 36],
|
||
].forEach(([name, size]) => {
|
||
assert(
|
||
themeStyle.includes(`url('assets/themes/wasteland/icons/ui/${name}.png')`),
|
||
`Wasteland composer should use the themed ${name} icon`
|
||
);
|
||
const icon = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, 'icons', 'ui', `${name}.png`));
|
||
assert(icon.readUInt32BE(16) === size && icon.readUInt32BE(20) === size, `${name}.png should retain its ${size}x${size} composer canvas`);
|
||
});
|
||
assert(
|
||
/html\[data-theme='wasteland'\] :is\(\.attach-btn, \.note-mode-btn, \.queue-send-btn, \.send-btn, \.abort-btn\) > svg\s*\{[^}]*display:\s*none;/.test(themeStyle),
|
||
'Wasteland composer controls should not stack native SVGs over themed icons'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] :is\(\.send-btn, \.abort-btn, \.queue-send-btn, \.note-mode-btn\)\s*\{[^}]*border:\s*0 !important;[^}]*background:\s*transparent !important;[^}]*box-shadow:\s*none !important;/.test(themeStyle)
|
||
&& /html\[data-theme='wasteland'\] :is\(\.send-btn, \.abort-btn, \.queue-send-btn, \.note-mode-btn\)::after\s*\{[^}]*display:\s*none;/.test(themeStyle)
|
||
&& !themeStyle.includes("url('assets/themes/wasteland/frames/composer-action.png')"),
|
||
'Wasteland composer actions should render centered icons without persistent frames or filled backgrounds'
|
||
);
|
||
assert(
|
||
themeStyle.includes('background: rgba(5, 7, 7, 0.08);')
|
||
&& themeStyle.includes('background: rgba(226, 214, 190, 0.022) !important;')
|
||
&& themeStyle.includes('backdrop-filter: blur(3px) saturate(1.16) brightness(1.28);'),
|
||
'Wasteland assistant bubbles should use a nearly colorless brightened frosted-glass surface on dark scenery'
|
||
);
|
||
assert(
|
||
themeStyle.includes("html[data-theme='wasteland'] :is(.tool-call, .tool-group)")
|
||
&& themeStyle.includes('background: rgba(5, 7, 7, 0.04) !important;')
|
||
&& themeStyle.includes('.tool-call-content.reasoning')
|
||
&& themeStyle.includes('.tool-call-content.command')
|
||
&& themeStyle.includes('.tool-call-content.file-change')
|
||
&& themeStyle.includes('background: rgba(5, 7, 7, 0.035) !important;'),
|
||
'Wasteland nested tool surfaces should not remain opaque inside transparent message bubbles'
|
||
);
|
||
assert(
|
||
/html\[data-theme='wasteland'\] \.tool-call\.ccweb-mcp-child-agent-tool-call \.collab-agent-header\s*\{[^}]*min-height:\s*24px;[^}]*padding:\s*4px 8px 0;/.test(themeStyle)
|
||
&& /html\[data-theme='wasteland'\] \.tool-call\.ccweb-mcp-child-agent-tool-call \.collab-agent-title-wrap\s*\{[^}]*align-items:\s*center;/.test(themeStyle),
|
||
'Wasteland collaboration header should clear the etched corner and center its title tokens'
|
||
);
|
||
const backgroundPath = path.join(WASTELAND_THEME_ASSETS.root, WASTELAND_THEME_ASSETS.background.filename);
|
||
assert(fs.existsSync(backgroundPath), 'Wasteland theme should ship its local WebP background');
|
||
const background = fs.readFileSync(backgroundPath);
|
||
assert(background.subarray(0, 4).toString('ascii') === 'RIFF' && background.subarray(8, 12).toString('ascii') === 'WEBP', 'Wasteland background should remain a WebP image');
|
||
const vp8SyncOffset = background.indexOf(Buffer.from([0x9d, 0x01, 0x2a]));
|
||
assert(vp8SyncOffset >= 0 && vp8SyncOffset + 7 <= background.length, 'Wasteland background should expose a readable VP8 frame header');
|
||
const backgroundWidth = background.readUInt16LE(vp8SyncOffset + 3) & 0x3fff;
|
||
const backgroundHeight = background.readUInt16LE(vp8SyncOffset + 5) & 0x3fff;
|
||
assert(
|
||
backgroundWidth === WASTELAND_THEME_ASSETS.background.width && backgroundHeight === WASTELAND_THEME_ASSETS.background.height,
|
||
`Wasteland background should retain its reviewed ${WASTELAND_THEME_ASSETS.background.width}x${WASTELAND_THEME_ASSETS.background.height} canvas`
|
||
);
|
||
const backgroundHash = crypto.createHash('sha256').update(background).digest('hex');
|
||
assert(backgroundHash === WASTELAND_THEME_ASSETS.background.sha256, 'Wasteland background should remain byte-identical to the reviewed source');
|
||
|
||
const manifestPath = path.join(WASTELAND_THEME_ASSETS.root, WASTELAND_THEME_ASSETS.manifestFilename);
|
||
assert(fs.existsSync(manifestPath), 'Wasteland theme should ship the icon slicing manifest');
|
||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||
const expectedIconNames = [...WASTELAND_THEME_ASSETS.iconNames].sort();
|
||
const manifestIconNames = Object.keys(manifest).sort();
|
||
assert(manifestIconNames.length === 17, 'Wasteland icon manifest should contain exactly 17 reviewed entries');
|
||
assert(JSON.stringify(manifestIconNames) === JSON.stringify(expectedIconNames), 'Wasteland icon manifest should expose the complete reviewed icon set');
|
||
const iconDir = path.join(WASTELAND_THEME_ASSETS.root, 'icons');
|
||
const actualIconNames = fs.readdirSync(iconDir)
|
||
.filter((filename) => filename.endsWith('.png'))
|
||
.map((filename) => filename.slice(0, -4))
|
||
.sort();
|
||
assert(JSON.stringify(actualIconNames) === JSON.stringify(expectedIconNames), 'Wasteland icon directory should contain exactly the 17 manifested PNG files');
|
||
expectedIconNames.forEach((name) => {
|
||
const entry = manifest[name];
|
||
assert(entry.source === '43bd9ed6-2ba6-4c9d-bd9d-25b068cb4711.webp', `${name} manifest entry should identify the reviewed icon board`);
|
||
assert(/^R[1-6]C[1-6]$/.test(entry.card) && entry.variant === '32px', `${name} manifest entry should preserve its card and 32px source variant`);
|
||
assert(Array.isArray(entry.bbox) && entry.bbox.length === 4 && entry.bbox.every((value) => Number.isInteger(value) && value > 0), `${name} manifest entry should preserve a valid source bounding box`);
|
||
assert(entry.output === `icons/${name}.png`, `${name} manifest entry should point to its local PNG output`);
|
||
const icon = fs.readFileSync(path.join(WASTELAND_THEME_ASSETS.root, entry.output));
|
||
assert(icon.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${name}.png should remain a PNG`);
|
||
assert(icon.readUInt32BE(16) === 64 && icon.readUInt32BE(20) === 64, `${name}.png should retain its 64x64 canvas`);
|
||
assert(icon[24] === 8 && icon[25] === 6, `${name}.png should remain an 8-bit RGBA asset`);
|
||
});
|
||
|
||
assert(!/url\(\s*['"]?https?:\/\//i.test(`${themeVars}\n${themeStyle}\n${JSON.stringify(manifest)}`), 'Wasteland variables, components and asset manifest should not load remote URLs');
|
||
assert(
|
||
/@media \(min-width:\s*721px\) and \(max-width:\s*1100px\)[\s\S]*?html\[data-theme='wasteland'\] body\s*\{[^}]*background-position:\s*center,\s*center,\s*75% 50%;/.test(highFidelityStyle),
|
||
'Wasteland 721-1100px layout should reveal the right side of the local background'
|
||
);
|
||
assert(
|
||
/@media \(max-width:\s*720px\)[\s\S]*?html\[data-theme='wasteland'\] body\s*\{[^}]*background-position:\s*center,\s*center,\s*75% 50%;/.test(highFidelityStyle),
|
||
'Wasteland 720px layout should keep the local background right-aligned'
|
||
);
|
||
assert(/@media \(max-width:\s*560px\)[\s\S]*?html\[data-theme='wasteland'\] \.input-wrapper\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?min-height:\s*48px;/.test(themeStyle), 'Wasteland 560px layout should keep the composer within the viewport');
|
||
assert(/@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?html\[data-theme='wasteland'\] \*[\s\S]*?animation:\s*none !important;[\s\S]*?transition:\s*none !important;/.test(themeStyle), 'Wasteland motion should respect reduced-motion preferences');
|
||
|
||
const rgb = (hex) => [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
|
||
const luminance = (hex) => rgb(hex)
|
||
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
|
||
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
|
||
const contrast = (foreground, backgroundColor) => {
|
||
const values = [luminance(foreground), luminance(backgroundColor)].sort((a, b) => b - a);
|
||
return (values[0] + 0.05) / (values[1] + 0.05);
|
||
};
|
||
['#090b0a', '#101311', '#251d16'].forEach((backgroundColor) => {
|
||
assert(contrast('#ffffff', backgroundColor) >= 7, `Wasteland primary text should reach AAA contrast on ${backgroundColor}`);
|
||
assert(contrast('#eadfc9', backgroundColor) >= 7, `Wasteland secondary text should reach AAA contrast on ${backgroundColor}`);
|
||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||
});
|
||
|
||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||
}
|
||
|
||
function assertFrontendCcwebPromptContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||
assert(source.includes('function createCcwebPromptElement(prompt, meta = {})'), 'Frontend should define ccweb prompt renderer');
|
||
assert(source.includes("type: 'ccweb_prompt_user_response'"), 'Frontend should send ccweb prompt answers over WebSocket');
|
||
assert(source.includes("type: 'ccweb_prompt_user_dismiss'"), 'Frontend should send ccweb prompt dismiss requests over WebSocket');
|
||
assert(source.includes("case 'ccweb_prompt_user_update':"), 'Frontend should handle ccweb prompt status updates');
|
||
assert(source.includes("case 'ccweb_prompt_user_remove':"), 'Frontend should remove submitted ccweb prompt bubbles');
|
||
assert(source.includes('applyCcwebPromptUserUpdate(msg);'), 'Frontend should apply ccweb prompt updates to cached messages and DOM');
|
||
assert(source.includes('removeCcwebPromptMessageFromSnapshot'), 'Frontend should remove submitted prompt messages from cached snapshots');
|
||
assert(source.includes('renderPendingCcwebPrompts'), 'Frontend should render pending ccweb prompt reminders');
|
||
assert(indexSource.includes('id="ccweb-prompt-outline-btn"') && indexSource.includes('class="ccweb-prompt-outline-anchor" hidden'), 'Frontend should expose a hidden ccweb prompt outline button');
|
||
assert(source.includes('toggleCcwebPromptOutlinePanel') && source.includes('ccwebPromptOutlineBtn.dataset.count'), 'Frontend should render pending forms behind a compact outline button');
|
||
assert(source.includes('dismissCcwebPrompt'), 'Frontend should allow users to ignore pending ccweb prompts');
|
||
assert(source.includes('CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY'), 'Frontend should persist ccweb prompt view mode');
|
||
assert(source.includes("className = 'ccweb-prompt-tabs'"), 'Frontend should render ccweb prompt tabs for multi-question forms');
|
||
assert(source.includes("className = 'pending-ccweb-prompt-dismiss'"), 'Frontend should render a compact dismiss action for pending prompts');
|
||
assert(source.includes('card.dataset.viewMode = normalized'), 'Frontend should switch ccweb prompt card view mode');
|
||
assert(source.includes('m.ccwebPrompt'), 'Message rebuild should render persisted ccweb prompt messages');
|
||
assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea');
|
||
assert(source.includes('function clearCcwebPromptSelection(questionEl, question)'), 'ccweb prompt options should expose an explicit no-selection action');
|
||
assert(source.includes("clearChoice.textContent = '不选择';"), 'ccweb prompt should render a visible no-selection control');
|
||
assert(source.includes("const shouldDeselect = buttons.some"), 'Single-select ccweb prompt options should allow clicking the selected option to deselect');
|
||
assert(source.includes("textarea.dataset.ccwebPromptSelectionText"), 'ccweb prompt should clear only option-generated answer text when selections are removed');
|
||
}
|
||
|
||
function assertFrontendMarkdownLinkContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(path.join(REPO_DIR, 'public', 'style.css'), 'utf8');
|
||
assert(source.includes('function parseLocalFileLinkHref(rawHref)'), 'Frontend should parse local file hrefs separately from web links');
|
||
assert(source.includes("link.dataset.localFileLink = 'true';"), 'Frontend should mark local file links with data-local-file-link');
|
||
assert(source.includes('hydrateLocalFileLinks(root);'), 'Rendered markdown should hydrate local file links');
|
||
assert(source.includes('openFileBrowserFile(relativePath, { line });'), 'Local file links should open the file browser with line metadata');
|
||
assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style');
|
||
}
|
||
|
||
function assertFrontendMcpReloadContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
assert(source.includes('function mcpStartupStatusToastText(status)'), 'Frontend should format MCP startup status toast text');
|
||
assert(source.includes("payload.status && typeof payload.status === 'object'"), 'Frontend should preserve plain MCP status summary objects');
|
||
assert(source.includes('data.mcpStatus'), 'Frontend reload button should consume reload-mcp mcpStatus payload');
|
||
assert(source.includes("case 'mcp_startup_status':"), 'Frontend should handle pushed MCP startup status updates');
|
||
assert(source.includes('showMcpStartupStatusToast'), 'Frontend should show explicit MCP startup status toasts');
|
||
assert(source.includes('notifyReady: true'), 'Frontend should only show ready MCP startup toasts for explicit reload actions');
|
||
assert(source.includes("state === 'ready' && !options.notifyReady"), 'Frontend should suppress background ready MCP startup toasts');
|
||
assert(source.includes('MCP 已启动'), 'Frontend should expose a ready toast for ccweb MCP');
|
||
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
|
||
}
|
||
|
||
function assertFrontendSubagentCardMetadataContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
|
||
const metadataStart = source.indexOf(' function cleanCollabAgentText(value)');
|
||
const metadataEnd = source.indexOf(' function normalizeCollabAgentAction(value)', metadataStart);
|
||
assert(metadataStart >= 0 && metadataEnd > metadataStart, 'Frontend should expose an isolated sub-agent metadata helper block');
|
||
const metadataApi = new Function(`
|
||
function shortChildAgentId(id) {
|
||
const value = String(id || '');
|
||
return value.length > 12 ? value.slice(0, 8) : value;
|
||
}
|
||
${source.slice(metadataStart, metadataEnd)}
|
||
return {
|
||
isGenericCollabAgentLabel,
|
||
readableCollabAgentAutoTitle,
|
||
pickCollabAgentTitle,
|
||
mergeCollabAgentTaskState,
|
||
};
|
||
`)();
|
||
const collabMergeStart = source.indexOf(' function toolKind(tool)');
|
||
const collabMergeEnd = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart);
|
||
const collabRenderStart = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart);
|
||
const collabRenderEnd = source.indexOf(' function buildToolContentElement', collabRenderStart);
|
||
const childUpdateStart = source.indexOf(' function applyCcwebMcpChildAgentUpdate(msg)');
|
||
const childUpdateEnd = source.indexOf(' function getDeleteConfirmMessage(agent)', childUpdateStart);
|
||
const buildMsgElementSource = extractFunctionSource(source, 'buildMsgElement');
|
||
assert(collabMergeStart >= 0 && collabMergeEnd > collabMergeStart, 'Frontend should expose collab merge helpers');
|
||
assert(collabRenderStart >= 0 && collabRenderEnd > collabRenderStart, 'Frontend should expose collab card render helpers');
|
||
assert(childUpdateStart >= 0 && childUpdateEnd > childUpdateStart, 'Frontend should define child-agent update handling before delete helpers');
|
||
const collabApi = new Function(`
|
||
let currentCwd = '';
|
||
let currentSessionId = 'session-a';
|
||
let closedCollabAgentIds = new Set();
|
||
let collabAgentStateCache = new Map();
|
||
let collabAgentIdsByToolUseId = new Map();
|
||
let closedCollabAgentIdsByToolUseId = new Map();
|
||
const activeToolCalls = new Map();
|
||
let cachedSnapshot = {
|
||
messages: [{
|
||
toolCalls: [{
|
||
id: 'tool-collab',
|
||
kind: 'collab_agent_tool_call',
|
||
input: {},
|
||
}],
|
||
}],
|
||
};
|
||
function updateCachedSession(sessionId, updater) {
|
||
updater(cachedSnapshot);
|
||
}
|
||
function updateToolCall() {}
|
||
function makeNode() {
|
||
return {
|
||
attributes: {},
|
||
children: [],
|
||
dataset: {},
|
||
listeners: {},
|
||
parentElement: null,
|
||
isConnected: true,
|
||
appendChild(child) {
|
||
if (child && typeof child === 'object') child.parentElement = this;
|
||
this.children.push(child);
|
||
return child;
|
||
},
|
||
insertBefore(child) {
|
||
if (child && typeof child === 'object') child.parentElement = this;
|
||
this.children.unshift(child);
|
||
return child;
|
||
},
|
||
replaceChildren(...children) {
|
||
this.children = [];
|
||
children.forEach((child) => this.appendChild(child));
|
||
},
|
||
setAttribute(name, value) {
|
||
this.attributes[name] = String(value);
|
||
},
|
||
addEventListener(name, handler) {
|
||
if (!this.listeners[name]) this.listeners[name] = [];
|
||
this.listeners[name].push(handler);
|
||
},
|
||
querySelector() {
|
||
return null;
|
||
},
|
||
};
|
||
}
|
||
function createMsgElement() {
|
||
const bubble = makeNode();
|
||
return {
|
||
bubble,
|
||
querySelector(selector) {
|
||
return selector === '.msg-bubble' ? bubble : null;
|
||
},
|
||
};
|
||
}
|
||
const messagesDiv = makeNode();
|
||
function findLatestToolCallElement(root, matcher) {
|
||
const matches = [];
|
||
const visit = (node) => {
|
||
if (!node || typeof node !== 'object') return;
|
||
if (node.dataset?.toolUseId && matcher(node)) matches.push(node);
|
||
(Array.isArray(node.children) ? node.children : []).forEach(visit);
|
||
};
|
||
visit(root);
|
||
return matches.length > 0 ? matches[matches.length - 1] : null;
|
||
}
|
||
function createCcwebPromptElement() { return makeNode(); }
|
||
function isEmptyReasoningTool() { return false; }
|
||
function createToolCallElement(toolUseId, tool, done) {
|
||
const node = { ...makeNode(), toolUseId, tool, done };
|
||
node.dataset.toolUseId = String(toolUseId || '');
|
||
node.dataset.toolKind = toolKind(tool) || '';
|
||
return node;
|
||
}
|
||
function isGroupableToolCall() { return false; }
|
||
function _refreshGroupSummary() {}
|
||
function markSessionMessageElement() {}
|
||
const document = { createElement: () => makeNode() };
|
||
function shortChildAgentId(id) {
|
||
const value = String(id || '');
|
||
return value.length > 12 ? value.slice(0, 8) : value;
|
||
}
|
||
${source.slice(collabMergeStart, collabMergeEnd)}
|
||
${source.slice(collabRenderStart, collabRenderEnd)}
|
||
${source.slice(childUpdateStart, childUpdateEnd)}
|
||
${buildMsgElementSource}
|
||
return {
|
||
toolKind,
|
||
collabStateTone,
|
||
mergeCollabAgentTools,
|
||
applyCcwebMcpChildAgentUpdate,
|
||
rememberCollabAgentState,
|
||
renderToolCallsForMessage: (toolCalls) => {
|
||
const el = buildMsgElement({ role: 'assistant', content: '', toolCalls });
|
||
return el.querySelector('.msg-bubble').children.map((node) => node.tool);
|
||
},
|
||
renderMessageElement: (toolCalls) => buildMsgElement({ role: 'assistant', content: '', toolCalls }),
|
||
mountMessageElement: (element) => messagesDiv.appendChild(element.querySelector('.msg-bubble')),
|
||
findCollabAgentToolElement,
|
||
renderCollabAgentToolElement: (tool) => createCollabAgentToolElement(tool),
|
||
getCachedState: (id) => collabAgentStateCache.get(id),
|
||
hasCachedState: (id) => collabAgentStateCache.has(id),
|
||
cacheSize: () => collabAgentStateCache.size,
|
||
agentIdsForTool: (id) => new Set(collabAgentIdsByToolUseId.get(id) || []),
|
||
closedIdsForTool: (id) => new Set(closedCollabAgentIdsByToolUseId.get(id) || []),
|
||
};
|
||
`)();
|
||
const findNodesByClass = (node, className, results = []) => {
|
||
if (!node || typeof node !== 'object') return results;
|
||
const classes = String(node.className || '').split(/\s+/).filter(Boolean);
|
||
if (classes.includes(className)) results.push(node);
|
||
(Array.isArray(node.children) ? node.children : []).forEach((child) => findNodesByClass(child, className, results));
|
||
return results;
|
||
};
|
||
|
||
assert(source.includes('function pickCollabAgentTitle(state, id, index)'), 'Frontend should pick sub-agent titles through a dedicated helper');
|
||
assert(
|
||
/const titleCandidates = \[\s*state\.label,\s*state\.title,\s*state\.nickname,\s*state\.name,\s*\]/.test(source),
|
||
'Sub-agent title priority should be label -> title -> nickname -> name'
|
||
);
|
||
assert(source.includes('isGenericCollabAgentLabel(candidate, id)'), 'Sub-agent title picker should skip generic labels and thread IDs');
|
||
assert(source.includes('collabAgentTitleFromPrompt(taskDescription)'), 'Generic sub-agent titles should be derived from that agent prompt');
|
||
assert(source.includes('return id ? `ID ${shortChildAgentId(id)}`'), 'Sub-agent title picker should fall back to a short thread id without a prompt');
|
||
assert(source.includes('function mergeCollabAgentTaskState(previousState = {}, incomingState = {}'), 'Frontend should centralize per-agent task metadata merging');
|
||
assert(source.includes('const hasReadableSourceTitle = state.hasReadableSourceTitle == null'), 'Normalized child states should preserve explicit source-title markers before inference');
|
||
assert(source.includes('hasReadableSourceTitle,') && source.includes('taskDescription, hasReadableSourceTitle'), 'Normalized child states should use one resolved source-title marker for title picking and returned entries');
|
||
assert(/mergeCollabAgentTaskState\(\s*states\[entry\.id\]/.test(source), 'Structured child states should use the tested metadata merge helper');
|
||
assert(/mergeCollabAgentTaskState\(\s*states\[id\]/.test(source), 'Receiver-only child states should use the tested metadata merge helper');
|
||
assert(source.includes('entry.detail ? `结果: ${entry.detail}` :'), 'Card title should keep runtime result in the container title');
|
||
assert(source.includes("description.className = 'collab-agent-item-description'"), 'Sub-agent cards should render a visible task intro node');
|
||
assert(source.includes('function createPlanProgressElementFromProgress(progress'), 'Frontend should expose reusable plan progress rendering for sub-agent cards');
|
||
assert(source.includes("plan.className = 'collab-agent-item-plan'"), 'Sub-agent cards should render a compact plan progress row');
|
||
assert(source.includes("currentStep.className = 'collab-agent-item-plan-current'"), 'Sub-agent cards should render the current plan step');
|
||
assert(source.includes('findCollabAgentToolElement(toolUseId, tool?.domElement)'), 'Live sub-agent updates should locate the original card before falling back to the latest assistant message');
|
||
assert(source.includes('description.title = descriptionTitle'), 'Task intro node should expose the full task intro or fallback in its title attribute');
|
||
assert(source.includes('label.textContent = displayTitle;'), 'Rendered card label should use normalized title selection');
|
||
assert(/\.collab-agent-item-description\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/.test(styleSource), 'Sub-agent task intro should use two-line truncation');
|
||
assert(/\.collab-agent-item-plan\s*\{[\s\S]*?display:\s*flex;/.test(styleSource), 'Sub-agent plan progress should use a compact flex row');
|
||
assert(/\.collab-agent-item-plan \.plan-progress-dot\s*\{[\s\S]*?width:\s*8px;[\s\S]*?height:\s*8px;[\s\S]*?margin-left:\s*0;/.test(styleSource), 'Sub-agent plan dots should remain compact inside narrow cards');
|
||
assert(/\.collab-agent-item-plan-current\s*\{[\s\S]*?text-overflow:\s*ellipsis;/.test(styleSource), 'Sub-agent current plan step should truncate safely');
|
||
assert(/\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?flex-direction:\s*column;/.test(styleSource), 'Sub-agent cards should be vertically composed and flex-shrink on narrow screens');
|
||
assert(/@media \(max-width:\s*640px\)[\s\S]*?\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?\}/.test(styleSource), 'Narrow screens should let sub-agent cards shrink without horizontal overflow');
|
||
|
||
const uuidV7 = '0190f01d-7b3e-7f03-9a5a-123456789abc';
|
||
assert(metadataApi.isGenericCollabAgentLabel(uuidV7, 'different-thread-id'), 'UUID v7 labels should be treated as thread identifiers');
|
||
assert(
|
||
metadataApi.pickCollabAgentTitle({ label: '子代理', title: '架构审查', name: uuidV7 }, 'child-a', 0) === '架构审查',
|
||
'Title selection should skip generic labels and preserve readable field priority'
|
||
);
|
||
[
|
||
['plan_review', '计划审查'],
|
||
['plan_reviewer', '计划审查'],
|
||
['backend_state_review', '后端状态审查'],
|
||
['frontend_state_review', '前端状态审查'],
|
||
['trellis_implement', 'Trellis 实现'],
|
||
['trellis_check', 'Trellis 检查'],
|
||
['custom_worker', 'Custom Worker'],
|
||
].forEach(([rawTitle, expectedTitle]) => {
|
||
assert(
|
||
metadataApi.readableCollabAgentAutoTitle(rawTitle) === expectedTitle,
|
||
`Automatic child-agent helper should map ${rawTitle} to ${expectedTitle}`
|
||
);
|
||
assert(
|
||
metadataApi.pickCollabAgentTitle({ label: rawTitle, hasReadableSourceTitle: true }, 'agent-thread-auto-title', 0) === expectedTitle,
|
||
`Automatic child-agent task title ${rawTitle} should render as ${expectedTitle}`
|
||
);
|
||
});
|
||
assert(
|
||
metadataApi.readableCollabAgentAutoTitle('已有自然语言标题') === '已有自然语言标题',
|
||
'Automatic child-agent helper should not rewrite natural-language titles'
|
||
);
|
||
assert(
|
||
metadataApi.pickCollabAgentTitle({ label: 'fix-bug', hasReadableSourceTitle: false }, 'child-prompt-derived', 0) === 'fix-bug',
|
||
'Prompt-derived kebab-case titles should remain unchanged'
|
||
);
|
||
assert(
|
||
metadataApi.pickCollabAgentTitle({ label: '子代理', taskDescription: 'frontend_state_review' }, 'child-prompt-title', 0) === 'frontend_state_review',
|
||
'Prompt-derived titles should not be auto-translated as protocol task names'
|
||
);
|
||
assert(
|
||
metadataApi.pickCollabAgentTitle({ label: '已有自然语言标题', hasReadableSourceTitle: true }, 'child-natural-title', 0) === '已有自然语言标题',
|
||
'Existing natural-language titles should remain unchanged'
|
||
);
|
||
const promptDerivedAutoNameThreadId = 'agent-thread-prompt-derived-auto-name';
|
||
const promptDerivedAutoNameState = metadataApi.mergeCollabAgentTaskState(
|
||
{},
|
||
{ label: '子代理', status: 'running' },
|
||
'frontend_state_review',
|
||
promptDerivedAutoNameThreadId,
|
||
0
|
||
);
|
||
assert(promptDerivedAutoNameState.label === 'frontend_state_review', 'Prompt-derived automatic-looking titles should stay raw after metadata merge');
|
||
assert(promptDerivedAutoNameState.hasReadableSourceTitle === false, 'Prompt-derived automatic-looking titles should keep a false source-title marker');
|
||
const promptDerivedAutoNameTool = {
|
||
id: 'tool-prompt-derived-auto-name',
|
||
name: 'spawn_agent',
|
||
kind: 'collab_agent_tool_call',
|
||
input: {
|
||
tool: 'spawn_agent',
|
||
receiverThreadIds: [promptDerivedAutoNameThreadId],
|
||
agentsStates: {
|
||
[promptDerivedAutoNameThreadId]: promptDerivedAutoNameState,
|
||
},
|
||
},
|
||
done: false,
|
||
};
|
||
const promptDerivedAutoNameElement = collabApi.renderCollabAgentToolElement(promptDerivedAutoNameTool);
|
||
const promptDerivedAutoNameLabels = findNodesByClass(promptDerivedAutoNameElement, 'collab-agent-item-label');
|
||
assert(promptDerivedAutoNameLabels.length === 1, 'Prompt-derived automatic-looking titles should render one child-agent label');
|
||
assert(
|
||
promptDerivedAutoNameLabels[0].textContent === 'frontend_state_review',
|
||
'Prompt-derived automatic-looking titles should stay raw through collabAgentStateEntries and card rendering'
|
||
);
|
||
const promptDerivedAutoNameDescriptions = findNodesByClass(promptDerivedAutoNameElement, 'collab-agent-item-description');
|
||
assert(promptDerivedAutoNameDescriptions.length === 0, 'Task descriptions identical to the resolved card title should not render twice');
|
||
|
||
const firstSpawn = metadataApi.mergeCollabAgentTaskState(
|
||
{},
|
||
{ label: '子代理', status: 'running' },
|
||
'请审查前端实现。核对标题和简介。',
|
||
'child-thread-a',
|
||
0
|
||
);
|
||
const secondSpawn = metadataApi.mergeCollabAgentTaskState(
|
||
{},
|
||
{ label: '子代理', status: 'running' },
|
||
'请验证后端回归。核对状态同步。',
|
||
'child-thread-b',
|
||
1
|
||
);
|
||
assert(firstSpawn.taskDescription !== secondSpawn.taskDescription, 'Independent spawns should retain different task introductions');
|
||
assert(firstSpawn.label !== secondSpawn.label, 'Independent spawns should derive different titles from their own prompts');
|
||
|
||
const afterWait = metadataApi.mergeCollabAgentTaskState(firstSpawn, { status: 'completed' }, '', 'child-thread-a', 0);
|
||
const afterClose = metadataApi.mergeCollabAgentTaskState(afterWait, { status: 'closed' }, '', 'child-thread-a', 0);
|
||
assert(afterWait.taskDescription === firstSpawn.taskDescription, 'Wait updates without a prompt should preserve the original task introduction');
|
||
assert(afterClose.taskDescription === firstSpawn.taskDescription, 'Close updates without a prompt should preserve the original task introduction');
|
||
|
||
const namedSpawn = metadataApi.mergeCollabAgentTaskState({}, { name: '实现代理', status: 'running' }, '', 'child-thread-c', 2);
|
||
const namedAfterWait = metadataApi.mergeCollabAgentTaskState(
|
||
namedSpawn,
|
||
{ label: 'ID child-th', title: '', nickname: '', name: '', status: 'completed' },
|
||
'',
|
||
'child-thread-c',
|
||
2
|
||
);
|
||
assert(namedAfterWait.label === '实现代理', 'Status updates without a readable title should preserve the existing protocol title');
|
||
|
||
const namedAfterPromptDerivedUpdate = metadataApi.mergeCollabAgentTaskState(
|
||
namedSpawn,
|
||
{
|
||
label: '整理前端改动并回报状态',
|
||
name: 'child-thread-c',
|
||
taskDescription: '请整理前端改动并回报状态。',
|
||
hasReadableSourceTitle: false,
|
||
status: 'completed',
|
||
},
|
||
'',
|
||
'child-thread-c',
|
||
2
|
||
);
|
||
assert(namedAfterPromptDerivedUpdate.label === '实现代理', 'Prompt-derived labels should not replace an existing protocol title');
|
||
assert(
|
||
namedAfterPromptDerivedUpdate.taskDescription === '请整理前端改动并回报状态。',
|
||
'Prompt-derived updates may refresh the task introduction while preserving the protocol title'
|
||
);
|
||
|
||
const noPrompt = metadataApi.mergeCollabAgentTaskState({}, { label: '子代理' }, '', uuidV7, 0);
|
||
assert(/^ID\s/.test(noPrompt.label) && noPrompt.label !== uuidV7, 'Missing prompts should fall back to a short thread id');
|
||
|
||
const spawnedTool = {
|
||
id: 'tool-collab',
|
||
name: 'spawn_agent',
|
||
kind: 'collab_agent_tool_call',
|
||
input: {
|
||
tool: 'spawn_agent',
|
||
prompt: '请实现子代理卡片关闭状态保留。',
|
||
receiverThreadIds: ['child-thread-a'],
|
||
agentsStates: {
|
||
'child-thread-a': {
|
||
title: '关闭验证代理',
|
||
taskDescription: '请实现子代理卡片关闭状态保留。',
|
||
role: 'implementer',
|
||
status: 'running',
|
||
},
|
||
},
|
||
},
|
||
done: false,
|
||
};
|
||
const spawnedMerge = collabApi.mergeCollabAgentTools([spawnedTool]);
|
||
assert(spawnedMerge.input.receiverThreadIds.length === 1, 'Spawned child should merge into one visible agent');
|
||
assert(collabApi.getCachedState('child-thread-a')?.label === '关闭验证代理', 'Structured child state should be cached by thread id');
|
||
|
||
const planCardTool = {
|
||
...spawnedTool,
|
||
id: 'tool-plan-progress',
|
||
input: {
|
||
...spawnedTool.input,
|
||
receiverThreadIds: ['child-thread-plan'],
|
||
agentsStates: {
|
||
'child-thread-plan': {
|
||
title: '进度验证代理',
|
||
taskDescription: '验证子代理计划简报。',
|
||
status: 'running',
|
||
planProgress: { completed: 1, total: 3 },
|
||
planCurrentStep: '同步父卡片进度',
|
||
},
|
||
},
|
||
},
|
||
};
|
||
const planCardElement = collabApi.renderCollabAgentToolElement(planCardTool);
|
||
const planRows = findNodesByClass(planCardElement, 'collab-agent-item-plan');
|
||
const planDots = findNodesByClass(planCardElement, 'plan-progress-dot');
|
||
const planCounts = findNodesByClass(planCardElement, 'plan-progress-count');
|
||
const planCurrentSteps = findNodesByClass(planCardElement, 'collab-agent-item-plan-current');
|
||
assert(planRows.length === 1, 'Sub-agent plan summary should render one compact progress row');
|
||
assert(planDots.length === 3, 'Sub-agent plan summary should render one progress dot per short plan item');
|
||
assert(planCounts.length === 1 && planCounts[0].textContent === '1/3', 'Sub-agent plan summary should always render completed/total');
|
||
assert(planCurrentSteps.length === 1 && planCurrentSteps[0].textContent === '当前:同步父卡片进度', 'Sub-agent plan summary should render the current in-progress step');
|
||
|
||
const persistedPlanSnapshotTool = {
|
||
id: 'tool-persisted-plan-progress',
|
||
name: 'subAgentActivity',
|
||
kind: 'collab_agent_tool_call',
|
||
input: {
|
||
tool: 'subAgentActivity',
|
||
receiverThreadIds: ['child-thread-persisted-plan'],
|
||
agentsStates: {
|
||
'child-thread-persisted-plan': {
|
||
title: '真实快照验证代理',
|
||
status: 'running',
|
||
},
|
||
},
|
||
},
|
||
result: JSON.stringify({
|
||
tool: 'subAgentActivity',
|
||
receiverThreadIds: ['child-thread-persisted-plan'],
|
||
agentsStates: {
|
||
'child-thread-persisted-plan': {
|
||
title: '真实快照验证代理',
|
||
status: 'running',
|
||
planProgress: { completed: 2, total: 3 },
|
||
planCurrentStep: '验证历史卡片增量更新',
|
||
},
|
||
},
|
||
}),
|
||
done: false,
|
||
};
|
||
const persistedMessageElement = collabApi.renderMessageElement([persistedPlanSnapshotTool]);
|
||
const persistedMessageBubble = persistedMessageElement.querySelector('.msg-bubble');
|
||
const persistedCard = persistedMessageBubble.children[0];
|
||
assert(persistedCard?.dataset?.collabMerged === 'true', 'Restored sub-agent cards should be marked as merged cards for later live updates');
|
||
assert(
|
||
persistedCard?.__collabTools instanceof Map && persistedCard.__collabTools.has(persistedPlanSnapshotTool.id),
|
||
'Restored sub-agent cards should retain their original tool snapshots for later result-only plan updates'
|
||
);
|
||
collabApi.mountMessageElement(persistedMessageElement);
|
||
assert(
|
||
collabApi.findCollabAgentToolElement(persistedPlanSnapshotTool.id) === persistedCard,
|
||
'Live result-only plan updates should find the restored card by its original tool id'
|
||
);
|
||
const persistedPlanElement = collabApi.renderCollabAgentToolElement(persistedCard.tool);
|
||
const persistedPlanCounts = findNodesByClass(persistedPlanElement, 'plan-progress-count');
|
||
assert(
|
||
persistedPlanCounts.length === 1 && persistedPlanCounts[0].textContent === '2/3',
|
||
'Persisted result-only plan progress should survive history reconstruction'
|
||
);
|
||
|
||
collabApi.rememberCollabAgentState(
|
||
'unrelated-child',
|
||
{ title: '无关历史代理', status: 'running' },
|
||
'请处理无关历史任务。',
|
||
1
|
||
);
|
||
collabApi.applyCcwebMcpChildAgentUpdate({
|
||
type: 'ccweb_mcp_child_agent_update',
|
||
sessionId: 'session-a',
|
||
toolUseId: 'tool-collab',
|
||
child: {
|
||
threadId: 'child-thread-a',
|
||
status: 'closed',
|
||
},
|
||
tool: {
|
||
id: 'tool-collab',
|
||
name: 'close_agent',
|
||
kind: 'collab_agent_tool_call',
|
||
input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} },
|
||
result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }),
|
||
done: true,
|
||
},
|
||
});
|
||
const closedMerge = collabApi.mergeCollabAgentTools([
|
||
{
|
||
id: 'tool-collab',
|
||
name: 'close_agent',
|
||
kind: 'collab_agent_tool_call',
|
||
input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} },
|
||
result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }),
|
||
done: true,
|
||
},
|
||
], {
|
||
restoreAgentIds: new Set(['child-thread-a', 'unrelated-child']),
|
||
closedAgentIds: collabApi.closedIdsForTool('tool-collab'),
|
||
});
|
||
assert(closedMerge.input.receiverThreadIds.length === 1, 'Empty close tool should keep exactly the closed child card');
|
||
assert(closedMerge.input.receiverThreadIds[0] === 'child-thread-a', 'Empty close tool should restore only the closed child thread id');
|
||
assert(!closedMerge.input.agentsStates['unrelated-child'], 'Empty close recovery must not mix unrelated cached children into the card');
|
||
assert(closedMerge.input.agentsStates['child-thread-a'].label === '关闭验证代理', 'Empty close recovery should preserve the cached child title');
|
||
assert(
|
||
closedMerge.input.agentsStates['child-thread-a'].taskDescription === '请实现子代理卡片关闭状态保留。',
|
||
'Empty close recovery should preserve the cached child introduction'
|
||
);
|
||
assert(closedMerge.input.agentsStates['child-thread-a'].status === 'closed', 'Empty close recovery should mark the child closed');
|
||
|
||
collabApi.rememberCollabAgentState(
|
||
'wait-child-a',
|
||
{ title: '等待验证代理', status: 'running' },
|
||
'请等待子代理返回。',
|
||
0
|
||
);
|
||
const emptyWaitMerge = collabApi.mergeCollabAgentTools([
|
||
{
|
||
id: 'tool-wait',
|
||
name: 'wait_agent',
|
||
kind: 'collab_agent_tool_call',
|
||
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
|
||
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
|
||
done: false,
|
||
},
|
||
], {
|
||
restoreAgentIds: new Set(['wait-child-a']),
|
||
});
|
||
assert(emptyWaitMerge.input.receiverThreadIds.length === 1, 'Empty wait tool should not reset a cached child card count to zero');
|
||
assert(emptyWaitMerge.input.agentsStates['wait-child-a'].label === '等待验证代理', 'Empty wait recovery should preserve the cached child title');
|
||
|
||
const cacheSizeBeforeOrdinaryTool = collabApi.cacheSize();
|
||
const ordinaryMerge = collabApi.mergeCollabAgentTools([
|
||
{
|
||
id: 'ordinary-tool',
|
||
name: 'shell',
|
||
kind: 'command_execution',
|
||
input: {
|
||
receiverThreadIds: ['ordinary-child'],
|
||
agentsStates: {
|
||
'ordinary-child': { title: '普通工具不应进入子代理缓存', status: 'closed' },
|
||
},
|
||
},
|
||
done: true,
|
||
},
|
||
], {
|
||
restoreAgentIds: new Set(['ordinary-child']),
|
||
closedAgentIds: new Set(['ordinary-child']),
|
||
});
|
||
assert(ordinaryMerge === null, 'Non-collab tools should not enter the collab merge path');
|
||
collabApi.applyCcwebMcpChildAgentUpdate({
|
||
type: 'ccweb_mcp_child_agent_update',
|
||
sessionId: 'session-a',
|
||
toolUseId: 'ordinary-tool',
|
||
child: { threadId: 'ordinary-child', status: 'closed' },
|
||
tool: { id: 'ordinary-tool', name: 'shell', kind: 'command_execution', input: {}, result: '', done: true },
|
||
});
|
||
assert(collabApi.cacheSize() === cacheSizeBeforeOrdinaryTool, 'Non-collab child updates should not write sub-agent state cache');
|
||
assert(!collabApi.hasCachedState('ordinary-child'), 'Non-collab tools should not cache child thread state');
|
||
|
||
const rawActivityThreadId = 'agent-thread-plan-reviewer-001';
|
||
const rawActivityPrompt = '请审查 Phase 8 的前端 helper 行为。';
|
||
const rawSubAgentActivity = (overrides = {}) => {
|
||
const input = {
|
||
kind: overrides.activityKind || 'started',
|
||
agentThreadId: overrides.agentThreadId || rawActivityThreadId,
|
||
agentPath: overrides.agentPath || '/root/plan_reviewer',
|
||
...(overrides.prompt === undefined ? { prompt: rawActivityPrompt } : {}),
|
||
...(overrides.prompt ? { prompt: overrides.prompt } : {}),
|
||
...(overrides.input || {}),
|
||
};
|
||
return {
|
||
id: overrides.id || `call_activity_${input.kind}`,
|
||
name: 'subAgentActivity',
|
||
kind: 'subAgentActivity',
|
||
input,
|
||
...(overrides.result !== undefined ? { result: overrides.result } : {}),
|
||
done: !!overrides.done,
|
||
};
|
||
};
|
||
const emptyWaitTool = {
|
||
id: 'call_wait_empty',
|
||
name: 'wait_agent',
|
||
kind: 'collab_agent_tool_call',
|
||
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
|
||
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
|
||
done: false,
|
||
};
|
||
const rawActivityMerge = collabApi.mergeCollabAgentTools([
|
||
rawSubAgentActivity(),
|
||
emptyWaitTool,
|
||
]);
|
||
assert(rawActivityMerge, 'Raw subAgentActivity with an empty wait should produce one merged collab card');
|
||
assert(
|
||
rawActivityMerge.input.receiverThreadIds.length === 1
|
||
&& rawActivityMerge.input.receiverThreadIds[0] === rawActivityThreadId,
|
||
'Raw subAgentActivity agentThreadId should be the sole receiverThreadId and must not fall back to the wait tool call id'
|
||
);
|
||
assert(
|
||
Object.keys(rawActivityMerge.input.agentsStates).length === 1
|
||
&& Object.prototype.hasOwnProperty.call(rawActivityMerge.input.agentsStates, rawActivityThreadId),
|
||
'Raw subAgentActivity agentThreadId should be the sole agentsStates key'
|
||
);
|
||
const rawActivityState = rawActivityMerge.input.agentsStates[rawActivityThreadId];
|
||
assert(rawActivityState.label === '计划审查', 'Raw subAgentActivity agentPath basename should become a readable card title');
|
||
assert(rawActivityState.role === '', 'Raw subAgentActivity agentPath should not be duplicated into role when no explicit role is present');
|
||
assert(!/^ID\s+call_/.test(rawActivityState.label || ''), 'Raw subAgentActivity title must not fall back to a tool call id');
|
||
assert(rawActivityState.status === 'running', 'Raw subAgentActivity started events should keep the child running');
|
||
assert(rawActivityMerge.input.status === 'running', 'Raw subAgentActivity aggregate status should match its running child state');
|
||
assert(rawActivityState.taskDescription === rawActivityPrompt, 'Raw subAgentActivity prompt should be preserved as taskDescription');
|
||
const rawActivityElement = collabApi.renderCollabAgentToolElement(rawActivityMerge);
|
||
const rawActivityDescriptions = findNodesByClass(rawActivityElement, 'collab-agent-item-description');
|
||
assert(rawActivityDescriptions.length === 1, 'Raw subAgentActivity with a real prompt should render one visible task description');
|
||
assert(rawActivityDescriptions[0].textContent === rawActivityPrompt, 'Real child-agent prompts should remain the visible card description');
|
||
assert(rawActivityDescriptions[0].title === rawActivityPrompt, 'Real child-agent prompts should remain the description DOM title');
|
||
|
||
const explicitRoleMerge = collabApi.mergeCollabAgentTools([
|
||
rawSubAgentActivity({
|
||
id: 'call_activity_explicit_role',
|
||
agentThreadId: 'agent-thread-explicit-role',
|
||
input: { role: 'reviewer' },
|
||
}),
|
||
]);
|
||
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].label === '计划审查', 'Raw subAgentActivity agentPath basename should remain the readable title when role is explicit');
|
||
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].role === 'reviewer', 'Raw subAgentActivity should preserve an explicit role');
|
||
|
||
const completedEmptyWaitTool = {
|
||
...emptyWaitTool,
|
||
id: 'call_wait_completed_empty',
|
||
result: JSON.stringify({ status: 'completed', receiverThreadIds: [], agentsStates: {} }),
|
||
done: true,
|
||
};
|
||
const runningStartedWithCompletedWait = collabApi.mergeCollabAgentTools([
|
||
rawSubAgentActivity({
|
||
id: 'call_activity_started_done_completed',
|
||
activityKind: 'started',
|
||
done: true,
|
||
input: { status: 'completed' },
|
||
result: JSON.stringify({ status: 'completed' }),
|
||
}),
|
||
completedEmptyWaitTool,
|
||
]);
|
||
assert(runningStartedWithCompletedWait.input.receiverThreadIds.length === 1, 'Started raw activity plus completed empty wait should keep one child');
|
||
assert(runningStartedWithCompletedWait.input.agentsStates[rawActivityThreadId].status === 'running', 'Started raw activity should stay running even when the empty wait is completed');
|
||
assert(runningStartedWithCompletedWait.input.status === 'running', 'Merged collab status should stay running when the only child is running');
|
||
|
||
collabApi.rememberCollabAgentState(
|
||
'restore-running-child',
|
||
{ title: '恢复运行代理', status: 'running' },
|
||
'请保持运行态。',
|
||
0
|
||
);
|
||
const restoredRunningWithCompletedWait = collabApi.mergeCollabAgentTools([
|
||
completedEmptyWaitTool,
|
||
], {
|
||
restoreAgentIds: new Set(['restore-running-child']),
|
||
});
|
||
assert(restoredRunningWithCompletedWait.input.agentsStates['restore-running-child'].status === 'running', 'Empty completed wait should not overwrite restored running cache status');
|
||
assert(restoredRunningWithCompletedWait.input.status === 'running', 'Empty completed wait should not make a restored running child look completed in the aggregate header');
|
||
|
||
['started', 'interacted'].forEach((activityKind) => {
|
||
const threadId = `agent-thread-${activityKind}`;
|
||
const merged = collabApi.mergeCollabAgentTools([
|
||
rawSubAgentActivity({
|
||
id: `call_activity_${activityKind}_only`,
|
||
activityKind,
|
||
agentThreadId: threadId,
|
||
prompt: activityKind === 'started' ? `请处理 ${activityKind} 状态。` : '',
|
||
}),
|
||
]);
|
||
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
|
||
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
|
||
assert(merged.input.agentsStates[threadId].status === 'running', `Raw ${activityKind} activity should map to running status`);
|
||
});
|
||
|
||
['completed', 'returned'].forEach((activityKind) => {
|
||
const threadId = `agent-thread-${activityKind}`;
|
||
const merged = collabApi.mergeCollabAgentTools([
|
||
rawSubAgentActivity({
|
||
id: `call_activity_${activityKind}_only`,
|
||
activityKind,
|
||
agentThreadId: threadId,
|
||
prompt: `请处理 ${activityKind} 状态。`,
|
||
done: true,
|
||
}),
|
||
]);
|
||
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
|
||
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
|
||
assert(collabApi.collabStateTone(merged.input.agentsStates[threadId].status) === 'done', `Raw ${activityKind} activity should map to a completed tone`);
|
||
});
|
||
|
||
const completedWithoutPrompt = collabApi.mergeCollabAgentTools([
|
||
rawSubAgentActivity(),
|
||
rawSubAgentActivity({
|
||
id: 'call_activity_completed_without_prompt',
|
||
activityKind: 'completed',
|
||
prompt: '',
|
||
done: true,
|
||
}),
|
||
]);
|
||
assert(
|
||
completedWithoutPrompt.input.agentsStates[rawActivityThreadId].taskDescription === rawActivityPrompt,
|
||
'Raw subAgentActivity updates for the same thread should retain an earlier prompt as taskDescription'
|
||
);
|
||
assert(
|
||
collabApi.collabStateTone(completedWithoutPrompt.input.agentsStates[rawActivityThreadId].status) === 'done',
|
||
'Raw subAgentActivity completed updates should finish the same child card'
|
||
);
|
||
|
||
const noPromptThreadId = 'agent-thread-no-prompt';
|
||
const noPromptActivityMerge = collabApi.mergeCollabAgentTools([
|
||
rawSubAgentActivity({
|
||
id: 'call_activity_no_prompt',
|
||
agentThreadId: noPromptThreadId,
|
||
prompt: '',
|
||
}),
|
||
]);
|
||
assert(noPromptActivityMerge.input.agentsStates[noPromptThreadId].label === '计划审查', 'Raw subAgentActivity without prompt should still use readable agentPath as title');
|
||
assert(!noPromptActivityMerge.input.agentsStates[noPromptThreadId].taskDescription, 'Raw subAgentActivity without prompt should not fabricate taskDescription');
|
||
const noPromptElement = collabApi.renderCollabAgentToolElement(noPromptActivityMerge);
|
||
const noPromptDescriptions = findNodesByClass(noPromptElement, 'collab-agent-item-description');
|
||
assert(noPromptDescriptions.length === 0, 'Raw subAgentActivity without prompt should not repeat the card title as a fallback description');
|
||
const noPromptItems = findNodesByClass(noPromptElement, 'collab-agent-item');
|
||
assert(noPromptItems.length === 1, 'Raw subAgentActivity without prompt should still render one rich child-agent card');
|
||
assert(
|
||
!String(noPromptItems[0].title || '').includes('子代理任务:'),
|
||
'Missing child-agent prompts should not add a duplicate fallback description to the card tooltip'
|
||
);
|
||
|
||
const renderCandidates = collabApi.renderToolCallsForMessage([
|
||
rawSubAgentActivity(),
|
||
emptyWaitTool,
|
||
]);
|
||
assert(renderCandidates.length === 1, 'Raw subAgentActivity plus empty wait should render only the merged collab card');
|
||
assert(renderCandidates[0].kind === 'collab_agent_tool_call', 'Raw subAgentActivity should render through the collab display tool');
|
||
assert(
|
||
!renderCandidates.some((tool) => tool?.id === 'call_activity_started' && tool?.kind === 'subAgentActivity'),
|
||
'Raw subAgentActivity should be filtered out of ordinary rendered tool rows'
|
||
);
|
||
|
||
const nameOnlyOrdinaryTool = {
|
||
id: 'call_name_only_subagent_activity',
|
||
name: 'subAgentActivity',
|
||
kind: 'command_execution',
|
||
input: { command: 'echo should-stay-ordinary' },
|
||
done: true,
|
||
};
|
||
assert(collabApi.toolKind(nameOnlyOrdinaryTool) === 'command_execution', 'Name-only subAgentActivity command tools without agentThreadId should stay ordinary tools');
|
||
assert(collabApi.mergeCollabAgentTools([nameOnlyOrdinaryTool]) === null, 'Name-only ordinary subAgentActivity tools should not produce a collab card');
|
||
const nameOnlyRenderCandidates = collabApi.renderToolCallsForMessage([nameOnlyOrdinaryTool]);
|
||
assert(nameOnlyRenderCandidates.length === 1 && nameOnlyRenderCandidates[0].kind === 'command_execution', 'Name-only ordinary subAgentActivity tools should not be filtered into a collab render candidate');
|
||
}
|
||
|
||
function assertCodexAppRuntimeSubAgentActivityContract() {
|
||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||
const sent = [];
|
||
const runtime = createCodexAppRuntime({
|
||
wsSend: (_ws, payload) => sent.push(payload),
|
||
loadSession: () => null,
|
||
saveSession: () => {},
|
||
});
|
||
const sessionId = 'runtime-subagent-session';
|
||
const threadId = 'agent-thread-runtime-001';
|
||
const prompt = '请审查 runtime subAgentActivity 结构。';
|
||
const entry = {
|
||
ws: {},
|
||
toolCalls: [],
|
||
fullText: '',
|
||
};
|
||
|
||
assert(typeof runtime.planUpdateFromNotification === 'function', 'Runtime should expose shared plan notification parsing');
|
||
const childPlan = runtime.planUpdateFromNotification({
|
||
method: 'turn/plan/updated',
|
||
params: {
|
||
plan: [
|
||
{ step: '解析子代理计划', status: 'completed' },
|
||
{ step: '同步父卡片进度', status: 'in_progress' },
|
||
{ step: '补充回归验证', status: 'pending' },
|
||
],
|
||
},
|
||
});
|
||
assert(childPlan?.progress?.completed === 1 && childPlan?.progress?.total === 3, 'Shared plan parser should summarize child plan progress');
|
||
assert(childPlan?.currentStep === '同步父卡片进度', 'Shared plan parser should expose the current in-progress step');
|
||
|
||
runtime.processCodexAppNotification(entry, {
|
||
method: 'item/started',
|
||
params: {
|
||
item: {
|
||
id: 'runtime-activity',
|
||
type: 'subAgentActivity',
|
||
kind: 'started',
|
||
agentThreadId: threadId,
|
||
agentPath: '/root/plan_reviewer',
|
||
prompt,
|
||
},
|
||
},
|
||
}, sessionId);
|
||
|
||
const started = sent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity');
|
||
assert(started, 'Runtime subAgentActivity item/started should emit tool_start');
|
||
assert(started.sessionId === sessionId, 'Runtime subAgentActivity tool_start should carry session id');
|
||
assert(started.name === 'subAgentActivity', 'Runtime subAgentActivity tool_start should preserve activity name');
|
||
assert(started.kind === 'collab_agent_tool_call', 'Runtime subAgentActivity should surface as a collab agent tool call');
|
||
assert(started.input?.type === 'subAgentActivity', 'Runtime subAgentActivity input should preserve original type');
|
||
assert(started.input?.kind === 'started', 'Runtime subAgentActivity input should preserve activity kind');
|
||
assert(started.input?.agentThreadId === threadId, 'Runtime subAgentActivity input should preserve agentThreadId');
|
||
assert(started.input?.agentPath === '/root/plan_reviewer', 'Runtime subAgentActivity input should preserve agentPath');
|
||
assert(started.input?.prompt === prompt, 'Runtime subAgentActivity input should preserve prompt');
|
||
assert(started.input?.receiverThreadIds?.[0] === threadId, 'Runtime subAgentActivity input should expose receiverThreadIds');
|
||
assert(started.input?.agentsStates?.[threadId]?.label === 'plan_reviewer', 'Runtime subAgentActivity should derive title from agentPath basename');
|
||
assert(started.input?.agentsStates?.[threadId]?.role === '', 'Runtime subAgentActivity should not duplicate agentPath title into role');
|
||
assert(started.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime subAgentActivity should copy prompt to taskDescription');
|
||
assert(started.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime started subAgentActivity should map to running');
|
||
|
||
runtime.processCodexAppNotification(entry, {
|
||
method: 'item/completed',
|
||
params: {
|
||
item: {
|
||
id: 'runtime-activity',
|
||
type: 'subAgentActivity',
|
||
agentThreadId: threadId,
|
||
agentPath: '/root/plan_reviewer',
|
||
},
|
||
},
|
||
}, sessionId);
|
||
|
||
const completed = sent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity');
|
||
assert(completed, 'Runtime subAgentActivity item/completed should emit tool_end');
|
||
assert(completed.kind === 'collab_agent_tool_call', 'Runtime completed subAgentActivity should keep collab tool kind');
|
||
assert(completed.name === 'subAgentActivity', 'Runtime completed subAgentActivity should keep activity name');
|
||
assert(completed.input?.type === 'subAgentActivity', 'Runtime completed subAgentActivity tool_end should carry input');
|
||
assert(completed.input?.kind === 'started', 'Runtime lifecycle-completed subAgentActivity input should inherit the started activity kind when completed omits kind');
|
||
assert(completed.input?.prompt === prompt, 'Runtime completed subAgentActivity input should retain the started prompt when completed omits it');
|
||
assert(completed.input?.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity input should keep receiverThreadIds');
|
||
assert(completed.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity input should retain the started taskDescription');
|
||
assert(completed.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity input should keep child running when completed omits kind');
|
||
const completedResult = JSON.parse(completed.result);
|
||
assert(completedResult.type === 'subAgentActivity', 'Runtime completed subAgentActivity result should preserve original type');
|
||
assert(completedResult.kind === 'started', 'Runtime lifecycle-completed subAgentActivity result should inherit the started activity kind when completed omits kind');
|
||
assert(completedResult.prompt === prompt, 'Runtime completed subAgentActivity result should retain the started prompt when completed omits it');
|
||
assert(completedResult.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity result should expose receiverThreadIds');
|
||
assert(completedResult.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity result should retain the started taskDescription');
|
||
assert(completedResult.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity result should keep child running when completed omits kind');
|
||
assert(entry.toolCalls[0]?.kind === 'collab_agent_tool_call', 'Runtime persisted tool call should keep collab tool kind');
|
||
assert(entry.toolCalls[0]?.input?.type === 'subAgentActivity', 'Runtime persisted tool call should keep subAgentActivity input type for routing recovery');
|
||
assert(entry.toolCalls[0]?.input?.prompt === prompt, 'Runtime persisted subAgentActivity input should retain the started prompt');
|
||
assert(entry.toolCalls[0]?.input?.kind === 'started', 'Runtime persisted lifecycle-completed subAgentActivity input should keep the started activity kind');
|
||
assert(entry.toolCalls[0]?.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime persisted lifecycle-completed subAgentActivity input should keep child running');
|
||
|
||
const explicitCompletedSent = [];
|
||
const explicitCompletedRuntime = createCodexAppRuntime({
|
||
wsSend: (_ws, payload) => explicitCompletedSent.push(payload),
|
||
loadSession: () => null,
|
||
saveSession: () => {},
|
||
});
|
||
const explicitCompletedEntry = { ws: {}, toolCalls: [], fullText: '' };
|
||
const explicitCompletedThreadId = 'agent-thread-runtime-completed';
|
||
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
|
||
method: 'item/started',
|
||
params: {
|
||
item: {
|
||
id: 'runtime-activity-completed',
|
||
type: 'subAgentActivity',
|
||
kind: 'started',
|
||
agentThreadId: explicitCompletedThreadId,
|
||
agentPath: '/root/plan_reviewer',
|
||
prompt,
|
||
},
|
||
},
|
||
}, sessionId);
|
||
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
|
||
method: 'item/completed',
|
||
params: {
|
||
item: {
|
||
id: 'runtime-activity-completed',
|
||
type: 'subAgentActivity',
|
||
kind: 'completed',
|
||
agentThreadId: explicitCompletedThreadId,
|
||
agentPath: '/root/plan_reviewer',
|
||
},
|
||
},
|
||
}, sessionId);
|
||
const explicitCompleted = explicitCompletedSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity-completed');
|
||
assert(explicitCompleted.input?.kind === 'completed', 'Runtime explicit completed subAgentActivity input should preserve completed activity kind');
|
||
assert(explicitCompleted.input?.prompt === prompt, 'Runtime explicit completed subAgentActivity input should retain the started prompt when completed omits it');
|
||
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity input should retain the started taskDescription');
|
||
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity input should map child status to completed');
|
||
const explicitCompletedResult = JSON.parse(explicitCompleted.result);
|
||
assert(explicitCompletedResult.kind === 'completed', 'Runtime explicit completed subAgentActivity result should preserve completed activity kind');
|
||
assert(explicitCompletedResult.prompt === prompt, 'Runtime explicit completed subAgentActivity result should retain the started prompt');
|
||
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity result should retain the started taskDescription');
|
||
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity result should map child status to completed');
|
||
|
||
const explicitRoleSent = [];
|
||
const explicitRoleRuntime = createCodexAppRuntime({
|
||
wsSend: (_ws, payload) => explicitRoleSent.push(payload),
|
||
loadSession: () => null,
|
||
saveSession: () => {},
|
||
});
|
||
explicitRoleRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
|
||
method: 'item/started',
|
||
params: {
|
||
item: {
|
||
id: 'runtime-activity-role',
|
||
type: 'subAgentActivity',
|
||
kind: 'started',
|
||
agentThreadId: 'agent-thread-runtime-role',
|
||
agentPath: '/root/plan_reviewer',
|
||
role: 'reviewer',
|
||
},
|
||
},
|
||
}, sessionId);
|
||
const explicitRoleStarted = explicitRoleSent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity-role');
|
||
assert(explicitRoleStarted.input?.agentsStates?.['agent-thread-runtime-role']?.role === 'reviewer', 'Runtime subAgentActivity should preserve explicit role');
|
||
|
||
const reasoningSent = [];
|
||
const reasoningRuntime = createCodexAppRuntime({
|
||
wsSend: (_ws, payload) => reasoningSent.push(payload),
|
||
loadSession: () => null,
|
||
saveSession: () => {},
|
||
});
|
||
reasoningRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
|
||
method: 'item/completed',
|
||
params: {
|
||
item: {
|
||
id: 'runtime-reasoning',
|
||
type: 'reasoning',
|
||
content: [{ text: '推理完成' }],
|
||
},
|
||
},
|
||
}, sessionId);
|
||
const reasoningEnd = reasoningSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-reasoning');
|
||
assert(reasoningEnd, 'Runtime reasoning item/completed should still emit tool_end');
|
||
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'input'), 'Runtime non-subAgentActivity reasoning tool_end should not gain input');
|
||
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
|
||
}
|
||
|
||
function assertFrontendPrimaryCodexAppUiContract() {
|
||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
|
||
assert(!indexSource.includes('id="chat-agent-btn"'), 'Ordinary UI should not render an agent picker button');
|
||
assert(!indexSource.includes('id="chat-agent-menu"'), 'Ordinary UI should not render an agent picker menu');
|
||
assert(!indexSource.includes('class="chat-agent-picker"'), 'Ordinary UI should not render the agent picker container');
|
||
assert(
|
||
/\.chat-agent-picker\s*\{[\s\S]*?display:\s*none;/.test(styleSource),
|
||
'Stale cached agent picker markup should be hidden by CSS'
|
||
);
|
||
|
||
assert(source.includes("const PRIMARY_UI_AGENT = 'codexapp';"), 'Frontend should define Codex App as the primary UI agent');
|
||
assert(source.includes('const DEFAULT_AGENT = PRIMARY_UI_AGENT;'), 'Frontend default agent should point at the primary UI agent');
|
||
assert(source.includes('let currentAgent = DEFAULT_AGENT;'), 'Frontend should not initialize currentAgent from stale localStorage');
|
||
assert(source.includes("localStorage.setItem('cc-web-agent', currentAgent);"), 'Frontend should overwrite stale cc-web-agent storage with the primary UI agent');
|
||
assert(source.includes('currentAgent = normalizeUiAgent(agent);'), 'setCurrentAgent should coerce ordinary UI agent changes back to Codex App');
|
||
assert(source.includes('return sessions.filter((s) => isPrimaryUiAgent(s.agent));'), 'Session list should only expose Codex App sessions in ordinary UI');
|
||
assert(
|
||
/function applySessionSnapshot\(snapshot[\s\S]*?!isPrimaryUiAgent\(snapshotAgent\)[\s\S]*?return false;[\s\S]*?return true;/.test(source),
|
||
'Frontend should reject legacy Claude/Codex snapshots from the current view'
|
||
);
|
||
assert(source.includes('if (!isPrimaryUiAgent(snapshot.agent)) return false;'), 'Cached legacy sessions should not render in ordinary UI');
|
||
assert(source.includes('const agent = normalizeUiAgent(options.agent || currentAgent);'), 'Ordinary new-session UI should create Codex App sessions');
|
||
assert(source.includes('const targetAgent = normalizeUiAgent(options.agent || currentAgent);'), 'New-session modal should present the Codex App space');
|
||
assert(
|
||
/function showSettingsPanel\(\)[\s\S]*?setCurrentAgent\(PRIMARY_UI_AGENT\);[\s\S]*?showCodexSettingsPanel\(\);/.test(source),
|
||
'Settings button should route to existing Codex settings in ordinary UI'
|
||
);
|
||
|
||
assert(indexSource.includes('id="mode-select"'), 'Permission mode selector should remain visible');
|
||
assert(indexSource.includes('<option value="yolo">YOLO</option>'), 'YOLO permission mode should remain available');
|
||
assert(indexSource.includes('<option value="default">默认</option>'), 'Default permission mode should remain available');
|
||
assert(indexSource.includes('<option value="plan">Plan</option>'), 'Plan permission mode should remain available');
|
||
assert(indexSource.includes('Claude / Codex Web Chat'), 'Static login copy should keep existing Claude/Codex wording');
|
||
assert(indexSource.includes('<h3 id="warframe-welcome-title" data-welcome-project-copy></h3>'), 'Static welcome should expose an empty project heading for runtime hydration');
|
||
assert(indexSource.includes('<p>本次你要构建什么?</p>'), 'Static welcome should show the requested build prompt');
|
||
assert(!indexSource.includes('开始与 Claude 对话'), 'Static welcome should not hard-code a legacy Claude label');
|
||
assert(
|
||
/function getWelcomeProjectName\(cwd = currentCwd\)[\s\S]*?getPathLeaf\(cwd\) \|\| '当前项目'[\s\S]*?function getWelcomeCopy\(cwd = currentCwd\)[\s\S]*?`你正在操作 \$\{getWelcomeProjectName\(cwd\)\}`/.test(source),
|
||
'Welcome heading should resolve the current project name from the active cwd'
|
||
);
|
||
assert(source.includes('<h3 id="warframe-welcome-title" data-welcome-project-copy>${escapeHtml(getWelcomeCopy(cwd))}</h3>'), 'Dynamic welcome heading should render the escaped current project name');
|
||
assert(source.includes('<p>本次你要构建什么?</p>'), 'Dynamic welcome markup should show the requested build prompt');
|
||
assert(source.includes('syncWelcomeCopy(currentCwd);'), 'Cwd changes should hydrate or refresh the visible project heading');
|
||
assert((source.match(/buildWelcomeMarkup\(currentCwd\)/g) || []).length >= 2, 'Empty-session welcome markup should use the active cwd');
|
||
const getPathLeafSource = source.match(/function getPathLeaf\(input\) \{[\s\S]*?\n \}/)?.[0];
|
||
const getWelcomeProjectNameSource = source.match(/function getWelcomeProjectName\(cwd = currentCwd\) \{[\s\S]*?\n \}/)?.[0];
|
||
const getWelcomeCopySource = source.match(/function getWelcomeCopy\(cwd = currentCwd\) \{[\s\S]*?\n \}/)?.[0];
|
||
assert(getPathLeafSource && getWelcomeProjectNameSource && getWelcomeCopySource, 'Welcome project-name helpers should remain extractable for behavioral checks');
|
||
const welcomeCopyApi = new Function(`${getPathLeafSource}\n${getWelcomeProjectNameSource}\n${getWelcomeCopySource}\nreturn { getWelcomeCopy };`)();
|
||
assert(welcomeCopyApi.getWelcomeCopy('/home/cc-web') === '你正在操作 cc-web', 'Welcome heading should display the current cwd leaf as the project name');
|
||
assert(welcomeCopyApi.getWelcomeCopy('C:\\work\\demo-project\\') === '你正在操作 demo-project', 'Welcome heading should normalize Windows-style project paths');
|
||
assert(welcomeCopyApi.getWelcomeCopy('') === '你正在操作 当前项目', 'Welcome heading should remain readable before a project is selected');
|
||
assert(
|
||
serverSource.includes("const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);"),
|
||
'Server explicit agent support should remain available for API/MCP paths'
|
||
);
|
||
}
|
||
|
||
function assertSetTitleMcpContract() {
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
|
||
assert(serverSource.includes("case 'ccweb_set_title':"), 'Server should route ccweb_set_title through internal MCP');
|
||
assert(serverSource.includes('function setCurrentConversationTitle'), 'Server should implement current-conversation title setting');
|
||
assert(serverSource.includes('ignored because title is manually locked'), 'ccweb_set_title should report manual title locks as ignored success');
|
||
assert(serverSource.includes("titleSource = 'manual'"), 'Manual UI rename should mark titleSource as manual');
|
||
assert(serverSource.includes('CCWEB_TITLE_TOOL_INSTRUCTIONS'), 'Server should define model guidance for the title tool');
|
||
assert(frontendSource.includes("createdFromKind || ''") && frontendSource.includes("' llm-created'"), 'Frontend should mark MCP-created sessions with llm-created class');
|
||
assert(frontendSource.includes('snapshot.titleSource') && frontendSource.includes('snapshot.createdFromKind'), 'Frontend should preserve title metadata in session snapshots');
|
||
assert(styleSource.includes('.session-item.llm-created:not(.pinned)::before'), 'LLM-created marker should be hidden when the session is pinned');
|
||
}
|
||
|
||
function assertSessionItemTooltipContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const tooltipSource = extractFunctionSource(frontendSource, 'buildSessionItemTooltip');
|
||
const buildSessionItemTooltip = new Function(`${tooltipSource}\nreturn buildSessionItemTooltip;`)();
|
||
const fullTitle = '这个标题显示不下,需要在悬停时完整展示';
|
||
|
||
assert(
|
||
buildSessionItemTooltip('cc-web', fullTitle) === `项目:cc-web\n标题:${fullTitle}`,
|
||
'Session item tooltip should show the project and the complete conversation title on separate lines'
|
||
);
|
||
assert(
|
||
buildSessionItemTooltip('', fullTitle) === `标题:${fullTitle}`,
|
||
'Session item tooltip should still expose the complete title when no project is available'
|
||
);
|
||
|
||
const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem');
|
||
assert(createItemSource.includes('getSessionProjectName(session)'), 'Session item tooltip should reuse the canonical project-name resolver');
|
||
assert(createItemSource.includes('item.title = buildSessionItemTooltip('), 'Session card should apply the combined project/title tooltip');
|
||
assert(!createItemSource.includes('item.title = sessionCwd'), 'Session card should not fall back to a project-only tooltip');
|
||
}
|
||
|
||
function assertSidebarTitleRefreshStormContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem');
|
||
const renderListSource = extractFunctionSource(frontendSource, 'renderSessionList');
|
||
const optionalSources = [
|
||
'buildSessionListStructureSignature',
|
||
'refreshSessionListRelativeTimes',
|
||
].map((name) => maybeExtractFunctionSource(frontendSource, name)).filter(Boolean).join('\n');
|
||
|
||
const api = new Function(`
|
||
class MiniElement {
|
||
constructor(tagName = 'div') {
|
||
this.tagName = String(tagName || 'div').toUpperCase();
|
||
this.children = [];
|
||
this.dataset = {};
|
||
this.attributes = {};
|
||
this.listeners = {};
|
||
this.parentElement = null;
|
||
this.hidden = false;
|
||
this._className = '';
|
||
this._textContent = '';
|
||
this._innerHTML = '';
|
||
}
|
||
get className() {
|
||
return this._className;
|
||
}
|
||
set className(value) {
|
||
this._className = String(value || '');
|
||
}
|
||
get classList() {
|
||
const node = this;
|
||
return {
|
||
contains(name) {
|
||
return node._className.split(/\\s+/).filter(Boolean).includes(name);
|
||
},
|
||
add(...names) {
|
||
const classes = new Set(node._className.split(/\\s+/).filter(Boolean));
|
||
names.filter(Boolean).forEach((name) => classes.add(name));
|
||
node._className = [...classes].join(' ');
|
||
},
|
||
remove(...names) {
|
||
const removeSet = new Set(names.filter(Boolean));
|
||
node._className = node._className.split(/\\s+/).filter(Boolean).filter((name) => !removeSet.has(name)).join(' ');
|
||
},
|
||
toggle(name, force) {
|
||
const hasClass = this.contains(name);
|
||
const shouldAdd = force === undefined ? !hasClass : !!force;
|
||
if (shouldAdd) this.add(name);
|
||
else this.remove(name);
|
||
return shouldAdd;
|
||
},
|
||
};
|
||
}
|
||
get childElementCount() {
|
||
return this.children.length;
|
||
}
|
||
get textContent() {
|
||
return this._textContent;
|
||
}
|
||
set textContent(value) {
|
||
this._textContent = String(value || '');
|
||
this.children = [];
|
||
}
|
||
get innerHTML() {
|
||
return this._innerHTML;
|
||
}
|
||
set innerHTML(value) {
|
||
this._innerHTML = String(value || '');
|
||
this.children = [];
|
||
this._parseInnerHtml(this._innerHTML);
|
||
}
|
||
_parseInnerHtml(html) {
|
||
const tagRe = /<([a-z][a-z0-9-]*)([^>]*)>/gi;
|
||
let match;
|
||
while ((match = tagRe.exec(html))) {
|
||
const tagName = match[1];
|
||
const attrSource = match[2] || '';
|
||
const classMatch = attrSource.match(/class="([^"]*)"/);
|
||
if (!classMatch) continue;
|
||
const child = new MiniElement(tagName);
|
||
child.className = classMatch[1];
|
||
const titleMatch = attrSource.match(/title="([^"]*)"/);
|
||
if (titleMatch) child.title = titleMatch[1];
|
||
const ariaExpandedMatch = attrSource.match(/aria-expanded="([^"]*)"/);
|
||
if (ariaExpandedMatch) child.setAttribute('aria-expanded', ariaExpandedMatch[1]);
|
||
const idMatch = attrSource.match(/id="([^"]*)"/);
|
||
if (idMatch) child.id = idMatch[1];
|
||
const closeTag = '</' + tagName + '>';
|
||
const closeIndex = html.indexOf(closeTag, tagRe.lastIndex);
|
||
if (closeIndex >= 0) {
|
||
const raw = html.slice(tagRe.lastIndex, closeIndex);
|
||
child._textContent = raw.replace(/<[^>]+>/g, '').replace(/\\s+/g, ' ').trim();
|
||
}
|
||
this.appendChild(child);
|
||
}
|
||
}
|
||
appendChild(child) {
|
||
if (child && typeof child === 'object') child.parentElement = this;
|
||
this.children.push(child);
|
||
return child;
|
||
}
|
||
setAttribute(name, value) {
|
||
this.attributes[name] = String(value);
|
||
}
|
||
getAttribute(name) {
|
||
return this.attributes[name];
|
||
}
|
||
addEventListener(name, handler) {
|
||
if (!this.listeners[name]) this.listeners[name] = [];
|
||
this.listeners[name].push(handler);
|
||
}
|
||
dispatchEvent(event) {
|
||
const evt = event || {};
|
||
evt.target = evt.target || this;
|
||
evt.stopPropagation = evt.stopPropagation || function stopPropagation() {};
|
||
(this.listeners[evt.type] || []).forEach((handler) => handler(evt));
|
||
}
|
||
matches(selector) {
|
||
return selector.split(',').some((part) => {
|
||
const classes = String(part || '').trim().match(/\\.([a-zA-Z0-9_-]+)/g)?.map((item) => item.slice(1)) || [];
|
||
return classes.length > 0 && classes.every((name) => this.classList.contains(name));
|
||
});
|
||
}
|
||
closest(selector) {
|
||
let node = this;
|
||
while (node) {
|
||
if (node.matches(selector)) return node;
|
||
node = node.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
querySelector(selector) {
|
||
return this.querySelectorAll(selector)[0] || null;
|
||
}
|
||
querySelectorAll(selector) {
|
||
const results = [];
|
||
const visit = (node) => {
|
||
if (!node || typeof node !== 'object') return;
|
||
if (node !== this && node.matches(selector)) results.push(node);
|
||
(Array.isArray(node.children) ? node.children : []).forEach(visit);
|
||
};
|
||
visit(this);
|
||
return results;
|
||
}
|
||
}
|
||
|
||
let sessions = [];
|
||
let currentSessionId = 's1';
|
||
let sessionSearchQuery = '';
|
||
let lastSessionListStructureSignature = '';
|
||
let collapseOlderSessions = false;
|
||
let failOldSessionLoadMoreOnce = false;
|
||
let currentAgent = 'codexapp';
|
||
let currentMode = 'yolo';
|
||
const AGENT_LABELS = { codexapp: 'Codex' };
|
||
const collapsedProjectKeys = new Set();
|
||
const pendingNotesByTarget = new Map();
|
||
const queuedMessagesByTarget = new Map();
|
||
const localStorage = { removeItem() {}, setItem() {}, getItem() { return null; } };
|
||
const sessionList = new MiniElement('div');
|
||
sessionList.clearCount = 0;
|
||
Object.defineProperty(sessionList, 'innerHTML', {
|
||
get() { return this._innerHTML; },
|
||
set(value) {
|
||
this._innerHTML = String(value || '');
|
||
this.children = [];
|
||
if (value === '') this.clearCount += 1;
|
||
},
|
||
});
|
||
const document = {
|
||
createElement(tagName) {
|
||
return new MiniElement(tagName);
|
||
},
|
||
querySelectorAll(selector) {
|
||
return sessionList.querySelectorAll(selector);
|
||
},
|
||
};
|
||
const Element = MiniElement;
|
||
let openedSessionIds = [];
|
||
|
||
function syncSessionSearchUi() {}
|
||
function normalizeAgent(agent) { return AGENT_LABELS[agent] ? agent : 'codexapp'; }
|
||
function getVisibleSessions() { return sessions; }
|
||
function normalizeSessionSearchQuery(query) { return String(query || '').trim().toLowerCase(); }
|
||
function sessionMatchesSearch(session, normalizedQuery) {
|
||
return !normalizedQuery || String(session.title || '').toLowerCase().includes(normalizedQuery);
|
||
}
|
||
function getPathLeaf(input) {
|
||
const normalized = String(input || '').replace(/\\\\/g, '/').replace(/\\/+$/, '');
|
||
return normalized.split('/').filter(Boolean).pop() || '';
|
||
}
|
||
function getSessionEffectiveCwd(session) { return session?.cwd || ''; }
|
||
function getSessionProjectName(session) { return session?.projectName || getPathLeaf(getSessionEffectiveCwd(session)); }
|
||
function buildSessionItemTooltip(projectName, title) {
|
||
return [projectName ? '项目:' + projectName : '', '标题:' + (title || 'Untitled')].filter(Boolean).join('\\n');
|
||
}
|
||
function escapeHtml(value) { return String(value ?? ''); }
|
||
function timeAgo(value) { return 'time:' + String(value || ''); }
|
||
function compareSessionUpdatedDesc(a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); }
|
||
function compareSessionPinnedDesc(a, b) { return new Date(b.pinnedAt || 0) - new Date(a.pinnedAt || 0); }
|
||
function splitPinnedSessions(sessionItems) {
|
||
const pinnedSessions = [];
|
||
const regularSessions = [];
|
||
for (const session of sessionItems) {
|
||
(session.pinnedAt ? pinnedSessions : regularSessions).push(session);
|
||
}
|
||
pinnedSessions.sort(compareSessionPinnedDesc);
|
||
regularSessions.sort(compareSessionUpdatedDesc);
|
||
return { pinnedSessions, regularSessions };
|
||
}
|
||
function groupSessionsByProject(sessionItems) {
|
||
const groups = [];
|
||
const groupMap = new Map();
|
||
const ungroupedSessions = [];
|
||
for (const session of sessionItems) {
|
||
const name = getSessionProjectName(session);
|
||
if (!name) {
|
||
ungroupedSessions.push(session);
|
||
continue;
|
||
}
|
||
if (!groupMap.has(name)) {
|
||
const group = { name, cwd: getSessionEffectiveCwd(session), sessions: [], latestUpdated: session.updated || '' };
|
||
groupMap.set(name, group);
|
||
groups.push(group);
|
||
}
|
||
const group = groupMap.get(name);
|
||
group.sessions.push(session);
|
||
if (new Date(session.updated || 0) > new Date(group.latestUpdated || 0)) {
|
||
group.latestUpdated = session.updated || group.latestUpdated;
|
||
group.cwd = getSessionEffectiveCwd(session) || group.cwd;
|
||
}
|
||
}
|
||
for (const group of groups) group.sessions.sort(compareSessionUpdatedDesc);
|
||
ungroupedSessions.sort(compareSessionUpdatedDesc);
|
||
return { groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)), ungroupedSessions };
|
||
}
|
||
function getProjectCollapseKey(group) { return normalizeAgent(currentAgent) + ':' + (group?.cwd || group?.name || ''); }
|
||
function getProjectOldSessionCollapseKey(group) { return 'project:' + getProjectCollapseKey(group); }
|
||
function getUngroupedOldSessionCollapseKey() { return normalizeAgent(currentAgent) + ':ungrouped'; }
|
||
function splitCollapsedSessions(sessionItems) {
|
||
if (!collapseOlderSessions || sessionItems.length < 2) {
|
||
return { visibleSessions: sessionItems, hiddenSessions: [] };
|
||
}
|
||
return { visibleSessions: sessionItems.slice(0, 1), hiddenSessions: sessionItems.slice(1) };
|
||
}
|
||
function createOldSessionLoadMoreButton() {
|
||
if (failOldSessionLoadMoreOnce) {
|
||
failOldSessionLoadMoreOnce = false;
|
||
throw new Error('synthetic old-session render failure');
|
||
}
|
||
return new MiniElement('button');
|
||
}
|
||
function setProjectCollapsed() {}
|
||
function quickCreateProjectSession() {}
|
||
function setSessionActionMenuOpen(item, open) { item.classList.toggle('menu-open', open); }
|
||
function closeSessionActionMenus() {}
|
||
function copyTextToClipboard() {}
|
||
function toggleSessionPinned() {}
|
||
function getLastSessionForAgent() { return ''; }
|
||
function getAgentSessionStorageKey() { return ''; }
|
||
function getSessionQueueKey(sessionId) { return sessionId ? 'session:' + sessionId : ''; }
|
||
function invalidateSessionCache() {}
|
||
function send() {}
|
||
function resetChatView() {}
|
||
const skipDeleteConfirm = true;
|
||
function showDeleteConfirm() {}
|
||
function isMobileInputMode() { return false; }
|
||
function closeSidebar() {}
|
||
function openSession(sessionId) { openedSessionIds.push(sessionId); }
|
||
function startEditSessionTitle() {}
|
||
|
||
${optionalSources}
|
||
${createItemSource}
|
||
${renderListSource}
|
||
|
||
function cloneSession(session, overrides = {}) {
|
||
return { ...session, ...overrides };
|
||
}
|
||
function setSessions(nextSessions) {
|
||
sessions = nextSessions.map((session) => ({ ...session }));
|
||
}
|
||
function nodesByClass(className) {
|
||
return sessionList.querySelectorAll('.' + className);
|
||
}
|
||
function timeTextFor(sessionId) {
|
||
const item = nodesByClass('session-item').find((node) => node.dataset.id === sessionId);
|
||
return item?.querySelector('.session-item-time')?.textContent || '';
|
||
}
|
||
return {
|
||
renderSessionList,
|
||
setSessions,
|
||
setCollapseOlderSessions(value) { collapseOlderSessions = !!value; },
|
||
failNextOldSessionLoadMore() { failOldSessionLoadMoreOnce = true; },
|
||
cloneSession,
|
||
nodeState() {
|
||
return {
|
||
clearCount: sessionList.clearCount,
|
||
groups: nodesByClass('session-project-group'),
|
||
items: nodesByClass('session-item'),
|
||
openedSessionIds: [...openedSessionIds],
|
||
};
|
||
},
|
||
timeTextFor,
|
||
clickFirstSession() {
|
||
const first = nodesByClass('session-item')[0];
|
||
first.dispatchEvent({ type: 'click', target: first });
|
||
},
|
||
};
|
||
`)();
|
||
|
||
const baseSessions = [
|
||
{
|
||
id: 's1',
|
||
agent: 'codexapp',
|
||
title: 'Alpha',
|
||
updated: '2026-07-30T08:00:00.000Z',
|
||
cwd: '/work/cc-web',
|
||
isRunning: false,
|
||
hasUnread: false,
|
||
waitingOnChildren: false,
|
||
readyReplyCount: 0,
|
||
pendingReplyCount: 0,
|
||
},
|
||
{
|
||
id: 's2',
|
||
agent: 'codexapp',
|
||
title: 'Beta',
|
||
updated: '2026-07-30T07:00:00.000Z',
|
||
cwd: '/work/cc-web',
|
||
isRunning: false,
|
||
hasUnread: false,
|
||
waitingOnChildren: false,
|
||
readyReplyCount: 0,
|
||
pendingReplyCount: 0,
|
||
},
|
||
];
|
||
|
||
api.setSessions(baseSessions);
|
||
api.renderSessionList();
|
||
const initial = api.nodeState();
|
||
assert(initial.clearCount === 1, 'Initial sidebar render should build the DOM once');
|
||
assert(initial.groups.length === 1, 'Initial sidebar render should create a project group');
|
||
assert(initial.items.length === 2, 'Initial sidebar render should create session items');
|
||
api.clickFirstSession();
|
||
assert(api.nodeState().openedSessionIds.join(',') === 's1', 'Initial session item click listener should work');
|
||
|
||
api.setSessions([
|
||
api.cloneSession(baseSessions[0], { updated: '2026-07-30T08:00:30.000Z' }),
|
||
baseSessions[1],
|
||
]);
|
||
api.renderSessionList();
|
||
const afterUpdatedOnly = api.nodeState();
|
||
assert(afterUpdatedOnly.clearCount === 1, 'Updated-only sidebar snapshots should not clear the list again');
|
||
assert(afterUpdatedOnly.groups[0] === initial.groups[0], 'Updated-only sidebar snapshots should keep project group node identity');
|
||
assert(afterUpdatedOnly.items[0] === initial.items[0], 'Updated-only sidebar snapshots should keep session item node identity');
|
||
assert(api.timeTextFor('s1') === 'time:2026-07-30T08:00:30.000Z', 'Updated-only sidebar snapshots should refresh relative time in place');
|
||
api.clickFirstSession();
|
||
assert(api.nodeState().openedSessionIds.join(',') === 's1,s1', 'Updated-only sidebar snapshots should keep existing click listener usable');
|
||
|
||
api.setSessions([
|
||
api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z' }),
|
||
baseSessions[1],
|
||
]);
|
||
api.renderSessionList();
|
||
const afterTitle = api.nodeState();
|
||
assert(afterTitle.clearCount === 2, 'Title changes should still rebuild the sidebar structure');
|
||
assert(afterTitle.items[0] !== afterUpdatedOnly.items[0], 'Title changes should replace the affected session node');
|
||
|
||
api.setSessions([
|
||
api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z', isRunning: true }),
|
||
baseSessions[1],
|
||
]);
|
||
api.renderSessionList();
|
||
const afterStatus = api.nodeState();
|
||
assert(afterStatus.clearCount === 3, 'Running status changes should still rebuild the sidebar structure');
|
||
|
||
api.setSessions([
|
||
api.cloneSession(baseSessions[1], { updated: '2026-07-30T09:00:00.000Z' }),
|
||
api.cloneSession(baseSessions[0], { title: 'Alpha renamed', updated: '2026-07-30T08:00:30.000Z', isRunning: true }),
|
||
]);
|
||
api.renderSessionList();
|
||
const afterOrder = api.nodeState();
|
||
assert(afterOrder.clearCount === 4, 'Order changes should still rebuild the sidebar structure');
|
||
assert(afterOrder.items[0].dataset.id === 's2', 'Order changes should render the new first session in place');
|
||
|
||
api.setCollapseOlderSessions(true);
|
||
api.renderSessionList();
|
||
const afterOldSessionCollapse = api.nodeState();
|
||
assert(afterOldSessionCollapse.clearCount === 5, 'Old-session collapse changes should rebuild without losing its collapse key');
|
||
assert(afterOldSessionCollapse.items.length === 1, 'Old-session collapse should keep only the recent project session visible');
|
||
|
||
api.setSessions([
|
||
api.cloneSession(baseSessions[0], { id: 'pinned', title: 'Pinned', pinnedAt: '2026-07-30T10:00:00.000Z' }),
|
||
api.cloneSession(baseSessions[0], { title: 'Alpha regular' }),
|
||
baseSessions[1],
|
||
]);
|
||
api.failNextOldSessionLoadMore();
|
||
let syntheticRenderFailed = false;
|
||
try {
|
||
api.renderSessionList();
|
||
} catch (err) {
|
||
syntheticRenderFailed = err?.message === 'synthetic old-session render failure';
|
||
}
|
||
assert(syntheticRenderFailed, 'The regression harness should exercise a partial sidebar render failure');
|
||
api.renderSessionList();
|
||
const afterRenderRetry = api.nodeState();
|
||
assert(afterRenderRetry.groups.length === 2, 'A render retry should rebuild both pinned and project groups after a partial failure');
|
||
assert(afterRenderRetry.items.length === 2, 'A render retry should not accept a partial pinned-only DOM as complete');
|
||
}
|
||
|
||
function assertCcwebMcpChildUpdateCoalescingContract() {
|
||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const functionNames = [
|
||
'isFinalCcwebMcpChildStatus',
|
||
'snapshotCcwebMcpChildForPersist',
|
||
'flushPendingCcwebMcpChildSession',
|
||
'updateCcwebMcpChildToolState',
|
||
'updatePersistedCcwebMcpChildTool',
|
||
'flushCcwebMcpChildSessionListBroadcast',
|
||
'scheduleCcwebMcpChildSessionListBroadcast',
|
||
'sendCcwebMcpChildAgentUpdate',
|
||
];
|
||
const helperSource = functionNames.map((name) => extractFunctionSource(source, name)).join('\n');
|
||
const api = new Function(`
|
||
const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = 250;
|
||
const pendingCcwebMcpChildSessionFlushes = new Map();
|
||
let ccwebMcpChildSessionListBroadcastTimer = null;
|
||
const activeCodexAppTurns = new Map();
|
||
const diskSessions = new Map();
|
||
const scheduledTimers = [];
|
||
const sentPayloads = [];
|
||
const targetWs = { readyState: 1 };
|
||
let loadCount = 0;
|
||
let saveCount = 0;
|
||
let broadcastCount = 0;
|
||
|
||
function clone(value) {
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
function setTimeout(callback) {
|
||
const timer = { callback, active: true, unref() {} };
|
||
scheduledTimers.push(timer);
|
||
return timer;
|
||
}
|
||
function clearTimeout(timer) {
|
||
if (timer) timer.active = false;
|
||
}
|
||
function loadSession(sessionId) {
|
||
loadCount += 1;
|
||
const session = diskSessions.get(sessionId);
|
||
return session ? clone(session) : null;
|
||
}
|
||
function saveSession(session) {
|
||
saveCount += 1;
|
||
diskSessions.set(session.id, clone(session));
|
||
return true;
|
||
}
|
||
function findViewingSessionWs() {
|
||
return targetWs;
|
||
}
|
||
function findCcwebMcpChildTargetToolInToolCalls(toolCalls, spawnToolId) {
|
||
return (Array.isArray(toolCalls) ? toolCalls : []).find((tool) => tool?.id === spawnToolId) || null;
|
||
}
|
||
function findCcwebMcpChildTargetToolInMessages(messages, spawnToolId) {
|
||
for (const message of Array.isArray(messages) ? messages : []) {
|
||
const tool = findCcwebMcpChildTargetToolInToolCalls(message?.toolCalls, spawnToolId);
|
||
if (tool) return tool;
|
||
}
|
||
return null;
|
||
}
|
||
function mergeCcwebMcpChildIntoTool(tool, child) {
|
||
if (!tool || !child) return null;
|
||
const result = tool.result ? JSON.parse(tool.result) : {};
|
||
const agentsStates = result.agentsStates || {};
|
||
agentsStates[child.threadId] = {
|
||
...(agentsStates[child.threadId] || {}),
|
||
status: child.status,
|
||
planCurrentStep: child.planCurrentStep || '',
|
||
finalMessage: child.finalMessage || '',
|
||
};
|
||
tool.result = JSON.stringify({ ...result, status: child.status, agentsStates });
|
||
tool.done = isFinalCcwebMcpChildStatus(child.status);
|
||
return tool;
|
||
}
|
||
function ccwebMcpChildPublicState(child) {
|
||
return { ...child };
|
||
}
|
||
function wsSend(ws, payload) {
|
||
sentPayloads.push({ ws, payload: clone(payload) });
|
||
}
|
||
function broadcastSessionList() {
|
||
broadcastCount += 1;
|
||
}
|
||
|
||
${helperSource}
|
||
|
||
function flushTimers() {
|
||
let progressed = true;
|
||
while (progressed) {
|
||
progressed = false;
|
||
for (const timer of scheduledTimers) {
|
||
if (!timer.active) continue;
|
||
timer.active = false;
|
||
timer.callback();
|
||
progressed = true;
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
seed(session, activeTool) {
|
||
diskSessions.set(session.id, clone(session));
|
||
activeCodexAppTurns.set(session.id, { ws: targetWs, toolCalls: [activeTool] });
|
||
},
|
||
send: sendCcwebMcpChildAgentUpdate,
|
||
flushTimers,
|
||
snapshot() {
|
||
return {
|
||
loadCount,
|
||
saveCount,
|
||
broadcastCount,
|
||
payloadCount: sentPayloads.length,
|
||
diskSessions: clone([...diskSessions.entries()]),
|
||
};
|
||
},
|
||
};
|
||
`)();
|
||
|
||
const persistedTool = {
|
||
id: 'spawn-child-1',
|
||
name: 'subAgentActivity',
|
||
kind: 'collab_agent_tool_call',
|
||
input: '{}',
|
||
result: JSON.stringify({ agentsStates: {} }),
|
||
done: false,
|
||
};
|
||
api.seed({
|
||
id: 'parent-session',
|
||
title: 'Parent',
|
||
updated: '2026-07-30T08:00:00.000Z',
|
||
messages: [{ role: 'assistant', content: '', toolCalls: [persistedTool] }],
|
||
}, { ...persistedTool });
|
||
|
||
for (const planCurrentStep of ['分析', '实现', '验证']) {
|
||
api.send('parent-session', {
|
||
threadId: 'child-1',
|
||
spawnToolId: 'spawn-child-1',
|
||
status: 'running',
|
||
planCurrentStep,
|
||
});
|
||
}
|
||
const duringBurst = api.snapshot();
|
||
assert(duringBurst.payloadCount === 3, 'Every child delta should still send a realtime local payload');
|
||
assert(duringBurst.loadCount === 1, 'A child update burst should reuse one pending parent session snapshot');
|
||
assert(duringBurst.saveCount === 0, 'A running child update burst should defer parent session persistence');
|
||
assert(duringBurst.broadcastCount === 0, 'A running child update burst should defer full session-list broadcasts');
|
||
|
||
api.flushTimers();
|
||
const afterBurst = api.snapshot();
|
||
assert(afterBurst.saveCount === 1, 'A child update burst should persist the parent session once');
|
||
assert(afterBurst.loadCount <= 2, 'A child update burst should not reload the parent session for every delta');
|
||
assert(afterBurst.broadcastCount === 1, 'A child update burst should broadcast the full session list once');
|
||
const storedAfterBurst = new Map(afterBurst.diskSessions).get('parent-session');
|
||
const storedBurstState = JSON.parse(storedAfterBurst.messages[0].toolCalls[0].result).agentsStates['child-1'];
|
||
assert(storedBurstState.planCurrentStep === '验证', 'The trailing flush should persist the latest child state');
|
||
|
||
api.send('parent-session', {
|
||
threadId: 'child-1',
|
||
spawnToolId: 'spawn-child-1',
|
||
status: 'returned',
|
||
planCurrentStep: '完成',
|
||
finalMessage: '最终结果',
|
||
});
|
||
const afterFinal = api.snapshot();
|
||
assert(afterFinal.payloadCount === 4, 'A final child update should still send its realtime local payload');
|
||
assert(afterFinal.saveCount === 2, 'A final child update should flush persistence immediately');
|
||
assert(afterFinal.broadcastCount === 2, 'A final child update should flush the session-list broadcast immediately');
|
||
const storedFinal = new Map(afterFinal.diskSessions).get('parent-session');
|
||
const storedFinalState = JSON.parse(storedFinal.messages[0].toolCalls[0].result).agentsStates['child-1'];
|
||
assert(storedFinalState.status === 'returned' && storedFinalState.finalMessage === '最终结果', 'The final child state must not be lost');
|
||
|
||
api.flushTimers();
|
||
const afterCancelledTimers = api.snapshot();
|
||
assert(afterCancelledTimers.saveCount === 2 && afterCancelledTimers.broadcastCount === 2, 'An immediate final flush should cancel stale trailing work');
|
||
|
||
const sharedSpawnTool = {
|
||
id: 'spawn-shared',
|
||
name: 'subAgentActivity',
|
||
kind: 'collab_agent_tool_call',
|
||
input: '{}',
|
||
result: JSON.stringify({ agentsStates: {} }),
|
||
done: false,
|
||
};
|
||
api.seed({
|
||
id: 'sibling-parent-session',
|
||
title: 'Sibling parent',
|
||
updated: '2026-07-30T08:00:00.000Z',
|
||
messages: [{ role: 'assistant', content: '', toolCalls: [sharedSpawnTool] }],
|
||
}, { ...sharedSpawnTool });
|
||
const beforeSiblingBurst = api.snapshot();
|
||
api.send('sibling-parent-session', {
|
||
threadId: 'sibling-a',
|
||
spawnToolId: 'spawn-shared',
|
||
status: 'running',
|
||
planCurrentStep: 'A 验证',
|
||
});
|
||
api.send('sibling-parent-session', {
|
||
threadId: 'sibling-b',
|
||
spawnToolId: 'spawn-shared',
|
||
status: 'running',
|
||
planCurrentStep: 'B 验证',
|
||
});
|
||
const duringSiblingBurst = api.snapshot();
|
||
assert(duringSiblingBurst.payloadCount === beforeSiblingBurst.payloadCount + 2, 'Sibling child deltas should both remain realtime');
|
||
assert(duringSiblingBurst.saveCount === beforeSiblingBurst.saveCount, 'Sibling child deltas should share the pending parent flush');
|
||
api.flushTimers();
|
||
const afterSiblingBurst = api.snapshot();
|
||
assert(afterSiblingBurst.saveCount === beforeSiblingBurst.saveCount + 1, 'Sibling child deltas should persist in one parent save');
|
||
assert(afterSiblingBurst.broadcastCount === beforeSiblingBurst.broadcastCount + 1, 'Sibling child deltas should share one full-list broadcast');
|
||
const storedSiblingSession = new Map(afterSiblingBurst.diskSessions).get('sibling-parent-session');
|
||
const storedSiblingStates = JSON.parse(storedSiblingSession.messages[0].toolCalls[0].result).agentsStates;
|
||
assert(storedSiblingStates['sibling-a']?.planCurrentStep === 'A 验证', 'The first sibling sharing a spawn tool must not be dropped');
|
||
assert(storedSiblingStates['sibling-b']?.planCurrentStep === 'B 验证', 'The second sibling sharing a spawn tool must be persisted');
|
||
}
|
||
|
||
function assertTitleHistoryOutlineContract() {
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||
|
||
const normalizeTitleHistorySource = extractFunctionSource(serverSource, 'normalizeTitleHistory');
|
||
const normalizeServerHistory = new Function(`${normalizeTitleHistorySource}\nreturn normalizeTitleHistory;`)();
|
||
const serverHistory = normalizeServerHistory([
|
||
{ title: '无效日期', changedAt: 'not-a-date', messageIndex: 1, source: 'llm' },
|
||
{ title: '无效索引', changedAt: '2026-07-27T00:00:00.000Z', messageIndex: -1, source: 'llm' },
|
||
...Array.from({ length: 102 }, (_, index) => ({
|
||
title: `主题 ${index}`,
|
||
changedAt: new Date(Date.UTC(2026, 6, 27, 0, index)).toISOString(),
|
||
messageIndex: index,
|
||
...(index > 0 ? { anchorMessageIndex: index - 1 } : {}),
|
||
source: 'llm',
|
||
})),
|
||
]);
|
||
assert(serverHistory.length === 100, 'Server should retain only the latest 100 valid title history events');
|
||
assert(serverHistory[0].title === '主题 2' && serverHistory.at(-1).title === '主题 101', 'Server title history should discard invalid events and trim from the oldest side');
|
||
assert(serverHistory[0].anchorMessageIndex === 1 && serverHistory.at(-1).anchorMessageIndex === 100, 'Server title history should preserve valid trigger-message anchors');
|
||
const invalidAnchorHistory = normalizeServerHistory([
|
||
{ title: '无效锚点仍保留事件', changedAt: '2026-07-27T00:00:00.000Z', messageIndex: 3, anchorMessageIndex: 3, source: 'llm' },
|
||
]);
|
||
assert(
|
||
invalidAnchorHistory.length === 1 && !Object.prototype.hasOwnProperty.call(invalidAnchorHistory[0], 'anchorMessageIndex'),
|
||
'Server title history should discard invalid anchors without discarding otherwise valid legacy events'
|
||
);
|
||
|
||
const normalizeOutlineHistorySource = extractFunctionSource(frontendSource, 'normalizeOutlineTitleHistory');
|
||
const formatOutlineDateSource = extractFunctionSource(frontendSource, 'formatUserOutlineDate');
|
||
const buildOutlineTimelineSource = extractFunctionSource(frontendSource, 'buildUserOutlineTimelineItems');
|
||
const outlineApi = new Function(`
|
||
${normalizeOutlineHistorySource}
|
||
${formatOutlineDateSource}
|
||
${buildOutlineTimelineSource}
|
||
return { normalizeOutlineTitleHistory, formatUserOutlineDate, buildUserOutlineTimelineItems };
|
||
`)();
|
||
const beforeMidnight = new Date(2026, 6, 27, 23, 59, 0).toISOString();
|
||
const titleChangedAt = new Date(2026, 6, 28, 0, 0, 30).toISOString();
|
||
const afterMidnight = new Date(2026, 6, 28, 0, 1, 0).toISOString();
|
||
const legacyTitleChangedAt = new Date(2026, 6, 28, 0, 1, 30).toISOString();
|
||
assert(outlineApi.formatUserOutlineDate(beforeMidnight) === '2026-07-27', 'Outline dates should use the browser local calendar day before midnight');
|
||
assert(outlineApi.formatUserOutlineDate(afterMidnight) === '2026-07-28', 'Outline dates should roll over at browser-local midnight');
|
||
|
||
const timeline = outlineApi.buildUserOutlineTimelineItems([
|
||
{ type: 'message', id: 'user-1', targetMessageId: 'hapi-message-user-1', label: '第一步', timestamp: beforeMidnight, messageIndex: 0 },
|
||
{ type: 'message', id: 'user-2', targetMessageId: 'hapi-message-user-2', label: '第二步', timestamp: afterMidnight, messageIndex: 2 },
|
||
], [
|
||
{ title: 'SQL 排查主题', changedAt: titleChangedAt, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' },
|
||
{ title: '旧历史兼容主题', changedAt: legacyTitleChangedAt, messageIndex: 3, source: 'llm' },
|
||
]);
|
||
assert(
|
||
JSON.stringify(timeline.map((item) => item.type)) === JSON.stringify(['date', 'title', 'message', 'date', 'title', 'message']),
|
||
'Outline timeline should render each title as a section heading before its triggering user message'
|
||
);
|
||
assert(timeline[0].label === '2026-07-27' && timeline[3].label === '2026-07-28', 'Outline should deduplicate dates and show YYYY-MM-DD only');
|
||
assert(timeline[2].messageNumber === 1 && timeline[5].messageNumber === 2, 'Only selectable message nodes should consume outline numbering');
|
||
assert(timeline[1].label === 'SQL 排查主题' && timeline[1].anchorMessageIndex === 0, 'Anchored title history should render before its exact triggering message');
|
||
assert(timeline[4].label === '旧历史兼容主题' && !timeline[4].targetMessageId, 'Legacy title history should fall back to the nearest preceding user message and remain read-only');
|
||
assert(timeline[0].label !== outlineApi.formatUserOutlineDate(titleChangedAt), 'A title crossing midnight should inherit the triggering message calendar day');
|
||
|
||
const updateOutlineSource = extractFunctionSource(frontendSource, 'updateUserOutlinePanel');
|
||
assert(updateOutlineSource.includes('user-outline-title-event') && updateOutlineSource.includes('user-outline-date'), 'Outline renderer should include dedicated title and date nodes');
|
||
assert(updateOutlineSource.includes("item.type === 'message'"), 'Outline renderer should reserve buttons for selectable message nodes');
|
||
assert(/closest\('\.user-outline-item'\)/.test(frontendSource), 'Outline click delegation should only target selectable message buttons');
|
||
assert(styleSource.includes('.user-outline-title-event') && styleSource.includes('.user-outline-date'), 'Outline title and date nodes should have dedicated styles');
|
||
assert(/\.user-outline-date::before[\s\S]*\.user-outline-date::after/.test(styleSource), 'Outline dates should use the requested two-sided divider treatment');
|
||
assert(serverSource.includes('titleHistory: refreshedSession.titleHistory'), 'session_info should expose persisted title history to the active conversation only');
|
||
assert(frontendSource.includes('titleHistory: normalizeOutlineTitleHistory(payload.titleHistory)'), 'Frontend snapshots should preserve normalized title history');
|
||
}
|
||
|
||
function assertSessionSwitchResilienceContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
|
||
|
||
assert(frontendSource.includes('SESSION_LOAD_REQUEST_TIMEOUT_MS'), 'Frontend should define a hard timeout for session load requests');
|
||
assert(frontendSource.includes('sessionLoadRequestTimer'), 'Frontend should track the session load request timeout timer');
|
||
assert(frontendSource.includes('function clearPendingSessionSwitchRequest'), 'Frontend should be able to cancel stale pending session switch requests');
|
||
assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should schedule cancellation for stuck load_session requests');
|
||
assert(frontendSource.includes('pendingSessionResumeRequest'), 'Frontend should track lightweight running-session resume requests');
|
||
assert(frontendSource.includes('SESSION_RESUME_FALLBACK_MS'), 'Frontend should keep a compatibility fallback for servers without resume_session');
|
||
assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload');
|
||
assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations');
|
||
assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results');
|
||
assert(
|
||
/case 'resume_session_result':[\s\S]*?if \(!msg\.isRunning && currentSessionId && msg\.sessionId === currentSessionId\) \{[\s\S]*?finishGenerating\(msg\.sessionId \|\| currentSessionId\);[\s\S]*?\}[\s\S]*?break;/.test(frontendSource),
|
||
'Frontend idle resume result should finish generation state for the current session'
|
||
);
|
||
assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view');
|
||
const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'");
|
||
const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1;
|
||
const visibilitySource = visibilityStart >= 0 && visibilityEnd > visibilityStart
|
||
? frontendSource.slice(visibilityStart, visibilityEnd)
|
||
: '';
|
||
assert(visibilitySource.includes('requestSessionResume(currentSessionId'), 'Visibility restore should use lightweight resume for running conversations');
|
||
assert(visibilitySource.includes("send({ type: 'list_sessions' });"), 'Visibility restore should only refresh session list for idle conversations');
|
||
assert(!visibilitySource.includes("type: 'load_session'"), 'Visibility restore must not force load_session and rerender the current conversation');
|
||
assert(!visibilitySource.includes('beginSessionSwitch('), 'Visibility restore must not force a session switch and scroll to bottom');
|
||
assert(
|
||
/else if \(currentSessionId && \(isGenerating \|\| currentSessionRunning\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource),
|
||
'Frontend should queue a lightweight resume request for running conversations when WS closes'
|
||
);
|
||
assert(
|
||
/const flushedSessionResume = flushedSessionSwitch \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource),
|
||
'Frontend should resume the current running session after auth without forcing load_session'
|
||
);
|
||
assert(
|
||
frontendSource.includes("send({ type: 'abort', sessionId: currentSessionId })"),
|
||
'Frontend abort button should explicitly target the current session'
|
||
);
|
||
assert(
|
||
/case 'background_done':[\s\S]*?if \(isNearBottom\(\)\)[\s\S]*?openSession\(msg\.sessionId,\s*\{ forceSync: true, blocking: false \}\)[\s\S]*?send\(\{ type: 'list_sessions' \}\)/.test(frontendSource),
|
||
'Frontend should not auto-rerender the current session on background_done while the user is reading history'
|
||
);
|
||
assert(
|
||
/function startGenerating\(sessionId = currentSessionId, options = \{\}\)[\s\S]*?const shouldFollow = options\.follow !== false[\s\S]*?if \(shouldFollow\)/.test(frontendSource),
|
||
'Frontend resume_generating should be able to create a streaming bubble without forcing scroll-to-bottom'
|
||
);
|
||
assert(
|
||
/const preserveStreaming = !!\(options\.preserveStreaming[\s\S]*?\(isGenerating \|\| currentSessionRunning \|\| hasStreamingElement\)\)/.test(frontendSource),
|
||
'Frontend should preserve the current running conversation DOM even when isGenerating was stale'
|
||
);
|
||
assert(
|
||
/case 'session_history_chunk':[\s\S]*?activeSessionLoad\?\.recoverCurrent[\s\S]*?break;/.test(frontendSource),
|
||
'Frontend should ignore history chunks from recovery fallback to avoid duplicating/redrawing bubbles'
|
||
);
|
||
assert(
|
||
/function flushPendingSessionSwitch\(\)[\s\S]*?return false[\s\S]*?return true/.test(frontendSource),
|
||
'Frontend flushPendingSessionSwitch should report whether it sent a load_session request'
|
||
);
|
||
assert(
|
||
/pendingSessionSwitchRequest\s*=\s*\{[\s\S]*?blocking:\s*false[\s\S]*?requestId:\s*activeSessionLoad\.requestId/.test(frontendSource),
|
||
'Frontend should retry load_session after WS close without re-blocking the UI'
|
||
);
|
||
assert(
|
||
frontendSource.includes('!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession'),
|
||
'Frontend should not let late requestId-bearing session_info switch an idle/welcome view'
|
||
);
|
||
assert(
|
||
frontendSource.includes('matchesActiveLoadError') && frontendSource.includes('errorRequestId === activeSessionLoad.requestId'),
|
||
'Frontend should clear active session load errors by requestId'
|
||
);
|
||
|
||
assert(serverSource.includes('SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS'), 'Server should cap session message size for WebSocket transport');
|
||
assert(serverSource.includes("case 'resume_session':"), 'Server should accept lightweight resume_session requests');
|
||
assert(serverSource.includes('function handleResumeSession'), 'Server should implement lightweight running-session resume');
|
||
assert(serverSource.includes('function attachActiveRuntimeToWs'), 'Server should share runtime re-attach logic without sending session_info first');
|
||
assert(/case 'abort':\s*handleAbort\(ws, msg\);/.test(serverSource), 'Server should pass abort request metadata to handleAbort');
|
||
assert(/function handleAbort\(ws, msg = \{\}\)/.test(serverSource), 'Server handleAbort should accept the abort request payload');
|
||
assert(
|
||
/function bindAbortSessionToWs\(sessionId, ws\)[\s\S]*?entry\.ws = ws/.test(serverSource),
|
||
'Server abort should rebind the target running session to the current WebSocket before stopping'
|
||
);
|
||
assert(serverSource.includes('WS_HEARTBEAT_MAX_MISSES'), 'Server should tolerate missed WebSocket pongs before terminating');
|
||
assert(serverSource.includes('function markWsActivity'), 'Server should mark WebSocket activity on send/message/pong');
|
||
assert(
|
||
serverSource.includes('markWsActivity(ws);') && serverSource.includes('markWsActivity(ws);'),
|
||
'Server should call markWsActivity from WebSocket send and message paths'
|
||
);
|
||
assert(
|
||
/hasRecentActivity[\s\S]*?_ccWebMissedPongs[\s\S]*?ws_heartbeat_terminate/.test(serverSource),
|
||
'Server heartbeat should consider recent activity and log before terminating stale sockets'
|
||
);
|
||
assert(serverSource.includes('function sanitizeMessagesForTransport'), 'Server should sanitize session messages before WebSocket transport');
|
||
assert(
|
||
/function splitHistoryMessages\(messages[\s\S]*?const list = sanitizeMessagesForTransport\(messages\)/.test(serverSource),
|
||
'Server history split should operate on transport-sanitized messages'
|
||
);
|
||
assert(
|
||
/function wsSend\(ws, data\)[\s\S]*?try\s*\{[\s\S]*?JSON\.stringify\(data\)[\s\S]*?catch/.test(serverSource),
|
||
'Server wsSend should guard JSON serialization failures'
|
||
);
|
||
assert(
|
||
runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') &&
|
||
runtimeSource.includes('function appendCappedText') &&
|
||
runtimeSource.includes('entry.fullText = appendCappedText'),
|
||
'Classic runtime should cap accumulated fullText in memory'
|
||
);
|
||
}
|
||
|
||
function assertSessionRenderEpochRaceContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const beginSessionSwitchSource = extractFunctionSource(frontendSource, 'beginSessionSwitch');
|
||
const renderMessagesSource = extractFunctionSource(frontendSource, 'renderMessages');
|
||
const api = new Function(`
|
||
let renderEpoch = 0;
|
||
let loadedHistorySessionId = 'session-a';
|
||
let activeSessionLoad = null;
|
||
let currentSessionId = 'session-a';
|
||
let closedCollabAgentIds = new Set();
|
||
let collabAgentStateCache = new Map();
|
||
let collabAgentIdsByToolUseId = new Map();
|
||
let closedCollabAgentIdsByToolUseId = new Map();
|
||
const scheduled = [];
|
||
const messagesDiv = {
|
||
nodes: [],
|
||
scrollTop: 0,
|
||
get scrollHeight() { return this.nodes.length; },
|
||
get firstChild() { return this.nodes[0] || null; },
|
||
get innerHTML() { return ''; },
|
||
set innerHTML(value) { this.nodes = []; },
|
||
appendChild(node) {
|
||
this.nodes.push(...(node && node.__fragment ? node.nodes : [node]));
|
||
},
|
||
insertBefore(node) {
|
||
this.nodes.unshift(...(node && node.__fragment ? node.nodes : [node]));
|
||
},
|
||
};
|
||
const document = {
|
||
createDocumentFragment() {
|
||
return {
|
||
__fragment: true,
|
||
nodes: [],
|
||
appendChild(node) { this.nodes.push(node); },
|
||
};
|
||
},
|
||
};
|
||
const currentCwd = '/tmp/session-render-race';
|
||
function setTimeout(callback) { scheduled.push(callback); return scheduled.length; }
|
||
function setSessionLoading(sessionId) {
|
||
activeSessionLoad = sessionId ? { sessionId, overlayReleased: false } : null;
|
||
}
|
||
function requestSessionLoad() {}
|
||
function collectClosedCollabAgentIds() { return new Set(); }
|
||
function clearUserMessageIndex() {}
|
||
function buildWelcomeMarkup() { return '<p>welcome</p>'; }
|
||
function updateUserOutlinePanel() {}
|
||
function renderPendingNotes() {}
|
||
function scrollToBottom() {}
|
||
function updateScrollbar() {}
|
||
function buildMsgElement(message) { return { id: message.id }; }
|
||
${beginSessionSwitchSource}
|
||
${renderMessagesSource}
|
||
return {
|
||
beginSessionSwitch,
|
||
renderMessages,
|
||
flushTimers() {
|
||
while (scheduled.length > 0) scheduled.shift()();
|
||
},
|
||
renderedIds: () => messagesDiv.nodes.map((node) => node.id),
|
||
};
|
||
`)();
|
||
|
||
const sessionAMessages = Array.from({ length: 11 }, (_, index) => ({ id: `a-${index}` }));
|
||
api.renderMessages(sessionAMessages);
|
||
assert(
|
||
api.renderedIds().length === 10 && !api.renderedIds().includes('a-0'),
|
||
'Render epoch race fixture should leave the oldest A message in the delayed batch'
|
||
);
|
||
api.beginSessionSwitch('session-b', { force: true, blocking: false });
|
||
api.flushTimers();
|
||
assert(
|
||
JSON.stringify(api.renderedIds()) === JSON.stringify(sessionAMessages.map((message) => message.id)),
|
||
'Beginning a B load must not cancel A delayed batches before the B snapshot is committed'
|
||
);
|
||
|
||
api.renderMessages(sessionAMessages);
|
||
api.beginSessionSwitch('session-b', { force: true, blocking: false });
|
||
api.renderMessages([{ id: 'b-0' }]);
|
||
api.flushTimers();
|
||
assert(
|
||
JSON.stringify(api.renderedIds()) === JSON.stringify(['b-0']),
|
||
'Committing the B render must invalidate A delayed batches so stale messages cannot leak into B'
|
||
);
|
||
assert(
|
||
!/\brenderEpoch\s*(?:\+\+|--|[+\-*/%]?=)/.test(beginSessionSwitchSource),
|
||
'beginSessionSwitch should not advance renderEpoch before the replacement snapshot renders'
|
||
);
|
||
}
|
||
|
||
function assertSessionRequestIdRaceContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const sessionInfoStart = frontendSource.indexOf("case 'session_info':");
|
||
const sessionInfoDecisionStart = frontendSource.indexOf('const messageRequestId', sessionInfoStart);
|
||
const sessionInfoDecisionEnd = frontendSource.indexOf('if (!canSwitchToSessionInfo)', sessionInfoDecisionStart);
|
||
assert(
|
||
sessionInfoStart >= 0 && sessionInfoDecisionStart > sessionInfoStart && sessionInfoDecisionEnd > sessionInfoDecisionStart,
|
||
'Frontend should keep an extractable request gate before applying session_info'
|
||
);
|
||
const sessionInfoDecisionSource = frontendSource.slice(sessionInfoDecisionStart, sessionInfoDecisionEnd);
|
||
const decideSessionInfo = new Function('msg', 'snapshot', 'activeLoad', 'pendingNewSession', 'currentSessionId', `
|
||
${sessionInfoDecisionSource}
|
||
return { canSwitchToSessionInfo, matchesActiveLoad };
|
||
`);
|
||
const staleCurrentDecision = decideSessionInfo(
|
||
{
|
||
type: 'session_info',
|
||
sessionId: 'session-a',
|
||
requestId: 'request-old-a',
|
||
messages: [{ id: 'stale-a' }],
|
||
historyPending: false,
|
||
isRunning: false,
|
||
},
|
||
{
|
||
sessionId: 'session-a',
|
||
agent: 'codexapp',
|
||
messages: [{ id: 'stale-a' }],
|
||
},
|
||
{
|
||
sessionId: 'session-b',
|
||
requestId: 'request-new-b',
|
||
snapshot: null,
|
||
},
|
||
null,
|
||
'session-a'
|
||
);
|
||
const rejectsStaleCurrentSessionInfo = staleCurrentDecision.canSwitchToSessionInfo === false;
|
||
|
||
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
|
||
const finalizeApi = new Function(`
|
||
let activeSessionLoad = {
|
||
sessionId: 'session-b',
|
||
requestId: 'request-new-b',
|
||
snapshot: { messages: [{ id: 'b-0' }] },
|
||
};
|
||
const cached = [];
|
||
const finished = [];
|
||
function cacheSessionSnapshot(snapshot) { cached.push(snapshot); }
|
||
function finishSessionSwitch(sessionId) { finished.push(sessionId); }
|
||
${finalizeLoadedSessionSource}
|
||
return {
|
||
finalizeLoadedSession,
|
||
cached,
|
||
finished,
|
||
reset() { cached.length = 0; finished.length = 0; },
|
||
};
|
||
`)();
|
||
finalizeApi.finalizeLoadedSession('session-b', 'request-old-b');
|
||
const rejectsStaleFinalize = finalizeApi.cached.length === 0 && finalizeApi.finished.length === 0;
|
||
finalizeApi.reset();
|
||
finalizeApi.finalizeLoadedSession('session-b', 'request-new-b');
|
||
const acceptsMatchingFinalize = finalizeApi.cached.length === 1 && finalizeApi.finished.length === 1;
|
||
|
||
const historyStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||
const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart);
|
||
assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable session_history_chunk handler');
|
||
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
|
||
const historyApi = new Function(`
|
||
let activeSessionLoad = {
|
||
sessionId: 'session-b',
|
||
requestId: 'request-new-b',
|
||
recoverCurrent: false,
|
||
snapshot: { messages: [{ id: 'b-recent' }] },
|
||
};
|
||
let currentSessionId = 'session-b';
|
||
let loadedHistorySessionId = 'session-b';
|
||
const prepended = [];
|
||
const finalized = [];
|
||
function cloneMessages(messages) { return messages.slice(); }
|
||
function isBlockingSessionLoad() { return false; }
|
||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
|
||
function handleHistoryMessage(msg) {
|
||
switch (msg.type) {
|
||
${historyCaseSource}
|
||
}
|
||
}
|
||
return {
|
||
handleHistoryMessage,
|
||
prepended,
|
||
finalized,
|
||
reset() { prepended.length = 0; finalized.length = 0; },
|
||
};
|
||
`)();
|
||
historyApi.handleHistoryMessage({
|
||
type: 'session_history_chunk',
|
||
sessionId: 'session-b',
|
||
requestId: 'request-old-b',
|
||
messages: [{ id: 'stale-history' }],
|
||
remaining: 0,
|
||
historyBaseIndex: 0,
|
||
});
|
||
const rejectsStaleHistoryChunk = historyApi.prepended.length === 0 && historyApi.finalized.length === 0;
|
||
historyApi.reset();
|
||
historyApi.handleHistoryMessage({
|
||
type: 'session_history_chunk',
|
||
sessionId: 'session-b',
|
||
requestId: 'request-new-b',
|
||
messages: [{ id: 'matching-history' }],
|
||
remaining: 0,
|
||
historyBaseIndex: 0,
|
||
});
|
||
const acceptsMatchingHistoryChunk = historyApi.prepended.some((message) => message.id === 'matching-history')
|
||
&& historyApi.finalized.some((entry) => entry.requestId === 'request-new-b');
|
||
|
||
const failures = [];
|
||
if (!rejectsStaleCurrentSessionInfo) {
|
||
failures.push('A stale requestId-bearing session_info for the current A view must not overwrite the pending B load');
|
||
}
|
||
if (!rejectsStaleFinalize) {
|
||
failures.push('finalizeLoadedSession must reject a stale requestId even when the sessionId still matches');
|
||
}
|
||
if (!acceptsMatchingFinalize) {
|
||
failures.push('finalizeLoadedSession should still commit the matching active load');
|
||
}
|
||
if (!rejectsStaleHistoryChunk) {
|
||
failures.push('session_history_chunk must reject stale requestIds before prepending or finalizing history');
|
||
}
|
||
if (!acceptsMatchingHistoryChunk) {
|
||
failures.push('session_history_chunk should preserve the matching active request path');
|
||
}
|
||
assert(failures.length === 0, failures.join('; '));
|
||
}
|
||
|
||
function assertBlockingFinishRafRequestRaceContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const isBlockingSessionLoadSource = extractFunctionSource(frontendSource, 'isBlockingSessionLoad');
|
||
const clearSessionLoadingSource = extractFunctionSource(frontendSource, 'clearSessionLoading');
|
||
const finishSessionSwitchSource = extractFunctionSource(frontendSource, 'finishSessionSwitch');
|
||
const api = new Function(`
|
||
let activeSessionLoad = {
|
||
sessionId: 'session-a',
|
||
requestId: 'request-a1',
|
||
blocking: true,
|
||
};
|
||
const rafCallbacks = [];
|
||
let scrollCount = 0;
|
||
function setSessionLoading(sessionId) {
|
||
activeSessionLoad = sessionId ? { sessionId, requestId: 'unexpected', blocking: true } : null;
|
||
}
|
||
function scrollToBottom() { scrollCount += 1; }
|
||
function requestAnimationFrame(callback) { rafCallbacks.push(callback); return rafCallbacks.length; }
|
||
${isBlockingSessionLoadSource}
|
||
${clearSessionLoadingSource}
|
||
${finishSessionSwitchSource}
|
||
return {
|
||
finishA1() { finishSessionSwitch('session-a', 'request-a1'); },
|
||
replaceWithA2() {
|
||
activeSessionLoad = {
|
||
sessionId: 'session-a',
|
||
requestId: 'request-a2',
|
||
blocking: true,
|
||
};
|
||
},
|
||
flushRaf() {
|
||
while (rafCallbacks.length > 0) rafCallbacks.shift()();
|
||
},
|
||
activeRequestId: () => activeSessionLoad?.requestId || null,
|
||
queuedRafCount: () => rafCallbacks.length,
|
||
scrollCount: () => scrollCount,
|
||
};
|
||
`)();
|
||
|
||
api.finishA1();
|
||
assert(
|
||
api.queuedRafCount() === 1 && api.scrollCount() === 1 && api.activeRequestId() === 'request-a1',
|
||
'Blocking A1 completion should defer clearing through the real finishSessionSwitch RAF path'
|
||
);
|
||
api.replaceWithA2();
|
||
api.flushRaf();
|
||
assert(
|
||
api.activeRequestId() === 'request-a2',
|
||
'The deferred A1 RAF callback must not clear a newer A2 load for the same sessionId'
|
||
);
|
||
}
|
||
|
||
function assertRecoverCurrentHistoryMergeContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
|
||
const historyStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||
const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart);
|
||
assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable recovery history handler');
|
||
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
|
||
const api = new Function(`
|
||
let activeSessionLoad = {
|
||
sessionId: 'session-recover',
|
||
requestId: 'request-recover-new',
|
||
recoverCurrent: true,
|
||
blocking: false,
|
||
snapshot: { messages: [{ id: 'recent' }] },
|
||
};
|
||
let currentSessionId = 'session-recover';
|
||
let loadedHistorySessionId = 'session-recover';
|
||
const prepended = [];
|
||
const cached = [];
|
||
const finished = [];
|
||
function cloneMessages(messages) { return messages.map((message) => ({ ...message })); }
|
||
function isBlockingSessionLoad() { return false; }
|
||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
|
||
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
|
||
${finalizeLoadedSessionSource}
|
||
function handleHistoryMessage(msg) {
|
||
switch (msg.type) {
|
||
${historyCaseSource}
|
||
}
|
||
}
|
||
return {
|
||
handleHistoryMessage,
|
||
snapshotMessageIds: () => activeSessionLoad.snapshot.messages.map((message) => message.id),
|
||
prepended,
|
||
cached,
|
||
finished,
|
||
};
|
||
`)();
|
||
|
||
api.handleHistoryMessage({
|
||
type: 'session_history_chunk',
|
||
sessionId: 'session-recover',
|
||
requestId: 'request-recover-stale',
|
||
messages: [{ id: 'stale-history' }],
|
||
remaining: 0,
|
||
historyBaseIndex: 0,
|
||
});
|
||
const rejectsStaleChunk = JSON.stringify(api.snapshotMessageIds()) === JSON.stringify(['recent'])
|
||
&& api.prepended.length === 0
|
||
&& api.cached.length === 0
|
||
&& api.finished.length === 0;
|
||
|
||
api.handleHistoryMessage({
|
||
type: 'session_history_chunk',
|
||
sessionId: 'session-recover',
|
||
requestId: 'request-recover-new',
|
||
messages: [{ id: 'history-later' }],
|
||
remaining: 1,
|
||
historyBaseIndex: 1,
|
||
});
|
||
const mergesIntermediateChunk = JSON.stringify(api.snapshotMessageIds())
|
||
=== JSON.stringify(['history-later', 'recent']);
|
||
const keepsRecoveryOffDom = api.prepended.length === 0;
|
||
const defersRecoveryFinalize = api.cached.length === 0 && api.finished.length === 0;
|
||
|
||
api.handleHistoryMessage({
|
||
type: 'session_history_chunk',
|
||
sessionId: 'session-recover',
|
||
requestId: 'request-recover-new',
|
||
messages: [{ id: 'history-oldest' }],
|
||
remaining: 0,
|
||
historyBaseIndex: 0,
|
||
});
|
||
const cachedSnapshot = api.cached[0] || null;
|
||
const cachesCompleteHistory = api.cached.length === 1
|
||
&& cachedSnapshot.complete === true
|
||
&& JSON.stringify((cachedSnapshot.messages || []).map((message) => message.id))
|
||
=== JSON.stringify(['history-oldest', 'history-later', 'recent']);
|
||
const finalizesMatchingRecovery = api.finished.length === 1
|
||
&& api.finished[0].requestId === 'request-recover-new';
|
||
const neverPrependsRecoveryHistory = api.prepended.length === 0;
|
||
|
||
const failures = [];
|
||
if (!rejectsStaleChunk) failures.push('recoverCurrent must still reject stale requestId history chunks');
|
||
if (!mergesIntermediateChunk) failures.push('recoverCurrent must merge each accepted history chunk into the active snapshot');
|
||
if (!keepsRecoveryOffDom || !neverPrependsRecoveryHistory) failures.push('recoverCurrent history chunks must not prepend the live DOM');
|
||
if (!defersRecoveryFinalize) failures.push('recoverCurrent must wait for the last history chunk before caching');
|
||
if (!cachesCompleteHistory) failures.push('recoverCurrent finalization must cache recent and historical messages as one complete snapshot');
|
||
if (!finalizesMatchingRecovery) failures.push('recoverCurrent finalization must retain the matching requestId');
|
||
assert(failures.length === 0, failures.join('; '));
|
||
}
|
||
|
||
function assertServerSessionHistoryRequestIdContract() {
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const handleLoadSessionSource = extractFunctionSource(serverSource, 'handleLoadSession');
|
||
const api = new Function(`
|
||
const sent = [];
|
||
const fixture = {
|
||
id: 'session-b',
|
||
title: 'Session B',
|
||
pinnedAt: null,
|
||
permissionMode: 'yolo',
|
||
model: 'gpt-5.5',
|
||
agent: 'codexapp',
|
||
hasUnread: false,
|
||
cwd: '/tmp/session-b',
|
||
totalCost: 0,
|
||
totalUsage: null,
|
||
updated: '2026-07-18T00:00:00.000Z',
|
||
messages: Array.from({ length: 11 }, (_, index) => ({ id: 'b-' + index })),
|
||
};
|
||
const activeProcesses = new Map();
|
||
const activeCodexAppTurns = new Map();
|
||
const wsSessionMap = new Map();
|
||
function sanitizeId(value) { return String(value || ''); }
|
||
function reconcilePendingCrossConversationReplies() {}
|
||
function loadSession() { return fixture; }
|
||
function wsSend(ws, message) { sent.push(message); }
|
||
function attachClientRequestId(message, source) {
|
||
return source && source.requestId ? { ...message, requestId: source.requestId } : message;
|
||
}
|
||
function flushPendingCrossConversationReplies() {}
|
||
function getSessionAgent(session) { return session.agent; }
|
||
function splitHistoryMessages(messages) {
|
||
return {
|
||
recentMessages: messages.slice(1),
|
||
olderChunks: [messages.slice(0, 1)],
|
||
historyRemaining: 0,
|
||
historyBuffered: messages.length,
|
||
};
|
||
}
|
||
function crossConversationWaitState() {
|
||
return {
|
||
waitingOnChildren: false,
|
||
pendingReplyCount: 0,
|
||
readyReplyCount: 0,
|
||
waitingReplyCount: 0,
|
||
failedReplyCount: 0,
|
||
pendingReplies: [],
|
||
};
|
||
}
|
||
function detachWsFromActiveRuntimes() {}
|
||
function saveSession() {}
|
||
function publicTitleMetadata() { return {}; }
|
||
function sessionModelLabel(session) { return session.model; }
|
||
function isSessionRunning() { return false; }
|
||
function attachActiveRuntimeToWs() {}
|
||
function resolveClaudeSessionLocalMeta() { return null; }
|
||
${handleLoadSessionSource}
|
||
return {
|
||
load(requestId) {
|
||
sent.length = 0;
|
||
handleLoadSession({}, { type: 'load_session', sessionId: fixture.id, requestId });
|
||
return sent.slice();
|
||
},
|
||
};
|
||
`)();
|
||
const messages = api.load('request-new-b');
|
||
const sessionInfo = messages.find((message) => message.type === 'session_info');
|
||
const historyChunks = messages.filter((message) => message.type === 'session_history_chunk');
|
||
assert(sessionInfo?.requestId === 'request-new-b', 'Server session_info fixture should echo the load requestId');
|
||
assert(historyChunks.length > 0, 'Server load fixture should emit at least one delayed history chunk');
|
||
assert(
|
||
historyChunks.every((message) => message.requestId === sessionInfo.requestId),
|
||
'Every server history chunk must echo the exact requestId carried by its session_info snapshot'
|
||
);
|
||
}
|
||
|
||
function assertSessionSwitchRaceContract() {
|
||
const checks = [
|
||
['render epoch behavior', assertSessionRenderEpochRaceContract],
|
||
['frontend requestId behavior', assertSessionRequestIdRaceContract],
|
||
['blocking finish RAF request behavior', assertBlockingFinishRafRequestRaceContract],
|
||
['recoverCurrent history merge behavior', assertRecoverCurrentHistoryMergeContract],
|
||
['server history requestId behavior', assertServerSessionHistoryRequestIdContract],
|
||
];
|
||
const failures = [];
|
||
for (const [label, check] of checks) {
|
||
try {
|
||
check();
|
||
} catch (err) {
|
||
failures.push(`${label}: ${err?.message || err}`);
|
||
}
|
||
}
|
||
if (failures.length > 0) {
|
||
throw new Error(`Session switch race regression failed:\n- ${failures.join('\n- ')}`);
|
||
}
|
||
}
|
||
|
||
function assertCodexAppStaleRunningRecoveryContract() {
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
|
||
const steerBlock = extractFunctionSource(serverSource, 'handleCodexAppSteerMessage');
|
||
assert(
|
||
completeBlock.includes('!options.deferPendingCrossConversationFlush') && completeBlock.includes('flushPendingCrossConversationReplies(sessionId)'),
|
||
'Stale recovery should be able to defer pending cross-conversation reply flushes'
|
||
);
|
||
assert(
|
||
steerBlock.includes('deferPendingCrossConversationFlush: true'),
|
||
'Stale steer recovery should defer pending cross-conversation reply flushes while replacing the turn'
|
||
);
|
||
assert(
|
||
steerBlock.includes('!activeCodexAppTurns.has(sessionId)') && steerBlock.includes('handleCodexAppMessage(ws, refreshedSession'),
|
||
'Stale steer recovery should only start the replacement turn while the active turn map is empty'
|
||
);
|
||
assert(
|
||
steerBlock.includes('mcpContext: entry.mcpContext || options.mcpContext || {}'),
|
||
'Stale steer recovery should prefer the original active entry MCP context'
|
||
);
|
||
}
|
||
|
||
function assertRuntimeImageSendStaticContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
|
||
const validationBlock = extractFunctionSource(frontendSource, 'getQueuedMessageValidationError');
|
||
assert(/attachments/.test(validationBlock), 'Queued message validation should consider attachments for image-only queue items');
|
||
|
||
const addQueuedBlock = extractFunctionSource(frontendSource, 'addQueuedMessage');
|
||
assert(
|
||
/attachments/.test(addQueuedBlock) &&
|
||
/map\(\s*\(?attachment\)?\s*=>\s*\(\{\s*\.\.\.attachment\s*\}\)\s*\)/.test(addQueuedBlock),
|
||
'Queued messages should save an attachment metadata snapshot when they are created'
|
||
);
|
||
|
||
const queueInputBlock = extractFunctionSource(frontendSource, 'queueMessageFromInput');
|
||
assert(
|
||
!queueInputBlock.includes('排队发送暂不支持图片附件'),
|
||
'Queue submit should not actively reject image attachments'
|
||
);
|
||
assert(
|
||
/pendingAttachments\.map\(\s*\(?attachment\)?\s*=>\s*\(\{\s*\.\.\.attachment\s*\}\)\s*\)/.test(queueInputBlock),
|
||
'Queue submit should snapshot pending attachments before clearing the composer'
|
||
);
|
||
assert(
|
||
/addQueuedMessage\(\s*text\s*,\s*\{[\s\S]*attachments/.test(queueInputBlock),
|
||
'Queue submit should pass the attachment snapshot into addQueuedMessage'
|
||
);
|
||
assert(
|
||
/pendingAttachments\s*=\s*\[\]/.test(queueInputBlock) && /renderPendingAttachments\(\)/.test(queueInputBlock),
|
||
'Queue submit should clear the composer attachment tray after enqueueing'
|
||
);
|
||
|
||
const queuedElementBlock = extractFunctionSource(frontendSource, 'createQueuedMessageElement');
|
||
assert(
|
||
/renderAttachmentPreviews\(\s*message\.attachments/.test(queuedElementBlock),
|
||
'Queued message cards should render an attachment summary'
|
||
);
|
||
|
||
const dropQueuedBlock = extractFunctionSource(frontendSource, 'dropQueuedMessage');
|
||
const removeQueuedBlock = extractFunctionSource(frontendSource, 'removeQueuedMessage');
|
||
assert(
|
||
/deleteUploadedAttachment/.test(`${dropQueuedBlock}\n${removeQueuedBlock}`),
|
||
'Deleting an unsent queued message should clean up its uploaded attachments'
|
||
);
|
||
|
||
const drainQueuedBlock = extractFunctionSource(frontendSource, 'drainQueuedMessages');
|
||
assert(
|
||
/submitUserMessage\(\s*text\s*,\s*(attachments|message\??\.attachments)/.test(drainQueuedBlock),
|
||
'Queue drain should send the queued attachment snapshot through submitUserMessage'
|
||
);
|
||
|
||
const sendMessageBlock = extractFunctionSource(frontendSource, 'sendMessage');
|
||
const runtimeStart = sendMessageBlock.indexOf('if (runtimeInsert)');
|
||
const runtimeEnd = sendMessageBlock.indexOf('// Slash commands:', runtimeStart);
|
||
assert(runtimeStart >= 0 && runtimeEnd > runtimeStart, 'Frontend should keep a distinct runtime insert branch');
|
||
const runtimeInsertBlock = sendMessageBlock.slice(runtimeStart, runtimeEnd);
|
||
assert(
|
||
!runtimeInsertBlock.includes('Codex App 运行中插入暂不支持图片附件'),
|
||
'Runtime insert should not actively reject image attachments'
|
||
);
|
||
assert(
|
||
/pendingAttachments\.map\(\s*\(?attachment\)?\s*=>\s*\(\{\s*\.\.\.attachment\s*\}\)\s*\)/.test(runtimeInsertBlock),
|
||
'Runtime insert should snapshot pending attachments'
|
||
);
|
||
assert(
|
||
/createMsgElement\(\s*'user'\s*,\s*text\s*,\s*attachments/.test(runtimeInsertBlock),
|
||
'Runtime insert user bubble should render the same attachments that will be sent'
|
||
);
|
||
assert(
|
||
/send\(\s*\{[\s\S]*type:\s*'message'[\s\S]*attachments/.test(runtimeInsertBlock),
|
||
'Runtime insert should include attachments in the WebSocket message'
|
||
);
|
||
assert(
|
||
/pendingAttachments\s*=\s*\[\]/.test(runtimeInsertBlock) && /renderPendingAttachments\(\)/.test(runtimeInsertBlock),
|
||
'Runtime insert should clear the composer attachment tray after submission'
|
||
);
|
||
|
||
const handleMessageBlock = extractFunctionSource(serverSource, 'handleMessage');
|
||
assert(
|
||
/handleCodexAppSteerMessage\(\s*ws\s*,\s*msg\s*,\s*\{[\s\S]*resolvedAttachments[\s\S]*savedAttachments/.test(handleMessageBlock),
|
||
'handleMessage should pass resolved and saved attachments into the Codex App steer path'
|
||
);
|
||
|
||
const steerBlock = extractFunctionSource(serverSource, 'handleCodexAppSteerMessage');
|
||
assert(
|
||
!steerBlock.includes('codexapp_running_attachment_unsupported') &&
|
||
!/attachments\.length\s*>\s*0\)\s*return fail/.test(steerBlock),
|
||
'Server Codex App steer path should not actively reject image attachments'
|
||
);
|
||
assert(
|
||
/attachments:\s*savedAttachments/.test(steerBlock),
|
||
'Server Codex App steer path should persist normalized attachment metadata'
|
||
);
|
||
assert(
|
||
/codexAppInputFromMessage\(\s*runtimeTextValue\s*,\s*resolvedAttachments\s*\)/.test(steerBlock),
|
||
'turn/steer input should include resolved localImage attachments'
|
||
);
|
||
assert(
|
||
/handleCodexAppMessage\(\s*ws\s*,\s*refreshedSession\s*,\s*runtimeTextValue\s*,\s*resolvedAttachments/.test(steerBlock),
|
||
'Stale steer replacement should reuse the resolved attachments'
|
||
);
|
||
}
|
||
|
||
async function runCodexAppRuntimeImageSteerRegression() {
|
||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-runtime-image-regression-'));
|
||
const configDir = path.join(tempRoot, 'config');
|
||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||
const logsDir = path.join(tempRoot, 'logs');
|
||
const homeDir = path.join(tempRoot, 'home');
|
||
mkdirp(configDir);
|
||
mkdirp(sessionsDir);
|
||
mkdirp(logsDir);
|
||
mkdirp(homeDir);
|
||
|
||
const port = await getFreePort();
|
||
const password = 'RuntimeImage!234';
|
||
await withServer({
|
||
PORT: String(port),
|
||
CC_WEB_PASSWORD: password,
|
||
CC_WEB_INTERNAL_MCP_TOKEN: 'RuntimeImageMcp!234',
|
||
CC_WEB_CONFIG_DIR: configDir,
|
||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||
CC_WEB_LOGS_DIR: logsDir,
|
||
HOME: homeDir,
|
||
CLAUDE_PATH: MOCK_CLAUDE,
|
||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||
}, async () => {
|
||
const { ws, messages, token } = await connectWs(port, password);
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
|
||
const sessionInfo = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
|
||
));
|
||
const sessionId = sessionInfo.sessionId;
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: 'slow runtime image base prompt',
|
||
sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
}));
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
|
||
), 5000);
|
||
await sleep(150);
|
||
|
||
const missingAttachment = {
|
||
id: 'runtime-missing-image',
|
||
kind: 'image',
|
||
filename: 'runtime-missing-image.png',
|
||
mime: 'image/png',
|
||
size: 128,
|
||
storageState: 'available',
|
||
};
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: 'runtime image missing attachment',
|
||
attachments: [missingAttachment],
|
||
sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
clientMessageId: 'regression-runtime-image-missing',
|
||
}));
|
||
const missingStatus = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === sessionId &&
|
||
msg.clientMessageId === 'regression-runtime-image-missing' &&
|
||
msg.status === 'failed'
|
||
), 5000);
|
||
assert(/失败/.test(missingStatus.message || ''), 'Missing image steer should mark its local user bubble as failed');
|
||
const missingError = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'error' &&
|
||
msg.sessionId === sessionId &&
|
||
msg.clientMessageId === 'regression-runtime-image-missing' &&
|
||
msg.code === 'attachment_unavailable'
|
||
), 5000);
|
||
assert(/图片附件已过期或不可用/.test(missingError.message || ''), 'Missing image steer should surface the attachment error');
|
||
|
||
const activeAttachment = await uploadAttachment(port, token, {
|
||
filename: 'runtime-active-steer.png',
|
||
mime: 'image/png',
|
||
data: Buffer.from('runtime-active-steer-image'),
|
||
});
|
||
const activeImageMarker = storedAttachmentImageMarker(activeAttachment);
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: 'runtime image steer insert',
|
||
attachments: [activeAttachment],
|
||
sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
clientMessageId: 'regression-runtime-image-steer',
|
||
}));
|
||
const activeStatus = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === sessionId &&
|
||
msg.clientMessageId === 'regression-runtime-image-steer' &&
|
||
['pending', 'failed'].includes(msg.status)
|
||
), 5000);
|
||
assert(activeStatus.status === 'pending', `Codex App image steer should be accepted, got ${activeStatus.status}: ${activeStatus.message || ''}`);
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' &&
|
||
msg.sessionId === sessionId &&
|
||
/steer accepted: runtime image steer insert/.test(msg.text || '') &&
|
||
activeImageMarker.test(msg.text || '')
|
||
), 5000);
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === sessionId &&
|
||
msg.clientMessageId === 'regression-runtime-image-steer' &&
|
||
msg.status === 'inserted'
|
||
), 5000);
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
|
||
|
||
const stored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${sessionId}.json`), 'utf8'));
|
||
assert(
|
||
!stored.messages.some((message) => message.role === 'user' && message.content === 'runtime image missing attachment'),
|
||
'Failed missing image steer should not persist a user message that never reached the model'
|
||
);
|
||
const steerUsers = stored.messages.filter((message) => message.role === 'user' && message.content === 'runtime image steer insert');
|
||
assert(steerUsers.length === 1, 'Active image steer user message should persist exactly once');
|
||
assertPersistedMessageAttachment(steerUsers[0], activeAttachment.filename, 'Active image steer user message');
|
||
assert(
|
||
stored.messages.some((message) => (
|
||
message.role === 'assistant' &&
|
||
/runtime image steer insert/.test(String(message.content || '')) &&
|
||
activeImageMarker.test(String(message.content || ''))
|
||
)),
|
||
'Active image steer assistant output should include the localImage marker from the mock'
|
||
);
|
||
ws.close();
|
||
});
|
||
}
|
||
|
||
async function runCodexAppStaleRunningRegression(options = {}) {
|
||
const includeRuntimeImage = options.includeRuntimeImage === true;
|
||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-stale-running-regression-'));
|
||
const configDir = path.join(tempRoot, 'config');
|
||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||
const logsDir = path.join(tempRoot, 'logs');
|
||
const homeDir = path.join(tempRoot, 'home');
|
||
mkdirp(configDir);
|
||
mkdirp(sessionsDir);
|
||
mkdirp(logsDir);
|
||
mkdirp(homeDir);
|
||
|
||
const port = await getFreePort();
|
||
const password = 'StaleRunning!234';
|
||
await withServer({
|
||
PORT: String(port),
|
||
CC_WEB_PASSWORD: password,
|
||
CC_WEB_INTERNAL_MCP_TOKEN: 'StaleRunningMcp!234',
|
||
CC_WEB_CONFIG_DIR: configDir,
|
||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||
CC_WEB_LOGS_DIR: logsDir,
|
||
HOME: homeDir,
|
||
CLAUDE_PATH: MOCK_CLAUDE,
|
||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||
}, async () => {
|
||
const { ws, messages, receivedMessages, token } = await connectWs(port, password, { trackReceived: true });
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
|
||
const sessionInfo = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir
|
||
));
|
||
const sessionId = sessionInfo.sessionId;
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: 'codexapp stale running first',
|
||
sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
}));
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
|
||
));
|
||
const staleOutput = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' && msg.sessionId === sessionId && /Codex App stale turn output/.test(msg.text || '')
|
||
));
|
||
assert(/codexapp stale running first/.test(staleOutput.text || ''), 'Stale running fixture should emit the first turn output');
|
||
await sleep(150);
|
||
|
||
const staleAttachment = includeRuntimeImage
|
||
? await uploadAttachment(port, token, {
|
||
filename: 'runtime-stale-follow-up.png',
|
||
mime: 'image/png',
|
||
data: Buffer.from('runtime-stale-follow-up-image'),
|
||
})
|
||
: null;
|
||
const staleImageMarker = staleAttachment ? storedAttachmentImageMarker(staleAttachment) : null;
|
||
const staleFollowUpPayload = {
|
||
type: 'message',
|
||
text: 'codexapp stale running follow-up',
|
||
sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
clientMessageId: 'regression-stale-running-follow-up',
|
||
};
|
||
if (staleAttachment) staleFollowUpPayload.attachments = [staleAttachment];
|
||
ws.send(JSON.stringify(staleFollowUpPayload));
|
||
const stalePending = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === sessionId &&
|
||
msg.clientMessageId === 'regression-stale-running-follow-up' &&
|
||
['pending', 'failed'].includes(msg.status)
|
||
), 5000);
|
||
assert(stalePending.status === 'pending', `Stale image steer should be accepted before replacement, got ${stalePending.status}: ${stalePending.message || ''}`);
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning)
|
||
), 5000);
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning)
|
||
), 5000);
|
||
const replacementResume = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'resume_generating' && msg.sessionId === sessionId
|
||
), 5000);
|
||
assert(replacementResume.text === '' && Array.isArray(replacementResume.toolCalls) && replacementResume.toolCalls.length === 0, 'Stale replacement should resume generation with an empty streaming payload');
|
||
const recoveredStatus = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === sessionId &&
|
||
msg.clientMessageId === 'regression-stale-running-follow-up' &&
|
||
msg.status === 'inserted'
|
||
), 5000);
|
||
assert(!/失败/.test(recoveredStatus.message || ''), 'Recovered stale steer should update the UI as a non-failure');
|
||
const recoveredHint = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'system_message' && msg.sessionId === sessionId && /已自动开始新一轮对话/.test(msg.message || '')
|
||
), 5000);
|
||
assert(recoveredHint.transient === true, 'Recovered stale steer hint should be transient');
|
||
const recoveredDelta = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || '')
|
||
), 5000);
|
||
if (staleAttachment) {
|
||
assert(
|
||
staleImageMarker.test(recoveredDelta.text || ''),
|
||
'Stale replacement turn should receive the same image attachment'
|
||
);
|
||
}
|
||
const replacementResumeIndex = receivedMessages.findIndex((msg) => msg.type === 'resume_generating' && msg.sessionId === sessionId);
|
||
const replacementDeltaIndex = receivedMessages.findIndex((msg) => msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || ''));
|
||
assert(replacementResumeIndex >= 0 && replacementResumeIndex < replacementDeltaIndex, 'Stale replacement should restore generating UI before its first text delta');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000);
|
||
const finalList = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning)
|
||
), 5000);
|
||
assert(finalList.sessions.some((session) => session.id === sessionId && !session.isRunning), 'Recovered follow-up should finish idle');
|
||
assert(!messages.some((msg) => msg.code === 'codexapp_steer_failed'), 'Recovered stale steer should not emit codexapp_steer_failed');
|
||
|
||
const stored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${sessionId}.json`), 'utf8'));
|
||
const staleFollowUpUsers = stored.messages.filter((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up');
|
||
assert(staleFollowUpUsers.length === 1, 'Recovered follow-up user message should persist exactly once');
|
||
if (staleAttachment) {
|
||
assertPersistedMessageAttachment(staleFollowUpUsers[0], staleAttachment.filename, 'Recovered stale follow-up user message');
|
||
}
|
||
assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || ''))).length === 1, 'Stale first turn output should persist exactly once');
|
||
assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || ''))).length === 1, 'Recovered follow-up output should persist exactly once');
|
||
if (staleAttachment) {
|
||
assert(
|
||
stored.messages.some((message) => (
|
||
message.role === 'assistant' &&
|
||
/Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || '')) &&
|
||
staleImageMarker.test(String(message.content || ''))
|
||
)),
|
||
'Recovered stale assistant output should include the localImage marker from the mock'
|
||
);
|
||
}
|
||
const staleAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || '')));
|
||
const followUpUserIndex = stored.messages.findIndex((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up');
|
||
const recoveredAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || '')));
|
||
assert(staleAssistantIndex < followUpUserIndex && followUpUserIndex < recoveredAssistantIndex, 'Recovered history should keep stale assistant before follow-up user before recovered assistant');
|
||
assert(!fs.existsSync(path.join(sessionsDir, `${sessionId}-run`)), 'Recovered follow-up should clean the Codex App run directory after completion');
|
||
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' }));
|
||
const mismatchSession = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId
|
||
));
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: 'codexapp expected turn mismatch first',
|
||
sessionId: mismatchSession.sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
}));
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' && msg.sessionId === mismatchSession.sessionId && /expected turn mismatch fixture/.test(msg.text || '')
|
||
), 5000);
|
||
await sleep(150);
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: 'codexapp mismatch follow-up',
|
||
sessionId: mismatchSession.sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
clientMessageId: 'regression-expected-turn-mismatch',
|
||
}));
|
||
const mismatchFailed = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === mismatchSession.sessionId &&
|
||
msg.clientMessageId === 'regression-expected-turn-mismatch' &&
|
||
msg.status === 'failed'
|
||
), 5000);
|
||
assert(/失败/.test(mismatchFailed.message || ''), 'Expected turn mismatch should keep the original failed steer status');
|
||
const mismatchError = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'error' && msg.sessionId === mismatchSession.sessionId && msg.code === 'codexapp_steer_failed'
|
||
), 5000);
|
||
assert(/expectedTurnId does not match active turn/.test(mismatchError.message || ''), 'Expected turn mismatch should not trigger stale-turn recovery');
|
||
assert(!messages.some((msg) => msg.type === 'system_message' && msg.sessionId === mismatchSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')), 'Expected turn mismatch should not start a replacement turn');
|
||
ws.close();
|
||
});
|
||
}
|
||
|
||
async function runRuntimeImageSendTarget() {
|
||
const failures = [];
|
||
const checks = [
|
||
['active steer image regression', () => runCodexAppRuntimeImageSteerRegression()],
|
||
['stale replacement image regression', () => runCodexAppStaleRunningRegression({ includeRuntimeImage: true })],
|
||
['runtime image static contract', () => assertRuntimeImageSendStaticContract()],
|
||
];
|
||
for (const [label, check] of checks) {
|
||
try {
|
||
await check();
|
||
} catch (err) {
|
||
failures.push(`${label}: ${err?.message || err}`);
|
||
}
|
||
}
|
||
if (failures.length > 0) {
|
||
throw new Error(`Runtime image send regression failed:\n- ${failures.join('\n- ')}`);
|
||
}
|
||
}
|
||
|
||
function assertUnlimitedImageAttachmentsContract() {
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
|
||
const handlerStart = frontendSource.indexOf('async function handleSelectedImageFiles(fileList)');
|
||
const handlerEnd = frontendSource.indexOf('\n function getVisibleSessions()', handlerStart);
|
||
assert(
|
||
handlerStart >= 0 && handlerEnd > handlerStart,
|
||
'Frontend should define handleSelectedImageFiles before getVisibleSessions'
|
||
);
|
||
const handlerBlock = frontendSource.slice(handlerStart, handlerEnd);
|
||
assert(
|
||
!/pendingAttachments\.length\s*\+\s*files\.length\s*>\s*\d+/.test(handlerBlock),
|
||
'Frontend image attachment picker should not enforce a numeric per-message attachment cap'
|
||
);
|
||
assert(
|
||
!/单条消息最多附带\s*\d+\s*张图片/.test(handlerBlock),
|
||
'Frontend image attachment picker should not show a per-message image count limit'
|
||
);
|
||
assert(
|
||
!serverSource.includes('MAX_MESSAGE_ATTACHMENTS'),
|
||
'Server should not define or use MAX_MESSAGE_ATTACHMENTS'
|
||
);
|
||
assert(
|
||
!/msg\.attachments\s*\.slice\s*\(\s*0\s*,/.test(serverSource),
|
||
'Server should not truncate msg.attachments with slice'
|
||
);
|
||
}
|
||
|
||
function extractFunctionSource(source, name) {
|
||
const start = source.indexOf(`function ${name}(`);
|
||
assert(start >= 0, `Server should define ${name}`);
|
||
let parenDepth = 0;
|
||
let signatureEnd = -1;
|
||
for (let i = start; i < source.length; i += 1) {
|
||
const ch = source[i];
|
||
if (ch === '(') parenDepth += 1;
|
||
if (ch === ')') {
|
||
parenDepth -= 1;
|
||
if (parenDepth === 0) {
|
||
signatureEnd = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
assert(signatureEnd > start, `Server function ${name} should have a complete signature`);
|
||
const open = source.indexOf('{', signatureEnd);
|
||
assert(open > start, `Server function ${name} should have a body`);
|
||
let depth = 0;
|
||
for (let i = open; i < source.length; i += 1) {
|
||
const ch = source[i];
|
||
if (ch === '{') depth += 1;
|
||
if (ch === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) return source.slice(start, i + 1);
|
||
}
|
||
}
|
||
throw new Error(`Could not parse function body for ${name}`);
|
||
}
|
||
|
||
function maybeExtractFunctionSource(source, name) {
|
||
return source.indexOf(`function ${name}(`) >= 0 ? extractFunctionSource(source, name) : '';
|
||
}
|
||
|
||
function assertCodexAppChildToolRoutingContract() {
|
||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const helperStart = source.indexOf('function parseMaybeJsonObject(value)');
|
||
const helperEnd = source.indexOf('function sendCcwebMcpChildAgentUpdate(sessionId, child)', helperStart);
|
||
assert(helperStart >= 0 && helperEnd > helperStart, 'Server should keep ccweb MCP child helper block before sendCcwebMcpChildAgentUpdate');
|
||
const helperSource = source.slice(helperStart, helperEnd);
|
||
const api = new Function(`
|
||
const sessions = new Map();
|
||
const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = 250;
|
||
const pendingCcwebMcpChildSessionFlushes = new Map();
|
||
let ccwebMcpChildSessionListBroadcastTimer = null;
|
||
let savedSession = null;
|
||
function truncateTextValue(value, maxLength, suffix = '...') {
|
||
const text = String(value || '');
|
||
return text.length > maxLength ? text.slice(0, maxLength - suffix.length) + suffix : text;
|
||
}
|
||
function loadSession(sessionId) {
|
||
return sessions.get(sessionId) || null;
|
||
}
|
||
function saveSession(session) {
|
||
savedSession = JSON.parse(JSON.stringify(session));
|
||
sessions.set(session.id, session);
|
||
}
|
||
function findViewingSessionWs() {
|
||
return null;
|
||
}
|
||
function broadcastSessionList() {}
|
||
${helperSource}
|
||
return {
|
||
setSession: (session) => sessions.set(session.id, session),
|
||
getSession: (sessionId) => sessions.get(sessionId),
|
||
getSavedSession: () => savedSession,
|
||
flushPendingCcwebMcpChildSession,
|
||
updatePersistedCcwebMcpChildTool,
|
||
};
|
||
`)();
|
||
|
||
const oldTool = {
|
||
id: 'call-old-reviewer',
|
||
name: 'subAgentActivity',
|
||
kind: 'collab_agent_tool_call',
|
||
input: {
|
||
tool: 'subAgentActivity',
|
||
receiverThreadIds: ['old-reviewer-thread'],
|
||
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
|
||
},
|
||
result: JSON.stringify({
|
||
receiverThreadIds: ['old-reviewer-thread'],
|
||
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
|
||
}),
|
||
done: true,
|
||
};
|
||
const currentTool = {
|
||
id: 'call-current-plan-demo',
|
||
name: 'subAgentActivity',
|
||
kind: 'collab_agent_tool_call',
|
||
input: {
|
||
tool: 'subAgentActivity',
|
||
receiverThreadIds: ['current-plan-thread'],
|
||
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
|
||
},
|
||
result: JSON.stringify({
|
||
receiverThreadIds: ['current-plan-thread'],
|
||
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
|
||
}),
|
||
done: false,
|
||
};
|
||
const session = {
|
||
id: 'child-tool-routing-session',
|
||
messages: [
|
||
{
|
||
role: 'assistant',
|
||
content: '',
|
||
toolCalls: [oldTool],
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: '',
|
||
toolCalls: [currentTool],
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: '',
|
||
toolCalls: [
|
||
{
|
||
id: 'ordinary-command-tool',
|
||
name: 'shell',
|
||
kind: 'command_execution',
|
||
input: { command: 'echo should-not-be-selected' },
|
||
result: 'ordinary output',
|
||
done: true,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
api.setSession(session);
|
||
const persistedTool = api.updatePersistedCcwebMcpChildTool(session.id, {
|
||
threadId: 'current-plan-thread',
|
||
spawnToolId: 'call-current-plan-demo',
|
||
label: '当前计划代理',
|
||
taskDescription: '验证计划只进入当前子代理卡片。',
|
||
status: 'running',
|
||
planProgress: { completed: 2, total: 4 },
|
||
planCurrentStep: '更新当前卡片',
|
||
planUpdatedAt: '2026-07-29T00:35:00.000Z',
|
||
});
|
||
assert(persistedTool?.id === 'call-current-plan-demo', 'Child plan updates should select the exact spawn tool id');
|
||
assert(session.messages[2].toolCalls[0].result === 'ordinary output', 'Child plan routing must not merge into ordinary command_execution tools');
|
||
const result = JSON.parse(persistedTool.result);
|
||
assert(result.agentsStates?.['current-plan-thread']?.planProgress?.completed === 2, 'Exact child card should receive plan progress');
|
||
assert(result.agentsStates?.['current-plan-thread']?.planCurrentStep === '更新当前卡片', 'Exact child card should receive the current plan step');
|
||
assert(!JSON.parse(oldTool.result).agentsStates?.['current-plan-thread'], 'Older unrelated child cards must remain untouched');
|
||
api.flushPendingCcwebMcpChildSession(session.id);
|
||
assert(api.getSavedSession()?.id === session.id, 'Exact child-card merge should save the session');
|
||
|
||
const currentResultBeforeMissingId = currentTool.result;
|
||
const missingTool = api.updatePersistedCcwebMcpChildTool(session.id, {
|
||
threadId: 'missing-route-thread',
|
||
spawnToolId: 'call-not-present',
|
||
label: '不应串卡代理',
|
||
status: 'running',
|
||
planProgress: { completed: 1, total: 2 },
|
||
});
|
||
assert(missingTool === null, 'A non-empty unknown spawn tool id must not fall back to another child card');
|
||
assert(currentTool.result === currentResultBeforeMissingId, 'Unknown spawn tool ids must not contaminate the latest child card');
|
||
assert(!JSON.parse(oldTool.result).agentsStates?.['missing-route-thread'], 'Unknown spawn tool ids must not contaminate older child cards');
|
||
|
||
const syncSource = extractFunctionSource(source, 'syncCcwebMcpChildAgentsFromCollabItem');
|
||
const syncApi = new Function(`
|
||
const ccwebMcpChildThreads = new Map();
|
||
const sent = [];
|
||
const path = { basename: (value) => String(value || '').split('/').filter(Boolean).pop() || '' };
|
||
const SESSION_MESSAGE_CONTENT_MAX_CHARS = 10000;
|
||
function normalizeCodexAppThreadId(value) { return String(value || '').trim(); }
|
||
function codexAppCollabToolName(value) { return String(value || '').trim(); }
|
||
function extractCcwebMcpStringArray(...values) { return values.flat().filter(Boolean).map(String); }
|
||
function ccwebMcpChildLabel(state, fallback) { return String(state?.label || state?.title || fallback || ''); }
|
||
function normalizeCcwebMcpChildPlanProgress() { return null; }
|
||
function extractCcwebMcpChildCandidate() { return ''; }
|
||
function truncateTextValue(value) { return String(value || ''); }
|
||
function ccwebMcpChildStatus(value, fallback) {
|
||
const normalized = String(value || '').toLowerCase();
|
||
return normalized === 'started' ? 'running' : (normalized || fallback);
|
||
}
|
||
function sendCcwebMcpChildAgentUpdate(sessionId, child) {
|
||
sent.push({ sessionId, child: { ...child } });
|
||
}
|
||
${syncSource}
|
||
return {
|
||
setChild: (id, child) => ccwebMcpChildThreads.set(id, child),
|
||
getChild: (id) => ccwebMcpChildThreads.get(id),
|
||
sync: syncCcwebMcpChildAgentsFromCollabItem,
|
||
sent,
|
||
};
|
||
`)();
|
||
syncApi.setChild('current-plan-thread', {
|
||
threadId: 'current-plan-thread',
|
||
parentSessionId: session.id,
|
||
parentThreadId: 'parent-thread',
|
||
spawnToolId: 'call-old-reviewer',
|
||
label: '当前计划代理',
|
||
status: 'running',
|
||
});
|
||
syncApi.sync({ sessionId: session.id, role: 'parent', entry: { threadId: 'parent-thread' } }, {
|
||
id: 'call-current-plan-demo',
|
||
type: 'subAgentActivity',
|
||
kind: 'started',
|
||
agentThreadId: 'current-plan-thread',
|
||
agentPath: '/root/current_plan_demo',
|
||
});
|
||
assert(
|
||
syncApi.getChild('current-plan-thread')?.spawnToolId === 'call-current-plan-demo',
|
||
'A canonical subAgentActivity started event must rebind an existing child to its current tool id'
|
||
);
|
||
assert(
|
||
syncApi.sent.at(-1)?.child?.spawnToolId === 'call-current-plan-demo',
|
||
'The first public update after rebind must target the current child card'
|
||
);
|
||
|
||
syncApi.setChild('parent-child-thread', {
|
||
threadId: 'parent-child-thread',
|
||
parentSessionId: session.id,
|
||
parentThreadId: 'parent-thread',
|
||
spawnToolId: 'call-visible-parent-child',
|
||
label: '父级子代理',
|
||
status: 'running',
|
||
});
|
||
syncApi.sync({
|
||
sessionId: session.id,
|
||
role: 'child',
|
||
child: syncApi.getChild('parent-child-thread'),
|
||
}, {
|
||
id: 'grandchild-internal-activity',
|
||
type: 'subAgentActivity',
|
||
kind: 'started',
|
||
agentThreadId: 'grandchild-thread',
|
||
agentPath: '/root/parent_child/grandchild',
|
||
});
|
||
assert(
|
||
syncApi.getChild('grandchild-thread')?.spawnToolId === 'call-visible-parent-child',
|
||
'A nested child should inherit its nearest visible parent card tool id'
|
||
);
|
||
assert(
|
||
syncApi.getChild('grandchild-thread')?.parentThreadId === 'parent-child-thread',
|
||
'A nested child should still retain its immediate parent thread id'
|
||
);
|
||
}
|
||
|
||
function assertCodexAppUnroutedNotificationRoutingContract() {
|
||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
assert(source.includes('const codexAppThreadSessionIndex = new Map();'), 'Server should keep an O(1) Codex App thread -> session index');
|
||
assert(source.includes('const codexAppUnknownThreadMisses = new Map();'), 'Server should keep a bounded negative cache for unknown Codex App threads');
|
||
assert(source.includes('const codexAppUnroutedNotificationLogTimes = new Map();'), 'Server should throttle unrouted notification logs by thread/method');
|
||
assert(source.includes('function recoverCcwebMcpChildThreadsFromPersistedToolCalls'), 'Server should restore child-thread routes from persisted collaboration tool calls');
|
||
assert(
|
||
/recoverCcwebMcpChildThreadsFromPersistedToolCalls\(\s*sessionId,\s*state,\s*toolCalls\s*\)/.test(source),
|
||
'Codex App recovery should rebuild persisted child-thread routes before cleaning run state'
|
||
);
|
||
assert(source.includes('function updateSessionRuntimeThreadIndex'), 'Server should centralize session thread index maintenance');
|
||
assert(
|
||
/function saveSession\(session\)[\s\S]*?updateSessionRuntimeThreadIndex\(session\)/.test(source),
|
||
'Saving a session should refresh the runtime thread index'
|
||
);
|
||
assert(
|
||
/function handleDeleteSession\(ws, sessionId\)[\s\S]*?removeSessionRuntimeThreadIndex\(sessionId\)/.test(source),
|
||
'Deleting a session should remove its runtime thread index entry'
|
||
);
|
||
|
||
const lookupBlock = extractFunctionSource(source, 'findCodexAppSessionByThreadId');
|
||
assert(lookupBlock.includes('codexAppThreadSessionIndex.get(targetThreadId)'), 'Thread lookup should consult the O(1) index');
|
||
assert(lookupBlock.includes('codexAppUnknownThreadMisses'), 'Thread lookup should use the unknown-thread negative cache');
|
||
assert(!lookupBlock.includes('fs.readdirSync(SESSIONS_DIR)'), 'Thread lookup must not synchronously scan all session files on the notification hot path');
|
||
|
||
const routeBlock = extractFunctionSource(source, 'findCodexAppRouteByRuntime');
|
||
const childIndex = routeBlock.indexOf("role: 'child'");
|
||
const adoptIndex = routeBlock.indexOf('adoptCodexAppUnroutedTurn');
|
||
assert(childIndex >= 0, 'Runtime routing should return child routes');
|
||
assert(adoptIndex >= 0, 'Runtime routing should still adopt parent notifications from disk state');
|
||
assert(childIndex < adoptIndex, 'Known child threads should route before parent disk adoption');
|
||
|
||
const notificationBlock = extractFunctionSource(source, 'handleCodexAppNotification');
|
||
assert(
|
||
notificationBlock.includes('shouldLogCodexAppUnroutedNotification(notification)'),
|
||
'Unrouted notification logging should be throttled by a helper'
|
||
);
|
||
}
|
||
|
||
function assertMultiAgentV2CompatibilityContract() {
|
||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
|
||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||
|
||
assert(
|
||
/CODEX_REASONING_LEVELS\s*=\s*new Set\(\[[^\]]*'ultra'/.test(serverSource),
|
||
'Codex config model parsing should accept the ultra reasoning level'
|
||
);
|
||
assert(
|
||
/low\|medium\|high\|xhigh\|ultra/.test(runtimeSource),
|
||
'Codex CLI model suffix parsing should accept ultra'
|
||
);
|
||
assert(
|
||
frontendSource.includes("{ value: 'ultra', label: 'ultra'"),
|
||
'Codex model picker should expose ultra reasoning'
|
||
);
|
||
const instructionsBlock = serverSource.slice(
|
||
serverSource.indexOf('const CODEX_APP_COLLABORATION_INSTRUCTIONS'),
|
||
serverSource.indexOf('function getLocalCodexConfigTomlPath')
|
||
);
|
||
assert(instructionsBlock.includes('current runtime tool schema'), 'Sub-agent guidance should defer to the current runtime tool schema');
|
||
assert(!instructionsBlock.includes('fork_context'), 'Sub-agent guidance should not hard-code the V1 fork_context field');
|
||
assert(!instructionsBlock.includes('additional wait_agent rounds'), 'Sub-agent guidance should not impose obsolete repeated wait_agent calls');
|
||
|
||
const syncBlock = extractFunctionSource(serverSource, 'syncCcwebMcpChildAgentsFromCollabItem');
|
||
assert(syncBlock.includes("itemType === 'subAgentActivity'"), 'Child routing should register canonical V2 subAgentActivity items');
|
||
const notificationBlock = extractFunctionSource(serverSource, 'handleCodexAppNotification');
|
||
const activitySyncIndex = notificationBlock.indexOf('syncCcwebMcpChildAgentsFromCollabItem(routed, item)');
|
||
const childReturnIndex = notificationBlock.indexOf("if (routed.role === 'child')");
|
||
assert(activitySyncIndex >= 0 && activitySyncIndex < childReturnIndex, 'Nested subAgentActivity registration should happen before child notification routing returns');
|
||
const childNotificationBlock = extractFunctionSource(serverSource, 'processCcwebMcpChildNotification');
|
||
assert(childNotificationBlock.includes('codexAppRuntime.planUpdateFromNotification(notification)'), 'Child notification routing should reuse runtime plan parsing');
|
||
const recoveryBlock = extractFunctionSource(serverSource, 'recoverCcwebMcpChildThreadsFromPersistedToolCalls');
|
||
assert(recoveryBlock.includes('parseMaybeJsonObject(tool?.result)'), 'Child recovery should read the latest persisted collaboration tool result');
|
||
assert(recoveryBlock.includes('recoveredState.planProgress'), 'Child recovery should restore persisted plan progress');
|
||
|
||
assert(frontendSource.includes("closeBtn.textContent = '中断';"), 'Sub-agent action should use interrupt semantics');
|
||
assert(frontendSource.includes('entry.agentPath ? `路径: ${entry.agentPath}`'), 'Sub-agent cards should expose the canonical agent path');
|
||
}
|
||
|
||
function assertWindowsStartupContract() {
|
||
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
|
||
|
||
assert(source.includes('cd /d "%~dp0"'), 'Windows startup should switch to its own directory and support other drive letters');
|
||
assert(/where node(?:\.exe)? >nul 2>&1/i.test(source), 'Windows startup should verify that Node.js is available');
|
||
assert(/process\.versions\.node[\s\S]*?>= 18/.test(source), 'Windows startup should reject Node.js versions below 18');
|
||
assert(/where npm\.cmd >nul 2>&1/i.test(source), 'Windows startup should verify that npm.cmd is available');
|
||
assert(/call npm\.cmd (?:ci|install)/i.test(source), 'Windows startup should use call when invoking npm.cmd');
|
||
assert(/if errorlevel 1[\s\S]*?exit \/b 1/i.test(source), 'Windows startup should stop after an environment or dependency failure');
|
||
assert(source.includes('set "APP_EXIT_CODE=%ERRORLEVEL%"'), 'Windows startup should preserve the server exit code');
|
||
assert(source.includes('exit /b %APP_EXIT_CODE%'), 'Windows startup should return the server exit code to the caller');
|
||
}
|
||
|
||
async function main() {
|
||
const targetIndex = process.argv.indexOf('--target');
|
||
const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim();
|
||
if (regressionTarget) {
|
||
if (regressionTarget === 'composer-slash-routing') {
|
||
assertComposerSlashRoutingContract();
|
||
console.log('Composer slash routing regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'codexapp-unrouted-routing') {
|
||
assertCodexAppUnroutedNotificationRoutingContract();
|
||
console.log('Codex App unrouted routing regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'subagent-card-metadata') {
|
||
assertFrontendSubagentCardMetadataContract();
|
||
assertCodexAppRuntimeSubAgentActivityContract();
|
||
assertMultiAgentV2CompatibilityContract();
|
||
console.log('Subagent card metadata regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'gilded-theme') {
|
||
assertFrontendGildedThemeContract();
|
||
console.log('Gilded theme regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'wasteland-theme') {
|
||
assertFrontendWastelandThemeContract();
|
||
assertPlanListProgressContract();
|
||
console.log('Wasteland theme regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'sidebar-collapse') {
|
||
assertFrontendSidebarCollapseContract();
|
||
console.log('Sidebar collapse regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'plan-list-progress') {
|
||
assertPlanListProgressContract();
|
||
console.log('Plan List progress regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'runtime-image-send') {
|
||
await runRuntimeImageSendTarget();
|
||
console.log('Runtime image send regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'codexapp-stale-running') {
|
||
await runCodexAppStaleRunningRegression();
|
||
assertSessionSwitchResilienceContract();
|
||
assertCodexAppStaleRunningRecoveryContract();
|
||
console.log('Codex App stale running regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'session-switch-race') {
|
||
assertSessionSwitchRaceContract();
|
||
console.log('Session switch race regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'title-history-outline') {
|
||
assertSetTitleMcpContract();
|
||
assertTitleHistoryOutlineContract();
|
||
console.log('Title history outline regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'session-item-tooltip') {
|
||
assertSessionItemTooltipContract();
|
||
console.log('Session item tooltip regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'sidebar-title-refresh-storm') {
|
||
assertSidebarTitleRefreshStormContract();
|
||
assertCcwebMcpChildUpdateCoalescingContract();
|
||
console.log('Sidebar title refresh storm regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'windows-startup') {
|
||
assertWindowsStartupContract();
|
||
console.log('Windows startup regression checks passed.');
|
||
return;
|
||
}
|
||
if (regressionTarget === 'subagent-card-routing') {
|
||
assertCodexAppChildToolRoutingContract();
|
||
console.log('Sub-agent card routing regression checks passed.');
|
||
return;
|
||
}
|
||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||
}
|
||
|
||
assertUnlimitedImageAttachmentsContract();
|
||
assertFrontendGildedThemeContract();
|
||
assertFrontendWastelandThemeContract();
|
||
assertFrontendSidebarCollapseContract();
|
||
assertFrontendGenerationControlsContract();
|
||
assertFrontendComposerMcpContract();
|
||
assertComposerSlashRoutingContract();
|
||
assertFrontendCcwebPromptContract();
|
||
assertFrontendMarkdownLinkContract();
|
||
assertMockCodexAppPromptUserNotTextTriggered();
|
||
assertFrontendMcpReloadContract();
|
||
assertPlanListProgressContract();
|
||
assertFrontendSubagentCardMetadataContract();
|
||
assertCodexAppRuntimeSubAgentActivityContract();
|
||
assertFrontendPrimaryCodexAppUiContract();
|
||
assertSetTitleMcpContract();
|
||
assertSessionItemTooltipContract();
|
||
assertCcwebMcpChildUpdateCoalescingContract();
|
||
assertTitleHistoryOutlineContract();
|
||
assertSessionSwitchResilienceContract();
|
||
assertSessionSwitchRaceContract();
|
||
assertCodexAppChildToolRoutingContract();
|
||
assertMultiAgentV2CompatibilityContract();
|
||
assertWindowsStartupContract();
|
||
|
||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||
const configDir = path.join(tempRoot, 'config');
|
||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||
const logsDir = path.join(tempRoot, 'logs');
|
||
const homeDir = path.join(tempRoot, 'home');
|
||
mkdirp(configDir);
|
||
mkdirp(sessionsDir);
|
||
mkdirp(logsDir);
|
||
mkdirp(homeDir);
|
||
|
||
fs.writeFileSync(path.join(configDir, 'notify.json'), JSON.stringify({
|
||
provider: 'off',
|
||
pushplus: { token: '' },
|
||
telegram: { botToken: '', chatId: '' },
|
||
serverchan: { sendKey: '' },
|
||
feishu: { webhook: '' },
|
||
qqbot: { qmsgKey: '' },
|
||
}, null, 2));
|
||
|
||
const skillDir = path.join(homeDir, '.codex', 'skills', 'regression-skill');
|
||
mkdirp(skillDir);
|
||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
||
'---',
|
||
'name: regression-skill',
|
||
'description: Regression skill for composer suggestions.',
|
||
'---',
|
||
'',
|
||
'# Regression Skill',
|
||
'',
|
||
'Use this only in regression tests.',
|
||
].join('\n'));
|
||
mkdirp(path.join(skillDir, 'agents'));
|
||
fs.writeFileSync(path.join(skillDir, 'agents', 'openai.yaml'), [
|
||
'interface:',
|
||
' display_name: "Regression Docs"',
|
||
' short_description: "Regression skill metadata for composer suggestions."',
|
||
' brand_color: "#2f6f64"',
|
||
' default_prompt: "Use Regression Docs for regression metadata coverage."',
|
||
'',
|
||
'dependencies:',
|
||
' tools:',
|
||
' - type: "mcp"',
|
||
' value: "openaiDeveloperDocs"',
|
||
' description: "Regression docs MCP server"',
|
||
' transport: "streamable_http"',
|
||
' url: "https://developers.openai.com/mcp"',
|
||
].join('\n'));
|
||
const codexPromptsDir = path.join(homeDir, '.codex', 'prompts');
|
||
mkdirp(path.join(codexPromptsDir, 'nested-tool'));
|
||
fs.writeFileSync(path.join(codexPromptsDir, 'quick-note.md'), [
|
||
'---',
|
||
'title: Quick Note',
|
||
'description: Prompt file shortcut from ~/.codex/prompts.',
|
||
'---',
|
||
'',
|
||
'Prompt body from @quick-note.',
|
||
].join('\n'));
|
||
fs.writeFileSync(path.join(codexPromptsDir, 'nested-tool', 'prompt.md'), [
|
||
'---',
|
||
'description: Directory-style prompt shortcut.',
|
||
'---',
|
||
'',
|
||
'Prompt body from @nested-tool.',
|
||
].join('\n'));
|
||
fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({
|
||
prompts: [
|
||
{
|
||
name: 'shipit',
|
||
title: 'Ship It',
|
||
description: 'Regression prompt template.',
|
||
content: 'Regression prompt body from @shipit.',
|
||
},
|
||
],
|
||
}, null, 2));
|
||
|
||
createFakeClaudeHistory(homeDir);
|
||
createFakeCodexConfig(homeDir, { reasoningEffort: 'ultra' });
|
||
const codexFixture = createFakeCodexHistory(homeDir);
|
||
const codexAppImportFixture = createFakeCodexHistory(homeDir, {
|
||
threadId: 'codexapp-import-thread',
|
||
cwd: '/tmp/project-c',
|
||
userText: 'Codex App import prompt',
|
||
answerText: 'Codex App import answer',
|
||
source: 'vscode',
|
||
fileStamp: '2026-03-12T00-00-10',
|
||
});
|
||
const duplicateSourceConversationId = '11111111-1111-4111-8111-111111111111';
|
||
const duplicateSourceConversationTitle = '你能看下 00a7cbc2-d0c3-457f-a262-aa5a5859fa54 这个对话么, 你来评估下,这个对话中';
|
||
createFakeCodexHistory(homeDir, {
|
||
threadId: 'codexapp-duplicate-thread-a',
|
||
cwd: '/tmp/project-c',
|
||
userText: `来自「${duplicateSourceConversationTitle}」对话(ID: ${duplicateSourceConversationId})的消息:\n\n旧候选`,
|
||
answerText: 'duplicate import answer a',
|
||
source: 'vscode',
|
||
fileStamp: '2026-03-12T00-00-20',
|
||
});
|
||
createFakeCodexHistory(homeDir, {
|
||
threadId: 'codexapp-duplicate-thread-b',
|
||
cwd: '/tmp/project-c',
|
||
userText: `来自「${duplicateSourceConversationTitle}」对话(ID: ${duplicateSourceConversationId})的消息:\n\n新候选`,
|
||
answerText: 'duplicate import answer b',
|
||
source: 'vscode',
|
||
fileStamp: '2026-03-12T00-00-21',
|
||
});
|
||
const codexAppObjectSourceFixture = createFakeCodexHistory(homeDir, {
|
||
threadId: 'codexapp-object-source-thread',
|
||
cwd: '/tmp/project-c',
|
||
userText: 'Object source import prompt',
|
||
answerText: 'Object source import answer',
|
||
source: { subagent: { thread_spawn: { parent_thread_id: 'parent-thread', depth: 1 } } },
|
||
fileStamp: '2026-03-12T00-00-22',
|
||
});
|
||
|
||
const port = await getFreePort();
|
||
const password = 'Regression!234';
|
||
const internalMcpToken = 'RegressionMcp!234';
|
||
|
||
await withServer({
|
||
PORT: String(port),
|
||
CC_WEB_PASSWORD: password,
|
||
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
|
||
CC_WEB_CONFIG_DIR: configDir,
|
||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||
CC_WEB_LOGS_DIR: logsDir,
|
||
HOME: homeDir,
|
||
CLAUDE_PATH: MOCK_CLAUDE,
|
||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||
CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS: '100',
|
||
}, async () => {
|
||
await assertWsUpgradeRejected(port, '/not-ws');
|
||
|
||
const { ws, messages, token } = await connectWs(port, password);
|
||
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||
ws.send(JSON.stringify({ type: 'load_session', sessionId: 'missing-session', requestId: 'reg-missing-session' }));
|
||
const missingSessionLoad = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'error' &&
|
||
msg.code === 'session_not_found' &&
|
||
msg.sessionId === 'missing-session'
|
||
));
|
||
assert(missingSessionLoad.requestId === 'reg-missing-session', 'Missing load_session error should echo requestId for frontend cleanup');
|
||
|
||
const pickerRoot = path.join(homeDir, 'picker-root');
|
||
mkdirp(path.join(pickerRoot, 'alpha'));
|
||
mkdirp(path.join(pickerRoot, 'beta'));
|
||
fs.writeFileSync(path.join(pickerRoot, 'note.txt'), 'not a directory');
|
||
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'plan', requestId: 'reg-new-default' }));
|
||
const defaultCodexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat');
|
||
assert(defaultCodexSession.requestId === 'reg-new-default', 'new_session session_info should echo requestId');
|
||
assert(defaultCodexSession.cwd === homeDir, 'Codex new_session without cwd should default to HOME');
|
||
|
||
const missingCwd = path.join(tempRoot, 'missing-space', 'nested-project');
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: missingCwd, mode: 'plan' }));
|
||
const missingCwdError = await nextMessage(messages, ws, (msg) => msg.type === 'error' && msg.code === 'new_session_cwd_missing');
|
||
assert(missingCwdError.cwd === missingCwd, 'Missing cwd error should return the requested absolute path');
|
||
assert(!fs.existsSync(missingCwd), 'Missing cwd should not be created before explicit confirmation');
|
||
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: missingCwd, mode: 'plan', createCwd: true }));
|
||
const createdCwdSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === missingCwd);
|
||
assert(createdCwdSession.cwd === missingCwd, 'Codex new_session should allow creating a missing cwd');
|
||
assert(fs.existsSync(missingCwd), 'Missing cwd should be created when createCwd is enabled');
|
||
|
||
const directoryPayload = await fetchAuthedJson(port, token, `/api/fs/directories?path=${encodeURIComponent(pickerRoot)}`);
|
||
assert(directoryPayload.currentPath === pickerRoot, 'Directory picker should return requested absolute path');
|
||
assert(directoryPayload.defaultPath === homeDir, 'Directory picker should expose HOME as default path');
|
||
assert(directoryPayload.entries.some((entry) => entry.name === 'alpha'), 'Directory picker should list child directories');
|
||
assert(directoryPayload.entries.some((entry) => entry.name === 'beta'), 'Directory picker should include all child directories');
|
||
assert(!directoryPayload.entries.some((entry) => entry.name === 'note.txt'), 'Directory picker should hide files');
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'save_codex_config',
|
||
config: {
|
||
mode: 'custom',
|
||
activeProfile: 'Regression Profile',
|
||
profiles: [{ name: 'Regression Profile', apiKey: 'sk-regression', apiBase: 'https://example.com/v1' }],
|
||
enableSearch: true,
|
||
retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 },
|
||
},
|
||
}));
|
||
const codexConfigMsg = await nextMessage(messages, ws, (msg) => msg.type === 'codex_config');
|
||
assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
|
||
assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
|
||
assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
|
||
assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
|
||
assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
|
||
assert(codexConfigMsg.config.retry?.mode === 'limited', 'Codex retry mode should round-trip');
|
||
assert(codexConfigMsg.config.retry?.intervalSeconds === 1, 'Codex retry interval should round-trip');
|
||
assert(codexConfigMsg.config.retry?.maxAttempts === 2, 'Codex retry max attempts should round-trip');
|
||
|
||
const codexInitCwd = path.join(tempRoot, 'codex-space');
|
||
mkdirp(codexInitCwd);
|
||
const projectSkillDir = path.join(codexInitCwd, '.agents', 'skills', 'project-skill');
|
||
mkdirp(projectSkillDir);
|
||
fs.writeFileSync(path.join(projectSkillDir, 'SKILL.md'), [
|
||
'---',
|
||
'name: project-skill',
|
||
'description: Project-scoped skill for composer suggestions.',
|
||
'---',
|
||
'',
|
||
'# Project Skill',
|
||
'',
|
||
'Use this only in regression tests.',
|
||
].join('\n'));
|
||
const projectCodexConfigDir = path.join(codexInitCwd, '.codex');
|
||
mkdirp(projectCodexConfigDir);
|
||
fs.writeFileSync(path.join(projectCodexConfigDir, 'config.toml'), [
|
||
'[mcp_servers.reg-project]',
|
||
'type = "stdio"',
|
||
`command = ${JSON.stringify(process.execPath)}`,
|
||
'args = ["regression-mcp.js"]',
|
||
'enabled = true',
|
||
'',
|
||
'[mcp_servers.reg-disabled]',
|
||
'type = "stdio"',
|
||
`command = ${JSON.stringify(process.execPath)}`,
|
||
'enabled = false',
|
||
'',
|
||
'[mcp_servers.reg-missing]',
|
||
'type = "stdio"',
|
||
'command = "definitely-missing-mcp-command"',
|
||
].join('\n'));
|
||
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
|
||
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd);
|
||
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
|
||
assert(codexSession.model === 'gpt-5.5(ultra)', 'Codex new_session should preserve ultra from ~/.codex/config.toml');
|
||
|
||
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: true }));
|
||
const pinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId);
|
||
assert(pinnedAck.pinnedAt, 'Pinning a session should return pinnedAt');
|
||
const pinnedList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && s.pinnedAt));
|
||
assert(pinnedList.sessions[0].id === codexSession.sessionId, 'Pinned session should sort before regular sessions');
|
||
let storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
assert(storedPinnedSession.pinnedAt === pinnedAck.pinnedAt, 'Pinned state should persist to session JSON');
|
||
|
||
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: false }));
|
||
const unpinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId && !msg.pinnedAt);
|
||
assert(unpinnedAck.pinnedAt === null, 'Unpinning a session should clear pinnedAt');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && !s.pinnedAt));
|
||
storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
assert(storedPinnedSession.pinnedAt === null, 'Unpinned state should persist to session JSON');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash', trigger: '/', query: 'mo', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const slashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash');
|
||
assert(slashComposer.items.some((item) => item.kind === 'command' && item.name === '/model'), 'Composer slash suggestions should include /model');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
|
||
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
|
||
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_set_title'), 'Composer slash suggestions should include ccweb_set_title MCP tool');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config');
|
||
assert(slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-project'), 'Composer slash suggestions should include available project MCP servers from session cwd');
|
||
assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-config'), 'Composer slash suggestions should not include MCP servers from unrelated global Codex config');
|
||
assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-disabled'), 'Composer slash suggestions should not include disabled project MCP servers');
|
||
assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-missing'), 'Composer slash suggestions should not include project MCP servers with unavailable commands');
|
||
|
||
const storedComposerFixture = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
storedComposerFixture.messages.push({
|
||
role: 'assistant',
|
||
content: 'Runtime tools include mcp__regRuntime__inspect_schema and mcp:reg-state/query.',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
fs.writeFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), JSON.stringify(storedComposerFixture, null, 2));
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-runtime', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const slashMcpRuntimeComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-runtime');
|
||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
|
||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');
|
||
|
||
const unknownSlashAttachment = await uploadAttachment(port, token, {
|
||
filename: 'unknown-slash.png',
|
||
mime: 'image/png',
|
||
data: Buffer.from('unknown-slash-image'),
|
||
});
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: '/report/mcps?search',
|
||
attachments: [unknownSlashAttachment],
|
||
sessionId: codexSession.sessionId,
|
||
mode: 'plan',
|
||
agent: 'codex',
|
||
requestId: 'reg-unknown-slash-draft',
|
||
}));
|
||
const unknownSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-unknown-slash-draft');
|
||
assert(unknownSlashDraft.sessionId === codexSession.sessionId, 'Unknown slash draft response should stay scoped to the active session');
|
||
assert(!unknownSlashDraft.preserveComposerDraft, 'Unknown slash hints should not restore text that is continuing through ordinary send');
|
||
assert(/未知指令: \/report\/mcps\?search/.test(unknownSlashDraft.message || ''), 'Unknown slash text should still show the normal hint');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
|
||
const storedUnknownSlashSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
const storedUnknownSlashMessage = storedUnknownSlashSession.messages.find((message) => message.role === 'user' && message.content === '/report/mcps?search');
|
||
assert(storedUnknownSlashMessage, 'Unknown slash text should continue through the ordinary message pipeline');
|
||
assert(storedUnknownSlashMessage.attachments?.some((attachment) => attachment.filename === unknownSlashAttachment.filename), 'Unknown slash text should preserve ordinary message attachments');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: '/help', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-help-slash-draft' }));
|
||
const helpSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-help-slash-draft');
|
||
assert(!helpSlashDraft.preserveComposerDraft, 'Successful slash command responses should clear pending drafts without restoring input');
|
||
assert(/可用指令/.test(helpSlashDraft.message || ''), 'Slash /help should keep the normal help output');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
|
||
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
|
||
const metadataSkill = skillComposer.items.find((item) => item.kind === 'skill' && item.name === 'regression-skill');
|
||
assert(metadataSkill?.title === 'Regression Docs', 'Composer skill suggestions should expose openai.yaml display_name');
|
||
assert(/metadata coverage/.test(metadataSkill?.defaultPromptPreview || ''), 'Composer skill suggestions should expose default prompt preview');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-project-skill', trigger: '$', query: 'project', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const projectSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-project-skill');
|
||
assert(projectSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'project-skill'), 'Composer skill suggestions should include project-scoped skill from session cwd');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp');
|
||
assert(!skillMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not include MCP tools');
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-declared-mcp', trigger: '$', query: 'openai', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const skillDeclaredMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-declared-mcp');
|
||
assert(!skillDeclaredMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer skill trigger suggestions should not list declared MCP dependencies from openai.yaml as available tools');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt');
|
||
assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-file', trigger: '@', query: 'quick', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const promptFileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-file');
|
||
assert(promptFileComposer.items.some((item) => item.kind === 'prompt' && item.name === 'quick-note'), 'Composer prompt suggestions should include ~/.codex/prompts/*.md shortcuts');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-dir', trigger: '@', query: 'nested', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const promptDirComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-dir');
|
||
assert(promptDirComposer.items.some((item) => item.kind === 'prompt' && item.name === 'nested-tool'), 'Composer prompt suggestions should include ~/.codex/prompts/<name>/prompt.md shortcuts');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-at-no-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const atNoMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-at-no-mcp');
|
||
assert(!atNoMcpComposer.items.some((item) => item.kind === 'mcp'), 'Composer @ suggestions should not include MCP tools');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-file', trigger: '@', query: 'context', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||
const fileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file');
|
||
assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file');
|
||
assert(!fileComposer.items.some((item) => item.kind === 'mcp'), 'Composer file suggestions should not include MCP tools');
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression',
|
||
sessionId: codexSession.sessionId,
|
||
mode: 'plan',
|
||
agent: 'codex',
|
||
}));
|
||
const composerExpanded = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' &&
|
||
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
|
||
/BEGIN CC-WEB PROMPT: quick-note/.test(msg.text || '') &&
|
||
/Composer file context body/.test(msg.text || '')
|
||
));
|
||
assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content');
|
||
assert(/Prompt body from @quick-note/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand ~/.codex/prompts prompt shortcuts');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
|
||
const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression');
|
||
assert(storedComposerMessage, 'Composer message should persist original user text');
|
||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata');
|
||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'quick-note'), 'Composer message should persist ~/.codex/prompts prompt mention metadata');
|
||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata');
|
||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata');
|
||
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'project-skill'), 'Composer message should persist project-scoped skill mention metadata');
|
||
const storedRegressionSkillMention = storedComposerMessage.composerMentions?.find((mention) => mention.kind === 'skill' && mention.name === 'regression-skill');
|
||
assert(storedRegressionSkillMention?.title === 'Regression Docs', 'Stored skill mention should persist display title from openai.yaml');
|
||
assert(storedRegressionSkillMention?.dependencies?.some((dep) => dep.value === 'openaiDeveloperDocs' && dep.state === 'declared'), 'Stored skill mention should persist MCP dependency metadata');
|
||
|
||
const mcpList = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_list_conversations',
|
||
sourceSessionId: codexSession.sessionId,
|
||
args: { agent: 'codex', limit: 20 },
|
||
});
|
||
assert(mcpList.status === 200 && mcpList.body?.ok, 'MCP conversation list should succeed');
|
||
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
|
||
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
|
||
|
||
const codexTitleSessionPath = path.join(sessionsDir, `${codexSession.sessionId}.json`);
|
||
const storedBeforeMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||
const mcpSetTitle = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_set_title',
|
||
sourceSessionId: codexSession.sessionId,
|
||
args: { title: 'Concise MCP Title' },
|
||
});
|
||
assert(mcpSetTitle.status === 200 && mcpSetTitle.body?.ok, `MCP set title should succeed: ${JSON.stringify(mcpSetTitle.body)}`);
|
||
assert(mcpSetTitle.body.changed === true && mcpSetTitle.body.ignored === false, 'MCP set title should report a real title change');
|
||
assert(mcpSetTitle.body.previousTitle === storedBeforeMcpTitle.title, 'MCP set title should return previousTitle');
|
||
assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename');
|
||
assert(mcpSetTitle.body.titleEvent?.title === 'Concise MCP Title', 'MCP set title should return the persisted title event');
|
||
assert(mcpSetTitle.body.titleEvent?.messageIndex === storedBeforeMcpTitle.messages.length, 'Title event should anchor after the messages persisted before the rename');
|
||
const expectedTitleAnchorMessageIndex = (() => {
|
||
for (let index = storedBeforeMcpTitle.messages.length - 1; index >= 0; index -= 1) {
|
||
if (storedBeforeMcpTitle.messages[index]?.role === 'user') return index;
|
||
}
|
||
return null;
|
||
})();
|
||
assert(expectedTitleAnchorMessageIndex !== null, 'Title regression fixture should contain a triggering user message');
|
||
assert(mcpSetTitle.body.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'MCP set title should return the triggering user message anchor');
|
||
const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_renamed' &&
|
||
msg.sessionId === codexSession.sessionId &&
|
||
msg.title === 'Concise MCP Title'
|
||
));
|
||
assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers');
|
||
assert(mcpTitleRenamed.titleEvent?.title === 'Concise MCP Title', 'MCP set title should push the title event to current viewers');
|
||
assert(mcpTitleRenamed.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Live session_renamed should preserve the title trigger anchor');
|
||
const mcpTitleList = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' &&
|
||
msg.sessions.some((session) => (
|
||
session.id === codexSession.sessionId &&
|
||
session.title === 'Concise MCP Title' &&
|
||
session.titleSource === 'llm'
|
||
))
|
||
));
|
||
assert(mcpTitleList.sessions.some((session) => session.id === codexSession.sessionId && session.updated === storedBeforeMcpTitle.updated), 'MCP set title should not bump session updated sorting timestamp');
|
||
const storedAfterMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||
assert(storedAfterMcpTitle.title === 'Concise MCP Title', 'MCP set title should persist title');
|
||
assert(storedAfterMcpTitle.titleSource === 'llm', 'MCP set title should persist llm titleSource');
|
||
assert(storedAfterMcpTitle.updated === storedBeforeMcpTitle.updated, 'MCP set title should not modify updated timestamp');
|
||
assert(storedAfterMcpTitle.titleHistory?.length === 1, 'MCP set title should append exactly one title history event');
|
||
assert(storedAfterMcpTitle.titleHistory[0].source === 'llm' && storedAfterMcpTitle.titleHistory[0].title === 'Concise MCP Title', 'Persisted title history should identify the LLM title change');
|
||
assert(!Number.isNaN(Date.parse(storedAfterMcpTitle.titleHistory[0].changedAt)), 'Persisted title history should include a valid change timestamp');
|
||
assert(storedAfterMcpTitle.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Persisted title history should retain the triggering user message anchor');
|
||
|
||
const unchangedMcpTitle = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_set_title',
|
||
sourceSessionId: codexSession.sessionId,
|
||
args: { title: 'Concise MCP Title' },
|
||
});
|
||
assert(unchangedMcpTitle.status === 200 && unchangedMcpTitle.body?.changed === false, 'Repeated MCP title should report unchanged');
|
||
assert(!unchangedMcpTitle.body.titleEvent, 'Repeated MCP title should not create a fake title event');
|
||
const storedAfterUnchangedMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||
assert(storedAfterUnchangedMcpTitle.titleHistory?.length === 1, 'Repeated MCP title should not append title history');
|
||
|
||
ws.send(JSON.stringify({ type: 'load_session', sessionId: codexSession.sessionId, requestId: 'reg-title-history-load' }));
|
||
const titleHistorySessionInfo = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_info' &&
|
||
msg.sessionId === codexSession.sessionId &&
|
||
msg.requestId === 'reg-title-history-load'
|
||
));
|
||
assert(titleHistorySessionInfo.titleHistory?.length === 1, 'session_info should restore persisted title history');
|
||
assert(titleHistorySessionInfo.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Reloaded session_info should retain the title trigger anchor');
|
||
|
||
const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_set_title',
|
||
sourceSessionId: codexSession.sessionId,
|
||
args: { title: ' ' },
|
||
});
|
||
assert(mcpEmptyTitle.status === 400 && mcpEmptyTitle.body?.code === 'invalid_title', 'MCP set title should reject empty titles');
|
||
|
||
ws.send(JSON.stringify({ type: 'rename_session', sessionId: codexSession.sessionId, title: 'Manual Locked Title' }));
|
||
const manualTitleRenamed = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_renamed' &&
|
||
msg.sessionId === codexSession.sessionId &&
|
||
msg.title === 'Manual Locked Title'
|
||
));
|
||
assert(manualTitleRenamed.titleSource === 'manual', 'Manual rename should push manual titleSource');
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' &&
|
||
msg.sessions.some((session) => (
|
||
session.id === codexSession.sessionId &&
|
||
session.title === 'Manual Locked Title' &&
|
||
session.titleSource === 'manual'
|
||
))
|
||
));
|
||
const mcpLockedTitle = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_set_title',
|
||
sourceSessionId: codexSession.sessionId,
|
||
args: { title: 'Ignored LLM Title' },
|
||
});
|
||
assert(mcpLockedTitle.status === 200 && mcpLockedTitle.body?.ok, 'MCP set title should return ok when manually locked');
|
||
assert(mcpLockedTitle.body.changed === false && mcpLockedTitle.body.ignored === true, 'MCP set title should report ignored under manual lock');
|
||
assert(mcpLockedTitle.body.reason === 'ignored because title is manually locked', 'MCP set title should explain manual lock ignores');
|
||
assert(mcpLockedTitle.body.title === 'Manual Locked Title' && mcpLockedTitle.body.lockedByUser === true, 'MCP set title should preserve manual title under lock');
|
||
const storedAfterManualLock = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||
assert(storedAfterManualLock.title === 'Manual Locked Title', 'Manual locked title should remain persisted after ignored MCP set title');
|
||
assert(storedAfterManualLock.titleSource === 'manual', 'Manual locked titleSource should remain manual after ignored MCP set title');
|
||
assert(storedAfterManualLock.titleHistory?.length === 1, 'Ignored MCP title should not append a fake title history event');
|
||
|
||
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_create_conversation',
|
||
sourceSessionId: codexSession.sessionId,
|
||
args: { cwd: 'relative-project', title: 'Relative path should fail' },
|
||
});
|
||
assert(mcpRelativeCreate.status === 400 && mcpRelativeCreate.body?.code === 'create_conversation_cwd_relative', 'MCP create conversation should reject relative cwd');
|
||
|
||
const mcpCreate = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_create_conversation',
|
||
sourceSessionId: codexSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
agent: 'claude',
|
||
title: 'MCP Created Conversation',
|
||
initialMessage: 'mcp created initial prompt',
|
||
},
|
||
});
|
||
assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`);
|
||
assert(mcpCreate.body.agent === 'codex', 'MCP create conversation should ignore agent args and inherit the source agent');
|
||
assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default');
|
||
assert(mcpCreate.body.mode === 'yolo', 'MCP create conversation should default to yolo when mode is omitted');
|
||
assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation');
|
||
assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
|
||
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
|
||
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
|
||
assert(storedMcpCreated.titleSource === 'system', 'MCP created conversation title should not be treated as user manual title');
|
||
assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent');
|
||
assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode');
|
||
assert(storedMcpCreated.createdFrom?.kind === 'mcp', 'MCP created conversation should persist mcp creation kind');
|
||
assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata');
|
||
assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message');
|
||
assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt');
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' &&
|
||
msg.sessions.some((session) => (
|
||
session.id === mcpCreate.body.conversationId &&
|
||
session.createdFromKind === 'mcp'
|
||
))
|
||
));
|
||
|
||
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
|
||
mkdirp(mcpReplyCreateCwd);
|
||
const mcpCreateReply = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_create_conversation',
|
||
sourceSessionId: codexSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
agent: 'claude',
|
||
cwd: mcpReplyCreateCwd,
|
||
title: 'MCP Reply Conversation',
|
||
initialMessage: 'mcp create request reply',
|
||
requestReply: true,
|
||
},
|
||
});
|
||
assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`);
|
||
assert(mcpCreateReply.body.agent === 'codex', 'MCP create requestReply should inherit source agent even if args.agent is passed');
|
||
assert(mcpCreateReply.body.mode === 'yolo', 'MCP create requestReply should default to yolo when mode is omitted');
|
||
assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd');
|
||
assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id');
|
||
assert(mcpCreateReply.body.replyDelivery === 'auto_run' && mcpCreateReply.body.sourceAutoRun === true, 'MCP create requestReply should declare source auto-run delivery');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId);
|
||
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
|
||
Array.isArray(session.messages) &&
|
||
session.messages.some((message) => (
|
||
message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId &&
|
||
message.crossConversation?.processed === true
|
||
))
|
||
));
|
||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
|
||
const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8'));
|
||
const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation');
|
||
const storedMcpCreateReplyIndex = storedMcpCreateSource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId);
|
||
assert(storedMcpCreateReplyIndex >= 0, 'MCP create requestReply should send a processed display-only reply back to source');
|
||
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.processed === true, 'MCP create requestReply should mark the returned message processed');
|
||
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.autoRun === true, 'MCP create requestReply should mark the returned message as auto-run');
|
||
assert(storedMcpCreateSource.messages.slice(storedMcpCreateReplyIndex + 1).some((message) => (
|
||
message.role === 'assistant' &&
|
||
/mcp create request reply/.test(String(message.content || '')) &&
|
||
/子对话/.test(String(message.content || ''))
|
||
)), 'MCP create requestReply should continue the source session after the child reply');
|
||
|
||
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
|
||
mkdirp(crossTargetCwd);
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' }));
|
||
const crossTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTargetCwd);
|
||
const crossSend = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_send_message',
|
||
sourceSessionId: codexSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
targetConversationId: crossTargetSession.sessionId,
|
||
content: 'cross hello from mcp',
|
||
},
|
||
});
|
||
assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`);
|
||
const crossUserBubble = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_message' &&
|
||
msg.sessionId === crossTargetSession.sessionId &&
|
||
msg.message?.crossConversation?.sourceSessionId === codexSession.sessionId &&
|
||
msg.message?.content === 'cross hello from mcp'
|
||
));
|
||
assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
|
||
const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8'));
|
||
const storedCrossSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
|
||
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
|
||
assert(storedCrossMessage.crossConversation.sourceTitle === storedCrossSource.title, 'Cross message should persist source title');
|
||
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
|
||
|
||
const hopAllowed = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_send_message',
|
||
sourceSessionId: codexSession.sessionId,
|
||
sourceHopCount: 1,
|
||
args: {
|
||
targetConversationId: crossTargetSession.sessionId,
|
||
content: 'cross hop still allowed',
|
||
},
|
||
});
|
||
assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`);
|
||
const hopAllowedBubble = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_message' &&
|
||
msg.sessionId === crossTargetSession.sessionId &&
|
||
msg.message?.crossConversation?.messageId === hopAllowed.body.messageId &&
|
||
msg.message?.content === 'cross hop still allowed'
|
||
));
|
||
assert(hopAllowedBubble.message.crossConversation.hopCount === 2, 'Cross message should keep incrementing hop count without blocking');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
|
||
|
||
const crossReplyTargetCwd = path.join(tempRoot, 'codex-mcp-cross-reply-target');
|
||
mkdirp(crossReplyTargetCwd);
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossReplyTargetCwd, mode: 'yolo' }));
|
||
const crossReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossReplyTargetCwd);
|
||
const requestReply = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_request_reply',
|
||
sourceSessionId: codexSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
targetConversationId: crossReplyTargetSession.sessionId,
|
||
content: 'cross reply requested',
|
||
},
|
||
});
|
||
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
|
||
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
|
||
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP request reply should declare source auto-run delivery');
|
||
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_message' &&
|
||
msg.sessionId === crossReplyTargetSession.sessionId &&
|
||
msg.message?.crossConversation?.replyRequestId === requestReply.body.requestId &&
|
||
msg.message?.crossConversation?.expectsReply === true &&
|
||
msg.message?.content === 'cross reply requested'
|
||
));
|
||
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId);
|
||
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
|
||
Array.isArray(session.messages) &&
|
||
session.messages.some((message) => (
|
||
message.crossConversation?.replyToRequestId === requestReply.body.requestId &&
|
||
message.crossConversation?.processed === true
|
||
))
|
||
));
|
||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
|
||
|
||
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
|
||
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||
const storedReplyRequestMessage = storedReplyTarget.messages.find((message) => message.crossConversation?.replyRequestId === requestReply.body.requestId);
|
||
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'Request reply target message should persist waiting metadata');
|
||
assert(storedReplyTarget.messages.some((message) => message.role === 'assistant' && /cross reply requested/.test(String(message.content || ''))), 'Request reply target should produce an assistant reply');
|
||
const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId);
|
||
assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session');
|
||
const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex];
|
||
assert(storedReplyMessage.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content');
|
||
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
|
||
assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker');
|
||
assert(storedReplyMessage.crossConversation.autoRun === true, 'Returned cross message should mark source auto-run');
|
||
assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only');
|
||
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
|
||
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
|
||
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
|
||
message.role === 'assistant' &&
|
||
/Codex mock handled/.test(String(message.content || '')) &&
|
||
/cross reply requested/.test(String(message.content || '')) &&
|
||
/子对话/.test(String(message.content || ''))
|
||
)), 'Returned cross message should trigger the source session to run again');
|
||
|
||
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
|
||
mkdirp(busySourceCwd);
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busySourceCwd, mode: 'yolo' }));
|
||
const busySourceSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busySourceCwd);
|
||
ws.send(JSON.stringify({ type: 'message', text: 'very slow cross-session prompt', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === busySourceSession.sessionId && s.isRunning));
|
||
|
||
const busyReplyTargetCwd = path.join(tempRoot, 'codex-mcp-busy-reply-target');
|
||
mkdirp(busyReplyTargetCwd);
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: busyReplyTargetCwd, mode: 'yolo' }));
|
||
const busyReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === busyReplyTargetCwd);
|
||
const busyRequestReply = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_request_reply',
|
||
sourceSessionId: busySourceSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
targetConversationId: busyReplyTargetSession.sessionId,
|
||
content: 'busy source reply requested',
|
||
},
|
||
});
|
||
assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`);
|
||
assert(busyRequestReply.body.requestId && busyRequestReply.body.status === 'waiting', 'Busy source request reply should return a waiting request id');
|
||
assert(busyRequestReply.body.replyDelivery === 'auto_run' && busyRequestReply.body.sourceAutoRun === true, 'Busy source request reply should declare source auto-run delivery');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busyReplyTargetSession.sessionId);
|
||
await waitForJsonCondition(path.join(configDir, 'cross-conversation-replies.json'), (state) => (
|
||
Array.isArray(state.replies) &&
|
||
state.replies.some((reply) => (
|
||
reply.requestId === busyRequestReply.body.requestId &&
|
||
reply.sourceConversationId === busySourceSession.sessionId &&
|
||
reply.status === 'ready' &&
|
||
/busy source reply requested/.test(String(reply.replyText || ''))
|
||
))
|
||
));
|
||
let storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
|
||
assert(!storedBusySource.messages.some((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId), 'Busy source should not receive display-only reply while it is still running');
|
||
|
||
const busyPendingList = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_list_pending_replies',
|
||
sourceSessionId: busySourceSession.sessionId,
|
||
args: { status: 'ready' },
|
||
});
|
||
assert(busyPendingList.status === 200 && busyPendingList.body?.ok, `MCP pending reply list should succeed: ${JSON.stringify(busyPendingList.body)}`);
|
||
assert(busyPendingList.body.waitingOnChildren === true, 'Pending reply list should report waitingOnChildren while ready reply is queued');
|
||
assert(busyPendingList.body.readyReplyCount === 1, 'Pending reply list should count ready replies');
|
||
assert(busyPendingList.body.replies.some((reply) => reply.requestId === busyRequestReply.body.requestId && reply.status === 'ready'), 'Pending reply list should include the queued ready reply');
|
||
|
||
const busyPendingDetail = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_get_pending_reply',
|
||
sourceSessionId: busySourceSession.sessionId,
|
||
args: { requestId: busyRequestReply.body.requestId },
|
||
});
|
||
assert(busyPendingDetail.status === 200 && busyPendingDetail.body?.ok, `MCP pending reply detail should succeed: ${JSON.stringify(busyPendingDetail.body)}`);
|
||
assert(busyPendingDetail.body.status === 'ready', 'Pending reply detail should expose ready status');
|
||
assert(/busy source reply requested/.test(String(busyPendingDetail.body.replyText || '')), 'Pending reply detail should expose target assistant output');
|
||
|
||
const busyConversationList = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_list_conversations',
|
||
sourceSessionId: busySourceSession.sessionId,
|
||
args: { limit: 50 },
|
||
});
|
||
assert(busyConversationList.status === 200 && busyConversationList.body?.ok, `MCP conversation list with waiting state should succeed: ${JSON.stringify(busyConversationList.body)}`);
|
||
assert(busyConversationList.body.waitingOnChildren === true && busyConversationList.body.readyReplyCount === 1, 'MCP list should expose source waiting state');
|
||
const busySourceSummary = busyConversationList.body.conversations.find((item) => item.id === busySourceSession.sessionId);
|
||
assert(busySourceSummary?.status === 'running', 'MCP list should still mark the busy source as running before it completes');
|
||
assert(busySourceSummary?.waitingOnChildren === true && busySourceSummary?.readyReplyCount === 1, 'MCP list should expose queued child replies on the source conversation');
|
||
|
||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
|
||
await waitForJsonCondition(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), (session) => (
|
||
Array.isArray(session.messages) &&
|
||
session.messages.some((message) => (
|
||
message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId &&
|
||
message.crossConversation?.processed === true &&
|
||
message.ccwebDisplayOnly === true
|
||
))
|
||
));
|
||
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
|
||
const busyReplyIndex = storedBusySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId);
|
||
assert(busyReplyIndex > 0, 'Busy source should receive queued display-only reply after its run completes');
|
||
assert(storedBusySource.messages[busyReplyIndex - 1]?.role === 'assistant' && /very slow cross-session prompt/.test(String(storedBusySource.messages[busyReplyIndex - 1].content || '')), 'Queued reply should be appended after the source run assistant message');
|
||
assert(storedBusySource.messages[busyReplyIndex].crossConversation?.autoRun === true, 'Queued reply should mark source auto-run');
|
||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
|
||
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
|
||
assert(storedBusySource.messages.slice(busyReplyIndex + 1).some((message) => (
|
||
message.role === 'assistant' &&
|
||
/busy source reply requested/.test(String(message.content || '')) &&
|
||
/子对话/.test(String(message.content || ''))
|
||
)), 'Busy source should auto-run after the queued child reply is flushed');
|
||
|
||
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_get_pending_reply',
|
||
sourceSessionId: busySourceSession.sessionId,
|
||
args: { requestId: busyRequestReply.body.requestId },
|
||
});
|
||
assert(returnedPendingDetail.status === 200 && returnedPendingDetail.body?.ok, 'Returned pending reply detail should remain queryable from source history');
|
||
assert(returnedPendingDetail.body.status === 'returned' && returnedPendingDetail.body.returned === true, 'Returned pending reply detail should report returned status');
|
||
|
||
ws.send(JSON.stringify({ type: 'load_session', sessionId: busySourceSession.sessionId, requestId: 'reg-load-busy-source' }));
|
||
const loadedBusySource = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === busySourceSession.sessionId);
|
||
assert(loadedBusySource.requestId === 'reg-load-busy-source', 'load_session session_info should echo requestId');
|
||
assert(loadedBusySource.isRunning === false, 'Busy source should be idle after background run completed');
|
||
assert(loadedBusySource.waitingOnChildren === false && loadedBusySource.pendingReplyCount === 0, 'Busy source should clear waiting state after queued reply is flushed');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'source remains usable after queued child reply', sessionId: busySourceSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busySourceSession.sessionId);
|
||
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
|
||
assert(storedBusySource.messages.some((message) => message.role === 'user' && message.content === 'source remains usable after queued child reply'), 'Source conversation should accept normal user messages after queued child reply is flushed');
|
||
|
||
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
|
||
const mcpSpawnLine = processLogAfterMcp
|
||
.trim()
|
||
.split('\n')
|
||
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8)));
|
||
assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config');
|
||
assert(mcpSpawnLine.includes('server.js') && mcpSpawnLine.includes('--ccweb-mcp-server'), 'Codex spawn should launch ccweb MCP through server.js in Node mode');
|
||
assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token');
|
||
const projectMcpSpawnLine = processLogAfterMcp
|
||
.trim()
|
||
.split('\n')
|
||
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(codexSession.sessionId.slice(0, 8)));
|
||
assert(projectMcpSpawnLine && projectMcpSpawnLine.includes('mcp_servers.reg-project.command'), 'Codex spawn should inject project MCP config from session cwd');
|
||
|
||
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
|
||
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions');
|
||
assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path');
|
||
assert(Array.isArray(cwdSuggestions.paths) && cwdSuggestions.paths.includes(codexInitCwd), 'CWD suggestions should include recently used session directories');
|
||
|
||
const crossTalkCwd = path.join(tempRoot, 'codex-cross-talk');
|
||
mkdirp(crossTalkCwd);
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTalkCwd, mode: 'yolo' }));
|
||
const crossTalkSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTalkCwd);
|
||
ws.send(JSON.stringify({ type: 'message', text: 'slow cross-session prompt', sessionId: crossTalkSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === crossTalkSession.sessionId && s.isRunning));
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'yolo' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat' && msg.sessionId !== crossTalkSession.sessionId);
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === crossTalkSession.sessionId, 8000);
|
||
const leakedCrossTalk = messages.find((msg) => (
|
||
['text_delta', 'content_blocks', 'tool_start', 'tool_update', 'tool_end', 'usage', 'cost', 'done', 'system_message', 'error'].includes(msg.type) &&
|
||
msg.sessionId === crossTalkSession.sessionId
|
||
));
|
||
assert(!leakedCrossTalk, `Running session leaked stream event into new session: ${leakedCrossTalk ? JSON.stringify(leakedCrossTalk) : ''}`);
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: '/init', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
|
||
const codexInitStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /AGENTS\.md/.test(msg.message || ''));
|
||
assert(/AGENTS\.md/.test(codexInitStart.message || ''), 'Codex /init should announce AGENTS.md generation');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
|
||
assert(fs.existsSync(path.join(codexInitCwd, 'AGENTS.md')), 'Codex /init should generate AGENTS.md in the workspace');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
|
||
const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex');
|
||
assert(codexModelChanged.model === 'gpt-5.3-codex', 'Codex /model should accept arbitrary Codex model names');
|
||
|
||
const codexAttachment = await uploadAttachment(port, token, {
|
||
filename: 'codex-test.png',
|
||
mime: 'image/png',
|
||
data: Buffer.from('codex-image'),
|
||
});
|
||
ws.send(JSON.stringify({ type: 'message', text: 'first codex prompt', attachments: [codexAttachment], mode: 'yolo', agent: 'codex' }));
|
||
const firstMessageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'first codex prompt');
|
||
assert(firstMessageSession.agent === 'codex', 'First-message path created wrong agent');
|
||
const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning));
|
||
assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
|
||
|
||
// Switching permission mode must not clear Codex thread id (otherwise resume loses context).
|
||
const codexSessionPath = path.join(sessionsDir, `${firstMessageSession.sessionId}.json`);
|
||
await waitForFile(codexSessionPath, 15000);
|
||
const storedAfterFirst = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
|
||
const threadIdBeforeMode = storedAfterFirst.codexThreadId;
|
||
assert(threadIdBeforeMode, 'Codex thread id should be persisted after first run');
|
||
|
||
ws.send(JSON.stringify({ type: 'set_mode', sessionId: firstMessageSession.sessionId, mode: 'plan' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan');
|
||
await waitForFile(codexSessionPath, 15000);
|
||
const storedAfterMode = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
|
||
assert(storedAfterMode.codexThreadId === threadIdBeforeMode, 'Codex thread id should survive mode switch');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'second codex prompt', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
|
||
|
||
const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
|
||
const spawnLine = processLog
|
||
.trim()
|
||
.split('\n')
|
||
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
|
||
assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag');
|
||
const parsedSpawnLine = JSON.parse(spawnLine);
|
||
assert(parsedSpawnLine.args.includes('model_reasoning_effort="ultra"'), 'Codex exec should pass the ultra reasoning level through model_reasoning_effort');
|
||
|
||
const allSpawnsForSession = processLog
|
||
.trim()
|
||
.split('\n')
|
||
.filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
|
||
const lastSpawn = allSpawnsForSession[allSpawnsForSession.length - 1] || '';
|
||
assert(lastSpawn.includes('resume') && lastSpawn.includes(threadIdBeforeMode), 'Codex mode switch should keep resume thread id');
|
||
assert(lastSpawn.includes('-s read-only'), 'Codex plan mode should set sandbox read-only');
|
||
assert(lastSpawn.includes('-s read-only resume'), 'Codex resume in plan mode must place -s before resume subcommand');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'trigger codex capacity retry', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' }));
|
||
const capacityRetryNotice = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /自动重试/.test(msg.message || '') && msg.sessionId === firstMessageSession.sessionId, 10000);
|
||
assert(/Codex 服务暂时繁忙/.test(capacityRetryNotice.message || ''), 'Codex transient capacity failure should announce automatic retry');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId, 20000);
|
||
const storedAfterCapacityRetry = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
|
||
const capacityRetryUsers = storedAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === 'trigger codex capacity retry');
|
||
assert(capacityRetryUsers.length === 1, 'Codex transient retry should not duplicate the user message');
|
||
assert(storedAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /trigger codex capacity retry/.test(String(message.content || ''))), 'Codex transient retry should persist the successful assistant response');
|
||
|
||
const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8');
|
||
assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
|
||
assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: firstMessageSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在执行/.test(msg.message || '') && /Codex \/compact/.test(msg.message || ''));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
|
||
const compactDoneMsg = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
|
||
assert(/已执行 Codex \/compact/.test(compactDoneMsg.message || ''), 'Codex /compact should complete with Codex-specific status message');
|
||
|
||
const autoCompactCwd = path.join(tempRoot, 'codex-auto-compact');
|
||
mkdirp(autoCompactCwd);
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: autoCompactCwd, mode: 'yolo' }));
|
||
const autoCompactSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === autoCompactCwd);
|
||
ws.send(JSON.stringify({ type: 'message', text: 'warm up auto compact', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId);
|
||
ws.send(JSON.stringify({ type: 'message', text: 'trigger codex context limit', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
|
||
const autoCompactStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在按 Codex \/compact 自动压缩/.test(msg.message || ''));
|
||
assert(/Codex \/compact/.test(autoCompactStart.message || ''), 'Codex auto /compact should announce auto compact start');
|
||
const autoCompactDone = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
|
||
assert(/已执行 Codex \/compact/.test(autoCompactDone.message || ''), 'Codex auto /compact should finish compact step');
|
||
const autoCompactResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /按 Codex 压缩计划继续执行/.test(msg.message || ''));
|
||
assert(/继续执行/.test(autoCompactResume.message || ''), 'Codex auto /compact should announce retry');
|
||
// Some Codex builds won't echo the original prompt text as a text delta on retry; accept either.
|
||
const autoCompactRetry = await nextMessage(messages, ws, (msg) => (
|
||
(msg.type === 'text_delta' && /trigger codex context limit/.test(msg.text || '')) ||
|
||
(msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId)
|
||
), 20000);
|
||
if (autoCompactRetry.type === 'text_delta') {
|
||
assert(/trigger codex context limit/.test(autoCompactRetry.text || ''), 'Codex auto /compact should replay the failed prompt after compact');
|
||
}
|
||
|
||
const codexAppCwd = path.join(tempRoot, 'codexapp-space');
|
||
mkdirp(codexAppCwd);
|
||
const codexAppProjectConfigDir = path.join(codexAppCwd, '.codex');
|
||
mkdirp(codexAppProjectConfigDir);
|
||
fs.writeFileSync(path.join(codexAppProjectConfigDir, 'config.toml'), [
|
||
'[mcp_servers.reg-app-project]',
|
||
'type = "stdio"',
|
||
`command = ${JSON.stringify(process.execPath)}`,
|
||
'args = ["regression-app-mcp.js"]',
|
||
'enabled = true',
|
||
].join('\n'));
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' }));
|
||
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
|
||
assert(codexAppSession.model === 'gpt-5.5(ultra)', 'Codex App new_session should preserve the ultra default Codex model');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
|
||
const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill');
|
||
assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill');
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-goal-slash', trigger: '/', query: 'go', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
|
||
const codexAppGoalSlashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-goal-slash');
|
||
assert(codexAppGoalSlashComposer.items.some((item) => item.kind === 'command' && item.name === '/goal'), 'Codex App composer slash suggestions should include /goal');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration default probe', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppDefaultCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
|
||
assert(/"mode":"default"/.test(codexAppDefaultCollab.text || ''), 'Codex App YOLO mode should pass default collaboration mode');
|
||
assert(/"hasModel":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include model');
|
||
assert(/"hasDeveloperInstructions":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include sub-agent developer instructions');
|
||
assert(/"hasSchemaDrivenSubagents":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should use runtime-schema-driven sub-agent guidance');
|
||
assert(/"hasLegacyV1Guidance":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should omit legacy V1 fork/wait guidance');
|
||
assert(/"reasoningEffort":"ultra"/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should pass ultra reasoning_effort');
|
||
assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level');
|
||
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'save_codex_config',
|
||
config: {
|
||
mode: 'custom',
|
||
activeProfile: 'Regression Profile Updated',
|
||
profiles: [{ name: 'Regression Profile Updated', apiKey: 'sk-regression-updated', apiBase: 'https://updated.example.com/v1' }],
|
||
enableSearch: false,
|
||
retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 },
|
||
},
|
||
}));
|
||
const codexAppChangedConfig = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated'
|
||
);
|
||
assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp after config change prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppAfterConfigChange = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/codexapp after config change prompt/.test(msg.text || '')
|
||
));
|
||
assert(/codexapp after config change prompt/.test(codexAppAfterConfigChange.text || ''), 'Codex App should not reject a new turn after config signature changes');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
|
||
const codexAppRetryText = 'codexapp capacity retry prompt';
|
||
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/自动重试/.test(msg.message || '')
|
||
), 10000);
|
||
assert(/Codex 服务暂时繁忙/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient capacity failure should announce automatic retry');
|
||
assert(/第 1\/2 次/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient retry should start at attempt 1');
|
||
assert(/从中断处继续/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App retry after a started turn should announce continuation mode');
|
||
const codexAppPartialCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/自动重试/.test(msg.message || '')
|
||
), 10000);
|
||
assert(/第 2\/2 次/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App transient retry should continue after partial output');
|
||
assert(/从中断处继续/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App partial-output retry should stay in continuation mode');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
|
||
const storedCodexAppAfterCapacityRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const codexAppCapacityRetryUsers = storedCodexAppAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryText);
|
||
assert(codexAppCapacityRetryUsers.length === 1, 'Codex App transient retry should not duplicate the user message');
|
||
assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /codexapp capacity retry prompt/.test(String(message.content || ''))), 'Codex App transient retry should persist the successful assistant response');
|
||
assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App transient retry should ask the model to continue instead of replaying the original prompt');
|
||
|
||
const codexAppReconnectRetryText = 'codexapp reconnect retry prompt';
|
||
ws.send(JSON.stringify({ type: 'message', text: codexAppReconnectRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppReconnectRetryNotice = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/自动重试/.test(msg.message || '')
|
||
), 10000);
|
||
assert(/Codex 服务暂时繁忙/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect failure should announce automatic retry');
|
||
assert(/第 1\/2 次/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App retry counter should reset after the previous retry succeeds');
|
||
assert(/从中断处继续/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect retry after a started turn should announce continuation mode');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
|
||
const storedCodexAppAfterReconnectRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const codexAppReconnectRetryUsers = storedCodexAppAfterReconnectRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppReconnectRetryText);
|
||
assert(codexAppReconnectRetryUsers.length === 1, 'Codex App reconnect retry should not duplicate the user message');
|
||
assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /codexapp reconnect retry prompt/.test(String(message.content || ''))), 'Codex App reconnect retry should persist the successful assistant response');
|
||
assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App reconnect retry should continue the interrupted turn instead of replaying the original prompt');
|
||
|
||
const codexAppThreadBeforeMismatch = storedCodexAppAfterReconnectRetry.codexAppThreadId;
|
||
assert(codexAppThreadBeforeMismatch, 'Codex App retry mismatch regression needs an existing app-server thread');
|
||
const codexAppRetryMismatchText = 'codexapp retry thread mismatch prompt';
|
||
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryMismatchText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppRetryMismatchNotice = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/自动重试/.test(msg.message || '')
|
||
), 10000);
|
||
assert(/Codex 服务暂时繁忙/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should first announce automatic retry');
|
||
assert(/第 1\/2 次/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App retry counter should reset for the next independent retryable turn');
|
||
assert(/从中断处继续/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should also be a continuation retry');
|
||
const codexAppRetryMismatchError = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'error' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/不同线程/.test(msg.message || '') &&
|
||
/上下文丢失/.test(msg.message || '')
|
||
), 20000);
|
||
assert(/已停止/.test(codexAppRetryMismatchError.message || ''), 'Codex App retry should stop when resume returns a different thread');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
|
||
const storedCodexAppAfterRetryMismatch = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
assert(storedCodexAppAfterRetryMismatch.codexAppThreadId === codexAppThreadBeforeMismatch, 'Codex App retry mismatch must not replace the persisted app-server thread id');
|
||
const codexAppRetryMismatchUsers = storedCodexAppAfterRetryMismatch.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryMismatchText);
|
||
assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message');
|
||
assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppGoalSyncing = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /正在同步 Goal/.test(msg.message || ''), 5000);
|
||
assert(/正在同步 Goal/.test(codexAppGoalSyncing.message || ''), 'Codex App /goal should immediately show a syncing notice');
|
||
const codexAppGoalRunningList = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' &&
|
||
Array.isArray(msg.sessions) &&
|
||
msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
|
||
), 5000);
|
||
assert(codexAppGoalRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /goal RPC should mark the session running while waiting for app-server');
|
||
const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
|
||
assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal');
|
||
const codexAppGoalBackgroundDelta = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/Goal background output: improve benchmark coverage/.test(msg.text || '')
|
||
), 5000);
|
||
assert(/Goal background output/.test(codexAppGoalBackgroundDelta.text || ''), 'Codex App /goal background turn should stream through the active session');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 5000);
|
||
const codexAppGoalIdleList = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' &&
|
||
Array.isArray(msg.sessions) &&
|
||
msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning)
|
||
), 5000);
|
||
assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds');
|
||
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
|
||
assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal');
|
||
ws.send(JSON.stringify({ type: 'message', text: '/goal pause', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppGoalPause = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal paused/.test(msg.message || ''));
|
||
assert(/Goal paused/.test(codexAppGoalPause.message || ''), 'Codex App /goal pause should pause the current goal');
|
||
ws.send(JSON.stringify({ type: 'message', text: '/goal resume', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppGoalResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || ''));
|
||
assert(/Goal active/.test(codexAppGoalResume.message || ''), 'Codex App /goal resume should resume the current goal');
|
||
ws.send(JSON.stringify({ type: 'message', text: '/goal clear', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppGoalClear = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal cleared/.test(msg.message || ''));
|
||
assert(/Goal cleared/.test(codexAppGoalClear.message || ''), 'Codex App /goal clear should clear the current goal');
|
||
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppGoalEmpty = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /用法: \/goal <目标描述>/.test(msg.message || ''));
|
||
assert(/\/goal <目标描述>/.test(codexAppGoalEmpty.message || ''), 'Codex App /goal should show usage when no goal exists');
|
||
const storedCodexAppAfterGoal = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Codex App /goal slash commands should not be persisted as normal user messages');
|
||
assert(storedCodexAppAfterGoal.messages.some((message) => (
|
||
message.role === 'assistant' &&
|
||
/Goal background output: improve benchmark coverage/.test(String(message.content || ''))
|
||
)), 'Codex App /goal background turn should be routed and persisted as an assistant message');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/Long threads and multiple compactions/.test(msg.message || '')
|
||
));
|
||
assert(/Long threads and multiple compactions/.test(codexAppRuntimeWarning.message || ''), 'Codex App should surface the first runtime warning');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
await sleep(150);
|
||
const duplicateRuntimeWarnings = messages.filter((msg) => (
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/Long threads and multiple compactions/.test(msg.message || '')
|
||
));
|
||
assert(duplicateRuntimeWarnings.length === 0, 'Codex App should suppress duplicate runtime warning banners');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const hasEmptyReasoningTool = storedCodexAppAfterReasoning.messages
|
||
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
||
.some((tool) => (tool.kind === 'reasoning' || tool.meta?.kind === 'reasoning') && !String(tool.result || '').trim());
|
||
assert(!hasEmptyReasoningTool, 'Codex App should not persist empty reasoning tool calls');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp tool prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||
const codexAppTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-cmd');
|
||
assert(/codexapp/.test(codexAppTool.result || ''), 'Codex App should stream app-server tool results');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
let storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const codexAppThreadId = storedCodexApp.codexAppThreadId;
|
||
assert(codexAppThreadId, 'Codex App thread id should be persisted');
|
||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
|
||
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp huge output prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppHugeTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'huge-tool');
|
||
assert((codexAppHugeTool.result || '').length <= 33000, 'Codex App huge tool result should be capped before sending to the browser');
|
||
assert(/内容过长|huge-output-start/.test(codexAppHugeTool.result || ''), 'Codex App huge tool result should keep a clear truncated preview');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const persistedHugeTool = storedCodexApp.messages
|
||
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
||
.find((tool) => tool.id === 'huge-tool');
|
||
assert(persistedHugeTool, 'Codex App huge tool call should be persisted as a preview');
|
||
assert(String(persistedHugeTool.result || '').length <= 33000, 'Persisted Codex App huge tool result should be capped');
|
||
assert(fs.statSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`)).size < 1024 * 1024, 'Codex App huge output should not inflate session JSON beyond 1MB');
|
||
|
||
const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
|
||
assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id');
|
||
assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload');
|
||
assert(reloadMcpResult.mcpStatus?.server === 'ccweb', 'Codex App MCP reload should return ccweb server startup status');
|
||
assert(reloadMcpResult.mcpStatus?.status === 'ready', 'Codex App MCP reload should surface ready startup status from app-server notification');
|
||
assert(reloadMcpResult.mcpStatus?.hasStartupStatus === true, 'Codex App MCP reload should distinguish real startupStatus from pending fallback');
|
||
const reloadMcpStatusText = JSON.stringify(reloadMcpResult.mcpStatus);
|
||
assert(/CC_WEB_MCP_TOKEN=\[redacted\]/.test(reloadMcpStatusText), 'Codex App MCP reload status should redact token-looking values');
|
||
assert(!reloadMcpStatusText.includes('mock-secret-token'), 'Codex App MCP reload status should not leak raw token-looking values');
|
||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
assert(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.status === 'ready', 'Codex App MCP startup status should be persisted on the session');
|
||
assert(!JSON.stringify(storedCodexApp.codexAppMcpStartupStatus).includes('mock-secret-token'), 'Persisted MCP startup status should not leak raw token-looking values');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
|
||
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
|
||
assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data');
|
||
assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config');
|
||
assert(/"hasProjectMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass project MCP config from session cwd');
|
||
assert(/"ccwebType": "streamable_http"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should default to shared streamable HTTP');
|
||
assert(/"ccwebUrl": "http:\/\/127\.0\.0\.1:\d+\/api\/internal\/mcp\/stream\?/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should point to the shared cc-web HTTP endpoint');
|
||
assert(/"ccwebBearerTokenEnvVar": "CC_WEB_CODEX_APP_MCP_TOKEN"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should use bearer_token_env_var for the shared endpoint');
|
||
assert(!/--ccweb-mcp-server/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should not launch a per-thread stdio bridge by default');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-prompt-user-mcp', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
|
||
const codexAppEmptySlashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-prompt-user-mcp');
|
||
const emptySlashPromptUserIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
|
||
const firstOtherCcwebMcpIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name !== 'ccweb_prompt_user');
|
||
assert(emptySlashPromptUserIndex >= 0, 'Codex App empty slash composer should include ccweb_prompt_user');
|
||
assert(firstOtherCcwebMcpIndex < 0 || emptySlashPromptUserIndex < firstOtherCcwebMcpIndex, 'ccweb_prompt_user should be pinned before other ccweb MCP tools');
|
||
|
||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
|
||
const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp');
|
||
const promptUserComposerItem = codexAppPromptUserMcpComposer.items.find((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
|
||
assert(promptUserComposerItem, 'Codex App composer should show ccweb_prompt_user when ccweb MCP is runtime-configured');
|
||
assert(promptUserComposerItem.itemType === 'tool', 'ccweb_prompt_user composer item should be a normal MCP tool suggestion');
|
||
assert(!promptUserComposerItem.action, 'ccweb_prompt_user composer item should not declare a form action');
|
||
assert(promptUserComposerItem.insertion === 'mcp:ccweb/ccweb_prompt_user', 'ccweb_prompt_user composer item should insert the MCP mention text');
|
||
assert(promptUserComposerItem.appendSpace === true, 'ccweb_prompt_user composer item should append a space like other MCP suggestions');
|
||
|
||
const promptUserResult = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_prompt_user',
|
||
sourceSessionId: codexAppSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
title: '确认实现方案',
|
||
description: '回归测试多问题表单',
|
||
questions: [
|
||
{
|
||
id: 'ui_choice',
|
||
title: '交互方式',
|
||
question: '用哪种交互方式?',
|
||
required: true,
|
||
options: [
|
||
{ id: 'fineui', label: 'FineUI 弹窗', recommended: true, answerText: '使用 FineUI 弹窗。' },
|
||
{ id: 'prompt', label: '浏览器 prompt', answerText: '使用浏览器 prompt。' },
|
||
],
|
||
answerPlaceholder: '填写方案',
|
||
},
|
||
{
|
||
id: 'button_id',
|
||
title: '按钮 ID',
|
||
question: '确认按钮 ID 是什么?',
|
||
required: true,
|
||
options: [
|
||
{ id: 'confirm', label: 'btnShortageReleaseConfirm', recommended: true, answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
assert(promptUserResult.status === 200 && promptUserResult.body?.ok, `MCP prompt user should render: ${JSON.stringify(promptUserResult.body)}`);
|
||
assert(promptUserResult.body.status === 'rendered', 'MCP prompt user should return rendered status without waiting for user input');
|
||
assert(promptUserResult.body.questionCount === 2, 'MCP prompt user should preserve multiple questions');
|
||
const promptRendered = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.message?.ccwebPrompt?.id === promptUserResult.body.promptId
|
||
));
|
||
assert(promptRendered.message.ccwebPrompt.status === 'pending', 'Prompt message should start pending');
|
||
assert(promptRendered.message.ccwebPrompt.questions?.length === 2, 'Prompt message should carry all questions to the UI');
|
||
assert(promptRendered.message.ccwebPrompt.questions[0]?.options?.some((option) => option.id === 'fineui' && option.recommended === true), 'Prompt message should preserve recommended options');
|
||
|
||
const promptDismissResult = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_prompt_user',
|
||
sourceSessionId: codexAppSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
title: '可忽略表单',
|
||
questions: [
|
||
{
|
||
id: 'dismiss_choice',
|
||
title: '是否忽略',
|
||
question: '这个表单会被忽略删除。',
|
||
required: false,
|
||
options: [
|
||
{ id: 'skip', label: '忽略', answerText: '忽略这个表单。' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
assert(promptDismissResult.status === 200 && promptDismissResult.body?.ok, 'Dismissable MCP prompt should render');
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.message?.ccwebPrompt?.id === promptDismissResult.body.promptId
|
||
));
|
||
ws.send(JSON.stringify({
|
||
type: 'ccweb_prompt_user_dismiss',
|
||
sessionId: codexAppSession.sessionId,
|
||
promptId: promptDismissResult.body.promptId,
|
||
}));
|
||
const promptDismissed = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'ccweb_prompt_user_remove' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.promptId === promptDismissResult.body.promptId
|
||
));
|
||
assert(promptDismissed.reason === 'dismissed', 'Dismissed prompt remove event should carry dismissed reason');
|
||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
assert(!storedCodexApp.messages.some((message) => message.ccwebPrompt?.id === promptDismissResult.body.promptId), 'Dismissed prompt message should be removed from session history');
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'ccweb_prompt_user_response',
|
||
sessionId: codexAppSession.sessionId,
|
||
promptId: promptUserResult.body.promptId,
|
||
answers: {
|
||
ui_choice: {
|
||
selectedOptionIds: ['fineui'],
|
||
answerText: '使用 FineUI 弹窗,方便固定按钮 ID。',
|
||
},
|
||
button_id: {
|
||
selectedOptionIds: ['confirm'],
|
||
answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。',
|
||
},
|
||
},
|
||
}));
|
||
const promptSubmitted = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'ccweb_prompt_user_remove' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.promptId === promptUserResult.body.promptId
|
||
));
|
||
assert(promptSubmitted.prompt?.status === 'submitted', 'Prompt remove event should carry submitted status');
|
||
assert(promptSubmitted.prompt?.answers?.ui_choice?.selectedOptionLabels?.[0] === 'FineUI 弹窗', 'Prompt remove event should include selected option labels');
|
||
const promptAnswerMessage = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.message?.role === 'user' &&
|
||
/表单答案/.test(msg.message.content || '') &&
|
||
/btnShortageReleaseConfirm/.test(msg.message.content || '')
|
||
));
|
||
assert(/使用 FineUI 弹窗,方便固定按钮 ID。/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should become a normal user message with the free-form answer');
|
||
assert(!/我已回答 ccweb 提示的问题/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should not use the old first-person hard-coded prefix');
|
||
assert(!/ccweb 提示表单答案/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should not expose the internal ccweb product name in the user message prefix');
|
||
const promptAnswerDelta = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/btnShortageReleaseConfirm/.test(msg.text || '')
|
||
));
|
||
assert(/表单答案/.test(promptAnswerDelta.text || ''), 'Prompt submission should trigger a Codex App turn with the answer text');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const storedPromptMessage = storedCodexApp.messages.find((message) => message.ccwebPrompt?.id === promptUserResult.body.promptId);
|
||
assert(!storedPromptMessage, 'Submitted prompt message should be removed from session history');
|
||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && /btnShortageReleaseConfirm/.test(String(message.content || ''))), 'Prompt response user message should persist in session history');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'child-thread-a' &&
|
||
msg.child?.status === 'running'
|
||
);
|
||
assert(ccwebMcpChildRunning.toolUseId === 'tool-collab', 'ccweb MCP child update should reference the parent collab tool');
|
||
const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab');
|
||
assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls');
|
||
assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'ccweb MCP collab tool should include child thread ids');
|
||
const ccwebMcpChildReturned = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'child-thread-a' &&
|
||
msg.child?.status === 'returned' &&
|
||
/子代理最终消息/.test(msg.child?.candidateResult || '')
|
||
);
|
||
assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' }));
|
||
const ccwebMcpChildInterrupted = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'child-thread-a' &&
|
||
msg.child?.status === 'interrupted'
|
||
);
|
||
assert(/"status": "interrupted"/.test(ccwebMcpChildInterrupted.tool?.result || ''), 'ccweb MCP child interrupt should update the parent collab tool state');
|
||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const hasCollabTool = storedCodexApp.messages
|
||
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
||
.some((tool) => tool.kind === 'collab_agent_tool_call');
|
||
assert(hasCollabTool, 'ccweb MCP collab tool should be persisted into session history');
|
||
const persistedClosedCollabTool = storedCodexApp.messages
|
||
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
||
.reverse()
|
||
.find((tool) => tool.id === 'tool-collab');
|
||
assert(/"status": "interrupted"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child interrupt should persist interrupted state');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent v2 prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
const codexAppV2ChildStarted = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'child-thread-v2' &&
|
||
msg.child?.status === 'running'
|
||
);
|
||
assert(codexAppV2ChildStarted.toolUseId === 'child-activity-v2', 'V2 child update should reference its subAgentActivity item');
|
||
assert(codexAppV2ChildStarted.child.agentPath === '/root/v2_parent', 'V2 child update should preserve its canonical agentPath');
|
||
const codexAppV2ChildPlan = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'child-thread-v2' &&
|
||
msg.child?.planProgress?.completed === 1 &&
|
||
msg.child?.planProgress?.total === 3
|
||
);
|
||
assert(codexAppV2ChildPlan.child.planCurrentStep === '同步父卡片进度', 'V2 child update should expose the current in-progress plan step');
|
||
const codexAppV2ChildPlanToolResult = JSON.parse(codexAppV2ChildPlan.tool?.result || '{}');
|
||
assert(
|
||
codexAppV2ChildPlanToolResult.agentsStates?.['child-thread-v2']?.planProgress?.completed === 1,
|
||
'V2 child plan progress should merge into the visible parent collaboration tool'
|
||
);
|
||
const codexAppV2GrandchildStarted = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'grandchild-thread-v2' &&
|
||
msg.child?.status === 'running'
|
||
);
|
||
assert(codexAppV2GrandchildStarted.child.parentThreadId === 'child-thread-v2', 'Nested V2 child should retain the immediate child as parentThreadId');
|
||
assert(codexAppV2GrandchildStarted.child.agentPath === '/root/v2_parent/v2_grandchild', 'Nested V2 child should preserve its canonical agentPath');
|
||
const codexAppV2GrandchildReturned = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'grandchild-thread-v2' &&
|
||
msg.child?.status === 'returned' &&
|
||
/V2 孙代理最终消息/.test(msg.child?.candidateResult || '')
|
||
);
|
||
assert(/grandchild-thread-v2/.test(codexAppV2GrandchildReturned.tool?.result || ''), 'Nested V2 child result should merge into a visible collaboration tool');
|
||
const codexAppV2ChildReturned = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'ccweb_mcp_child_agent_update' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.child?.threadId === 'child-thread-v2' &&
|
||
msg.child?.status === 'returned' &&
|
||
/V2 子代理最终消息/.test(msg.child?.candidateResult || '')
|
||
);
|
||
assert(codexAppV2ChildReturned.child.parentThreadId, 'V2 child should retain the root parent thread id');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
const persistedV2ChildTool = storedCodexApp.messages
|
||
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
|
||
.find((tool) => tool.id === 'child-activity-v2');
|
||
const persistedV2ChildResult = JSON.parse(persistedV2ChildTool?.result || '{}');
|
||
assert(
|
||
persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planProgress?.completed === 1
|
||
&& persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planProgress?.total === 3,
|
||
'V2 child plan progress should persist in the session collaboration tool for refresh recovery'
|
||
);
|
||
assert(
|
||
persistedV2ChildResult.agentsStates?.['child-thread-v2']?.planCurrentStep === '同步父卡片进度',
|
||
'V2 child current plan step should persist for refresh recovery'
|
||
);
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
|
||
const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
|
||
assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode');
|
||
assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions');
|
||
assert(/"hasSchemaDrivenSubagents":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep runtime-schema-driven guidance');
|
||
assert(/"hasLegacyV1Guidance":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should omit legacy V1 guidance');
|
||
assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level');
|
||
assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp guided prompt', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
|
||
const guidedRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_user_input_request' && msg.sessionId === codexAppSession.sessionId);
|
||
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
|
||
ws.send(JSON.stringify({
|
||
type: 'codex_app_user_input_response',
|
||
action: 'submit',
|
||
sessionId: codexAppSession.sessionId,
|
||
requestId: guidedRequest.requestId,
|
||
answers: { choice: { answers: ['A'] } },
|
||
}));
|
||
const guidedSubmitted = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/已提交.*引导输入/.test(msg.message || '')
|
||
);
|
||
assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint');
|
||
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
|
||
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
|
||
const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId);
|
||
assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests');
|
||
assert(approvalRequest.itemId === 'approval-command-call', 'Codex App approval request should keep item id');
|
||
assert(/echo approved/.test(JSON.stringify(approvalRequest.payload || {})), 'Codex App approval request should include command payload');
|
||
ws.send(JSON.stringify({
|
||
type: 'codex_app_approval_response',
|
||
action: 'approve_session',
|
||
sessionId: codexAppSession.sessionId,
|
||
requestId: approvalRequest.requestId,
|
||
}));
|
||
const approvalSubmitted = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/本会话执行/.test(msg.message || '')
|
||
);
|
||
assert(/本会话执行/.test(approvalSubmitted.message || ''), 'Codex App should show approval confirmation hint');
|
||
const approvalDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /approval decision: acceptForSession/.test(msg.text || ''));
|
||
assert(/approval decision: acceptForSession/.test(approvalDelta.text || ''), 'Codex App should continue after approval response');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||
await sleep(500);
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: '/runtime/report',
|
||
sessionId: codexAppSession.sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
clientMessageId: 'regression-unknown-slash-steer',
|
||
}));
|
||
const runningUnknownSlashHint = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/未知指令: \/runtime\/report/.test(msg.message || '')
|
||
));
|
||
assert(!runningUnknownSlashHint.preserveComposerDraft, 'Running Codex App unknown slash hints should not restore a message that continues through steer');
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.clientMessageId === 'regression-unknown-slash-steer' &&
|
||
msg.status === 'pending'
|
||
));
|
||
const runningUnknownSlashDelta = await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'text_delta' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/steer accepted: \/runtime\/report/.test(msg.text || '')
|
||
));
|
||
assert(/\/runtime\/report/.test(runningUnknownSlashDelta.text || ''), 'Running Codex App unknown slash text should continue through turn/steer');
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.clientMessageId === 'regression-unknown-slash-steer' &&
|
||
msg.status === 'inserted'
|
||
));
|
||
ws.send(JSON.stringify({
|
||
type: 'message',
|
||
text: 'runtime steer insert',
|
||
sessionId: codexAppSession.sessionId,
|
||
mode: 'yolo',
|
||
agent: 'codexapp',
|
||
clientMessageId: 'regression-steer-message',
|
||
}));
|
||
const steerPending = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.clientMessageId === 'regression-steer-message' &&
|
||
msg.status === 'pending'
|
||
);
|
||
assert(/引导中/.test(steerPending.message || ''), 'Codex App steer should expose pending status');
|
||
const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || ''));
|
||
assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer');
|
||
const steerInserted = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'codex_app_steer_status' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
msg.clientMessageId === 'regression-steer-message' &&
|
||
msg.status === 'inserted'
|
||
);
|
||
assert(/已插入/.test(steerInserted.message || ''), 'Codex App steer should expose inserted status');
|
||
const steerSystemMessage = await nextMessage(messages, ws, (msg) =>
|
||
msg.type === 'system_message' &&
|
||
msg.sessionId === codexAppSession.sessionId &&
|
||
/已引导对话: runtime steer insert/.test(msg.message || '')
|
||
);
|
||
assert(steerSystemMessage.transient === true, 'Codex App steer marker should be transient');
|
||
assert(/已引导对话: runtime steer insert/.test(steerSystemMessage.message || ''), 'Codex App steer should show guided conversation marker with preview');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||
assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
|
||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === '/runtime/report'), 'Running Codex App unknown slash text should persist as ordinary user history');
|
||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
|
||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp abort prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||
const codexAppRunningMcp = await callInternalMcp(port, internalMcpToken, {
|
||
tool: 'ccweb_send_message',
|
||
sourceSessionId: codexSession.sessionId,
|
||
sourceHopCount: 0,
|
||
args: {
|
||
targetConversationId: codexAppSession.sessionId,
|
||
content: 'running codexapp target should reject this',
|
||
},
|
||
});
|
||
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');
|
||
await sleep(150);
|
||
ws.send(JSON.stringify({ type: 'detach_view' }));
|
||
await sleep(50);
|
||
ws.send(JSON.stringify({ type: 'abort', sessionId: codexAppSession.sessionId }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||
|
||
const tinyPng = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||
'base64'
|
||
);
|
||
const claudeAttachments = await Promise.all(Array.from({ length: 5 }, (_, index) => uploadAttachment(port, token, {
|
||
filename: `claude-test-${index + 1}.png`,
|
||
mime: 'image/png',
|
||
data: tinyPng,
|
||
})));
|
||
ws.send(JSON.stringify({ type: 'message', text: 'describe attachments', attachments: claudeAttachments, mode: 'yolo', agent: 'claude' }));
|
||
const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachments');
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
|
||
const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
|
||
.trim()
|
||
.split('\n')
|
||
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
|
||
assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
|
||
const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
|
||
const storedClaudeUserMessage = storedClaudeSession.messages?.find((message) => message.role === 'user' && message.content === 'describe attachments');
|
||
assert(
|
||
Array.isArray(storedClaudeUserMessage?.attachments) && storedClaudeUserMessage.attachments.length === claudeAttachments.length,
|
||
'Claude message should persist all attachment metadata'
|
||
);
|
||
const storedClaudeAttachmentNames = storedClaudeUserMessage.attachments.map((attachment) => attachment.filename);
|
||
for (const attachment of claudeAttachments) {
|
||
assert(storedClaudeAttachmentNames.includes(attachment.filename), `Claude message should preserve attachment ${attachment.filename}`);
|
||
}
|
||
assert(storedClaudeSession.claudeSessionId, 'Claude session id should be persisted after first run');
|
||
const claudeSessionIdBeforeMode = storedClaudeSession.claudeSessionId;
|
||
|
||
// Mode switching must not clear Claude runtime session id (resume should keep context).
|
||
ws.send(JSON.stringify({ type: 'set_mode', sessionId: claudeImageSession.sessionId, mode: 'plan' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan');
|
||
const storedClaudeAfterMode = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
|
||
assert(storedClaudeAfterMode.claudeSessionId === claudeSessionIdBeforeMode, 'Claude session id should survive mode switch');
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'second claude prompt', sessionId: claudeImageSession.sessionId, mode: 'plan', agent: 'claude' }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
|
||
const claudeSpawns = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
|
||
.trim()
|
||
.split('\n')
|
||
.filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
|
||
const lastClaudeSpawn = claudeSpawns[claudeSpawns.length - 1] || '';
|
||
assert(lastClaudeSpawn.includes(`--resume ${claudeSessionIdBeforeMode}`), 'Claude mode switch should keep --resume session id');
|
||
assert(lastClaudeSpawn.includes('--permission-mode plan'), 'Claude plan mode should set --permission-mode plan');
|
||
|
||
ws.send(JSON.stringify({ type: 'list_native_sessions' }));
|
||
const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions');
|
||
assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed');
|
||
const firstClaude = nativeSessions.groups[0].sessions[0];
|
||
ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir }));
|
||
const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt');
|
||
assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message');
|
||
|
||
ws.send(JSON.stringify({ type: 'list_codex_sessions' }));
|
||
const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
|
||
const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId);
|
||
assert(importedCodexItem, 'Codex session listing failed');
|
||
const codexSubagentItem = codexSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId);
|
||
assert(!codexSubagentItem, 'Codex import list should hide subagent rollout threads');
|
||
|
||
ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath }));
|
||
const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt');
|
||
assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions');
|
||
assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed');
|
||
|
||
ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
|
||
const codexAppImportSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
|
||
const codexAppImportItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
|
||
assert(codexAppImportItem, 'Codex App session listing failed');
|
||
assert(codexAppImportItem.agent === 'codexapp', 'Codex App import listing should echo target agent');
|
||
assert(codexAppImportItem.alreadyImported === false, 'Codex App import should not reuse old Codex imported state');
|
||
const duplicateSourceItems = codexAppImportSessions.sessions.filter((item) => item.sourceConversationId === duplicateSourceConversationId);
|
||
assert(duplicateSourceItems.length === 1, 'Codex App import list should collapse rollout entries from the same cc-web source conversation');
|
||
assert(duplicateSourceItems[0].duplicateCount === 2, 'Collapsed Codex App import item should report duplicate rollout count');
|
||
const objectSourceItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId);
|
||
assert(!objectSourceItem, 'Codex App import list should hide subagent rollout threads');
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'import_codex_session',
|
||
agent: 'codexapp',
|
||
threadId: codexAppImportItem.threadId,
|
||
rolloutPath: codexAppImportItem.rolloutPath,
|
||
}));
|
||
const importedCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt');
|
||
assert(importedCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App import parsed wrong first message');
|
||
assert(importedCodexApp.totalUsage?.inputTokens === 20, 'Codex App import usage parse failed');
|
||
const importedCodexAppPath = path.join(sessionsDir, `${importedCodexApp.sessionId}.json`);
|
||
const storedImportedCodexApp = JSON.parse(fs.readFileSync(importedCodexAppPath, 'utf8'));
|
||
assert(storedImportedCodexApp.agent === 'codexapp', 'Codex App import should persist codexapp agent');
|
||
assert(storedImportedCodexApp.codexAppThreadId === codexAppImportFixture.threadId, 'Codex App import should persist codexAppThreadId');
|
||
assert(!storedImportedCodexApp.codexThreadId, 'Codex App import should not persist legacy codexThreadId');
|
||
|
||
ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
|
||
const codexAppImportSessionsAfter = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
|
||
const codexAppImportItemAfter = codexAppImportSessionsAfter.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
|
||
assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported');
|
||
|
||
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId }));
|
||
await nextMessage(messages, ws, (msg) => (
|
||
msg.type === 'session_list' &&
|
||
!msg.sessions.some((s) => s.id === importedCodexApp.sessionId) &&
|
||
!fs.existsSync(importedCodexAppPath)
|
||
));
|
||
assert(!fs.existsSync(importedCodexAppPath), 'Deleting Codex App imported session did not remove cc-web session JSON');
|
||
assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery');
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'import_codex_session',
|
||
agent: 'codexapp',
|
||
threadId: codexAppImportFixture.threadId,
|
||
rolloutPath: codexAppImportFixture.rolloutPath,
|
||
}));
|
||
const restoredCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt');
|
||
assert(restoredCodexApp.sessionId !== importedCodexApp.sessionId, 'Codex App deleted session should be recreated from rollout history');
|
||
assert(restoredCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App re-import should restore messages after cc-web deletion');
|
||
|
||
const importedSessionId = importedCodex.sessionId;
|
||
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId }));
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId));
|
||
|
||
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
|
||
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
|
||
if (codexFixture.stateDb) {
|
||
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
|
||
}
|
||
|
||
ws.close();
|
||
console.log('Regression checks passed.');
|
||
});
|
||
|
||
const recoveryPort = await getFreePort();
|
||
const recoveryEnv = {
|
||
PORT: String(recoveryPort),
|
||
CC_WEB_PASSWORD: password,
|
||
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
|
||
CC_WEB_CONFIG_DIR: configDir,
|
||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||
CC_WEB_LOGS_DIR: logsDir,
|
||
HOME: homeDir,
|
||
CLAUDE_PATH: MOCK_CLAUDE,
|
||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||
};
|
||
|
||
const recoveryServer = await startServer(recoveryEnv);
|
||
let recoverySessionId = null;
|
||
let recoveryStatePath = null;
|
||
try {
|
||
const { ws, messages } = await connectWs(recoveryPort, password);
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||
const recoverCwd = path.join(tempRoot, 'codexapp-recover-space');
|
||
mkdirp(recoverCwd);
|
||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: recoverCwd, mode: 'yolo' }));
|
||
const recoverSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === recoverCwd);
|
||
recoverySessionId = recoverSession.sessionId;
|
||
|
||
ws.send(JSON.stringify({ type: 'message', text: 'slow recover codexapp prompt', sessionId: recoverySessionId, mode: 'yolo', agent: 'codexapp' }));
|
||
recoveryStatePath = path.join(sessionsDir, `${recoverySessionId}-run`, 'codexapp-state.json');
|
||
await waitForFile(recoveryStatePath);
|
||
let recoverStateStarted = null;
|
||
const stateStartedAt = Date.now();
|
||
while (Date.now() - stateStartedAt < 5000) {
|
||
recoverStateStarted = JSON.parse(fs.readFileSync(recoveryStatePath, 'utf8'));
|
||
if (/partial before restart/.test(recoverStateStarted.fullText || '')
|
||
&& recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || ''))) {
|
||
break;
|
||
}
|
||
await sleep(50);
|
||
}
|
||
assert(/partial before restart/.test(recoverStateStarted?.fullText || ''), 'Codex App running state should persist partial text before completion');
|
||
assert(recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Codex App running state should persist partial tool output');
|
||
ws.close();
|
||
} finally {
|
||
await recoveryServer.stop('SIGKILL');
|
||
}
|
||
|
||
assert(recoverySessionId, 'Codex App recovery test did not create a session');
|
||
assert(recoveryStatePath && fs.existsSync(recoveryStatePath), 'Codex App recovery state should survive server crash');
|
||
|
||
const restartedRecoveryServer = await startServer(recoveryEnv);
|
||
try {
|
||
const { ws, messages } = await connectWs(recoveryPort, password);
|
||
const recoveredList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === recoverySessionId));
|
||
const recoveredMeta = recoveredList.sessions.find((session) => session.id === recoverySessionId);
|
||
assert(recoveredMeta && !recoveredMeta.isRunning, 'Recovered Codex App partial turn should not stay marked running');
|
||
assert(!fs.existsSync(recoveryStatePath), 'Recovered Codex App state file should be cleaned after startup recovery');
|
||
|
||
ws.send(JSON.stringify({ type: 'load_session', sessionId: recoverySessionId }));
|
||
const recoveredSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === recoverySessionId);
|
||
const recoveredAssistantMessages = (recoveredSessionInfo.messages || []).filter((message) => message.role === 'assistant' && /partial before restart/.test(String(message.content || '')));
|
||
assert(recoveredAssistantMessages.length === 1, 'Recovered Codex App partial assistant output should be persisted exactly once');
|
||
assert(recoveredAssistantMessages[0].codexAppRecoveredPartial === true, 'Recovered Codex App assistant output should be marked partial');
|
||
assert(recoveredAssistantMessages[0].toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Recovered Codex App assistant output should keep tool calls');
|
||
|
||
ws.close();
|
||
} finally {
|
||
await restartedRecoveryServer.stop();
|
||
}
|
||
|
||
const oversizedRecoverySessionId = 'oversized-recovery-session';
|
||
const oversizedRecoveryStateDir = path.join(sessionsDir, `${oversizedRecoverySessionId}-run`);
|
||
const oversizedRecoveryStatePath = path.join(oversizedRecoveryStateDir, 'codexapp-state.json');
|
||
fs.writeFileSync(path.join(sessionsDir, `${oversizedRecoverySessionId}.json`), JSON.stringify({
|
||
id: oversizedRecoverySessionId,
|
||
title: 'Oversized Recovery',
|
||
created: new Date().toISOString(),
|
||
updated: new Date().toISOString(),
|
||
pinnedAt: null,
|
||
agent: 'codexapp',
|
||
claudeSessionId: null,
|
||
codexThreadId: null,
|
||
codexAppThreadId: 'oversized-thread',
|
||
model: 'gpt-5.5(xhigh)',
|
||
permissionMode: 'yolo',
|
||
totalCost: 0,
|
||
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||
messages: [],
|
||
cwd: homeDir,
|
||
}, null, 2));
|
||
mkdirp(oversizedRecoveryStateDir);
|
||
fs.writeFileSync(oversizedRecoveryStatePath, JSON.stringify({
|
||
version: 1,
|
||
agent: 'codexapp',
|
||
sessionId: oversizedRecoverySessionId,
|
||
threadId: 'oversized-thread',
|
||
turnId: 'oversized-turn',
|
||
turnStatus: 'running',
|
||
fullText: 'x'.repeat(5 * 1024 * 1024),
|
||
toolCalls: [],
|
||
}));
|
||
assert(fs.statSync(oversizedRecoveryStatePath).size > 4 * 1024 * 1024, 'Oversized recovery fixture should exceed the state load guard');
|
||
|
||
const oversizedRecoveryServer = await startServer(recoveryEnv);
|
||
try {
|
||
const { ws, messages } = await connectWs(recoveryPort, password);
|
||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === oversizedRecoverySessionId));
|
||
assert(!fs.existsSync(oversizedRecoveryStateDir), 'Oversized Codex App recovery state directory should be cleaned without parsing the state');
|
||
ws.send(JSON.stringify({ type: 'load_session', sessionId: oversizedRecoverySessionId }));
|
||
const oversizedSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === oversizedRecoverySessionId);
|
||
assert((oversizedSessionInfo.messages || []).some((message) => (
|
||
message.role === 'system' &&
|
||
/状态文件异常/.test(String(message.content || '')) &&
|
||
/跳过恢复/.test(String(message.content || ''))
|
||
)), 'Oversized Codex App recovery should add a system notice');
|
||
ws.close();
|
||
} finally {
|
||
await oversizedRecoveryServer.stop();
|
||
}
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error(err.stack || err.message);
|
||
process.exit(1);
|
||
});
|