Files
pxpipe/tests/sessions.test.ts
T
teamchong f8902732bb feat(dashboard): real saved% from count_tokens, rip α-regression dead code
The headline saved%/saved$ numbers are now MEASURED, not estimated:

  baseline = sum of /v1/messages/count_tokens(originalBody).input_tokens
  actual   = sum of (input + cache_create×1.25 + cache_read×0.10)
  saved    = baseline − actual

Output is excluded from both sides — it's identical with or without
compression (same prompt → same response), so it cancels in the
numerator and would only drag the percentage toward zero. The proxy
now makes one parallel side call to /v1/messages/count_tokens (free,
no billing) before forwarding /v1/messages, and the result lands on
TransformInfo.baselineTokens → TrackEvent.baseline_tokens → the
DashboardState rollup.

Removed (~280 lines of dead code):
  - FALLBACK_ALPHA, FALLBACK_ALPHA_LOW, FALLBACK_ALPHA_HIGH constants
  - estImageTokens(), baselineCost(), effectiveCost() helpers
  - FitSample interface + FIT_SAMPLE_CAP + fitSamples ring buffer
  - DashboardState.fitCosts() (~218 lines of OLS regression)
  - Three-mode ladder (joint / constrained / stale) and all UI for it
  - saved_pct_low/high, saved_effective_tokens_low/high range fields
  - renderSavingsMath() range/regime branches
  - m_pct_regime DOM node + its color/text wiring
  - cost_fit payload field + all frontend consumers

/proxy-stats payload is now: requests, compressed_requests,
baseline_input_tokens, actual_input_weighted, saved_input_tokens,
saved_pct, saved_usd, pricing_assumptions, uptime_sec,
compression_enabled. Nothing else.

The recent-requests table now shows baseline / actual / saved per
row (input-side, matching the cards) instead of the old img-tok
estimate + effective_actual (which mixed output × 5 in inconsistently).

Sessions panel: per-session tokensSavedEst now uses the same real
baseline−actual formula. Events without both a count_tokens probe
AND an upstream usage block don't contribute to the rollup — no
synthetic 4-char/tok fallback.

Tests:
  - dashboard-fit.test.ts: deleted (fitCosts is gone)
  - proxy-usage.test.ts: inverted the privacy regression — count_tokens
    is now explicitly expected as the one whitelisted side path; added
    baselineTokens-attachment and count_tokens-failure-survives tests
  - sessions.test.ts: rewrote the two savings tests against the new
    baseline_tokens + usage formula
2026-05-19 22:00:14 -04:00

649 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
aggregateSessions,
collectSessionEvents,
diskUsage,
filterSessions,
prune,
redactEvent,
selectSessionsToRemove,
type SessionsPaths,
} from '../src/sessions.js';
import type { TrackEvent } from '../src/core/tracker.js';
// ---- Test scaffolding ------------------------------------------------------
/** Build a tmpdir with a fresh events.jsonl and 4xx-bodies/ for each test. We
* intentionally keep this synchronous and verbose — the prune surface is
* small enough that test setup doubles as documentation. */
function makeTmp(): SessionsPaths {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pixelpipe-sessions-'));
const eventsFile = path.join(dir, 'events.jsonl');
const sidecarDir = path.join(dir, '4xx-bodies');
return { eventsFile, sidecarDir };
}
function ev(partial: Partial<TrackEvent>): TrackEvent {
return {
ts: '2026-05-18T00:00:00Z',
method: 'POST',
path: '/v1/messages',
status: 200,
duration_ms: 100,
...partial,
};
}
function writeEvents(paths: SessionsPaths, events: TrackEvent[]): void {
fs.mkdirSync(path.dirname(paths.eventsFile), { recursive: true });
const lines = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
fs.writeFileSync(paths.eventsFile, lines);
}
function writeSidecar(
paths: SessionsPaths,
name: string,
bytes = 256,
): string {
fs.mkdirSync(paths.sidecarDir, { recursive: true });
const full = path.join(paths.sidecarDir, name);
fs.writeFileSync(full, Buffer.alloc(bytes, 'x'));
return full;
}
let tmp: SessionsPaths;
beforeEach(() => {
tmp = makeTmp();
});
afterEach(() => {
// Best-effort cleanup; on failure the tmpdir leaks but the OS handles it.
try {
fs.rmSync(path.dirname(tmp.eventsFile), { recursive: true, force: true });
} catch {
/* ignore */
}
});
// ---- Aggregation -----------------------------------------------------------
describe('aggregateSessions', () => {
it('groups events by first_user_sha8', async () => {
writeEvents(tmp, [
ev({ ts: '2026-05-18T00:00:00Z', first_user_sha8: 'aaaaaaaa', cwd: '/a' }),
ev({ ts: '2026-05-18T00:00:01Z', first_user_sha8: 'aaaaaaaa', cwd: '/a' }),
ev({ ts: '2026-05-18T00:00:02Z', first_user_sha8: 'bbbbbbbb', cwd: '/b' }),
]);
const { sessions } = await aggregateSessions(tmp);
expect(sessions.size).toBe(2);
expect(sessions.get('aaaaaaaa')?.requestCount).toBe(2);
expect(sessions.get('bbbbbbbb')?.requestCount).toBe(1);
});
it('uses earliest ts for firstSeen and latest for lastSeen even when input is unordered', async () => {
writeEvents(tmp, [
ev({ ts: '2026-05-18T00:00:05Z', first_user_sha8: 'aaaaaaaa' }),
ev({ ts: '2026-05-18T00:00:01Z', first_user_sha8: 'aaaaaaaa' }),
ev({ ts: '2026-05-18T00:00:09Z', first_user_sha8: 'aaaaaaaa' }),
]);
const { sessions } = await aggregateSessions(tmp);
const s = sessions.get('aaaaaaaa')!;
expect(s.firstSeen).toBe('2026-05-18T00:00:01Z');
expect(s.lastSeen).toBe('2026-05-18T00:00:09Z');
});
it('puts events with no first_user_sha8 into <unknown>', async () => {
writeEvents(tmp, [ev({ first_user_sha8: undefined })]);
const { sessions } = await aggregateSessions(tmp);
expect(sessions.has('<unknown>')).toBe(true);
});
it('credits sidecar bytes to the right session', async () => {
const sidecar = writeSidecar(tmp, 'sample.json.gz', 1024);
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa', req_body_sample_path: sidecar }),
ev({ first_user_sha8: 'bbbbbbbb' }),
]);
const { sessions, sidecarsBySession } = await aggregateSessions(tmp);
expect(sessions.get('aaaaaaaa')?.sidecarBytes).toBe(1024);
expect(sessions.get('bbbbbbbb')?.sidecarBytes).toBe(0);
expect(sidecarsBySession.get('aaaaaaaa')?.has(sidecar)).toBe(true);
});
it('returns empty when events.jsonl is missing', async () => {
const missing: SessionsPaths = {
eventsFile: path.join(path.dirname(tmp.eventsFile), 'nope.jsonl'),
sidecarDir: tmp.sidecarDir,
};
const { sessions } = await aggregateSessions(missing);
expect(sessions.size).toBe(0);
});
it('drops malformed JSONL lines silently', async () => {
fs.mkdirSync(path.dirname(tmp.eventsFile), { recursive: true });
fs.writeFileSync(
tmp.eventsFile,
[
JSON.stringify(ev({ first_user_sha8: 'aaaaaaaa' })),
'this is not json',
JSON.stringify(ev({ first_user_sha8: 'aaaaaaaa' })),
].join('\n') + '\n',
);
const { sessions } = await aggregateSessions(tmp);
expect(sessions.get('aaaaaaaa')?.requestCount).toBe(2);
});
it('accumulates tokens saved from baseline_tokens vs upstream usage (allows negative)', async () => {
writeEvents(tmp, [
// baseline 20000, actual_input_eff = 1000 + 800*1.25 + 100*0.10 = 2010
// saved = 20000 − 2010 = 17990
ev({
first_user_sha8: 'aaaaaaaa',
compressed: true,
baseline_tokens: 20_000,
input_tokens: 1_000,
cache_create_tokens: 800,
cache_read_tokens: 100,
}),
// baseline 2000, actual_input_eff = 3000 + 0 + 0 = 3000 → NET LOSS −1000
// (must NOT be clamped to zero)
ev({
first_user_sha8: 'aaaaaaaa',
compressed: true,
baseline_tokens: 2_000,
input_tokens: 3_000,
cache_create_tokens: 0,
cache_read_tokens: 0,
}),
// Missing baseline — skipped from savings rollup, still counts toward requests.
ev({
first_user_sha8: 'aaaaaaaa',
compressed: false,
input_tokens: 500,
}),
// Has baseline but no usage block — also skipped (apples-to-apples).
ev({
first_user_sha8: 'aaaaaaaa',
compressed: true,
baseline_tokens: 9_999,
}),
]);
const { sessions } = await aggregateSessions(tmp);
const s = sessions.get('aaaaaaaa')!;
// 17990 + (−1000) = 16990
expect(s.tokensSavedEst).toBe(16_990);
expect(s.charsSaved).toBe(16_990 * 4);
expect(s.requestCount).toBe(4);
});
it('reports negative tokensSavedEst when all events net-lose', async () => {
writeEvents(tmp, [
// Each event: baseline=1000, actual=2000 → −1000
ev({
first_user_sha8: 'bbbbbbbb',
compressed: true,
baseline_tokens: 1_000,
input_tokens: 2_000,
}),
ev({
first_user_sha8: 'bbbbbbbb',
compressed: true,
baseline_tokens: 1_000,
input_tokens: 2_000,
}),
ev({
first_user_sha8: 'bbbbbbbb',
compressed: true,
baseline_tokens: 1_000,
input_tokens: 2_000,
}),
]);
const { sessions } = await aggregateSessions(tmp);
const s = sessions.get('bbbbbbbb')!;
// 3 × −1000 = −3000
expect(s.tokensSavedEst).toBe(-3_000);
expect(s.charsSaved).toBe(-12_000);
});
});
// ---- filter + list ---------------------------------------------------------
describe('filterSessions', () => {
it('filters by project (substring match)', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa', cwd: '/Users/me/code/pixelpipe' }),
ev({ first_user_sha8: 'bbbbbbbb', cwd: '/Users/me/code/other' }),
]);
const { sessions } = await aggregateSessions(tmp);
expect(filterSessions(sessions, { project: 'pixelpipe' }).map((s) => s.id)).toEqual([
'aaaaaaaa',
]);
expect(filterSessions(sessions, { project: 'other' }).map((s) => s.id)).toEqual([
'bbbbbbbb',
]);
});
it('filters by since (ISO date)', async () => {
writeEvents(tmp, [
ev({ ts: '2026-04-01T00:00:00Z', first_user_sha8: 'old1' }),
ev({ ts: '2026-05-01T00:00:00Z', first_user_sha8: 'new1' }),
]);
const { sessions } = await aggregateSessions(tmp);
const filtered = filterSessions(sessions, { since: '2026-04-15T00:00:00Z' });
expect(filtered.map((s) => s.id)).toEqual(['new1']);
});
it('sorts results most-recent-first', async () => {
writeEvents(tmp, [
ev({ ts: '2026-04-01T00:00:00Z', first_user_sha8: 'old1' }),
ev({ ts: '2026-05-01T00:00:00Z', first_user_sha8: 'mid1' }),
ev({ ts: '2026-06-01T00:00:00Z', first_user_sha8: 'new1' }),
]);
const { sessions } = await aggregateSessions(tmp);
const ids = filterSessions(sessions, {}).map((s) => s.id);
expect(ids).toEqual(['new1', 'mid1', 'old1']);
});
});
// ---- selectSessionsToRemove ------------------------------------------------
describe('selectSessionsToRemove', () => {
it('selects by --older-than days', async () => {
writeEvents(tmp, [
ev({ ts: '2026-04-01T00:00:00Z', first_user_sha8: 'old1' }),
ev({ ts: '2026-05-10T00:00:00Z', first_user_sha8: 'mid1' }),
ev({ ts: '2026-05-15T00:00:00Z', first_user_sha8: 'new1' }),
]);
const { sessions } = await aggregateSessions(tmp);
const now = new Date('2026-05-18T00:00:00Z');
// older than 7 days at now -> cutoff = 2026-05-11
const removed = selectSessionsToRemove(sessions, { olderThanDays: 7, force: false }, now);
expect([...removed].sort()).toEqual(['mid1', 'old1']);
});
it('selects by --keep-last N', async () => {
writeEvents(tmp, [
ev({ ts: '2026-04-01T00:00:00Z', first_user_sha8: 'a' }),
ev({ ts: '2026-04-02T00:00:00Z', first_user_sha8: 'b' }),
ev({ ts: '2026-04-03T00:00:00Z', first_user_sha8: 'c' }),
ev({ ts: '2026-04-04T00:00:00Z', first_user_sha8: 'd' }),
]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, { keepLast: 2, force: false });
// keep d, c — remove a, b
expect([...removed].sort()).toEqual(['a', 'b']);
});
it('selects by --session', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa' }),
ev({ first_user_sha8: 'bbbbbbbb' }),
]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(
sessions,
{ sessionId: 'aaaaaaaa', force: false },
);
expect([...removed]).toEqual(['aaaaaaaa']);
});
it('returns empty set when --session does not match', async () => {
writeEvents(tmp, [ev({ first_user_sha8: 'aaaaaaaa' })]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, {
sessionId: 'nonexistent',
force: false,
});
expect(removed.size).toBe(0);
});
it('selects multiple via sessionIds[] (bulk-delete UI)', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa' }),
ev({ first_user_sha8: 'bbbbbbbb' }),
ev({ first_user_sha8: 'cccccccc' }),
ev({ first_user_sha8: 'dddddddd' }),
]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, {
sessionIds: ['aaaaaaaa', 'cccccccc'],
force: false,
});
expect([...removed].sort()).toEqual(['aaaaaaaa', 'cccccccc']);
});
it('sessionIds[] silently skips unknown ids (race-safe)', async () => {
writeEvents(tmp, [ev({ first_user_sha8: 'aaaaaaaa' })]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, {
sessionIds: ['aaaaaaaa', 'doesnotexist', 'alsogone'],
force: false,
});
expect([...removed]).toEqual(['aaaaaaaa']);
});
it('sessionIds[] and sessionId compose (single + bulk in one call)', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa' }),
ev({ first_user_sha8: 'bbbbbbbb' }),
ev({ first_user_sha8: 'cccccccc' }),
]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, {
sessionId: 'aaaaaaaa',
sessionIds: ['bbbbbbbb'],
force: false,
});
expect([...removed].sort()).toEqual(['aaaaaaaa', 'bbbbbbbb']);
});
});
// ---- prune (dry-run + apply) ----------------------------------------------
describe('prune', () => {
it('dry-run by default — does not modify events.jsonl', async () => {
writeEvents(tmp, [
ev({ ts: '2026-04-01T00:00:00Z', first_user_sha8: 'old1' }),
ev({ ts: '2026-05-15T00:00:00Z', first_user_sha8: 'new1' }),
]);
const before = fs.readFileSync(tmp.eventsFile, 'utf8');
const report = await prune(
tmp,
{ olderThanDays: 7, force: false },
new Date('2026-05-18T00:00:00Z'),
);
const after = fs.readFileSync(tmp.eventsFile, 'utf8');
expect(after).toBe(before);
expect(report.applied).toBe(false);
expect(report.sessionsRemoved).toEqual(['old1']);
expect(report.eventsRemoved).toBe(1);
expect(report.eventsKept).toBe(1);
});
it('removes only matching events when --force is set', async () => {
writeEvents(tmp, [
ev({ ts: '2026-04-01T00:00:00Z', first_user_sha8: 'old1' }),
ev({ ts: '2026-04-02T00:00:00Z', first_user_sha8: 'old1' }),
ev({ ts: '2026-05-15T00:00:00Z', first_user_sha8: 'new1' }),
]);
const report = await prune(
tmp,
{ olderThanDays: 7, force: true },
new Date('2026-05-18T00:00:00Z'),
);
expect(report.applied).toBe(true);
const remaining = fs
.readFileSync(tmp.eventsFile, 'utf8')
.trim()
.split('\n')
.map((l) => JSON.parse(l) as TrackEvent);
expect(remaining).toHaveLength(1);
expect(remaining[0]!.first_user_sha8).toBe('new1');
});
it('removes only the targeted session when --session is set', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa' }),
ev({ first_user_sha8: 'bbbbbbbb' }),
ev({ first_user_sha8: 'aaaaaaaa' }),
ev({ first_user_sha8: 'cccccccc' }),
]);
await prune(tmp, { sessionId: 'aaaaaaaa', force: true });
const remaining = fs
.readFileSync(tmp.eventsFile, 'utf8')
.trim()
.split('\n')
.map((l) => JSON.parse(l) as TrackEvent);
expect(remaining).toHaveLength(2);
expect(remaining.map((r) => r.first_user_sha8).sort()).toEqual([
'bbbbbbbb',
'cccccccc',
]);
});
it('deletes sidecar files referenced by removed sessions', async () => {
const sidecar = writeSidecar(tmp, 'doomed.json.gz', 512);
writeEvents(tmp, [
ev({
ts: '2026-04-01T00:00:00Z',
first_user_sha8: 'old1',
req_body_sample_path: sidecar,
}),
ev({ ts: '2026-05-15T00:00:00Z', first_user_sha8: 'new1' }),
]);
expect(fs.existsSync(sidecar)).toBe(true);
const report = await prune(
tmp,
{ olderThanDays: 7, force: true },
new Date('2026-05-18T00:00:00Z'),
);
expect(fs.existsSync(sidecar)).toBe(false);
expect(report.sidecarsRemoved).toBe(1);
expect(report.sidecarBytesFreed).toBe(512);
});
it('preserves sidecars belonging to surviving sessions', async () => {
const doomed = writeSidecar(tmp, 'doomed.json.gz', 100);
const kept = writeSidecar(tmp, 'kept.json.gz', 100);
writeEvents(tmp, [
ev({
ts: '2026-04-01T00:00:00Z',
first_user_sha8: 'old1',
req_body_sample_path: doomed,
}),
ev({
ts: '2026-05-15T00:00:00Z',
first_user_sha8: 'new1',
req_body_sample_path: kept,
}),
]);
await prune(
tmp,
{ olderThanDays: 7, force: true },
new Date('2026-05-18T00:00:00Z'),
);
expect(fs.existsSync(doomed)).toBe(false);
expect(fs.existsSync(kept)).toBe(true);
});
it('rewrite is atomic — leaves no .tmp behind on success', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'old1', ts: '2026-04-01T00:00:00Z' }),
ev({ first_user_sha8: 'new1', ts: '2026-05-15T00:00:00Z' }),
]);
await prune(
tmp,
{ olderThanDays: 7, force: true },
new Date('2026-05-18T00:00:00Z'),
);
const dir = path.dirname(tmp.eventsFile);
const entries = fs.readdirSync(dir);
expect(entries).not.toContain('events.jsonl.tmp');
});
it('is a no-op when no sessions match (force=true, empty selection)', async () => {
writeEvents(tmp, [ev({ first_user_sha8: 'new1', ts: '2026-05-15T00:00:00Z' })]);
const before = fs.readFileSync(tmp.eventsFile, 'utf8');
const report = await prune(
tmp,
{ olderThanDays: 365, force: true },
new Date('2026-05-18T00:00:00Z'),
);
const after = fs.readFileSync(tmp.eventsFile, 'utf8');
expect(after).toBe(before);
expect(report.applied).toBe(true);
expect(report.sessionsRemoved).toEqual([]);
});
});
// ---- diskUsage -------------------------------------------------------------
describe('diskUsage', () => {
it('reports 0 when nothing exists', () => {
const d = diskUsage(tmp);
expect(d.eventsJsonlBytes).toBe(0);
expect(d.sidecarsBytes).toBe(0);
expect(d.sidecarCount).toBe(0);
expect(d.totalBytes).toBe(0);
});
it('matches actual file sizes', () => {
writeEvents(tmp, [ev({ first_user_sha8: 'a' }), ev({ first_user_sha8: 'b' })]);
writeSidecar(tmp, 'one.json.gz', 100);
writeSidecar(tmp, 'two.json.gz', 200);
const d = diskUsage(tmp);
const actualEvents = fs.statSync(tmp.eventsFile).size;
expect(d.eventsJsonlBytes).toBe(actualEvents);
expect(d.sidecarsBytes).toBe(300);
expect(d.sidecarCount).toBe(2);
expect(d.totalBytes).toBe(actualEvents + 300);
});
});
// ---- redactEvent + collectSessionEvents ------------------------------------
describe('show / redactEvent', () => {
it('strips body fields by default', () => {
const e = ev({
first_user_sha8: 'aaaaaaaa',
req_body_sample_b64: 'ZmFrZQ==',
req_body_sample_path: '/tmp/x.json.gz',
error_body: '{"error":"bad"}',
});
const r = redactEvent(e, false);
expect((r as TrackEvent).req_body_sample_b64).toBeUndefined();
expect((r as TrackEvent).req_body_sample_path).toBeUndefined();
expect((r as TrackEvent).error_body).toBeUndefined();
// Non-sensitive fields survive.
expect(r.first_user_sha8).toBe('aaaaaaaa');
});
it('keeps body fields when includeBodies=true', () => {
const e = ev({
first_user_sha8: 'aaaaaaaa',
error_body: '{"error":"bad"}',
});
const r = redactEvent(e, true);
expect((r as TrackEvent).error_body).toBe('{"error":"bad"}');
});
it('collectSessionEvents returns only matching session events', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa', ts: '2026-05-18T00:00:00Z' }),
ev({ first_user_sha8: 'bbbbbbbb', ts: '2026-05-18T00:00:01Z' }),
ev({ first_user_sha8: 'aaaaaaaa', ts: '2026-05-18T00:00:02Z' }),
]);
const got = await collectSessionEvents(tmp, 'aaaaaaaa');
expect(got).toHaveLength(2);
expect(got.every((e) => e.first_user_sha8 === 'aaaaaaaa')).toBe(true);
});
});
// ---- Claude Code session fingerprint map ----------------------------------
import {
claudeCodeMap,
decodeClaudeProjectDir,
fingerprintFirstUser,
readFirstUserFromClaudeSession,
} from '../src/sessions.js';
describe('Claude Code session map', () => {
/** Build a synthetic `~/.claude/projects/<proj>/<session>.jsonl` tree under
* a tmpdir and return the root path. */
function makeCCRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pixelpipe-ccmap-'));
}
it('returns an empty map when the directory does not exist', async () => {
const m = await claudeCodeMap(path.join(os.tmpdir(), 'definitely-missing-xyz'));
expect(m.size).toBe(0);
});
it('fingerprints the first user message and maps to the session id', async () => {
const root = makeCCRoot();
const proj = path.join(root, '-Users-me-code-pixelpipe');
fs.mkdirSync(proj, { recursive: true });
const firstUser = 'hello, this is the start of a conversation';
const sessionFile = path.join(proj, 'abc-123.jsonl');
fs.writeFileSync(
sessionFile,
[
JSON.stringify({ type: 'permission-mode' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: firstUser } }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: 'hi' } }),
].join('\n') + '\n',
);
const m = await claudeCodeMap(root);
const expectedSha = fingerprintFirstUser(firstUser);
const ref = m.get(expectedSha);
expect(ref).toBeDefined();
expect(ref!.sessionId).toBe('abc-123');
expect(ref!.projectPath).toBe('/Users/me/code/pixelpipe');
expect(ref!.firstUserPreview).toContain('hello');
});
it('parses content-array blocks (the modern Claude Code shape)', async () => {
const root = makeCCRoot();
const proj = path.join(root, '-Users-me-foo');
fs.mkdirSync(proj, { recursive: true });
fs.writeFileSync(
path.join(proj, 'sess.jsonl'),
JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [
{ type: 'text', text: 'first user prompt with text block shape' },
],
},
}) + '\n',
);
const m = await claudeCodeMap(root);
expect(m.size).toBe(1);
const ref = [...m.values()][0]!;
expect(ref.firstUserPreview).toContain('first user prompt');
});
it('decodes project directory names back to a slash-path', () => {
expect(decodeClaudeProjectDir('-Users-me-code-foo')).toBe('/Users/me/code/foo');
expect(decodeClaudeProjectDir('foo-bar')).toBe('foo/bar');
});
it('matches the proxy fingerprint: 4 KiB cap and 8-hex prefix', () => {
// Two strings that differ only past the 4 KiB cap must produce the same
// sha8 — otherwise the mapping silently misses every cross-pass-the-cap
// conversation.
const base = 'x'.repeat(4096);
expect(fingerprintFirstUser(base + 'A')).toBe(fingerprintFirstUser(base + 'B'));
expect(fingerprintFirstUser('hello')).toMatch(/^[0-9a-f]{8}$/);
});
it('skips sessions whose first user row is unreadable', async () => {
const root = makeCCRoot();
const proj = path.join(root, '-tmp-x');
fs.mkdirSync(proj, { recursive: true });
// First user row has neither string content nor an array of text blocks
// → readFirstUserFromClaudeSession returns undefined and we don't add a
// bogus mapping by hashing some later assistant turn.
fs.writeFileSync(
path.join(proj, 'sess.jsonl'),
[
JSON.stringify({ type: 'user', message: { role: 'user', content: { weird: true } } }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'later user message' } }),
].join('\n') + '\n',
);
const m = await claudeCodeMap(root);
expect(m.size).toBe(0);
});
it('readFirstUserFromClaudeSession handles missing file gracefully', async () => {
const got = await readFirstUserFromClaudeSession('/nope/does/not/exist.jsonl');
expect(got).toBeUndefined();
});
});