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