Merge remote-tracking branch 'origin/v141/integration' into v141/d-settings

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:54:43 +02:00
commit 85ebdad5c9
89 files changed
+9023 -661

No files matched your search

@@ -102,6 +102,11 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
capabilities: ["coach"],
why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.",
},
"src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts": {
kind: "mixed",
capabilities: ["coach"],
why: "The trail's reasoning text is model-written and served only while the Coach's text may be shown; otherwise 200 with trail null and the ai state.",
},
"src/app/api/insights/generate/route.ts": {
kind: "action",
capabilities: ["briefing"],
@@ -0,0 +1,102 @@
/**
* v1.41 — the trail route: owner-narrowed, 404 for anything else, and the
* model-written text only while the Coach's text may be shown.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, readMessageTrail, aiCapabilityToServe } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
readMessageTrail: vi.fn(),
aiCapabilityToServe: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
return {
...actual,
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
handler,
requireAuth,
};
});
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageTrail }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
import { GET } from "../route";
const USER_ID = "user-1";
const TRAIL = {
entries: [
{
id: "a1",
title: "Weighing the two weeks",
text: "Looked at sleep first.",
},
],
};
function call(id = "c1", messageId = "m1") {
return GET(
new NextRequest(
`http://localhost/api/insights/chat/${id}/messages/${messageId}/trail`,
),
{ params: Promise.resolve({ id, messageId }) },
) as Promise<Response>;
}
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
aiCapabilityToServe.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => {
it("reads under the caller's own id and the path ids only", async () => {
readMessageTrail.mockResolvedValue({ trail: TRAIL });
const response = await call("c1", "m1");
expect(response.status).toBe(200);
expect(readMessageTrail).toHaveBeenCalledWith(USER_ID, "c1", "m1");
// Requires a session or a full-access token, like every Coach read.
expect(requireAuth).toHaveBeenCalledWith();
const body = await response.json();
expect(body.data.trail).toEqual(TRAIL);
expect(body.data.ai.available).toBe(true);
});
it("answers 404 for a message the caller does not own", async () => {
readMessageTrail.mockResolvedValue(null);
const response = await call();
expect(response.status).toBe(404);
expect(aiCapabilityToServe).not.toHaveBeenCalled();
});
it("serves no model text while the Coach's text may not be shown", async () => {
readMessageTrail.mockResolvedValue({ trail: TRAIL });
aiCapabilityToServe.mockResolvedValue({
available: false,
reason: "disabled_by_user",
onDeviceAllowed: false,
});
const response = await call();
expect(response.status).toBe(200);
const body = await response.json();
expect(body.data.trail).toBeNull();
expect(body.data.ai.available).toBe(false);
expect(aiCapabilityToServe).toHaveBeenCalledWith(USER_ID, "coach");
});
it("answers a message without a trail with null", async () => {
readMessageTrail.mockResolvedValue({ trail: null });
const body = await (await call()).json();
expect(body.data.trail).toBeNull();
});
});
@@ -0,0 +1,55 @@
/**
* GET /api/insights/chat/[id]/messages/[messageId]/trail — the model-written
* text of one assistant message's trail (v1.41): the screened reasoning
* titles and summaries and checkpoint sentences by trail entry, decrypted for
* their owner. The structure of the trail (phases, statuses, counts) is
* plaintext on the message's `metricSource.activity`; only the text is here,
* read lazily so a long thread does not carry every trail.
*
* Narrowed exactly like `…/results`: the message must sit in a conversation
* the caller owns, and a foreign or unknown id is 404, never 403. A session
* or a full-access token reads it; a narrow token is refused by
* `requireAuth()` like on every Coach read.
*
* A mixed read: the text is model-written, so it is served only while the
* Coach's text may be shown for the record (`aiCapabilityToServe`). When it
* may not, the answer is 200 with `trail: null` and the `ai` state beside it,
* and the client keeps the trail's catalog labels.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { readMessageTrail } from "@/lib/ai/coach/persistence";
interface RouteCtx {
params: Promise<{ id: string; messageId: string }>;
}
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
const userId = auth.user.id;
const { id, messageId } = await ctx.params;
if (!id || !messageId) return apiError("coach.message.notFound", 404);
const read = await readMessageTrail(userId, id, messageId);
if (!read) return apiError("coach.message.notFound", 404);
const ai = await aiCapabilityToServe(userId, "coach");
const trail = ai.available ? read.trail : null;
annotate({
action: { name: "insights.coach.trail.read" },
meta: {
conversationId: id,
entries: trail?.entries.length ?? 0,
served: ai.available,
},
});
return apiSuccess({ trail, ai });
});
export const dynamic = "force-dynamic";
File diff suppressed because it is too large. Load diff
@@ -458,7 +458,11 @@ export const modules = {
coachConversation: {
findFirst: async () => ({ id: CONVERSATION_ID }),
},
coachMessage: { findMany: async () => latestRows() },
coachMessage: {
findMany: async () => latestRows(),
// v1.41 — the clarification brake's count of today's questions.
count: async () => 0,
},
measurement: {
groupBy: async (args: { where: { type: { in: string[] } } }) =>
measurementBounds(args),
@@ -473,7 +477,10 @@ export const modules = {
labResult: { aggregate: async () => EMPTY_AGGREGATE },
},
}),
rateLimit: () => ({ checkRateLimit: m.checkRateLimit }),
rateLimit: () => ({
checkRateLimit: m.checkRateLimit,
refundRateLimit: async () => {},
}),
serverLocale: () => ({
resolveServerLocale: async (args: { override?: string }) =>
args.override ?? "en",
@@ -373,7 +373,26 @@ export async function runCoachToolLoop(args: Record<string, unknown>) {
timeoutMs: args.timeoutMs,
signal: args.signal instanceof AbortSignal,
});
return m.runCoachToolLoop(args);
const out = await m.runCoachToolLoop(args);
// The real loop settles each round on the turn's ledger as it returns;
// the scripted loop settles its whole cost as one round.
const spend = args.spend as
| {
settleRound(usage: {
tokens: number;
cachedTokens: number;
servedBy: string;
final: boolean;
}): Promise<void>;
}
| undefined;
await spend?.settleRound({
tokens: out.totalTokens,
cachedTokens: out.cachedTokens,
servedBy: out.workingProviderType,
final: false,
});
return out;
}
export async function runStreamingRawCompletionWithFallback(
@@ -65,7 +65,10 @@ vi.mock("@/lib/db", () => ({
coachMessage: { findMany: h.m.coachMessageFindMany },
},
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: h.m.checkRateLimit,
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: h.m.resolveServerLocale,
}));
@@ -0,0 +1,345 @@
/**
* v1.41 — the live trail and the dialog of a turn through the real route,
* pipeline and loop, with a scripted provider:
*
* - frame order: the trail and the steps first (thinking before the
* provider is called, each fetch, the digest), interim tables as soon as
* they are read, the answer entry before the first token, then the reply
* frames as before;
* - the stored trail carries the model text, the provenance only the
* structure;
* - a question asked through the tool ends the turn with its choices;
* - a person may run two turns at once, not three;
* - a tapped decision is answered without a model, and a stale one goes to
* the model as an ordinary message.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = await vi.hoisted(
() => import("@/app/api/insights/chat/__tests__/dialog-harness"),
);
const contract = vi.hoisted(() => ({
decideFactProposal: vi.fn(),
decidePlanProposal: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => h.modules.apiHandler());
vi.mock("@/lib/api-response", () => h.modules.apiResponse());
vi.mock("@/lib/modules/gate", () => h.modules.gate());
vi.mock("@/lib/ai/capabilities/gate", () => h.modules.capabilities());
vi.mock("@/lib/logging/context", () => h.modules.logging());
vi.mock("@/lib/auth/audit", () => h.modules.audit());
vi.mock("@/lib/db", () => h.modules.db());
vi.mock("@/lib/rate-limit", () => h.modules.rateLimit());
vi.mock("@/lib/i18n/server-locale", () => h.modules.serverLocale());
vi.mock("@/lib/ai/provider-runner", () => h.modules.providerRunner());
vi.mock("@/lib/ai/provider", () => h.modules.provider());
vi.mock("@/lib/ai/consent-guard", () => h.modules.consent());
vi.mock("@/lib/ai/coach/persistence", () => h.modules.persistence());
vi.mock("@/lib/ai/coach/coach-memory-shared", () => h.modules.memory());
vi.mock("@/lib/ai/coach/facts", () => h.modules.facts());
vi.mock("@/lib/ai/coach/budget", () => h.modules.budget());
vi.mock("@/lib/ai/coach/about-me", () => h.modules.aboutMe());
vi.mock("@/lib/ai/coach/snapshot", () => h.modules.snapshot());
vi.mock("@/lib/medications/scheduled-doses", () => h.modules.scheduledDoses());
vi.mock("@/lib/ai/coach/workout-evidence-builder", () =>
h.modules.workoutEvidence(),
);
vi.mock("@/lib/ai/coach/suggest-gate", () => h.modules.suggestGate());
vi.mock("@/lib/monitoring-settings", () => h.modules.glitchtipSettings());
vi.mock("@/lib/monitoring/glitchtip", () => h.modules.glitchtip());
vi.mock("@/lib/tz/resolver", () => h.modules.timezone());
vi.mock("@/lib/measurements/daily-series-read", () => h.modules.dailySeries());
vi.mock("@/lib/rollups/measurement-read", () => h.modules.sourcePriority());
vi.mock("@/lib/ai/coach/bytes-codec", () => h.modules.bytesCodec());
vi.mock("@/lib/ai/coach/tools/inventory", async (importOriginal) => ({
...(await importOriginal<object>()),
buildCoachDataInventory: h.modules.buildInventory,
}));
vi.mock("@/lib/ai/coach/memory/contract", async (importOriginal) => ({
...(await importOriginal<object>()),
decideFactProposal: contract.decideFactProposal,
decidePlanProposal: contract.decidePlanProposal,
}));
import type { CoachActivity, CoachProvenance } from "@/lib/ai/coach/types";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
import { POST } from "@/app/api/insights/chat/route";
const post = POST as unknown as (req: Request) => Promise<Response>;
const { world, providerCalls, framesOf, m } = h;
const series = (metric: string, count: number): InventoryEntry => ({
tool: "get_metric_series",
metric,
domain: metric,
present: true,
count,
});
beforeEach(() => {
vi.setSystemTime(h.NOW);
h.resetDialog();
contract.decideFactProposal.mockReset();
contract.decidePlanProposal.mockReset();
world.inventory = [
series("bp", 58),
series("pulse", 61),
series("resting_hr", 240),
series("walking_hr", 212),
];
});
function rank(frame: { type: string; interim?: boolean }): number {
if (frame.type === "activity" || frame.type === "step") return 0;
if (frame.type === "result" && frame.interim) return 0;
return [
"live",
"token",
"provenance",
"result",
"suggestion",
"suggestedAction",
"memoryNote",
"planProposal",
"clarification",
"followUps",
"done",
].indexOf(frame.type);
}
describe("the live trail", () => {
it("streams every phase before the first token, in the promised order", async () => {
world.script = [
{
text: "Let me look at your blood pressure first.",
calls: [
{
name: "get_metric_table",
args: { metric: "bp", window: "last30days" },
},
],
},
{ text: "Your blood pressure held steady. result:r1" },
];
const { status, frames } = await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
expect(status).toBe(200);
expect(frames.at(-1)?.type).toBe("done");
const order = frames.map(rank);
expect(order.every((r) => r >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
const activity = framesOf<{ activity: CoachActivity }>(
frames,
"activity",
).map((f) => f.activity);
// The thinking entry opens first; the answer entry is the last live one.
expect(activity[0]).toMatchObject({ phase: "thinking", status: "running" });
expect(activity.at(-1)).toMatchObject({
phase: "answer",
status: "running",
});
const phases = new Set(activity.map((a) => a.phase));
for (const phase of [
"thinking",
"checkpoint",
"fetch",
"digest",
"answer",
]) {
expect(phases).toContain(phase);
}
const fetch = activity.filter((a) => a.phase === "fetch").at(-1)!;
expect(fetch).toMatchObject({
status: "done",
stepRef: "s1",
labelKey: "insights.coach.activity.fetching",
});
expect(fetch.label).toBe("Fetching Blood pressure, last 30 days…");
const checkpoint = activity.filter((a) => a.phase === "checkpoint").at(-1)!;
expect(checkpoint.title).toBe("Let me look at your blood pressure first.");
// The table went out as soon as it was read, before the first token.
const firstToken = frames.findIndex((f) => f.type === "token");
const interim = frames.findIndex(
(f) => f.type === "result" && f.interim === true,
);
expect(interim).toBeGreaterThan(-1);
expect(interim).toBeLessThan(firstToken);
});
it("persists the structure on the provenance and the text in the trail", async () => {
world.script = [
{
text: "Let me look at your blood pressure first.",
calls: [
{
name: "get_metric_table",
args: { metric: "bp", window: "last30days" },
},
],
},
{ text: "Your blood pressure held steady. result:r1" },
];
await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
const assistant = m.appendMessage.mock.calls
.map(
(c) =>
c[0] as {
role: string;
metricSource?: CoachProvenance;
trail?: unknown;
},
)
.find((args) => args.role === "assistant")!;
const provenance = assistant.metricSource!;
expect(provenance.activity?.length).toBeGreaterThan(3);
// The answer entry is stored closed.
expect(provenance.activity?.at(-1)).toMatchObject({
phase: "answer",
status: "done",
});
expect(JSON.stringify(provenance)).not.toContain(
"look at your blood pressure",
);
expect(assistant.trail).toMatchObject({
entries: [
expect.objectContaining({
title: "Let me look at your blood pressure first.",
}),
],
});
});
});
describe("a question asked through the tool", () => {
it("ends the turn with the question and its choices, no chips", async () => {
world.script = [
{
calls: [
{
name: "ask_clarification",
args: {
kind: "metric",
question:
"Do you mean resting or walking heart rate? Otherwise I'll look at resting.",
choices: ["resting_hr", "walking_hr"],
assumption: "resting_hr",
},
},
],
},
];
const { frames } = await h.postTurn(post, {
message: "Is my heart rate good?",
locale: "en",
});
expect(providerCalls).toHaveLength(1);
const prose = framesOf<{ token: string }>(frames, "token")
.map((f) => f.token)
.join("");
expect(prose).toBe(
"Do you mean resting or walking heart rate? Otherwise I'll look at resting.",
);
const [clarification] = framesOf<{
clarification: { kind: string; assumption?: string; choices: unknown[] };
}>(frames, "clarification");
expect(clarification.clarification).toMatchObject({
kind: "metric",
assumption: "c1",
});
expect(framesOf(frames, "followUps")).toHaveLength(0);
const asking = framesOf<{ activity: CoachActivity }>(
frames,
"activity",
).find((f) => f.activity.phase === "asking");
expect(asking?.activity.label).toBe("Asking you…");
});
});
describe("concurrent turns", () => {
it("refuses a third turn at once and gives its slot back", async () => {
m.checkRateLimit.mockImplementation(async (key: string) =>
key.startsWith("coach-turn-active:")
? { allowed: false, resetAt: 0 }
: { allowed: true, resetAt: 0 },
);
const { status } = await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
expect(status).toBe(429);
expect(providerCalls).toHaveLength(0);
expect(m.checkRateLimit).toHaveBeenCalledWith(
"coach-turn-active:u1",
2,
180_000,
);
});
});
describe("a tapped decision", () => {
beforeEach(() => {
world.priorTurns = [
{ role: "user", content: "I started a weekly injection in May." },
{
role: "assistant",
content: "Noted. Shall I remember that you take a weekly injection?",
},
];
world.storedProvenance = {
memoryNote: { proposal: true, proposalId: "p1", category: "medication" },
};
});
it("saves through the contract and answers with the catalog line, no model", async () => {
contract.decideFactProposal.mockResolvedValue({
kind: "saved",
factId: "f1",
});
const { frames } = await h.postTurn(post, {
message: "Yes, remember it",
locale: "en",
conversationId: h.CONVERSATION_ID,
memoryDecision: {
messageId: h.LAST_ASSISTANT_ID,
proposalId: "p1",
accept: true,
},
});
expect(providerCalls).toHaveLength(0);
expect(contract.decideFactProposal).toHaveBeenCalledWith({
userId: "u1",
conversationId: h.CONVERSATION_ID,
messageId: h.LAST_ASSISTANT_ID,
proposalId: "p1",
accept: true,
});
const prose = framesOf<{ token: string }>(frames, "token")
.map((f) => f.token)
.join("");
expect(prose).toBe("Got it, I'll remember.");
expect(m.appendMessage).toHaveBeenCalledWith(
expect.objectContaining({ role: "assistant", providerType: "decision" }),
);
});
it("goes to the model with the tap's label when the proposal is not on the latest reply", async () => {
world.script = [{ text: "Sure." }];
const { frames } = await h.postTurn(post, {
message: "Yes, remember it",
locale: "en",
conversationId: h.CONVERSATION_ID,
memoryDecision: { messageId: "m-older", proposalId: "p1", accept: true },
});
expect(contract.decideFactProposal).not.toHaveBeenCalled();
expect(providerCalls).toHaveLength(1);
expect(frames.at(-1)?.type).toBe("done");
});
});
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
@@ -52,7 +52,10 @@ vi.mock("@/lib/api-response", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
@@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
@@ -69,7 +69,10 @@ vi.mock("@/lib/db", () => ({
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit,
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
@@ -62,7 +62,10 @@ vi.mock("@/lib/db", () => ({
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit,
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
@@ -53,7 +53,10 @@ vi.mock("@/lib/db", () => ({
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit,
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
@@ -38,7 +38,10 @@ vi.mock("@/lib/api-response", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
@@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
@@ -70,7 +70,10 @@ vi.mock("@/lib/db", () => ({
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit,
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
@@ -316,21 +319,25 @@ describe("coach chat — tool-mode routing (F1)", () => {
expect(args.messages[0].content).toContain("2400");
});
it("reserves the multi-round budget in tool mode", async () => {
it("reserves round one and the final answer up front in tool mode", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
await post(chatReq({ message: "How is my BP?" }));
// maxTokens (1500) × MAX_ROUNDS (3). The 4th arg is the provider-aware
// daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain.
// v1.41 — the first round (its input estimate plus a round's answer and
// the default medium thinking budget) and the final answer's reserve, in
// one reservation. The 4th arg is the provider-aware daily cap —
// the user-plan ceiling for this BYOK chain.
expect(reserveBudget).toHaveBeenCalledWith(
"u1",
4500,
expect.any(Number),
"2026-06-21",
2_000_000,
"user",
"coach",
);
const reserved = (reserveBudget.mock.calls[0] as unknown[])[1] as number;
expect(reserved).toBeGreaterThanOrEqual(600 + 4_096 + 1_200);
});
it("surfaces a graceful provider-error stream (NOT an HTTP 500) when the tool loop throws a tagged provider error", async () => {
@@ -132,6 +132,7 @@ vi.mock("@/lib/workouts/sport-context", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
+53 -1
View File
@@ -29,7 +29,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -37,6 +37,10 @@ import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import { listConversations } from "@/lib/ai/coach/persistence";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { runCoachTurn } from "@/lib/ai/coach/turn/pipeline";
import {
DEFAULT_REASONING_LEVEL,
type ReasoningLevel,
} from "@/lib/ai/reasoning/levels";
import { streamProviderError, streamRefusal } from "@/lib/ai/coach/turn/sse";
/**
@@ -63,6 +67,19 @@ async function coachCapabilityRefusal(): Promise<Response | null> {
}
}
/**
* v1.41 — at most this many Coach turns of one person at once. A budgeted
* turn can run for minutes; three tabs firing at the same time would run
* three of them against one daily budget and one provider account.
*/
const COACH_CONCURRENT_TURNS = 2;
/**
* How long a turn's slot is held at most: a turn's wall time (150 s) plus
* the streaming of its reply. A slot whose release never arrived (a crashed
* process) frees itself when the window ends.
*/
const COACH_TURN_SLOT_MS = 180_000;
async function handleChatRequest(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// The `coach` capability, right after auth and before the rate limit and
@@ -111,6 +128,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
workoutId,
followUp,
clarification,
memoryDecision,
planDecision,
} = parsed.data;
// Per-user request-rate ceiling layered in front of the daily budget
@@ -162,6 +181,35 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
});
}
// v1.41 — the person's effective reasoning level: their choice after the
// operator's switch and highest level. Integration points this one call
// site at the shared resolver (`src/lib/ai/reasoning/resolve.ts`); until
// then every turn thinks at the default level.
const reasoningLevel: ReasoningLevel = DEFAULT_REASONING_LEVEL;
// v1.41 — the person's turn slot. The counter is the rate-limit row's own
// (an atomic upsert), given back when the turn is over; a refused slot is
// given back at once, since the refused attempt never ran.
const slotKey = `coach-turn-active:${userId}`;
const slot = await checkRateLimit(
slotKey,
COACH_CONCURRENT_TURNS,
COACH_TURN_SLOT_MS,
);
const releaseSlot = () => {
void refundRateLimit(slotKey).catch(() => {
// The window frees the slot on its own.
});
};
if (!slot.allowed) {
releaseSlot();
annotate({
action: { name: "insights.coach.concurrent-limited" },
meta: { limit: COACH_CONCURRENT_TURNS },
});
return apiError("Too many Coach requests, please wait a moment", 429);
}
// Conversation, context, chain, egress re-check, budget, and the streamed
// reply: `src/lib/ai/coach/turn/`.
return runCoachTurn({
@@ -175,6 +223,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
workoutId,
followUp,
clarification,
memoryDecision,
planDecision,
reasoningLevel,
releaseSlot,
recheckCapability: coachCapabilityRefusal,
});
}