Files
google--adk-python/tests/unittests/streaming/test_streaming.py
T
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

1389 lines
45 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.
import asyncio
from contextlib import aclosing
from typing import Any
from typing import AsyncGenerator
from typing import Awaitable
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.agents.llm_agent import Agent
from google.adk.models.llm_response import LlmResponse
from google.genai import types
import pytest
from .. import testing_utils
async def _wait_for_queue_empty(queue: LiveRequestQueue):
"""Wait until the queue is empty and the background consumer has finished."""
while not queue._queue.empty():
await asyncio.sleep(0)
# Give opportunity for _send_to_model to finish processing (e.g. append_event)
for _ in range(10):
await asyncio.sleep(0)
class StreamingTestRunner(testing_utils.InMemoryRunner):
"""A robust runner for streaming tests that avoids resource leaks."""
def __init__(self, *args, max_responses=3, **kwargs):
super().__init__(*args, **kwargs)
self.max_responses = max_responses
def _run_with_loop(self, coro):
try:
old_loop = asyncio.get_event_loop()
except RuntimeError:
old_loop = None
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(coro)
except (asyncio.TimeoutError, asyncio.CancelledError):
pass
finally:
# Cancel all pending tasks to prevent leaks and warnings
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
try:
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
except Exception: # pylint: disable=broad-except
pass
loop.close()
asyncio.set_event_loop(old_loop)
def run_live(
self,
live_request_queue: LiveRequestQueue,
run_config: testing_utils.RunConfig = None,
) -> list[testing_utils.Event]:
collected_responses = []
async def consume_responses(session: testing_utils.Session):
run_res = self.runner.run_live(
session=session,
live_request_queue=live_request_queue,
run_config=run_config or testing_utils.RunConfig(),
)
async with aclosing(run_res) as agen:
async for response in agen:
collected_responses.append(response)
if len(collected_responses) >= self.max_responses:
await _wait_for_queue_empty(live_request_queue)
return
self._run_with_loop(
asyncio.wait_for(consume_responses(self.session), timeout=5.0)
)
return collected_responses
def run_live_and_get_session(
self,
live_request_queue: LiveRequestQueue,
run_config: testing_utils.RunConfig = None,
) -> tuple[list[testing_utils.Event], testing_utils.Session]:
events = self.run_live(live_request_queue, run_config)
return events, self.session
def test_streaming():
response1 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1])
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[],
)
runner = testing_utils.InMemoryRunner(
root_agent=root_agent, response_modalities=["AUDIO"]
)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"\x00\xFF", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert (
len(res_events) > 0
), "Expected at least one response, but got an empty list."
def test_live_streaming_function_call_single():
"""Test live streaming with a single function call response."""
# Create a function call response
function_call = types.Part.from_function_call(
name="get_weather", args={"location": "San Francisco", "unit": "celsius"}
)
# Create LLM responses: function call followed by turn completion
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
response2 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2])
# Mock function that would be called
def get_weather(location: str, unit: str = "celsius") -> dict:
return {
"temperature": 22,
"condition": "sunny",
"location": location,
"unit": unit,
}
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[get_weather],
)
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(
data=b"What is the weather in San Francisco?", mime_type="audio/pcm"
)
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check that we got a function call event
function_call_found = False
function_response_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call and part.function_call.name == "get_weather":
function_call_found = True
assert part.function_call.args["location"] == "San Francisco"
assert part.function_call.args["unit"] == "celsius"
elif (
part.function_response
and part.function_response.name == "get_weather"
):
function_response_found = True
assert part.function_response.response["temperature"] == 22
assert part.function_response.response["condition"] == "sunny"
assert function_call_found, "Expected a function call event."
# Note: In live streaming, function responses might be handled differently,
# so we check for the function call which is the primary indicator of function calling working
def test_live_streaming_function_call_multiple():
"""Test live streaming with multiple function calls in sequence."""
# Create multiple function call responses
function_call1 = types.Part.from_function_call(
name="get_weather", args={"location": "San Francisco"}
)
function_call2 = types.Part.from_function_call(
name="get_time", args={"timezone": "PST"}
)
# Create LLM responses: two function calls followed by turn completion
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call1]),
turn_complete=False,
)
response2 = LlmResponse(
content=types.Content(role="model", parts=[function_call2]),
turn_complete=False,
)
response3 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2, response3])
# Mock functions
def get_weather(location: str) -> dict:
return {"temperature": 22, "condition": "sunny", "location": location}
def get_time(timezone: str) -> dict:
return {"time": "14:30", "timezone": timezone}
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[get_weather, get_time],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(
data=b"What is the weather and current time?", mime_type="audio/pcm"
)
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check function calls
weather_call_found = False
time_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call:
if part.function_call.name == "get_weather":
weather_call_found = True
assert part.function_call.args["location"] == "San Francisco"
elif part.function_call.name == "get_time":
time_call_found = True
assert part.function_call.args["timezone"] == "PST"
# In live streaming, we primarily check that function calls are generated correctly
assert (
weather_call_found or time_call_found
), "Expected at least one function call."
def test_live_streaming_function_call_parallel():
"""Test live streaming with parallel function calls."""
# Create parallel function calls in the same response
function_call1 = types.Part.from_function_call(
name="get_weather", args={"location": "San Francisco"}
)
function_call2 = types.Part.from_function_call(
name="get_weather", args={"location": "New York"}
)
# Create LLM response with parallel function calls
response1 = LlmResponse(
content=types.Content(
role="model", parts=[function_call1, function_call2]
),
turn_complete=False,
)
response2 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2])
# Mock function
def get_weather(location: str) -> dict:
temperatures = {"San Francisco": 22, "New York": 15}
return {"temperature": temperatures.get(location, 20), "location": location}
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[get_weather],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(
data=b"Compare weather in SF and NYC", mime_type="audio/pcm"
)
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check parallel function calls
sf_call_found = False
nyc_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call and part.function_call.name == "get_weather":
location = part.function_call.args["location"]
if location == "San Francisco":
sf_call_found = True
elif location == "New York":
nyc_call_found = True
assert (
sf_call_found and nyc_call_found
), "Expected both location function calls."
def test_live_streaming_function_call_with_error():
"""Test live streaming with function call that returns an error."""
# Create a function call response
function_call = types.Part.from_function_call(
name="get_weather", args={"location": "Invalid Location"}
)
# Create LLM responses
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
response2 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2])
# Mock function that returns an error for invalid locations
def get_weather(location: str) -> dict:
if location == "Invalid Location":
return {"error": "Location not found"}
return {"temperature": 22, "condition": "sunny", "location": location}
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[get_weather],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(
data=b"What is weather in Invalid Location?", mime_type="audio/pcm"
)
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check that we got the function call (error handling happens at execution time)
function_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call and part.function_call.name == "get_weather":
function_call_found = True
assert part.function_call.args["location"] == "Invalid Location"
assert function_call_found, "Expected function call event with error case."
def test_live_streaming_function_call_sync_tool():
"""Test live streaming with synchronous function call."""
# Create a function call response
function_call = types.Part.from_function_call(
name="calculate", args={"x": 5, "y": 3}
)
# Create LLM responses
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
response2 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2])
# Mock sync function
def calculate(x: int, y: int) -> dict:
return {"result": x + y, "operation": "addition"}
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[calculate],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"Calculate 5 plus 3", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check function call
function_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call and part.function_call.name == "calculate":
function_call_found = True
assert part.function_call.args["x"] == 5
assert part.function_call.args["y"] == 3
assert function_call_found, "Expected calculate function call event."
def test_live_streaming_simple_streaming_tool():
"""Test live streaming with a simple streaming tool (non-video)."""
# Create a function call response for the streaming tool
function_call = types.Part.from_function_call(
name="monitor_stock_price", args={"stock_symbol": "AAPL"}
)
# Create LLM responses
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
response2 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2])
# Mock simple streaming tool (without return type annotation to avoid parsing issues)
async def monitor_stock_price(stock_symbol: str):
"""Mock streaming tool that monitors stock prices."""
# Simulate some streaming updates
yield f"Stock {stock_symbol} price: $150"
await asyncio.sleep(0.1)
yield f"Stock {stock_symbol} price: $155"
await asyncio.sleep(0.1)
yield f"Stock {stock_symbol} price: $160"
def stop_streaming(function_name: str):
"""Stop the streaming tool."""
pass
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_stock_price, stop_streaming],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"Monitor AAPL stock price", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check that we got the streaming tool function call
function_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if (
part.function_call
and part.function_call.name == "monitor_stock_price"
):
function_call_found = True
assert part.function_call.args["stock_symbol"] == "AAPL"
assert (
function_call_found
), "Expected monitor_stock_price function call event."
def test_live_streaming_video_streaming_tool():
"""Test live streaming with a video streaming tool."""
# Create a function call response for the video streaming tool
function_call = types.Part.from_function_call(
name="monitor_video_stream", args={}
)
# Create LLM responses
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
response2 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2])
# Mock video streaming tool (without return type annotation to avoid parsing issues)
async def monitor_video_stream(input_stream: LiveRequestQueue):
"""Mock video streaming tool that processes video frames."""
# Simulate processing a few frames from the input stream
frame_count = 0
while frame_count < 3: # Process a few frames
try:
# Try to get a frame from the queue with timeout
live_req = await asyncio.wait_for(input_stream.get(), timeout=0.1)
if live_req.blob and live_req.blob.mime_type == "image/jpeg":
frame_count += 1
yield f"Processed frame {frame_count}: detected 2 people"
except asyncio.TimeoutError:
# No more frames, simulate detection anyway for testing
frame_count += 1
yield f"Simulated frame {frame_count}: detected 1 person"
await asyncio.sleep(0.1)
def stop_streaming(function_name: str):
"""Stop the streaming tool."""
pass
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_video_stream, stop_streaming],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
# Send some mock video frames
live_request_queue.send_realtime(
blob=types.Blob(data=b"fake_jpeg_data_1", mime_type="image/jpeg")
)
live_request_queue.send_realtime(
blob=types.Blob(data=b"fake_jpeg_data_2", mime_type="image/jpeg")
)
live_request_queue.send_realtime(
blob=types.Blob(data=b"Monitor video stream", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check that we got the video streaming tool function call
function_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if (
part.function_call
and part.function_call.name == "monitor_video_stream"
):
function_call_found = True
assert (
function_call_found
), "Expected monitor_video_stream function call event."
def test_live_streaming_stop_streaming_tool():
"""Test live streaming with stop_streaming functionality."""
# Create function calls for starting and stopping a streaming tool
start_function_call = types.Part.from_function_call(
name="monitor_stock_price", args={"stock_symbol": "TSLA"}
)
stop_function_call = types.Part.from_function_call(
name="stop_streaming", args={"function_name": "monitor_stock_price"}
)
# Create LLM responses: start streaming, then stop streaming
response1 = LlmResponse(
content=types.Content(role="model", parts=[start_function_call]),
turn_complete=False,
)
response2 = LlmResponse(
content=types.Content(role="model", parts=[stop_function_call]),
turn_complete=False,
)
response3 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2, response3])
# Mock streaming tool and stop function
async def monitor_stock_price(stock_symbol: str):
"""Mock streaming tool that monitors stock prices."""
yield f"Started monitoring {stock_symbol}"
while True: # Infinite stream (would be stopped by stop_streaming)
yield f"Stock {stock_symbol} price update"
await asyncio.sleep(0.1)
def stop_streaming(function_name: str):
"""Stop the streaming tool."""
return f"Stopped streaming for {function_name}"
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_stock_price, stop_streaming],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"Monitor TSLA and then stop", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check that we got both function calls
monitor_call_found = False
stop_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call:
if part.function_call.name == "monitor_stock_price":
monitor_call_found = True
assert part.function_call.args["stock_symbol"] == "TSLA"
elif part.function_call.name == "stop_streaming":
stop_call_found = True
assert (
part.function_call.args["function_name"]
== "monitor_stock_price"
)
assert monitor_call_found, "Expected monitor_stock_price function call event."
assert stop_call_found, "Expected stop_streaming function call event."
def test_live_streaming_multiple_streaming_tools():
"""Test live streaming with multiple streaming tools running simultaneously."""
# Create function calls for multiple streaming tools
stock_function_call = types.Part.from_function_call(
name="monitor_stock_price", args={"stock_symbol": "NVDA"}
)
video_function_call = types.Part.from_function_call(
name="monitor_video_stream", args={}
)
# Create LLM responses: start both streaming tools
response1 = LlmResponse(
content=types.Content(
role="model", parts=[stock_function_call, video_function_call]
),
turn_complete=False,
)
response2 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1, response2])
# Mock streaming tools
async def monitor_stock_price(stock_symbol: str):
"""Mock streaming tool that monitors stock prices."""
yield f"Stock {stock_symbol} price: $800"
await asyncio.sleep(0.1)
yield f"Stock {stock_symbol} price: $805"
async def monitor_video_stream(input_stream: LiveRequestQueue):
"""Mock video streaming tool."""
yield "Video monitoring started"
await asyncio.sleep(0.1)
yield "Detected motion in video stream"
def stop_streaming(function_name: str):
"""Stop the streaming tool."""
pass
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_stock_price, monitor_video_stream, stop_streaming],
)
# Use the custom runner
runner = StreamingTestRunner(root_agent=root_agent, max_responses=3)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(
data=b"Monitor both stock and video", mime_type="audio/pcm"
)
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
# Check that we got both streaming tool function calls
stock_call_found = False
video_call_found = False
for event in res_events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call:
if part.function_call.name == "monitor_stock_price":
stock_call_found = True
assert part.function_call.args["stock_symbol"] == "NVDA"
elif part.function_call.name == "monitor_video_stream":
video_call_found = True
assert stock_call_found, "Expected monitor_stock_price function call event."
assert video_call_found, "Expected monitor_video_stream function call event."
def test_live_streaming_function_call_yielded_before_finished_transcription():
"""Test that function calls arriving during live transcription are yielded immediately.
This verifies that tool call events are not buffered and are permitted to
arrive in the stream before the final completed transcription event.
"""
function_call = types.Part.from_function_call(
name="get_weather", args={"location": "San Francisco"}
)
response1 = LlmResponse(
input_transcription=types.Transcription(text="Show"),
partial=True, # ← Triggers is_transcribing = True
)
response2 = LlmResponse(
content=types.Content(
role="model", parts=[function_call]
), # ← Gets buffered
turn_complete=False,
)
response3 = LlmResponse(
input_transcription=types.Transcription(text="Show me the weather"),
partial=False, # ← Transcription ends, buffered events yielded
)
response4 = LlmResponse(
turn_complete=True,
)
mock_model = testing_utils.MockModel.create(
[response1, response2, response3, response4]
)
def get_weather(location: str) -> dict:
return {"temperature": 22, "location": location}
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[get_weather],
)
runner = StreamingTestRunner(root_agent=root_agent, max_responses=5)
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"Show me the weather", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
assert len(res_events) >= 1, "Expected at least one event."
function_call_index = -1
finished_transcription_index = -1
for idx, event in enumerate(res_events):
if event.content and event.content.parts:
for part in event.content.parts:
if part.function_call and part.function_call.name == "get_weather":
function_call_index = idx
assert part.function_call.args["location"] == "San Francisco"
if (
part.function_response
and part.function_response.name == "get_weather"
):
assert part.function_response.response["temperature"] == 22
if (
event.input_transcription
and event.input_transcription.text == "Show me the weather"
):
finished_transcription_index = idx
assert function_call_index != -1, "Function call event was not yielded."
assert (
finished_transcription_index != -1
), "Finished transcription event was not yielded."
assert function_call_index < finished_transcription_index, (
f"Expected function call (at index {function_call_index}) to arrive"
" before finished transcription (at index"
f" {finished_transcription_index})."
)
def test_live_streaming_text_content_persisted_in_session():
"""Test that user text content sent via send_content is persisted in session."""
response1 = LlmResponse(
content=types.Content(
role="model", parts=[types.Part(text="Hello! How can I help you?")]
),
turn_complete=True,
)
mock_model = testing_utils.MockModel.create([response1])
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[],
)
runner = StreamingTestRunner(root_agent=root_agent, max_responses=1)
live_request_queue = LiveRequestQueue()
# Send text content (not audio blob)
user_text = "Hello, this is a test message"
live_request_queue.send_content(
types.Content(role="user", parts=[types.Part(text=user_text)])
)
res_events, session = runner.run_live_and_get_session(live_request_queue)
assert res_events is not None, "Expected a list of events, got None."
# Check that user text content was persisted in the session
user_content_found = False
for event in session.events:
if event.author == "user" and event.content:
for part in event.content.parts:
if part.text and user_text in part.text:
user_content_found = True
break
assert user_content_found, (
f'Expected user text content "{user_text}" to be persisted in session. '
f"Session events: {[e.content for e in session.events]}"
)
def _collect_function_call_names(events):
"""Extract the set of function call names from a list of events."""
return {fc.name for event in events for fc in event.get_function_calls()}
class _LiveTestRunner(testing_utils.InMemoryRunner):
"""Test runner with custom event loop management for live streaming tests."""
def _run_with_loop(self, coro: Awaitable[Any]) -> None:
"""Run a coroutine in a new event loop, suppressing timeouts."""
try:
old_loop = asyncio.get_event_loop()
except RuntimeError:
old_loop = None
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(coro)
except (asyncio.TimeoutError, asyncio.CancelledError):
pass
finally:
# Cancel all pending tasks to prevent leaks and warnings
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
try:
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
except Exception: # pylint: disable=broad-except
pass
loop.close()
asyncio.set_event_loop(old_loop)
def run_live(
self,
live_request_queue: LiveRequestQueue,
max_responses: int = 3,
) -> list[testing_utils.Event]:
"""Run live and collect up to max_responses events."""
collected = []
async def consume(session: testing_utils.Session):
run_res = self.runner.run_live(
session=session,
live_request_queue=live_request_queue,
)
async with aclosing(run_res) as agen:
async for response in agen:
collected.append(response)
if len(collected) >= max_responses:
await _wait_for_queue_empty(live_request_queue)
return
self._run_with_loop(asyncio.wait_for(consume(self.session), timeout=5.0))
return collected
def test_input_streaming_tool_registered_lazily_with_stream():
"""Test that input-streaming tools are registered lazily when called and receive a stream."""
# A text response before the function call lets us observe that the
# tool is NOT registered before the model calls it.
text_response = LlmResponse(
content=types.Content(
role="model",
parts=[types.Part(text="Processing...")],
),
turn_complete=False,
)
function_call = types.Part.from_function_call(
name="monitor_video_stream", args={}
)
call_response = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
done_response = LlmResponse(turn_complete=True)
mock_model = testing_utils.MockModel.create(
[text_response, call_response, done_response]
)
stream_state_during_call = None
async def monitor_video_stream(
input_stream: LiveRequestQueue,
) -> AsyncGenerator[str, None]:
"""Record whether input_stream was provided."""
nonlocal stream_state_during_call
stream_state_during_call = input_stream is not None
yield "monitoring started"
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_video_stream],
)
runner = _LiveTestRunner(root_agent=root_agent)
# Capture the invocation context to inspect registration state.
captured_context = None
original_method = runner.runner._new_invocation_context_for_live
def capturing_method(*args, **kwargs) -> Any:
nonlocal captured_context
ctx = original_method(*args, **kwargs)
captured_context = ctx
return ctx
runner.runner._new_invocation_context_for_live = capturing_method
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"test_data", mime_type="audio/pcm")
)
# Collect events and check that the tool is NOT registered before
# the model calls it.
collected = []
not_registered_before_call = None
async def consume(session: testing_utils.Session):
nonlocal not_registered_before_call
run_res = runner.runner.run_live(
session=session,
live_request_queue=live_request_queue,
)
async with aclosing(run_res) as agen:
async for response in agen:
collected.append(response)
# On the first non-function-call event, verify the tool is not
# yet registered (lazy registration).
active = (
captured_context.active_streaming_tools
if captured_context
else None
)
if (
not_registered_before_call is None
and not response.get_function_calls()
):
not_registered_before_call = (
active is None or "monitor_video_stream" not in active
)
if len(collected) >= 4:
await _wait_for_queue_empty(live_request_queue)
return
runner._run_with_loop(asyncio.wait_for(consume(runner.session), timeout=5.0))
# Tool should not be registered before the model calls it.
assert (
not_registered_before_call is True
), "Expected tool to NOT be registered before the model calls it"
# When the model calls the tool, input_stream should be provided.
assert (
stream_state_during_call is True
), "Expected input_stream to be provided to the streaming tool when called"
def test_stop_streaming_resets_stream_to_none():
"""Test that stop_streaming sets stream back to None."""
start_call = types.Part.from_function_call(
name="monitor_stock_price", args={"stock_symbol": "GOOG"}
)
stop_call = types.Part.from_function_call(
name="stop_streaming", args={"function_name": "monitor_stock_price"}
)
response1 = LlmResponse(
content=types.Content(role="model", parts=[start_call]),
turn_complete=False,
)
response2 = LlmResponse(
content=types.Content(role="model", parts=[stop_call]),
turn_complete=False,
)
response3 = LlmResponse(turn_complete=True)
mock_model = testing_utils.MockModel.create([response1, response2, response3])
async def monitor_stock_price(
stock_symbol: str,
) -> AsyncGenerator[str, None]:
"""Yield periodic price updates for the given stock symbol."""
yield f"Monitoring {stock_symbol}"
while True:
await asyncio.sleep(0.1)
yield f"{stock_symbol} price update"
def stop_streaming(function_name: str) -> None:
"""Stop a running streaming tool by name."""
pass
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_stock_price, stop_streaming],
)
runner = _LiveTestRunner(root_agent=root_agent)
# Capture the child invocation context (created by _create_invocation_context
# inside base_agent.run_live) to inspect active_streaming_tools.
# We cannot use the parent context from _new_invocation_context_for_live
# because model_copy creates a separate child object.
captured_child_context = None
original_create = root_agent._create_invocation_context
def capturing_create(*args, **kwargs) -> Any:
nonlocal captured_child_context
ctx = original_create(*args, **kwargs)
captured_child_context = ctx
return ctx
root_agent._create_invocation_context = capturing_create
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"Monitor GOOG then stop", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue, max_responses=4)
# Verify both function calls were processed.
call_names = _collect_function_call_names(res_events)
assert (
"monitor_stock_price" in call_names
), "Expected monitor_stock_price function call."
assert (
"stop_streaming" in call_names
), "Expected stop_streaming function call."
# Verify that stop_streaming reset the stream to None.
assert (
captured_child_context is not None
), "Expected child invocation context to be captured"
active_tools = captured_child_context.active_streaming_tools or {}
assert (
"monitor_stock_price" in active_tools
), "Expected monitor_stock_price in active_streaming_tools"
assert (
active_tools["monitor_stock_price"].stream is None
), "Expected stream to be reset to None after stop_streaming"
def test_output_streaming_tool_registered_lazily_without_stream():
"""Test that output-streaming tools are registered lazily when called, with stream=None."""
function_call = types.Part.from_function_call(
name="monitor_stock_price", args={"stock_symbol": "GOOG"}
)
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
response2 = LlmResponse(turn_complete=True)
mock_model = testing_utils.MockModel.create([response1, response2])
async def monitor_stock_price(
stock_symbol: str,
) -> AsyncGenerator[str, None]:
"""Yield periodic price updates."""
yield f"price for {stock_symbol}"
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_stock_price],
)
runner = _LiveTestRunner(root_agent=root_agent)
# Capture the child invocation context (created by _create_invocation_context
# inside base_agent.run_live) to inspect active_streaming_tools.
captured_child_context = None
original_create = root_agent._create_invocation_context
def capturing_create(*args, **kwargs) -> Any:
nonlocal captured_child_context
ctx = original_create(*args, **kwargs)
captured_child_context = ctx
return ctx
root_agent._create_invocation_context = capturing_create
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"test", mime_type="audio/pcm")
)
runner.run_live(live_request_queue, max_responses=3)
# After the model calls the tool, it should be registered with
# stream=None (output-streaming tools don't consume the live stream).
assert captured_child_context is not None
active_tools = captured_child_context.active_streaming_tools or {}
assert (
"monitor_stock_price" in active_tools
), "Expected output-streaming tool to be registered when called"
assert (
active_tools["monitor_stock_price"].stream is None
), "Expected stream to be None for output-streaming tool"
def _run_single_tool_live(
tool_func,
func_name: str,
func_args: dict[str, Any] | None = None,
max_responses: int = 3,
) -> dict[str, Any]:
"""Run a live session that invokes a single tool and return active_streaming_tools.
Sets up a mock model that issues one function call then completes,
creates an agent with the given tool, captures the invocation context,
and returns the ``active_streaming_tools`` dict after execution.
"""
function_call = types.Part.from_function_call(
name=func_name, args=func_args or {}
)
response1 = LlmResponse(
content=types.Content(role="model", parts=[function_call]),
turn_complete=False,
)
response2 = LlmResponse(turn_complete=True)
mock_model = testing_utils.MockModel.create([response1, response2])
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[tool_func],
)
runner = _LiveTestRunner(root_agent=root_agent)
captured_child_context = None
original_create = root_agent._create_invocation_context
def capturing_create(*args, **kwargs) -> Any:
nonlocal captured_child_context
ctx = original_create(*args, **kwargs)
captured_child_context = ctx
return ctx
root_agent._create_invocation_context = capturing_create
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"test", mime_type="audio/pcm")
)
runner.run_live(live_request_queue, max_responses=max_responses)
assert captured_child_context is not None
return captured_child_context.active_streaming_tools or {}
def test_input_streaming_tool_has_stream_set_at_registration():
"""Test that input-streaming tools get .stream set to a LiveRequestQueue during registration."""
async def monitor_video_stream(
input_stream: LiveRequestQueue,
) -> AsyncGenerator[str, None]:
"""Simulate an input-streaming tool."""
yield "started"
active_tools = _run_single_tool_live(
monitor_video_stream, "monitor_video_stream"
)
assert (
"monitor_video_stream" in active_tools
), "Expected input-streaming tool to be registered when called"
# Stream should be a LiveRequestQueue, not None.
assert (
active_tools["monitor_video_stream"].stream is not None
), "Expected .stream to be set for input-streaming tool"
assert isinstance(
active_tools["monitor_video_stream"].stream, LiveRequestQueue
), "Expected .stream to be a LiveRequestQueue instance"
def test_input_streaming_tool_stream_recreated_after_stop():
"""Test that re-invoking an input-streaming tool after stop creates a new stream."""
start_call = types.Part.from_function_call(name="monitor_video", args={})
stop_call = types.Part.from_function_call(
name="stop_streaming", args={"function_name": "monitor_video"}
)
restart_call = types.Part.from_function_call(name="monitor_video", args={})
response1 = LlmResponse(
content=types.Content(role="model", parts=[start_call]),
turn_complete=False,
)
response2 = LlmResponse(
content=types.Content(role="model", parts=[stop_call]),
turn_complete=False,
)
response3 = LlmResponse(
content=types.Content(role="model", parts=[restart_call]),
turn_complete=False,
)
response4 = LlmResponse(turn_complete=True)
mock_model = testing_utils.MockModel.create(
[response1, response2, response3, response4]
)
call_count = 0
async def monitor_video(
input_stream: LiveRequestQueue,
) -> AsyncGenerator[str, None]:
"""Simulate an input-streaming tool that tracks invocation count."""
nonlocal call_count
call_count += 1
yield f"started (call {call_count})"
while True:
await asyncio.sleep(0.1)
yield "frame"
def stop_streaming(function_name: str) -> None:
"""Stop a running streaming tool by name."""
pass
root_agent = Agent(
name="root_agent",
model=mock_model,
tools=[monitor_video, stop_streaming],
)
runner = _LiveTestRunner(root_agent=root_agent)
captured_child_context = None
original_create = root_agent._create_invocation_context
def capturing_create(*args, **kwargs) -> Any:
nonlocal captured_child_context
ctx = original_create(*args, **kwargs)
captured_child_context = ctx
return ctx
root_agent._create_invocation_context = capturing_create
live_request_queue = LiveRequestQueue()
live_request_queue.send_realtime(
blob=types.Blob(data=b"test", mime_type="audio/pcm")
)
res_events = runner.run_live(live_request_queue, max_responses=8)
# monitor_video should appear at least twice in function calls
# (start + restart). Function response events may add extra
# occurrences.
call_names = [
fc.name for event in res_events for fc in event.get_function_calls()
]
assert (
call_names.count("monitor_video") >= 2
), f"Expected monitor_video called at least twice, got: {call_names}"
# After re-invocation, stream should be set again (not None).
assert captured_child_context is not None
active_tools = captured_child_context.active_streaming_tools or {}
assert "monitor_video" in active_tools
assert (
active_tools["monitor_video"].stream is not None
), "Expected .stream to be recreated after stop + re-invocation"
def test_async_gen_with_input_stream_wrong_annotation_gets_no_stream():
"""Test that an async generator with input_stream param but wrong annotation gets no stream."""
received_input_stream = None
async def my_tool(input_stream: str) -> AsyncGenerator[str, None]:
"""Simulate an async generator whose input_stream is typed as str."""
nonlocal received_input_stream
received_input_stream = input_stream
yield f"got: {input_stream}"
active_tools = _run_single_tool_live(
my_tool, "my_tool", func_args={"input_stream": "some_value"}
)
assert (
"my_tool" in active_tools
), "Expected async generator tool to be registered"
# Stream should be None because annotation is str, not LiveRequestQueue.
assert active_tools["my_tool"].stream is None, (
"Expected .stream to be None when input_stream annotation is not"
" LiveRequestQueue"
)
# The tool should have received the model-provided arg value, not a
# LiveRequestQueue.
assert (
received_input_stream == "some_value"
), "Expected input_stream to be the model-provided string value"