Merge wave D — the shared provider is offered only when it is known to work

The done screen's assistant panel stops claiming that the instance's
provider already serves the person. A tri-state read over the operator's
own provider ledger decides between a one-tap offer that mints the
consent receipt, a plain "not answering right now" note, and today's
setup link; anything unknown, any read error, a managed profile and an
operator who has switched the assistant off all fall to no offer.

# Conflicts:
#	e2e/setup/global-setup.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 10:25:19 +02:00
commit 1885b7867d
18 files changed
+1204 -23

No files matched your search

@@ -12,15 +12,52 @@ import { I18nProvider } from "@/lib/i18n/context";
*/
const aiProviderState = vi.hoisted(() => ({
data: undefined as { managedBy?: string | null } | undefined,
data: undefined as
| {
managedBy?: string | null;
serverProviderHealth?: string;
serverProviderOffer?: boolean;
serverProviderConsent?: boolean;
}
| undefined,
}));
/**
* The consent grant is a mutation, and this suite renders to static markup
* (the house convention — there is no jsdom in this repo), so the click
* itself cannot be dispatched. What IS worth proving is the request the
* button would send, so every `useMutation` registration is captured here
* and its `mutationFn` invoked directly.
*/
const mutations = vi.hoisted(
() =>
[] as Array<{
mutationKey?: readonly unknown[];
mutationFn?: () => Promise<unknown>;
}>,
);
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: aiProviderState.data }),
useMutation: () => ({ mutate: () => {}, isPending: false }),
useMutation: (options?: {
mutationKey?: readonly unknown[];
mutationFn?: () => Promise<unknown>;
}) => {
if (options) mutations.push(options);
return { mutate: () => {}, mutateAsync: async () => {}, isPending: false };
},
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
const apiFetchRaw = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiFetchRaw,
}));
const toastError = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({ toast: { error: toastError } }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: () => {}, replace: () => {} }),
}));
@@ -110,11 +147,133 @@ describe("<DoneScreen> AI panel", () => {
expect(render("both")).toContain('href="/settings/access"');
expect(render("me")).not.toContain('href="/settings/access"');
});
});
it("shows the shared-key note only when the operator key serves the user", () => {
aiProviderState.data = { managedBy: "user" };
expect(render()).not.toContain('data-slot="onboarding-ai-shared-key"');
aiProviderState.data = { managedBy: "server" };
expect(render()).toContain('data-slot="onboarding-ai-shared-key"');
/**
* v1.38.19 (wave D) — the shared provider, offered only when it works.
*
* The card used to read `managedBy === "server"` and promise that insights
* "work for you right now". That is a presence read: it knew a key was
* configured, never that it worked, and it said nothing about the consent
* receipt the egress actually needs — so a fresh account followed the
* promise straight into a `consent.ai.required` refusal. On 2026-09-11 the
* operator's own instance was answering HTTP 500 while the screen kept
* making that promise.
*
* Four variants now, and only one of them has a button.
*/
function grantMutation() {
return mutations.find((m) =>
(m.mutationKey ?? []).some(
(part) => typeof part === "string" && part.includes("consent"),
),
);
}
describe("<DoneScreen> shared-provider offer", () => {
it("offers one tap when the server says the provider is known to work", () => {
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: true,
serverProviderConsent: false,
};
const html = render();
expect(html).toContain('data-slot="onboarding-ai-offer"');
expect(html).toContain('data-slot="onboarding-ai-offer-grant"');
// The unproven promise is gone from this branch.
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
});
it.each(["unhealthy", "unknown"] as const)(
"says the provider is not answering on %s, and offers no button",
(health) => {
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: health,
serverProviderOffer: false,
serverProviderConsent: false,
};
const html = render();
expect(html).toContain('data-slot="onboarding-ai-unavailable"');
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
// The rest of the panel is untouched in every branch.
expect(html).toContain('data-slot="onboarding-ai-keyless"');
expect(html).toContain('href="/settings/ai"');
},
);
it("keeps the shared-key note for the one state it was ever true of", () => {
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: false,
serverProviderConsent: true,
};
const html = render();
expect(html).toContain('data-slot="onboarding-ai-shared-key"');
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
});
it("leaves a user with their own provider the setup link and nothing else", () => {
aiProviderState.data = {
managedBy: "user",
serverProviderHealth: "healthy",
serverProviderOffer: false,
serverProviderConsent: false,
};
const html = render();
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
expect(html).toContain('href="/settings/ai"');
});
it("says nothing at all while the status is still in flight", () => {
aiProviderState.data = undefined;
const html = render();
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
});
it("grants through the affirmative web intent — the only path that may lift a revocation", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: true });
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: true,
serverProviderConsent: false,
};
render();
const grant = grantMutation();
expect(grant).toBeDefined();
await grant!.mutationFn!();
expect(apiFetchRaw).toHaveBeenCalledWith(
"/api/consent/ai/web",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ intent: "affirmative" }),
}),
);
});
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: false, status: 429 });
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: true,
serverProviderConsent: false,
};
render();
const grant = grantMutation();
await expect(grant!.mutationFn!()).rejects.toThrow();
});
});
+121 -9
View File
@@ -3,7 +3,8 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
CheckCircle2,
Compass,
@@ -24,7 +25,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
@@ -33,6 +34,15 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
interface AiProviderStatus {
/** Origin of the provider that would serve this user, if any. */
managedBy?: "user" | "local" | "server" | null;
/**
* v1.38.19 (wave D) — the instance-wide tri-state. Fail closed: `unknown`
* is treated exactly like `unhealthy`, here and everywhere.
*/
serverProviderHealth?: "healthy" | "unhealthy" | "unknown";
/** Whether the shared provider may honestly be offered in one tap. */
serverProviderOffer?: boolean;
/** Whether this user already holds an active `ai_full` / `ai_coach` receipt. */
serverProviderConsent?: boolean;
}
/**
@@ -101,10 +111,21 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
router.push("/");
}
// The shared-key note is the ONLY honest divergence in the panel: on a
// deployment that ships an operator key, insights already work for the
// user, so we say so plainly instead of pushing a BYOK/local setup they
// don't need. Presence-only read; no key material is exposed.
// ── v1.38.19 (wave D) — the shared provider, offered only when it works ──
//
// This block used to be one line: `managedBy === "server"` painted a note
// reading "insights work for you right now — no setup needed". Both halves
// were wrong. `managedBy` is a PRESENCE read — it knows the operator
// configured a key, never that the key answers — and "no setup needed"
// skipped the consent receipt `consent-guard.ts` demands before any health
// data reaches `admin-openai` / `admin-codex`, so the person who believed
// the note walked into a `consent.ai.required` refusal on their first
// briefing. On 2026-09-11 the operator's own instance was answering HTTP
// 500 from its OAuth proxy while this note kept promising otherwise.
//
// The server decides now, and the four branches below only READ it. The
// rest of the panel — sample, ladder, the "fully useful without AI" line,
// the setup link — is identical in every branch; nothing here is a gate.
const { data: aiProvider } = useQuery<AiProviderStatus>({
queryKey: queryKeys.userAiProvider(),
queryFn: async () => {
@@ -112,7 +133,58 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
},
enabled: !!user,
});
const sharedKeyServes = aiProvider?.managedBy === "server";
// The tap's own outcome, held locally so the line changes the moment the
// receipt is minted rather than a refetch later.
const [justGranted, setJustGranted] = useState(false);
const queryClient = useQueryClient();
const grantConsent = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
mutationFn: async () => {
// `intent: "affirmative"` marks this as the user's own consent act —
// the only web path allowed to supersede an earlier withdrawal. The
// silent mount heal posts no body and deliberately cannot.
const res = await apiFetchRaw("/api/consent/ai/web", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intent: "affirmative" }),
});
if (!res.ok) throw new Error("consent grant failed");
},
onSuccess: async () => {
setJustGranted(true);
await queryClient.invalidateQueries({
queryKey: queryKeys.userAiProvider(),
});
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
},
onError: () => {
// The button stays. A failed grant is a failed grant; the screen must
// not read as though consent were on file.
toast.error(t("onboarding.ai.offer.error"));
},
});
const operatorProvides = aiProvider?.managedBy === "server";
const consentOnFile =
justGranted || aiProvider?.serverProviderConsent === true;
const offerShared =
operatorProvides &&
!consentOnFile &&
aiProvider?.serverProviderOffer === true;
// Only a health verdict paints the "not answering" card. When the offer is
// refused for a reason that is not health — the operator switched the
// assistant surfaces off, or this is a managed profile whose provider is
// the operator's by definition — the panel falls back to its neutral form
// and claims nothing either way.
const sharedUnavailable =
operatorProvides &&
!consentOnFile &&
!offerShared &&
(aiProvider?.serverProviderHealth === "unhealthy" ||
aiProvider?.serverProviderHealth === "unknown");
return (
<section
@@ -185,15 +257,55 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
</Button>
)}
{sharedKeyServes ? (
{consentOnFile && operatorProvides ? (
<p
data-slot="onboarding-ai-shared-key"
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
>
{t("onboarding.ai.sharedKeyNote")}
{justGranted
? t("onboarding.ai.offer.granted")
: t("onboarding.ai.sharedKeyNote")}
</p>
) : null}
{offerShared ? (
<div
data-slot="onboarding-ai-offer"
className="border-primary/20 bg-primary/5 space-y-2.5 rounded-lg border px-3 py-3"
>
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.offer.title")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.offer.body")}
</p>
<Button
type="button"
size="sm"
className="min-h-9 w-full"
data-slot="onboarding-ai-offer-grant"
disabled={grantConsent.isPending}
onClick={() => grantConsent.mutate()}
>
{t("onboarding.ai.offer.cta")}
</Button>
</div>
) : null}
{sharedUnavailable ? (
<div
data-slot="onboarding-ai-unavailable"
className="border-border bg-muted/40 space-y-1 rounded-lg border px-3 py-3"
>
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.unavailable.title")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.unavailable.body")}
</p>
</div>
) : null}
<div className="space-y-2.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderTitle")}