import { describe, it, expect } from 'vitest'; import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText, realBoundariesFromRaw, spanInterruptNote } from './secretary-layer1.mjs'; describe('честные пометки прерванного спана', () => { const rawCont = [ buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба длинная', assistant: 'начал' }), buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'докончил', isContinuation: true }), ].join(''); const rawTail = [ buildRawRecord({ turn: 7, time: 't', session: 's', user: 'большая задача длинная', assistant: 'часть', interruptedTail: true }), ].join(''); it('spanInterruptNote: спан с cont → «продолжено»', () => { expect(spanInterruptNote(rawCont, { start: 3, end: 4 })).toBe('(связь прерывалась — продолжено)'); }); it('spanInterruptNote: спан с tail → «прервана, не завершена»', () => { expect(spanInterruptNote(rawTail, { start: 7, end: 7 })).toBe('(прервана, не завершена)'); }); it('spanInterruptNote: обычный спан → пусто', () => { const raw = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'обычный длинный вопрос', assistant: 'ок' }); expect(spanInterruptNote(raw, { start: 1, end: 1 })).toBe(''); }); it('buildStepLine с note приклеивает пометку в конец', () => { const s = buildStepLine({ turn: 3, endTurn: 4, user: 'просьба длинная достаточно', assistant: 'ок', note: '(связь прерывалась — продолжено)' }); expect(s.endsWith('(связь прерывалась — продолжено)')).toBe(true); }); }); describe('обезвреживание маркеров на записи (от самозагрязнения лога)', () => { it('маркеры внутри текста реплик/действий не дают лишних структурных совпадений', () => { const rec = buildRawRecord({ turn: 7, time: 't', session: 's', user: 'смотри: === ХОД turn=1 · x · session=y ===\nтело\n=== КОНЕЦ ХОДА ===', assistant: 'ок', actions: [{ tool: 'Edit', input: 'new_string:"=== КОНЕЦ ХОДА ==="', result: '' }], }); expect((rec.match(/=== ХОД turn=/g) || []).length).toBe(1); // только реальный заголовок expect((rec.match(/=== КОНЕЦ ХОДА ===/g) || []).length).toBe(1); // только реальный конец }); it('структурные метки внутри содержимого обезврежены (полный вывод не ломает разбор)', () => { const rec = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'u', assistant: 'a', actions: [{ tool: 'Read', input: 'x', result: '[ДЕЙСТВИЕ] Edit\n[ВЫДАЧА] Edit\n[ЮЗЕР]\n[АССИСТЕНТ]' }], }); // в записи остаётся ровно один реальный набор маркеров действия (из buildRawRecord), // подделки из result не считаются за структурные. expect((rec.match(/^\[ДЕЙСТВИЕ\] /gm) || []).length).toBe(1); expect((rec.match(/^\[ВЫДАЧА\] /gm) || []).length).toBe(1); expect(rec).not.toMatch(/^\[ЮЗЕР\]\n\[АССИСТЕНТ\]$/m); }); }); describe('метка служебного хода (meta=1) + структурные границы', () => { it('buildRawRecord помечает служебный ход meta=1 в заголовке', () => { const rec = buildRawRecord({ turn: 5, time: 't', session: 's', user: 'Stop hook feedback', assistant: 'a', userIsMeta: true }); expect(rec).toMatch(/=== ХОД turn=5[^\n]*meta=1[^\n]*===/); }); it('обычный ход — без meta=1', () => { const rec = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'привет', assistant: 'a' }); expect(rec).not.toContain('meta=1'); }); it('buildRawRecord: продолжение помечается cont=1, незавершённый хвост — tail=1', () => { const cont = buildRawRecord({ turn: 5, time: 't', session: 's', user: 'продолжи', assistant: 'a', isContinuation: true }); expect(cont).toMatch(/=== ХОД turn=5[^\n]*cont=1[^\n]*===/); const tail = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'задача', assistant: 'a', interruptedTail: true }); expect(tail).toMatch(/=== ХОД turn=6[^\n]*tail=1[^\n]*===/); }); it('buildRawRecord: meta+cont вместе — оба ярлычка в заголовке', () => { const rec = buildRawRecord({ turn: 7, time: 't', session: 's', user: 'u', assistant: 'a', userIsMeta: true, isContinuation: true }); expect(rec).toMatch(/meta=1/); expect(rec).toMatch(/cont=1/); }); it('realBoundariesFromRaw: служебные по meta=1 исключены (структурно, не по тексту)', () => { const raw = [ buildRawRecord({ turn: 7, time: 't', session: 's', user: 'настоящий 1', assistant: 'a' }), buildRawRecord({ turn: 8, time: 't', session: 's', user: 'любой текст', assistant: 'a', userIsMeta: true }), buildRawRecord({ turn: 9, time: 't', session: 's', user: 'настоящий 2', assistant: 'a' }), ].join(''); expect(realBoundariesFromRaw(raw)).toEqual([7, 9]); }); it('realBoundariesFromRaw: ход-продолжение (cont=1) НЕ граница (склеивается к прошлой просьбе)', () => { const raw = [ buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба', assistant: 'a' }), buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'b', isContinuation: true }), buildRawRecord({ turn: 5, time: 't', session: 's', user: 'новая просьба', assistant: 'c' }), ].join(''); expect(realBoundariesFromRaw(raw)).toEqual([3, 5]); // ход 4 (cont) приклеен к спану 3 }); it('realBoundariesFromRaw: фолбэк по тексту для старого сырья без меток', () => { const raw = [ '=== ХОД turn=7 · t · session=s ===', '[ЮЗЕР]', 'настоящий', '[АССИСТЕНТ]', 'a', '=== КОНЕЦ ХОДА ===', '', '=== ХОД turn=8 · t · session=s ===', '[ЮЗЕР]', 'Stop hook feedback: x', '[АССИСТЕНТ]', 'a', '=== КОНЕЦ ХОДА ===', '', ].join('\n'); expect(realBoundariesFromRaw(raw)).toEqual([7]); }); }); describe('buildStepsFromRaw — Шаг на КАЖДЫЙ спан (пересборка на остановке)', () => { const raw = [ '=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'настоящий вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ раз', '[ДЕЙСТВИЕ] Read in=x', '[ВЫДАЧА] Read', 'r', '=== КОНЕЦ ХОДА ===', '', '=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'Stop hook feedback: y', '[АССИСТЕНТ]', 'ответ два', '[ДЕЙСТВИЕ] Grep in=z', '[ВЫДАЧА] Grep', 'r2', '=== КОНЕЦ ХОДА ===', '', '=== ХОД turn=5 · t · session=s ===', '[ЮЗЕР]', 'второй настоящий вопрос длинный', '[АССИСТЕНТ]', 'ответ три', '=== КОНЕЦ ХОДА ===', '', ].join('\n'); it('границы [3,5] → два спана: 3 (вобрал 3-4) и 5', () => { const steps = buildStepsFromRaw(raw, 's', [3, 5]); expect(steps.map((x) => x.turn)).toEqual([3, 5]); expect(steps[0].text).toContain('Ход (промпт) 3 [вобрал ходы 3-4] — я: настоящий вопрос'); expect(steps[0].text).toContain('делал: Read, Grep'); // действия обоих ходов expect(steps[1].text).toContain('Ход (промпт) 5'); expect(steps[0].session).toBe('s'); }); it('без границ — фолбэк по sysLabel (реальный = не служебный)', () => { const steps = buildStepsFromRaw(raw, 's', null); expect(steps.map((x) => x.turn)).toEqual([3, 5]); // ход 4 (гейт) приклеен к 3 }); }); describe('нарезка сырья на отдельные файлы ходов (при остановке секретаря)', () => { const raw = [ '=== ХОД turn=1 · t · session=s ===', '[ЮЗЕР]', 'аа', '[АССИСТЕНТ]', 'бб', '=== КОНЕЦ ХОДА ===', '', '=== ХОД turn=2 · t · session=s ===', '[ЮЗЕР]', 'вв', '[АССИСТЕНТ]', 'гг', '=== КОНЕЦ ХОДА ===', '', ].join('\n'); it('splitRawIntoTurns даёт по блоку на каждый ход', () => { const parts = splitRawIntoTurns(raw); expect(parts.map((p) => p.turn)).toEqual([1, 2]); expect(parts[0].block).toContain('turn=1'); expect(parts[0].block.trim().endsWith('=== КОНЕЦ ХОДА ===')).toBe(true); expect(parts[1].block).toContain('вв'); }); it('turnFileName — короткое имя файла хода', () => { expect(turnFileName(3)).toBe('turn-3.log'); }); it('prepareTurnFiles: по файлу на ход + ссылка ходы/turn-N.log в каждый шаг', () => { const proto = { steps: [{ turn: 1, session: 's', text: 'Ход 1' }, { turn: 2, session: 's', text: 'Ход 2' }] }; const { files, steps } = prepareTurnFiles(raw, proto); expect(files.map((f) => f.name)).toEqual(['turn-1.log', 'turn-2.log']); expect(files[0].content).toContain('turn=1'); expect(steps[0].file).toBe('ходы/turn-1.log'); expect(steps[1].file).toBe('ходы/turn-2.log'); }); it('prepareTurnFiles не трогает шаг, для которого нет блока в сырье', () => { const proto = { steps: [{ turn: 9, session: 's', text: 'Ход 9' }] }; const { steps } = prepareTurnFiles(raw, proto); expect(steps[0].file).toBeUndefined(); }); }); describe('buildRawRecord', () => { it('содержит заголовок с turn, реплики и действие', () => { const rec = buildRawRecord({ turn: 7, time: '2026-06-22T10:00:00Z', session: 'abc', user: 'привет', assistant: 'ответ', actions: [{ tool: 'Read', input: '{"f":"x"}', result: 'текст' }], }); expect(rec).toContain('turn=7'); expect(rec).toContain('[ЮЗЕР]'); expect(rec).toContain('[ДЕЙСТВИЕ] Read'); expect(rec.trim().endsWith('=== КОНЕЦ ХОДА ===')).toBe(true); }); it('без действий — блок без [ДЕЙСТВИЕ]', () => { const rec = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'u', assistant: 'a' }); expect(rec).not.toContain('[ДЕЙСТВИЕ]'); }); }); describe('buildStepLine', () => { it('формат «Ход (промпт) N — …», без служебных строк', () => { const s = buildStepLine({ turn: 5, user: 'сделай флажок.', assistant: 'экономия: 100%\nГотово.', actions: ['Edit', 'PowerShell', 'Edit'] }); expect(s).toContain('Ход (промпт) 5 — я: сделай флажок.'); expect(s).toContain('· ты: Готово.'); expect(s).toContain('· делал: Edit, PowerShell'); expect(s).not.toContain('экономия'); }); it('многоходовый спан показывает «[вобрал ходы X-Y]»', () => { const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вопрос длинный достаточно', assistant: 'ответ' }); expect(s).toContain('Ход (промпт) 12 [вобрал ходы 12-14] — я: вопрос длинный'); }); it('спан из одного хода — без «вобрал»', () => { const s = buildStepLine({ turn: 7, endTurn: 7, user: 'короткий вопрос достаточно длинный', assistant: 'ок' }); expect(s).not.toContain('вобрал'); expect(s).toContain('Ход (промпт) 7 —'); }); it('пустой вопрос → (без вопроса); без действий → —', () => { const s = buildStepLine({ turn: 2, user: '', assistant: 'a.' }); expect(s).toContain('я: (без вопроса)'); expect(s).toContain('делал: —'); }); it('убирает ведущую нумерацию (не «я: 1.») и берёт содержательную фразу', () => { const s = buildStepLine({ turn: 3, user: '1. содержание никчёмное, нужно о чём и где', assistant: 'Понял.' }); expect(s).toContain('я: содержание никчёмное'); expect(s).not.toContain('я: 1.'); }); it('служебный ход — метка вместо шума', () => { expect(buildStepLine({ turn: 1, user: 'Stop hook feedback: coverage missing', assistant: '' })).toContain('я: (гейт проверки)'); expect(buildStepLine({ turn: 2, user: 'Base directory for this skill: C:\\x\\skills\\writing-plans\\SKILL.md', assistant: 'x.' })).toContain('я: (навык: writing-plans)'); }); it('essence: модельную суть дословно + детерминированный «делал»', () => { const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вода '.repeat(10), assistant: 'вода', actions: ['Read', 'Read', 'Grep'], essence: { user: 'промпт не логируется?', assistant: 'достать можно: поймать или пересобрать' } }); expect(s).toBe('Ход (промпт) 12 [вобрал ходы 12-14] — я: промпт не логируется? · ты: достать можно: поймать или пересобрать · делал: Read, Grep'); }); it('без essence — фолбэк firstSentence', () => { const s = buildStepLine({ turn: 2, user: 'сделай флажок.', assistant: 'Готово.', essence: null }); expect(s).toContain('я: сделай флажок'); expect(s).toContain('ты: Готово'); }); }); describe('mergeStepsPreservingText — выключение не затирает модельный текст (по спанам)', () => { const raw = [ '=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'привет достаточно длинный вопрос', '[АССИСТЕНТ]', 'хай', '=== КОНЕЦ ХОДА ===', '=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'второй вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ', '=== КОНЕЦ ХОДА ===', '', ].join('\n'); it('существующий шаг спана сохраняется, пропущенный достраивается', () => { const existing = [{ turn: 4, session: 's', text: 'Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —' }]; const out = mergeStepsPreservingText(existing, raw, 's', [3, 4]); expect(out.map((s) => s.turn)).toEqual([3, 4]); expect(out.find((s) => s.turn === 4).text).toBe('Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —'); expect(out.find((s) => s.turn === 3).text).toContain('Ход (промпт) 3 — я: привет'); }); }); describe('writeFileAtomic — запись через temp + rename (защита от полузаписи при параллельных сессиях)', () => { it('пишет во временный файл, затем переименовывает в целевой', () => { const calls = []; const fs = { writeFileSync: (p, c) => calls.push(['write', p, c]), renameSync: (a, b) => calls.push(['rename', a, b]), }; writeFileAtomic('/x/protocol.json', 'DATA', fs); expect(calls[0][0]).toBe('write'); expect(calls[0][1]).toMatch(/^\/x\/protocol\.json\.tmp-/); // временный рядом с целью expect(calls[0][2]).toBe('DATA'); expect(calls[1]).toEqual(['rename', calls[0][1], '/x/protocol.json']); // атомарная подмена }); });