11import {
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" ;
3133import { postgresTest } from "@internal/testcontainers" ;
3234import type { PrismaClient } from "@trigger.dev/database" ;
3335import { 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" ) ;
9194const { 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+
16051853describe ( "appendChatMessage" , ( ) => {
16061854 postgresTest (
16071855 "appends in order without rewriting the transcript" ,
0 commit comments