diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 792fd2a..0038839 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 716ad1f..bb06a27 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -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; diff --git a/scripts/regression.js b/scripts/regression.js index cb3881a..f2c4f78 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -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' })); diff --git a/server.js b/server.js index fa89d98..e345c5b 100644 --- a/server.js +++ b/server.js @@ -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);