From 2ef1b970737541f6e05b60c2bd770311122ac8de Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 8 Jun 2026 00:29:39 +0800 Subject: [PATCH 1/7] feat: declare resume profiles in UPSTREAM_PROFILES, expose via /api/settings Codex only writes a rollout file after a successful API turn, so resume eligibility is provider policy: anthropic resumes always, openai requires usage. Declarative {template, condition} follows the B4 cache-profile shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/providers.js | 4 ++++ server/routes/api.js | 2 +- test/providers.test.js | 12 ++++++++++++ test/settings-endpoint.test.js | 12 ++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) 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/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/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/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', + }); + }); }); From f5691658ea4c8d38141f21e66abb1d194a86d3d4 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 8 Jun 2026 00:32:14 +0800 Subject: [PATCH 2/7] feat: server-side resume eligibility in store (interprets resume profiles) markSessionUsage flags a session once any non-subagent turn reports usage (monotonic). computeSessionResume interprets UPSTREAM_PROFILES[provider] .resume {template, condition} instead of hardcoding per-agent branches; missing provider falls back to anthropic, sentinel sessions never resume. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/store.js | 42 +++++++++++++++++++++++++++++++++ test/store.test.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/server/store.js b/server/store.js index 5d58166..bc5e1b6 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,40 @@ 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. + const profile = getUpstreamProfile(provider) || getUpstreamProfile('anthropic'); + 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 +320,7 @@ module.exports = { getSessionTitle, attributeTitleGen, propagateLoadedSkills, + markSessionUsage, + computeSessionResume, + NON_RESUMABLE_SESSIONS, }; diff --git a/test/store.test.js b/test/store.test.js index 614bc50..df4328a 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -54,6 +54,64 @@ 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('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. From 6524533ae93e37c26d8c710932d332e427324855 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 8 Jun 2026 00:38:18 +0800 Subject: [PATCH 3/7] feat: wire resume eligibility through SSE summaries and restore summarizeEntry (the single funnel for SSE broadcast and /api/entries) now emits resumable + resumeCommand per entry. The restore loop marks usage for all entries before any serialization, so eligibility is rebuilt from the index alone and is order-independent after a restart. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/restore.js | 4 +++ server/sse-broadcast.js | 7 ++++ test/restore.test.js | 68 ++++++++++++++++++++++++++++++++++++++ test/sse-broadcast.test.js | 34 +++++++++++++++++++ 4 files changed, 113 insertions(+) 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/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/test/restore.test.js b/test/restore.test.js index 17744ed..b3acb94 100644 --- a/test/restore.test.js +++ b/test/restore.test.js @@ -177,3 +177,71 @@ 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('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/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 = { From 3fa759d0668265f0079446dbc393ce5de6fa6862 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 8 Jun 2026 00:42:24 +0800 Subject: [PATCH 4/7] fix: resume copy button is a pure view of server-computed resumeCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both hardcoded agent ternaries are gone. The client sticky-accumulates e.resumeCommand onto the session and renders the copy button only when non-null — a no-usage codex session (e.g. 502-only) gets no button instead of a known-bad 'codex resume '. copySessionContinue now copies the exact string it was handed rather than re-deriving the command. Fails closed: no client-side fallback that could reintroduce the bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/entry-rendering.js | 5 ++++- public/miller-columns.js | 16 ++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) 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) + '' + From bb5fc601e29ddbf5a379ce113651009090ad565d Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 8 Jun 2026 00:49:54 +0800 Subject: [PATCH 5/7] test: render-level regression for resume button gating vm-loads session-label.js + miller-columns.js with DOM stubs and asserts renderSessionItem omits the copy button when sess.resumeCommand is null and renders the exact server-computed command when present. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/resume-button-render.test.js | 73 +++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 test/resume-button-render.test.js 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); + }); +}); From 74947ac7479b75821df8b9d27e93d84809033eff Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 8 Jun 2026 01:01:09 +0800 Subject: [PATCH 6/7] fix: unknown provider fails closed for resume; cover restore pre-marking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review findings: (1) the anthropic fallback in computeSessionResume was meant for legacy entries with no provider, but also mapped unknown future providers to 'claude --resume' — now only a missing provider falls back, unknown providers get no resume command. (2) the restore-loop pre-marking had no dedicated coverage (summarizeEntry's own marking masked its removal); added an index-order test that fails without it. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/store.js | 5 ++++- test/restore.test.js | 24 ++++++++++++++++++++++++ test/store.test.js | 5 +++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/server/store.js b/server/store.js index bc5e1b6..6e1eedb 100644 --- a/server/store.js +++ b/server/store.js @@ -279,7 +279,10 @@ function computeSessionResume(sessionId, provider) { return { resumable: false, resumeCommand: null }; } // Entries without a provider predate provider tagging — they are anthropic. - const profile = getUpstreamProfile(provider) || getUpstreamProfile('anthropic'); + // An unknown provider, however, fails closed: better no button than a + // command we can't vouch for. + const profile = provider ? getUpstreamProfile(provider) : getUpstreamProfile('anthropic'); + 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) { diff --git a/test/restore.test.js b/test/restore.test.js index b3acb94..ce76926 100644 --- a/test/restore.test.js +++ b/test/restore.test.js @@ -226,6 +226,30 @@ describe('restoreFromLogs — codex resume eligibility', () => { 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'; diff --git a/test/store.test.js b/test/store.test.js index df4328a..17aa03b 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -67,6 +67,11 @@ describe('store', () => { 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('codex session with no usage is not resumable', () => { const r = store.computeSessionResume('codex-sid-nousage', 'openai'); assert.deepEqual(r, { resumable: false, resumeCommand: null }); From dffdb4e139e4fbc58d835d95394f7946da1ebe6d Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 8 Jun 2026 01:07:46 +0800 Subject: [PATCH 7/7] fix: present-but-empty provider fails closed in computeSessionResume Distinguish missing (== null, legacy anthropic fallback) from present-but-empty '' which now gets no resume command like any other unknown provider. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/store.js | 2 +- test/store.test.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/store.js b/server/store.js index 6e1eedb..029d103 100644 --- a/server/store.js +++ b/server/store.js @@ -281,7 +281,7 @@ function computeSessionResume(sessionId, provider) { // 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 ? getUpstreamProfile(provider) : getUpstreamProfile('anthropic'); + 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 }; diff --git a/test/store.test.js b/test/store.test.js index 17aa03b..9045dce 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -72,6 +72,10 @@ describe('store', () => { 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 });