feat(coach): TanStack Query hooks + SSE streaming client

Adds the data layer for the AI Coach drawer (B2b):

- `useCoachConversations()` reads `GET /api/insights/chat` for the
  history rail (default page; cursor pre-wired for v1.4.21 infinite
  scroll).
- `useCoachConversation(id)` reads the single decrypted thread.
- `useDeleteCoachConversation()` removes the thread with optimistic
  list cache update + rollback on failure.
- `useSendCoachMessage()` POSTs to `/api/insights/chat`, walks the
  body via `fetch().body.getReader()`, and decodes SSE frames
  (`data: <json>\n\n`) with `TextDecoder`. Token / provenance / done /
  error events update a `streaming` state object that the message
  thread will render as the live assistant bubble. On `done` we
  invalidate the conversation cache + the rail so the next mount sees
  the canonical persisted shape.
- `parseSseChunk()` is exported as a pure helper so the parser is
  unit-testable without a live network stream — 11 cases cover
  single-frame, multi-frame, byte-by-byte interleaving, malformed
  payload tolerance, and the full token + provenance + done round-trip.

No third-party SSE library introduced — `fetch` + `getReader()` is
enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 16:20:04 +02:00
1 parent e7287d34e7
commit c42c0a50cb
2 files changed
+601

No files matched your search

@@ -0,0 +1,198 @@
import { describe, it, expect } from "vitest";
import { parseSseChunk } from "../use-coach";
import type { CoachStreamEvent } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — SSE parser unit tests.
*
* The streaming hook is a thin wrapper around `parseSseChunk` plus a
* `fetch` reader. We test the parser exhaustively here and rely on
* vitest's node environment to validate that browser primitives like
* `ReadableStream` and `TextDecoder` are wired correctly.
*/
function frame(event: CoachStreamEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
describe("parseSseChunk", () => {
it("parses a single token frame in one chunk", () => {
const { events, rest } = parseSseChunk(
"",
frame({ type: "token", token: "Hello" }),
);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: "token", token: "Hello" });
expect(rest).toBe("");
});
it("accumulates tokens across two frames in one chunk", () => {
const chunk =
frame({ type: "token", token: "Hello " }) +
frame({ type: "token", token: "world" });
const { events, rest } = parseSseChunk("", chunk);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ type: "token", token: "Hello " });
expect(events[1]).toEqual({ type: "token", token: "world" });
expect(rest).toBe("");
});
it("carries an incomplete frame across calls in `rest`", () => {
const full = frame({ type: "token", token: "abcdef" });
const half = full.slice(0, 12); // partial — no \n\n
const remainder = full.slice(12);
const first = parseSseChunk("", half);
expect(first.events).toHaveLength(0);
expect(first.rest).toBe(half);
const second = parseSseChunk(first.rest, remainder);
expect(second.events).toHaveLength(1);
expect(second.events[0]).toEqual({ type: "token", token: "abcdef" });
expect(second.rest).toBe("");
});
it("parses provenance frames with the metricSource shape", () => {
const provenance = frame({
type: "provenance",
metricSource: {
windows: ["last30days"],
metrics: ["bp", "pulse"],
counts: { bp: 12 },
},
});
const { events } = parseSseChunk("", provenance);
expect(events).toHaveLength(1);
const evt = events[0];
expect(evt.type).toBe("provenance");
if (evt.type === "provenance") {
expect(evt.metricSource.windows).toEqual(["last30days"]);
expect(evt.metricSource.metrics).toEqual(["bp", "pulse"]);
expect(evt.metricSource.counts).toEqual({ bp: 12 });
}
});
it("parses a done frame with conversationId + messageId", () => {
const { events } = parseSseChunk(
"",
frame({
type: "done",
conversationId: "conv-abc",
messageId: "msg-xyz",
}),
);
expect(events).toEqual([
{ type: "done", conversationId: "conv-abc", messageId: "msg-xyz" },
]);
});
it("parses an error frame", () => {
const { events } = parseSseChunk(
"",
frame({
type: "error",
code: "coach.provider.unavailable",
message: "coach.provider.unavailable",
}),
);
expect(events).toEqual([
{
type: "error",
code: "coach.provider.unavailable",
message: "coach.provider.unavailable",
},
]);
});
it("ignores malformed frames silently", () => {
const chunk =
"data: not-json\n\n" + frame({ type: "token", token: "ok" });
const { events } = parseSseChunk("", chunk);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: "token", token: "ok" });
});
it("ignores frames without a data: line", () => {
const chunk = "event: heartbeat\n\n" + frame({ type: "token", token: "x" });
const { events } = parseSseChunk("", chunk);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: "token", token: "x" });
});
it("handles a stream split byte-by-byte (worst-case interleaving)", () => {
const full =
frame({ type: "token", token: "Hi " }) +
frame({ type: "token", token: "there" }) +
frame({ type: "done", conversationId: "c", messageId: "m" });
let buffer = "";
const events: CoachStreamEvent[] = [];
for (const ch of full) {
const out = parseSseChunk(buffer, ch);
events.push(...out.events);
buffer = out.rest;
}
expect(events).toHaveLength(3);
expect(events[0]).toEqual({ type: "token", token: "Hi " });
expect(events[1]).toEqual({ type: "token", token: "there" });
expect(events[2]).toEqual({
type: "done",
conversationId: "c",
messageId: "m",
});
});
it("returns no events for an empty input", () => {
const { events, rest } = parseSseChunk("", "");
expect(events).toEqual([]);
expect(rest).toBe("");
});
it("simulates the full round-trip a streaming hook would observe", () => {
// Reproduce what the hook does internally: feed three chunks (with
// a split frame across chunks 2 and 3), verify the assistant's
// content + provenance are accumulated correctly.
const tokens = [
frame({ type: "token", token: "Looking " }),
frame({ type: "token", token: "at " }),
frame({ type: "token", token: "your " }),
frame({ type: "token", token: "BP, " }),
].join("");
const provenance = frame({
type: "provenance",
metricSource: { windows: ["last7days"], metrics: ["bp"] },
});
const done = frame({
type: "done",
conversationId: "conv-1",
messageId: "msg-1",
});
const all = tokens + provenance + done;
// Split deliberately mid-frame.
const chunks = [
all.slice(0, 30),
all.slice(30, 80),
all.slice(80, 140),
all.slice(140),
];
let buffer = "";
const events: CoachStreamEvent[] = [];
for (const c of chunks) {
const out = parseSseChunk(buffer, c);
events.push(...out.events);
buffer = out.rest;
}
const tokenText = events
.filter((e): e is Extract<CoachStreamEvent, { type: "token" }> =>
e.type === "token",
)
.map((e) => e.token)
.join("");
expect(tokenText).toBe("Looking at your BP, ");
const provEvent = events.find((e) => e.type === "provenance");
expect(provEvent).toBeDefined();
const doneEvent = events.find((e) => e.type === "done");
expect(doneEvent).toBeDefined();
});
});
@@ -0,0 +1,403 @@
"use client";
import { useCallback, useRef, useState } from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type {
CoachConversationDetailDTO,
CoachConversationDTO,
CoachConversationsPage,
CoachProvenance,
CoachStreamEvent,
} from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — TanStack Query + SSE client for the AI Coach
* drawer.
*
* The shape mirrors the backend wire format from `phase-B2a-report.md`:
* `GET /api/insights/chat`, `GET /api/insights/chat/[id]`,
* `DELETE /api/insights/chat/[id]`, and the SSE-streaming
* `POST /api/insights/chat`.
*
* Streaming is implemented with `fetch` + `getReader()` — no third-party
* SSE lib pulled in. The byte stream is decoded with `TextDecoder` and
* scanned for the `\n\n` frame separator; each frame is JSON-parsed and
* dispatched to the event callback. We also export `parseSseChunk` as a
* pure helper so the parser can be unit-tested without spinning up a
* real network stream.
*/
const QUERY_KEYS = {
list: () => ["coachConversations"] as const,
one: (id: string) => ["coachConversation", id] as const,
};
interface ConversationsApiResponse {
data: CoachConversationsPage;
}
interface ConversationApiResponse {
data: CoachConversationDetailDTO;
}
/**
* List of conversations for the rail. Cursor pagination is exposed via
* `loadMore` — the rail can call it when the user scrolls past the
* end. v1.4.20 lands with default-page-only; cursors are pre-wired so
* v1.4.21 / v1.5 can add infinite scroll without reshaping the hook.
*/
export function useCoachConversations(enabled = true) {
const query = useQuery({
queryKey: QUERY_KEYS.list(),
queryFn: async (): Promise<CoachConversationsPage> => {
const res = await fetch("/api/insights/chat", {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as ConversationsApiResponse;
return json.data;
},
enabled,
staleTime: 30 * 1000,
});
return {
conversations: query.data?.conversations ?? [],
nextCursor: query.data?.nextCursor ?? null,
isLoading: query.isLoading,
isError: query.isError,
refetch: query.refetch,
};
}
/**
* Fetch one conversation with every message decrypted server-side.
* `id === null` disables the query so the drawer can mount without a
* selection.
*/
export function useCoachConversation(id: string | null) {
return useQuery({
queryKey: id ? QUERY_KEYS.one(id) : ["coachConversation", "null"],
queryFn: async (): Promise<CoachConversationDetailDTO> => {
if (!id) throw new Error("missing id");
const res = await fetch(`/api/insights/chat/${id}`, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as ConversationApiResponse;
return json.data;
},
enabled: id !== null,
staleTime: 60 * 1000,
});
}
/**
* Delete a conversation with optimistic removal from the rail cache.
* Rolls back if the server responds with a non-2xx.
*/
export function useDeleteCoachConversation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/insights/chat/${id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return id;
},
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() });
const previous = queryClient.getQueryData<CoachConversationsPage>(
QUERY_KEYS.list(),
);
if (previous) {
queryClient.setQueryData<CoachConversationsPage>(QUERY_KEYS.list(), {
...previous,
conversations: previous.conversations.filter((c) => c.id !== id),
});
}
return { previous };
},
onError: (_err, _id, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(QUERY_KEYS.list(), ctx.previous);
}
},
onSettled: (_data, _err, id) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
if (typeof id === "string") {
queryClient.removeQueries({ queryKey: QUERY_KEYS.one(id) });
}
},
});
}
/**
* Pure SSE parser used by the streaming hook AND the unit tests.
*
* Takes a chunk of decoded text + the residual buffer carried from the
* previous chunk and returns:
* - `events`: every fully-formed `CoachStreamEvent` parsed out of the
* concatenated buffer
* - `rest`: the trailing partial frame, kept around for the next call
*
* Frames look like `data: {"type":"token","token":"…"}\n\n`. We split
* on `\n\n`, then strip the `data: ` prefix and JSON-parse the
* remainder. Lines that don't look like SSE data are silently dropped
* (matches the additive-evolution comment on the wire schema).
*/
export function parseSseChunk(
buffer: string,
chunk: string,
): { events: CoachStreamEvent[]; rest: string } {
const combined = buffer + chunk;
const events: CoachStreamEvent[] = [];
let cursor = 0;
while (cursor < combined.length) {
const sep = combined.indexOf("\n\n", cursor);
if (sep === -1) break;
const frame = combined.slice(cursor, sep);
cursor = sep + 2;
const dataLine = frame
.split("\n")
.map((l) => l.trim())
.find((l) => l.startsWith("data:"));
if (!dataLine) continue;
const payload = dataLine.slice("data:".length).trim();
if (!payload) continue;
try {
const parsed = JSON.parse(payload) as CoachStreamEvent;
if (parsed && typeof parsed === "object" && "type" in parsed) {
events.push(parsed);
}
} catch {
// ignore malformed frame; the wire format is forward-compatible.
}
}
return { events, rest: combined.slice(cursor) };
}
/**
* One assistant turn that is currently being streamed. The drawer
* renders this alongside the persisted conversation history so the user
* sees tokens arrive as they generate.
*/
export interface CoachStreamingMessage {
/** Concatenated tokens received so far. */
content: string;
/** Provenance frame — populated once the server emits it. */
metricSource: CoachProvenance | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
messageId: string | null;
/** Server-side error code from an `error` frame, if any. */
errorCode: string | null;
}
const EMPTY_STREAMING: CoachStreamingMessage = {
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: null,
};
export interface SendCoachMessageParams {
conversationId?: string;
message: string;
prefill?: string;
locale?: "en" | "de";
}
export interface UseSendCoachMessageOptions {
/**
* Fired with the resolved `conversationId` after the `done` frame.
* Lets the drawer flip to the just-created conversation when the
* user starts a brand-new thread.
*/
onDone?: (conversationId: string) => void;
}
/**
* The streaming hook.
*
* `send()` POSTs to `/api/insights/chat`, walks the byte stream via
* `fetch().body.getReader()`, and updates `streaming` after every
* token / provenance / done / error frame. The drawer reads `streaming`
* to render the live assistant bubble.
*
* On `done` we invalidate the conversation cache so the next mount
* picks up the persisted shape (including the assistant message id and
* any server-side editorialisation of the body).
*/
export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
const queryClient = useQueryClient();
const [streaming, setStreaming] =
useState<CoachStreamingMessage>(EMPTY_STREAMING);
const abortRef = useRef<AbortController | null>(null);
const reset = useCallback(() => {
setStreaming(EMPTY_STREAMING);
}, []);
const send = useCallback(
async (params: SendCoachMessageParams): Promise<void> => {
// Cancel any prior in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setStreaming({
content: "",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
});
let response: Response;
try {
response = await fetch("/api/insights/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
conversationId: params.conversationId,
message: params.message,
prefill: params.prefill,
locale: params.locale,
}),
signal: controller.signal,
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
setStreaming({
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: "coach.network",
});
return;
}
if (!response.ok || !response.body) {
setStreaming({
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
});
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let resolvedConversationId: string | null = null;
let collectedContent = "";
let collectedProvenance: CoachProvenance | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const { events, rest } = parseSseChunk(buffer, text);
buffer = rest;
for (const evt of events) {
switch (evt.type) {
case "token":
collectedContent += evt.token;
setStreaming((prev) => ({
...prev,
content: prev.content + evt.token,
}));
break;
case "provenance":
collectedProvenance = evt.metricSource;
setStreaming((prev) => ({
...prev,
metricSource: evt.metricSource,
}));
break;
case "done":
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
break;
case "error":
lastError = evt.code;
break;
}
}
}
// Final flush of any trailing frame in the residual buffer.
const tail = parseSseChunk(buffer, "\n\n");
for (const evt of tail.events) {
if (evt.type === "done") {
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
} else if (evt.type === "error") {
lastError = evt.code;
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
lastError = "coach.stream";
}
}
setStreaming({
content: collectedContent,
metricSource: collectedProvenance,
inProgress: false,
messageId,
errorCode: lastError,
});
if (resolvedConversationId) {
// Invalidate the freshly-persisted conversation + the rail so
// the drawer's next mount picks up the encrypted-on-disk shape
// and the rail orders the thread to the top.
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.one(resolvedConversationId),
});
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
opts.onDone?.(resolvedConversationId);
}
},
[opts, queryClient],
);
const cancel = useCallback(() => {
abortRef.current?.abort();
}, []);
return {
streaming,
isStreaming: streaming.inProgress,
send,
cancel,
reset,
};
}
// Re-exports for adjacent components that don't want to reach into
// `@/lib/ai/coach/types` directly.
export type {
CoachConversationDTO,
CoachConversationDetailDTO,
CoachProvenance,
};