import { describe, expect, test } from 'bun:test'; import { buildFallbackState, findLatestAssistantState, flattenMessage, parseAssistantState, } from './parser'; describe('parseAssistantState', () => { test('parses valid interview state with questions', () => { const text = 'Here are questions.\n\n{"summary":"Test app","questions":[{"id":"q-1","question":"Platform?","options":["Web","Mobile"],"suggested":"Web"}]}\n'; const result = parseAssistantState(text, 2); expect(result.state).not.toBeNull(); expect(result.state?.summary).toBe('Test app'); expect(result.state?.questions).toHaveLength(1); expect(result.state?.questions[0].id).toBe('q-1'); expect(result.state?.questions[0].question).toBe('Platform?'); expect(result.state?.questions[0].options).toEqual(['Web', 'Mobile']); expect(result.state?.questions[0].suggested).toBe('Web'); }); test('parses state with title field', () => { const text = '\n{"summary":"Building X","title":"my-project","questions":[]}\n'; const result = parseAssistantState(text); expect(result.state).not.toBeNull(); expect(result.state?.title).toBe('my-project'); }); test('returns null when no interview_state block', () => { const result = parseAssistantState('No state block here.'); expect(result.state).toBeNull(); expect(result.error).toBeUndefined(); }); test('returns error for invalid JSON inside block', () => { const text = '\n{not valid json}\n'; const result = parseAssistantState(text); expect(result.state).toBeNull(); expect(result.error).toBeDefined(); }); test('handles malformed question objects gracefully', () => { const text = '\n{"summary":"Test","questions":[null,123,"string",{"question":"Valid?"}]}\n'; const result = parseAssistantState(text, 5); expect(result.state).not.toBeNull(); // Only the valid question should survive Zod validation expect(result.state?.questions).toHaveLength(1); expect(result.state?.questions[0].question).toBe('Valid?'); }); test('respects maxQuestions limit', () => { const text = '\n{"summary":"Test","questions":[{"id":"q-1","question":"Q1?"},{"id":"q-2","question":"Q2?"},{"id":"q-3","question":"Q3?"},{"id":"q-4","question":"Q4?"}]}\n'; const result = parseAssistantState(text, 2); expect(result.state?.questions).toHaveLength(2); }); test('handles empty questions array', () => { const text = '\n{"summary":"Waiting","questions":[]}\n'; const result = parseAssistantState(text); expect(result.state).not.toBeNull(); expect(result.state?.questions).toHaveLength(0); expect(result.state?.summary).toBe('Waiting'); }); test('strips whitespace from question text', () => { const text = '\n{"summary":"Test","questions":[{"question":" Spaced question ","options":[" A "," B "]}]}\n'; const result = parseAssistantState(text); expect(result.state?.questions[0].question).toBe('Spaced question'); expect(result.state?.questions[0].options).toEqual(['A', 'B']); }); test('generates fallback ID when question has no id', () => { const text = '\n{"summary":"Test","questions":[{"question":"No ID?"}]}\n'; const result = parseAssistantState(text); expect(result.state?.questions[0].id).toBe('q-1'); }); test('trims options to max 4', () => { const text = '\n{"summary":"Test","questions":[{"question":"Q?","options":["A","B","C","D","E","F"]}]}\n'; const result = parseAssistantState(text); expect(result.state?.questions[0].options).toHaveLength(4); }); test('filters out non-string options', () => { const text = '\n{"summary":"Test","questions":[{"question":"Q?","options":["A",42,true,null,"B"]}]}\n'; const result = parseAssistantState(text); expect(result.state?.questions[0].options).toEqual(['A', 'B']); }); test('handles non-string summary gracefully', () => { const text = '\n{"summary":123,"questions":[]}\n'; const result = parseAssistantState(text); expect(result.state).not.toBeNull(); expect(result.state?.summary).toBe(''); }); test('handles non-string title gracefully', () => { const text = '\n{"summary":"Test","title":456,"questions":[]}\n'; const result = parseAssistantState(text); expect(result.state).not.toBeNull(); expect(result.state?.title).toBeUndefined(); }); test('handles missing questions field', () => { const text = '\n{"summary":"No questions field"}\n'; const result = parseAssistantState(text); expect(result.state).not.toBeNull(); expect(result.state?.questions).toHaveLength(0); }); test('handles question with empty question text', () => { const text = '\n{"summary":"Test","questions":[{"question":" "}]}\n'; const result = parseAssistantState(text); expect(result.state?.questions).toHaveLength(0); }); test('is case-insensitive for interview_state tag', () => { const text = '\n{"summary":"Upper","questions":[]}\n'; const result = parseAssistantState(text); expect(result.state).not.toBeNull(); expect(result.state?.summary).toBe('Upper'); }); test('handles deeply nested unexpected objects', () => { const text = '\n{"summary":"Test","questions":[{"question":"Q?","options":[{"nested":true}]}]}\n'; const result = parseAssistantState(text); expect(result.state?.questions[0].options).toEqual([]); }); test('handles extremely large input gracefully', () => { const questions = Array.from({ length: 100 }, (_, i) => ({ id: `q-${i}`, question: `Question ${i}?`, })); const text = `\n${JSON.stringify({ summary: 'Large', questions })}\n`; const result = parseAssistantState(text, 5); expect(result.state?.questions).toHaveLength(5); }); }); describe('flattenMessage', () => { test('joins text parts with newline', () => { const message = { parts: [ { type: 'text', text: 'Hello' }, { type: 'text', text: 'World' }, ], }; expect(flattenMessage(message as any)).toBe('Hello\nWorld'); }); test('handles missing parts', () => { expect(flattenMessage({} as any)).toBe(''); }); test('handles parts without text', () => { const message = { parts: [{ type: 'image' }, { type: 'text', text: 'only this' }], }; expect(flattenMessage(message as any)).toBe('only this'); }); }); describe('buildFallbackState', () => { test('returns waiting message for no answers', () => { const state = buildFallbackState([]); expect(state.summary).toContain('Waiting'); expect(state.questions).toHaveLength(0); }); test('returns in-progress message when answers exist', () => { const messages = [ { info: { role: 'user' } }, { info: { role: 'assistant' } }, ]; const state = buildFallbackState(messages as any); expect(state.summary).toContain('in progress'); }); }); describe('findLatestAssistantState', () => { test('finds state in last assistant message', () => { const messages = [ { info: { role: 'assistant' }, parts: [ { type: 'text', text: '\n{"summary":"First","questions":[]}\n', }, ], }, { info: { role: 'user' }, parts: [{ type: 'text', text: 'reply' }] }, { info: { role: 'assistant' }, parts: [ { type: 'text', text: '\n{"summary":"Latest","questions":[]}\n', }, ], }, ]; const result = findLatestAssistantState(messages as any); expect(result.state).not.toBeNull(); expect(result.state?.summary).toBe('Latest'); }); test('returns null when no assistant messages', () => { const messages = [ { info: { role: 'user' }, parts: [{ type: 'text', text: 'hello' }] }, ]; const result = findLatestAssistantState(messages as any); expect(result.state).toBeNull(); }); test('captures parse error from earlier messages', () => { const messages = [ { info: { role: 'assistant' }, parts: [ { type: 'text', text: '\n{bad json}\n', }, ], }, { info: { role: 'assistant' }, parts: [ { type: 'text', text: '\n{"summary":"Recovery","questions":[]}\n', }, ], }, ]; const result = findLatestAssistantState(messages as any); expect(result.state).not.toBeNull(); expect(result.state?.summary).toBe('Recovery'); }); test('returns latest error when no valid state found', () => { const messages = [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'No state block here.' }], }, ]; const result = findLatestAssistantState(messages as any); expect(result.state).toBeNull(); expect(result.latestAssistantError).toContain('Missing'); }); });