Main now gives every project its own main agent. Coaching stays with the user's personal agent, the main bot of the home space: only it gets the goal, habit, check-in, brief and nudge tools, the coach summary and the open-onboarding note, and the tools refuse a project's main bot too. The onboarding questions start with the personal agent only. A project the scan creates gets its main agent like any other, and the inbox lists asks from another space's main conversation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
232 lines
8.2 KiB
TypeScript
232 lines
8.2 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import {
|
|
bootstrapUserSpace,
|
|
createDb,
|
|
createSpaceForMember,
|
|
ensureMainConversation,
|
|
savePushSubscription,
|
|
updateNotificationSettings,
|
|
} from "@rakazo/db";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { notifyUserFromTool } from "./notify-user-tool.js";
|
|
import type { PushPayload, PushSender } from "./push-notifications.js";
|
|
import { PushNotificationProvider } from "./push-notifications.js";
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
const describePostgres =
|
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
|
|
|
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
|
const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) };
|
|
|
|
describePostgres("push delivery and notify_user (PostgreSQL)", () => {
|
|
let prisma: PrismaClient;
|
|
let close: () => Promise<void>;
|
|
const userIds: string[] = [];
|
|
|
|
async function newUser() {
|
|
const id = `nu${randomUUID().replaceAll("-", "")}`;
|
|
userIds.push(id);
|
|
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
|
|
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
|
|
const conversation = await ensureMainConversation(prisma, id);
|
|
const main = await prisma.bot.findUniqueOrThrow({
|
|
where: { id: conversation.leadBotId },
|
|
select: { id: true, name: true, kind: true },
|
|
});
|
|
return { userId: id, conversation, main };
|
|
}
|
|
|
|
function recordingSender(outcome: "sent" | "gone" = "sent") {
|
|
const sent: PushPayload[] = [];
|
|
const sender: PushSender = {
|
|
send: vi.fn(async (_target, payload: PushPayload) => {
|
|
sent.push(payload);
|
|
return outcome;
|
|
}),
|
|
};
|
|
return { sender, sent };
|
|
}
|
|
|
|
function toolContext(user: Awaited<ReturnType<typeof newUser>>, bot = user.main) {
|
|
return {
|
|
userId: user.userId,
|
|
spaceId: user.conversation.spaceId,
|
|
runId: `run-${randomUUID()}`,
|
|
bot,
|
|
signal: new AbortController().signal,
|
|
};
|
|
}
|
|
|
|
beforeAll(() => {
|
|
const db = createDb(databaseUrl!);
|
|
prisma = db.prisma;
|
|
close = async () => {
|
|
await db.prisma.$disconnect();
|
|
await db.pool.end();
|
|
};
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!prisma) return;
|
|
const memberships = await prisma.member.findMany({
|
|
where: { userId: { in: userIds } },
|
|
select: { organizationId: true },
|
|
});
|
|
await prisma.organization.deleteMany({
|
|
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
|
});
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
await close();
|
|
});
|
|
|
|
it("delivers to each device, links the conversation and drops gone devices", async () => {
|
|
const user = await newUser();
|
|
const browser = `https://push.example.test/${randomUUID()}`;
|
|
await savePushSubscription(prisma, user.userId, {
|
|
kind: "webpush",
|
|
endpoint: browser,
|
|
...keys,
|
|
});
|
|
await savePushSubscription(prisma, user.userId, {
|
|
kind: "expo",
|
|
endpoint: `ExponentPushToken[${user.userId}]`,
|
|
});
|
|
const web = recordingSender("sent");
|
|
const expo = recordingSender("gone");
|
|
await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send(
|
|
{
|
|
kind: "completion",
|
|
title: "Chief finished",
|
|
body: "Done",
|
|
botId: user.main.id,
|
|
threadId: user.conversation.threadId,
|
|
},
|
|
{
|
|
operationId: "notify",
|
|
traceId: "notify",
|
|
spaceId: user.conversation.spaceId,
|
|
userId: user.userId,
|
|
signal: new AbortController().signal,
|
|
},
|
|
);
|
|
expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`);
|
|
const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } });
|
|
expect(devices).toHaveLength(1);
|
|
expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) });
|
|
});
|
|
|
|
it("lets only the personal main agent nudge, within the user's settings and the daily cap", async () => {
|
|
const user = await newUser();
|
|
const web = recordingSender();
|
|
const deps = {
|
|
prisma,
|
|
notifications: new PushNotificationProvider(prisma, { webpush: web.sender }),
|
|
};
|
|
|
|
await expect(
|
|
notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), {
|
|
body: "Hi",
|
|
}),
|
|
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
|
|
// A project's main bot leads its project; nudging the user is the personal agent's job.
|
|
const project = await createSpaceForMember(prisma, {
|
|
currentSpaceId: user.conversation.spaceId,
|
|
userId: user.userId,
|
|
name: "Launch",
|
|
});
|
|
const lead = await ensureMainConversation(prisma, user.userId, project.id);
|
|
await expect(
|
|
notifyUserFromTool(
|
|
deps,
|
|
{ ...toolContext(user, { ...user.main, id: lead.leadBotId }), spaceId: project.id },
|
|
{ body: "Hi" },
|
|
),
|
|
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
|
|
await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({
|
|
error: expect.stringContaining("body is required"),
|
|
});
|
|
await expect(
|
|
notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }),
|
|
).resolves.toMatchObject({ sent: false });
|
|
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
|
|
sent: false,
|
|
reason: "The user has no device with notifications turned on.",
|
|
});
|
|
|
|
await savePushSubscription(prisma, user.userId, {
|
|
kind: "webpush",
|
|
endpoint: `https://push.example.test/${randomUUID()}`,
|
|
...keys,
|
|
});
|
|
await updateNotificationSettings(prisma, user.userId, { nudges: false });
|
|
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
|
|
sent: false,
|
|
reason: "The user turned nudges off.",
|
|
});
|
|
|
|
// Quiet hours around the current time.
|
|
await updateNotificationSettings(prisma, user.userId, {
|
|
nudges: true,
|
|
quietHours: quietAroundNow(),
|
|
});
|
|
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
|
|
sent: false,
|
|
reason: "It is the user's quiet hours.",
|
|
});
|
|
expect(web.sent).toHaveLength(0);
|
|
|
|
await updateNotificationSettings(prisma, user.userId, { quietHours: null });
|
|
for (let index = 0; index < 3; index += 1) {
|
|
await expect(
|
|
notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }),
|
|
).resolves.toEqual({ sent: true });
|
|
}
|
|
await expect(
|
|
notifyUserFromTool(deps, toolContext(user), { body: "One too many" }),
|
|
).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." });
|
|
|
|
expect(web.sent).toHaveLength(3);
|
|
expect(web.sent[0]).toMatchObject({
|
|
kind: "nudge",
|
|
title: user.main.name,
|
|
body: "Check-in 0",
|
|
threadId: user.conversation.threadId,
|
|
url: `/app/g/${user.conversation.groupId}`,
|
|
});
|
|
});
|
|
|
|
it("reports a nudge no device accepted", async () => {
|
|
const user = await newUser();
|
|
await savePushSubscription(prisma, user.userId, {
|
|
kind: "webpush",
|
|
endpoint: `https://push.example.test/${randomUUID()}`,
|
|
...keys,
|
|
});
|
|
const failing: PushSender = {
|
|
send: async () => {
|
|
throw new Error("Web push failed (500)");
|
|
},
|
|
};
|
|
await expect(
|
|
notifyUserFromTool(
|
|
{ prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) },
|
|
toolContext(user),
|
|
{ body: "Hi", title: "Evening check-in" },
|
|
),
|
|
).resolves.toEqual({ sent: false, reason: "No device accepted the notification." });
|
|
});
|
|
});
|
|
|
|
/** A UTC range that covers the current minute (users without a time zone use UTC). */
|
|
function quietAroundNow() {
|
|
const now = new Date();
|
|
const minute = now.getUTCHours() * 60 + now.getUTCMinutes();
|
|
const clock = (value: number) => {
|
|
const wrapped = (value + 1440) % 1440;
|
|
return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`;
|
|
};
|
|
return { start: clock(minute - 60), end: clock(minute + 60) };
|
|
}
|