// tools/router-gate-decide.test.mjs import { describe, it, expect } from 'vitest'; import { SAFE_BASELINE_TOOLS, MUTATING_TOOLS, isSafeBaselineTool, isMutatingTool, nodeMatches, detectDirectInvocation, crossCheckSelfSuggested, isChainExpired, newChainState, chainStateUpdate, decide, } from './router-gate-decide.mjs'; // ─── Step 1: tool classification + nodeMatches ──────────────────────────── describe('tool classification', () => { it('Read is safe-baseline', () => { expect(isSafeBaselineTool('Read')).toBe(true); }); it('Edit is mutating', () => { expect(isMutatingTool('Edit')).toBe(true); }); it('Edit is not safe-baseline', () => { expect(isSafeBaselineTool('Edit')).toBe(false); }); it('SAFE_BASELINE_TOOLS contains TodoWrite', () => { expect(SAFE_BASELINE_TOOLS).toContain('TodoWrite'); }); it('MUTATING_TOOLS contains Bash', () => { expect(MUTATING_TOOLS).toContain('Bash'); }); it('unknown tool is not safe-baseline', () => { expect(isSafeBaselineTool('Unknown')).toBe(false); }); it('unknown tool is not mutating', () => { expect(isMutatingTool('Unknown')).toBe(false); }); }); describe('nodeMatches', () => { it('matches Skill tool against skill-name recommendation', () => { const tu = { name: 'Skill', input: { skill: 'superpowers:writing-plans' } }; expect(nodeMatches('superpowers:writing-plans', tu, (x) => x)).toBe(true); }); it('matches via alias resolver (#19 -> writing-plans)', () => { const tu = { name: 'Skill', input: { skill: 'superpowers:writing-plans' } }; const resolve = (rec) => (rec === '#19' ? 'superpowers:writing-plans' : rec); expect(nodeMatches('#19', tu, resolve)).toBe(true); }); it('matches Task subagent_type', () => { const tu = { name: 'Task', input: { subagent_type: 'code-reviewer' } }; expect(nodeMatches('code-reviewer', tu, (x) => x)).toBe(true); }); it('no match for unrelated', () => { const tu = { name: 'Skill', input: { skill: 'brain-retro' } }; expect(nodeMatches('superpowers:writing-plans', tu, (x) => x)).toBe(false); }); it('no match for Read tool (not Skill/Task)', () => { const tu = { name: 'Read', input: {} }; expect(nodeMatches('#19', tu, (x) => x)).toBe(false); }); it('suffix match: recommendation writing-plans matches superpowers:writing-plans', () => { const tu = { name: 'Skill', input: { skill: 'superpowers:writing-plans' } }; expect(nodeMatches('writing-plans', tu, (x) => x)).toBe(true); }); it('null recommendation returns false', () => { const tu = { name: 'Skill', input: { skill: 'brain-retro' } }; expect(nodeMatches(null, tu, (x) => x)).toBe(false); }); }); // ─── Step 5: detectDirectInvocation ───────────────────────────────────── describe('detectDirectInvocation', () => { it('/brain-retro → slash match', () => { const r = detectDirectInvocation('/brain-retro'); expect(r.matched).toBe(true); expect(r.type).toBe('slash'); expect(r.name).toBe('brain-retro'); }); it('/code-review ultra → slash match, name code-review', () => { const r = detectDirectInvocation('/code-review ultra'); expect(r.matched).toBe(true); expect(r.type).toBe('slash'); expect(r.name).toBe('code-review'); }); it('вызови Skill(superpowers:writing-plans) → skill_call', () => { const r = detectDirectInvocation('вызови Skill(superpowers:writing-plans)'); expect(r.matched).toBe(true); expect(r.type).toBe('skill_call'); expect(r.name).toBe('superpowers:writing-plans'); }); it('используй #19 → registry_num, name 19', () => { const r = detectDirectInvocation('используй #19', { registryHas: (id) => id === '#19' }); expect(r.matched).toBe(true); expect(r.type).toBe('registry_num'); expect(r.name).toBe('19'); expect(r.knownInRegistry).toBe(true); }); it('делай subagent-driven-development → literal_name', () => { const r = detectDirectInvocation('делай subagent-driven-development'); expect(r.matched).toBe(true); expect(r.type).toBe('literal_name'); expect(r.name).toBe('subagent-driven-development'); }); it('почини баг в парсере → no match', () => { const r = detectDirectInvocation('почини баг в парсере'); expect(r.matched).toBe(false); }); it('используй #999 with restrictive registryHas → matched:true, knownInRegistry:false', () => { const r = detectDirectInvocation('используй #999', { registryHas: (id) => id === '#19' }); expect(r.matched).toBe(true); expect(r.knownInRegistry).toBe(false); }); it('slash precedence: /foo делай bar → matches slash first', () => { const r = detectDirectInvocation('/foo делай bar'); expect(r.matched).toBe(true); expect(r.type).toBe('slash'); expect(r.name).toBe('foo'); }); it('примени Skill(brain-retro) → skill_call', () => { const r = detectDirectInvocation('примени Skill(brain-retro)'); expect(r.matched).toBe(true); expect(r.type).toBe('skill_call'); expect(r.name).toBe('brain-retro'); }); it('empty string → no match', () => { const r = detectDirectInvocation(''); expect(r.matched).toBe(false); }); // Fix 3: boundary tests for Fix 1+2 it('делай subagent-driven-development → literal_name (boundary at string start)', () => { const r = detectDirectInvocation('делай subagent-driven-development'); expect(r.matched).toBe(true); expect(r.type).toBe('literal_name'); expect(r.name).toBe('subagent-driven-development'); }); it('вообщеиспользуй brain-retro → does NOT match literal_name (fused word rejected)', () => { const r = detectDirectInvocation('вообщеиспользуй brain-retro'); // The literal_name pattern must NOT fire because "используй" is fused with "вообще" expect(r.matched).toBe(false); }); it('примените Skill(brain-retro) → skill_call (polite form Fix 2)', () => { const r = detectDirectInvocation('примените Skill(brain-retro)'); expect(r.matched).toBe(true); expect(r.type).toBe('skill_call'); expect(r.name).toBe('brain-retro'); }); }); // ─── crossCheckSelfSuggested ───────────────────────────────────────────── describe('crossCheckSelfSuggested', () => { it('directName writing-plans, responses contain "делай writing-plans" → selfSuggested:true', () => { const r = crossCheckSelfSuggested('writing-plans', ['советую делай writing-plans']); expect(r.selfSuggested).toBe(true); }); it('directName brain-retro, Skill() in response → selfSuggested:true via suffix', () => { const r = crossCheckSelfSuggested('brain-retro', ['используй Skill(superpowers:brain-retro)']); expect(r.selfSuggested).toBe(true); }); it('directName foo, response about bar → selfSuggested:false', () => { const r = crossCheckSelfSuggested('foo', ['нечто про bar']); expect(r.selfSuggested).toBe(false); }); it('empty responses → selfSuggested:false', () => { const r = crossCheckSelfSuggested('foo', []); expect(r.selfSuggested).toBe(false); }); it('null responses → selfSuggested:false', () => { const r = crossCheckSelfSuggested('foo', null); expect(r.selfSuggested).toBe(false); }); }); // ─── Step 6: chain-state ───────────────────────────────────────────────── describe('newChainState', () => { it('creates proper init state', () => { const s = newChainState(['#55', '#19'], 0); expect(s.chain_step).toBe(0); expect(s.schema_version).toBe(1); expect(s.chain_active).toEqual(['#55', '#19']); expect(s.initialized_at).toBe(new Date(0).toISOString()); expect(s.expires_at).toBe(new Date(86_400_000).toISOString()); }); }); describe('isChainExpired', () => { it('expired when nowMs > initialized_at + 24h', () => { const s = newChainState(['#55'], 0); expect(isChainExpired(s, 86_400_001)).toBe(true); }); it('not expired when nowMs < initialized_at + 24h', () => { const s = newChainState(['#55'], 0); expect(isChainExpired(s, 86_399_999)).toBe(false); }); it('null chainState → false', () => { expect(isChainExpired(null, 9999)).toBe(false); }); it('exactly at boundary (= 24h) → false (not strictly greater)', () => { const s = newChainState(['#55'], 0); expect(isChainExpired(s, 86_400_000)).toBe(false); }); }); describe('chainStateUpdate — anti-tickle', () => { it('advances step on matching node, keeps initialized_at unchanged', () => { const s = newChainState(['#55', '#19'], 0); const updated = chainStateUpdate(s, '#55', 1000); expect(updated.chain_step).toBe(1); expect(updated.initialized_at).toBe(new Date(0).toISOString()); // UNCHANGED expect(updated.last_step_at).toBe(new Date(1000).toISOString()); // updated }); it('no change on non-expected node', () => { const s = newChainState(['#55', '#19'], 0); const updated = chainStateUpdate(s, '#19', 1000); // expected is #55 at step 0 expect(updated.chain_step).toBe(0); // unchanged }); it('second step still keeps original initialized_at', () => { const s0 = newChainState(['#55', '#19'], 0); const s1 = chainStateUpdate(s0, '#55', 1000); const s2 = chainStateUpdate(s1, '#19', 2000); expect(s2.chain_step).toBe(2); expect(s2.initialized_at).toBe(new Date(0).toISOString()); // still epoch }); }); // ─── Step 7: decide() 4 поведения ──────────────────────────────────────── const emptyClass = { recommended_node: null, recommended_chain: [] }; describe('decide — Поведение 1 direct invocation', () => { it('Skill tool + direct matched → unlock', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Skill', input: { skill: 'brain-retro' } }, directInvocation: { matched: true, name: 'brain-retro' }, }); expect(r.decision).toBe('unlock'); expect(r.behavior_branch).toBe('1_direct_invocation'); }); it('Edit tool + direct matched → allow (precedence)', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Edit' }, directInvocation: { matched: true, name: 'brain-retro' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('1_direct_invocation'); }); it('Bash tool + direct matched → allow', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Bash' }, directInvocation: { matched: true, name: 'brain-retro' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('1_direct_invocation'); }); it('Task tool + direct matched → unlock', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Task', input: { subagent_type: 'code-reviewer' } }, directInvocation: { matched: true, name: 'code-reviewer' }, }); expect(r.decision).toBe('unlock'); expect(r.behavior_branch).toBe('1_direct_invocation'); }); it('no direct invocation → does NOT enter behavior 1', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Read' }, directInvocation: { matched: false }, }); expect(r.behavior_branch).not.toBe('1_direct_invocation'); }); }); describe('decide — Поведение 2 single rec', () => { const rec2class = { recommended_node: '#19', recommended_chain: [] }; it('Edit with recNode, no askuser → block', () => { const r = decide({ classification: rec2class, turnState: { askuser_called: false, skill_invoked_matching: false }, toolUse: { name: 'Edit' }, }); expect(r.decision).toBe('block'); expect(r.behavior_branch).toBe('2_single_rec'); }); it('Read with recNode → allow', () => { const r = decide({ classification: rec2class, turnState: { askuser_called: false, skill_invoked_matching: false }, toolUse: { name: 'Read' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('2_single_rec'); }); it('Skill matching recNode → unlock', () => { const r = decide({ classification: rec2class, turnState: { askuser_called: false, skill_invoked_matching: false }, toolUse: { name: 'Skill', input: { skill: 'superpowers:writing-plans' } }, resolveAlias: (rec) => (rec === '#19' ? 'superpowers:writing-plans' : rec), }); expect(r.decision).toBe('unlock'); expect(r.behavior_branch).toBe('2_single_rec'); }); it('skill_invoked_matching:true + Edit → allow', () => { const r = decide({ classification: rec2class, turnState: { askuser_called: false, skill_invoked_matching: true }, toolUse: { name: 'Edit' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('2_single_rec'); }); it('askuser_called:true + Bash → allow', () => { const r = decide({ classification: rec2class, turnState: { askuser_called: true }, toolUse: { name: 'Bash' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('2_single_rec'); }); it('Skill non-matching recNode, no askuser → block', () => { const r = decide({ classification: rec2class, turnState: { askuser_called: false, skill_invoked_matching: false }, toolUse: { name: 'Skill', input: { skill: 'brain-retro' } }, resolveAlias: (rec) => (rec === '#19' ? 'superpowers:writing-plans' : rec), }); expect(r.decision).toBe('block'); expect(r.behavior_branch).toBe('2_single_rec'); }); it('AskUserQuestion tool with recNode → allow (safe-baseline)', () => { const r = decide({ classification: rec2class, turnState: {}, toolUse: { name: 'AskUserQuestion' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('2_single_rec'); }); it('block reason mentions recNode', () => { const r = decide({ classification: rec2class, turnState: {}, toolUse: { name: 'Write' }, }); expect(r.decision).toBe('block'); expect(r.reason).toContain('#19'); }); }); describe('decide — Поведение 3 chain', () => { const chainClass = { recommended_node: null, recommended_chain: ['#55', '#19'] }; it('Edit with active chain, no askuser → block', () => { const r = decide({ classification: chainClass, turnState: {}, toolUse: { name: 'Edit' }, }); expect(r.decision).toBe('block'); expect(r.behavior_branch).toBe('3_chain'); // Fix 4: chain-block reason must mention AskUserQuestion expect(r.reason).toContain('AskUserQuestion'); }); it('Read with active chain → allow', () => { const r = decide({ classification: chainClass, turnState: {}, toolUse: { name: 'Read' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('3_chain'); }); it('Skill matching expected chain node → unlock', () => { const cs = newChainState(['#55', '#19'], 0); const r = decide({ classification: emptyClass, chainState: cs, turnState: {}, toolUse: { name: 'Skill', input: { skill: '#55' } }, resolveAlias: (x) => x, nowMs: 1000, }); expect(r.decision).toBe('unlock'); expect(r.behavior_branch).toBe('3_chain'); }); it('expired chainState → block with "expired" in reason', () => { const cs = newChainState(['#55', '#19'], 0); const r = decide({ classification: emptyClass, chainState: cs, turnState: {}, toolUse: { name: 'Edit' }, nowMs: 86_400_001, }); expect(r.decision).toBe('block'); expect(r.reason).toMatch(/expired/i); expect(r.behavior_branch).toBe('3_chain'); }); it('chain active + askuser_called:true + Bash → allow', () => { const r = decide({ classification: chainClass, turnState: { askuser_called: true }, toolUse: { name: 'Bash' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('3_chain'); }); it('chain via recommended_chain, Skill matching first node → unlock', () => { const r = decide({ classification: { recommended_node: null, recommended_chain: ['brain-retro', '#19'] }, turnState: {}, toolUse: { name: 'Skill', input: { skill: 'brain-retro' } }, resolveAlias: (x) => x, }); expect(r.decision).toBe('unlock'); expect(r.behavior_branch).toBe('3_chain'); }); it('chain via chainState with recommended_chain in ctx class → expired wins over match', () => { const cs = newChainState(['#55'], 0); const r = decide({ classification: { recommended_node: null, recommended_chain: ['#55'] }, chainState: cs, turnState: {}, toolUse: { name: 'Skill', input: { skill: '#55' } }, nowMs: 86_400_001, }); expect(r.decision).toBe('block'); expect(r.reason).toMatch(/expired/i); }); }); describe('decide — Поведение 4 silence', () => { it('Edit + silence + no askuser → block', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Edit' }, }); expect(r.decision).toBe('block'); expect(r.behavior_branch).toBe('4_silence'); }); it('Read + silence → allow', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Read' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('4_silence'); }); it('askuser_called:true + Write + silence → allow', () => { const r = decide({ classification: emptyClass, turnState: { askuser_called: true }, toolUse: { name: 'Write' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('4_silence'); }); it('TodoWrite + silence → allow (safe-baseline)', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'TodoWrite' }, }); expect(r.decision).toBe('allow'); expect(r.behavior_branch).toBe('4_silence'); }); it('Bash + silence + no askuser → block', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Bash' }, }); expect(r.decision).toBe('block'); expect(r.behavior_branch).toBe('4_silence'); }); it('silence block reason mentions AskUserQuestion', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Edit' }, }); expect(r.reason).toMatch(/AskUserQuestion/i); }); }); describe('decide — edge cases', () => { it('null directInvocation → does not enter behavior 1', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Read' }, directInvocation: null, }); expect(r.behavior_branch).toBe('4_silence'); }); it('directInvocation.matched=false → does not enter behavior 1', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Read' }, directInvocation: { matched: false }, }); expect(r.behavior_branch).toBe('4_silence'); }); it('behavior 3 takes precedence over behavior 2 when chain is present', () => { // Both recNode and chain present — chain wins const r = decide({ classification: { recommended_node: '#19', recommended_chain: ['#55'] }, turnState: {}, toolUse: { name: 'Edit' }, }); expect(r.behavior_branch).toBe('3_chain'); }); it('Glob is safe-baseline', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'Glob' }, }); expect(r.decision).toBe('allow'); }); it('LS is safe-baseline', () => { const r = decide({ classification: emptyClass, turnState: {}, toolUse: { name: 'LS' }, }); expect(r.decision).toBe('allow'); }); it('MultiEdit is mutating', () => { expect(isMutatingTool('MultiEdit')).toBe(true); }); it('NotebookEdit is mutating', () => { expect(isMutatingTool('NotebookEdit')).toBe(true); }); });