修复 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' }));

174
server.js
View File

@@ -3486,14 +3486,45 @@ function summarizeCodexAppMcpInventory(servers) {
};
}
function expectedCodexAppMcpServers(session, options = {}) {
return listRuntimeMcpServerConfigs({
...options,
session,
agent: 'codexapp',
})
.map((item) => normalizeMcpServerName(item?.server || item?.name))
.filter(Boolean);
}
function missingCodexAppMcpServers(servers, requiredServers = []) {
const available = new Map(
(Array.isArray(servers) ? servers : []).map((server) => [
codexAppMcpStatusKey(server?.server),
Array.isArray(server?.tools) ? server.tools.length : 0,
]),
);
return Array.from(new Set(requiredServers
.map((server) => normalizeMcpServerName(server))
.filter(Boolean)))
.filter((server) => !available.has(codexAppMcpStatusKey(server))
|| available.get(codexAppMcpStatusKey(server)) <= 0);
}
function recordCodexAppMcpInventoryState(session, inventoryResult) {
if (!session || !isCodexAppSession(session)) return;
const state = ensureCodexAppMcpStartupState(session);
if (!state) return;
const summary = summarizeCodexAppMcpInventory(inventoryResult?.servers || []);
const inventoryThreadId = inventoryResult?.threadId || getRuntimeSessionId(session) || null;
if (inventoryResult?.ok && inventoryThreadId) {
codexAppMcpInventoryByThread.set(inventoryThreadId, {
fetchedAt: Date.now(),
servers: inventoryResult.servers || [],
});
}
state.inventory = {
status: inventoryResult?.ok ? 'ready' : 'failed',
threadId: inventoryResult?.threadId || getRuntimeSessionId(session) || null,
threadId: inventoryThreadId,
updatedAt: new Date().toISOString(),
...summary,
error: inventoryResult?.ok ? '' : safeMcpStatusString(inventoryResult?.error || 'MCP 工具清单查询失败', 500),
@@ -3545,6 +3576,8 @@ async function queryCodexAppMcpInventory(session, options = {}) {
const servers = mergeCodexAppMcpInventories(allServers);
const summary = summarizeCodexAppMcpInventory(servers);
const requiredServers = Array.isArray(options.requireServers) ? options.requireServers : [];
const missingServers = missingCodexAppMcpServers(servers, requiredServers);
if (options.requireCcweb && !summary.ccwebReady) {
return {
ok: false,
@@ -3552,9 +3585,21 @@ async function queryCodexAppMcpInventory(session, options = {}) {
threadId,
servers,
...summary,
missingServers: missingServers.length > 0 ? missingServers : [CODEX_APP_MCP_DEFAULT_SERVER],
error: '当前线程的 ccweb MCP 未返回可调用工具,不能判定 MCP 已就绪。',
};
}
if (missingServers.length > 0) {
return {
ok: false,
code: 'configured_mcp_tools_unavailable',
threadId,
servers,
...summary,
missingServers,
error: `当前线程缺少可调用 MCP 工具:${missingServers.join('、')}`,
};
}
return { ok: true, code: 'ok', threadId, servers, ...summary, error: '' };
}
@@ -3568,7 +3613,7 @@ async function waitForCodexAppMcpInventory(session, options = {}) {
...options,
timeoutMs: Math.min(Number(options.timeoutMs || 5000), remaining),
});
if (lastResult.ok || !['ccweb_tools_unavailable', 'inventory_request_failed'].includes(lastResult.code)) return lastResult;
if (lastResult.ok || !['ccweb_tools_unavailable', 'configured_mcp_tools_unavailable', 'inventory_request_failed'].includes(lastResult.code)) return lastResult;
if (Date.now() >= deadline) return lastResult;
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now()))));
}
@@ -3648,20 +3693,71 @@ async function loadCodexAppMcpInventory(session) {
return pending;
}
function resetCodexAppMcpStateForThread(session, threadId, reason = '') {
if (!session || !isCodexAppSession(session)) return null;
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
if (!normalizedThreadId) return null;
const state = ensureCodexAppMcpStartupState(session);
if (!state) return null;
const previousThreadId = normalizeCodexAppThreadId(state.threadId || '');
if (reason === 'reload' || (previousThreadId && previousThreadId !== normalizedThreadId)) {
state.servers = {};
state.inventory = null;
}
state.threadId = normalizedThreadId;
state.updatedAt = new Date().toISOString();
return state;
}
async function refreshCodexAppThreadMcpConfig(client, session, threadId, options = {}) {
const normalizedThreadId = normalizeCodexAppThreadId(threadId);
if (!normalizedThreadId) {
const error = new Error('重载 MCP 前无法恢复原对话:当前会话没有 Codex App 线程 ID。');
error.code = 'codexapp_thread_rebind_missing';
throw error;
}
const threadParams = codexAppThreadParams(session, options);
let resumed;
try {
resumed = await client.request('thread/resume', {
...threadParams,
threadId: normalizedThreadId,
}, options.timeoutMs || 60000);
} catch (err) {
const error = new Error(`重载 MCP 前恢复原对话线程失败:${err?.message || String(err)}`);
error.code = 'codexapp_thread_rebind_failed';
throw error;
}
const resumedThreadId = normalizeCodexAppThreadId(resumed?.thread?.id || normalizedThreadId);
if (resumedThreadId !== normalizedThreadId) {
const error = new Error(`重载 MCP 恢复到了不同线程(期望 ${normalizedThreadId.slice(0, 24)},实际 ${resumedThreadId.slice(0, 24)})。`);
error.code = 'codexapp_thread_rebind_mismatch';
throw error;
}
return resumedThreadId;
}
async function ensureCodexAppMcpReadyForThread(session, threadId) {
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
if (!normalizedThreadId) {
throw new Error('Codex App MCP 校验失败:当前线程 ID 不存在。');
}
const requiredServers = expectedCodexAppMcpServers(session);
const cached = codexAppMcpInventoryByThread.get(normalizedThreadId);
if (cached && Date.now() - cached.fetchedAt < CODEX_APP_MCP_INVENTORY_TTL_MS) {
const cachedSummary = summarizeCodexAppMcpInventory(cached.servers);
if (cachedSummary.ccwebReady) return cached.servers;
const cachedMissing = missingCodexAppMcpServers(cached.servers, requiredServers);
if (cachedSummary.ccwebReady && cachedMissing.length === 0) return cached.servers;
}
// MCP 启动状态和完整 inventory 都是运行能力探测,不能阻断首轮 turn/start。
// app-server 会在工具真正调用时继续管理 MCP 生命周期;慢的全局 MCP例如
// 远程 playwright不应让当前线程连普通消息也发不出去。
const result = await waitForCodexAppMcpReadyStatus(session, normalizedThreadId, { maxWaitMs: 0 });
const result = await waitForCodexAppMcpInventory(session, {
threadId: normalizedThreadId,
requireCcweb: true,
requireServers: requiredServers,
timeoutMs: 5000,
// 工具清单探测是 best-effort全局慢 MCP 不能把普通对话阻塞到启动超时。
// 重载接口仍会用更长窗口并明确返回失败;普通消息只在确认配置服务器缺失时阻断。
maxWaitMs: 5000,
});
if (!result.ok) {
plog('WARN', 'codex_app_mcp_preflight_failed', {
sessionId: session?.id ? session.id.slice(0, 8) : null,
@@ -3669,9 +3765,25 @@ async function ensureCodexAppMcpReadyForThread(session, threadId) {
code: result.code,
error: result.error,
});
return [];
if (['inventory_request_failed', 'client_unavailable', 'ccweb_tools_unavailable'].includes(result.code)) {
plog('WARN', 'codex_app_mcp_preflight_degraded', {
sessionId: session?.id ? session.id.slice(0, 8) : null,
threadId: normalizedThreadId.slice(0, 16),
code: result.code,
error: result.error,
});
return codexAppMcpInventoryByThread.get(normalizedThreadId)?.servers || [];
}
const missing = Array.isArray(result.missingServers) && result.missingServers.length > 0
? `(缺少:${result.missingServers.join('、')}`
: '';
throw new Error(`当前线程的 MCP 工具清单不可用${missing}${result.error || '请点击“重载 MCP”后重试。'}`);
}
return codexAppMcpInventoryByThread.get(normalizedThreadId)?.servers || [];
codexAppMcpInventoryByThread.set(normalizedThreadId, {
fetchedAt: Date.now(),
servers: result.servers,
});
return result.servers;
}
function summarizeSkillDependencies(skill) {
@@ -4739,6 +4851,9 @@ function buildCodexAppMcpStatusSummary(session, options = {}) {
const serversObject = mcpStatusObject(state.servers) || {};
const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record));
const inventory = mcpStatusObject(state.inventory) || null;
const inventoryRuntime = inventory?.threadId
? codexAppMcpInventoryByThread.get(normalizeCodexAppThreadId(inventory.threadId))?.servers
: null;
let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER);
const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null;
if (!current) {
@@ -4765,6 +4880,7 @@ function buildCodexAppMcpStatusSummary(session, options = {}) {
inventoryServerCount: Number.isFinite(Number(inventory?.serverCount)) ? Number(inventory.serverCount) : 0,
inventoryToolCount: Number.isFinite(Number(inventory?.toolCount)) ? Number(inventory.toolCount) : 0,
ccwebToolCount: Number.isFinite(Number(inventory?.ccwebToolCount)) ? Number(inventory.ccwebToolCount) : 0,
inventoryServers: Array.isArray(inventoryRuntime) ? inventoryRuntime : [],
inventoryError: safeMcpStatusString(inventory?.error || '', 500) || null,
};
}
@@ -4775,6 +4891,7 @@ function markCodexAppMcpReloadPending(session, sessionId) {
const reloadRequestId = crypto.randomUUID();
const state = ensureCodexAppMcpStartupState(session);
if (!state) return { requestedAt, summary: null };
resetCodexAppMcpStateForThread(session, threadId, 'reload');
state.reloadRequestedAt = requestedAt;
state.reloadRequestId = reloadRequestId;
state.updatedAt = requestedAt;
@@ -4876,20 +4993,28 @@ function updateCodexAppSessionMcpStatus(sessionId, statusRecord) {
if (!normalizedId) return null;
const session = loadSession(normalizedId);
if (!session || !isCodexAppSession(session)) return null;
const sessionThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(session) || '');
const statusThreadId = normalizeCodexAppThreadId(statusRecord?.threadId || '');
if (sessionThreadId && statusThreadId && sessionThreadId !== statusThreadId) {
plog('WARN', 'codex_app_mcp_status_thread_mismatch', {
sessionId: normalizedId.slice(0, 8),
expectedThreadId: sessionThreadId.slice(0, 24),
actualThreadId: statusThreadId.slice(0, 24),
});
return null;
}
resetCodexAppMcpStateForThread(session, sessionThreadId || statusThreadId, 'status');
const state = ensureCodexAppMcpStartupState(session);
if (!state) return null;
const record = publicCodexAppMcpStatusRecord({
...statusRecord,
threadId: statusRecord.threadId || state.threadId || getRuntimeSessionId(session) || null,
threadId: statusThreadId || state.threadId || sessionThreadId || null,
source: 'notification',
});
const key = codexAppMcpStatusKey(record.name);
state.servers[key] = record;
state.updatedAt = record.updatedAt;
state.threadId = record.threadId || state.threadId || null;
if (key === codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER) && isFinalCodexAppMcpStatus(record.status)) {
pendingCodexAppMcpReloads.delete(normalizedId);
}
saveSession(session);
const summary = buildCodexAppMcpStatusSummary(session);
resolveCodexAppMcpStatusWaiters(normalizedId, summary);
@@ -5012,7 +5137,19 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
const client = clientResult.client;
await client.start();
const currentThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
if (currentThreadId) codexAppMcpInventoryByThread.delete(currentThreadId);
if (!currentThreadId) {
const error = new Error('重载 MCP 失败:当前会话没有可恢复的原始 Codex App 线程,请先发送一条消息建立线程。');
error.code = 'codexapp_thread_missing';
throw error;
}
codexAppMcpInventoryByThread.delete(currentThreadId);
codexAppMcpInventoryPending.delete(currentThreadId);
resetCodexAppMcpStateForThread(session, currentThreadId, 'reload');
saveSession(session);
await refreshCodexAppThreadMcpConfig(client, session, currentThreadId, {
mcpContext: {},
timeoutMs: 60000,
});
const pendingMcp = markCodexAppMcpReloadPending(session, sessionId);
reloadRequestedAt = pendingMcp.requestedAt;
const result = typeof client.reloadMcpServers === 'function'
@@ -5039,10 +5176,12 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
inventoryResult = await waitForCodexAppMcpInventory(targetSession, {
threadId: currentThreadId,
requireCcweb: true,
requireServers: expectedCodexAppMcpServers(targetSession),
timeoutMs: 5000,
maxWaitMs: 5000,
});
updateCodexAppSessionMcpInventory(sessionId, inventoryResult);
mcpStatus = buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt });
if (!inventoryResult.ok) {
const inventoryMessage = inventoryResult.error || '当前线程 MCP 工具清单不可用,不能判定 MCP 已就绪。';
mcpStatus = markCodexAppMcpReloadFailed(sessionId, inventoryMessage)
@@ -5057,6 +5196,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
}
const reloadReady = mcpStatus?.status === 'ready' && inventoryResult?.ok === true;
pendingCodexAppMcpReloads.delete(sessionId);
plog('INFO', 'codex_app_mcp_reload_requested', {
sessionId: sessionId.slice(0, 8),
@@ -5083,6 +5223,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
const mcpStatus = reloadRequestedAt
? markCodexAppMcpReloadFailed(sessionId, message)
: buildCodexAppMcpStatusSummary(session);
pendingCodexAppMcpReloads.delete(sessionId);
return jsonResponse(res, unsupported ? 501 : 500, {
ok: false,
code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed',
@@ -5105,6 +5246,7 @@ function setRuntimeSessionId(session, runtimeId) {
codexAppMcpInventoryByThread.delete(previousThreadId);
codexAppMcpInventoryPending.delete(previousThreadId);
}
if (nextThreadId) resetCodexAppMcpStateForThread(session, nextThreadId, 'thread_changed');
if (previousThreadId && previousThreadId !== nextThreadId) {
session.codexAppGoal = null;
if (session.id) codexAppGoalStates.delete(session.id);
@@ -13889,8 +14031,8 @@ async function startCodexAppTurn(sessionId, input) {
saveSession(session);
persistCodexAppTurnState(sessionId, entry, { immediate: true });
// 轮消息必须先确认当前线程真的拿到了 ccweb 工具;仅收到 startupStatus=ready
// 不足以证明模型可调用 MCP失败时保留会话并给出可重试错误。
// 轮消息先确认当前线程的配置服务器和工具库存;仅收到 startupStatus=ready
// 不足以证明模型可调用具体 MCP失败时保留会话并给出可重试错误。
await ensureCodexAppMcpReadyForThread(session, threadId);
const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);