@@ -1218,6 +1218,122 @@ describe("TriggerChatTransport", () => {
12181218 } ) ;
12191219 } ) ;
12201220
1221+ describe ( "watch mode across long-poll window boundaries" , ( ) => {
1222+ function settled ( response : Response ) : Response {
1223+ const headers = new Headers ( response . headers ) ;
1224+ headers . set ( "X-Session-Settled" , "true" ) ;
1225+ return new Response ( response . body , { status : 200 , headers } ) ;
1226+ }
1227+
1228+ it ( "resubscribes after a completed turn and receives a later wake" , async ( ) => {
1229+ let subscribeCount = 0 ;
1230+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
1231+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1232+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1233+ subscribeCount ++ ;
1234+ // Window 1: a turn completes, then the body EOFs with no
1235+ // settled header — the quiet long-poll boundary.
1236+ return subscribeCount === 1
1237+ ? defaultSseResponse ( [
1238+ { type : "text-delta" , id : "p1" , delta : "turn1" } ,
1239+ { type : "trigger:turn-complete" } ,
1240+ ] )
1241+ : settled ( defaultSseResponse ( [ { type : "text-delta" , id : "p2" , delta : "wake" } ] ) ) ;
1242+ }
1243+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1244+ } ) ;
1245+
1246+ const transport = new TriggerChatTransport ( {
1247+ task : "my-chat-task" ,
1248+ accessToken : ( ) => "pat" ,
1249+ watch : true ,
1250+ sessions : { "chat-watch-eof" : { publicAccessToken : "p" , isStreaming : true } } ,
1251+ } ) ;
1252+
1253+ const stream = await transport . reconnectToStream ( { chatId : "chat-watch-eof" } ) ;
1254+ const chunks = await drainChunks ( stream ! ) ;
1255+
1256+ expect ( subscribeCount ) . toBe ( 2 ) ;
1257+ expect ( chunks ) . toEqual ( [
1258+ { type : "text-delta" , id : "p1" , delta : "turn1" } ,
1259+ { type : "text-delta" , id : "p2" , delta : "wake" } ,
1260+ ] ) ;
1261+ } ) ;
1262+
1263+ it ( "stops when the server says the session settled" , async ( ) => {
1264+ let subscribeCount = 0 ;
1265+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
1266+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1267+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1268+ subscribeCount ++ ;
1269+ return settled (
1270+ defaultSseResponse ( [
1271+ { type : "text-delta" , id : "p1" , delta : "last" } ,
1272+ { type : "trigger:turn-complete" } ,
1273+ ] )
1274+ ) ;
1275+ }
1276+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1277+ } ) ;
1278+
1279+ const transport = new TriggerChatTransport ( {
1280+ task : "my-chat-task" ,
1281+ accessToken : ( ) => "pat" ,
1282+ watch : true ,
1283+ sessions : { "chat-watch-settled" : { publicAccessToken : "p" , isStreaming : true } } ,
1284+ } ) ;
1285+
1286+ const stream = await transport . reconnectToStream ( { chatId : "chat-watch-settled" } ) ;
1287+ const chunks = await drainChunks ( stream ! ) ;
1288+
1289+ expect ( subscribeCount ) . toBe ( 1 ) ;
1290+ expect ( chunks ) . toHaveLength ( 1 ) ;
1291+ expect ( transport . getSession ( "chat-watch-settled" ) ?. isStreaming ) . toBe ( false ) ;
1292+ } ) ;
1293+
1294+ it ( "stops promptly when aborted during backoff" , async ( ) => {
1295+ vi . useFakeTimers ( ) ;
1296+ try {
1297+ let subscribeCount = 0 ;
1298+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
1299+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1300+ if ( isSessionStreamAppendUrl ( urlStr ) ) return defaultAppendResponse ( ) ;
1301+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1302+ subscribeCount ++ ;
1303+ // Every window is quiet: EOF with no records, never settled.
1304+ return defaultSseResponse ( [ ] ) ;
1305+ }
1306+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1307+ } ) ;
1308+
1309+ const abortController = new AbortController ( ) ;
1310+ const transport = new TriggerChatTransport ( {
1311+ task : "my-chat-task" ,
1312+ accessToken : ( ) => "pat" ,
1313+ watch : true ,
1314+ sessions : { "chat-watch-abort" : { publicAccessToken : "p" , isStreaming : true } } ,
1315+ } ) ;
1316+
1317+ const stream = await transport . reconnectToStream ( {
1318+ chatId : "chat-watch-abort" ,
1319+ abortSignal : abortController . signal ,
1320+ } ) ;
1321+ const drained = drainChunks ( stream ! ) ;
1322+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
1323+ // The budget doesn't apply in watch mode, so it is still reconnecting.
1324+ expect ( subscribeCount ) . toBeGreaterThan ( 6 ) ;
1325+
1326+ const countAtAbort = subscribeCount ;
1327+ abortController . abort ( ) ;
1328+ await drained ;
1329+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
1330+ expect ( subscribeCount ) . toBe ( countAtAbort ) ;
1331+ } finally {
1332+ vi . useRealTimers ( ) ;
1333+ }
1334+ } ) ;
1335+ } ) ;
1336+
12211337 describe ( "multi-tab coordination" , ( ) => {
12221338 it ( "isReadOnly defaults to false when multiTab is disabled" , ( ) => {
12231339 const transport = new TriggerChatTransport ( {
@@ -1488,9 +1604,18 @@ describe("TriggerChatTransport", () => {
14881604 { type : "text-delta" , id : "p2" , delta : "Again" } ,
14891605 { type : "trigger:turn-complete" } ,
14901606 ] ;
1607+ let subscribeCount = 0 ;
14911608 global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
14921609 const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1493- if ( isSessionOutSubscribeUrl ( urlStr ) ) return defaultSseResponse ( turn1 ) ;
1610+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1611+ subscribeCount ++ ;
1612+ if ( subscribeCount === 1 ) return defaultSseResponse ( turn1 ) ;
1613+ // Watch mode reconnects past the body EOF; settle so the drain ends.
1614+ const response = defaultSseResponse ( [ ] ) ;
1615+ const headers = new Headers ( response . headers ) ;
1616+ headers . set ( "X-Session-Settled" , "true" ) ;
1617+ return new Response ( response . body , { status : 200 , headers } ) ;
1618+ }
14941619 throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
14951620 } ) ;
14961621
0 commit comments