@@ -29,10 +29,13 @@ import {
2929} from "./helpers/sessionStream" ;
3030import { runChatAgentSession , runRealChatAgent } from "./helpers/agentHarness" ;
3131import {
32+ endAndContinueGuardEvents ,
3233 suspendResumeEvents ,
3334 testApprovalChatAgent ,
3435 testChatAgent ,
3536 testChatModelLocal ,
37+ testEndAndContinueCustomAgent ,
38+ testEndAndContinueIteratorGuardCustomAgent ,
3639 testEndRunChatAgent ,
3740 testHitlChatAgent ,
3841 testHitlIdleChatAgent ,
@@ -123,6 +126,34 @@ async function setupSession(agentId: string = testChatAgent.id) {
123126 return { addressingKey, token, apiKey, baseUrl : server . webapp . baseUrl } ;
124127}
125128
129+ async function setupStartedSession ( agentId : string ) {
130+ const { environment, apiKey } = await seedTestEnvironment ( server . prisma ) ;
131+ const addressingKey = `chat-${ randomBytes ( 6 ) . toString ( "hex" ) } ` ;
132+ const createRes = await fetch ( `${ server . webapp . baseUrl } /api/v1/sessions` , {
133+ method : "POST" ,
134+ headers : { Authorization : `Bearer ${ apiKey } ` , "Content-Type" : "application/json" } ,
135+ body : JSON . stringify ( {
136+ type : "chat.agent" ,
137+ externalId : addressingKey ,
138+ taskIdentifier : agentId ,
139+ triggerConfig : { basePayload : { } } ,
140+ } ) ,
141+ } ) ;
142+
143+ expect ( createRes . ok ) . toBe ( true ) ;
144+ const created = ( await createRes . json ( ) ) as {
145+ runId : string ;
146+ publicAccessToken : string ;
147+ } ;
148+ return {
149+ ...created ,
150+ addressingKey,
151+ apiKey,
152+ environment,
153+ baseUrl : server . webapp . baseUrl ,
154+ } ;
155+ }
156+
126157function promptText ( prompt : unknown ) : string {
127158 if ( ! Array . isArray ( prompt ) ) return "" ;
128159 let out = "" ;
@@ -1533,4 +1564,211 @@ describe("session agent e2e (real chat.agent loop)", () => {
15331564 await agent . close ( ) ;
15341565 }
15351566 } ) ;
1567+
1568+ it ( "EA23: custom endAndContinue hands pending input to a fresh run" , async ( ) => {
1569+ const { addressingKey, publicAccessToken, runId, apiKey, environment, baseUrl } =
1570+ await setupStartedSession ( testEndAndContinueCustomAgent . id ) ;
1571+ const initialRun = await server . prisma . taskRun . findFirstOrThrow ( {
1572+ where : { friendlyId : runId } ,
1573+ select : { id : true } ,
1574+ } ) ;
1575+
1576+ const append = await appendInput ( {
1577+ baseUrl,
1578+ addressingKey,
1579+ token : publicAccessToken ,
1580+ partId : "pending-handoff-input" ,
1581+ body : submitBody (
1582+ addressingKey ,
1583+ userMessage ( "deliver after endAndContinue" , "pending-handoff-input" )
1584+ ) ,
1585+ } ) ;
1586+ expect ( append . status ) . toBe ( 200 ) ;
1587+
1588+ const oldRun = runRealChatAgent ( {
1589+ agentId : testEndAndContinueCustomAgent . id ,
1590+ baseUrl,
1591+ addressingKey,
1592+ secretKey : apiKey ,
1593+ model : textModel ( "unused" ) ,
1594+ modelLocal : testChatModelLocal ,
1595+ runId,
1596+ } ) ;
1597+ let continuation : ReturnType < typeof runRealChatAgent > | undefined ;
1598+
1599+ try {
1600+ await expect ( oldRun . done ) . resolves . toBeUndefined ( ) ;
1601+
1602+ const session = await server . prisma . session . findFirstOrThrow ( {
1603+ where : { runtimeEnvironmentId : environment . id , externalId : addressingKey } ,
1604+ select : { currentRunId : true , currentRunVersion : true } ,
1605+ } ) ;
1606+ expect ( session . currentRunId ) . not . toBe ( initialRun . id ) ;
1607+ expect ( session . currentRunVersion ) . toBeGreaterThan ( 1 ) ;
1608+
1609+ const successor = await server . prisma . taskRun . findFirstOrThrow ( {
1610+ where : { id : session . currentRunId ! } ,
1611+ select : { friendlyId : true } ,
1612+ } ) ;
1613+ continuation = runRealChatAgent ( {
1614+ agentId : testEndAndContinueCustomAgent . id ,
1615+ baseUrl,
1616+ addressingKey,
1617+ secretKey : apiKey ,
1618+ model : textModel ( "unused" ) ,
1619+ modelLocal : testChatModelLocal ,
1620+ runId : successor . friendlyId ,
1621+ continuation : true ,
1622+ previousRunId : runId ,
1623+ } ) ;
1624+
1625+ const { parts } = await collectSessionOut ( {
1626+ baseUrl,
1627+ addressingKey,
1628+ token : publicAccessToken ,
1629+ until : ( p ) => p . some ( isTurnComplete ) ,
1630+ maxMs : 30_000 ,
1631+ } ) ;
1632+ expect ( joinChunks ( parts ) ) . toContain ( "received:deliver after endAndContinue" ) ;
1633+ await expect ( continuation . done ) . resolves . toBeUndefined ( ) ;
1634+ } finally {
1635+ await continuation ?. close ( ) ;
1636+ await oldRun . close ( ) ;
1637+ }
1638+ } ) ;
1639+
1640+ it ( "EA24: custom endAndContinue rejects when the server rejects the handoff" , async ( ) => {
1641+ const { addressingKey, apiKey, baseUrl } = await setupStartedSession (
1642+ testEndAndContinueCustomAgent . id
1643+ ) ;
1644+ const agent = runRealChatAgent ( {
1645+ agentId : testEndAndContinueCustomAgent . id ,
1646+ baseUrl,
1647+ addressingKey,
1648+ secretKey : apiKey ,
1649+ model : textModel ( "unused" ) ,
1650+ modelLocal : testChatModelLocal ,
1651+ runId : "run_missing_end_and_continue" ,
1652+ } ) ;
1653+
1654+ try {
1655+ await expect ( agent . done ) . rejects . toThrow ( "callingRunId not found in this environment" ) ;
1656+ } finally {
1657+ await agent . close ( ) ;
1658+ }
1659+ } ) ;
1660+
1661+ it ( "EA25: custom endAndContinue keeps the guard while iterator next is active" , async ( ) => {
1662+ const { addressingKey, publicAccessToken, runId, apiKey, environment, baseUrl } =
1663+ await setupStartedSession ( testEndAndContinueIteratorGuardCustomAgent . id ) ;
1664+ const initialRun = await server . prisma . taskRun . findFirstOrThrow ( {
1665+ where : { friendlyId : runId } ,
1666+ select : { id : true } ,
1667+ } ) ;
1668+
1669+ const append = await appendInput ( {
1670+ baseUrl,
1671+ addressingKey,
1672+ token : publicAccessToken ,
1673+ partId : "iterator-guard-initial-input" ,
1674+ body : submitBody (
1675+ addressingKey ,
1676+ userMessage ( "start iterator guard test" , "iterator-guard-initial-input" )
1677+ ) ,
1678+ } ) ;
1679+ expect ( append . status ) . toBe ( 200 ) ;
1680+
1681+ const agent = runRealChatAgent ( {
1682+ agentId : testEndAndContinueIteratorGuardCustomAgent . id ,
1683+ baseUrl,
1684+ addressingKey,
1685+ secretKey : apiKey ,
1686+ model : textModel ( "unused" ) ,
1687+ modelLocal : testChatModelLocal ,
1688+ runId,
1689+ } ) ;
1690+ let agentSettled = false ;
1691+ let agentFailure : unknown ;
1692+ void agent . done . then (
1693+ ( ) => {
1694+ agentSettled = true ;
1695+ } ,
1696+ ( error ) => {
1697+ agentSettled = true ;
1698+ agentFailure = error ;
1699+ }
1700+ ) ;
1701+ let continuation : ReturnType < typeof runRealChatAgent > | undefined ;
1702+
1703+ try {
1704+ await waitFor (
1705+ ( ) =>
1706+ agentSettled ||
1707+ endAndContinueGuardEvents . some (
1708+ ( event ) => event . chatId === addressingKey && event . kind === "guard-held"
1709+ ) ,
1710+ 20_000
1711+ ) ;
1712+ if ( agentFailure ) throw agentFailure ;
1713+ expect ( agentSettled ) . toBe ( false ) ;
1714+ expect (
1715+ endAndContinueGuardEvents . some (
1716+ ( event ) => event . chatId === addressingKey && event . kind === "guard-held"
1717+ )
1718+ ) . toBe ( true ) ;
1719+
1720+ const release = await appendInput ( {
1721+ baseUrl,
1722+ addressingKey,
1723+ token : publicAccessToken ,
1724+ partId : "iterator-guard-release-input" ,
1725+ body : submitBody (
1726+ addressingKey ,
1727+ userMessage ( "release pending next" , "iterator-guard-release-input" )
1728+ ) ,
1729+ } ) ;
1730+ expect ( release . status ) . toBe ( 200 ) ;
1731+ await expect ( agent . done ) . resolves . toBeUndefined ( ) ;
1732+
1733+ expect (
1734+ endAndContinueGuardEvents . some (
1735+ ( event ) => event . chatId === addressingKey && event . kind === "return-settled"
1736+ )
1737+ ) . toBe ( true ) ;
1738+ const session = await server . prisma . session . findFirstOrThrow ( {
1739+ where : { runtimeEnvironmentId : environment . id , externalId : addressingKey } ,
1740+ select : { currentRunId : true } ,
1741+ } ) ;
1742+ expect ( session . currentRunId ) . not . toBe ( initialRun . id ) ;
1743+
1744+ const successor = await server . prisma . taskRun . findFirstOrThrow ( {
1745+ where : { id : session . currentRunId ! } ,
1746+ select : { friendlyId : true } ,
1747+ } ) ;
1748+ continuation = runRealChatAgent ( {
1749+ agentId : testEndAndContinueIteratorGuardCustomAgent . id ,
1750+ baseUrl,
1751+ addressingKey,
1752+ secretKey : apiKey ,
1753+ model : textModel ( "unused" ) ,
1754+ modelLocal : testChatModelLocal ,
1755+ runId : successor . friendlyId ,
1756+ continuation : true ,
1757+ previousRunId : runId ,
1758+ } ) ;
1759+
1760+ const { parts } = await collectSessionOut ( {
1761+ baseUrl,
1762+ addressingKey,
1763+ token : publicAccessToken ,
1764+ until : ( records ) => records . filter ( isTurnComplete ) . length >= 2 ,
1765+ maxMs : 30_000 ,
1766+ } ) ;
1767+ expect ( joinChunks ( parts ) ) . toContain ( "received:release pending next" ) ;
1768+ await expect ( continuation . done ) . resolves . toBeUndefined ( ) ;
1769+ } finally {
1770+ await continuation ?. close ( ) ;
1771+ await agent . close ( ) ;
1772+ }
1773+ } ) ;
15361774} ) ;
0 commit comments