修复 Codex App MCP 跨线程假就绪

This commit is contained in:
shiyue
2026-09-14 17:42:05 +08:00
parent e785e3c5dd
commit b1cd820819
4 changed files with 385 additions and 55 deletions

View File

@@ -296,6 +296,19 @@ async function postAuthedJson(port, token, pathname, body = {}) {
return payload;
}
async function postAuthedJsonAllowFailure(port, token, pathname, body = {}) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const payload = await response.json();
return { status: response.status, payload };
}
async function callInternalMcp(port, token, payload) {
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
method: 'POST',
@@ -1617,16 +1630,21 @@ function assertCcwebMcpRecoveryContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
const mockSource = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
const startTurnSource = extractFunctionSource(serverSource, 'startCodexAppTurn');
assert(
serverSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
&& serverSource.includes('CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,')
&& serverSource.includes('CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS')
&& serverSource.includes('CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS,')
&& serverSource.includes('allowThreadMismatch: true'),
'ccweb MCP recovery should expose configurable startup/reload windows and tolerate global reload thread IDs'
&& serverSource.includes('queryCodexAppMcpInventory')
&& serverSource.includes('inventory_thread_mismatch')
&& startTurnSource.includes('ensureCodexAppMcpReadyForThread')
&& !serverSource.includes('allowThreadMismatch: true'),
'ccweb MCP recovery should expose configurable windows, strict thread routing, and inventory verification'
);
assert(
serverSource.includes('codex_app_mcp_reload_timeout')
&& serverSource.includes('codex_app_mcp_reload_inventory_failed')
&& serverSource.includes("creationStatus: 'created_but_initial_message_failed'")
&& serverSource.includes('mcpStatus: buildCodexAppMcpStatusSummary'),
'ccweb MCP recovery should persist timeout failure and expose creation/MCP status'
@@ -1636,7 +1654,13 @@ function assertCcwebMcpRecoveryContract() {
&& runtimeSource.includes('mcp_servers.ccweb.startup_timeout_sec=${CCWEB_MCP_STARTUP_TIMEOUT_SEC}'),
'Legacy Codex runtime should use the same configurable ccweb MCP startup timeout'
);
assert(mockSource.includes('mock-reload-unrelated-thread'), 'MCP reload regression fixture should cover unrelated notification thread ids');
assert(
mockSource.includes('mock-reload-unrelated-thread')
&& mockSource.includes('without thread id')
&& mockSource.includes("name: 'ccweb'")
&& mockSource.includes('ccweb_list_conversations'),
'MCP reload regression fixture should cover unrelated notifications and current-thread tool inventory'
);
}
function assertFrontendSubagentCardMetadataContract() {
@@ -2603,6 +2627,23 @@ function assertCodexAppTransientReconnectContract() {
assert(sent.filter((message) => message.type === 'system_message').length === 7, 'Reconnect progress and terminal errors should each be forwarded as system messages');
}
function assertCodexAppBranchForkContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
const branchSource = extractFunctionSource(source, 'resolveBranchSource');
const turnSource = extractFunctionSource(source, 'startCodexAppTurn');
assert(branchSource.includes('sourceThreadId'), 'Codex App branch should persist the source thread id');
assert(branchSource.includes('sourceTurnId'), 'Codex App branch should persist the selected source turn id');
assert(branchSource.includes("selectedMessage?.role !== 'assistant'"), 'Codex App branch should require the selected assistant message');
assert(branchSource.includes("selectedMessage?.codexAppTurnId"), 'Codex App branch should use the selected assistant turn id only');
assert(!branchSource.includes('slice(0, sourceMessageIndex + 1)].reverse'), 'Codex App branch should not guess an earlier turn id');
assert(turnSource.includes("client.request('thread/fork'"), 'Codex App branch should call native thread/fork');
assert(turnSource.includes('lastTurnId: sourceTurnId'), 'Codex App fork should stop at the selected source turn');
assert(turnSource.includes('缺少来源线程或回合信息'), 'Codex App branch should refuse to start an empty thread when fork metadata is missing');
const goalSource = extractFunctionSource(source, 'ensureCodexAppGoalThread');
assert(goalSource.includes("client.request('thread/fork'"), 'Codex App goal should call native thread/fork for branches');
assert(goalSource.includes('lastTurnId: sourceTurnId'), 'Codex App goal fork should stop at the selected source turn');
}
function assertGoalModeTitleContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
@@ -7000,6 +7041,11 @@ async function main() {
console.log('Codex App retry runtime regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-branch-fork') {
assertCodexAppBranchForkContract();
console.log('Codex App branch fork regression checks passed.');
return;
}
if (regressionTarget === 'goal-mode-title') {
assertGoalModeTitleContract();
console.log('Goal mode/title regression checks passed.');
@@ -7026,6 +7072,7 @@ async function main() {
assertFrontendSubagentCardMetadataContract();
assertCodexAppRuntimeSubAgentActivityContract();
assertCodexAppTransientReconnectContract();
assertCodexAppBranchForkContract();
assertGoalModeTitleContract();
assertFrontendPrimaryCodexAppUiContract();
assertSetTitleMcpContract();
@@ -8594,7 +8641,10 @@ async function main() {
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`);
const reloadAfterTrackingResponse = await postAuthedJsonAllowFailure(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
const reloadAfterTracking = reloadAfterTrackingResponse.payload;
assert(reloadAfterTrackingResponse.status === 503, 'MCP reload without a final startup notification should return HTTP 503');
assert(reloadAfterTracking.ok === false, 'MCP reload without a final startup notification should report ok=false');
assert(
Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1,
'Task tracking changes must not trigger an implicit MCP reload; only the explicit reload request should count'