7f379bd6a2
Pure module — extracts options (numbered/lettered/bullets/AskUserQuestion) from last assistant message, detects user reference (position-based + substring), returns decision_provenance for the 3rd kind. 23/23 tests GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
6.9 KiB
JavaScript
167 lines
6.9 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import { extractOptions, detectReference, detectChoiceProvenance } 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',
|
||
});
|
||
});
|
||
});
|