fix: persist codexapp streaming state
This commit is contained in:
@@ -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: [] } };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user