import { describe, it, expect } from 'vitest'; import { parseTranscript } from './observer-transcript-parser.mjs'; // Build a JSONL transcript string from entry objects. function jsonl(entries) { return entries.map((e) => JSON.stringify(e)).join('\n'); } function userPrompt(text, ts, sessionId = 's1') { return { type: 'user', message: { role: 'user', content: text }, timestamp: ts, sessionId }; } function assistantTurn(blocks, ts, sessionId = 's1') { return { type: 'assistant', message: { role: 'assistant', content: blocks }, timestamp: ts, sessionId }; } function toolResult(toolUseId, isError, ts, sessionId = 's1') { return { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'r', is_error: isError }], }, timestamp: ts, sessionId, }; } describe('parseTranscript', () => { it('extracts task_id from sessionId in transcript', () => { const t = jsonl([userPrompt('add a feature', '2026-05-19T10:00:00Z', 'sess-xyz')]); expect(parseTranscript(t).task_id).toBe('sess-xyz'); }); it('falls back to provided sessionId when transcript has none', () => { const t = jsonl([ { type: 'user', message: { role: 'user', content: 'hi' }, timestamp: '2026-05-19T10:00:00Z' }, ]); expect(parseTranscript(t, 'fallback-id').task_id).toBe('fallback-id'); }); it('extracts timestamps from the last turn', () => { const t = jsonl([ userPrompt('first', '2026-05-19T09:00:00Z'), userPrompt('second turn', '2026-05-19T10:00:00Z'), assistantTurn([{ type: 'text', text: 'done' }], '2026-05-19T10:05:00Z'), ]); const ep = parseTranscript(t); expect(ep.timestamps.started_at).toBe('2026-05-19T10:00:00Z'); expect(ep.timestamps.ended_at).toBe('2026-05-19T10:05:00Z'); }); it('emits skill_invoked events for Skill tool_use', () => { const t = jsonl([ userPrompt('do tdd', '2026-05-19T10:00:00Z'), assistantTurn( [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:test-driven-development' } }], '2026-05-19T10:01:00Z' ), ]); const skillEvents = parseTranscript(t).events.filter((e) => e.kind === 'skill_invoked'); expect(skillEvents).toHaveLength(1); expect(skillEvents[0].skill).toBe('superpowers:test-driven-development'); }); it('emits a tool_summary event with per-tool counts', () => { const t = jsonl([ userPrompt('work', '2026-05-19T10:00:00Z'), assistantTurn( [ { type: 'tool_use', id: 't1', name: 'Read', input: {} }, { type: 'tool_use', id: 't2', name: 'Read', input: {} }, { type: 'tool_use', id: 't3', name: 'Bash', input: {} }, ], '2026-05-19T10:01:00Z' ), ]); const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary'); expect(summary.counts).toEqual({ Read: 2, Bash: 1 }); }); it('node_chosen is the first skill invoked', () => { const t = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn( [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }], '2026-05-19T10:01:00Z' ), ]); expect(parseTranscript(t).primary_rationale.node_chosen).toBe('superpowers:brainstorming'); }); it('node_chosen is "direct" when no skill invoked', () => { const t = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'), ]); expect(parseTranscript(t).primary_rationale.node_chosen).toBe('direct'); }); it('hard_floor invoked when a superpowers skill is used', () => { const t = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn( [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:writing-plans' } }], '2026-05-19T10:01:00Z' ), ]); const hf = parseTranscript(t).primary_rationale.hard_floor; expect(hf.invoked).toBe(true); expect(hf.rules).toContain('Pravila §12'); }); it('hard_floor not invoked for a non-superpowers skill', () => { const t = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn( [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'claude-md-management:claude-md-improver' } }], '2026-05-19T10:01:00Z' ), ]); expect(parseTranscript(t).primary_rationale.hard_floor.invoked).toBe(false); }); it('classifies task from the user prompt text', () => { const cls = (text) => parseTranscript(jsonl([userPrompt(text, '2026-05-19T10:00:00Z')])).primary_rationale.task_classification; expect(cls('почини баг в логине')).toBe('bugfix'); expect(cls('добавь новую фичу экспорта')).toBe('feature'); expect(cls('отрефактори этот модуль')).toBe('refactor'); expect(cls('как это работает?')).toBe('question'); expect(cls('обнови документацию readme')).toBe('docs'); }); it('scopes to the last turn — earlier turns are excluded', () => { const t = jsonl([ userPrompt('turn 1', '2026-05-19T09:00:00Z'), assistantTurn( [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }], '2026-05-19T09:01:00Z' ), userPrompt('turn 2', '2026-05-19T10:00:00Z'), assistantTurn([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'), ]); const ep = parseTranscript(t); expect(ep.events.filter((e) => e.kind === 'skill_invoked')).toHaveLength(0); expect(ep.primary_rationale.node_chosen).toBe('direct'); }); it('does not treat a tool_result message as a turn boundary', () => { const t = jsonl([ userPrompt('the only real prompt', '2026-05-19T10:00:00Z'), assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'), toolResult('t1', false, '2026-05-19T10:02:00Z'), assistantTurn([{ type: 'tool_use', id: 't2', name: 'Edit', input: {} }], '2026-05-19T10:03:00Z'), ]); const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary'); expect(summary.counts).toEqual({ Bash: 1, Edit: 1 }); }); it('emits error events for tool_result with is_error', () => { const t = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'), toolResult('t1', true, '2026-05-19T10:02:00Z'), ]); expect(parseTranscript(t).events.filter((e) => e.kind === 'error')).toHaveLength(1); }); it('skips broken JSONL lines without throwing', () => { const t = [ JSON.stringify(userPrompt('go', '2026-05-19T10:00:00Z')), '{ broken json', JSON.stringify(assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z')), ].join('\n'); expect(() => parseTranscript(t)).not.toThrow(); expect(parseTranscript(t).events.find((e) => e.kind === 'tool_summary').counts).toEqual({ Read: 1 }); }); it('path_type is regulated with a superpowers skill, improvised otherwise', () => { const reg = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn( [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }], '2026-05-19T10:01:00Z' ), ]); const imp = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'), ]); expect(parseTranscript(reg).path_type).toBe('regulated'); expect(parseTranscript(imp).path_type).toBe('improvised'); }); it('returns safe defaults for an empty transcript', () => { const ep = parseTranscript(''); expect(ep.task_id).toBeTruthy(); expect(ep.primary_rationale.node_chosen).toBe('direct'); expect(ep.events).toEqual([]); expect(ep.outcome).toBe('success'); }); it('produces a complete 7-field primary_rationale', () => { const ep = parseTranscript(jsonl([userPrompt('go', '2026-05-19T10:00:00Z')])); const r = ep.primary_rationale; for (const f of [ 'step', 'node_chosen', 'triggers_matched', 'candidates_considered', 'boundaries_applied', 'hard_floor', 'task_classification', ]) { expect(r[f]).toBeDefined(); } }); });