修复 Codex App MCP 重载与线程库存恢复
This commit is contained in:
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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
174
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);
|
||||
|
||||
Reference in New Issue
Block a user