feat: overhaul task board and cross-conversation workflows

This commit is contained in:
shiyue
2026-08-12 09:41:36 +08:00
parent e7d28935ef
commit 86231f0d97
55 changed files with 11628 additions and 280 deletions

View File

@@ -604,12 +604,38 @@ function assertFrontendSidebarCollapseContract() {
const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart);
const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd);
assert(
/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle),
'Compact-mobile user locator should open left from the second grid column'
/\.user-outline-anchor,\s*\.ccweb-prompt-outline-anchor\s*\{[^}]*position:\s*static;/.test(compactMobileStyle),
'Compact-mobile locator anchors should use the shared chat-controls positioning context'
);
assert(
/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
'Compact-mobile pending-form locator should open right from the first grid column'
/\.user-outline-panel,\s*\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*0;[^}]*width:\s*auto;[^}]*bottom:\s*calc\(100% \+ 6px\);/.test(compactMobileStyle),
'Compact-mobile locator panels should span the chat controls width without horizontal overflow'
);
assert(
!/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle)
&& !/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
'Compact-mobile locator panels should not be overridden back to per-button anchor positioning'
);
const compactMobileChatControls = compactMobileStyle.match(/\.chat-controls\s*\{[^}]*\}/)?.[0] || '';
assert(
/display:\s*flex;/.test(compactMobileChatControls) && /flex-wrap:\s*wrap;/.test(compactMobileChatControls),
'Compact-mobile chat controls should keep flex wrapping instead of switching to a two-column grid'
);
assert(
!/grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/.test(compactMobileStyle),
'Compact-mobile chat controls should not force two equal columns'
);
assert(
!/\.mode-select,\s*\.user-outline-btn,\s*\.ccweb-prompt-outline-btn,\s*\.reload-mcp-btn,\s*\.chat-runtime-state\s*\{[^}]*width:\s*100%;/.test(compactMobileStyle),
'Compact-mobile short chat controls should keep content-width sizing'
);
assert(
/\.chat-cwd\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*none;/.test(compactMobileStyle),
'Compact-mobile cwd header control should retain its previous full-width behavior'
);
assert(
/\.ccweb-prompt-outline-anchor\s*\{[^}]*width:\s*auto;/.test(compactMobileStyle),
'Compact-mobile temporary form control should keep content width and wrap without overflow'
);
assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment');
assert(
@@ -629,6 +655,96 @@ function assertFrontendSidebarCollapseContract() {
);
}
function assertTaskBoardIntegrationContract() {
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const taskBoardServiceSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-service.js'), 'utf8');
const taskBoardMcpSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-mcp.js'), 'utf8');
const taskBoardLifecycleSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'task-board-lifecycle.js'), 'utf8');
const taskBoardFrontendSource = fs.readFileSync(path.join(PUBLIC_DIR, 'task-board.js'), 'utf8');
const taskBoardStyleSource = fs.readFileSync(path.join(PUBLIC_DIR, 'task-board.css'), 'utf8');
const trackingIdCount = (indexSource.match(/id="task-tracking-control"/g) || []).length;
assert(trackingIdCount === 1, 'Task tracking should reuse exactly one DOM mount point');
assert(
/<div class="chat-controls"[^>]*>[\s\S]*?<div id="task-tracking-control" class="task-tracking-control" hidden><\/div>\s*<\/div>\s*<div id="attachment-tray"/.test(indexSource),
'Task tracking should be the trailing control inside the existing chat-controls row'
);
assert(frontendSource.includes("const taskTrackingControl = $('#task-tracking-control');"), 'Task tracking should keep the existing frontend mount id');
assert(frontendSource.includes('window.CcwebTaskBoard.renderTrackingControl(taskTrackingControl,'), 'Task tracking should keep the existing controller mount path');
assert(!extractFunctionSource(frontendSource, 'openTaskBoard').includes('createSession'), 'Task board mount should not restore the retired createSession adapter');
const filtersSource = extractFunctionSource(serverSource, 'taskBoardQueryFilters');
const queryPayloadSource = extractFunctionSource(serverSource, 'taskBoardQueryPayload');
const snapshotSource = extractFunctionSource(serverSource, 'taskTrackingSnapshotForSession');
const wsHandlerSource = extractFunctionSource(serverSource, 'handleTaskBoardWsMessage');
const internalMcpSource = extractFunctionSource(serverSource, 'callInternalMcpTool');
const composerSource = extractFunctionSource(serverSource, 'listComposerSuggestions');
const composerMcpSource = extractFunctionSource(serverSource, 'listComposerMcpItems');
assert(!serverSource.includes('function taskBoardFacets(') && !queryPayloadSource.includes('facets'), 'Task board queries should not expose retired Agent facets');
assert(!filtersSource.includes("'agent'") && !filtersSource.includes('source.agent') && !filtersSource.includes('filters.agent'), 'Task board queries should ignore retired Agent filters');
assert(!filtersSource.includes('source.priority') && !filtersSource.includes('filters.priority'), 'Task board queries should not forward the retired priority filter');
assert(
[snapshotSource, taskBoardServiceSource, taskBoardMcpSource, taskBoardLifecycleSource, taskBoardFrontendSource]
.every((source) => !source.includes('baseStatus')),
'Task board production chain should not retain baseStatus mappings'
);
assert(
[snapshotSource, taskBoardServiceSource, taskBoardMcpSource, taskBoardLifecycleSource, taskBoardFrontendSource]
.every((source) => !source.includes('reportingStatus') && !source.includes('本轮未上报')),
'Task board production chain should not retain per-turn reporting audit semantics'
);
const retiredTaskProgressPattern = /\bprogress\b|百分比|进度条|任务进度/i;
assert(
[
taskBoardServiceSource,
taskBoardMcpSource,
taskBoardLifecycleSource,
taskBoardFrontendSource,
taskBoardStyleSource,
snapshotSource,
wsHandlerSource,
queryPayloadSource,
].every((source) => !retiredTaskProgressPattern.test(source)),
'Task board production chain should not retain percentage progress semantics'
);
assert(
frontendSource.includes('function normalizePlanProgress(value)')
&& frontendSource.includes('function createPlanProgressElementFromProgress(progress, options = {})'),
'Retiring task board percentages must preserve unrelated Plan List progress'
);
assert(
composerMcpSource.includes('taskBoardMcpToolDefinitionsForSession')
&& composerSource.includes('const reservedMcpItems = [...promptUserMcpItems, ...taskBoardMcpItems]')
&& composerSource.includes('mergeComposerSuggestionGroups(reservedMcpItems, commands, otherMcpItems)')
&& !composerMcpSource.includes('ccweb_task_status_list')
&& !composerSource.includes('ccweb_task_status_list'),
'Slash composer should dynamically gate the sole task update tool by the current source session'
);
assert(taskBoardMcpSource.includes('createTaskBoardMcpToolDefinitions'), 'Task MCP should expose a dynamic per-session definition factory');
assert(!taskBoardMcpSource.includes("name: 'ccweb_task_status_list'"), 'Task status_list must be retired from the model tool surface');
assert(!taskBoardMcpSource.includes('expectedVersion') && !taskBoardMcpSource.includes("'version'"), 'Task update MCP must not expose redundant version parameters');
assert(
!taskBoardServiceSource.includes('SOURCE_PRIORITY')
&& !taskBoardServiceSource.includes('higher_priority_source')
&& !taskBoardServiceSource.includes('ignoredReason')
&& !taskBoardMcpSource.includes('higher_priority_source')
&& !taskBoardMcpSource.includes('ignoredReason'),
'Task status writes should use optimistic versions and last valid write, never source priority'
);
assert(
!wsHandlerSource.includes('task.ignored') && !internalMcpSource.includes('result.ignored'),
'Task status WebSocket and MCP responses should not retain ignored-write branches'
);
assert(
serverSource.includes('ensureTaskBoardMcpToolsFresh(client, session, currentThreadId)')
&& serverSource.includes('taskSchema: taskSchemaFingerprint')
&& serverSource.includes("client.reloadMcpServers()"),
'Codex App turns should invalidate cached MCP tools and carry a schema fingerprint fallback'
);
}
function assertFrontendGenerationControlsContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const controlsStart = source.indexOf('function updateGenerationControls()');
@@ -3594,6 +3710,9 @@ function assertServerSessionHistoryRequestIdContract() {
function publicTitleMetadata() { return {}; }
function sessionModelLabel(session) { return session.model; }
function isSessionRunning() { return false; }
function taskTrackingSnapshotForSession() {
return { enabled: false, statusId: 'unassigned', version: 0 };
}
function attachActiveRuntimeToWs() {}
function resolveClaudeSessionLocalMeta() { return null; }
${handleLoadSessionSource}
@@ -4070,6 +4189,19 @@ async function runCodexAppStaleRunningRegression(options = {}) {
), 5000);
assert(/expectedTurnId does not match active turn/.test(mismatchError.message || ''), 'Expected turn mismatch should not trigger stale-turn recovery');
assert(!messages.some((msg) => msg.type === 'system_message' && msg.sessionId === mismatchSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')), 'Expected turn mismatch should not start a replacement turn');
const mismatchStored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mismatchSession.sessionId}.json`), 'utf8'));
assert(
mismatchStored.messages.filter((message) => (
message.role === 'user' && message.id === 'regression-expected-turn-mismatch'
)).length === 0,
'Expected turn mismatch should roll back the persisted steer user message by clientMessageId'
);
assert(
mismatchStored.messages.filter((message) => (
message.role === 'user' && message.content === 'codexapp mismatch follow-up'
)).length === 0,
'Expected turn mismatch should not leave a ghost user message with the steer text'
);
ws.close();
});
}
@@ -4144,6 +4276,11 @@ function assertFrontendAssetVersionContract() {
indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Index template should load app.js with the __CC_WEB_FRONTEND_ASSET_VERSION__ placeholder'
);
assert(
indexSource.includes('task-board.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__')
&& indexSource.includes('task-board.css?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Task board assets should share the dynamic frontend asset version placeholder'
);
assert(
indexSource.includes('__CC_WEB_FRONTEND_ASSET_VERSION__') &&
indexSource.includes('window.ccWebFrontendAssetVersion'),
@@ -4155,12 +4292,12 @@ function assertFrontendAssetVersionContract() {
const computeSource = extractFunctionSource(serverSource, 'computeFrontendAssetVersion');
assert(
/crypto\.createHash\(['"]sha256['"]\)/.test(computeSource),
'computeFrontendAssetVersion should hash the app.js bytes with SHA-256'
);
assert(
/fs\.readFileSync\(\s*(?:PUBLIC_APP_PATH|path\.join\(\s*PUBLIC_DIR\s*,\s*['"]app\.js['"]\s*\))/.test(computeSource),
'computeFrontendAssetVersion should read public/app.js, not a hard-coded version string'
'computeFrontendAssetVersion should hash versioned frontend assets with SHA-256'
);
for (const assetName of ['index.html', 'app.js', 'task-board.js', 'task-board.css']) {
assert(computeSource.includes(`'${assetName}'`), `computeFrontendAssetVersion should include ${assetName}`);
}
assert(/fs\.readFileSync\(\s*path\.join\(\s*PUBLIC_DIR\s*,\s*assetName\s*\)\s*\)/.test(computeSource), 'computeFrontendAssetVersion should read each declared asset instead of a hard-coded version string');
assert(
/\.digest\(['"]hex['"]\)/.test(computeSource),
'computeFrontendAssetVersion should return a hex digest value'
@@ -4290,10 +4427,14 @@ async function runFrontendAssetVersionRegression() {
mkdirp(sessionsDir);
mkdirp(logsDir);
const expectedVersion = crypto.createHash('sha256')
.update(fs.readFileSync(PUBLIC_APP_PATH))
.digest('hex')
.slice(0, 16);
const expectedHash = crypto.createHash('sha256');
for (const assetName of ['index.html', 'app.js', 'task-board.js', 'task-board.css']) {
expectedHash.update(assetName);
expectedHash.update('\0');
expectedHash.update(fs.readFileSync(path.join(PUBLIC_DIR, assetName)));
expectedHash.update('\0');
}
const expectedVersion = expectedHash.digest('hex').slice(0, 16);
const port = await getFreePort();
const password = 'FrontendAssetVersion!234';
@@ -4312,14 +4453,19 @@ async function runFrontendAssetVersionRegression() {
const html = await response.text();
const injectedGlobalVersion = html.match(/window\.ccWebFrontendAssetVersion\s*=\s*'([a-f0-9]{16,64})'/)?.[1] || '';
const injectedScriptVersion = html.match(/app\.js\?v=([a-f0-9]{16,64})/)?.[1] || '';
const injectedTaskBoardScriptVersion = html.match(/task-board\.js\?v=([a-f0-9]{16,64})/)?.[1] || '';
const injectedTaskBoardStyleVersion = html.match(/task-board\.css\?v=([a-f0-9]{16,64})/)?.[1] || '';
assert(response.ok, `Frontend asset version index request should succeed, got ${response.status}`);
assert(
injectedGlobalVersion === expectedVersion,
`Served index should expose the computed app.js version to the frontend, got ${JSON.stringify(injectedGlobalVersion)}`
);
assert(
injectedScriptVersion === expectedVersion && !html.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'),
`Served index should replace every frontend asset version placeholder, got ${JSON.stringify(injectedScriptVersion)}`
injectedScriptVersion === expectedVersion
&& injectedTaskBoardScriptVersion === expectedVersion
&& injectedTaskBoardStyleVersion === expectedVersion
&& !html.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'),
`Served index should replace every frontend asset version placeholder, got ${JSON.stringify({ injectedScriptVersion, injectedTaskBoardScriptVersion, injectedTaskBoardStyleVersion })}`
);
const { ws, messages } = await connectWs(port, password);
@@ -5280,11 +5426,14 @@ async function runSessionPreviewMetadataRegression() {
const fullParseBytes = 64 * 1024;
const previewBytes = 16 * 1024;
const sourceSessionId = 'preview-parent-source';
const tailCwd = path.join(homeDir, '上下游协同平台');
const tailSessionId = 'preview-tail-top-level';
const childSessionId = 'preview-mcp-direct-child';
const nullSessionId = 'preview-null-fields';
const boundarySessionId = 'preview-boundary-join-risk';
const tailSessionPath = path.join(sessionsDir, `${tailSessionId}.json`);
const childSessionPath = path.join(sessionsDir, `${childSessionId}.json`);
const nullSessionPath = path.join(sessionsDir, `${nullSessionId}.json`);
const boundarySessionPath = path.join(sessionsDir, `${boundarySessionId}.json`);
@@ -5299,6 +5448,17 @@ async function runSessionPreviewMetadataRegression() {
agent: 'codexapp',
cwd: tailCwd,
}));
fs.writeFileSync(childSessionPath, makeLargeSessionPreviewFixture(childSessionId, {
title: 'Preview MCP Direct Child',
updated: '2026-08-06T00:00:30.000Z',
created: '2026-08-05T23:30:00.000Z',
pinnedAt: null,
titleSource: 'system',
createdFrom: { kind: 'mcp', sourceSessionId },
hasUnread: false,
agent: 'codexapp',
cwd: tailCwd,
}));
fs.writeFileSync(nullSessionPath, makeLargeSessionPreviewFixture(nullSessionId, {
title: null,
updated: '2026-08-06T00:01:00.000Z',
@@ -5315,6 +5475,7 @@ async function runSessionPreviewMetadataRegression() {
makeHeadTailJoinRiskFixture(boundarySessionId, previewBytes)
);
assert(fs.statSync(tailSessionPath).size > fullParseBytes, 'Tail fixture must use oversized preview parsing');
assert(fs.statSync(childSessionPath).size > fullParseBytes, 'MCP child fixture must use oversized preview parsing');
assert(fs.statSync(nullSessionPath).size > fullParseBytes, 'Null fixture must use oversized preview parsing');
assert(fs.statSync(boundarySessionPath).size > fullParseBytes, 'Boundary fixture must use oversized preview parsing');
@@ -5336,7 +5497,7 @@ async function runSessionPreviewMetadataRegression() {
const { ws, messages } = await connectWs(port, password);
const list = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
[tailSessionId, nullSessionId, boundarySessionId].every((id) => (
[tailSessionId, childSessionId, nullSessionId, boundarySessionId].every((id) => (
msg.sessions.some((session) => session.id === id)
))
), 5000);
@@ -5359,6 +5520,18 @@ async function runSessionPreviewMetadataRegression() {
assert(boundarySession.cwd === '', `Preview parser should not join head cwd key with tail value, got ${JSON.stringify(boundarySession.cwd)}`);
assert(boundarySession.projectName === '', `Boundary preview should not derive a projectName from a joined value, got ${JSON.stringify(boundarySession.projectName)}`);
const childList = await callInternalMcp(port, 'SessionPreviewMcp!234', {
tool: 'ccweb_list_conversations',
sourceSessionId,
args: { scope: 'children', limit: 10 },
});
assert(childList.status === 200 && childList.body?.ok, 'Preview children scope list should succeed');
const childIds = childList.body.conversations.map((conversation) => conversation.id);
assert(
JSON.stringify(childIds) === JSON.stringify([childSessionId]),
`Preview children scope should return only the direct MCP child, got ${JSON.stringify(childIds)}`
);
ws.close();
});
}
@@ -5438,6 +5611,70 @@ function assertCcwebDisplayImageContract() {
assert(prepareImagePayload({ source: 'relative.png' }).code === 'invalid_local_path', 'Local images should require absolute paths');
}
function assertCcwebListConversationsScopeContract() {
const server = fs.readFileSync(SERVER_PATH, 'utf8');
const {
TOOLS,
codexAppCommunicationTools,
isCallableToolName,
} = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server'));
const listTool = TOOLS.find((tool) => tool.name === 'ccweb_list_conversations');
const scopeSchema = listTool?.inputSchema?.properties?.scope;
assert(listTool, 'ccweb MCP should expose ccweb_list_conversations');
assert(
JSON.stringify(scopeSchema?.enum) === JSON.stringify(['all', 'children']),
'ccweb_list_conversations stdio/shared MCP schema should expose all and children scopes'
);
assert(
/默认返回全部对话/.test(listTool.description || '') &&
/scope=children/.test(listTool.description || '') &&
/直接创建/.test(listTool.description || ''),
'ccweb_list_conversations stdio/shared MCP prompt should explain children scope'
);
const toolNames = TOOLS.map((tool) => tool.name);
const sendTool = TOOLS.find((tool) => tool.name === 'ccweb_send_message');
assert(sendTool, 'ccweb MCP should expose the unified ccweb_send_message tool');
assert(!toolNames.includes('ccweb_request_reply'), 'ccweb MCP public tools/list should hide ccweb_request_reply');
assert(
sendTool.inputSchema?.required?.includes('replyMode') &&
JSON.stringify(sendTool.inputSchema?.properties?.replyMode?.enum) === JSON.stringify(['one_way', 'return_and_continue']),
'ccweb_send_message should require the exact one_way/return_and_continue replyMode enum'
);
assert(isCallableToolName('ccweb_request_reply'), 'Hidden legacy ccweb_request_reply should remain callable');
const dynamicTools = codexAppCommunicationTools();
const dynamicListTool = dynamicTools.find((tool) => tool.name === 'ccweb_list_conversations');
const dynamicSendTool = dynamicTools.find((tool) => tool.name === 'ccweb_send_message');
assert(dynamicListTool && dynamicSendTool, 'Codex App compatibility tools should include list and unified send');
assert(!dynamicTools.some((tool) => tool.name === 'ccweb_request_reply'), 'Codex App compatibility tools should hide ccweb_request_reply');
const { namespace: listNamespace, ...dynamicListDefinition } = dynamicListTool;
const { namespace: sendNamespace, ...dynamicSendDefinition } = dynamicSendTool;
assert(listNamespace === 'ccweb' && sendNamespace === 'ccweb', 'Codex App compatibility tools should use the ccweb namespace');
assert(JSON.stringify(dynamicListDefinition) === JSON.stringify(listTool), 'Codex App list tool should reuse the formal MCP definition');
assert(JSON.stringify(dynamicSendDefinition) === JSON.stringify(sendTool), 'Codex App send tool should reuse the formal MCP definition');
const dynamicToolsStart = server.indexOf('function codexAppCommunicationDynamicTools()');
const dynamicToolsEnd = server.indexOf('function handleCodexAppDynamicToolCall', dynamicToolsStart);
const dynamicToolsSource = server.slice(dynamicToolsStart, dynamicToolsEnd);
assert(
/codexAppCommunicationTools\s*\(/.test(dynamicToolsSource),
'Codex App compatibility tools should delegate to the formal shared definitions'
);
const dynamicCallStart = server.indexOf('function handleCodexAppDynamicToolCall');
const dynamicCallEnd = server.indexOf('function normalizeCodexAppUserInputAnswers', dynamicCallStart);
assert(
/ccweb_request_reply/.test(server.slice(dynamicCallStart, dynamicCallEnd)),
'Codex App old dynamic calls should keep accepting the hidden legacy reply tool'
);
const sharedMcpStart = server.indexOf('function handleMcpJsonRpcMessage');
const sharedMcpEnd = server.indexOf('async function handleSharedMcpHttpApi', sharedMcpStart);
assert(
/case ['"]tools\/call['"]:[\s\S]*isCallableToolName\s*\(/.test(server.slice(sharedMcpStart, sharedMcpEnd)),
'Shared MCP tools/call should accept hidden legacy names through the callable gate'
);
}
async function main() {
const targetIndex = process.argv.indexOf('--target');
const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim();
@@ -5527,6 +5764,7 @@ async function main() {
return;
}
if (regressionTarget === 'session-preview-metadata') {
assertCcwebListConversationsScopeContract();
await runSessionPreviewMetadataRegression();
console.log('Session preview metadata regression checks passed.');
return;
@@ -5566,6 +5804,7 @@ async function main() {
assertFrontendGildedThemeContract();
assertFrontendWastelandThemeContract();
assertFrontendSidebarCollapseContract();
assertTaskBoardIntegrationContract();
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
assertComposerSlashRoutingContract();
@@ -5591,6 +5830,7 @@ async function main() {
assertMultiAgentV2CompatibilityContract();
assertWindowsStartupContract();
assertCcwebDisplayImageContract();
assertCcwebListConversationsScopeContract();
await runSessionPreviewMetadataRegression();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
@@ -6113,6 +6353,81 @@ async function main() {
))
));
const scopeFixtureTimestamp = new Date().toISOString();
const branchFixtureId = 'mcp-children-branch-fixture';
const grandchildFixtureId = 'mcp-children-grandchild-fixture';
const scopeFixtureBase = {
created: scopeFixtureTimestamp,
updated: scopeFixtureTimestamp,
pinnedAt: null,
titleSource: 'system',
agent: 'codex',
permissionMode: 'yolo',
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
cwd: codexInitCwd,
};
fs.writeFileSync(path.join(sessionsDir, `${branchFixtureId}.json`), JSON.stringify({
...scopeFixtureBase,
id: branchFixtureId,
title: 'Branch Child Fixture',
createdFrom: {
kind: 'branch',
sourceSessionId: codexSession.sessionId,
sourceTitle: 'Regression Source',
sourceMessageIndex: 0,
createdAt: scopeFixtureTimestamp,
},
}, null, 2));
fs.writeFileSync(path.join(sessionsDir, `${grandchildFixtureId}.json`), JSON.stringify({
...scopeFixtureBase,
id: grandchildFixtureId,
title: 'MCP Grandchild Fixture',
createdFrom: {
kind: 'mcp',
sourceSessionId: mcpCreate.body.conversationId,
sourceTitle: 'MCP Created Conversation',
hopCount: 2,
createdAt: scopeFixtureTimestamp,
},
}, null, 2));
const listArgs = { agent: 'codex', limit: 100 };
const mcpListDefaultScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: listArgs,
});
const mcpListAllScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { ...listArgs, scope: 'all' },
});
const mcpListInvalidScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { ...listArgs, scope: 'unexpected' },
});
const defaultScopeIds = mcpListDefaultScope.body?.conversations?.map((conversation) => conversation.id) || [];
const allScopeIds = mcpListAllScope.body?.conversations?.map((conversation) => conversation.id) || [];
const invalidScopeIds = mcpListInvalidScope.body?.conversations?.map((conversation) => conversation.id) || [];
assert(mcpListDefaultScope.status === 200 && mcpListAllScope.status === 200, 'Default and all conversation scopes should succeed');
assert(JSON.stringify(defaultScopeIds) === JSON.stringify(allScopeIds), 'Omitted scope should return exactly the same conversations as scope=all');
assert(JSON.stringify(invalidScopeIds) === JSON.stringify(allScopeIds), 'Unexpected scope should conservatively fall back to all');
const mcpListChildrenScope = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
args: { ...listArgs, scope: 'children' },
});
assert(mcpListChildrenScope.status === 200 && mcpListChildrenScope.body?.ok, 'Children conversation scope should succeed');
const childrenScopeIds = mcpListChildrenScope.body.conversations.map((conversation) => conversation.id);
assert(
JSON.stringify(childrenScopeIds) === JSON.stringify([mcpCreate.body.conversationId]),
`Children scope should include only direct MCP children, got ${JSON.stringify(childrenScopeIds)}`
);
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
mkdirp(mcpReplyCreateCwd);
const mcpCreateReply = await callInternalMcp(port, internalMcpToken, {
@@ -6159,6 +6474,18 @@ async function main() {
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 invalidReplyMode = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'invalid reply mode should reject',
replyMode: 'wait_forever',
},
});
assert(invalidReplyMode.status === 400 && invalidReplyMode.body?.code === 'invalid_reply_mode', 'Explicit invalid replyMode should be rejected');
const crossSend = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
@@ -6166,9 +6493,11 @@ async function main() {
args: {
targetConversationId: crossTargetSession.sessionId,
content: 'cross hello from mcp',
replyMode: 'one_way',
},
});
assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`);
assert(crossSend.body.replyMode === 'one_way' && !crossSend.body.requestId, 'one_way send should return its mode without creating a reply request');
const crossUserBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
@@ -6182,7 +6511,13 @@ async function main() {
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');
assert(storedCrossMessage.crossConversation.replyMode === 'one_way' && storedCrossMessage.crossConversation.expectsReply !== true, 'one_way target metadata should not expect a reply');
assert(storedCrossTarget.messages.some((message) => (
message.role === 'assistant' &&
/来自/.test(String(message.content || '')) &&
/one_way/.test(String(message.content || '')) &&
/不会自动回传/.test(String(message.content || ''))
)), 'one_way runtime prompt should include source context and state that output will not auto-return');
const hopAllowed = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
@@ -6194,6 +6529,7 @@ async function main() {
},
});
assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`);
assert(hopAllowed.body.replyMode === 'one_way' && !hopAllowed.body.requestId, 'Legacy ccweb_send_message without replyMode should remain one_way');
const hopAllowedBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
@@ -6208,17 +6544,19 @@ async function main() {
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',
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossReplyTargetSession.sessionId,
content: 'cross reply requested',
replyMode: 'return_and_continue',
},
});
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');
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP return_and_continue should succeed: ${JSON.stringify(requestReply.body)}`);
assert(requestReply.body.replyMode === 'return_and_continue', 'MCP return_and_continue should echo its normalized reply mode');
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP return_and_continue should return a waiting request id');
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP return_and_continue should declare source auto-run delivery');
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossReplyTargetSession.sessionId &&
@@ -6226,7 +6564,8 @@ async function main() {
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');
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'return_and_continue target message should persist hop count');
assert(requestReplyTargetBubble.message.crossConversation.replyMode === 'return_and_continue', 'return_and_continue target metadata should persist reply mode');
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) &&
@@ -6240,8 +6579,14 @@ async function main() {
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');
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'return_and_continue target message should persist waiting metadata');
assert(storedReplyTarget.messages.some((message) => (
message.role === 'assistant' &&
/cross reply requested/.test(String(message.content || '')) &&
/return_and_continue/.test(String(message.content || '')) &&
/自动回传/.test(String(message.content || '')) &&
/(不要|禁止)[\s\S]*(手工|手动)/.test(String(message.content || ''))
)), 'return_and_continue target prompt should promise automatic return and prevent duplicate manual return');
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];
@@ -6249,15 +6594,19 @@ async function main() {
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.crossConversation.originalRequest === 'cross reply requested', 'Returned cross message should retain the original request for correlation');
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) => (
const replyAutoRunAssistant = storedReplySource.messages.slice(storedReplyMessageIndex + 1).find((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');
/cross reply requested/.test(String(message.content || ''))
));
assert(replyAutoRunAssistant, 'Returned cross message should trigger the source session to run again');
assert(new RegExp(requestReply.body.requestId).test(String(replyAutoRunAssistant.content || '')), 'Source auto-run prompt should include requestId');
assert(new RegExp(crossReplyTargetSession.sessionId).test(String(replyAutoRunAssistant.content || '')), 'Source auto-run prompt should include target conversation id');
assert(/子对话/.test(String(replyAutoRunAssistant.content || '')) && /已返回不等于已完成/.test(String(replyAutoRunAssistant.content || '')), 'Source auto-run prompt should identify the child reply and require a completion check');
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
mkdirp(busySourceCwd);
@@ -6280,6 +6629,7 @@ async function main() {
},
});
assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`);
assert(busyRequestReply.body.replyMode === 'return_and_continue', 'Legacy ccweb_request_reply should map to return_and_continue');
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);
@@ -6289,6 +6639,7 @@ async function main() {
reply.requestId === busyRequestReply.body.requestId &&
reply.sourceConversationId === busySourceSession.sessionId &&
reply.status === 'ready' &&
reply.originalRequest === 'busy source reply requested' &&
/busy source reply requested/.test(String(reply.replyText || ''))
))
));
@@ -6303,7 +6654,11 @@ async function main() {
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');
assert(busyPendingList.body.replies.some((reply) => (
reply.requestId === busyRequestReply.body.requestId &&
reply.status === 'ready' &&
reply.originalRequest === 'busy source reply requested'
)), 'Pending reply list should include the queued ready reply and original request');
const busyPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
@@ -6312,6 +6667,7 @@ async function main() {
});
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(busyPendingDetail.body.originalRequest === 'busy source reply requested', 'Pending reply detail should expose the original request');
assert(/busy source reply requested/.test(String(busyPendingDetail.body.replyText || '')), 'Pending reply detail should expose target assistant output');
const busyConversationList = await callInternalMcp(port, internalMcpToken, {
@@ -6341,11 +6697,14 @@ async function main() {
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) => (
const busyAutoRunAssistant = storedBusySource.messages.slice(busyReplyIndex + 1).find((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');
/busy source reply requested/.test(String(message.content || ''))
));
assert(busyAutoRunAssistant, 'Busy source should auto-run after the queued child reply is flushed');
assert(new RegExp(busyRequestReply.body.requestId).test(String(busyAutoRunAssistant.content || '')), 'Queued source auto-run should include requestId');
assert(new RegExp(busyReplyTargetSession.sessionId).test(String(busyAutoRunAssistant.content || '')), 'Queued source auto-run should include target conversation id');
assert(/子对话/.test(String(busyAutoRunAssistant.content || '')) && /已返回不等于已完成/.test(String(busyAutoRunAssistant.content || '')), 'Queued source auto-run should require a completion check');
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
@@ -6354,6 +6713,7 @@ async function main() {
});
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');
assert(returnedPendingDetail.body.originalRequest === 'busy source reply requested', 'Returned pending reply detail should retain the original request');
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);
@@ -6725,6 +7085,7 @@ async function main() {
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');
const baselineMcpReloadCount = Number(reloadMcpResult.result?.reloadCount || 0);
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');
@@ -6742,11 +7103,39 @@ async function main() {
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');
const codexAppUntrackedComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-prompt-user-mcp');
assert(codexAppUntrackedComposer.items[0]?.name === 'ccweb_prompt_user', 'Untracked Codex App composer should preserve prompt_user priority');
assert(!codexAppUntrackedComposer.items.some((item) => item.name === 'ccweb_task_status_list'), 'Untracked composer must not expose retired status_list');
assert(!codexAppUntrackedComposer.items.some((item) => item.name === 'ccweb_task_update'), 'Untracked composer must not expose task update');
assert(codexAppUntrackedComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-app-project'), 'Untracked task MCP regression should retain other project MCP servers');
ws.send(JSON.stringify({
type: 'task_tracking_set',
requestId: 'reg-codexapp-enable-task-tracking',
sessionId: codexAppSession.sessionId,
enabled: true,
expectedVersion: codexAppSession.taskTracking?.version || 0,
}));
const trackedResult = await nextMessage(messages, ws, (msg) => msg.type === 'task_tracking_result' && msg.requestId === 'reg-codexapp-enable-task-tracking');
assert(trackedResult.ok === true && trackedResult.task?.taskTracking?.enabled === true, 'Regression fixture should enable task tracking through the production WebSocket path');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-tracked', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppTrackedComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-tracked');
assert(
codexAppTrackedComposer.items.slice(0, 2).map((item) => item.name).join(',')
=== 'ccweb_prompt_user,ccweb_task_update',
'Tracked empty slash initial viewport should expose prompt_user then the sole dynamic task update tool'
);
assert(!codexAppTrackedComposer.items.some((item) => item.name === 'ccweb_task_status_list'), 'Tracked composer must not expose retired status_list');
assert(codexAppTrackedComposer.items.findIndex((item) => item.name === 'ccweb_task_update') === 1, 'Tracked task update must stay inside the initial visible suggestions');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp task schema refresh', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const reloadAfterTracking = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
assert(
Number(reloadAfterTracking.result?.reloadCount || 0) >= baselineMcpReloadCount + 2,
'Changing tracking should trigger one implicit Codex App MCP reload before the next turn'
);
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');
@@ -7117,6 +7506,7 @@ async function main() {
args: {
targetConversationId: codexAppSession.sessionId,
content: 'running codexapp target should reject this',
replyMode: 'one_way',
},
});
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');