diff --git a/src/api/aiRunEvents.test.ts b/src/api/aiRunEvents.test.ts new file mode 100644 index 0000000..54b90b8 --- /dev/null +++ b/src/api/aiRunEvents.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { subscribeAiRunEvents, type AiRunPublicEvent } from './aiRunEvents' +import { setAccessToken } from './client' + +const EVENT: AiRunPublicEvent = { + event_id: 2, + ai_run_id: 'A-1', + type: 'RUN_STARTED', + status: 'RUNNING', + analysis_outcome: null, + attempt_count: 1, + version: 2, + occurred_at: '2026-08-09T00:00:00Z', +} + +function eventStream(chunks: string[]) { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))) + controller.close() + }, + }) +} + +beforeEach(() => { + setAccessToken('access-token') + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + setAccessToken(null) + vi.unstubAllGlobals() +}) + +describe('subscribeAiRunEvents', () => { + it('uses fetch with bearer auth and parses SSE split across response chunks', async () => { + const payload = JSON.stringify(EVENT) + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + eventStream([ + ':heartbeat\n\nid:2\nevent:RUN_STARTED\ndata:', + `${payload.slice(0, 25)}`, + `${payload.slice(25)}\n\n`, + ]), + { status: 200, headers: { 'Content-Type': 'text/event-stream;charset=UTF-8' } }, + ), + ) + const onEvent = vi.fn() + + await subscribeAiRunEvents('A/1', { lastEventId: '1', onEvent }) + + expect(onEvent).toHaveBeenCalledWith(EVENT) + const [url, init] = vi.mocked(fetch).mock.calls[0] + const headers = new Headers(init?.headers) + expect(String(url)).toContain('/ai-runs/A%2F1/events') + expect(headers.get('Accept')).toBe('text/event-stream') + expect(headers.get('Authorization')).toBe('Bearer access-token') + expect(headers.get('Last-Event-ID')).toBe('1') + }) + + it('rejects a non-stream response so the caller can fall back to polling', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ code: 'AI_RUN_SSE_CONNECTION_LIMIT' }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + await expect(subscribeAiRunEvents('A-1', { onEvent: vi.fn() })).rejects.toThrow( + 'event stream failed with 429', + ) + }) +}) diff --git a/src/api/aiRunEvents.ts b/src/api/aiRunEvents.ts new file mode 100644 index 0000000..c47907f --- /dev/null +++ b/src/api/aiRunEvents.ts @@ -0,0 +1,85 @@ +import type { AiAnalysisOutcome, AiRunStatus } from './aiRuns' +import { getAccessToken, getApiUrl } from './client' + +export type AiRunPublicEventType = + | 'RUN_QUEUED' + | 'RUN_STARTED' + | 'SLOT_CHECKING' + | 'NEEDS_INFO' + | 'REVIEW_REQUIRED' + | 'COMPLETED' + | 'FAILED' + +export interface AiRunPublicEvent { + event_id: number + ai_run_id: string + type: AiRunPublicEventType + status: AiRunStatus + analysis_outcome: AiAnalysisOutcome | null + attempt_count: number + version: number + occurred_at: string +} + +export interface SubscribeAiRunEventsOptions { + signal?: AbortSignal + lastEventId?: string + onEvent: (event: AiRunPublicEvent) => void +} + +function parseEventBlock(block: string): AiRunPublicEvent | null { + const dataLines: string[] = [] + for (const line of block.split(/\r?\n/)) { + if (line.startsWith(':')) continue + const separator = line.indexOf(':') + const field = separator === -1 ? line : line.slice(0, separator) + const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '') + if (field === 'data') dataLines.push(value) + } + if (dataLines.length === 0) return null + return JSON.parse(dataLines.join('\n')) as AiRunPublicEvent +} + +export async function subscribeAiRunEvents( + aiRunId: string, + options: SubscribeAiRunEventsOptions, +): Promise { + const headers = new Headers({ Accept: 'text/event-stream' }) + const token = getAccessToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + if (options.lastEventId) headers.set('Last-Event-ID', options.lastEventId) + + const response = await fetch(getApiUrl(`/ai-runs/${encodeURIComponent(aiRunId)}/events`), { + method: 'GET', + headers, + credentials: 'include', + signal: options.signal, + }) + if (!response.ok) { + throw new Error(`AI Run event stream failed with ${response.status}`) + } + if (!response.headers.get('Content-Type')?.includes('text/event-stream') || !response.body) { + throw new Error('AI Run event stream response is invalid') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + const blocks = buffer.split(/\r?\n\r?\n/) + buffer = blocks.pop() ?? '' + for (const block of blocks) { + const event = parseEventBlock(block) + if (event) options.onEvent(event) + } + if (done) break + } + + if (buffer.trim()) { + const event = parseEventBlock(buffer) + if (event) options.onEvent(event) + } +} diff --git a/src/api/client.ts b/src/api/client.ts index 3ba4440..871a439 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -5,6 +5,10 @@ import { ApiError, networkApiError, type ApiErrorBody } from './errors' // 명시적으로 다루도록 하고, 응답 전체를 자동으로 camelCase 변환하지 않는다. const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1' +export function getApiUrl(path: string) { + return `${API_BASE_URL}${path}` +} + let accessToken: string | null = null export function setAccessToken(token: string | null) { @@ -78,7 +82,7 @@ async function rawFetch(path: string, init: RequestInit): Promise { headers.set('Authorization', `Bearer ${accessToken}`) } - return fetch(`${API_BASE_URL}${path}`, { + return fetch(getApiUrl(path), { ...init, headers, credentials: 'include', diff --git a/src/pages/ReviewWorkPage/AiRunReview.test.tsx b/src/pages/ReviewWorkPage/AiRunReview.test.tsx index 14bf26a..4a07130 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.test.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -86,6 +86,55 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()) +function processingRun(): AiRunResponse { + return { + ...RUN, + status: 'RUNNING', + analysis_outcome: null, + detected_intent: null, + version: 1, + questions: [], + candidates: [], + } +} + +function needsInfoRun(): AiRunResponse { + return { + ...processingRun(), + status: 'SUCCEEDED', + analysis_outcome: 'NEEDS_INFO', + detected_intent: 'EXPIRY_RENEWAL', + version: 2, + questions: [ + { + slot_key: 'due_at', + label: '신청 목표일을 입력해 주세요.', + input_type: 'DATE', + required: true, + answer: null, + }, + ], + } +} + +function renderProcessingReview() { + render( + + + , + ) +} + +function eventStream(event: unknown) { + const payload = `id:2\nevent:NEEDS_INFO\ndata:${JSON.stringify(event)}\n\n` + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)) + controller.close() + }, + }) +} + function renderReview() { render( @@ -184,3 +233,69 @@ describe('AiRunReview candidate decision', () => { expect(screen.getByRole('button', { name: '선택한 업무 생성' })).toBeDisabled() }) }) + +describe('AiRunReview progress updates', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('uses a terminal SSE event to fetch the complete questions and candidates', async () => { + const terminalEvent = { + event_id: 2, + ai_run_id: RUN.ai_run_id, + type: 'NEEDS_INFO', + status: 'SUCCEEDED', + analysis_outcome: 'NEEDS_INFO', + attempt_count: 1, + version: 2, + occurred_at: '2026-08-09T00:00:02Z', + } + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.includes('/events')) { + return Promise.resolve( + new Response(eventStream(terminalEvent), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }), + ) + } + if (url.includes(`/ai-runs/${RUN.ai_run_id}`)) { + return Promise.resolve(jsonResponse(needsInfoRun())) + } + return Promise.reject(new Error(`Unexpected request: ${url}`)) + }) + + renderProcessingReview() + + expect(await screen.findByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).includes('/events'))).toBe(true) + expect( + vi + .mocked(fetch) + .mock.calls.some(([url]) => String(url).endsWith(`/ai-runs/${RUN.ai_run_id}`)), + ).toBe(true) + }) + + it('falls back to the existing polling endpoint when the SSE connection fails', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.includes('/events')) return Promise.reject(new TypeError('stream disconnected')) + if (url.includes(`/ai-runs/${RUN.ai_run_id}`)) { + return Promise.resolve(jsonResponse(needsInfoRun())) + } + return Promise.reject(new Error(`Unexpected request: ${url}`)) + }) + renderProcessingReview() + + await waitFor(() => + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).includes('/events'))).toBe( + true, + ), + ) + await vi.advanceTimersByTimeAsync(1300) + + expect(await screen.findByLabelText('신청 목표일을 입력해 주세요. *')).toBeInTheDocument() + }) +}) diff --git a/src/pages/ReviewWorkPage/AiRunReview.tsx b/src/pages/ReviewWorkPage/AiRunReview.tsx index 3eaa114..671e8ee 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { ApiError, getErrorMessage } from '../../api/errors' +import { subscribeAiRunEvents, type AiRunPublicEvent } from '../../api/aiRunEvents' import { createAiRun, decideAiRunCandidates, @@ -135,14 +136,24 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { workerId: initialDraft?.workerId ?? '', } + const isProcessing = run.status === 'QUEUED' || run.status === 'RUNNING' + useEffect(() => { - if (run.status !== 'QUEUED' && run.status !== 'RUNNING') return + if (!isProcessing) return let cancelled = false - const timer = window.setTimeout(async () => { + let pollingTimer: number | null = null + let terminalEventSeen = false + const controller = new AbortController() + + const updateFromFullRun = async () => { try { const latest = await fetchAiRun(run.ai_run_id) - if (!cancelled) setRun(latest) + if (!cancelled) { + setRun(latest) + setError(null) + } + return latest } catch (pollError) { if (!cancelled) { setError( @@ -151,14 +162,66 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { : '분석 상태를 확인하지 못했습니다.', ) } + return null } - }, 1200) + } + + const schedulePolling = () => { + if (cancelled || pollingTimer !== null) return + pollingTimer = window.setTimeout(async () => { + pollingTimer = null + const latest = await updateFromFullRun() + if (!latest || latest.status === 'QUEUED' || latest.status === 'RUNNING') { + schedulePolling() + } + }, 1200) + } + + const handleEvent = (event: AiRunPublicEvent) => { + if (event.ai_run_id !== run.ai_run_id || cancelled) return + const terminal = + event.type === 'NEEDS_INFO' || + event.type === 'REVIEW_REQUIRED' || + event.type === 'COMPLETED' || + event.type === 'FAILED' + if (terminal) { + terminalEventSeen = true + void updateFromFullRun().then((latest) => { + if (!latest) schedulePolling() + }) + return + } + setRun((current) => + current.version > event.version + ? current + : { + ...current, + status: event.status, + analysis_outcome: event.analysis_outcome, + attempt_count: event.attempt_count, + version: event.version, + updated_at: event.occurred_at, + }, + ) + } + + subscribeAiRunEvents(run.ai_run_id, { + signal: controller.signal, + onEvent: handleEvent, + }) + .then(() => { + if (!terminalEventSeen) schedulePolling() + }) + .catch(() => { + if (!cancelled && !controller.signal.aborted) schedulePolling() + }) return () => { cancelled = true - window.clearTimeout(timer) + controller.abort() + if (pollingTimer !== null) window.clearTimeout(pollingTimer) } - }, [run]) + }, [isProcessing, run.ai_run_id]) useEffect(() => { setAnswers(