397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
6.8 KiB
JavaScript
137 lines
6.8 KiB
JavaScript
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);
|
||
});
|
||
});
|