feat: overhaul task board and cross-conversation workflows
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user