fix(history): exclude non-interactive (SDK-driven) transcripts from Past Sessions

Automated tools (CI review bots, etc.) invoke Claude Code via the SDK
and write their transcripts into the same ~/.claude/projects tree as
real interactive sessions, but were never something a user can resume
into -- no PTY, no running process. Their one-shot review prompts also
embed the full diff inline as a single message, often exceeding the
16KB head / 32KB tail windows this scanner reads, so they cluttered
Past Sessions two ways: as blank rows when the huge message couldn't
be parsed, or as N identical "Review this change for security
vulnerabilities..." rows when it could.

Claude Code stamps `entrypoint` on its own message records ('cli' for
a real interactive session, e.g. 'sdk-py' for an SDK invocation).
Exclude any transcript whose entrypoint isn't 'cli' from the history
list entirely, checked last so it reuses whatever head/tail the prompt
extraction already read. Missing entrypoint (older transcripts) reads
as interactive -- fail open, matching every other gating check in this
codebase. Shared by /api/history/sessions and /api/sessions/unified,
since both call the same scanProjectDir().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
timkjr
2026-08-05 11:11:18 -05:00
co-authored by Claude Sonnet 5
parent e2a644997e
commit e888c65c52
2 changed files with 85 additions and 0 deletions
+45
View File
@@ -1350,6 +1350,51 @@ 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);
});
});
// ========== POST /api/sessions (with resumeSessionId) ==========