@@ -63,6 +63,81 @@ public void processRawResponses_withTextChunks_emitsPartialResponses() {
6363 isFunctionCallResponse ());
6464 }
6565
66+ // Regression test for b/513501918. gemini-3.1-flash-lite emits an extra trailing chunk after a
67+ // function call: `{parts:[{text:""}], finishReason:STOP}`. That chunk must not be propagated as
68+ // a non-partial event because BaseLlmFlow#run would treat it as the final response and
69+ // terminate the loop before the function response is sent back to the model. The chunk's
70+ // metadata (e.g. `finishReason`, `usageMetadata`) is preserved by emitting it on a content-less
71+ // partial response instead of dropping the chunk entirely.
72+ @ Test
73+ public void
74+ processRawResponses_functionCallThenEmptyTextWithStop_emitsFunctionCallAndMetadataOnlyPartial () {
75+ Flowable <GenerateContentResponse > rawResponses =
76+ Flowable .just (
77+ toResponse (Part .fromFunctionCall ("test_function" , ImmutableMap .of ())),
78+ toResponseWithText ("" , FinishReason .Known .STOP ));
79+
80+ Flowable <LlmResponse > llmResponses = Gemini .processRawResponses (rawResponses );
81+
82+ assertLlmResponses (
83+ llmResponses ,
84+ isFunctionCallResponse (),
85+ isContentlessPartialWithFinishReason (FinishReason .Known .STOP ));
86+ }
87+
88+ // Same as above but with `usageMetadata` on the trailing empty chunk: the metadata must survive
89+ // on the emitted content-less partial.
90+ @ Test
91+ public void
92+ processRawResponses_functionCallThenEmptyTextWithUsageMetadata_preservesUsageMetadata () {
93+ GenerateContentResponseUsageMetadata metadata = createUsageMetadata (5 , 10 , 15 );
94+ Flowable <GenerateContentResponse > rawResponses =
95+ Flowable .just (
96+ toResponse (Part .fromFunctionCall ("test_function" , ImmutableMap .of ())),
97+ toResponseWithText ("" , FinishReason .Known .STOP , metadata ));
98+
99+ Flowable <LlmResponse > llmResponses = Gemini .processRawResponses (rawResponses );
100+
101+ assertLlmResponses (
102+ llmResponses , isFunctionCallResponse (), isContentlessPartialWithUsageMetadata (metadata ));
103+ }
104+
105+ // Same as above but without a finishReason or usageMetadata: the trailing empty chunk carries no
106+ // useful payload and must be suppressed entirely.
107+ @ Test
108+ public void processRawResponses_functionCallThenEmptyText_doesNotEmitExtraEmptyResponse () {
109+ Flowable <GenerateContentResponse > rawResponses =
110+ Flowable .just (
111+ toResponse (Part .fromFunctionCall ("test_function" , ImmutableMap .of ())),
112+ toResponseWithText ("" ));
113+
114+ Flowable <LlmResponse > llmResponses = Gemini .processRawResponses (rawResponses );
115+
116+ assertLlmResponses (llmResponses , isFunctionCallResponse ());
117+ }
118+
119+ // Combined scenario: leading partial text, then a function call, then the trailing empty-text
120+ // chunk with STOP. Accumulated text must still be flushed, the function call must still be
121+ // emitted, and the trailing chunk must surface only its metadata on a content-less partial.
122+ @ Test
123+ public void
124+ processRawResponses_textThenFunctionCallThenEmptyTextWithStop_emitsTextFunctionCallAndMetadata () {
125+ Flowable <GenerateContentResponse > rawResponses =
126+ Flowable .just (
127+ toResponseWithText ("Thinking..." ),
128+ toResponse (Part .fromFunctionCall ("test_function" , ImmutableMap .of ())),
129+ toResponseWithText ("" , FinishReason .Known .STOP ));
130+
131+ Flowable <LlmResponse > llmResponses = Gemini .processRawResponses (rawResponses );
132+
133+ assertLlmResponses (
134+ llmResponses ,
135+ isPartialTextResponse ("Thinking..." ),
136+ isFinalTextResponse ("Thinking..." ),
137+ isFunctionCallResponse (),
138+ isContentlessPartialWithFinishReason (FinishReason .Known .STOP ));
139+ }
140+
66141 @ Test
67142 public void processRawResponses_textAndStopReason_emitsPartialThenFinalText () {
68143 Flowable <GenerateContentResponse > rawResponses =
@@ -175,6 +250,93 @@ public void processRawResponses_thoughtChunksAndStop_includeUsageMetadata() {
175250 isFinalThoughtResponseWithUsageMetadata ("Thinking deeply" , metadata2 ));
176251 }
177252
253+ // Test cases for the shouldEmit filter applied by generateContent after processRawResponses.
254+ // shouldEmit drops chunks that are empty-text-only AND carry no useful metadata; everything else
255+ // is forwarded. processRawResponses normally already strips empty-text-only chunks, so shouldEmit
256+ // is defense-in-depth, but it must still behave correctly when fed any LlmResponse directly.
257+
258+ @ Test
259+ public void shouldEmit_emptyTextOnlyResponseWithNoMetadata_returnsFalse () {
260+ LlmResponse response =
261+ LlmResponse .builder ()
262+ .content (Content .builder ().role ("model" ).parts (Part .fromText ("" )).build ())
263+ .build ();
264+
265+ assertThat (Gemini .shouldEmit (response )).isFalse ();
266+ }
267+
268+ @ Test
269+ public void shouldEmit_emptyTextOnlyResponseWithFinishReason_returnsTrue () {
270+ LlmResponse response =
271+ LlmResponse .builder ()
272+ .content (Content .builder ().role ("model" ).parts (Part .fromText ("" )).build ())
273+ .finishReason (new FinishReason (FinishReason .Known .STOP ))
274+ .build ();
275+
276+ assertThat (Gemini .shouldEmit (response )).isTrue ();
277+ }
278+
279+ @ Test
280+ public void shouldEmit_emptyTextOnlyResponseWithUsageMetadata_returnsTrue () {
281+ LlmResponse response =
282+ LlmResponse .builder ()
283+ .content (Content .builder ().role ("model" ).parts (Part .fromText ("" )).build ())
284+ .usageMetadata (createUsageMetadata (5 , 10 , 15 ))
285+ .build ();
286+
287+ assertThat (Gemini .shouldEmit (response )).isTrue ();
288+ }
289+
290+ @ Test
291+ public void shouldEmit_nonEmptyTextResponse_returnsTrue () {
292+ LlmResponse response =
293+ LlmResponse .builder ()
294+ .content (Content .builder ().role ("model" ).parts (Part .fromText ("hello" )).build ())
295+ .build ();
296+
297+ assertThat (Gemini .shouldEmit (response )).isTrue ();
298+ }
299+
300+ @ Test
301+ public void shouldEmit_functionCallResponse_returnsTrue () {
302+ LlmResponse response =
303+ LlmResponse .builder ()
304+ .content (
305+ Content .builder ()
306+ .role ("model" )
307+ .parts (Part .fromFunctionCall ("test_function" , ImmutableMap .of ()))
308+ .build ())
309+ .build ();
310+
311+ assertThat (Gemini .shouldEmit (response )).isTrue ();
312+ }
313+
314+ @ Test
315+ public void shouldEmit_contentlessResponse_returnsTrue () {
316+ // A response with no content at all is not an empty-text-only response, so it should pass
317+ // through regardless of metadata. This is the shape emitted by processRawResponses after it
318+ // strips empty-text content while preserving metadata.
319+ LlmResponse response = LlmResponse .builder ().build ();
320+
321+ assertThat (Gemini .shouldEmit (response )).isTrue ();
322+ }
323+
324+ @ Test
325+ public void shouldEmit_multiPartResponseWithEmptyTextPart_returnsTrue () {
326+ // Only single-part empty-text responses are considered "empty-text-only". A multi-part response
327+ // is treated as carrying semantic content and must always pass through.
328+ LlmResponse response =
329+ LlmResponse .builder ()
330+ .content (
331+ Content .builder ()
332+ .role ("model" )
333+ .parts (Part .fromText ("" ), Part .fromText ("hello" ))
334+ .build ())
335+ .build ();
336+
337+ assertThat (Gemini .shouldEmit (response )).isTrue ();
338+ }
339+
178340 @ Test
179341 public void processRawResponses_thoughtAndTextWithStop_onlyFinalTextIncludesUsageMetadata () {
180342 GenerateContentResponseUsageMetadata metadata1 = createUsageMetadata (5 , 5 , 10 );
@@ -232,6 +394,26 @@ private static Predicate<LlmResponse> isFunctionCallResponse() {
232394 };
233395 }
234396
397+ private static Predicate <LlmResponse > isContentlessPartialWithFinishReason (
398+ FinishReason .Known expectedFinishReason ) {
399+ return response -> {
400+ assertThat (response .partial ()).hasValue (true );
401+ assertThat (response .content ()).isEmpty ();
402+ assertThat (response .finishReason ().map (fr -> fr .knownEnum ())).hasValue (expectedFinishReason );
403+ return true ;
404+ };
405+ }
406+
407+ private static Predicate <LlmResponse > isContentlessPartialWithUsageMetadata (
408+ GenerateContentResponseUsageMetadata expectedMetadata ) {
409+ return response -> {
410+ assertThat (response .partial ()).hasValue (true );
411+ assertThat (response .content ()).isEmpty ();
412+ assertThat (response .usageMetadata ()).hasValue (expectedMetadata );
413+ return true ;
414+ };
415+ }
416+
235417 private static Predicate <LlmResponse > isEmptyResponse () {
236418 return response -> {
237419 assertThat (response .partial ()).isEmpty ();
0 commit comments