f22f8bd2ef
Новый router-pin-store: пин совета роутера по (task_id, goalHash) пер-сессионно. on-plan-write пин-aware: пин-попадание по неизменной цели → совет переиспользуется, classifyImpl НЕ зовётся; промах/смена цели → classify + сохранение пина. Проводка в активный наставник-хук инъекцией реального стора с sessionId (инъекция-выкл по умолчанию, старое поведение/тесты целы). Хвост спеки роутера §4 (пининг по goalHash), эпик роутер-реестр этап 3, item 2. Граница не тронута (recommended_chain, цепочки, observer-stop-hook, owner-seal). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
9.4 KiB
JavaScript
182 lines
9.4 KiB
JavaScript
// 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('onPlanWrite возвращает результат роутера наружу (видимость)', () => {
|
|
const GO = { plan_points_addressed: ['ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' };
|
|
it('routerClassification = результат classify', async () => {
|
|
const r = await onPlanWrite({
|
|
planSteps: STEPS,
|
|
classifyImpl: async () => ({ recommended_chain: ['systematic-debugging'] }),
|
|
registry: {}, llmCall: async () => GO, journalKey: 'k', nowMs: 1, planGoal: 'g',
|
|
});
|
|
expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] });
|
|
});
|
|
it('classifyImpl бросил → routerClassification = { unavailable: true }', async () => {
|
|
const r = await onPlanWrite({
|
|
planSteps: STEPS,
|
|
classifyImpl: async () => { throw new Error('нет'); },
|
|
registry: {}, llmCall: async () => GO, journalKey: 'k', nowMs: 1, planGoal: 'g',
|
|
});
|
|
expect(r.routerClassification).toEqual({ unavailable: true });
|
|
});
|
|
it('без classifyImpl → routerClassification = null (роутер не звался)', async () => {
|
|
const r = await onPlanWrite({ planSteps: STEPS, llmCall: async () => GO, journalKey: 'k', nowMs: 1 });
|
|
expect(r.routerClassification).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('пининг роутера по goalHash (D2)', () => {
|
|
it('пин-попадание → classifyImpl НЕ зовётся, routerClassification = пин', async () => {
|
|
let called = false;
|
|
const r = await onPlanWrite({
|
|
planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1, planGoal: 'g',
|
|
classifyImpl: async () => { called = true; return { recommended_chain: ['fresh'] }; },
|
|
pinLoadImpl: () => ({ recommended_chain: ['pinned'] }),
|
|
});
|
|
expect(called).toBe(false);
|
|
expect(r.routerClassification).toEqual({ recommended_chain: ['pinned'] });
|
|
});
|
|
it('промах пина → classifyImpl зовётся и pinSaveImpl получает результат', async () => {
|
|
let saved = null;
|
|
const r = await onPlanWrite({
|
|
planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1, planGoal: 'g',
|
|
classifyImpl: async () => ({ recommended_chain: ['fresh'] }),
|
|
pinLoadImpl: () => null,
|
|
pinSaveImpl: (rec) => { saved = rec; },
|
|
});
|
|
expect(r.routerClassification).toEqual({ recommended_chain: ['fresh'] });
|
|
expect(saved && saved.classification).toEqual({ recommended_chain: ['fresh'] });
|
|
});
|
|
it('без пин-имплов → classifyImpl зовётся (старое поведение)', async () => {
|
|
let called = false;
|
|
await onPlanWrite({
|
|
planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1, planGoal: 'g',
|
|
classifyImpl: async () => { called = true; return { recommended_chain: ['x'] }; },
|
|
});
|
|
expect(called).toBe(true);
|
|
});
|
|
});
|
|
|
|
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('память круга спеки');
|
|
});
|
|
});
|