diff --git a/src/lib/ai/coach/__tests__/context-reminders.test.ts b/src/lib/ai/coach/__tests__/context-reminders.test.ts index dfccf9437..407f7857e 100644 --- a/src/lib/ai/coach/__tests__/context-reminders.test.ts +++ b/src/lib/ai/coach/__tests__/context-reminders.test.ts @@ -9,6 +9,17 @@ */ import { describe, it, expect, vi } from "vitest"; +// Reminders are held while the Coach is unavailable; these cases are about +// surfacing, so the Coach answers. The hold is pinned against Postgres in +// `tests/integration/coach-reminder-surfacing.test.ts`. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityToServe: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); + vi.mock("@/lib/ai/coach/bytes-codec", () => ({ decryptFromBytes: (buf: Uint8Array) => Buffer.from(buf).toString("utf8"), encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), diff --git a/src/lib/ai/coach/reminder-surface.ts b/src/lib/ai/coach/reminder-surface.ts index f10056678..9b7ed4719 100644 --- a/src/lib/ai/coach/reminder-surface.ts +++ b/src/lib/ai/coach/reminder-surface.ts @@ -15,6 +15,7 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; @@ -56,6 +57,18 @@ export function buildReminderSurfaceMessage( return { title, body }; } +/** + * Whether a user's Coach reminders are held: the record's `coach` capability + * is unavailable (operator switch, module off, opt-out, no provider, no + * consent), which is the same answer that keeps the badge dark. A reminder + * surfaced into a Coach nobody can open is a message that never shows, and + * the nag cap would then dismiss it unseen; held, it waits and surfaces once + * the Coach is back. + */ +export async function coachRemindersHeld(userId: string): Promise { + return !(await aiCapabilityToServe(userId, "coach")).available; +} + export interface SurfaceOutcome { /** Reminders that reached the conversation in this call. */ surfaced: number; @@ -70,7 +83,8 @@ export interface SurfaceOutcome { * cost the user the reminders beside it. At most `MAX_NOTES_PER_MESSAGE` * notes ride one message; the remainder keep their status and surface on * the next trigger. A write failure leaves every row untouched: no badge - * claims a message that was never written. + * claims a message that was never written. While the Coach is unavailable + * nothing is written and every row keeps its status. */ export async function surfaceCoachReminders( prisma: ReminderSurfacePrisma, @@ -80,6 +94,7 @@ export async function surfaceCoachReminders( ): Promise { const outcome: SurfaceOutcome = { surfaced: 0, errored: 0 }; if (rows.length === 0) return outcome; + if (await coachRemindersHeld(userId)) return outcome; const usable: { id: string; note: string }[] = []; for (const row of rows) { diff --git a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts index 015b10d74..f71009da6 100644 --- a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts +++ b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts @@ -19,6 +19,17 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), })); +// Reminders are held while the Coach is unavailable; these cases are about +// surfacing, so the Coach answers. The hold is pinned against Postgres in +// `tests/integration/coach-reminder-surfacing.test.ts`. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityToServe: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); + // The context-cue backstop delegates to the shared evaluator; its own // behaviour is pinned in `ai/coach/__tests__/context-reminders.test.ts`. const { contextEvaluateMock } = vi.hoisted(() => ({ diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index a15ff20a5..850f053db 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -34,6 +34,10 @@ * that has been stuck on an empty conversation resolves into the message it * always implied rather than staying stuck. * + * While the record's `coach` capability is unavailable its reminders are held: + * none is surfaced, charged or auto-dismissed, and they resume once the Coach + * is back (`coachRemindersHeld`, the same answer that keeps the badge dark). + * * The sweep still never calls a provider and never pushes. The message body is * a deterministic localized template around the user's OWN note text — the * words they asked to be reminded of, never anything fabricated. @@ -50,7 +54,10 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; -import { surfaceCoachReminders } from "@/lib/ai/coach/reminder-surface"; +import { + coachRemindersHeld, + surfaceCoachReminders, +} from "@/lib/ai/coach/reminder-surface"; export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep"; // Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both @@ -258,11 +265,26 @@ export async function runCoachReminderSweep( // it has already sat there for a day as one more surfacing, then // auto-dismiss the rows that reached the cap. Two bulk statements, no // message: dismissal is silence, not another nag. + // A day the Coach was unavailable is not a day the reminder sat ignored: + // the badge was dark and there was nothing to open. Those users' rows are + // neither charged nor dismissed. + const surfacedOwners = await prisma.coachReminder.findMany({ + where: { deletedAt: null, status: "surfaced" }, + distinct: ["userId"], + select: { userId: true }, + }); + const heldOwners: string[] = []; + for (const { userId } of surfacedOwners) { + if (await coachRemindersHeld(userId)) heldOwners.push(userId); + } + const notHeld = + heldOwners.length > 0 ? { userId: { notIn: heldOwners } } : {}; await prisma.coachReminder.updateMany({ where: { deletedAt: null, status: "surfaced", lastSurfacedAt: { lte: new Date(now.getTime() - NAG_STALE_MS) }, + ...notHeld, }, data: { surfaceCount: { increment: 1 } }, }); @@ -271,6 +293,7 @@ export async function runCoachReminderSweep( deletedAt: null, status: "surfaced", surfaceCount: { gte: REMINDER_NAG_CAP }, + ...notHeld, }, data: { status: "dismissed" }, }); diff --git a/tests/integration/coach-reminder-surfacing.test.ts b/tests/integration/coach-reminder-surfacing.test.ts index cbd927b0a..06293671e 100644 --- a/tests/integration/coach-reminder-surfacing.test.ts +++ b/tests/integration/coach-reminder-surfacing.test.ts @@ -23,6 +23,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep"; import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; +import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; const NOW = new Date("2026-08-07T05:20:00.000Z"); @@ -32,17 +33,34 @@ beforeEach(async () => { await truncateAllTables(getPrismaClient()); }); -async function seedUser(username: string, locale = "en") { +/** + * A user whose Coach can answer: a provider (presence only) and a Coach + * consent receipt. Reminders are held while the Coach is unavailable, so a + * user without them would prove only the hold. + */ +async function seedUser( + username: string, + { coach = true }: { coach?: boolean } = {}, +) { return getPrismaClient().user.create({ data: { username, email: `${username}@example.test`, timezone: "UTC", - locale, + locale: "en", + ...(coach ? COACH_READY : {}), }, }); } +const COACH_READY = { + aiProvider: "ANTHROPIC", + aiAnthropicKeyEncrypted: "v1:presence-only", + consentReceipts: { + create: { kind: "ai_full", artefact: "test", signedAt: new Date() }, + }, +} as const; + describe("a due Coach reminder reaches the conversation the badge points at", () => { it("writes the note as an assistant message, and that message is what makes the badge unread", async () => { const prisma = getPrismaClient(); @@ -236,4 +254,95 @@ describe("a due Coach reminder reaches the conversation the badge points at", () expect(untouched.status).toBe("active"); expect((await readCoachNudgeStatus(user.id)).unread).toBe(false); }); + + it("holds every reminder while the Coach is unavailable, and resumes when it is back", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-held", { coach: false }); + const due = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("held until the Coach is back"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + // One more ignored day would reach the nag cap. + const nagging = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("surfaced before the Coach went away"), + triggerKind: "date", + dueAt: new Date("2026-08-01T09:00:00.000Z"), + status: "surfaced", + lastSurfacedAt: new Date("2026-08-04T05:20:00.000Z"), + surfaceCount: 2, + source: "user", + }, + }); + const context = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("ask about the weigh-in"), + triggerKind: "context", + contextCue: "NEXT_WEIGHT_LOGGED", + status: "active", + source: "user", + }, + }); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 70, + unit: "kg", + measuredAt: YESTERDAY, + }, + }); + + // Three daily ticks: none surfaces, none is charged or dismissed. + for (let day = 0; day < 3; day++) { + const summary = await runCoachReminderSweep( + prisma, + new Date(NOW.getTime() + day * 86_400_000), + ); + expect(summary.remindersDue).toBe(0); + expect(summary.contextSurfaced).toBe(0); + expect(summary.nagDismissed).toBe(0); + } + // The ingest hook holds too. + expect( + (await evaluateCoachContextReminders(prisma, user.id, "measurement", NOW)) + .surfaced, + ).toBe(0); + + expect( + await prisma.coachMessage.count({ + where: { conversation: { userId: user.id } }, + }), + ).toBe(0); + const rows = await prisma.coachReminder.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "asc" }, + }); + const byId = new Map(rows.map((r) => [r.id, r])); + expect(byId.get(due.id)?.status).toBe("active"); + expect(byId.get(nagging.id)?.status).toBe("surfaced"); + expect(byId.get(nagging.id)?.surfaceCount).toBe(2); + expect(byId.get(context.id)?.status).toBe("active"); + + // The Coach comes back: the held reminders surface on the next tick. + await prisma.user.update({ + where: { id: user.id }, + data: { + aiProvider: COACH_READY.aiProvider, + aiAnthropicKeyEncrypted: COACH_READY.aiAnthropicKeyEncrypted, + consentReceipts: { create: COACH_READY.consentReceipts.create }, + }, + }); + const resumed = await runCoachReminderSweep(prisma, NOW); + expect(resumed.remindersDue).toBe(1); + expect(resumed.contextSurfaced).toBe(1); + }); });