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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
+13
View File
@@ -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.
+218
View File
@@ -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"}
+447
View File
@@ -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)