Files
brain/tools/secretary-layer1.test.mjs
T
Дмитрий 90f1360065 feat(secretary): устойчивость к обрывам — источник=транскрипт, склейка продолжений, догон сессий
Секретарь перестал терять промпт владельца при обрыве (сбой API / ручной стоп /
жёсткий крах). Источник правды — транскрипт на диске: сырьё (Слой 1) пересобирается
из всего транскрипта на каждом завершении, а не дописывается по последнему обмену.

- classifyEntry/assembleExchanges: распознавание машинных меток (isApiErrorMessage,
  [Request interrupted by user] обе формы, isCompactSummary, isMeta) — метка не считается
  настоящим промптом; промпт после обрыва помечается продолжением (cont=1), хвост — tail=1.
- realBoundariesFromRaw: продолжение не открывает новый спан (одна работа не дробится).
- честные пометки спана: «(связь прерывалась — продолжено)» / «(прервана, не завершена)».
- stop-хук: пересборка сырья из транскрипта + догон недоразобранного хвоста прошлых
  (умерших) сессий дела при «включи секретаря <дело>» (_sessions.json, secretary-sessions).
- parseLastExchange → тонкая обёртка над assembleExchanges (без дубля логики).

Свод секретаря зелёный: 172 теста / 12 файлов.
Спека: docs/superpowers/specs/2026-06-23-secretary-interruption-resilience-spec.md
План: docs/superpowers/plans/2026-06-23-secretary-interruption-resilience.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 04:19:16 +03:00

238 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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']); // атомарная подмена
});
});