diff --git a/core/src/test/java/com/google/adk/models/GeminiProcessRawResponsesTest.java b/core/src/test/java/com/google/adk/models/GeminiProcessRawResponsesTest.java new file mode 100644 index 000000000..13daf4645 --- /dev/null +++ b/core/src/test/java/com/google/adk/models/GeminiProcessRawResponsesTest.java @@ -0,0 +1,841 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// ********RoostGPT******** +/* +Test generated by RoostGPT for test unit-java-adk-v11 using AI Type Azure Open AI and AI Model gpt-4.1 + +ROOST_METHOD_HASH=processRawResponses_490580de43 +ROOST_METHOD_SIG_HASH=processRawResponses_b550986252 + +Here are your existing test cases which we found out and are not considered for test generation: + +File Path: C:\var\tmp\Roost\RoostGPT\unit-java-adk-v11\1768999218\source\adk-java\core\src\test\java\com\google\adk\models\GeminiTest.java +Tests: + "@Test +public void processRawResponses_withTextChunks_emitsPartialResponses() { + Flowable rawResponses = Flowable.just(toResponseWithText("Hello"), toResponseWithText(" world")); + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + assertLlmResponses(llmResponses, isPartialTextResponse("Hello"), isPartialTextResponse(" world")); + } +" + "@Test +public void processRawResponses_textThenFunctionCall_emitsPartialTextThenFullTextAndFunctionCall() { + Flowable rawResponses = Flowable.just(toResponseWithText("Thinking..."), toResponse(Part.fromFunctionCall("test_function", ImmutableMap.of()))); + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + assertLlmResponses(llmResponses, isPartialTextResponse("Thinking..."), isFinalTextResponse("Thinking..."), isFunctionCallResponse()); + } +" + "@Test +public void processRawResponses_textAndStopReason_emitsPartialThenFinalText() { + Flowable rawResponses = Flowable.just(toResponseWithText("Hello"), toResponseWithText(" world", FinishReason.Known.STOP)); + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + assertLlmResponses(llmResponses, isPartialTextResponse("Hello"), isPartialTextResponse(" world"), isFinalTextResponse("Hello world")); + } +" + "@Test +public void processRawResponses_emptyStream_emitsNothing() { + Flowable rawResponses = Flowable.empty(); + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + assertLlmResponses(llmResponses); + } +" + "@Test +public void processRawResponses_singleEmptyResponse_emitsOneEmptyResponse() { + Flowable rawResponses = Flowable.just(GenerateContentResponse.builder().build()); + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + assertLlmResponses(llmResponses, isEmptyResponse()); + } +" + "@Test +public void processRawResponses_finishReasonNotStop_doesNotEmitFinalAccumulatedText() { + Flowable rawResponses = Flowable.just(toResponseWithText("Hello"), toResponseWithText(" world", FinishReason.Known.MAX_TOKENS)); + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + assertLlmResponses(llmResponses, isPartialTextResponse("Hello"), isPartialTextResponse(" world")); + } +" + "@Test +public void processRawResponses_textThenEmpty_emitsPartialTextThenFullTextAndEmpty() { + Flowable rawResponses = Flowable.just(toResponseWithText("Thinking..."), GenerateContentResponse.builder().build()); + Flowable llmResponses = Gemini.processRawResponses(rawResponses); + assertLlmResponses(llmResponses, isPartialTextResponse("Thinking..."), isFinalTextResponse("Thinking..."), isEmptyResponse()); + } +"Scenario 1: Handling a single response with a thought part + +Details: + TestName: singleThoughtPartResponse + Description: This test verifies that when a single GenerateContentResponse containing a "thought" Part with non-blank text is streamed, the method emits a partial LlmResponse marked as a "thinking" response, and no accumulation or concatenation occurs. + +Execution: + Arrange: + - Prepare one GenerateContentResponse which contains a Part with: + - text="I'm thinking", thought=true, and is not blank. + - All other fields can be default. + - Wrap that in a Flowable with one element. + Act: + - Call Gemini.processRawResponses(flowableContainingASingleThoughtResponse). + Assert: + - Verify that a single LlmResponse is emitted, it has the "thinking" content with the text, and is marked as partial=true. +Validation: + - This test ensures that the method recognizes a single "thought" response, creates a partial LlmResponse accordingly, and does not try to emit any aggregated or final responses. + - It demonstrates the handling of simple, non-accumulated thought messages. + +Scenario 2: Aggregation of multiple sequential thought responses + +Details: + TestName: consecutiveThoughtsAreAggregated + Description: Tests that multiple consecutive streaming responses, each with a "thought" Part and non-blank text, are accumulated and when an empty response with shouldEmitAccumulatedText triggers, the full aggregation is emitted as a single final thought LlmResponse. + +Execution: + Arrange: + - Prepare three GenerateContentResponse objects: + - Each has a "thought" Part with non-blank text. + - Prepare a fourth GenerateContentResponse that is empty and where GeminiUtil.shouldEmitAccumulatedText returns true. + - Package the four in sequence into a Flowable. + Act: + - Call Gemini.processRawResponses with these four responses. + Assert: + - First three partial thinking LlmResponse objects are emitted, each with the respective text. + - After the empty response, a full/thought LlmResponse is emitted with the concatenation of all prior texts. + - The empty response itself is also emitted. +Validation: + - Confirms handling of accumulation and aggregation of thought responses, as well as triggering emission when a subsequent (possibly empty) response indicates so. + - Validates the flow for conversational AI that thinks in stages. + +Scenario 3: Sequential mix of text and thought responses + +Details: + TestName: mixedThoughtAndTextChunkAggregation + Description: Checks correct differentiation between normal text and thought parts arriving in sequence, and accumulation is separated for thoughts and non-thoughts. Verifies that emitting on an empty response triggers both accumulations correctly. + +Execution: + Arrange: + - Create a sequence of responses: + - First: non-blank text Part with thought=false (just plain text). + - Second: non-blank text Part with thought=true. + - Third: non-blank text Part with thought=false. + - Fourth: empty response with shouldEmitAccumulatedText returning true. + - Compose them in a Flowable. + Act: + - Pass to Gemini.processRawResponses. + Assert: + - Partial text response with first text. + - Partial thinking response with second text. + - Partial text response with third text. + - At the empty response, emits both: accumulated thought LlmResponse and accumulated text LlmResponse with correct concatenations (thoughts for thought, text for normal). + - Also emits the empty final LlmResponse for the fourth response. +Validation: + - Validates that text and thought accumulations are managed independently and the trigger emits the correct full responses for each type. + - Important for disjoint thought/text tracking in advanced conversational scenarios. + +Scenario 4: Response with blank text part and no accumulation present + +Details: + TestName: blankTextWithNoAccumulation + Description: Verifies a single GenerateContentResponse with a blank Part and no accumulation present does not emit an aggregated final response; it should only emit the transformed empty LlmResponse. + +Execution: + Arrange: + - Prepare one GenerateContentResponse object with a Part whose text="" (blank). + - No prior accumulations are present. + Act: + - Process with Gemini.processRawResponses. + Assert: + - Only one LlmResponse is emitted, representing the blank/empty response, and no aggregation logic is triggered. +Validation: + - Protects against erroneous final or thought emissions when the stream is essentially empty in content. + - Validates the base case for handling blank inputs. + +Scenario 5: Handling an initial function call part in the response + +Details: + TestName: functionCallResponseEmittedDirectly + Description: Ensures that when a FunctionCall Part is the first/only element in a streamed GenerateContentResponse, no text or thought accumulation occurs, and the response is emitted as-is. + +Execution: + Arrange: + - Compose one GenerateContentResponse with a single Part that is a function call (using Part.fromFunctionCall). + - Use Flowable.just(response). + Act: + - Call Gemini.processRawResponses. + Assert: + - LlmResponse.create(response) is emitted directly. +Validation: + - Checks that non-textual payloads are not misinterpreted or cause failures in accumulation/aggregation logic. + +Scenario 6: Text chunk followed by multiple empty responses with shouldEmitAccumulatedText false + +Details: + TestName: textFollowedByEmptyNoAggregationEmitted + Description: Tests that when a non-blank text chunk is sent, but subsequent empty responses do NOT trigger shouldEmitAccumulatedText, no full/aggregated text message is emitted at the end. Only partials and as-is empties are produced. + +Execution: + Arrange: + - First response: Part with some text, thought=false. + - Second and third responses: empty (blank Part/text), shouldEmitAccumulatedText returns false for both. + - Insert into Flowable. + Act: + - Process responses via method. + Assert: + - Only a partial LlmResponse for the text is emitted, followed by two empty LlmResponse objects for the blank responses. + - No aggregation into a full LlmResponse. +Validation: + - Ensures that accumulation is only emitted when the utility method signals it should, not unconditionally. + +Scenario 7: No Candidates present in final response (tail emission branch) + +Details: + TestName: noCandidatesInFinalTailNoStop + Description: Ensures that when concatWith branch is reached, and lastRawResponseHolder[0] contains no candidates, the tail emission does not emit an aggregation, no matter the accumulation state. + +Execution: + Arrange: + - Compose a sequence of GenerateContentResponse objects each with text. + - Last received response (lastRawResponseHolder[0]) contains no candidates. + - Use Flowable.just(...) and ensure last object triggers the tail-processing. + Act: + - Process with method. + Assert: + - Partial responses are emitted for each text chunk. + - At the end, tail emits nothing further because isStop is false due to lack of candidates. +Validation: + - Ensures edge cases for missing candidate structures are handled gracefully in tail logic. + +Scenario 8: Final response with STOP finish reason emits accumulated thoughts and text + +Details: + TestName: stopReasonTriggersTailAggregation + Description: Crafts a sequence where both accumulated thoughts and text exist, and the last response’s candidate has FinishReason.STOP, so both are emitted in the tail phase. + +Execution: + Arrange: + - Sequence includes several responses building up thought and text accumulations (alternate between parts, mark some with thought=true). + - Final response: candidate’s finish reason set to STOP, and blank part. + - Use Flowable.just(...). + Act: + - Pass through method under test. + Assert: + - Partial responses as appropriate for incoming chunks. + - In concatWith tail, both a full accumulated thought LlmResponse and a full text LlmResponse are emitted. +Validation: + - Proves that STOP finish triggers proper emission of both accumulations in the tail-branch. + +Scenario 9: Mixed text, thought, then non-triggered “stop” candidate + +Details: + TestName: finishReasonNotStopNoTailAggregation + Description: Verifies that when the last response’s candidate finish reason is not STOP (e.g., MAX_TOKENS or null), no tail aggregation is emitted, even if content was accumulated. + +Execution: + Arrange: + - Sequence accumulates text/thought as above. + - Last response contains a candidate with finish reason MAX_TOKENS (not STOP). + Act: + - Call Gemini.processRawResponses. + Assert: + - Only the expected partials are emitted for text/thought chunks, with no tail-level aggregation. +Validation: + - Protects against premature or incorrect emission of aggregated results in non-STOP final state. + +Scenario 10: Handling responses with missing or empty parts list + +Details: + TestName: emptyPartsListEmitsEmpty + Description: Ensures that when the GenerateContentResponse contains an empty or missing parts list, the method emits a plain LlmResponse (no content), and no errors or aggregations occur. + +Execution: + Arrange: + - GenerateContentResponse with no parts (parts() returns Optional.empty or empty list). + - Use Flowable.just(response). + Act: + - Pass to method under test. + Assert: + - One empty LlmResponse is emitted (possibly as LlmResponse.create(response)). +Validation: + - Handles real-world cases when up- or downstream components produce incomplete payloads. + +Scenario 11: Null text in part, with a thought key present (i.e., thought=true, text=null) + +Details: + TestName: thoughtButNoTextIsIgnored + Description: Ensures that when a part has thought=true but text is null or blank, it neither triggers a partial thought response nor accumulates anything improperly. + +Execution: + Arrange: + - Generate a Part with thought=true and text absent or explicitly null/empty. + - Wrap in a GenerateContentResponse and Flowable.just. + Act: + - Call Gemini.processRawResponses. + Assert: + - Only the base LlmResponse (empty/unchanged) is emitted, no partial-thought response is generated. +Validation: + - Ensures resilience in the presence of incomplete or malformed thought markers. + +Scenario 12: Interleaved thought, text, function call, and blank parts + +Details: + TestName: interleavedPartsProcessedCorrectly + Description: Delivers a deliberately interleaved sequence of GenerateContentResponse objects covering: + - Thought part with text + - Normal text part + - FunctionCall part + - Blank text part + ... to verify correct partials, aggregated, and passthrough emissions. + +Execution: + Arrange: + - Flowable with four responses covering above sequence, and set GeminiUtil.shouldEmitAccumulatedText to trigger at blank. + Act: + - Execute processRawResponses. + Assert: + - Partial thinking LlmResponse, partial text LlmResponse, passthrough for function call, aggregated responses as appropriate at blank. +Validation: + - Verifies framework handles complex conversational exchanges with interleaving of thought, functional, and empty steps. + +Scenario 13: Long sequence leading to accumulation buffer overflow/large aggregation + +Details: + TestName: largeAggregationHandledProperly + Description: Supplies a long (e.g., 100+) sequence of non-blank text parts to test if StringBuilder accumulator handles large text/chunks, and only emits one aggregated response at STOP. + +Execution: + Arrange: + - Generate 100+ GenerateContentResponse objects with text parts, all thought=false. + - Last response contains candidate with STOP finish reason. + Act: + - Pass the Flowable to processRawResponses. + Assert: + - 100+ partial text responses emitted. + - Tail emits a single full aggregated LlmResponse containing concatenated text from all chunks. +Validation: + - Ensures the implementation scales and does not lose or split text in long streams. + +Scenario 14: No content parts, but candidate with STOP present + +Details: + TestName: stopWithNoContentEmitsNothing + Description: Ensures if every GenerateContentResponse is empty (no part, possibly only candidate with STOP), no spurious full responses are emitted at the end. + +Execution: + Arrange: + - Sequence of two GenerateContentResponse objects, both empty; + - Second has a candidate with finish reason STOP. + - Flowable with both. + Act: + - Process. + Assert: + - Only blank/empty LlmResponses are emitted, no final aggregation. +Validation: + - Ensures empty but stop-completed exchanges do not cause aggregation errors. + +Scenario 15: Accumulated partials cleared after aggregation + +Details: + TestName: accumulationClearedAfterEmit + Description: Verifies that after an aggregation/emit (triggered by shouldEmitAccumulatedText), further responses properly start accumulating from empty again. + +Execution: + Arrange: + - Responses: + - Text ("first"), + - Blank (shouldEmitAccumulatedText == true), + - Text ("second"), + - Blank (shouldEmitAccumulatedText == true). + Act: + - Process through method. + Assert: + - After each blank/aggregation, the accumulator is reset; only the new chunk is in each subsequent LlmResponse. +Validation: + - Ensures correct buffer management, preventing data merging over aggregation boundaries. + +*/ + +// ********RoostGPT******** + +package com.google.adk.models; +import static com.google.common.base.StandardSystemProperty.JAVA_VERSION; +import com.google.genai.Client; +import com.google.genai.ResponseStream; +import com.google.genai.types.Candidate; +import com.google.genai.types.Content; +import com.google.genai.types.FinishReason; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.HttpOptions; +import com.google.genai.types.LiveConnectConfig; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Flowable; +import com.google.common.collect.ImmutableMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.adk.models.BaseLlm; +import com.google.adk.models.LlmResponse; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.VertexCredentials; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.*; +import com.google.adk.Version; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +@ExtendWith(MockitoExtension.class) +public class GeminiProcessRawResponsesTest extends BaseLlm { + public GeminiProcessRawResponsesTest() { + super("test-model"); // TODO: Adjust model name as needed + } + @BeforeEach + public void setUp() { + // No-op. Add setup steps here if required. + } + @Test + @Tag("valid") + public void testSingleThoughtPartResponse() { + Part thoughtPart = Part.fromText("I'm thinking").toBuilder().thought(true).build(); + Content content = Content.builder().role("model").parts(thoughtPart).build(); + Candidate candidate = Candidate.builder().content(content).build(); + GenerateContentResponse response = GenerateContentResponse.builder() + .candidates(List.of(candidate)) + .build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())).thenReturn(Optional.of(thoughtPart)); + Flowable results = Gemini.processRawResponses(Flowable.just(response)); + List emitted = results.toList().blockingGet(); + assertEquals(1, emitted.size()); + LlmResponse result = emitted.get(0); + assertTrue(result.partial().orElse(false)); + String partText = result.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse(""); + assertEquals("I'm thinking", partText); + assertEquals(true, result.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::thought).orElse(false)); + } + } + @Test + @Tag("valid") + public void testConsecutiveThoughtsAreAggregated() { + Part thoughtPart1 = Part.fromText("First thought.").toBuilder().thought(true).build(); + Part thoughtPart2 = Part.fromText("Second thought.").toBuilder().thought(true).build(); + Part thoughtPart3 = Part.fromText("Third thought.").toBuilder().thought(true).build(); + Content content1 = Content.builder().role("model").parts(thoughtPart1).build(); + Content content2 = Content.builder().role("model").parts(thoughtPart2).build(); + Content content3 = Content.builder().role("model").parts(thoughtPart3).build(); + Candidate candidate1 = Candidate.builder().content(content1).build(); + Candidate candidate2 = Candidate.builder().content(content2).build(); + Candidate candidate3 = Candidate.builder().content(content3).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate1)).build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().candidates(List.of(candidate2)).build(); + GenerateContentResponse resp3 = GenerateContentResponse.builder().candidates(List.of(candidate3)).build(); + // Empty response, triggers shouldEmitAccumulatedText + GenerateContentResponse resp4 = GenerateContentResponse.builder().build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, false, false, true); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2, resp3, resp4)); + List emitted = results.toList().blockingGet(); + // Three partial 'thinking' responses then one aggregate full thought and the empty + assertEquals(5, emitted.size()); + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("First thought.", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertTrue(emitted.get(1).partial().orElse(false)); + assertEquals("Second thought.", emitted.get(1).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertTrue(emitted.get(2).partial().orElse(false)); + assertEquals("Third thought.", emitted.get(2).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Aggregated + assertFalse(emitted.get(3).partial().orElse(false)); + assertEquals("First thought.Second thought.Third thought.", emitted.get(3).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Final empty as-is + assertEquals(emitted.get(4), LlmResponse.create(resp4)); + } + } + @Test + @Tag("valid") + public void testMixedThoughtAndTextChunkAggregation() { + Part text1 = Part.fromText("Hello world!"); + Part thought2 = Part.fromText("Thinking...").toBuilder().thought(true).build(); + Part text3 = Part.fromText("More text."); + Content content1 = Content.builder().role("model").parts(text1).build(); + Content content2 = Content.builder().role("model").parts(thought2).build(); + Content content3 = Content.builder().role("model").parts(text3).build(); + Candidate candidate1 = Candidate.builder().content(content1).build(); + Candidate candidate2 = Candidate.builder().content(content2).build(); + Candidate candidate3 = Candidate.builder().content(content3).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate1)).build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().candidates(List.of(candidate2)).build(); + GenerateContentResponse resp3 = GenerateContentResponse.builder().candidates(List.of(candidate3)).build(); + GenerateContentResponse resp4 = GenerateContentResponse.builder().build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, false, false, true); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2, resp3, resp4)); + List emitted = results.toList().blockingGet(); + assertEquals(6, emitted.size()); + // Partial text + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("Hello world!", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Partial thought + assertTrue(emitted.get(1).partial().orElse(false)); + assertEquals("Thinking...", emitted.get(1).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Partial text 2 + assertTrue(emitted.get(2).partial().orElse(false)); + assertEquals("More text.", emitted.get(2).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Aggregated thought + assertFalse(emitted.get(3).partial().orElse(false)); + assertEquals("Thinking...", emitted.get(3).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Aggregated text + assertFalse(emitted.get(4).partial().orElse(false)); + assertEquals("Hello world!More text.", emitted.get(4).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Final empty + assertEquals(emitted.get(5), LlmResponse.create(resp4)); + } + } + @Test + @Tag("valid") + public void testBlankTextWithNoAccumulation() { + Part blankPart = Part.fromText(""); + Content content = Content.builder().role("model").parts(blankPart).build(); + Candidate candidate = Candidate.builder().content(content).build(); + GenerateContentResponse response = GenerateContentResponse.builder().candidates(List.of(candidate)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenReturn(Optional.of(blankPart)); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(true); + Flowable results = Gemini.processRawResponses(Flowable.just(response)); + List emitted = results.toList().blockingGet(); + assertEquals(1, emitted.size()); + String partText = emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse(""); + assertEquals("", partText); + } + } + @Test + @Tag("valid") + public void testFunctionCallResponseEmittedDirectly() { + Part functionCallPart = Part.fromFunctionCall("myFunction", ImmutableMap.of()); + Content content = Content.builder().role("model").parts(functionCallPart).build(); + Candidate candidate = Candidate.builder().content(content).build(); + GenerateContentResponse response = GenerateContentResponse.builder().candidates(List.of(candidate)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenReturn(Optional.of(functionCallPart)); + Flowable results = Gemini.processRawResponses(Flowable.just(response)); + List emitted = results.toList().blockingGet(); + assertEquals(1, emitted.size()); + assertEquals(LlmResponse.create(response), emitted.get(0)); + } + } + @Test + @Tag("boundary") + public void testTextFollowedByEmptyNoAggregationEmitted() { + Part textPart = Part.fromText("Hello"); + Content content1 = Content.builder().role("model").parts(textPart).build(); + Candidate candidate1 = Candidate.builder().content(content1).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate1)).build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().build(); + GenerateContentResponse resp3 = GenerateContentResponse.builder().build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, false, false); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2, resp3)); + List emitted = results.toList().blockingGet(); + assertEquals(3, emitted.size()); + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("Hello", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertEquals(LlmResponse.create(resp2), emitted.get(1)); + assertEquals(LlmResponse.create(resp3), emitted.get(2)); + } + } + @Test + @Tag("boundary") + public void testNoCandidatesInFinalTailNoStop() { + Part text1 = Part.fromText("tchunk"); + Content content1 = Content.builder().role("model").parts(text1).build(); + Candidate candidate1 = Candidate.builder().content(content1).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate1)).build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().build(); // No candidates + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, false); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2)); + List emitted = results.toList().blockingGet(); + assertEquals(2, emitted.size()); + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("tchunk", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertEquals(LlmResponse.create(resp2), emitted.get(1)); + } + } + @Test + @Tag("valid") + public void testStopReasonTriggersTailAggregation() { + Part thoughtPart = Part.fromText("Thought!").toBuilder().thought(true).build(); + Part textPart = Part.fromText("Text!"); + Content content1 = Content.builder().role("model").parts(thoughtPart).build(); + Content content2 = Content.builder().role("model").parts(textPart).build(); + Candidate candidate1 = Candidate.builder().content(content1).build(); + Candidate candidate2 = Candidate.builder().content(content2).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate1)).build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().candidates(List.of(candidate2)).build(); + Candidate stopCandidate = Candidate.builder() + .finishReason(FinishReason.builder().knownEnum(FinishReason.Known.STOP).build()) + .build(); + GenerateContentResponse resp3 = GenerateContentResponse.builder().candidates(List.of(stopCandidate)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, false, false); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2, resp3)); + List emitted = results.toList().blockingGet(); + // First two partials, then in tail two aggregated responses (thought+text) + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("Thought!", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertTrue(emitted.get(1).partial().orElse(false)); + assertEquals("Text!", emitted.get(1).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertEquals("Thought!", emitted.get(2).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertEquals("Text!", emitted.get(3).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + } + } + @Test + @Tag("invalid") + public void testFinishReasonNotStopNoTailAggregation() { + Part textPart = Part.fromText("txt"); + Content content = Content.builder().role("model").parts(textPart).build(); + Candidate candidate = Candidate.builder().content(content).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate)).build(); + Candidate maxTokensCandidate = Candidate.builder() + .finishReason(FinishReason.builder().knownEnum(FinishReason.Known.MAX_TOKENS).build()) + .build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().candidates(List.of(maxTokensCandidate)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, false); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2)); + List emitted = results.toList().blockingGet(); + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("txt", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertEquals(LlmResponse.create(resp2), emitted.get(1)); + // No tail aggregation + assertEquals(2, emitted.size()); + } + } + @Test + @Tag("boundary") + public void testEmptyPartsListEmitsEmpty() { + Content content = Content.builder().role("model").parts().build(); + Candidate candidate = Candidate.builder().content(content).build(); + GenerateContentResponse response = GenerateContentResponse.builder().candidates(List.of(candidate)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenReturn(Optional.empty()); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(true); + Flowable results = Gemini.processRawResponses(Flowable.just(response)); + List emitted = results.toList().blockingGet(); + assertEquals(1, emitted.size()); + assertTrue(emitted.get(0).content().isEmpty() || emitted.get(0).content().flatMap(Content::parts).isEmpty()); + } + } + @Test + @Tag("invalid") + public void testThoughtButNoTextIsIgnored() { + Part part = Part.fromText("").toBuilder().thought(true).build(); + Content content = Content.builder().role("model").parts(part).build(); + Candidate candidate = Candidate.builder().content(content).build(); + GenerateContentResponse response = GenerateContentResponse.builder().candidates(List.of(candidate)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())).thenReturn(Optional.of(part)); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())).thenReturn(true); + Flowable results = Gemini.processRawResponses(Flowable.just(response)); + List emitted = results.toList().blockingGet(); + // Only base LlmResponse created, no partials + String partText = emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse(""); + assertEquals("", partText); + } + } + @Test + @Tag("integration") + public void testInterleavedPartsProcessedCorrectly() { + Part thoughtPart = Part.fromText("t1").toBuilder().thought(true).build(); + Part textPart = Part.fromText("x2"); + Part funcPart = Part.fromFunctionCall("func", ImmutableMap.of()); + Part blankPart = Part.fromText(""); + Content content1 = Content.builder().role("model").parts(thoughtPart).build(); + Content content2 = Content.builder().role("model").parts(textPart).build(); + Content content3 = Content.builder().role("model").parts(funcPart).build(); + Content content4 = Content.builder().role("model").parts(blankPart).build(); + Candidate candidate1 = Candidate.builder().content(content1).build(); + Candidate candidate2 = Candidate.builder().content(content2).build(); + Candidate candidate3 = Candidate.builder().content(content3).build(); + Candidate candidate4 = Candidate.builder().content(content4).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate1)).build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().candidates(List.of(candidate2)).build(); + GenerateContentResponse resp3 = GenerateContentResponse.builder().candidates(List.of(candidate3)).build(); + GenerateContentResponse resp4 = GenerateContentResponse.builder().candidates(List.of(candidate4)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, false, false, true); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2, resp3, resp4)); + List emitted = results.toList().blockingGet(); + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("t1", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertTrue(emitted.get(1).partial().orElse(false)); + assertEquals("x2", emitted.get(1).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Function call should be emitted directly + assertEquals(LlmResponse.create(resp3), emitted.get(2)); + // Aggregated "thought" and "text" after blank + assertEquals("t1", emitted.get(3).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertEquals("x2", emitted.get(4).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + // Final blank LlmResponse + assertEquals(LlmResponse.create(resp4), emitted.get(5)); + } + } + @Test + @Tag("boundary") + public void testLargeAggregationHandledProperly() { + List responses = new ArrayList<>(); + StringBuilder expectedFullText = new StringBuilder(); + for (int i = 0; i < 105; i++) { + String text = "Chunk" + i; + expectedFullText.append(text); + Part p = Part.fromText(text); + Content content = Content.builder().role("model").parts(p).build(); + Candidate c = Candidate.builder().content(content).build(); + responses.add(GenerateContentResponse.builder().candidates(List.of(c)).build()); + } + Candidate stopCandidate = Candidate.builder() + .finishReason(FinishReason.builder().knownEnum(FinishReason.Known.STOP).build()) + .build(); + GenerateContentResponse stopResp = GenerateContentResponse.builder().candidates(List.of(stopCandidate)).build(); + responses.add(stopResp); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + for (int i = 0; i < 105; i++) { + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())).thenReturn(false); + } + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())).thenReturn(false); + Flowable results = Gemini.processRawResponses(Flowable.fromIterable(responses)); + List emitted = results.toList().blockingGet(); + // Partial responses first, then one final aggregated text + assertEquals(106, emitted.size()); + for (int i = 0; i < 105; i++) { + assertTrue(emitted.get(i).partial().orElse(false)); + String partText = emitted.get(i).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse(""); + assertEquals("Chunk"+i, partText); + } + // Last: aggregated full text + String aggText = emitted.get(105).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse(""); + assertEquals(expectedFullText.toString(), aggText); + } + } + @Test + @Tag("invalid") + public void testStopWithNoContentEmitsNothing() { + Candidate stopCandidate = Candidate.builder() + .finishReason(FinishReason.builder().knownEnum(FinishReason.Known.STOP).build()) + .build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().candidates(List.of(stopCandidate)).build(); + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())).thenReturn(Optional.empty()); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())).thenReturn(true, true); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2)); + List emitted = results.toList().blockingGet(); + // Only two empty responses, no aggregated + assertEquals(2, emitted.size()); + assertTrue(emitted.get(0).content().isEmpty() || emitted.get(0).content().flatMap(Content::parts).isEmpty()); + assertTrue(emitted.get(1).content().isEmpty() || emitted.get(1).content().flatMap(Content::parts).isEmpty()); + } + } + @Test + @Tag("valid") + public void testAccumulationClearedAfterEmit() { + Part text1 = Part.fromText("first"); + Content content1 = Content.builder().role("model").parts(text1).build(); + Candidate candidate1 = Candidate.builder().content(content1).build(); + GenerateContentResponse resp1 = GenerateContentResponse.builder().candidates(List.of(candidate1)).build(); + GenerateContentResponse resp2 = GenerateContentResponse.builder().build(); // blank, triggers aggregation + Part text2 = Part.fromText("second"); + Content content2 = Content.builder().role("model").parts(text2).build(); + Candidate candidate2 = Candidate.builder().content(content2).build(); + GenerateContentResponse resp3 = GenerateContentResponse.builder().candidates(List.of(candidate2)).build(); + GenerateContentResponse resp4 = GenerateContentResponse.builder().build(); // blank again, trigger aggregation + try (MockedStatic utilMock = Mockito.mockStatic(GeminiUtil.class)) { + utilMock.when(() -> GeminiUtil.getPart0FromLlmResponse(Mockito.any())) + .thenAnswer(inv -> { + LlmResponse r = (LlmResponse) inv.getArgument(0); + return r.content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()); + }); + utilMock.when(() -> GeminiUtil.shouldEmitAccumulatedText(Mockito.any())) + .thenReturn(false, true, false, true); + Flowable results = Gemini.processRawResponses(Flowable.just(resp1, resp2, resp3, resp4)); + List emitted = results.toList().blockingGet(); + // 1: partial "first", 2: aggregated "first", 3: partial "second", 4: aggregated "second", 5: blank, 6: blank + assertTrue(emitted.get(0).partial().orElse(false)); + assertEquals("first", emitted.get(0).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertFalse(emitted.get(1).partial().orElse(false)); + assertEquals("first", emitted.get(1).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertTrue(emitted.get(2).partial().orElse(false)); + assertEquals("second", emitted.get(2).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertFalse(emitted.get(3).partial().orElse(false)); + assertEquals("second", emitted.get(3).content().flatMap(c -> c.parts()).flatMap(parts -> parts.stream().findFirst()).flatMap(Part::text).orElse("")); + assertEquals(LlmResponse.create(resp4), emitted.get(4)); + } + } +} \ No newline at end of file