mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge remote-tracking branch 'origin/v141/integration' into v141/d-settings
This commit is contained in:
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";
|
||||
+1489
-108
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"),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user