Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/api/aiRunEvents.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>({
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',
)
})
})
85 changes: 85 additions & 0 deletions src/api/aiRunEvents.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
6 changes: 5 additions & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -78,7 +82,7 @@ async function rawFetch(path: string, init: RequestInit): Promise<Response> {
headers.set('Authorization', `Bearer ${accessToken}`)
}

return fetch(`${API_BASE_URL}${path}`, {
return fetch(getApiUrl(path), {
...init,
headers,
credentials: 'include',
Expand Down
117 changes: 116 additions & 1 deletion src/pages/ReviewWorkPage/AiRunReview.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(
<MemoryRouter>
<AiRunReview initialRun={processingRun()} />
</MemoryRouter>,
)
}

function eventStream(event: unknown) {
const payload = `id:2\nevent:NEEDS_INFO\ndata:${JSON.stringify(event)}\n\n`
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(payload))
controller.close()
},
})
}

function renderReview() {
render(
<MemoryRouter initialEntries={['/tasks/new/review']}>
Expand Down Expand Up @@ -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()
})
})
Loading