165 lines
8.4 KiB
JavaScript
165 lines
8.4 KiB
JavaScript
// Phase 5b golden test: pins the claude adapter's parse() record stream.
|
|
// This is the binding-independent contract — no database is involved. If the
|
|
// per-line parse behavior drifts, this fails before persist ever runs.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
|
|
import { createClaudeProvider, parse } from '../packages/core/src/providers/claude.ts';
|
|
import { assembleSessionDetail } from '../packages/core/src/session-detail.ts';
|
|
import { persist } from '../packages/core/src/persist.ts';
|
|
|
|
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
|
|
|
function writeFixture() {
|
|
const dir = mkdtempSync(join(tmpdir(), 'obelisk-claude-parse-'));
|
|
const path = join(dir, 'sid-x.jsonl');
|
|
const lines = [
|
|
{ type: 'ai-title', aiTitle: 'My Session' },
|
|
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', gitBranch: 'main', message: { role: 'user', content: 'hi' } },
|
|
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'claude-opus', content: [{ type: 'text', text: 'ok' }, { 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: 1234 },
|
|
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'file body', is_error: false }] } },
|
|
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'a summary' },
|
|
];
|
|
writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
|
return path;
|
|
}
|
|
|
|
// Drain a generator, returning both the yielded values and its return value.
|
|
function drain(gen) {
|
|
const values = [];
|
|
let step = gen.next();
|
|
while (!step.done) { values.push(step.value); step = gen.next(); }
|
|
return { values, ret: step.value };
|
|
}
|
|
|
|
test('claude parse() yields the expected record stream for a main session', () => {
|
|
const path = writeFixture();
|
|
const { values, ret } = drain(parse({ key: path, sessionId: 'sid-x', project: 'quiet-zero' }, null));
|
|
|
|
const byKind = k => values.filter(r => r.kind === k);
|
|
|
|
// Three user/assistant messages, correct order and fields.
|
|
assert.deepEqual(byKind('message').map(m => m.uuid), ['u1', 'a1', 'u2']);
|
|
assert.equal(byKind('message').find(m => m.uuid === 'a1').model, 'claude-opus');
|
|
assert.deepEqual(
|
|
(({ input_tokens, output_tokens }) => ({ input_tokens, output_tokens }))(
|
|
byKind('message').find(m => m.uuid === 'a1'),
|
|
),
|
|
{ input_tokens: 60, output_tokens: 5 },
|
|
);
|
|
assert.equal(byKind('message').every(m => m.source === 'claude'), true);
|
|
|
|
// Tool call + tool result extracted.
|
|
assert.deepEqual(byKind('tool_call').map(t => ({ id: t.id, name: t.name })), [{ id: 'tc1', name: 'Read' }]);
|
|
assert.deepEqual(byKind('tool_result').map(t => ({ id: t.tool_use_id, err: t.is_error })), [{ id: 'tc1', err: 0 }]);
|
|
|
|
// turn_duration is an update op keyed on the assistant message.
|
|
assert.deepEqual(byKind('message-turn-duration'), [{ kind: 'message-turn-duration', uuid: 'a1', turn_duration_ms: 1234 }]);
|
|
|
|
// Away summary.
|
|
assert.deepEqual(byKind('summary').map(s => s.id), ['s1']);
|
|
|
|
// Exactly one session aggregate, reflecting THIS chunk.
|
|
const sessions = byKind('session');
|
|
assert.equal(sessions.length, 1);
|
|
assert.equal(sessions[0].title, 'My Session');
|
|
assert.equal(sessions[0].message_count, 3);
|
|
assert.equal(sessions[0].started_at, '2026-06-10T10:00:00Z');
|
|
assert.equal(sessions[0].ended_at, '2026-06-10T10:00:10Z');
|
|
assert.equal(sessions[0].git_branch, 'main');
|
|
|
|
const detail = assembleSessionDetail(values);
|
|
assert.deepEqual(detail.messages.map((message) => message.text), ['hi', 'ok']);
|
|
assert.equal(detail.messages[1].tool_calls[0].result.content, 'file body');
|
|
|
|
// Cursor encodes mtime:lines (6 lines consumed).
|
|
assert.equal(ret, `${statSync(path).mtimeMs}:6`);
|
|
});
|
|
|
|
test('claude parse() emits no session record for a subagent transcript', () => {
|
|
const path = writeFixture();
|
|
const { values } = drain(parse({ key: path, sessionId: 'sid-x', isSubagent: true, agentId: 'agent-7' }, null));
|
|
|
|
assert.equal(values.filter(r => r.kind === 'session').length, 0);
|
|
// Subagent messages carry the unit's agent id.
|
|
assert.equal(values.filter(r => r.kind === 'message').every(m => m.agent_id === 'agent-7'), true);
|
|
});
|
|
|
|
test('claude parse() resumes from a cursor, skipping already-indexed lines', () => {
|
|
const path = writeFixture();
|
|
// Cursor with 6 lines already processed → nothing new to parse.
|
|
const { values } = drain(parse({ key: path, sessionId: 'sid-x', project: 'quiet-zero' }, '0:6'));
|
|
// Only the (empty-chunk) session record, with message_count 0.
|
|
assert.deepEqual(values.filter(r => r.kind !== 'session'), []);
|
|
assert.equal(values.find(r => r.kind === 'session').message_count, 0);
|
|
});
|
|
|
|
test('claude provider emits workflow artifacts with an explicit canonical tool edge', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'obelisk-claude-workflow-'));
|
|
const projectDir = join(root, 'projects', '-proj');
|
|
const workflowDir = join(projectDir, 'sid-workflow', 'workflows');
|
|
const workflowAgentDir = join(projectDir, 'sid-workflow', 'subagents', 'workflows', 'run-workflow');
|
|
mkdirSync(workflowDir, { recursive: true });
|
|
mkdirSync(workflowAgentDir, { recursive: true });
|
|
writeFileSync(join(projectDir, 'sid-workflow.jsonl'), [
|
|
{
|
|
uuid: 'assistant-workflow', type: 'assistant', timestamp: '2026-06-10T10:00:00Z',
|
|
message: { role: 'assistant', content: [{ type: 'tool_use', id: 'workflow-tool', name: 'Workflow', input: {} }] },
|
|
},
|
|
{
|
|
uuid: 'workflow-result', type: 'user', timestamp: '2026-06-10T10:00:01Z',
|
|
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'workflow-tool', content: 'run-workflow complete' }] },
|
|
},
|
|
].map(line => JSON.stringify(line)).join('\n') + '\n');
|
|
writeFileSync(join(workflowDir, 'run-workflow.json'), JSON.stringify({
|
|
runId: 'run-workflow',
|
|
workflowName: 'Review',
|
|
status: 'complete',
|
|
workflowProgress: [{ type: 'workflow_agent', agentId: '7', phaseTitle: 'review', label: 'Reviewer' }],
|
|
}));
|
|
writeFileSync(join(workflowAgentDir, 'agent-7.jsonl'), `${JSON.stringify({
|
|
uuid: 'workflow-agent-message', type: 'user', timestamp: '2026-06-10T10:00:00Z',
|
|
message: { role: 'user', content: 'review it' },
|
|
})}\n`);
|
|
writeFileSync(join(workflowAgentDir, 'agent-7.meta.json'), JSON.stringify({
|
|
agentType: 'reviewer', description: 'Review the implementation',
|
|
}));
|
|
writeFileSync(join(root, 'history.jsonl'), `${JSON.stringify({
|
|
sessionId: 'sid-workflow', title: 'History-owned title',
|
|
})}\n`);
|
|
const provider = createClaudeProvider({ rootDir: root });
|
|
const units = provider.discover({ lastCursor: () => null });
|
|
const records = units.flatMap(unit => drain(provider.parse(unit, null)).values);
|
|
|
|
const workflow = records.find(record => record.kind === 'workflow');
|
|
assert.equal(workflow.parent_tool_use_id, 'workflow-tool');
|
|
const detail = assembleSessionDetail(records);
|
|
assert.equal(detail.session.title, 'History-owned title');
|
|
assert.equal(detail.messages[0].tool_calls[0].workflow.run_id, 'run-workflow');
|
|
assert.equal(detail.workflows[0].agents[0].label, 'Reviewer');
|
|
assert.equal(detail.workflows[0].agents.length, 1);
|
|
|
|
const db = new DatabaseSync(':memory:');
|
|
db.exec(SCHEMA);
|
|
for (const unit of units) persist(db, unit, provider.parse(unit, null));
|
|
const workflows = db.prepare('SELECT * FROM workflows').all();
|
|
for (const row of workflows) row.agents = db.prepare('SELECT * FROM workflow_agents WHERE run_id=?').all(row.run_id);
|
|
const persistedDetail = assembleSessionDetail({
|
|
session: db.prepare('SELECT * FROM sessions').get(),
|
|
messages: db.prepare('SELECT * FROM messages ORDER BY timestamp, uuid').all(),
|
|
toolCalls: db.prepare('SELECT * FROM tool_calls').all(),
|
|
toolResults: db.prepare('SELECT * FROM tool_results').all(),
|
|
subagents: db.prepare('SELECT * FROM subagents').all(),
|
|
workflows,
|
|
summaries: db.prepare('SELECT * FROM summaries').all(),
|
|
});
|
|
assert.deepEqual(persistedDetail, detail);
|
|
db.close();
|
|
});
|