evOo-local/packages/adapters/src/messaging-delivery.test.ts
Cursor Agent 2ee72af28b fix(messaging): set introPostedAt only after intro delivery succeeds
Group intros were stamped as posted before the outbox drain ran. When
delivery enqueue failed or the intro row exhausted retries, the channel
stayed marked and no retry path existed — unlinked participants never saw
the onboarding message.

Stamp introPostedAt in the drain path after a successful provider send,
matching the messagingMirroredAt pattern from #42.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 07:06:21 +00:00

1011 lines
33 KiB
TypeScript

import type { AdapterContext, MessagingSurface } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import {
applyMessagingOutboundStatus,
deliverMessagingOutbound,
MESSAGING_DM_OUTBOUND_CAP,
MESSAGING_OUTBOUND_MAX_ATTEMPTS,
type MessagingDeliveryDeps,
} from "./messaging-delivery.js";
const context: AdapterContext = {
operationId: "op-1",
traceId: "trace-1",
spaceId: "ws-1",
userId: "user-1",
signal: new AbortController().signal,
};
const identity = {
id: "mi-1",
provider: "sendblue",
address: "+15551234567",
dmThreadId: "sendblue:dm-1",
userId: "user-1",
spaceId: "ws-1",
botId: "bot-1",
outboundSinceInbound: 0,
};
const messagingRun = {
id: "run-1",
botId: "bot-1",
trigger: "messaging",
sourceMessage: { blocks: [{ kind: "text", text: "hi" }] },
};
function createFakeSurface(sendError?: Error) {
const sendToThread = vi.fn(async () => ({ handle: "handle-out-1" }));
if (sendError) sendToThread.mockRejectedValue(sendError);
const openDirectThread = vi.fn(
async (_provider: string, address: string) => `sendblue:opened:${address}`,
);
const messaging: MessagingSurface = {
describe: () => ({
id: "chat-sdk",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { providers: ["sendblue"] },
}),
platforms: () => [
{ provider: "sendblue", capabilities: { direct: true, groups: true, typing: true } },
],
handleWebhook: () => null,
onInbound: () => undefined,
sendToThread,
openDirectThread,
sendTyping: vi.fn(async () => undefined),
};
return { messaging, sendToThread, openDirectThread };
}
function createDeps(overrides: {
run?: unknown;
identity?: Record<string, unknown> | null;
messages?: unknown[];
outboundRows?: unknown[];
existingOutbox?: unknown;
sendError?: Error;
connectionStatus?: string | null;
}) {
const rows = [...(overrides.outboundRows ?? [])] as Array<Record<string, unknown>>;
const { messaging, sendToThread, openDirectThread } = createFakeSurface(overrides.sendError);
// Stateful identity row so a cached dmThreadId is visible to later reads.
const identityRow =
overrides.identity === null ? null : { ...identity, ...(overrides.identity ?? {}) };
const prisma = {
run: {
findUnique: vi.fn(async () => overrides.run ?? messagingRun),
updateMany: vi.fn(async () => ({ count: 1 })),
},
message: {
findMany: vi.fn(
async () =>
overrides.messages ?? [
{ id: "m-1", blocks: [{ kind: "text", text: "Hello from your bot" }] },
],
),
},
messagingIdentity: {
findUnique: vi.fn(async () => identityRow),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
if (identityRow && typeof data.dmThreadId === "string") {
identityRow.dmThreadId = data.dmThreadId;
}
return identityRow;
}),
},
agentConnection: {
findUnique: vi.fn(async () =>
overrides.connectionStatus === null
? null
: overrides.connectionStatus
? { status: overrides.connectionStatus }
: { status: "pending" },
),
},
$queryRaw: vi.fn(async () =>
overrides.connectionStatus === null
? []
: [{ status: overrides.connectionStatus ?? "pending" }],
),
messagingChannel: {
updateMany: vi.fn(async () => ({ count: 1 })),
},
messagingOutbound: {
findUnique: vi.fn(async ({ where }: { where?: { id?: string } } = {}) => {
if (overrides.existingOutbox) return overrides.existingOutbox;
if (where?.id) return rows.find((row) => row.id === where.id) ?? null;
return null;
}),
findMany: vi.fn(async ({ where }: { where?: { status?: string; OR?: unknown[] } } = {}) => {
const now = Date.now();
return rows.filter((row) => {
if (where?.status && row.status !== where.status) return false;
if (!where?.OR) return row.status === "pending";
const next = row.nextAttemptAt;
if (next == null) return true;
const nextMs = next instanceof Date ? next.getTime() : new Date(String(next)).getTime();
return nextMs <= now;
});
}),
createMany: vi.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
let count = 0;
for (const item of data) {
// Simulates skipDuplicates against the idempotencyKey unique key.
const duplicate =
rows.some((row) => row.idempotencyKey === item.idempotencyKey) ||
(overrides.existingOutbox as { idempotencyKey?: string } | null)?.idempotencyKey ===
item.idempotencyKey;
if (duplicate) continue;
rows.push({ id: `out-${rows.length + 1}`, status: "pending", ...item });
count += 1;
}
return { count };
}),
update: vi.fn(async ({ where, data }: { where: { id: string }; data: unknown }) => {
const row = rows.find((candidate) => candidate.id === where.id);
if (row) Object.assign(row, data);
return row;
}),
updateMany: vi.fn(
async ({
where,
data,
}: {
where: { id?: string; providerHandle?: string; status?: string };
data: Record<string, unknown>;
}) => {
let count = 0;
for (const row of rows) {
if (where.id && row.id !== where.id) continue;
if (where.providerHandle && row.providerHandle !== where.providerHandle) continue;
if (where.status && row.status !== where.status) continue;
Object.assign(row, data);
count += 1;
}
return { count };
},
),
},
};
(prisma as { $transaction?: unknown }).$transaction = vi.fn(
async (fn: (tx: unknown) => Promise<unknown>) => fn(prisma),
);
return {
prisma: prisma as unknown as PrismaClient & typeof prisma,
messaging,
events: {
sendUserMessage: vi.fn(),
notify: vi.fn(async () => undefined),
} as unknown as MessagingDeliveryDeps["events"],
jobs: { enqueue: vi.fn(async () => undefined) },
sendToThread,
openDirectThread,
rows,
};
}
describe("deliverMessagingOutbound", () => {
it("mirrors a messaging DM run's bot text to the identity's conversation", async () => {
const deps = createDeps({});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).toHaveBeenCalledWith(
{ threadId: "sendblue:dm-1", body: "Hello from your bot" },
context,
);
expect(deps.rows).toEqual([
expect.objectContaining({
idempotencyKey: "msg:m-1",
kind: "dm",
identityId: "mi-1",
status: "sent",
providerHandle: "handle-out-1",
sourceMessageId: "m-1",
}),
]);
expect(deps.prisma.messagingIdentity.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { outboundSinceInbound: { increment: 1 } } }),
);
expect(deps.prisma.run.updateMany).toHaveBeenCalledWith({
where: { id: "run-1", trigger: "messaging" },
data: { messagingMirroredAt: expect.any(Date) },
});
});
it("resolves the DM thread once and caches it when the identity has none", async () => {
const deps = createDeps({
identity: { dmThreadId: null },
messages: [
{ id: "m-1", blocks: [{ kind: "text", text: "first" }] },
{ id: "m-2", blocks: [{ kind: "text", text: "second" }] },
],
});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.openDirectThread).toHaveBeenCalledTimes(1);
expect(deps.openDirectThread).toHaveBeenCalledWith("sendblue", "+15551234567", context);
expect(deps.prisma.messagingIdentity.update).toHaveBeenCalledWith({
where: { id: "mi-1" },
data: { dmThreadId: "sendblue:opened:+15551234567" },
});
expect(deps.sendToThread).toHaveBeenNthCalledWith(
1,
{ threadId: "sendblue:opened:+15551234567", body: "first" },
context,
);
expect(deps.sendToThread).toHaveBeenNthCalledWith(
2,
{ threadId: "sendblue:opened:+15551234567", body: "second" },
context,
);
});
it("does not mirror a message that already has an outbox row", async () => {
const deps = createDeps({
existingOutbox: { id: "out-1", idempotencyKey: "msg:m-1", status: "sent" },
});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.rows).toHaveLength(0);
expect(deps.sendToThread).not.toHaveBeenCalled();
});
it("ignores in-app runs and runs without a messaging identity", async () => {
const notMessaging = createDeps({ run: { ...messagingRun, trigger: "user" } });
await deliverMessagingOutbound(notMessaging, { runId: "run-1" }, context);
expect(notMessaging.sendToThread).not.toHaveBeenCalled();
const noIdentity = createDeps({ identity: null });
await deliverMessagingOutbound(noIdentity, { runId: "run-1" }, context);
expect(noIdentity.sendToThread).not.toHaveBeenCalled();
});
it("mirrors delegated bot_message replies to the linked DM", async () => {
const deps = createDeps({ run: { ...messagingRun, trigger: "bot_message" } });
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).toHaveBeenCalledWith(
{ threadId: "sendblue:dm-1", body: "Hello from your bot" },
context,
);
});
it("holds sendblue DM sends at the consecutive-outbound cap", async () => {
const deps = createDeps({
identity: {
provider: "sendblue",
outboundSinceInbound: MESSAGING_DM_OUTBOUND_CAP,
},
});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).not.toHaveBeenCalled();
expect(deps.rows).toEqual([expect.objectContaining({ kind: "dm", status: "pending" })]);
});
it("does not apply the sendblue outbound cap to other providers", async () => {
const deps = createDeps({
identity: {
provider: "slack",
address: "U123",
outboundSinceInbound: MESSAGING_DM_OUTBOUND_CAP,
},
});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).toHaveBeenCalled();
expect(deps.rows).toEqual([
expect.objectContaining({ kind: "dm", status: "sent", providerHandle: "handle-out-1" }),
]);
});
it("returns a transient send failure to pending and schedules a delayed retry", async () => {
const deps = createDeps({ sendError: new Error("messaging provider 500") });
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.rows).toEqual([
expect.objectContaining({
kind: "dm",
status: "pending",
attempts: 1,
nextAttemptAt: expect.any(Date),
}),
]);
expect(deps.rows[0]!.providerHandle ?? null).toBeNull();
expect(deps.jobs.enqueue).toHaveBeenCalledWith(
expect.objectContaining({ name: "messaging.deliver", availableAt: expect.any(Date) }),
);
});
it("skips pending rows whose nextAttemptAt is still in the future", async () => {
const deps = createDeps({
run: null,
outboundRows: [
{
id: "out-wait",
idempotencyKey: "invite:ch-1:mi-1",
kind: "dm",
identityId: "mi-1",
body: "backoff hold",
status: "pending",
attempts: 1,
nextAttemptAt: new Date(Date.now() + 60_000),
providerHandle: null,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).not.toHaveBeenCalled();
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "pending", attempts: 1 }));
});
it("marks introPostedAt only after a pending intro row is delivered", async () => {
const deps = createDeps({
run: null,
outboundRows: [
{
id: "out-intro",
idempotencyKey: "intro:ch-1",
kind: "intro",
threadId: "sendblue:grp-1",
body: "Hi. This line hosts Rakazo personal agents.",
status: "pending",
providerHandle: null,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).toHaveBeenCalledWith(
{ threadId: "sendblue:grp-1", body: "Hi. This line hosts Rakazo personal agents." },
context,
);
expect(deps.prisma.messagingChannel.updateMany).toHaveBeenCalledWith({
where: { threadId: "sendblue:grp-1", introPostedAt: null },
data: { introPostedAt: expect.any(Date) },
});
});
it("does not mark introPostedAt when intro delivery fails", async () => {
const deps = createDeps({
run: null,
sendError: new Error("provider down"),
outboundRows: [
{
id: "out-intro",
idempotencyKey: "intro:ch-1",
kind: "intro",
threadId: "sendblue:grp-1",
body: "Hi. This line hosts Rakazo personal agents.",
status: "pending",
attempts: 0,
providerHandle: null,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.prisma.messagingChannel.updateMany).not.toHaveBeenCalled();
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "pending", attempts: 1 }));
});
it("drains leftover pending rows without a run id", async () => {
const deps = createDeps({
run: null,
outboundRows: [
{
id: "out-9",
idempotencyKey: "invite:ch-1:mi-1",
kind: "dm",
identityId: "mi-1",
body: "pending earlier",
status: "pending",
providerHandle: null,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).toHaveBeenCalledWith(
{ threadId: "sendblue:dm-1", body: "pending earlier" },
context,
);
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "sent" }));
});
it("claims a row before sending so concurrent drains cannot double-send", async () => {
const deps = createDeps({});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
const calls = deps.prisma.messagingOutbound.updateMany.mock.calls as Array<
[{ where?: { status?: string } }]
>;
const claimIndex = calls.findIndex(([args]) => args.where?.status === "pending");
expect(claimIndex).toBeGreaterThanOrEqual(0);
expect(
deps.prisma.messagingOutbound.updateMany.mock.invocationCallOrder[claimIndex]!,
).toBeLessThan(deps.sendToThread.mock.invocationCallOrder[0]!);
});
it("skips the send when another drain won the claim", async () => {
const deps = createDeps({});
deps.prisma.messagingOutbound.updateMany = vi.fn(async () => ({
count: 0,
})) as unknown as typeof deps.prisma.messagingOutbound.updateMany;
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).not.toHaveBeenCalled();
});
it("fails malformed rows instead of re-scanning them forever", async () => {
const deps = createDeps({
run: null,
outboundRows: [
{
id: "out-bad",
idempotencyKey: "broken:1",
kind: "dm",
identityId: null,
body: "nowhere to go",
status: "pending",
providerHandle: null,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).not.toHaveBeenCalled();
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "failed" }));
});
it("does not send a connect invite after the connection was revoked", async () => {
const deps = createDeps({
run: null,
connectionStatus: "revoked",
outboundRows: [
{
id: "out-connect",
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
identityId: "mi-1",
body: "wants to connect. Reply YES to allow, NO to decline.",
status: "pending",
providerHandle: null,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).not.toHaveBeenCalled();
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "failed" }));
expect(deps.prisma.$queryRaw).toHaveBeenCalled();
});
it("does not send a connect invite when revoke already deleted the claim", async () => {
const deps = createDeps({
run: null,
connectionStatus: "pending",
outboundRows: [
{
id: "out-connect",
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
identityId: "mi-1",
body: "wants to connect. Reply YES to allow, NO to decline.",
status: "pending",
providerHandle: null,
},
],
});
// After claim, revoke deletes the row before the locked gate runs.
deps.prisma.messagingOutbound.findUnique = vi.fn(async () => null) as never;
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).not.toHaveBeenCalled();
});
it("still sends a connect invite while the connection is pending", async () => {
const deps = createDeps({
run: null,
connectionStatus: "pending",
outboundRows: [
{
id: "out-connect",
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
identityId: "mi-1",
body: "wants to connect. Reply YES to allow, NO to decline.",
status: "pending",
providerHandle: null,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).toHaveBeenCalledWith(
{
threadId: "sendblue:dm-1",
body: "wants to connect. Reply YES to allow, NO to decline.",
},
context,
);
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "sent" }));
expect(deps.prisma.messagingIdentity.update).toHaveBeenCalledWith({
where: { id: "mi-1" },
data: { outboundSinceInbound: { increment: 1 } },
});
});
it("does not re-queue a connect invite when the provider send fails", async () => {
const deps = createDeps({
run: null,
connectionStatus: "pending",
sendError: new Error("provider down"),
outboundRows: [
{
id: "out-connect",
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
identityId: "mi-1",
body: "wants to connect",
status: "pending",
providerHandle: null,
attempts: 0,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
// At-most-once: keep the claim rather than risk a duplicate YES/NO.
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "sent" }));
expect(deps.jobs.enqueue).not.toHaveBeenCalled();
expect(deps.prisma.messagingIdentity.update).not.toHaveBeenCalled();
});
it("does not re-queue a connect invite after a delivery transaction timeout", async () => {
const deps = createDeps({
run: null,
connectionStatus: "pending",
outboundRows: [
{
id: "out-connect",
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
identityId: "mi-1",
body: "wants to connect",
status: "pending",
providerHandle: null,
},
],
});
(deps.prisma as unknown as { $transaction: unknown }).$transaction = vi.fn(async () => {
throw new Error("Transaction API error: Transaction already closed");
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.sendToThread).not.toHaveBeenCalled();
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "sent" }));
expect(deps.jobs.enqueue).not.toHaveBeenCalled();
});
});
describe("applyMessagingOutboundStatus", () => {
it("maps terminal statuses onto outbox rows by handle", async () => {
const deps = createDeps({});
await applyMessagingOutboundStatus(deps.prisma, {
type: "status",
provider: "sendblue",
handle: "h-1",
status: "ERROR",
});
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledWith({
where: { providerHandle: "h-1" },
data: { status: "failed" },
});
await applyMessagingOutboundStatus(deps.prisma, {
type: "status",
provider: "sendblue",
handle: "h-2",
status: "DELIVERED",
});
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledWith({
where: { providerHandle: "h-2" },
data: { status: "sent" },
});
await applyMessagingOutboundStatus(deps.prisma, {
type: "status",
provider: "sendblue",
handle: "h-3",
status: "QUEUED",
});
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledTimes(2);
});
});
function createChannelDeps(
overrides: {
text?: string;
sourceHop?: number;
transport?: string;
peerBotName?: string | null;
messages?: unknown[];
} = {},
) {
const text = overrides.text ?? "found it";
const channelRun = {
id: "run-1",
botId: "bot-1",
trigger: "messaging",
sourceMessage: {
blocks: [
{
kind: "channel_message",
provider: "sendblue",
...(overrides.transport ? { transport: overrides.transport } : {}),
channelId: "ch-1",
fromAddress: "+15551111111",
fromLabel: "Alice",
text: "group hi",
hop: overrides.sourceHop ?? 0,
},
],
},
};
const posterIdentity = {
id: "mi-1",
provider: "sendblue",
address: "+15551111111",
dmThreadId: "sendblue:dm-1",
userId: "user-1",
spaceId: "ws-1",
botId: "bot-1",
outboundSinceInbound: 0,
};
const peerIdentity = {
id: "mi-2",
provider: "sendblue",
address: "+15553333333",
dmThreadId: "sendblue:dm-2",
userId: "user-2",
spaceId: "ws-2",
botId: "bot-2",
outboundSinceInbound: 0,
};
const rows: Array<Record<string, unknown>> = [];
const contextMessages: Array<Record<string, unknown>> = [];
const { messaging, sendToThread } = createFakeSurface();
const sendUserMessage = vi.fn(async () => ({ messageId: "msg-wake", runId: "run-wake", seq: 4 }));
const notify = vi.fn(async () => undefined);
const enqueue = vi.fn(async () => undefined);
const peerBotName = overrides.peerBotName === undefined ? "Helper" : overrides.peerBotName;
const txMock = {
thread: { update: vi.fn(async () => ({ nextMessageSeq: 5, nextEventSeq: 10 })) },
message: {
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const message = { id: `ctx-${contextMessages.length + 1}`, seq: 4, ...data };
contextMessages.push(message);
return message;
}),
},
run: { findUnique: vi.fn(async () => null) },
event: {
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
id: "evt-1",
...data,
})),
},
};
const prisma = {
run: {
findUnique: vi.fn(async () => channelRun),
updateMany: vi.fn(async () => ({ count: 1 })),
},
message: {
findMany: vi.fn(
async () => overrides.messages ?? [{ id: "m-1", blocks: [{ kind: "text", text }] }],
),
findUnique: vi.fn(async () => null),
},
messagingIdentity: {
findUnique: vi.fn(async ({ where }: { where: { botId?: string; id?: string } }) => {
if (where.botId === "bot-1" || where.id === "mi-1") return posterIdentity;
if (where.botId === "bot-2" || where.id === "mi-2") return peerIdentity;
return null;
}),
update: vi.fn(async () => posterIdentity),
},
messagingChannel: {
findUnique: vi.fn(async () => ({
id: "ch-1",
provider: "sendblue",
threadId: "sendblue:grp-1",
name: "Family",
introPostedAt: null,
})),
},
messagingChannelMember: {
findMany: vi.fn(async () => [
{
id: "mm-2",
channelId: "ch-1",
address: "+15553333333",
identityId: "mi-2",
status: "approved",
},
]),
},
messagingOutbound: {
findMany: vi.fn(async ({ where }: { where?: { status?: string; OR?: unknown[] } } = {}) => {
const now = Date.now();
return rows.filter((row) => {
if (where?.status && row.status !== where.status) return false;
if (!where?.OR) return row.status === "pending";
const next = row.nextAttemptAt;
if (next == null) return true;
const nextMs = next instanceof Date ? next.getTime() : new Date(String(next)).getTime();
return nextMs <= now;
});
}),
createMany: vi.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
for (const item of data)
rows.push({ id: `out-${rows.length + 1}`, status: "pending", ...item });
return { count: data.length };
}),
update: vi.fn(async ({ where, data }: { where: { id: string }; data: unknown }) => {
const row = rows.find((candidate) => candidate.id === where.id);
if (row) Object.assign(row, data);
return row;
}),
updateMany: vi.fn(
async ({ where, data }: { where: { id?: string; status?: string }; data: unknown }) => {
let count = 0;
for (const row of rows) {
if (where.id && row.id !== where.id) continue;
if (where.status && row.status !== where.status) continue;
Object.assign(row, data);
count += 1;
}
return { count };
},
),
},
user: { findUnique: vi.fn(async () => ({ id: "user-1", name: "Alice Owner" })) },
bot: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
where.id === "bot-1"
? { id: "bot-1", name: "Assistant" }
: peerBotName
? { id: "bot-2", name: peerBotName }
: null,
),
},
thread: { findFirst: vi.fn(async () => ({ id: "thread-2" })) },
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(txMock)),
};
return {
prisma: prisma as unknown as PrismaClient & typeof prisma,
messaging,
events: { sendUserMessage, notify } as unknown as MessagingDeliveryDeps["events"],
jobs: { enqueue },
sendToThread,
sendUserMessage,
notify,
enqueue,
rows,
contextMessages,
txMock,
};
}
describe("deliverMessagingOutbound channel runs", () => {
it("keeps privately answered runs out of the group and peer fan-out on retries", async () => {
const deps = createChannelDeps({
messages: [
{
id: "ask-1",
blocks: [{ kind: "ask", text: "Details?", status: "answered", answer: "Private detail" }],
},
{ id: "reply-1", blocks: [{ kind: "text", text: "Reply containing private detail" }] },
],
});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.rows).toEqual([]);
expect(deps.sendToThread).not.toHaveBeenCalled();
expect(deps.contextMessages).toEqual([]);
expect(deps.sendUserMessage).not.toHaveBeenCalled();
});
it("can still publish a group reply alongside an unanswered ask", async () => {
const deps = createChannelDeps({
messages: [
{ id: "ask-1", blocks: [{ kind: "ask", text: "Details?", status: "pending" }] },
{ id: "reply-1", blocks: [{ kind: "text", text: "Public update" }] },
],
});
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).toHaveBeenCalledWith(
{ threadId: "sendblue:grp-1", body: "Alice's agent: Public update" },
context,
);
});
it("posts the bot reply to the group with owner attribution and fans it out to peers", async () => {
const deps = createChannelDeps();
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).toHaveBeenCalledWith(
{ threadId: "sendblue:grp-1", body: "Alice's agent: found it" },
context,
);
expect(deps.rows).toEqual([
expect.objectContaining({
idempotencyKey: "msg:m-1",
kind: "group",
threadId: "sendblue:grp-1",
status: "sent",
}),
]);
// peer context: appended without a run, carrying the next hop
expect(deps.contextMessages).toEqual([
expect.objectContaining({
threadId: "thread-2",
role: "user",
clientNonce: "messaging-peer:m-1:bot-2",
blocks: [
{
kind: "channel_message",
provider: "sendblue",
channelId: "ch-1",
fromAddress: "+15551111111",
fromLabel: "Alice's agent",
text: "found it",
hop: 1,
},
],
}),
]);
expect(deps.sendUserMessage).not.toHaveBeenCalled();
expect(deps.enqueue).not.toHaveBeenCalled();
});
it("preserves source transport on peer fan-out blocks", async () => {
const deps = createChannelDeps({ transport: "SMS" });
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.contextMessages).toEqual([
expect.objectContaining({
clientNonce: "messaging-peer:m-1:bot-2",
blocks: [
expect.objectContaining({
kind: "channel_message",
provider: "sendblue",
transport: "SMS",
text: "found it",
hop: 1,
}),
],
}),
]);
});
it("preserves source transport when waking an @-mentioned peer", async () => {
const deps = createChannelDeps({ text: "@Helper what do you think?", transport: "RCS" });
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendUserMessage).toHaveBeenCalledWith(
expect.objectContaining({
botId: "bot-2",
blocks: [
expect.objectContaining({
kind: "channel_message",
transport: "RCS",
}),
],
}),
);
});
it("wakes an @-mentioned peer bot with a run", async () => {
const deps = createChannelDeps({ text: "@Helper what do you think?" });
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendUserMessage).toHaveBeenCalledWith(
expect.objectContaining({
spaceId: "ws-2",
threadId: "thread-2",
botId: "bot-2",
trigger: "messaging",
clientNonce: "messaging-peer:m-1:bot-2",
}),
);
expect(deps.enqueue).toHaveBeenCalledWith(
expect.objectContaining({ name: "run.continue", payload: { runId: "run-wake" } }),
);
expect(deps.contextMessages).toHaveLength(0);
});
it("wakes an @-mentioned peer even when the casing differs", async () => {
const deps = createChannelDeps({ text: "@helper ping" });
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendUserMessage).toHaveBeenCalledWith(
expect.objectContaining({ botId: "bot-2", clientNonce: "messaging-peer:m-1:bot-2" }),
);
});
it("does not wake anyone when the hop budget is exhausted", async () => {
const deps = createChannelDeps({ text: "@Helper again?", sourceHop: 6 });
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendUserMessage).not.toHaveBeenCalled();
expect(deps.enqueue).not.toHaveBeenCalled();
// still delivered as context, with the clamped hop recorded
expect(deps.contextMessages[0]).toEqual(
expect.objectContaining({ clientNonce: "messaging-peer:m-1:bot-2" }),
);
});
});
describe("deliverMessagingOutbound transient failure retry", () => {
it("marks the row failed only once the attempt budget is exhausted", async () => {
const deps = createDeps({
run: null,
sendError: new Error("messaging provider 500"),
outboundRows: [
{
id: "out-9",
idempotencyKey: "msg:m-9",
kind: "dm",
identityId: "mi-1",
body: "fourth failure",
status: "pending",
providerHandle: null,
attempts: MESSAGING_OUTBOUND_MAX_ATTEMPTS - 1,
},
],
});
await deliverMessagingOutbound(deps, {}, context);
expect(deps.rows).toEqual([
expect.objectContaining({ status: "failed", attempts: MESSAGING_OUTBOUND_MAX_ATTEMPTS }),
]);
expect(deps.jobs.enqueue).not.toHaveBeenCalled();
});
});
describe("deliverMessagingOutbound retry enqueue failure", () => {
it("propagates the enqueue failure so the job queue retries the drain", async () => {
const deps = createDeps({});
deps.sendToThread.mockRejectedValueOnce(new Error("messaging provider 500"));
deps.jobs.enqueue.mockRejectedValueOnce(new Error("queue down"));
// A swallowed enqueue failure would strand the row in pending forever:
// no job reconciler reclaims messaging_outbound rows.
await expect(deliverMessagingOutbound(deps, { runId: "run-1" }, context)).rejects.toThrow(
"queue down",
);
expect(deps.rows).toEqual([
expect.objectContaining({
kind: "dm",
status: "pending",
attempts: 1,
nextAttemptAt: expect.any(Date),
}),
]);
// The delayed messaging.deliver job fires at nextAttemptAt: make the row due.
deps.rows[0]!.nextAttemptAt = new Date(Date.now() - 1);
await deliverMessagingOutbound(deps, { runId: "run-1" }, context);
expect(deps.sendToThread).toHaveBeenCalledTimes(2);
expect(deps.rows).toEqual([
expect.objectContaining({ kind: "dm", status: "sent", providerHandle: "handle-out-1" }),
]);
});
});