修复 Codex App MCP 重载与线程库存恢复

This commit is contained in:
shiyue
2026-09-15 13:58:38 +08:00
parent a1e66c08f1
commit c38c937c05
4 changed files with 211 additions and 24 deletions

View File

@@ -1170,6 +1170,9 @@ function handleRequest(message) {
}
if (method === 'mcpServerStatus/list') {
const thread = ensureThread(params.threadId, params);
const projectMcpConfigured = Boolean(thread.config?.['mcp_servers.reg-app-project'])
|| thread.staleProjectMcpInventoryWarmup === true;
if (thread.staleProjectMcpInventoryWarmup === true) thread.staleProjectMcpInventoryWarmup = false;
send({
id,
result: {
@@ -1188,7 +1191,7 @@ function handleRequest(message) {
},
},
},
{
...(projectMcpConfigured ? [{
name: 'reg-app-project',
authStatus: 'unsupported',
resources: [],
@@ -1201,7 +1204,7 @@ function handleRequest(message) {
inputSchema: { type: 'object' },
},
},
},
}] : []),
{
name: 'reg-runtime-only',
authStatus: 'unsupported',
@@ -1218,13 +1221,19 @@ function handleRequest(message) {
},
],
nextCursor: null,
threadId: thread.id,
},
});
return;
}
if (method === 'thread/start') {
const thread = ensureThread(null, params);
if (process.env.MOCK_CODEX_APP_STALE_PROJECT_MCP === '1'
&& String(params.cwd || '').includes('codexapp-stale-mcp')
&& params.config?.['mcp_servers.reg-app-project']) {
thread.staleProjectMcpConfig = params.config['mcp_servers.reg-app-project'];
thread.staleProjectMcpInventoryWarmup = true;
delete thread.config['mcp_servers.reg-app-project'];
}
thread.lastThreadConfigMethod = 'thread/start';
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;

View File

@@ -1635,6 +1635,9 @@ function assertCcwebMcpRecoveryContract() {
const readinessSource = extractFunctionSource(serverSource, 'waitForCodexAppMcpReadyStatus');
const preflightSource = extractFunctionSource(serverSource, 'ensureCodexAppMcpReadyForThread');
const inventorySource = extractFunctionSource(serverSource, 'loadCodexAppMcpInventory');
const reloadStart = serverSource.indexOf('async function handleReloadMcpApi');
const reloadEnd = serverSource.indexOf('\nfunction setRuntimeSessionId', reloadStart);
const reloadSource = reloadStart >= 0 && reloadEnd > reloadStart ? serverSource.slice(reloadStart, reloadEnd) : '';
assert(
serverSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
&& serverSource.includes('CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,')
@@ -1645,11 +1648,12 @@ function assertCcwebMcpRecoveryContract() {
&& startTurnSource.includes('ensureCodexAppMcpReadyForThread')
&& readinessSource.includes("status === 'ready'")
&& statusSource.includes('statusThreadId !== normalizedThreadId')
&& preflightSource.includes('waitForCodexAppMcpReadyStatus')
&& !preflightSource.includes('waitForCodexAppMcpInventory')
&& preflightSource.includes('waitForCodexAppMcpInventory')
&& preflightSource.includes('requireServers')
&& inventorySource.includes('return []')
&& reloadSource.includes('refreshCodexAppThreadMcpConfig')
&& !serverSource.includes('allowThreadMismatch: true'),
'ccweb MCP recovery should gate turns on current-thread startup status and degrade inventory lookup safely'
'ccweb MCP recovery should gate turns on current-thread inventory and rebind the original thread before reload'
);
assert(
serverSource.includes('codex_app_mcp_reload_timeout')
@@ -1667,7 +1671,8 @@ function assertCcwebMcpRecoveryContract() {
mockSource.includes('mock-reload-unrelated-thread')
&& mockSource.includes('without thread id')
&& mockSource.includes("name: 'ccweb'")
&& mockSource.includes('ccweb_list_conversations'),
&& mockSource.includes('ccweb_list_conversations')
&& mockSource.includes('projectMcpConfigured'),
'MCP reload regression fixture should cover unrelated notifications and current-thread tool inventory'
);
}
@@ -1678,9 +1683,11 @@ function assertCodexAppMcpReadinessContract() {
const statusSource = extractFunctionSource(source, 'codexAppMcpStatusForThread');
const readinessSource = extractFunctionSource(source, 'waitForCodexAppMcpReadyStatus');
const inventorySource = extractFunctionSource(source, 'loadCodexAppMcpInventory');
assert(readinessSource.includes("status === 'ready'"), 'Codex App first turn should wait for current-thread ccweb readiness');
const preflightSource = extractFunctionSource(source, 'ensureCodexAppMcpReadyForThread');
assert(readinessSource.includes("status === 'ready'"), 'Codex App readiness should retain current-thread startup status handling');
assert(readinessSource.includes("status === 'failed'") && readinessSource.includes("status === 'cancelled'"), 'Codex App readiness should surface terminal MCP startup failures');
assert(statusSource.includes('statusThreadId !== normalizedThreadId'), 'Codex App readiness must reject stale status from another thread');
assert(preflightSource.includes('waitForCodexAppMcpInventory') && preflightSource.includes('requireServers'), 'Codex App first turn should verify the configured server inventory before sending a turn');
assert(inventorySource.includes('return []'), 'Composer MCP inventory failure should degrade to local suggestions');
assert(workerClientSource.includes("sendWorker('start', {}, 120000)"), 'Codex App worker start should cover initialize and best-effort capability probes');
}
@@ -7374,6 +7381,7 @@ async function main() {
CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS: '100',
CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS: '1000',
MOCK_CODEX_APP_MCP_RELOAD_TIMEOUT_ON_SECOND: '1',
MOCK_CODEX_APP_STALE_PROJECT_MCP: '1',
}, async () => {
await assertWsUpgradeRejected(port, '/not-ws');
@@ -8320,6 +8328,34 @@ async function main() {
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
assert(codexAppSession.model === 'gpt-5.5(max)', 'Codex App new_session should preserve the max default Codex model');
const staleMcpCwd = path.join(tempRoot, 'codexapp-stale-mcp');
mkdirp(path.join(staleMcpCwd, '.codex'));
fs.writeFileSync(path.join(staleMcpCwd, '.codex', 'config.toml'), [
'[mcp_servers.reg-app-project]',
'type = "stdio"',
`command = ${JSON.stringify(process.execPath)}`,
'args = ["regression-app-mcp.js"]',
'enabled = true',
].join('\n'));
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: staleMcpCwd, mode: 'yolo' }));
const staleMcpSession = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === staleMcpCwd
));
ws.send(JSON.stringify({ type: 'message', text: 'stale mcp dynamic inventory probe', sessionId: staleMcpSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const staleMcpTool = await nextMessage(messages, ws, (msg) => (
msg.type === 'tool_end' && msg.sessionId === staleMcpSession.sessionId && msg.toolUseId === 'mcp-ccweb-list'
));
assert(/"hasProjectMcpConfig": false/.test(staleMcpTool.result || ''), 'Stale MCP fixture should reproduce an original thread missing its project MCP config');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === staleMcpSession.sessionId);
const staleMcpReload = await postAuthedJson(port, token, `/api/sessions/${staleMcpSession.sessionId}/reload-mcp`);
assert(staleMcpReload.ok === true, `MCP reload should restore the stale original thread: ${JSON.stringify(staleMcpReload)}`);
assert(staleMcpReload.mcpStatus?.inventoryStatus === 'ready', `MCP reload should verify the restored thread inventory: ${JSON.stringify(staleMcpReload)}`);
assert(staleMcpReload.mcpStatus?.inventoryServers?.some((server) => (
server.server === 'reg-app-project'
&& Array.isArray(server.tools)
&& server.tools.some((tool) => tool.name === 'reg_app_inspect')
)), `MCP reload inventory should contain the project MCP restored by thread resume: ${JSON.stringify(staleMcpReload.mcpStatus)}`);
const codexAppGoalTitleCwd = path.join(tempRoot, 'codexapp-goal-title');
mkdirp(codexAppGoalTitleCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppGoalTitleCwd, mode: 'yolo' }));