import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { appendEpisode, buildEpisodeFromContext } from './observer-stop-hook.mjs'; let workdir; beforeEach(() => { workdir = mkdtempSync(join(tmpdir(), 'observer-test-')); mkdirSync(join(workdir, 'docs', 'observer'), { recursive: true }); }); afterEach(() => { rmSync(workdir, { recursive: true, force: true }); }); // Helper for v1.1 — primary_rationale fixture (factor analysis amendment) const defaultRat = () => ({ step: 1, node_chosen: '#1', triggers_matched: [], candidates_considered: [], boundaries_applied: [], hard_floor: { invoked: false, rules: [] }, task_classification: 'other', }); describe('appendEpisode', () => { it('appends one JSONL line to monthly file', () => { const ep = { task_id: 'abc-123', timestamps: { started_at: '2026-05-19T10:00:00+03:00', ended_at: '2026-05-19T10:05:00+03:00' }, path_type: 'regulated', outcome: 'success', primary_rationale: defaultRat(), }; appendEpisode(ep, workdir, '2026-05'); const file = join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'); const content = readFileSync(file, 'utf-8'); expect(content).toContain('"task_id":"abc-123"'); expect(content).toContain('"primary_rationale"'); expect(content.endsWith('\n')).toBe(true); }); it('appends to existing file without overwrite', () => { appendEpisode({ task_id: 'a', timestamps: {}, path_type: 'regulated', outcome: 'success', primary_rationale: defaultRat() }, workdir, '2026-05'); appendEpisode({ task_id: 'b', timestamps: {}, path_type: 'improvised', outcome: 'partial', primary_rationale: defaultRat() }, workdir, '2026-05'); const lines = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim().split('\n'); expect(lines).toHaveLength(2); expect(JSON.parse(lines[0]).task_id).toBe('a'); expect(JSON.parse(lines[1]).task_id).toBe('b'); }); it('applies PII filter before write (including events[])', () => { appendEpisode({ task_id: 'c', timestamps: {}, path_type: 'regulated', outcome: 'success', primary_rationale: defaultRat(), events: [{ kind: 'error', message: 'call +79991234567 / mail x@y.com' }], }, workdir, '2026-05'); const content = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8'); expect(content).toContain('+7XXXXXXXXXX'); expect(content).toContain('***@***'); expect(content).not.toContain('79991234567'); }); it('throws on missing required top-level fields', () => { expect(() => appendEpisode({}, workdir, '2026-05')).toThrow(/required/i); expect(() => appendEpisode({ task_id: 'x' }, workdir, '2026-05')).toThrow(/required/i); expect(() => appendEpisode({ task_id: 'x', timestamps: {}, path_type: 'regulated', outcome: 'success' }, workdir, '2026-05')).toThrow(/primary_rationale/i); }); it('throws when primary_rationale field is missing', () => { const ep = { task_id: 'd', timestamps: {}, path_type: 'regulated', outcome: 'success', primary_rationale: { step: 1, node_chosen: '#1' }, // missing other 5 fields }; expect(() => appendEpisode(ep, workdir, '2026-05')).toThrow(/primary_rationale field missing/i); }); it('persists routing_decision events with structured fields', () => { appendEpisode({ task_id: 'e', timestamps: {}, path_type: 'regulated', outcome: 'success', primary_rationale: defaultRat(), events: [ { kind: 'routing_decision', step: 1, node_chosen: '#55', triggers_matched: ['discovery'], candidates_considered: [{ node_id: '#53', dropped_because: 'ADR-009' }], boundaries_applied: ['ADR-009'], hard_floor: { invoked: false, rules: [] }, task_classification: 'discovery' }, ], }, workdir, '2026-05'); const line = JSON.parse(readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim()); expect(line.events[0].kind).toBe('routing_decision'); expect(line.events[0].triggers_matched).toEqual(['discovery']); expect(line.events[0].candidates_considered[0].dropped_because).toBe('ADR-009'); expect(line.events[0].boundaries_applied).toEqual(['ADR-009']); }); }); describe('buildEpisodeFromContext', () => { it('extracts 5 mandatory fields from context object', () => { const ctx = { sessionId: 'sess-1', started: '2026-05-19T09:00:00+03:00', ended: '2026-05-19T09:30:00+03:00', result: 'success', }; const ep = buildEpisodeFromContext(ctx); expect(ep.task_id).toBe('sess-1'); expect(ep.timestamps.started_at).toBe(ctx.started); expect(ep.outcome).toBe('success'); expect(['regulated', 'improvised', 'alternative', 'mixed']).toContain(ep.path_type); expect(ep.primary_rationale).toBeDefined(); expect(ep.primary_rationale.step).toBe(1); expect(ep.primary_rationale.hard_floor).toEqual({ invoked: false, rules: [] }); }); it('preserves user-provided primary_rationale unchanged', () => { const rat = { step: 1, node_chosen: '#55', triggers_matched: ['discovery'], candidates_considered: [], boundaries_applied: ['ADR-009'], hard_floor: { invoked: true, rules: ['Pravila §12'] }, task_classification: 'discovery', }; const ep = buildEpisodeFromContext({ sessionId: 'x', primary_rationale: rat }); expect(ep.primary_rationale).toEqual(rat); }); it('derives the episode from transcriptText when provided', () => { const transcript = [ JSON.stringify({ type: 'user', message: { role: 'user', content: 'fix the bug' }, timestamp: '2026-05-19T10:00:00Z', sessionId: 'sess-t', }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }, ], }, timestamp: '2026-05-19T10:01:00Z', sessionId: 'sess-t', }), ].join('\n'); const ep = buildEpisodeFromContext({ session_id: 'sess-t' }, transcript); expect(ep.task_id).toBe('sess-t'); expect(ep.primary_rationale.node_chosen).toBe('superpowers:systematic-debugging'); expect(ep.primary_rationale.hard_floor.invoked).toBe(true); expect(ep.events.some((e) => e.kind === 'skill_invoked')).toBe(true); }); });