@@ -34,10 +34,12 @@ import {
3434 testChatModelLocal ,
3535 testEndRunChatAgent ,
3636 testHitlChatAgent ,
37+ testHitlIdleChatAgent ,
3738 testIdleChatAgent ,
3839 testPlainChatAgent ,
3940 testToolChatAgent ,
4041 testUpgradeChatAgent ,
42+ testUpgradeOnceChatAgent ,
4143} from "./helpers/testChatAgent" ;
4244
4345async function waitFor ( predicate : ( ) => boolean , maxMs : number ) : Promise < void > {
@@ -1116,4 +1118,236 @@ describe("session agent e2e (real chat.agent loop)", () => {
11161118 await agent . close ( ) ;
11171119 }
11181120 } ) ;
1121+
1122+ it ( "EA16: a HITL tool approval survives a suspend/resume boundary" , async ( ) => {
1123+ const { addressingKey, token, apiKey, baseUrl } = await setupSession ( testHitlIdleChatAgent . id ) ;
1124+ const toolCallId = "tc_ask_suspend" ;
1125+ const agent = runRealChatAgent ( {
1126+ agentId : testHitlIdleChatAgent . id ,
1127+ baseUrl,
1128+ addressingKey,
1129+ secretKey : apiKey ,
1130+ model : toolCallThenText ( {
1131+ toolName : "askUser" ,
1132+ toolCallId,
1133+ input : { question : "what color?" } ,
1134+ finalText : "blue it is" ,
1135+ } ) ,
1136+ modelLocal : testChatModelLocal ,
1137+ } ) ;
1138+
1139+ try {
1140+ await appendInput ( {
1141+ baseUrl,
1142+ addressingKey,
1143+ token,
1144+ partId : "u1" ,
1145+ body : submitBody ( addressingKey , userMessage ( "pick a color" , "u1" ) ) ,
1146+ } ) ;
1147+ const turn1 = await collectSessionOut ( {
1148+ baseUrl,
1149+ addressingKey,
1150+ token,
1151+ until : ( p ) => p . some ( isTurnComplete ) ,
1152+ maxMs : 30_000 ,
1153+ } ) ;
1154+ expect ( joinChunks ( turn1 . parts ) , "turn 1 parks on the tool call" ) . toContain ( "askUser" ) ;
1155+
1156+ await new Promise ( ( r ) => setTimeout ( r , 2500 ) ) ;
1157+
1158+ const answer = {
1159+ id : "a-answer" ,
1160+ role : "assistant" ,
1161+ parts : [
1162+ {
1163+ type : "tool-askUser" ,
1164+ toolCallId,
1165+ state : "output-available" ,
1166+ input : { question : "what color?" } ,
1167+ output : { color : "blue" } ,
1168+ } ,
1169+ ] ,
1170+ } ;
1171+ await appendInput ( {
1172+ baseUrl,
1173+ addressingKey,
1174+ token,
1175+ partId : "u2" ,
1176+ body : submitBody ( addressingKey , answer ) ,
1177+ } ) ;
1178+ const turn2 = await collectSessionOut ( {
1179+ baseUrl,
1180+ addressingKey,
1181+ token,
1182+ until : ( p ) => joinChunks ( p ) . includes ( "blue it is" ) ,
1183+ maxMs : 30_000 ,
1184+ } ) ;
1185+ expect (
1186+ joinChunks ( turn2 . parts ) ,
1187+ "the answer sent after the idle window resumed the suspended run"
1188+ ) . toContain ( "blue it is" ) ;
1189+ } finally {
1190+ await agent . close ( ) ;
1191+ }
1192+ } ) ;
1193+
1194+ it ( "EA17: the run suspends and resumes across multiple turns" , async ( ) => {
1195+ const { addressingKey, token, apiKey, baseUrl } = await setupSession ( testIdleChatAgent . id ) ;
1196+ const replies = [ "reply-one" , "reply-two" , "reply-three" ] ;
1197+ const agent = runRealChatAgent ( {
1198+ agentId : testIdleChatAgent . id ,
1199+ baseUrl,
1200+ addressingKey,
1201+ secretKey : apiKey ,
1202+ model : sequenceModel ( replies ) ,
1203+ modelLocal : testChatModelLocal ,
1204+ } ) ;
1205+
1206+ try {
1207+ for ( let i = 0 ; i < replies . length ; i ++ ) {
1208+ await new Promise ( ( r ) => setTimeout ( r , i === 0 ? 1500 : 2000 ) ) ;
1209+ await appendInput ( {
1210+ baseUrl,
1211+ addressingKey,
1212+ token,
1213+ partId : `u${ i + 1 } ` ,
1214+ body : submitBody ( addressingKey , userMessage ( `turn ${ i + 1 } ` , `u${ i + 1 } ` ) ) ,
1215+ } ) ;
1216+ const { parts } = await collectSessionOut ( {
1217+ baseUrl,
1218+ addressingKey,
1219+ token,
1220+ until : ( p ) => joinChunks ( p ) . includes ( replies [ i ] ! ) ,
1221+ maxMs : 30_000 ,
1222+ } ) ;
1223+ expect (
1224+ joinChunks ( parts ) ,
1225+ `turn ${ i + 1 } resumed from a suspend and produced its reply`
1226+ ) . toContain ( replies [ i ] ! ) ;
1227+ }
1228+ } finally {
1229+ await agent . close ( ) ;
1230+ }
1231+ } ) ;
1232+
1233+ it ( "EA18: requestUpgrade defers the message; the upgraded run processes it" , async ( ) => {
1234+ const { addressingKey, token, apiKey, baseUrl } = await setupSession (
1235+ testUpgradeOnceChatAgent . id
1236+ ) ;
1237+ const session = runChatAgentSession ( {
1238+ agentId : testUpgradeOnceChatAgent . id ,
1239+ baseUrl,
1240+ addressingKey,
1241+ secretKey : apiKey ,
1242+ model : echoModel ( ) ,
1243+ modelLocal : testChatModelLocal ,
1244+ } ) ;
1245+
1246+ try {
1247+ await appendInput ( {
1248+ baseUrl,
1249+ addressingKey,
1250+ token,
1251+ partId : "u1" ,
1252+ body : submitBody ( addressingKey , userMessage ( "DEFER-ME" , "u1" ) ) ,
1253+ } ) ;
1254+ const turn1 = await collectSessionOut ( {
1255+ baseUrl,
1256+ addressingKey,
1257+ token,
1258+ until : ( p ) => p . some ( isUpgradeRequired ) ,
1259+ maxMs : 30_000 ,
1260+ } ) ;
1261+ expect (
1262+ turn1 . parts . some ( isUpgradeRequired ) ,
1263+ "the fresh run defers the message with upgrade-required"
1264+ ) . toBe ( true ) ;
1265+
1266+ await waitFor ( ( ) => session . runCount ( ) >= 2 , 10_000 ) ;
1267+
1268+ const { parts } = await collectSessionOut ( {
1269+ baseUrl,
1270+ addressingKey,
1271+ token,
1272+ until : ( p ) => joinChunks ( p ) . includes ( "DEFER-ME" ) ,
1273+ maxMs : 30_000 ,
1274+ } ) ;
1275+ expect (
1276+ joinChunks ( parts ) ,
1277+ "the continuation run processed the deferred message instead of upgrading again"
1278+ ) . toContain ( "DEFER-ME" ) ;
1279+ } finally {
1280+ await session . close ( ) ;
1281+ }
1282+ } ) ;
1283+
1284+ it ( "EA19: endRun continuation restores history across multiple hops" , async ( ) => {
1285+ const { addressingKey, token, apiKey, baseUrl } = await setupSession ( testEndRunChatAgent . id ) ;
1286+ const session = runChatAgentSession ( {
1287+ agentId : testEndRunChatAgent . id ,
1288+ baseUrl,
1289+ addressingKey,
1290+ secretKey : apiKey ,
1291+ model : echoModel ( ) ,
1292+ modelLocal : testChatModelLocal ,
1293+ } ) ;
1294+
1295+ try {
1296+ await appendInput ( {
1297+ baseUrl,
1298+ addressingKey,
1299+ token,
1300+ partId : "u1" ,
1301+ body : submitBody ( addressingKey , userMessage ( "HOP-MARKER" , "u1" ) ) ,
1302+ } ) ;
1303+ await collectSessionOut ( {
1304+ baseUrl,
1305+ addressingKey,
1306+ token,
1307+ until : ( p ) => p . some ( isTurnComplete ) ,
1308+ maxMs : 30_000 ,
1309+ } ) ;
1310+
1311+ await waitFor ( ( ) => session . runCount ( ) >= 2 , 10_000 ) ;
1312+ await appendInput ( {
1313+ baseUrl,
1314+ addressingKey,
1315+ token,
1316+ partId : "u2" ,
1317+ body : submitBody ( addressingKey , userMessage ( "second hop" , "u2" ) ) ,
1318+ } ) ;
1319+ await collectSessionOut ( {
1320+ baseUrl,
1321+ addressingKey,
1322+ token,
1323+ until : ( p ) => joinChunks ( p ) . includes ( "second hop" ) ,
1324+ maxMs : 30_000 ,
1325+ } ) ;
1326+
1327+ await waitFor ( ( ) => session . runCount ( ) >= 3 , 10_000 ) ;
1328+ await appendInput ( {
1329+ baseUrl,
1330+ addressingKey,
1331+ token,
1332+ partId : "u3" ,
1333+ body : submitBody ( addressingKey , userMessage ( "third hop" , "u3" ) ) ,
1334+ } ) ;
1335+ const { parts } = await collectSessionOut ( {
1336+ baseUrl,
1337+ addressingKey,
1338+ token,
1339+ until : ( p ) => joinChunks ( p ) . includes ( "third hop" ) ,
1340+ maxMs : 30_000 ,
1341+ } ) ;
1342+
1343+ const blob = joinChunks ( parts ) ;
1344+ expect ( blob , "history from the first hop is restored two continuations later" ) . toContain (
1345+ "HOP-MARKER"
1346+ ) ;
1347+ expect ( blob ) . toContain ( "third hop" ) ;
1348+ expect ( session . runCount ( ) ) . toBeGreaterThanOrEqual ( 3 ) ;
1349+ } finally {
1350+ await session . close ( ) ;
1351+ }
1352+ } ) ;
11191353} ) ;
0 commit comments