diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 09bea413c..14f770197 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -74400,6 +74400,8 @@ components: $ref: "#/components/schemas/ModuleAccessMap" ai: $ref: "#/components/schemas/AiAccountBlock" + coachReasoning: + $ref: "#/components/schemas/CoachReasoningState" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" @@ -74426,6 +74428,7 @@ components: - accountAccess - moduleAccess - ai + - coachReasoning - recordSession - notificationPrefs - onboarding @@ -74440,7 +74443,9 @@ components: carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record, - with the same masking; it replaces reading `GET /api/feature-flags`." + with the same masking; it replaces reading `GET /api/feature-flags`. `coachReasoning` (v1.41) is the Coach's + resolved thinking depth: the caller's own preference against the operator's reasoning controls and the record's + provider." AccountAccess: type: object properties: @@ -74933,6 +74938,67 @@ components: - responseTimeoutMs additionalProperties: false description: The account-level companion to the capability map. + CoachReasoningState: + type: object + properties: + level: + type: string + enum: + - off + - low + - medium + - high + description: "The level a Coach turn runs at right now, every cap applied: the person's choice, lowered to the + operator's highest level, to `medium` when the operator pays for the provider, and `off` when the operator + switched reasoning off or the provider cannot reason." + preference: + type: string + enum: + - off + - low + - medium + - high + description: What the person picked (`reasoning` on `PUT /api/auth/me/coach-prefs`), `medium` when they never chose. + Bind the settings field to this, not to `level`. + maxLevel: + type: string + enum: + - off + - low + - medium + - high + description: The highest level the settings may offer. Options above it are shown disabled as limited by the operator. + `off` when the operator switched reasoning off. + available: + type: boolean + description: False when the operator switched reasoning off or the configured provider cannot reason; the settings field + is then locked. + offIsReal: + type: boolean + description: False when the provider cannot switch reasoning off (it thinks at its lowest level instead); label the + lowest option "Minimal" rather than "Off". + source: + type: string + enum: + - user + - job + - admin_off + - admin_cap + - cost_cap + - unsupported + description: "Which layer decided `level`: the person's own choice (`user`), the operator's switch (`admin_off`) or cap + (`admin_cap`), the operator-funded ceiling (`cost_cap`), or a provider that cannot reason (`unsupported`). + `job` never appears here." + required: + - level + - preference + - maxLevel + - available + - offIsReal + - source + additionalProperties: false + description: The Coach's thinking depth for the signed-in person, resolved on the server from their preference, the + operator's reasoning switch and cap, who pays for the provider, and what the provider can do. RecordSessionState: type: object properties: diff --git a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts index 0a5592c0f..b87a157ee 100644 --- a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts @@ -172,3 +172,50 @@ describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => { expect(prisma.user.updateMany).not.toHaveBeenCalled(); }); }); + +describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => { + it("stores the chosen level and names it on the wide event", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({ + updatedAt: new Date("2026-10-06T11:00:00.000Z"), + } as never); + const { annotate } = await import("@/lib/logging/context"); + + const res = await (PUT as (r: Request) => Promise)( + mkPut({ reasoning: "high" }), + ); + expect(res.status).toBe(200); + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + data: { coachPrefsJson: { reasoning?: string } }; + }; + expect(call.data.coachPrefsJson.reasoning).toBe("high"); + const env = (await res.json()) as { data: { reasoning?: string } }; + expect(env.data.reasoning).toBe("high"); + const meta = vi + .mocked(annotate) + .mock.calls.map((c) => c[0]) + .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; + expect(meta).toMatchObject({ reasoning: "high" }); + }); + + it("reads an absent level as the default on the wide event", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({ + updatedAt: new Date("2026-10-06T11:00:00.000Z"), + } as never); + const { annotate } = await import("@/lib/logging/context"); + + await (PUT as (r: Request) => Promise)(mkPut({ tone: "warm" })); + const meta = vi + .mocked(annotate) + .mock.calls.map((c) => c[0]) + .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; + expect(meta).toMatchObject({ reasoning: "medium" }); + }); + + it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => { + const res = await (PUT as (r: Request) => Promise)( + mkPut({ reasoning: value }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index 4e1bf8593..7624b4d5e 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -31,6 +31,7 @@ import { } from "@/lib/optimistic-lock"; import { coachPrefsSchema, + coachReasoningLevel, parseCoachPrefs, } from "@/lib/validations/coach-prefs"; @@ -130,6 +131,10 @@ export const PUT = apiHandler(async (req: Request) => { // v1.7.0 — `null` when the caller left `dataClusters` absent // (legacy defaults stay in force); otherwise the selected count. clusterCount: parsed.data.dataClusters?.length ?? null, + // v1.41 — the stored thinking depth, absent read as the default. The + // operator's switch and cap apply on top of it at turn time; the + // resolved value is `coachReasoning` on GET /api/auth/me. + reasoning: coachReasoningLevel(parsed.data), }, }); return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt }); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index e150408eb..4967136e2 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -72,6 +72,8 @@ import { } from "@/lib/sharing/module-disclosure"; import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; import { loadAiCapabilities } from "@/lib/ai/capabilities/load"; +import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls"; +import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; export const dynamic = "force-dynamic"; @@ -143,6 +145,7 @@ export const GET = apiHandler(async () => { moduleAvailability, onboardingRow, ai, + coachReasoning, ] = await Promise.all([ // The actor's own row is already in hand; a switched session needs the // record's `gender`, which is the column the cycle gate derives from. @@ -206,6 +209,16 @@ export const GET = apiHandler(async () => { sections, recordKind, }), + // v1.41 — the Coach's thinking depth, resolved: the caller's own + // preference (Coach preferences belong to the person, like every other + // display preference here) against the operator's switch and cap and the + // record's provider. The provider read is the memoised presence probe the + // `ai` block above already ran. + loadCoachReasoningState({ + prefs: parseCoachPrefs(user.coachPrefsJson), + recordId, + authority: aiAuthority, + }), ]); // The masking step, and the one place the record scoping is narrowed rather @@ -397,6 +410,14 @@ export const GET = apiHandler(async () => { // the RECORD and masked to the grant, like `modules`. Clients render from // it and never recompute it; data never depends on it. ai, + // v1.41 — the Coach's thinking depth, resolved on the server so the web + // settings and the native client show the same answer: `level` is what a + // turn runs at now, `preference` what the person picked, `maxLevel` the + // highest option the settings may offer (`off` when the operator switched + // reasoning off), `available` whether reasoning can run at all, + // `offIsReal` whether the lowest option is a real off or "Minimal", and + // `source` which layer decided. Clients never recompute it. + coachReasoning, // v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu, // `active` is the record this session is inside (null when it is in its // own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds diff --git a/src/hooks/__tests__/use-ai-capability.test.tsx b/src/hooks/__tests__/use-ai-capability.test.tsx index 38a57a787..01d343a8d 100644 --- a/src/hooks/__tests__/use-ai-capability.test.tsx +++ b/src/hooks/__tests__/use-ai-capability.test.tsx @@ -12,7 +12,11 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: authUser }), })); -import { useAiCapability, useAiProviderState } from "../use-ai-capability"; +import { + useAiCapability, + useAiProviderState, + useCoachReasoning, +} from "../use-ai-capability"; import type { AiCapabilityKey } from "@/lib/ai/capabilities/types"; function Probe({ capability }: { capability: AiCapabilityKey }) { @@ -107,3 +111,47 @@ describe("useAiCapability", () => { expect(render("briefing", false).state).toEqual(UNKNOWN); }); }); + +function ReasoningProbe() { + return {JSON.stringify(useCoachReasoning())}; +} + +function renderReasoning(withClient = true): unknown { + const html = renderToStaticMarkup( + withClient ? ( + + + + ) : ( + + ), + ); + return JSON.parse( + html.replace(/^|<\/span>$/g, "").replace(/"/g, '"'), + ); +} + +describe("useCoachReasoning", () => { + const STATE = { + level: "low", + preference: "high", + maxLevel: "low", + available: true, + offIsReal: true, + source: "admin_cap", + }; + + it("hands over the server's resolved block untouched", () => { + authUser = { coachReasoning: STATE }; + expect(renderReasoning()).toEqual(STATE); + }); + + it("is null while the payload is not there or carries no block", () => { + authUser = null; + expect(renderReasoning()).toBeNull(); + authUser = { coachReasoning: null }; + expect(renderReasoning()).toBeNull(); + authUser = { coachReasoning: STATE }; + expect(renderReasoning(false)).toBeNull(); + }); +}); diff --git a/src/hooks/use-ai-capability.ts b/src/hooks/use-ai-capability.ts index 6a8abd503..156115a4f 100644 --- a/src/hooks/use-ai-capability.ts +++ b/src/hooks/use-ai-capability.ts @@ -8,6 +8,7 @@ import type { AiProviderState, } from "@/lib/ai/capabilities/types"; import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout"; +import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve"; /** * The web reader for the `ai` block on `GET /api/auth/me`. @@ -66,6 +67,24 @@ function useAiProviderStateInner(): AiProviderState { return user?.ai?.provider ?? NO_PROVIDER_STATE; } +/** + * v1.41 — the Coach's thinking depth, resolved on the server (`coachReasoning` + * on `GET /api/auth/me`), or `null` while it is not known: `/me` loading, no + * query client, or a server that does not publish it. Render the settings + * from it; never recompute the cap or the switch. + */ +export function useCoachReasoning(): CoachReasoningState | null { + const hasClient = useQueryClientMounted(); + if (!hasClient) return null; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useCoachReasoningInner(); +} + +function useCoachReasoningInner(): CoachReasoningState | null { + const { user } = useAuth(); + return user?.coachReasoning ?? null; +} + /** * The server's answer for one capability, or `null` while there is none yet * (`/me` loading, no signed-in account, no query client). For the few places diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index dae619122..9a85faa76 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -23,6 +23,7 @@ import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format"; import { storeTimezone } from "@/lib/timezone-mirror"; import type { ModuleKey } from "@/lib/modules/registry"; import type { AiCapabilities } from "@/lib/ai/capabilities/types"; +import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve"; import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; @@ -238,6 +239,13 @@ export interface AuthUser { * against a payload that does not carry a well-formed block. */ ai?: AiCapabilities | null; + /** + * v1.41 — the Coach's thinking depth, resolved on the server: the level a + * turn runs at, the person's own pick, the highest option the operator + * allows, and why. Read it through `useCoachReasoning`; `null` against a + * server image that does not publish it. + */ + coachReasoning?: CoachReasoningState | null; /** * v1.36.0 — account sharing, resolved server-side. `accounts` is the * switcher's menu, `active` is the record this browser is inside (null when @@ -455,6 +463,12 @@ export async function fetchMe(): Promise { typeof data.ai.provider === "object" ? data.ai : null, + // v1.41 — an older server image publishes no reasoning block; null, which + // the hook reads as "not available here". + coachReasoning: + data.coachReasoning && typeof data.coachReasoning === "object" + ? data.coachReasoning + : null, accountAccess, accountAccessStatus, }; diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index 3414b51d1..3187b5854 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -20,6 +20,10 @@ import { } from "@/lib/export/account-settings-backup"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { reasoningEffortFor } from "@/lib/ai/reasoning-effort"; +import { + coachReasoningLevel, + parseCoachPrefs, +} from "@/lib/validations/coach-prefs"; /** A 1x1 PNG, the smallest image the avatar upload accepts. */ const PNG_1X1 = @@ -223,6 +227,28 @@ describe("admitAccountSettings", () => { expect(reasoningEffortFor(restored, "openai-compatible")).toBe("high"); }); + it("carries the Coach's thinking depth through a round trip", async () => { + const prefs = { + tone: "warm", + verbosity: "default", + excludeMetrics: [], + showEvidenceByDefault: false, + defaultWindow: "allTime", + reasoning: "high", + }; + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith({ coachPrefsJson: prefs })), + "u1", + { purpose: "portable-export" }, + ); + const restored = admitAccountSettings( + JSON.parse(JSON.stringify(accountSettings)), + ctx, + ).data.coachPrefsJson; + expect(restored).toEqual(prefs); + expect(coachReasoningLevel(parseCoachPrefs(restored))).toBe("high"); + }); + it("writes a database null for a JSON setting the file clears", () => { expect( admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index d4d59dc54..c5b91e755 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -25,6 +25,8 @@ import { z } from "zod/v4"; import { accountAccessBlockSchema } from "@/lib/sharing/account-access-schema"; import { SHARE_DOMAINS } from "@/lib/sharing/scope"; +import { REASONING_LEVELS } from "@/lib/ai/reasoning/levels"; +import { REASONING_SOURCES } from "@/lib/ai/reasoning/resolve"; import { inviteGrantSchema, switchAccountSchema, @@ -436,6 +438,51 @@ export const accountAccessBlock = accountAccessBlockSchema.meta({ "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field.", }); +/** + * v1.41 — the Coach's thinking depth, resolved. Enumerated because a client + * DECIDES on it: the settings field offers the levels up to `maxLevel`, locks + * itself when `available` is false, and names the lowest option "Off" or + * "Minimal" from `offIsReal`. None of it is recomputed on a client. + */ +const coachReasoningBlock = z + .object({ + level: z + .enum(REASONING_LEVELS) + .describe( + "The level a Coach turn runs at right now, every cap applied: the person's choice, lowered to the operator's highest level, to `medium` when the operator pays for the provider, and `off` when the operator switched reasoning off or the provider cannot reason.", + ), + preference: z + .enum(REASONING_LEVELS) + .describe( + "What the person picked (`reasoning` on `PUT /api/auth/me/coach-prefs`), `medium` when they never chose. Bind the settings field to this, not to `level`.", + ), + maxLevel: z + .enum(REASONING_LEVELS) + .describe( + "The highest level the settings may offer. Options above it are shown disabled as limited by the operator. `off` when the operator switched reasoning off.", + ), + available: z + .boolean() + .describe( + "False when the operator switched reasoning off or the configured provider cannot reason; the settings field is then locked.", + ), + offIsReal: z + .boolean() + .describe( + 'False when the provider cannot switch reasoning off (it thinks at its lowest level instead); label the lowest option "Minimal" rather than "Off".', + ), + source: z + .enum(REASONING_SOURCES) + .describe( + "Which layer decided `level`: the person's own choice (`user`), the operator's switch (`admin_off`) or cap (`admin_cap`), the operator-funded ceiling (`cost_cap`), or a provider that cannot reason (`unsupported`). `job` never appears here.", + ), + }) + .meta({ + id: "CoachReasoningState", + description: + "The Coach's thinking depth for the signed-in person, resolved on the server from their preference, the operator's reasoning switch and cap, who pays for the provider, and what the provider can do.", + }); + /** * The account payload, documented for the one field this release adds. * @@ -455,6 +502,8 @@ const accountPayload = z // renders an AI surface only when its capability is available, and never // derives the answer from the switches, the modules or the provider. ai: aiAccountBlock, + // v1.41 — enumerated for the reason `ai` is: the settings decide on it. + coachReasoning: coachReasoningBlock, recordSession: recordSessionState.nullable(), // Enumerated because a client DECIDES on it rather than displays it: the // medication surface hides the server-side reminder switch when @@ -480,7 +529,7 @@ const accountPayload = z .meta({ id: "AccountPayload", description: - "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record, with the same masking; it replaces reading `GET /api/feature-flags`.", + "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record, with the same masking; it replaces reading `GET /api/feature-flags`. `coachReasoning` (v1.41) is the Coach's resolved thinking depth: the caller's own preference against the operator's reasoning controls and the record's provider.", }); /** diff --git a/tests/integration/auth-me-coach-reasoning.test.ts b/tests/integration/auth-me-coach-reasoning.test.ts new file mode 100644 index 000000000..186352bd5 --- /dev/null +++ b/tests/integration/auth-me-coach-reasoning.test.ts @@ -0,0 +1,256 @@ +/** + * v1.41 — `coachReasoning` on `GET /api/auth/me`, through the real routes + * against real Postgres. + * + * The resolution order is proven exhaustively by the unit table in + * `src/lib/ai/reasoning/__tests__/resolve.test.ts`. What only this file can + * prove is the wiring end to end: the person's choice written through + * `PUT /api/auth/me/coach-prefs`, the operator's switch and cap written + * through `PUT /api/admin/settings/assistant-flags` (migration 0376's two + * columns), and the provider read off the record, all arriving at the one + * block the web settings and the native client read. + * + * Mutation check: drop the `admin` argument's switch in `resolveReasoning` + * (return the preference whatever `enabled` says) and the operator-off case + * goes red; publish the raw preference as `level` and the cap case does. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +interface CoachReasoning { + level: string; + preference: string; + maxLevel: string; + available: boolean; + offIsReal: boolean; + source: string; +} + +let counter = 0; + +async function makeUser(overrides: Record = {}) { + const suffix = `${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `reason-${suffix}`, + email: `reason-${suffix}@example.test`, + role: "USER", + timezone: "UTC", + locale: "en", + onboardingCompletedAt: new Date(), + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + // Presence only: the probe never decrypts, so any value will do. + aiAnthropicKeyEncrypted: "v1:presence-only", + ...overrides, + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function readReasoning(): Promise { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + return ((await res.json()) as { data: { coachReasoning: CoachReasoning } }) + .data.coachReasoning; +} + +async function putPrefs(body: Record) { + const { PUT } = await import("@/app/api/auth/me/coach-prefs/route"); + const res = await (PUT as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/coach-prefs", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + expect(res.status).toBe(200); +} + +/** Flip the operator's controls through the admin route, as an admin. */ +async function putControls(body: Record) { + const admin = await getPrismaClient().user.create({ + data: { + username: `admin-${counter++}`, + email: `admin-${counter}@example.test`, + role: "ADMIN", + }, + }); + const previous = cookieJar.get("healthlog_session"); + await signIn(admin.id); + const { PUT } = + await import("@/app/api/admin/settings/assistant-flags/route"); + const { NextRequest } = await import("next/server"); + const res = await PUT( + new NextRequest("http://localhost/api/admin/settings/assistant-flags", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + expect(res.status).toBe(200); + if (previous) cookieJar.set("healthlog_session", previous); + return (await res.json()) as { + data: { reasoning: { enabled: boolean; maxEffort: string } }; + }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("GET /api/auth/me — coachReasoning", () => { + it("runs a person who never chose at medium, uncapped", async () => { + const user = await makeUser(); + await signIn(user.id); + expect(await readReasoning()).toEqual({ + level: "medium", + preference: "medium", + maxLevel: "high", + available: true, + offIsReal: true, + source: "user", + }); + }); + + it("publishes the level the person stored through the Coach settings", async () => { + const user = await makeUser(); + await signIn(user.id); + await putPrefs({ reasoning: "high" }); + const stored = await getPrismaClient().user.findUnique({ + where: { id: user.id }, + select: { coachPrefsJson: true }, + }); + expect(stored?.coachPrefsJson).toMatchObject({ reasoning: "high" }); + expect(await readReasoning()).toMatchObject({ + level: "high", + preference: "high", + source: "user", + }); + }); + + it("clamps the person's level to the operator's cap", async () => { + const user = await makeUser(); + await signIn(user.id); + await putPrefs({ reasoning: "high" }); + const controls = await putControls({ aiReasoningMaxEffort: "low" }); + expect(controls.data.reasoning).toEqual({ + enabled: true, + maxEffort: "low", + }); + const row = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + select: { aiReasoningMaxEffort: true }, + }); + expect(row?.aiReasoningMaxEffort).toBe("low"); + expect(await readReasoning()).toMatchObject({ + level: "low", + preference: "high", + maxLevel: "low", + source: "admin_cap", + }); + }); + + it("is off everywhere once the operator switches reasoning off", async () => { + const user = await makeUser(); + await signIn(user.id); + await putPrefs({ reasoning: "high" }); + await putControls({ aiReasoningEnabled: false }); + expect(await readReasoning()).toEqual({ + level: "off", + preference: "high", + maxLevel: "off", + available: false, + offIsReal: true, + source: "admin_off", + }); + }); + + it("holds an operator-funded provider to medium", async () => { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { + id: "singleton", + adminAiKeyEncrypted: "v1:presence-only", + adminAiModel: "gpt-5.5", + }, + update: { + adminAiKeyEncrypted: "v1:presence-only", + adminAiModel: "gpt-5.5", + }, + }); + const user = await makeUser({ + aiProvider: null, + aiModel: null, + aiAnthropicKeyEncrypted: null, + }); + await signIn(user.id); + await putPrefs({ reasoning: "high" }); + expect(await readReasoning()).toMatchObject({ + level: "medium", + maxLevel: "medium", + available: true, + source: "cost_cap", + }); + }); + + it("says the lowest option is not a real off on a model that always thinks", async () => { + const user = await makeUser({ aiModel: "claude-opus-5" }); + await signIn(user.id); + expect(await readReasoning()).toMatchObject({ + offIsReal: false, + available: true, + }); + }); + + it("is unavailable on a provider that cannot reason", async () => { + const user = await makeUser({ + aiProvider: "OPENAI", + aiModel: "gpt-4o", + aiAnthropicKeyEncrypted: null, + aiOpenaiKeyEncrypted: "v1:presence-only", + }); + await signIn(user.id); + expect(await readReasoning()).toMatchObject({ + level: "off", + available: false, + source: "unsupported", + }); + }); +});