diff --git a/frontend/src/test/components/test_phase4_response_panel.test.tsx b/frontend/src/test/components/test_phase4_response_panel.test.tsx
new file mode 100644
index 0000000..0704c6e
--- /dev/null
+++ b/frontend/src/test/components/test_phase4_response_panel.test.tsx
@@ -0,0 +1,214 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { ResponsePanel } from '../../components/ResponsePanel'
+import type { SubQuestionSources, SourceMetadata } from '../../types'
+
+vi.mock('../../lib/api', () => ({
+ getChunkPdfUrl: (filePath: string) => `http://localhost:8000/api/v1/chunks/${filePath}/pdf`,
+ getPdfViewerUrl: (filePath: string, page?: number) => {
+ const base = `/pdf-viewer?url=http://localhost:8000/api/v1/chunks/${filePath}/pdf`
+ return page !== undefined ? `${base}&page=${page}` : base
+ },
+}))
+
+const mockSubQuestionSources: SubQuestionSources[] = [
+ {
+ sub_question_index: 0,
+ sub_question_text: 'What are time extensions?',
+ sources: [
+ {
+ filename: 'time_ext.pdf',
+ upload_date: '2026-04-23',
+ content_summary: 'Clause 61.3...',
+ chunk_index: 0,
+ page_number: 3,
+ chunk_file_path: 'chunk_0.pdf',
+ },
+ ],
+ },
+ {
+ sub_question_index: 1,
+ sub_question_text: 'What notice is required?',
+ sources: [
+ {
+ filename: 'notice_req.pdf',
+ upload_date: '2026-04-23',
+ content_summary: 'Notice must...',
+ chunk_index: 1,
+ page_number: 7,
+ chunk_file_path: 'chunk_1.pdf',
+ },
+ ],
+ },
+]
+
+const mockAnswer = `## Sub-question 1: What are time extensions?
+- Time extensions must be notified [time_ext.pdf, page 3]
+
+## Sub-question 2: What notice is required?
+- Written notice must be given [notice_req.pdf, page 7]`
+
+describe('ResponsePanel — per-sub-question rendering (Phase 4)', () => {
+ it('renders per-subq sections when subQuestionSources provided', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('What are time extensions?')).toBeInTheDocument()
+ expect(screen.getByText('What notice is required?')).toBeInTheDocument()
+ expect(screen.getByText('Time extensions must be notified')).toBeInTheDocument()
+ expect(screen.getByText('Written notice must be given')).toBeInTheDocument()
+ })
+
+ it('each section has its own sources', () => {
+ render(
+
+ )
+
+ const toggles = screen.getAllByTestId('sources-toggle')
+ expect(toggles).toHaveLength(2)
+ expect(toggles[0]).toHaveTextContent('Sources (1)')
+ expect(screen.getAllByTestId('sources-container')).toHaveLength(2)
+
+ fireEvent.click(toggles[1])
+ const sourceCards = screen.getAllByTestId('sources-container')
+ expect(sourceCards).toHaveLength(1)
+ expect(screen.getByText(/Page 3/)).toBeInTheDocument()
+ expect(screen.queryByText(/Page 7/)).not.toBeInTheDocument()
+ })
+
+ it('falls back to flat rendering when subQuestionSources is null', () => {
+ const flatSources: SourceMetadata[] = [
+ {
+ filename: 'doc.pdf',
+ upload_date: '2026-04-23',
+ content_summary: 'Summary',
+ chunk_index: 0,
+ page_number: 1,
+ chunk_file_path: 'chunk.pdf',
+ },
+ ]
+
+ render(
+
+ )
+
+ expect(screen.getByText('Flat answer text')).toBeInTheDocument()
+ expect(screen.getByText('doc.pdf')).toBeInTheDocument()
+ expect(screen.queryByText('What are time extensions?')).not.toBeInTheDocument()
+ })
+
+ it('falls back to flat rendering when subQuestionSources is undefined', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Flat answer text')).toBeInTheDocument()
+ expect(screen.queryByText('What are time extensions?')).not.toBeInTheDocument()
+ })
+
+ it('copy button copies full answer including all sub-question headers', async () => {
+ const mockClipboardWrite = vi.fn().mockResolvedValue(undefined)
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: mockClipboardWrite,
+ },
+ })
+
+ render(
+
+ )
+
+ const copyButton = screen.getByTestId('copy-answer-btn')
+ fireEvent.click(copyButton)
+
+ expect(mockClipboardWrite).toHaveBeenCalledWith(mockAnswer)
+ await waitFor(() => {
+ expect(screen.getByText('Copied!')).toBeInTheDocument()
+ })
+ })
+
+ it('shows skeleton loaders when isLoading is true', () => {
+ render(
+
+ )
+
+ const skeletonElements = screen.getAllByTestId('skeleton-line')
+ expect(skeletonElements.length).toBeGreaterThan(0)
+ })
+
+ it('shows no-relevant-information message for sub-question with empty sources', () => {
+ const subqWithNoSources: SubQuestionSources[] = [
+ {
+ sub_question_index: 0,
+ sub_question_text: 'What are time extensions?',
+ sources: [],
+ },
+ ]
+
+ const answer = '## Sub-question 1: What are time extensions?\n- No relevant information found.'
+
+ render(
+
+ )
+
+ expect(screen.getByText('What are time extensions?')).toBeInTheDocument()
+ expect(screen.queryByTestId('sources-toggle')).not.toBeInTheDocument()
+ })
+
+ it('adds subq-{index} id to each section container', () => {
+ render(
+
+ )
+
+ expect(document.getElementById('subq-0')).toBeInTheDocument()
+ expect(document.getElementById('subq-1')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/e2e/test_phase4_e2e_query_flow.test.tsx b/frontend/src/test/e2e/test_phase4_e2e_query_flow.test.tsx
new file mode 100644
index 0000000..41f1b98
--- /dev/null
+++ b/frontend/src/test/e2e/test_phase4_e2e_query_flow.test.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
+import { queryClient } from '../../lib/queries'
+import App from '../../App'
+import type { SubQuestionSources } from '../../types'
+
+vi.mock('../../lib/api', async () => {
+ const actual = await vi.importActual('../../lib/api')
+ return {
+ ...(actual as Record),
+ queryDocumentStream: vi.fn(),
+ ingestDocument: vi.fn(),
+ }
+})
+
+import { queryDocumentStream, ingestDocument } from '../../lib/api'
+
+const mockQueryDocumentStream = vi.mocked(queryDocumentStream)
+const mockIngestDocument = vi.mocked(ingestDocument)
+
+const mockSubQuestionSources: SubQuestionSources[] = [
+ {
+ sub_question_index: 0,
+ sub_question_text: 'Who created Python?',
+ sources: [
+ {
+ filename: 'python.pdf',
+ upload_date: '2024-01-15',
+ content_summary: 'Python origins',
+ chunk_index: 0,
+ page_number: 1,
+ chunk_file_path: null,
+ },
+ ],
+ },
+ {
+ sub_question_index: 1,
+ sub_question_text: 'What paradigms does Python support?',
+ sources: [
+ {
+ filename: 'python.pdf',
+ upload_date: '2024-01-15',
+ content_summary: 'Python paradigms',
+ chunk_index: 1,
+ page_number: 2,
+ chunk_file_path: null,
+ },
+ ],
+ },
+]
+
+const mockCompletedAnswer = [
+ '## Sub-question 1: Who created Python?',
+ '- Python was created by Guido van Rossum [python.pdf, page 1]',
+ '',
+ '## Sub-question 2: What paradigms does Python support?',
+ '- Python supports procedural and object-oriented paradigms [python.pdf, page 2]',
+].join('\n')
+
+describe('Phase 4 per-sub-question query flow', () => {
+ beforeEach(() => {
+ queryClient.clear()
+ vi.restoreAllMocks()
+ mockIngestDocument.mockResolvedValue({
+ document_id: 'doc-abc',
+ chunk_count: 3,
+ filename: 'test.pdf',
+ })
+ })
+
+ it('displays per-sub-question sections with sources from SSE stream', async () => {
+ mockQueryDocumentStream.mockImplementation(async (_request, onEvent) => {
+ onEvent({ phase: 'decomposed', extracted_questions: ['Who created Python?', 'What paradigms?'] })
+ onEvent({ phase: 'retrieving' })
+ onEvent({ phase: 'filtering' })
+ onEvent({ phase: 'generating' })
+ onEvent({ phase: 'generating_subquestion', sub_question_index: 0, sub_question_text: 'Who created Python?' })
+ onEvent({ phase: 'generating_subquestion', sub_question_index: 1, sub_question_text: 'What paradigms?' })
+ onEvent({
+ phase: 'completed',
+ answer: mockCompletedAnswer,
+ sub_question_sources: mockSubQuestionSources,
+ sources: mockSubQuestionSources.flatMap(sq => sq.sources),
+ })
+ })
+
+ render()
+
+ const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
+ fireEvent.change(textarea, { target: { value: 'Who created Python and what paradigms?' } })
+
+ const submitButton = screen.getByRole('button', { name: /submit/i })
+ await act(async () => {
+ fireEvent.click(submitButton)
+ })
+
+ await waitFor(() => {
+ expect(screen.getAllByText('Who created Python?').length).toBeGreaterThanOrEqual(1)
+ })
+ expect(screen.getAllByText(/What paradigms/).length).toBeGreaterThanOrEqual(1)
+
+ await waitFor(() => {
+ expect(screen.getByText(/Guido van Rossum/)).toBeInTheDocument()
+ })
+ expect(screen.getByText(/procedural and object-oriented/)).toBeInTheDocument()
+ })
+
+ it('shows progressive generation state via generating_subquestion events', async () => {
+ let onEventRef: ((event: any) => void) | null = null
+
+ mockQueryDocumentStream.mockImplementation(async (_request, onEvent) => {
+ onEventRef = onEvent
+ onEvent({ phase: 'decomposed', extracted_questions: ['Question A'] })
+ onEvent({ phase: 'retrieving' })
+ onEvent({ phase: 'filtering' })
+ onEvent({ phase: 'generating' })
+ })
+
+ render()
+
+ const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
+ fireEvent.change(textarea, { target: { value: 'Test progressive' } })
+
+ const submitButton = screen.getByRole('button', { name: /submit/i })
+ await act(async () => {
+ fireEvent.click(submitButton)
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Question A')).toBeInTheDocument()
+ })
+
+ expect(onEventRef).toBeTruthy()
+ if (onEventRef) {
+ onEventRef({ phase: 'generating_subquestion', sub_question_index: 0, sub_question_text: 'Question A' })
+ }
+
+ await act(async () => {
+ if (onEventRef) {
+ onEventRef({
+ phase: 'completed',
+ answer: '## Sub-question 1: Question A\n- Answer here',
+ sub_question_sources: mockSubQuestionSources.slice(0, 1),
+ sources: mockSubQuestionSources[0].sources,
+ })
+ }
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText(/Answer here/)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/src/test/lib/test_phase4_stream_state.test.tsx b/frontend/src/test/lib/test_phase4_stream_state.test.tsx
new file mode 100644
index 0000000..e318308
--- /dev/null
+++ b/frontend/src/test/lib/test_phase4_stream_state.test.tsx
@@ -0,0 +1,288 @@
+import React from 'react'
+import { renderHook, act } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useQueryDocumentStream } from '../../lib/queries'
+import type { QueryStreamEvent, SubQuestionSources, SourceMetadata } from '../../types'
+
+/**
+ * Tests for Phase 4.5: SSE stream state handling for per-sub-question response format.
+ *
+ * Covers:
+ * - completed event with sub_question_sources populates state
+ * - backward-compatible flat sources array still accessible
+ * - generating_subquestion event updates phase tracking
+ * - full stream lifecycle with all new event shapes
+ */
+
+vi.mock('../../lib/api', () => ({
+ queryDocumentStream: vi.fn(),
+}))
+
+import { queryDocumentStream } from '../../lib/api'
+
+const mockQueryDocumentStream = vi.mocked(queryDocumentStream)
+
+function createWrapper() {
+ const queryClient = new QueryClient()
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children}
+ }
+}
+
+const sampleSources: SourceMetadata[] = [
+ {
+ filename: 'doc1.pdf',
+ upload_date: '2024-01-15',
+ content_summary: 'Summary of doc1',
+ chunk_index: 0,
+ page_number: 1,
+ chunk_file_path: 'chunk_0.pdf',
+ },
+]
+
+const sampleSubQuestionSources: SubQuestionSources[] = [
+ {
+ sub_question_index: 0,
+ sub_question_text: 'What is RAG?',
+ sources: sampleSources,
+ },
+ {
+ sub_question_index: 1,
+ sub_question_text: 'How does retrieval work?',
+ sources: [],
+ },
+]
+
+describe('Phase 4.5 Stream State', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('test_completed_event_sets_sub_question_sources', async () => {
+ let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined
+ mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => {
+ capturedOnEvent = onEvent
+ })
+
+ const { result } = renderHook(() => useQueryDocumentStream(), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ result.current.mutate({ question: 'test question' })
+ })
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'completed',
+ answer: '## Sub-question 1: What is RAG?\n- RAG is...',
+ sub_question_sources: sampleSubQuestionSources,
+ sources: sampleSources,
+ })
+ })
+
+ expect(result.current.subQuestionSources).toEqual(sampleSubQuestionSources)
+ expect(result.current.subQuestionSources).toHaveLength(2)
+ expect(result.current.subQuestionSources![0].sub_question_text).toBe('What is RAG?')
+ expect(result.current.subQuestionSources![1].sub_question_text).toBe('How does retrieval work?')
+ })
+
+ it('test_completed_event_keeps_backward_compat_sources', async () => {
+ let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined
+ mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => {
+ capturedOnEvent = onEvent
+ })
+
+ const { result } = renderHook(() => useQueryDocumentStream(), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ result.current.mutate({ question: 'test question' })
+ })
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'completed',
+ answer: 'Test answer',
+ sub_question_sources: sampleSubQuestionSources,
+ sources: sampleSources,
+ })
+ })
+
+ expect(result.current.sources).toEqual(sampleSources)
+ expect(result.current.sources).toHaveLength(1)
+ expect(result.current.sources![0].filename).toBe('doc1.pdf')
+ })
+
+ it('test_generating_subquestion_event_updates_phase', async () => {
+ let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined
+ mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => {
+ capturedOnEvent = onEvent
+ })
+
+ const { result } = renderHook(() => useQueryDocumentStream(), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ result.current.mutate({ question: 'test question' })
+ })
+
+ await act(async () => {
+ capturedOnEvent!({ phase: 'generating' })
+ })
+ expect(result.current.phase).toBe('generating')
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'generating_subquestion',
+ sub_question_index: 0,
+ sub_question_text: 'What is RAG?',
+ })
+ })
+ expect(result.current.phase).toBe('generating')
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'generating_subquestion',
+ sub_question_index: 1,
+ sub_question_text: 'How does retrieval work?',
+ })
+ })
+ expect(result.current.phase).toBe('generating')
+ })
+
+ it('test_completed_event_without_sub_question_sources_sets_null', async () => {
+ let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined
+ mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => {
+ capturedOnEvent = onEvent
+ })
+
+ const { result } = renderHook(() => useQueryDocumentStream(), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ result.current.mutate({ question: 'test question' })
+ })
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'completed',
+ answer: 'Simple answer',
+ sources: sampleSources,
+ })
+ })
+
+ expect(result.current.subQuestionSources).toBeNull()
+ expect(result.current.answer).toBe('Simple answer')
+ expect(result.current.sources).toEqual(sampleSources)
+ })
+
+ it('test_full_stream_lifecycle_with_sub_questions', async () => {
+ let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined
+ mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => {
+ capturedOnEvent = onEvent
+ })
+
+ const { result } = renderHook(() => useQueryDocumentStream(), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ result.current.mutate({ question: 'complex question' })
+ })
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'decomposed',
+ extracted_questions: ['q1', 'q2'],
+ })
+ })
+ expect(result.current.phase).toBe('retrieving')
+ expect(result.current.extractedQuestions).toEqual(['q1', 'q2'])
+
+ await act(async () => {
+ capturedOnEvent!({ phase: 'retrieving' })
+ })
+ expect(result.current.phase).toBe('retrieving')
+
+ await act(async () => {
+ capturedOnEvent!({ phase: 'filtering' })
+ })
+ expect(result.current.phase).toBe('filtering')
+
+ await act(async () => {
+ capturedOnEvent!({ phase: 'generating' })
+ })
+ expect(result.current.phase).toBe('generating')
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'generating_subquestion',
+ sub_question_index: 0,
+ sub_question_text: 'q1',
+ })
+ })
+ expect(result.current.phase).toBe('generating')
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'generating_subquestion',
+ sub_question_index: 1,
+ sub_question_text: 'q2',
+ })
+ })
+ expect(result.current.phase).toBe('generating')
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'completed',
+ answer: '## Sub-question 1: q1\n- answer1\n\n## Sub-question 2: q2\n- answer2',
+ sub_question_sources: sampleSubQuestionSources,
+ sources: sampleSources,
+ })
+ })
+ expect(result.current.phase).toBe('completed')
+ expect(result.current.answer).toContain('Sub-question 1: q1')
+ expect(result.current.subQuestionSources).toEqual(sampleSubQuestionSources)
+ expect(result.current.sources).toEqual(sampleSources)
+ })
+
+ it('test_reset_clears_sub_question_sources', async () => {
+ let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined
+ mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => {
+ capturedOnEvent = onEvent
+ })
+
+ const { result } = renderHook(() => useQueryDocumentStream(), {
+ wrapper: createWrapper(),
+ })
+
+ await act(async () => {
+ result.current.mutate({ question: 'test question' })
+ })
+
+ await act(async () => {
+ capturedOnEvent!({
+ phase: 'completed',
+ answer: 'answer',
+ sub_question_sources: sampleSubQuestionSources,
+ sources: sampleSources,
+ })
+ })
+
+ expect(result.current.subQuestionSources).toEqual(sampleSubQuestionSources)
+
+ await act(async () => {
+ result.current.reset()
+ })
+
+ expect(result.current.subQuestionSources).toBeNull()
+ expect(result.current.answer).toBeNull()
+ expect(result.current.sources).toBeNull()
+ expect(result.current.phase).toBe('idle')
+ })
+})
diff --git a/frontend/src/test/lib/test_phase4_types.test.ts b/frontend/src/test/lib/test_phase4_types.test.ts
new file mode 100644
index 0000000..b0f984f
--- /dev/null
+++ b/frontend/src/test/lib/test_phase4_types.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect } from 'vitest'
+import type {
+ SubQuestionSources,
+ QueryStreamEvent,
+ SourceMetadata,
+ QueryResponse,
+} from '../../types'
+
+/**
+ * Tests for Phase 4.5: TypeScript type definitions for per-sub-question response format.
+ *
+ * Covers:
+ * - SubQuestionSources interface shape
+ * - QueryStreamEvent discriminated union on `phase` field
+ * - QueryResponse includes sub_question_sources
+ * - Completed event shape narrows correctly via discriminated union
+ */
+
+const sampleSource: SourceMetadata = {
+ filename: 'test.pdf',
+ upload_date: '2024-01-01',
+ content_summary: 'Test summary',
+ chunk_index: 0,
+ page_number: 1,
+ chunk_file_path: 'chunk_0.pdf',
+}
+
+describe('Phase 4.5 Types', () => {
+ it('test_sub_question_sources_type', () => {
+ const sqs: SubQuestionSources = {
+ sub_question_index: 0,
+ sub_question_text: 'What is RAG?',
+ sources: [sampleSource],
+ }
+
+ expect(sqs.sub_question_index).toBe(0)
+ expect(sqs.sub_question_text).toBe('What is RAG?')
+ expect(sqs.sources).toHaveLength(1)
+ expect(sqs.sources[0].filename).toBe('test.pdf')
+ })
+
+ it('test_sub_question_sources_with_empty_sources', () => {
+ const sqs: SubQuestionSources = {
+ sub_question_index: 1,
+ sub_question_text: 'No sources question',
+ sources: [],
+ }
+
+ expect(sqs.sources).toHaveLength(0)
+ })
+
+ it('test_query_stream_event_discriminated_union_completed', () => {
+ const event: QueryStreamEvent = {
+ phase: 'completed',
+ answer: '## Sub-question 1: q1\n- a1',
+ sub_question_sources: [
+ { sub_question_index: 0, sub_question_text: 'q1', sources: [sampleSource] },
+ ],
+ sources: [sampleSource],
+ }
+
+ if (event.phase === 'completed') {
+ expect(event.answer).toBeDefined()
+ expect(event.sub_question_sources).toHaveLength(1)
+ expect(event.sub_question_sources[0].sub_question_text).toBe('q1')
+ }
+ })
+
+ it('test_query_stream_event_discriminated_union_generating_subquestion', () => {
+ const event: QueryStreamEvent = {
+ phase: 'generating_subquestion',
+ sub_question_index: 0,
+ sub_question_text: 'What is RAG?',
+ }
+
+ if (event.phase === 'generating_subquestion') {
+ expect(event.sub_question_index).toBe(0)
+ expect(event.sub_question_text).toBe('What is RAG?')
+ }
+ })
+
+ it('test_query_stream_event_discriminated_union_decomposed', () => {
+ const event: QueryStreamEvent = {
+ phase: 'decomposed',
+ extracted_questions: ['q1', 'q2'],
+ }
+
+ if (event.phase === 'decomposed') {
+ expect(event.extracted_questions).toEqual(['q1', 'q2'])
+ }
+ })
+
+ it('test_query_stream_event_discriminated_union_simple_phases', () => {
+ const retrieving: QueryStreamEvent = { phase: 'retrieving' }
+ const filtering: QueryStreamEvent = { phase: 'filtering' }
+ const generating: QueryStreamEvent = { phase: 'generating' }
+ const error: QueryStreamEvent = { phase: 'error', message: 'fail' }
+
+ expect(retrieving.phase).toBe('retrieving')
+ expect(filtering.phase).toBe('filtering')
+ expect(generating.phase).toBe('generating')
+ if (error.phase === 'error') {
+ expect(error.message).toBe('fail')
+ }
+ })
+
+ it('test_query_response_includes_sub_question_sources', () => {
+ const response: QueryResponse = {
+ extracted_questions: ['q1', 'q2'],
+ answer: 'Answer text',
+ sub_question_sources: [
+ { sub_question_index: 0, sub_question_text: 'q1', sources: [sampleSource] },
+ { sub_question_index: 1, sub_question_text: 'q2', sources: [] },
+ ],
+ sources: [sampleSource],
+ }
+
+ expect(response.sub_question_sources).toHaveLength(2)
+ expect(response.sub_question_sources[0].sub_question_text).toBe('q1')
+ expect(response.sources).toHaveLength(1)
+ })
+})
diff --git a/frontend/src/test/utils/test_phase4_citation_parser.test.ts b/frontend/src/test/utils/test_phase4_citation_parser.test.ts
new file mode 100644
index 0000000..f47131e
--- /dev/null
+++ b/frontend/src/test/utils/test_phase4_citation_parser.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect } from 'vitest'
+import { buildCitationLookupForSubq, processCitationsForSubq } from '../../utils/citationParser'
+import type { SubQuestionSources } from '../../types'
+
+const mockSubQuestionSources: SubQuestionSources[] = [
+ {
+ sub_question_index: 0,
+ sub_question_text: 'What are time extensions?',
+ sources: [
+ {
+ filename: 'time_ext.pdf',
+ upload_date: '2026-04-23',
+ content_summary: 'Clause 61.3...',
+ chunk_index: 0,
+ page_number: 3,
+ chunk_file_path: 'chunk_0.pdf',
+ },
+ ],
+ },
+ {
+ sub_question_index: 1,
+ sub_question_text: 'What notice is required?',
+ sources: [
+ {
+ filename: 'notice_req.pdf',
+ upload_date: '2026-04-23',
+ content_summary: 'Notice must...',
+ chunk_index: 1,
+ page_number: 7,
+ chunk_file_path: 'chunk_1.pdf',
+ },
+ ],
+ },
+]
+
+describe('buildCitationLookupForSubq', () => {
+ it('returns lookup scoped to specified sub-question only', () => {
+ const lookup = buildCitationLookupForSubq(mockSubQuestionSources, 0)
+ expect(lookup.has('time_ext.pdf, page 3')).toBe(true)
+ expect(lookup.has('notice_req.pdf, page 7')).toBe(false)
+ })
+
+ it('returns empty lookup for out-of-range subq index', () => {
+ const lookup = buildCitationLookupForSubq(mockSubQuestionSources, 99)
+ expect(lookup.size).toBe(0)
+ })
+
+ it('does not leak sources from other sub-questions', () => {
+ const lookup0 = buildCitationLookupForSubq(mockSubQuestionSources, 0)
+ const lookup1 = buildCitationLookupForSubq(mockSubQuestionSources, 1)
+
+ expect(lookup0.has('notice_req.pdf, page 7')).toBe(false)
+ expect(lookup1.has('time_ext.pdf, page 3')).toBe(false)
+ })
+})
+
+describe('processCitationsForSubq', () => {
+ it('replaces citation when it exists in the sub-question sources', () => {
+ const text = 'Time extensions [time_ext.pdf, page 3] are important.'
+ const result = processCitationsForSubq(text, mockSubQuestionSources, 0)
+ expect(result).toContain('](')
+ expect(result).toContain('/pdf-viewer')
+ })
+
+ it('leaves citation plain when it belongs to a different sub-question', () => {
+ const text = 'Notice [notice_req.pdf, page 7] is required.'
+ const result = processCitationsForSubq(text, mockSubQuestionSources, 0)
+ expect(result).toBe(text)
+ expect(result).not.toContain('/pdf-viewer')
+ })
+
+ it('correctly processes each sub-question independently', () => {
+ const text0 = 'Clause [time_ext.pdf, page 3] applies.'
+ const text1 = 'Notice [notice_req.pdf, page 7] applies.'
+
+ const result0 = processCitationsForSubq(text0, mockSubQuestionSources, 0)
+ const result1 = processCitationsForSubq(text1, mockSubQuestionSources, 1)
+
+ expect(result0).toContain('/pdf-viewer')
+ expect(result0).not.toBe(text0)
+
+ expect(result1).toContain('/pdf-viewer')
+ expect(result1).not.toBe(text1)
+ })
+
+ it('returns original text when subq index is out of range', () => {
+ const text = 'Citation [time_ext.pdf, page 3] here.'
+ const result = processCitationsForSubq(text, mockSubQuestionSources, 99)
+ expect(result).toBe(text)
+ })
+})