Files
wehub-resource-sync ec2b666284
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
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

897 lines
26 KiB
Python

# 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)}"