Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions server/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,14 +258,17 @@ function setRestoreState(patch) {
}

// Mark a session as having produced a real completed turn. Codex only writes a
// resumable rollout file once a turn reports usage; a non-subagent entry with
// usage is our proxy-side signal for "this session can be resumed". Monotonic:
// once true it never flips back.
// resumable rollout file once a turn produces output; turns that billed input
// but emitted zero output (hung WS turns, cross-session retries) leave no
// rollout file, so `usage` alone is a false signal — status and stopReason are
// unreliable too (verified against ~/.codex/sessions ground truth, issue #44).
// output_tokens > 0 on a non-subagent entry is the only discriminator that
// matches. Monotonic: once true it never flips back.
function markSessionUsage(entry) {
const sid = entry?.sessionId;
if (!sid || NON_RESUMABLE_SESSIONS.has(sid)) return;
if (entry.isSubagent) return;
if (!entry.usage) return;
if (!(entry.usage?.output_tokens > 0)) return;
const meta = sessionMeta[sid] || (sessionMeta[sid] = {});
meta.hasUsage = true;
}
Expand Down
27 changes: 24 additions & 3 deletions test/restore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe('restoreFromLogs — maxContext re-inference for legacy entries', () =>

// Resume-eligibility must survive a restart: it is rebuilt purely from the
// index (no rollout-file probing), so a codex session is resumable iff the
// index holds a non-subagent usage turn for it.
// index holds a non-subagent turn with output_tokens > 0 for it.
describe('restoreFromLogs — codex resume eligibility', () => {
const config = require('../server/config');
const store = require('../server/store');
Expand Down Expand Up @@ -213,7 +213,7 @@ describe('restoreFromLogs — codex resume eligibility', () => {
await config.storage.appendIndex(JSON.stringify({
id: '2026-05-20T10-00-00-000', ts: '10:00:00', sessionId: sid,
provider: 'openai', agent: 'codex', model: 'gpt-5',
usage: { input_tokens: 42 }, isSubagent: false,
usage: { input_tokens: 42, output_tokens: 12 }, isSubagent: false,
isSSE: true, status: 200, receivedAt: 1779000000000,
}) + '\n');

Expand Down Expand Up @@ -241,7 +241,7 @@ describe('restoreFromLogs — codex resume eligibility', () => {
await config.storage.appendIndex(JSON.stringify({
id: '2026-05-20T12-01-00-000', ts: '12:01:00', sessionId: sid,
provider: 'openai', agent: 'codex', model: 'gpt-5',
usage: { input_tokens: 7 }, isSubagent: false,
usage: { input_tokens: 7, output_tokens: 2 }, isSubagent: false,
isSSE: true, status: 200, receivedAt: 1779000060000,
}) + '\n');

Expand All @@ -250,6 +250,27 @@ describe('restoreFromLogs — codex resume eligibility', () => {
assert.equal(summarizeEntry(first).resumeCommand, `codex resume ${sid}`);
});

it('a codex session with only a billed zero-output turn is not resumable after restore', async () => {
store.entries.length = 0;
const sid = 'codex-restore-zero-output';
// Issue #44 specimen 2: hung WS turn — input billed, zero output, no
// rollout file on disk. Restore must not resurrect the resume button.
await config.storage.appendIndex(JSON.stringify({
id: '2026-05-20T13-00-00-000', ts: '13:00:00', sessionId: sid,
provider: 'openai', agent: 'codex', model: 'gpt-5',
usage: { input_tokens: 9953, output_tokens: 0 }, isSubagent: false,
isSSE: true, status: 499, receivedAt: 1779000000000,
}) + '\n');

await restoreFromLogs();
assert.deepEqual(
store.computeSessionResume(sid, 'openai'),
{ resumable: false, resumeCommand: null },
);
const summary = summarizeEntry(store.entries.find(e => e.sessionId === sid));
assert.equal(summary.resumeCommand, null);
});

it('a codex session with only a 502-style turn (no usage) is not resumable', async () => {
store.entries.length = 0;
const sid = 'codex-restore-502';
Expand Down
2 changes: 1 addition & 1 deletion test/sse-broadcast.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('sse-broadcast', () => {

// A real completed turn flips the session to resumable; summarizeEntry both
// records the signal and reads it back (single source of truth).
const withUsage = summarizeEntry({ id: 'r2', sessionId: sid, provider: 'openai', usage: { input_tokens: 9 }, isSubagent: false });
const withUsage = summarizeEntry({ id: 'r2', sessionId: sid, provider: 'openai', usage: { input_tokens: 9, output_tokens: 4 }, isSubagent: false });
assert.equal(withUsage.resumable, true);
assert.equal(withUsage.resumeCommand, `codex resume ${sid}`);

Expand Down
26 changes: 20 additions & 6 deletions test/store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('store', () => {
});

it('an unknown provider fails closed (no resume command)', () => {
store.markSessionUsage({ sessionId: 'future-sid', isSubagent: false, usage: { input_tokens: 5 } });
store.markSessionUsage({ sessionId: 'future-sid', isSubagent: false, usage: { input_tokens: 5, output_tokens: 3 } });
assert.deepEqual(store.computeSessionResume('future-sid', 'future-provider'), { resumable: false, resumeCommand: null });
});

Expand All @@ -81,16 +81,16 @@ describe('store', () => {
assert.deepEqual(r, { resumable: false, resumeCommand: null });
});

it('codex session becomes resumable after a non-subagent usage turn', () => {
it('codex session becomes resumable after a non-subagent turn with output', () => {
const sid = 'codex-sid-withusage';
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5 } });
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5, output_tokens: 3 } });
const r = store.computeSessionResume(sid, 'openai');
assert.deepEqual(r, { resumable: true, resumeCommand: `codex resume ${sid}` });
});

it('subagent usage alone does not make a codex session resumable', () => {
const sid = 'codex-sid-subonly';
store.markSessionUsage({ sessionId: sid, isSubagent: true, usage: { input_tokens: 5 } });
store.markSessionUsage({ sessionId: sid, isSubagent: true, usage: { input_tokens: 5, output_tokens: 3 } });
assert.deepEqual(store.computeSessionResume(sid, 'openai'), { resumable: false, resumeCommand: null });
});

Expand All @@ -100,16 +100,30 @@ describe('store', () => {
assert.deepEqual(store.computeSessionResume(sid, 'openai'), { resumable: false, resumeCommand: null });
});

it('legacy usage without an output_tokens field fails closed', () => {
const sid = 'codex-sid-legacy-no-output-field';
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5 } });
assert.deepEqual(store.computeSessionResume(sid, 'openai'), { resumable: false, resumeCommand: null });
});

it('a billed zero-output turn does not mark the session (hung WS / cross-session retry)', () => {
const sid = 'codex-sid-zero-output';
// Specimen from issue #44: status 499 after 45m, input billed, no output,
// no rollout file on disk — `codex resume` would fail.
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 9953, output_tokens: 0 } });
assert.deepEqual(store.computeSessionResume(sid, 'openai'), { resumable: false, resumeCommand: null });
});

it('hasUsage is monotonic — a later usage-less turn keeps resumability', () => {
const sid = 'codex-sid-monotonic';
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5 } });
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5, output_tokens: 3 } });
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: null });
assert.equal(store.computeSessionResume(sid, 'openai').resumable, true);
});

it('sentinel sessions are never resumable regardless of provider', () => {
for (const sid of ['direct-api', 'codex-raw', 'unknown']) {
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5 } });
store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5, output_tokens: 3 } });
assert.deepEqual(store.computeSessionResume(sid, 'openai'), { resumable: false, resumeCommand: null });
assert.deepEqual(store.computeSessionResume(sid, 'anthropic'), { resumable: false, resumeCommand: null });
}
Expand Down
Loading