diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7c1e733a..afc7ec6a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2463,15 +2463,20 @@ export function registerSessionRoutes( * (CI review bots, etc.) from the resumable history list — they were never * something a user can resume into. * - * Scoped to `"type":"user"`/`"type":"assistant"` lines specifically, mirroring - * `extractFirstUserPrompt`'s type check, rather than any line that happens to - * contain the substring "entrypoint". A transcript that started under an older - * Claude Code version (no entrypoint field) and got resumed under a newer one - * mid-conversation could otherwise pick up the field from a much later message - * than the true first one, misattributing the session's origin. + * Scans every `"type":"user"`/`"type":"assistant"` line with an entrypoint + * field — not just the first one — and returns 'cli' the moment ANY of them + * carries it. A transcript is excluded only when every entrypoint-bearing + * message says something else; "first field wins" would misattribute a + * transcript that started under an older Claude Code version (no entrypoint + * on its true first message) and later picked up a non-'cli' entrypoint on + * some later message, wrongly hiding a genuinely interactive session. This + * deliberately errs toward keeping a session visible: one real interactive + * message anywhere is enough. Returns undefined ("unknown", fail-open) only + * when nothing scanned carries the field at all. */ function extractTranscriptEntrypoint(text: string): string | undefined { let start = 0; + let sawNonCli: string | undefined; while (start < text.length) { const end = text.indexOf('\n', start); const line = end === -1 ? text.slice(start) : text.slice(start, end); @@ -2481,13 +2486,14 @@ export function registerSessionRoutes( try { const entry = JSON.parse(line); if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') { - return entry.entrypoint; + if (entry.entrypoint === 'cli') return 'cli'; + sawNonCli ??= entry.entrypoint; } } catch { // Malformed/truncated line — skip } } - return undefined; + return sawNonCli; } /** @@ -2702,7 +2708,7 @@ export function registerSessionRoutes( return finalExists ? current : process.env.HOME || '/tmp'; } - /** Read the first 16KB of a file for content sniffing. */ + /** Read the first `buf.length` bytes of a file for content sniffing. */ async function readFileHead(path: string, buf: Buffer): Promise { try { const fd = await fs.open(path, 'r'); @@ -2745,7 +2751,12 @@ export function registerSessionRoutes( // Scan a single project directory and return all valid history sessions in it. // Reused by both the global overview and the single-folder drill-down. - async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise { + async function scanProjectDir( + projPath: string, + projDir: string, + smallHeadBuf: Buffer, + headBuf: Buffer + ): Promise { const out: HistorySession[] = []; const stat = await fs.stat(projPath).catch(() => null); if (!stat?.isDirectory()) return out; @@ -2763,22 +2774,45 @@ export function registerSessionRoutes( if (!fileStat) continue; if (fileStat.size < 4000) continue; - let firstPrompt: string | undefined; - const head = await readFileHead(filePath, headBuf); const hasConversation = (text: string) => text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"'); + // Two-tier head read: try the cheap smallHeadBuf (16KB) size first -- enough + // for the vast majority of transcripts -- and only escalate to the full + // headBuf (128KB) when that wasn't enough. Reading 128KB unconditionally for + // EVERY file in the directory roughly quadrupled the cost of a full scan + // (measured against a real ~/.claude/projects tree: ~4x both bytes read and + // wall time) to fix a problem only ~28% of files actually have. Escalating + // resolves the restart-bookkeeping case (the reason 128KB exists at all) + // without ever touching the tail-read fallback below for most of that 28%. + let head = await readFileHead(filePath, smallHeadBuf); let foundContent = head ? hasConversation(head) : false; + let firstPrompt = head ? extractFirstUserPrompt(head) : undefined; + if ((!foundContent || !firstPrompt) && head !== null && fileStat.size > smallHeadBuf.length) { + const biggerHead = await readFileHead(filePath, headBuf); + if (biggerHead) { + head = biggerHead; + if (!foundContent) foundContent = hasConversation(head); + if (!firstPrompt) firstPrompt = extractFirstUserPrompt(head); + } + } + let tail: string | null = null; - if (!foundContent && fileStat.size > headBuf.length) { + // `head === null` (a failed read -- e.g. EMFILE while scanning hundreds of + // files) must also get a shot at the tail, not just "file bigger than the + // head buffer". Losing this dropped the session from history entirely + // instead of giving it a second chance, for any file at or under the head + // buffer size whose head read happened to fail. + if (!foundContent && (head === null || fileStat.size > headBuf.length)) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); if (tail) foundContent = hasConversation(tail); } if (!foundContent) continue; - if (head) firstPrompt = extractFirstUserPrompt(head); - if (!firstPrompt && fileStat.size > headBuf.length) { + // firstPrompt was already attempted from head (both tiers) above; this is + // purely the tail fallback for whatever's left unresolved. + if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) { if (!tail) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); @@ -2808,9 +2842,15 @@ export function registerSessionRoutes( // an extra file read. Missing entrypoint (older transcripts) reads as // interactive — fail open, matching every other gating check in this // codebase. + // + // head and tail are checked independently and merged with "cli wins" (not + // a first-truthy-value `??` chain): a large file's head might land on a + // non-'cli' message while a real interactive message sits in the tail (or + // vice versa), and either one being 'cli' is enough to keep the session. + const headEntrypoint = head ? extractTranscriptEntrypoint(head) : undefined; + const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined; const entrypoint = - (head ? extractTranscriptEntrypoint(head) : undefined) ?? - (tail ? extractTranscriptEntrypoint(tail) : undefined); + headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint); if (entrypoint && entrypoint !== 'cli') continue; out.push({ @@ -2829,13 +2869,12 @@ export function registerSessionRoutes( app.get('/api/history/sessions', async (req) => { const query = req.query as { projectKey?: string; offset?: string; limit?: string }; const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); - // 128KB (was 16KB): a session restarted many times over the course of a long - // conversation accumulates small bookkeeping lines (mode/permission-mode/ - // last-prompt/queue-operation, one batch per restart) ahead of the real first - // message. 16KB was enough margin for a handful of restarts but not dozens — - // a genuinely tiny first message still came up blank because the metadata - // alone crossed the window. 128KB matches the existing precedent elsewhere in - // this file (line ~1431) rather than inventing a new size. + // scanProjectDir tries smallHeadBuf (16KB, the original size) first for every + // file and only escalates to headBuf (128KB) when that wasn't enough — see the + // comment at the escalation site in scanProjectDir for why unconditional 128KB + // reads were too expensive to keep. 128KB matches the existing precedent + // elsewhere in this file (line ~1431). + const smallHeadBuf = Buffer.alloc(16384); const headBuf = Buffer.alloc(131072); // Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin // must only see history whose decoded workingDir is inside their own case space. @@ -2854,7 +2893,7 @@ export function registerSessionRoutes( const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0); const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20)); const projPath = join(projectsDir, query.projectKey); - let all = await scanProjectDir(projPath, query.projectKey, headBuf); + let all = await scanProjectDir(projPath, query.projectKey, smallHeadBuf, headBuf); // Confine to the caller's workspace (a projectKey maps to a single foreign cwd). if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir)); all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()); @@ -2867,7 +2906,7 @@ export function registerSessionRoutes( const projectDirs = await fs.readdir(projectsDir); for (const projDir of projectDirs) { const projPath = join(projectsDir, projDir); - const list = await scanProjectDir(projPath, projDir, headBuf); + const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf); results.push(...list); } } catch { @@ -2943,12 +2982,13 @@ export function registerSessionRoutes( const history: HistoryInput[] = []; try { const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); - // 128KB (was 16KB) — see the sibling allocation above for why. + // See the sibling allocation above for why there are two sizes. + const smallHeadBuf = Buffer.alloc(16384); const headBuf = Buffer.alloc(131072); const projectDirs = await fs.readdir(projectsDir); for (const projDir of projectDirs) { const projPath = join(projectsDir, projDir); - const list = await scanProjectDir(projPath, projDir, headBuf); + const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf); for (const h of list) { history.push({ sessionId: h.sessionId, diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 4d85cf86..b9ca43a0 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1396,44 +1396,105 @@ describe('session-routes', () => { expect(ids).not.toContain(sdkId); }); - it('attributes entrypoint from the true first message, not a later one or a bookkeeping line', async () => { - // A transcript that started under an older Claude Code version (no - // entrypoint field) and got resumed under a newer one mid-conversation - // could otherwise pick up entrypoint from a later message, misattributing - // the session's origin. Scanning must anchor on "type":"user"/"assistant" - // lines specifically, not any line that happens to mention "entrypoint". + it('ignores a bookkeeping line that happens to mention "entrypoint" outside a real message record', async () => { + // Scanning must anchor on "type":"user"/"assistant" lines specifically, + // not any line that happens to contain the substring "entrypoint". const home = process.env.HOME as string; - const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-scoping-test'); + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-bookkeeping-test'); await mkdir(projPath, { recursive: true }); const sessionId = '77777777-7777-7777-7777-777777777777'; - // True first message: no entrypoint field (old-version transcript). - const firstLine = - JSON.stringify({ type: 'user', message: { role: 'user', content: 'the genuine first message' } }) + '\n'; - // A bookkeeping line that (hypothetically) mentions entrypoint outside a - // real message record — must not be mistaken for message metadata. const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', entrypoint: 'sdk-py' }) + '\n'; - // A later message, after the resume, that DOES carry entrypoint: 'cli' — - // this is what the (fixed) scan should find, since it's the first - // user/assistant line that actually carries the field. - const laterLine = - JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a later message' } }) + + const realLine = + JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real message' } }) + '\n'; // scanProjectDir skips files under 4000 bytes. - const body = firstLine + bookkeepingLine + laterLine; + const body = bookkeepingLine + realLine; await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length)); const res = await harness.app.inject({ method: 'GET', - url: '/api/history/sessions?projectKey=proj-entrypoint-scoping-test', + url: '/api/history/sessions?projectKey=proj-entrypoint-bookkeeping-test', }); expect(res.statusCode).toBe(200); const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); - // entrypoint: 'cli' (from the later message) — shown, not excluded. expect(ids).toContain(sessionId); }); + it('shows a session with ANY interactive (cli) message, even if an earlier message was automated', async () => { + // "First field wins" would have misattributed this: an old transcript + // whose true first message predates the entrypoint field, later resumed + // under something automated (entrypoint: 'sdk-py' on message 2), then + // continued interactively by a real person (entrypoint: 'cli' on message + // 3). Stopping at the first entrypoint-bearing line found ('sdk-py') + // would wrongly exclude a session a human genuinely used. One real + // interactive message anywhere is enough to keep it visible. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-any-cli-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '99999999-9999-9999-9999-999999999999'; + const firstLine = + JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-entrypoint-field message' } }) + '\n'; + const automatedLine = + JSON.stringify({ + type: 'user', + entrypoint: 'sdk-py', + message: { role: 'user', content: 'an automated follow-up' }, + }) + '\n'; + const interactiveLine = + JSON.stringify({ + type: 'user', + entrypoint: 'cli', + message: { role: 'user', content: 'a real person continued this' }, + }) + '\n'; + + const body = firstLine + automatedLine + interactiveLine; + await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length)); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-any-cli-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('still excludes a session where every entrypoint-bearing message is automated', async () => { + // Mirror of the previous test with no 'cli' message anywhere — proves the + // "any cli wins" fix isn't just failing open unconditionally. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-all-automated-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const firstLine = + JSON.stringify({ + type: 'user', + entrypoint: 'sdk-py', + message: { role: 'user', content: 'Review this change for security vulnerabilities.' }, + }) + '\n'; + const secondLine = + JSON.stringify({ + type: 'assistant', + entrypoint: 'sdk-py', + message: { role: 'assistant', content: [{ type: 'text', text: 'Looking at the diff...' }] }, + }) + '\n'; + + const body = firstLine + secondLine; + await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length)); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-all-automated-test', + }); + expect(res.statusCode).toBe(200); + const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); + expect(ids).not.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) diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index 2d6e1db8..539af732 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -303,6 +303,42 @@ describe('mergeUnifiedSessions', () => { expect(old!.firstPrompt).toBeUndefined(); }); + it('leaves a RESUMED session blank rather than borrowing a sibling, once its own transcript is aliased in', () => { + // The exact scenario COD-140's own comment lists first: a live/persisted row + // whose claudeSessionId aliases to an on-disk transcript. Once that alias + // successfully folds the transcript's own (failed) extraction into this row + // (sources includes 'history'), it must NOT then fall through to the + // workingDir guess and borrow an unrelated sibling's prompt -- same bug as + // the plain history-only case above, but for the resumed-session path the + // backfill mechanism was actually built for. + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-resumed', status: 'working', claudeSessionId: 'resumed-uuid', workingDir: '/shared' }], + history: [ + // The resumed session's OWN transcript -- aliased in via claudeSessionId, + // but its own extraction found nothing. + { + sessionId: 'resumed-uuid', + workingDir: '/shared', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: undefined, + }, + // An unrelated, newer sibling in the same directory. + { + sessionId: 'sibling-uuid', + workingDir: '/shared', + sizeBytes: 6000, + lastModified: '2026-06-01T00:00:00.000Z', + firstPrompt: "unrelated sibling's prompt", + }, + ], + }); + const resumed = merged.find((m) => m.sessionId === 'codeman-resumed'); + expect(resumed).toBeDefined(); + expect([...resumed!.sources].sort()).toEqual(['history', 'live']); + expect(resumed!.firstPrompt).toBeUndefined(); + }); + // COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the // most-recent user prompt also reaches live rows whose id ≠ transcript UUID. it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {