diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 198ebc9c..466346eb 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -286,6 +286,29 @@ export function extractTokenUsage(turn) { }; } +/** + * For each AskUserQuestion toolUseResult in the turn, emit one event per question. + * answer_kind: 'option' (exact label match), 'custom' (free-text), 'no_answer' (missing/empty). + */ +export function extractAskUserQuestionEvents(turn) { + const events = []; + for (const e of turn || []) { + const tur = e && e.toolUseResult; + if (!tur || !Array.isArray(tur.questions) || !tur.answers) continue; + const qCount = tur.questions.length; + for (const q of tur.questions) { + const labels = (q.options || []).map((o) => o && o.label).filter((l) => typeof l === 'string'); + const answer = tur.answers[q.question]; + let answer_kind; + if (typeof answer !== 'string' || answer.length === 0) answer_kind = 'no_answer'; + else if (labels.some((l) => l.trim() === answer.trim())) answer_kind = 'option'; + else answer_kind = 'custom'; + events.push({ kind: 'ask_user_question', question_count: qCount, answer_kind }); + } + } + return events; +} + /** Classify the opening user-prompt sentiment (per spec §6 / gap-resolution 1). */ export function classifyPromptSignal(text) { const t = String(text || '').toLowerCase().trim(); @@ -472,6 +495,7 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) { events.push({ kind: 'error', message: 'tool_result reported is_error' }); } events.push(...extractProcessEvents(turn, broken, total, durationMs)); + events.push(...extractAskUserQuestionEvents(turn)); const usedSuperpowers = skills.some((s) => String(s).startsWith(SUPERPOWERS_PREFIX)); const prompt = promptText(entries[start]); diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 1482030a..c42ed74d 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -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'); + }); +});