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'); }); });