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'); }); });