feat(observer): capture ask_user_question events with answer_kind classification (Task 4)

Add extractAskUserQuestionEvents() — for each AskUserQuestion toolUseResult emits
one event per question with answer_kind: option|custom|no_answer and question_count.
Integrated into parseTranscript events pipeline. 7 new tests (263 total, 0 failed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-20 12:58:24 +03:00
parent 8a0c84be88
commit a8008b66eb
2 changed files with 101 additions and 0 deletions
+77
View File
@@ -1003,3 +1003,80 @@ describe('parseTranscript — task_cost integration (Task 2)', () => {
});
});
});
import { extractAskUserQuestionEvents } from './observer-transcript-parser.mjs';
describe('extractAskUserQuestionEvents (Task 4)', () => {
it('records "option" when user picked an offered label exactly', () => {
const turn = [
{ message: { content: [{ type: 'tool_use', name: 'AskUserQuestion',
input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
answers: { q1: 'A' } } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs).toHaveLength(1);
expect(evs[0].kind).toBe('ask_user_question');
expect(evs[0].question_count).toBe(1);
expect(evs[0].answer_kind).toBe('option');
});
it('records "custom" when user wrote free-text Other', () => {
const turn = [
{ message: { content: [{ type: 'tool_use', name: 'AskUserQuestion',
input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
answers: { q1: 'C — мой вариант' } } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs[0].answer_kind).toBe('custom');
});
it('records "no_answer" when answer key missing', () => {
const turn = [
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
answers: {} } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs[0].answer_kind).toBe('no_answer');
});
it('reports question_count from toolUseResult.questions length', () => {
const turn = [
{ toolUseResult: { questions: [
{ question: 'q1', options: [{ label: 'A' }] },
{ question: 'q2', options: [{ label: 'B' }] },
], answers: { q1: 'A', q2: 'B' } } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs).toHaveLength(2); // one event per question
expect(evs[0].question_count).toBe(2);
expect(evs[1].question_count).toBe(2);
});
it('returns empty for a turn without AskUserQuestion', () => {
expect(extractAskUserQuestionEvents([{ message: { content: [{ type: 'tool_use', name: 'Read' }] } }])).toEqual([]);
});
it('handles null/undefined turn safely', () => {
expect(extractAskUserQuestionEvents(null)).toEqual([]);
expect(extractAskUserQuestionEvents(undefined)).toEqual([]);
expect(extractAskUserQuestionEvents([])).toEqual([]);
});
});
describe('parseTranscript — ask_user_question events (Task 4)', () => {
it('emits ask_user_question event from AskUserQuestion + toolUseResult', () => {
const transcript = [
JSON.stringify({ sessionId: 's1' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 't1', name: 'AskUserQuestion',
input: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }] } },
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 't1' },
] }, toolUseResult: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }],
answers: { q: 'X' } }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
].join('\n');
const ep = parseTranscript(transcript);
const aq = ep.events.filter((e) => e.kind === 'ask_user_question');
expect(aq).toHaveLength(1);
expect(aq[0].answer_kind).toBe('option');
});
});