Files
brain/tools/askuser-cosmetic-detector.test.mjs
T

137 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
isSimpleAB,
decide,
countSimpleSession,
brainstormingInvokedSession,
skillMatchedThisTurn,
} from './askuser-cosmetic-detector.mjs';
const simpleQ = { question: 'A или B?', options: [{ label: 'Да' }, { label: 'Нет' }] };
const richQ = {
question: 'Какой подход?',
options: [{ label: 'Использовать skill brainstorming' }, { label: 'Свой путь' }, { label: 'Стоп' }],
};
describe('askuser-cosmetic-detector / isSimpleAB', () => {
it('true for 2-option short-label questions with no skill mention', () => {
expect(isSimpleAB([simpleQ])).toBe(true);
});
it('false when an option mentions a skill', () => {
expect(isSimpleAB([richQ])).toBe(false);
});
it('false for 3-option questions', () => {
expect(isSimpleAB([{ question: 'q', options: [{ label: 'a' }, { label: 'b' }, { label: 'c' }] }])).toBe(false);
});
it('false when a label is long (>=30 chars)', () => {
expect(isSimpleAB([{ question: 'q', options: [{ label: 'a' }, { label: 'x'.repeat(40) }] }])).toBe(false);
});
it('false for empty/invalid input', () => {
expect(isSimpleAB(null)).toBe(false);
expect(isSimpleAB([])).toBe(false);
});
});
describe('askuser-cosmetic-detector / decide', () => {
it('allows a rich (non-simple) AskUser', () => {
const r = decide({ questions: [richQ], simpleCountSession: 0, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
expect(r.action).toBe('allow');
expect(r.block).toBe(false);
expect(r.isSimpleAB).toBe(false);
expect(r.newSessionCount).toBe(0);
expect(r.newTurnCount).toBe(0);
});
it('soft-flags first simple A/B in a turn without skill match', () => {
const r = decide({ questions: [simpleQ], simpleCountSession: 0, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
expect(r.action).toBe('soft_flag');
expect(r.block).toBe(false);
expect(r.newSessionCount).toBe(1);
expect(r.newTurnCount).toBe(1);
});
it('allows simple A/B when a skill matched this turn', () => {
const r = decide({ questions: [simpleQ], simpleCountSession: 0, simpleCountTurn: 0, skillMatchedThisTurn: true, brainstormingInvoked: false });
expect(r.action).toBe('allow');
});
it('hard-blocks the 3rd simple AskUser in session without brainstorming', () => {
const r = decide({ questions: [simpleQ], simpleCountSession: 2, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
expect(r.action).toBe('hard_block');
expect(r.block).toBe(true);
expect(r.reason).toMatch(/brainstorming/i);
});
it('does NOT hard-block when brainstorming was invoked this session', () => {
const r = decide({ questions: [simpleQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: true });
expect(r.action).not.toBe('hard_block');
expect(r.block).toBe(false);
});
it('hard-block takes precedence over soft_flag', () => {
const r = decide({ questions: [simpleQ], simpleCountSession: 2, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
expect(r.action).toBe('hard_block');
});
});
describe('askuser-cosmetic-detector / transcript helpers', () => {
const sess = (uses) => uses.map((u) => ({ message: { content: [{ type: 'tool_use', name: u.name, input: u.input || {} }] } }));
it('brainstormingInvokedSession true when Skill(superpowers:brainstorming) used', () => {
const entries = sess([{ name: 'Skill', input: { skill: 'superpowers:brainstorming' } }]);
expect(brainstormingInvokedSession(entries)).toBe(true);
});
it('brainstormingInvokedSession false when only other skills used', () => {
const entries = sess([{ name: 'Skill', input: { skill: 'superpowers:writing-plans' } }]);
expect(brainstormingInvokedSession(entries)).toBe(false);
});
it('skillMatchedThisTurn true when a Skill tool_use is in the last turn', () => {
const entries = [
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'go' }] } },
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Skill', input: { skill: 'graphify' } }] } },
];
expect(skillMatchedThisTurn(entries)).toBe(true);
});
it('countSimpleSession reads prior count from a flags file array', () => {
const flags = [{ isSimpleAB: true }, { isSimpleAB: false }, { isSimpleAB: true }];
expect(countSimpleSession(flags)).toBe(2);
});
});
import { isGitApprovalQuestion } from './askuser-cosmetic-detector.mjs';
// Calibration 5 (2026-05-31, SCOPE fix, NOT a discipline drop): a git-operation
// APPROVAL AskUser (an option label is a literal git command) is the sanctioned
// git-approval channel — enforce-askuser-answer-parser turns the chosen answer
// into an approve_git_operation record. It is never a substitute for structured
// ideation, so it must not be counted/blocked as "cosmetic A/B". Design A/B
// questions (non-git labels) are unchanged — still counted, still hard-blocked.
describe('isGitApprovalQuestion (calibration 5)', () => {
it('true when an option label is a git command (push)', () => {
expect(isGitApprovalQuestion([{ options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] }])).toBe(true);
});
it('true when an option label is a git command (commit with pathspec)', () => {
expect(isGitApprovalQuestion([{ options: [{ label: 'git commit -F x.txt -- a.mjs b.mjs' }, { label: 'Отмена' }] }])).toBe(true);
});
it('false for a non-git A/B', () => {
expect(isGitApprovalQuestion([{ options: [{ label: 'Вариант А' }, { label: 'Вариант Б' }] }])).toBe(false);
});
it('false for empty/invalid input', () => {
expect(isGitApprovalQuestion(null)).toBe(false);
expect(isGitApprovalQuestion([])).toBe(false);
});
});
describe('decide — git-approval exemption (calibration 5)', () => {
const gitQ = { question: 'Подтверди?', options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] };
it('allows a git-approval question and does NOT count it even past the session limit', () => {
const r = decide({ questions: [gitQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
expect(r.block).toBe(false);
expect(r.action).toBe('allow');
expect(r.isSimpleAB).toBe(false);
expect(r.newSessionCount).toBe(5); // unchanged — not counted toward the cosmetic limit
});
it('REGRESSION: a non-git simple A/B past the limit STILL hard-blocks (discipline intact)', () => {
const r = decide({ questions: [simpleQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false });
expect(r.action).toBe('hard_block');
expect(r.block).toBe(true);
});
});