2026-07-08 19:01:54 +08:00
|
|
|
// 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';
|
|
|
|
|
|
2026-07-12 00:28:17 +08:00
|
|
|
import { parse } from '../packages/core/src/providers/claude.ts';
|
|
|
|
|
import { persist } from '../packages/core/src/persist.ts';
|
2026-07-08 19:01:54 +08:00
|
|
|
|
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
|
const { DatabaseSync } = require('node:sqlite');
|
2026-07-12 00:28:17 +08:00
|
|
|
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
2026-07-08 19:01:54 +08:00
|
|
|
|
|
|
|
|
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' } },
|
2026-07-13 21:02:38 +08:00
|
|
|
{ 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 } } },
|
2026-07-08 19:01:54 +08:00
|
|
|
{ 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);
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-13 21:02:38 +08:00
|
|
|
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);
|
2026-07-08 19:01:54 +08:00
|
|
|
|
|
|
|
|
// Cursor persisted into index_state (mtime:lines → two columns).
|
|
|
|
|
const state = db.prepare('SELECT lines_processed FROM index_state WHERE jsonl_path=?').get(unit.key);
|
|
|
|
|
assert.equal(state.lines_processed, 6);
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-21 00:58:34 +08:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-08 19:01:54 +08:00
|
|
|
test('delete-session cascades across tables', () => {
|
|
|
|
|
const db = freshDb();
|
|
|
|
|
const unit = fixtureUnit();
|
|
|
|
|
persist(db, unit, parse(unit, null));
|
2026-07-21 00:58:34 +08:00
|
|
|
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');
|
2026-07-08 19:01:54 +08:00
|
|
|
|
|
|
|
|
// 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);
|
2026-07-21 00:58:34 +08:00
|
|
|
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);
|
2026-07-08 19:01:54 +08:00
|
|
|
});
|