When deliverDelegatedReplies could not resolve the Slack/Teams origin thread, it still set teamChatMirroredAt and permanently dropped the delegated reply. Leave the mirror timestamp unset when sourceMessageId exists so reconciliation retries once the wake chain can be resolved again. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2063 lines
66 KiB
TypeScript
2063 lines
66 KiB
TypeScript
import type { TeamChatInboundMessage, TeamChatSendRequest } from "@rakazo/adapter-kit";
|
|
import type { MessageBlock } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
TeamChatBridge,
|
|
teamChatAmbientPrompt,
|
|
teamChatPrompt,
|
|
teamChatResponseText,
|
|
} from "./team-chat-bridge.js";
|
|
import { inboundDeliveryClientNonce, messagingWakeIdempotencyKey } from "./webhook-inbound.js";
|
|
|
|
describe("team chat bridge", () => {
|
|
it("attributes an external speaker without changing their message", () => {
|
|
expect(teamChatPrompt("slack", "Ada Lovelace", "Review the launch plan")).toBe(
|
|
"Slack message from Ada Lovelace:\n\nReview the launch plan",
|
|
);
|
|
});
|
|
|
|
it("keeps provider IDs out of ambient conversation context", () => {
|
|
const prompt = teamChatAmbientPrompt({
|
|
provider: "slack",
|
|
channelId: "G123",
|
|
channelName: "leadership",
|
|
rules: "Engage on launch risks.",
|
|
messages: [{ senderName: "Pat", senderId: "U123", content: "Launch moved to Friday." }],
|
|
});
|
|
expect(prompt).toContain("Slack channel update from #leadership.");
|
|
expect(prompt).toContain("Pat: Launch moved to Friday.");
|
|
expect(prompt).not.toContain("G123");
|
|
expect(prompt).not.toContain("U123");
|
|
});
|
|
|
|
it("returns written agent output without leaking tool or computer blocks", () => {
|
|
const blocks: MessageBlock[] = [
|
|
{ kind: "progress", text: "Searching" },
|
|
{ kind: "text", text: "The plan is ready." },
|
|
{ kind: "meta", text: "internal metadata" },
|
|
];
|
|
expect(teamChatResponseText(blocks)).toBe("The plan is ready.");
|
|
expect(teamChatResponseText([])).toBe("Bot completed the request without a written reply.");
|
|
});
|
|
|
|
it("retries delegated replies when the external origin is temporarily missing", async () => {
|
|
const send = vi.fn(async () => ({ handle: "reply-delegated" }));
|
|
const runUpdateMany = vi.fn(async () => ({ count: 1 }));
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
run: {
|
|
findMany: vi.fn(async () => [
|
|
{
|
|
id: "run-delegated",
|
|
status: "completed",
|
|
sourceMessageId: "msg-source",
|
|
},
|
|
]),
|
|
findUnique: vi.fn(async () => null),
|
|
updateMany: runUpdateMany,
|
|
},
|
|
message: {
|
|
findUnique: vi.fn(async () => null),
|
|
findFirst: vi.fn(async () => ({
|
|
blocks: [{ kind: "text", text: "Delegated work finished." }],
|
|
})),
|
|
},
|
|
externalMessage: { findUnique: vi.fn(async () => null) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send,
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Analyst" };
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
deliverDelegatedReplies: (target: { id: string; name: string }) => Promise<void>;
|
|
}
|
|
).deliverDelegatedReplies({ id: "bot-1", name: "Analyst" });
|
|
|
|
expect(send).not.toHaveBeenCalled();
|
|
expect(runUpdateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers delegated tool-only replies before marking them mirrored", async () => {
|
|
const send = vi.fn(async () => ({ handle: "reply-delegated" }));
|
|
const runUpdateMany = vi.fn(async () => ({ count: 1 }));
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
run: {
|
|
findMany: vi.fn(async () => [
|
|
{
|
|
id: "run-delegated",
|
|
status: "completed",
|
|
sourceMessageId: "msg-source",
|
|
},
|
|
]),
|
|
findUnique: vi.fn(async () => null),
|
|
updateMany: runUpdateMany,
|
|
},
|
|
message: {
|
|
findUnique: vi.fn(async () => ({ replyTo: { runId: "parent-run-1" } })),
|
|
findFirst: vi.fn(async () => ({
|
|
blocks: [{ kind: "progress", text: "Searching files" }],
|
|
})),
|
|
},
|
|
externalMessage: {
|
|
findUnique: vi.fn(async () => ({
|
|
replyThreadId: "100.1",
|
|
externalConversation: { conversationId: "C-1" },
|
|
})),
|
|
},
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send,
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Analyst" };
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
deliverDelegatedReplies: (target: { id: string; name: string }) => Promise<void>;
|
|
}
|
|
).deliverDelegatedReplies({ id: "bot-1", name: "Analyst" });
|
|
|
|
expect(send).toHaveBeenCalledWith({
|
|
conversationId: "C-1",
|
|
replyThreadId: "100.1",
|
|
content: "Analyst completed the request without a written reply.",
|
|
idempotencyKey: "team-chat-run:run-delegated",
|
|
});
|
|
expect(runUpdateMany).toHaveBeenCalledWith({
|
|
where: { id: "run-delegated", teamChatMirroredAt: null },
|
|
data: { teamChatMirroredAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it("keeps deferred messages outside reconciliation until routine routing resolves them", async () => {
|
|
const upsert = vi.fn(async ({ create }: { create: Record<string, unknown> }) => ({
|
|
id: "external-deferred",
|
|
...create,
|
|
threadMessageId: null,
|
|
}));
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const prisma = {
|
|
externalConversation: {
|
|
upsert: vi.fn(async () => ({
|
|
id: "conversation-1",
|
|
provider: "slack",
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
})),
|
|
},
|
|
externalMessage: {
|
|
upsert,
|
|
update: vi.fn(async () => ({})),
|
|
updateMany,
|
|
},
|
|
} as unknown as PrismaClient;
|
|
const bridge = new TeamChatBridge({
|
|
prisma,
|
|
events: {
|
|
sendUserMessage: vi.fn(async () => ({
|
|
messageId: "transcript-1",
|
|
runId: null,
|
|
seq: 1,
|
|
taskId: null,
|
|
})),
|
|
},
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
await bridge.receive(
|
|
{
|
|
eventId: "Ev-deferred",
|
|
workspaceId: "T-1",
|
|
kind: "mention",
|
|
conversationKey: "channel:C-1:100.1",
|
|
conversationId: "C-1",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "Run the release routine",
|
|
},
|
|
{ queueAgent: false },
|
|
);
|
|
|
|
expect(upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({ create: expect.objectContaining({ status: "deferred" }) }),
|
|
);
|
|
await expect(
|
|
bridge.resolveDeferredMessage("external-deferred", "routine", "mention"),
|
|
).resolves.toBe(true);
|
|
expect(updateMany).toHaveBeenLastCalledWith({
|
|
where: {
|
|
id: "external-deferred",
|
|
status: { in: ["deferred", "received", "observed"] },
|
|
externalConversation: { provider: "slack", botId: "bot-1" },
|
|
},
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
|
|
await bridge.resolveDeferredMessage("external-ambient", "agent", "ambient");
|
|
expect(updateMany).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
data: { status: "observed", engagementReason: null, nextAttemptAt: null },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("recovers expired deferred reservations before normal reconciliation", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 0 }));
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) =>
|
|
where.status === "deferred"
|
|
? [
|
|
{
|
|
id: "external-expired",
|
|
kind: "mention",
|
|
providerEventId: "Ev-expired",
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const findUnique = vi.fn(async () => null);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: {
|
|
updateMany,
|
|
findMany,
|
|
},
|
|
message: { findUnique },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
status: "deferred",
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
}),
|
|
}),
|
|
);
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "external-expired",
|
|
status: "deferred",
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
OR: [
|
|
{ engagementReason: null },
|
|
{
|
|
NOT: {
|
|
engagementReason: {
|
|
in: [
|
|
"message_routine_routing",
|
|
"message_routine_routing_rearmed",
|
|
"message_teamchat_agent",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
data: { status: "received", engagementReason: null, nextAttemptAt: null },
|
|
});
|
|
});
|
|
|
|
it("ignores expired deferred messages that already woke a message routine", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) =>
|
|
where.status === "deferred"
|
|
? [
|
|
{
|
|
id: "external-woken",
|
|
kind: "mention",
|
|
providerEventId: "Ev-woken",
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const findUnique = vi.fn(async () => ({ id: "msg-routine-wake" }));
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: {
|
|
updateMany,
|
|
findMany,
|
|
},
|
|
message: { findUnique },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(findUnique).toHaveBeenCalledWith({
|
|
where: {
|
|
threadId_clientNonce: {
|
|
threadId: "thread-1",
|
|
clientNonce: inboundDeliveryClientNonce(
|
|
"messaging",
|
|
"bot-1",
|
|
messagingWakeIdempotencyKey("slack", "Ev-woken"),
|
|
),
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "external-woken",
|
|
status: "deferred",
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
},
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
expect(updateMany).not.toHaveBeenCalledWith({
|
|
where: {
|
|
id: "external-woken",
|
|
status: "deferred",
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
},
|
|
data: { status: "received", nextAttemptAt: null },
|
|
});
|
|
});
|
|
|
|
it("recovers emulator wakes when deliveryProvider matches bridge providerId", async () => {
|
|
// teamchat-emulator inbound uses event.provider !== bridge.providerId ("slack").
|
|
// Recovery must look up the same nonce TeamChat wakes persist via deliveryProvider.
|
|
const eventId = "Ev-emulator-1";
|
|
const aligned = inboundDeliveryClientNonce(
|
|
"messaging",
|
|
"bot-1",
|
|
messagingWakeIdempotencyKey("slack", eventId),
|
|
);
|
|
const mismatched = inboundDeliveryClientNonce(
|
|
"messaging",
|
|
"bot-1",
|
|
messagingWakeIdempotencyKey("teamchat-emulator", eventId),
|
|
);
|
|
expect(aligned).not.toBe(mismatched);
|
|
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) =>
|
|
where.status === "deferred"
|
|
? [
|
|
{
|
|
id: "external-emulator",
|
|
kind: "direct",
|
|
providerEventId: eventId,
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const findUnique = vi.fn(
|
|
async ({ where }: { where: { threadId_clientNonce: { clientNonce: string } } }) =>
|
|
where.threadId_clientNonce.clientNonce === aligned ? { id: "msg-emulator-wake" } : null,
|
|
);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany, findMany },
|
|
message: { findUnique },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
expect(bridge.providerId).toBe("slack");
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(findUnique).toHaveBeenCalledWith({
|
|
where: {
|
|
threadId_clientNonce: {
|
|
threadId: "thread-1",
|
|
clientNonce: aligned,
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "external-emulator",
|
|
status: "deferred",
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
},
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reclaims a received row when routine resolve races lease expiry", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const bridge = new TeamChatBridge({
|
|
prisma: { externalMessage: { updateMany } } as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
await expect(
|
|
bridge.resolveDeferredMessage("external-raced", "routine", "mention"),
|
|
).resolves.toBe(true);
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "external-raced",
|
|
status: { in: ["deferred", "received", "observed"] },
|
|
externalConversation: { provider: "slack", botId: "bot-1" },
|
|
},
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("extends deferred routing leases for thirty minutes", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const bridge = new TeamChatBridge({
|
|
prisma: { externalMessage: { updateMany } } as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
const before = Date.now();
|
|
await expect(bridge.extendDeferredReservation("external-deferred")).resolves.toBe(true);
|
|
const after = Date.now();
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "external-deferred",
|
|
status: "deferred",
|
|
externalConversation: { provider: "slack", botId: "bot-1" },
|
|
},
|
|
data: {
|
|
nextAttemptAt: expect.any(Date),
|
|
engagementReason: "message_routine_routing",
|
|
},
|
|
});
|
|
const nextAttemptAt = (updateMany.mock.calls[0]![0] as { data: { nextAttemptAt: Date } }).data
|
|
.nextAttemptAt;
|
|
expect(nextAttemptAt.getTime()).toBeGreaterThanOrEqual(before + 25 * 60_000);
|
|
expect(nextAttemptAt.getTime()).toBeLessThanOrEqual(after + 30 * 60_000);
|
|
});
|
|
|
|
it("keeps renewing a deferred lease while routine delivery remains blocked", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
try {
|
|
let leaseUntil = new Date(0);
|
|
const updateMany = vi.fn(
|
|
async (input: { data?: { nextAttemptAt?: Date; status?: string } }) => {
|
|
if (input.data?.nextAttemptAt) leaseUntil = input.data.nextAttemptAt;
|
|
return { count: 1 };
|
|
},
|
|
);
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) =>
|
|
where.status === "deferred" && leaseUntil.getTime() <= Date.now()
|
|
? [
|
|
{
|
|
id: "external-routing",
|
|
kind: "mention",
|
|
providerEventId: "Ev-routing",
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany, findMany },
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
// Keeping the stopper open models wakeMessageRoutines still waiting on
|
|
// delivery while reconciliation continues on its normal timer.
|
|
const leaseHeartbeat = await bridge.startDeferredReservationHeartbeat("external-routing");
|
|
await vi.advanceTimersByTimeAsync(31 * 60_000);
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(updateMany.mock.calls.length).toBeGreaterThan(30);
|
|
expect(leaseUntil.getTime()).toBeGreaterThan(Date.now());
|
|
expect(updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
id: "external-routing",
|
|
status: "deferred",
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
},
|
|
data: { status: "received", nextAttemptAt: null },
|
|
}),
|
|
);
|
|
|
|
leaseHeartbeat.stop();
|
|
const renewalsAfterRoute = updateMany.mock.calls.length;
|
|
await vi.advanceTimersByTimeAsync(2 * 60_000);
|
|
expect(updateMany).toHaveBeenCalledTimes(renewalsAfterRoute);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("aborts the routing heartbeat when a lease renewal is lost", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const updateMany = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ count: 1 })
|
|
.mockResolvedValueOnce({ count: 0 });
|
|
const bridge = new TeamChatBridge({
|
|
prisma: { externalMessage: { updateMany } } as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
const leaseHeartbeat = await bridge.startDeferredReservationHeartbeat(
|
|
"external-routing",
|
|
1_000,
|
|
);
|
|
const lost = expect(leaseHeartbeat.lost).rejects.toThrow(
|
|
/Team chat deferred reservation was lost/,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await lost;
|
|
leaseHeartbeat.stop();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps routing ownership when renewal fails while a reconciler runs", async () => {
|
|
// Periodic renewal returns zero rows while routine delivery stays blocked.
|
|
// Another reconciler must leave exclusive routing ownership in place instead
|
|
// of promoting to a TeamChat agent run, even past the former grace window.
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
try {
|
|
let held = true;
|
|
let leaseUntil = new Date(0);
|
|
let engagementReason: string | null = null;
|
|
const sendUserMessage = vi.fn();
|
|
const updateMany = vi.fn(
|
|
async (input: {
|
|
where?: {
|
|
id?: string;
|
|
status?: string | { in?: string[] };
|
|
nextAttemptAt?: unknown;
|
|
};
|
|
data?: {
|
|
nextAttemptAt?: Date | null;
|
|
status?: string;
|
|
engagementReason?: string | null;
|
|
};
|
|
}) => {
|
|
const isRoutingHold =
|
|
input.where?.status === "deferred" &&
|
|
input.data?.nextAttemptAt instanceof Date &&
|
|
(input.data.engagementReason === "message_routine_routing" ||
|
|
input.data.engagementReason === "message_routine_routing_rearmed") &&
|
|
input.data.status === undefined;
|
|
// Heartbeat renewals omit nextAttemptAt from the where clause.
|
|
if (isRoutingHold && input.where?.nextAttemptAt === undefined) {
|
|
if (!held) return { count: 0 };
|
|
leaseUntil = input.data!.nextAttemptAt as Date;
|
|
engagementReason = input.data!.engagementReason ?? "message_routine_routing";
|
|
return { count: 1 };
|
|
}
|
|
if (
|
|
input.where?.status === "deferred" &&
|
|
(input.data?.status === "received" || input.data?.status === "observed")
|
|
) {
|
|
engagementReason = input.data.engagementReason ?? null;
|
|
leaseUntil = new Date(0);
|
|
return { count: 1 };
|
|
}
|
|
return { count: 1 };
|
|
},
|
|
);
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) => {
|
|
if (where.status === "deferred" && leaseUntil.getTime() <= Date.now()) {
|
|
return [
|
|
{
|
|
id: "external-lost",
|
|
kind: "mention",
|
|
providerEventId: "Ev-lost",
|
|
engagementReason,
|
|
nextAttemptAt: leaseUntil,
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany, findMany },
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
const lease = await bridge.startDeferredReservationHeartbeat("external-lost", 60_000);
|
|
expect(engagementReason).toBe("message_routine_routing");
|
|
const lost = expect(lease.lost).rejects.toThrow("Team chat deferred reservation was lost");
|
|
|
|
held = false;
|
|
leaseUntil = new Date(Date.now() - 1);
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
await lost;
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
expect(engagementReason).toBe("message_routine_routing");
|
|
expect(updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "external-lost", status: "deferred" }),
|
|
data: expect.objectContaining({ status: "received" }),
|
|
}),
|
|
);
|
|
|
|
// Ownership survives the former two-minute grace window: reconcile must
|
|
// not promote while an in-flight wake may still commit.
|
|
leaseUntil = new Date(Date.now() - 1);
|
|
await vi.advanceTimersByTimeAsync(2 * 60_000);
|
|
await bridge.reconcileOnce();
|
|
expect(engagementReason).toBe("message_routine_routing");
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
expect(updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "external-lost", status: "deferred" }),
|
|
data: expect.objectContaining({ status: "received", engagementReason: null }),
|
|
}),
|
|
);
|
|
|
|
lease.stop();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("does not promote a rearmed routing claim after grace while wake is blocked", async () => {
|
|
// Heartbeat renewal fails; wake stays blocked past the old re-armed grace
|
|
// window. First reconcile only drops orphaned ownership; it must not queue a
|
|
// TeamChat run beside the wake in that same pass.
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
try {
|
|
let leaseUntil = new Date(Date.now() - 1);
|
|
let engagementReason: string | null = "message_routine_routing_rearmed";
|
|
let status = "deferred";
|
|
const sendUserMessage = vi.fn();
|
|
const updateMany = vi.fn(
|
|
async (input: {
|
|
where?: { id?: string; status?: string | { in?: string[] } };
|
|
data?: {
|
|
nextAttemptAt?: Date | null;
|
|
status?: string;
|
|
engagementReason?: string | null;
|
|
};
|
|
}) => {
|
|
if (
|
|
input.where?.id === "external-grace" &&
|
|
(input.data?.status === "received" || input.data?.status === "queueing")
|
|
) {
|
|
status = input.data.status;
|
|
engagementReason = input.data.engagementReason ?? null;
|
|
leaseUntil = input.data.nextAttemptAt ?? new Date(0);
|
|
return { count: 1 };
|
|
}
|
|
if (
|
|
input.where?.id === "external-grace" &&
|
|
input.data?.nextAttemptAt instanceof Date &&
|
|
input.data.status === undefined
|
|
) {
|
|
leaseUntil = input.data.nextAttemptAt;
|
|
if ("engagementReason" in (input.data ?? {})) {
|
|
engagementReason = input.data.engagementReason ?? null;
|
|
}
|
|
return { count: 1 };
|
|
}
|
|
return { count: 0 };
|
|
},
|
|
);
|
|
const findMany = vi.fn(
|
|
async ({ where }: { where: { status?: string | { in?: string[] } } }) => {
|
|
if (
|
|
where.status === "deferred" &&
|
|
status === "deferred" &&
|
|
leaseUntil.getTime() <= Date.now()
|
|
) {
|
|
return [
|
|
{
|
|
id: "external-grace",
|
|
kind: "mention",
|
|
providerEventId: "Ev-grace",
|
|
engagementReason,
|
|
nextAttemptAt: leaseUntil,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
},
|
|
];
|
|
}
|
|
if (where.status === "received" && status === "received") {
|
|
return [
|
|
{
|
|
id: "external-grace",
|
|
kind: "mention",
|
|
providerEventId: "Ev-grace",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "hello",
|
|
batchContext: null,
|
|
engagementReason,
|
|
nextAttemptAt: null,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
},
|
|
);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: {
|
|
updateMany,
|
|
findMany,
|
|
findUnique: vi.fn(async () => ({
|
|
status,
|
|
engagementReason,
|
|
})),
|
|
},
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
// Past the former two-minute re-armed grace; wake still blocked (no nonce).
|
|
await vi.advanceTimersByTimeAsync(3 * 60_000);
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(status).toBe("deferred");
|
|
expect(engagementReason).toBeNull();
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: "external-grace",
|
|
status: "deferred",
|
|
engagementReason: {
|
|
in: ["message_routine_routing", "message_routine_routing_rearmed"],
|
|
},
|
|
}),
|
|
data: { engagementReason: null, nextAttemptAt: expect.any(Date) },
|
|
}),
|
|
);
|
|
expect(updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "external-grace" }),
|
|
data: expect.objectContaining({ status: "received" }),
|
|
}),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("promotes deferred rows that still carry an ambient engagement reason", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) =>
|
|
where.status === "deferred"
|
|
? [
|
|
{
|
|
id: "external-ambient",
|
|
kind: "ambient",
|
|
providerEventId: "Ev-ambient",
|
|
engagementReason: "Channel may need a reply",
|
|
nextAttemptAt: new Date(Date.now() - 1_000),
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany, findMany },
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: "external-ambient",
|
|
status: "deferred",
|
|
OR: [
|
|
{ engagementReason: null },
|
|
{
|
|
NOT: {
|
|
engagementReason: {
|
|
in: [
|
|
"message_routine_routing",
|
|
"message_routine_routing_rearmed",
|
|
"message_teamchat_agent",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
data: expect.objectContaining({ status: "observed", engagementReason: null }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("clears expired routing ownership during reconcile so orphans are not stuck", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) =>
|
|
where.status === "deferred"
|
|
? [
|
|
{
|
|
id: "external-orphan",
|
|
kind: "mention",
|
|
providerEventId: "Ev-orphan",
|
|
engagementReason: "message_routine_routing",
|
|
nextAttemptAt: new Date(Date.now() - 1_000),
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany, findMany },
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: "external-orphan",
|
|
engagementReason: {
|
|
in: ["message_routine_routing", "message_routine_routing_rearmed"],
|
|
},
|
|
}),
|
|
data: { engagementReason: null, nextAttemptAt: expect.any(Date) },
|
|
}),
|
|
);
|
|
expect(updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "external-orphan" }),
|
|
data: expect.objectContaining({ status: "received" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps in-flight routine wakes exclusive after lease expiry", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findMany = vi.fn(async ({ where }: { where: { status?: string } }) =>
|
|
where.status === "deferred"
|
|
? [
|
|
{
|
|
id: "external-inflight",
|
|
kind: "mention",
|
|
providerEventId: "Ev-inflight",
|
|
engagementReason: "message_routine_routing",
|
|
nextAttemptAt: new Date(Date.now() - 1_000),
|
|
externalConversation: { thread: { id: "thread-1" } },
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany, findMany },
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
(
|
|
bridge as unknown as {
|
|
target: { id: string; spaceId: string; userId: string; name: string };
|
|
}
|
|
).target = { id: "bot-1", spaceId: "space-1", userId: "owner-1", name: "Chief" };
|
|
bridge.markRoutineWakeInFlight("external-inflight");
|
|
|
|
await bridge.reconcileOnce();
|
|
|
|
expect(updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "external-inflight" }),
|
|
data: expect.objectContaining({ engagementReason: null }),
|
|
}),
|
|
);
|
|
expect(updateMany).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "external-inflight" }),
|
|
data: expect.objectContaining({ status: "received" }),
|
|
}),
|
|
);
|
|
bridge.clearRoutineWakeInFlight("external-inflight");
|
|
});
|
|
|
|
it("releases only expired routing ownership before startup reconciliation", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findMany = vi.fn(async () => []);
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
bot: {
|
|
findFirst: vi.fn(async () => ({
|
|
id: "bot-1",
|
|
spaceId: "space-1",
|
|
userId: "owner-1",
|
|
name: "Chief",
|
|
modelProvider: null,
|
|
modelId: null,
|
|
})),
|
|
},
|
|
externalMessage: {
|
|
findMany,
|
|
updateMany,
|
|
},
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
reconcileIntervalMs: 60_000,
|
|
});
|
|
|
|
await bridge.start();
|
|
await bridge.stop();
|
|
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
status: "deferred",
|
|
engagementReason: {
|
|
in: ["message_routine_routing", "message_routine_routing_rearmed"],
|
|
},
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
externalConversation: { provider: "slack", botId: "bot-1", spaceId: "space-1" },
|
|
},
|
|
data: { engagementReason: null, nextAttemptAt: expect.any(Date) },
|
|
});
|
|
});
|
|
|
|
it("releases expired routing ownership even when transcript mirroring fails", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findMany = vi.fn(async () => {
|
|
throw new Error("mirror failed");
|
|
});
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
bot: {
|
|
findFirst: vi.fn(async () => ({
|
|
id: "bot-1",
|
|
spaceId: "space-1",
|
|
userId: "owner-1",
|
|
name: "Chief",
|
|
modelProvider: null,
|
|
modelId: null,
|
|
})),
|
|
},
|
|
externalMessage: { findMany, updateMany },
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
await expect(bridge.start()).rejects.toThrow("mirror failed");
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
engagementReason: {
|
|
in: ["message_routine_routing", "message_routine_routing_rearmed"],
|
|
},
|
|
nextAttemptAt: { lte: expect.any(Date) },
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not queue a received message that already woke a message routine", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findUnique = vi.fn(async () => ({ id: "msg-routine-wake" }));
|
|
const sendUserMessage = vi.fn();
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany },
|
|
message: { findUnique },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
queue(message: {
|
|
id: string;
|
|
providerEventId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
content: string;
|
|
batchContext: null;
|
|
externalConversation: {
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
thread: { id: string };
|
|
};
|
|
}): Promise<void>;
|
|
}
|
|
).queue({
|
|
id: "external-received",
|
|
providerEventId: "Ev-woken",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "hello",
|
|
batchContext: null,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
});
|
|
|
|
expect(findUnique).toHaveBeenCalled();
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: { id: "external-received", status: "received" },
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("abandons a queueing reservation when the wake nonce appears after claim", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const findUnique = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ id: "msg-routine-wake" });
|
|
const sendUserMessage = vi.fn();
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany },
|
|
message: { findUnique },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
queue(message: {
|
|
id: string;
|
|
providerEventId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
content: string;
|
|
batchContext: null;
|
|
externalConversation: {
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
thread: { id: string };
|
|
};
|
|
}): Promise<void>;
|
|
}
|
|
).queue({
|
|
id: "external-claimed",
|
|
providerEventId: "Ev-woken",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "hello",
|
|
batchContext: null,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
});
|
|
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
id: "external-claimed",
|
|
status: "received",
|
|
OR: [
|
|
{ engagementReason: null },
|
|
{
|
|
NOT: {
|
|
engagementReason: {
|
|
in: [
|
|
"message_routine_routing",
|
|
"message_routine_routing_rearmed",
|
|
"message_teamchat_agent",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
data: expect.objectContaining({
|
|
status: "queueing",
|
|
engagementReason: "message_teamchat_agent",
|
|
}),
|
|
}),
|
|
);
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: { id: "external-claimed", status: "queueing", runId: null },
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks fallback queueing when an in-flight wake commits after the queueing claim", async () => {
|
|
// Lost-lease path can promote/queue while wakeMessageRoutines is still in
|
|
// flight. The final pre-create barrier must see the routine nonce and abandon
|
|
// before creating a distinct TeamChat agent run.
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const messageFindUnique = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null) // pre-claim
|
|
.mockResolvedValueOnce(null) // post-claim
|
|
.mockResolvedValueOnce({ id: "msg-routine-wake" }); // final barrier
|
|
const sendUserMessage = vi.fn();
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: {
|
|
updateMany,
|
|
findUnique: vi.fn(async () => ({
|
|
status: "queueing",
|
|
engagementReason: "message_teamchat_agent",
|
|
})),
|
|
},
|
|
message: { findUnique: messageFindUnique },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
queue(message: {
|
|
id: string;
|
|
providerEventId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
content: string;
|
|
batchContext: null;
|
|
engagementReason?: string | null;
|
|
externalConversation: {
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
thread: { id: string };
|
|
};
|
|
}): Promise<void>;
|
|
}
|
|
).queue({
|
|
id: "external-raced",
|
|
providerEventId: "Ev-raced",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "hello",
|
|
batchContext: null,
|
|
engagementReason: null,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
});
|
|
|
|
expect(messageFindUnique).toHaveBeenCalledTimes(3);
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: { id: "external-raced", status: "queueing", runId: null },
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not continue an agent run when a routine wake wins during sendUserMessage", async () => {
|
|
// Pause inside sendUserMessage after the final pre-create ownership/nonce
|
|
// checks so a concurrent wake can commit first; only one path may continue.
|
|
let releaseSend!: () => void;
|
|
const sendGate = new Promise<void>((resolve) => {
|
|
releaseSend = resolve;
|
|
});
|
|
let woken = false;
|
|
const messageFindUnique = vi.fn(async () => (woken ? { id: "msg-routine-wake" } : null));
|
|
const enqueue = vi.fn();
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const runUpdateMany = vi.fn(async () => ({ count: 1 }));
|
|
const taskUpdateMany = vi.fn(async () => ({ count: 1 }));
|
|
const sendUserMessage = vi.fn(async () => {
|
|
await sendGate;
|
|
return { messageId: "message-1", runId: "run-1", seq: 1, taskId: "task-1" };
|
|
});
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: {
|
|
updateMany,
|
|
findUnique: vi.fn(async () => ({
|
|
status: "queueing",
|
|
engagementReason: "message_teamchat_agent",
|
|
})),
|
|
},
|
|
message: { findUnique: messageFindUnique },
|
|
run: { updateMany: runUpdateMany },
|
|
task: { updateMany: taskUpdateMany },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
const queuePromise = (
|
|
bridge as unknown as {
|
|
queue(message: {
|
|
id: string;
|
|
providerEventId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
content: string;
|
|
batchContext: null;
|
|
engagementReason?: string | null;
|
|
externalConversation: {
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
thread: { id: string };
|
|
};
|
|
}): Promise<void>;
|
|
}
|
|
).queue({
|
|
id: "external-atomic",
|
|
providerEventId: "Ev-atomic",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "hello",
|
|
batchContext: null,
|
|
engagementReason: null,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendUserMessage).toHaveBeenCalled();
|
|
});
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
id: "external-atomic",
|
|
status: "received",
|
|
OR: [
|
|
{ engagementReason: null },
|
|
{
|
|
NOT: {
|
|
engagementReason: {
|
|
in: [
|
|
"message_routine_routing",
|
|
"message_routine_routing_rearmed",
|
|
"message_teamchat_agent",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
data: expect.objectContaining({
|
|
status: "queueing",
|
|
engagementReason: "message_teamchat_agent",
|
|
}),
|
|
}),
|
|
);
|
|
// Simulate wakeMessageRoutines committing after the final pre-create check.
|
|
woken = true;
|
|
releaseSend();
|
|
await queuePromise;
|
|
|
|
expect(enqueue).not.toHaveBeenCalled();
|
|
expect(runUpdateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "run-1" }),
|
|
data: expect.objectContaining({ status: "cancelled" }),
|
|
}),
|
|
);
|
|
expect(taskUpdateMany).toHaveBeenCalledWith({
|
|
where: { id: "task-1" },
|
|
data: { status: "cancelled" },
|
|
});
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: { id: "external-atomic", status: "queueing", runId: null },
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("blocks fallback queueing when routing ownership remains after lease loss", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const sendUserMessage = vi.fn();
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: {
|
|
updateMany,
|
|
findUnique: vi.fn(async () => ({
|
|
status: "queueing",
|
|
engagementReason: "message_routine_routing",
|
|
})),
|
|
},
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
queue(message: {
|
|
id: string;
|
|
providerEventId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
content: string;
|
|
batchContext: null;
|
|
engagementReason?: string | null;
|
|
externalConversation: {
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
thread: { id: string };
|
|
};
|
|
}): Promise<void>;
|
|
}
|
|
).queue({
|
|
id: "external-owned",
|
|
providerEventId: "Ev-owned",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "hello",
|
|
batchContext: null,
|
|
// Snapshot missed ownership (e.g. stale reconcile read after lease loss).
|
|
engagementReason: null,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
});
|
|
|
|
expect(updateMany).toHaveBeenCalledWith({
|
|
where: { id: "external-owned", status: "queueing", runId: null },
|
|
data: {
|
|
status: "ignored",
|
|
engagementReason: "message_routine_wake",
|
|
nextAttemptAt: null,
|
|
},
|
|
});
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not queue a stale received snapshot after another path deferred it", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 0 }));
|
|
const sendUserMessage = vi.fn();
|
|
const bridge = new TeamChatBridge({
|
|
prisma: {
|
|
externalMessage: { updateMany },
|
|
message: { findUnique: vi.fn(async () => null) },
|
|
} as unknown as PrismaClient,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
queue(message: {
|
|
id: string;
|
|
providerEventId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
content: string;
|
|
batchContext: null;
|
|
externalConversation: {
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
thread: { id: string };
|
|
};
|
|
}): Promise<void>;
|
|
}
|
|
).queue({
|
|
id: "external-1",
|
|
providerEventId: "Ev-1",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
content: "Run the release routine",
|
|
batchContext: null,
|
|
externalConversation: {
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-1" },
|
|
},
|
|
});
|
|
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ id: "external-1", status: "received" }),
|
|
}),
|
|
);
|
|
expect(sendUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates one isolated run and one reply for duplicate provider events", async () => {
|
|
const records: Array<Record<string, unknown>> = [];
|
|
const sendUserMessage = vi.fn(async (input: { createRun?: boolean }) =>
|
|
input.createRun === false
|
|
? { messageId: "message-visible", seq: 1, taskId: null, runId: null }
|
|
: { messageId: "message-prompt", seq: 2, taskId: "task-1", runId: "run-1" },
|
|
);
|
|
const enqueue = vi.fn(async () => undefined);
|
|
const sent: TeamChatSendRequest[] = [];
|
|
const send = vi.fn(async (request: TeamChatSendRequest) => {
|
|
sent.push(request);
|
|
return { handle: `reply-${sent.length}` };
|
|
});
|
|
|
|
const conversation = {
|
|
id: "conversation-1",
|
|
provider: "slack",
|
|
workspaceId: "T-1",
|
|
externalKey: "channel:C-1:100.1",
|
|
conversationId: "C-1",
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
displayName: "Leadership",
|
|
participantNames: ["Ada", "Grace", "Arthur"],
|
|
teamChatAmbientEnabled: null,
|
|
teamChatRules: null,
|
|
automatedSenderPolicies: {},
|
|
thread: { id: "thread-1" },
|
|
};
|
|
|
|
const prisma = {
|
|
bot: {
|
|
findFirst: vi.fn(async () => ({
|
|
id: "bot-1",
|
|
spaceId: "space-1",
|
|
userId: "owner-1",
|
|
name: "Arthur",
|
|
modelProvider: null,
|
|
modelId: null,
|
|
teamChatAmbientEnabled: false,
|
|
teamChatRules: "",
|
|
})),
|
|
},
|
|
externalConversation: { upsert: vi.fn(async () => conversation) },
|
|
externalMessage: {
|
|
upsert: vi.fn(async ({ create }: { create: Record<string, unknown> }) => {
|
|
const existing = records.find((r) => r.providerEventId === create.providerEventId);
|
|
if (existing) return { ...existing, externalConversation: conversation };
|
|
const record = {
|
|
id: "external-1",
|
|
status: create.status ?? "received",
|
|
attempts: 0,
|
|
runId: null,
|
|
threadMessageId: null,
|
|
replyThreadId: create.replyThreadId ?? null,
|
|
kind: create.kind ?? "mention",
|
|
senderId: create.senderId,
|
|
senderName: create.senderName,
|
|
senderIsBot: create.senderIsBot ?? false,
|
|
content: create.content,
|
|
providerEventId: create.providerEventId,
|
|
batchContext: null,
|
|
nextAttemptAt: null,
|
|
createdAt: new Date(0),
|
|
externalConversationId: conversation.id,
|
|
externalConversation: conversation,
|
|
};
|
|
records.push(record);
|
|
return record;
|
|
}),
|
|
findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
|
if (where.threadMessageId === null) {
|
|
return records
|
|
.filter((r) => r.threadMessageId === null)
|
|
.map((r) => ({ ...r, externalConversation: conversation }));
|
|
}
|
|
if (where.status === "received") {
|
|
return records
|
|
.filter((r) => r.status === "received")
|
|
.map((r) => ({ ...r, externalConversation: conversation }));
|
|
}
|
|
if (
|
|
typeof where.status === "object" &&
|
|
where.status &&
|
|
"in" in (where.status as object)
|
|
) {
|
|
const statuses = (where.status as { in: string[] }).in;
|
|
return records
|
|
.filter((r) => statuses.includes(String(r.status)))
|
|
.map((r) => ({
|
|
...r,
|
|
externalConversation: conversation,
|
|
run: r.runId === "run-1" ? { id: "run-1", status: "completed", error: null } : null,
|
|
}));
|
|
}
|
|
if (where.status === "observed") return [];
|
|
return [];
|
|
}),
|
|
update: vi.fn(
|
|
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
const record = records.find((r) => r.id === where.id);
|
|
Object.assign(record ?? {}, data);
|
|
return record;
|
|
},
|
|
),
|
|
updateMany: vi.fn(
|
|
async ({
|
|
where,
|
|
data,
|
|
}: {
|
|
where: Record<string, unknown>;
|
|
data: Record<string, unknown>;
|
|
}) => {
|
|
let count = 0;
|
|
for (const record of records) {
|
|
if (where.id && record.id !== where.id) continue;
|
|
if (where.status && record.status !== where.status) continue;
|
|
if (
|
|
"providerReplyHandle" in where &&
|
|
where.providerReplyHandle === null &&
|
|
record.providerReplyHandle
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
typeof where.providerReplyHandle === "object" &&
|
|
where.providerReplyHandle &&
|
|
"not" in (where.providerReplyHandle as object) &&
|
|
!record.providerReplyHandle
|
|
) {
|
|
continue;
|
|
}
|
|
Object.assign(record, data);
|
|
count += 1;
|
|
}
|
|
return { count };
|
|
},
|
|
),
|
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
return records.find((record) => record.id === where.id) ?? null;
|
|
}),
|
|
findFirst: vi.fn(async ({ where }: { where?: Record<string, unknown> } = {}) => {
|
|
if (!where) return null;
|
|
return (
|
|
records.find((record) => {
|
|
if (where.id && record.id !== where.id) return false;
|
|
if (where.status && record.status !== where.status) return false;
|
|
if (
|
|
typeof where.providerReplyHandle === "object" &&
|
|
where.providerReplyHandle &&
|
|
"not" in (where.providerReplyHandle as object)
|
|
) {
|
|
return Boolean(record.providerReplyHandle);
|
|
}
|
|
return true;
|
|
}) ?? null
|
|
);
|
|
}),
|
|
},
|
|
run: { findMany: vi.fn(async () => []) },
|
|
message: {
|
|
findUnique: vi.fn(async () => null),
|
|
findFirst: vi.fn(async () => ({
|
|
blocks: [{ kind: "text", text: "The launch plan is ready." }],
|
|
})),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
const bridge = new TeamChatBridge({
|
|
prisma,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue },
|
|
send,
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
reconcileIntervalMs: 60_000,
|
|
});
|
|
|
|
const inbound: TeamChatInboundMessage = {
|
|
eventId: "Ev-1",
|
|
workspaceId: "T-1",
|
|
kind: "mention",
|
|
conversationKey: "channel:C-1:100.1",
|
|
conversationId: "C-1",
|
|
replyThreadId: "100.1",
|
|
senderId: "U-1",
|
|
senderName: "Ada",
|
|
conversationName: "Leadership",
|
|
participantNames: ["Ada", "Grace", "Arthur"],
|
|
content: "Review the launch plan",
|
|
};
|
|
|
|
await bridge.start();
|
|
await expect(bridge.receive(inbound)).resolves.toEqual({
|
|
spaceId: "space-1",
|
|
userId: "owner-1",
|
|
botId: "bot-1",
|
|
threadId: "thread-1",
|
|
});
|
|
await bridge.receive(inbound);
|
|
await bridge.stop();
|
|
|
|
expect(prisma.externalConversation.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
create: expect.objectContaining({
|
|
botId: "bot-1",
|
|
displayName: "Leadership",
|
|
participantNames: ["Ada", "Grace", "Arthur"],
|
|
thread: { create: { spaceId: "space-1", userId: "owner-1" } },
|
|
}),
|
|
}),
|
|
);
|
|
expect(sendUserMessage).toHaveBeenCalledTimes(2);
|
|
expect(sendUserMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
blocks: [{ kind: "text", text: "Review the launch plan" }],
|
|
createRun: false,
|
|
clientNonce: "teamchat-transcript:slack:Ev-1",
|
|
}),
|
|
);
|
|
expect(sendUserMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spaceId: "space-1",
|
|
threadId: "thread-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
prompt: "Slack message from Ada:\n\nReview the launch plan",
|
|
trigger: "messaging",
|
|
clientNonce: "teamchat:slack:Ev-1",
|
|
}),
|
|
);
|
|
expect(enqueue).toHaveBeenCalledTimes(1);
|
|
expect(sent).toEqual([
|
|
expect.objectContaining({
|
|
conversationId: "C-1",
|
|
replyThreadId: "100.1",
|
|
content: "The launch plan is ready.",
|
|
}),
|
|
]);
|
|
expect(records[0]).toMatchObject({ status: "delivered" });
|
|
});
|
|
|
|
it("repairs previously observed messages that do not have transcript rows", async () => {
|
|
const conversation = {
|
|
id: "conversation-legacy",
|
|
provider: "slack",
|
|
workspaceId: "T-1",
|
|
conversationId: "C-1",
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
userId: "owner-1",
|
|
thread: { id: "thread-legacy" },
|
|
};
|
|
const record = {
|
|
id: "external-legacy",
|
|
providerEventId: "Ev-legacy",
|
|
senderName: "Pat",
|
|
content: "This ordinary message was already observed.",
|
|
threadMessageId: null as string | null,
|
|
status: "ignored",
|
|
externalConversation: conversation,
|
|
};
|
|
const sendUserMessage = vi.fn(async () => ({
|
|
messageId: "message-visible",
|
|
seq: 1,
|
|
taskId: null,
|
|
runId: null,
|
|
}));
|
|
const prisma = {
|
|
bot: {
|
|
findFirst: vi.fn(async () => ({
|
|
id: "bot-1",
|
|
spaceId: "space-1",
|
|
userId: "owner-1",
|
|
name: "Arthur",
|
|
modelProvider: null,
|
|
modelId: null,
|
|
})),
|
|
},
|
|
externalMessage: {
|
|
findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
|
if (where.threadMessageId === null && record.threadMessageId === null) {
|
|
return [{ ...record, externalConversation: conversation }];
|
|
}
|
|
return [];
|
|
}),
|
|
update: vi.fn(async ({ data }: { data: { threadMessageId?: string } }) => {
|
|
if (data.threadMessageId) record.threadMessageId = data.threadMessageId;
|
|
return record;
|
|
}),
|
|
updateMany: vi.fn(async () => ({ count: 0 })),
|
|
},
|
|
run: { findMany: vi.fn(async () => []) },
|
|
} as unknown as PrismaClient;
|
|
|
|
const bridge = new TeamChatBridge({
|
|
prisma,
|
|
events: { sendUserMessage },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
reconcileIntervalMs: 60_000,
|
|
});
|
|
|
|
await bridge.start();
|
|
await bridge.stop();
|
|
|
|
expect(sendUserMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
threadId: "thread-legacy",
|
|
blocks: [{ kind: "text", text: "This ordinary message was already observed." }],
|
|
createRun: false,
|
|
}),
|
|
);
|
|
expect(record.threadMessageId).toBe("message-visible");
|
|
});
|
|
it("does not reopen deliveries finalized as unconfirmed", async () => {
|
|
const update = vi.fn();
|
|
const updateMany = vi.fn(async () => ({ count: 0 }));
|
|
const prisma = {
|
|
externalMessage: {
|
|
findUnique: vi.fn(async () => ({
|
|
status: "delivered",
|
|
providerReplyHandle: "unconfirmed",
|
|
})),
|
|
update,
|
|
updateMany,
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
const bridge = new TeamChatBridge({
|
|
prisma,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
reconcileIntervalMs: 60_000,
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
retry: (
|
|
message: { id: string; status: string; attempts: number },
|
|
error: unknown,
|
|
) => Promise<void>;
|
|
}
|
|
).retry({ id: "message-1", status: "delivering", attempts: 0 }, new Error("send failed"));
|
|
|
|
expect(update).not.toHaveBeenCalled();
|
|
expect(updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("only retries rows that are still delivering without a provider handle", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 0 }));
|
|
const prisma = {
|
|
externalMessage: {
|
|
findUnique: vi.fn(async () => ({
|
|
status: "delivering",
|
|
providerReplyHandle: null,
|
|
})),
|
|
update: vi.fn(),
|
|
updateMany,
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
const bridge = new TeamChatBridge({
|
|
prisma,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
reconcileIntervalMs: 60_000,
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
retry: (
|
|
message: { id: string; status: string; attempts: number },
|
|
error: unknown,
|
|
) => Promise<void>;
|
|
}
|
|
).retry({ id: "message-2", status: "running", attempts: 1 }, new Error("send failed"));
|
|
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
id: "message-2",
|
|
providerReplyHandle: null,
|
|
status: "delivering",
|
|
},
|
|
data: expect.objectContaining({
|
|
status: "delivering",
|
|
attempts: 2,
|
|
lastError: "send failed",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not reopen a linked run from a stale received snapshot", async () => {
|
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
|
const prisma = {
|
|
externalMessage: {
|
|
findUnique: vi.fn(async () => ({
|
|
status: "running",
|
|
providerReplyHandle: null,
|
|
})),
|
|
update: vi.fn(),
|
|
updateMany,
|
|
},
|
|
} as unknown as PrismaClient;
|
|
const bridge = new TeamChatBridge({
|
|
prisma,
|
|
events: { sendUserMessage: vi.fn() },
|
|
jobs: { enqueue: vi.fn() },
|
|
send: vi.fn(),
|
|
providerId: "slack",
|
|
botId: "bot-1",
|
|
});
|
|
|
|
await (
|
|
bridge as unknown as {
|
|
retry: (
|
|
message: { id: string; status: string; attempts: number },
|
|
error: unknown,
|
|
) => Promise<void>;
|
|
}
|
|
).retry({ id: "message-3", status: "received", attempts: 0 }, new Error("enqueue failed"));
|
|
|
|
expect(updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
id: "message-3",
|
|
providerReplyHandle: null,
|
|
status: "running",
|
|
},
|
|
data: expect.objectContaining({ status: "running", lastError: "enqueue failed" }),
|
|
}),
|
|
);
|
|
});
|
|
});
|