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
448 lines
13 KiB
Python
448 lines
13 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.
|
|
|
|
from __future__ import annotations
|
|
|
|
"""Unit tests for the helper methods on the Event class."""
|
|
|
|
import copy
|
|
|
|
from google.adk.events.event import Event
|
|
from google.adk.events.event import NodeInfo
|
|
from google.adk.events.event_actions import EventActions
|
|
from google.adk.events.request_input import RequestInput
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
|
|
def _text_part(text: str = 'hello') -> types.Part:
|
|
return types.Part(text=text)
|
|
|
|
|
|
def _function_call_part(name: str = 'my_func') -> types.Part:
|
|
return types.Part(function_call=types.FunctionCall(name=name, args={'x': 1}))
|
|
|
|
|
|
def _function_response_part(name: str = 'my_func') -> types.Part:
|
|
return types.Part(
|
|
function_response=types.FunctionResponse(name=name, response={'y': 2})
|
|
)
|
|
|
|
|
|
def _code_execution_result_part(output: str = '42') -> types.Part:
|
|
return types.Part(
|
|
code_execution_result=types.CodeExecutionResult(
|
|
outcome=types.Outcome.OUTCOME_OK, output=output
|
|
)
|
|
)
|
|
|
|
|
|
def _event(parts: list[types.Part] | None = None, **kwargs) -> Event:
|
|
content = (
|
|
types.Content(role='model', parts=parts) if parts is not None else None
|
|
)
|
|
return Event(author='agent', content=content, **kwargs)
|
|
|
|
|
|
# --- is_final_response -------------------------------------------------------
|
|
|
|
|
|
def test_is_final_response_plain_text_event_is_final():
|
|
event = _event(parts=[_text_part()])
|
|
assert event.is_final_response() is True
|
|
|
|
|
|
def test_is_final_response_empty_event_is_final():
|
|
event = _event()
|
|
assert event.is_final_response() is True
|
|
|
|
|
|
def test_is_final_response_with_function_call_is_not_final():
|
|
event = _event(parts=[_text_part(), _function_call_part()])
|
|
assert event.is_final_response() is False
|
|
|
|
|
|
def test_is_final_response_with_function_response_is_not_final():
|
|
event = _event(parts=[_function_response_part()])
|
|
assert event.is_final_response() is False
|
|
|
|
|
|
def test_is_final_response_partial_event_is_not_final():
|
|
event = _event(parts=[_text_part()], partial=True)
|
|
assert event.is_final_response() is False
|
|
|
|
|
|
def test_is_final_response_with_trailing_code_result_is_not_final():
|
|
event = _event(parts=[_text_part(), _code_execution_result_part()])
|
|
assert event.is_final_response() is False
|
|
|
|
|
|
def test_is_final_response_skip_summarization_overrides_function_response():
|
|
event = _event(
|
|
parts=[_function_response_part()],
|
|
actions=EventActions(skip_summarization=True),
|
|
)
|
|
assert event.is_final_response() is True
|
|
|
|
|
|
def test_is_final_response_long_running_tool_ids_overrides_function_call():
|
|
event = _event(
|
|
parts=[_function_call_part()], long_running_tool_ids={'tool-1'}
|
|
)
|
|
assert event.is_final_response() is True
|
|
|
|
|
|
# --- get_function_calls ------------------------------------------------------
|
|
|
|
|
|
def test_get_function_calls_returns_calls_in_order():
|
|
event = _event(
|
|
parts=[
|
|
_text_part(),
|
|
_function_call_part('first'),
|
|
_function_response_part(),
|
|
_function_call_part('second'),
|
|
]
|
|
)
|
|
assert [call.name for call in event.get_function_calls()] == [
|
|
'first',
|
|
'second',
|
|
]
|
|
|
|
|
|
def test_get_function_calls_no_content_returns_empty():
|
|
assert _event().get_function_calls() == []
|
|
|
|
|
|
def test_get_function_calls_empty_parts_returns_empty():
|
|
assert _event(parts=[]).get_function_calls() == []
|
|
|
|
|
|
def test_get_function_calls_text_only_returns_empty():
|
|
assert _event(parts=[_text_part()]).get_function_calls() == []
|
|
|
|
|
|
# --- get_function_responses --------------------------------------------------
|
|
|
|
|
|
def test_get_function_responses_returns_responses_in_order():
|
|
event = _event(
|
|
parts=[
|
|
_function_response_part('first'),
|
|
_text_part(),
|
|
_function_call_part(),
|
|
_function_response_part('second'),
|
|
]
|
|
)
|
|
assert [resp.name for resp in event.get_function_responses()] == [
|
|
'first',
|
|
'second',
|
|
]
|
|
|
|
|
|
def test_get_function_responses_no_content_returns_empty():
|
|
assert _event().get_function_responses() == []
|
|
|
|
|
|
def test_get_function_responses_empty_parts_returns_empty():
|
|
assert _event(parts=[]).get_function_responses() == []
|
|
|
|
|
|
# --- has_trailing_code_execution_result --------------------------------------
|
|
|
|
|
|
def test_has_trailing_code_execution_result_true_when_last():
|
|
event = _event(parts=[_text_part(), _code_execution_result_part()])
|
|
assert event.has_trailing_code_execution_result() is True
|
|
|
|
|
|
def test_has_trailing_code_execution_result_false_when_not_last():
|
|
event = _event(parts=[_code_execution_result_part(), _text_part()])
|
|
assert event.has_trailing_code_execution_result() is False
|
|
|
|
|
|
def test_has_trailing_code_execution_result_false_no_content():
|
|
assert _event().has_trailing_code_execution_result() is False
|
|
|
|
|
|
def test_has_trailing_code_execution_result_false_empty_parts():
|
|
assert _event(parts=[]).has_trailing_code_execution_result() is False
|
|
|
|
|
|
# --- id generation (model_post_init) -----------------------------------------
|
|
|
|
|
|
def test_event_id_auto_assigned_when_missing():
|
|
assert _event().id != ''
|
|
|
|
|
|
def test_event_ids_are_unique():
|
|
assert _event().id != _event().id
|
|
|
|
|
|
def test_event_id_preserved_when_provided():
|
|
assert _event(id='fixed-id').id == 'fixed-id'
|
|
|
|
|
|
# --- state initialization ----------------------------------------------------
|
|
|
|
|
|
def test_event_constructor_with_state():
|
|
"""Tests that the event constructor handles the state argument."""
|
|
my_event = Event(state={'key': 'value'})
|
|
assert my_event.actions is not None
|
|
assert my_event.actions.state_delta == {'key': 'value'}
|
|
|
|
|
|
def test_event_constructor_without_state():
|
|
"""Tests that the event constructor works without the state argument."""
|
|
my_event = Event()
|
|
assert my_event.actions is not None
|
|
assert my_event.actions.state_delta == {}
|
|
|
|
|
|
# --- isolation scope ---------------------------------------------------------
|
|
|
|
|
|
def test_event_isolation_scope():
|
|
"""Tests Event.isolation_scope default value and serialization."""
|
|
ev = Event()
|
|
assert ev.isolation_scope is None
|
|
|
|
ev2 = Event(isolation_scope='task:fc-123')
|
|
dumped = ev2.model_dump(mode='json', by_alias=True, exclude_none=True)
|
|
assert dumped['isolationScope'] == 'task:fc-123'
|
|
|
|
|
|
# --- serialization -----------------------------------------------------------
|
|
|
|
|
|
def test_event_serialization_always_camel_case():
|
|
"""Tests that Event serialization produces camelCase keys."""
|
|
request_input = RequestInput(interrupt_id='fc-1', message='test')
|
|
|
|
# Create an event with fields that would produce snake_case if not dumped by alias
|
|
event = Event(
|
|
invocation_id='i-1',
|
|
node_info=NodeInfo(
|
|
path='a/b',
|
|
output_for=['c'],
|
|
message_as_output=True,
|
|
),
|
|
output=request_input,
|
|
)
|
|
|
|
dumped = event.model_dump(by_alias=True)
|
|
|
|
def check_no_snake_case_keys(data):
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
assert '_' not in key, f'Found snake_case key: {key} in {data}'
|
|
check_no_snake_case_keys(value)
|
|
elif isinstance(data, list):
|
|
for item in data:
|
|
check_no_snake_case_keys(item)
|
|
|
|
check_no_snake_case_keys(dumped)
|
|
|
|
# Also verify that expected keys are indeed camelCased
|
|
assert 'invocationId' in dumped
|
|
assert 'nodeInfo' in dumped
|
|
assert 'outputFor' in dumped['nodeInfo']
|
|
assert 'messageAsOutput' in dumped['nodeInfo']
|
|
|
|
# Verify RequestInput fields are camelCased
|
|
assert 'output' in dumped
|
|
assert 'interruptId' in dumped['output']
|
|
|
|
|
|
# --- message alias for content -----------------------------------------------
|
|
|
|
|
|
class TestMessageConstructor:
|
|
"""Tests for Event(message=...) constructor parameter."""
|
|
|
|
def test_message_str_sets_content(self):
|
|
event = Event(message='Hello!')
|
|
assert event.content is not None
|
|
assert event.content.parts[0].text == 'Hello!'
|
|
|
|
def test_message_content_passes_through(self):
|
|
content = types.Content(
|
|
parts=[types.Part(text='from Content')], role='model'
|
|
)
|
|
event = Event(message=content)
|
|
assert event.content is content
|
|
|
|
def test_message_part_converts_to_content(self):
|
|
part = types.Part(text='from Part')
|
|
event = Event(message=part)
|
|
assert event.content is not None
|
|
assert event.content.parts[0].text == 'from Part'
|
|
|
|
def test_message_list_of_parts(self):
|
|
parts = [types.Part(text='part1'), types.Part(text='part2')]
|
|
event = Event(message=parts)
|
|
assert event.content is not None
|
|
assert len(event.content.parts) == 2
|
|
assert event.content.parts[0].text == 'part1'
|
|
assert event.content.parts[1].text == 'part2'
|
|
|
|
def test_message_and_content_raises(self):
|
|
with pytest.raises(ValueError, match='mutually exclusive'):
|
|
Event(
|
|
message='hello',
|
|
content=types.Content(parts=[types.Part(text='world')]),
|
|
)
|
|
|
|
def test_content_still_works(self):
|
|
content = types.Content(
|
|
parts=[types.Part(text='via content')], role='model'
|
|
)
|
|
event = Event(content=content)
|
|
assert event.content is content
|
|
assert event.content.parts[0].text == 'via content'
|
|
|
|
def test_neither_message_nor_content(self):
|
|
event = Event()
|
|
assert event.content is None
|
|
|
|
|
|
class TestMessageProperty:
|
|
"""Tests for Event.message property getter and setter."""
|
|
|
|
def test_message_getter_aliases_content(self):
|
|
content = types.Content(parts=[types.Part(text='hello')], role='model')
|
|
event = Event(content=content)
|
|
assert event.message is event.content
|
|
|
|
def test_message_getter_none_when_no_content(self):
|
|
event = Event()
|
|
assert event.message is None
|
|
|
|
def test_message_setter_updates_content(self):
|
|
event = Event()
|
|
new_content = types.Content(
|
|
parts=[types.Part(text='updated')], role='model'
|
|
)
|
|
event.message = new_content
|
|
assert event.content is new_content
|
|
|
|
def test_message_setter_accepts_str(self):
|
|
event = Event()
|
|
event.message = 'updated via setter'
|
|
assert event.content is not None
|
|
assert event.content.parts[0].text == 'updated via setter'
|
|
|
|
def test_message_setter_none_clears_content(self):
|
|
event = Event(message='hello')
|
|
event.message = None
|
|
assert event.content is None
|
|
|
|
def test_message_from_constructor_readable_via_property(self):
|
|
event = Event(message='Hello!')
|
|
assert event.message is not None
|
|
assert event.message.parts[0].text == 'Hello!'
|
|
|
|
|
|
class TestMessageSerialization:
|
|
"""Tests that serialization uses 'content', not 'message'."""
|
|
|
|
def test_serialized_uses_content_field(self):
|
|
event = Event(message='Hello!')
|
|
data = event.model_dump(exclude_none=True)
|
|
assert 'content' in data
|
|
assert 'message' not in data
|
|
|
|
def test_round_trip_via_content(self):
|
|
event = Event(message='Hello!')
|
|
data = event.model_dump()
|
|
restored = Event.model_validate(data)
|
|
assert restored.content is not None
|
|
assert restored.content.parts[0].text == 'Hello!'
|
|
assert restored.message is not None
|
|
assert restored.message.parts[0].text == 'Hello!'
|
|
|
|
def test_model_validate_does_not_mutate_input_dict(self):
|
|
data = {
|
|
'message': 'Hello!',
|
|
'state': {'key': 'value'},
|
|
'route': 'next',
|
|
'node_path': 'root.node',
|
|
}
|
|
original = copy.deepcopy(data)
|
|
|
|
event = Event.model_validate(data)
|
|
|
|
assert data == original
|
|
assert event.content is not None
|
|
assert event.content.parts[0].text == 'Hello!'
|
|
assert event.actions.state_delta == {'key': 'value'}
|
|
assert event.actions.route == 'next'
|
|
assert event.node_info.path == 'root.node'
|
|
|
|
|
|
class TestMessageWithOtherKwargs:
|
|
"""Tests message combined with other convenience kwargs."""
|
|
|
|
def test_message_with_state(self):
|
|
event = Event(message='hello', state={'key': 'val'})
|
|
assert event.content is not None
|
|
assert event.content.parts[0].text == 'hello'
|
|
assert event.actions.state_delta == {'key': 'val'}
|
|
|
|
def test_message_with_route(self):
|
|
event = Event(message='hello', route='next')
|
|
assert event.content is not None
|
|
assert event.actions.route == 'next'
|
|
|
|
|
|
class TestMessageSubclassField:
|
|
"""Tests that a subclass declaring `message` as a real field is honored.
|
|
|
|
`_accept_convenience_kwargs` already routes construction kwargs to such a
|
|
field; the `message` property/setter must defer to it too instead of
|
|
aliasing `content`.
|
|
"""
|
|
|
|
def test_subclass_field_readable_via_property(self):
|
|
class _Sub(Event):
|
|
message: str = ''
|
|
|
|
event = _Sub(message='hello', author='a')
|
|
assert event.message == 'hello'
|
|
|
|
def test_subclass_field_serializes_and_round_trips(self):
|
|
class _Sub(Event):
|
|
message: str = ''
|
|
|
|
event = _Sub(message='hello', author='a')
|
|
data = event.model_dump()
|
|
assert data['message'] == 'hello'
|
|
assert _Sub.model_validate(data).message == 'hello'
|
|
|
|
def test_subclass_field_setter_updates_field_not_content(self):
|
|
class _Sub(Event):
|
|
message: str = ''
|
|
|
|
event = _Sub(message='hello', author='a')
|
|
event.message = 'updated'
|
|
assert event.message == 'updated'
|
|
assert event.content is None
|
|
|
|
def test_base_event_message_still_aliases_content(self):
|
|
content = types.Content(parts=[types.Part(text='hi')], role='model')
|
|
event = Event(content=content)
|
|
assert event.message is event.content
|