From a0e3998da655bca00d2dddb4baaba585e88d9cf2 Mon Sep 17 00:00:00 2001 From: cc-dan Date: Fri, 13 Mar 2026 13:31:06 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20Codex=20/compact=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=8E=E8=87=AA=E5=8A=A8=E7=BB=AD=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mock-codex.js | 41 ++++++++++++++++++- scripts/regression.js | 22 ++++++++++ server.js | 95 +++++++++++++++++++++++++++++-------------- 3 files changed, 125 insertions(+), 33 deletions(-) diff --git a/scripts/mock-codex.js b/scripts/mock-codex.js index 6de43bd..a014c99 100755 --- a/scripts/mock-codex.js +++ b/scripts/mock-codex.js @@ -1,6 +1,9 @@ #!/usr/bin/env node const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); function readStdin() { return new Promise((resolve) => { @@ -14,9 +17,22 @@ function readStdin() { (async function main() { const args = process.argv.slice(2); const isResume = args[0] === 'exec' && args[1] === 'resume'; - const threadId = isResume && args[2] ? args[2] : `mock-${crypto.randomUUID()}`; + const threadId = (() => { + if (!isResume) return `mock-${crypto.randomUUID()}`; + for (let i = args.length - 1; i >= 2; i--) { + const arg = args[i]; + if (arg === '-' || String(arg).startsWith('-')) continue; + return arg; + } + return `mock-${crypto.randomUUID()}`; + })(); const input = (await readStdin()).trim(); const imageCount = args.filter((arg) => arg === '--image').length; + const statePath = path.join(os.tmpdir(), `cc-web-mock-codex-${threadId}.json`); + let state = {}; + try { + state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + } catch {} process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: threadId })}\n`); process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`); @@ -46,15 +62,36 @@ function readStdin() { })}\n`); } + if (input === '/compact') { + state.compacted = true; + fs.writeFileSync(statePath, JSON.stringify(state)); + } + + if (input === 'trigger codex context limit' && !state.compacted) { + process.stdout.write(`${JSON.stringify({ + type: 'turn.failed', + error: { message: 'Context window exceeded. Please use /compact and retry.' }, + })}\n`); + process.exit(1); + } + + const responseText = input === '/compact' + ? 'Codex compact finished.' + : `Codex mock handled (${imageCount} image): ${input}`; + process.stdout.write(`${JSON.stringify({ type: 'item.completed', item: { id: 'item_msg', type: 'agent_message', - text: `Codex mock handled (${imageCount} image): ${input}`, + text: responseText, }, })}\n`); + if (input === 'trigger codex context limit' && state.compacted) { + try { fs.unlinkSync(statePath); } catch {} + } + process.stdout.write(`${JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 5 }, diff --git a/scripts/regression.js b/scripts/regression.js index 79433d5..e559f7d 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -350,6 +350,28 @@ async function main() { assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode'); assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url'); + ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: firstMessageSession.sessionId, mode: 'yolo', agent: 'codex' })); + await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在执行 Codex \/compact/.test(msg.message || '')); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId); + const compactDoneMsg = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || '')); + assert(/已执行 Codex \/compact/.test(compactDoneMsg.message || ''), 'Codex /compact should complete with Codex-specific status message'); + + const autoCompactCwd = path.join(tempRoot, 'codex-auto-compact'); + mkdirp(autoCompactCwd); + ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: autoCompactCwd, mode: 'yolo' })); + const autoCompactSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === autoCompactCwd); + ws.send(JSON.stringify({ type: 'message', text: 'warm up auto compact', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' })); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === autoCompactSession.sessionId); + ws.send(JSON.stringify({ type: 'message', text: 'trigger codex context limit', sessionId: autoCompactSession.sessionId, mode: 'yolo', agent: 'codex' })); + const autoCompactStart = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /正在按 Codex \/compact 自动压缩/.test(msg.message || '')); + assert(/Codex \/compact/.test(autoCompactStart.message || ''), 'Codex auto /compact should announce auto compact start'); + const autoCompactDone = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /已执行 Codex \/compact/.test(msg.message || '')); + assert(/已执行 Codex \/compact/.test(autoCompactDone.message || ''), 'Codex auto /compact should finish compact step'); + const autoCompactResume = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && /按 Codex 压缩计划继续执行/.test(msg.message || '')); + assert(/继续执行/.test(autoCompactResume.message || ''), 'Codex auto /compact should announce retry'); + const autoCompactRetryText = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && /trigger codex context limit/.test(msg.text || ''), 8000); + assert(/trigger codex context limit/.test(autoCompactRetryText.text || ''), 'Codex auto /compact should replay the failed prompt after compact'); + const claudeAttachment = await uploadAttachment(port, token, { filename: 'claude-test.png', mime: 'image/png', diff --git a/server.js b/server.js index 3db345b..cda1fd9 100644 --- a/server.js +++ b/server.js @@ -895,6 +895,39 @@ function formatRuntimeError(agent, raw, context = {}) { return `Claude 任务失败${exitInfo}:${condensed}`; } +function compactStartMessage(agent) { + return agent === 'codex' + ? '正在执行 Codex /compact 压缩上下文,请稍候…' + : '正在执行 Claude 原生 /compact 压缩上下文,请稍候…'; +} + +function compactDoneMessage(agent) { + return agent === 'codex' + ? '上下文压缩完成。已执行 Codex /compact,下次继续在同一会话发送即可。' + : '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。'; +} + +function compactAutoStartMessage(agent) { + return agent === 'codex' + ? '检测到上下文达到上限,正在按 Codex /compact 自动压缩,然后继续当前任务…' + : '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact,然后继续当前任务…'; +} + +function compactAutoResumeMessage(agent) { + return agent === 'codex' + ? '检测到上一条请求因上下文过大失败,现已按 Codex 压缩计划继续执行。' + : '检测到上一条请求因上下文过大失败,现已自动按压缩计划继续执行。'; +} + +function isContextLimitError(agent, raw) { + const text = String(raw || ''); + if (!text) return false; + if (agent === 'claude') { + return /Request too large \(max 20MB\)/i.test(text); + } + return /context\s+(window|length)|maximum context length|context limit|token limit|too many tokens|input.*too long|prompt.*too long|request too large|please use\s*\/compact|use\s*\/compact|reduce (the )?(input|prompt|message)|exceed(?:ed|s).*(token|context)/i.test(text); +} + function handleProcessComplete(sessionId, exitCode, signal) { const entry = activeProcesses.get(sessionId); if (!entry) return; @@ -906,7 +939,7 @@ function handleProcessComplete(sessionId, exitCode, signal) { : null; const pendingRetry = pendingCompactRetries.get(sessionId) || null; - let requestTooLarge = false; + let contextLimitExceeded = false; // Read stderr for error clues let stderrSnippet = ''; @@ -918,13 +951,12 @@ function handleProcessComplete(sessionId, exitCode, signal) { } } catch {} - requestTooLarge = entry.agent === 'claude' - && (/Request too large \(max 20MB\)/i.test(entry.fullText || '') || /Request too large \(max 20MB\)/i.test(stderrSnippet || '')); const rawCompletionError = entry.lastError || ( ((typeof exitCode === 'number' && exitCode !== 0) || (!!signal && signal !== 'SIGTERM')) ? (stderrSnippet || null) : null ); + contextLimitExceeded = isContextLimitError(entry.agent || 'claude', `${entry.fullText || ''}\n${stderrSnippet || ''}\n${rawCompletionError || ''}`); const completionError = rawCompletionError ? formatRuntimeError(entry.agent || 'claude', rawCompletionError, { exitCode, signal }) : null; if (!entry.lastError && rawCompletionError) entry.lastError = rawCompletionError; @@ -943,7 +975,7 @@ function handleProcessComplete(sessionId, exitCode, signal) { usage: entry.lastUsage || null, error: rawCompletionError, stderr: stderrSnippet || null, - requestTooLarge, + requestTooLarge: contextLimitExceeded, }); // Final read @@ -978,6 +1010,7 @@ function handleProcessComplete(sessionId, exitCode, signal) { } let shouldReturnForFollowup = false; + let shouldAutoCompact = false; activeProcesses.delete(sessionId); cleanRunDir(sessionId); @@ -987,30 +1020,28 @@ function handleProcessComplete(sessionId, exitCode, signal) { if (entry.ws) { if (pendingSlash?.kind === 'compact') { const retry = pendingCompactRetries.get(sessionId); - if (retry?.reason === 'auto') { - wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。' }); - pendingCompactRetries.delete(sessionId); - } else if (retry?.text) { - if (requestTooLarge) { + const autoRetryRequested = !!(retry?.text && retry?.reason === 'auto'); + if (autoRetryRequested) { + if (contextLimitExceeded) { pendingCompactRetries.delete(sessionId); wsSend(entry.ws, { type: 'system_message', message: '已尝试执行 /compact,但仍未成功解除上下文超限。请手动缩小输入范围后重试。' }); } else { - wsSend(entry.ws, { type: 'system_message', message: '检测到上一条请求因上下文过大失败,现已自动按压缩计划继续执行。' }); + wsSend(entry.ws, { type: 'system_message', message: compactDoneMessage(entry.agent || 'claude') }); + wsSend(entry.ws, { type: 'system_message', message: compactAutoResumeMessage(entry.agent || 'claude') }); shouldReturnForFollowup = true; - pendingCompactRetries.delete(sessionId); } } else { - wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。' }); + wsSend(entry.ws, { type: 'system_message', message: compactDoneMessage(entry.agent || 'claude') }); } } - if (requestTooLarge && !pendingSlash && session && session.claudeSessionId) { + if (contextLimitExceeded && !pendingSlash && session && getRuntimeSessionId(session)) { pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo', reason: 'auto' }); - wsSend(entry.ws, { type: 'system_message', message: '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact,然后继续当前任务…' }); - shouldReturnForFollowup = true; + wsSend(entry.ws, { type: 'system_message', message: compactAutoStartMessage(entry.agent || 'claude') }); + shouldAutoCompact = true; } - if (completionError && !entry.errorSent) { + if (completionError && !entry.errorSent && !shouldAutoCompact) { entry.errorSent = true; wsSend(entry.ws, { type: 'error', message: completionError }); } @@ -1041,7 +1072,7 @@ function handleProcessComplete(sessionId, exitCode, signal) { ); } - if (!shouldReturnForFollowup && !requestTooLarge && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) { + if (!shouldReturnForFollowup && !shouldAutoCompact && !contextLimitExceeded && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) { pendingCompactRetries.delete(sessionId); } @@ -1054,12 +1085,12 @@ function handleProcessComplete(sessionId, exitCode, signal) { } return; } + } - if (requestTooLarge && !pendingSlash && session.claudeSessionId) { - pendingSlashCommands.set(sessionId, { kind: 'compact' }); - handleMessage(entry.ws, { text: '/compact', sessionId, mode: session.permissionMode || 'yolo' }, { hideInHistory: true }); - return; - } + if (shouldAutoCompact && entry.ws && entry.ws.readyState === 1 && session) { + pendingSlashCommands.set(sessionId, { kind: 'compact' }); + handleMessage(entry.ws, { text: '/compact', sessionId, mode: session.permissionMode || 'yolo' }, { hideInHistory: true }); + return; } } @@ -1698,10 +1729,6 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { } case '/compact': { - if (agent !== 'claude') { - wsSend(ws, { type: 'system_message', message: 'Codex 会话暂不支持 /compact。' }); - break; - } if (!sessionId || !session) { wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' }); break; @@ -1710,12 +1737,18 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' }); break; } - if (!session.claudeSessionId) { - wsSend(ws, { type: 'system_message', message: '当前会话尚未建立 Claude 上下文,暂时无需压缩。' }); + const runtimeId = getRuntimeSessionId(session); + if (!runtimeId) { + wsSend(ws, { + type: 'system_message', + message: agent === 'codex' + ? '当前会话尚未建立 Codex 上下文,暂时无需压缩。' + : '当前会话尚未建立 Claude 上下文,暂时无需压缩。', + }); break; } - wsSend(ws, { type: 'system_message', message: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…' }); + wsSend(ws, { type: 'system_message', message: compactStartMessage(agent) }); pendingSlashCommands.set(session.id, { kind: 'compact' }); handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true }); break; @@ -1753,7 +1786,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { wsSend(ws, { type: 'system_message', message: agent === 'codex' - ? base + '\n/model [名称] — 查看/切换 Codex 模型(自由输入)' + ? base + '\n/model [名称] — 查看/切换 Codex 模型(自由输入)\n/compact — 执行 Codex /compact 压缩上下文' : base + '\n/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)', }); break; @@ -2111,7 +2144,7 @@ function handleMessage(ws, msg, options = {}) { session.permissionMode = mode; } - if (!hideInHistory && normalizedText !== '/compact' && session.claudeSessionId) { + if (!hideInHistory && normalizedText !== '/compact' && getRuntimeSessionId(session)) { pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' }); }