mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
177 lines
6.2 KiB
TypeScript
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*["']/);
|
|
});
|
|
});
|