@@ -371,6 +371,201 @@ public void processRawResponses_twoStreamingFunctionCalls_keepArgsSeparate() {
371371 assertThat (second .args ().get ()).containsExactly ("b" , "2" );
372372 }
373373
374+ // The last partialArgs chunk keeps willContinue=true; completion arrives on a separate empty
375+ // willContinue=false marker, then trailing text follows. The marker must flush the call so it
376+ // precedes the text. Without handling the marker, close() flushes the call after the text,
377+ // reversing their order (a single call alone would be masked by that end-of-stream flush).
378+ @ Test
379+ public void processRawResponses_streamedCallEndedByEmptyMarker_flushesCallBeforeTrailingText () {
380+ GenerateContentResponse name =
381+ toResponse (
382+ functionCallPart (FunctionCall .builder ().name ("bookFlight" ).willContinue (true ).build ()));
383+ GenerateContentResponse origin1 =
384+ toResponse (
385+ functionCallPart (
386+ FunctionCall .builder ()
387+ .partialArgs (
388+ PartialArg .builder ().jsonPath ("$.origin" ).stringValue ("Krak" ).build ())
389+ .willContinue (true )
390+ .build ()));
391+ GenerateContentResponse origin2 =
392+ toResponse (
393+ functionCallPart (
394+ FunctionCall .builder ()
395+ .partialArgs (
396+ PartialArg .builder ().jsonPath ("$.origin" ).stringValue ("ow" ).build ())
397+ .willContinue (true )
398+ .build ()));
399+ GenerateContentResponse destination =
400+ toResponse (
401+ functionCallPart (
402+ FunctionCall .builder ()
403+ .partialArgs (
404+ PartialArg .builder ()
405+ .jsonPath ("$.destination" )
406+ .stringValue ("Warsaw" )
407+ .build ())
408+ .willContinue (true )
409+ .build ()));
410+ GenerateContentResponse endMarker =
411+ toResponse (functionCallPart (FunctionCall .builder ().willContinue (false ).build ()));
412+ GenerateContentResponse trailingText = toResponseWithText ("Booked." , FinishReason .Known .STOP );
413+
414+ ImmutableList <LlmResponse > responses =
415+ ImmutableList .copyOf (
416+ Gemini .processRawResponses (
417+ Flowable .just (name , origin1 , origin2 , destination , endMarker , trailingText ))
418+ .blockingIterable ());
419+
420+ LlmResponse finalResponse = Iterables .getLast (responses );
421+ assertThat (finalResponse .content ().get ().parts ().get ()).hasSize (2 );
422+ FunctionCall finalCall =
423+ finalResponse .content ().get ().parts ().get ().get (0 ).functionCall ().get ();
424+ assertThat (finalCall .name ()).hasValue ("bookFlight" );
425+ assertThat (finalCall .args ().get ()).containsExactly ("origin" , "Krakow" , "destination" , "Warsaw" );
426+ assertThat (finalResponse .content ().get ().parts ().get ().get (1 ).text ()).hasValue ("Booked." );
427+ }
428+
429+ // Two multi-arg streamed calls each ended by an empty willContinue=false marker must not drop the
430+ // first call nor bleed its args into the second.
431+ @ Test
432+ public void processRawResponses_twoStreamedCallsEndedByEmptyMarkers_keepArgsSeparate () {
433+ GenerateContentResponse call1Name =
434+ toResponse (
435+ functionCallPart (
436+ FunctionCall .builder ().name ("getTemperature" ).willContinue (true ).build ()));
437+ GenerateContentResponse call1City =
438+ toResponse (
439+ functionCallPart (
440+ FunctionCall .builder ()
441+ .partialArgs (
442+ PartialArg .builder ().jsonPath ("$.city" ).stringValue ("Krakow" ).build ())
443+ .willContinue (true )
444+ .build ()));
445+ GenerateContentResponse call1Unit =
446+ toResponse (
447+ functionCallPart (
448+ FunctionCall .builder ()
449+ .partialArgs (PartialArg .builder ().jsonPath ("$.unit" ).stringValue ("C" ).build ())
450+ .willContinue (true )
451+ .build ()));
452+ GenerateContentResponse marker1 =
453+ toResponse (functionCallPart (FunctionCall .builder ().willContinue (false ).build ()));
454+ GenerateContentResponse call2Name =
455+ toResponse (
456+ functionCallPart (
457+ FunctionCall .builder ().name ("getCondition" ).willContinue (true ).build ()));
458+ GenerateContentResponse call2City =
459+ toResponse (
460+ functionCallPart (
461+ FunctionCall .builder ()
462+ .partialArgs (
463+ PartialArg .builder ().jsonPath ("$.city" ).stringValue ("Warsaw" ).build ())
464+ .willContinue (true )
465+ .build ()));
466+ GenerateContentResponse call2Unit =
467+ toResponse (
468+ functionCallPart (
469+ FunctionCall .builder ()
470+ .partialArgs (PartialArg .builder ().jsonPath ("$.unit" ).stringValue ("F" ).build ())
471+ .willContinue (true )
472+ .build ()));
473+ GenerateContentResponse marker2 =
474+ toResponse (
475+ Candidate .builder ()
476+ .content (
477+ Content .builder ()
478+ .parts (functionCallPart (FunctionCall .builder ().willContinue (false ).build ()))
479+ .build ())
480+ .finishReason (new FinishReason (FinishReason .Known .STOP ))
481+ .build ());
482+
483+ ImmutableList <LlmResponse > responses =
484+ ImmutableList .copyOf (
485+ Gemini .processRawResponses (
486+ Flowable .just (
487+ call1Name , call1City , call1Unit , marker1 , call2Name , call2City , call2Unit ,
488+ marker2 ))
489+ .blockingIterable ());
490+
491+ LlmResponse finalResponse = Iterables .getLast (responses );
492+ assertThat (finalResponse .content ().get ().parts ().get ()).hasSize (2 );
493+ FunctionCall first = finalResponse .content ().get ().parts ().get ().get (0 ).functionCall ().get ();
494+ FunctionCall second = finalResponse .content ().get ().parts ().get ().get (1 ).functionCall ().get ();
495+ assertThat (first .name ()).hasValue ("getTemperature" );
496+ assertThat (first .args ().get ()).containsExactly ("city" , "Krakow" , "unit" , "C" );
497+ assertThat (second .name ()).hasValue ("getCondition" );
498+ assertThat (second .args ().get ()).containsExactly ("city" , "Warsaw" , "unit" , "F" );
499+ }
500+
501+ // Safety guard for non-conforming output: a streamed call still in progress (the model should
502+ // have terminated it with willContinue=false) is followed by a complete non-streaming call. The
503+ // in-progress call is flushed before appending, so neither is dropped nor merged.
504+ @ Test
505+ public void processRawResponses_streamedCallFollowedByCompleteCall_flushesInProgressFirst () {
506+ GenerateContentResponse streamedName =
507+ toResponse (
508+ functionCallPart (
509+ FunctionCall .builder ().name ("stream_call" ).willContinue (true ).build ()));
510+ GenerateContentResponse streamedArg =
511+ toResponse (
512+ functionCallPart (
513+ FunctionCall .builder ()
514+ .partialArgs (PartialArg .builder ().jsonPath ("$.a" ).stringValue ("1" ).build ())
515+ .willContinue (true )
516+ .build ()));
517+ GenerateContentResponse completeCall =
518+ toResponse (
519+ Candidate .builder ()
520+ .content (
521+ Content .builder ()
522+ .parts (
523+ functionCallPart (
524+ FunctionCall .builder ()
525+ .name ("plain_call" )
526+ .args (ImmutableMap .of ("b" , "2" ))
527+ .build ()))
528+ .build ())
529+ .finishReason (new FinishReason (FinishReason .Known .STOP ))
530+ .build ());
531+
532+ ImmutableList <LlmResponse > responses =
533+ ImmutableList .copyOf (
534+ Gemini .processRawResponses (Flowable .just (streamedName , streamedArg , completeCall ))
535+ .blockingIterable ());
536+
537+ LlmResponse finalResponse = Iterables .getLast (responses );
538+ assertThat (finalResponse .content ().get ().parts ().get ()).hasSize (2 );
539+ FunctionCall first = finalResponse .content ().get ().parts ().get ().get (0 ).functionCall ().get ();
540+ FunctionCall second = finalResponse .content ().get ().parts ().get ().get (1 ).functionCall ().get ();
541+ assertThat (first .name ()).hasValue ("stream_call" );
542+ assertThat (first .args ().get ()).containsExactly ("a" , "1" );
543+ assertThat (second .name ()).hasValue ("plain_call" );
544+ assertThat (second .args ().get ()).containsExactly ("b" , "2" );
545+ }
546+
547+ // A stray nameless willContinue=false marker with no call in progress must be a safe no-op (the
548+ // currentFcName != null half of the guard): it must not add a function call nor split the
549+ // surrounding text. Without that half it would be treated as a streamed part and prematurely
550+ // flush the text buffer, splitting "Hello world" into two parts.
551+ @ Test
552+ public void processRawResponses_strayNamelessMarker_isNoOpAndDoesNotSplitText () {
553+ GenerateContentResponse hello = toResponseWithText ("Hello " );
554+ GenerateContentResponse strayMarker =
555+ toResponse (functionCallPart (FunctionCall .builder ().willContinue (false ).build ()));
556+ GenerateContentResponse world = toResponseWithText ("world" , FinishReason .Known .STOP );
557+
558+ ImmutableList <LlmResponse > responses =
559+ ImmutableList .copyOf (
560+ Gemini .processRawResponses (Flowable .just (hello , strayMarker , world ))
561+ .blockingIterable ());
562+
563+ LlmResponse finalResponse = Iterables .getLast (responses );
564+ assertThat (finalResponse .content ().get ().parts ().get ()).hasSize (1 );
565+ assertThat (finalResponse .content ().get ().parts ().get ().get (0 ).text ()).hasValue ("Hello world" );
566+ assertThat (finalResponse .content ().get ().parts ().get ().get (0 ).functionCall ()).isEmpty ();
567+ }
568+
374569 @ Test
375570 public void processRawResponses_imageOnlyWithStop_emitsFinalImagePart () {
376571 Part imagePart = Part .fromBytes (new byte [] {1 , 2 , 3 }, "image/png" );
0 commit comments