From bd58571d460273a109270c4423412b0e5787b3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:11:21 +0200 Subject: [PATCH] fix(coach): hold reminders while the Coach is unavailable The daily sweep and the measurement-ingest hook surfaced due reminders into a Coach conversation whatever the record's coach capability said, and the nag cap then dismissed them after three ticks although no badge ever showed. The app-open path already waited on the capability. surfaceCoachReminders now holds every row while the coach capability is unavailable (the same answer that keeps the badge dark), and the nag cap neither charges nor dismisses a held user's surfaced rows. They surface on the first tick after the Coach is back. The integration fixture gives its users a provider and consent, and a held case covers the sweep, the nag cap and the ingest hook. --- .../coach/__tests__/context-reminders.test.ts | 11 ++ src/lib/ai/coach/reminder-surface.ts | 17 ++- .../__tests__/coach-reminder-sweep.test.ts | 11 ++ src/lib/jobs/coach-reminder-sweep.ts | 25 +++- .../coach-reminder-surfacing.test.ts | 113 +++++++++++++++++- 5 files changed, 173 insertions(+), 4 deletions(-) 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); + }); });