Files
obelisk/tests/persist.test.mjs
T
SaladDay 2589384e68 feat(core): add first-class Pi session indexing (#23)
Pi cannot be read as another linear JSONL stream. Its history is a tree with
a durable leaf, orphan roots, branch summaries, and two compaction forms, so
the active context is something the format states rather than something line
order implies. The adapter keeps those semantics inside itself and projects
the result into the existing canonical tables.

Sessions are keyed by (normalized header cwd, header id) rather than by path,
because Pi's --session-id lookup is project-local: two projects may reuse an
id, while a move or an identical copy is still one session. Discovery covers
both layouts Pi writes and fingerprints each file by mtime, ctime, size and
inode, so a rewrite that preserves mtime is not read as unchanged.

Abandoned branches are preserved rather than dropped. Visibility becomes
three-state -- visible, inactive, hidden -- and helpers return only visible
rows until includeInactive asks for the superseded path, labeling every row
so a caller knows which it holds. Usage counts all three, because an
abandoned call still spent tokens; message_count reports only the visible
transcript.

A committed MIT-licensed oracle transcribed from Pi 0.83.0 pins the context
algorithms, and a fixed-seed differential runs 512 generated sessions against
it on every test run. Schema changes are additive.
2026-08-04 23:33:01 +08:00

142 lines
6.9 KiB
JavaScript

// Phase 5b-2a: unit-tests the shared persist layer in isolation (no buildIndex).
// Feeds claude.parse output into persist against an in-memory node:sqlite db and
// asserts rows + the drift-fixed session merge semantics.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parse } from '../packages/core/src/providers/claude.ts';
import { persist } from '../packages/core/src/persist.ts';
import { storedProviderCursor } from '../packages/core/src/provider-indexing.ts';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
function fixtureUnit() {
const dir = mkdtempSync(join(tmpdir(), 'obelisk-persist-'));
const path = join(dir, 'sid-p.jsonl');
const lines = [
{ type: 'ai-title', aiTitle: 'Persist Session' },
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', message: { role: 'user', content: 'hi' } },
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'm', content: [{ type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }], usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 20, cache_read_input_tokens: 30 } } },
{ type: 'system', subtype: 'turn_duration', parentUuid: 'a1', durationMs: 999 },
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'body', is_error: false }] } },
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'sum' },
];
writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
return { key: path, sessionId: 'sid-p', project: 'quiet-zero' };
}
function freshDb() {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
return db;
}
test('persist writes all record kinds from one claude parse', () => {
const db = freshDb();
const unit = fixtureUnit();
const cursor = persist(db, unit, parse(unit, null));
assert.equal(db.prepare('SELECT COUNT(*) c FROM messages').get().c, 3);
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_calls').get().c, 1);
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_results').get().c, 1);
assert.equal(db.prepare('SELECT COUNT(*) c FROM summaries').get().c, 1);
assert.deepEqual(
{ ...db.prepare('SELECT visibility,input_tokens,output_tokens FROM summaries').get() },
{ visibility: 'visible', input_tokens: null, output_tokens: null },
);
const ses = db.prepare('SELECT * FROM sessions WHERE id=?').get('sid-p');
assert.equal(ses.title, 'Persist Session');
assert.equal(ses.message_count, 3);
assert.equal(ses.started_at, '2026-06-10T10:00:00Z');
assert.equal(ses.ended_at, '2026-06-10T10:00:10Z');
// turn_duration applied via targeted UPDATE.
assert.equal(db.prepare('SELECT turn_duration_ms FROM messages WHERE uuid=?').get('a1').turn_duration_ms, 999);
const usage = db.prepare('SELECT input_tokens, output_tokens FROM messages WHERE uuid=?').get('a1');
assert.equal(usage.input_tokens, 60);
assert.equal(usage.output_tokens, 5);
// The legacy numeric columns remain queryable, while the opaque token
// round-trips byte-for-byte for snapshot providers.
const state = db.prepare('SELECT lines_processed,cursor FROM index_state WHERE jsonl_path=?').get(unit.key);
assert.equal(state.lines_processed, 6);
assert.equal(state.cursor, cursor);
assert.equal(storedProviderCursor(db, unit.key), cursor);
assert.equal(cursor.split(':')[1], '6');
});
test('resuming from cursor does not double-count message_count', () => {
const db = freshDb();
const unit = fixtureUnit();
const c1 = persist(db, unit, parse(unit, null));
assert.equal(db.prepare('SELECT message_count m FROM sessions WHERE id=?').get('sid-p').m, 3);
// Second run resumes at the stored cursor: parse skips all lines, yields an
// empty-chunk session (count 0); persist accumulates 3 + 0 = 3, not 6.
persist(db, unit, parse(unit, c1));
assert.equal(db.prepare('SELECT message_count m FROM sessions WHERE id=?').get('sid-p').m, 3);
assert.equal(db.prepare('SELECT COUNT(*) c FROM messages').get().c, 3);
});
test('fresh full re-scan (no prior cursor) resets message_count instead of accumulating', () => {
const db = freshDb();
const unit = fixtureUnit();
persist(db, unit, parse(unit, null));
db.prepare('DELETE FROM index_state').run(); // simulate force / lost state
persist(db, unit, parse(unit, null));
assert.equal(db.prepare('SELECT message_count m FROM sessions WHERE id=?').get('sid-p').m, 3);
assert.equal(db.prepare('SELECT COUNT(*) c FROM messages').get().c, 3);
});
test('persist round-trips canonical workflow records and their tool relationship', () => {
const db = freshDb();
function* records() {
yield {
kind: 'workflow', run_id: 'run-1', session_id: 'sid-p', parent_tool_use_id: 'tool-1',
task_id: 'task-1', script: 'review', result_json: '{}', timestamp: '2026-06-10T10:00:00Z',
agent_count: 1, duration_ms: 10, total_tokens: 20, status: 'complete', workflow_name: 'Review',
};
yield {
kind: 'workflow_agent', agent_id: 'agent-1', run_id: 'run-1', session_id: 'sid-p',
agent_type: 'reviewer', phase: 'review', label: 'Reviewer', state: 'complete', tokens: 20,
};
return null;
}
persist(db, { key: 'workflow', sessionId: 'sid-p' }, records());
assert.equal(db.prepare('SELECT parent_tool_use_id FROM workflows WHERE run_id=?').get('run-1').parent_tool_use_id, 'tool-1');
assert.equal(db.prepare('SELECT phase FROM workflow_agents WHERE agent_id=?').get('agent-1').phase, 'review');
db.close();
});
test('delete-session cascades across tables', () => {
const db = freshDb();
const unit = fixtureUnit();
persist(db, unit, parse(unit, null));
db.prepare('INSERT INTO workflows (run_id,session_id) VALUES (?,?)').run('run-delete', 'sid-p');
db.prepare('INSERT INTO workflow_agents (agent_id,run_id,session_id) VALUES (?,?,?)').run('agent-delete', 'run-delete', 'sid-p');
// Hand-roll a one-shot generator emitting a delete for the session.
function* del() { yield { kind: 'delete-session', sessionId: 'sid-p' }; return null; }
persist(db, { key: 'x', sessionId: 'sid-p' }, del());
assert.equal(db.prepare('SELECT COUNT(*) c FROM sessions WHERE id=?').get('sid-p').c, 0);
assert.equal(db.prepare('SELECT COUNT(*) c FROM messages WHERE session_id=?').get('sid-p').c, 0);
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_calls WHERE session_id=?').get('sid-p').c, 0);
assert.equal(db.prepare('SELECT COUNT(*) c FROM workflows WHERE session_id=?').get('sid-p').c, 0);
assert.equal(db.prepare('SELECT COUNT(*) c FROM workflow_agents WHERE session_id=?').get('sid-p').c, 0);
});