Files
brain/tools/secretary-transcript.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

237 lines
14 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 { parseLastExchange, classifyEntry, assembleExchanges, rebuildRawFromTranscript } from './secretary-transcript.mjs';
import { realBoundariesFromRaw } from './secretary-layer1.mjs';
describe('rebuildRawFromTranscript — пересборка сырья (источник = транскрипт)', () => {
it('сбой API + продолжи → 2 хода, ход-продолжение помечен cont=1, граница одна', () => {
const t = [
{ message: { role: 'user', content: 'настоящая просьба' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'начал' }] } },
{ isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } },
{ message: { role: 'user', content: 'продолжи' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const raw = rebuildRawFromTranscript(t, { session: 's' });
expect((raw.match(/=== ХОД turn=/g) || []).length).toBe(2);
expect(raw).toMatch(/=== ХОД turn=2[^\n]*cont=1/);
expect(realBoundariesFromRaw(raw)).toEqual([1]); // одна логическая работа, не «продолжи»
});
it('пустой транскрипт → пустое сырьё', () => {
expect(rebuildRawFromTranscript('', { session: 's' })).toBe('');
});
it('sanitize применяется к каждой записи', () => {
const t = JSON.stringify({ message: { role: 'user', content: 'СЕКРЕТ' } });
const raw = rebuildRawFromTranscript(t, { session: 's', sanitize: (x) => x.replace('СЕКРЕТ', '[вырезано]') });
expect(raw).toContain('[вырезано]');
expect(raw).not.toContain('СЕКРЕТ');
});
});
describe('assembleExchanges — обмены из всего транскрипта', () => {
it('два настоящих промпта → два обмена с накопленным ответом и действиями', () => {
const t = [
{ message: { role: 'user', content: 'первый' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'ответ1' }, { type: 'tool_use', id: 'a', name: 'Read', input: { f: 'x' } }] } },
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'a', content: 'r' }] } },
{ message: { role: 'user', content: 'второй' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'ответ2' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['первый', 'второй']);
expect(ex[0].assistant).toBe('ответ1');
expect(ex[0].actions).toEqual([{ tool: 'Read', input: '{"f":"x"}', result: 'r' }]);
expect(ex[0].isContinuation).toBe(false);
});
it('сбой API + следующий промпт → продолжение (isContinuation), не новый настоящий промпт', () => {
const t = [
{ message: { role: 'user', content: 'настоящая просьба' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'начал работу' }] } },
{ isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } },
{ message: { role: 'user', content: 'продолжи' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['настоящая просьба', 'продолжи']);
expect(ex[1].isContinuation).toBe(true); // «продолжи» — продолжение, не новая просьба
expect(ex[ex.length - 1].interruptedTail).toBe(false); // работа доведена → не хвост
});
it('ручной стоп + следующий промпт → продолжение (склеиваем по умолчанию)', () => {
const t = [
{ message: { role: 'user', content: 'просьба' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'работаю' }] } },
{ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
{ message: { role: 'user', content: 'дальше давай' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['просьба', 'дальше давай']);
expect(ex[1].isContinuation).toBe(true);
});
it('прерван и НЕ продолжен (хвост в конце) → interruptedTail на последнем обмене', () => {
const t = [
{ message: { role: 'user', content: 'большая задача' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'делаю часть' }] } },
{ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex).toHaveLength(1);
expect(ex[0].user).toBe('большая задача');
expect(ex[0].interruptedTail).toBe(true);
});
it('выжимка сжатия не становится обменом', () => {
const t = [
{ message: { role: 'user', content: 'реальный' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } },
{ isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: 'СЖАТАЯ ВЫЖИМКА' } },
{ message: { role: 'user', content: 'после сжатия' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'дальше' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['реальный', 'после сжатия']);
expect(ex.some((x) => x.user.includes('ВЫЖИМКА'))).toBe(false);
});
it('служебный ход (meta) — отдельный обмен с userIsMeta', () => {
const t = [
{ message: { role: 'user', content: 'настоящий' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'a' }] } },
{ isMeta: true, message: { role: 'user', content: 'Stop hook feedback: x' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'b' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex).toHaveLength(2);
expect(ex[1].userIsMeta).toBe(true);
expect(ex[1].isContinuation).toBe(false);
});
});
describe('classifyEntry — вид записи транскрипта', () => {
it('настоящий промпт владельца → real', () => {
expect(classifyEntry({ message: { role: 'user', content: 'сделай X' } })).toBe('real');
});
it('служебный ход (isMeta) → meta', () => {
expect(classifyEntry({ isMeta: true, message: { role: 'user', content: 'Stop hook feedback' } })).toBe('meta');
});
it('сбой API (isApiErrorMessage) → interrupt-api', () => {
expect(classifyEntry({ isApiErrorMessage: true, type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } })).toBe('interrupt-api');
});
it('ручной стоп (обе формы) → interrupt-stop', () => {
expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } })).toBe('interrupt-stop');
expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }] } })).toBe('interrupt-stop');
});
it('выжимка сжатия (isCompactSummary) → summary', () => {
expect(classifyEntry({ isCompactSummary: true, message: { role: 'user', content: 'итог...' } })).toBe('summary');
});
it('ответ ассистента → assistant; tool_result → tool_result', () => {
expect(classifyEntry({ message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } })).toBe('assistant');
expect(classifyEntry({ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'r' }] } })).toBe('tool_result');
});
});
describe('parseLastExchange', () => {
it('тащит последний user + assistant + действия', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'старое' } }),
JSON.stringify({ message: { role: 'user', content: 'привет' } }),
JSON.stringify({ message: { role: 'assistant', content: [
{ type: 'text', text: 'ответ' },
{ type: 'tool_use', name: 'Read', input: { f: 'x' } },
] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.user).toBe('привет');
expect(ex.assistant).toBe('ответ');
expect(ex.actions).toEqual([{ tool: 'Read', input: '{"f":"x"}' }]);
});
it('строковый content тоже понимает; битые строки пропускает', () => {
const t = ['не-json', JSON.stringify({ message: { role: 'user', content: 'у' } }),
JSON.stringify({ message: { role: 'assistant', content: 'а' } })].join('\n');
const ex = parseLastExchange(t);
expect(ex.user).toBe('у');
expect(ex.assistant).toBe('а');
expect(ex.actions).toEqual([]);
});
it('пропускает tool_result (role:user) — берёт настоящий промпт + все действия', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'настоящий вопрос' } }),
JSON.stringify({ message: { role: 'assistant', content: [
{ type: 'text', text: 'думаю' }, { type: 'tool_use', name: 'Read', input: { f: 'a' } }] } }),
JSON.stringify({ message: { role: 'user', content: [{ type: 'tool_result', content: 'результат' }] } }),
JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.user).toBe('настоящий вопрос');
expect(ex.assistant).toContain('думаю');
expect(ex.assistant).toContain('готово');
expect(ex.actions).toEqual([{ tool: 'Read', input: '{"f":"a"}' }]);
});
});
describe('parseLastExchange — захват выдачи инструмента (tool_result по tool_use_id)', () => {
it('привязывает результат к действию по совпадающему id', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'вопрос' } }),
JSON.stringify({ message: { role: 'assistant', content: [
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { f: 'a' } }] } }),
JSON.stringify({ message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'tu_1', content: 'СОДЕРЖИМОЕ ФАЙЛА' }] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.actions).toEqual([{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ ФАЙЛА' }]);
});
it('результат из массива text-блоков склеивается', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'в' } }),
JSON.stringify({ message: { role: 'assistant', content: [
{ type: 'tool_use', id: 'tu_9', name: 'Bash', input: {} }] } }),
JSON.stringify({ message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'tu_9', content: [{ type: 'text', text: 'строка вывода' }] }] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.actions[0].result).toBe('строка вывода');
});
it('длинный результат НЕ обрезается (полная картина для секретаря)', () => {
const big = 'x'.repeat(5000);
const t = [
JSON.stringify({ message: { role: 'user', content: 'в' } }),
JSON.stringify({ message: { role: 'assistant', content: [
{ type: 'tool_use', id: 'tu_2', name: 'Read', input: {} }] } }),
JSON.stringify({ message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'tu_2', content: big }] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.actions[0].result).toBe(big); // целиком
expect(ex.actions[0].result.endsWith('…')).toBe(false);
});
it('помечает userIsMeta для служебного сообщения (isMeta:true на записи)', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'настоящий' } }),
JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'ответ' }] } }),
JSON.stringify({ isMeta: true, message: { role: 'user', content: 'Stop hook feedback: x' } }),
JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'продолжение' }] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.user).toBe('Stop hook feedback: x'); // выбор сообщения прежний (последнее текстовое)
expect(ex.userIsMeta).toBe(true); // но помечено как служебное
});
it('реальный промпт — userIsMeta false', () => {
const t = [JSON.stringify({ message: { role: 'user', content: 'привет' } }),
JSON.stringify({ message: { role: 'assistant', content: 'ок' } })].join('\n');
expect(parseLastExchange(t).userIsMeta).toBe(false);
});
it('без совпадающего id результат не привязывается — старая форма {tool,input} цела', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'в' } }),
JSON.stringify({ message: { role: 'assistant', content: [
{ type: 'tool_use', id: 'tu_3', name: 'Read', input: { f: 'z' } }] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.actions).toEqual([{ tool: 'Read', input: '{"f":"z"}' }]);
});
});