mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13: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:
@@ -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) ==========
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user