feat: improve codex app controls and recovery

This commit is contained in:
shiyue
2026-06-15 13:22:36 +08:00
parent 3a4006b7d3
commit ed3238fa49
8 changed files with 448 additions and 29 deletions

View File

@@ -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 };
}

View File

@@ -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,
};

View File

@@ -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 = `
<span class="session-list-load-more-title">加载更多</span>
<span class="session-list-load-more-meta">${hiddenCount} 条 7 天前会话</span>
`;
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 @@
<span class="session-item-time">${timeAgo(session.updated)}</span>
<div class="session-item-actions">
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
<button class="session-item-btn copy-id" title="复制 ID">ID</button>
<button class="session-item-btn edit" title="重命名"></button>
<button class="session-item-btn delete" title="删除">×</button>
<div class="session-item-more">
<button class="session-item-btn more" type="button" title="更多操作" aria-label="更多操作" aria-haspopup="menu"></button>
<div class="session-item-menu" role="menu" aria-label="会话操作">
<button class="session-item-menu-btn copy-id" type="button" role="menuitem">复制 ID</button>
<button class="session-item-menu-btn edit" type="button" role="menuitem">重命名</button>
<button class="session-item-menu-btn delete" type="button" role="menuitem">删除</button>
</div>
</div>
</div>
`;
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) => {

View File

@@ -14,7 +14,7 @@
document.documentElement.dataset.dividerTime = dividerTime;
})();
</script>
<link rel="stylesheet" href="style.css?v=20260614-divider-time-selectfix">
<link rel="stylesheet" href="style.css?v=20260615-reload-mcp">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head>
<body>
@@ -102,6 +102,7 @@
<button id="user-outline-btn" class="user-outline-btn" type="button" aria-expanded="false" aria-controls="user-outline-panel" title="定位用户消息">定位</button>
<div id="user-outline-panel" class="user-outline-panel" hidden></div>
</div>
<button id="reload-mcp-btn" class="reload-mcp-btn" type="button" title="重载 Codex App MCP 配置" hidden>重载 MCP</button>
<span id="cost-display" class="cost-display" hidden></span>
</div>
<div id="attachment-tray" class="attachment-tray" hidden></div>
@@ -149,6 +150,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="app.js?v=20260614-divider-time-selectfix"></script>
<script src="app.js?v=20260615-reload-mcp"></script>
</body>
</html>

View File

@@ -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,

View File

@@ -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' } });

View File

@@ -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);

View File

@@ -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) {