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 5644eff..86509f2 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 85e8717..716ad1f 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -1155,6 +1155,15 @@ function handleRequest(message) { threadId: 'mock-reload-unrelated-thread', }, }); + // 全局通知可能不带线程;服务端只有在单个重载窗口内才允许安全归属。 + send({ + method: 'mcpServer/startupStatus/updated', + params: { + name: 'ccweb', + status: 'ready', + message: 'ccweb MCP ready without thread id CC_WEB_MCP_TOKEN=mock-secret-token', + }, + }); } send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } }); return; @@ -1165,6 +1174,20 @@ function handleRequest(message) { id, result: { data: [ + { + name: 'ccweb', + authStatus: 'unsupported', + resources: [], + resourceTemplates: [], + serverInfo: { name: 'ccweb', version: '1.0.0' }, + tools: { + ccweb_list_conversations: { + name: 'ccweb_list_conversations', + description: 'Regression ccweb MCP tool.', + inputSchema: { type: 'object' }, + }, + }, + }, { name: 'reg-app-project', authStatus: 'unsupported', @@ -1206,6 +1229,16 @@ function handleRequest(message) { 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; } + if (method === 'thread/fork') { + const source = ensureThread(params.threadId, params); + const thread = ensureThread(null, params); + thread.forkedFromId = source.id; + thread.lastForkTurnId = params.lastTurnId || params.beforeTurnId || null; + thread.lastThreadConfigMethod = 'thread/fork'; + thread.lastForkConfig = params.config || {}; + 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; + } if (method === 'thread/resume') { if (params.threadId && resumeMismatchThreads.delete(params.threadId)) { const thread = ensureThread(null, params); diff --git a/scripts/regression.js b/scripts/regression.js index 7f1c422..8907f99 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -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' diff --git a/server.js b/server.js index c519892..ee427ec 100644 --- a/server.js +++ b/server.js @@ -3455,6 +3455,124 @@ function normalizeCodexAppMcpInventory(result) { return servers; } +function mergeCodexAppMcpInventories(inventories) { + const byName = new Map(); + for (const inventory of inventories) { + for (const server of Array.isArray(inventory) ? inventory : []) { + const current = byName.get(server.server); + if (!current) { + byName.set(server.server, { ...server, tools: [...(server.tools || [])] }); + continue; + } + const tools = new Map(current.tools.map((tool) => [tool.name, tool])); + for (const tool of server.tools || []) tools.set(tool.name, tool); + current.tools = Array.from(tools.values()); + } + } + return Array.from(byName.values()); +} + +function summarizeCodexAppMcpInventory(servers) { + const normalized = Array.isArray(servers) ? servers : []; + const ccweb = normalized.find((server) => codexAppMcpStatusKey(server.server) === CODEX_APP_MCP_DEFAULT_SERVER); + const toolCount = normalized.reduce((total, server) => total + (Array.isArray(server.tools) ? server.tools.length : 0), 0); + return { + serverCount: normalized.length, + toolCount, + ccwebServerCount: ccweb ? 1 : 0, + ccwebToolCount: ccweb?.tools?.length || 0, + ccwebReady: !!ccweb && ccweb.tools.length > 0, + }; +} + +function recordCodexAppMcpInventoryState(session, inventoryResult) { + if (!session || !isCodexAppSession(session)) return; + const state = ensureCodexAppMcpStartupState(session); + if (!state) return; + const summary = summarizeCodexAppMcpInventory(inventoryResult?.servers || []); + state.inventory = { + status: inventoryResult?.ok ? 'ready' : 'failed', + threadId: inventoryResult?.threadId || getRuntimeSessionId(session) || null, + updatedAt: new Date().toISOString(), + ...summary, + error: inventoryResult?.ok ? '' : safeMcpStatusString(inventoryResult?.error || 'MCP 工具清单查询失败', 500), + }; + saveSession(session); +} + +async function queryCodexAppMcpInventory(session, options = {}) { + if (!isCodexAppSession(session)) return { ok: false, code: 'not_codexapp', servers: [], error: '当前会话不是 Codex App。' }; + const threadId = normalizeCodexAppThreadId(options.threadId || getRuntimeSessionId(session)); + if (!threadId) return { ok: false, code: 'missing_thread_id', servers: [], error: 'Codex App 当前没有可验证的线程。' }; + if (!codexAppClient?.isRunning()) return { ok: false, code: 'client_unavailable', threadId, servers: [], error: 'Codex App app-server 尚未运行。' }; + + const allServers = []; + let cursor = null; + try { + for (let page = 0; page < CODEX_APP_MCP_INVENTORY_MAX_PAGES; page += 1) { + const response = await codexAppClient.request('mcpServerStatus/list', { + threadId, + detail: 'full', + limit: 200, + cursor, + }, options.timeoutMs || 5000); + const responseThreadId = normalizeCodexAppThreadId(response?.threadId || response?.thread?.id); + if (responseThreadId && responseThreadId !== threadId) { + return { + ok: false, + code: 'inventory_thread_mismatch', + threadId, + responseThreadId, + servers: [], + error: `MCP 工具清单属于其他线程(期望 ${threadId.slice(0, 24)},实际 ${responseThreadId.slice(0, 24)})。`, + }; + } + allServers.push(normalizeCodexAppMcpInventory(response)); + const nextCursor = String(response?.nextCursor || '').trim(); + if (!nextCursor || nextCursor === cursor) break; + cursor = nextCursor; + } + } catch (err) { + return { + ok: false, + code: 'inventory_request_failed', + threadId, + servers: [], + error: `MCP 工具清单查询失败: ${err?.message || String(err)}`, + }; + } + + const servers = mergeCodexAppMcpInventories(allServers); + const summary = summarizeCodexAppMcpInventory(servers); + if (options.requireCcweb && !summary.ccwebReady) { + return { + ok: false, + code: 'ccweb_tools_unavailable', + threadId, + servers, + ...summary, + error: '当前线程的 ccweb MCP 未返回可调用工具,不能判定 MCP 已就绪。', + }; + } + return { ok: true, code: 'ok', threadId, servers, ...summary, error: '' }; +} + +async function waitForCodexAppMcpInventory(session, options = {}) { + const maxWaitMs = Math.max(0, Number(options.maxWaitMs ?? CODEX_APP_MCP_STARTUP_TIMEOUT_SEC * 1000)); + const deadline = Date.now() + maxWaitMs; + let lastResult = null; + while (true) { + const remaining = Math.max(1, deadline - Date.now()); + lastResult = await queryCodexAppMcpInventory(session, { + ...options, + timeoutMs: Math.min(Number(options.timeoutMs || 5000), remaining), + }); + if (lastResult.ok || !['ccweb_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())))); + } +} + async function loadCodexAppMcpInventory(session) { if (!isCodexAppSession(session)) return []; const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session)); @@ -3466,49 +3584,55 @@ async function loadCodexAppMcpInventory(session) { const existing = codexAppMcpInventoryPending.get(threadId); if (existing) return existing; const pending = (async () => { - const allServers = []; - let cursor = null; - for (let page = 0; page < CODEX_APP_MCP_INVENTORY_MAX_PAGES; page += 1) { - const response = await codexAppClient.request('mcpServerStatus/list', { - threadId, - detail: 'full', - limit: 200, - cursor, - }, 5000); - allServers.push(...normalizeCodexAppMcpInventory(response)); - const nextCursor = String(response?.nextCursor || '').trim(); - if (!nextCursor || nextCursor === cursor) break; - cursor = nextCursor; - } - const byName = new Map(); - for (const server of allServers) { - const current = byName.get(server.server); - if (!current) { - byName.set(server.server, server); - continue; - } - const tools = new Map(current.tools.map((tool) => [tool.name, tool])); - for (const tool of server.tools) tools.set(tool.name, tool); - current.tools = Array.from(tools.values()); - } - const servers = Array.from(byName.values()); - codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers }); - return servers; - })() - .catch((err) => { + const result = await queryCodexAppMcpInventory(session, { threadId }); + if (!result.ok) { plog('WARN', 'codex_app_mcp_inventory_failed', { threadId: threadId.slice(0, 16), - error: err?.message || String(err), + code: result.code, + error: result.error, }); - // 对旧版 app-server 做短暂负缓存,避免每次输入都重复等待不支持的方法。 - codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: [] }); - return []; - }) + const error = new Error(result.error || 'MCP 工具清单查询失败。'); + error.code = result.code || 'codexapp_mcp_inventory_failed'; + throw error; + } + codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: result.servers }); + return result.servers; + })() .finally(() => codexAppMcpInventoryPending.delete(threadId)); codexAppMcpInventoryPending.set(threadId, pending); return pending; } +async function ensureCodexAppMcpReadyForThread(session, threadId) { + const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session)); + if (!normalizedThreadId) { + throw new Error('Codex App MCP 校验失败:当前线程 ID 不存在。'); + } + 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 result = await waitForCodexAppMcpInventory(session, { + threadId: normalizedThreadId, + requireCcweb: true, + timeoutMs: 5000, + maxWaitMs: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC * 1000, + }); + recordCodexAppMcpInventoryState(session, result); + if (!result.ok) { + plog('WARN', 'codex_app_mcp_preflight_failed', { + sessionId: session?.id ? session.id.slice(0, 8) : null, + threadId: normalizedThreadId.slice(0, 16), + code: result.code, + error: result.error, + }); + throw new Error(result.error || '当前线程 MCP 工具不可用,已停止发送本轮消息。'); + } + codexAppMcpInventoryByThread.set(normalizedThreadId, { fetchedAt: Date.now(), servers: result.servers }); + return result.servers; +} + function summarizeSkillDependencies(skill) { const tools = Array.isArray(skill?.dependencies?.tools) ? skill.dependencies.tools : []; return tools @@ -4573,6 +4697,7 @@ function buildCodexAppMcpStatusSummary(session, options = {}) { const state = mcpStatusObject(session?.codexAppMcpStartupStatus) || {}; const serversObject = mcpStatusObject(state.servers) || {}; const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record)); + const inventory = mcpStatusObject(state.inventory) || null; let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER); const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null; if (!current) { @@ -4593,6 +4718,13 @@ function buildCodexAppMcpStatusSummary(session, options = {}) { reloadRequestId: safeMcpStatusString(state.reloadRequestId || '', 80) || null, hasStartupStatus: current.source === 'notification', servers, + inventoryStatus: safeMcpStatusString(inventory?.status || 'unknown', 40) || 'unknown', + inventoryThreadId: safeMcpStatusString(inventory?.threadId || '', 160) || null, + inventoryUpdatedAt: safeMcpStatusString(inventory?.updatedAt || '', 80) || null, + 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, + inventoryError: safeMcpStatusString(inventory?.error || '', 500) || null, }; } @@ -4606,6 +4738,17 @@ function markCodexAppMcpReloadPending(session, sessionId) { state.reloadRequestId = reloadRequestId; state.updatedAt = requestedAt; state.threadId = threadId; + state.inventory = { + status: 'pending', + threadId, + updatedAt: requestedAt, + serverCount: 0, + toolCount: 0, + ccwebServerCount: 0, + ccwebToolCount: 0, + ccwebReady: false, + error: '已请求重载,等待当前线程 MCP 工具清单', + }; state.servers[codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER)] = publicCodexAppMcpStatusRecord({ name: CODEX_APP_MCP_DEFAULT_SERVER, status: 'pending', @@ -4620,9 +4763,6 @@ function markCodexAppMcpReloadPending(session, sessionId) { requestedAt, expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS, reloadRequestId, - // config/mcpServer/reload 作用于整个 app-server,不保证通知携带当前线程。 - // 因此 reload 窗口内允许用同一 app-server 的其他 threadId 状态完成关联。 - allowThreadMismatch: true, }); saveSession(session); return { @@ -4721,6 +4861,22 @@ function updateCodexAppSessionMcpStatus(sessionId, statusRecord) { return summary; } +function updateCodexAppSessionMcpInventory(sessionId, inventoryResult) { + const normalizedId = sanitizeId(sessionId || ''); + if (!normalizedId) return null; + const session = loadSession(normalizedId); + if (!session || !isCodexAppSession(session)) return null; + recordCodexAppMcpInventoryState(session, inventoryResult); + const summary = buildCodexAppMcpStatusSummary(session); + sendSessionEventToViewers(normalizedId, { + type: 'mcp_startup_status', + sessionId: normalizedId, + status: summary, + mcpStatus: summary, + }); + return summary; +} + function markCodexAppMcpReloadFailed(sessionId, message) { return updateCodexAppSessionMcpStatus(sessionId, { name: CODEX_APP_MCP_DEFAULT_SERVER, @@ -4735,14 +4891,22 @@ function markCodexAppMcpReloadFailed(sessionId, message) { function codexAppMcpStatusTargetSessionIds(statusRecord, routed) { cleanupExpiredCodexAppMcpReloads(); const targetSessionIds = new Set(); - if (routed?.sessionId) targetSessionIds.add(routed.sessionId); + const statusThreadId = normalizeCodexAppThreadId(statusRecord?.threadId); + if (routed?.sessionId) { + const routedSession = loadSession(routed.sessionId); + const routedThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(routedSession)); + if (!statusThreadId || !routedThreadId || statusThreadId === routedThreadId) targetSessionIds.add(routed.sessionId); + } for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) { - if ( - statusRecord.threadId - && pending.threadId - && statusRecord.threadId !== pending.threadId - && pending.allowThreadMismatch !== true - ) continue; + const pendingThreadId = normalizeCodexAppThreadId(pending.threadId); + if (statusThreadId && pendingThreadId && statusThreadId === pendingThreadId) { + targetSessionIds.add(sessionId); + } + } + // app-server 的全局通知有时不带 threadId。只有恰好一个重载请求在窗口内时, + // 才能安全归属;多个并发会话时必须放弃关联,不能广播到所有会话。 + if (!statusThreadId && targetSessionIds.size === 0 && pendingCodexAppMcpReloads.size === 1) { + const [sessionId] = pendingCodexAppMcpReloads.keys(); targetSessionIds.add(sessionId); } return targetSessionIds; @@ -4828,6 +4992,31 @@ async function handleReloadMcpApi(req, res, rawSessionId) { }); } + let inventoryResult = null; + if (mcpStatus?.status === 'ready') { + const targetSession = loadSession(sessionId); + inventoryResult = await waitForCodexAppMcpInventory(targetSession, { + threadId: currentThreadId, + requireCcweb: true, + timeoutMs: 5000, + maxWaitMs: 5000, + }); + updateCodexAppSessionMcpInventory(sessionId, inventoryResult); + if (!inventoryResult.ok) { + const inventoryMessage = inventoryResult.error || '当前线程 MCP 工具清单不可用,不能判定 MCP 已就绪。'; + mcpStatus = markCodexAppMcpReloadFailed(sessionId, inventoryMessage) + || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }); + plog('WARN', 'codex_app_mcp_reload_inventory_failed', { + sessionId: sessionId.slice(0, 8), + threadId: currentThreadId || null, + code: inventoryResult.code, + error: inventoryMessage, + }); + } + } + + const reloadReady = mcpStatus?.status === 'ready' && inventoryResult?.ok === true; + plog('INFO', 'codex_app_mcp_reload_requested', { sessionId: sessionId.slice(0, 8), threadId: getRuntimeSessionId(session) || null, @@ -4835,8 +5024,10 @@ async function handleReloadMcpApi(req, res, rawSessionId) { reloadTimedOut, }); - return jsonResponse(res, 200, { - ok: true, + return jsonResponse(res, reloadReady ? 200 : 503, { + ok: reloadReady, + code: reloadReady ? undefined : 'codexapp_mcp_not_ready', + message: reloadReady ? undefined : (mcpStatus?.message || 'MCP 重载未完成:当前线程工具不可用。'), sessionId, threadId: getRuntimeSessionId(session) || null, result: result || {}, @@ -9220,6 +9411,9 @@ wss.on('connection', (ws, req) => { trigger: ['/', '$', '@'].includes(msg.trigger) ? msg.trigger : '', query: String(msg.query || ''), items: [], + ok: false, + code: err?.code || 'composer_mcp_inventory_failed', + error: err?.message || 'MCP 工具清单查询失败,请稍后重试。', }); }); break; @@ -9818,8 +10012,24 @@ async function ensureCodexAppGoalThread(session) { const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000); threadId = resumed?.thread?.id || threadId; } else { - const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000); + const branch = session.createdFrom?.kind === 'branch' ? session.createdFrom : null; + const sourceThreadId = normalizeCodexAppThreadId(branch?.sourceThreadId || ''); + const sourceTurnId = String(branch?.sourceTurnId || '').trim(); + if (branch && (!sourceThreadId || !sourceTurnId)) { + throw new Error('Codex App 分支缺少来源线程或回合信息,已停止启动空线程;请从原会话重新创建分支。'); + } + const started = sourceThreadId && sourceTurnId + ? await client.request('thread/fork', { ...threadParams, threadId: sourceThreadId, lastTurnId: sourceTurnId }, 60000) + : await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000); threadId = started?.thread?.id || null; + if (branch) { + plog('INFO', 'codex_app_goal_thread_forked', { + sessionId: session.id.slice(0, 8), + sourceThreadId: sourceThreadId.slice(0, 24), + sourceTurnId: sourceTurnId.slice(0, 24), + threadId: threadId ? threadId.slice(0, 24) : null, + }); + } } if (!threadId) throw new Error('Codex app-server 未返回 threadId。'); @@ -10543,6 +10753,15 @@ function resolveBranchSource(args = {}) { const sourceMessageIndex = Number.isFinite(parsedIndex) ? Math.max(0, Math.min(sourceMessages.length - 1, parsedIndex)) : sourceMessages.length - 1; + const selectedMessage = sourceMessages[sourceMessageIndex] || null; + if (isCodexAppSession(sourceSession) + && (selectedMessage?.role !== 'assistant' || !selectedMessage?.codexAppTurnId)) { + return mcpToolError('branch_source_turn_not_found', '选中的 Codex App 消息没有可分叉的原生回合,请选择已完成的助手回复。', { sourceSessionId, sourceMessageIndex }); + } + const sourceThreadId = isCodexAppSession(sourceSession) + ? normalizeCodexAppThreadId(getRuntimeSessionId(sourceSession)) + : null; + const sourceTurnId = selectedMessage?.codexAppTurnId || null; const createdAt = new Date().toISOString(); return { @@ -10554,6 +10773,8 @@ function resolveBranchSource(args = {}) { sourceSessionId: sourceSession.id, sourceTitle: sourceSession.title || 'Untitled', sourceMessageIndex, + sourceThreadId, + sourceTurnId, createdAt, }, defaultTitle: buildBranchSessionTitle(sourceSession), @@ -13574,9 +13795,31 @@ async function startCodexAppTurn(sessionId, input) { throw new Error(`${prefix}恢复到不同线程,已停止以避免上下文丢失(期望 ${expectedShort},实际 ${actualShort})。`); } threadId = resumedThreadId; - } else { - const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000); - threadId = started?.thread?.id || null; + } else { + const branch = session.createdFrom?.kind === 'branch' ? session.createdFrom : null; + const sourceThreadId = normalizeCodexAppThreadId(branch?.sourceThreadId || ''); + const sourceTurnId = String(branch?.sourceTurnId || '').trim(); + if (branch && (!sourceThreadId || !sourceTurnId)) { + throw new Error('Codex App 分支缺少来源线程或回合信息,已停止启动空线程;请从原会话重新创建分支。'); + } + if (sourceThreadId && sourceTurnId) { + const forked = await client.request('thread/fork', { + ...threadParams, + threadId: sourceThreadId, + lastTurnId: sourceTurnId, + }, 60000); + threadId = forked?.thread?.id || null; + if (!threadId) throw new Error('Codex App thread/fork 未返回新 threadId。'); + plog('INFO', 'codex_app_thread_forked', { + sessionId: sessionId.slice(0, 8), + sourceThreadId: sourceThreadId.slice(0, 24), + sourceTurnId: sourceTurnId.slice(0, 24), + threadId: threadId.slice(0, 24), + }); + } else { + const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000); + threadId = started?.thread?.id || null; + } } if (!threadId) throw new Error('Codex app-server 未返回 threadId。'); @@ -13586,6 +13829,10 @@ async function startCodexAppTurn(sessionId, input) { saveSession(session); persistCodexAppTurnState(sessionId, entry, { immediate: true }); + // 首轮消息必须先确认当前线程真的拿到了 ccweb 工具;仅收到 startupStatus=ready + // 不足以证明模型可调用 MCP,失败时保留会话并给出可重试错误。 + await ensureCodexAppMcpReadyForThread(session, threadId); + const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000); if (turn?.turn?.id) entry.turnId = turn.turn.id; if (entry.giteaWorkflow?.handleTurnStarted && entry.giteaWorkflowTaskId && entry.turnId) {