// tools/mentor-seam.test.mjs import { describe, it, expect } from 'vitest'; import { buildMentorPrompt, renderSkillContext } from './mentor-seam.mjs'; describe('renderSkillContext (мерж роутер↔наставник)', () => { it('содержит объявленные скилы и рекомендацию роутера', () => { const s = renderSkillContext({ declared: ['executing-plans'], recommendedChain: ['systematic-debugging', 'test-driven-development'] }); expect(s).toMatch(/executing-plans/); expect(s).toMatch(/systematic-debugging/); expect(s).toMatch(/скил/i); }); it('classify недоступен (null) → маркер «рекомендация недоступна», не пусто', () => { const s = renderSkillContext({ declared: ['x'], recommendedChain: null }); expect(s).toMatch(/недоступн/i); }); }); describe('renderSkillContext — резолв ID→имя (фикс)', () => { const registry = { indexById: new Map([['#4', { id: '#4', name: 'markdownlint-cli2', slug: 'markdownlint' }]]) }; it('с реестром резолвит #4 → имя', () => { const s = renderSkillContext({ declared: ['executing-plans'], recommendedChain: ['#4'], registry }); expect(s).toMatch(/#4 — markdownlint-cli2/); }); it('без реестра → голый ID (fail-safe)', () => { const s = renderSkillContext({ declared: ['x'], recommendedChain: ['#4'] }); expect(s).toMatch(/#4/); expect(s).not.toMatch(/markdownlint/); }); it('неизвестный ID → голый ID (не падает)', () => { const s = renderSkillContext({ declared: ['x'], recommendedChain: ['#999'], registry }); expect(s).toMatch(/#999/); }); }); describe('buildMentorPrompt (§6.2)', () => { const graphSection = { kind: 'project-graph', districtCount: 2, layer0: [{ district: 'tools', nodeCount: 3 }, { district: 'app-backend', nodeCount: 7 }], staleness: { stale: true, commits_behind: 12, uncommitted: 1 } }; const verifiedContext = [{ id: '1', claim: 'есть runRouter', ref: 'router-engine.mjs:140', kind: 'EXTRACTED' }]; const negotiationLog = [{ round: 1, side: 'mentor', utterance: 'расширь', justification: 'риск' }]; it('возвращает {system, user}', () => { const p = buildMentorPrompt({ prompt: 'сделай X', graphSection, verifiedContext, negotiationLog, catalog: { nodes: [{ id: 'n1', slug: 's', capabilities: 'c' }] } }); expect(typeof p.system).toBe('string'); expect(typeof p.user).toBe('string'); }); it('user содержит проверенный контекст и журнал переговоров', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection, verifiedContext, negotiationLog }); expect(p.user).toMatch(/ПРОВЕРЕННЫЙ КОНТЕКСТ/); expect(p.user).toMatch(/runRouter/); expect(p.user).toMatch(/ПЕРЕГОВОРЫ/); expect(p.user).toMatch(/расширь/); }); // FR-2 (финревью 2026-06-11, канон W1): районы+staleness рендерит БАЗОВЫЙ // buildRouterPrompt в system; seam НЕ дублирует в user (Д-1а-обход снят). it('staleness виден наставнику (✅O16 сигнал) — в system (W1-канон)', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection, verifiedContext, negotiationLog }); expect(p.system).toMatch(/staleness|устарел|commits_behind/i); }); it('карта районов (layer0) рендерится в system (W1-канон; A3 нах.F1/C-2)', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection, verifiedContext, negotiationLog }); expect(p.system).toMatch(/КАРТА РАЙОНОВ/); expect(p.system).toMatch(/tools/); expect(p.system).toMatch(/app-backend/); }); it('FR-2: карта районов НЕ дублируется — ровно одно вхождение на system+user', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection, verifiedContext, negotiationLog }); const total = `${p.system}\n${p.user}`; expect(total.match(/КАРТА РАЙОНОВ/g)).toHaveLength(1); expect(total.match(/staleness/gi)).toHaveLength(1); }); it('FR-2: пустой layer0 + staleness → маркер ОТСУТСТВИЯ (F-C6) и staleness-строка в user (O16 сигнал-всегда)', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection: { layer0: [], staleness: { stale: true, commits_behind: null, uncommitted: 3 } } }); expect(p.user).toMatch(/КАРТА РАЙОНОВ ОТСУТСТВУЕТ/); expect(p.user).toMatch(/СВЕЖЕСТЬ ГРАФА|staleness/i); }); // VA-2 (финревью 3/5, «сигнал-всегда»): пустой verifiedContext НЕ молчит — наставник // видит, что контекста НЕТ (VA-9 ловит только на печати, круги идут до неё). it('VA-2: пустой verifiedContext → явный маркер КОНТЕКСТ ПУСТ в user', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection, verifiedContext: [] }); expect(p.user).toMatch(/КОНТЕКСТ ПУСТ/); }); it('system несёт ДР-1 гранулярность (A8): растяжка + detectHighRisk', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection }); expect(p.system).toMatch(/растяжка до следующей НЕИЗВЕСТНОСТИ/); expect(p.system).toMatch(/detectHighRisk/); }); }); // Task 5 — runMentorRound + петля + F7: импорты внизу перед describe (ESM hoisting) import { runMentorRound, mentorLoop, MENTOR_ROUND_CAP, DISTRICT_PROBE_CAP } from './mentor-seam.mjs'; import { buildNodeGraph } from './node-graph.mjs'; describe('runMentorRound (§4/6.1)', () => { // Д-3: фикстура через buildNodeGraph (resolveNode ждёт byId/bySlug/byName Map) const groundingGraph = buildNodeGraph({ nodes: [ { id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' }, ], chains: {} }); const trace = { candidates: ['writing-plans'], chosen: 'writing-plans', why_chosen: 'подходит', twins_considered: 'не brainstorming', confidence: 0.9 }; it('MENTOR_ROUND_CAP = 3', () => { expect(MENTOR_ROUND_CAP).toBe(3); }); it('валидный трейс → ok, wired:true', async () => { const r = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [] }, llmCall: async () => trace }); expect(r.ok).toBe(true); expect(r.wired).toBe(true); expect(r.abstain).toBe(false); }); it('низкая уверенность → abstain (5.2)', async () => { const lo = { ...trace, confidence: 0.2 }; const r = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [] }, llmCall: async () => lo }); expect(r.abstain).toBe(true); }); it('сбой вызова → ok:false, wired:false (НЕ суд, SE-R6-6)', async () => { const r = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [] }, llmCall: async () => { throw new Error('сеть'); } }); expect(r.ok).toBe(false); expect(r.wired).toBe(false); }); it('выдуманный узел → ok:false (заземление)', async () => { const bad = { candidates: ['ВЫДУМКА'], chosen: 'ВЫДУМКА', why_chosen: 'w', twins_considered: 't', confidence: 0.9 }; const r = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [] }, llmCall: async () => bad }); expect(r.ok).toBe(false); }); }); describe('F7 — дозапрос соседнего района (A6/Н-3, ДР-P1)', () => { const groundingGraph = buildNodeGraph({ nodes: [ { id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' }, ], chains: {} }); it('DISTRICT_PROBE_CAP = 2', () => { expect(DISTRICT_PROBE_CAP).toBe(2); }); it('трасса с request_district в пределах cap → сигнал escalateDistrict (конструктивное воздержание)', async () => { const r = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [] }, districtProbesUsed: 0, llmCall: async () => ({ request_district: 'app-backend', justification: 'нужен сосед' }) }); expect(r.ok).toBe(true); expect(r.abstain).toBe(true); expect(r.escalateDistrict).toBe('app-backend'); }); it('request_district СВЕРХ cap → отказ (ok:false, без escalateDistrict)', async () => { const r = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [] }, districtProbesUsed: 2, llmCall: async () => ({ request_district: 'app-backend', justification: 'ещё' }) }); expect(r.ok).toBe(false); expect(r.escalateDistrict).toBe(null); expect(r.reason).toMatch(/DISTRICT_PROBE_CAP/); }); // VA-3 (финревью 3/5): несуществующий район НЕ эскалируется молча — при непустой // карте (layer0) имя проверяется; вне карты → отказ с reason (опечатка ≠ пустая деталь). it('VA-3: request_district вне карты районов (layer0 непуст) → отказ, не эскалация', async () => { const gs = { layer0: [{ district: 'tools', nodeCount: 3 }] }; const r = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: gs, districtProbesUsed: 0, llmCall: async () => ({ request_district: 'нет-такого', justification: 'опечатка' }) }); expect(r.ok).toBe(false); expect(r.escalateDistrict).toBe(null); expect(r.reason).toMatch(/район|VA-3/i); }); it('VA-3: район ИЗ карты → эскалация как раньше; пустая карта → старое поведение (валидировать нечем)', async () => { const inMap = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [{ district: 'tools', nodeCount: 3 }] }, districtProbesUsed: 0, llmCall: async () => ({ request_district: 'tools', justification: 'сосед' }) }); expect(inMap.abstain).toBe(true); expect(inMap.escalateDistrict).toBe('tools'); const noMap = await runMentorRound({ prompt: 'X', groundingGraph, graphSection: { layer0: [] }, districtProbesUsed: 0, llmCall: async () => ({ request_district: 'app-backend', justification: 'сосед' }) }); expect(noMap.escalateDistrict).toBe('app-backend'); }); }); describe('mentorLoop (§4/6.4 — Н-1)', () => { it('круг ok без abstain → resolved, без эскалации', async () => { const out = await mentorLoop({ runRoundImpl: async () => ({ ok: true, abstain: false }) }); expect(out.resolved).toBe(true); expect(out.escalate).toBe(false); expect(out.round).toBe(1); }); it('потолок кругов без согласия → escalate к владельцу (ДР-6)', async () => { const out = await mentorLoop({ runRoundImpl: async () => ({ ok: true, abstain: true }) }); expect(out.resolved).toBe(false); expect(out.escalate).toBe(true); expect(out.round).toBe(MENTOR_ROUND_CAP); }); }); describe('sharp-edges F-C5/F-C6', () => { const gg = buildNodeGraph({ nodes: [ { id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' }, ], chains: {} }); it('F-C6: без graphSection в user явный маркер ОТСУТСТВИЯ карты (слой-0 ВСЕГДА, сигнал §5.4)', () => { const p = buildMentorPrompt({ prompt: 'X' }); expect(p.user).toMatch(/КАРТА РАЙОНОВ ОТСУТСТВУЕТ/); }); it('F-C6: пустой layer0 → тоже маркер отсутствия', () => { const p = buildMentorPrompt({ prompt: 'X', graphSection: { layer0: [] } }); expect(p.user).toMatch(/КАРТА РАЙОНОВ ОТСУТСТВУЕТ/); }); it('F-C5: districtProbesUsed НЕ передан → дозапрос района отклоняется (secure default — омиссия не даёт безлимит)', async () => { const r = await runMentorRound({ prompt: 'X', groundingGraph: gg, graphSection: { layer0: [] }, llmCall: async () => ({ request_district: 'app-backend', justification: 'нужен' }) }); expect(r.ok).toBe(false); expect(r.escalateDistrict).toBe(null); expect(r.reason).toMatch(/DISTRICT_PROBE_CAP/); }); });