diff --git a/.env.example b/.env.example index 3222517..bf08cc9 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,12 @@ CLAUDE_PATH=claude # Codex CLI 路径(默认在 PATH 中查找 codex) CODEX_PATH=codex +# Codex App / ccweb MCP 启动与调用超时(可选) +# CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC=30 +# CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC=60 +# CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS=35000 +# CC_WEB_CODEX_APP_MCP_RELOAD_TRACK_MS=60000 + # PushPlus Token(可选,首次启动会自动迁移到 config/notify.json) PUSHPLUS_TOKEN= diff --git a/.planning/2026-09-12-ccweb-mcp-create-failure/findings.md b/.planning/2026-09-12-ccweb-mcp-create-failure/findings.md new file mode 100644 index 0000000..bfd713b --- /dev/null +++ b/.planning/2026-09-12-ccweb-mcp-create-failure/findings.md @@ -0,0 +1,39 @@ +# 调查发现 + +## 用户现象 + +- ccweb MCP 创建对话有时失败。 +- 创建失败后重载 MCP 也挂载不上,导致当前对话不可继续使用。 +- 截图中的 `$log-ccweb-title` 提示显示:当前会话没有提供 `ccweb_list_conversations`、`ccweb_set_title` 或 `wiznote_mcp_wiz_*` 工具,因此无法获取对话 ID,无法安全写入 `/coding` 日志。 + +## 代码与运行证据 + +### 1. MCP 冷启动窗口固定为 10 秒 + +- `server.js:3528-3572` 的 `buildCcwebMcpRuntimeConfig()` 在 streamable HTTP 和 stdio 两条路径都固定写入 `startup_timeout_sec: 10`,工具调用窗口为 60 秒。 +- 历史会话 `723ffd3f-71fc-42ee-87b6-768836316099`、`4c0f6be3-b46c-4ec0-b6f5-03c31188b7d8`、`5ed15712-312c-4d1b-b629-3d3a3c0d06a7` 均持久化了:`MCP client for ccweb timed out after 10 seconds`。 +- 同一批历史记录随后出现 `ccweb_list_conversations` 60 秒工具调用超时,说明客户端失败后仍可能继续尝试调用失效连接。 + +### 2. 创建对话成功不等于目标 MCP 已就绪 + +- `createMcpConversation()` 先通过 `createPersistentConversationSession()` 写入会话文件,再调用 `sendCrossConversationMessage()` 投递首条消息。 +- `sendCrossConversationMessage()` 调用 `handleMessage()`;Codex App 分支的 `handleCodexAppMessage()` 只登记 active turn 并异步执行 `startCodexAppTurn(...).catch(...)`,立即返回 `{ ok: true }`。 +- 因此创建接口返回的 `ok/status=running` 只表示“会话已落盘、首轮已开始”,不保证首轮完成,更不保证 ccweb MCP 已 ready。MCP 启动失败会在返回之后发生。 + +### 3. 重载状态关联存在 threadId 严格匹配竞态 + +- `handleReloadMcpApi()` 在 `markCodexAppMcpReloadPending()` 后调用无 thread 参数的 `config/mcpServer/reload`,并只等待 `CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200` 毫秒。 +- `codexAppMcpStatusTargetSessionIds()` 对 pending 会话要求 `statusRecord.threadId === pending.threadId`;不一致时直接跳过。 +- `logs/process.old.log:6713-6721` 中,重载请求针对 `019ff161-...`,随后上报的是 `019fef64-...` 与 `019fef0f-...`,且全部 `targetSessions=0`。对应会话 `b73e4b07-4aaa-43d5-906b-413747a09f4b` 最终仍持久化为 `ccweb.status=starting/rawStatus=pending`。 +- `cleanupExpiredCodexAppMcpReloads()` 只删除内存 pending 映射,不会把持久化状态从 `starting/pending` 改成失败或可重试,因此会话在 UI 上长期像“挂载中”。 + +### 4. 当前环境可复现“并发启动时序差异” + +- 当前调查会话的 `ccweb` 最终为 `ready`,但同一线程的 `playwright` 在 20 秒后失败,说明多个 MCP 并发启动时不同服务的 ready/fail 到达时间并不一致;固定 10 秒窗口和 1.2 秒重载等待会放大这个时序问题。 + +## 已实施修复 + +- `server.js` 和 `lib/agent-runtime.js`:ccweb MCP 默认启动超时提高到 30 秒,工具超时和 reload 等待窗口支持环境变量覆盖;reload 默认等待 35 秒。 +- `server.js`:reload pending 记录允许同一 app-server 的全局 reload 通知跨 threadId 关联;等待窗口结束仍未收到 ready/failed/cancelled 时,将持久化状态收敛为 `failed` 并标记可重试。 +- `server.js`:Codex App 创建对话结果附带当前 `mcpStatus`;首条消息投递失败时保留会话 ID、失败阶段和可重试标记,避免“已创建的会话”被误认为完全不存在。 +- `scripts/mock-codex-app-server.js`、`scripts/regression.js`:新增无关 threadId 和无最终状态通知的回归场景。 diff --git a/.planning/2026-09-12-ccweb-mcp-create-failure/progress.md b/.planning/2026-09-12-ccweb-mcp-create-failure/progress.md new file mode 100644 index 0000000..56e5ddd --- /dev/null +++ b/.planning/2026-09-12-ccweb-mcp-create-failure/progress.md @@ -0,0 +1,13 @@ +# 调查进度 + +## 记录 + +- 2026-09-12:建立本轮 scoped 排查计划;确认 codebase-memory 项目 `home-cc-web` 索引状态为 ready。 +- 2026-09-12:截图现象先记录为“当前会话 MCP 工具未注入/不可见”,待与创建和重载链路对照。 +- 2026-09-12:确认 `buildCcwebMcpRuntimeConfig()` 两条传输路径均固定 `startup_timeout_sec=10`;历史会话持久化了 ccweb 10 秒超时。 +- 2026-09-12:确认 `createMcpConversation()` 的首条消息通过 `handleCodexAppMessage()` 异步启动,接口返回不等待目标线程/MCP ready。 +- 2026-09-12:确认 reload 仅等待 1200ms,状态通知按 threadId 严格关联;历史日志出现请求线程与上报线程不一致、`targetSessions=0`,导致会话持久化为 `starting/pending`。 +- 2026-09-12:用户确认进入修复实施;已将 ccweb MCP 启动超时、工具超时、reload 等待窗口改为可配置,默认分别为 30 秒、60 秒、35 秒。 +- 2026-09-12:已修正 reload 的跨 threadId 全局状态关联,并在等待窗口超时后持久化 `failed` 状态和可重试提示。 +- 2026-09-12:创建对话结果补充 Codex App MCP 状态;首条消息投递失败保留会话 ID、creationStatus 和 retryable 元数据。 +- 2026-09-12:mock + 完整 regression 已通过;未重启服务,待收尾清理临时清单。 diff --git a/.planning/2026-09-12-ccweb-mcp-create-failure/task_plan.md b/.planning/2026-09-12-ccweb-mcp-create-failure/task_plan.md new file mode 100644 index 0000000..30fb836 --- /dev/null +++ b/.planning/2026-09-12-ccweb-mcp-create-failure/task_plan.md @@ -0,0 +1,25 @@ +# ccweb MCP 创建对话失败与重载失败排查 + +## 目标 + +定位 ccweb MCP 创建新对话偶发失败、失败后重载 MCP 无法挂载的共同根因,判断是否需要修复代码并完成最小回归验证。 + +## 阶段 + +- [completed] 梳理现有 MCP 配置与重载回归入口 +- [completed] 提高并配置 ccweb MCP 启动与重载等待窗口 +- [completed] 修正重载状态的 threadId 关联与超时收敛 +- [completed] 补充创建对话的 MCP 状态可见性与失败信息 +- [completed] 补充回归断言并运行相关验证 +- [completed] 清理临时清单并汇总交付风险 + +## 约束 + +- 使用 codebase-memory-mcp 做代码定位,rg 只做行号与文本补充校验。 +- 不覆盖用户已有未提交改动。 +- 仅在确认根因且改动属于本问题范围时修改业务代码。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| diff --git a/README.md b/README.md index 73c1042..725b343 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,10 @@ node server.js | `CC_WEB_LOGS_DIR` | `./logs` | 日志目录覆写,常用于测试隔离 | | `CC_WEB_CODEX_APP_WORKER` | 开启 | 设为 `0` / `false` / `off` 可关闭 Codex App worker | | `CC_WEB_PROCESS_CLEAN_PATH` | 自动探测 | 清理旧 Codex app-server 进程时使用的匹配路径覆写 | +| `CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC` | `30` | Codex App MCP 启动等待秒数,范围 10-300 | +| `CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC` | `60` | ccweb MCP 工具调用超时秒数,范围 10-600 | +| `CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS` | `35000` | 重载 MCP 等待最终状态的毫秒数,范围 1000-180000 | +| `CC_WEB_CODEX_APP_MCP_RELOAD_TRACK_MS` | `60000` 或等待窗口+10秒的较大值 | 重载状态关联保留时间,范围 5000-300000 | 还有若干面向大历史、长输出和 Codex App 状态落盘的高级限制参数,例如: 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 433b2e9..b990cb7 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/lib/agent-runtime.js b/lib/agent-runtime.js index 88bc836..5b3e39d 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -37,6 +37,16 @@ function createAgentRuntime(deps) { 256 * 1024, { min: 4096 }, ); + const CCWEB_MCP_STARTUP_TIMEOUT_SEC = readRuntimePositiveIntEnv( + 'CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC', + 30, + { min: 10, max: 300 }, + ); + const CCWEB_MCP_TOOL_TIMEOUT_SEC = readRuntimePositiveIntEnv( + 'CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC', + 60, + { min: 10, max: 600 }, + ); const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n'; function keepTail(value, maxLen) { @@ -99,8 +109,8 @@ function createAgentRuntime(deps) { '-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`, '-c', `mcp_servers.ccweb.args=${tomlStringArray(serverArgs)}`, '-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`, - '-c', 'mcp_servers.ccweb.startup_timeout_sec=10', - '-c', 'mcp_servers.ccweb.tool_timeout_sec=60' + '-c', `mcp_servers.ccweb.startup_timeout_sec=${CCWEB_MCP_STARTUP_TIMEOUT_SEC}`, + '-c', `mcp_servers.ccweb.tool_timeout_sec=${CCWEB_MCP_TOOL_TIMEOUT_SEC}` ); } diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index a39cfdb..85e8717 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -1133,24 +1133,29 @@ function handleRequest(message) { } if (method === 'config/mcpServer/reload') { mcpReloadCount += 1; - send({ - method: 'mcpServer/startupStatus/updated', - params: { - server: 'ccweb', - state: 'starting', - message: 'ccweb MCP starting', - threadId: null, - }, - }); - send({ - method: 'mcpServer/startupStatus/updated', - params: { - name: 'ccweb', - status: 'ready', - message: 'ccweb MCP ready CC_WEB_MCP_TOKEN=mock-secret-token', - threadId: null, - }, - }); + const suppressReloadStatus = process.env.MOCK_CODEX_APP_MCP_RELOAD_TIMEOUT_ON_SECOND === '1' + && mcpReloadCount >= 2; + if (!suppressReloadStatus) { + send({ + method: 'mcpServer/startupStatus/updated', + params: { + server: 'ccweb', + state: 'starting', + message: 'ccweb MCP starting', + // 模拟 app-server 的全局 reload 通知可能携带其他线程 ID。 + threadId: 'mock-reload-unrelated-thread', + }, + }); + send({ + method: 'mcpServer/startupStatus/updated', + params: { + name: 'ccweb', + status: 'ready', + message: 'ccweb MCP ready CC_WEB_MCP_TOKEN=mock-secret-token', + threadId: 'mock-reload-unrelated-thread', + }, + }); + } send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } }); return; } diff --git a/scripts/regression.js b/scripts/regression.js index 24285a4..c758dc0 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -1613,6 +1613,32 @@ function assertFrontendMcpReloadContract() { assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast'); } +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'); + 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' + ); + assert( + serverSource.includes('codex_app_mcp_reload_timeout') + && serverSource.includes("creationStatus: 'created_but_initial_message_failed'") + && serverSource.includes('mcpStatus: buildCodexAppMcpStatusSummary'), + 'ccweb MCP recovery should persist timeout failure and expose creation/MCP status' + ); + assert( + runtimeSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC') + && 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'); +} + function assertFrontendSubagentCardMetadataContract() { const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8'); @@ -6637,6 +6663,7 @@ async function main() { assertFrontendMarkdownLinkContract(); assertMockCodexAppPromptUserNotTextTriggered(); assertFrontendMcpReloadContract(); + assertCcwebMcpRecoveryContract(); assertPlanListProgressContract(); assertFrontendSubagentCardMetadataContract(); assertCodexAppRuntimeSubAgentActivityContract(); @@ -6793,6 +6820,8 @@ async function main() { CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER, 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', }, async () => { await assertWsUpgradeRejected(port, '/not-ws'); @@ -8212,6 +8241,11 @@ async function main() { Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1, 'Task tracking changes must not trigger an implicit MCP reload; only the explicit reload request should count' ); + assert(reloadAfterTracking.reloadTimedOut === true, 'MCP reload without a final startup notification should report a timeout'); + assert(reloadAfterTracking.mcpStatus?.status === 'failed', 'MCP reload timeout should converge to failed instead of pending'); + storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + assert(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.status === 'failed', 'MCP reload timeout should persist failed status for retry'); + assert(/可稍后再次重试/.test(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.message || ''), 'MCP reload timeout should explain that retry is available'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' })); const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp'); diff --git a/server.js b/server.js index ef5010f..59ce3e7 100644 --- a/server.js +++ b/server.js @@ -1444,8 +1444,28 @@ const CODEX_APP_UNROUTED_NOTIFICATION_LOG_THROTTLE_MS = 30 * 1000; const CODEX_APP_UNROUTED_NOTIFICATION_LOG_MAX = 1000; const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated'; const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb'; -const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200; -const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000; +// Codex App 会并发启动多个 MCP;10 秒和 1.2 秒都不足以覆盖冷启动抖动。 +// 保留环境变量覆盖,便于低配机器或 CI 按需调整,而不必重新发布代码。 +const CODEX_APP_MCP_STARTUP_TIMEOUT_SEC = readPositiveIntEnv( + 'CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC', + 30, + { min: 10, max: 300 }, +); +const CODEX_APP_MCP_TOOL_TIMEOUT_SEC = readPositiveIntEnv( + 'CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC', + 60, + { min: 10, max: 600 }, +); +const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = readPositiveIntEnv( + 'CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS', + 35000, + { min: 1000, max: 180000 }, +); +const CODEX_APP_MCP_RELOAD_TRACK_MS = readPositiveIntEnv( + 'CC_WEB_CODEX_APP_MCP_RELOAD_TRACK_MS', + Math.max(60000, CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS + 10000), + { min: 5000, max: 300000 }, +); const CODEX_APP_MCP_INVENTORY_TTL_MS = 3000; const CODEX_APP_MCP_INVENTORY_MAX_PAGES = 10; const codexAppMcpStartupStatusByServer = new Map(); @@ -3547,8 +3567,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) { type: 'streamable_http', url: `http://127.0.0.1:${PORT}/api/internal/mcp/stream?${params.toString()}`, bearer_token_env_var: CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV, - startup_timeout_sec: 10, - tool_timeout_sec: 60, + startup_timeout_sec: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC, + tool_timeout_sec: CODEX_APP_MCP_TOOL_TIMEOUT_SEC, }, }; } @@ -3565,8 +3585,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) { command: commandSpec.command, args: commandSpec.args, env, - startup_timeout_sec: 10, - tool_timeout_sec: 60, + startup_timeout_sec: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC, + tool_timeout_sec: CODEX_APP_MCP_TOOL_TIMEOUT_SEC, }, }; } @@ -4600,6 +4620,9 @@ 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 { @@ -4714,7 +4737,12 @@ function codexAppMcpStatusTargetSessionIds(statusRecord, routed) { const targetSessionIds = new Set(); if (routed?.sessionId) targetSessionIds.add(routed.sessionId); for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) { - if (statusRecord.threadId && pending.threadId && statusRecord.threadId !== pending.threadId) continue; + if ( + statusRecord.threadId + && pending.threadId + && statusRecord.threadId !== pending.threadId + && pending.allowThreadMismatch !== true + ) continue; targetSessionIds.add(sessionId); } return targetSessionIds; @@ -4785,12 +4813,26 @@ async function handleReloadMcpApi(req, res, rawSessionId) { const result = typeof client.reloadMcpServers === 'function' ? await client.reloadMcpServers() : await client.request('config/mcpServer/reload', {}, 30000); - const mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt); + let mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt); + let reloadTimedOut = false; + if (!isFinalCodexAppMcpStatus(mcpStatus?.status)) { + reloadTimedOut = true; + const timeoutMessage = `重载 MCP 在 ${Math.ceil(CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS / 1000)} 秒内未收到最终启动状态,可稍后再次重试。`; + mcpStatus = markCodexAppMcpReloadFailed(sessionId, timeoutMessage) + || mcpStatus + || pendingMcp.summary; + plog('WARN', 'codex_app_mcp_reload_timeout', { + sessionId: sessionId.slice(0, 8), + threadId: getRuntimeSessionId(session) || null, + waitMs: CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS, + }); + } plog('INFO', 'codex_app_mcp_reload_requested', { sessionId: sessionId.slice(0, 8), threadId: getRuntimeSessionId(session) || null, status: mcpStatus?.status || pendingMcp.summary?.status || 'pending', + reloadTimedOut, }); return jsonResponse(res, 200, { @@ -4798,6 +4840,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) { sessionId, threadId: getRuntimeSessionId(session) || null, result: result || {}, + reloadTimedOut, mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }), }); } catch (err) { @@ -7061,6 +7104,7 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = content: initialMessage, }, sourceId, normalizedHopCount, { expectReply: requestReply }); if (!delivery?.ok) { + const persistedSession = loadSession(session.id) || session; return mcpToolError(delivery?.code || 'initial_message_failed', delivery?.message || '创建对话后发送首条消息失败。', { conversationId: session.id, title: session.title, @@ -7068,10 +7112,16 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = cwd: session.cwd || '', mode: session.permissionMode || 'yolo', status: isSessionRunning(session.id) ? 'running' : 'idle', + creationStatus: 'created_but_initial_message_failed', + retryable: true, + ...(isCodexAppSession(persistedSession) + ? { mcpStatus: buildCodexAppMcpStatusSummary(persistedSession) } + : {}), }); } } + const persistedSession = loadSession(session.id) || session; broadcastSessionList(); return { ok: true, @@ -7082,6 +7132,9 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = mode: session.permissionMode || 'yolo', status: isSessionRunning(session.id) ? 'running' : 'idle', sourceConversationId: sourceId || null, + ...(isCodexAppSession(persistedSession) + ? { mcpStatus: buildCodexAppMcpStatusSummary(persistedSession) } + : {}), ...(delivery ? { messageId: delivery.messageId || null, deliveryStatus: delivery.deliveryStatus || 'delivered',