Evaluate the context cues the reminder grammar has promised since v1.22

A Coach reminder captured with NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED,
NEXT_SLEEP_LOGGED or NEXT_APP_OPEN confirmed to the user and then never
fired: the daily sweep skipped context triggers and no other evaluator
existed.

Build the evaluator. The three logging cues resolve on evidence — a
live matching-type measurement logged after capture — and ride the same
eventful reminder-satisfy hook every ingest path already fires, with the
daily sweep re-running them as the idempotent backstop. NEXT_APP_OPEN
resolves on the nudge-status read (the poll that mounts with the app
chrome), owner-only so a delegate's visit never satisfies it, with a
capture grace so the session that created the reminder does not count as
the next open. Every trigger delivers through the surfacing transaction
extracted from the sweep: conversation + assistant message + status
flip, all-or-nothing.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 01:45:47 +02:00
1 parent 0cec8b07bd
commit cd127010fe
11 files changed
+764 -91

No files matched your search

@@ -0,0 +1,94 @@
/**
* GET /api/insights/coach/nudge-status — the NEXT_APP_OPEN evaluation
* hook. This poll mounts with the app chrome, which makes it the
* app-open signal, so the route runs the context evaluation before it
* computes the unread flag (owner only — a delegate opening the record
* is not the owner opening their app).
*
* Watched red: with the `evaluateCoachContextReminders` call removed
* from the route the first test fails on the hook assertion — the
* pre-fix state where NEXT_APP_OPEN reminders could never fire.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({
requireRecordAuth: vi.fn(),
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireRecordAuth,
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/nudge-status", () => ({
readCoachNudgeStatus: readStatus,
}));
vi.mock("@/lib/ai/coach/context-reminders", () => ({
evaluateCoachContextReminders: evaluateContext,
}));
vi.mock("@/lib/db", () => ({ prisma: { __tag: "prisma" } }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
import { GET } from "../route";
beforeEach(() => {
vi.clearAllMocks();
readStatus.mockResolvedValue({ unread: false, nudgedAt: null });
});
describe("nudge-status — NEXT_APP_OPEN hook", () => {
it("runs the app-open evaluation for the owner BEFORE computing the unread flag", async () => {
requireRecordAuth.mockResolvedValue({
user: { id: "u1" },
actor: { id: "u1" },
});
await (GET as unknown as () => Promise<unknown>)();
expect(evaluateContext).toHaveBeenCalledTimes(1);
const [prismaArg, userId, trigger] = evaluateContext.mock.calls[0]!;
expect((prismaArg as { __tag: string }).__tag).toBe("prisma");
expect(userId).toBe("u1");
expect(trigger).toBe("app-open");
// Ordering: the surfaced message must be visible to this very read.
expect(evaluateContext.mock.invocationCallOrder[0]).toBeLessThan(
readStatus.mock.invocationCallOrder[0]!,
);
});
it("never satisfies the cue for a switched caller", async () => {
requireRecordAuth.mockResolvedValue({
user: { id: "owner" },
actor: { id: "delegate" },
});
await (GET as unknown as () => Promise<unknown>)();
expect(evaluateContext).not.toHaveBeenCalled();
expect(readStatus).toHaveBeenCalledWith("owner");
});
it("keeps the badge alive when the evaluator fails", async () => {
requireRecordAuth.mockResolvedValue({
user: { id: "u1" },
actor: { id: "u1" },
});
evaluateContext.mockRejectedValueOnce(new Error("evaluator down"));
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
error: null;
};
expect(res.error).toBeNull();
expect(readStatus).toHaveBeenCalledWith("u1");
} finally {
warn.mockRestore();
}
});
});
@@ -26,6 +26,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
import { prisma } from "@/lib/db";
export const GET = apiHandler(async () => {
// The RECORD's unread signal, not the caller's. The tempting reading is that
@@ -43,11 +45,26 @@ export const GET = apiHandler(async () => {
// switch and that is not in tension with this: chat spends the owner's AI
// budget and writes into their conversation, and reading whether the thread
// has something unopened does neither.
const { user } = await requireRecordAuth("read", "record");
const { user, actor } = await requireRecordAuth("read", "record");
// Operator-level flag — an `AppSettings` singleton, unaffected by whose
// record is open.
await requireAssistantSurface("coach");
// NEXT_APP_OPEN Coach reminders resolve here: this poll mounts with the
// app chrome, which makes it the app-open signal itself. Owner-only — a
// delegate opening the record is not the owner opening their app, so a
// switched caller never satisfies the cue. Awaited so the reminder's
// message is already in the thread when this very response computes the
// unread flag; fault-isolated so an evaluator hiccup can never break the
// badge. Steady-state cost is one indexed query returning zero rows.
if (actor.id === user.id) {
try {
await evaluateCoachContextReminders(prisma, user.id, "app-open");
} catch (err) {
console.warn("[coach] app-open context evaluation failed", err);
}
}
// Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both
// readers compute the unread signal identically.
return apiSuccess(await readCoachNudgeStatus(user.id));
@@ -0,0 +1,195 @@
/**
* Context-cue evaluation for Coach reminders — the evaluator the capture
* grammar promised since v1.22 and never had.
*
* Watched red: with `evaluateCoachContextReminders` gutted to return
* `{ surfaced: 0, errored: 0 }` unconditionally (the pre-fix reality —
* captured, confirmed, never fired), every surfacing test below fails
* naming the missing conversation write. Verified red against that stub.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: (buf: Uint8Array) => Buffer.from(buf).toString("utf8"),
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
}));
import {
APP_OPEN_GRACE_MS,
evaluateCoachContextReminders,
} from "../context-reminders";
const NOW = new Date("2026-06-27T12:00:00.000Z");
function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
function minutesBefore(now: Date, minutes: number): Date {
return new Date(now.getTime() - minutes * 60 * 1000);
}
function makePrisma(options: {
reminders?: {
id: string;
noteEncrypted: Uint8Array;
contextCue: string;
createdAt: Date;
}[];
/** Measurement evidence per findFirst call: null = none. */
evidence?: { id: string } | null;
}) {
const tx = {
coachConversation: { create: vi.fn(async () => ({ id: "c1" })) },
coachMessage: { create: vi.fn(async () => ({ id: "m1" })) },
coachReminder: { updateMany: vi.fn(async () => ({ count: 1 })) },
};
const prisma = {
coachReminder: {
findMany: vi.fn(async () => options.reminders ?? []),
},
measurement: {
findFirst: vi.fn(async () => options.evidence ?? null),
},
user: { findUnique: vi.fn(async () => ({ locale: "en" })) },
$transaction: vi.fn(async (fn: (t: typeof tx) => Promise<unknown>) =>
fn(tx),
),
tx,
};
return prisma;
}
describe("evaluateCoachContextReminders — measurement cues", () => {
it("surfaces a NEXT_WEIGHT_LOGGED reminder when a matching row was logged after capture", async () => {
const createdAt = minutesBefore(NOW, 60);
const prisma = makePrisma({
reminders: [
{
id: "r1",
noteEncrypted: bytes("check in on the diet"),
contextCue: "NEXT_WEIGHT_LOGGED",
createdAt,
},
],
evidence: { id: "m-weight" },
});
const outcome = await evaluateCoachContextReminders(
prisma as never,
"u1",
"measurement",
NOW,
);
expect(outcome.surfaced).toBe(1);
// Evidence query: matching types, live rows only, logged AFTER capture
// (row createdAt, not measuredAt — back-dating still counts as logging).
expect(prisma.measurement.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "u1",
type: { in: ["WEIGHT"] },
deletedAt: null,
createdAt: { gt: createdAt },
}),
}),
);
// Delivered through the shared surfacing transaction: message + flip.
expect(prisma.tx.coachMessage.create).toHaveBeenCalledTimes(1);
expect(prisma.tx.coachReminder.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["r1"] } },
data: expect.objectContaining({ status: "surfaced" }),
}),
);
});
it("leaves the reminder untouched when no matching measurement exists", async () => {
const prisma = makePrisma({
reminders: [
{
id: "r1",
noteEncrypted: bytes("check in on the diet"),
contextCue: "NEXT_BP_LOGGED",
createdAt: minutesBefore(NOW, 60),
},
],
evidence: null,
});
const outcome = await evaluateCoachContextReminders(
prisma as never,
"u1",
"measurement",
NOW,
);
expect(outcome.surfaced).toBe(0);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("never evaluates NEXT_APP_OPEN on the measurement trigger", async () => {
const prisma = makePrisma({ reminders: [] });
await evaluateCoachContextReminders(
prisma as never,
"u1",
"measurement",
NOW,
);
const where = prisma.coachReminder.findMany.mock.calls[0]![0]!.where as {
contextCue: { in: string[] };
};
expect(where.contextCue.in).not.toContain("NEXT_APP_OPEN");
});
});
describe("evaluateCoachContextReminders — app open", () => {
it("surfaces a NEXT_APP_OPEN reminder once the capture grace has passed", async () => {
const prisma = makePrisma({
reminders: [
{
id: "r2",
noteEncrypted: bytes("look at the sleep chart"),
contextCue: "NEXT_APP_OPEN",
createdAt: new Date(NOW.getTime() - APP_OPEN_GRACE_MS),
},
],
});
const outcome = await evaluateCoachContextReminders(
prisma as never,
"u1",
"app-open",
NOW,
);
expect(outcome.surfaced).toBe(1);
// The app-open leg needs no measurement evidence.
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
expect(prisma.tx.coachMessage.create).toHaveBeenCalledTimes(1);
});
it("stays quiet inside the capture grace — the session that created it is not the next open", async () => {
const prisma = makePrisma({
reminders: [
{
id: "r2",
noteEncrypted: bytes("look at the sleep chart"),
contextCue: "NEXT_APP_OPEN",
createdAt: minutesBefore(NOW, 1),
},
],
});
const outcome = await evaluateCoachContextReminders(
prisma as never,
"u1",
"app-open",
NOW,
);
expect(outcome.surfaced).toBe(0);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* Context-cue evaluation for Coach reminders (`triggerKind: "context"`).
*
* The capture grammar has advertised four cues since v1.22 —
* NEXT_BP_LOGGED / NEXT_WEIGHT_LOGGED / NEXT_SLEEP_LOGGED /
* NEXT_APP_OPEN — and the resolver persisted them, but no evaluator
* existed: the user got a confirmation for a reminder that could never
* fire. This module is that evaluator.
*
* Two triggers, both converging on the same surfacing transaction the
* date sweep uses (`reminder-surface.ts`):
*
* - `"measurement"` — the three logging cues. Called by the
* `reminder-satisfy` worker right after any measurement ingest (the
* same eventful hook that auto-resolves Vorsorge reminders), and once
* a day by the Coach-reminder sweep as the idempotent backstop. A cue
* is satisfied by EVIDENCE, not by the caller's claim: a live row of
* a matching type must exist that was LOGGED (row `createdAt`, not
* `measuredAt` — back-dating an old paper reading still counts as
* logging) after the reminder was captured.
*
* - `"app-open"` — NEXT_APP_OPEN. Evaluated on the nudge-status read,
* the cheapest request that fires exactly when the app chrome mounts.
* A capture-time grace keeps the reminder from firing into the very
* session that created it: "next time you open the app" must not
* mean "in the message list you are already looking at". After
* surfacing the row leaves the candidate set (`status: "surfaced"`),
* so the steady-state cost per read is one indexed query returning
* zero rows.
*
* Server-only — reads `@/lib/db`-shaped clients (injected).
*/
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import type { CoachReminderContextCue } from "@/lib/ai/coach/reminders";
import {
surfaceCoachReminders,
type SurfaceOutcome,
} from "@/lib/ai/coach/reminder-surface";
/** Which measurement types count as evidence for each logging cue. */
export const CONTEXT_CUE_MEASUREMENT_TYPES: Partial<
Record<CoachReminderContextCue, readonly MeasurementType[]>
> = {
NEXT_BP_LOGGED: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
NEXT_WEIGHT_LOGGED: ["WEIGHT"],
NEXT_SLEEP_LOGGED: ["SLEEP_DURATION"],
};
/**
* How long after capture a NEXT_APP_OPEN reminder stays quiet. The
* capture happens inside a live Coach chat — the user is demonstrably in
* the app — so an ungated evaluation would surface the reminder on the
* very next status poll of the same session.
*/
export const APP_OPEN_GRACE_MS = 15 * 60 * 1000;
export type ContextTrigger = "measurement" | "app-open";
type ContextPrisma = Pick<
PrismaClient,
| "coachReminder"
| "coachConversation"
| "coachMessage"
| "measurement"
| "user"
| "$transaction"
>;
/** Bound one evaluation pass (a user rarely has >50 reminders — the capture cap). */
const CANDIDATE_BATCH = 100;
/**
* Evaluate one user's context reminders for one trigger and surface the
* satisfied ones through the shared delivery transaction. Fault-isolated
* end to end — a failed evaluation must never fail the ingest or the
* status read that hosts it.
*/
export async function evaluateCoachContextReminders(
prisma: ContextPrisma,
userId: string,
trigger: ContextTrigger,
now: Date = new Date(),
): Promise<SurfaceOutcome> {
const cues: CoachReminderContextCue[] =
trigger === "app-open"
? ["NEXT_APP_OPEN"]
: (Object.keys(
CONTEXT_CUE_MEASUREMENT_TYPES,
) as CoachReminderContextCue[]);
// `due` rides along with `active` for the same reason as the date sweep:
// a row a partial write left mid-flight must resolve, not stick.
const candidates = await prisma.coachReminder.findMany({
where: {
userId,
deletedAt: null,
status: { in: ["active", "due"] },
triggerKind: "context",
contextCue: { in: cues },
},
take: CANDIDATE_BATCH,
orderBy: { createdAt: "asc" },
select: {
id: true,
noteEncrypted: true,
contextCue: true,
createdAt: true,
},
});
if (candidates.length === 0) return { surfaced: 0, errored: 0 };
const satisfied: { id: string; noteEncrypted: Uint8Array }[] = [];
let errored = 0;
for (const reminder of candidates) {
try {
if (reminder.contextCue === "NEXT_APP_OPEN") {
if (now.getTime() - reminder.createdAt.getTime() >= APP_OPEN_GRACE_MS) {
satisfied.push(reminder);
}
continue;
}
const types =
CONTEXT_CUE_MEASUREMENT_TYPES[
reminder.contextCue as CoachReminderContextCue
];
if (!types) continue; // unknown cue — never guess
// Evidence check: a live matching-type row logged after capture.
const evidence = await prisma.measurement.findFirst({
where: {
userId,
type: { in: [...types] },
deletedAt: null,
createdAt: { gt: reminder.createdAt },
},
select: { id: true },
});
if (evidence) satisfied.push(reminder);
} catch {
errored += 1;
}
}
if (satisfied.length === 0) return { surfaced: 0, errored };
const outcome = await surfaceCoachReminders(prisma, userId, satisfied, now);
return { surfaced: outcome.surfaced, errored: errored + outcome.errored };
}
+136
View File
@@ -0,0 +1,136 @@
/**
* The ONE surfacing step every Coach-reminder trigger converges on.
*
* Extracted from the daily sweep so the context-cue evaluators
* (`context-reminders.ts`) deliver through the exact same transaction a
* date reminder does: conversation + assistant message + status flip,
* all-or-nothing. A partial write is the failure this shape exists to
* remove — a status without a message is a badge pointing at nothing,
* and a message without a status change re-surfaces the same note on
* every following trigger.
*
* Deterministic and provider-free: the message body is a localized
* template around the user's OWN note text — the words they asked to be
* reminded of, never anything generated.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
/** How many notes one surfacing message carries before the rest wait. */
export const MAX_NOTES_PER_MESSAGE = 6;
export type ReminderSurfacePrisma = Pick<
PrismaClient,
| "coachReminder"
| "coachConversation"
| "coachMessage"
| "user"
| "$transaction"
>;
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* The surfacing message for one user's due reminders. Deterministic,
* localized, and built only from the user's own note text.
*/
export function buildReminderSurfaceMessage(
notes: readonly string[],
locale: string | null | undefined,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const title = t("coachNudges.reminderSurfacedTitle");
const lead =
notes.length === 1
? t("coachNudges.reminderSurfacedLeadOne")
: t("coachNudges.reminderSurfacedLeadMany", { count: notes.length });
const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n");
return { title, body };
}
export interface SurfaceOutcome {
/** Reminders that reached the conversation in this call. */
surfaced: number;
/** Undecryptable notes / failed writes (fault-isolated). */
errored: number;
}
/**
* Surface a batch of one user's reminders into their Coach thread.
*
* Decrypt is fault-isolated per row — one undecryptable note must not
* 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.
*/
export async function surfaceCoachReminders(
prisma: ReminderSurfacePrisma,
userId: string,
rows: readonly { id: string; noteEncrypted: Uint8Array }[],
now: Date,
): Promise<SurfaceOutcome> {
const outcome: SurfaceOutcome = { surfaced: 0, errored: 0 };
if (rows.length === 0) return outcome;
const usable: { id: string; note: string }[] = [];
for (const row of rows) {
try {
usable.push({ id: row.id, note: decryptFromBytes(row.noteEncrypted) });
} catch {
outcome.errored += 1;
}
}
if (usable.length === 0) return outcome;
try {
const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const { title, body } = buildReminderSurfaceMessage(
batch.map((r) => r.note),
user?.locale,
);
await prisma.$transaction(async (tx) => {
const conversation = await tx.coachConversation.create({
data: { userId, title: title.slice(0, 80) },
});
await tx.coachMessage.create({
data: {
conversationId: conversation.id,
role: "assistant",
encryptedContent: encryptToBytes(body),
metricSourceJson: null,
// Same tag the proactive nudge uses — this IS a proactive message,
// and the nudge cron's frequency gate reads the tag back.
providerType: "nudge",
promptVersion: null,
},
});
await tx.coachReminder.updateMany({
where: { id: { in: batch.map((r) => r.id) } },
data: {
status: "surfaced",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
});
outcome.surfaced += batch.length;
} catch {
outcome.errored += 1;
}
return outcome;
}
@@ -19,6 +19,14 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
}));
// 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.fn(async () => ({ surfaced: 0, errored: 0 }));
vi.mock("@/lib/ai/coach/context-reminders", () => ({
evaluateCoachContextReminders: (...args: unknown[]) =>
contextEvaluateMock(...args),
}));
import { runCoachReminderSweep } from "../coach-reminder-sweep";
const NOW = new Date("2026-06-27T05:20:00.000Z");
@@ -188,6 +196,43 @@ describe("runCoachReminderSweep", () => {
expect(flip[0].where.id.in).toEqual(["r-ok"]);
});
// Watched red: with the context-backstop pass removed from the sweep this
// fails on the evaluator assertion — the pre-fix sweep skipped context
// reminders entirely ("follow-on (F4)").
it("re-evaluates the measurement context cues as the daily backstop", async () => {
const prisma = makePrisma({});
contextEvaluateMock.mockClear();
contextEvaluateMock.mockResolvedValue({ surfaced: 1, errored: 0 });
prisma.coachReminder.findMany
.mockResolvedValueOnce([]) // step 2: overdue date reminders
.mockResolvedValueOnce([
{ userId: "u1" },
{ userId: "u1" },
{ userId: "u2" },
] as never); // step 3: pending context rows
const summary = await runCoachReminderSweep(prisma as never, NOW);
// One evaluation per distinct user, measurement trigger only.
expect(contextEvaluateMock).toHaveBeenCalledTimes(2);
expect(contextEvaluateMock).toHaveBeenCalledWith(
prisma,
"u1",
"measurement",
NOW,
);
expect(summary.contextSurfaced).toBe(2);
// NEXT_APP_OPEN is deliberately outside the backstop: only a real
// app-open signal may satisfy it.
const where = (
prisma.coachReminder.findMany.mock.calls[1] as unknown as [
{ where: { contextCue: { not: string } } },
]
)[0].where;
expect(where.contextCue).toEqual({ not: "NEXT_APP_OPEN" });
});
it("skips an undecryptable plan without sinking the tick", async () => {
const prisma = makePrisma({
plans: [
@@ -16,6 +16,17 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => bossInstance,
}));
// The Coach context-cue evaluation rides this hook; its behaviour is pinned
// in `src/lib/ai/coach/__tests__/context-reminders.test.ts`. Stubbed here so
// the Vorsorge assertions stay isolated; one test below pins that the hook
// actually fires (watched red: removing the call site in
// `runReminderSatisfyForUser` fails it).
const evaluateContextMock = vi.fn(async () => ({ surfaced: 0, errored: 0 }));
vi.mock("@/lib/ai/coach/context-reminders", () => ({
evaluateCoachContextReminders: (...args: unknown[]) =>
evaluateContextMock(...args),
}));
import {
enqueueReminderSatisfy,
REMINDER_SATISFY_QUEUE,
@@ -228,6 +239,43 @@ describe("runReminderSatisfyForUser", () => {
expect(updates).toHaveLength(0);
});
it("evaluates the Coach context cues on the same ingest event", async () => {
const now = new Date("2026-06-14T20:00:00Z");
const { prisma } = makePrisma({ reminders: [] });
evaluateContextMock.mockClear();
evaluateContextMock.mockResolvedValueOnce({ surfaced: 2, errored: 0 });
const summary = await runReminderSatisfyForUser(prisma as never, "u1", now);
expect(evaluateContextMock).toHaveBeenCalledWith(
prisma,
"u1",
"measurement",
now,
);
expect(summary.coachContextSurfaced).toBe(2);
});
it("never fails the Vorsorge resolution over a Coach context error", async () => {
const measuredAt = new Date("2026-06-14T18:00:00Z");
const { prisma } = makePrisma({
reminders: [reminderRow()],
measurement: { measuredAt },
});
evaluateContextMock.mockClear();
evaluateContextMock.mockRejectedValueOnce(new Error("coach down"));
const summary = await runReminderSatisfyForUser(
prisma as never,
"u1",
new Date("2026-06-14T20:00:00Z"),
{ isModuleEnabled: vi.fn(async () => true) },
);
expect(summary.satisfied).toBe(1);
expect(summary.failed).toBe(1);
});
it("is forward-only: a stale event is a no-op (cron + hook converge)", async () => {
const { prisma, updates } = makePrisma({
reminders: [
+51 -89
View File
@@ -38,16 +38,19 @@
* a deterministic localized template around the user's OWN note text — the
* words they asked to be reminded of, never anything fabricated.
*
* Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that
* is a follow-on (F4); the column exists and capture supports it, but the date
* sweep is the high-value slice.
* Context-cue reminders (`triggerKind: "context"`) are evaluated eventfully:
* the measurement cues by the `reminder-satisfy` worker after every ingest,
* `NEXT_APP_OPEN` on the nudge-status read (`ai/coach/context-reminders.ts`).
* This sweep re-runs the measurement-cue evaluation once a day as the
* idempotent backstop for any ingest path that never enqueued — the same
* cron-behind-event pattern the Vorsorge engine uses. Every trigger
* converges on the ONE surfacing transaction in `ai/coach/reminder-surface.ts`.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
import { 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
@@ -58,13 +61,15 @@ export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *";
const PLAN_REVIEW_BATCH = 200;
/** Bound the surfacing fan-out per tick for the same reason. */
const SURFACE_BATCH = 500;
/** How many notes one surfacing message carries before the rest wait a tick. */
const MAX_NOTES_PER_MESSAGE = 6;
/** Bound the context-backstop fan-out per tick for the same reason. */
const CONTEXT_BACKSTOP_BATCH = 500;
export interface CoachReminderSweepSummary {
/** Reminders whose moment arrived AND that reached the conversation. */
remindersDue: number;
planReviewsMinted: number;
/** Context-cue reminders the daily backstop surfaced. */
contextSurfaced: number;
errored: number;
}
@@ -74,35 +79,11 @@ type SweepPrisma = Pick<
| "coachPlan"
| "coachConversation"
| "coachMessage"
| "measurement"
| "user"
| "$transaction"
>;
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* The surfacing message for one user's due reminders. Deterministic, localized,
* and built only from the user's own note text — the sweep composes, it never
* generates.
*/
export function buildReminderSurfaceMessage(
notes: readonly string[],
locale: string | null | undefined,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const title = t("coachNudges.reminderSurfacedTitle");
const lead =
notes.length === 1
? t("coachNudges.reminderSurfacedLeadOne")
: t("coachNudges.reminderSurfacedLeadMany", { count: notes.length });
const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n");
return { title, body };
}
/**
* Run one sweep tick. Idempotent: a reminder already `surfaced` is not
* re-surfaced, and a plan whose `reviewDate` was cleared on a prior tick is not
@@ -115,6 +96,7 @@ export async function runCoachReminderSweep(
const summary: CoachReminderSweepSummary = {
remindersDue: 0,
planReviewsMinted: 0,
contextSurfaced: 0,
errored: 0,
};
@@ -207,65 +189,45 @@ export async function runCoachReminderSweep(
}
for (const [userId, rows] of byUser) {
// The shared surfacing step: decrypt fault-isolated per row, one
// localized message per user, conversation + message + status flip in
// ONE transaction. On failure the rows stay where they were — the next
// tick retries, and until then no badge claims a message that was
// never written.
const outcome = await surfaceCoachReminders(prisma, userId, rows, now);
summary.remindersDue += outcome.surfaced;
summary.errored += outcome.errored;
}
// ── 3. context-cue backstop ───────────────────────────────────
//
// The measurement cues fire eventfully from the `reminder-satisfy`
// worker; this daily pass re-evaluates them so an ingest path that
// never enqueued (boss unavailable, direct DB write) still resolves
// within a day. NEXT_APP_OPEN is deliberately absent: only a real
// app-open signal may satisfy it.
const contextRows = await prisma.coachReminder.findMany({
where: {
deletedAt: null,
status: { in: ["active", "due"] },
triggerKind: "context",
contextCue: { not: "NEXT_APP_OPEN" },
},
take: CONTEXT_BACKSTOP_BATCH,
orderBy: { createdAt: "asc" },
select: { userId: true },
});
for (const userId of new Set(contextRows.map((r) => r.userId))) {
try {
// Decrypt fault-isolated per row: one undecryptable note must not cost
// the user the reminders that sit beside it.
const usable: { id: string; note: string }[] = [];
for (const row of rows) {
try {
usable.push({
id: row.id,
note: decryptFromBytes(row.noteEncrypted),
});
} catch {
summary.errored += 1;
}
}
if (usable.length === 0) continue;
const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const { title, body } = buildReminderSurfaceMessage(
batch.map((r) => r.note),
user?.locale,
const outcome = await evaluateCoachContextReminders(
prisma,
userId,
"measurement",
now,
);
// Conversation + message + status flip in ONE transaction. A partial
// write is the failure this whole change exists to remove: a status
// without a message is a badge pointing at nothing, and a message
// without a status change re-surfaces the same note every night.
await prisma.$transaction(async (tx) => {
const conversation = await tx.coachConversation.create({
data: { userId, title: title.slice(0, 80) },
});
await tx.coachMessage.create({
data: {
conversationId: conversation.id,
role: "assistant",
encryptedContent: encryptToBytes(body),
metricSourceJson: null,
// Same tag the proactive nudge uses — this IS a proactive message,
// and the nudge cron's frequency gate reads the tag back.
providerType: "nudge",
promptVersion: null,
},
});
await tx.coachReminder.updateMany({
where: { id: { in: batch.map((r) => r.id) } },
data: {
status: "surfaced",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
});
summary.remindersDue += batch.length;
summary.contextSurfaced += outcome.surfaced;
summary.errored += outcome.errored;
} catch {
// Leave the rows where they were: the next tick retries, and until then
// no badge claims a message that was never written.
summary.errored += 1;
}
}
+1
View File
@@ -54,6 +54,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
"connect_tickets_deleted",
"connections_deleted",
"considered",
"context_surfaced",
"created",
"daily_rows_retired",
"days_consolidated",
+25 -1
View File
@@ -43,6 +43,7 @@ import {
} from "@/lib/measurement-reminders/scheduling";
import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
/**
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
@@ -387,6 +388,8 @@ export interface ReminderSatisfySummary {
satisfied: number;
skippedModuleDisabled: number;
skippedNoEvent: number;
/** Coach context-cue reminders the same ingest event surfaced. */
coachContextSurfaced: number;
failed: number;
}
@@ -413,7 +416,6 @@ export async function runReminderSatisfyForUser(
now: Date,
options: { isModuleEnabled?: typeof isModuleEnabled } = {},
): Promise<ReminderSatisfySummary> {
void now;
const moduleGate = options.isModuleEnabled ?? isModuleEnabled;
const summary: ReminderSatisfySummary = {
@@ -421,6 +423,7 @@ export async function runReminderSatisfyForUser(
satisfied: 0,
skippedModuleDisabled: 0,
skippedNoEvent: 0,
coachContextSurfaced: 0,
failed: 0,
};
@@ -478,6 +481,27 @@ export async function runReminderSatisfyForUser(
}
}
// Coach context-cue reminders ("next time you log X") ride the same
// eventful hook: the measurement that satisfies a Vorsorge cadence is
// the same evidence a NEXT_*_LOGGED cue waits for. Fault-isolated — a
// context failure never costs the Vorsorge resolution above.
try {
const outcome = await evaluateCoachContextReminders(
prisma,
userId,
"measurement",
now,
);
summary.coachContextSurfaced = outcome.surfaced;
summary.failed += outcome.errored;
} catch (err: unknown) {
summary.failed += 1;
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(
`reminder-satisfy coach context evaluation failed for ${userId}: ${message}`,
);
}
return summary;
}
+2
View File
@@ -844,12 +844,14 @@ export async function registerStatusQueues(
result: {
reminders_due: summary.remindersDue,
plan_reviews_minted: summary.planReviewsMinted,
context_surfaced: summary.contextSurfaced,
errored: summary.errored,
},
});
return jobDone({
reminders_due: summary.remindersDue,
plan_reviews_minted: summary.planReviewsMinted,
context_surfaced: summary.contextSurfaced,
errored: summary.errored,
});
} catch (err) {