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