Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
@@ -0,0 +1,879 @@
# Snap 백엔드 연결 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** snap 목업(sessions/conversations/stream)을 걷어내고 base-backend(000)의 세션 기반 chat 계약(`/api/v1/chat`)에 real 연결한다 — 세션 목록/조회/생성 + SSE 토큰 스트리밍 + LLM 자동제목, 그리고 .NET 웹뷰용 Bearer 토큰 주입 seam.
**Architecture:** 기존 seam 에 real 구현을 끼운다. `snap.api.ts` 3함수는 mock 반환 → `apiGet/apiList/apiPost` 호출로 교체, `snap.stream.ts``mockStream` 제거하고 `streamLLM("/chat/stream")` 로 직결. `streamLLM``title` 이벤트 파싱 추가, 인증은 `getAccessToken()` provider 한 곳을 만들어 `client.ts`(axios 요청 인터셉터)와 `sse.ts`(헤더)가 참조 — 토큰 있으면 `Authorization: Bearer`, 없으면(=오늘) 쿠키 폴백. UI/store/컴포넌트는 안 건드린다.
**Tech Stack:** React 18 + TS + Vite + Zustand + TanStack Query + axios + `@microsoft/fetch-event-source` + vitest + @testing-library/react.
## Global Constraints
- 작업 디렉토리: `D:\project\021.code-assistant-v2\2_frontend` (모든 경로 이 기준 상대).
- git: 브랜치 `feat/snap-backend-connect` (이미 생성됨) → **태스크마다 자기 파일만 add 후 커밋, push 금지**.
- 게이트: 자기 파일 `npx eslint <파일>` 클린 + 해당 테스트 통과. Wave 경계에서 `npm run build`.
- baseline lint 주의: 프로젝트 전체 `npm run lint` 에 이번 작업 무관 기존 에러/경고 있음 — 스코프는 항상 `npx eslint <자기 파일>`.
- 응답·주석 톤: 한글 반말 (CLAUDE.md 0번). 백엔드 계약은 camelCase(CamelModel 정렬).
- import 별칭: `@/``src/`.
- dev 서버: `npm run dev` → http://localhost:15173. 백엔드: base-backend 로컬 8001, Vite proxy `/api`→8001.
- 테스트: vitest. 네트워크/스트림은 mock (실제 백엔드 호출 금지). 실제 백엔드 연동은 §검증에서 육안.
- **건드리지 말 것**: `features/chat/*`, `chatStore`, `DashboardLayout`, snap 의 store/components/pages. 이번 수정 파일은 아래 8개 태스크에 명시된 것만.
---
## 파일 구조 (수정/신규/삭제)
```
신규:
src/lib/auth/tokenProvider.ts # accessToken 단일 소스 (get/set)
src/lib/auth/tokenProvider.test.ts
src/lib/streaming/streamLLM.test.ts # title 이벤트 테스트 (신규)
src/features/snap/hooks/useSnapChat.test.tsx
수정:
src/lib/streaming/streamLLM.ts # title 이벤트 + onTitle 핸들러
src/lib/api/client.ts # 요청 인터셉터 Bearer 주입
src/lib/api/client.test.ts # Bearer 테스트 추가
src/lib/streaming/sse.ts # Authorization 헤더 (토큰 있을 때)
src/lib/streaming/sse.test.ts # Bearer 테스트 추가
src/features/snap/api/snap.api.ts # mock → apiGet/apiList/apiPost
src/features/snap/api/snap.api.test.tsx # mock client 로 재작성
src/features/snap/api/snap.stream.ts # USE_MOCK 제거, streamLLM 직결 + onTitle
src/features/snap/api/snap.stream.test.ts# streamLLM mock 으로 재작성
src/features/snap/hooks/useSnapChat.ts # title 캐시 패치 + 409 처리 + 중복 가드
삭제:
src/features/snap/mock/sessions.ts
src/features/snap/mock/conversations.ts
src/features/snap/mock/stream.ts
src/features/snap/mock/mock.test.ts
```
---
## Wave 실행 맵
| Wave | Task | 병렬성 | 파일(disjoint) | 의존 |
|---|---|---|---|---|
| W1 | T1 tokenProvider | 병렬 | `lib/auth/tokenProvider.ts(+test)` | — |
| W1 | T2 streamLLM title | 병렬 | `lib/streaming/streamLLM.ts(+test)` | — |
| W1 | T3 snap.api real | 병렬 | `features/snap/api/snap.api.ts(+test)` | — |
| W2 | T4 client Bearer | 병렬 | `lib/api/client.ts(+test)` | T1 |
| W2 | T5 sse Bearer | 병렬 | `lib/streaming/sse.ts(+test)` | T1 |
| W2 | T6 snap.stream real+title | 병렬 | `features/snap/api/snap.stream.ts(+test)` | T2 |
| W3 | T7 useSnapChat title+409 | 병렬 | `features/snap/hooks/useSnapChat.ts(+test)` | T3,T6 |
| W3 | T8 목업 제거 | 병렬 | `mock/*.ts` 삭제 | T3,T6 |
같은 wave = 파일 disjoint + 시그니처 import 없음. W1 셋은 서로 안 엮임. W2 셋은 각각 W1 산출(getAccessToken/onTitle)만 소비하고 서로 안 엮임. W3 둘은 파일 disjoint.
---
## Task T1: tokenProvider (accessToken 단일 소스)
**Files:**
- Create: `src/lib/auth/tokenProvider.ts`
- Test: `src/lib/auth/tokenProvider.test.ts`
**Interfaces:**
- Consumes: 없음
- Produces: `getAccessToken(): string | null`, `setAccessToken(token: string | null): void`
- [ ] **Step 1: 실패 테스트 작성**`src/lib/auth/tokenProvider.test.ts`
```ts
import { describe, it, expect, afterEach } from "vitest"
import { getAccessToken, setAccessToken } from "./tokenProvider"
afterEach(() => setAccessToken(null))
describe("tokenProvider", () => {
it("기본값은 null (쿠키 모드)", () => {
expect(getAccessToken()).toBeNull()
})
it("set 하면 그 토큰을 돌려준다", () => {
setAccessToken("abc")
expect(getAccessToken()).toBe("abc")
})
it("null 로 다시 초기화 가능", () => {
setAccessToken("abc")
setAccessToken(null)
expect(getAccessToken()).toBeNull()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/auth/tokenProvider.test.ts`
Expected: FAIL — `Failed to resolve import "./tokenProvider"`
- [ ] **Step 3: 구현**`src/lib/auth/tokenProvider.ts`
```ts
// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스.
// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면
// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다.
let accessToken: string | null = null
export function getAccessToken(): string | null {
return accessToken
}
export function setAccessToken(token: string | null): void {
accessToken = token
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/auth/tokenProvider.test.ts`
Expected: PASS (3 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
git add src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
git commit -m "feat(auth): add accessToken provider seam for .NET webview"
```
---
## Task T2: streamLLM 에 title 이벤트 추가
**Files:**
- Modify: `src/lib/streaming/streamLLM.ts`
- Test: `src/lib/streaming/streamLLM.test.ts` (신규)
**Interfaces:**
- Consumes: 없음 (기존 `streamSSE`)
- Produces: `LLMStreamHandlers.onTitle?: (title: string) => void` — T6 이 소비
- [ ] **Step 1: 실패 테스트 작성**`src/lib/streaming/streamLLM.test.ts`
```ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { streamLLM } from "./streamLLM"
import * as sse from "./sse"
vi.mock("./sse", () => ({ streamSSE: vi.fn() }))
describe("streamLLM title 이벤트", () => {
beforeEach(() => vi.clearAllMocks())
it("title 이벤트를 onTitle 로 전달한다", async () => {
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
opts.onEvent({ event: "title", data: JSON.stringify({ title: "판매문서 조인" }) })
})
const onTitle = vi.fn()
await streamLLM({
path: "/chat/stream",
body: {},
handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle },
})
expect(onTitle).toHaveBeenCalledWith("판매문서 조인")
})
it("malformed title 은 조용히 무시(throw 안 함)", async () => {
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
opts.onEvent({ event: "title", data: "not-json" })
})
const onTitle = vi.fn()
await expect(
streamLLM({ path: "/x", body: {}, handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle } }),
).resolves.toBeUndefined()
expect(onTitle).not.toHaveBeenCalled()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/streaming/streamLLM.test.ts`
Expected: FAIL — `onTitle` 이 호출 안 됨 (title 분기 없음)
- [ ] **Step 3: 구현**`src/lib/streaming/streamLLM.ts`
3-1. `LLMStreamHandlers` 인터페이스에 `onTitle` 추가 (기존 `onToken` 위, 라인 61 근처):
```ts
/** `title` 이벤트 — 첫 메시지 후 LLM 이 지은 세션 제목 (선택) */
onTitle?: (title: string) => void
/** `token` 이벤트 — 토큰 조각 누적해서 표시 */
onToken: (delta: string) => void
```
3-2. `ErrorPayload` 인터페이스 아래(라인 87 근처)에 payload 타입 추가:
```ts
interface TitlePayload {
title: string
}
```
3-3. `onEvent` 스위치에서 `result` 분기 다음에 `title` 분기 추가 (라인 126 `else if (e.event === "clarify")` 앞):
```ts
} else if (e.event === "title") {
try {
const payload = JSON.parse(e.data) as TitlePayload
if (typeof payload.title === "string") handlers.onTitle?.(payload.title)
} catch {
// 제목 갱신은 부가정보 — 실패해도 토큰 흐름엔 영향 없음
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/streaming/streamLLM.test.ts`
Expected: PASS (2 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
git add src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
git commit -m "feat(streaming): parse SSE title event into onTitle handler"
```
---
## Task T3: snap.api 목업 → real HTTP
**Files:**
- Modify: `src/features/snap/api/snap.api.ts`
- Test: `src/features/snap/api/snap.api.test.tsx` (재작성)
**Interfaces:**
- Consumes: `apiGet<T>(path)`, `apiPost<T>(path, body?)`, `apiList<T>(path)` from `@/lib/api/client`
- Produces: `useSessionList()``SnapSession[]` (queryKey `["snap","sessions"]`), `useSessionMessages(id)``SnapSessionDetail` (queryKey `["snap","session",id]`), `useCreateSession()` → mutation `SnapSession`
- [ ] **Step 1: 테스트 재작성 (실패)**`src/features/snap/api/snap.api.test.tsx` 전체 교체
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useSessionList, useSessionMessages } from "./snap.api"
import * as client from "@/lib/api/client"
vi.mock("@/lib/api/client")
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
const SESSION = {
id: "s1",
title: "t",
titleLlm: null,
isGenerating: false,
createdAt: "2026-07-18T00:00:00Z",
updatedAt: "2026-07-18T00:00:00Z",
}
describe("snap.api (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("useSessionList 는 GET /chat/sessions 의 items 를 반환", async () => {
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
const { result } = renderHook(() => useSessionList(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions")
expect(result.current.data![0].id).toBe("s1")
})
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
expect(result.current.data!.messages).toEqual([])
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/features/snap/api/snap.api.test.tsx`
Expected: FAIL — 현재 `snap.api.ts` 가 mock 반환이라 `client.apiList` 미호출
- [ ] **Step 3: 구현**`src/features/snap/api/snap.api.ts` 전체 교체
```ts
import { useQuery, useMutation } from "@tanstack/react-query"
import { apiGet, apiList, apiPost } from "@/lib/api/client"
import type { SnapSession, SnapSessionDetail } from "../contract/types"
export function useSessionList() {
return useQuery({
queryKey: ["snap", "sessions"],
queryFn: async (): Promise<SnapSession[]> => {
const { items } = await apiList<SnapSession>("/chat/sessions")
return items
},
})
}
export function useSessionMessages(id: string) {
return useQuery({
queryKey: ["snap", "session", id],
enabled: !!id,
queryFn: (): Promise<SnapSessionDetail> =>
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
})
}
export function useCreateSession() {
return useMutation({
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
})
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/features/snap/api/snap.api.test.tsx`
Expected: PASS (2 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
git add src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
git commit -m "feat(snap): wire session list/detail/create to real backend"
```
---
## Task T4: client.ts 요청 인터셉터 Bearer 주입
**Files:**
- Modify: `src/lib/api/client.ts`
- Test: `src/lib/api/client.test.ts` (describe 블록 추가)
**Interfaces:**
- Consumes: `getAccessToken()` from `@/lib/auth/tokenProvider` (T1)
- Produces: `apiClient` 가 토큰 있으면 `Authorization: Bearer <t>` 헤더 부착
- [ ] **Step 1: 실패 테스트 추가**`src/lib/api/client.test.ts` 파일 끝에 아래 추가. 파일 상단 import 에 `setAccessToken`, `apiClient`, `afterEach` 가 없으면 추가.
```ts
import { afterEach } from "vitest"
import { apiClient } from "./client"
import { setAccessToken } from "@/lib/auth/tokenProvider"
describe("apiClient Bearer 주입", () => {
afterEach(() => {
setAccessToken(null)
delete apiClient.defaults.adapter
})
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
setAccessToken("tok123")
let seen: unknown
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
}
await apiClient.get("/ping")
expect(seen).toBe("Bearer tok123")
})
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
setAccessToken(null)
let seen: unknown = "sentinel"
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
}
await apiClient.get("/ping")
expect(seen).toBeUndefined()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/api/client.test.ts`
Expected: FAIL — 토큰 넣어도 `Authorization` 이 undefined (인터셉터 없음)
- [ ] **Step 3: 구현**`src/lib/api/client.ts`
3-1. import 추가 (파일 상단 import 그룹):
```ts
import { getAccessToken } from "@/lib/auth/tokenProvider"
```
3-2. `apiClient` 생성 직후(라인 26 `})` 다음), 응답 인터셉터 위에 요청 인터셉터 추가:
```ts
// .NET 웹뷰 호스트가 토큰을 주입하면 Bearer 로, 아니면(=오늘) 쿠키로.
apiClient.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/api/client.test.ts`
Expected: PASS (기존 테스트 + 새 2개)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/api/client.ts src/lib/api/client.test.ts
git add src/lib/api/client.ts src/lib/api/client.test.ts
git commit -m "feat(api): inject Bearer token from provider when present"
```
---
## Task T5: sse.ts Authorization 헤더
**Files:**
- Modify: `src/lib/streaming/sse.ts`
- Test: `src/lib/streaming/sse.test.ts` (테스트 추가)
**Interfaces:**
- Consumes: `getAccessToken()` from `@/lib/auth/tokenProvider` (T1)
- Produces: SSE 요청이 토큰 있으면 `Authorization: Bearer` 헤더 포함
- [ ] **Step 1: 실패 테스트 추가**`src/lib/streaming/sse.test.ts`
1-1. 상단 import 에 추가:
```ts
import { afterEach } from "vitest"
import { setAccessToken } from "@/lib/auth/tokenProvider"
```
1-2. `describe("streamSSE", ...)` 안, 기존 `beforeEach` 아래에 추가:
```ts
afterEach(() => setAccessToken(null))
it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => {
setAccessToken("tok")
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
fetchEventSource.mockResolvedValue(undefined)
await streamSSE({ path: "/chat/stream", body: { message: "hi" }, onEvent: vi.fn() })
const [, opts] = fetchEventSource.mock.calls[0]
expect(opts.headers.Authorization).toBe("Bearer tok")
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/streaming/sse.test.ts`
Expected: FAIL — 새 테스트에서 `Authorization` 이 undefined
- [ ] **Step 3: 구현**`src/lib/streaming/sse.ts`
3-1. import 추가 (라인 2 `env` import 아래):
```ts
import { getAccessToken } from "@/lib/auth/tokenProvider"
```
3-2. `open()` 함수 시작부에서 헤더를 토큰 유무로 구성 (기존 `await fetchEventSource(...)` 의 인라인 `headers` 를 교체):
```ts
async function open(opts: StreamSSEOptions, retried: boolean): Promise<void> {
const token = getAccessToken()
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (token) headers.Authorization = `Bearer ${token}`
await fetchEventSource(`${env.apiBaseUrl}${opts.path}`, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify(opts.body),
signal: opts.signal,
openWhenHidden: true,
onopen: async (res) => {
if (res.ok) return
if (res.status === 401 && !retried) {
throw new RetryableUnauthorized()
}
throw new Error(`SSE open failed: ${res.status}`)
},
onmessage: (msg) => {
opts.onEvent({ event: msg.event || "message", data: msg.data, id: msg.id })
},
onerror: (err) => {
if (err instanceof RetryableUnauthorized) throw err
// 자동 reconnect 방지: throw하면 종료
opts.onError?.(err)
throw err
},
})
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/streaming/sse.test.ts`
Expected: PASS (기존 3개 + 새 1개). 기존 "쿠키 기반" 테스트의 `not.toHaveProperty("Authorization")` 는 토큰 null 이라 그대로 통과.
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
git add src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
git commit -m "feat(streaming): add Authorization header to SSE when token present"
```
---
## Task T6: snap.stream USE_MOCK 제거 + streamLLM 직결 + onTitle
**Files:**
- Modify: `src/features/snap/api/snap.stream.ts`
- Test: `src/features/snap/api/snap.stream.test.ts` (재작성)
**Interfaces:**
- Consumes: `streamLLM` (T2 의 `onTitle` 포함)
- Produces: `SnapStreamHandlers { onToken, onDone, onTitle?, onError? }`, `snapStream(req, handlers, opts?)` — T7 이 소비
- [ ] **Step 1: 테스트 재작성 (실패)**`src/features/snap/api/snap.stream.test.ts` 전체 교체
```ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { snapStream } from "./snap.stream"
import * as streaming from "@/lib/streaming"
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
describe("snapStream (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
expect(streamLLM).toHaveBeenCalledTimes(1)
const arg = streamLLM.mock.calls[0][0]
expect(arg.path).toBe("/chat/stream")
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
})
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockImplementation(
async (opts: { handlers: { onToken: (d: string) => void; onTitle?: (t: string) => void; onDone: (p: object) => void } }) => {
opts.handlers.onToken("a")
opts.handlers.onTitle?.("제목")
opts.handlers.onDone({})
},
)
const onToken = vi.fn()
const onTitle = vi.fn()
const onDone = vi.fn()
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
expect(onToken).toHaveBeenCalledWith("a")
expect(onTitle).toHaveBeenCalledWith("제목")
expect(onDone).toHaveBeenCalledTimes(1)
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/features/snap/api/snap.stream.test.ts`
Expected: FAIL — 현재 `USE_MOCK=true``streamLLM` 미호출
- [ ] **Step 3: 구현**`src/features/snap/api/snap.stream.ts` 전체 교체
```ts
import { streamLLM } from "@/lib/streaming"
import type { SnapStreamRequest } from "../contract/types"
export interface SnapStreamHandlers {
onToken: (delta: string) => void
onDone: () => void
onTitle?: (title: string) => void
onError?: (e: Error) => void
}
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
export function snapStream(
req: SnapStreamRequest,
handlers: SnapStreamHandlers,
opts?: { signal?: AbortSignal },
): Promise<void> {
return streamLLM({
path: "/chat/stream",
body: req,
signal: opts?.signal,
handlers: {
onToken: handlers.onToken,
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onError: handlers.onError,
},
})
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/features/snap/api/snap.stream.test.ts`
Expected: PASS (2 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git add src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git commit -m "feat(snap): stream via real /chat/stream and forward title event"
```
---
## Task T7: useSnapChat — title 캐시 패치 + 409 처리 + 중복 가드
**Files:**
- Modify: `src/features/snap/hooks/useSnapChat.ts`
- Test: `src/features/snap/hooks/useSnapChat.test.tsx` (신규)
**Interfaces:**
- Consumes: `snapStream` + `SnapStreamHandlers.onTitle` (T6), queryKeys `["snap","sessions"]`/`["snap","session",id]` (T3), `useSnapChatStore`
- Produces: `useSnapChat(sessionId)``{ send, stop }` (시그니처 불변)
- [ ] **Step 1: 실패 테스트 작성**`src/features/snap/hooks/useSnapChat.test.tsx`
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { useSnapChat } from "./useSnapChat"
import * as stream from "../api/snap.stream"
import { useSnapChatStore } from "../store/snapChatStore"
vi.mock("../api/snap.stream")
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
let qc: QueryClient
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => {
qc = new QueryClient()
useSnapChatStore.getState().reset()
vi.clearAllMocks()
})
describe("useSnapChat", () => {
it("onTitle 이 세션 목록 캐시의 title 을 갱신한다", async () => {
qc.setQueryData(
["snap", "sessions"],
[{ id: "s1", title: null, titleLlm: null, isGenerating: false, createdAt: "", updatedAt: "" }],
)
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onTitle?.("새 제목")
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
const list = qc.getQueryData(["snap", "sessions"]) as { id: string; title: string | null }[]
expect(list[0].title).toBe("새 제목")
})
it("409 에러면 '이미 생성 중' 안내 toast", async () => {
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onError?.(new Error("SSE open failed: 409"))
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("이미 생성 중"))
})
it("open 실패로 snapStream 이 reject 해도 send 는 throw 하지 않는다", async () => {
// sse.ts 는 open 실패 시 onError 를 부른 뒤 promise 도 reject 한다(이중 신호).
// send 는 그 rejection 을 삼켜 unhandled rejection 을 막아야 한다.
vi.mocked(stream.snapStream).mockRejectedValue(new Error("SSE open failed: 500"))
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await expect(result.current.send("hi")).resolves.toBeUndefined()
})
it("이미 스트리밍 중이면 두 번째 send 는 무시(중복 가드)", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(stream.snapStream).not.toHaveBeenCalled()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/features/snap/hooks/useSnapChat.test.tsx`
Expected: FAIL — title 미갱신 / 409 분기 없음 / 가드 없음
- [ ] **Step 3: 구현**`src/features/snap/hooks/useSnapChat.ts` 전체 교체
```ts
import { useCallback } from "react"
import { toast } from "sonner"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import { useSnapChatStore } from "../store/snapChatStore"
import { snapStream } from "../api/snap.stream"
import type { SnapSession, SnapSessionDetail } from "../contract/types"
// title 이벤트 → react-query 세션 캐시(목록 + 상세)의 title 갱신.
function patchSessionTitle(
queryClient: QueryClient,
sessionId: string,
title: string,
): void {
queryClient.setQueryData<SnapSession[]>(["snap", "sessions"], (prev) =>
prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)),
)
queryClient.setQueryData<SnapSessionDetail>(["snap", "session", sessionId], (prev) =>
prev ? { ...prev, title } : prev,
)
}
/** store + snapStream 배선 — 전송/중단. sessionId 는 현재 열린 세션. */
export function useSnapChat(sessionId: string) {
const queryClient = useQueryClient()
const send = useCallback(
async (text: string) => {
const store = useSnapChatStore.getState()
if (store.isStreaming) return // 이 클라이언트가 이미 생성 중 — 중복 전송 차단
store.currentController?.abort()
store.addUserMessage(text)
store.startAssistantMessage()
store.setStreaming(true)
const ctrl = new AbortController()
store.setController(ctrl)
try {
await snapStream(
{ sessionId, content: text },
{
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
onDone: () => {},
onTitle: (title) => patchSessionTitle(queryClient, sessionId, title),
onError: (e) => {
if (e.message.includes("409")) {
toast.error("이미 생성 중인 세션이야. 잠깐 기다렸다 다시 보내.")
} else {
toast.error(`스트림 오류: ${e.message}`)
}
},
},
{ signal: ctrl.signal },
)
} catch {
// sse.ts 는 open 실패(409/5xx) 시 onError 를 부른 뒤 promise 도 reject 한다.
// 오류 표시는 위 onError 에서 이미 함 → 여기선 rejection 만 삼켜 unhandled 방지.
// abort 는 라이브러리가 resolve 처리하므로 여기로 안 옴.
} finally {
const s = useSnapChatStore.getState()
if (s.currentController === ctrl) {
s.setController(null)
s.setStreaming(false)
}
}
},
[sessionId, queryClient],
)
const stop = useCallback(() => useSnapChatStore.getState().stop(), [])
return { send, stop }
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/features/snap/hooks/useSnapChat.test.tsx`
Expected: PASS (3 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git add src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git commit -m "feat(snap): patch session title on title event, handle 409 and dup send"
```
---
## Task T8: 목업 제거
**Files:**
- Delete: `src/features/snap/mock/sessions.ts`, `src/features/snap/mock/conversations.ts`, `src/features/snap/mock/stream.ts`, `src/features/snap/mock/mock.test.ts`
**Interfaces:**
- Consumes: 없음 (T3 에서 `snap.api` 가, T6 에서 `snap.stream` 이 이미 mock import 제거)
- Produces: 없음
- [ ] **Step 1: 잔여 import 확인 (없어야 함)**
Run: `git grep -n "mock/sessions\|mock/conversations\|mock/stream\|MOCK_SESSIONS\|MOCK_CONVERSATIONS\|mockStream" src/`
Expected: 매치 없음(삭제할 mock 파일 자신 제외). 매치 나오면 그 파일부터 정리.
- [ ] **Step 2: 삭제**
```bash
git rm src/features/snap/mock/sessions.ts src/features/snap/mock/conversations.ts src/features/snap/mock/stream.ts src/features/snap/mock/mock.test.ts
```
- [ ] **Step 3: 타입/빌드 확인**
Run: `npx tsc --noEmit`
Expected: 에러 없음 (dangling import 없음)
- [ ] **Step 4: 커밋**
```bash
git commit -m "chore(snap): remove mock session/conversation/stream data"
```
---
## Wave 경계 게이트
각 Wave 끝에서:
- [ ] **W1 후**: `npx vitest run src/lib/auth src/lib/streaming/streamLLM.test.ts src/features/snap/api/snap.api.test.tsx` → 전부 PASS
- [ ] **W2 후**: `npx vitest run src/lib/api/client.test.ts src/lib/streaming/sse.test.ts src/features/snap/api/snap.stream.test.ts` → 전부 PASS
- [ ] **W3 후**: `npm run build` → 성공 + `npx vitest run src/features/snap` → PASS
CLAUDE.md 5·8번: 각 Wave 완료 후 `docs/working/snap-backend-connect-wave-N.md` 떨어뜨리고, 디버깅 사건 있었으면 `docs/troubleshootings/` 기록.
---
## 검증 (수동 · 실제 백엔드)
1. base-backend 로컬 8001 기동 + LLM(NVIDIA NIM) 설정 확인.
2. `npm run dev` → http://localhost:15173 → 로그인.
3. `/snap` → 세션 목록이 **실제 DB 세션**으로 뜸 (mock 아님).
4. `/snap/new` → 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후 사이드바 제목이 LLM 이 지은 제목으로 자동 변경 (title 이벤트).
5. 기존 세션 재진입 → 과거 메시지 렌더 정상.
6. 생성 중 같은 세션에 재전송 → 중복 가드 또는 409 toast.
7. Network 탭: `POST /api/v1/chat/stream``text/event-stream` 으로 열리고 쿠키 전송됨.
---
## 미룸 (범위 밖 — 후속 spec)
- rename(PATCH)/delete/search 엔드포인트
- `subagent_start`/`subagent_done` 진행 UI, `usage` 토큰 카운터
- Bearer 모드 refresh(.NET 호스트 위임) 실제 배선 — 호스트 생기면