From ecd3f3f32a62702ee68cc1c14422de8ae3659017 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 5 Aug 2026 21:47:06 +0200 Subject: [PATCH] harden(history): exclude automated transcripts by SDK shape, not by "not cli" #215 filters non-interactive transcripts out of Past Sessions with `entrypoint !== 'cli'`. That is an allowlist on a value, and the check hides rows, so it fails CLOSED on anything Claude Code has not shipped yet: the day it stamps a new interactive entrypoint (a rename, or a second interactive host), no transcript matches 'cli' any more and the entire Past Sessions list goes blank with nothing in the UI explaining why. Invert it to a blocklist on the SDK shape (`sdk`, `sdk-cli`, `sdk-py`). An automated entrypoint we do not recognize yet now costs a few noisy rows, which is the annoyance the filter set out to fix, rather than a dead feature. Matches the fail-open reasoning #215 already applied to a MISSING entrypoint field; only the unknown-VALUE case was inverted. Test fails against the pre-fix line and passes after. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/routes/session-routes.ts | 20 +++++++++++++++++++- test/routes/session-routes.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 16d97152..c2273bfe 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2478,6 +2478,24 @@ export function registerSessionRoutes( return undefined; } + /** + * Is this `entrypoint` value an automated/SDK-driven invocation? + * + * ⚠️ Deliberately a BLOCKLIST on the SDK shape, not an allowlist on `'cli'`. + * The exclusion below hides rows, so an allowlist fails CLOSED on any value + * Claude Code has not shipped yet: the day it stamps a new interactive + * entrypoint (a rename, or a second interactive host), every transcript stops + * matching `'cli'` and the whole Past Sessions list goes blank with nothing in + * the UI to explain it. A blocklist fails OPEN instead — an automated + * entrypoint we do not recognize yet costs a few noisy rows, which is the + * annoyance this filter set out to fix rather than a broken feature. + * + * Observed values: `cli` (interactive), `sdk-cli` / `sdk-py` (automated). + */ + function isAutomatedEntrypoint(entrypoint: string): boolean { + return /^sdk(-|$)/.test(entrypoint); + } + /** * The `entrypoint` field Claude Code stamps on its own message records: * 'cli' for a real interactive session, something else (e.g. 'sdk-py') for @@ -2873,7 +2891,7 @@ export function registerSessionRoutes( const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined; const entrypoint = headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint); - if (entrypoint && entrypoint !== 'cli') continue; + if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue; out.push({ sessionId, diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b9ca43a0..1cec3cb0 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1495,6 +1495,35 @@ describe('session-routes', () => { expect(ids).not.toContain(sessionId); }); + it('keeps a session whose entrypoint is an unrecognized non-SDK value (fail open)', async () => { + // The exclusion is a blocklist on the SDK shape, NOT an allowlist on 'cli'. + // An allowlist fails CLOSED on any value Claude Code has not shipped yet: + // the day it stamps a new interactive entrypoint, nothing matches 'cli' and + // the entire Past Sessions list silently goes blank. Excluding only what we + // positively recognize as automated fails open instead — a few noisy rows, + // not a dead feature. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-unknown-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + const line = + JSON.stringify({ + type: 'user', + entrypoint: 'cli-next', + message: { role: 'user', content: 'a question from a future interactive host' }, + }) + '\n'; + await writeFile(join(projPath, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length)); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-unknown-test', + }); + expect(res.statusCode).toBe(200); + const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); + expect(ids).toContain(sessionId); + }); + it('finds the real first prompt past a large run of pre-message bookkeeping lines', async () => { // A session restarted many times over a long conversation accumulates a batch // of small bookkeeping lines (mode/permission-mode/last-prompt/queue-operation)