import { describe, it, expect } from 'vitest'; import { decide } from './enforce-graph-first.mjs'; // Shared helpers const GREP_TOOL = { name: 'Grep', input: { pattern: 'foo' } }; const GLOB_TOOL = { name: 'Glob', input: { pattern: '**/*.ts' } }; const READ_TOOL = { name: 'Read', input: { file_path: 'x.ts' } }; const EDIT_TOOL = { name: 'Edit', input: { file_path: 'x.mjs' } }; const BASH_TOOL = { name: 'Bash', input: { command: 'ls -la' } }; describe('enforce-graph-first / decide', () => { // Test 1: No searches → pass it('no searches at all → pass', () => { expect(decide({ toolUses: [EDIT_TOOL], graphifyInvoked: false, assistantText: '', override: null, }).block).toBe(false); }); // Test 2: Below threshold (2 searches) → pass it('below threshold (2 Grep searches) → pass', () => { expect(decide({ toolUses: [GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: null, }).block).toBe(false); }); // Test 3: 3 searches, no graphify, no override → block it('3 Grep searches, no graphify, no override → block', () => { const r = decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: null, }); expect(r.block).toBe(true); expect(r.message).toMatch(/3/); expect(r.message).toMatch(/graphify/i); expect(r.message).toMatch(/graph-skip:/); }); // Test 4: 5 searches but graphifyInvoked: true → pass it('5 searches but graphifyInvoked: true → pass', () => { expect(decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: true, assistantText: '', override: null, }).block).toBe(false); }); // Test 5: 3 searches with valid graph-skip line → pass it('3 searches with valid graph-skip line → pass', () => { expect(decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: 'graph-skip: узкий regex по литералу X\nдалее обычный ответ...', override: null, }).block).toBe(false); }); // Test 6: 3 searches with empty graph-skip reason → block it('3 searches with graph-skip: but empty reason → block', () => { expect(decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: 'graph-skip:\n', override: null, }).block).toBe(true); }); // Test 7: 3 searches with global override → pass it('3 searches with global override → pass', () => { expect(decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: { phrase: 'срочно', suppresses: ['graph-first'] }, }).block).toBe(false); }); // Test 8: Mixed Grep + Glob count toward threshold → block it('1 Grep + 2 Glob = 3 → block (mixed counts toward threshold)', () => { const r = decide({ toolUses: [GREP_TOOL, GLOB_TOOL, GLOB_TOOL], graphifyInvoked: false, assistantText: '', override: null, }); expect(r.block).toBe(true); }); // Test 9: Other tools (Read, Edit, Bash) don't count as searches → pass it('Read × 4 + Edit × 1 = 0 searches → pass', () => { expect(decide({ toolUses: [READ_TOOL, READ_TOOL, READ_TOOL, READ_TOOL, EDIT_TOOL], graphifyInvoked: false, assistantText: '', override: null, }).block).toBe(false); }); // Test 10: Message includes per-spec wording it('block message includes §5 п.14, graphify, graph-skip: wording', () => { const r = decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: null, }); expect(r.block).toBe(true); expect(r.message).toMatch(/§5 п\.14/); expect(r.message).toMatch(/graphify/i); expect(r.message).toMatch(/graph-skip:/); }); // Extra edge cases it('exactly THRESHOLD=3 searches → block (boundary condition)', () => { expect(decide({ toolUses: [GREP_TOOL, GLOB_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: null, }).block).toBe(true); }); it('2 searches (below threshold) regardless of graphify state → pass', () => { // Even without graphify, 2 searches is under the threshold expect(decide({ toolUses: [GREP_TOOL, GLOB_TOOL], graphifyInvoked: false, assistantText: '', override: null, }).block).toBe(false); }); it('graph-skip: with non-empty reason in middle of text → pass', () => { const text = 'Some analysis first.\ngraph-skip: known file path, not cross-cutting\nThen conclusion.'; expect(decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: text, override: null, }).block).toBe(false); }); it('graph-skip: with only whitespace reason (not \\ S+) → block', () => { expect(decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: 'graph-skip: \n', override: null, }).block).toBe(true); }); it('empty toolUses → pass', () => { expect(decide({ toolUses: [], graphifyInvoked: false, assistantText: '', override: null, }).block).toBe(false); }); it('Bash tool alone does not count as search', () => { expect(decide({ toolUses: [BASH_TOOL, BASH_TOOL, BASH_TOOL, BASH_TOOL], graphifyInvoked: false, assistantText: '', override: null, }).block).toBe(false); }); it('block message includes the actual count N', () => { const r = decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: null, }); expect(r.block).toBe(true); expect(r.message).toMatch(/5/); }); it('override null value → treated as falsy, block still fires', () => { const r = decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: null, }); expect(r.block).toBe(true); }); it('override false value → treated as falsy, block still fires', () => { const r = decide({ toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], graphifyInvoked: false, assistantText: '', override: false, }); expect(r.block).toBe(true); }); });