Skip to content

Commit cbc2edc

Browse files
committed
test(webapp): pin the watch submit's ordering, its retry repair and its refusal record
1 parent 49a9538 commit cbc2edc

2 files changed

Lines changed: 258 additions & 1 deletion

File tree

apps/webapp/app/services/dashboardAgentWatches.server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,16 @@ export async function submitDashboardAgentWatch(params: {
525525
}
526526
}
527527

528+
// The consent record is already in the transcript, so the refusal is recorded under it
529+
// rather than left to a toast the reload forgets. Keyed off the request, so a retry
530+
// that succeeds adds its confirmation and this stays the record of the attempt.
528531
if (!result.ok) {
532+
const refusal: WatchTranscriptMessage = {
533+
id: `${WATCH_CONFIRMATION_MESSAGE_ID_PREFIX}refused:${clientRequestId}`,
534+
role: "assistant",
535+
parts: [{ type: "text", text: result.error }],
536+
};
537+
await appendChatMessageOnce(dashboardAgentDb, { chatId, userId, message: refusal });
529538
return { ...result, chatId };
530539
}
531540

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 249 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
appendChatMessage,
3+
appendChatMessageOnce,
34
armWatchBatch,
45
cancelWatch,
56
chatExists,
@@ -10,6 +11,7 @@ import {
1011
listWatchBatchGroupsToArm,
1112
stopWatchBatch,
1213
countUnreadWatchWakes,
14+
countUserMessages,
1315
createChat,
1416
createDashboardAgentDb,
1517
getChatMessages,
@@ -27,7 +29,7 @@ import {
2729
type DashboardAgentDbClient,
2830
type Watch,
2931
} from "@internal/dashboard-agent-db";
30-
import type { WatchSpec } from "@internal/dashboard-agent-contracts";
32+
import type { WatchDraft, WatchSpec } from "@internal/dashboard-agent-contracts";
3133
import { postgresTest } from "@internal/testcontainers";
3234
import type { PrismaClient } from "@trigger.dev/database";
3335
import { readdirSync, readFileSync } from "node:fs";
@@ -86,6 +88,7 @@ const {
8688
createDashboardAgentWatch,
8789
deleteChatWithWatches,
8890
listActiveWatchesForChats,
91+
submitDashboardAgentWatch,
8992
watchBatchStaleMs,
9093
} = await import("~/services/dashboardAgentWatches.server");
9194
const { action: checkAction } =
@@ -1602,6 +1605,251 @@ describe("the check endpoint", () => {
16021605
});
16031606
});
16041607

1608+
/** A configured card, with both follow-ups off unless a test turns one on. */
1609+
function draftFor(spec: WatchSpec, followUp: Partial<WatchDraft["followUp"]> = {}): WatchDraft {
1610+
return {
1611+
spec,
1612+
followUp: { investigateOnAttention: false, notifyExternally: false, ...followUp },
1613+
};
1614+
}
1615+
1616+
function submit(args: {
1617+
seeded: Seeded;
1618+
draft?: WatchDraft;
1619+
chatId?: string;
1620+
clientRequestId?: string;
1621+
checkDeps?: Partial<WatchCheckDeps>;
1622+
subscribed?: boolean;
1623+
onSchedule?: () => void;
1624+
}) {
1625+
return submitDashboardAgentWatch({
1626+
environment: authenticated(args.seeded),
1627+
userId: args.seeded.user.id,
1628+
organizationId: args.seeded.organization.id,
1629+
chatId: args.chatId,
1630+
clientRequestId: args.clientRequestId ?? "wreq_1",
1631+
draft: args.draft ?? draftFor(RUN_START),
1632+
deps: {
1633+
configured: () => true,
1634+
checkDeps: () => fakeCheckDeps(args.checkDeps),
1635+
scheduleTick: async () => args.onSchedule?.(),
1636+
subscribe: async () =>
1637+
args.subscribed === false
1638+
? { ok: false, reason: "dashboard_agent_disabled" }
1639+
: { ok: true, email: args.seeded.user.email },
1640+
},
1641+
});
1642+
}
1643+
1644+
function storedMessages(seeded: Seeded, chatId: string) {
1645+
return getChatMessages(ctx.agentDb, {
1646+
chatId,
1647+
userId: seeded.user.id,
1648+
organizationId: seeded.organization.id,
1649+
}) as Promise<Array<{ id: string; role: string }> | null>;
1650+
}
1651+
1652+
describe("the watch card submit", () => {
1653+
postgresTest(
1654+
"records what the user confirmed before the watch, and confirms it after",
1655+
async ({ prisma, postgresContainer }) => {
1656+
await boot(prisma, postgresContainer.getConnectionUri());
1657+
const seeded = await seed(prisma, "submit");
1658+
await seedChat(seeded);
1659+
1660+
const result = await submit({
1661+
seeded,
1662+
chatId: "chat_1",
1663+
draft: draftFor(RUN_START, { investigateOnAttention: true }),
1664+
});
1665+
1666+
expect(result.ok).toBe(true);
1667+
if (!result.ok) return;
1668+
expect(result.watching).toBe(true);
1669+
expect(result.repaired).toBe(false);
1670+
1671+
const stored = await storedMessages(seeded, "chat_1");
1672+
expect(stored?.map((message) => message.id)).toEqual([
1673+
"watch-request:wreq_1",
1674+
`watch-confirmation:${result.watchId}`,
1675+
]);
1676+
// The consent record is the user's, and it states the condition and the lifetime.
1677+
expect(stored?.[0]).toMatchObject({ role: "user" });
1678+
expect(JSON.stringify(stored?.[0])).toContain("Watch run run_1 until it starts.");
1679+
expect(JSON.stringify(stored?.[0])).toContain("Investigate straight away");
1680+
expect(result.messages.map((message) => message.id)).toEqual(
1681+
stored?.map((message) => message.id)
1682+
);
1683+
}
1684+
);
1685+
1686+
postgresTest(
1687+
"leaves a repairable state when the confirmation never lands, and the retry repairs it",
1688+
async ({ prisma, postgresContainer }) => {
1689+
await boot(prisma, postgresContainer.getConnectionUri());
1690+
const seeded = await seed(prisma, "submit-repair");
1691+
await seedChat(seeded);
1692+
1693+
// The crash state: the request record is written and the watch is live, but the
1694+
// process died before the confirmation was appended.
1695+
await appendChatMessageOnce(ctx.agentDb, {
1696+
chatId: "chat_1",
1697+
userId: seeded.user.id,
1698+
message: { id: "watch-request:wreq_1", role: "user", parts: [] } as never,
1699+
});
1700+
const created = await create({ seeded, chatId: "chat_1" });
1701+
expect(created.ok).toBe(true);
1702+
if (!created.ok || !created.watching) return;
1703+
1704+
const retry = await submit({ seeded, chatId: "chat_1", clientRequestId: "wreq_1" });
1705+
1706+
expect(retry.ok).toBe(true);
1707+
if (!retry.ok) return;
1708+
expect(retry.repaired).toBe(true);
1709+
expect(retry.watchId).toBe(created.watchId);
1710+
1711+
const stored = await storedMessages(seeded, "chat_1");
1712+
expect(stored?.map((message) => message.id)).toEqual([
1713+
"watch-request:wreq_1",
1714+
`watch-confirmation:${created.watchId}`,
1715+
]);
1716+
1717+
// Still exactly one watch: the repair loaded it rather than creating another.
1718+
const active = await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" });
1719+
expect(active).toHaveLength(1);
1720+
}
1721+
);
1722+
1723+
postgresTest(
1724+
"a retried submit duplicates neither record",
1725+
async ({ prisma, postgresContainer }) => {
1726+
await boot(prisma, postgresContainer.getConnectionUri());
1727+
const seeded = await seed(prisma, "submit-retry");
1728+
await seedChat(seeded);
1729+
1730+
const first = await submit({ seeded, chatId: "chat_1" });
1731+
const second = await submit({ seeded, chatId: "chat_1" });
1732+
1733+
expect(first.ok && second.ok).toBe(true);
1734+
if (!first.ok || !second.ok) return;
1735+
expect(second.repaired).toBe(true);
1736+
expect(second.watchId).toBe(first.watchId);
1737+
1738+
const stored = await storedMessages(seeded, "chat_1");
1739+
expect(stored?.map((message) => message.id)).toEqual([
1740+
"watch-request:wreq_1",
1741+
`watch-confirmation:${first.watchId}`,
1742+
]);
1743+
}
1744+
);
1745+
1746+
postgresTest(
1747+
"a genuinely different request still conflicts",
1748+
async ({ prisma, postgresContainer }) => {
1749+
await boot(prisma, postgresContainer.getConnectionUri());
1750+
const seeded = await seed(prisma, "submit-conflict");
1751+
await seedChat(seeded);
1752+
1753+
const first = await submit({ seeded, chatId: "chat_1" });
1754+
expect(first.ok).toBe(true);
1755+
if (!first.ok) return;
1756+
1757+
// Same condition, so the same identity, but a different window: not a retry.
1758+
const longer = await submit({
1759+
seeded,
1760+
chatId: "chat_1",
1761+
clientRequestId: "wreq_2",
1762+
draft: draftFor({ ...RUN_START, maxHours: 6 }),
1763+
});
1764+
expect(longer).toMatchObject({ ok: false, code: "duplicate", existingId: first.watchId });
1765+
1766+
// Same spec, different consent: also not a retry.
1767+
const investigating = await submit({
1768+
seeded,
1769+
chatId: "chat_1",
1770+
clientRequestId: "wreq_3",
1771+
draft: draftFor(RUN_START, { investigateOnAttention: true }),
1772+
});
1773+
expect(investigating).toMatchObject({ ok: false, code: "duplicate" });
1774+
1775+
// The refused attempts are recorded under their own consent records, so the
1776+
// transcript never shows a request with no answer.
1777+
const stored = await storedMessages(seeded, "chat_1");
1778+
expect(stored?.map((message) => message.id)).toEqual([
1779+
"watch-request:wreq_1",
1780+
`watch-confirmation:${first.watchId}`,
1781+
"watch-request:wreq_2",
1782+
"watch-confirmation:refused:wreq_2",
1783+
"watch-request:wreq_3",
1784+
"watch-confirmation:refused:wreq_3",
1785+
]);
1786+
}
1787+
);
1788+
1789+
postgresTest(
1790+
"a fresh panel's retry reuses the chat the first attempt created",
1791+
async ({ prisma, postgresContainer }) => {
1792+
await boot(prisma, postgresContainer.getConnectionUri());
1793+
const seeded = await seed(prisma, "submit-fresh");
1794+
1795+
const first = await submit({ seeded, clientRequestId: "wreq_fresh" });
1796+
const second = await submit({ seeded, clientRequestId: "wreq_fresh" });
1797+
1798+
expect(first.ok && second.ok).toBe(true);
1799+
if (!first.ok || !second.ok) return;
1800+
expect(second.chatId).toBe(first.chatId);
1801+
1802+
const stored = await storedMessages(seeded, first.chatId);
1803+
expect(stored).toHaveLength(2);
1804+
}
1805+
);
1806+
1807+
postgresTest(
1808+
"an answered condition records the request and a one-shot result, and never a watch",
1809+
async ({ prisma, postgresContainer }) => {
1810+
await boot(prisma, postgresContainer.getConnectionUri());
1811+
const seeded = await seed(prisma, "submit-oneshot");
1812+
await seedChat(seeded);
1813+
1814+
const result = await submit({
1815+
seeded,
1816+
chatId: "chat_1",
1817+
checkDeps: { readRun: async () => runRow({ status: "EXECUTING", startedAt: new Date() }) },
1818+
});
1819+
1820+
expect(result.ok).toBe(true);
1821+
if (!result.ok) return;
1822+
expect(result.watching).toBe(false);
1823+
expect(result.watchId).toBeNull();
1824+
1825+
const stored = await storedMessages(seeded, "chat_1");
1826+
expect(stored?.map((message) => message.id)).toEqual([
1827+
"watch-request:wreq_1",
1828+
"watch-confirmation:one-shot:wreq_1",
1829+
]);
1830+
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0);
1831+
}
1832+
);
1833+
1834+
postgresTest(
1835+
"the consent record never spends a message from the cap",
1836+
async ({ prisma, postgresContainer }) => {
1837+
await boot(prisma, postgresContainer.getConnectionUri());
1838+
const seeded = await seed(prisma, "submit-quota");
1839+
await seedChat(seeded);
1840+
1841+
await submit({ seeded, chatId: "chat_1" });
1842+
1843+
expect(
1844+
await countUserMessages(ctx.agentDb, {
1845+
organizationId: seeded.organization.id,
1846+
userId: seeded.user.id,
1847+
})
1848+
).toBe(0);
1849+
}
1850+
);
1851+
});
1852+
16051853
describe("appendChatMessage", () => {
16061854
postgresTest(
16071855
"appends in order without rewriting the transcript",

0 commit comments

Comments
 (0)