Fix Codex context retention across mode switch
This commit is contained in:
@@ -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');
|
||||
|
||||
69
server.js
69
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 = [];
|
||||
|
||||
Reference in New Issue
Block a user