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

220 lines
8.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 {
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');
});
});