diff --git a/src/__tests__/ai-reasoning-admin-guard.test.ts b/src/__tests__/ai-reasoning-admin-guard.test.ts index f40ab3103..221419db5 100644 --- a/src/__tests__/ai-reasoning-admin-guard.test.ts +++ b/src/__tests__/ai-reasoning-admin-guard.test.ts @@ -149,3 +149,28 @@ describe("every call that carries reasoning took it from the resolver", () => { ).toEqual([]); }); }); + +/** + * The Coach turn receives its level on `TurnInput.reasoningLevel` and carries + * it through the loop under its own names, which the setter shapes above do + * not see. The one place that level is decided is the chat route, so the + * route is pinned: it resolves the level, and it does not fall back to a + * constant that would skip the operator's switch. + */ +describe("the Coach turn's level comes from the resolver", () => { + const route = stripComments( + readFileSync(join(SRC, "app/api/insights/chat/route.ts"), "utf8"), + ); + + it("resolves the level in the chat route", () => { + expect(route).toMatch( + /reasoningLevel[^=]*=\s*await\s+resolveCoachTurnReasoningLevel\s*\(/, + ); + expect(route).toMatch(RESOLVER_IMPORT); + }); + + it("never hands the turn a constant level", () => { + expect(route).not.toMatch(/\bDEFAULT_REASONING_LEVEL\b/); + expect(route).not.toMatch(/reasoningLevel\s*:\s*["']/); + }); +}); diff --git a/src/app/api/insights/chat/__tests__/golden-harness.ts b/src/app/api/insights/chat/__tests__/golden-harness.ts index 7590d7ceb..867f12e45 100644 --- a/src/app/api/insights/chat/__tests__/golden-harness.ts +++ b/src/app/api/insights/chat/__tests__/golden-harness.ts @@ -175,6 +175,8 @@ export function resetGolden(): void { }, ); m.getEvent.mockReturnValue({ + // A request event, so the per-request cache memoises as in production. + getKind: () => "request", setError: (err: Error) => record("event.setError", { message: err.message }), }); diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts index 96f297edb..1703970b6 100644 --- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts +++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts @@ -63,6 +63,8 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: h.m.userFindUnique, update: h.m.userUpdate }, coachConversation: { findFirst: h.m.conversationFindFirst }, coachMessage: { findMany: h.m.coachMessageFindMany }, + // v1.41 — the operator's reasoning controls; an untouched instance. + appSettings: { findUnique: vi.fn(async () => null) }, }, })); vi.mock("@/lib/rate-limit", () => ({ diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 7fe9a17de..971f391f8 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -37,10 +37,8 @@ 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 type { ReasoningLevel } from "@/lib/ai/reasoning/levels"; +import { resolveCoachTurnReasoningLevel } from "@/lib/ai/reasoning/controls"; import { streamProviderError, streamRefusal } from "@/lib/ai/coach/turn/sse"; /** @@ -182,10 +180,13 @@ async function handleChatRequest(request: NextRequest): Promise { } // 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; + // operator's switch and highest level, from the shared resolver. Who pays + // is not known until the turn resolves its chain, so the turn applies the + // operator-paid ceiling itself (`turn/reasoning.ts`); the provider's own + // ability is left to its client. + const reasoningLevel: ReasoningLevel = await resolveCoachTurnReasoningLevel( + auth.user.coachPrefsJson, + ); // 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 diff --git a/src/lib/ai/reasoning/__tests__/controls.test.ts b/src/lib/ai/reasoning/__tests__/controls.test.ts new file mode 100644 index 000000000..11f6a196f --- /dev/null +++ b/src/lib/ai/reasoning/__tests__/controls.test.ts @@ -0,0 +1,90 @@ +/** + * v1.41 — the Coach turn's level as the chat route resolves it: the stored + * preference against the operator's controls, read through the real loader + * against a stubbed settings row. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { row } = vi.hoisted(() => ({ + row: { current: null as unknown, fail: false }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(async () => { + if (row.fail) throw new Error("db down"); + return row.current; + }), + }, + }, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => undefined, +})); + +import { + loadReasoningControls, + resolveCoachTurnReasoningLevel, +} from "../controls"; + +beforeEach(() => { + row.current = null; + row.fail = false; +}); + +describe("loadReasoningControls", () => { + it("reads an untouched instance as on and uncapped", async () => { + expect(await loadReasoningControls()).toEqual({ + enabled: true, + maxEffort: "high", + }); + }); + + it("fails closed when the row cannot be read", async () => { + row.fail = true; + expect((await loadReasoningControls()).enabled).toBe(false); + }); + + it("reads an unknown stored cap as no cap", async () => { + row.current = { aiReasoningEnabled: true, aiReasoningMaxEffort: "xhigh" }; + expect((await loadReasoningControls()).maxEffort).toBe("high"); + }); +}); + +describe("resolveCoachTurnReasoningLevel", () => { + it("runs a never-chosen preference at medium", async () => { + expect(await resolveCoachTurnReasoningLevel(null)).toBe("medium"); + expect(await resolveCoachTurnReasoningLevel({ tone: "warm" })).toBe( + "medium", + ); + }); + + it("reads a malformed stored level as the default", async () => { + expect(await resolveCoachTurnReasoningLevel({ reasoning: "max" })).toBe( + "medium", + ); + }); + + it("clamps the stored level to the operator's cap", async () => { + row.current = { aiReasoningEnabled: true, aiReasoningMaxEffort: "low" }; + expect(await resolveCoachTurnReasoningLevel({ reasoning: "high" })).toBe( + "low", + ); + }); + + it("is off when the operator switched reasoning off", async () => { + row.current = { aiReasoningEnabled: false, aiReasoningMaxEffort: "high" }; + expect(await resolveCoachTurnReasoningLevel({ reasoning: "high" })).toBe( + "off", + ); + }); + + it("is off when the controls cannot be read", async () => { + row.fail = true; + expect(await resolveCoachTurnReasoningLevel({ reasoning: "high" })).toBe( + "off", + ); + }); +}); diff --git a/src/lib/ai/reasoning/controls.ts b/src/lib/ai/reasoning/controls.ts index 4beea7ef7..9751573ec 100644 --- a/src/lib/ai/reasoning/controls.ts +++ b/src/lib/ai/reasoning/controls.ts @@ -18,7 +18,12 @@ import { type CoachPrefs, } from "@/lib/validations/coach-prefs"; -import type { BackgroundReasoningJob } from "./levels"; +import { + DEFAULT_REASONING_LEVEL, + isReasoningLevel, + type BackgroundReasoningJob, + type ReasoningLevel, +} from "./levels"; import { coachReasoningCeiling, completionReasoning, @@ -45,7 +50,17 @@ const UNREADABLE: ReasoningAdminControls = Object.freeze({ * inside a background run, so switching reasoning off at 02:05 stops the * nightly pass that began at 02:00 for every account after the switch. */ -export function loadReasoningControls(): Promise { +export async function loadReasoningControls(): Promise { + // The request cache itself can fail (an event without a kind); the + // controls then fail closed like an unreadable row, never the caller. + try { + return await loadMemoisedControls(); + } catch { + return UNREADABLE; + } +} + +function loadMemoisedControls(): Promise { return memoizePerRequest( "ai-reasoning-controls", async () => { @@ -174,3 +189,26 @@ export async function resolveJobReasoning( }); return completionReasoning(resolved, job); } + +/** + * The effective Coach level for a turn: the person's stored choice + * (`coachPrefsJson.reasoning`, absent read as the default) after the + * operator's switch and cap. Who pays is applied inside the turn, once its + * chain is known; the provider's ability is left to its client. Never + * throws: an unreadable preference reads as the default, unreadable + * controls as "off". + */ +export async function resolveCoachTurnReasoningLevel( + coachPrefsJson: unknown, +): Promise { + const stored = + coachPrefsJson && typeof coachPrefsJson === "object" + ? (coachPrefsJson as { reasoning?: unknown }).reasoning + : undefined; + return resolveReasoning({ + surface: "coach", + userPref: isReasoningLevel(stored) ? stored : DEFAULT_REASONING_LEVEL, + admin: await loadReasoningControls(), + costOwner: "user", + }).effort; +}