397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
190 lines
12 KiB
JavaScript
190 lines
12 KiB
JavaScript
// 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/);
|
||
});
|
||
});
|