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

@@ -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