feat(coach): surface per-turn token usage on the chat stream

Carry an additive usage envelope on the SSE done frame
({ totalTokens, model }) and persist tokensUsed + model onto
coach_messages (migration 0182, nullable, no backfill) so the
per-message token footer survives a conversation reload. Add an
optional reasoning frame the client can render inside the thinking
disclosure when a reasoning-capable provider emits a summary; providers
without reasoning emit none. Extend CoachMessageDTO and regenerate the
OpenAPI contract. Server-authoritative: clients display the count, never
recompute it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:58:58 +02:00
1 parent a5b3715a46
commit f46e9d3bb8
9 files changed
+242 -29

No files matched your search

@@ -60,6 +60,28 @@ import {
type InterleavedThreadItem,
} from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
import type { CoachStreamingMessage } from "../use-coach";
// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` /
// `reasoning` (per-message token footer + thinking disclosure). The
// fixtures below predate those fields; this builder fills the defaults so
// each test only spells out the fields it cares about.
function streaming(
partial: Partial<CoachStreamingMessage>,
): CoachStreamingMessage {
return {
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
startedAt: null,
reasoning: "",
...partial,
};
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -82,6 +104,8 @@ const baseConversation: CoachConversationDetailDTO = {
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: "m2",
@@ -96,6 +120,8 @@ const baseConversation: CoachConversationDetailDTO = {
},
providerType: "admin-openai",
promptVersion: "4.20.0",
tokensUsed: 312,
model: "gpt-4o",
},
],
};
@@ -143,14 +169,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
@@ -168,14 +194,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "Drafting…",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Drafting…");
@@ -186,14 +212,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Thinking");
@@ -203,14 +229,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "errorProvider",
}}
})}
/>,
);
// v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights
@@ -237,6 +263,8 @@ describe("<MessageThread>", () => {
metricSource: null,
providerType: "admin-openai",
promptVersion: "4.20.0",
tokensUsed: null,
model: null,
},
],
};
@@ -244,14 +272,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={conv}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
}}
})}
/>,
);
// 1 persisted user + 2 persisted assistant + 0 streaming → 2.
@@ -271,14 +299,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
const assistantBubbles = (
@@ -470,14 +498,14 @@ describe("<MessageThread>", () => {
content: "Wie ist mein Blutdruck letzte Woche?",
conversationId: null,
}}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Wie ist mein Blutdruck letzte Woche?");
@@ -577,14 +605,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
}}
})}
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
@@ -598,14 +626,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
}}
})}
/>,
);
expect(html).toContain(
@@ -627,14 +655,14 @@ describe("<MessageThread>", () => {
content: "Why was BP higher on Monday?",
conversationId: "conv-1",
}}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
}}
})}
/>,
);
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
@@ -11,6 +11,7 @@ import type {
CoachScope,
CoachStreamEvent,
CoachSuggestion,
CoachUsage,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -191,6 +192,26 @@ export interface CoachStreamingMessage {
messageId: string | null;
/** Server-side error code from an `error` frame, if any. */
errorCode: string | null;
/**
* v1.18.9 — per-turn token usage from the additive `done.usage` frame.
* Null until `done` lands (or when the provider returned no count). The
* thread's token footer reads this for the just-finished streaming
* bubble; persisted bubbles read `CoachMessageDTO.tokensUsed` instead.
*/
usage: CoachUsage | null;
/**
* v1.18.9 — epoch-ms timestamp the send fired, so the thinking
* disclosure can show a live "thinking for N s" counter that freezes
* to a past-tense summary once the first token lands. Null before a
* turn starts.
*/
startedAt: number | null;
/**
* v1.18.9 — optional reasoning-summary text from the additive
* `reasoning` frame, rendered inside the thinking disclosure when a
* reasoning-capable provider emits it. Empty string when none.
*/
reasoning: string;
}
/**
@@ -231,6 +252,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
startedAt: null,
reasoning: "",
};
export interface SendCoachMessageParams {
@@ -324,6 +348,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: true,
messageId: null,
errorCode: null,
usage: null,
// v1.18.9 — anchor the thinking-disclosure elapsed timer to the
// moment the send fired.
startedAt: Date.now(),
reasoning: "",
});
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
@@ -337,6 +366,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: "coach.network",
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -370,6 +402,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: "coach.network",
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -382,6 +417,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -419,6 +457,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: structured ?? `coach.http.${response.status}`,
usage: null,
startedAt: null,
reasoning: "",
});
// v1.16.4 — KEEP the optimistic user bubble: a rejected turn
// (budget gate, 4xx) has no persisted twin coming, and dropping
@@ -437,6 +478,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedSuggestion: CoachSuggestion | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
let collectedReasoning = "";
try {
while (true) {
@@ -468,9 +511,21 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
suggestion: evt.suggestion,
}));
break;
case "reasoning":
// v1.18.9 — additive reasoning-summary frame. Append so
// multiple chunks accumulate; the disclosure renders it
// when present, else falls back to the elapsed-time label.
collectedReasoning += evt.text;
setStreaming((prev) => ({
...prev,
reasoning: prev.reasoning + evt.text,
}));
break;
case "done":
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
// v1.18.9 — additive per-turn usage envelope.
collectedUsage = evt.usage ?? null;
break;
case "error":
lastError = evt.code;
@@ -484,8 +539,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
if (evt.type === "done") {
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
collectedUsage = evt.usage ?? null;
} else if (evt.type === "error") {
lastError = evt.code;
} else if (evt.type === "reasoning") {
collectedReasoning += evt.text;
}
}
} catch (err) {
@@ -494,14 +552,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}
}
setStreaming({
setStreaming((prev) => ({
content: collectedContent,
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
inProgress: false,
messageId,
errorCode: lastError,
});
usage: collectedUsage,
// v1.18.9 — keep the send-anchored timestamp so the disclosure can
// freeze the elapsed time to a past-tense "thought for N s" once
// the turn settles.
startedAt: prev.startedAt,
reasoning: collectedReasoning,
}));
if (resolvedConversationId) {
// Invalidate the freshly-persisted conversation + the rail so
@@ -550,4 +614,5 @@ export type {
CoachConversationDetailDTO,
CoachProvenance,
CoachSuggestion,
CoachUsage,
};