@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44import {
55 TriggerChatTransport ,
66 type ChatSessionPersistedState ,
7+ type ChatTransportEvent ,
78 type TriggerChatTransportOptions ,
89} from "./chat.js" ;
910
@@ -505,7 +506,7 @@ describe("Stop with a successor response", () => {
505506 await hydrateBlockedSession ( "constructor" ) ;
506507 const recover = transport . prepareTranscriptRecovery ( "chat" ) ;
507508 if ( ! recover ) throw new Error ( "Expected transcript recovery" ) ;
508- expect ( recover ( "5" , "9" ) ) . toBe ( false ) ;
509+ expect ( recover ( { lastOutEventId : "5" , lastInEventId : "9" } ) ) . toBe ( false ) ;
509510 await expect ( send ( ) ) . rejects . toThrow ( "Stopped chat response cannot be matched" ) ;
510511 expect ( inputSeq ) . toBe ( 13 ) ;
511512 } ) ;
@@ -542,11 +543,11 @@ describe("Stop with a successor response", () => {
542543 if ( hydrate === "setSession" ) transport . setSession ( "chat" , session ) ;
543544 const stale = transport . prepareTranscriptRecovery ( "chat" ) ;
544545 if ( ! stale ) throw new Error ( "Expected transcript recovery" ) ;
545- expect ( stale ( "5" , "9" ) ) . toBe ( false ) ;
546+ expect ( stale ( { lastOutEventId : "5" , lastInEventId : "9" } ) ) . toBe ( false ) ;
546547 await expect ( send ( ) ) . rejects . toThrow ( "Stopped chat response cannot be matched" ) ;
547548 const fresh = transport . prepareTranscriptRecovery ( "chat" ) ;
548549 if ( ! fresh ) throw new Error ( "Expected transcript recovery" ) ;
549- expect ( fresh ( "11" , "10" ) ) . toBe ( true ) ;
550+ expect ( fresh ( { lastOutEventId : "11" , lastInEventId : "10" } ) ) . toBe ( true ) ;
550551 const next = await transport . reconnectToStream ( { chatId : "chat" } ) ;
551552 if ( ! next ) throw new Error ( "Expected a resumed stream" ) ;
552553 await vi . waitFor ( ( ) => expect ( outputs ) . toHaveLength ( 2 ) ) ;
@@ -575,7 +576,7 @@ describe("Stop with a successor response", () => {
575576 }
576577 const recover = transport . prepareTranscriptRecovery ( "chat" ) ;
577578 if ( ! recover ) throw new Error ( "Expected transcript recovery" ) ;
578- expect ( recover ( "17" , "11" ) ) . toBe ( true ) ;
579+ expect ( recover ( { lastOutEventId : "17" , lastInEventId : "11" } ) ) . toBe ( true ) ;
579580 const next = await send ( ) ;
580581 emit ( [ ...reply ( 18 ) , complete ( 23 , 13 ) ] ) ;
581582 await expect ( readText ( next ) ) . resolves . toBe ( "New response" ) ;
@@ -597,8 +598,15 @@ describe("Stop with a successor response", () => {
597598 expect ( await stopped ) . toBe ( true ) ;
598599 const recover = transport . prepareTranscriptRecovery ( "chat" ) ;
599600 if ( ! recover ) throw new Error ( "Expected transcript recovery" ) ;
600- expect ( recover ( "17" , "11" ) ) . toBe ( false ) ;
601+ expect ( ( ) => recover ( { lastOutEventId : "17" , lastInEventId : "11" } ) ) . toThrow (
602+ "Transcript recovery requires a stopped input sequence"
603+ ) ;
601604 await expect ( send ( ) ) . rejects . toThrow ( "Stopped chat response cannot be matched" ) ;
605+ holdStop = false ;
606+ expect ( await transport . stopGeneration ( "chat" ) ) . toBe ( true ) ;
607+ expect (
608+ transport . prepareTranscriptRecovery ( "chat" ) ?.( { lastOutEventId : "17" , lastInEventId : "11" } )
609+ ) . toBe ( true ) ;
602610 } ) ;
603611
604612 it ( "accepts a sequence-free response without a stopped boundary" , async ( ) => {
@@ -632,7 +640,7 @@ describe("Stop with a successor response", () => {
632640 await hydrateBlockedSession ( hydrate ) ;
633641 const recover = transport . prepareTranscriptRecovery ( "chat" ) ;
634642 if ( ! recover ) throw new Error ( "Expected transcript recovery" ) ;
635- expect ( recover ( "11" , "11" ) ) . toBe ( true ) ;
643+ expect ( recover ( { lastOutEventId : "11" , lastInEventId : "11" } ) ) . toBe ( true ) ;
636644 transport . seedResumeCursor ( "chat" , "11" ) ;
637645 expect ( saved ) . toMatchObject ( {
638646 lastEventId : "11" ,
@@ -658,7 +666,13 @@ describe("Stop with a successor response", () => {
658666 await hydrateBlockedSession ( "constructor" ) ;
659667 const recover = transport . prepareTranscriptRecovery ( "chat" ) ;
660668 if ( ! recover ) throw new Error ( "Expected transcript recovery" ) ;
661- expect ( recover ( cursor , "11" ) ) . toBe ( false ) ;
669+ if ( cursor === undefined ) {
670+ expect ( ( ) => recover ( { lastOutEventId : cursor , lastInEventId : "11" } ) ) . toThrow (
671+ "Transcript recovery requires numeric input and output cursors"
672+ ) ;
673+ } else {
674+ expect ( recover ( { lastOutEventId : cursor , lastInEventId : "11" } ) ) . toBe ( false ) ;
675+ }
662676 await expect ( send ( ) ) . rejects . toThrow ( "Stopped chat response cannot be matched" ) ;
663677 await expect ( transport . sendAction ( "chat" , { type : "undo" } ) ) . rejects . toThrow (
664678 "Stopped chat response cannot be matched"
@@ -685,7 +699,7 @@ describe("Stop with a successor response", () => {
685699 await hydrateBlockedSession ( "constructor" ) ;
686700 const recover = transport . prepareTranscriptRecovery ( "chat" ) ;
687701 if ( ! recover ) throw new Error ( "Expected transcript recovery" ) ;
688- expect ( recover ( "11" , "11" ) ) . toBe ( true ) ;
702+ expect ( recover ( { lastOutEventId : "11" , lastInEventId : "11" } ) ) . toBe ( true ) ;
689703 if ( hydrate !== "none" ) {
690704 const session = transport . getSession ( "chat" ) ;
691705 if ( ! session ) throw new Error ( "Expected persisted state" ) ;
@@ -707,16 +721,21 @@ describe("Stop with a successor response", () => {
707721 }
708722 ) ;
709723
710- it ( "retains unknown recovery state after a bounded empty response " , async ( ) => {
724+ it ( "reports an empty recovery poll and reconnects without another append " , async ( ) => {
711725 await hydrateBlockedSession ( "constructor" ) ;
712726 const recover = transport . prepareTranscriptRecovery ( "chat" ) ;
713727 if ( ! recover ) throw new Error ( "Expected transcript recovery" ) ;
714- expect ( recover ( "11" , "11" ) ) . toBe ( true ) ;
728+ expect ( recover ( { lastOutEventId : "11" , lastInEventId : "11" } ) ) . toBe ( true ) ;
729+ const events : ChatTransportEvent [ ] = [ ] ;
730+ transport . setOnEvent ( ( event ) => events . push ( event ) ) ;
715731 resumeAfterStoppedCheckpoint = true ;
716732 emptyRecoveredOutput = true ;
717733 const resumed = await transport . reconnectToStream ( { chatId : "chat" } ) ;
718734 if ( ! resumed ) throw new Error ( "Expected a resumed stream" ) ;
719- await expect ( readText ( resumed ) ) . resolves . toBe ( "" ) ;
735+ await expect ( readText ( resumed ) ) . rejects . toThrow (
736+ "Chat recovery received no output before the poll ended. Reconnect to resume the accepted message."
737+ ) ;
738+ expect ( events . filter ( ( event ) => event . type === "stream-error" ) ) . toHaveLength ( 1 ) ;
720739 const request = outputHeaders . at ( - 1 ) ;
721740 if ( ! request ) throw new Error ( "Expected a stream request" ) ;
722741 expect ( request . peek ) . toBe ( false ) ;
@@ -742,7 +761,7 @@ describe("Stop with a successor response", () => {
742761 holdStop = true ;
743762 const stopped = transport . stopGeneration ( "chat" ) ;
744763 await vi . waitFor ( ( ) => expect ( pendingStop ) . toBeDefined ( ) ) ;
745- expect ( recover ( "11" , "11" ) ) . toBe ( false ) ;
764+ expect ( recover ( { lastOutEventId : "11" , lastInEventId : "11" } ) ) . toBe ( false ) ;
746765 await expect ( send ( ) ) . rejects . toThrow ( "Stopped chat response cannot be matched" ) ;
747766 const pending = pendingStop ! ;
748767 appendResponse ( pending . response , pending . seq ) ;
@@ -751,6 +770,120 @@ describe("Stop with a successor response", () => {
751770 expect ( transport . getSession ( "chat" ) ) . toMatchObject ( { requiresTranscriptReload : true } ) ;
752771 } ) ;
753772
773+ it . each ( [ "abort" , "stop" ] as const ) ( "closes recovery quietly after %s" , async ( operation ) => {
774+ await hydrateBlockedSession ( "constructor" ) ;
775+ expect (
776+ transport . prepareTranscriptRecovery ( "chat" ) ?.( { lastOutEventId : "11" , lastInEventId : "11" } )
777+ ) . toBe ( true ) ;
778+ const events : ChatTransportEvent [ ] = [ ] ;
779+ transport . setOnEvent ( ( event ) => events . push ( event ) ) ;
780+ const abort = new AbortController ( ) ;
781+ const resumed = await transport . reconnectToStream ( {
782+ chatId : "chat" ,
783+ abortSignal : abort . signal ,
784+ } ) ;
785+ if ( ! resumed ) throw new Error ( "Expected a resumed stream" ) ;
786+ const result = readText ( resumed ) ;
787+ await vi . waitFor ( ( ) => expect ( outputs ) . toHaveLength ( 2 ) ) ;
788+ if ( operation === "abort" ) abort . abort ( ) ;
789+ else await transport . stopGeneration ( "chat" ) ;
790+ await expect ( result ) . resolves . toBe ( "" ) ;
791+ expect ( events . filter ( ( event ) => event . type === "stream-error" ) ) . toEqual ( [ ] ) ;
792+ if ( operation === "abort" ) {
793+ expect ( transport . getSession ( "chat" ) ) . toMatchObject ( {
794+ isStreaming : undefined ,
795+ skipSettledPeek : true ,
796+ } ) ;
797+ expect ( inputSeq ) . toBe ( 13 ) ;
798+ } else {
799+ expect ( transport . getSession ( "chat" ) ) . toMatchObject ( {
800+ isStreaming : false ,
801+ skipToTurnComplete : true ,
802+ } ) ;
803+ expect ( inputSeq ) . toBe ( 14 ) ;
804+ }
805+ } ) ;
806+
807+ it ( "closes an empty settled recovery without an error" , async ( ) => {
808+ await hydrateBlockedSession ( "constructor" ) ;
809+ expect (
810+ transport . prepareTranscriptRecovery ( "chat" ) ?.( { lastOutEventId : "11" , lastInEventId : "11" } )
811+ ) . toBe ( true ) ;
812+ const events : ChatTransportEvent [ ] = [ ] ;
813+ transport . setOnEvent ( ( event ) => events . push ( event ) ) ;
814+ settled = true ;
815+ emptyRecoveredOutput = true ;
816+ const resumed = await transport . reconnectToStream ( { chatId : "chat" } ) ;
817+ if ( ! resumed ) throw new Error ( "Expected a resumed stream" ) ;
818+ await expect ( readText ( resumed ) ) . resolves . toBe ( "" ) ;
819+ expect ( events . filter ( ( event ) => event . type === "stream-error" ) ) . toEqual ( [ ] ) ;
820+ expect ( transport . getSession ( "chat" ) ?. isStreaming ) . toBe ( false ) ;
821+ expect ( inputSeq ) . toBe ( 13 ) ;
822+ } ) ;
823+
824+ it ( "reconnects a watch after an empty recovery response" , async ( ) => {
825+ await hydrateBlockedSession ( "constructor" ) ;
826+ expect (
827+ transport . prepareTranscriptRecovery ( "chat" ) ?.( { lastOutEventId : "11" , lastInEventId : "11" } )
828+ ) . toBe ( true ) ;
829+ const session = transport . getSession ( "chat" ) ! ;
830+ transport . dispose ( ) ;
831+ const events : ChatTransportEvent [ ] = [ ] ;
832+ transport = createTransport ( session , { watch : true , onEvent : ( event ) => events . push ( event ) } ) ;
833+ emptyRecoveredOutput = true ;
834+ const resumed = await transport . reconnectToStream ( { chatId : "chat" } ) ;
835+ if ( ! resumed ) throw new Error ( "Expected a resumed stream" ) ;
836+ const result = readWatchedTurn ( resumed ) ;
837+ await vi . waitFor ( ( ) => expect ( outputs . length ) . toBeGreaterThanOrEqual ( 2 ) ) ;
838+ emptyRecoveredOutput = false ;
839+ resumeAfterStoppedCheckpoint = true ;
840+ await expect ( result ) . resolves . toBe ( "New response" ) ;
841+ expect ( events . filter ( ( event ) => event . type === "stream-error" ) ) . toEqual ( [ ] ) ;
842+ expect ( inputSeq ) . toBe ( 13 ) ;
843+ } ) ;
844+
845+ it ( "uses the active-turn retry policy after recovery receives data" , async ( ) => {
846+ await hydrateBlockedSession ( "constructor" ) ;
847+ expect (
848+ transport . prepareTranscriptRecovery ( "chat" ) ?.( { lastOutEventId : "11" , lastInEventId : "11" } )
849+ ) . toBe ( true ) ;
850+ const events : ChatTransportEvent [ ] = [ ] ;
851+ transport . setOnEvent ( ( event ) => events . push ( event ) ) ;
852+ const resumed = await transport . reconnectToStream ( { chatId : "chat" } ) ;
853+ if ( ! resumed ) throw new Error ( "Expected a resumed stream" ) ;
854+ const reader = resumed . getReader ( ) ;
855+ await vi . waitFor ( ( ) => expect ( outputs ) . toHaveLength ( 2 ) ) ;
856+ emit ( [ chunk ( 12 , { type : "start" , messageId : "new" } ) ] ) ;
857+ await expect ( reader . read ( ) ) . resolves . toMatchObject ( { value : { type : "start" } } ) ;
858+ outputs . at ( - 1 ) ! . end ( ) ;
859+ await vi . waitFor ( ( ) => expect ( outputs ) . toHaveLength ( 3 ) ) ;
860+ emit ( [ ...reply ( 12 ) . slice ( 1 ) , complete ( 17 , 12 ) ] ) ;
861+ while ( ! ( await reader . read ( ) ) . done ) { }
862+ expect ( events . filter ( ( event ) => event . type === "stream-error" ) ) . toEqual ( [ ] ) ;
863+ expect ( transport . getSession ( "chat" ) ?. isStreaming ) . toBe ( false ) ;
864+ expect ( inputSeq ) . toBe ( 13 ) ;
865+ } ) ;
866+
867+ it ( "does not let a replaced recovery stream settle the new response" , async ( ) => {
868+ await hydrateBlockedSession ( "constructor" ) ;
869+ expect (
870+ transport . prepareTranscriptRecovery ( "chat" ) ?.( { lastOutEventId : "11" , lastInEventId : "11" } )
871+ ) . toBe ( true ) ;
872+ const events : ChatTransportEvent [ ] = [ ] ;
873+ transport . setOnEvent ( ( event ) => events . push ( event ) ) ;
874+ const resumed = await transport . reconnectToStream ( { chatId : "chat" } ) ;
875+ if ( ! resumed ) throw new Error ( "Expected a resumed stream" ) ;
876+ const oldResult = readText ( resumed ) ;
877+ await vi . waitFor ( ( ) => expect ( outputs ) . toHaveLength ( 2 ) ) ;
878+ const replacement = await send ( ) ;
879+ await expect ( oldResult ) . resolves . toBe ( "" ) ;
880+ expect ( transport . getSession ( "chat" ) ?. isStreaming ) . toBe ( true ) ;
881+ emit ( [ ...reply ( 12 ) , complete ( 17 , 13 ) ] ) ;
882+ await expect ( readText ( replacement ) ) . resolves . toBe ( "New response" ) ;
883+ expect ( events . filter ( ( event ) => event . type === "stream-error" ) ) . toEqual ( [ ] ) ;
884+ expect ( inputSeq ) . toBe ( 14 ) ;
885+ } ) ;
886+
754887 it . each ( [ "constructor" , "setSession" ] as const ) (
755888 "retains the abandoned-turn marker through %s hydration in watch mode" ,
756889 async ( hydrate ) => {
0 commit comments