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

+49 -1
View File
@@ -12,7 +12,11 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: authUser }),
}));
import { useAiCapability, useAiProviderState } from "../use-ai-capability";
import {
useAiCapability,
useAiProviderState,
useCoachReasoning,
} from "../use-ai-capability";
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
function Probe({ capability }: { capability: AiCapabilityKey }) {
@@ -107,3 +111,47 @@ describe("useAiCapability", () => {
expect(render("briefing", false).state).toEqual(UNKNOWN);
});
});
function ReasoningProbe() {
return <span>{JSON.stringify(useCoachReasoning())}</span>;
}
function renderReasoning(withClient = true): unknown {
const html = renderToStaticMarkup(
withClient ? (
<QueryClientProvider client={new QueryClient()}>
<ReasoningProbe />
</QueryClientProvider>
) : (
<ReasoningProbe />
),
);
return JSON.parse(
html.replace(/^<span>|<\/span>$/g, "").replace(/&quot;/g, '"'),
);
}
describe("useCoachReasoning", () => {
const STATE = {
level: "low",
preference: "high",
maxLevel: "low",
available: true,
offIsReal: true,
source: "admin_cap",
};
it("hands over the server's resolved block untouched", () => {
authUser = { coachReasoning: STATE };
expect(renderReasoning()).toEqual(STATE);
});
it("is null while the payload is not there or carries no block", () => {
authUser = null;
expect(renderReasoning()).toBeNull();
authUser = { coachReasoning: null };
expect(renderReasoning()).toBeNull();
authUser = { coachReasoning: STATE };
expect(renderReasoning(false)).toBeNull();
});
});
+19
View File
@@ -8,6 +8,7 @@ import type {
AiProviderState,
} from "@/lib/ai/capabilities/types";
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
/**
* The web reader for the `ai` block on `GET /api/auth/me`.
@@ -66,6 +67,24 @@ function useAiProviderStateInner(): AiProviderState {
return user?.ai?.provider ?? NO_PROVIDER_STATE;
}
/**
* v1.41 — the Coach's thinking depth, resolved on the server (`coachReasoning`
* on `GET /api/auth/me`), or `null` while it is not known: `/me` loading, no
* query client, or a server that does not publish it. Render the settings
* from it; never recompute the cap or the switch.
*/
export function useCoachReasoning(): CoachReasoningState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useCoachReasoningInner();
}
function useCoachReasoningInner(): CoachReasoningState | null {
const { user } = useAuth();
return user?.coachReasoning ?? null;
}
/**
* The server's answer for one capability, or `null` while there is none yet
* (`/me` loading, no signed-in account, no query client). For the few places
+14
View File
@@ -23,6 +23,7 @@ import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format";
import { storeTimezone } from "@/lib/timezone-mirror";
import type { ModuleKey } from "@/lib/modules/registry";
import type { AiCapabilities } from "@/lib/ai/capabilities/types";
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
@@ -238,6 +239,13 @@ export interface AuthUser {
* against a payload that does not carry a well-formed block.
*/
ai?: AiCapabilities | null;
/**
* v1.41 — the Coach's thinking depth, resolved on the server: the level a
* turn runs at, the person's own pick, the highest option the operator
* allows, and why. Read it through `useCoachReasoning`; `null` against a
* server image that does not publish it.
*/
coachReasoning?: CoachReasoningState | null;
/**
* v1.36.0 — account sharing, resolved server-side. `accounts` is the
* switcher's menu, `active` is the record this browser is inside (null when
@@ -455,6 +463,12 @@ export async function fetchMe(): Promise<AuthUser> {
typeof data.ai.provider === "object"
? data.ai
: null,
// v1.41 — an older server image publishes no reasoning block; null, which
// the hook reads as "not available here".
coachReasoning:
data.coachReasoning && typeof data.coachReasoning === "object"
? data.coachReasoning
: null,
accountAccess,
accountAccessStatus,
};