361 lines
12 KiB
JavaScript
361 lines
12 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { decide, classifyOutcome } from './enforce-chain-recommendation.mjs';
|
|
|
|
describe('classifyOutcome', () => {
|
|
it('returns "passed-short-chain" when chain length < 2', () => {
|
|
expect(classifyOutcome({ chainLength: 0 })).toBe('passed-short-chain');
|
|
expect(classifyOutcome({ chainLength: 1 })).toBe('passed-short-chain');
|
|
});
|
|
it('returns "passed-no-mutating" when no mutating tool used', () => {
|
|
expect(classifyOutcome({ chainLength: 2, hasMutating: false })).toBe('passed-no-mutating');
|
|
});
|
|
it('returns "passed-global-override" when override present', () => {
|
|
expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: true })).toBe('passed-global-override');
|
|
});
|
|
it('returns "passed-with-skill" when a chain skill was invoked', () => {
|
|
expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: false, hasChainSkill: true })).toBe('passed-with-skill');
|
|
});
|
|
it('returns "passed-inline-override" when chain-override regex matched', () => {
|
|
expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: false, hasChainSkill: false, hasInlineOverride: true })).toBe('passed-inline-override');
|
|
});
|
|
it('returns "blocked" when none of the escapes apply', () => {
|
|
expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: false, hasChainSkill: false, hasInlineOverride: false })).toBe('blocked');
|
|
});
|
|
});
|
|
|
|
// Shared helpers
|
|
const EDIT_TOOL = { name: 'Edit', input: { file_path: 'x.mjs' } };
|
|
const READ_TOOL = { name: 'Read', input: { file_path: 'x.mjs' } };
|
|
const GREP_TOOL = { name: 'Grep', input: {} };
|
|
|
|
describe('enforce-chain-recommendation / decide', () => {
|
|
// Test 1: empty chain → pass
|
|
it('empty chain → pass', () => {
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: [],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 2: chain of 1 → pass (single-node handled by enforce-classifier-match)
|
|
it('chain of 1 → pass (single-node handled elsewhere)', () => {
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 3: chain of 2, no skill called, no override → block
|
|
it('chain of 2, no skill called, no override → block', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
expect(r.message).toMatch(/#19 → #34/);
|
|
expect(r.message).toMatch(/chain-override:/);
|
|
});
|
|
|
|
// Test 4: chain of 2, first skill called → pass
|
|
it('chain of 2, first skill called → pass', () => {
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(['#19']),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 5: chain of 2, second skill called → pass (any one is enough)
|
|
it('chain of 2, second skill called → pass (any one is enough)', () => {
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(['#34']),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 6: chain of 2, valid chain-override present → pass
|
|
it('chain of 2, chain-override with reason present → pass', () => {
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: 'chain-override: трёхшаговая цепочка не нужна — задача чисто читающая\nдалее обычный ответ...',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 7: chain of 2, chain-override present BUT empty reason → block
|
|
it('chain of 2, chain-override with empty reason → block', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: 'chain-override:\n',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
});
|
|
|
|
// Test 8: chain of 2, global override → pass
|
|
it('chain of 2, global override → pass', () => {
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: { phrase: 'срочно', suppresses: ['chain-recommendation'] },
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 9: chain of 2, but no mutating tool (only Read/Grep) → pass
|
|
it('chain of 2, no mutating tools used → pass', () => {
|
|
expect(decide({
|
|
toolUses: [READ_TOOL, GREP_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 10: chain of 5 (long), one mid-chain skill called → pass
|
|
it('chain of 5, one mid-chain skill called → pass', () => {
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34', '#18', '#10', '#3'],
|
|
calledSkillIds: new Set(['#18']),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
// Test 11: block message contains arrow-rendered chain
|
|
it('block message format includes arrow-rendered chain', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34', '#18'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
expect(r.message).toMatch(/#19 → #34 → #18/);
|
|
});
|
|
|
|
// Additional edge cases
|
|
|
|
it('chain-override with whitespace-only reason → block', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: 'chain-override: \n',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
});
|
|
|
|
it('chain-override mid-text (not at line start) → block (must be line-start)', () => {
|
|
// Regex requires ^ in multiline mode, so inline text should not match
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: 'some text chain-override: inline reason here',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
});
|
|
|
|
it('chain-override at true line start → pass', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: 'reasoning here\nchain-override: direct edit acceptable for single-file fix\nmore text',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(false);
|
|
});
|
|
|
|
it('empty toolUses → pass (no mutating tools)', () => {
|
|
expect(decide({
|
|
toolUses: [],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
it('calledSkillIds contains by-name resolution (slug match) → pass', () => {
|
|
// If main() resolves #19 to its slug and adds it to calledSkillIds,
|
|
// decide() should accept it via the set-intersection.
|
|
expect(decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(['superpowers:writing-plans', '#19']),
|
|
assistantText: '',
|
|
override: null,
|
|
}).block).toBe(false);
|
|
});
|
|
|
|
it('block message mentions chain-override instruction text', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
expect(r.message).toContain('[enforce-chain-recommendation]');
|
|
expect(r.message).toContain('chain-override:');
|
|
});
|
|
|
|
it('decide() has no side-effects: calling twice returns same result', () => {
|
|
const args = {
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
};
|
|
const r1 = decide({ ...args, calledSkillIds: new Set() });
|
|
const r2 = decide({ ...args, calledSkillIds: new Set() });
|
|
expect(r1.block).toBe(r2.block);
|
|
});
|
|
|
|
it('Bash tool counts as mutating', () => {
|
|
const r = decide({
|
|
toolUses: [{ name: 'Bash', input: { command: 'echo hi' } }],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
});
|
|
|
|
it('Task tool counts as mutating', () => {
|
|
const r = decide({
|
|
toolUses: [{ name: 'Task', input: { subagent_type: 'general-purpose' } }],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('decide() returns enriched flags for DRY consumption by main()', () => {
|
|
it('returns hasMutating=true when a mutating tool is used', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.hasMutating).toBe(true);
|
|
});
|
|
|
|
it('returns hasMutating=false when only read tools are used', () => {
|
|
const r = decide({
|
|
toolUses: [READ_TOOL, GREP_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.hasMutating).toBe(false);
|
|
});
|
|
|
|
it('returns hasChainSkill=true when any chain skill is in calledSkillIds', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(['#34']),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.hasChainSkill).toBe(true);
|
|
});
|
|
|
|
it('returns hasChainSkill=false when no chain skill matched', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(['#99']),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.hasChainSkill).toBe(false);
|
|
});
|
|
|
|
it('returns hasInlineOverride=true when chain-override regex matches', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: 'reason: ...\nchain-override: valid reason here',
|
|
override: null,
|
|
});
|
|
expect(r.hasInlineOverride).toBe(true);
|
|
});
|
|
|
|
it('returns hasInlineOverride=false when no chain-override pattern', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: 'plain assistant text without escape hatch',
|
|
override: null,
|
|
});
|
|
expect(r.hasInlineOverride).toBe(false);
|
|
});
|
|
|
|
it('returns enriched flags even when block=true (so main() can classify outcome)', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19', '#34'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
expect(r.hasMutating).toBe(true);
|
|
expect(r.hasChainSkill).toBe(false);
|
|
expect(r.hasInlineOverride).toBe(false);
|
|
});
|
|
|
|
it('returns enriched flags when block=false (chain too short)', () => {
|
|
const r = decide({
|
|
toolUses: [EDIT_TOOL],
|
|
recommendedChain: ['#19'],
|
|
calledSkillIds: new Set(),
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(false);
|
|
expect(r.hasMutating).toBe(true);
|
|
expect(r.hasChainSkill).toBe(false);
|
|
expect(r.hasInlineOverride).toBe(false);
|
|
});
|
|
});
|