mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
Merge pull request #215 from timkjr/fix/past-sessions-history-quality
fix(history): three Past Sessions data-quality bugs (automated-session noise, cross-contaminated previews, blank restart-heavy rows)
This commit is contained in:
@@ -226,6 +226,16 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
|
// 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
|
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
|
||||||
// firstPrompt (so rows keyed to their own transcript are untouched).
|
// 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<string, string>();
|
const firstPromptByUuid = new Map<string, string>();
|
||||||
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||||
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
|
// 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()) {
|
for (const item of map.values()) {
|
||||||
|
const hasOwnHistoryEntry = item.sources.includes('history');
|
||||||
if (!item.firstPrompt) {
|
if (!item.firstPrompt) {
|
||||||
// never overwrite an existing non-empty prompt
|
// never overwrite an existing non-empty prompt
|
||||||
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||||
if (byUuid) {
|
if (byUuid) {
|
||||||
item.firstPrompt = byUuid;
|
item.firstPrompt = byUuid;
|
||||||
} else if (item.workingDir) {
|
} else if (item.workingDir && !hasOwnHistoryEntry) {
|
||||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||||
if (byDir) item.firstPrompt = byDir.prompt;
|
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;
|
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||||
if (byUuid) {
|
if (byUuid) {
|
||||||
item.lastPrompt = byUuid;
|
item.lastPrompt = byUuid;
|
||||||
} else if (item.workingDir) {
|
} else if (item.workingDir && !hasOwnHistoryEntry) {
|
||||||
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
||||||
if (byDir) item.lastPrompt = byDir.prompt;
|
if (byDir) item.lastPrompt = byDir.prompt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2478,6 +2478,46 @@ export function registerSessionRoutes(
|
|||||||
return undefined;
|
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
|
* Extract the text of the LAST user message from a JSONL transcript chunk
|
||||||
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
||||||
@@ -2690,7 +2730,7 @@ export function registerSessionRoutes(
|
|||||||
return finalExists ? current : process.env.HOME || '/tmp';
|
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<string | null> {
|
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const fd = await fs.open(path, 'r');
|
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.
|
// 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.
|
// Reused by both the global overview and the single-folder drill-down.
|
||||||
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
|
async function scanProjectDir(
|
||||||
|
projPath: string,
|
||||||
|
projDir: string,
|
||||||
|
smallHeadBuf: Buffer,
|
||||||
|
headBuf: Buffer
|
||||||
|
): Promise<HistorySession[]> {
|
||||||
const out: HistorySession[] = [];
|
const out: HistorySession[] = [];
|
||||||
const stat = await fs.stat(projPath).catch(() => null);
|
const stat = await fs.stat(projPath).catch(() => null);
|
||||||
if (!stat?.isDirectory()) return out;
|
if (!stat?.isDirectory()) return out;
|
||||||
@@ -2751,22 +2796,45 @@ export function registerSessionRoutes(
|
|||||||
if (!fileStat) continue;
|
if (!fileStat) continue;
|
||||||
if (fileStat.size < 4000) continue;
|
if (fileStat.size < 4000) continue;
|
||||||
|
|
||||||
let firstPrompt: string | undefined;
|
|
||||||
const head = await readFileHead(filePath, headBuf);
|
|
||||||
const hasConversation = (text: string) =>
|
const hasConversation = (text: string) =>
|
||||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
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 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;
|
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);
|
const tailBuf = Buffer.alloc(32768);
|
||||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||||
if (tail) foundContent = hasConversation(tail);
|
if (tail) foundContent = hasConversation(tail);
|
||||||
}
|
}
|
||||||
if (!foundContent) continue;
|
if (!foundContent) continue;
|
||||||
|
|
||||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
// firstPrompt was already attempted from head (both tiers) above; this is
|
||||||
if (!firstPrompt && fileStat.size > 65536) {
|
// purely the tail fallback for whatever's left unresolved.
|
||||||
|
if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) {
|
||||||
if (!tail) {
|
if (!tail) {
|
||||||
const tailBuf = Buffer.alloc(32768);
|
const tailBuf = Buffer.alloc(32768);
|
||||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
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
|
// 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
|
// 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
|
// (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`,
|
||||||
// then contains the whole transcript — scan it for the last match instead.
|
// which then contains the whole transcript — scan it for the last match instead.
|
||||||
if (!tail && fileStat.size > 65536) {
|
if (!tail && fileStat.size > headBuf.length) {
|
||||||
const tailBuf = Buffer.alloc(32768);
|
const tailBuf = Buffer.alloc(32768);
|
||||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||||
}
|
}
|
||||||
const lastPrompt =
|
const lastPrompt =
|
||||||
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
|
(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({
|
out.push({
|
||||||
sessionId,
|
sessionId,
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -2801,7 +2891,13 @@ export function registerSessionRoutes(
|
|||||||
app.get('/api/history/sessions', async (req) => {
|
app.get('/api/history/sessions', async (req) => {
|
||||||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
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
|
// 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.
|
// 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.
|
// 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 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 limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||||
const projPath = join(projectsDir, query.projectKey);
|
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).
|
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
||||||
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
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);
|
const projectDirs = await fs.readdir(projectsDir);
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
const projPath = join(projectsDir, projDir);
|
const projPath = join(projectsDir, projDir);
|
||||||
const list = await scanProjectDir(projPath, projDir, headBuf);
|
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
|
||||||
results.push(...list);
|
results.push(...list);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2908,11 +3004,13 @@ export function registerSessionRoutes(
|
|||||||
const history: HistoryInput[] = [];
|
const history: HistoryInput[] = [];
|
||||||
try {
|
try {
|
||||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
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);
|
const projectDirs = await fs.readdir(projectsDir);
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
const projPath = join(projectsDir, projDir);
|
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) {
|
for (const h of list) {
|
||||||
history.push({
|
history.push({
|
||||||
sessionId: h.sessionId,
|
sessionId: h.sessionId,
|
||||||
|
|||||||
@@ -1350,6 +1350,224 @@ describe('session-routes', () => {
|
|||||||
expect(row.workingDir).toBe(dotDir);
|
expect(row.workingDir).toBe(dotDir);
|
||||||
expect(row.workingDir).not.toContain('//');
|
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) ==========
|
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||||
|
|||||||
@@ -270,6 +270,75 @@ describe('mergeUnifiedSessions', () => {
|
|||||||
expect(live!.firstPrompt).toBeUndefined();
|
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
|
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
|
||||||
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
|
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
|
||||||
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user