Files
CODE_ASSISTANT/docs/superpowers/plans/2026-07-18-snap-backend-connect.md
T
2026-09-16 17:22:14 +09:00

32 KiB

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.tsmockStream 제거하고 streamLLM("/chat/stream") 로 직결. streamLLMtitle 이벤트 파싱 추가, 인증은 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 devhttp://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

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
// .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 + 커밋
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

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 근처):

  /** `title` 이벤트 — 첫 메시지 후 LLM 이 지은 세션 제목 (선택) */
  onTitle?: (title: string) => void
  /** `token` 이벤트 — 토큰 조각 누적해서 표시 */
  onToken: (delta: string) => void

3-2. ErrorPayload 인터페이스 아래(라인 87 근처)에 payload 타입 추가:

interface TitlePayload {
  title: string
}

3-3. onEvent 스위치에서 result 분기 다음에 title 분기 추가 (라인 126 else if (e.event === "clarify") 앞):

      } 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 + 커밋
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 전체 교체

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 전체 교체
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 + 커밋
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 가 없으면 추가.

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 그룹):

import { getAccessToken } from "@/lib/auth/tokenProvider"

3-2. apiClient 생성 직후(라인 26 }) 다음), 응답 인터셉터 위에 요청 인터셉터 추가:

// .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 + 커밋
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 에 추가:

import { afterEach } from "vitest"
import { setAccessToken } from "@/lib/auth/tokenProvider"

1-2. describe("streamSSE", ...) 안, 기존 beforeEach 아래에 추가:

  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 아래):

import { getAccessToken } from "@/lib/auth/tokenProvider"

3-2. open() 함수 시작부에서 헤더를 토큰 유무로 구성 (기존 await fetchEventSource(...) 의 인라인 headers 를 교체):

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 + 커밋
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 전체 교체

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=truestreamLLM 미호출

  • Step 3: 구현src/features/snap/api/snap.stream.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 + 커밋
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

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 전체 교체
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 + 커밋
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: 삭제
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: 커밋
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 devhttp://localhost:15173 → 로그인.
  3. /snap → 세션 목록이 실제 DB 세션으로 뜸 (mock 아님).
  4. /snap/new → 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후 사이드바 제목이 LLM 이 지은 제목으로 자동 변경 (title 이벤트).
  5. 기존 세션 재진입 → 과거 메시지 렌더 정상.
  6. 생성 중 같은 세션에 재전송 → 중복 가드 또는 409 toast.
  7. Network 탭: POST /api/v1/chat/streamtext/event-stream 으로 열리고 쿠키 전송됨.

미룸 (범위 밖 — 후속 spec)

  • rename(PATCH)/delete/search 엔드포인트
  • subagent_start/subagent_done 진행 UI, usage 토큰 카운터
  • Bearer 모드 refresh(.NET 호스트 위임) 실제 배선 — 호스트 생기면