@@ -502,6 +502,145 @@ describe("TriggerChatTransport", () => {
502502
503503 expect ( receivedChunks . length ) . toBeGreaterThan ( 0 ) ;
504504 } ) ;
505+
506+ it ( "should return null when session exists but isStreaming is false (TRI-8557)" , async ( ) => {
507+ // Simulate a session restored from DB after a completed turn
508+ const transport = new TriggerChatTransport ( {
509+ task : "my-task" ,
510+ accessToken : "token" ,
511+ sessions : {
512+ "chat-completed" : {
513+ runId : "run_completed" ,
514+ publicAccessToken : "pub_token" ,
515+ lastEventId : "42" ,
516+ isStreaming : false ,
517+ } ,
518+ } ,
519+ } ) ;
520+
521+ // reconnectToStream should return null immediately — no hanging
522+ const result = await transport . reconnectToStream ( {
523+ chatId : "chat-completed" ,
524+ } ) ;
525+
526+ expect ( result ) . toBeNull ( ) ;
527+ } ) ;
528+
529+ it ( "should reconnect when session exists and isStreaming is true" , async ( ) => {
530+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
531+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
532+
533+ if ( urlStr . includes ( "/realtime/v1/streams/" ) ) {
534+ const chunks : UIMessageChunk [ ] = [
535+ { type : "text-start" , id : "part-1" } ,
536+ { type : "text-delta" , id : "part-1" , delta : "Resumed!" } ,
537+ { type : "text-end" , id : "part-1" } ,
538+ ] ;
539+ return new Response ( createSSEStream ( sseEncode ( chunks ) ) , {
540+ status : 200 ,
541+ headers : {
542+ "content-type" : "text/event-stream" ,
543+ "X-Stream-Version" : "v1" ,
544+ } ,
545+ } ) ;
546+ }
547+
548+ throw new Error ( `Unexpected fetch URL: ${ urlStr } ` ) ;
549+ } ) ;
550+
551+ const transport = new TriggerChatTransport ( {
552+ task : "my-task" ,
553+ accessToken : "token" ,
554+ baseURL : "https://api.test.trigger.dev" ,
555+ sessions : {
556+ "chat-streaming" : {
557+ runId : "run_streaming" ,
558+ publicAccessToken : "pub_token" ,
559+ lastEventId : "10" ,
560+ isStreaming : true ,
561+ } ,
562+ } ,
563+ } ) ;
564+
565+ const stream = await transport . reconnectToStream ( {
566+ chatId : "chat-streaming" ,
567+ } ) ;
568+
569+ expect ( stream ) . toBeInstanceOf ( ReadableStream ) ;
570+ } ) ;
571+
572+ it ( "should set isStreaming to false via onSessionChange when turn completes" , async ( ) => {
573+ const sessionChanges : Array < {
574+ chatId : string ;
575+ session : { isStreaming ?: boolean } | null ;
576+ } > = [ ] ;
577+
578+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
579+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
580+
581+ if ( urlStr . includes ( "/trigger" ) ) {
582+ return new Response ( JSON . stringify ( { id : "run_streaming_flag" } ) , {
583+ status : 200 ,
584+ headers : {
585+ "content-type" : "application/json" ,
586+ "x-trigger-jwt" : "pub_token" ,
587+ } ,
588+ } ) ;
589+ }
590+
591+ if ( urlStr . includes ( "/realtime/v1/streams/" ) ) {
592+ const chunks = [
593+ { type : "text-start" , id : "part-1" } ,
594+ { type : "text-delta" , id : "part-1" , delta : "Hi" } ,
595+ { type : "text-end" , id : "part-1" } ,
596+ { type : "trigger:turn-complete" , publicAccessToken : "refreshed_token" } ,
597+ ] ;
598+ return new Response ( createSSEStream ( sseEncode ( chunks ) ) , {
599+ status : 200 ,
600+ headers : {
601+ "content-type" : "text/event-stream" ,
602+ "X-Stream-Version" : "v1" ,
603+ } ,
604+ } ) ;
605+ }
606+
607+ throw new Error ( `Unexpected fetch URL: ${ urlStr } ` ) ;
608+ } ) ;
609+
610+ const transport = new TriggerChatTransport ( {
611+ task : "my-task" ,
612+ accessToken : "token" ,
613+ baseURL : "https://api.test.trigger.dev" ,
614+ onSessionChange : ( chatId , session ) => {
615+ sessionChanges . push ( { chatId, session } ) ;
616+ } ,
617+ } ) ;
618+
619+ const stream = await transport . sendMessages ( {
620+ trigger : "submit-message" ,
621+ chatId : "chat-flag-test" ,
622+ messageId : undefined ,
623+ messages : [ createUserMessage ( "Hello" ) ] ,
624+ abortSignal : undefined ,
625+ } ) ;
626+
627+ // Drain the stream
628+ const reader = stream . getReader ( ) ;
629+ while ( true ) {
630+ const { done } = await reader . read ( ) ;
631+ if ( done ) break ;
632+ }
633+
634+ // Find the session changes for this chat
635+ const changes = sessionChanges . filter ( ( c ) => c . chatId === "chat-flag-test" ) ;
636+
637+ // First change: session created with isStreaming: true
638+ expect ( changes [ 0 ] ?. session ?. isStreaming ) . toBe ( true ) ;
639+
640+ // Last change: turn completed, isStreaming: false
641+ const lastChange = changes [ changes . length - 1 ] ;
642+ expect ( lastChange ?. session ?. isStreaming ) . toBe ( false ) ;
643+ } ) ;
505644 } ) ;
506645
507646 describe ( "renewRunAccessToken" , ( ) => {
@@ -651,6 +790,15 @@ describe("TriggerChatTransport", () => {
651790 if ( done ) break ;
652791 }
653792
793+ // Simulate mid-stream state (isStreaming must be true for reconnect to attempt)
794+ const session = transport . getSession ( "chat-fail-renew" ) ;
795+ transport . setOnSessionChange ( undefined ) ; // prevent side-effects
796+ // Re-seed with isStreaming: true to simulate reconnect during an active turn
797+ ( transport as any ) . sessions . set ( "chat-fail-renew" , {
798+ ...session ,
799+ isStreaming : true ,
800+ } ) ;
801+
654802 const stream = await transport . reconnectToStream ( { chatId : "chat-fail-renew" } ) ;
655803 const reader = stream ! . getReader ( ) ;
656804 await expect ( reader . read ( ) ) . rejects . toMatchObject ( { status : 401 } ) ;
@@ -1013,13 +1161,21 @@ describe("TriggerChatTransport", () => {
10131161 const r2 = s2 . getReader ( ) ;
10141162 while ( ! ( await r2 . read ( ) ) . done ) { }
10151163
1016- // Both sessions should be independently reconnectable
1164+ // Both sessions should exist but not be reconnectable (turns completed)
1165+ const sessionA = transport . getSession ( "session-a" ) ;
1166+ const sessionB = transport . getSession ( "session-b" ) ;
1167+ expect ( sessionA ) . toBeDefined ( ) ;
1168+ expect ( sessionB ) . toBeDefined ( ) ;
1169+ expect ( sessionA ! . isStreaming ) . toBe ( false ) ;
1170+ expect ( sessionB ! . isStreaming ) . toBe ( false ) ;
1171+
1172+ // Completed turns return null on reconnect (TRI-8557 fix)
10171173 const streamA = await transport . reconnectToStream ( { chatId : "session-a" } ) ;
10181174 const streamB = await transport . reconnectToStream ( { chatId : "session-b" } ) ;
10191175 const streamC = await transport . reconnectToStream ( { chatId : "nonexistent" } ) ;
10201176
1021- expect ( streamA ) . toBeInstanceOf ( ReadableStream ) ;
1022- expect ( streamB ) . toBeInstanceOf ( ReadableStream ) ;
1177+ expect ( streamA ) . toBeNull ( ) ;
1178+ expect ( streamB ) . toBeNull ( ) ;
10231179 expect ( streamC ) . toBeNull ( ) ;
10241180 } ) ;
10251181 } ) ;
@@ -2060,6 +2216,7 @@ describe("TriggerChatTransport", () => {
20602216 runId : triggerRunId ,
20612217 publicAccessToken : publicToken ,
20622218 lastEventId : undefined ,
2219+ isStreaming : true ,
20632220 } ) ;
20642221
20652222 // Consume stream
0 commit comments