feat(coach): resolve the turn's thinking depth from the person and the operator

The chat route hands the turn the level resolveCoachTurnReasoningLevel
returns: the stored Coach preference, absent read as medium, after the
operator's switch and cap. The turn still applies the operator-paid
ceiling once its chain is known. The controls loader no longer throws
when the request cache cannot serve it; it fails closed to off. The
admin guard pins the route to the resolver.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:57:04 +02:00
1 parent 85ebdad5c9
commit ae5cee331e
6 files changed
+168 -10

No files matched your search

@@ -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*["']/);
});
});
@@ -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 }),
});
@@ -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", () => ({
+9 -8
View File
@@ -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<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;
// 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
@@ -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",
);
});
});
+40 -2
View File
@@ -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<ReasoningAdminControls> {
export async function loadReasoningControls(): Promise<ReasoningAdminControls> {
// 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<ReasoningAdminControls> {
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<ReasoningLevel> {
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;
}