// tools/on-plan-write.test.mjs import { describe, it, expect } from 'vitest'; import { onPlanWrite, onSpecWrite } from './on-plan-write.mjs'; import { planId } from './plan-lock.mjs'; const STEPS = [{ n: 1, op: 'Write', object: 'tools/a.mjs' }]; const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'разбор', recommendation: 'ок', confidence: 0.8, decision: 'GO' }; const llmOk = async () => goodVerdict; describe('onPlanWrite (W3, A0 — нах.F5/C-1)', () => { it('первый план → task-id присвоен из planId(steps) и персистнут', async () => { const persisted = []; const r = await onPlanWrite({ planSteps: STEPS, existingTaskId: null, persistTaskIdImpl: (id) => persisted.push(id), llmCall: llmOk, journalKey: 'k', nowMs: 1 }); expect(r.taskId).toBe(`task:${planId(STEPS)}`); expect(persisted).toEqual([r.taskId]); }); it('re-issue плана с новым хешем НЕ сбрасывает task-id (✅O17)', async () => { const NEW_STEPS = [{ n: 1, op: 'Write', object: 'tools/b.mjs' }]; const r = await onPlanWrite({ planSteps: NEW_STEPS, existingTaskId: 'task:OLD', llmCall: llmOk, journalKey: 'k', nowMs: 1 }); expect(r.taskId).toBe('task:OLD'); }); it('вердикт произведён и привязан: verdict.plan_hash === planId(steps) (нах.F4)', async () => { const r = await onPlanWrite({ planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1 }); expect(r.ok).toBe(true); expect(r.wired).toBe(true); expect(r.verdict.plan_hash).toBe(planId(STEPS)); }); it('журнал переговоров пишется (side=mentor, круг 1, task-id-bound)', async () => { const r = await onPlanWrite({ planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1 }); expect(r.journalOk).toBe(true); expect(r.journal.entries.length).toBe(1); const p = r.journal.entries[0].payload; expect(p.side).toBe('mentor'); expect(p.round).toBe(1); expect(p.task_id).toBe(r.taskId); }); it('SE10: сбой журнала НЕ крашит круг — journalOk:false, вердикт/task-id целы', async () => { const boom = () => { throw new Error('журнал упал'); }; const r = await onPlanWrite({ planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1, appendNegotiationImpl: boom }); expect(r.journalOk).toBe(false); expect(r.taskId).toBe(`task:${planId(STEPS)}`); expect(r.verdict.plan_hash).toBe(planId(STEPS)); }); it('сбой персиста task-id НЕ крашит — taskIdPersisted:false', async () => { const r = await onPlanWrite({ planSteps: STEPS, persistTaskIdImpl: () => { throw new Error('disk'); }, llmCall: llmOk, journalKey: 'k', nowMs: 1 }); expect(r.taskIdPersisted).toBe(false); expect(r.taskId).toBe(`task:${planId(STEPS)}`); }); it('сбой вызова наставника → ok:false, wired:false (SE-R6-6), без краша', async () => { const r = await onPlanWrite({ planSteps: STEPS, llmCall: async () => { throw new Error('сеть'); }, journalKey: 'k', nowMs: 1 }); expect(r.ok).toBe(false); expect(r.wired).toBe(false); expect(r.verdict).toBe(null); }); }); describe('onPlanWrite — скил-сверка через classify (мерж)', () => { const GO = { plan_points_addressed: ['ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' }; it('зовёт classifyImpl и кладёт рекомендацию в промпт вердикта', async () => { let classified = null; let capturedUser = null; const r = await onPlanWrite({ planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }], declaredSkills: ['executing-plans'], classifyImpl: async (goal) => { classified = goal; return { recommended_chain: ['systematic-debugging'] }; }, registry: {}, llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; }, planGoal: 'починить парсер', }); expect(classified).toBe('починить парсер'); expect(capturedUser).toMatch(/systematic-debugging/); expect(capturedUser).toMatch(/executing-plans/); expect(r.ok).toBe(true); }); it('classifyImpl бросил → вердикт без скил-сверки (маркер недоступности), не падает', async () => { let capturedUser = null; const r = await onPlanWrite({ planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }], declaredSkills: ['x'], classifyImpl: async () => { throw new Error('классификатор недоступен'); }, registry: {}, llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; }, planGoal: 'g', }); expect(r.ok).toBe(true); expect(capturedUser).toMatch(/недоступн/i); }); it('резолвит ID рекомендации в имя через registry (фикс)', async () => { let capturedUser = null; const registry = { indexById: new Map([['#4', { id: '#4', name: 'markdownlint-cli2' }]]) }; await onPlanWrite({ planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }], declaredSkills: ['executing-plans'], classifyImpl: async () => ({ recommended_chain: ['#4'] }), registry, llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; }, planGoal: 'правка .md', }); expect(capturedUser).toMatch(/markdownlint-cli2/); expect(capturedUser).not.toMatch(/#4(?! — )/); // голого #4 без имени нет }); }); describe('оркестратор протягивает roundMemory до построителя (SP2c-2)', () => { it('onPlanWrite → roundMemory доходит до промпта вердикта', async () => { let capturedUser = null; await onPlanWrite({ planSteps: STEPS, roundMemory: { objections: ['память круга плана'] }, llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return goodVerdict; }, journalKey: 'k', nowMs: 1, }); expect(capturedUser).toContain('память круга плана'); }); it('onSpecWrite → roundMemory доходит до промпта вердикта спеки', async () => { let capturedUser = null; await onSpecWrite({ specContent: '# Спека', specHash: 'SH', roundMemory: { objections: ['память круга спеки'] }, llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return goodVerdict; }, journalKey: 'k', nowMs: 1, }); expect(capturedUser).toContain('память круга спеки'); }); });