# 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 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 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(path)`, `apiPost(path, body?)`, `apiList(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 {children} } 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 => { const { items } = await apiList("/chat/sessions") return items }, }) } export function useSessionMessages(id: string) { return useQuery({ queryKey: ["snap", "session", id], enabled: !!id, queryFn: (): Promise => apiGet(`/chat/sessions/${id}/messages`), }) } export function useCreateSession() { return useMutation({ mutationFn: (): Promise => apiPost("/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 ` 헤더 부착 - [ ] **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 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 { const token = getAccessToken() const headers: Record = { "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 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 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 { 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 {children} } 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(["snap", "sessions"], (prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)), ) queryClient.setQueryData(["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 호스트 위임) 실제 배선 — 호스트 생기면