fix: persist codexapp streaming state

This commit is contained in:
shiyue
2026-06-15 18:17:41 +08:00
parent fbfbcf1ce4
commit 0f4a1c27fe
6 changed files with 375 additions and 10 deletions

View File

@@ -468,7 +468,41 @@ function startTurn(params) {
return { turn: { id: turnId, status: 'running', items: [] } };
}
const delay = /slow/i.test(text) ? 900 : 80;
const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;
if (/recover/i.test(text)) {
send({
method: 'item/agentMessage/delta',
params: {
threadId: thread.id,
turnId,
itemId: 'agent-msg',
delta: `partial before restart: ${text}`,
},
});
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'recover-tool',
type: 'commandExecution',
command: '/bin/bash -lc echo recover',
status: 'inProgress',
},
},
});
send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: thread.id,
turnId,
itemId: 'recover-tool',
delta: 'recover tool output\n',
},
});
}
thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);
return { turn: { id: turnId, status: 'running', items: [] } };
}

View File

@@ -87,6 +87,31 @@ async function withServer(env, fn) {
}
}
async function startServer(env) {
const child = spawn('/usr/bin/node', [SERVER_PATH], {
cwd: REPO_DIR,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
await waitForPort(env.PORT, 10000);
return {
child,
stdout: () => stdout,
stderr: () => stderr,
async stop(signal = 'SIGTERM') {
if (child.exitCode !== null || child.signalCode) return;
child.kill(signal);
await sleep(300);
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
await sleep(200);
},
};
}
function connectWs(port, password) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
@@ -1005,6 +1030,74 @@ async function main() {
ws.close();
console.log('Regression checks passed.');
});
const recoveryPort = await getFreePort();
const recoveryEnv = {
PORT: String(recoveryPort),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
};
const recoveryServer = await startServer(recoveryEnv);
let recoverySessionId = null;
let recoveryStatePath = null;
try {
const { ws, messages } = await connectWs(recoveryPort, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const recoverCwd = path.join(tempRoot, 'codexapp-recover-space');
mkdirp(recoverCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: recoverCwd, mode: 'yolo' }));
const recoverSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === recoverCwd);
recoverySessionId = recoverSession.sessionId;
ws.send(JSON.stringify({ type: 'message', text: 'slow recover codexapp prompt', sessionId: recoverySessionId, mode: 'yolo', agent: 'codexapp' }));
recoveryStatePath = path.join(sessionsDir, `${recoverySessionId}-run`, 'codexapp-state.json');
await waitForFile(recoveryStatePath);
let recoverStateStarted = null;
const stateStartedAt = Date.now();
while (Date.now() - stateStartedAt < 5000) {
recoverStateStarted = JSON.parse(fs.readFileSync(recoveryStatePath, 'utf8'));
if (/partial before restart/.test(recoverStateStarted.fullText || '')
&& recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || ''))) {
break;
}
await sleep(50);
}
assert(/partial before restart/.test(recoverStateStarted?.fullText || ''), 'Codex App running state should persist partial text before completion');
assert(recoverStateStarted.toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Codex App running state should persist partial tool output');
ws.close();
} finally {
await recoveryServer.stop('SIGKILL');
}
assert(recoverySessionId, 'Codex App recovery test did not create a session');
assert(recoveryStatePath && fs.existsSync(recoveryStatePath), 'Codex App recovery state should survive server crash');
const restartedRecoveryServer = await startServer(recoveryEnv);
try {
const { ws, messages } = await connectWs(recoveryPort, password);
const recoveredList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((session) => session.id === recoverySessionId));
const recoveredMeta = recoveredList.sessions.find((session) => session.id === recoverySessionId);
assert(recoveredMeta && !recoveredMeta.isRunning, 'Recovered Codex App partial turn should not stay marked running');
assert(!fs.existsSync(recoveryStatePath), 'Recovered Codex App state file should be cleaned after startup recovery');
ws.send(JSON.stringify({ type: 'load_session', sessionId: recoverySessionId }));
const recoveredSessionInfo = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.sessionId === recoverySessionId);
const recoveredAssistantMessages = (recoveredSessionInfo.messages || []).filter((message) => message.role === 'assistant' && /partial before restart/.test(String(message.content || '')));
assert(recoveredAssistantMessages.length === 1, 'Recovered Codex App partial assistant output should be persisted exactly once');
assert(recoveredAssistantMessages[0].codexAppRecoveredPartial === true, 'Recovered Codex App assistant output should be marked partial');
assert(recoveredAssistantMessages[0].toolCalls?.some((tool) => tool.id === 'recover-tool' && /recover tool output/.test(tool.result || '')), 'Recovered Codex App assistant output should keep tool calls');
ws.close();
} finally {
await restartedRecoveryServer.stop();
}
}
main().catch((err) => {