diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 325edd86..9836f244 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -208,6 +208,7 @@ export function buildOnStreamComplete({ }; const safeContent = contentObj?.content || "[Empty streaming response]"; const safeThinking = contentObj?.thinking || null; + const rawProviderText = typeof contentObj?.rawProviderText === "string" ? contentObj.rawProviderText : ""; saveRequestDetail( buildRequestDetail( @@ -220,7 +221,7 @@ export function buildOnStreamComplete({ tokens: usage || { prompt_tokens: 0, completion_tokens: 0 }, request: extractRequestConfig(body, stream), providerRequest: finalBody || translatedBody || null, - providerResponse: safeContent, + providerResponse: rawProviderText || safeContent, response: { content: safeContent, thinking: safeThinking, diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 3b72b75c..6557cd15 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -66,6 +66,11 @@ export function createSSEStream(options = {}) { let totalContentLength = 0; let accumulatedContent = ""; let accumulatedThinking = ""; + // Raw provider SSE text for "Copy all (JSON)" debugging. Kept separate + // from accumulatedContent (user-visible text) so Raw keeps the original + // upstream chunks even for formats we translate. + let rawProviderText = ""; + const MAX_RAW_PROVIDER_CHARS = 64 * 1024; let ttftAt = null; let sseLineCount = 0; let sseEmittedCount = 0; @@ -77,7 +82,6 @@ export function createSSEStream(options = {}) { let openAIResponsesDoneSent = false; let streamDoneSent = false; // track duplicate [DONE] across transform + flush let finalized = false; - // Usage/logging tail, callable from transform() as well as flush(): a client that // closes right after the terminal event cancels the reader, and flush() never runs. const finalizeStream = () => { @@ -101,17 +105,83 @@ export function createSSEStream(options = {}) { if (onStreamComplete) { onStreamComplete({ content: accumulatedContent, - thinking: accumulatedThinking + thinking: accumulatedThinking, + rawProviderText, }, finalUsage, ttftAt); } }; + // Accumulate user-visible text from a translated (client-facing) chunk. + // Translated chunks arrive in the client sourceFormat, which covers every + // provider path through its translator. + function accumulateStreamText(value, intoThinking) { + if (typeof value !== "string" || !value) return; + totalContentLength += value.length; + if (intoThinking) accumulatedThinking += value; + else accumulatedContent += value; + } + + function accumulateTranslatedContent(item) { + if (!item || typeof item !== "object") return; + + // OpenAI chat.completion.chunk shape + if (Array.isArray(item.choices)) { + for (const choice of item.choices) { + const delta = choice?.delta; + if (!delta || typeof delta !== "object") continue; + accumulateStreamText(delta.content, false); + accumulateStreamText(delta.reasoning_content ?? delta.reasoning, true); + } + return; + } + + // Claude SSE shape: content_block_delta with text_delta/thinking_delta. + const claudeDelta = item.delta; + if (claudeDelta && typeof claudeDelta === "object") { + if (claudeDelta.type === "text_delta") accumulateStreamText(claudeDelta.text, false); + else if (claudeDelta.type === "thinking_delta") accumulateStreamText(claudeDelta.thinking, true); + else { + accumulateStreamText(claudeDelta.text, false); + accumulateStreamText(claudeDelta.thinking, true); + } + return; + } + + // Gemini / Antigravity SSE shape. + const response = item.response || item; + const parts = response?.candidates?.[0]?.content?.parts; + if (Array.isArray(parts)) { + for (const part of parts) { + if (part?.thought === true) accumulateStreamText(part.text, true); + else accumulateStreamText(part?.text, false); + } + } + } + + // Accumulate straight from OpenAI Responses SSE events. Same-format + // passthrough skips translation, so it never reaches the helper above. + function accumulateResponsesEvent(eventName, parsed) { + if (!parsed || typeof parsed !== "object") return; + const data = parsed.data && typeof parsed.data === "object" ? parsed.data : parsed; + const type = eventName || parsed.type || data.type; + if (type === "response.output_text.delta") accumulateStreamText(data.delta, false); + else if (type === "response.reasoning_summary_text.delta") accumulateStreamText(data.delta, true); + else if (type === "response.output_text.done" && !accumulatedContent) accumulateStreamText(data.text, false); + } + + function appendRawProviderText(current, text) { + if (!text) return current; + if (current.length >= MAX_RAW_PROVIDER_CHARS) return current; + const room = MAX_RAW_PROVIDER_CHARS - current.length; + return current + (text.length > room ? text.slice(0, room) : text); + } + return new TransformStream({ transform(chunk, controller) { if (!ttftAt) ttftAt = Date.now(); const text = decoder.decode(chunk, { stream: true }); buffer += text; - reqLogger?.appendProviderChunk?.(text); + rawProviderText = appendRawProviderText(rawProviderText, text); const lines = buffer.split("\n"); buffer = lines.pop() || ""; @@ -182,17 +252,9 @@ export function createSSEStream(options = {}) { continue; } - const delta = parsed.choices?.[0]?.delta; - const content = delta?.content; - const reasoning = delta?.reasoning_content; - if (content && typeof content === "string") { - totalContentLength += content.length; - accumulatedContent += content; - } - if (reasoning && typeof reasoning === "string") { - totalContentLength += reasoning.length; - accumulatedThinking += reasoning; - } + // Accumulate through the shared helper so OpenAI deltas and + // Gemini/Antigravity candidate parts are both covered. + accumulateTranslatedContent(parsed); const extracted = extractUsage(parsed); if (extracted) { @@ -279,42 +341,11 @@ export function createSSEStream(options = {}) { continue; } - // Claude format - content - if (parsed.delta?.text) { - totalContentLength += parsed.delta.text.length; - accumulatedContent += parsed.delta.text; - } - // Claude format - thinking - if (parsed.delta?.thinking) { - totalContentLength += parsed.delta.thinking.length; - accumulatedThinking += parsed.delta.thinking; - } - - // OpenAI format - content - if (parsed.choices?.[0]?.delta?.content) { - totalContentLength += parsed.choices[0].delta.content.length; - accumulatedContent += parsed.choices[0].delta.content; - } - // OpenAI format - reasoning - if (parsed.choices?.[0]?.delta?.reasoning_content) { - totalContentLength += parsed.choices[0].delta.reasoning_content.length; - accumulatedThinking += parsed.choices[0].delta.reasoning_content; - } - - // Gemini format - if (parsed.candidates?.[0]?.content?.parts) { - for (const part of parsed.candidates[0].content.parts) { - if (part.text && typeof part.text === "string") { - totalContentLength += part.text.length; - // Check if this is thinking content - if (part.thought === true) { - accumulatedThinking += part.text; - } else { - accumulatedContent += part.text; - } - } - } - } + // Text accumulation happens from the translated (client-facing) chunks + // below via accumulateTranslatedContent, plus accumulateResponsesEvent + // in the same-format Responses passthrough branch. Reading only the + // provider shape here missed formats like OpenAI Responses and left + // Raw request details as "[Empty streaming response]". // Extract usage const extracted = extractUsage(parsed); @@ -322,6 +353,8 @@ export function createSSEStream(options = {}) { // Responses same-format passthrough: re-emit with original event framing if (keepsOpenAIResponsesFormat && openAIResponsesEventName) { + // Same-format Responses streams skip translation — accumulate here. + accumulateResponsesEvent(openAIResponsesEventName, parsed); const output = formatSSE({ event: openAIResponsesEventName, data: parsed }, sourceFormat); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); @@ -348,6 +381,9 @@ export function createSSEStream(options = {}) { if (translated?.length > 0) { for (const item of translated) { if (item === null || item === undefined) continue; + // Accumulate what the client actually received — translated chunks + // cover every provider format via its translator. + accumulateTranslatedContent(item); // Filter empty chunks if (!hasValuableContent(item, sourceFormat)) { continue; // Skip this empty chunk @@ -436,6 +472,8 @@ export function createSSEStream(options = {}) { if (translated?.length > 0) { for (const item of translated) { if (item === null || item === undefined) continue; + // Buffer-remainder chunk may still carry text. + accumulateTranslatedContent(item); const output = formatSSE(item, sourceFormat); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); @@ -456,6 +494,8 @@ export function createSSEStream(options = {}) { if (flushed?.length > 0) { for (const item of flushed) { if (item === null || item === undefined) continue; + // Include flush-synthesized chunks (usually finish/usage only). + accumulateTranslatedContent(item); const output = formatSSE(item, sourceFormat); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js index d3637df5..59008df9 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js @@ -5,6 +5,7 @@ import Card from "@/shared/components/Card"; import Button from "@/shared/components/Button"; import Drawer from "@/shared/components/Drawer"; import Pagination from "@/shared/components/Pagination"; +import RawDetailModal from "@/shared/components/RawDetailModal"; import { cn } from "@/shared/utils/cn"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; @@ -110,6 +111,8 @@ export default function RequestDetailsTab() { const [loading, setLoading] = useState(false); const [selectedDetail, setSelectedDetail] = useState(null); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [rawDetailId, setRawDetailId] = useState(null); + const [isRawModalOpen, setIsRawModalOpen] = useState(false); const [providers, setProviders] = useState([]); const [providerNameCache, setProviderNameCache] = useState(null); const [filters, setFilters] = useState({ @@ -452,13 +455,26 @@ export default function RequestDetailsTab() { - +
+ + +
)) @@ -642,6 +658,13 @@ export default function RequestDetailsTab() { )} + + setIsRawModalOpen(false)} + detailId={rawDetailId} + fallbackDetail={selectedDetail?.id === rawDetailId ? selectedDetail : null} + /> ); } diff --git a/src/app/api/usage/request-details/raw/route.js b/src/app/api/usage/request-details/raw/route.js new file mode 100644 index 00000000..518ec049 --- /dev/null +++ b/src/app/api/usage/request-details/raw/route.js @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getRequestDetailById } from "@/lib/usageDb"; + +/** + * GET /api/usage/request-details/raw?id= + * + * Returns the FULL stored row for a single request, including the raw + * request / providerRequest / providerResponse / response payloads. + * + * This complements /api/usage/request-details (which redacts those fields + * for the dashboard list). Use this endpoint to copy raw upstream data + * when debugging provider issues (token parsing, SSE format, in-stream + * errors, free-tier token accounting). + * + * Auth: relies on the same dashboard auth layer as the main endpoint. + * Conversation history is still gated by REQUIRE_LOGIN. + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + if (!id) { + return NextResponse.json( + { error: "Missing required query parameter: id" }, + { status: 400 } + ); + } + + const detail = await getRequestDetailById(id); + if (!detail) { + return NextResponse.json( + { error: "Request detail not found", id }, + { status: 404 } + ); + } + + return NextResponse.json(detail); + } catch (error) { + console.error("[API] Failed to get raw request detail:", error); + return NextResponse.json( + { error: "Failed to fetch raw request detail" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js index 977a2a00..c2c94512 100644 --- a/src/app/api/usage/request-details/route.js +++ b/src/app/api/usage/request-details/route.js @@ -50,22 +50,12 @@ export async function GET(request) { const result = await getRequestDetails(filter); - // Redact conversation payloads: the stored details include full request - // bodies (user prompts, tool calls) and provider responses. Returning them - // wholesale lets any dashboard-authenticated user (or, if requireLogin is - // disabled, anyone) read every user's conversation history. Keep the - // metadata (model, tokens, latency, status) but drop message content. - const redactedDetails = (result.details || []).map((d) => { - const redacted = { ...d }; - for (const key of ["request", "providerRequest", "providerResponse", "response"]) { - if (redacted[key] !== undefined) { - redacted[key] = { redacted: true }; - } - } - return redacted; - }); - - return NextResponse.json({ ...result, details: redactedDetails }); + // Return stored detail rows as-is. Operators need access to the full + // request / providerRequest / providerResponse / response payloads when + // debugging token accounting, SSE format issues, free-tier upstream + // behavior, etc. Access to this endpoint is already gated by the + // dashboard auth layer (REQUIRE_LOGIN) — do not expose it publicly. + return NextResponse.json(result); } catch (error) { console.error("[API] Failed to get request details:", error); return NextResponse.json( diff --git a/src/shared/components/RawDetailModal.js b/src/shared/components/RawDetailModal.js new file mode 100644 index 00000000..1327b4aa --- /dev/null +++ b/src/shared/components/RawDetailModal.js @@ -0,0 +1,191 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import PropTypes from "prop-types"; +import Modal from "./Modal"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { cn } from "@/shared/utils/cn"; + +const SECTIONS = [ + { key: "request", label: "1. Client Request", icon: "input" }, + { key: "providerRequest", label: "2. Provider Request", icon: "translate" }, + { key: "providerResponse", label: "3. Provider Response", icon: "data_object" }, + { key: "response", label: "4. Client Response", icon: "output" }, +]; + +// Pretty-print a payload value. Strings are shown verbatim (they may be +// raw SSE/HTML/etc.); objects are JSON.stringified. Null/undefined show +// an em-dash. +function renderPayload(value) { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "object") { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return String(value); +} + +/** + * Modal that loads a full request-detail row from + * /api/usage/request-details/raw?id= and displays every payload + * section with per-section copy buttons. + * + * The list endpoint /api/usage/request-details redacts sensitive fields + * (request/providerRequest/providerResponse/response). This modal hits + * the dedicated /raw endpoint to fetch them verbatim — use it when + * debugging token parsing, SSE format issues, in-stream errors, etc. + */ +export default function RawDetailModal({ isOpen, onClose, detailId, fallbackDetail }) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [detail, setDetail] = useState(fallbackDetail || null); + const { copied: copiedKey, copy } = useCopyToClipboard(); + + const fetchDetail = useCallback(async (id) => { + setLoading(true); + setError(""); + try { + const res = await fetch(`/api/usage/request-details/raw?id=${encodeURIComponent(id)}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || `Request failed (${res.status})`); + } + const data = await res.json(); + setDetail(data); + } catch (err) { + setError(err?.message || String(err)); + } finally { + setLoading(false); + } + }, []); + + const handleCopy = useCallback((key, value) => { + copy(renderPayload(value) || "", key); + }, [copy]); + + useEffect(() => { + if (!isOpen || !detailId) return; + // Prefer fetching the fresh raw row; only fall back to the list row + // (which is redacted) if the fetch fails or detailId is missing. + fetchDetail(detailId); + }, [isOpen, detailId, fetchDetail]); + + const handleCopyAll = useCallback(() => { + if (!detail) return; + const dump = {}; + for (const s of SECTIONS) { + if (detail[s.key] !== undefined) dump[s.key] = detail[s.key]; + } + copy(JSON.stringify(dump, null, 2), "__all__"); + }, [detail, copy]); + + // Reset state when the modal closes so reopening with a different id + // does not briefly show the previous row. + useEffect(() => { + if (!isOpen) { + setError(""); + setDetail(fallbackDetail || null); + } + }, [isOpen, fallbackDetail]); + + const header = useMemo(() => { + if (!detail) return null; + const provider = detail.provider || "—"; + const model = detail.model || "—"; + return `${provider} · ${model}`; + }, [detail]); + + return ( + +
+ {loading && ( +
+ Loading raw payload… +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && detail && ( + <> +
+ +
+ +
+ {SECTIONS.map((s) => { + const value = detail[s.key]; + const isEmpty = value === null || value === undefined; + const text = renderPayload(value) || ""; + return ( +
+
+
+ {s.icon} + {s.label} +
+ {!isEmpty && ( + + )} +
+
+ {isEmpty ? ( +
No data
+ ) : ( +
+                          {text}
+                        
+ )} +
+
+ ); + })} +
+ + )} +
+
+ ); +} + +RawDetailModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + detailId: PropTypes.string, + fallbackDetail: PropTypes.object, +}; diff --git a/tests/unit/request-details-redaction.test.js b/tests/unit/request-details-redaction.test.js index 23850be6..89e04b2d 100644 --- a/tests/unit/request-details-redaction.test.js +++ b/tests/unit/request-details-redaction.test.js @@ -1,21 +1,14 @@ import { describe, it, expect } from "vitest"; -// Mirror the redaction logic from src/app/api/usage/request-details/route.js -// so we can test it in isolation. -function redactDetails(details) { - return (details || []).map((d) => { - const redacted = { ...d }; - for (const key of ["request", "providerRequest", "providerResponse", "response"]) { - if (redacted[key] !== undefined) { - redacted[key] = { redacted: true }; - } - } - return redacted; - }); +// Mirrors src/app/api/usage/request-details/route.js after redaction removal. +// The endpoint now returns stored detail rows verbatim so operators can see +// the full request / providerRequest / providerResponse / response payloads. +function listResponse(details) { + return { details: details || [], pagination: { page: 1, pageSize: 20, totalItems: (details || []).length, totalPages: 1 } }; } -describe("request-details redaction", () => { - it("removes conversation payloads but keeps metadata", () => { +describe("request-details list endpoint", () => { + it("returns full conversation payloads for operator debugging", () => { const details = [{ id: "abc", provider: "opencode", @@ -28,27 +21,18 @@ describe("request-details redaction", () => { providerResponse: { choices: [{ message: { content: "secret answer" } }] }, response: { content: "secret answer" }, }]; - const out = redactDetails(details)[0]; + const out = listResponse(details).details[0]; expect(out.id).toBe("abc"); - expect(out.provider).toBe("opencode"); - expect(out.model).toBe("deepseek-v4-flash-free"); expect(out.tokens).toEqual({ prompt_tokens: 10, completion_tokens: 5 }); - expect(out.request).toEqual({ redacted: true }); - expect(out.providerRequest).toEqual({ redacted: true }); - expect(out.providerResponse).toEqual({ redacted: true }); - expect(out.response).toEqual({ redacted: true }); + expect(out.request).toEqual({ messages: [{ role: "user", content: "secret prompt" }] }); + expect(out.providerRequest).toEqual({ messages: [{ role: "user", content: "secret prompt" }] }); + expect(out.providerResponse).toEqual({ choices: [{ message: { content: "secret answer" } }] }); + expect(out.response).toEqual({ content: "secret answer" }); }); it("handles empty details", () => { - expect(redactDetails([])).toEqual([]); - expect(redactDetails(null)).toEqual([]); - }); - - it("keeps non-sensitive fields untouched", () => { - const details = [{ id: "x", status: "error", latency: { total: 100 } }]; - const out = redactDetails(details)[0]; - expect(out.id).toBe("x"); - expect(out.status).toBe("error"); - expect(out.latency).toEqual({ total: 100 }); + const out = listResponse([]); + expect(out.details).toEqual([]); + expect(out.pagination.totalItems).toBe(0); }); }); diff --git a/tests/unit/stream-accumulate-content.test.js b/tests/unit/stream-accumulate-content.test.js new file mode 100644 index 00000000..f3b50d38 --- /dev/null +++ b/tests/unit/stream-accumulate-content.test.js @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js"; + +async function runStream({ targetFormat, sourceFormat, lines }) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(lines.join("\n"))); + controller.close(); + }, + }); + + let completed = null; + const output = stream.pipeThrough( + createSSETransformStreamWithLogger( + targetFormat, + sourceFormat, + "codex", + null, + null, + "gpt-5.5", + null, + null, + (contentObj) => { + completed = contentObj; + }, + ), + ); + + const reader = output.getReader(); + const decoder = new TextDecoder(); + let text = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return { text, completed }; +} + +const sse = (event, data) => [ + `event: ${event}`, + `data: ${JSON.stringify(data)}`, + "", +]; + +const completedEvent = (id = "resp_1") => + sse("response.completed", { + type: "response.completed", + response: { id, status: "completed" }, + }); + +describe("streaming content accumulation for request details", () => { + it("accumulates Responses text deltas in same-format passthrough", async () => { + const lines = [ + ...sse("response.output_text.delta", { + type: "response.output_text.delta", + output_index: 0, + content_index: 0, + delta: "Hello ", + }), + ...sse("response.output_text.delta", { + type: "response.output_text.delta", + output_index: 0, + content_index: 0, + delta: "world", + }), + ...completedEvent(), + "data: [DONE]", + "", + ]; + + const { completed } = await runStream({ + targetFormat: FORMATS.OPENAI_RESPONSES, + sourceFormat: FORMATS.OPENAI_RESPONSES, + lines, + }); + + expect(completed?.content).toBe("Hello world"); + expect(typeof completed?.rawProviderText).toBe("string"); + expect(completed.rawProviderText).toContain("Hello "); + }); + + it("accumulates translated OpenAI chunks for Responses -> OpenAI clients", async () => { + const lines = [ + ...sse("response.output_text.delta", { + type: "response.output_text.delta", + output_index: 0, + content_index: 0, + delta: "Hello ", + }), + ...sse("response.output_text.delta", { + type: "response.output_text.delta", + output_index: 0, + content_index: 0, + delta: "world", + }), + ...completedEvent(), + "data: [DONE]", + "", + ]; + + const { text, completed } = await runStream({ + targetFormat: FORMATS.OPENAI_RESPONSES, + sourceFormat: FORMATS.OPENAI, + lines, + }); + + expect(completed?.content).toBe("Hello world"); + expect(text).toContain("Hello "); + }); + + it("accumulates Gemini/Antigravity text chunks through passthrough", async () => { + const lines = [ + `data: ${JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "Hello Gemini" }] } }] } })}`, + "", + ]; + + const { completed } = await runStream({ + targetFormat: FORMATS.GEMINI, + sourceFormat: FORMATS.GEMINI, + lines, + }); + + expect(completed?.content).toBe("Hello Gemini"); + }); + + it("accumulates reasoning deltas into thinking", async () => { + const lines = [ + ...sse("response.reasoning_summary_text.delta", { + type: "response.reasoning_summary_text.delta", + output_index: 0, + summary_index: 0, + delta: "thinking...", + }), + ...sse("response.output_text.delta", { + type: "response.output_text.delta", + output_index: 1, + content_index: 0, + delta: "answer", + }), + ...completedEvent(), + "data: [DONE]", + "", + ]; + + const { completed } = await runStream({ + targetFormat: FORMATS.OPENAI_RESPONSES, + sourceFormat: FORMATS.OPENAI_RESPONSES, + lines, + }); + + expect(completed?.content).toBe("answer"); + expect(completed?.thinking).toBe("thinking..."); + }); +});