#!/usr/bin/env node

const fs = require('fs');
const os = require('os');
const path = require('path');
const net = require('net');
const { spawn, spawnSync } = require('child_process');
const WebSocket = require('ws');

const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;

function mkdirp(dir) {
  fs.mkdirSync(dir, { recursive: true });
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function getFreePort() {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.on('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const addr = server.address();
      const port = addr && typeof addr === 'object' ? addr.port : null;
      server.close(() => resolve(port));
    });
  });
}

function assert(condition, message) {
  if (!condition) {
    throw new Error(message);
  }
}

function sql(dbPath, statement) {
  if (!HAS_SQLITE3) throw new Error('sqlite3 is not available');
  const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
  if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
  return result.stdout.trim();
}

async function waitForPort(port, timeoutMs = 10000) {
  const started = Date.now();
  while (Date.now() - started < timeoutMs) {
    const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' });
    if (probe.status === 0) return;
    await sleep(100);
  }
  throw new Error(`Timed out waiting for port ${port}`);
}

async function waitForFile(filePath, timeoutMs = 10000) {
  const started = Date.now();
  while (Date.now() - started < timeoutMs) {
    if (fs.existsSync(filePath)) return;
    await sleep(50);
  }
  throw new Error(`Timed out waiting for file: ${filePath}`);
}

async function waitForJsonCondition(filePath, predicate, timeoutMs = 5000) {
  const started = Date.now();
  let lastError = null;
  while (Date.now() - started < timeoutMs) {
    try {
      if (fs.existsSync(filePath)) {
        const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
        if (predicate(parsed)) return parsed;
      }
    } catch (err) {
      lastError = err;
    }
    await sleep(50);
  }
  throw new Error(`Timed out waiting for JSON condition: ${filePath}${lastError ? ` (${lastError.message})` : ''}`);
}

async function withServer(env, fn) {
  const child = spawn('/usr/bin/node', [SERVER_PATH], {
    cwd: REPO_DIR,
    env: { ...process.env, ...env },
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  let stdout = '';
  let stderr = '';
  child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
  child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });

  try {
    await waitForPort(env.PORT, 10000);
    await fn({ child, stdout: () => stdout, stderr: () => stderr });
  } finally {
    child.kill('SIGTERM');
    await sleep(300);
    if (!child.killed) child.kill('SIGKILL');
  }
}

async function startServer(env) {
  const child = spawn('/usr/bin/node', [SERVER_PATH], {
    cwd: REPO_DIR,
    env: { ...process.env, ...env },
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  let stdout = '';
  let stderr = '';
  child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
  child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
  await waitForPort(env.PORT, 10000);
  return {
    child,
    stdout: () => stdout,
    stderr: () => stderr,
    async stop(signal = 'SIGTERM') {
      if (child.exitCode !== null || child.signalCode) return;
      child.kill(signal);
      await sleep(300);
      if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
      await sleep(200);
    },
  };
}

function connectWs(port, password) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
    const messages = [];
    let settled = false;

    ws.on('open', () => {
      ws.send(JSON.stringify({ type: 'auth', password }));
    });
    ws.on('message', (buf) => {
      const msg = JSON.parse(String(buf));
      messages.push(msg);
      if (msg.type === 'auth_result' && msg.success) {
        settled = true;
        resolve({ ws, messages, token: msg.token });
      }
      if (msg.type === 'auth_result' && !msg.success) {
        settled = true;
        reject(new Error('Auth failed'));
      }
    });
    ws.on('error', (err) => {
      if (settled) return;
      settled = true;
      reject(err);
    });
  });
}

function assertWsUpgradeRejected(port, pathname) {
  return new Promise((resolve, reject) => {
    let settled = false;
    const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`);
    const timer = setTimeout(() => finish(reject, new Error(`WebSocket upgrade was not rejected for ${pathname}`)), 5000);

    function finish(done, value) {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
        try { ws.terminate(); } catch {}
      }
      done(value);
    }

    ws.on('open', () => {
      finish(reject, new Error(`Unexpected WebSocket connection opened for ${pathname}`));
    });
    ws.on('unexpected-response', (req, res) => {
      res.resume();
      if (res.statusCode === 404) {
        finish(resolve);
        return;
      }
      finish(reject, new Error(`Expected 404 for ${pathname}, got ${res.statusCode}`));
    });
    ws.on('error', (err) => {
      if (/Unexpected server response: 404/.test(err.message || '')) {
        finish(resolve);
        return;
      }
      finish(reject, err);
    });
  });
}

async function uploadAttachment(port, token, { filename, mime, data }) {
  const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': mime,
      'X-Filename': encodeURIComponent(filename),
    },
    body: data,
  });
  const payload = await response.json();
  assert(response.ok && payload.ok, `Attachment upload failed: ${payload.message || response.status}`);
  return payload.attachment;
}

async function fetchAuthedJson(port, token, pathname) {
  const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });
  const payload = await response.json();
  assert(response.ok && payload.ok, `Request failed for ${pathname}: ${payload.message || response.status}`);
  return payload;
}

async function postAuthedJson(port, token, pathname, body = {}) {
  const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });
  const payload = await response.json();
  assert(response.ok && payload.ok, `POST failed for ${pathname}: ${payload.message || response.status}`);
  return payload;
}

async function callInternalMcp(port, token, payload) {
  const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CC-Web-MCP-Token': token,
    },
    body: JSON.stringify(payload),
  });
  let body = null;
  try {
    body = await response.json();
  } catch {}
  return { status: response.status, body };
}

function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
  const callSite = (() => {
    const stack = String(new Error().stack || '').split('\n');
    return (stack[3] || stack[2] || '').trim();
  })();
  return new Promise((resolve, reject) => {
    const started = Date.now();
    const timer = setInterval(() => {
      const idx = messages.findIndex(predicate);
      if (idx !== -1) {
        clearInterval(timer);
        const found = messages.splice(idx, 1)[0];
        resolve(found);
        return;
      }
      if (Date.now() - started > timeoutMs) {
        clearInterval(timer);
        const recentTypes = messages.slice(-12).map((m) => m?.type).join(', ');
        const pendingTypes = messages.slice(0, 12).map((m) => m?.type).join(', ');
        const recentDetails = messages.slice(-6).map((m) => JSON.stringify({
          type: m?.type,
          sessionId: m?.sessionId,
          status: m?.status,
          clientMessageId: m?.clientMessageId,
          code: m?.code,
          text: typeof m?.text === 'string' ? m.text.slice(0, 120) : undefined,
          message: typeof m?.message === 'string' ? m.message.slice(0, 120) : undefined,
        })).join(' | ');
        reject(new Error(`Timed out waiting for expected WebSocket message (wsState=${ws.readyState}, callSite=${callSite}, pendingTypes=[${pendingTypes}], recentTypes=[${recentTypes}], recentDetails=[${recentDetails}])`));
      }
    }, 50);
  });
}

function isSessionCompletionMessage(msg, sessionId) {
  return (msg?.type === 'done' || msg?.type === 'background_done') && msg.sessionId === sessionId;
}

function createFakeClaudeHistory(homeDir) {
  const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
  mkdirp(projectDir);
  const sessionId = 'claude-import-test';
  const filePath = path.join(projectDir, `${sessionId}.jsonl`);
  const lines = [
    JSON.stringify({
      type: 'user',
      cwd: '/tmp/project-a',
      timestamp: '2026-03-12T00:00:00.000Z',
      message: { content: 'Claude import prompt' },
    }),
    JSON.stringify({
      type: 'assistant',
      timestamp: '2026-03-12T00:00:02.000Z',
      message: { content: [{ type: 'text', text: 'Claude import answer' }] },
    }),
  ];
  fs.writeFileSync(filePath, `${lines.join('\n')}\n`);
  return { sessionId, projectDir: 'tmp-project', filePath };
}

function createFakeCodexHistory(homeDir, options = {}) {
  const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12');
  mkdirp(sessionsDir);
  const threadId = options.threadId || 'codex-import-thread';
  const cwd = options.cwd || '/tmp/project-b';
  const userText = options.userText || 'Codex import prompt';
  const answerText = options.answerText || 'Codex import answer';
  const source = options.source || 'exec';
  const cliVersion = options.cliVersion || '0.114.0';
  const fileStamp = options.fileStamp || '2026-03-12T00-00-00';
  const rolloutPath = path.join(sessionsDir, `rollout-${fileStamp}-${threadId}.jsonl`);
  const rolloutLines = [
    JSON.stringify({
      timestamp: '2026-03-12T00:00:00.000Z',
      type: 'session_meta',
      payload: { id: threadId, cwd, cli_version: cliVersion, source },
    }),
    JSON.stringify({
      timestamp: '2026-03-12T00:00:00.100Z',
      type: 'response_item',
      payload: {
        type: 'message',
        role: 'user',
        content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }],
      },
    }),
    JSON.stringify({
      timestamp: '2026-03-12T00:00:01.000Z',
      type: 'event_msg',
      payload: { type: 'user_message', message: userText },
    }),
    JSON.stringify({
      timestamp: '2026-03-12T00:00:02.000Z',
      type: 'response_item',
      payload: {
        type: 'message',
        role: 'assistant',
        content: [{ type: 'output_text', text: answerText }],
      },
    }),
    JSON.stringify({
      timestamp: '2026-03-12T00:00:03.000Z',
      type: 'event_msg',
      payload: {
        type: 'token_count',
        info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } },
      },
    }),
  ];
  fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);

  let stateDb = null;
  let logsDb = null;
  if (HAS_SQLITE3) {
    stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
    mkdirp(path.dirname(stateDb));
    sql(stateDb, `
      PRAGMA journal_mode = WAL;
      CREATE TABLE IF NOT EXISTS threads (
        id TEXT PRIMARY KEY,
        rollout_path TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        source TEXT NOT NULL,
        model_provider TEXT NOT NULL,
        cwd TEXT NOT NULL,
        title TEXT NOT NULL,
        sandbox_policy TEXT NOT NULL,
        approval_mode TEXT NOT NULL,
        tokens_used INTEGER NOT NULL DEFAULT 0,
        has_user_event INTEGER NOT NULL DEFAULT 0,
        archived INTEGER NOT NULL DEFAULT 0,
        archived_at INTEGER,
        git_sha TEXT,
        git_branch TEXT,
        git_origin_url TEXT,
        cli_version TEXT NOT NULL DEFAULT '',
        first_user_message TEXT NOT NULL DEFAULT '',
        agent_nickname TEXT,
        agent_role TEXT,
        memory_mode TEXT NOT NULL DEFAULT 'enabled'
      );
      CREATE TABLE IF NOT EXISTS stage1_outputs (
        thread_id TEXT PRIMARY KEY,
        source_updated_at INTEGER NOT NULL,
        raw_memory TEXT NOT NULL,
        rollout_summary TEXT NOT NULL,
        generated_at INTEGER NOT NULL
      );
      CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
        thread_id TEXT NOT NULL,
        position INTEGER NOT NULL,
        name TEXT NOT NULL,
        description TEXT NOT NULL,
        input_schema TEXT NOT NULL,
        PRIMARY KEY(thread_id, position)
      );
      CREATE TABLE IF NOT EXISTS logs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ts INTEGER NOT NULL,
        ts_nanos INTEGER NOT NULL,
        level TEXT NOT NULL,
        target TEXT NOT NULL,
        message TEXT,
        module_path TEXT,
        file TEXT,
        line INTEGER,
        thread_id TEXT,
        process_uuid TEXT,
        estimated_bytes INTEGER NOT NULL DEFAULT 0
      );
      INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
      VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, '${source}', 'OpenAI', '${cwd.replace(/'/g, "''")}', '${userText.replace(/'/g, "''")}', '{}', 'never', '${cliVersion}');
      INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
    `);

    logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
    sql(logsDb, `
      CREATE TABLE IF NOT EXISTS logs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ts INTEGER NOT NULL,
        ts_nanos INTEGER NOT NULL,
        level TEXT NOT NULL,
        target TEXT NOT NULL,
        message TEXT,
        module_path TEXT,
        file TEXT,
        line INTEGER,
        thread_id TEXT,
        process_uuid TEXT,
        estimated_bytes INTEGER NOT NULL DEFAULT 0
      );
      INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
    `);
  }

  return { threadId, rolloutPath, stateDb, logsDb };
}

function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = 'xhigh' } = {}) {
  const codexDir = path.join(homeDir, '.codex');
  mkdirp(codexDir);
  fs.writeFileSync(path.join(codexDir, 'config.toml'), [
    'model_provider = "test"',
    `model = "${model}"`,
    `model_reasoning_effort = "${reasoningEffort}"`,
    '',
    '[projects."/tmp/project-b"]',
    'trust_level = "trusted"',
    '',
    '[mcp_servers.reg-config]',
    'command = "node"',
    'args = ["regression-mcp.js"]',
    '',
  ].join('\n'));
}

function assertFrontendGenerationControlsContract() {
  const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
  const controlsStart = source.indexOf('function updateGenerationControls()');
  const controlsEnd = source.indexOf('\n  function updateNoteModeUI()', controlsStart);
  assert(controlsStart >= 0 && controlsEnd > controlsStart, 'Frontend should define updateGenerationControls before updateNoteModeUI');

  for (const target of ['sendBtn.hidden', 'abortBtn.hidden']) {
    const regex = new RegExp(`${target.replace('.', '\\.')}\\s*=`, 'g');
    let match;
    while ((match = regex.exec(source))) {
      assert(
        match.index > controlsStart && match.index < controlsEnd,
        `${target} should only be assigned in updateGenerationControls`
      );
    }
  }

  const resumeStart = source.indexOf("case 'resume_generating':");
  const resumeEnd = source.indexOf("case 'error':", resumeStart);
  assert(resumeStart >= 0 && resumeEnd > resumeStart, 'Frontend should keep an explicit resume_generating handler');
  const resumeBlock = source.slice(resumeStart, resumeEnd);
  assert(
    resumeBlock.includes('updateGenerationControls();'),
    'resume_generating should refresh send/abort controls when reusing an existing streaming bubble'
  );

  const controlsBlock = source.slice(controlsStart, controlsEnd);
  assert(
    /allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock),
    'Codex App should keep the runtime insert send button visible while generating'
  );
  const staleDefaultApprovalWarning = ['默认模式的', '授权申请功能', '暂未实现'].join('');
  assert(
    !source.includes(staleDefaultApprovalWarning),
    'Frontend should not show the stale default-mode approval warning after Codex App approvals are supported'
  );
  assert(
    !source.includes('Codex App 暂不支持导入') && !source.includes('Codex App 模式暂不支持导入'),
    'Frontend should not disable Codex App native session import'
  );
  assert(
    source.includes("send({ type: 'list_codex_sessions', agent: importAgent })") &&
      source.includes("send({ type: 'import_codex_session', agent: importAgent"),
    'Frontend Codex import modal should pass the selected Codex-like agent'
  );
  assert(
    source.includes('function appendImportVisibilityToggle') &&
      source.includes('显示已导入会话') &&
      source.includes('cc-web 已存在的会话'),
    'Frontend import modal should expose a toggle for already imported sessions'
  );
  assert(
    source.includes('(group.sessions || []).filter((sess) => !sess.alreadyImported)') &&
      source.includes('codexItems.filter((sess) => !sess.alreadyImported)'),
    'Frontend import modal should hide already imported sessions by default'
  );
}

function assertFrontendComposerMcpContract() {
  const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
  const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
  const requestStart = source.indexOf('function requestComposerSuggestions()');
  const requestEnd = source.indexOf('\n  function handleComposerSuggestions', requestStart);
  assert(requestStart >= 0 && requestEnd > requestStart, 'Frontend should define requestComposerSuggestions before handleComposerSuggestions');

  const requestBlock = source.slice(requestStart, requestEnd);
  const slashStart = requestBlock.indexOf("if (token.trigger === '/')");
  const slashEnd = requestBlock.indexOf('clearTimeout(composerSuggestionTimer);', slashStart);
  assert(slashStart >= 0 && slashEnd > slashStart, 'Slash composer branch should precede backend debounce');
  const slashBlock = requestBlock.slice(slashStart, slashEnd);
  assert(slashBlock.includes('getLocalSlashSuggestions(token.query)'), 'Slash composer should keep local fallback suggestions');
  assert(!/\breturn\s*;/.test(slashBlock), 'Slash composer should continue to backend suggestions so MCP items can be merged');

  const menuStart = source.indexOf('function showCmdMenu(token, items)');
  const menuEnd = source.indexOf('\n  function requestComposerSuggestions()', menuStart);
  assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
  const menuBlock = source.slice(menuStart, menuEnd);
  assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels');
  const selectStart = source.indexOf('function selectComposerItemByIndex(index)');
  const selectEnd = source.indexOf('\n  function selectCmdMenuItem()', selectStart);
  assert(selectStart >= 0 && selectEnd > selectStart, 'Frontend should define selectComposerItemByIndex before selectCmdMenuItem');
  const selectBlock = source.slice(selectStart, selectEnd);
  assert(selectBlock.includes('const insertion = String(item.insertion || item.label || item.name || \'\');'), 'Composer should insert selected item text through the generic insertion path');
  assert(selectBlock.includes('const appendSpace = item.appendSpace !== false;'), 'Composer should honor appendSpace for generic MCP insertion');
  assert(!source.includes('function showCcwebPromptUserComposerModal'), 'Composer should not open a parameter builder for ccweb_prompt_user');
  assert(!source.includes('composer_mcp_tool_submit'), 'Frontend should not submit ccweb_prompt_user from slash composer as structured MCP args');
  assert(!serverSource.includes('composer_mcp_tool_submit'), 'Server should not accept slash-composer structured MCP tool submissions');
  assert(!source.includes('data-composer-mcp-questions'), 'Frontend should not render a slash-composer MCP argument builder');
  assert(!source.includes('data-option-field="recommended"'), 'Frontend should not render slash-composer MCP option editors');
  assert(source.includes('function renderComposerMentionsStrip(meta)'), 'Frontend should define composer mention strip renderer');
  assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
}

function assertFrontendSlashDraftPreservationContract() {
  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 sendStart = source.indexOf('function sendMessage()');
  const slashStart = source.indexOf("if (text.startsWith('/'))", 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 keep slash send branch explicit');
  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(serverSource.includes('handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);'), 'Server should pass client request metadata into slash handler');
  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(serverSource.includes('preserveComposerDraft: true'), 'Server should explicitly mark slash failures as draft-preserving');
  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 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 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('开始与 Claude 对话'), 'Static welcome copy should keep existing Claude wording');
  assert(
    serverSource.includes("const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);"),
    'Server explicit agent support should remain available for API/MCP paths'
  );
}

function assertSetTitleMcpContract() {
  const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
  const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
  const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');

  assert(serverSource.includes("case 'ccweb_set_title':"), 'Server should route ccweb_set_title through internal MCP');
  assert(serverSource.includes('function setCurrentConversationTitle'), 'Server should implement current-conversation title setting');
  assert(serverSource.includes('ignored because title is manually locked'), 'ccweb_set_title should report manual title locks as ignored success');
  assert(serverSource.includes("titleSource = 'manual'"), 'Manual UI rename should mark titleSource as manual');
  assert(serverSource.includes('CCWEB_TITLE_TOOL_INSTRUCTIONS'), 'Server should define model guidance for the title tool');
  assert(frontendSource.includes("createdFromKind || ''") && frontendSource.includes("' llm-created'"), 'Frontend should mark MCP-created sessions with llm-created class');
  assert(frontendSource.includes('snapshot.titleSource') && frontendSource.includes('snapshot.createdFromKind'), 'Frontend should preserve title metadata in session snapshots');
  assert(styleSource.includes('.session-item.llm-created:not(.pinned)::before'), 'LLM-created marker should be hidden when the session is pinned');
}

function assertSessionSwitchResilienceContract() {
  const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
  const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
  const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');

  assert(frontendSource.includes('SESSION_LOAD_REQUEST_TIMEOUT_MS'), 'Frontend should define a hard timeout for session load requests');
  assert(frontendSource.includes('sessionLoadRequestTimer'), 'Frontend should track the session load request timeout timer');
  assert(frontendSource.includes('function clearPendingSessionSwitchRequest'), 'Frontend should be able to cancel stale pending session switch requests');
  assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should schedule cancellation for stuck load_session requests');
  assert(frontendSource.includes('pendingSessionResumeRequest'), 'Frontend should track lightweight running-session resume requests');
  assert(frontendSource.includes('SESSION_RESUME_FALLBACK_MS'), 'Frontend should keep a compatibility fallback for servers without resume_session');
  assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload');
  assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations');
  assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results');
  assert(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(
    /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(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'
  );
}

async function main() {
  assertFrontendGenerationControlsContract();
  assertFrontendComposerMcpContract();
  assertFrontendSlashDraftPreservationContract();
  assertFrontendCcwebPromptContract();
  assertFrontendMarkdownLinkContract();
  assertMockCodexAppPromptUserNotTextTriggered();
  assertFrontendMcpReloadContract();
  assertFrontendPrimaryCodexAppUiContract();
  assertSetTitleMcpContract();
  assertSessionSwitchResilienceContract();

  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
  const configDir = path.join(tempRoot, 'config');
  const sessionsDir = path.join(tempRoot, 'sessions');
  const logsDir = path.join(tempRoot, 'logs');
  const homeDir = path.join(tempRoot, 'home');
  mkdirp(configDir);
  mkdirp(sessionsDir);
  mkdirp(logsDir);
  mkdirp(homeDir);

  fs.writeFileSync(path.join(configDir, 'notify.json'), JSON.stringify({
    provider: 'off',
    pushplus: { token: '' },
    telegram: { botToken: '', chatId: '' },
    serverchan: { sendKey: '' },
    feishu: { webhook: '' },
    qqbot: { qmsgKey: '' },
  }, null, 2));

  const skillDir = path.join(homeDir, '.codex', 'skills', 'regression-skill');
  mkdirp(skillDir);
  fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
    '---',
    'name: regression-skill',
    'description: Regression skill for composer suggestions.',
    '---',
    '',
    '# Regression Skill',
    '',
    'Use this only in regression tests.',
  ].join('\n'));
  mkdirp(path.join(skillDir, 'agents'));
  fs.writeFileSync(path.join(skillDir, 'agents', 'openai.yaml'), [
    'interface:',
    '  display_name: "Regression Docs"',
    '  short_description: "Regression skill metadata for composer suggestions."',
    '  brand_color: "#2f6f64"',
    '  default_prompt: "Use Regression Docs for regression metadata coverage."',
    '',
    'dependencies:',
    '  tools:',
    '    - type: "mcp"',
    '      value: "openaiDeveloperDocs"',
    '      description: "Regression docs MCP server"',
    '      transport: "streamable_http"',
    '      url: "https://developers.openai.com/mcp"',
  ].join('\n'));
  const codexPromptsDir = path.join(homeDir, '.codex', 'prompts');
  mkdirp(path.join(codexPromptsDir, 'nested-tool'));
  fs.writeFileSync(path.join(codexPromptsDir, 'quick-note.md'), [
    '---',
    'title: Quick Note',
    'description: Prompt file shortcut from ~/.codex/prompts.',
    '---',
    '',
    'Prompt body from @quick-note.',
  ].join('\n'));
  fs.writeFileSync(path.join(codexPromptsDir, 'nested-tool', 'prompt.md'), [
    '---',
    'description: Directory-style prompt shortcut.',
    '---',
    '',
    'Prompt body from @nested-tool.',
  ].join('\n'));
  fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({
    prompts: [
      {
        name: 'shipit',
        title: 'Ship It',
        description: 'Regression prompt template.',
        content: 'Regression prompt body from @shipit.',
      },
    ],
  }, null, 2));

  createFakeClaudeHistory(homeDir);
  createFakeCodexConfig(homeDir);
  const codexFixture = createFakeCodexHistory(homeDir);
  const codexAppImportFixture = createFakeCodexHistory(homeDir, {
    threadId: 'codexapp-import-thread',
    cwd: '/tmp/project-c',
    userText: 'Codex App import prompt',
    answerText: 'Codex App import answer',
    source: 'vscode',
    fileStamp: '2026-03-12T00-00-10',
  });
  const duplicateSourceConversationId = '11111111-1111-4111-8111-111111111111';
  const duplicateSourceConversationTitle = '你能看下 00a7cbc2-d0c3-457f-a262-aa5a5859fa54 这个对话么， 你来评估下，这个对话中';
  createFakeCodexHistory(homeDir, {
    threadId: 'codexapp-duplicate-thread-a',
    cwd: '/tmp/project-c',
    userText: `来自「${duplicateSourceConversationTitle}」对话（ID: ${duplicateSourceConversationId}）的消息：\n\n旧候选`,
    answerText: 'duplicate import answer a',
    source: 'vscode',
    fileStamp: '2026-03-12T00-00-20',
  });
  createFakeCodexHistory(homeDir, {
    threadId: 'codexapp-duplicate-thread-b',
    cwd: '/tmp/project-c',
    userText: `来自「${duplicateSourceConversationTitle}」对话（ID: ${duplicateSourceConversationId}）的消息：\n\n新候选`,
    answerText: 'duplicate import answer b',
    source: 'vscode',
    fileStamp: '2026-03-12T00-00-21',
  });
  const codexAppObjectSourceFixture = createFakeCodexHistory(homeDir, {
    threadId: 'codexapp-object-source-thread',
    cwd: '/tmp/project-c',
    userText: 'Object source import prompt',
    answerText: 'Object source import answer',
    source: { subagent: { thread_spawn: { parent_thread_id: 'parent-thread', depth: 1 } } },
    fileStamp: '2026-03-12T00-00-22',
  });

  const port = await getFreePort();
  const password = 'Regression!234';
  const internalMcpToken = 'RegressionMcp!234';

  await withServer({
    PORT: String(port),
    CC_WEB_PASSWORD: password,
    CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
    CC_WEB_CONFIG_DIR: configDir,
    CC_WEB_SESSIONS_DIR: sessionsDir,
    CC_WEB_LOGS_DIR: logsDir,
    HOME: homeDir,
    CLAUDE_PATH: MOCK_CLAUDE,
    CODEX_PATH: MOCK_CODEX_APP_SERVER,
    CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS: '100',
  }, async () => {
    await assertWsUpgradeRejected(port, '/not-ws');

    const { ws, messages, token } = await connectWs(port, password);

    await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
    ws.send(JSON.stringify({ type: 'load_session', sessionId: 'missing-session', requestId: 'reg-missing-session' }));
    const missingSessionLoad = await nextMessage(messages, ws, (msg) => (
      msg.type === 'error' &&
      msg.code === 'session_not_found' &&
      msg.sessionId === 'missing-session'
    ));
    assert(missingSessionLoad.requestId === 'reg-missing-session', 'Missing load_session error should echo requestId for frontend cleanup');

    const pickerRoot = path.join(homeDir, 'picker-root');
    mkdirp(path.join(pickerRoot, 'alpha'));
    mkdirp(path.join(pickerRoot, 'beta'));
    fs.writeFileSync(path.join(pickerRoot, 'note.txt'), 'not a directory');

    ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', mode: 'plan', requestId: 'reg-new-default' }));
    const defaultCodexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'New Chat');
    assert(defaultCodexSession.requestId === 'reg-new-default', 'new_session session_info should echo requestId');
    assert(defaultCodexSession.cwd === homeDir, 'Codex new_session without cwd should default to HOME');

    const missingCwd = path.join(tempRoot, 'missing-space', 'nested-project');
    ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: missingCwd, mode: 'plan' }));
    const missingCwdError = await nextMessage(messages, ws, (msg) => msg.type === 'error' && msg.code === 'new_session_cwd_missing');
    assert(missingCwdError.cwd === missingCwd, 'Missing cwd error should return the requested absolute path');
    assert(!fs.existsSync(missingCwd), 'Missing cwd should not be created before explicit confirmation');

    ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: missingCwd, mode: 'plan', createCwd: true }));
    const createdCwdSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === missingCwd);
    assert(createdCwdSession.cwd === missingCwd, 'Codex new_session should allow creating a missing cwd');
    assert(fs.existsSync(missingCwd), 'Missing cwd should be created when createCwd is enabled');

    const directoryPayload = await fetchAuthedJson(port, token, `/api/fs/directories?path=${encodeURIComponent(pickerRoot)}`);
    assert(directoryPayload.currentPath === pickerRoot, 'Directory picker should return requested absolute path');
    assert(directoryPayload.defaultPath === homeDir, 'Directory picker should expose HOME as default path');
    assert(directoryPayload.entries.some((entry) => entry.name === 'alpha'), 'Directory picker should list child directories');
    assert(directoryPayload.entries.some((entry) => entry.name === 'beta'), 'Directory picker should include all child directories');
    assert(!directoryPayload.entries.some((entry) => entry.name === 'note.txt'), 'Directory picker should hide files');

    ws.send(JSON.stringify({
      type: 'save_codex_config',
      config: {
        mode: 'custom',
        activeProfile: 'Regression Profile',
        profiles: [{ name: 'Regression Profile', apiKey: 'sk-regression', apiBase: 'https://example.com/v1' }],
        enableSearch: true,
        retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 },
      },
    }));
    const codexConfigMsg = await nextMessage(messages, ws, (msg) => msg.type === 'codex_config');
    assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
    assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
    assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
    assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
    assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
    assert(codexConfigMsg.config.retry?.mode === 'limited', 'Codex retry mode should round-trip');
    assert(codexConfigMsg.config.retry?.intervalSeconds === 1, 'Codex retry interval should round-trip');
    assert(codexConfigMsg.config.retry?.maxAttempts === 2, 'Codex retry max attempts should round-trip');

    const codexInitCwd = path.join(tempRoot, 'codex-space');
    mkdirp(codexInitCwd);
    const projectSkillDir = path.join(codexInitCwd, '.agents', 'skills', 'project-skill');
    mkdirp(projectSkillDir);
    fs.writeFileSync(path.join(projectSkillDir, 'SKILL.md'), [
      '---',
      'name: project-skill',
      'description: Project-scoped skill for composer suggestions.',
      '---',
      '',
      '# Project Skill',
      '',
      'Use this only in regression tests.',
    ].join('\n'));
    const projectCodexConfigDir = path.join(codexInitCwd, '.codex');
    mkdirp(projectCodexConfigDir);
    fs.writeFileSync(path.join(projectCodexConfigDir, 'config.toml'), [
      '[mcp_servers.reg-project]',
      'type = "stdio"',
      `command = ${JSON.stringify(process.execPath)}`,
      'args = ["regression-mcp.js"]',
      'enabled = true',
      '',
      '[mcp_servers.reg-disabled]',
      'type = "stdio"',
      `command = ${JSON.stringify(process.execPath)}`,
      'enabled = false',
      '',
      '[mcp_servers.reg-missing]',
      'type = "stdio"',
      'command = "definitely-missing-mcp-command"',
    ].join('\n'));
    fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
    ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
    const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd);
    assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
    assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml');

    ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: true }));
    const pinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId);
    assert(pinnedAck.pinnedAt, 'Pinning a session should return pinnedAt');
    const pinnedList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && s.pinnedAt));
    assert(pinnedList.sessions[0].id === codexSession.sessionId, 'Pinned session should sort before regular sessions');
    let storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
    assert(storedPinnedSession.pinnedAt === pinnedAck.pinnedAt, 'Pinned state should persist to session JSON');

    ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: false }));
    const unpinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId && !msg.pinnedAt);
    assert(unpinnedAck.pinnedAt === null, 'Unpinning a session should clear pinnedAt');
    await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && !s.pinnedAt));
    storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
    assert(storedPinnedSession.pinnedAt === null, 'Unpinned state should persist to session JSON');

    ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash', trigger: '/', query: 'mo', sessionId: codexSession.sessionId, agent: 'codex' }));
    const slashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash');
    assert(slashComposer.items.some((item) => item.kind === 'command' && item.name === '/model'), 'Composer slash suggestions should include /model');

    ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
    const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
    assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
    assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_set_title'), 'Composer slash suggestions should include ccweb_set_title MCP tool');

    ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
    const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config');
    assert(slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-project'), 'Composer slash suggestions should include available project MCP servers from session cwd');
    assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-config'), 'Composer slash suggestions should not include MCP servers from unrelated global Codex config');
    assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-disabled'), 'Composer slash suggestions should not include disabled project MCP servers');
    assert(!slashMcpConfigComposer.items.some((item) => item.kind === 'mcp' && item.name === 'reg-missing'), 'Composer slash suggestions should not include project MCP servers with unavailable commands');

    const storedComposerFixture = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
    storedComposerFixture.messages.push({
      role: 'assistant',
      content: 'Runtime tools include mcp__regRuntime__inspect_schema and mcp:reg-state/query.',
      timestamp: new Date().toISOString(),
    });
    fs.writeFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), JSON.stringify(storedComposerFixture, null, 2));

    ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-runtime', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
    const slashMcpRuntimeComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-runtime');
    assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
    assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');

    ws.send(JSON.stringify({ type: 'message', text: '/grilling', 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 === true, 'Unknown slash command should tell the frontend to restore the composer draft');
    assert(/未知指令: \/grilling/.test(unknownSlashDraft.message || ''), 'Unknown slash command should still show the normal failure hint');

    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');
    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');
    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');

    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');

    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 allSpawnsForSession = processLog
	      .trim()
	      .split('\n')
	      .filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
	    const lastSpawn = allSpawnsForSession[allSpawnsForSession.length - 1] || '';
	    assert(lastSpawn.includes('resume') && lastSpawn.includes(threadIdBeforeMode), 'Codex mode switch should keep resume thread id');
	    assert(lastSpawn.includes('-s read-only'), 'Codex plan mode should set sandbox read-only');
	    assert(lastSpawn.includes('-s read-only resume'), 'Codex resume in plan mode must place -s before resume subcommand');

    ws.send(JSON.stringify({ type: 'message', text: 'trigger codex capacity retry', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' }));
    const capacityRetryNotice = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /自动重试/.test(msg.message || '') && msg.sessionId === firstMessageSession.sessionId, 10000);
    assert(/Codex 服务暂时繁忙/.test(capacityRetryNotice.message || ''), 'Codex transient capacity failure should announce automatic retry');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId, 20000);
    const storedAfterCapacityRetry = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8'));
    const capacityRetryUsers = storedAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === 'trigger codex capacity retry');
    assert(capacityRetryUsers.length === 1, 'Codex transient retry should not duplicate the user message');
    assert(storedAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /trigger codex capacity retry/.test(String(message.content || ''))), 'Codex transient retry should persist the successful assistant response');

    const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8');
    assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
    assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');

    ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: firstMessageSession.sessionId, mode: 'yolo', agent: 'codex' }));
    await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在执行/.test(msg.message || '') && /Codex \/compact/.test(msg.message || ''));
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
    const compactDoneMsg = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
    assert(/已执行 Codex \/compact/.test(compactDoneMsg.message || ''), 'Codex /compact should complete with Codex-specific status message');

    const autoCompactCwd = path.join(tempRoot, 'codex-auto-compact');
    mkdirp(autoCompactCwd);
    ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: autoCompactCwd, mode: 'yolo' }));
    const autoCompactSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === autoCompactCwd);
    ws.send(JSON.stringify({ type: 'message', text: 'warm up auto compact', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId);
    ws.send(JSON.stringify({ type: 'message', text: 'trigger codex context limit', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' }));
    const autoCompactStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在按 Codex \/compact 自动压缩/.test(msg.message || ''));
    assert(/Codex \/compact/.test(autoCompactStart.message || ''), 'Codex auto /compact should announce auto compact start');
    const autoCompactDone = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || ''));
    assert(/已执行 Codex \/compact/.test(autoCompactDone.message || ''), 'Codex auto /compact should finish compact step');
	    const autoCompactResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /按 Codex 压缩计划继续执行/.test(msg.message || ''));
	    assert(/继续执行/.test(autoCompactResume.message || ''), 'Codex auto /compact should announce retry');
	    // Some Codex builds won't echo the original prompt text as a text delta on retry; accept either.
	    const autoCompactRetry = await nextMessage(messages, ws, (msg) => (
	      (msg.type === 'text_delta' && /trigger codex context limit/.test(msg.text || '')) ||
	      (msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId)
	    ), 20000);
	    if (autoCompactRetry.type === 'text_delta') {
	      assert(/trigger codex context limit/.test(autoCompactRetry.text || ''), 'Codex auto /compact should replay the failed prompt after compact');
	    }

    const codexAppCwd = path.join(tempRoot, 'codexapp-space');
    mkdirp(codexAppCwd);
    const codexAppProjectConfigDir = path.join(codexAppCwd, '.codex');
    mkdirp(codexAppProjectConfigDir);
    fs.writeFileSync(path.join(codexAppProjectConfigDir, 'config.toml'), [
      '[mcp_servers.reg-app-project]',
      'type = "stdio"',
      `command = ${JSON.stringify(process.execPath)}`,
      'args = ["regression-app-mcp.js"]',
      'enabled = true',
    ].join('\n'));
    ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' }));
    const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
    assert(codexAppSession.model === 'gpt-5.5(xhigh)', 'Codex App new_session should read default Codex model');

    ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
    const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill');
    assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill');
    ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-goal-slash', trigger: '/', query: 'go', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
    const codexAppGoalSlashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-goal-slash');
    assert(codexAppGoalSlashComposer.items.some((item) => item.kind === 'command' && item.name === '/goal'), 'Codex App composer slash suggestions should include /goal');

    ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration default probe', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    const codexAppDefaultCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
    assert(/"mode":"default"/.test(codexAppDefaultCollab.text || ''), 'Codex App YOLO mode should pass default collaboration mode');
    assert(/"hasModel":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include model');
    assert(/"hasDeveloperInstructions":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include sub-agent developer instructions');
    assert(/"hasWaitAgentRetryGuidance":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include wait_agent retry guidance');
    assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level');
    assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);

    ws.send(JSON.stringify({
      type: 'save_codex_config',
      config: {
        mode: 'custom',
        activeProfile: 'Regression Profile Updated',
        profiles: [{ name: 'Regression Profile Updated', apiKey: 'sk-regression-updated', apiBase: 'https://updated.example.com/v1' }],
        enableSearch: false,
        retry: { mode: 'limited', intervalSeconds: 1, maxAttempts: 2 },
      },
    }));
    const codexAppChangedConfig = await nextMessage(messages, ws, (msg) =>
      msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated'
    );
    assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode');

    ws.send(JSON.stringify({ type: 'message', text: 'codexapp after config change prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    const codexAppAfterConfigChange = await nextMessage(messages, ws, (msg) => (
      msg.type === 'text_delta' &&
      msg.sessionId === codexAppSession.sessionId &&
      /codexapp after config change prompt/.test(msg.text || '')
    ));
    assert(/codexapp after config change prompt/.test(codexAppAfterConfigChange.text || ''), 'Codex App should not reject a new turn after config signature changes');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);

    const codexAppRetryText = 'codexapp capacity retry prompt';
    ws.send(JSON.stringify({ type: 'message', text: codexAppRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    const codexAppCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
      msg.type === 'system_message' &&
      msg.sessionId === codexAppSession.sessionId &&
      /自动重试/.test(msg.message || '')
    ), 10000);
    assert(/Codex 服务暂时繁忙/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient capacity failure should announce automatic retry');
    assert(/第 1\/2 次/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient retry should start at attempt 1');
    assert(/从中断处继续/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App retry after a started turn should announce continuation mode');
    const codexAppPartialCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
      msg.type === 'system_message' &&
      msg.sessionId === codexAppSession.sessionId &&
      /自动重试/.test(msg.message || '')
    ), 10000);
    assert(/第 2\/2 次/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App transient retry should continue after partial output');
    assert(/从中断处继续/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App partial-output retry should stay in continuation mode');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
    const storedCodexAppAfterCapacityRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
    const codexAppCapacityRetryUsers = storedCodexAppAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryText);
    assert(codexAppCapacityRetryUsers.length === 1, 'Codex App transient retry should not duplicate the user message');
    assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /codexapp capacity retry prompt/.test(String(message.content || ''))), 'Codex App transient retry should persist the successful assistant response');
    assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App transient retry should ask the model to continue instead of replaying the original prompt');

    const codexAppReconnectRetryText = 'codexapp reconnect retry prompt';
    ws.send(JSON.stringify({ type: 'message', text: codexAppReconnectRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    const codexAppReconnectRetryNotice = await nextMessage(messages, ws, (msg) => (
      msg.type === 'system_message' &&
      msg.sessionId === codexAppSession.sessionId &&
      /自动重试/.test(msg.message || '')
    ), 10000);
    assert(/Codex 服务暂时繁忙/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect failure should announce automatic retry');
    assert(/第 1\/2 次/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App retry counter should reset after the previous retry succeeds');
    assert(/从中断处继续/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect retry after a started turn should announce continuation mode');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
    const storedCodexAppAfterReconnectRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
    const codexAppReconnectRetryUsers = storedCodexAppAfterReconnectRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppReconnectRetryText);
    assert(codexAppReconnectRetryUsers.length === 1, 'Codex App reconnect retry should not duplicate the user message');
    assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /codexapp reconnect retry prompt/.test(String(message.content || ''))), 'Codex App reconnect retry should persist the successful assistant response');
    assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App reconnect retry should continue the interrupted turn instead of replaying the original prompt');

    const codexAppThreadBeforeMismatch = storedCodexAppAfterReconnectRetry.codexAppThreadId;
    assert(codexAppThreadBeforeMismatch, 'Codex App retry mismatch regression needs an existing app-server thread');
    const codexAppRetryMismatchText = 'codexapp retry thread mismatch prompt';
    ws.send(JSON.stringify({ type: 'message', text: codexAppRetryMismatchText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    const codexAppRetryMismatchNotice = await nextMessage(messages, ws, (msg) => (
      msg.type === 'system_message' &&
      msg.sessionId === codexAppSession.sessionId &&
      /自动重试/.test(msg.message || '')
    ), 10000);
    assert(/Codex 服务暂时繁忙/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should first announce automatic retry');
    assert(/第 1\/2 次/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App retry counter should reset for the next independent retryable turn');
    assert(/从中断处继续/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should also be a continuation retry');
    const codexAppRetryMismatchError = await nextMessage(messages, ws, (msg) => (
      msg.type === 'error' &&
      msg.sessionId === codexAppSession.sessionId &&
      /不同线程/.test(msg.message || '') &&
      /上下文丢失/.test(msg.message || '')
    ), 20000);
    assert(/已停止/.test(codexAppRetryMismatchError.message || ''), 'Codex App retry should stop when resume returns a different thread');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
    const storedCodexAppAfterRetryMismatch = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
    assert(storedCodexAppAfterRetryMismatch.codexAppThreadId === codexAppThreadBeforeMismatch, 'Codex App retry mismatch must not replace the persisted app-server thread id');
    const codexAppRetryMismatchUsers = storedCodexAppAfterRetryMismatch.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryMismatchText);
    assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message');
    assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread');

    ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    const 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 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');

    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 ccwebMcpChildClosed = await nextMessage(messages, ws, (msg) =>
      msg.type === 'ccweb_mcp_child_agent_update' &&
      msg.sessionId === codexAppSession.sessionId &&
      msg.child?.threadId === 'child-thread-a' &&
      msg.child?.status === 'closed'
    );
    assert(/"status": "closed"/.test(ccwebMcpChildClosed.tool?.result || ''), 'ccweb MCP child close should update the parent collab tool state');
    storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
    const hasCollabTool = storedCodexApp.messages
      .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
      .some((tool) => tool.kind === 'collab_agent_tool_call');
    assert(hasCollabTool, 'ccweb MCP collab tool should be persisted into session history');
    const persistedClosedCollabTool = storedCodexApp.messages
      .flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
      .reverse()
      .find((tool) => tool.id === 'tool-collab');
    assert(/"status": "closed"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child close should persist closed state');

    ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
    const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
    assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode');
    assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions');
    assert(/"hasWaitAgentRetryGuidance":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep wait_agent retry guidance');
    assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level');
    assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);

    ws.send(JSON.stringify({ type: 'message', text: 'codexapp guided prompt', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
    const guidedRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_user_input_request' && msg.sessionId === codexAppSession.sessionId);
    assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
    ws.send(JSON.stringify({
      type: 'codex_app_user_input_response',
      action: 'submit',
      sessionId: codexAppSession.sessionId,
      requestId: guidedRequest.requestId,
      answers: { choice: { answers: ['A'] } },
    }));
    const guidedSubmitted = await nextMessage(messages, ws, (msg) =>
      msg.type === 'system_message' &&
      msg.sessionId === codexAppSession.sessionId &&
      /已提交.*引导输入/.test(msg.message || '')
    );
    assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint');
    const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
    assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);

    ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
    const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId);
    assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests');
    assert(approvalRequest.itemId === 'approval-command-call', 'Codex App approval request should keep item id');
    assert(/echo approved/.test(JSON.stringify(approvalRequest.payload || {})), 'Codex App approval request should include command payload');
    ws.send(JSON.stringify({
      type: 'codex_app_approval_response',
      action: 'approve_session',
      sessionId: codexAppSession.sessionId,
      requestId: approvalRequest.requestId,
    }));
    const approvalSubmitted = await nextMessage(messages, ws, (msg) =>
      msg.type === 'system_message' &&
      msg.sessionId === codexAppSession.sessionId &&
      /本会话执行/.test(msg.message || '')
    );
    assert(/本会话执行/.test(approvalSubmitted.message || ''), 'Codex App should show approval confirmation hint');
    const approvalDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /approval decision: acceptForSession/.test(msg.text || ''));
    assert(/approval decision: acceptForSession/.test(approvalDelta.text || ''), 'Codex App should continue after approval response');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);

    ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
    await sleep(500);
    ws.send(JSON.stringify({
      type: 'message',
      text: 'runtime steer insert',
      sessionId: codexAppSession.sessionId,
      mode: 'yolo',
      agent: 'codexapp',
      clientMessageId: 'regression-steer-message',
    }));
    const steerPending = await nextMessage(messages, ws, (msg) =>
      msg.type === 'codex_app_steer_status' &&
      msg.sessionId === codexAppSession.sessionId &&
      msg.clientMessageId === 'regression-steer-message' &&
      msg.status === 'pending'
    );
    assert(/引导中/.test(steerPending.message || ''), 'Codex App steer should expose pending status');
    const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || ''));
    assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer');
    const steerInserted = await nextMessage(messages, ws, (msg) =>
      msg.type === 'codex_app_steer_status' &&
      msg.sessionId === codexAppSession.sessionId &&
      msg.clientMessageId === 'regression-steer-message' &&
      msg.status === 'inserted'
    );
    assert(/已插入/.test(steerInserted.message || ''), 'Codex App steer should expose inserted status');
    const steerSystemMessage = await nextMessage(messages, ws, (msg) =>
      msg.type === 'system_message' &&
      msg.sessionId === codexAppSession.sessionId &&
      /已引导对话: runtime steer insert/.test(msg.message || '')
    );
    assert(steerSystemMessage.transient === true, 'Codex App steer marker should be transient');
    assert(/已引导对话: runtime steer insert/.test(steerSystemMessage.message || ''), 'Codex App steer should show guided conversation marker with preview');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
    storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
    assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
    assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
    assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');

    ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp abort prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
    await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
    const codexAppRunningMcp = await callInternalMcp(port, internalMcpToken, {
      tool: 'ccweb_send_message',
      sourceSessionId: codexSession.sessionId,
      sourceHopCount: 0,
      args: {
        targetConversationId: codexAppSession.sessionId,
        content: 'running codexapp target should reject this',
      },
    });
    assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');
    await sleep(150);
    ws.send(JSON.stringify({ type: 'abort' }));
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);

    const claudeAttachment = await uploadAttachment(port, token, {
      filename: 'claude-test.png',
      mime: 'image/png',
      data: Buffer.from('claude-image'),
    });
    ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' }));
    const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment');
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
    const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
      .trim()
      .split('\n')
      .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
    assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
    const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
    assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata');
    assert(storedClaudeSession.claudeSessionId, 'Claude session id should be persisted after first run');
    const claudeSessionIdBeforeMode = storedClaudeSession.claudeSessionId;

    // Mode switching must not clear Claude runtime session id (resume should keep context).
    ws.send(JSON.stringify({ type: 'set_mode', sessionId: claudeImageSession.sessionId, mode: 'plan' }));
    await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan');
    const storedClaudeAfterMode = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
    assert(storedClaudeAfterMode.claudeSessionId === claudeSessionIdBeforeMode, 'Claude session id should survive mode switch');

    ws.send(JSON.stringify({ type: 'message', text: 'second claude prompt', sessionId: claudeImageSession.sessionId, mode: 'plan', agent: 'claude' }));
    await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
    const claudeSpawns = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
      .trim()
      .split('\n')
      .filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
    const lastClaudeSpawn = claudeSpawns[claudeSpawns.length - 1] || '';
    assert(lastClaudeSpawn.includes(`--resume ${claudeSessionIdBeforeMode}`), 'Claude mode switch should keep --resume session id');
    assert(lastClaudeSpawn.includes('--permission-mode plan'), 'Claude plan mode should set --permission-mode plan');

    ws.send(JSON.stringify({ type: 'list_native_sessions' }));
    const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions');
    assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed');
    const firstClaude = nativeSessions.groups[0].sessions[0];
    ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir }));
    const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt');
    assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message');

    ws.send(JSON.stringify({ type: 'list_codex_sessions' }));
    const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
    const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId);
    assert(importedCodexItem, 'Codex session listing failed');

    ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath }));
    const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt');
    assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions');
    assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed');

    ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
    const codexAppImportSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
    const codexAppImportItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
    assert(codexAppImportItem, 'Codex App session listing failed');
    assert(codexAppImportItem.agent === 'codexapp', 'Codex App import listing should echo target agent');
    assert(codexAppImportItem.alreadyImported === false, 'Codex App import should not reuse old Codex imported state');
    const duplicateSourceItems = codexAppImportSessions.sessions.filter((item) => item.sourceConversationId === duplicateSourceConversationId);
    assert(duplicateSourceItems.length === 1, 'Codex App import list should collapse rollout entries from the same cc-web source conversation');
    assert(duplicateSourceItems[0].duplicateCount === 2, 'Collapsed Codex App import item should report duplicate rollout count');
    const objectSourceItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId);
    assert(objectSourceItem?.source === 'subagent', 'Codex App import list should format object source metadata');

    ws.send(JSON.stringify({
      type: 'import_codex_session',
      agent: 'codexapp',
      threadId: codexAppImportItem.threadId,
      rolloutPath: codexAppImportItem.rolloutPath,
    }));
    const importedCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt');
    assert(importedCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App import parsed wrong first message');
    assert(importedCodexApp.totalUsage?.inputTokens === 20, 'Codex App import usage parse failed');
    const importedCodexAppPath = path.join(sessionsDir, `${importedCodexApp.sessionId}.json`);
    const storedImportedCodexApp = JSON.parse(fs.readFileSync(importedCodexAppPath, 'utf8'));
    assert(storedImportedCodexApp.agent === 'codexapp', 'Codex App import should persist codexapp agent');
    assert(storedImportedCodexApp.codexAppThreadId === codexAppImportFixture.threadId, 'Codex App import should persist codexAppThreadId');
    assert(!storedImportedCodexApp.codexThreadId, 'Codex App import should not persist legacy codexThreadId');

    ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' }));
    const codexAppImportSessionsAfter = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
    const codexAppImportItemAfter = codexAppImportSessionsAfter.sessions.find((item) => item.threadId === codexAppImportFixture.threadId);
    assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported');

    ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId }));
    await nextMessage(messages, ws, (msg) => (
      msg.type === 'session_list' &&
      !msg.sessions.some((s) => s.id === importedCodexApp.sessionId) &&
      !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);
});
