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,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,218 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for _BranchPath.
|
||||
|
||||
Verifies that _BranchPath correctly parses, serializes, and manipulates
|
||||
hierarchical dynamic execution branch paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google.adk.events._branch_path import _BranchPath
|
||||
import pytest
|
||||
|
||||
|
||||
def test_from_string_with_empty_string_returns_empty_path():
|
||||
"""Parsing an empty string returns a _BranchPath with no segments."""
|
||||
path = _BranchPath.from_string("")
|
||||
|
||||
assert path.segments == []
|
||||
assert str(path) == ""
|
||||
|
||||
|
||||
def test_from_string_with_single_segment_returns_path_with_one_segment():
|
||||
"""Parsing a single name returns a _BranchPath with one segment."""
|
||||
path = _BranchPath.from_string("agent_0")
|
||||
|
||||
assert path.segments == ["agent_0"]
|
||||
assert str(path) == "agent_0"
|
||||
|
||||
|
||||
def test_from_string_with_multiple_segments_returns_path_with_all_segments():
|
||||
"""Parsing a dot-separated string returns a _BranchPath with all segments."""
|
||||
path = _BranchPath.from_string("parent.child.node")
|
||||
|
||||
assert path.segments == ["parent", "child", "node"]
|
||||
assert str(path) == "parent.child.node"
|
||||
|
||||
|
||||
def test_equality_compares_path_segments():
|
||||
"""Two _BranchPath objects are equal if and only if their segments match."""
|
||||
path1 = _BranchPath.from_string("parent.child")
|
||||
path2 = _BranchPath.from_string("parent.child")
|
||||
path3 = _BranchPath.from_string("parent.other")
|
||||
|
||||
assert path1 == path2
|
||||
assert path1 != path3
|
||||
assert path1 != "parent.child" # Different type
|
||||
|
||||
|
||||
def test_run_ids_extracts_all_run_ids_from_path():
|
||||
"""run_ids extracts all run IDs (the part after '@') from all segments."""
|
||||
# Given paths with various run ID patterns
|
||||
path_with_ids = _BranchPath.from_string("parent@1.child@2.node")
|
||||
path_no_ids = _BranchPath.from_string("parent.child")
|
||||
path_mixed = _BranchPath.from_string("parent@1.child.node@3")
|
||||
|
||||
# Then the extracted run IDs match expectations
|
||||
assert path_with_ids.run_ids == {"1", "2"}
|
||||
assert path_no_ids.run_ids == set()
|
||||
assert path_mixed.run_ids == {"1", "3"}
|
||||
|
||||
|
||||
def test_parent_returns_parent_path_or_none_for_root():
|
||||
"""parent returns a new _BranchPath excluding the leaf segment, or None."""
|
||||
path = _BranchPath.from_string("parent.child.node")
|
||||
|
||||
assert path.parent == _BranchPath.from_string("parent.child")
|
||||
assert path.parent.parent == _BranchPath.from_string("parent")
|
||||
assert path.parent.parent.parent is None
|
||||
|
||||
|
||||
def test_is_descendant_of_verifies_path_hierarchy_safely():
|
||||
"""is_descendant_of returns True if the path is a strict sub-path of ancestor."""
|
||||
# Given an ancestor and various comparison paths
|
||||
ancestor = _BranchPath.from_string("parent.child")
|
||||
descendant = _BranchPath.from_string("parent.child.node.leaf")
|
||||
not_descendant = _BranchPath.from_string("parent.other")
|
||||
same = _BranchPath.from_string("parent.child")
|
||||
|
||||
# Then descendant checks match expectations
|
||||
assert descendant.is_descendant_of(ancestor)
|
||||
assert not ancestor.is_descendant_of(descendant)
|
||||
assert not not_descendant.is_descendant_of(ancestor)
|
||||
assert not same.is_descendant_of(ancestor)
|
||||
|
||||
|
||||
def test_is_descendant_of_is_immune_to_partial_name_prefix_match():
|
||||
"""is_descendant_of compares segments, avoiding partial string prefix bugs."""
|
||||
# Given an ancestor and a path that has a partial string prefix but different segment
|
||||
ancestor = _BranchPath.from_string("agent_0")
|
||||
descendant_with_prefix = _BranchPath.from_string("agent_00.child")
|
||||
|
||||
# Then it is not recognized as a descendant because segments don't match
|
||||
assert not descendant_with_prefix.is_descendant_of(ancestor)
|
||||
|
||||
|
||||
def test_common_prefix_finds_longest_shared_path():
|
||||
"""common_prefix returns the longest common prefix of a list of paths."""
|
||||
# Given a list of paths sharing a common prefix
|
||||
paths = [
|
||||
_BranchPath.from_string("parent.child.node1"),
|
||||
_BranchPath.from_string("parent.child.node2.leaf"),
|
||||
_BranchPath.from_string("parent.child.node3"),
|
||||
]
|
||||
|
||||
# When finding the common prefix
|
||||
result = _BranchPath.common_prefix(paths)
|
||||
|
||||
# Then the result matches the shared parent path
|
||||
assert result == _BranchPath.from_string("parent.child")
|
||||
|
||||
|
||||
def test_common_prefix_with_no_shared_path_returns_empty():
|
||||
"""common_prefix returns an empty path if there is no shared prefix."""
|
||||
paths = [
|
||||
_BranchPath.from_string("parent.child"),
|
||||
_BranchPath.from_string("other.child"),
|
||||
]
|
||||
|
||||
result = _BranchPath.common_prefix(paths)
|
||||
|
||||
assert result == _BranchPath.from_string("")
|
||||
|
||||
|
||||
def test_common_prefix_with_empty_list_returns_empty():
|
||||
"""common_prefix returns an empty path if the input list is empty."""
|
||||
result = _BranchPath.common_prefix([])
|
||||
|
||||
assert result == _BranchPath.from_string("")
|
||||
|
||||
|
||||
def test_constructor_copies_segments_list():
|
||||
"""_BranchPath copies the input segments list to ensure immutability."""
|
||||
segments = ["parent", "child"]
|
||||
path = _BranchPath(segments)
|
||||
|
||||
# Mutate the original list
|
||||
segments.append("grandchild")
|
||||
|
||||
# The path segments should remain unchanged
|
||||
assert path.segments == ["parent", "child"]
|
||||
|
||||
|
||||
def test_append_single_segment_returns_new_path():
|
||||
"""append adds a single segment to an existing path."""
|
||||
path = _BranchPath.from_string("parent")
|
||||
new_path = path.append("child")
|
||||
|
||||
assert new_path == _BranchPath.from_string("parent.child")
|
||||
assert path == _BranchPath.from_string("parent") # Immutability check
|
||||
|
||||
|
||||
def test_append_with_run_id_formats_segment():
|
||||
"""append formats the segment as 'name@run_id' when run_id is provided."""
|
||||
path = _BranchPath.from_string("parent")
|
||||
new_path = path.append("child", run_id="call_123")
|
||||
|
||||
assert new_path == _BranchPath.from_string("parent.child@call_123")
|
||||
|
||||
|
||||
def test_append_another_branch_path():
|
||||
"""append combines segments from another _BranchPath instance."""
|
||||
path1 = _BranchPath.from_string("parent")
|
||||
path2 = _BranchPath.from_string("child.grandchild")
|
||||
new_path = path1.append(path2)
|
||||
|
||||
assert new_path == _BranchPath.from_string("parent.child.grandchild")
|
||||
|
||||
|
||||
def test_create_sub_branch_formats_string_correctly():
|
||||
"""create_sub_branch constructs sub-branch strings safely."""
|
||||
# With base branch and run ID
|
||||
res1 = _BranchPath.create_sub_branch(
|
||||
"parent.sub", name="child", run_id="run_1"
|
||||
)
|
||||
assert res1 == "parent.sub.child@run_1"
|
||||
|
||||
# Without base branch (None or empty)
|
||||
res2 = _BranchPath.create_sub_branch(None, name="agent", run_id="fc_456")
|
||||
assert res2 == "agent@fc_456"
|
||||
|
||||
# Dot-separated sub-path without run ID
|
||||
res3 = _BranchPath.create_sub_branch("parent", name="agent.sub_agent")
|
||||
assert res3 == "parent.agent.sub_agent"
|
||||
|
||||
|
||||
def test_append_with_run_id_and_branch_path_raises_value_error():
|
||||
"""append raises ValueError when run_id is provided with a _BranchPath."""
|
||||
path1 = _BranchPath.from_string("parent")
|
||||
path2 = _BranchPath.from_string("child")
|
||||
with pytest.raises(ValueError, match="run_id cannot be provided"):
|
||||
path1.append(path2, run_id="123")
|
||||
|
||||
|
||||
def test_append_with_run_id_and_dot_separated_path_raises_value_error():
|
||||
"""append raises ValueError when run_id is provided with a dot-separated path."""
|
||||
path = _BranchPath.from_string("parent")
|
||||
with pytest.raises(ValueError, match="run_id cannot be provided"):
|
||||
path.append("child.sub", run_id="123")
|
||||
|
||||
|
||||
def test_run_ids_filters_out_empty_run_ids():
|
||||
"""run_ids filters out segments with empty run IDs (e.g. ending with '@')."""
|
||||
path = _BranchPath.from_string("parent@.child@2.node@")
|
||||
|
||||
assert path.run_ids == {"2"}
|
||||
@@ -0,0 +1,447 @@
|
||||
# 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
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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 EventActions serialization and its fallback helper."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from google.adk.events.event_actions import _make_json_serializable
|
||||
from google.adk.events.event_actions import EventActions
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class _Sample(BaseModel):
|
||||
x: int = 5
|
||||
label: str = 'hi'
|
||||
|
||||
|
||||
class TestMakeJsonSerializable:
|
||||
"""Tests for the `_make_json_serializable` fallback helper."""
|
||||
|
||||
def test_plain_values_are_unchanged(self):
|
||||
value = {'a': 1, 'b': [1, 2], 'c': {'d': 'e'}, 'f': None, 'g': True}
|
||||
assert _make_json_serializable(value) == value
|
||||
|
||||
def test_datetime_is_preserved_not_discarded(self):
|
||||
dt = datetime.datetime(2024, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
|
||||
assert _make_json_serializable(dt) == '2024-01-02T03:04:05Z'
|
||||
|
||||
def test_pydantic_model_is_serialized_to_dict(self):
|
||||
assert _make_json_serializable(_Sample()) == {'x': 5, 'label': 'hi'}
|
||||
|
||||
def test_nested_rich_types_are_serialized(self):
|
||||
dt = datetime.datetime(2024, 5, 6, tzinfo=datetime.timezone.utc)
|
||||
result = _make_json_serializable({'when': dt, 'model': _Sample(), 'n': [1]})
|
||||
assert result == {
|
||||
'when': '2024-05-06T00:00:00Z',
|
||||
'model': {'x': 5, 'label': 'hi'},
|
||||
'n': [1],
|
||||
}
|
||||
|
||||
def test_unserializable_value_is_replaced_with_repr(self):
|
||||
result = _make_json_serializable(lambda: 1)
|
||||
assert isinstance(result, str)
|
||||
assert 'function' in result
|
||||
|
||||
def test_unserializable_value_nested(self):
|
||||
result = _make_json_serializable({'cb': lambda: 1, 'ok': 2})
|
||||
assert result['ok'] == 2
|
||||
assert isinstance(result['cb'], str)
|
||||
|
||||
|
||||
class TestStateDeltaSerialization:
|
||||
"""Tests for the `state_delta` wrap serializer."""
|
||||
|
||||
def test_serializable_state_delta_round_trips(self):
|
||||
actions = EventActions(state_delta={'a': 1, 'b': [1, 2], 'c': {'d': 'e'}})
|
||||
dumped = actions.model_dump(mode='json')
|
||||
assert dumped['state_delta'] == {'a': 1, 'b': [1, 2], 'c': {'d': 'e'}}
|
||||
|
||||
def test_non_serializable_state_delta_does_not_raise(self):
|
||||
actions = EventActions(state_delta={'cb': lambda: 1, 'ok': 2})
|
||||
dumped = actions.model_dump(mode='json')
|
||||
assert dumped['state_delta']['ok'] == 2
|
||||
assert isinstance(dumped['state_delta']['cb'], str)
|
||||
|
||||
def test_non_serializable_state_delta_logs_warning(self, caplog):
|
||||
actions = EventActions(state_delta={'cb': lambda: 1})
|
||||
with caplog.at_level(logging.WARNING):
|
||||
actions.model_dump(mode='json')
|
||||
assert any(
|
||||
'Failed to serialize `state_delta`' in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
def test_datetime_preserved_when_fallback_triggered(self):
|
||||
# A callable forces the fallback path; the datetime must still serialize
|
||||
# faithfully rather than being discarded.
|
||||
dt = datetime.datetime(2024, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
|
||||
actions = EventActions(state_delta={'when': dt, 'cb': lambda: 1})
|
||||
dumped = actions.model_dump(mode='json')
|
||||
assert dumped['state_delta']['when'] == '2024-01-02T03:04:05Z'
|
||||
|
||||
def test_exclude_is_respected_for_serializable_state(self):
|
||||
actions = EventActions(
|
||||
state_delta={'_adk_replay_config': {'dir': '/x'}, 'foo': 1}
|
||||
)
|
||||
dumped = actions.model_dump(
|
||||
mode='json', exclude={'state_delta': {'_adk_replay_config': True}}
|
||||
)
|
||||
assert dumped['state_delta'] == {'foo': 1}
|
||||
|
||||
def test_exclude_is_respected_in_fallback_path(self):
|
||||
# Even when sanitization is required (callable present), caller `exclude`
|
||||
# directives must still be applied to the fallback output.
|
||||
actions = EventActions(
|
||||
state_delta={
|
||||
'_adk_replay_config': {'dir': '/x'},
|
||||
'cb': lambda: 1,
|
||||
'ok': 2,
|
||||
}
|
||||
)
|
||||
dumped = actions.model_dump(
|
||||
mode='json', exclude={'state_delta': {'_adk_replay_config': True}}
|
||||
)
|
||||
assert '_adk_replay_config' not in dumped['state_delta']
|
||||
assert dumped['state_delta']['ok'] == 2
|
||||
assert isinstance(dumped['state_delta']['cb'], str)
|
||||
|
||||
|
||||
class TestAgentStateSerialization:
|
||||
"""Tests for the `agent_state` wrap serializer."""
|
||||
|
||||
def test_none_agent_state_serializes_to_none(self):
|
||||
assert EventActions().model_dump(mode='json')['agent_state'] is None
|
||||
|
||||
def test_serializable_agent_state_round_trips(self):
|
||||
actions = EventActions(agent_state={'a': 1, 'b': 'two'})
|
||||
assert actions.model_dump(mode='json')['agent_state'] == {
|
||||
'a': 1,
|
||||
'b': 'two',
|
||||
}
|
||||
|
||||
def test_non_serializable_agent_state_does_not_raise(self):
|
||||
actions = EventActions(agent_state={'cb': lambda: 1, 'n': 3})
|
||||
dumped = actions.model_dump(mode='json')
|
||||
assert dumped['agent_state']['n'] == 3
|
||||
assert isinstance(dumped['agent_state']['cb'], str)
|
||||
|
||||
def test_non_serializable_agent_state_logs_warning(self, caplog):
|
||||
actions = EventActions(agent_state={'cb': lambda: 1})
|
||||
with caplog.at_level(logging.WARNING):
|
||||
actions.model_dump(mode='json')
|
||||
assert any(
|
||||
'Failed to serialize `agent_state`' in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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
|
||||
|
||||
from google.adk.events._node_path_builder import _NodePathBuilder
|
||||
import pytest
|
||||
|
||||
|
||||
def test_from_string_returns_empty_path_when_string_is_empty():
|
||||
"""Parsing an empty string returns a path with no segments."""
|
||||
path = _NodePathBuilder.from_string('')
|
||||
assert str(path) == ''
|
||||
|
||||
|
||||
def test_from_string_parses_single_segment():
|
||||
"""Parsing a single segment string returns a path with that segment."""
|
||||
path = _NodePathBuilder.from_string('wf@1')
|
||||
assert str(path) == 'wf@1'
|
||||
|
||||
|
||||
def test_from_string_parses_multiple_segments():
|
||||
"""Parsing a slash-separated string returns a path with all segments."""
|
||||
path = _NodePathBuilder.from_string('wf@1/node@2')
|
||||
assert str(path) == 'wf@1/node@2'
|
||||
|
||||
|
||||
def test_str_joins_segments_with_slash():
|
||||
"""String representation joins segments with slashes."""
|
||||
path = _NodePathBuilder(['wf@1', 'node@2'])
|
||||
assert str(path) == 'wf@1/node@2'
|
||||
|
||||
|
||||
def test_eq_returns_true_for_same_segments():
|
||||
"""Equality returns True if both paths have identical segments."""
|
||||
path1 = _NodePathBuilder(['wf@1', 'node@2'])
|
||||
path2 = _NodePathBuilder(['wf@1', 'node@2'])
|
||||
assert path1 == path2
|
||||
|
||||
|
||||
def test_eq_returns_false_for_different_segments():
|
||||
"""Equality returns False if segments differ."""
|
||||
path1 = _NodePathBuilder(['wf@1', 'node@1'])
|
||||
path2 = _NodePathBuilder(['wf@1', 'node@2'])
|
||||
assert path1 != path2
|
||||
|
||||
|
||||
def test_eq_returns_not_implemented_for_other_types():
|
||||
"""Equality returns False when comparing with non-_NodePathBuilder objects."""
|
||||
path = _NodePathBuilder(['wf@1'])
|
||||
assert path != 'wf@1'
|
||||
|
||||
|
||||
def test_node_name_returns_name_without_run_id():
|
||||
"""node_name returns the name part of the leaf segment, removing @id."""
|
||||
path = _NodePathBuilder.from_string('wf@1/node@2')
|
||||
assert path.node_name == 'node'
|
||||
|
||||
|
||||
def test_node_name_returns_full_segment_when_no_id_present():
|
||||
"""node_name returns the full segment if no @id is present."""
|
||||
path = _NodePathBuilder.from_string('wf@1/node')
|
||||
assert path.node_name == 'node'
|
||||
|
||||
|
||||
def test_run_id_returns_id_when_present():
|
||||
"""run_id returns the ID part after @ in the leaf segment."""
|
||||
path = _NodePathBuilder.from_string('wf@1/node@2')
|
||||
assert path.run_id == '2'
|
||||
|
||||
|
||||
def test_run_id_returns_none_when_no_id_present():
|
||||
"""run_id returns None if no @ is present in the leaf segment."""
|
||||
path = _NodePathBuilder.from_string('wf@1/node')
|
||||
assert path.run_id is None
|
||||
|
||||
|
||||
def test_parent_returns_prefix_path():
|
||||
"""parent returns a new path with the last segment removed."""
|
||||
path = _NodePathBuilder.from_string('wf@1/node@2')
|
||||
parent = path.parent
|
||||
assert parent is not None
|
||||
assert str(parent) == 'wf@1'
|
||||
|
||||
|
||||
def test_parent_returns_none_for_root_path():
|
||||
"""parent returns None for a path with a single segment."""
|
||||
path = _NodePathBuilder.from_string('wf@1')
|
||||
assert path.parent is None
|
||||
|
||||
|
||||
def test_append_adds_segment_with_id():
|
||||
"""append adds a new segment with the specified name and run ID."""
|
||||
path = _NodePathBuilder.from_string('wf@1')
|
||||
child = path.append('node', '2')
|
||||
assert str(child) == 'wf@1/node@2'
|
||||
|
||||
|
||||
def test_append_adds_segment_without_id():
|
||||
"""append adds a new segment without run ID if not provided."""
|
||||
path = _NodePathBuilder.from_string('wf@1')
|
||||
child = path.append('node')
|
||||
assert str(child) == 'wf@1/node'
|
||||
|
||||
|
||||
def test_is_descendant_of_returns_true_for_deep_child():
|
||||
"""is_descendant_of returns True if the path starts with the ancestor segments."""
|
||||
ancestor = _NodePathBuilder.from_string('wf@1')
|
||||
descendant = _NodePathBuilder.from_string('wf@1/node@2')
|
||||
assert descendant.is_descendant_of(ancestor)
|
||||
|
||||
|
||||
def test_is_descendant_of_returns_false_for_unrelated_path():
|
||||
"""is_descendant_of returns False if the path does not start with ancestor segments."""
|
||||
ancestor = _NodePathBuilder.from_string('wf@1')
|
||||
other = _NodePathBuilder.from_string('other@1/node@2')
|
||||
assert not other.is_descendant_of(ancestor)
|
||||
|
||||
|
||||
def test_is_direct_child_of_returns_true_for_direct_child():
|
||||
"""is_direct_child_of returns True if the path is exactly one segment longer than parent."""
|
||||
parent = _NodePathBuilder.from_string('wf@1')
|
||||
child = _NodePathBuilder.from_string('wf@1/node@2')
|
||||
assert child.is_direct_child_of(parent)
|
||||
|
||||
|
||||
def test_is_direct_child_of_returns_false_for_grandchild():
|
||||
"""is_direct_child_of returns False if the path is more than one segment longer."""
|
||||
parent = _NodePathBuilder.from_string('wf@1')
|
||||
descendant = _NodePathBuilder.from_string('wf@1/inner@1/node@2')
|
||||
assert not descendant.is_direct_child_of(parent)
|
||||
|
||||
|
||||
def test_get_direct_child_returns_path_object():
|
||||
"""get_direct_child returns a new path object for the direct child."""
|
||||
parent = _NodePathBuilder.from_string('wf@1')
|
||||
descendant = _NodePathBuilder.from_string('wf@1/inner@1/node@2')
|
||||
child = parent.get_direct_child(descendant)
|
||||
assert isinstance(child, _NodePathBuilder)
|
||||
assert str(child) == 'wf@1/inner@1'
|
||||
|
||||
|
||||
def test_get_direct_child_raises_value_error_for_unrelated_path():
|
||||
"""get_direct_child raises ValueError if descendant does not start with self."""
|
||||
parent = _NodePathBuilder.from_string('wf@1')
|
||||
other = _NodePathBuilder.from_string('other@1/node@2')
|
||||
with pytest.raises(ValueError):
|
||||
parent.get_direct_child(other)
|
||||
Reference in New Issue
Block a user