Files
brain/tools/router-gate-decide.test.mjs
T

534 lines
20 KiB
JavaScript

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