chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,896 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Tests for Progressive SSE Streaming Stage 1 implementation."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.agents.run_config import RunConfig
|
||||
from google.adk.agents.run_config import StreamingMode
|
||||
from google.adk.models.base_llm import BaseLlm
|
||||
from google.adk.models.llm_request import LlmRequest
|
||||
from google.adk.models.llm_response import LlmResponse
|
||||
from google.adk.runners import InMemoryRunner
|
||||
from google.adk.utils.streaming_utils import StreamingResponseAggregator
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
def get_weather(location: str) -> dict[str, Any]:
|
||||
"""Mock weather function for testing.
|
||||
|
||||
Args:
|
||||
location: The location to get the weather for.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the weather information.
|
||||
"""
|
||||
return {
|
||||
"temperature": 22,
|
||||
"condition": "sunny",
|
||||
"location": location,
|
||||
}
|
||||
|
||||
|
||||
class StreamingMockModel(BaseLlm):
|
||||
"""A mock model that properly streams multiple chunks in a single call."""
|
||||
|
||||
model: str = "streaming-mock"
|
||||
stream_chunks: list[LlmResponse] = []
|
||||
call_count: int = 0
|
||||
|
||||
@classmethod
|
||||
def supported_models(cls) -> list[str]:
|
||||
return ["streaming-mock"]
|
||||
|
||||
async def generate_content_async(
|
||||
self, llm_request: LlmRequest, stream: bool = False
|
||||
) -> AsyncGenerator[LlmResponse, None]:
|
||||
"""Yield all chunks in a single streaming call."""
|
||||
self.call_count += 1
|
||||
|
||||
# Only stream on the first call
|
||||
if self.call_count > 1:
|
||||
# On subsequent calls, return a simple final response
|
||||
yield LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[types.Part.from_text(text="Task completed.")],
|
||||
),
|
||||
partial=False,
|
||||
)
|
||||
return
|
||||
|
||||
aggregator = StreamingResponseAggregator()
|
||||
|
||||
# Process each chunk through the aggregator
|
||||
for chunk in self.stream_chunks:
|
||||
# Convert LlmResponse to types.GenerateContentResponse
|
||||
# Since we don't have the full response object, we'll simulate it
|
||||
async for processed_chunk in aggregator.process_response(
|
||||
self._llm_response_to_generate_content_response(chunk)
|
||||
):
|
||||
yield processed_chunk
|
||||
|
||||
# Call close() to get the final aggregated response
|
||||
if final_response := aggregator.close():
|
||||
yield final_response
|
||||
|
||||
def _llm_response_to_generate_content_response(
|
||||
self, llm_response: LlmResponse
|
||||
) -> types.GenerateContentResponse:
|
||||
"""Convert LlmResponse to GenerateContentResponse for aggregator."""
|
||||
# Create a minimal GenerateContentResponse that the aggregator can process
|
||||
candidates = []
|
||||
if llm_response.content:
|
||||
candidates.append(
|
||||
types.Candidate(
|
||||
content=llm_response.content,
|
||||
finish_reason=llm_response.finish_reason,
|
||||
finish_message=llm_response.error_message,
|
||||
)
|
||||
)
|
||||
|
||||
return types.GenerateContentResponse(
|
||||
candidates=candidates,
|
||||
usage_metadata=llm_response.usage_metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_progressive_sse_streaming_function_calls():
|
||||
"""Test that function calls are buffered and executed in parallel."""
|
||||
|
||||
# Setup: Create mock responses simulating streaming chunks
|
||||
response1 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part.from_text(text="Checking weather...")]
|
||||
),
|
||||
)
|
||||
|
||||
response2 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name="get_weather", args={"location": "Tokyo"}
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
response3 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name="get_weather", args={"location": "New York"}
|
||||
)
|
||||
],
|
||||
),
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
)
|
||||
|
||||
# Create a streaming mock that yields all chunks in one call
|
||||
mock_model = StreamingMockModel(
|
||||
stream_chunks=[response1, response2, response3]
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="weather_agent",
|
||||
model=mock_model,
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
||||
|
||||
# Use the real InMemoryRunner to get access to run_config parameter
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
# Create session manually
|
||||
session = runner.session_service.create_session_sync(
|
||||
app_name=runner.app_name, user_id="test_user"
|
||||
)
|
||||
|
||||
events = []
|
||||
for event in runner.run(
|
||||
user_id="test_user",
|
||||
session_id=session.id,
|
||||
new_message=types.Content(
|
||||
role="user",
|
||||
parts=[types.Part.from_text(text="What is the weather?")],
|
||||
),
|
||||
run_config=run_config,
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
# Verify event structure (Stage 1 expectations)
|
||||
# Expected events:
|
||||
# 0-2: Partial events (text + 2 FCs) - not executed
|
||||
# 3: Final aggregated model event (text + 2 FCs) - partial=False
|
||||
# 4: Aggregated function response (both get_weather results executed in
|
||||
# parallel)
|
||||
# 5: Final model response after FCs
|
||||
assert len(events) == 6
|
||||
|
||||
assert events[0].partial
|
||||
assert events[0].content.parts[0].text == "Checking weather..."
|
||||
|
||||
assert events[1].partial
|
||||
assert events[1].content.parts[0].function_call.name == "get_weather"
|
||||
assert events[1].content.parts[0].function_call.args["location"] == "Tokyo"
|
||||
|
||||
assert events[2].partial
|
||||
assert events[2].content.parts[0].function_call.name == "get_weather"
|
||||
assert events[2].content.parts[0].function_call.args["location"] == "New York"
|
||||
|
||||
assert not events[3].partial
|
||||
assert events[3].content.parts[0].text == "Checking weather..."
|
||||
assert events[3].content.parts[1].function_call.name == "get_weather"
|
||||
assert events[3].content.parts[1].function_call.args["location"] == "Tokyo"
|
||||
assert events[3].content.parts[2].function_call.name == "get_weather"
|
||||
assert events[3].content.parts[2].function_call.args["location"] == "New York"
|
||||
|
||||
assert not events[4].partial
|
||||
assert events[4].content.parts[0].function_response.name == "get_weather"
|
||||
assert (
|
||||
events[4].content.parts[0].function_response.response["location"]
|
||||
== "Tokyo"
|
||||
)
|
||||
assert events[4].content.parts[1].function_response.name == "get_weather"
|
||||
assert (
|
||||
events[4].content.parts[1].function_response.response["location"]
|
||||
== "New York"
|
||||
)
|
||||
|
||||
assert not events[5].partial
|
||||
assert events[5].content.parts[0].text == "Task completed."
|
||||
|
||||
|
||||
def test_progressive_sse_preserves_part_ordering():
|
||||
"""Test that part ordering is preserved, especially for thought parts.
|
||||
|
||||
This test verifies that when the model outputs:
|
||||
- chunk1(thought1_1)
|
||||
- chunk2(thought1_2)
|
||||
- chunk3(text1_1)
|
||||
- chunk4(text1_2)
|
||||
- chunk5(FC1)
|
||||
- chunk6(thought2_1)
|
||||
- chunk7(thought2_2)
|
||||
- chunk8(FC2)
|
||||
|
||||
The final aggregated output should be:
|
||||
- Part(thought1) # thought1_1 + thought1_2 merged
|
||||
- Part(text1) # text1_1 + text1_2 merged
|
||||
- Part(FC1)
|
||||
- Part(thought2) # thought2_1 + thought2_2 merged
|
||||
- Part(FC2)
|
||||
"""
|
||||
|
||||
# Create streaming chunks that test the ordering requirement
|
||||
chunk1 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[types.Part(text="Initial thought part 1. ", thought=True)],
|
||||
)
|
||||
)
|
||||
|
||||
chunk2 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[types.Part(text="Initial thought part 2.", thought=True)],
|
||||
)
|
||||
)
|
||||
|
||||
chunk3 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[types.Part.from_text(text="Let me check Tokyo. ")],
|
||||
)
|
||||
)
|
||||
|
||||
chunk4 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part.from_text(text="And New York too.")]
|
||||
)
|
||||
)
|
||||
|
||||
chunk5 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name="get_weather", args={"location": "Tokyo"}
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
chunk6 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part(
|
||||
text="Now processing second thought part 1. ", thought=True
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
chunk7 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[types.Part(text="Second thought part 2.", thought=True)],
|
||||
)
|
||||
)
|
||||
|
||||
chunk8 = LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name="get_weather", args={"location": "New York"}
|
||||
)
|
||||
],
|
||||
),
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
)
|
||||
|
||||
mock_model = StreamingMockModel(
|
||||
stream_chunks=[
|
||||
chunk1,
|
||||
chunk2,
|
||||
chunk3,
|
||||
chunk4,
|
||||
chunk5,
|
||||
chunk6,
|
||||
chunk7,
|
||||
chunk8,
|
||||
]
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="ordering_test_agent",
|
||||
model=mock_model,
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
||||
|
||||
# Use the real InMemoryRunner to get access to run_config parameter
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
# Create session manually
|
||||
session = runner.session_service.create_session_sync(
|
||||
app_name=runner.app_name, user_id="test_user"
|
||||
)
|
||||
|
||||
events = []
|
||||
for event in runner.run(
|
||||
user_id="test_user",
|
||||
session_id=session.id,
|
||||
new_message=types.Content(
|
||||
role="user",
|
||||
parts=[types.Part.from_text(text="What is the weather?")],
|
||||
),
|
||||
run_config=run_config,
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
# Find the final aggregated model event (partial=False, from model)
|
||||
aggregated_event = None
|
||||
for event in events:
|
||||
if (
|
||||
not event.partial
|
||||
and event.author == "ordering_test_agent"
|
||||
and event.content
|
||||
and len(event.content.parts) > 2
|
||||
):
|
||||
aggregated_event = event
|
||||
break
|
||||
|
||||
assert aggregated_event is not None, "Should find an aggregated model event"
|
||||
|
||||
# Verify the part ordering
|
||||
parts = aggregated_event.content.parts
|
||||
assert len(parts) == 5, f"Expected 5 parts, got {len(parts)}"
|
||||
|
||||
# Part 0: First thought (merged from chunk1 + chunk2)
|
||||
assert parts[0].thought
|
||||
assert parts[0].text == "Initial thought part 1. Initial thought part 2."
|
||||
|
||||
# Part 1: Regular text (merged from chunk3 + chunk4)
|
||||
assert not parts[1].thought
|
||||
assert parts[1].text == "Let me check Tokyo. And New York too."
|
||||
|
||||
# Part 2: First function call (from chunk5)
|
||||
assert parts[2].function_call.name == "get_weather"
|
||||
assert parts[2].function_call.args["location"] == "Tokyo"
|
||||
|
||||
# Part 3: Second thought (merged from chunk6 + chunk7)
|
||||
assert parts[3].thought
|
||||
assert (
|
||||
parts[3].text
|
||||
== "Now processing second thought part 1. Second thought part 2."
|
||||
)
|
||||
|
||||
# Part 4: Second function call (from chunk8)
|
||||
assert parts[4].function_call.name == "get_weather"
|
||||
assert parts[4].function_call.args["location"] == "New York"
|
||||
|
||||
|
||||
def test_progressive_sse_streaming_function_call_arguments():
|
||||
"""Test streaming function call arguments feature.
|
||||
|
||||
This test simulates the streamFunctionCallArguments feature where a function
|
||||
call's arguments are streamed incrementally across multiple chunks:
|
||||
|
||||
Chunk 1: FC name + partial location argument ("New ")
|
||||
Chunk 2: Continue location argument ("York") -> concatenated to "New York"
|
||||
Chunk 3: Add unit argument ("celsius"), willContinue=False -> FC complete
|
||||
|
||||
Expected result: FunctionCall(name="get_weather",
|
||||
args={"location": "New York", "unit":
|
||||
"celsius"},
|
||||
id="fc_001")
|
||||
"""
|
||||
|
||||
aggregator = StreamingResponseAggregator()
|
||||
|
||||
# Chunk 1: FC name + partial location argument
|
||||
chunk1_fc = types.FunctionCall(
|
||||
name="get_weather",
|
||||
id="fc_001",
|
||||
partial_args=[
|
||||
types.PartialArg(json_path="$.location", string_value="New ")
|
||||
],
|
||||
will_continue=True,
|
||||
)
|
||||
chunk1 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk1_fc)]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Chunk 2: Continue streaming location argument
|
||||
chunk2_fc = types.FunctionCall(
|
||||
partial_args=[
|
||||
types.PartialArg(json_path="$.location", string_value="York")
|
||||
],
|
||||
will_continue=True,
|
||||
)
|
||||
chunk2 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk2_fc)]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Chunk 3: Add unit argument, FC complete
|
||||
chunk3_fc = types.FunctionCall(
|
||||
partial_args=[
|
||||
types.PartialArg(json_path="$.unit", string_value="celsius")
|
||||
],
|
||||
will_continue=False, # FC complete
|
||||
)
|
||||
chunk3 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk3_fc)]
|
||||
),
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Process all chunks through aggregator
|
||||
processed_chunks = []
|
||||
for chunk in [chunk1, chunk2, chunk3]:
|
||||
|
||||
async def process():
|
||||
results = []
|
||||
async for response in aggregator.process_response(chunk):
|
||||
results.append(response)
|
||||
return results
|
||||
|
||||
import asyncio
|
||||
|
||||
chunk_results = asyncio.run(process())
|
||||
processed_chunks.extend(chunk_results)
|
||||
|
||||
# Get final aggregated response
|
||||
final_response = aggregator.close()
|
||||
|
||||
# Verify final aggregated response has complete FC
|
||||
assert final_response is not None
|
||||
assert len(final_response.content.parts) == 1
|
||||
|
||||
fc_part = final_response.content.parts[0]
|
||||
assert fc_part.function_call is not None
|
||||
assert fc_part.function_call.name == "get_weather"
|
||||
assert fc_part.function_call.id == "fc_001"
|
||||
|
||||
# Verify arguments were correctly assembled from streaming chunks
|
||||
args = fc_part.function_call.args
|
||||
assert args["location"] == "New York" # "New " + "York" concatenated
|
||||
assert args["unit"] == "celsius"
|
||||
|
||||
|
||||
def test_progressive_sse_preserves_thought_signature():
|
||||
"""Test that thought_signature is preserved when streaming FC arguments.
|
||||
|
||||
This test verifies that when a streaming function call has a thought_signature
|
||||
in the Part, it is correctly preserved in the final aggregated FunctionCall.
|
||||
"""
|
||||
|
||||
aggregator = StreamingResponseAggregator()
|
||||
|
||||
# Create a thought signature (simulating what Gemini returns)
|
||||
# thought_signature is bytes (base64 encoded)
|
||||
test_thought_signature = b"test_signature_abc123"
|
||||
|
||||
# Chunk with streaming FC args and thought_signature
|
||||
chunk_fc = types.FunctionCall(
|
||||
name="add_5_numbers",
|
||||
id="fc_003",
|
||||
partial_args=[
|
||||
types.PartialArg(json_path="$.num1", number_value=10),
|
||||
types.PartialArg(json_path="$.num2", number_value=20),
|
||||
],
|
||||
will_continue=False,
|
||||
)
|
||||
|
||||
# Create Part with both function_call AND thought_signature
|
||||
chunk_part = types.Part(
|
||||
function_call=chunk_fc, thought_signature=test_thought_signature
|
||||
)
|
||||
|
||||
chunk = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(role="model", parts=[chunk_part]),
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Process chunk through aggregator
|
||||
async def process():
|
||||
results = []
|
||||
async for response in aggregator.process_response(chunk):
|
||||
results.append(response)
|
||||
return results
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(process())
|
||||
|
||||
# Get final aggregated response
|
||||
final_response = aggregator.close()
|
||||
|
||||
# Verify thought_signature was preserved in the Part
|
||||
assert final_response is not None
|
||||
assert len(final_response.content.parts) == 1
|
||||
|
||||
fc_part = final_response.content.parts[0]
|
||||
assert fc_part.function_call is not None
|
||||
assert fc_part.function_call.name == "add_5_numbers"
|
||||
|
||||
assert fc_part.thought_signature == test_thought_signature
|
||||
|
||||
|
||||
def test_progressive_sse_handles_empty_function_call():
|
||||
"""Test that empty function calls are skipped.
|
||||
|
||||
When using streamFunctionCallArguments, Gemini may send an empty
|
||||
functionCall: {} as the final chunk to signal streaming completion.
|
||||
This test verifies that such empty function calls are properly skipped
|
||||
and don't cause errors.
|
||||
"""
|
||||
|
||||
aggregator = StreamingResponseAggregator()
|
||||
|
||||
# Chunk 1: Streaming FC with partial args
|
||||
chunk1_fc = types.FunctionCall(
|
||||
name="concat_number_and_string",
|
||||
id="fc_001",
|
||||
partial_args=[
|
||||
types.PartialArg(json_path="$.num", number_value=100),
|
||||
types.PartialArg(json_path="$.s", string_value="ADK"),
|
||||
],
|
||||
will_continue=False,
|
||||
)
|
||||
chunk1 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk1_fc)]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Chunk 2: Empty function call (streaming end marker)
|
||||
chunk2_fc = types.FunctionCall() # Empty function call
|
||||
chunk2 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk2_fc)]
|
||||
),
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Process all chunks through aggregator
|
||||
async def process():
|
||||
results = []
|
||||
for chunk in [chunk1, chunk2]:
|
||||
async for response in aggregator.process_response(chunk):
|
||||
results.append(response)
|
||||
return results
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(process())
|
||||
|
||||
# Get final aggregated response
|
||||
final_response = aggregator.close()
|
||||
|
||||
# Verify final response only has the real FC, not the empty one
|
||||
assert final_response is not None
|
||||
assert len(final_response.content.parts) == 1
|
||||
|
||||
fc_part = final_response.content.parts[0]
|
||||
assert fc_part.function_call is not None
|
||||
assert fc_part.function_call.name == "concat_number_and_string"
|
||||
assert fc_part.function_call.id == "fc_001"
|
||||
|
||||
# Verify arguments
|
||||
args = fc_part.function_call.args
|
||||
assert args["num"] == 100
|
||||
assert args["s"] == "ADK"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_chunk_partial_args",
|
||||
[
|
||||
pytest.param(None, id="partial_args_none"),
|
||||
pytest.param([], id="partial_args_empty_list"),
|
||||
],
|
||||
)
|
||||
def test_streaming_fc_chunk_with_will_continue_but_no_partial_args(
|
||||
first_chunk_partial_args,
|
||||
):
|
||||
"""Test streaming function call with will_continue=True but no partial_args."""
|
||||
|
||||
aggregator = StreamingResponseAggregator()
|
||||
|
||||
# Chunk 1: FC name + will_continue=True, but NO partial_args (or empty list)
|
||||
# This is the first chunk that Gemini 3 sends for streaming FC
|
||||
chunk1_fc = types.FunctionCall(
|
||||
name="my_tool",
|
||||
id="fc_gemini3",
|
||||
will_continue=True,
|
||||
partial_args=first_chunk_partial_args,
|
||||
)
|
||||
chunk1_part = types.Part(
|
||||
function_call=chunk1_fc,
|
||||
thought_signature=b"test_sig_123",
|
||||
)
|
||||
chunk1 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(role="model", parts=[chunk1_part])
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Chunk 2: Middle chunk with partial_args, name is None
|
||||
chunk2_fc = types.FunctionCall(
|
||||
partial_args=[
|
||||
types.PartialArg(json_path="$.document", string_value="Once upon ")
|
||||
],
|
||||
will_continue=True,
|
||||
)
|
||||
chunk2 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk2_fc)]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Chunk 3: Another middle chunk continuing the string argument
|
||||
chunk3_fc = types.FunctionCall(
|
||||
partial_args=[
|
||||
types.PartialArg(json_path="$.document", string_value="a time...")
|
||||
],
|
||||
will_continue=True,
|
||||
)
|
||||
chunk3 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk3_fc)]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Chunk 4: Final chunk - no name, no partial_args, will_continue=False
|
||||
# This signals the end of the streaming function call
|
||||
chunk4_fc = types.FunctionCall(
|
||||
will_continue=False,
|
||||
)
|
||||
chunk4 = types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
role="model", parts=[types.Part(function_call=chunk4_fc)]
|
||||
),
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Process all chunks through aggregator
|
||||
async def process():
|
||||
results = []
|
||||
for chunk in [chunk1, chunk2, chunk3, chunk4]:
|
||||
async for response in aggregator.process_response(chunk):
|
||||
results.append(response)
|
||||
return results
|
||||
|
||||
processed_chunks = asyncio.run(process())
|
||||
|
||||
# All intermediate chunks should be marked as partial
|
||||
assert all(chunk.partial for chunk in processed_chunks)
|
||||
|
||||
# Get final aggregated response
|
||||
final_response = aggregator.close()
|
||||
|
||||
# Verify final aggregated response has the complete FC with accumulated args
|
||||
assert final_response is not None
|
||||
assert len(final_response.content.parts) == 1
|
||||
|
||||
fc_part = final_response.content.parts[0]
|
||||
assert fc_part.function_call is not None
|
||||
assert fc_part.function_call.name == "my_tool"
|
||||
assert fc_part.function_call.id == "fc_gemini3"
|
||||
|
||||
# Verify the document argument was correctly accumulated
|
||||
args = fc_part.function_call.args
|
||||
assert "document" in args
|
||||
assert (
|
||||
args["document"] == "Once upon a time..."
|
||||
) # Concatenated from chunks 2 + 3
|
||||
|
||||
# Verify thought_signature was preserved from the first chunk
|
||||
assert fc_part.thought_signature == b"test_sig_123"
|
||||
|
||||
|
||||
class PartialFunctionCallMockModel(BaseLlm):
|
||||
"""A mock model that yields partial function call events followed by final."""
|
||||
|
||||
model: str = "partial-fc-mock"
|
||||
tool_call_count: int = 0
|
||||
|
||||
@classmethod
|
||||
def supported_models(cls) -> list[str]:
|
||||
return ["partial-fc-mock"]
|
||||
|
||||
async def generate_content_async(
|
||||
self, llm_request: LlmRequest, stream: bool = False
|
||||
) -> AsyncGenerator[LlmResponse, None]:
|
||||
"""Yield partial FC events then final, simulating streaming behavior."""
|
||||
|
||||
# Check if this is a follow-up call (after function response)
|
||||
has_function_response = False
|
||||
for content in llm_request.contents:
|
||||
for part in content.parts or []:
|
||||
if part.function_response:
|
||||
has_function_response = True
|
||||
break
|
||||
|
||||
if has_function_response:
|
||||
# Final response after function execution
|
||||
yield LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[types.Part.from_text(text="Function executed once.")],
|
||||
),
|
||||
partial=False,
|
||||
)
|
||||
return
|
||||
|
||||
# First call: yield partial FC events then final
|
||||
# Partial event 1
|
||||
yield LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name="track_execution", args={"call_id": "partial_1"}
|
||||
)
|
||||
],
|
||||
),
|
||||
partial=True,
|
||||
)
|
||||
|
||||
# Partial event 2
|
||||
yield LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name="track_execution", args={"call_id": "partial_2"}
|
||||
)
|
||||
],
|
||||
),
|
||||
partial=True,
|
||||
)
|
||||
|
||||
# Final aggregated event (only this should trigger execution)
|
||||
yield LlmResponse(
|
||||
content=types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name="track_execution", args={"call_id": "final"}
|
||||
)
|
||||
],
|
||||
),
|
||||
partial=False,
|
||||
finish_reason=types.FinishReason.STOP,
|
||||
)
|
||||
|
||||
|
||||
def test_partial_function_calls_not_executed_in_none_streaming_mode():
|
||||
"""Test that partial function call events are skipped regardless of mode."""
|
||||
execution_log = []
|
||||
|
||||
def track_execution(call_id: str) -> str:
|
||||
"""A tool that logs each execution to verify call count."""
|
||||
execution_log.append(call_id)
|
||||
return f"Executed: {call_id}"
|
||||
|
||||
mock_model = PartialFunctionCallMockModel()
|
||||
|
||||
agent = Agent(
|
||||
name="partial_fc_test_agent",
|
||||
model=mock_model,
|
||||
tools=[track_execution],
|
||||
)
|
||||
|
||||
# Use StreamingMode.NONE to verify partial FCs are still skipped
|
||||
run_config = RunConfig(streaming_mode=StreamingMode.NONE)
|
||||
|
||||
runner = InMemoryRunner(agent=agent)
|
||||
|
||||
session = runner.session_service.create_session_sync(
|
||||
app_name=runner.app_name, user_id="test_user"
|
||||
)
|
||||
|
||||
events = []
|
||||
for event in runner.run(
|
||||
user_id="test_user",
|
||||
session_id=session.id,
|
||||
new_message=types.Content(
|
||||
role="user",
|
||||
parts=[types.Part.from_text(text="Test partial FC handling")],
|
||||
),
|
||||
run_config=run_config,
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
# Verify the tool was only executed once (from the final event)
|
||||
assert (
|
||||
len(execution_log) == 1
|
||||
), f"Expected 1 execution, got {len(execution_log)}: {execution_log}"
|
||||
assert (
|
||||
execution_log[0] == "final"
|
||||
), f"Expected 'final' execution, got: {execution_log[0]}"
|
||||
|
||||
# Verify partial events were yielded but not executed
|
||||
partial_events = [e for e in events if e.partial]
|
||||
assert (
|
||||
len(partial_events) == 2
|
||||
), f"Expected 2 partial events, got {len(partial_events)}"
|
||||
|
||||
# Verify there's a function response event (from the final FC execution)
|
||||
function_response_events = [
|
||||
e
|
||||
for e in events
|
||||
if e.content
|
||||
and e.content.parts
|
||||
and any(p.function_response for p in e.content.parts)
|
||||
]
|
||||
assert (
|
||||
len(function_response_events) == 1
|
||||
), f"Expected 1 function response event, got {len(function_response_events)}"
|
||||
Reference in New Issue
Block a user