diff --git a/public/entry-rendering.js b/public/entry-rendering.js
index a6c19e6..27f126f 100644
--- a/public/entry-rendering.js
+++ b/public/entry-rendering.js
@@ -235,7 +235,7 @@ function addEntry(e) {
const entryCwd = e.cwd || null;
if (!sessionsMap.has(sid)) {
const shortSid = sid.slice(0, 8);
- sessionsMap.set(sid, { id: sid, firstTs: e.ts, firstId: entryId, lastId: entryId, count: 0, mainCount: 0, subCount: 0, model, totalCost: 0, cwd: entryCwd, title: null, titleReqTs: 0, lastAssistantText: null, agent: e.agent || 'claude', provider: e.provider || 'anthropic', latestCacheHitRatio: 0, latestCacheReadTokens: 0 });
+ sessionsMap.set(sid, { id: sid, firstTs: e.ts, firstId: entryId, lastId: entryId, count: 0, mainCount: 0, subCount: 0, model, totalCost: 0, cwd: entryCwd, title: null, titleReqTs: 0, lastAssistantText: null, agent: e.agent || 'claude', provider: e.provider || 'anthropic', latestCacheHitRatio: 0, latestCacheReadTokens: 0, resumeCommand: null });
// Live-update visibleProviders when a new provider appears
const settings = window.ccxraySettings;
if (!Array.isArray(settings.visibleProviders)) settings.visibleProviders = [];
@@ -268,6 +268,9 @@ function addEntry(e) {
}
}
const sess = sessionsMap.get(sid);
+ // Resume command is computed server-side (single source of truth). Sticky:
+ // once any turn reports a command, keep it even if later turns lack usage.
+ if (e.resumeCommand) sess.resumeCommand = e.resumeCommand;
// Update cwd if not yet known or was only a quota-check
if (entryCwd && (!sess.cwd || sess.cwd === '(quota-check)')) sess.cwd = entryCwd;
if (model && model !== '?') sess.model = model;
diff --git a/public/miller-columns.js b/public/miller-columns.js
index be54516..3b04d0d 100644
--- a/public/miller-columns.js
+++ b/public/miller-columns.js
@@ -1386,8 +1386,8 @@ function formatEntryDateShort(id) {
}
-function copySessionContinue(sid, btn, agent) {
- const cmd = agent === 'codex' ? 'codex resume ' + sid : (agent || 'claude') + ' --resume ' + sid;
+function copySessionContinue(cmd, btn) {
+ if (!cmd) return;
navigator.clipboard.writeText(cmd).then(() => {
const orig = btn.textContent;
btn.textContent = '✓ copied!';
@@ -1495,12 +1495,12 @@ function renderSessionItem(sess, sid) {
const titleRow = sess.title
? '
' + escapeHtml(sess.title) + '
'
: '';
- const resumeCmd = (sess.agent || 'claude') === 'codex'
- ? 'codex resume ' + sid
- : (sess.agent || 'claude') + ' --resume ' + sid;
- const copyBtn = sid === 'direct-api' || sid === 'codex-raw'
- ? ''
- : '';
+ // Resume command is computed server-side; null means this session can't be
+ // resumed (e.g. a codex session that only errored before any turn completed).
+ const resumeCmd = sess.resumeCommand || null;
+ const copyBtn = resumeCmd
+ ? ''
+ : '';
return '' +
'' +
'' + escapeHtml(shortSid) + '' +
diff --git a/server/providers.js b/server/providers.js
index b97083f..1090216 100644
--- a/server/providers.js
+++ b/server/providers.js
@@ -87,11 +87,15 @@ const UPSTREAM_PROFILES = Object.freeze({
cache: 'ephemeral-ttl',
inputIncludesCached: false,
label: 'Anthropic',
+ resume: Object.freeze({ template: '{agent} --resume {sid}', condition: 'always' }),
}),
openai: Object.freeze({
cache: 'server-managed',
inputIncludesCached: true,
label: 'OpenAI',
+ // Codex only writes a rollout file (resumable session) after a successful
+ // API turn — sessions with only startup errors can't be resumed.
+ resume: Object.freeze({ template: 'codex resume {sid}', condition: 'has-usage' }),
}),
});
diff --git a/server/restore.js b/server/restore.js
index 041d70e..06958be 100644
--- a/server/restore.js
+++ b/server/restore.js
@@ -175,6 +175,10 @@ async function restoreFromLogs() {
if (meta.provider) store.sessionMeta[meta.sessionId].provider = meta.provider;
if (meta.cwd) store.sessionMeta[meta.sessionId].cwd = meta.cwd;
if (meta.receivedAt) store.sessionMeta[meta.sessionId].lastSeenAt = meta.receivedAt;
+ // Mark resume-eligibility before any summarizeEntry pass so every entry in
+ // the session reports the final (monotonic) resumable value, not the value
+ // as of its position in the index.
+ store.markSessionUsage(meta);
}
if (meta.cost?.cost != null && meta.sessionId) {
store.sessionCosts.set(meta.sessionId, (store.sessionCosts.get(meta.sessionId) || 0) + meta.cost.cost);
diff --git a/server/routes/api.js b/server/routes/api.js
index 16dfef6..cff77a6 100644
--- a/server/routes/api.js
+++ b/server/routes/api.js
@@ -28,7 +28,7 @@ function computeSettings() {
const visibleProviders = [...new Set([...fromMeta, ...fromEntries])];
const providerProfiles = Object.fromEntries(
- Object.entries(UPSTREAM_PROFILES).map(([k, v]) => [k, { cache: v.cache, label: v.label }])
+ Object.entries(UPSTREAM_PROFILES).map(([k, v]) => [k, { cache: v.cache, label: v.label, resume: v.resume }])
);
return {
diff --git a/server/sse-broadcast.js b/server/sse-broadcast.js
index e2271f3..1622fbe 100644
--- a/server/sse-broadcast.js
+++ b/server/sse-broadcast.js
@@ -6,10 +6,17 @@ const { agentForProvider } = require('./providers');
// Strip req/res from broadcast — browser only needs summary for the turn list
function summarizeEntry(entry) {
const tok = entry.tokens;
+ // Server owns the resume-button policy. Record this turn's usage signal first,
+ // then compute the per-session resume command so the client is a pure view.
+ // (Deliberate side-effect in a serialize function: this is the single funnel
+ // both SSE broadcast and the /api/entries restore batch pass through.)
+ store.markSessionUsage(entry);
+ const { resumable, resumeCommand } = store.computeSessionResume(entry.sessionId, entry.provider);
return {
id: entry.id, ts: entry.ts, sessionId: entry.sessionId,
provider: entry.provider || 'anthropic',
agent: entry.agent || agentForProvider(entry.provider),
+ resumable, resumeCommand,
method: entry.method, url: entry.url,
elapsed: entry.elapsed, status: entry.status, isSSE: entry.isSSE,
receivedAt: entry.receivedAt || null,
diff --git a/server/store.js b/server/store.js
index 5d58166..029d103 100644
--- a/server/store.js
+++ b/server/store.js
@@ -1,5 +1,10 @@
'use strict';
+const { agentForProvider, getUpstreamProfile } = require('./providers');
+
+// Synthetic session buckets that have no resumable rollout/session file.
+const NON_RESUMABLE_SESSIONS = new Set(['direct-api', 'codex-raw', 'unknown']);
+
// ── In-memory store & SSE clients ───────────────────────────────────
const MAX_ENTRIES = parseInt(process.env.CCXRAY_MAX_ENTRIES || '5000', 10);
const entries = [];
@@ -252,6 +257,43 @@ function setRestoreState(patch) {
restoreState.entryCount = entries.length;
}
+// 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.
+function markSessionUsage(entry) {
+ const sid = entry?.sessionId;
+ if (!sid || NON_RESUMABLE_SESSIONS.has(sid)) return;
+ if (entry.isSubagent) return;
+ if (!entry.usage) return;
+ const meta = sessionMeta[sid] || (sessionMeta[sid] = {});
+ meta.hasUsage = true;
+}
+
+// Single source of truth for the dashboard's resume button. Interprets the
+// declarative resume profile from UPSTREAM_PROFILES ({template, condition}):
+// 'always' resumes unconditionally, 'has-usage' requires a completed turn.
+// Returns the resume command string (null when the session can't be resumed).
+function computeSessionResume(sessionId, provider) {
+ if (!sessionId || NON_RESUMABLE_SESSIONS.has(sessionId)) {
+ return { resumable: false, resumeCommand: null };
+ }
+ // Entries without a provider predate provider tagging — they are anthropic.
+ // An unknown provider, however, fails closed: better no button than a
+ // command we can't vouch for.
+ const profile = provider == null ? getUpstreamProfile('anthropic') : getUpstreamProfile(provider);
+ if (!profile) return { resumable: false, resumeCommand: null };
+ const resume = profile.resume;
+ if (!resume) return { resumable: false, resumeCommand: null };
+ if (resume.condition === 'has-usage' && !sessionMeta[sessionId]?.hasUsage) {
+ return { resumable: false, resumeCommand: null };
+ }
+ const resumeCommand = resume.template
+ .replace('{agent}', agentForProvider(provider))
+ .replace('{sid}', sessionId);
+ return { resumable: true, resumeCommand };
+}
+
module.exports = {
MAX_ENTRIES,
entries,
@@ -281,4 +323,7 @@ module.exports = {
getSessionTitle,
attributeTitleGen,
propagateLoadedSkills,
+ markSessionUsage,
+ computeSessionResume,
+ NON_RESUMABLE_SESSIONS,
};
diff --git a/test/providers.test.js b/test/providers.test.js
index d90d34e..b289f44 100644
--- a/test/providers.test.js
+++ b/test/providers.test.js
@@ -74,6 +74,18 @@ describe('agent provider registry', () => {
assert.equal(providers.PROVIDER_AGENT.openai, 'codex');
});
+ it('declares resume profiles per upstream', () => {
+ const { UPSTREAM_PROFILES } = providers;
+ assert.deepEqual(UPSTREAM_PROFILES.anthropic.resume, {
+ template: '{agent} --resume {sid}',
+ condition: 'always',
+ });
+ assert.deepEqual(UPSTREAM_PROFILES.openai.resume, {
+ template: 'codex resume {sid}',
+ condition: 'has-usage',
+ });
+ });
+
it('centralizes display names and unsupported-provider handling', () => {
assert.equal(providers.getDisplayName('claude', {}), 'ccxray');
assert.equal(providers.getDisplayName('codex', {}), 'ccxray');
diff --git a/test/restore.test.js b/test/restore.test.js
index 17744ed..ce76926 100644
--- a/test/restore.test.js
+++ b/test/restore.test.js
@@ -177,3 +177,95 @@ describe('restoreFromLogs — maxContext re-inference for legacy entries', () =>
assert.equal(entry.maxContext, null);
});
});
+
+// ── codex resume eligibility ────────────────────────────────────────
+
+// 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.
+describe('restoreFromLogs — codex resume eligibility', () => {
+ const config = require('../server/config');
+ const store = require('../server/store');
+ const { restoreFromLogs } = require('../server/restore');
+ const { summarizeEntry } = require('../server/sse-broadcast');
+ const tmpDir = path.join(os.tmpdir(), 'ccxray-restore-resume-' + Date.now());
+ let realStorage;
+ let realRestoreDays;
+
+ before(async () => {
+ realStorage = config.storage;
+ realRestoreDays = config.RESTORE_DAYS;
+ config.RESTORE_DAYS = 0;
+ const tmpStorage = require('../server/storage/local').createLocalStorage(tmpDir);
+ await tmpStorage.init();
+ config.storage = tmpStorage;
+ });
+
+ after(() => {
+ config.storage = realStorage;
+ config.RESTORE_DAYS = realRestoreDays;
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('a codex session with a completed turn is resumable after restore', async () => {
+ store.entries.length = 0;
+ const sid = 'codex-restore-ok';
+ 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,
+ isSSE: true, status: 200, receivedAt: 1779000000000,
+ }) + '\n');
+
+ await restoreFromLogs();
+ assert.deepEqual(
+ store.computeSessionResume(sid, 'openai'),
+ { resumable: true, resumeCommand: `codex resume ${sid}` },
+ );
+ const summary = summarizeEntry(store.entries.find(e => e.sessionId === sid));
+ assert.equal(summary.resumeCommand, `codex resume ${sid}`);
+ });
+
+ it('marks usage before serialization: a usage-less entry earlier in the index still reports the final command', async () => {
+ store.entries.length = 0;
+ const sid = 'codex-restore-late-usage';
+ // First indexed entry has no usage; the usage-bearing turn comes later.
+ // Without the restore-loop pre-marking, serializing entry 1 first would
+ // report resumable:false (summarizeEntry only marks as it goes).
+ await config.storage.appendIndex(JSON.stringify({
+ id: '2026-05-20T12-00-00-000', ts: '12:00:00', sessionId: sid,
+ provider: 'openai', agent: 'codex', model: 'gpt-5',
+ usage: null, isSubagent: false,
+ isSSE: false, status: 502, receivedAt: 1779000000000,
+ }) + '\n');
+ 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,
+ isSSE: true, status: 200, receivedAt: 1779000060000,
+ }) + '\n');
+
+ await restoreFromLogs();
+ const first = store.entries.find(e => e.id === '2026-05-20T12-00-00-000');
+ assert.equal(summarizeEntry(first).resumeCommand, `codex resume ${sid}`);
+ });
+
+ 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';
+ await config.storage.appendIndex(JSON.stringify({
+ id: '2026-05-20T11-00-00-000', ts: '11:00:00', sessionId: sid,
+ provider: 'openai', agent: 'codex', model: 'gpt-5',
+ usage: null, isSubagent: false,
+ isSSE: false, status: 502, 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);
+ });
+});
diff --git a/test/resume-button-render.test.js b/test/resume-button-render.test.js
new file mode 100644
index 0000000..38adba9
--- /dev/null
+++ b/test/resume-button-render.test.js
@@ -0,0 +1,73 @@
+'use strict';
+
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+// Render-level regression for the resume copy button: the client is a pure
+// view of the server-computed sess.resumeCommand — no button when null.
+function loadMillerColumnsContext() {
+ const publicDir = path.join(__dirname, '..', 'public');
+ function el() {
+ return {
+ style: {}, dataset: {}, innerHTML: '', textContent: '',
+ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
+ addEventListener() {}, appendChild() {}, insertBefore() {},
+ querySelector: () => el(), querySelectorAll: () => [],
+ };
+ }
+ const context = {
+ console, window: {},
+ document: {
+ getElementById: () => el(), createElement: () => el(),
+ querySelector: () => el(), querySelectorAll: () => [],
+ addEventListener() {}, body: el(),
+ },
+ localStorage: { getItem: () => null, setItem() {} },
+ sessionStorage: { getItem: () => null, setItem() {} },
+ navigator: {}, location: { search: '', hash: '' }, history: {},
+ URLSearchParams, setTimeout, clearTimeout,
+ };
+ vm.createContext(context);
+ for (const f of ['session-label.js', 'miller-columns.js']) {
+ vm.runInContext(fs.readFileSync(path.join(publicDir, f), 'utf8'), context);
+ }
+ return context;
+}
+
+function makeSession(overrides) {
+ return {
+ id: 'sid', agent: 'claude', provider: 'anthropic', resumeCommand: null,
+ count: 1, mainCount: 1, subCount: 0, totalCost: 0, model: 'gpt-5',
+ firstTs: '10:00:00', firstId: '2026-06-08T10-00-00-000', lastId: '2026-06-08T10-00-00-000',
+ latestCacheHitRatio: 0, latestCacheReadTokens: 0,
+ ...overrides,
+ };
+}
+
+describe('renderSessionItem – resume copy button', () => {
+ it('omits the button for a codex session without a server-computed command', () => {
+ const ctx = loadMillerColumnsContext();
+ const sess = makeSession({ id: 'codex-502-sid', agent: 'codex', provider: 'openai', resumeCommand: null });
+ const html = ctx.renderSessionItem(sess, 'codex-502-sid');
+ assert.equal(html.includes('launch-btn'), false);
+ assert.equal(html.includes('copySessionContinue'), false);
+ });
+
+ it('renders the button with the exact server-computed command', () => {
+ const ctx = loadMillerColumnsContext();
+ const sess = makeSession({ id: 'codex-ok-sid', agent: 'codex', provider: 'openai', resumeCommand: 'codex resume codex-ok-sid' });
+ const html = ctx.renderSessionItem(sess, 'codex-ok-sid');
+ assert.equal(html.includes('launch-btn'), true);
+ assert.equal(html.includes('codex resume codex-ok-sid'), true);
+ });
+
+ it('renders a claude resume button from the server-computed command', () => {
+ const ctx = loadMillerColumnsContext();
+ const sess = makeSession({ id: 'claude-sid', resumeCommand: 'claude --resume claude-sid' });
+ const html = ctx.renderSessionItem(sess, 'claude-sid');
+ assert.equal(html.includes('claude --resume claude-sid'), true);
+ });
+});
diff --git a/test/settings-endpoint.test.js b/test/settings-endpoint.test.js
index 58ad6f8..ddca750 100644
--- a/test/settings-endpoint.test.js
+++ b/test/settings-endpoint.test.js
@@ -64,4 +64,16 @@ describe('/_api/settings (computeSettings)', () => {
const s = computeSettings();
assert.equal(s.autoCompactPct, 0.835);
});
+
+ it('exposes resume profiles in providerProfiles', () => {
+ const s = computeSettings();
+ assert.deepEqual(s.providerProfiles.anthropic.resume, {
+ template: '{agent} --resume {sid}',
+ condition: 'always',
+ });
+ assert.deepEqual(s.providerProfiles.openai.resume, {
+ template: 'codex resume {sid}',
+ condition: 'has-usage',
+ });
+ });
});
diff --git a/test/sse-broadcast.test.js b/test/sse-broadcast.test.js
index e62b65d..debf009 100644
--- a/test/sse-broadcast.test.js
+++ b/test/sse-broadcast.test.js
@@ -5,6 +5,40 @@ const assert = require('node:assert/strict');
const { summarizeEntry } = require('../server/sse-broadcast');
describe('sse-broadcast', () => {
+ describe('summarizeEntry – resume fields', () => {
+ it('emits a claude resume command for an anthropic entry', () => {
+ const summary = summarizeEntry({
+ id: 'r-claude', sessionId: 'resume-claude-sid', provider: 'anthropic',
+ usage: { input_tokens: 1 }, isSubagent: false,
+ });
+ assert.equal(summary.resumable, true);
+ assert.equal(summary.resumeCommand, 'claude --resume resume-claude-sid');
+ });
+
+ it('withholds resume for a codex entry until a non-subagent usage turn is seen', () => {
+ const sid = 'resume-codex-sid';
+ const noUsage = summarizeEntry({ id: 'r1', sessionId: sid, provider: 'openai', usage: null, isSubagent: false });
+ assert.equal(noUsage.resumable, false);
+ assert.equal(noUsage.resumeCommand, null);
+
+ // 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 });
+ assert.equal(withUsage.resumable, true);
+ assert.equal(withUsage.resumeCommand, `codex resume ${sid}`);
+
+ // Subsequent usage-less turns still report resumable (monotonic).
+ const after = summarizeEntry({ id: 'r3', sessionId: sid, provider: 'openai', usage: null, isSubagent: false });
+ assert.equal(after.resumable, true);
+ });
+
+ it('never resumes a codex sentinel session', () => {
+ const summary = summarizeEntry({ id: 'r-raw', sessionId: 'codex-raw', provider: 'openai', usage: { input_tokens: 1 }, isSubagent: false });
+ assert.equal(summary.resumable, false);
+ assert.equal(summary.resumeCommand, null);
+ });
+ });
+
describe('summarizeEntry', () => {
it('returns all summary fields from pre-computed entry properties when req/res are null', () => {
const entry = {
diff --git a/test/store.test.js b/test/store.test.js
index 614bc50..9045dce 100644
--- a/test/store.test.js
+++ b/test/store.test.js
@@ -54,6 +54,73 @@ describe('store', () => {
});
});
+ describe('computeSessionResume / markSessionUsage', () => {
+ const store = require('../server/store');
+
+ it('claude sessions are always resumable with --resume', () => {
+ const r = store.computeSessionResume('claude-sid-aaa', 'anthropic');
+ assert.deepEqual(r, { resumable: true, resumeCommand: 'claude --resume claude-sid-aaa' });
+ });
+
+ it('entries without a provider fall back to anthropic (always resumable)', () => {
+ const r = store.computeSessionResume('legacy-sid', undefined);
+ assert.deepEqual(r, { resumable: true, resumeCommand: 'claude --resume legacy-sid' });
+ });
+
+ it('an unknown provider fails closed (no resume command)', () => {
+ store.markSessionUsage({ sessionId: 'future-sid', isSubagent: false, usage: { input_tokens: 5 } });
+ assert.deepEqual(store.computeSessionResume('future-sid', 'future-provider'), { resumable: false, resumeCommand: null });
+ });
+
+ it('a present-but-empty provider fails closed (only missing falls back to anthropic)', () => {
+ assert.deepEqual(store.computeSessionResume('empty-provider-sid', ''), { resumable: false, resumeCommand: null });
+ });
+
+ it('codex session with no usage is not resumable', () => {
+ const r = store.computeSessionResume('codex-sid-nousage', 'openai');
+ assert.deepEqual(r, { resumable: false, resumeCommand: null });
+ });
+
+ it('codex session becomes resumable after a non-subagent usage turn', () => {
+ const sid = 'codex-sid-withusage';
+ store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: { input_tokens: 5 } });
+ 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 } });
+ assert.deepEqual(store.computeSessionResume(sid, 'openai'), { resumable: false, resumeCommand: null });
+ });
+
+ it('a turn without usage does not mark the session', () => {
+ const sid = 'codex-sid-nousagefield';
+ store.markSessionUsage({ sessionId: sid, isSubagent: false, usage: null });
+ 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: 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 } });
+ assert.deepEqual(store.computeSessionResume(sid, 'openai'), { resumable: false, resumeCommand: null });
+ assert.deepEqual(store.computeSessionResume(sid, 'anthropic'), { resumable: false, resumeCommand: null });
+ }
+ });
+
+ it('empty/missing session id is not resumable', () => {
+ assert.deepEqual(store.computeSessionResume(null, 'anthropic'), { resumable: false, resumeCommand: null });
+ assert.deepEqual(store.computeSessionResume('', 'openai'), { resumable: false, resumeCommand: null });
+ });
+ });
+
describe('detectSession – subagent attribution', () => {
// Fresh store state for each test — we manipulate module-level globals
// so we need to reset between tests.