@@ -592,22 +592,56 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
592592 expect ( lastConnectParams ( ) . config ) . not . toHaveProperty ( "sessionResumption" ) ;
593593 } ) ;
594594
595- it ( "captures Google Live resumption handles and reuses them on reconnect " , async ( ) => {
595+ it ( "preserves transcript fragments while reusing a resumption handle " , async ( ) => {
596596 const provider = buildGoogleRealtimeVoiceProvider ( ) ;
597+ const onTranscript = vi . fn ( ) ;
597598 const bridge = provider . createBridge ( {
598599 providerConfig : { apiKey : "gemini-key" } ,
599600 onAudio : vi . fn ( ) ,
600601 onClearAudio : vi . fn ( ) ,
602+ onTranscript,
601603 } ) ;
602604
603605 await bridge . connect ( ) ;
604606 lastConnectParams ( ) . callbacks . onmessage ( {
605607 sessionResumptionUpdate : { resumable : true , newHandle : "resume-1" } ,
608+ serverContent : { inputTranscription : { text : "Before " } } ,
606609 } ) ;
607610
608611 await bridge . connect ( ) ;
612+ lastConnectParams ( ) . callbacks . onmessage ( {
613+ serverContent : { inputTranscription : { text : "after" , finished : true } } ,
614+ } ) ;
609615
610616 expect ( lastConnectParams ( ) . config . sessionResumption ) . toEqual ( { handle : "resume-1" } ) ;
617+ expect ( onTranscript . mock . calls . filter ( ( call ) => call [ 2 ] === true ) ) . toEqual ( [
618+ [ "user" , "Before after" , true ] ,
619+ ] ) ;
620+ } ) ;
621+
622+ it ( "drops unfinished hypotheses when a new session has no continuity" , async ( ) => {
623+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
624+ const onTranscript = vi . fn ( ) ;
625+ const bridge = provider . createBridge ( {
626+ providerConfig : { apiKey : "gemini-key" , sessionResumption : false } ,
627+ onAudio : vi . fn ( ) ,
628+ onClearAudio : vi . fn ( ) ,
629+ onTranscript,
630+ } ) ;
631+
632+ await bridge . connect ( ) ;
633+ lastConnectParams ( ) . callbacks . onmessage ( {
634+ serverContent : { inputTranscription : { text : "Old fragment " } } ,
635+ } ) ;
636+
637+ await bridge . connect ( ) ;
638+ lastConnectParams ( ) . callbacks . onmessage ( {
639+ serverContent : { inputTranscription : { text : "New turn" , finished : true } } ,
640+ } ) ;
641+
642+ expect ( onTranscript . mock . calls . filter ( ( call ) => call [ 2 ] === true ) ) . toEqual ( [
643+ [ "user" , "New turn" , true ] ,
644+ ] ) ;
611645 } ) ;
612646
613647 it ( "reconnects unexpected Google Live closes with the latest resumption handle" , async ( ) => {
@@ -629,6 +663,9 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
629663 setupComplete : { sessionId : "session-1" } ,
630664 sessionResumptionUpdate : { resumable : true , newHandle : "resume-1" } ,
631665 } ) ;
666+ lastConnectParams ( ) . callbacks . onmessage ( {
667+ sessionResumptionUpdate : { resumable : false } ,
668+ } ) ;
632669 lastConnectParams ( ) . callbacks . onclose ( {
633670 code : 1011 ,
634671 reason : "temporary upstream close" ,
@@ -648,6 +685,106 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
648685 }
649686 } ) ;
650687
688+ it ( "does not finalize or merge a hypothesis across a fresh automatic reconnect" , async ( ) => {
689+ vi . useFakeTimers ( ) ;
690+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
691+ const onTranscript = vi . fn ( ) ;
692+ const bridge = provider . createBridge ( {
693+ providerConfig : { apiKey : "gemini-key" } ,
694+ onAudio : vi . fn ( ) ,
695+ onClearAudio : vi . fn ( ) ,
696+ onTranscript,
697+ } ) ;
698+
699+ await bridge . connect ( ) ;
700+ const firstSession = lastConnectParams ( ) . callbacks ;
701+ firstSession . onmessage ( {
702+ setupComplete : { } ,
703+ serverContent : { inputTranscription : { text : "Interrupted hypothesis" } } ,
704+ } ) ;
705+ firstSession . onclose ( { code : 1011 , reason : "temporary" } ) ;
706+
707+ expect ( onTranscript . mock . calls . filter ( ( call ) => call [ 2 ] === true ) ) . toEqual ( [ ] ) ;
708+ await vi . advanceTimersByTimeAsync ( 250 ) ;
709+ lastConnectParams ( ) . callbacks . onmessage ( {
710+ setupComplete : { } ,
711+ serverContent : { inputTranscription : { text : "New utterance" , finished : true } } ,
712+ } ) ;
713+
714+ expect ( onTranscript . mock . calls . filter ( ( call ) => call [ 2 ] === true ) ) . toEqual ( [
715+ [ "user" , "New utterance" , true ] ,
716+ ] ) ;
717+ } ) ;
718+
719+ it ( "keeps transcript fragments pending across a resumable reconnect" , async ( ) => {
720+ vi . useFakeTimers ( ) ;
721+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
722+ const onTranscript = vi . fn ( ) ;
723+ const bridge = provider . createBridge ( {
724+ providerConfig : { apiKey : "gemini-key" } ,
725+ onAudio : vi . fn ( ) ,
726+ onClearAudio : vi . fn ( ) ,
727+ onTranscript,
728+ } ) ;
729+
730+ await bridge . connect ( ) ;
731+ const firstSession = lastConnectParams ( ) . callbacks ;
732+ firstSession . onmessage ( {
733+ setupComplete : { } ,
734+ sessionResumptionUpdate : { resumable : true , newHandle : "resume-1" } ,
735+ serverContent : {
736+ outputTranscription : { text : "Before " } ,
737+ turnComplete : true ,
738+ } ,
739+ } ) ;
740+ await vi . advanceTimersByTimeAsync ( 500 ) ;
741+ firstSession . onclose ( { code : 1011 , reason : "temporary" } ) ;
742+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
743+
744+ expect ( onTranscript . mock . calls ) . toEqual ( [ [ "assistant" , "Before " , false ] ] ) ;
745+
746+ lastConnectParams ( ) . callbacks . onmessage ( {
747+ setupComplete : { } ,
748+ serverContent : { outputTranscription : { text : "after" , finished : true } } ,
749+ } ) ;
750+ expect ( onTranscript . mock . calls . at ( - 1 ) ) . toEqual ( [ "assistant" , "Before after" , true ] ) ;
751+ } ) ;
752+
753+ it ( "flushes pending transcripts before closing after reconnect failures" , async ( ) => {
754+ vi . useFakeTimers ( ) ;
755+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
756+ const onClose = vi . fn ( ) ;
757+ const onTranscript = vi . fn ( ) ;
758+ const bridge = provider . createBridge ( {
759+ providerConfig : { apiKey : "gemini-key" } ,
760+ onAudio : vi . fn ( ) ,
761+ onClearAudio : vi . fn ( ) ,
762+ onClose,
763+ onTranscript,
764+ } ) ;
765+
766+ await bridge . connect ( ) ;
767+ const firstSession = lastConnectParams ( ) . callbacks ;
768+ firstSession . onmessage ( {
769+ setupComplete : { } ,
770+ sessionResumptionUpdate : { resumable : true , newHandle : "resume-1" } ,
771+ serverContent : { inputTranscription : { text : "Last words" } } ,
772+ } ) ;
773+ connectMock
774+ . mockRejectedValueOnce ( new Error ( "connect failed 1" ) )
775+ . mockRejectedValueOnce ( new Error ( "connect failed 2" ) )
776+ . mockRejectedValueOnce ( new Error ( "connect failed 3" ) ) ;
777+ firstSession . onclose ( { code : 1011 , reason : "temporary" } ) ;
778+
779+ await vi . advanceTimersByTimeAsync ( 1_750 ) ;
780+
781+ expect ( onTranscript . mock . calls . at ( - 1 ) ) . toEqual ( [ "user" , "Last words" , true ] ) ;
782+ expect ( onClose ) . toHaveBeenCalledWith ( "error" ) ;
783+ expect ( onTranscript . mock . invocationCallOrder . at ( - 1 ) ) . toBeLessThan (
784+ onClose . mock . invocationCallOrder [ 0 ] ?? Number . MAX_SAFE_INTEGER ,
785+ ) ;
786+ } ) ;
787+
651788 it ( "waits for setup completion before draining audio and firing ready" , async ( ) => {
652789 const provider = buildGoogleRealtimeVoiceProvider ( ) ;
653790 const onReady = vi . fn ( ) ;
@@ -855,7 +992,7 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
855992 expect ( requireFirstAudio ( onAudio ) ) . toEqual ( pcm24k ) ;
856993 } ) ;
857994
858- it ( "does not forward Google thought text as assistant transcript " , async ( ) => {
995+ it ( "uses official output transcription instead of model-turn text " , async ( ) => {
859996 const provider = buildGoogleRealtimeVoiceProvider ( ) ;
860997 const onTranscript = vi . fn ( ) ;
861998 const bridge = provider . createBridge ( {
@@ -870,14 +1007,143 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
8701007 setupComplete : { } ,
8711008 serverContent : {
8721009 modelTurn : {
873- parts : [ { text : "internal reasoning" , thought : true } ] ,
1010+ parts : [
1011+ { text : "internal reasoning" , thought : true } ,
1012+ { text : "uncorrelated model text" } ,
1013+ ] ,
8741014 } ,
8751015 } ,
8761016 } ) ;
8771017
8781018 expect ( onTranscript ) . not . toHaveBeenCalled ( ) ;
8791019 } ) ;
8801020
1021+ it ( "emits one complete transcript after Google marks a transcription finished" , async ( ) => {
1022+ vi . useFakeTimers ( ) ;
1023+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
1024+ const onTranscript = vi . fn ( ) ;
1025+ const bridge = provider . createBridge ( {
1026+ providerConfig : { apiKey : "gemini-key" } ,
1027+ onAudio : vi . fn ( ) ,
1028+ onClearAudio : vi . fn ( ) ,
1029+ onTranscript,
1030+ } ) ;
1031+
1032+ await bridge . connect ( ) ;
1033+ const onmessage = lastConnectParams ( ) . callbacks . onmessage ;
1034+ onmessage ( { serverContent : { outputTranscription : { text : "Hi, " } } } ) ;
1035+ onmessage ( {
1036+ serverContent : { outputTranscription : { text : "how can I help?" , finished : true } } ,
1037+ } ) ;
1038+ onmessage ( { serverContent : { modelTurn : { parts : [ { text : "ignored fallback" } ] } } } ) ;
1039+ onmessage ( { serverContent : { turnComplete : true } } ) ;
1040+
1041+ expect ( onTranscript . mock . calls ) . toEqual ( [
1042+ [ "assistant" , "Hi, " , false ] ,
1043+ [ "assistant" , "how can I help?" , false ] ,
1044+ [ "assistant" , "Hi, how can I help?" , true ] ,
1045+ ] ) ;
1046+ } ) ;
1047+
1048+ it ( "honors a finish-only transcription message" , async ( ) => {
1049+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
1050+ const onTranscript = vi . fn ( ) ;
1051+ const bridge = provider . createBridge ( {
1052+ providerConfig : { apiKey : "gemini-key" } ,
1053+ onAudio : vi . fn ( ) ,
1054+ onClearAudio : vi . fn ( ) ,
1055+ onTranscript,
1056+ } ) ;
1057+
1058+ await bridge . connect ( ) ;
1059+ const onmessage = lastConnectParams ( ) . callbacks . onmessage ;
1060+ onmessage ( { serverContent : { inputTranscription : { text : "Last words" } } } ) ;
1061+ onmessage ( { serverContent : { inputTranscription : { finished : true } } } ) ;
1062+
1063+ expect ( onTranscript . mock . calls ) . toEqual ( [
1064+ [ "user" , "Last words" , false ] ,
1065+ [ "user" , "Last words" , true ] ,
1066+ ] ) ;
1067+ } ) ;
1068+
1069+ it ( "retains unordered transcript chunks until a protocol terminal or close" , async ( ) => {
1070+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
1071+ const onTranscript = vi . fn ( ) ;
1072+ const bridge = provider . createBridge ( {
1073+ providerConfig : { apiKey : "gemini-key" } ,
1074+ onAudio : vi . fn ( ) ,
1075+ onClearAudio : vi . fn ( ) ,
1076+ onTranscript,
1077+ } ) ;
1078+
1079+ await bridge . connect ( ) ;
1080+ const onmessage = lastConnectParams ( ) . callbacks . onmessage ;
1081+ onmessage ( { serverContent : { inputTranscription : { text : "Earlier question. " } } } ) ;
1082+ onmessage ( { serverContent : { outputTranscription : { text : "Interrupted response " } } } ) ;
1083+ onmessage ( { serverContent : { interrupted : true } } ) ;
1084+ onmessage ( { serverContent : { turnComplete : true } } ) ;
1085+ onmessage ( {
1086+ serverContent : {
1087+ inputTranscription : { text : "New question" } ,
1088+ outputTranscription : { text : "ending" } ,
1089+ turnComplete : true ,
1090+ } ,
1091+ } ) ;
1092+
1093+ expect ( onTranscript . mock . calls . filter ( ( call ) => call [ 2 ] === true ) ) . toEqual ( [ ] ) ;
1094+
1095+ bridge . close ( ) ;
1096+ expect ( onTranscript . mock . calls . filter ( ( call ) => call [ 2 ] === true ) ) . toEqual ( [
1097+ [ "user" , "Earlier question. New question" , true ] ,
1098+ [ "assistant" , "Interrupted response ending" , true ] ,
1099+ ] ) ;
1100+ } ) ;
1101+
1102+ it ( "flushes pending transcripts when the bridge closes" , async ( ) => {
1103+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
1104+ const onTranscript = vi . fn ( ) ;
1105+ const bridge = provider . createBridge ( {
1106+ providerConfig : { apiKey : "gemini-key" } ,
1107+ onAudio : vi . fn ( ) ,
1108+ onClearAudio : vi . fn ( ) ,
1109+ onTranscript,
1110+ } ) ;
1111+
1112+ await bridge . connect ( ) ;
1113+ lastConnectParams ( ) . callbacks . onmessage ( {
1114+ serverContent : { inputTranscription : { text : "Last words" } } ,
1115+ } ) ;
1116+ bridge . close ( ) ;
1117+
1118+ expect ( onTranscript . mock . calls . at ( - 1 ) ) . toEqual ( [ "user" , "Last words" , true ] ) ;
1119+ } ) ;
1120+
1121+ it ( "closes the Live session when the final transcript callback throws" , async ( ) => {
1122+ const provider = buildGoogleRealtimeVoiceProvider ( ) ;
1123+ const callbackError = new Error ( "transcript persistence failed" ) ;
1124+ const onError = vi . fn ( ) ;
1125+ const bridge = provider . createBridge ( {
1126+ providerConfig : { apiKey : "gemini-key" } ,
1127+ onAudio : vi . fn ( ) ,
1128+ onClearAudio : vi . fn ( ) ,
1129+ onError,
1130+ onTranscript : vi . fn ( ( _role , _text , isFinal ) => {
1131+ if ( isFinal ) {
1132+ throw callbackError ;
1133+ }
1134+ } ) ,
1135+ } ) ;
1136+
1137+ await bridge . connect ( ) ;
1138+ lastConnectParams ( ) . callbacks . onmessage ( {
1139+ serverContent : { inputTranscription : { text : "Last words" } } ,
1140+ } ) ;
1141+
1142+ expect ( ( ) => bridge . close ( ) ) . not . toThrow ( ) ;
1143+ expect ( onError ) . toHaveBeenCalledWith ( callbackError ) ;
1144+ expect ( session . close ) . toHaveBeenCalledTimes ( 1 ) ;
1145+ } ) ;
1146+
8811147 it ( "reports provider-confirmed input interruption as barge-in" , async ( ) => {
8821148 const provider = buildGoogleRealtimeVoiceProvider ( ) ;
8831149 const onClearAudio = vi . fn ( ) ;
0 commit comments