From 29bb9383859e9b74ff08e91af9eac2db6884b832 Mon Sep 17 00:00:00 2001 From: cc-dan Date: Mon, 16 Mar 2026 15:26:43 +0000 Subject: [PATCH] Fix Codex context retention across mode switch --- scripts/regression.js | 52 ++++++++++++++++++++++++++++---- server.js | 69 ++++++++++++++++++++++--------------------- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/scripts/regression.js b/scripts/regression.js index e4b82e7..e53467b 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -41,6 +41,15 @@ async function waitForPort(port, timeoutMs = 10000) { throw new Error(`Timed out waiting for port ${port}`); } +async function waitForFile(filePath, timeoutMs = 10000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (fs.existsSync(filePath)) return; + await sleep(50); + } + throw new Error(`Timed out waiting for file: ${filePath}`); +} + async function withServer(env, fn) { const child = spawn('/usr/bin/node', [SERVER_PATH], { cwd: REPO_DIR, @@ -95,19 +104,26 @@ async function uploadAttachment(port, token, { filename, mime, data }) { return payload.attachment; } -function nextMessage(messages, ws, predicate, timeoutMs = 5000) { +function nextMessage(messages, ws, predicate, timeoutMs = 15000) { + const callSite = (() => { + const stack = String(new Error().stack || '').split('\n'); + return (stack[3] || stack[2] || '').trim(); + })(); return new Promise((resolve, reject) => { const started = Date.now(); const timer = setInterval(() => { - const found = messages.find(predicate); - if (found) { + const idx = messages.findIndex(predicate); + if (idx !== -1) { clearInterval(timer); + const found = messages.splice(idx, 1)[0]; resolve(found); return; } if (Date.now() - started > timeoutMs) { clearInterval(timer); - reject(new Error('Timed out waiting for expected WebSocket message')); + const recentTypes = messages.slice(-12).map((m) => m?.type).join(', '); + const pendingTypes = messages.slice(0, 12).map((m) => m?.type).join(', '); + reject(new Error(`Timed out waiting for expected WebSocket message (wsState=${ws.readyState}, callSite=${callSite}, pendingTypes=[${pendingTypes}], recentTypes=[${recentTypes}])`)); } }, 50); }); @@ -340,18 +356,44 @@ async function main() { const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning)); assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId); + + // Switching permission mode must not clear Codex thread id (otherwise resume loses context). + const codexSessionPath = path.join(sessionsDir, `${firstMessageSession.sessionId}.json`); + await waitForFile(codexSessionPath, 15000); + const storedAfterFirst = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8')); + const threadIdBeforeMode = storedAfterFirst.codexThreadId; + assert(threadIdBeforeMode, 'Codex thread id should be persisted after first run'); + + ws.send(JSON.stringify({ type: 'set_mode', sessionId: firstMessageSession.sessionId, mode: 'plan' })); + await nextMessage(messages, ws, (msg) => msg.type === 'mode_changed' && msg.mode === 'plan'); + await waitForFile(codexSessionPath, 15000); + const storedAfterMode = JSON.parse(fs.readFileSync(codexSessionPath, 'utf8')); + assert(storedAfterMode.codexThreadId === threadIdBeforeMode, 'Codex thread id should survive mode switch'); + + ws.send(JSON.stringify({ type: 'message', text: 'second codex prompt', sessionId: firstMessageSession.sessionId, mode: 'plan', agent: 'codex' })); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId); + const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8'); const spawnLine = processLog .trim() .split('\n') .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8))); assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag'); + + const allSpawnsForSession = processLog + .trim() + .split('\n') + .filter((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8))); + const lastSpawn = allSpawnsForSession[allSpawnsForSession.length - 1] || ''; + assert(lastSpawn.includes('exec resume') && lastSpawn.includes(threadIdBeforeMode), 'Codex mode switch should keep resume thread id'); + assert(lastSpawn.includes('-s read-only'), 'Codex plan mode should set sandbox read-only'); + const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8'); 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 === 'system_message' && /正在执行/.test(msg.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'); diff --git a/server.js b/server.js index 52f340e..cd1120d 100644 --- a/server.js +++ b/server.js @@ -2055,25 +2055,27 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { break; } - case '/mode': { - const modeInput = parts[1]; - const VALID_MODES = ['default', 'plan', 'yolo']; - const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' }; - if (!modeInput) { - const cur = session?.permissionMode || 'yolo'; - wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` }); - } else if (VALID_MODES.includes(modeInput.toLowerCase())) { - const mode = modeInput.toLowerCase(); - if (session) { - session.permissionMode = mode; - clearRuntimeSessionId(session); - session.updated = new Date().toISOString(); - saveSession(session); - } - wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` }); - wsSend(ws, { type: 'mode_changed', mode }); - } else { - wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` }); + case '/mode': { + const modeInput = parts[1]; + const VALID_MODES = ['default', 'plan', 'yolo']; + const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' }; + if (!modeInput) { + const cur = session?.permissionMode || 'yolo'; + wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` }); + } else if (VALID_MODES.includes(modeInput.toLowerCase())) { + const mode = modeInput.toLowerCase(); + if (session) { + session.permissionMode = mode; + // Claude CLI permission mode changes should reset runtime resume id. + // For Codex, keep thread id so mode switching does not drop context. + if (isClaudeSession(session)) clearRuntimeSessionId(session); + session.updated = new Date().toISOString(); + saveSession(session); + } + wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` }); + wsSend(ws, { type: 'mode_changed', mode }); + } else { + wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` }); } break; } @@ -2330,20 +2332,21 @@ function handleRenameSession(ws, sessionId, title) { } } -function handleSetMode(ws, sessionId, mode) { - const VALID_MODES = ['default', 'plan', 'yolo']; - if (!mode || !VALID_MODES.includes(mode)) return; - if (sessionId) { - const session = loadSession(sessionId); - if (session) { - session.permissionMode = mode; - clearRuntimeSessionId(session); - session.updated = new Date().toISOString(); - saveSession(session); - } - } - wsSend(ws, { type: 'mode_changed', mode }); -} + function handleSetMode(ws, sessionId, mode) { + const VALID_MODES = ['default', 'plan', 'yolo']; + if (!mode || !VALID_MODES.includes(mode)) return; + if (sessionId) { + const session = loadSession(sessionId); + if (session) { + session.permissionMode = mode; + // Same rule as /mode: don't clear Codex thread id on mode changes. + if (isClaudeSession(session)) clearRuntimeSessionId(session); + session.updated = new Date().toISOString(); + saveSession(session); + } + } + wsSend(ws, { type: 'mode_changed', mode }); + } function handleDisconnect(ws, wsId) { const affectedSessions = [];