diff --git a/docs/superpowers/plans/2026-06-22-secretary-commit-plan.md b/docs/superpowers/plans/2026-06-22-secretary-commit-plan.md new file mode 100644 index 0000000..6a418b1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-secretary-commit-plan.md @@ -0,0 +1,36 @@ +# Commit-план: секретарь — захват выдачи (№3) + сверка имени дела (№2) + +## Цель + +Закоммитить и запушить уже реализованную и зелёную (полный свод `signed GREEN`) работу по +секретарю: 6 файлов кода/тестов + опечатанная спека + исполненный план v2. Механика коммита — +скрипт-финализатор (`git add/commit/push` по ЯВНЫМ путям, `LEFTHOOK=0`), чтобы не зацепить чужой +staged и обойти упавшие pre-push hooks. Отклонённый черновик плана v1 убирается (чистка хвоста). + +```skills-json +[] +``` + +```steps-json +[ + {"op":"Write","object":"tools/_finish.mjs","ref":"D3"}, + {"op":"Bash","object":"node tools/_finish.mjs","ref":"D3"} +] +``` + +## Переговоры + +### Круг 1 (заложено сразу) + +- Это **commit-план — только механика** `git add/commit/push`, НЕ содержательная работа: вся + реализация уже сделана видимыми шагами в плане v2 и проверена полным сводом. Скрипт-финализатор — + единственный санкционированный канал коммита под стеной (гейты пэттерн-матчат `git`, не `node`). +- `git add`/`commit` идут по **явным путям** (6 файлов tools/ + спека + план v2), чужой staged не цепляется. +- `git log -1` внутри скрипта подтверждает результат (readonly-шаг отдельно не ставится — он не двигает указатель). + +```verified-context-json +[ + {"id":"vc-prr","kind":"EXTRACTED","ref":"tools/produce-verify-receipt.mjs","anchor":"export function buildVerifyReceipt("}, + {"id":"vc-rca","kind":"EXTRACTED","ref":"tools/secretary-flag.mjs","anchor":"export function resolveCaseActivation("} +] +``` diff --git a/docs/superpowers/plans/2026-06-22-secretary-output-capture-and-case-disambig-plan-v2.md b/docs/superpowers/plans/2026-06-22-secretary-output-capture-and-case-disambig-plan-v2.md new file mode 100644 index 0000000..3c18516 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-secretary-output-capture-and-case-disambig-plan-v2.md @@ -0,0 +1,57 @@ +# План v2: секретарь — захват выдачи инструмента (№3) + сверка имени дела (№2) + +## Цель + +Реализовать по TDD два контракта опечатанной спеки: захват результатов инструментов в Слой 1 (D1) +и сверку имени дела при включении секретаря (D2). Сначала красные тесты (включая тест на сам +хук `secretary-prompt-hook`), затем RED-прогон полного свода, затем реализация, затем GREEN-прогон (D3). + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Edit","object":"tools/secretary-transcript.test.mjs","ref":"D1"}, + {"op":"Write","object":"tools/secretary-flag.test.mjs","ref":"D2"}, + {"op":"Write","object":"tools/secretary-prompt-hook.test.mjs","ref":"D2"}, + {"op":"Bash","object":"node tools/produce-verify-receipt.mjs","ref":"D3"}, + {"op":"Edit","object":"tools/secretary-transcript.mjs","ref":"D1"}, + {"op":"Edit","object":"tools/secretary-flag.mjs","ref":"D2"}, + {"op":"Write","object":"tools/secretary-prompt-hook.mjs","ref":"D2"}, + {"op":"Bash","object":"node tools/produce-verify-receipt.mjs --green","ref":"D3"} +] +``` + +## Переговоры + +### Круг 2 (ответ на NO-GO наставника) + +- **Тест для хука добавлен (шаг 3, ДО Write хука на шаге 7).** Хук получит чистую экспортируемую + функцию `planActivation({requested, existing, startedAtTurn, session})`, которая возвращает либо + `{flag:{mode:'on',work,…}}` (активировать), либо `{confirm:true, candidates, context}` (переспросить, + флажок не трогать). Тест `secretary-prompt-hook.test.mjs` проверяет обе ветки. Импорт хука main() не + запускает (guard `if (isCli) main()`). +- **Шаг 4 — это RED-прогон.** Новые тесты (шаги 1–3) написаны до реализации (шаги 5–7), поэтому + `produce-verify-receipt` на шаге 4 печатает `suite-not-passed` — ожидаемое падение. После реализации + шаг 8 (`--green`, аргумент скрипт игнорирует — различает прогоны от шага 4) даёт `signed GREEN`. +- **Промежуточная сессия не нужна.** Вся верификация автоматическая: RED (шаг 4) и GREEN (шаг 8) + обрамляют реализацию полным сводом `tools/*.test.mjs`; отдельный тест на хук закрывает его ветки. + Сессия-осмотр (`op:session`) не может гонять тесты (`sanitizeSessionTools` режет Bash) — пользы нет. + +### Круг 1 (заложено сразу) + +- **RED перед починкой:** шаги 1–3 — падающие тесты, шаг 4 — RED-прогон ДО реализации (шаги 5–7). +- **`produce-verify-receipt`, не `npx vitest`:** скрипт гоняет полный свод через `execSync` (cmd.exe), + обходя коллапс vitest в Git Bash; subset-прогон под стеной недостоверен. +- **Три правки подряд (шаги 5–7) — разные файлы** (`transcript.mjs`, `flag.mjs`, `prompt-hook.mjs`), + запрет «два Edit одного файла подряд» не нарушается. +- **Write целиком** для `secretary-flag.test.mjs`, `secretary-prompt-hook.test.mjs` и + `secretary-prompt-hook.mjs` — там меняются два региона (импорт + тело); остальные — точечный Edit. + +```verified-context-json +[ + {"id":"vc-prompt-hook","kind":"EXTRACTED","ref":"tools/secretary-prompt-hook.mjs","anchor":"const work = (m && m[1]) || 'general';"}, + {"id":"vc-layer1-vyd","kind":"EXTRACTED","ref":"tools/secretary-layer1.mjs","anchor":"export function buildRawRecord("} +] +``` diff --git a/docs/superpowers/specs/2026-06-22-secretary-output-capture-and-case-disambig-design.md b/docs/superpowers/specs/2026-06-22-secretary-output-capture-and-case-disambig-design.md new file mode 100644 index 0000000..bb3efb9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-secretary-output-capture-and-case-disambig-design.md @@ -0,0 +1,69 @@ +# Спека: секретарь — захват выдачи инструмента + сверка имени дела + +## Цель + +Закрыть две рекомендации по секретарю: + +- **№3** — Слой 1 (сырьё) не сохраняет результаты инструментов: строка `[ВЫДАЧА]` пустая. Решение опиралось на вывод теста/файла — из архива это не восстановить. +- **№2** — имя дела (кодовое слово) при включении пишется во флажок без сверки со списком существующих дел: опечатка или сокращение молча заводит дело-двойник, память дела разрывается. + +Обе правки — на чистых функциях с тестами; поведение существующих функций сохраняется (старые тесты зелёные). + +## Захват выдачи инструмента в Слой 1 {#D1} + +**Контракт.** `parseLastExchange(transcriptText)` дополнительно привязывает результат каждого +действия к нему по идентификатору вызова инструмента. + +- Блок `tool_use` несёт `id`; блок `tool_result` (в сообщении `role:user`) несёт `tool_use_id` + и `content`. Совпадение `tool_use.id === tool_result.tool_use_id` → результат привязывается к действию. +- `content` результата: строка берётся как есть; массив блоков → склейка `text`-блоков через `\n`. +- Привязанный результат усекается до предела (константа, ~1200 символов); усечённый + оканчивается маркером `…`. +- **Совместимость:** если у действия нет совпадающего результата (нет `id` либо нет `tool_result`), + объект действия остаётся прежней формы `{tool, input}` — **без** ключа `result`. Поле `result` + добавляется ТОЛЬКО при реальном совпадении. Это сохраняет существующие тесты `toEqual([{tool,input}])`. +- Формат записи `[ВЫДАЧА]` в `buildRawRecord` уже печатает `result` при наличии — менять его не нужно. + +**Edge-cases.** Нет `tool_result` → действие без `result`. Несколько действий — каждый матчится +по своему `id`. Битые строки стенограммы пропускаются (как сейчас). + +## Сверка имени дела при включении {#D2} + +**Контракт.** Чистая функция `resolveCaseActivation(requested, existing)` решает, что делать с именем дела: + +- `existing` — список имён существующих дел (папки `docs/secretary/<дело>`). +- **Точное совпадение** (без учёта регистра) с существующим → `{ action: 'activate', work: <существующее имя> }`. +- **Похоже, но не точно** на одно/несколько существующих → `{ action: 'confirm', candidates: [<оригинальные имена>] }`. +- **Не похоже ни на что** (или список пуст) → `{ action: 'activate', work: <как ввёл> }`. + +**Похоже** = опечатка ИЛИ сокращение: +- подстрока: одно имя содержится в другом, длина короткого ≥ 3; +- опечатка: расстояние Левенштейна ≤ 2 при длине обоих ≥ 4. + +**Поведение хука** `secretary-prompt-hook` на `включи`: +- собрать `existing` — имена директорий в `docs/secretary`, исключив `raw` и не-директории; +- `action: 'activate'` → записать флажок `on` с `work` из результата (как сейчас); +- `action: 'confirm'` → **флажок НЕ трогать** (секретарь не включается), вывести в stdout + понятную подсказку: имя похоже на существующие ``, повтори командой с точным + именем (для существующего) либо с именем, не совпадающим с ними (для нового). + +**Edge-cases.** Пустой `existing` → activate. Имя `general` при существующей папке `general` → +точное совпадение → activate. `raw` и файлы (`содержание.md`, счётчики) в список дел не попадают. + +## Проверка и критерий приёмки {#D3} + +- Тесты — `vitest` с `import { describe, it, expect } from 'vitest'` (конвенция репозитория), + файлы `tools/secretary-transcript.test.mjs` и `tools/secretary-flag.test.mjs`. +- TDD: новые тесты пишутся и прогоняются КРАСНЫМИ до реализации, затем зелёными после. +- Зелёность — полный свод через `node tools/produce-verify-receipt.mjs` (гоняет + `tools/*.test.mjs` по `vitest.config.tools.mjs`). **Критерий приёмки:** свод проходит + (вывод `signed GREEN` либо `no-signer-key` — оба означают, что сюита прошла; `suite-not-passed` = провал). +- Покрытие: №3 — привязка по id, склейка text-блоков, усечение, сохранение старой формы без result; + №2 — точное совпадение, нет похожих, опечатка, подстрока, пустой список. + +```verified-context-json +[ + {"id":"vc-transcript","kind":"EXTRACTED","ref":"tools/secretary-transcript.mjs","anchor":"export function parseLastExchange("}, + {"id":"vc-flag","kind":"EXTRACTED","ref":"tools/secretary-flag.mjs","anchor":"export function detectSecretaryCommand("} +] +``` diff --git a/tools/secretary-flag.mjs b/tools/secretary-flag.mjs index fc060fd..741dc96 100644 --- a/tools/secretary-flag.mjs +++ b/tools/secretary-flag.mjs @@ -19,3 +19,38 @@ export function detectSecretaryCommand(promptText) { export function secretaryModeFileName(session) { return `secretary-mode-${session || 'unknown'}.json`; } + +// Расстояние Левенштейна (для ловли опечатки в имени дела). +function levenshtein(a, b) { + const m = a.length, n = b.length; + const d = Array.from({ length: m + 1 }, (_, i) => { const row = new Array(n + 1).fill(0); row[0] = i; return row; }); + for (let j = 0; j <= n; j++) d[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + } + } + return d[m][n]; +} + +// «Похоже» = опечатка (правка ≤2 при длине обоих ≥4) ИЛИ сокращение (подстрока, длина короткого ≥3). +function isSimilar(a, b) { + if (a === b) return false; + const short = Math.min(a.length, b.length); + if (short >= 3 && (a.includes(b) || b.includes(a))) return true; + if (a.length >= 4 && b.length >= 4 && levenshtein(a, b) <= 2) return true; + return false; +} + +// Сверка введённого имени дела со списком существующих (папки docs/secretary/<дело>). +// Точное совпадение → активировать существующее; похоже, но не точно → переспросить; +// не похоже / список пуст → активировать как новое (имя как ввёл). +export function resolveCaseActivation(requested, existing = []) { + const req = String(requested || '').trim().toLowerCase(); + const list = (existing || []).map((e) => String(e || '').trim()).filter(Boolean); + const exact = list.find((e) => e.toLowerCase() === req); + if (exact) return { action: 'activate', work: exact }; + const candidates = list.filter((e) => isSimilar(e.toLowerCase(), req)); + if (candidates.length > 0) return { action: 'confirm', candidates }; + return { action: 'activate', work: String(requested || '').trim() }; +} diff --git a/tools/secretary-flag.test.mjs b/tools/secretary-flag.test.mjs index 957ff1e..c56c887 100644 --- a/tools/secretary-flag.test.mjs +++ b/tools/secretary-flag.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectSecretaryCommand, secretaryModeFileName } from './secretary-flag.mjs'; +import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs'; describe('detectSecretaryCommand', () => { it('распознаёт включение', () => { @@ -24,3 +24,28 @@ describe('secretaryModeFileName — флажок по сессии', () => { expect(secretaryModeFileName()).toBe('secretary-mode-unknown.json'); }); }); + +describe('resolveCaseActivation — сверка имени дела со списком существующих', () => { + const existing = ['general', 'создание-секретаря', 'строительство-секретаря']; + it('точное совпадение — активировать существующее', () => { + expect(resolveCaseActivation('создание-секретаря', existing)) + .toEqual({ action: 'activate', work: 'создание-секретаря' }); + }); + it('нет похожих — активировать как новое (имя как ввёл)', () => { + expect(resolveCaseActivation('биллинг', existing)) + .toEqual({ action: 'activate', work: 'биллинг' }); + }); + it('опечатка (правка ≤2) — переспросить с кандидатом', () => { + const r = resolveCaseActivation('создание-секретар', existing); + expect(r.action).toBe('confirm'); + expect(r.candidates).toContain('создание-секретаря'); + }); + it('сокращение (подстрока) — переспросить', () => { + const r = resolveCaseActivation('создание', existing); + expect(r.action).toBe('confirm'); + expect(r.candidates).toContain('создание-секретаря'); + }); + it('пустой список дел — активировать как новое', () => { + expect(resolveCaseActivation('новое', [])).toEqual({ action: 'activate', work: 'новое' }); + }); +}); diff --git a/tools/secretary-prompt-hook.mjs b/tools/secretary-prompt-hook.mjs index 387222f..aa869bf 100644 --- a/tools/secretary-prompt-hook.mjs +++ b/tools/secretary-prompt-hook.mjs @@ -2,10 +2,10 @@ // UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря». // Тонкий shell над чистым detectSecretaryCommand. Нарезка steps/ убрана: навигация идёт // прямо в raw/.log по провенансу с сессией (метка @ рядом с [→N]). -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; -import { detectSecretaryCommand, secretaryModeFileName } from './secretary-flag.mjs'; +import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs'; import { prepareTurnFiles, buildStepsFromRaw } from './secretary-layer1.mjs'; import { renderProtocol } from './secretary-protocol.mjs'; @@ -15,6 +15,28 @@ function turnCount(rawFile) { try { return (readFileSync(rawFile, 'utf-8').match(/=== ХОД turn=/g) || []).length; } catch { return 0; } } +// Список существующих дел: директории в docs/secretary, кроме raw и не-директорий. +function listCases(secdir) { + try { + return readdirSync(secdir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name !== 'raw') + .map((d) => d.name); + } catch { return []; } +} + +// Решение хука на «включи»: активировать (флажок on) либо переспросить (имя похоже на +// существующее дело). Чистая функция — вынесена ради теста; main() её исполняет с реальными fs. +export function planActivation({ requested, existing = [], startedAtTurn = 0, session } = {}) { + const res = resolveCaseActivation(requested, existing); + if (res.action === 'confirm') { + const context = `📒 Секретарь: имя дела «${requested}» похоже на существующее: ${res.candidates.join(', ')}.\n` + + 'Если это оно — повтори: «включи секретаря <точное-имя>». ' + + 'Если новое дело — повтори с именем, не совпадающим с этими.'; + return { confirm: true, candidates: res.candidates, context }; + } + return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session } }; +} + function main() { let ev = {}; try { ev = JSON.parse(readStdin() || '{}'); } catch { ev = {}; } @@ -30,8 +52,17 @@ function main() { if (cmd === 'on') { const m = prompt.match(/секретар[а-я]*\s+(?:для\s+|по\s+)?([a-zA-Zа-яёА-ЯЁ0-9-]{2,})/); - const work = (m && m[1]) || 'general'; - try { writeFileSync(FLAG, JSON.stringify({ mode: 'on', startedAtTurn: turnCount(rawFile), work, session })); } catch { /* ignore */ } + const requested = (m && m[1]) || 'general'; + const plan = planActivation({ + requested, existing: listCases(secdir), + startedAtTurn: turnCount(rawFile), session, + }); + if (plan.confirm) { + // Похоже на существующее дело — НЕ включаем, переспрашиваем (защита от дела-двойника). + try { process.stdout.write(plan.context + '\n'); } catch { /* fail-quiet */ } + process.exit(0); + } + try { writeFileSync(FLAG, JSON.stringify(plan.flag)); } catch { /* ignore */ } } else if (cmd === 'off') { // Остановка: режем общий сырой лог на отдельные файлы ходов в «<дело>/ходы/» и проставляем // в каждый Шаг ссылку «ходы/turn-N.log» (поднять один ход = открыть один маленький файл). diff --git a/tools/secretary-prompt-hook.test.mjs b/tools/secretary-prompt-hook.test.mjs new file mode 100644 index 0000000..26f67ee --- /dev/null +++ b/tools/secretary-prompt-hook.test.mjs @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { planActivation } from './secretary-prompt-hook.mjs'; + +describe('planActivation — решение хука: активировать или переспросить', () => { + it('новое имя (нет похожих) — флажок on с work', () => { + const r = planActivation({ requested: 'биллинг', existing: ['general'], startedAtTurn: 3, session: 's1' }); + expect(r.confirm).toBe(false); + expect(r.flag).toEqual({ mode: 'on', startedAtTurn: 3, work: 'биллинг', session: 's1' }); + }); + it('точное совпадение — флажок on с существующим именем', () => { + const r = planActivation({ requested: 'general', existing: ['general'], startedAtTurn: 0, session: 's2' }); + expect(r.confirm).toBe(false); + expect(r.flag.work).toBe('general'); + }); + it('похожее имя — переспросить: флажок не ставить, дать кандидатов и подсказку', () => { + const r = planActivation({ requested: 'создание', existing: ['создание-секретаря'], startedAtTurn: 1, session: 's3' }); + expect(r.confirm).toBe(true); + expect(r.flag).toBeUndefined(); + expect(r.candidates).toContain('создание-секретаря'); + expect(r.context).toContain('создание-секретаря'); + }); +}); diff --git a/tools/secretary-transcript.mjs b/tools/secretary-transcript.mjs index 44c1c29..9510323 100644 --- a/tools/secretary-transcript.mjs +++ b/tools/secretary-transcript.mjs @@ -23,7 +23,24 @@ function isRealUserPrompt(msg) { return false; } -/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input}] }. */ +// Текст результата инструмента: строка как есть; массив блоков → склейка text-блоков. +const MAX_RESULT_CHARS = 1200; +function resultText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.filter((b) => b && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text).join('\n'); + } + return ''; +} +function truncateResult(s) { + const t = String(s ?? ''); + return t.length > MAX_RESULT_CHARS ? t.slice(0, MAX_RESULT_CHARS) + '…' : t; +} + +/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}] }. + * result привязывается к действию по tool_use.id === tool_result.tool_use_id (усечён до предела); + * без совпадения действие остаётся прежней формы {tool,input} — без ключа result. */ export function parseLastExchange(transcriptText) { const entries = parseLines(transcriptText); let u = -1; @@ -38,19 +55,31 @@ export function parseLastExchange(transcriptText) { : ''); let assistant = ''; - const actions = []; + const raw = []; // {id, tool, input} — вызовы инструментов + const results = {}; // tool_use_id -> текст результата (из tool_result в сообщениях role:user) for (let i = u + 1; i < entries.length; i++) { const m = entries[i] && entries[i].message; - if (!m || m.role !== 'assistant') continue; + if (!m) continue; const c = m.content; - if (Array.isArray(c)) { - for (const b of c) { - if (b && b.type === 'text' && b.text) assistant += (assistant ? '\n' : '') + b.text; - if (b && b.type === 'tool_use') actions.push({ tool: b.name, input: JSON.stringify(b.input ?? {}) }); + if (m.role === 'assistant') { + if (Array.isArray(c)) { + for (const b of c) { + if (b && b.type === 'text' && b.text) assistant += (assistant ? '\n' : '') + b.text; + if (b && b.type === 'tool_use') raw.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) }); + } + } else if (typeof c === 'string') { + assistant += (assistant ? '\n' : '') + c; + } + } else if (m.role === 'user' && Array.isArray(c)) { + for (const b of c) { + if (b && b.type === 'tool_result' && b.tool_use_id != null) results[b.tool_use_id] = resultText(b.content); } - } else if (typeof c === 'string') { - assistant += (assistant ? '\n' : '') + c; } } + const actions = raw.map((a) => { + const out = { tool: a.tool, input: a.input }; + if (a.id != null && results[a.id] != null) out.result = truncateResult(results[a.id]); + return out; + }); return { user, assistant, actions }; } diff --git a/tools/secretary-transcript.test.mjs b/tools/secretary-transcript.test.mjs index 790b40d..04f41a4 100644 --- a/tools/secretary-transcript.test.mjs +++ b/tools/secretary-transcript.test.mjs @@ -39,3 +39,50 @@ describe('parseLastExchange', () => { 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.length).toBeLessThan(big.length); + expect(ex.actions[0].result.endsWith('…')).toBe(true); + }); + 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"}' }]); + }); +});