feat(auth): publish the resolved Coach reasoning on the account payload

GET /api/auth/me gains coachReasoning: the level a turn runs at, the
person's own pick, the highest level the settings may offer, whether
reasoning can run at all, whether the lowest option is a real off, and
the layer that decided. Resolved on the server so the web settings and
the native client read the same answer.

The web reads it through useCoachReasoning. The Coach preferences route
names the stored level on its wide event, and a unit test round-trips
the level through the account-settings backup section.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:44:52 +02:00
1 parent adb4cd5c40
commit df0689ffbe
10 files changed
+554 -3

No files matched your search

@@ -172,3 +172,50 @@ describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => {
it("stores the chosen level and names it on the wide event", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
} as never);
const { annotate } = await import("@/lib/logging/context");
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ reasoning: "high" }),
);
expect(res.status).toBe(200);
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { coachPrefsJson: { reasoning?: string } };
};
expect(call.data.coachPrefsJson.reasoning).toBe("high");
const env = (await res.json()) as { data: { reasoning?: string } };
expect(env.data.reasoning).toBe("high");
const meta = vi
.mocked(annotate)
.mock.calls.map((c) => c[0])
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
expect(meta).toMatchObject({ reasoning: "high" });
});
it("reads an absent level as the default on the wide event", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
} as never);
const { annotate } = await import("@/lib/logging/context");
await (PUT as (r: Request) => Promise<Response>)(mkPut({ tone: "warm" }));
const meta = vi
.mocked(annotate)
.mock.calls.map((c) => c[0])
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
expect(meta).toMatchObject({ reasoning: "medium" });
});
it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => {
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ reasoning: value }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+5
View File
@@ -31,6 +31,7 @@ import {
} from "@/lib/optimistic-lock";
import {
coachPrefsSchema,
coachReasoningLevel,
parseCoachPrefs,
} from "@/lib/validations/coach-prefs";
@@ -130,6 +131,10 @@ export const PUT = apiHandler(async (req: Request) => {
// v1.7.0 — `null` when the caller left `dataClusters` absent
// (legacy defaults stay in force); otherwise the selected count.
clusterCount: parsed.data.dataClusters?.length ?? null,
// v1.41 — the stored thinking depth, absent read as the default. The
// operator's switch and cap apply on top of it at turn time; the
// resolved value is `coachReasoning` on GET /api/auth/me.
reasoning: coachReasoningLevel(parsed.data),
},
});
return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt });
+21
View File
@@ -72,6 +72,8 @@ import {
} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
export const dynamic = "force-dynamic";
@@ -143,6 +145,7 @@ export const GET = apiHandler(async () => {
moduleAvailability,
onboardingRow,
ai,
coachReasoning,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
@@ -206,6 +209,16 @@ export const GET = apiHandler(async () => {
sections,
recordKind,
}),
// v1.41 — the Coach's thinking depth, resolved: the caller's own
// preference (Coach preferences belong to the person, like every other
// display preference here) against the operator's switch and cap and the
// record's provider. The provider read is the memoised presence probe the
// `ai` block above already ran.
loadCoachReasoningState({
prefs: parseCoachPrefs(user.coachPrefsJson),
recordId,
authority: aiAuthority,
}),
]);
// The masking step, and the one place the record scoping is narrowed rather
@@ -397,6 +410,14 @@ export const GET = apiHandler(async () => {
// the RECORD and masked to the grant, like `modules`. Clients render from
// it and never recompute it; data never depends on it.
ai,
// v1.41 — the Coach's thinking depth, resolved on the server so the web
// settings and the native client show the same answer: `level` is what a
// turn runs at now, `preference` what the person picked, `maxLevel` the
// highest option the settings may offer (`off` when the operator switched
// reasoning off), `available` whether reasoning can run at all,
// `offIsReal` whether the lowest option is a real off or "Minimal", and
// `source` which layer decided. Clients never recompute it.
coachReasoning,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds