Files
brain/tools/observer-choice-detector.test.mjs
T

220 lines
8.8 KiB
JavaScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import {
extractOptions,
detectReference,
detectChoiceProvenance,
detectAskUserQuestionChoice,
} from './observer-choice-detector.mjs';
describe('extractOptions', () => {
it('returns null when content has fewer than 2 options', () => {
expect(extractOptions('Just one paragraph of text.')).toBeNull();
expect(extractOptions('1. only one item')).toBeNull();
});
it('extracts numbered list (1. ... 2. ...)', () => {
const text = '1. First option\n2. Second option\n3. Third option';
expect(extractOptions(text)).toEqual(['First option', 'Second option', 'Third option']);
});
it('extracts numbered list (1) ... 2) ...)', () => {
const text = '1) Alpha\n2) Beta';
expect(extractOptions(text)).toEqual(['Alpha', 'Beta']);
});
it('extracts lettered list (Latin)', () => {
const text = 'A. First choice\nB. Second choice\nC. Third choice';
expect(extractOptions(text)).toEqual(['First choice', 'Second choice', 'Third choice']);
});
it('extracts lettered list (Cyrillic)', () => {
const text = 'А. Первый\nБ. Второй\nВ. Третий';
expect(extractOptions(text)).toEqual(['Первый', 'Второй', 'Третий']);
});
it('extracts bullet list with hyphens', () => {
const text = '- Apple\n- Banana\n- Cherry';
expect(extractOptions(text)).toEqual(['Apple', 'Banana', 'Cherry']);
});
it('extracts bullet list with asterisks', () => {
const text = '* Red\n* Green\n* Blue';
expect(extractOptions(text)).toEqual(['Red', 'Green', 'Blue']);
});
it('extracts AskUserQuestion options from tool_use block', () => {
const askUser = {
type: 'tool_use',
name: 'AskUserQuestion',
input: {
questions: [{
options: [
{ label: 'Option A' },
{ label: 'Option B' },
{ label: 'Option C' },
],
}],
},
};
expect(extractOptions(askUser)).toEqual(['Option A', 'Option B', 'Option C']);
});
it('returns null when AskUserQuestion has < 2 options', () => {
const askUser = {
type: 'tool_use',
name: 'AskUserQuestion',
input: { questions: [{ options: [{ label: 'Only one' }] }] },
};
expect(extractOptions(askUser)).toBeNull();
});
});
describe('detectReference', () => {
const options = ['First option label', 'Second option label', 'Third option label'];
it('returns null on empty prompt', () => {
expect(detectReference('', options)).toBeNull();
expect(detectReference(' ', options)).toBeNull();
});
it('matches bare number at start', () => {
expect(detectReference('1', options)).toEqual({ index: 0, label: 'First option label' });
expect(detectReference('2 экономия 5%', options)).toEqual({ index: 1, label: 'Second option label' });
expect(detectReference('3, делаем', options)).toEqual({ index: 2, label: 'Third option label' });
});
it('matches verb-prefixed number', () => {
expect(detectReference('делай 1', options)).toEqual({ index: 0, label: 'First option label' });
expect(detectReference('выбираю 2', options)).toEqual({ index: 1, label: 'Second option label' });
expect(detectReference('беру 3', options)).toEqual({ index: 2, label: 'Third option label' });
expect(detectReference('хочу 1', options)).toEqual({ index: 0, label: 'First option label' });
expect(detectReference('вариант 2', options)).toEqual({ index: 1, label: 'Second option label' });
});
it('matches bare letter at start (Latin)', () => {
expect(detectReference('A', options)).toEqual({ index: 0, label: 'First option label' });
expect(detectReference('B делаем', options)).toEqual({ index: 1, label: 'Second option label' });
expect(detectReference('c, идём', options)).toEqual({ index: 2, label: 'Third option label' });
});
it('matches bare letter at start (Cyrillic)', () => {
expect(detectReference('А', options)).toEqual({ index: 0, label: 'First option label' });
expect(detectReference('б делаем', options)).toEqual({ index: 1, label: 'Second option label' });
expect(detectReference('в, идём', options)).toEqual({ index: 2, label: 'Third option label' });
});
it('matches verb-prefixed letter', () => {
expect(detectReference('делай A', options)).toEqual({ index: 0, label: 'First option label' });
expect(detectReference('выбираю Б', options)).toEqual({ index: 1, label: 'Second option label' });
});
it('matches label substring (first 2-4 words)', () => {
expect(detectReference('First option уточни', options)).toEqual({ index: 0, label: 'First option label' });
expect(detectReference('делаем Second option', options)).toEqual({ index: 1, label: 'Second option label' });
});
it('returns null when no signal matches', () => {
expect(detectReference('какой-то текст без ссылки', options)).toBeNull();
expect(detectReference('просто продолжай работу', options)).toBeNull();
});
it('returns null on out-of-range position', () => {
expect(detectReference('99', options)).toBeNull();
expect(detectReference('Z', options)).toBeNull();
expect(detectReference('Я', options)).toBeNull();
});
});
describe('detectChoiceProvenance', () => {
it('returns null when no options', () => {
expect(detectChoiceProvenance('1 делаем', 'just text without list')).toBeNull();
});
it('returns null when options exist but no reference', () => {
const lastAsst = '1. First\n2. Second\n3. Third';
expect(detectChoiceProvenance('расскажи как дела', lastAsst)).toBeNull();
});
it('returns full result on successful match (numbered)', () => {
const lastAsst = '1. Phase A\n2. Phase B\n3. Phase C';
expect(detectChoiceProvenance('2 делаем', lastAsst)).toEqual({
kind: 'user_chose_from_options',
node: 'Phase B',
options_offered: ['Phase A', 'Phase B', 'Phase C'],
claude_would_have_chosen: 'Phase A',
});
});
it('returns full result on successful match (Cyrillic letter, keyboard layout)', () => {
const lastAsst = 'А. Первый\nБ. Второй\nВ. Третий';
expect(detectChoiceProvenance('в делаем', lastAsst)).toEqual({
kind: 'user_chose_from_options',
node: 'Третий',
options_offered: ['Первый', 'Второй', 'Третий'],
claude_would_have_chosen: 'Первый',
});
});
it('returns full result with AskUserQuestion source', () => {
const askUser = {
type: 'tool_use',
name: 'AskUserQuestion',
input: { questions: [{ options: [{ label: 'Recommended' }, { label: 'Alternative' }] }] },
};
expect(detectChoiceProvenance('делай 2', askUser)).toEqual({
kind: 'user_chose_from_options',
node: 'Alternative',
options_offered: ['Recommended', 'Alternative'],
claude_would_have_chosen: 'Recommended',
});
});
});
describe('detectAskUserQuestionChoice', () => {
const answeredEntry = (answer) => ({
type: 'user',
message: { role: 'user', content: [{ type: 'tool_result', content: 'User has answered...' }] },
toolUseResult: {
questions: [
{
question: 'Каким режимом?',
options: [
{ label: 'Subagent-Driven (recommended)' },
{ label: 'Inline execution' },
{ label: 'Ничего не исполнять' },
],
},
],
answers: { 'Каким режимом?': answer },
},
});
it('returns null for non-array input', () => {
expect(detectAskUserQuestionChoice(null)).toBeNull();
expect(detectAskUserQuestionChoice('x')).toBeNull();
});
it('returns null when turn has no AskUserQuestion toolUseResult', () => {
expect(detectAskUserQuestionChoice([{ type: 'assistant', message: { role: 'assistant' } }])).toBeNull();
});
it('classifies a genuine option click (answer exactly equals a label)', () => {
expect(detectAskUserQuestionChoice([answeredEntry('Inline execution')])).toEqual({
kind: 'user_chose_from_options',
node: 'Inline execution',
options_offered: ['Subagent-Driven (recommended)', 'Inline execution', 'Ничего не исполнять'],
claude_would_have_chosen: 'Subagent-Driven (recommended)',
});
});
it('returns null when answer is custom free-text (Other), not an offered label', () => {
expect(detectAskUserQuestionChoice([answeredEntry('давай обсудим все 3 варианта подробнее')])).toBeNull();
});
it('uses the last answered AskUserQuestion in the turn', () => {
const first = answeredEntry('Subagent-Driven (recommended)');
const last = answeredEntry('Inline execution');
expect(detectAskUserQuestionChoice([first, { type: 'assistant' }, last]).node).toBe('Inline execution');
});
});