2026-06-15 08:06:08 +03:00
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"' ) ;
} ) ;
} ) ;
2026-06-15 14:16:14 +03:00
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' ) ;
} ) ;
} ) ;