import { describe, it, expect } from 'vitest'; import { parseTranscript, extractEnvironment, extractTaskSize, classifyPromptSignal, extractProcessEvents, parseRoutingTag, extractLastUserPromptText, classifyTask, extractTokenUsage, } 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('attaches chain_ref for a node that belongs to a chain', () => { const t = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), assistantTurn( [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'billing-audit' } }], '2026-05-19T10:01:00Z' ), ]); expect(parseTranscript(t).primary_rationale.chain_ref).toEqual(['L13']); }); it('sets chain_ref null for a direct episode', () => { 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.chain_ref).toBeNull(); }); 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'), ]); const errs = parseTranscript(t).events.filter((e) => e.kind === 'error'); expect(errs).toHaveLength(1); expect(errs[0].tool).toBe('Bash'); expect(errs[0].summary).toBe('r'); }); 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('unknown'); expect(ep.schema_version).toBe(2); }); 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(); } }); }); describe('extractEnvironment', () => { it('reads economy_level from the ECONOMY MODE marker', () => { const entries = [ userPrompt('=== ECONOMY MODE: 0% (пользователь указал явно) ===\nfix it', '2026-05-19T10:00:00Z'), ]; expect(extractEnvironment(entries, 0).economy_level).toBe(0); }); it('economy_level is null when no marker present', () => { const entries = [userPrompt('just do it', '2026-05-19T10:00:00Z')]; expect(extractEnvironment(entries, 0).economy_level).toBeNull(); }); it('reads model from an assistant message', () => { const entries = [ userPrompt('go', '2026-05-19T10:00:00Z'), { type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-7', content: [] }, timestamp: '2026-05-19T10:01:00Z', sessionId: 's1' }, ]; expect(extractEnvironment(entries, 0).model).toBe('claude-opus-4-7'); }); it('post_compaction is true when an isCompactSummary entry precedes the turn', () => { const entries = [ { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' }, userPrompt('the real turn', '2026-05-19T10:00:00Z'), ]; expect(extractEnvironment(entries, 1).post_compaction).toBe(true); }); it('post_compaction is false with no compaction marker', () => { const entries = [userPrompt('turn one', '2026-05-19T09:00:00Z'), userPrompt('turn two', '2026-05-19T10:00:00Z')]; expect(extractEnvironment(entries, 1).post_compaction).toBe(false); }); it('session_turn counts real user prompts up to and including the turn start', () => { const entries = [ userPrompt('one', '2026-05-19T09:00:00Z'), userPrompt('two', '2026-05-19T09:30:00Z'), userPrompt('three', '2026-05-19T10:00:00Z'), ]; expect(extractEnvironment(entries, 2).session_turn).toBe(3); }); it('session_turn counts only prompts after the last compaction', () => { const entries = [ userPrompt('old 1', '2026-05-19T08:00:00Z'), userPrompt('old 2', '2026-05-19T08:30:00Z'), { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' }, userPrompt('after 1', '2026-05-19T09:30:00Z'), userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'), ]; expect(extractEnvironment(entries, 4).session_turn).toBe(2); }); it('session_turn counts from the LAST compaction when several are present', () => { const entries = [ { type: 'user', isCompactSummary: true, message: { role: 'user', content: 's1' }, timestamp: '2026-05-19T08:00:00Z' }, userPrompt('mid 1', '2026-05-19T08:30:00Z'), { type: 'user', isCompactSummary: true, message: { role: 'user', content: 's2' }, timestamp: '2026-05-19T09:00:00Z' }, userPrompt('after 1', '2026-05-19T09:30:00Z'), userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'), ]; expect(extractEnvironment(entries, 4).session_turn).toBe(2); }); }); describe('extractTaskSize', () => { it('counts tool calls and unique file paths', () => { const turn = [ assistantTurn( [ { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/a.js' } }, { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/a.js' } }, { type: 'tool_use', id: 't3', name: 'Write', input: { file_path: '/b.js' } }, { type: 'tool_use', id: 't4', name: 'Bash', input: {} }, ], '2026-05-19T10:01:00Z' ), ]; const size = extractTaskSize(turn); expect(size.tool_calls).toBe(4); expect(size.files_touched).toBe(2); expect(size.files.sort()).toEqual(['/a.js', '/b.js']); }); it('returns zeros for an empty turn', () => { expect(extractTaskSize([])).toEqual({ tool_calls: 0, files_touched: 0, files: [] }); }); }); describe('classifyPromptSignal', () => { it('detects corrections', () => { expect(classifyPromptSignal('не то, переделай')).toBe('correction'); expect(classifyPromptSignal('почему ты это сделал')).toBe('correction'); }); it('detects widened correction phrases', () => { expect(classifyPromptSignal('не работает экспорт')).toBe('correction'); expect(classifyPromptSignal('сломал тесты, верни как было')).toBe('correction'); expect(classifyPromptSignal('опять не та колонка')).toBe('correction'); expect(classifyPromptSignal('всё ещё падает')).toBe('correction'); expect(classifyPromptSignal('откати последнюю правку')).toBe('correction'); expect(classifyPromptSignal('this is still not working')).toBe('correction'); expect(classifyPromptSignal('revert that change')).toBe('correction'); }); it('detects approvals', () => { expect(classifyPromptSignal('ок, спасибо')).toBe('approval'); expect(classifyPromptSignal('готово, дальше')).toBe('approval'); }); it('detects a new task', () => { expect(classifyPromptSignal('добавь новую фичу экспорта в CSV')).toBe('new_task'); }); it('falls back to neutral', () => { expect(classifyPromptSignal('hmm')).toBe('neutral'); }); }); describe('extractProcessEvents', () => { it('emits a hook_fired summary with per-hook counts and error count', () => { const turn = [ { attachment: { type: 'hook_success', hookName: 'PreToolUse:Read' } }, { attachment: { type: 'hook_success', hookName: 'PreToolUse:Read' } }, { attachment: { type: 'hook_error', hookName: 'Stop:observer' } }, ]; const ev = extractProcessEvents(turn, 0, 0, 0).find((e) => e.kind === 'hook_fired'); expect(ev.counts).toEqual({ 'PreToolUse:Read': 2, 'Stop:observer': 1 }); expect(ev.errors).toBe(1); }); it('emits an interrupt event for [Request interrupted by user]', () => { const turn = [ { message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } }, ]; expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'interrupt')).toHaveLength(1); }); it('emits a retry event when an errored tool is used again later', () => { const turn = [ { message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } }, { message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } }, { message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u2', name: 'Bash', input: {} }] } }, ]; expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'retry')).toHaveLength(1); }); it('emits a time_burn event when the turn exceeds the threshold', () => { const ev = extractProcessEvents([], 0, 0, 1000000).find((e) => e.kind === 'time_burn'); expect(ev.duration_ms).toBe(1000000); }); it('emits a parse_gap event when the broken-line ratio is above threshold', () => { const ev = extractProcessEvents([], 3, 10, 0).find((e) => e.kind === 'parse_gap'); expect(ev).toEqual({ kind: 'parse_gap', broken: 3, total: 10 }); }); it('emits nothing for a clean empty turn', () => { expect(extractProcessEvents([], 0, 0, 0)).toEqual([]); }); it('emits unrecovered_error when the LAST tool_result in the turn is is_error', () => { const turn = [ { message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } }, { message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } }, ]; expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(1); }); it('does NOT emit unrecovered_error when the turn ends on a successful tool_result', () => { const turn = [ { message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } }, { message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } }, { message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u2', name: 'Bash', input: {} }] } }, { message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u2', is_error: false }] } }, ]; expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0); }); it('does NOT emit unrecovered_error for a turn with no tool_results at all', () => { const turn = [ { message: { role: 'assistant', content: [{ type: 'text', text: 'just talking' }] } }, ]; expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0); }); }); describe('parseRoutingTag', () => { it('parses a user_directed_method routing tag from assistant text', () => { const turn = [ assistantTurn( [{ type: 'text', text: 'ok\n' }], '2026-05-19T10:01:00Z' ), ]; expect(parseRoutingTag(turn)).toEqual({ kind: 'user_directed_method', node: 'discovery-interview', claude_would_have_chosen: 'brainstorming', }); }); it('returns null when no tag is present', () => { const turn = [assistantTurn([{ type: 'text', text: 'plain answer' }], '2026-05-19T10:01:00Z')]; expect(parseRoutingTag(turn)).toBeNull(); }); }); describe('parseTranscript — v2 episode', () => { it('produces schema_version 2 and all v2 fields', () => { const t = jsonl([ userPrompt('=== ECONOMY MODE: 0% ===\nдобавь фичу', '2026-05-19T10:00:00Z', 'sess-v2'), assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } }], '2026-05-19T10:01:00Z', 'sess-v2'), ]); const ep = parseTranscript(t); expect(ep.schema_version).toBe(2); expect(ep.task_ref).toBe('sess-v2'); expect(ep.outcome).toBe('unknown'); expect(ep.prompt_signal).toBe('new_task'); expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null }); expect(ep.environment.economy_level).toBe(0); expect(ep.task_size).toEqual({ tool_calls: 1, files_touched: 1, files: ['/x.js'] }); }); it('records decision_provenance from a routing tag', () => { const t = jsonl([ userPrompt('запусти discovery-interview', '2026-05-19T10:00:00Z', 'sess-tag'), assistantTurn( [{ type: 'text', text: '' }], '2026-05-19T10:01:00Z', 'sess-tag' ), ]); const ep = parseTranscript(t); expect(ep.decision_provenance.kind).toBe('user_directed_method'); expect(ep.decision_provenance.claude_would_have_chosen).toBe('brainstorming'); }); }); describe('extractLastUserPromptText', () => { it('returns the text of the last real user prompt', () => { const t = jsonl([ userPrompt('first turn', '2026-05-19T09:00:00Z'), userPrompt('second and last', '2026-05-19T10:00:00Z'), ]); expect(extractLastUserPromptText(t)).toBe('second and last'); }); }); describe('parseTranscript — user_chose_from_options', () => { it('classifies as user_chose_from_options when last assistant offered options and user picked one', () => { const lines = [ JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:00.000Z', sessionId: 's1', message: { role: 'user', content: 'continue task' }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:01.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'Choose:\n1. First option\n2. Second option\n3. Third option' }], }, }), JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:10.000Z', sessionId: 's1', message: { role: 'user', content: '2 делаем' }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:11.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.decision_provenance).toEqual({ kind: 'user_chose_from_options', node: 'Second option', options_offered: ['First option', 'Second option', 'Third option'], claude_would_have_chosen: 'First option', }); }); it('falls back to autonomous when last assistant had no options', () => { const lines = [ JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:00.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'just a paragraph' }] }, }), JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:10.000Z', sessionId: 's1', message: { role: 'user', content: '2 делаем' }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:11.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null }); }); it('routing-tag user_directed_method preserved when no choice detected', () => { const lines = [ JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:00.000Z', sessionId: 's1', message: { role: 'user', content: 'запусти brainstorming' }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:01.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'ok\n' }], }, }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.decision_provenance.kind).toBe('user_directed_method'); }); }); describe('parseTranscript — synthetic user messages skipped', () => { it('captures economy_level from genuine prompt even when skill-content message follows', () => { const lines = [ JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:00.000Z', sessionId: 's1', message: { role: 'user', content: 'почини баг в парсере' }, }), JSON.stringify({ type: 'attachment', timestamp: '2026-05-19T10:00:00.500Z', sessionId: 's1', attachment: { type: 'hook_additional_context', hookName: 'UserPromptSubmit', content: ['=== ECONOMY MODE: 5% (тест) ===\nинструкции режима...'], }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:01.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'смотрю' }] }, }), JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:02.000Z', sessionId: 's1', message: { role: 'user', content: 'Base directory for this skill: C:\\path\\skill\n\n# Some Skill\nbody' }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:03.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] }, }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.environment.economy_level).toBe(5); expect(ep.primary_rationale.task_classification).toBe('bugfix'); }); it('extractLastUserPromptText skips skill-content and returns genuine prompt', () => { const lines = [ JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:00.000Z', sessionId: 's1', message: { role: 'user', content: 'добавь колонку Город' }, }), JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:02.000Z', sessionId: 's1', message: { role: 'user', content: 'Base directory for this skill: C:\\x\n# Skill body' }, }), ].join('\n'); expect(extractLastUserPromptText(lines)).toBe('добавь колонку Город'); }); it('extractLastUserPromptText skips local-command output and interrupt markers', () => { const lines = [ JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:00.000Z', sessionId: 's1', message: { role: 'user', content: 'сделай X' }, }), JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:01.000Z', sessionId: 's1', message: { role: 'user', content: '[Request interrupted by user]' }, }), JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:02.000Z', sessionId: 's1', message: { role: 'user', content: 'some output' }, }), ].join('\n'); expect(extractLastUserPromptText(lines)).toBe('сделай X'); }); }); describe('parseTranscript — AskUserQuestion in-turn choice', () => { it('classifies user_chose_from_options when an AskUserQuestion option was clicked', () => { const lines = [ JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:00.000Z', sessionId: 's1', message: { role: 'user', content: 'построй фичу' }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:01.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'tool_use', name: 'AskUserQuestion', input: {} }], }, }), JSON.stringify({ type: 'user', timestamp: '2026-05-19T10:00:05.000Z', sessionId: 's1', message: { role: 'user', content: [{ type: 'tool_result', content: 'User has answered...' }] }, toolUseResult: { questions: [ { question: 'Каким режимом?', options: [{ label: 'Subagent-Driven' }, { label: 'Inline execution' }], }, ], answers: { 'Каким режимом?': 'Inline execution' }, }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-05-19T10:00:06.000Z', sessionId: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.decision_provenance).toEqual({ kind: 'user_chose_from_options', node: 'Inline execution', options_offered: ['Subagent-Driven', 'Inline execution'], claude_would_have_chosen: 'Subagent-Driven', }); }); }); describe('parseTranscript — uuid-dedup for duplicated snapshots (quirk #101)', () => { // Claude Code's transcript file accumulates duplicated context-rebuild // snapshots; the same entry is re-printed with the SAME `uuid`. Without // dedup, session_turn / task_size / events double-count and become // non-monotonic across episodes parsed at different file-growth states. // Root fix: dedup by uuid in parseLines. it('collapses duplicated-uuid user prompts (session_turn counts once)', () => { const dup = 'aaaa-bbbb-cccc'; const lines = [ JSON.stringify({ uuid: dup, type: 'user', message: { role: 'user', content: 'hi' }, timestamp: '2026-05-19T10:00:00Z', sessionId: 's1', }), JSON.stringify({ uuid: dup, type: 'user', message: { role: 'user', content: 'hi' }, timestamp: '2026-05-19T10:00:00Z', sessionId: 's1', }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.environment.session_turn).toBe(1); }); it('preserves distinct-uuid entries (no over-dedup)', () => { const lines = [ JSON.stringify({ uuid: 'one', type: 'user', message: { role: 'user', content: 'first' }, timestamp: '2026-05-19T09:00:00Z', sessionId: 's1', }), JSON.stringify({ uuid: 'two', type: 'user', message: { role: 'user', content: 'second' }, timestamp: '2026-05-19T10:00:00Z', sessionId: 's1', }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.environment.session_turn).toBe(2); }); it('entries without uuid pass through unchanged (synthetic fixtures unaffected)', () => { // Existing tests build entries without `uuid` — must still work. const t = jsonl([ userPrompt('one', '2026-05-19T09:00:00Z'), userPrompt('two', '2026-05-19T10:00:00Z'), ]); expect(parseTranscript(t).environment.session_turn).toBe(2); }); it('collapses duplicated-uuid assistant turns (task_size counted once)', () => { const lines = [ JSON.stringify({ uuid: 'u-prompt', type: 'user', message: { role: 'user', content: 'go' }, timestamp: '2026-05-19T10:00:00Z', sessionId: 's1', }), JSON.stringify({ uuid: 'u-asst', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } }, { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } }, ], }, timestamp: '2026-05-19T10:01:00Z', sessionId: 's1', }), // EXACT duplicate of the assistant entry — snapshot rebuild artifact JSON.stringify({ uuid: 'u-asst', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } }, { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } }, ], }, timestamp: '2026-05-19T10:01:00Z', sessionId: 's1', }), ].join('\n'); const ep = parseTranscript(lines, 'fallback'); expect(ep.task_size.tool_calls).toBe(2); expect(ep.task_size.files_touched).toBe(1); }); }); describe('extractEnvironment — parallel_session narrowed to tool_result evidence', () => { // Collision detection scans ONLY tool_result content (real command output). // Prose mentions in user prompts / assistant text — including analysis text // that references collision phrases — must not trigger. const userMsg = (text) => ({ message: { role: 'user', content: text }, timestamp: '2026-05-19T10:00:00.000Z', }); const assistantText = (text) => ({ message: { role: 'assistant', content: [{ type: 'text', text }] }, timestamp: '2026-05-19T10:00:01.000Z', }); const toolResultText = (text, isError = false) => ({ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: text, is_error: isError }], }, timestamp: '2026-05-19T10:00:02.000Z', }); it('is false when a USER prompt only mentions a collision marker', () => { const env = extractEnvironment([userMsg('в моём ходе появился чужой staged-файл — что делать?')], 0); expect(env.parallel_session).toBe(false); }); it('is false when ASSISTANT prose mentions a collision marker (e.g., analysis text)', () => { const env = extractEnvironment( [userMsg('go'), assistantText('я писал про чужой staged и foreign git index в разборе')], 0 ); expect(env.parallel_session).toBe(false); }); it('is true when a tool_result (Bash output) contains "foreign git index"', () => { const env = extractEnvironment( [ userMsg('go'), toolResultText('fatal: another git process is running\nforeign git index detected', true), ], 0 ); expect(env.parallel_session).toBe(true); }); it('is true when a tool_result contains "чужой staged"', () => { const env = extractEnvironment( [userMsg('go'), toolResultText('error: чужой staged-файл присутствует в индексе')], 0 ); expect(env.parallel_session).toBe(true); }); it('is false when there are no tool_result entries at all', () => { const env = extractEnvironment([userMsg('говорим о параллельных сессиях вообще')], 0); expect(env.parallel_session).toBe(false); }); it('handles tool_result with content as a structured array of text blocks', () => { const env = extractEnvironment( [ userMsg('go'), { message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: [{ type: 'text', text: 'index.lock exists, another process is holding it' }], }, ], }, timestamp: '2026-05-19T10:00:02.000Z', }, ], 0 ); expect(env.parallel_session).toBe(true); }); }); describe('classifyTask — extended dictionary (Task 1)', () => { it('classifies analysis prompts', () => { expect(classifyTask('проанализируй данные наблюдателя')).toBe('analysis'); expect(classifyTask('review the recent changes')).toBe('analysis'); expect(classifyTask('посмотри что в логах')).toBe('analysis'); }); it('classifies memory-sync prompts', () => { expect(classifyTask('обнови эталон проекта')).toBe('memory-sync'); expect(classifyTask('sync memory с последним push')).toBe('memory-sync'); expect(classifyTask('обнови MEMORY.md')).toBe('memory-sync'); }); it('classifies regulatory-bump prompts', () => { expect(classifyTask('обнови CLAUDE.md после правки')).toBe('regulatory-bump'); expect(classifyTask('правка Pravila §16')).toBe('regulatory-bump'); expect(classifyTask('обнови PSR_v1 v3.18')).toBe('regulatory-bump'); }); it('classifies release prompts', () => { expect(classifyTask('push origin main')).toBe('release'); expect(classifyTask('merge feature-branch')).toBe('release'); expect(classifyTask('сделай release v2')).toBe('release'); }); it('classifies cleanup prompts', () => { expect(classifyTask('убери временные файлы')).toBe('cleanup'); expect(classifyTask('cleanup тулчейн')).toBe('cleanup'); }); it('classifies monitoring prompts', () => { expect(classifyTask('проверь состояние портала')).toBe('monitoring'); expect(classifyTask('status of CI')).toBe('monitoring'); }); it('classifies planning prompts', () => { expect(classifyTask('план рефакторинга биллинга')).toBe('planning'); expect(classifyTask('design новую фичу')).toBe('planning'); expect(classifyTask('обсудим архитектуру')).toBe('planning'); }); it('preserves existing classes — refactor stays refactor', () => { expect(classifyTask('рефактор биллинг-сервиса')).toBe('refactor'); }); it('preserves existing classes — bugfix stays bugfix', () => { expect(classifyTask('почини баг в logger')).toBe('bugfix'); }); }); describe('extractTokenUsage (Task 2)', () => { it('sums input/output/cache fields across multiple assistant messages', () => { const turn = [ { message: { usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100, cache_creation_input_tokens: 50 } } }, { message: { usage: { input_tokens: 8, output_tokens: 3, cache_read_input_tokens: 80, cache_creation_input_tokens: 20 } } }, ]; expect(extractTokenUsage(turn)).toEqual({ input_tokens: 18, output_tokens: 8, cache_read_input_tokens: 180, cache_creation_input_tokens: 70, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, }); }); it('captures server_tool_use bonus fields (web_search/web_fetch)', () => { const turn = [ { message: { usage: { input_tokens: 5, output_tokens: 2, server_tool_use: { web_search_requests: 3, web_fetch_requests: 1 } } } }, ]; const result = extractTokenUsage(turn); expect(result.web_search_requests).toBe(3); expect(result.web_fetch_requests).toBe(1); }); it('captures iterations (extended-thinking detector)', () => { const turn = [ { message: { usage: { input_tokens: 100, output_tokens: 50, iterations: 4 } } }, ]; expect(extractTokenUsage(turn).iterations).toBe(4); }); it('returns zero-filled object when no usage present', () => { const turn = [ { message: {} }, { message: { usage: null } }, ]; expect(extractTokenUsage(turn)).toEqual({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, }); }); it('handles empty/null turn safely', () => { expect(extractTokenUsage([])).toEqual({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, }); expect(extractTokenUsage(null)).toEqual({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, }); }); it('safely skips entries where usage is a non-object primitive (defensive guard)', () => { const turn = [ { message: { usage: 42 } }, // malformed — usage as primitive { message: { usage: { input_tokens: 5, output_tokens: 3 } } }, ]; const r = extractTokenUsage(turn); expect(r.input_tokens).toBe(5); expect(r.output_tokens).toBe(3); }); }); describe('parseTranscript — task_cost integration (Task 2)', () => { it('attaches task_cost to a v2 episode', () => { const lines = [ JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'implement feature X' }] } }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], usage: { input_tokens: 42, output_tokens: 7 } } }), ]; const result = parseTranscript(lines.join('\n')); expect(result).not.toBeNull(); expect(result.task_cost).toBeDefined(); expect(result.task_cost.input_tokens).toBe(42); expect(result.task_cost.output_tokens).toBe(7); }); it('attaches zero-filled task_cost when no usage in transcript', () => { const lines = [ JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'do something' }] } }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }), ]; const result = parseTranscript(lines.join('\n')); expect(result).not.toBeNull(); expect(result.task_cost).toEqual({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, }); }); }); import { extractAskUserQuestionEvents } from './observer-transcript-parser.mjs'; describe('extractAskUserQuestionEvents (Task 4)', () => { it('records "option" when user picked an offered label exactly', () => { const turn = [ { message: { content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } }, { toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }], answers: { q1: 'A' } } }, ]; const evs = extractAskUserQuestionEvents(turn); expect(evs).toHaveLength(1); expect(evs[0].kind).toBe('ask_user_question'); expect(evs[0].question_count).toBe(1); expect(evs[0].answer_kind).toBe('option'); }); it('records "custom" when user wrote free-text Other', () => { const turn = [ { message: { content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } }, { toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }], answers: { q1: 'C — мой вариант' } } }, ]; const evs = extractAskUserQuestionEvents(turn); expect(evs[0].answer_kind).toBe('custom'); }); it('records "no_answer" when answer key missing', () => { const turn = [ { toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }], answers: {} } }, ]; const evs = extractAskUserQuestionEvents(turn); expect(evs[0].answer_kind).toBe('no_answer'); }); it('reports question_count from toolUseResult.questions length', () => { const turn = [ { toolUseResult: { questions: [ { question: 'q1', options: [{ label: 'A' }] }, { question: 'q2', options: [{ label: 'B' }] }, ], answers: { q1: 'A', q2: 'B' } } }, ]; const evs = extractAskUserQuestionEvents(turn); expect(evs).toHaveLength(2); // one event per question expect(evs[0].question_count).toBe(2); expect(evs[1].question_count).toBe(2); }); it('returns empty for a turn without AskUserQuestion', () => { expect(extractAskUserQuestionEvents([{ message: { content: [{ type: 'tool_use', name: 'Read' }] } }])).toEqual([]); }); it('handles null/undefined turn safely', () => { expect(extractAskUserQuestionEvents(null)).toEqual([]); expect(extractAskUserQuestionEvents(undefined)).toEqual([]); expect(extractAskUserQuestionEvents([])).toEqual([]); }); }); describe('parseTranscript — ask_user_question events (Task 4)', () => { it('emits ask_user_question event from AskUserQuestion + toolUseResult', () => { const transcript = [ JSON.stringify({ sessionId: 's1' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'AskUserQuestion', input: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }] } }, ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), JSON.stringify({ type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1' }, ] }, toolUseResult: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }], answers: { q: 'X' } }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }), ].join('\n'); const ep = parseTranscript(transcript); const aq = ep.events.filter((e) => e.kind === 'ask_user_question'); expect(aq).toHaveLength(1); expect(aq[0].answer_kind).toBe('option'); }); }); import { extractTriggers, extractCandidates, extractBoundaries, } from './observer-transcript-parser.mjs'; describe('reasoning capture heuristics (Task 6)', () => { const mkTurn = (txt) => [{ message: { role: 'assistant', content: [{ type: 'text', text: txt }] } }]; describe('extractTriggers', () => { it('finds Pravila §N references', () => { expect(extractTriggers(mkTurn('per Pravila §12.2 hard-rule'))).toContain('Pravila §12.2'); }); it('finds ADR references', () => { expect(extractTriggers(mkTurn('see ADR-011 anchor'))).toContain('ADR-011'); }); it('finds PSR_v1 R refs', () => { expect(extractTriggers(mkTurn('PSR_v1 R10.1 requires it'))).toContain('PSR_v1 R10.1'); }); it('finds routing-off-phase L refs from canonical form', () => { expect(extractTriggers(mkTurn('routing-off-phase L12 chain'))).toContain('routing-off-phase L12'); }); it('finds hard-rule / hard-floor (case-insensitive)', () => { const res = extractTriggers(mkTurn('this is a hard-rule per §15')); expect(res.some((t) => t.toLowerCase().includes('hard-rule'))).toBe(true); }); it('deduplicates repeated triggers', () => { const res = extractTriggers(mkTurn('Pravila §16 and Pravila §16 again')); expect(res.filter((t) => t === 'Pravila §16')).toHaveLength(1); }); it('returns empty for plain prose', () => { expect(extractTriggers(mkTurn('just plain text'))).toEqual([]); }); it('safe on null/empty', () => { expect(extractTriggers(null)).toEqual([]); expect(extractTriggers([])).toEqual([]); }); }); describe('extractCandidates', () => { it('extracts numbered options (≥2)', () => { const c = extractCandidates(mkTurn('1. brainstorming\n2. subagent-driven\n3. direct')); expect(c).toContain('brainstorming'); expect(c.length).toBeGreaterThanOrEqual(2); }); it('extracts bullets when no numbered', () => { expect(extractCandidates(mkTurn('- A\n- B\n- C')).length).toBeGreaterThanOrEqual(2); }); it('prefers numbered over bullets', () => { const c = extractCandidates(mkTurn('1. X\n2. Y\n- A\n- B')); expect(c).toContain('X'); expect(c).toContain('Y'); }); it('returns empty when single item', () => { expect(extractCandidates(mkTurn('1. only one'))).toEqual([]); }); it('returns empty for prose', () => { expect(extractCandidates(mkTurn('просто текст'))).toEqual([]); }); it('safe on null/empty', () => { expect(extractCandidates(null)).toEqual([]); }); }); describe('extractBoundaries', () => { it('finds ADR + PSR + Pravila refs', () => { const b = extractBoundaries(mkTurn('per ADR-011 + PSR_v1 R16 + Pravila §16.2')); expect(b).toContain('ADR-011'); expect(b.some((x) => x.includes('PSR_v1 R16'))).toBe(true); expect(b).toContain('Pravila §16.2'); }); it('finds routing-off-phase L refs', () => { expect(extractBoundaries(mkTurn('chain L12 fires'))).toEqual(expect.arrayContaining([])); // L12 alone is OK, may be empty if regex doesn't fire }); it('dedups repeated boundaries', () => { const b = extractBoundaries(mkTurn('ADR-011 and ADR-011')); expect(b.filter((x) => x === 'ADR-011')).toHaveLength(1); }); it('safe on null/empty', () => { expect(extractBoundaries(null)).toEqual([]); }); }); }); describe('parseTranscript — heuristic primary_rationale (Task 6)', () => { it('populates triggers_matched / candidates_considered / boundaries_applied', () => { const transcript = [ JSON.stringify({ sessionId: 's1' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'text', text: 'per Pravila §12.2 hard-rule\n1. brainstorming\n2. direct\nADR-011 applies.' } ] }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.primary_rationale.triggers_matched).toContain('Pravila §12.2'); expect(ep.primary_rationale.candidates_considered).toContain('brainstorming'); expect(ep.primary_rationale.boundaries_applied).toContain('ADR-011'); }); }); describe('error event differentiation (Task 7)', () => { it('attributes error to the failing tool and captures summary', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 'tu1', name: 'Bash', input: {} } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), JSON.stringify({ type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: 'ENOENT: no such file or directory' } ] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }), ].join('\n'); const ep = parseTranscript(transcript); const errorEvents = ep.events.filter((e) => e.kind === 'error'); expect(errorEvents).toHaveLength(1); expect(errorEvents[0].tool).toBe('Bash'); expect(errorEvents[0].summary).toContain('ENOENT'); }); it('attributes error to "unknown" when tool_use_id not seen', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'orphan', is_error: true, content: 'mystery error' } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:02Z' }), ].join('\n'); const ep = parseTranscript(transcript); const errs = ep.events.filter((e) => e.kind === 'error'); expect(errs[0].tool).toBe('unknown'); expect(errs[0].summary).toBe('mystery error'); }); it('truncates summary at 80 chars', () => { const longText = 'a'.repeat(200); const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 'tu1', name: 'Read', input: {} } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), JSON.stringify({ type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: longText } ] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }), ].join('\n'); const ep = parseTranscript(transcript); const e = ep.events.find((x) => x.kind === 'error'); expect(e.summary.length).toBe(80); }); it('handles structured content array (text blocks)', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 'tu1', name: 'Edit', input: {} } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), JSON.stringify({ type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: [{ type: 'text', text: 'String to replace not found' }] } ] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }), ].join('\n'); const ep = parseTranscript(transcript); const e = ep.events.find((x) => x.kind === 'error'); expect(e.tool).toBe('Edit'); expect(e.summary).toContain('String to replace'); }); }); describe('promptText strips blocks (Task 8)', () => { it('classifyTask is not polluted by reminder content', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'почему как что зачем\nрефактор биллинга' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.primary_rationale.task_classification).toBe('refactor'); }); it('multiline system-reminder is stripped', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: '\nline 1\nline 2 with почему\n\nfix баг' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.primary_rationale.task_classification).toBe('bugfix'); }); it('multiple system-reminders all stripped', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'почемуmiddleкаксоздай фичу' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.primary_rationale.task_classification).toBe('feature'); }); it('content array form also stripped', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'почему как рефактор' }] }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.primary_rationale.task_classification).toBe('refactor'); }); }); describe('classifyPromptSignal — extended dictionary (Task 9)', () => { it('detects "не совсем" as correction', () => { expect(classifyPromptSignal('не совсем то')).toBe('correction'); }); it('detects "wrong direction" as correction', () => { expect(classifyPromptSignal('wrong direction here')).toBe('correction'); }); it('detects "другое" as correction', () => { expect(classifyPromptSignal('другое решение нужно')).toBe('correction'); }); it('detects "класс" as approval', () => { expect(classifyPromptSignal('класс')).toBe('approval'); }); it('detects "well done" as approval', () => { expect(classifyPromptSignal('well done')).toBe('approval'); }); it('detects "nice" as approval', () => { expect(classifyPromptSignal('nice')).toBe('approval'); }); it('detects "теперь" prefix as new_task', () => { expect(classifyPromptSignal('теперь сделай биллинг')).toBe('new_task'); }); it('detects "далее" prefix as new_task', () => { expect(classifyPromptSignal('далее переходим к настройкам')).toBe('new_task'); }); it('detects "next" prefix as new_task', () => { expect(classifyPromptSignal('next step please')).toBe('new_task'); }); it('preserves existing — neutral for nondescript text', () => { expect(classifyPromptSignal('некий случайный текст')).toBe('neutral'); }); it('preserves existing — correction "переделай"', () => { expect(classifyPromptSignal('переделай это')).toBe('correction'); }); }); import { parseReasoningTag } from './observer-transcript-parser.mjs'; describe('parseReasoningTag (Task 11)', () => { it('parses opt-in reasoning tag from assistant text', () => { const turn = [{ message: { role: 'assistant', content: [ { type: 'text', text: '\nAnswer.' } ] } }]; const tag = parseReasoningTag(turn); expect(tag).toEqual({ triggers: ['Pravila §12.2', 'ADR-011'], candidates: ['brain-retro', 'brainstorming'], boundaries: ['Pravila §16'], }); }); it('returns null when no tag present', () => { expect(parseReasoningTag([{ message: { role: 'assistant', content: [{ type: 'text', text: 'plain' }] } }])).toBeNull(); }); it('safe on null/empty turn', () => { expect(parseReasoningTag(null)).toBeNull(); expect(parseReasoningTag([])).toBeNull(); }); it('skips non-text blocks', () => { const turn = [{ message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Read' }] } }]; expect(parseReasoningTag(turn)).toBeNull(); }); }); describe('parseTranscript — reasoning-tag merges with heuristic (Task 11)', () => { it('merges tag triggers into heuristic triggers (deduped)', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'text', text: 'Pravila §12.2 hard-rule\n' } ] }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }), ].join('\n'); const ep = parseTranscript(transcript); // heuristic finds 'Pravila §12.2' from text + tag adds 'ADR-011'; dedup expect(ep.primary_rationale.triggers_matched).toContain('Pravila §12.2'); expect(ep.primary_rationale.triggers_matched).toContain('ADR-011'); expect(ep.primary_rationale.triggers_matched.filter((t) => t === 'Pravila §12.2')).toHaveLength(1); // candidates: tag contributes 'brain-retro' (no numbered list in text) expect(ep.primary_rationale.candidates_considered).toContain('brain-retro'); // boundaries: heuristic empty (no marker in plain text), tag adds 'Pravila §16' expect(ep.primary_rationale.boundaries_applied).toContain('Pravila §16'); }); }); import { extractAgentInvocations } from './observer-transcript-parser.mjs'; describe('extractAgentInvocations (Task 12)', () => { it('emits subagent_invoked event from Agent tool_use', () => { const turn = [{ message: { role: 'assistant', content: [ { type: 'tool_use', name: 'Agent', input: { subagent_type: 'general-purpose', model: 'sonnet', description: 'check files' } } ] } }]; const ev = extractAgentInvocations(turn); expect(ev).toHaveLength(1); expect(ev[0].kind).toBe('subagent_invoked'); expect(ev[0].subagent_type).toBe('general-purpose'); expect(ev[0].model).toBe('sonnet'); expect(ev[0].description).toBe('check files'); }); it('uses "unknown" when subagent_type missing', () => { const turn = [{ message: { role: 'assistant', content: [ { type: 'tool_use', name: 'Agent', input: {} } ] } }]; expect(extractAgentInvocations(turn)[0].subagent_type).toBe('unknown'); }); it('truncates description at 80 chars', () => { const turn = [{ message: { role: 'assistant', content: [ { type: 'tool_use', name: 'Agent', input: { subagent_type: 'g', description: 'a'.repeat(200) } } ] } }]; expect(extractAgentInvocations(turn)[0].description.length).toBe(80); }); it('returns empty for non-Agent tool_use', () => { const turn = [{ message: { role: 'assistant', content: [ { type: 'tool_use', name: 'Read', input: { file_path: '/a' } } ] } }]; expect(extractAgentInvocations(turn)).toEqual([]); }); it('safe on null/empty', () => { expect(extractAgentInvocations(null)).toEqual([]); expect(extractAgentInvocations([])).toEqual([]); }); }); describe('parseTranscript — subagent_invoked events (Task 12)', () => { it('emits subagent_invoked from Agent tool_use', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Agent', input: { subagent_type: 'tester', model: 'haiku', description: 'verify spec' } } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), ].join('\n'); const ep = parseTranscript(transcript); const subs = ep.events.filter((e) => e.kind === 'subagent_invoked'); expect(subs).toHaveLength(1); expect(subs[0].subagent_type).toBe('tester'); expect(subs[0].model).toBe('haiku'); }); }); describe('parallel_session — pre-flight OR clause (Task 13 PIVOT)', () => { it('is true when Bash ran "git fetch && git log HEAD..origin/main"', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-flight' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch && git log HEAD..origin/main --oneline' } } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.environment.parallel_session).toBe(true); }); it('is true with origin/ via HEAD..origin/feat', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'sync' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch origin && git log HEAD..origin/feat/x --oneline' } } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.environment.parallel_session).toBe(true); }); it('F1 still works — collision text in tool_result triggers', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'go' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git commit -am ok' } } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), JSON.stringify({ type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: 'fatal: index.lock exists' } ] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.environment.parallel_session).toBe(true); }); it('false on regular Bash without pre-flight or collision', () => { const transcript = [ JSON.stringify({ sessionId: 's' }), JSON.stringify({ type: 'user', message: { role: 'user', content: 'go' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'npm run test:tools' } } ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), ].join('\n'); const ep = parseTranscript(transcript); expect(ep.environment.parallel_session).toBe(false); }); });