90f1360065
Секретарь перестал терять промпт владельца при обрыве (сбой 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>
237 lines
14 KiB
JavaScript
237 lines
14 KiB
JavaScript
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"}' }]);
|
||
});
|
||
});
|