import { describe, it, expect, beforeAll } from 'vitest';
import { splitExecutions, resetIdCounter } from '../src/lib/ingest/execution-split.ts';
import { splitIntoTurns, resetIdCounter as resetTurnIdCounter } from '../src/lib/ingest/turn-split.ts';
import { normalize } from '../src/lib/ingest/normalize.ts';
import type { RawInteraction } from '../src/lib/shared/types.ts';
import type { TurnRow, ToolCallRow, SkillEventRow } from '../src/lib/ingest/turn-split.ts';
import type { ExecutionRow } from '../src/lib/ingest/execution-split.ts';
import fs from 'node:fs';
import path from 'node:path';
const SYNTHETIC_DATA_PATH = path.resolve(__dirname, 'data/synthetic-opencode.json');
function loadSyntheticData(): RawInteraction[] {
const raw = fs.readFileSync(SYNTHETIC_DATA_PATH, 'utf-8');
return JSON.parse(raw) as RawInteraction[];
}
describe('execution-split', () => {
describe('with synthetic data (has root + subagent)', () => {
let turns: TurnRow[];
let toolCalls: ToolCallRow[];
let skillEvents: SkillEventRow[];
let executions: ExecutionRow[];
beforeAll(() => {
resetTurnIdCounter();
resetIdCounter();
const interactions = loadSyntheticData();
const normalized = normalize(interactions, 'opencode-db');
const splitResult = splitIntoTurns(normalized, 'session-synthetic-001');
turns = splitResult.turns;
toolCalls = splitResult.toolCalls;
skillEvents = splitResult.skillEvents;
executions = splitExecutions(turns, toolCalls, skillEvents, 'session-synthetic-001');
});
it('produces 2 executions: 1 root + 1 subagent', () => {
expect(executions.length).toBe(2);
const rootExecs = executions.filter(e => !e.isSubagent);
const subExecs = executions.filter(e => e.isSubagent);
expect(rootExecs.length).toBe(1);
expect(subExecs.length).toBe(1);
});
it('each execution has all required ExecutionRow fields', () => {
const requiredKeys: Array<keyof ExecutionRow> = [
'id', 'sessionId', 'agentName', 'agentSessionId', 'isSubagent',
'subagentType', 'subagentName', 'parentExecutionId', 'rootExecutionId',
'depth', 'tokens', 'inputTokens', 'outputTokens', 'reasoningTokens',
'cacheReadInputTokens', 'cacheCreationInputTokens', 'maxSingleCallTokens',
'cost', 'latencyMs', 'createdAt', 'toolCallCount', 'toolCallErrorCount',
'llmCallCount', 'skillLoadCount', 'skillInvokeCount',
'finalResult', 'model',
];
for (const exec of executions) {
for (const key of requiredKeys) {
expect(exec).toHaveProperty(key);
}
}
});
it('root execution aggregates non-subagent turns', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurns = turns.filter(t => !t.isSubagent);
expect(rootExec.tokens).toBe(rootTurns.reduce((s, t) => s + t.totalTokens, 0));
expect(rootExec.inputTokens).toBe(rootTurns.reduce((s, t) => s + t.inputTokens, 0));
expect(rootExec.outputTokens).toBe(rootTurns.reduce((s, t) => s + t.outputTokens, 0));
expect(rootExec.isSubagent).toBe(false);
expect(rootExec.parentExecutionId).toBeNull();
expect(rootExec.rootExecutionId).toBe(rootExec.id);
expect(rootExec.depth).toBe(0);
expect(rootExec.agentSessionId).toBe('session-synthetic-001');
});
it('subagent execution aggregates subagent turns', () => {
const subExec = executions.find(e => e.isSubagent)!;
const subTurns = turns.filter(t => t.isSubagent);
expect(subExec.tokens).toBe(subTurns.reduce((s, t) => s + t.totalTokens, 0));
expect(subExec.inputTokens).toBe(subTurns.reduce((s, t) => s + t.inputTokens, 0));
expect(subExec.outputTokens).toBe(subTurns.reduce((s, t) => s + t.outputTokens, 0));
expect(subExec.isSubagent).toBe(true);
expect(subExec.parentExecutionId).toBe(executions.find(e => !e.isSubagent)!.id);
expect(subExec.rootExecutionId).toBe(executions.find(e => !e.isSubagent)!.id);
expect(subExec.depth).toBe(1);
expect(subExec.agentSessionId).toBe('ses_synthetic_subagent_001');
});
it('root execution llmCallCount equals number of root assistant turns', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootAssistantTurns = turns.filter(t => !t.isSubagent && t.role === 'assistant');
expect(rootExec.llmCallCount).toBe(rootAssistantTurns.length);
});
it('root execution toolCallCount equals root tool calls', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurnIds = new Set(turns.filter(t => !t.isSubagent).map(t => t.id));
const rootToolCalls = toolCalls.filter(tc => rootTurnIds.has(tc.turnId));
expect(rootExec.toolCallCount).toBe(rootToolCalls.length);
});
it('root execution skillLoadCount and skillInvokeCount', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurnIds = new Set(turns.filter(t => !t.isSubagent).map(t => t.id));
const rootSkillEvents = skillEvents.filter(se => rootTurnIds.has(se.turnId));
expect(rootExec.skillLoadCount).toBe(rootSkillEvents.filter(se => se.eventType === 'load').length);
expect(rootExec.skillInvokeCount).toBe(rootSkillEvents.filter(se => se.eventType === 'invoke' || se.eventType === 'use').length);
});
it('subagent execution toolCallCount equals subagent tool calls', () => {
const subExec = executions.find(e => e.isSubagent)!;
const subTurnIds = new Set(turns.filter(t => t.isSubagent).map(t => t.id));
const subToolCalls = toolCalls.filter(tc => subTurnIds.has(tc.turnId));
expect(subExec.toolCallCount).toBe(subToolCalls.length);
});
it('maxSingleCallTokens is max of turn totalTokens', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurns = turns.filter(t => !t.isSubagent);
const expectedMax = Math.max(...rootTurns.map(t => t.totalTokens));
expect(rootExec.maxSingleCallTokens).toBe(expectedMax);
});
it('cost is sum of turn costs', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurns = turns.filter(t => !t.isSubagent);
const expectedCost = rootTurns.reduce((s, t) => s + t.cost, 0);
expect(rootExec.cost).toBeCloseTo(expectedCost, 4);
});
it('latencyMs is wall-clock duration from first turn start to last turn end', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurns = turns.filter(t => !t.isSubagent);
const timestamps = rootTurns.filter(t => t.createdAt_ts).map(t => new Date(t.createdAt_ts!).getTime());
const endTimestamps = rootTurns.filter(t => t.completedAt).map(t => new Date(t.completedAt!).getTime());
const earliestStart = Math.min(...timestamps);
const latestEnd = endTimestamps.length > 0 ? Math.max(...endTimestamps) : Math.max(...timestamps) + rootTurns[rootTurns.length - 1].latencyMs;
expect(rootExec.latencyMs).toBe(latestEnd - earliestStart);
});
it('cacheReadInputTokens from cacheReadTokens', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurns = turns.filter(t => !t.isSubagent);
const expected = rootTurns.reduce((s, t) => s + t.cacheReadTokens, 0);
expect(rootExec.cacheReadInputTokens).toBe(expected);
});
it('cacheCreationInputTokens from cacheWriteTokens', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const rootTurns = turns.filter(t => !t.isSubagent);
const expected = rootTurns.reduce((s, t) => s + t.cacheWriteTokens, 0);
expect(rootExec.cacheCreationInputTokens).toBe(expected);
});
it('finalResult is last root turn contentSummary', () => {
const rootExec = executions.find(e => !e.isSubagent)!;
const lastRootTurn = turns.filter(t => !t.isSubagent)[turns.filter(t => !t.isSubagent).length - 1];
expect(rootExec.finalResult).toBe(lastRootTurn?.contentSummary ?? null);
});
it('subagentName extracted from subagent turns', () => {
const subExec = executions.find(e => e.isSubagent)!;
expect(subExec.subagentName).toBe('Kuafu');
});
});
describe('session with no subagents produces just 1 root ExecutionRow', () => {
it('only root execution when no subagent turns', () => {
resetTurnIdCounter();
resetIdCounter();
const interactions: RawInteraction[] = [
{
role: 'user',
content: 'Hello',
timestamp: '2026-01-01T00:00:00.000Z',
timeInfo: { created: 1735689600000 },
agent: 'build', subagent_name: null, subagent_session_id: null, tool_calls: null,
usage: null, model: 'test', modelID: null, providerID: null, latency: null, finish_reason: null,
},
{
role: 'assistant',
content: 'Response',
timestamp: '2026-01-01T00:00:01.000Z',
timeInfo: { created: 1735689601000, completed: 1735689605000 },
agent: 'build', subagent_name: null, subagent_session_id: null,
tool_calls: [{
toolCallId: 'tc1', toolName: 'bash', argsJson: '{"command":"ls"}',
resultJson: 'output', state: 'ok',
}],
usage: { total: 500, input: 400, output: 100, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0.005, inputMessagesTokens: 400 },
model: 'test', modelID: null, providerID: null, latency: 4000, finish_reason: 'stop',
},
];
const normalized = normalize(interactions, 'opencode-db');
const splitResult = splitIntoTurns(normalized, 'session-no-sub');
const executions = splitExecutions(splitResult.turns, splitResult.toolCalls, splitResult.skillEvents, 'session-no-sub');
expect(executions.length).toBe(1);
expect(executions[0].isSubagent).toBe(false);
expect(executions[0].parentExecutionId).toBeNull();
expect(executions[0].tokens).toBe(500);
expect(executions[0].llmCallCount).toBe(1);
expect(executions[0].toolCallCount).toBe(1);
});
});
describe('edge cases', () => {
it('handles empty turns array', () => {
resetIdCounter();
const executions = splitExecutions([], [], [], 'session-empty');
expect(executions.length).toBe(1);
expect(executions[0].isSubagent).toBe(false);
expect(executions[0].tokens).toBe(0);
expect(executions[0].toolCallCount).toBe(0);
});
it('handles turns with only user messages', () => {
resetTurnIdCounter();
resetIdCounter();
const interactions: RawInteraction[] = [
{
role: 'user',
content: 'Hello',
timestamp: '2026-01-01T00:00:00.000Z',
timeInfo: { created: 1735689600000 },
agent: 'build', subagent_name: null, subagent_session_id: null, tool_calls: null,
usage: null, model: 'test', modelID: null, providerID: null, latency: null, finish_reason: null,
},
];
const normalized = normalize(interactions, 'opencode-db');
const splitResult = splitIntoTurns(normalized, 'session-user-only');
const executions = splitExecutions(splitResult.turns, splitResult.toolCalls, splitResult.skillEvents, 'session-user-only');
expect(executions.length).toBe(1);
expect(executions[0].llmCallCount).toBe(0);
expect(executions[0].toolCallCount).toBe(0);
});
it('toolCallErrorCount counts non-ok states', () => {
resetTurnIdCounter();
resetIdCounter();
const interactions: RawInteraction[] = [
{
role: 'assistant',
content: 'Response',
timestamp: '2026-01-01T00:00:01.000Z',
timeInfo: { created: 1735689601000, completed: 1735689605000 },
agent: 'build', subagent_name: null, subagent_session_id: null,
tool_calls: [
{ toolCallId: 'tc1', toolName: 'bash', argsJson: '{"command":"ls"}', resultJson: 'ok', state: 'ok' },
{ toolCallId: 'tc2', toolName: 'bash', argsJson: '{"command":"rm"}', resultJson: 'error', state: 'error' },
],
usage: { total: 500, input: 400, output: 100, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0, inputMessagesTokens: 400 },
model: 'test', modelID: null, providerID: null, latency: 4000, finish_reason: 'stop',
},
];
const normalized = normalize(interactions, 'opencode-db');
const splitResult = splitIntoTurns(normalized, 'session-errors');
const executions = splitExecutions(splitResult.turns, splitResult.toolCalls, splitResult.skillEvents, 'session-errors');
expect(executions[0].toolCallCount).toBe(2);
expect(executions[0].toolCallErrorCount).toBe(1);
});
});
});