From ed3238fa497928bb6fe5f5d629c7c5a58c0dd3ce Mon Sep 17 00:00:00 2001 From: shiyue Date: Mon, 15 Jun 2026 13:22:36 +0800 Subject: [PATCH] feat: improve codex app controls and recovery --- lib/codex-app-runtime.js | 26 +++++- lib/codex-app-server-client.js | 5 + public/app.js | 150 +++++++++++++++++++++++++++--- public/index.html | 5 +- public/style.css | 151 ++++++++++++++++++++++++++++--- scripts/mock-codex-app-server.js | 20 ++++ scripts/regression.js | 41 +++++++++ server.js | 79 +++++++++++++++- 8 files changed, 448 insertions(+), 29 deletions(-) diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index 279fb8b..3f8bf72 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -1,5 +1,10 @@ 'use strict'; +const CODEX_APP_ONCE_NOTICE_PATTERNS = [ + /^Under-development features enabled:/i, + /^Heads up: Long threads and multiple compactions/i, +]; + function createCodexAppRuntime(deps = {}) { const { wsSend, @@ -14,6 +19,23 @@ function createCodexAppRuntime(deps = {}) { return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value; } + const shownOnceNoticeKeys = new Set(); + + function normalizeNoticeMessage(message) { + return String(message || '').trim().replace(/\s+/g, ' '); + } + + function shouldShowRuntimeNotice(method, message) { + const normalized = normalizeNoticeMessage(message); + const isOnceNotice = CODEX_APP_ONCE_NOTICE_PATTERNS.some((pattern) => pattern.test(normalized)); + if (!isOnceNotice) return true; + + const key = `${method}:${normalized}`; + if (shownOnceNoticeKeys.has(key)) return false; + shownOnceNoticeKeys.add(key); + return true; + } + function sendRuntime(entry, sessionId, payload) { wsSend(entry.ws, { ...payload, sessionId }); } @@ -483,7 +505,9 @@ function createCodexAppRuntime(deps = {}) { const message = params.message || params.title || ''; if (message) { if (method === 'error') entry.lastError = message; - sendRuntime(entry, sessionId, { type: 'system_message', message }); + if (method === 'error' || shouldShowRuntimeNotice(method, message)) { + sendRuntime(entry, sessionId, { type: 'system_message', message }); + } } return { done: false }; } diff --git a/lib/codex-app-server-client.js b/lib/codex-app-server-client.js index 16bb2d6..1ec67ec 100644 --- a/lib/codex-app-server-client.js +++ b/lib/codex-app-server-client.js @@ -133,6 +133,10 @@ function createCodexAppServerClient(options = {}) { sendRaw({ method, params }); } + function reloadMcpServers() { + return request('config/mcpServer/reload', {}, 30000); + } + function start() { if (initPromise) return initPromise; exited = false; @@ -212,6 +216,7 @@ function createCodexAppServerClient(options = {}) { stop, request, notification, + reloadMcpServers, isRunning, pid: () => proc?.pid || null, }; diff --git a/public/app.js b/public/app.js index a3b7d0e..33f036e 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260614-divider-time-selectfix'; + const ASSET_VERSION = '20260615-reload-mcp'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -35,6 +35,9 @@ const SESSION_CACHE_MAX_WEIGHT = 1_500_000; const SIDEBAR_SWIPE_TRIGGER = 72; const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42; + const OLD_SESSION_COLLAPSE_VISIBLE_LIMIT = 5; + const OLD_SESSION_COLLAPSE_DAYS = 7; + const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000; const MODEL_OPTIONS = [ { value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' }, @@ -159,8 +162,10 @@ let pendingInitialSessionLoad = false; let noteMode = false; let noteDraftSeq = 0; + let isReloadingMcp = false; const pendingNotesByTarget = new Map(); const userMessageIndex = new Map(); + const expandedOldSessionAgents = new Set(); document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide'; // --- DOM --- @@ -191,6 +196,7 @@ const chatCwd = $('#chat-cwd'); const userOutlineBtn = $('#user-outline-btn'); const userOutlinePanel = $('#user-outline-panel'); + const reloadMcpBtn = $('#reload-mcp-btn'); const costDisplay = $('#cost-display'); const attachmentTray = $('#attachment-tray'); const pendingNotesTray = $('#pending-notes-tray'); @@ -1087,6 +1093,32 @@ return data || {}; } + function updateReloadMcpButtonUI() { + if (!reloadMcpBtn) return; + const visible = !!currentSessionId && isCodexAppAgent(currentAgent); + reloadMcpBtn.hidden = !visible; + reloadMcpBtn.disabled = !visible || isReloadingMcp; + reloadMcpBtn.textContent = isReloadingMcp ? '重载中' : '重载 MCP'; + reloadMcpBtn.setAttribute('aria-busy', isReloadingMcp ? 'true' : 'false'); + } + + async function reloadCurrentMcpServers() { + if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return; + isReloadingMcp = true; + updateReloadMcpButtonUI(); + try { + await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, { + method: 'POST', + }); + showToast('已请求重载 MCP'); + } catch (err) { + showToast(err?.message || '重载 MCP 失败'); + } finally { + isReloadingMcp = false; + updateReloadMcpButtonUI(); + } + } + function closeCodexAppUserInputModal(sendCancel = false) { if (!codexAppUserInputModal) return; const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal; @@ -1096,6 +1128,7 @@ if (sendCancel && requestId) { send({ type: 'codex_app_user_input_response', + action: 'cancel', sessionId, requestId, answers: {}, @@ -1219,6 +1252,7 @@ overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => { send({ type: 'codex_app_user_input_response', + action: 'submit', sessionId: msg.sessionId, requestId: msg.requestId, answers: collectCodexAppUserInputAnswers(panel, questions), @@ -2186,6 +2220,55 @@ return { pinnedSessions, regularSessions }; } + function isOlderThanOldSessionWindow(session, nowMs = Date.now()) { + const updatedMs = new Date(session?.updated || 0).getTime(); + return Number.isFinite(updatedMs) && updatedMs > 0 && nowMs - updatedMs > OLD_SESSION_COLLAPSE_MS; + } + + function splitCollapsedOldSessions(regularSessions, pinnedCount) { + if (expandedOldSessionAgents.has(currentAgent)) { + return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] }; + } + + const totalCount = pinnedCount + regularSessions.length; + if (totalCount <= OLD_SESSION_COLLAPSE_VISIBLE_LIMIT) { + return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] }; + } + + const nowMs = Date.now(); + const visibleRegularLimit = Math.max(0, OLD_SESSION_COLLAPSE_VISIBLE_LIMIT - pinnedCount); + const visibleRegularSessions = []; + const hiddenOldSessions = []; + + regularSessions.forEach((session, index) => { + const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread; + const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs); + if (canCollapse && !shouldKeepVisible) { + hiddenOldSessions.push(session); + } else { + visibleRegularSessions.push(session); + } + }); + + return { visibleRegularSessions, hiddenOldSessions }; + } + + function createOldSessionLoadMoreButton(hiddenCount) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'session-list-load-more'; + button.setAttribute('aria-label', `加载更多 ${hiddenCount} 条 7 天前会话`); + button.innerHTML = ` + 加载更多 + ${hiddenCount} 条 7 天前会话 + `; + button.addEventListener('click', () => { + expandedOldSessionAgents.add(currentAgent); + renderSessionList(); + }); + return button; + } + function applySessionPinnedState(sessionId, pinnedAt) { sessions = sessions.map((session) => ( session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session @@ -2221,25 +2304,36 @@ ${timeAgo(session.updated)}
- - - +
+ + +
`; item.addEventListener('click', (e) => { - const target = e.target; - if (target.classList.contains('copy-id')) { + const target = e.target instanceof Element + ? e.target.closest('.session-item-btn, .session-item-menu-btn') + : null; + if (target?.classList.contains('more')) { + e.stopPropagation(); + return; + } + if (target?.classList.contains('copy-id')) { e.stopPropagation(); copyTextToClipboard(session.id, '会话 ID 已复制'); return; } - if (target.classList.contains('pin')) { + if (target?.classList.contains('pin')) { e.stopPropagation(); toggleSessionPinned(session); return; } - if (target.classList.contains('delete')) { + if (target?.classList.contains('delete')) { e.stopPropagation(); const doDelete = () => { if (getLastSessionForAgent(currentAgent) === session.id) { @@ -2259,11 +2353,15 @@ } return; } - if (target.classList.contains('edit')) { + if (target?.classList.contains('edit')) { e.stopPropagation(); startEditSessionTitle(item, session); return; } + if (e.target instanceof Element && e.target.closest('.session-item-more')) { + e.stopPropagation(); + return; + } openSession(session.id); }); @@ -2318,6 +2416,7 @@ importSessionBtn.disabled = false; } } + updateReloadMcpButtonUI(); } function setCurrentAgent(agent) { @@ -2364,6 +2463,7 @@ chatTitle.textContent = '新会话'; updateSessionIdBadge(); updateCwdBadge(); + updateReloadMcpButtonUI(); messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); setStatsDisplay(null); renderPendingAttachments(); @@ -3286,7 +3386,23 @@ if (role === 'system') { const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; - bubble.textContent = content; + const text = document.createElement('span'); + text.className = 'system-message-text'; + text.textContent = content; + bubble.appendChild(text); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'system-message-close'; + closeBtn.title = '关闭提示'; + closeBtn.setAttribute('aria-label', '关闭提示'); + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', (event) => { + event.stopPropagation(); + div.remove(); + updateScrollbar(); + }); + bubble.appendChild(closeBtn); div.appendChild(bubble); return div; } @@ -4564,6 +4680,7 @@ } const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions); + const { visibleRegularSessions, hiddenOldSessions } = splitCollapsedOldSessions(regularSessions, pinnedSessions.length); if (pinnedSessions.length > 0) { const pinnedGroupEl = document.createElement('section'); pinnedGroupEl.className = 'session-project-group session-pinned-group'; @@ -4582,7 +4699,7 @@ sessionList.appendChild(pinnedGroupEl); } - const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions); + const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleRegularSessions); for (const group of projectGroups) { const groupEl = document.createElement('section'); groupEl.className = 'session-project-group'; @@ -4614,6 +4731,10 @@ for (const s of ungroupedSessions) { sessionList.appendChild(createSessionListItem(s)); } + + if (hiddenOldSessions.length > 0) { + sessionList.appendChild(createOldSessionLoadMoreButton(hiddenOldSessions.length)); + } } function startEditSessionTitle(itemEl, session) { @@ -5253,6 +5374,13 @@ }); } + if (reloadMcpBtn) { + reloadMcpBtn.addEventListener('click', (e) => { + e.stopPropagation(); + reloadCurrentMcpServers(); + }); + } + // Split new-chat button newChatBtn.addEventListener('click', () => showNewSessionModal()); newChatArrow.addEventListener('click', (e) => { diff --git a/public/index.html b/public/index.html index ee8585a..3edeaa1 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -102,6 +102,7 @@ + @@ -149,6 +150,6 @@ - + diff --git a/public/style.css b/public/style.css index 440b616..2b1bd91 100644 --- a/public/style.css +++ b/public/style.css @@ -1194,32 +1194,39 @@ body.session-loading-active { } .session-item-actions { display: none; + align-items: center; gap: 2px; margin-left: 4px; + position: relative; flex-shrink: 0; } -.session-item:hover .session-item-actions { display: flex; } +.session-item:hover .session-item-actions, +.session-item:focus-within .session-item-actions { + display: flex; +} .session-item-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; - padding: 2px 5px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; font-size: 13px; border-radius: 4px; line-height: 1; } .session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); } -.session-item-btn.copy-id { - min-width: 24px; - font-size: 10px; - font-weight: 800; - letter-spacing: 0.02em; -} .session-item-btn.pin { - min-width: 24px; font-weight: 800; } +.session-item-btn.more { + font-size: 16px; + letter-spacing: 0; +} .session-item-btn.pin.active { color: var(--accent); background: rgba(192, 85, 58, 0.1); @@ -1228,7 +1235,88 @@ body.session-loading-active { color: var(--accent); background: rgba(192, 85, 58, 0.12); } -.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); } +.session-item-more { + position: relative; + display: inline-flex; +} +.session-item-menu { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 20; + display: none; + min-width: 104px; + padding: 5px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + box-shadow: 0 10px 24px rgba(61, 42, 26, 0.14); +} +.session-item-more:hover .session-item-menu, +.session-item-more:focus-within .session-item-menu { + display: grid; + gap: 2px; +} +.session-item-menu-btn { + width: 100%; + padding: 7px 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 12px; + line-height: 1.2; + text-align: left; + white-space: nowrap; +} +.session-item-menu-btn:hover, +.session-item-menu-btn:focus-visible { + background: var(--bg-tertiary); + color: var(--text-primary); +} +.session-item-menu-btn.delete:hover, +.session-item-menu-btn.delete:focus-visible { + background: var(--accent-light); + color: var(--danger); +} +.session-list-load-more { + width: calc(100% - 4px); + margin: 6px 2px 10px; + padding: 9px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: rgba(255, 249, 242, 0.72); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + text-align: left; + transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s; +} +.session-list-load-more:hover { + background: var(--bg-tertiary); + border-color: rgba(192, 85, 58, 0.24); + color: var(--accent); + transform: translateY(-1px); +} +.session-list-load-more:focus-visible { + outline: 2px solid rgba(192, 85, 58, 0.22); + outline-offset: 2px; +} +.session-list-load-more-title { + min-width: 0; + font-size: 13px; + font-weight: 700; +} +.session-list-load-more-meta { + flex-shrink: 0; + color: var(--text-muted); + font-size: 11px; +} /* Inline edit in sidebar */ .session-item-edit-input { flex: 1; @@ -1329,7 +1417,8 @@ body.session-loading-active { .chat-agent-btn:disabled, .chat-cwd:disabled, .mode-select:disabled, -.user-outline-btn:disabled { +.user-outline-btn:disabled, +.reload-mcp-btn:disabled { opacity: 0.5; cursor: default; } @@ -1428,7 +1517,8 @@ body.session-loading-active { display: none !important; } .cost-display:empty { display: none; } -.user-outline-btn { +.user-outline-btn, +.reload-mcp-btn { appearance: none; border: 1px solid rgba(91, 126, 161, 0.22); border-radius: 999px; @@ -1446,7 +1536,8 @@ body.session-loading-active { display: inline-flex; flex-shrink: 0; } -.user-outline-btn:hover { +.user-outline-btn:hover, +.reload-mcp-btn:hover:not(:disabled) { background: rgba(91, 126, 161, 0.16); border-color: rgba(91, 126, 161, 0.34); } @@ -1987,9 +2078,37 @@ body.session-loading-active { color: var(--text-secondary); font-size: 13px; padding: 10px 16px; + position: relative; text-align: center; white-space: pre-line; } +.msg.system .system-message-text { + display: block; + padding-right: 26px; +} +.msg.system .system-message-close { + align-items: center; + background: transparent; + border: 0; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 16px; + height: 24px; + justify-content: center; + line-height: 1; + padding: 0; + position: absolute; + right: 7px; + top: 7px; + width: 24px; +} +.msg.system .system-message-close:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} /* Markdown content */ .msg-bubble p { margin: 0 0 8px 0; } @@ -2930,7 +3049,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { padding: 4px 20px 4px 8px; font-size: 11px; } - .user-outline-btn { + .user-outline-btn, + .reload-mcp-btn { padding: 4px 8px; font-size: 10px; } @@ -2975,6 +3095,7 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { .chat-cwd, .mode-select, .user-outline-btn, + .reload-mcp-btn, .chat-runtime-state { width: 100%; max-width: none; @@ -4548,10 +4669,12 @@ html[data-theme='coolvibe'] .settings-back:hover { :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-list-empty, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-header, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-create, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-list-load-more, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-agent-btn, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .mode-select, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-cwd, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-btn, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .reload-mcp-btn, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-item, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-back, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-nav-card, diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index fd2360c..086790b 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -25,6 +25,7 @@ if (args[0] !== 'app-server') { const threads = new Map(); const pendingServerRequests = new Map(); let nextServerRequestId = 1; +let mcpReloadCount = 0; function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); @@ -429,6 +430,20 @@ function startTurn(params) { }, }); + if (/runtime warning/i.test(text)) { + const message = 'Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.'; + for (let i = 0; i < 2; i += 1) { + send({ + method: 'warning', + params: { + threadId: thread.id, + turnId, + message, + }, + }); + } + } + if (/collaboration/i.test(text)) { completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`); return { turn: { id: turnId, status: 'running', items: [] } }; @@ -516,6 +531,11 @@ function handleRequest(message) { send({ id, result: { data: [{ mode: 'default' }, { mode: 'plan' }] } }); return; } + if (method === 'config/mcpServer/reload') { + mcpReloadCount += 1; + send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } }); + return; + } if (method === 'thread/start') { const thread = ensureThread(null, params); 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' } }); diff --git a/scripts/regression.js b/scripts/regression.js index 4174fa9..41320c2 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -168,6 +168,20 @@ async function fetchAuthedJson(port, token, pathname) { return payload; } +async function postAuthedJson(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(); + assert(response.ok && payload.ok, `POST failed for ${pathname}: ${payload.message || response.status}`); + return payload; +} + async function callInternalMcp(port, token, payload) { const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, { method: 'POST', @@ -785,6 +799,22 @@ async function main() { assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /Long threads and multiple compactions/.test(msg.message || '') + )); + assert(/Long threads and multiple compactions/.test(codexAppRuntimeWarning.message || ''), 'Codex App should surface the first runtime warning'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + await sleep(150); + const duplicateRuntimeWarnings = messages.filter((msg) => ( + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /Long threads and multiple compactions/.test(msg.message || '') + )); + assert(duplicateRuntimeWarnings.length === 0, 'Codex App should suppress duplicate runtime warning banners'); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); @@ -804,6 +834,10 @@ async function main() { assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted'); assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted'); + const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`); + assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id'); + assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload'); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list'); assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls'); @@ -835,10 +869,17 @@ async function main() { assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions'); ws.send(JSON.stringify({ type: 'codex_app_user_input_response', + action: 'submit', sessionId: codexAppSession.sessionId, requestId: guidedRequest.requestId, answers: { choice: { answers: ['A'] } }, })); + const guidedSubmitted = await nextMessage(messages, ws, (msg) => + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /已提交.*引导输入/.test(msg.message || '') + ); + assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint'); const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || '')); assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); diff --git a/server.js b/server.js index 3f54ff8..b401565 100644 --- a/server.js +++ b/server.js @@ -2015,6 +2015,69 @@ function getRuntimeSessionId(session) { return session.claudeSessionId || null; } +async function handleReloadMcpApi(req, res, rawSessionId) { + const token = extractBearerToken(req); + if (!token || !activeTokens.has(token)) { + return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' }); + } + + const sessionId = sanitizeId(rawSessionId || ''); + if (!sessionId) { + return jsonResponse(res, 400, { ok: false, code: 'missing_session_id', message: '缺少会话 ID' }); + } + + const session = loadSession(sessionId); + if (!session) { + return jsonResponse(res, 404, { ok: false, code: 'session_not_found', message: '会话不存在' }); + } + + if (!isCodexAppSession(session)) { + return jsonResponse(res, 400, { + ok: false, + code: 'reload_mcp_unsupported_agent', + message: '重载 MCP 仅支持 Codex App 会话。旧 Codex 会话请重启本地 Codex 后再继续。', + }); + } + + try { + const clientResult = getCodexAppClient(); + if (clientResult.error) { + return jsonResponse(res, 500, { + ok: false, + code: 'codexapp_client_unavailable', + message: clientResult.error, + }); + } + + const client = clientResult.client; + await client.start(); + const result = typeof client.reloadMcpServers === 'function' + ? await client.reloadMcpServers() + : await client.request('config/mcpServer/reload', {}, 30000); + + plog('INFO', 'codex_app_mcp_reload_requested', { + sessionId: sessionId.slice(0, 8), + threadId: getRuntimeSessionId(session) || null, + }); + + return jsonResponse(res, 200, { + ok: true, + sessionId, + threadId: getRuntimeSessionId(session) || null, + result: result || {}, + }); + } catch (err) { + const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || '')); + return jsonResponse(res, unsupported ? 501 : 500, { + ok: false, + code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed', + message: unsupported + ? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}` + : `重载 MCP 失败: ${err?.message || err}`, + }); + } +} + function setRuntimeSessionId(session, runtimeId) { if (!session) return; const agent = getSessionAgent(session); @@ -2900,6 +2963,11 @@ const server = http.createServer((req, res) => { return handleInternalMcpApi(req, res); } + const reloadMcpMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/reload-mcp$/); + if (req.method === 'POST' && reloadMcpMatch) { + return handleReloadMcpApi(req, res, decodeURIComponent(reloadMcpMatch[1] || '')); + } + if (req.method === 'POST' && url.pathname === '/api/attachments') { const token = extractBearerToken(req); if (!token || !activeTokens.has(token)) { @@ -4554,7 +4622,16 @@ function handleCodexAppUserInputResponse(ws, msg = {}) { pendingCodexAppUserInputs.delete(requestId); clearTimeout(pending.timer); - pending.resolve(normalizeCodexAppUserInputAnswers(msg.answers || {})); + const action = String(msg.action || 'submit').trim(); + const isCancel = action === 'cancel'; + if (!isCancel) { + wsSend(ws, { + type: 'system_message', + sessionId: pending.sessionId, + message: '已提交 Codex App 引导输入。', + }); + } + pending.resolve(isCancel ? { answers: {} } : normalizeCodexAppUserInputAnswers(msg.answers || {})); } function resolvePendingCodexAppUserInputsForSession(sessionId) {