diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 5d4fa134..6eaf5545 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -226,6 +226,16 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte // different UUID. Backfill from the already-passed history: first try the claudeSessionId // join, then the newest transcript in the same workingDir. Never overwrite a non-empty // firstPrompt (so rows keyed to their own transcript are untouched). + // + // The workingDir guess is a last resort and MUST be skipped for any item that + // already has its own 'history' entry (step 1 above already gave it a real, + // direct scan of its own transcript). Without this guard, a history row whose + // OWN extraction genuinely failed (oversized first message, etc.) silently + // inherited the newest OTHER session's opening line from the same directory — + // not a blank, but actively wrong: old sessions displayed today's conversation + // as if it were their own. A row with no 'history' source at all (its + // transcript hasn't been linked/scanned under its own id yet) has no such + // direct attempt to prefer, so the guess remains a reasonable stand-in there. const firstPromptByUuid = new Map(); const firstPromptByWorkingDir = new Map(); // COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite). @@ -254,12 +264,13 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte } } for (const item of map.values()) { + const hasOwnHistoryEntry = item.sources.includes('history'); if (!item.firstPrompt) { // never overwrite an existing non-empty prompt const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.firstPrompt = byUuid; - } else if (item.workingDir) { + } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = firstPromptByWorkingDir.get(item.workingDir); if (byDir) item.firstPrompt = byDir.prompt; } @@ -268,7 +279,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.lastPrompt = byUuid; - } else if (item.workingDir) { + } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = lastPromptByWorkingDir.get(item.workingDir); if (byDir) item.lastPrompt = byDir.prompt; } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index f3fee8a3..16d97152 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2478,6 +2478,46 @@ export function registerSessionRoutes( return undefined; } + /** + * The `entrypoint` field Claude Code stamps on its own message records: + * 'cli' for a real interactive session, something else (e.g. 'sdk-py') for + * an SDK/automated invocation. Used to exclude non-interactive transcripts + * (CI review bots, etc.) from the resumable history list — they were never + * something a user can resume into. + * + * 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); + start = end === -1 ? text.length : end + 1; + if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue; + if (!line.includes('"entrypoint"')) continue; + try { + const entry = JSON.parse(line); + if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') { + if (entry.entrypoint === 'cli') return 'cli'; + sawNonCli ??= entry.entrypoint; + } + } catch { + // Malformed/truncated line — skip + } + } + return sawNonCli; + } + /** * Extract the text of the LAST user message from a JSONL transcript chunk * (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message @@ -2690,7 +2730,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'); @@ -2733,7 +2773,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; @@ -2751,22 +2796,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 > 16384) { + // `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 > 65536) { + // 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); @@ -2776,15 +2844,37 @@ export function registerSessionRoutes( // COD-145: last (most recent) user prompt lives near the END of the file, so // prefer the tail. For large files where no tail was read yet, read one - // (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which - // then contains the whole transcript — scan it for the last match instead. - if (!tail && fileStat.size > 65536) { + // (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`, + // which then contains the whole transcript — scan it for the last match instead. + if (!tail && fileStat.size > headBuf.length) { const tailBuf = Buffer.alloc(32768); tail = await readFileTail(filePath, tailBuf, fileStat.size); } const lastPrompt = (tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined); + // Automated/SDK-driven invocations (CI review bots, etc.) write transcripts + // into the same ~/.claude/projects tree as interactive sessions but were + // never something a user can resume into — no PTY, no running process, and + // their "conversation" is typically a single one-shot prompt (often with a + // full diff embedded, which is exactly why it dwarfs this scanner's read + // windows and shows up above as blank or as an identical boilerplate + // sentence across many rows). Checked last, so it reuses whatever `head`/ + // `tail` the prompt extraction above already read rather than triggering + // 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 = + headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint); + if (entrypoint && entrypoint !== 'cli') continue; + out.push({ sessionId, workingDir, @@ -2801,7 +2891,13 @@ 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'); - const headBuf = Buffer.alloc(16384); + // 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. // Do NOT trust the caller-supplied projectKey — confine on the decoded path. @@ -2819,7 +2915,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()); @@ -2832,7 +2928,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 { @@ -2908,11 +3004,13 @@ export function registerSessionRoutes( const history: HistoryInput[] = []; try { const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); - const headBuf = Buffer.alloc(16384); + // 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 6d1a02cc..b9ca43a0 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1350,6 +1350,224 @@ describe('session-routes', () => { expect(row.workingDir).toBe(dotDir); expect(row.workingDir).not.toContain('//'); }); + + it('excludes non-interactive (SDK-driven) transcripts from the history list', async () => { + // CI review bots and other automated tools write transcripts into the same + // ~/.claude/projects tree as interactive sessions (entrypoint "sdk-py" etc.) + // but were never something a user can resume into — no PTY, no running + // process. They cluttered Past Sessions as blank rows or identical + // boilerplate ("Review this change for security vulnerabilities..."). + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-test'); + await mkdir(projPath, { recursive: true }); + + const cliId = '33333333-3333-3333-3333-333333333333'; + const sdkId = '44444444-4444-4444-4444-444444444444'; + const noEntrypointId = '55555555-5555-5555-5555-555555555555'; + + const cliLine = + JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real question' } }) + + '\n'; + const sdkLine = + JSON.stringify({ + type: 'user', + entrypoint: 'sdk-py', + message: { role: 'user', content: 'Review this change for security vulnerabilities.' }, + }) + '\n'; + // Older transcripts predate the entrypoint field entirely — must still show. + const noEntrypointLine = + JSON.stringify({ type: 'user', message: { role: 'user', content: 'a pre-entrypoint session' } }) + '\n'; + + await writeFile(join(projPath, `${cliId}.jsonl`), cliLine + '#'.repeat(4200 - cliLine.length)); + await writeFile(join(projPath, `${sdkId}.jsonl`), sdkLine + '#'.repeat(4200 - sdkLine.length)); + await writeFile( + join(projPath, `${noEntrypointId}.jsonl`), + noEntrypointLine + '#'.repeat(4200 - noEntrypointLine.length) + ); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-entrypoint-test', + }); + expect(res.statusCode).toBe(200); + const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId); + expect(ids).toContain(cliId); + expect(ids).toContain(noEntrypointId); + expect(ids).not.toContain(sdkId); + }); + + 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-bookkeeping-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '77777777-7777-7777-7777-777777777777'; + const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', entrypoint: 'sdk-py' }) + '\n'; + const realLine = + JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real message' } }) + + '\n'; + + // scanProjectDir skips files under 4000 bytes. + 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-bookkeeping-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('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) + // per restart, ahead of the real first message. With enough restarts these can + // push the genuine first prompt past a 16KB head-read window even though the + // message itself is tiny — the row showed up blank despite having real content. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-bookkeeping-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '66666666-6666-6666-6666-666666666666'; + const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n'; + // > 16KB (the old head-read size) but well under 128KB (the new one). + const prefix = bookkeepingLine.repeat(Math.ceil(20000 / bookkeepingLine.length)); + const realLine = + JSON.stringify({ + type: 'user', + entrypoint: 'cli', + message: { role: 'user', content: 'the real first message' }, + }) + '\n'; + expect(prefix.length).toBeGreaterThan(16384); + + await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-bookkeeping-test', + }); + expect(res.statusCode).toBe(200); + const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId); + expect(row).toBeDefined(); + expect(row.firstPrompt).toBe('the real first message'); + }); + + it('still falls back to the tail read when bookkeeping alone exceeds the new 128KB head window', async () => { + // Raising the head buffer to 128KB helps most restart-heavy sessions, but an + // even more extreme case (many more restarts) can still exceed it. This + // proves the tail-read fallback itself is intact after the threshold + // rewrite (`fileStat.size > headBuf.length` replacing the old hardcoded + // 16384/65536) — the fallback's own logic, not the exact threshold value, + // is what could have silently broken (e.g. a copy-paste slip that dropped + // the `> headBuf.length` check entirely). The real message sits near the + // end of the file, well inside the 32KB tail window, so a working fallback + // finds it; a broken one leaves the row blank exactly like the bug this + // whole fix addresses. + const home = process.env.HOME as string; + const projPath = join(home, '.claude', 'projects', 'proj-tail-fallback-test'); + await mkdir(projPath, { recursive: true }); + + const sessionId = '88888888-8888-8888-8888-888888888888'; + const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n'; + // Comfortably past the new 128KB head window (was 16KB), so the head read + // never reaches a single "type":"user"/"assistant"/"summary" line. + const prefix = bookkeepingLine.repeat(Math.ceil(140000 / bookkeepingLine.length)); + const realLine = + JSON.stringify({ + type: 'user', + entrypoint: 'cli', + message: { role: 'user', content: 'found via tail fallback' }, + }) + '\n'; + expect(prefix.length).toBeGreaterThan(131072); + + await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine); + + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions?projectKey=proj-tail-fallback-test', + }); + expect(res.statusCode).toBe(200); + const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId); + expect(row).toBeDefined(); + expect(row.firstPrompt).toBe('found via tail fallback'); + }); }); // ========== POST /api/sessions (with resumeSessionId) ========== diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index f25faff7..539af732 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -270,6 +270,75 @@ describe('mergeUnifiedSessions', () => { expect(live!.firstPrompt).toBeUndefined(); }); + it('does NOT borrow a sibling transcript for a history-only row whose own extraction failed (no cross-contamination)', () => { + // A pure history row already got its own real scan (step 1 keys it under its + // OWN sessionId) — if that extraction genuinely failed (oversized first + // message, noise-filtered, etc.), the workingDir guess must not paper over + // it with an unrelated session's opening line. Regression: an old session + // in a shared workingDir was displaying TODAY's live session's firstPrompt + // as its own, because the guess didn't check whether this row already had + // its own (failed) attempt. + const merged = mergeUnifiedSessions({ + history: [ + // This session's own transcript scan found no usable prompt. + { + sessionId: 'old-uuid', + workingDir: '/shared', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: undefined, + }, + // A much newer, unrelated session in the same directory. + { + sessionId: 'newer-uuid', + workingDir: '/shared', + sizeBytes: 6000, + lastModified: '2026-06-01T00:00:00.000Z', + firstPrompt: "today's real prompt", + }, + ], + }); + const old = merged.find((m) => m.sessionId === 'old-uuid'); + expect(old).toBeDefined(); + 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)', () => {