Files
HealthLog/src/__tests__/ai-reasoning-admin-guard.test.ts
T
Marc-André Bombeck ae5cee331e 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.
2026-10-06 19:57:04 +02:00

177 lines
6.2 KiB
TypeScript

/**
* v1.41 — the operator's reasoning switch and cap cannot be bypassed.
*
* Two halves.
*
* Behaviour: `resolveReasoning` is the one decision. With the switch off it
* answers `off` for the Coach and every background job, whatever the person
* chose, whoever pays and whatever the provider can do; with it on, no answer
* is above the cap. Proven exhaustively over the whole input space.
*
* Structure: a call carries reasoning only through `CompletionParams.reasoning`.
* Every production file outside the provider clients that puts that key on a
* call must take its value from the resolver (`@/lib/ai/reasoning/resolve` or
* `@/lib/ai/reasoning/controls`). A file that hand-built `{ effort: "high" }`
* would skip the switch, and goes red here.
*
* Its limit: the structural half reads source text for the setter shapes in
* use (`reasoning: { effort`, `{ reasoning }`, `reasoning: completionReasoning(`,
* `reasoning: resolveJobReasoning(`). A value laundered through another name
* slips it; the resolver import is still required of the file that sets it.
*
* Mutation check: replace `resolveJobReasoning(...)` in `status-provider.ts`
* with `{ effort: "high", summaries: false }` and drop the import: red.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
BACKGROUND_REASONING_JOBS,
REASONING_LEVELS,
REASONING_MAX_EFFORTS,
} from "@/lib/ai/reasoning/levels";
import {
completionReasoning,
resolveReasoning,
type ReasoningSurface,
} from "@/lib/ai/reasoning/resolve";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS];
describe("the switch and the cap win, behaviourally", () => {
const supports = [
null,
{
effort: true,
summaries: true,
liveSummaries: true,
stateRoundTrip: true,
offIsReal: false,
},
];
it("off reaches no surface as anything but off", () => {
let cases = 0;
for (const surface of SURFACES)
for (const userPref of REASONING_LEVELS)
for (const costOwner of ["user", "operator"] as const)
for (const support of supports) {
const r = resolveReasoning({
surface,
userPref,
admin: { enabled: false, maxEffort: "high" },
costOwner,
support,
});
expect(r.effort).toBe("off");
expect(r.source).toBe("admin_off");
const sent = completionReasoning(r, surface);
// A job sends nothing; the Coach sends an explicit off.
if (surface === "coach") {
expect(sent).toEqual({ effort: "off", summaries: false });
} else {
expect(sent).toBeUndefined();
}
cases++;
}
expect(cases).toBeGreaterThanOrEqual(100);
});
it("no surface ever resolves above the cap", () => {
for (const surface of SURFACES)
for (const userPref of REASONING_LEVELS)
for (const maxEffort of REASONING_MAX_EFFORTS) {
const r = resolveReasoning({
surface,
userPref,
admin: { enabled: true, maxEffort },
costOwner: "user",
});
expect(REASONING_LEVELS.indexOf(r.effort)).toBeLessThanOrEqual(
REASONING_LEVELS.indexOf(maxEffort),
);
}
});
});
/** The provider clients map an already-resolved value onto their wire. */
const WIRE_LAYER = (rel: string) =>
/^lib\/ai\/[a-z-]+-client\.ts$/.test(rel) ||
rel === "lib/ai/provider-runner.ts" ||
rel.startsWith("lib/ai/reasoning/");
const SETTERS = [
/\breasoning\s*:\s*\{\s*effort\b/,
/\{\s*reasoning\s*\}/,
/\breasoning\s*:\s*(?:completionReasoning|resolveJobReasoning)\s*\(/,
];
const RESOLVER_IMPORT =
/from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/;
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
describe("every call that carries reasoning took it from the resolver", () => {
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
// Client components bind a form field called `reasoning`, not a call.
.filter((p) => !p.startsWith("components/"))
.filter((p) => !WIRE_LAYER(p));
const setters = files.filter((rel) => {
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
return SETTERS.some((re) => re.test(code));
});
it("finds the setters (an empty set is a failure, not a pass)", () => {
expect(setters.length).toBeGreaterThanOrEqual(2);
expect(setters).toContain("lib/insights/status-provider.ts");
expect(setters).toContain("lib/insights/briefing-provider.ts");
});
it("each one imports the resolver", () => {
expect(
setters.filter(
(rel) => !RESOLVER_IMPORT.test(readFileSync(join(SRC, rel), "utf8")),
),
"A call carries reasoning that did not come from resolveReasoning, so the operator's switch and cap do not reach it.",
).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*["']/);
});
});