88aa122cf8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
668 lines
31 KiB
JavaScript
668 lines
31 KiB
JavaScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { classifyByRegex, prefilter } from './router-classifier.mjs';
|
|
|
|
describe('prefilter — Phase 2 Task 9 (spec §4.1, 7 checks)', () => {
|
|
it('manual override has priority over continuation (delai cherez TDD)', () => {
|
|
const r = prefilter('делай через TDD', { prevState: null });
|
|
expect(r.task_type).toBe('manual_override');
|
|
expect(r.source).toBe('prefilter');
|
|
expect(r.requested_node).toContain('test-driven-development');
|
|
});
|
|
|
|
it('continuation inherits classification within 30 min', () => {
|
|
const prevState = {
|
|
classification: { task_type: 'feature', recommendedNode: '#19' },
|
|
timestamp: new Date().toISOString(),
|
|
task_id: 'prev-abc',
|
|
};
|
|
const r = prefilter('делай', { prevState });
|
|
expect(r.source).toBe('prefilter_inherited');
|
|
expect(r.task_type).toBe('feature');
|
|
expect(r.inheritance?.inherited_from_task_id).toBe('prev-abc');
|
|
});
|
|
|
|
it('continuation falls through to short-conversation when prev state > 30 min', () => {
|
|
const old = new Date(Date.now() - 31 * 60000).toISOString();
|
|
const r = prefilter('делай', { prevState: { classification: { task_type: 'feature' }, timestamp: old } });
|
|
expect(r.task_type).toBe('conversation');
|
|
});
|
|
|
|
it('acknowledgment is plain conversation (spasibo)', () => {
|
|
expect(prefilter('спасибо', {}).task_type).toBe('conversation');
|
|
});
|
|
|
|
it('cancellation flags previous task rejected (net)', () => {
|
|
expect(prefilter('нет', { prevState: { task_id: 'abc' } }).previous_rejected).toBe(true);
|
|
});
|
|
|
|
it('anchor protection saves "делай аудит" from short-conversation → null fall through', () => {
|
|
expect(prefilter('делай аудит', {})).toBeNull();
|
|
});
|
|
|
|
it('micro keyword fires (poprav\' typo v stroke)', () => {
|
|
expect(prefilter('поправь typo в строке', {}).task_type).toBe('micro');
|
|
});
|
|
|
|
it('content prompt with anchor returns null (forwards to Layer 2)', () => {
|
|
expect(prefilter('добавь endpoint для экспорта сделок', {})).toBeNull();
|
|
});
|
|
|
|
// Brain-retro #6 follow-up (2026-05-26): project-vocabulary anchors so
|
|
// короткие business-prompts проходят через LLM-классификатор, а не
|
|
// глушатся Layer-1 prefilter'ом как "conversation".
|
|
it.each([
|
|
['проверь webhook поставщика', 'supplier domain'],
|
|
['перезапусти очередь', 'queue ops'],
|
|
['накати миграцию', 'migration ops'],
|
|
['проверь RLS на проде', 'RLS policy'],
|
|
['создай партицию на июнь', 'partitioning'],
|
|
['поставщик прислал лиды', 'supplier domain (noun)'],
|
|
['списание дублей за вчера', 'billing'],
|
|
['сделка зависла в воронке', 'CRM funnel'],
|
|
['тенант не видит данные', 'multi-tenant'],
|
|
['джоб не отрабатывает в очереди', 'jobs/queue'],
|
|
])('anchor "%s" forwards to LLM, not glushed (%s)', (prompt /* , label */) => {
|
|
expect(prefilter(prompt, {})).toBeNull();
|
|
});
|
|
});
|
|
|
|
const fakeRegistry = {
|
|
nodes: [
|
|
{ id: '#19', name: 'Superpowers', status: 'active', triggers: [
|
|
{ classification: 'feature', weight: 1.0 },
|
|
{ classification: 'planning', weight: 1.0 },
|
|
] },
|
|
{ id: '#62', name: 'billing-audit', status: 'active', triggers: [
|
|
{ keyword: 'списание', weight: 1.0 },
|
|
{ keyword: 'биллинг', weight: 1.0 },
|
|
{ classification: 'bugfix', weight: 0.5 },
|
|
] },
|
|
{ id: '#74', name: 'marketing', status: 'active', triggers: [
|
|
{ keyword: 'email-рассылка', weight: 1.0 },
|
|
{ keyword: 'кампания', weight: 1.0 },
|
|
{ classification: 'marketing', weight: 1.0 },
|
|
] },
|
|
{ id: '#11', name: 'pint', status: 'active', triggers: [
|
|
{ classification: 'refactor', weight: 1.0 },
|
|
{ classification: 'cleanup', weight: 1.0 },
|
|
] },
|
|
],
|
|
};
|
|
|
|
describe('classifyByRegex — task type', () => {
|
|
it('detects feature from RU keyword «фича»', () => {
|
|
const r = classifyByRegex('давай сделаем новую фичу для биллинга', fakeRegistry);
|
|
expect(r.taskType).toBe('feature');
|
|
});
|
|
|
|
it('detects planning from RU «план»', () => {
|
|
const r = classifyByRegex('напиши план рефакторинга модуля X', fakeRegistry);
|
|
expect(r.taskType).toBe('planning');
|
|
});
|
|
|
|
it('detects bugfix from EN «bug»', () => {
|
|
const r = classifyByRegex('there is a bug in the auth flow', fakeRegistry);
|
|
expect(r.taskType).toBe('bugfix');
|
|
});
|
|
|
|
it('detects micro for typo', () => {
|
|
const r = classifyByRegex('опечатка в файле X', fakeRegistry);
|
|
expect(r.micro).toBe(true);
|
|
});
|
|
|
|
it('detects micro for rename', () => {
|
|
const r = classifyByRegex('переименуй функцию foo в bar', fakeRegistry);
|
|
expect(r.micro).toBe(true);
|
|
});
|
|
|
|
it('returns taskType=unknown when no signal', () => {
|
|
const r = classifyByRegex('просто привет', fakeRegistry);
|
|
expect(r.taskType).toBe('unknown');
|
|
expect(r.micro).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('classifyByRegex — domain node match', () => {
|
|
it('picks #62 billing-audit on «списание»', () => {
|
|
const r = classifyByRegex('почини двойное списание лида', fakeRegistry);
|
|
expect(r.recommendedNode).toBe('#62');
|
|
});
|
|
|
|
it('picks #74 marketing on «email-рассылка»', () => {
|
|
const r = classifyByRegex('составь email-рассылку для тарифа Бизнес', fakeRegistry);
|
|
expect(r.recommendedNode).toBe('#74');
|
|
});
|
|
|
|
it('falls back to classification trigger when no keyword match', () => {
|
|
const r = classifyByRegex('рефакторинг кода', fakeRegistry);
|
|
// 'рефакторинг' → classification: refactor → #11 pint
|
|
expect(r.recommendedNode).toBe('#11');
|
|
});
|
|
|
|
it('returns null when no node matched', () => {
|
|
const r = classifyByRegex('просто вопрос', fakeRegistry);
|
|
expect(r.recommendedNode).toBeNull();
|
|
});
|
|
|
|
it('case-insensitive keyword match', () => {
|
|
const r = classifyByRegex('СПИСАНИЕ дублируется', fakeRegistry);
|
|
expect(r.recommendedNode).toBe('#62');
|
|
});
|
|
});
|
|
|
|
// brain-retro #7 C1 (2026-05-27): owner's translit slang wasn't mapped.
|
|
// «пуш и обнови пилот» / «обнови мозг» / «обнови эталон» bypassed the
|
|
// classifier (taskType=unknown), agent improvised. Cover the vocabulary.
|
|
describe('classifyByRegex — C1 translit slang (brain-retro #7)', () => {
|
|
it('«обнови мозг» → memory-sync', () => {
|
|
const r = classifyByRegex('обнови мозг', fakeRegistry);
|
|
expect(r.taskType).toBe('memory-sync');
|
|
});
|
|
|
|
it('«обнови эталон» → memory-sync (ЭТАЛОН.md file)', () => {
|
|
const r = classifyByRegex('обнови эталон после деплоя', fakeRegistry);
|
|
expect(r.taskType).toBe('memory-sync');
|
|
});
|
|
|
|
it('«обнови пилот» → memory-sync (ПИЛОТ.md file)', () => {
|
|
const r = classifyByRegex('обнови пилот', fakeRegistry);
|
|
expect(r.taskType).toBe('memory-sync');
|
|
});
|
|
|
|
it('«пуш» / «push» → deploy task type', () => {
|
|
expect(classifyByRegex('пуш на main', fakeRegistry).taskType).toBe('deploy');
|
|
expect(classifyByRegex('push origin main', fakeRegistry).taskType).toBe('deploy');
|
|
});
|
|
|
|
it('«запушь» → deploy', () => {
|
|
const r = classifyByRegex('запушь ветку', fakeRegistry);
|
|
expect(r.taskType).toBe('deploy');
|
|
});
|
|
|
|
it('compound «пуш и обнови пилот» — memory-sync wins (later in chain, but specific noun)', () => {
|
|
// Owner's actual phrase from retro #7. First-match rule applies; we test
|
|
// that SOME meaningful classification is returned (not 'unknown').
|
|
const r = classifyByRegex('пуш и обнови пилот', fakeRegistry);
|
|
expect(r.taskType).not.toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('classifyByRegex — source tag', () => {
|
|
it('always marks source: regex', () => {
|
|
const r = classifyByRegex('test', fakeRegistry);
|
|
expect(r.source).toBe('regex');
|
|
});
|
|
});
|
|
|
|
describe('classifyByRegex — confidence', () => {
|
|
it('returns confidence>=0.8 for clean keyword match', () => {
|
|
const r = classifyByRegex('списание дублируется', fakeRegistry);
|
|
expect(r.confidence).toBeGreaterThanOrEqual(0.8);
|
|
});
|
|
|
|
it('returns confidence<0.5 when ambiguous (no clean match)', () => {
|
|
const r = classifyByRegex('что-то непонятное', fakeRegistry);
|
|
expect(r.confidence).toBeLessThan(0.5);
|
|
});
|
|
});
|
|
|
|
import { buildLLMPrompt, parseLLMResponse, classify, callAnthropicAPI, buildClassifierPrompt, parseClassifierResponse, buildClassifierPromptStructured } from './router-classifier.mjs';
|
|
|
|
describe('buildClassifierPrompt — Phase 2 Task 10 (spec §4.2)', () => {
|
|
it('includes 4 памятка patterns when enrichment=true', () => {
|
|
const p = buildClassifierPrompt('добавь фичу', { nodes: [], chains: {} }, { enrichment: true });
|
|
expect(p).toContain('ПАТТЕРН 1');
|
|
expect(p).toContain('ПАТТЕРН 2');
|
|
expect(p).toContain('ПАТТЕРН 3');
|
|
expect(p).toContain('ПАТТЕРН 4');
|
|
});
|
|
|
|
it('omits памятка when enrichment=false', () => {
|
|
const p = buildClassifierPrompt('x', { nodes: [], chains: {} }, { enrichment: false });
|
|
expect(p).not.toContain('ПАТТЕРН 1');
|
|
});
|
|
|
|
it('embeds user prompt verbatim', () => {
|
|
const p = buildClassifierPrompt('почини двойное списание', { nodes: [], chains: {} });
|
|
expect(p).toContain('почини двойное списание');
|
|
});
|
|
|
|
it('lists only active nodes with capabilities in YAML-ish block', () => {
|
|
const reg = {
|
|
nodes: [
|
|
{ id: '#62', name: 'billing-audit', slug: 'billing-audit', status: 'active', capabilities: 'audits money invariants', triggers: [{ keyword: 'списание', weight: 1 }] },
|
|
{ id: '#999', name: 'gone', slug: 'gone', status: 'historic', capabilities: 'should be hidden', triggers: [] },
|
|
],
|
|
chains: {},
|
|
};
|
|
const p = buildClassifierPrompt('test', reg);
|
|
expect(p).toMatch(/#62/);
|
|
expect(p).toMatch(/billing-audit/);
|
|
expect(p).toMatch(/audits money invariants/);
|
|
expect(p).not.toMatch(/#999/);
|
|
expect(p).not.toMatch(/should be hidden/);
|
|
});
|
|
});
|
|
|
|
describe('parseClassifierResponse — Phase 2 Task 10 (spec §4.2)', () => {
|
|
it('accepts null recommended_chain_id', () => {
|
|
const r = parseClassifierResponse('{"task_type":"feature","recommended_node":"x","recommended_chain":["x"],"recommended_chain_id":null,"alternatives_considered":[],"no_skill_found":false}');
|
|
expect(r.recommended_chain_id).toBeNull();
|
|
expect(r.task_type).toBe('feature');
|
|
});
|
|
|
|
it('returns null on malformed JSON', () => {
|
|
expect(parseClassifierResponse('nope')).toBeNull();
|
|
});
|
|
|
|
it('returns null when task_type missing', () => {
|
|
expect(parseClassifierResponse('{"recommended_node":"x"}')).toBeNull();
|
|
});
|
|
|
|
it('strips ```json fence wrapper', () => {
|
|
const r = parseClassifierResponse('```json\n{"task_type":"bugfix","recommended_node":"#62","recommended_chain":[],"recommended_chain_id":null,"alternatives_considered":[],"no_skill_found":false}\n```');
|
|
expect(r.task_type).toBe('bugfix');
|
|
});
|
|
|
|
// G (2026-05-26): brain-retro #6 surfaced parse_null on real LLM responses.
|
|
// parseClassifierResponse used to fail on raw newlines inside string values
|
|
// and trailing commas, common in Sonnet output with long reason_for_choice.
|
|
it('handles raw newlines inside string values (Sonnet long reason_for_choice)', () => {
|
|
const r = parseClassifierResponse('{"task_type":"chain","reason_for_choice":"Запрос проверки\nspans two lines"}');
|
|
expect(r).not.toBeNull();
|
|
expect(r.task_type).toBe('chain');
|
|
});
|
|
|
|
it('handles trailing commas before closing brace', () => {
|
|
const r = parseClassifierResponse('{"task_type":"feature","recommended_node":"#19",}');
|
|
expect(r).not.toBeNull();
|
|
expect(r.task_type).toBe('feature');
|
|
});
|
|
|
|
it('handles raw newlines AND fence wrapper combined', () => {
|
|
const r = parseClassifierResponse('```json\n{"task_type":"bugfix","reason":"first line\nsecond line\nthird"}\n```');
|
|
expect(r).not.toBeNull();
|
|
expect(r.task_type).toBe('bugfix');
|
|
});
|
|
});
|
|
|
|
describe('buildLLMPrompt', () => {
|
|
it('serializes active nodes with id+name+top-3 triggers', () => {
|
|
const prompt = buildLLMPrompt('почини списание', fakeRegistry);
|
|
expect(prompt).toMatch(/#62/);
|
|
expect(prompt).toMatch(/billing-audit/);
|
|
expect(prompt).toMatch(/списание/);
|
|
expect(prompt).toMatch(/почини списание/);
|
|
});
|
|
|
|
it('excludes inactive nodes', () => {
|
|
const reg = { nodes: [...fakeRegistry.nodes, { id: '#999', name: 'gone', status: 'historic', triggers: [] }] };
|
|
const prompt = buildLLMPrompt('test', reg);
|
|
expect(prompt).not.toMatch(/#999/);
|
|
});
|
|
});
|
|
|
|
describe('parseLLMResponse', () => {
|
|
it('parses JSON object', () => {
|
|
const r = parseLLMResponse('{"taskType":"bugfix","micro":false,"recommendedNode":"#62","confidence":0.9,"recommendedChain":null,"reasoning":"keyword списание"}');
|
|
expect(r.taskType).toBe('bugfix');
|
|
expect(r.recommendedNode).toBe('#62');
|
|
expect(r.confidence).toBe(0.9);
|
|
});
|
|
|
|
it('parses JSON wrapped in ```json``` block', () => {
|
|
const r = parseLLMResponse('```json\n{"taskType":"feature","micro":false,"recommendedNode":"#19","confidence":0.8}\n```');
|
|
expect(r.taskType).toBe('feature');
|
|
});
|
|
|
|
it('returns null on unparseable response', () => {
|
|
expect(parseLLMResponse('I cannot help with this')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('classify — full integration (with mock LLM)', () => {
|
|
it('falls back to regex on LLM transport error (long prompt, prefilter null)', async () => {
|
|
const r = await classify('почини двойное списание лида срочно', fakeRegistry, {
|
|
llmCall: () => { throw new Error('proxyapi 503'); },
|
|
});
|
|
expect(r.source).toBe('regex');
|
|
expect(r.recommendedNode).toBe('#62');
|
|
expect(r.degraded).toBe(true);
|
|
expect(r.llmError).toContain('proxyapi 503');
|
|
});
|
|
|
|
it('escalates to LLM when prefilter returns null', async () => {
|
|
const r = await classify('добавь endpoint экспорта сделок', fakeRegistry, {
|
|
llmCall: async () => ({ task_type: 'feature', recommended_node: '#19', recommended_chain: ['#19'], recommended_chain_id: 'L1', alternatives_considered: [], no_skill_found: false }),
|
|
});
|
|
expect(r.source).toBe('llm');
|
|
expect(r.task_type).toBe('feature');
|
|
});
|
|
|
|
it('uses cache on second call with same long prompt', async () => {
|
|
let calls = 0;
|
|
const llmCall = async () => {
|
|
calls++;
|
|
return { task_type: 'feature', recommended_node: '#19', recommended_chain: ['#19'], recommended_chain_id: 'L1', alternatives_considered: [], no_skill_found: false };
|
|
};
|
|
const cache = new Map();
|
|
await classify('добавь endpoint для нового lookup сервиса', fakeRegistry, { llmCall, cache });
|
|
await classify('добавь endpoint для нового lookup сервиса', fakeRegistry, { llmCall, cache });
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('returns prefilter result without invoking LLM (short conversation)', async () => {
|
|
let llmCalled = false;
|
|
const r = await classify('спасибо', fakeRegistry, { llmCall: async () => { llmCalled = true; return null; } });
|
|
expect(r.task_type).toBe('conversation');
|
|
expect(r.source).toBe('prefilter');
|
|
expect(llmCalled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('callAnthropicAPI — ProxyAPI wiring', () => {
|
|
it('posts to ProxyAPI base by default with Bearer auth', async () => {
|
|
delete process.env.ROUTER_LLM_BASE_URL; // герметичность: машинный env не влияет на default-тест
|
|
let captured;
|
|
const fetchImpl = async (url, opts) => {
|
|
captured = { url, opts };
|
|
return { ok: true, json: async () => ({ content: [{ text: '{"taskType":"question"}' }] }) };
|
|
};
|
|
const text = await callAnthropicAPI('hi', { apiKey: 'sk-test', fetchImpl });
|
|
expect(captured.url).toBe('https://api.proxyapi.ru/anthropic/v1/messages');
|
|
expect(captured.opts.headers.authorization).toBe('Bearer sk-test');
|
|
expect(text).toContain('question');
|
|
});
|
|
|
|
it('ROUTER_LLM_BASE_URL env переключает оператора для ВСЕХ потребителей транспорта (смена оператора 2026-06-12: судья/наставник звали callAnthropicAPI без baseUrl → хардкод-прокси)', async () => {
|
|
process.env.ROUTER_LLM_BASE_URL = 'https://api.aitunnel.ru';
|
|
try {
|
|
let capturedUrl;
|
|
const fetchImpl = async (url) => { capturedUrl = url; return { ok: true, json: async () => ({ content: [{ text: 'x' }] }) }; };
|
|
await callAnthropicAPI('hi', { apiKey: 'k', fetchImpl });
|
|
expect(capturedUrl).toBe('https://api.aitunnel.ru/v1/messages');
|
|
} finally { delete process.env.ROUTER_LLM_BASE_URL; }
|
|
});
|
|
|
|
it('явный baseUrl-параметр сильнее env (контракт вызова не ломается)', async () => {
|
|
process.env.ROUTER_LLM_BASE_URL = 'https://api.aitunnel.ru';
|
|
try {
|
|
let capturedUrl;
|
|
const fetchImpl = async (url) => { capturedUrl = url; return { ok: true, json: async () => ({ content: [{ text: 'x' }] }) }; };
|
|
await callAnthropicAPI('hi', { apiKey: 'k', baseUrl: 'https://example.test', fetchImpl });
|
|
expect(capturedUrl).toBe('https://example.test/v1/messages');
|
|
} finally { delete process.env.ROUTER_LLM_BASE_URL; }
|
|
});
|
|
|
|
it('honors a custom baseUrl and strips trailing slash', async () => {
|
|
let capturedUrl;
|
|
const fetchImpl = async (url) => {
|
|
capturedUrl = url;
|
|
return { ok: true, json: async () => ({ content: [{ text: 'x' }] }) };
|
|
};
|
|
await callAnthropicAPI('hi', { apiKey: 'k', baseUrl: 'https://example.test/', fetchImpl });
|
|
expect(capturedUrl).toBe('https://example.test/v1/messages');
|
|
});
|
|
|
|
it('throws on non-ok response', async () => {
|
|
const fetchImpl = async () => ({ ok: false, status: 401, text: async () => 'Invalid API Key' });
|
|
await expect(callAnthropicAPI('hi', { apiKey: 'bad', fetchImpl })).rejects.toThrow(/401/);
|
|
});
|
|
});
|
|
|
|
describe('classify — isolation from Claude Code auth', () => {
|
|
it('skips LLM and falls back to regex when ROUTER_LLM_KEY is absent', async () => {
|
|
const saved = process.env.ROUTER_LLM_KEY;
|
|
delete process.env.ROUTER_LLM_KEY;
|
|
try {
|
|
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
|
expect(r.source).toBe('regex');
|
|
} finally {
|
|
if (saved !== undefined) process.env.ROUTER_LLM_KEY = saved;
|
|
}
|
|
});
|
|
|
|
it('does NOT read ANTHROPIC_API_KEY (would hijack the main session)', async () => {
|
|
const savedRouter = process.env.ROUTER_LLM_KEY;
|
|
const savedAnthropic = process.env.ANTHROPIC_API_KEY;
|
|
delete process.env.ROUTER_LLM_KEY;
|
|
process.env.ANTHROPIC_API_KEY = 'sk-should-not-be-used';
|
|
try {
|
|
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
|
// No ROUTER_LLM_KEY → must stay on regex even though ANTHROPIC_API_KEY is set.
|
|
expect(r.source).toBe('regex');
|
|
} finally {
|
|
if (savedRouter !== undefined) process.env.ROUTER_LLM_KEY = savedRouter;
|
|
if (savedAnthropic !== undefined) process.env.ANTHROPIC_API_KEY = savedAnthropic;
|
|
else delete process.env.ANTHROPIC_API_KEY;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('callAnthropicAPI — Pass 2 metrics (project-brain-factor-analysis-4passes)', () => {
|
|
it('emits onMetrics({latency_ms, retry_count_internal}) on success', async () => {
|
|
const fetchImpl = async () => ({ ok: true, json: async () => ({ content: [{ text: '{"task_type":"question"}' }] }) });
|
|
let captured = null;
|
|
await callAnthropicAPI('hi', { apiKey: 'k', fetchImpl, onMetrics: (m) => { captured = m; } });
|
|
expect(captured).not.toBeNull();
|
|
expect(typeof captured.latency_ms).toBe('number');
|
|
expect(captured.latency_ms).toBeGreaterThanOrEqual(0);
|
|
expect(captured.retry_count_internal).toBe(0);
|
|
});
|
|
|
|
it('emits onMetrics with retry_count_internal>0 after 5xx retries', async () => {
|
|
let calls = 0;
|
|
const fetchImpl = async () => {
|
|
calls += 1;
|
|
if (calls < 3) return { ok: false, status: 503, text: async () => 'unavailable' };
|
|
return { ok: true, json: async () => ({ content: [{ text: '{"task_type":"question"}' }] }) };
|
|
};
|
|
let captured = null;
|
|
const sleepImpl = () => Promise.resolve(); // skip backoff in tests
|
|
await callAnthropicAPI('hi', { apiKey: 'k', fetchImpl, sleepImpl, onMetrics: (m) => { captured = m; } });
|
|
expect(captured.retry_count_internal).toBe(2);
|
|
});
|
|
|
|
it('emits onMetrics even on fatal 4xx (so latency / retry count reach the classifier state)', async () => {
|
|
const fetchImpl = async () => ({ ok: false, status: 401, text: async () => 'invalid key' });
|
|
let captured = null;
|
|
await expect(callAnthropicAPI('hi', { apiKey: 'k', fetchImpl, onMetrics: (m) => { captured = m; } })).rejects.toThrow(/401/);
|
|
expect(captured).not.toBeNull();
|
|
expect(typeof captured.latency_ms).toBe('number');
|
|
expect(captured.retry_count_internal).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('classify — Pass 2 metrics surface to result', () => {
|
|
const fakeRegistry = { nodes: [{ id: '#19', status: 'active', triggers: [] }], chains: {} };
|
|
|
|
it('attaches latency_ms / retry_count_internal on LLM success', async () => {
|
|
const llmCall = async ({ onMetrics } = {}) => {
|
|
if (onMetrics) onMetrics({ latency_ms: 432, retry_count_internal: 1 });
|
|
return { task_type: 'feature', recommended_node: '#19', recommended_chain: null, recommended_chain_id: null, alternatives_considered: [] };
|
|
};
|
|
const r = await classify('новая фича: добавь endpoint X', fakeRegistry, { llmCall });
|
|
expect(r.source).toBe('llm');
|
|
expect(r.latency_ms).toBe(432);
|
|
expect(r.retry_count_internal).toBe(1);
|
|
});
|
|
|
|
it('passes through alternatives_considered from Sonnet (truncated to top-3 by enricher, not by classify)', async () => {
|
|
const llmCall = async () => ({
|
|
task_type: 'feature', recommended_node: '#19', recommended_chain: null, recommended_chain_id: null,
|
|
alternatives_considered: [{ node: '#19', score: 0.8 }, { node: '#62', score: 0.4 }],
|
|
});
|
|
const r = await classify('новая фича X', fakeRegistry, { llmCall });
|
|
expect(r.alternatives_considered).toBeDefined();
|
|
expect(r.alternatives_considered).toHaveLength(2);
|
|
});
|
|
|
|
it('sets llm_error_type=econnreset / latency / retry_count on transport error', async () => {
|
|
const llmCall = async ({ onMetrics } = {}) => {
|
|
if (onMetrics) onMetrics({ latency_ms: 1234, retry_count_internal: 4 });
|
|
const e = new Error('fetch failed: ECONNRESET'); throw e;
|
|
};
|
|
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
|
expect(r.source).toBe('regex');
|
|
expect(r.llm_error_type).toBe('econnreset');
|
|
expect(r.latency_ms).toBe(1234);
|
|
expect(r.retry_count_internal).toBe(4);
|
|
});
|
|
|
|
it('sets llm_error_type=timeout on AbortError or per-attempt timeout', async () => {
|
|
const llmCall = async () => {
|
|
const e = new Error('per-attempt timeout 30000ms'); throw e;
|
|
};
|
|
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
|
expect(r.llm_error_type).toBe('timeout');
|
|
});
|
|
|
|
it('sets llm_error_type=http_4xx on fatal upstream 4xx', async () => {
|
|
const llmCall = async () => { const e = new Error('Router LLM 401: invalid key'); e.fatal = true; throw e; };
|
|
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
|
expect(r.llm_error_type).toBe('http_4xx');
|
|
});
|
|
|
|
it('sets llm_error_type=http_5xx on exhausted retries', async () => {
|
|
const llmCall = async () => { const e = new Error('Router LLM 503: bad gateway'); throw e; };
|
|
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
|
expect(r.llm_error_type).toBe('http_5xx');
|
|
});
|
|
|
|
it('sets llm_error_type=parse_null when llmCall returns null (LLM produced unparseable response)', async () => {
|
|
// Mocked llmCall returns null without throwing — simulates upstream parse failure
|
|
// after a successful HTTP exchange. onMetrics still fires from the mocked path.
|
|
const llmCall = async ({ onMetrics } = {}) => {
|
|
if (onMetrics) onMetrics({ latency_ms: 800, retry_count_internal: 0 });
|
|
return null;
|
|
};
|
|
const r = await classify('что-то непонятное вообще', fakeRegistry, { llmCall });
|
|
expect(r.llm_error_type).toBe('parse_null');
|
|
expect(r.latency_ms).toBe(800);
|
|
});
|
|
});
|
|
|
|
// Phase 3 PAMYATKA extensions — patterns 5-8 added per brain-retro #9 candidates 7/1/8/10.
|
|
describe('PAMYATKA extensions (Phase 3 brain-retro #9)', () => {
|
|
const registry = { nodes: [{ id: '#19', name: 'coder', slug: 'coder', status: 'active', triggers: [] }], chains: {} };
|
|
|
|
it('PATTERN 5 (feature → writing-plans) is present in system prompt when enrichment=true', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
expect(system).toContain('ПАТТЕРН 5');
|
|
expect(system).toMatch(/добавь.*реализуй.*сделай|реализуй.*добавь|writing-plans/);
|
|
expect(system).toMatch(/feature.*≥3|≥3.*шаг/);
|
|
});
|
|
|
|
it('PATTERN 5 absent when enrichment=false', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: false });
|
|
expect(system).not.toContain('ПАТТЕРН 5');
|
|
});
|
|
|
|
it('PATTERN 6 (bugfix → systematic-debugging + Pest #18) is present', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
expect(system).toContain('ПАТТЕРН 6');
|
|
expect(system).toMatch(/systematic-debugging.*Pest|Pest.*systematic-debugging|#18/);
|
|
expect(system).toMatch(/regex|catastrophic|backtracking|исправь баг/);
|
|
});
|
|
|
|
it('PATTERN 7 (prod error → Sentry MCP #34) is present', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
expect(system).toContain('ПАТТЕРН 7');
|
|
expect(system).toMatch(/Sentry|#34/);
|
|
expect(system).toMatch(/боевой|prod|production|liderra\.ru|клиент сообщ/);
|
|
});
|
|
|
|
it('PATTERN 8 (mechanical work → coder-agent via Task) is present', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
expect(system).toContain('ПАТТЕРН 8');
|
|
expect(system).toMatch(/coder-agent|#19|Task tool|субагент/);
|
|
expect(system).toMatch(/однотипн|механич|N одинаковых|перенеси все/);
|
|
});
|
|
|
|
it('PAMYATKA header reflects 8 patterns total', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
expect(system).toMatch(/=== ПАМЯТКА \(8 паттернов\) ===/);
|
|
});
|
|
|
|
it('all 8 patterns present in correct order', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
const indices = [1, 2, 3, 4, 5, 6, 7, 8].map(n => system.indexOf(`ПАТТЕРН ${n}`));
|
|
indices.forEach((idx, i) => expect(idx, `ПАТТЕРН ${i + 1} missing`).toBeGreaterThan(-1));
|
|
for (let i = 1; i < indices.length; i++) {
|
|
expect(indices[i]).toBeGreaterThan(indices[i - 1]);
|
|
}
|
|
});
|
|
|
|
it('original 4 patterns (brainstorming, discovery, plans, debugging) preserved verbatim', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
expect(system).toContain('минимум 3 alternative_considered');
|
|
expect(system).toContain('discovery-interview');
|
|
expect(system).toMatch(/single-step.*multi-step|multi-step.*single-step/);
|
|
expect(system).toContain('system/expected/actual');
|
|
});
|
|
});
|
|
|
|
// ── Rasinhron + max_tokens fixes (router-classifier.mjs) ────────────────────
|
|
// 2026-06-01: classifier parse_null ~46% root cause — structured prompt never
|
|
// asked for a `task_type` field while parseClassifierResponse hard-required it;
|
|
// plus max_tokens 1500 too low for future long skill chains (silent truncation).
|
|
|
|
describe('callAnthropicAPI — max_tokens budget for long chains', () => {
|
|
it('sends max_tokens 15000 for the structured {system,user} form', async () => {
|
|
let body;
|
|
const fetchImpl = async (url, opts) => {
|
|
body = JSON.parse(opts.body);
|
|
return { ok: true, json: async () => ({ content: [{ text: '{"task_type":"question"}' }] }) };
|
|
};
|
|
await callAnthropicAPI({ system: 'S', user: 'U' }, { apiKey: 'k', fetchImpl });
|
|
expect(body.max_tokens).toBe(15000);
|
|
});
|
|
|
|
it('sends max_tokens 15000 for the legacy string form', async () => {
|
|
let body;
|
|
const fetchImpl = async (url, opts) => {
|
|
body = JSON.parse(opts.body);
|
|
return { ok: true, json: async () => ({ content: [{ text: '{"task_type":"question"}' }] }) };
|
|
};
|
|
await callAnthropicAPI('hi', { apiKey: 'k', fetchImpl });
|
|
expect(body.max_tokens).toBe(15000);
|
|
});
|
|
});
|
|
|
|
describe('parseClassifierResponse — task_type/taskType contract (rasinhron)', () => {
|
|
it('accepts camelCase taskType and normalizes to task_type', () => {
|
|
const r = parseClassifierResponse('{"taskType":"bugfix","recommended_chain":["#18"]}');
|
|
expect(r).not.toBeNull();
|
|
expect(r.task_type).toBe('bugfix');
|
|
});
|
|
|
|
it('still returns null when neither task_type nor taskType present', () => {
|
|
expect(parseClassifierResponse('{"recommended_node":"x"}')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildClassifierPromptStructured — requires task_type field (rasinhron)', () => {
|
|
const registry = { nodes: [{ id: '#19', name: 'coder', slug: 'coder', status: 'active', triggers: [] }], chains: {} };
|
|
|
|
it('includes a JSON output example with a quoted "task_type" field', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: true });
|
|
expect(system).toContain('"task_type"');
|
|
});
|
|
|
|
it('keeps the example present even when enrichment=false', () => {
|
|
const { system } = buildClassifierPromptStructured('тест', registry, { enrichment: false });
|
|
expect(system).toContain('"task_type"');
|
|
});
|
|
});
|
|
|
|
describe('buildClassifierPromptStructured classifierContext (config-seam §D1)', () => {
|
|
const reg = { nodes: [], chains: {} };
|
|
it('дефолт → текущая строка «Лидерра»', () => {
|
|
expect(buildClassifierPromptStructured('p', reg).system).toContain('«Лидерра»');
|
|
});
|
|
it('classifierContext инъектируется', () => {
|
|
expect(buildClassifierPromptStructured('p', reg, { classifierContext: 'ТестПроект XYZ' }).system)
|
|
.toContain('ТестПроект XYZ');
|
|
});
|
|
});
|