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));