@@ -1054,6 +1054,170 @@ describe("TriggerChatTransport", () => {
10541054 } ) ;
10551055 } ) ;
10561056
1057+ describe ( "stream body ends mid-turn" , ( ) => {
1058+ it ( "resubscribes from the last event id when the close was not settled" , async ( ) => {
1059+ const subscribeHeaders : Headers [ ] = [ ] ;
1060+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL , init ?: RequestInit ) => {
1061+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1062+ if ( isSessionStreamAppendUrl ( urlStr ) ) return defaultAppendResponse ( ) ;
1063+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1064+ subscribeHeaders . push ( new Headers ( init ?. headers ) ) ;
1065+ // First connection ends mid-turn: one chunk, no turn-complete,
1066+ // no `X-Session-Settled`.
1067+ return subscribeHeaders . length === 1
1068+ ? defaultSseResponse ( [ { type : "text-start" , id : "part-1" } ] )
1069+ : defaultSseResponse ( [
1070+ { type : "text-delta" , id : "part-1" , delta : "resumed" } ,
1071+ { type : "trigger:turn-complete" } ,
1072+ ] ) ;
1073+ }
1074+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1075+ } ) ;
1076+
1077+ const transport = new TriggerChatTransport ( {
1078+ task : "my-chat-task" ,
1079+ accessToken : ( ) => "pat" ,
1080+ sessions : { "chat-eof" : { publicAccessToken : "p" } } ,
1081+ } ) ;
1082+
1083+ const stream = await transport . sendMessages ( {
1084+ trigger : "submit-message" ,
1085+ chatId : "chat-eof" ,
1086+ messageId : undefined ,
1087+ messages : [ createUserMessage ( "hi" ) ] ,
1088+ abortSignal : undefined ,
1089+ } ) ;
1090+ const chunks = await drainChunks ( stream ) ;
1091+
1092+ expect ( subscribeHeaders ) . toHaveLength ( 2 ) ;
1093+ expect ( subscribeHeaders [ 1 ] ?. get ( "Last-Event-ID" ) ) . toBe ( "1" ) ;
1094+ expect ( chunks ) . toEqual ( [
1095+ { type : "text-start" , id : "part-1" } ,
1096+ { type : "text-delta" , id : "part-1" , delta : "resumed" } ,
1097+ ] ) ;
1098+ expect ( transport . getSession ( "chat-eof" ) ?. isStreaming ) . toBe ( false ) ;
1099+ } ) ;
1100+
1101+ it ( "stops and clears isStreaming when the close was settled" , async ( ) => {
1102+ let subscribeCount = 0 ;
1103+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
1104+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1105+ if ( isSessionStreamAppendUrl ( urlStr ) ) return defaultAppendResponse ( ) ;
1106+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1107+ subscribeCount ++ ;
1108+ const response = defaultSseResponse ( [ { type : "text-start" , id : "part-1" } ] ) ;
1109+ const headers = new Headers ( response . headers ) ;
1110+ headers . set ( "X-Session-Settled" , "true" ) ;
1111+ return new Response ( response . body , { status : 200 , headers } ) ;
1112+ }
1113+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1114+ } ) ;
1115+
1116+ const onSessionChange = vi . fn ( ) ;
1117+ const transport = new TriggerChatTransport ( {
1118+ task : "my-chat-task" ,
1119+ accessToken : ( ) => "pat" ,
1120+ onSessionChange,
1121+ sessions : { "chat-settled" : { publicAccessToken : "p" } } ,
1122+ } ) ;
1123+
1124+ const stream = await transport . sendMessages ( {
1125+ trigger : "submit-message" ,
1126+ chatId : "chat-settled" ,
1127+ messageId : undefined ,
1128+ messages : [ createUserMessage ( "hi" ) ] ,
1129+ abortSignal : undefined ,
1130+ } ) ;
1131+ await drainChunks ( stream ) ;
1132+
1133+ expect ( subscribeCount ) . toBe ( 1 ) ;
1134+ expect ( transport . getSession ( "chat-settled" ) ?. isStreaming ) . toBe ( false ) ;
1135+ expect (
1136+ onSessionChange . mock . calls . some ( ( [ , session ] ) => session && session . isStreaming === false )
1137+ ) . toBe ( true ) ;
1138+ } ) ;
1139+
1140+ it ( "keeps streaming when every window delivers a single record" , async ( ) => {
1141+ // One record per window arrives via `primed` on the resumed connection —
1142+ // it must still re-earn the budget, otherwise a slow turn is truncated.
1143+ const WINDOWS = 8 ;
1144+ let subscribeCount = 0 ;
1145+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
1146+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1147+ if ( isSessionStreamAppendUrl ( urlStr ) ) return defaultAppendResponse ( ) ;
1148+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1149+ subscribeCount ++ ;
1150+ return subscribeCount > WINDOWS
1151+ ? defaultSseResponse ( [ { type : "trigger:turn-complete" } ] )
1152+ : defaultSseResponse ( [
1153+ { type : "text-delta" , id : "part-1" , delta : `d${ subscribeCount } ` } ,
1154+ ] ) ;
1155+ }
1156+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1157+ } ) ;
1158+
1159+ const transport = new TriggerChatTransport ( {
1160+ task : "my-chat-task" ,
1161+ accessToken : ( ) => "pat" ,
1162+ sessions : { "chat-slow" : { publicAccessToken : "p" } } ,
1163+ } ) ;
1164+
1165+ const stream = await transport . sendMessages ( {
1166+ trigger : "submit-message" ,
1167+ chatId : "chat-slow" ,
1168+ messageId : undefined ,
1169+ messages : [ createUserMessage ( "hi" ) ] ,
1170+ abortSignal : undefined ,
1171+ } ) ;
1172+ const chunks = await drainChunks ( stream ) ;
1173+
1174+ expect ( subscribeCount ) . toBe ( WINDOWS + 1 ) ;
1175+ expect ( chunks ) . toHaveLength ( WINDOWS ) ;
1176+ expect ( transport . getSession ( "chat-slow" ) ?. isStreaming ) . toBe ( false ) ;
1177+ } ) ;
1178+
1179+ it ( "gives up after a bounded number of resubscribes" , async ( ) => {
1180+ // Fake timers so the 100ms..1.6s backoffs don't cost real seconds.
1181+ vi . useFakeTimers ( ) ;
1182+ try {
1183+ let subscribeCount = 0 ;
1184+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
1185+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1186+ if ( isSessionStreamAppendUrl ( urlStr ) ) return defaultAppendResponse ( ) ;
1187+ if ( isSessionOutSubscribeUrl ( urlStr ) ) {
1188+ subscribeCount ++ ;
1189+ // Never any records, never settled — the pathological case.
1190+ return defaultSseResponse ( [ ] ) ;
1191+ }
1192+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1193+ } ) ;
1194+
1195+ const transport = new TriggerChatTransport ( {
1196+ task : "my-chat-task" ,
1197+ accessToken : ( ) => "pat" ,
1198+ sessions : { "chat-empty" : { publicAccessToken : "p" } } ,
1199+ } ) ;
1200+
1201+ const stream = await transport . sendMessages ( {
1202+ trigger : "submit-message" ,
1203+ chatId : "chat-empty" ,
1204+ messageId : undefined ,
1205+ messages : [ createUserMessage ( "hi" ) ] ,
1206+ abortSignal : undefined ,
1207+ } ) ;
1208+ const drained = drainChunks ( stream ) ;
1209+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
1210+ await drained ;
1211+
1212+ // One initial connect plus the five-attempt resubscribe budget.
1213+ expect ( subscribeCount ) . toBe ( 6 ) ;
1214+ expect ( transport . getSession ( "chat-empty" ) ?. isStreaming ) . toBe ( false ) ;
1215+ } finally {
1216+ vi . useRealTimers ( ) ;
1217+ }
1218+ } ) ;
1219+ } ) ;
1220+
10571221 describe ( "multi-tab coordination" , ( ) => {
10581222 it ( "isReadOnly defaults to false when multiTab is disabled" , ( ) => {
10591223 const transport = new TriggerChatTransport ( {
0 commit comments