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,381 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for Graph parser utility."""
|
||||
|
||||
from google.adk.workflow import Edge
|
||||
from google.adk.workflow import FunctionNode
|
||||
from google.adk.workflow import START
|
||||
from google.adk.workflow._graph import DEFAULT_ROUTE
|
||||
from google.adk.workflow.utils._graph_parser import parse_edge_items
|
||||
import pytest
|
||||
|
||||
from ..workflow_testing_utils import TestingNode
|
||||
|
||||
|
||||
def test_parse_edge_items_with_node_reuse() -> None:
|
||||
"""Tests that node reuse during parsing works and deduplicates wrapped nodes."""
|
||||
|
||||
def my_node_func() -> None:
|
||||
pass
|
||||
|
||||
node_b = TestingNode(name='NodeB')
|
||||
edges = parse_edge_items([
|
||||
(START, my_node_func),
|
||||
(my_node_func, node_b),
|
||||
])
|
||||
|
||||
assert len(edges) == 2
|
||||
edge1, edge2 = edges
|
||||
assert edge1.from_node == START
|
||||
assert isinstance(edge1.to_node, FunctionNode)
|
||||
assert edge1.to_node.name == 'my_node_func'
|
||||
|
||||
assert isinstance(edge2.from_node, FunctionNode)
|
||||
assert edge2.from_node.name == 'my_node_func'
|
||||
assert edge2.to_node == node_b
|
||||
|
||||
# Verify exact same object instance was reused
|
||||
assert edge1.to_node is edge2.from_node
|
||||
|
||||
|
||||
def test_routing_map_basic() -> None:
|
||||
"""Tests that a string-keyed routing map expands to correct edges."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {'route_b': node_b, 'route_c': node_c}),
|
||||
])
|
||||
|
||||
assert len(edges) == 3 # START->A, A->B(route_b), A->C(route_c)
|
||||
|
||||
routed_edges = [e for e in edges if e.route is not None]
|
||||
assert len(routed_edges) == 2
|
||||
|
||||
routes_and_targets = {(e.route, e.to_node.name) for e in routed_edges}
|
||||
assert routes_and_targets == {('route_b', 'NodeB'), ('route_c', 'NodeC')}
|
||||
|
||||
for e in routed_edges:
|
||||
assert e.from_node.name == 'NodeA'
|
||||
|
||||
|
||||
def test_routing_map_int_keys() -> None:
|
||||
"""Tests that integer route keys work in routing maps."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {1: node_b, 2: node_c}),
|
||||
])
|
||||
|
||||
routed_edges = [e for e in edges if e.route is not None]
|
||||
assert len(routed_edges) == 2
|
||||
routes = [e.route for e in routed_edges]
|
||||
assert 1 in routes
|
||||
assert 2 in routes
|
||||
|
||||
|
||||
def test_routing_map_bool_keys() -> None:
|
||||
"""Tests that boolean route keys work in routing maps."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {True: node_b, False: node_c}),
|
||||
])
|
||||
|
||||
routed_edges = [e for e in edges if e.route is not None]
|
||||
assert len(routed_edges) == 2
|
||||
routes = [e.route for e in routed_edges]
|
||||
assert True in routes
|
||||
assert False in routes
|
||||
|
||||
|
||||
def test_routing_map_with_fan_in_source() -> None:
|
||||
"""Tests that fan-in on the source side works with routing maps."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
node_d = TestingNode(name='NodeD')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a),
|
||||
(START, node_b),
|
||||
((node_a, node_b), {'route_x': node_c, 'route_y': node_d}),
|
||||
])
|
||||
|
||||
# 2 from START + 4 from fan-in (A->C, A->D, B->C, B->D)
|
||||
assert len(edges) == 6
|
||||
|
||||
fan_in_edges = [e for e in edges if e.from_node.name in ('NodeA', 'NodeB')]
|
||||
assert len(fan_in_edges) == 4
|
||||
|
||||
combos = {(e.from_node.name, e.to_node.name, e.route) for e in fan_in_edges}
|
||||
assert combos == {
|
||||
('NodeA', 'NodeC', 'route_x'),
|
||||
('NodeA', 'NodeD', 'route_y'),
|
||||
('NodeB', 'NodeC', 'route_x'),
|
||||
('NodeB', 'NodeD', 'route_y'),
|
||||
}
|
||||
|
||||
|
||||
def test_routing_map_with_callable_target() -> None:
|
||||
"""Tests that callable values in routing maps get wrapped via build_node."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
|
||||
def my_target_func() -> None:
|
||||
pass
|
||||
|
||||
edges = parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {'route_x': my_target_func}),
|
||||
])
|
||||
|
||||
target_edge = next(e for e in edges if e.route == 'route_x')
|
||||
assert isinstance(target_edge.to_node, FunctionNode)
|
||||
assert target_edge.to_node.name == 'my_target_func'
|
||||
|
||||
|
||||
def test_routing_map_node_reuse() -> None:
|
||||
"""Tests that the same callable used in a map and elsewhere is deduplicated."""
|
||||
|
||||
def my_func() -> None:
|
||||
pass
|
||||
|
||||
node_b = TestingNode(name='NodeB')
|
||||
edges = parse_edge_items([
|
||||
(START, my_func),
|
||||
(my_func, {'route_x': node_b}),
|
||||
])
|
||||
|
||||
# my_func should be wrapped once and reused.
|
||||
assert len(edges) == 2
|
||||
assert edges[0].to_node is edges[1].from_node
|
||||
assert isinstance(edges[0].to_node, FunctionNode)
|
||||
|
||||
|
||||
def test_routing_map_empty_dict_raises() -> None:
|
||||
"""Tests that an empty routing map raises ValueError."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Routing map must not be empty',
|
||||
):
|
||||
parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {}),
|
||||
])
|
||||
|
||||
|
||||
def test_routing_map_invalid_key_raises() -> None:
|
||||
"""Tests that a non-RouteValue key raises ValueError."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Invalid routing map key',
|
||||
):
|
||||
parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {1.5: node_b}),
|
||||
])
|
||||
|
||||
|
||||
def test_routing_map_invalid_value_raises() -> None:
|
||||
"""Tests that a non-NodeLike value raises ValueError."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Invalid routing map value',
|
||||
):
|
||||
parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {'route_x': 42}),
|
||||
])
|
||||
|
||||
|
||||
def test_routing_map_fan_out_target() -> None:
|
||||
"""Tests that a tuple value in a routing map creates fan-out edges."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {'route_x': (node_b, node_c)}),
|
||||
])
|
||||
|
||||
# START->A, A->B(route_x), A->C(route_x)
|
||||
assert len(edges) == 3
|
||||
|
||||
routed_edges = [e for e in edges if e.route is not None]
|
||||
assert len(routed_edges) == 2
|
||||
|
||||
# Both fan-out edges share the same route and source.
|
||||
for e in routed_edges:
|
||||
assert e.from_node.name == 'NodeA'
|
||||
assert e.route == 'route_x'
|
||||
|
||||
targets = {e.to_node.name for e in routed_edges}
|
||||
assert targets == {'NodeB', 'NodeC'}
|
||||
|
||||
|
||||
def test_routing_map_fan_out_invalid_element_raises() -> None:
|
||||
"""Tests that a non-NodeLike element inside a fan-out tuple raises."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Invalid node in fan-out tuple',
|
||||
):
|
||||
parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {'route_x': (node_b, 42)}),
|
||||
])
|
||||
|
||||
|
||||
# --- Routing map as chain element tests ---
|
||||
|
||||
|
||||
def test_routing_map_chain_ending_with_dict() -> None:
|
||||
"""Tests a chain ending with a routing map creates correct edges."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a, {'r1': node_b, 'r2': node_c}),
|
||||
])
|
||||
|
||||
# START->A (None), A->B (r1), A->C (r2)
|
||||
assert len(edges) == 3
|
||||
|
||||
start_edge = next(e for e in edges if e.from_node.name == '__START__')
|
||||
assert start_edge.to_node.name == 'NodeA'
|
||||
assert start_edge.route is None
|
||||
|
||||
routed_edges = [e for e in edges if e.route is not None]
|
||||
assert len(routed_edges) == 2
|
||||
routes_and_targets = {(e.route, e.to_node.name) for e in routed_edges}
|
||||
assert routes_and_targets == {('r1', 'NodeB'), ('r2', 'NodeC')}
|
||||
for e in routed_edges:
|
||||
assert e.from_node.name == 'NodeA'
|
||||
|
||||
|
||||
def test_routing_map_mid_chain_with_fan_in() -> None:
|
||||
"""Tests routing map mid-chain with fan-in to the next element."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
node_d = TestingNode(name='NodeD')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a, {'r1': node_b, 'r2': node_c}, node_d),
|
||||
])
|
||||
|
||||
# START->A (None), A->B (r1), A->C (r2), B->D (None), C->D (None)
|
||||
assert len(edges) == 5
|
||||
|
||||
routed_edges = sorted(
|
||||
[e for e in edges if e.route is not None],
|
||||
key=lambda e: e.to_node.name,
|
||||
)
|
||||
assert len(routed_edges) == 2
|
||||
assert routed_edges[0].from_node.name == 'NodeA'
|
||||
assert routed_edges[0].to_node.name == 'NodeB'
|
||||
assert routed_edges[0].route == 'r1'
|
||||
assert routed_edges[1].from_node.name == 'NodeA'
|
||||
assert routed_edges[1].to_node.name == 'NodeC'
|
||||
assert routed_edges[1].route == 'r2'
|
||||
|
||||
fan_in_edges = sorted(
|
||||
[e for e in edges if e.to_node.name == 'NodeD'],
|
||||
key=lambda e: e.from_node.name,
|
||||
)
|
||||
assert len(fan_in_edges) == 2
|
||||
assert fan_in_edges[0].from_node.name == 'NodeB'
|
||||
assert fan_in_edges[0].route is None
|
||||
assert fan_in_edges[1].from_node.name == 'NodeC'
|
||||
assert fan_in_edges[1].route is None
|
||||
|
||||
|
||||
def test_routing_map_mid_chain_fan_out_values() -> None:
|
||||
"""Tests routing map with fan-out tuple values, followed by fan-in."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
node_d = TestingNode(name='NodeD')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a, {'r1': (node_b, node_c)}, node_d),
|
||||
])
|
||||
|
||||
# START->A (None), A->B (r1), A->C (r1), B->D (None), C->D (None)
|
||||
assert len(edges) == 5
|
||||
|
||||
routed_edges = [e for e in edges if e.route is not None]
|
||||
assert len(routed_edges) == 2
|
||||
for e in routed_edges:
|
||||
assert e.from_node.name == 'NodeA'
|
||||
assert e.route == 'r1'
|
||||
|
||||
fan_in_edges = [e for e in edges if e.to_node.name == 'NodeD']
|
||||
assert len(fan_in_edges) == 2
|
||||
fan_in_sources = {e.from_node.name for e in fan_in_edges}
|
||||
assert fan_in_sources == {'NodeB', 'NodeC'}
|
||||
|
||||
|
||||
def test_routing_map_consecutive_dicts_raises() -> None:
|
||||
"""Tests that consecutive routing maps in a chain are rejected."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
node_d = TestingNode(name='NodeD')
|
||||
with pytest.raises(
|
||||
ValueError, match=r'Consecutive routing maps are not allowed'
|
||||
):
|
||||
parse_edge_items([
|
||||
(START, node_a, {'r1': node_b, 'r2': node_c}, {'r3': node_d}),
|
||||
])
|
||||
|
||||
|
||||
def test_routing_map_empty_dict_in_chain_raises() -> None:
|
||||
"""Tests that an empty routing map in a chain raises ValueError."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
with pytest.raises(ValueError, match=r'Routing map must not be empty'):
|
||||
parse_edge_items([
|
||||
(START, node_a, {}, node_b),
|
||||
])
|
||||
|
||||
|
||||
def test_routing_map_invalid_key_in_chain_raises() -> None:
|
||||
"""Tests that invalid routing map keys in a chain raise ValueError."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
with pytest.raises(ValueError, match=r'Invalid routing map key'):
|
||||
parse_edge_items([
|
||||
(START, node_a, {1.5: node_b}),
|
||||
])
|
||||
|
||||
|
||||
def test_routing_map_2_tuple_backward_compat() -> None:
|
||||
"""Ensures existing 2-tuple routing map syntax still works."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
edges = parse_edge_items([
|
||||
(START, node_a),
|
||||
(node_a, {'r1': node_b, 'r2': node_c}),
|
||||
])
|
||||
assert len(edges) == 3
|
||||
@@ -0,0 +1,390 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for Graph validation utility."""
|
||||
|
||||
import logging
|
||||
|
||||
from google.adk.workflow import Edge
|
||||
from google.adk.workflow import START
|
||||
from google.adk.workflow._graph import DEFAULT_ROUTE
|
||||
from google.adk.workflow._graph import Graph
|
||||
from google.adk.workflow.utils._graph_validation import validate_graph
|
||||
from pydantic import BaseModel
|
||||
import pytest
|
||||
|
||||
from ..workflow_testing_utils import TestingNode
|
||||
|
||||
|
||||
def test_missing_start_node() -> None:
|
||||
"""Tests that a graph missing the START node fails validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
graph = Graph(
|
||||
edges=[Edge(from_node=node_a, to_node=node_b)],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r"Graph validation failed\. START node \(name: '__START__'\) not"
|
||||
r' found in graph nodes\.'
|
||||
),
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_unreachable_node() -> None:
|
||||
"""Tests that a graph with an unreachable node fails validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB') # Unreachable
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_b, to_node=node_a),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r'Graph validation failed\. The following nodes are unreachable'
|
||||
r" from START: \['NodeB'\]"
|
||||
),
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_disconnected_routed_subgraph_is_unreachable() -> None:
|
||||
"""Tests that a disconnected subgraph with routed edges fails validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_b, to_node=node_c, route='x'),
|
||||
Edge(from_node=node_c, to_node=node_b, route='y'),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r'Graph validation failed\. The following nodes are unreachable'
|
||||
r" from START: \['NodeB', 'NodeC'\]"
|
||||
),
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'routes',
|
||||
[
|
||||
(None, None),
|
||||
('route1', 'route1'),
|
||||
('route1', 'route2'),
|
||||
('route1', None),
|
||||
],
|
||||
)
|
||||
def test_duplicate_edges_fail_validation(
|
||||
routes: tuple[str | None, str | None],
|
||||
) -> None:
|
||||
"""Tests that duplicate edges fail validation, regardless of routes."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(
|
||||
from_node=node_a,
|
||||
to_node=node_b,
|
||||
route=routes[0],
|
||||
),
|
||||
Edge(
|
||||
from_node=node_a,
|
||||
to_node=node_b,
|
||||
route=routes[1],
|
||||
),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r'Graph validation failed\. Duplicate edge found: from=NodeA,'
|
||||
r' to=NodeB'
|
||||
),
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_routed_start_edge_fails_validation() -> None:
|
||||
"""Tests that routed edges from START node fail validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a, route='route1'),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Graph validation failed\. Edges from START must not have routes',
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_start_node_with_incoming_edge() -> None:
|
||||
"""Tests graph with incoming edge to START node fails validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=node_a, to_node=START),
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r'Graph validation failed\. START node must not have incoming edges\.'
|
||||
),
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_multiple_default_routes_fail_validation() -> None:
|
||||
"""Tests that multiple DEFAULT_ROUTE edges from a node fail validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b, route=DEFAULT_ROUTE),
|
||||
Edge(from_node=node_a, to_node=node_c, route=DEFAULT_ROUTE),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r'Graph validation failed\. Multiple DEFAULT_ROUTE edges found from'
|
||||
r' node NodeA to NodeB and NodeC'
|
||||
),
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_single_default_route_passes_validation() -> None:
|
||||
"""Tests that a single DEFAULT_ROUTE edge from a node passes validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b, route=DEFAULT_ROUTE),
|
||||
Edge(from_node=node_a, to_node=node_c, route='another_route'),
|
||||
],
|
||||
)
|
||||
validate_graph(graph.nodes, graph.edges) # Should not raise
|
||||
|
||||
|
||||
def test_duplicate_node_names_fail_validation() -> None:
|
||||
"""Tests that duplicate nodes raise error."""
|
||||
node_a1 = TestingNode(name='NodeA')
|
||||
node_a2 = TestingNode(name='NodeA')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a1),
|
||||
Edge(from_node=node_a1, to_node=node_a2),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r"Graph validation failed\. Duplicate node names found: \['NodeA'\]\."
|
||||
r' This means multiple distinct node objects have the same name\. If'
|
||||
r' you intended to reuse the same node, ensure you pass the exact'
|
||||
r' same object instance\. If you intended to have distinct nodes,'
|
||||
r' ensure they have unique names\.'
|
||||
),
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_unconditional_cycle_fails_validation() -> None:
|
||||
"""Tests that a cycle of unconditional edges (route=None) fails."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b),
|
||||
Edge(from_node=node_b, to_node=node_a),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Graph validation failed\. Unconditional cycle detected:',
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_unconditional_self_loop_fails_validation() -> None:
|
||||
"""Tests that an unconditional self-loop (A -> A) fails."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_a),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Graph validation failed\. Unconditional cycle detected:',
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_longer_unconditional_cycle_fails_validation() -> None:
|
||||
"""Tests that a longer unconditional cycle (A -> B -> C -> A) fails."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b),
|
||||
Edge(from_node=node_b, to_node=node_c),
|
||||
Edge(from_node=node_c, to_node=node_a),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Graph validation failed\. Unconditional cycle detected:',
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_conditional_cycle_passes_validation() -> None:
|
||||
"""Tests that a cycle with a routed edge (loop pattern) passes."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b),
|
||||
Edge(from_node=node_b, to_node=node_a, route='retry'),
|
||||
],
|
||||
)
|
||||
validate_graph(
|
||||
graph.nodes, graph.edges
|
||||
) # Should not raise — routed back-edge
|
||||
|
||||
|
||||
def test_conditional_self_loop_passes_validation() -> None:
|
||||
"""Tests that a self-loop with a route passes validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_a, route='continue'),
|
||||
Edge(from_node=node_a, to_node=node_b, route='done'),
|
||||
],
|
||||
)
|
||||
validate_graph(
|
||||
graph.nodes, graph.edges
|
||||
) # Should not raise — routed self-loop
|
||||
|
||||
|
||||
def test_dag_with_diamond_passes_validation() -> None:
|
||||
"""Tests that a DAG with a diamond shape passes validation."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=START, to_node=node_b),
|
||||
Edge(from_node=node_a, to_node=node_c),
|
||||
Edge(from_node=node_b, to_node=node_c),
|
||||
],
|
||||
)
|
||||
validate_graph(graph.nodes, graph.edges) # Should not raise
|
||||
|
||||
|
||||
class ModelA(BaseModel):
|
||||
x: int
|
||||
|
||||
|
||||
class ModelB(BaseModel):
|
||||
x: int
|
||||
|
||||
|
||||
def test_schema_match_passes() -> None:
|
||||
"""Tests that edges with matching schemas pass validation."""
|
||||
node_a = TestingNode(name='NodeA', output_schema=ModelA)
|
||||
node_b = TestingNode(name='NodeB', input_schema=ModelA)
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b),
|
||||
],
|
||||
)
|
||||
validate_graph(graph.nodes, graph.edges) # Should not raise
|
||||
|
||||
|
||||
def test_schema_mismatch_raises() -> None:
|
||||
"""Tests that edges with mismatching schemas fail validation."""
|
||||
node_a = TestingNode(name='NodeA', output_schema=ModelA)
|
||||
node_b = TestingNode(name='NodeB', input_schema=ModelB)
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b),
|
||||
],
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Graph validation failed\. Schema mismatch on edge',
|
||||
):
|
||||
validate_graph(graph.nodes, graph.edges)
|
||||
|
||||
|
||||
def test_schema_missing_passes() -> None:
|
||||
"""Tests that edges with missing schemas pass validation."""
|
||||
node_a = TestingNode(name='NodeA', output_schema=ModelA)
|
||||
node_b = TestingNode(name='NodeB') # No input schema
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b),
|
||||
],
|
||||
)
|
||||
validate_graph(graph.nodes, graph.edges) # Should not raise
|
||||
|
||||
|
||||
def test_chat_agent_wiring_validation_only_runs_on_llm_agent() -> None:
|
||||
"""Tests that _validate_chat_agent_wiring checks non-LlmAgent nodes safely."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
# Set mode='chat' on a non-LlmAgent node
|
||||
object.__setattr__(node_b, 'mode', 'chat')
|
||||
|
||||
graph = Graph(
|
||||
edges=[
|
||||
Edge(from_node=START, to_node=node_a),
|
||||
Edge(from_node=node_a, to_node=node_b),
|
||||
],
|
||||
)
|
||||
validate_graph(
|
||||
graph.nodes, graph.edges
|
||||
) # Should not raise because node_b is a TestingNode, not LlmAgent
|
||||
@@ -0,0 +1,389 @@
|
||||
# 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.event import Event
|
||||
from google.adk.events.event import NodeInfo
|
||||
from google.adk.events.request_input import RequestInput
|
||||
from google.adk.workflow._base_node import BaseNode
|
||||
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
|
||||
from google.adk.workflow.utils._rehydration_utils import _process_rehydrated_output
|
||||
from google.adk.workflow.utils._rehydration_utils import _reconstruct_node_states
|
||||
from google.adk.workflow.utils._rehydration_utils import _unwrap_response
|
||||
from google.adk.workflow.utils._rehydration_utils import _validate_resume_response
|
||||
from google.adk.workflow.utils._rehydration_utils import _wrap_response
|
||||
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_event
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel
|
||||
import pytest
|
||||
|
||||
# --- _wrap_response ---
|
||||
|
||||
|
||||
class TestWrapResponse:
|
||||
|
||||
def test_dict_returned_as_is(self):
|
||||
d = {"foo": "bar"}
|
||||
assert _wrap_response(d) is d
|
||||
|
||||
def test_string_wrapped(self):
|
||||
assert _wrap_response("hello") == {"result": "hello"}
|
||||
|
||||
def test_int_wrapped(self):
|
||||
assert _wrap_response(42) == {"result": 42}
|
||||
|
||||
def test_none_wrapped(self):
|
||||
assert _wrap_response(None) == {"result": None}
|
||||
|
||||
def test_list_wrapped(self):
|
||||
assert _wrap_response([1, 2]) == {"result": [1, 2]}
|
||||
|
||||
|
||||
# --- _unwrap_response ---
|
||||
|
||||
|
||||
class TestUnwrapResponse:
|
||||
|
||||
def test_single_result_key_string(self):
|
||||
assert _unwrap_response({"result": "hello"}) == "hello"
|
||||
|
||||
def test_single_result_key_int(self):
|
||||
assert _unwrap_response({"result": 42}) == 42
|
||||
|
||||
def test_single_result_key_none(self):
|
||||
assert _unwrap_response({"result": None}) is None
|
||||
|
||||
def test_dict_without_result_key_unchanged(self):
|
||||
d = {"foo": "bar"}
|
||||
assert _unwrap_response(d) == {"foo": "bar"}
|
||||
|
||||
def test_dict_with_multiple_keys_unchanged(self):
|
||||
d = {"result": "x", "other": "y"}
|
||||
assert _unwrap_response(d) == {"result": "x", "other": "y"}
|
||||
|
||||
def test_non_dict_unchanged(self):
|
||||
assert _unwrap_response("hello") == "hello"
|
||||
assert _unwrap_response(42) == 42
|
||||
assert _unwrap_response(None) is None
|
||||
|
||||
def test_json_string_parsed_to_dict(self):
|
||||
"""Web frontend sends {"result": '{"approved": false}'}."""
|
||||
assert _unwrap_response({"result": '{"approved": false}'}) == {
|
||||
"approved": False
|
||||
}
|
||||
|
||||
def test_json_string_parsed_to_list(self):
|
||||
assert _unwrap_response({"result": "[1, 2, 3]"}) == [1, 2, 3]
|
||||
|
||||
def test_json_string_parsed_to_number(self):
|
||||
assert _unwrap_response({"result": "42"}) == 42
|
||||
|
||||
def test_json_string_parsed_to_bool(self):
|
||||
assert _unwrap_response({"result": "true"}) is True
|
||||
|
||||
def test_non_json_string_stays_string(self):
|
||||
assert _unwrap_response({"result": "plain text"}) == "plain text"
|
||||
|
||||
def test_roundtrip_wrap_unwrap_string(self):
|
||||
assert _unwrap_response(_wrap_response("hello")) == "hello"
|
||||
|
||||
def test_roundtrip_wrap_unwrap_dict(self):
|
||||
"""Dicts are not wrapped, so unwrap is a no-op."""
|
||||
d = {"foo": "bar"}
|
||||
assert _unwrap_response(_wrap_response(d)) == d
|
||||
|
||||
|
||||
# --- _process_rehydrated_output ---
|
||||
|
||||
|
||||
class TestProcessRehydratedOutput:
|
||||
|
||||
def test_extracts_plain_text_without_schema(self):
|
||||
node = BaseNode(name="dummy")
|
||||
content = types.Content(parts=[types.Part(text="hello world")])
|
||||
assert _process_rehydrated_output(node, content) == "hello world"
|
||||
|
||||
def test_returns_plain_text_even_if_json_when_no_schema(self):
|
||||
node = BaseNode(name="dummy")
|
||||
content = types.Content(parts=[types.Part(text='{"foo": "bar"}')])
|
||||
assert _process_rehydrated_output(node, content) == '{"foo": "bar"}'
|
||||
|
||||
def test_parses_json_text_with_output_schema(self):
|
||||
class MySchema(BaseModel):
|
||||
foo: str
|
||||
|
||||
node = BaseNode(name="dummy", output_schema=MySchema)
|
||||
content = types.Content(parts=[types.Part(text='{"foo": "bar"}')])
|
||||
assert _process_rehydrated_output(node, content) == {"foo": "bar"}
|
||||
|
||||
def test_joins_multiple_parts(self):
|
||||
node = BaseNode(name="dummy")
|
||||
content = types.Content(
|
||||
parts=[types.Part(text="hello "), types.Part(text="world")]
|
||||
)
|
||||
assert _process_rehydrated_output(node, content) == "hello world"
|
||||
|
||||
def test_filters_thought_parts(self):
|
||||
class MySchema(BaseModel):
|
||||
answer: int
|
||||
|
||||
node = BaseNode(name="dummy", output_schema=MySchema)
|
||||
content = types.Content(
|
||||
parts=[
|
||||
types.Part(text="thinking...", thought=True),
|
||||
types.Part(text='{"answer": 42}'),
|
||||
]
|
||||
)
|
||||
assert _process_rehydrated_output(node, content) == {"answer": 42}
|
||||
|
||||
def test_returns_none_for_empty_text(self):
|
||||
node = BaseNode(name="dummy")
|
||||
content = types.Content(parts=[types.Part(text=" ")])
|
||||
assert _process_rehydrated_output(node, content) is None
|
||||
|
||||
def test_gracefully_falls_back_on_schema_mismatch(self, caplog):
|
||||
class MySchema(BaseModel):
|
||||
foo: str
|
||||
bar: int # Required field that is missing in the stored output
|
||||
|
||||
node = BaseNode(name="dummy", output_schema=MySchema)
|
||||
content = types.Content(parts=[types.Part(text='{"foo": "only"}')])
|
||||
|
||||
# Should NOT raise ValueError, but fallback to unvalidated parsed dict
|
||||
res = _process_rehydrated_output(node, content)
|
||||
assert res == {"foo": "only"}
|
||||
assert (
|
||||
"Validation failed for rehydrated output against schema" in caplog.text
|
||||
)
|
||||
|
||||
def test_raises_value_error_if_not_valid_json_on_schema_mismatch(self):
|
||||
class MySchema(BaseModel):
|
||||
foo: str
|
||||
|
||||
node = BaseNode(name="dummy", output_schema=MySchema)
|
||||
content = types.Content(parts=[types.Part(text="invalid json")])
|
||||
|
||||
# Should raise ValueError because it's not valid JSON
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Validation failed for rehydrated output against schema",
|
||||
):
|
||||
_process_rehydrated_output(node, content)
|
||||
|
||||
|
||||
# --- _validate_resume_response ---
|
||||
|
||||
|
||||
class TestValidateResumeResponse:
|
||||
|
||||
def test_none_schema_returns_data(self):
|
||||
assert _validate_resume_response("hello", None) == "hello"
|
||||
|
||||
def test_str_to_int_coercion(self):
|
||||
assert _validate_resume_response("42", {"type": "integer"}) == 42
|
||||
|
||||
def test_str_to_float_coercion(self):
|
||||
assert _validate_resume_response("42.5", {"type": "number"}) == 42.5
|
||||
|
||||
def test_str_to_bool_true(self):
|
||||
assert _validate_resume_response("true", {"type": "boolean"}) is True
|
||||
assert _validate_resume_response("1", {"type": "boolean"}) is True
|
||||
|
||||
def test_str_to_bool_false(self):
|
||||
assert _validate_resume_response("false", {"type": "boolean"}) is False
|
||||
assert _validate_resume_response("0", {"type": "boolean"}) is False
|
||||
|
||||
def test_invalid_coercion_raises_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_resume_response("abc", {"type": "integer"})
|
||||
|
||||
def test_object_schema_validates_dict_type(self):
|
||||
schema = {"type": "object"}
|
||||
assert _validate_resume_response({"name": "Alice"}, schema) == {
|
||||
"name": "Alice"
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Failed to coerce data to object"):
|
||||
_validate_resume_response("not a dict", schema)
|
||||
|
||||
def test_array_schema_validates_list_type(self):
|
||||
schema = {"type": "array"}
|
||||
assert _validate_resume_response([1, 2], schema) == [1, 2]
|
||||
|
||||
with pytest.raises(ValueError, match="Failed to coerce data to array"):
|
||||
_validate_resume_response("not a list", schema)
|
||||
|
||||
def test_pydantic_type_validation(self):
|
||||
class User(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
assert _validate_resume_response(
|
||||
{"name": "Alice", "age": 30}, User
|
||||
) == User(name="Alice", age=30)
|
||||
|
||||
|
||||
# --- _reconstruct_node_states ---
|
||||
|
||||
|
||||
class TestScanNodeEvents:
|
||||
|
||||
def test_scan_empty_events(self):
|
||||
results = _reconstruct_node_states([], "/wf@1", invocation_id="test_id")
|
||||
assert results == {}
|
||||
|
||||
def test_scan_direct_child_output(self):
|
||||
event = Event(
|
||||
node_info=NodeInfo(path="/wf@1/node_a@1"),
|
||||
output="node_a output",
|
||||
invocation_id="test_id",
|
||||
)
|
||||
results = _reconstruct_node_states(
|
||||
[event], "/wf@1", invocation_id="test_id", group_by_direct_child=True
|
||||
)
|
||||
|
||||
assert "node_a@1" in results
|
||||
assert results["node_a@1"].output == "node_a output"
|
||||
assert results["node_a@1"].run_id == "1"
|
||||
|
||||
def test_scan_message_as_output(self):
|
||||
content = types.Content(parts=[types.Part(text="hello")])
|
||||
event = Event(
|
||||
node_info=NodeInfo(path="/wf@1/node_a@1"),
|
||||
content=content,
|
||||
invocation_id="test_id",
|
||||
)
|
||||
event.node_info.message_as_output = True
|
||||
|
||||
results = _reconstruct_node_states(
|
||||
[event], "/wf@1", invocation_id="test_id", group_by_direct_child=True
|
||||
)
|
||||
|
||||
assert "node_a@1" in results
|
||||
assert results["node_a@1"].output == content
|
||||
|
||||
def test_scan_descendant_interrupts(self):
|
||||
event = Event(
|
||||
node_info=NodeInfo(path="/wf@1/node_a@1/sub_node@1"),
|
||||
long_running_tool_ids={"interrupt-1"},
|
||||
invocation_id="test_id",
|
||||
)
|
||||
results = _reconstruct_node_states(
|
||||
[event], "/wf@1", invocation_id="test_id", group_by_direct_child=True
|
||||
)
|
||||
|
||||
assert "node_a@1" in results
|
||||
assert "interrupt-1" in results["node_a@1"].interrupt_ids
|
||||
|
||||
def test_scan_resolve_interrupts(self):
|
||||
event_int = Event(
|
||||
node_info=NodeInfo(path="/wf@1/node_a@1"),
|
||||
long_running_tool_ids={"interrupt-1"},
|
||||
invocation_id="test_id",
|
||||
)
|
||||
event_fr = Event(
|
||||
author="user",
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
function_response=types.FunctionResponse(
|
||||
id="interrupt-1",
|
||||
name="adk_request_input",
|
||||
response={"result": "user answer"},
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
invocation_id="test_id",
|
||||
)
|
||||
|
||||
# Act
|
||||
results = _reconstruct_node_states(
|
||||
[event_int, event_fr],
|
||||
"/wf@1",
|
||||
invocation_id="test_id",
|
||||
group_by_direct_child=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "node_a@1" in results
|
||||
assert "interrupt-1" in results["node_a@1"].resolved_ids
|
||||
assert (
|
||||
results["node_a@1"].resolved_responses["interrupt-1"] == "user answer"
|
||||
)
|
||||
|
||||
def test_scan_matches_specific_node_path_without_child_grouping(self):
|
||||
"""Scanning matches events for a specific node path when not grouping by direct child."""
|
||||
event = Event(
|
||||
node_info=NodeInfo(path="/wf@1/node_a@1"),
|
||||
output="node_a output",
|
||||
invocation_id="test_id",
|
||||
)
|
||||
|
||||
# Act
|
||||
results = _reconstruct_node_states(
|
||||
[event],
|
||||
"/wf@1/node_a@1",
|
||||
invocation_id="test_id",
|
||||
group_by_direct_child=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "/wf@1/node_a@1" in results
|
||||
assert results["/wf@1/node_a@1"].output == "node_a output"
|
||||
|
||||
def test_scan_validates_and_coerces_response_against_schema(self):
|
||||
"""Scanning validates and coerces user response data against the provided schema."""
|
||||
|
||||
class MySchema(BaseModel):
|
||||
count: int
|
||||
|
||||
ri = RequestInput(
|
||||
interrupt_id="interrupt-1",
|
||||
response_schema=MySchema,
|
||||
)
|
||||
event_int = create_request_input_event(ri)
|
||||
event_int.node_info = NodeInfo(path="/wf@1/node_a@1")
|
||||
event_int.invocation_id = "test_id"
|
||||
|
||||
event_fr = Event(
|
||||
author="user",
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
function_response=types.FunctionResponse(
|
||||
id="interrupt-1",
|
||||
name="adk_request_input",
|
||||
response={"result": '{"count": "42"}'},
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
invocation_id="test_id",
|
||||
)
|
||||
|
||||
# Act
|
||||
results = _reconstruct_node_states(
|
||||
[event_int, event_fr],
|
||||
"/wf@1",
|
||||
invocation_id="test_id",
|
||||
group_by_direct_child=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "node_a@1" in results
|
||||
assert results["node_a@1"].resolved_responses["interrupt-1"] == {
|
||||
"count": 42
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for ReplayInterceptor.
|
||||
|
||||
Verifies that ReplayInterceptor correctly checks and manages workflow resumption
|
||||
replay interception.
|
||||
"""
|
||||
|
||||
from google.adk.workflow._base_node import BaseNode
|
||||
from google.adk.workflow._dynamic_node_scheduler import DynamicNodeRun
|
||||
from google.adk.workflow._node_state import NodeState
|
||||
from google.adk.workflow._node_status import NodeStatus
|
||||
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
|
||||
from google.adk.workflow.utils._replay_interceptor import check_interception
|
||||
import pytest
|
||||
|
||||
|
||||
def test_same_turn_completed():
|
||||
"""Same-turn completed run intercepts and returns cached output."""
|
||||
# Given a same-turn completed run
|
||||
run = DynamicNodeRun(
|
||||
state=NodeState(status=NodeStatus.COMPLETED),
|
||||
output='cached-out',
|
||||
transfer_to_agent='target-agent',
|
||||
)
|
||||
|
||||
# When checked
|
||||
result = check_interception(
|
||||
node=BaseNode(name='node'),
|
||||
current_run=run,
|
||||
)
|
||||
|
||||
# Then it intercepts with cached results
|
||||
assert not result.should_run
|
||||
assert result.output == 'cached-out'
|
||||
assert result.transfer_to_agent == 'target-agent'
|
||||
|
||||
|
||||
def test_same_turn_waiting():
|
||||
"""Same-turn waiting run intercepts and returns unresolved interrupts."""
|
||||
# Given a same-turn waiting run
|
||||
run = DynamicNodeRun(
|
||||
state=NodeState(status=NodeStatus.WAITING, interrupts=['fc-1']),
|
||||
)
|
||||
|
||||
# When checked
|
||||
result = check_interception(
|
||||
node=BaseNode(name='node'),
|
||||
current_run=run,
|
||||
)
|
||||
|
||||
# Then it intercepts and keeps waiting
|
||||
assert not result.should_run
|
||||
assert result.interrupts == {'fc-1'}
|
||||
|
||||
|
||||
def test_cross_turn_unresolved_interrupts_no_rerun():
|
||||
"""Cross-turn unresolved interrupts keep waiting without rerun."""
|
||||
# Given unresolved interrupts and node without rerun_on_resume
|
||||
recovered = _ChildScanState(
|
||||
run_id='1',
|
||||
interrupt_ids={'fc-1', 'fc-2'},
|
||||
resolved_ids={'fc-1'},
|
||||
)
|
||||
node = BaseNode(name='node', rerun_on_resume=False)
|
||||
|
||||
# When checked
|
||||
result = check_interception(
|
||||
node=node,
|
||||
recovered=recovered,
|
||||
)
|
||||
|
||||
# Then it stays waiting on unresolved interrupts
|
||||
assert not result.should_run
|
||||
assert result.interrupts == {'fc-2'}
|
||||
|
||||
|
||||
def test_cross_turn_unresolved_interrupts_rerun():
|
||||
"""Cross-turn unresolved interrupts with rerun resolves progress and reruns."""
|
||||
# Given unresolved interrupts and node with rerun_on_resume
|
||||
recovered = _ChildScanState(
|
||||
run_id='1',
|
||||
interrupt_ids={'fc-1', 'fc-2'},
|
||||
resolved_ids={'fc-1'},
|
||||
resolved_responses={'fc-1': 'ans'},
|
||||
)
|
||||
node = BaseNode(name='node', rerun_on_resume=True)
|
||||
|
||||
# When checked
|
||||
result = check_interception(
|
||||
node=node,
|
||||
recovered=recovered,
|
||||
)
|
||||
|
||||
# Then it reruns with partial resolved inputs
|
||||
assert result.should_run
|
||||
assert result.resume_inputs == {'fc-1': 'ans'}
|
||||
|
||||
|
||||
def test_cross_turn_completed():
|
||||
"""Cross-turn completed run fast-forwards output and route."""
|
||||
# Given a completed run from history
|
||||
recovered = _ChildScanState(
|
||||
run_id='1',
|
||||
output='past-out',
|
||||
route='route-a',
|
||||
)
|
||||
node = BaseNode(name='node')
|
||||
|
||||
# When checked
|
||||
result = check_interception(
|
||||
node=node,
|
||||
recovered=recovered,
|
||||
)
|
||||
|
||||
# Then it fast-forwards with cached output and route
|
||||
assert not result.should_run
|
||||
assert result.output == 'past-out'
|
||||
assert result.route == 'route-a'
|
||||
|
||||
|
||||
def test_cross_turn_all_resolved_no_rerun():
|
||||
"""Cross-turn all resolved run without rerun auto-completes with responses."""
|
||||
# Given all resolved interrupts and node without rerun_on_resume
|
||||
recovered = _ChildScanState(
|
||||
run_id='1',
|
||||
interrupt_ids={'fc-1'},
|
||||
resolved_ids={'fc-1'},
|
||||
resolved_responses={'fc-1': 'ans'},
|
||||
)
|
||||
node = BaseNode(name='node', rerun_on_resume=False)
|
||||
|
||||
# When checked
|
||||
result = check_interception(
|
||||
node=node,
|
||||
recovered=recovered,
|
||||
)
|
||||
|
||||
# Then it auto-completes
|
||||
assert not result.should_run
|
||||
assert result.output == 'ans'
|
||||
|
||||
|
||||
def test_cross_turn_all_resolved_rerun():
|
||||
"""Cross-turn all resolved run with rerun triggers rerun with responses."""
|
||||
# Given all resolved interrupts and node with rerun_on_resume
|
||||
recovered = _ChildScanState(
|
||||
run_id='1',
|
||||
interrupt_ids={'fc-1'},
|
||||
resolved_ids={'fc-1'},
|
||||
resolved_responses={'fc-1': 'ans'},
|
||||
)
|
||||
node = BaseNode(name='node', rerun_on_resume=True)
|
||||
|
||||
# When checked
|
||||
result = check_interception(
|
||||
node=node,
|
||||
recovered=recovered,
|
||||
)
|
||||
|
||||
# Then it reruns
|
||||
assert result.should_run
|
||||
assert result.resume_inputs == {'fc-1': 'ans'}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for ReplayManager utility."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.events.event import NodeInfo
|
||||
from google.adk.workflow.utils._replay_manager import ReplayManager
|
||||
import pytest
|
||||
|
||||
|
||||
def test_replay_manager_init() -> None:
|
||||
"""Tests that ReplayManager initializes with empty state."""
|
||||
mgr = ReplayManager()
|
||||
assert mgr.recovered_executions == {}
|
||||
assert mgr.sequence_barrier is None
|
||||
|
||||
|
||||
def _make_event(
|
||||
path='', output=None, interrupt_ids=None, invocation_id='inv-1'
|
||||
):
|
||||
"""Create a minimal Event for session event lists."""
|
||||
event = MagicMock(spec=Event)
|
||||
event.invocation_id = invocation_id
|
||||
event.author = 'node'
|
||||
event.output = output
|
||||
event.partial = False
|
||||
event.node_info = MagicMock(spec=NodeInfo)
|
||||
event.node_info.path = path
|
||||
event.node_info.output_for = None
|
||||
event.node_info.message_as_output = None
|
||||
event.branch = None
|
||||
event.isolation_scope = None
|
||||
event.long_running_tool_ids = set(interrupt_ids) if interrupt_ids else None
|
||||
event.content = None
|
||||
event.actions = None
|
||||
return event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_workflow_events():
|
||||
"""Scan workflow events populates recovered_executions and sequence_barrier."""
|
||||
mgr = ReplayManager()
|
||||
events = [
|
||||
_make_event(path='wf/child1@1', output='out1'),
|
||||
_make_event(path='wf/child2@1', output='out2'),
|
||||
]
|
||||
ctx = MagicMock()
|
||||
ctx._invocation_context = MagicMock()
|
||||
ctx._invocation_context.invocation_id = 'inv-1'
|
||||
ctx._invocation_context.session = MagicMock()
|
||||
ctx._invocation_context.session.events = events
|
||||
ctx.node_path = 'wf'
|
||||
|
||||
recovered, sequence = mgr.scan_workflow_events(ctx)
|
||||
|
||||
assert 'child1@1' in recovered
|
||||
assert 'child2@1' in recovered
|
||||
assert sequence == ['child1@1', 'child2@1']
|
||||
assert mgr.sequence_barrier is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_child_events_ignores_descendant_run_id_resets():
|
||||
"""scan_workflow_events only resets run_id from direct child events."""
|
||||
mgr = ReplayManager()
|
||||
|
||||
event1 = Event(
|
||||
author='node',
|
||||
node_info=NodeInfo(path='wf@1/child@1', run_id='1'),
|
||||
invocation_id='test_inv',
|
||||
)
|
||||
event2 = Event(
|
||||
author='node',
|
||||
node_info=NodeInfo(path='wf@1/child@1/grandchild@2', run_id='2'),
|
||||
invocation_id='test_inv',
|
||||
)
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx._invocation_context = MagicMock()
|
||||
ctx._invocation_context.invocation_id = 'test_inv'
|
||||
ctx._invocation_context.session = MagicMock()
|
||||
ctx._invocation_context.session.events = [event1, event2]
|
||||
ctx.node_path = 'wf@1'
|
||||
|
||||
children, _ = mgr.scan_workflow_events(ctx)
|
||||
|
||||
# Assert child 'child' run_id remains '1' (not '2' from the descendant).
|
||||
assert children['child@1'].run_id == '1'
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for ReplaySequenceBarrier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from google.adk.workflow.utils._replay_sequence_barrier import ReplaySequenceBarrier
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_barrier_initialization():
|
||||
"""Verifies that barrier initializes sequence index and sets the first event."""
|
||||
# Given a chronological sequence of completions
|
||||
sequence = ['NodeA@1', 'NodeB@1']
|
||||
|
||||
# When barrier is created
|
||||
barrier = ReplaySequenceBarrier(sequence)
|
||||
|
||||
# Then state is correctly set
|
||||
assert barrier.sequence == sequence
|
||||
assert barrier.current_index == 0
|
||||
assert len(barrier.events) == 2
|
||||
assert barrier.events['NodeA@1'].is_set()
|
||||
assert not barrier.events['NodeB@1'].is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_barrier_wait_blocks_and_unblocks():
|
||||
"""Verifies that wait blocks on subsequent keys and is unblocked by advance."""
|
||||
sequence = ['NodeA@1', 'NodeB@1']
|
||||
barrier = ReplaySequenceBarrier(sequence)
|
||||
|
||||
# When first key waits, it completes instantly
|
||||
await barrier.wait('NodeA@1')
|
||||
|
||||
# When second key waits, it blocks
|
||||
b_completed = False
|
||||
|
||||
async def wait_b():
|
||||
nonlocal b_completed
|
||||
await barrier.wait('NodeB@1')
|
||||
b_completed = True
|
||||
|
||||
task = asyncio.create_task(wait_b())
|
||||
await asyncio.sleep(0.05)
|
||||
assert not b_completed # Still blocked
|
||||
|
||||
# When first key advances the sequence
|
||||
barrier.check_and_advance('NodeA@1')
|
||||
|
||||
# Then index progresses and second event is released
|
||||
await task
|
||||
assert b_completed
|
||||
assert barrier.current_index == 1
|
||||
assert barrier.events['NodeB@1'].is_set()
|
||||
|
||||
|
||||
def test_barrier_advance_out_of_order_ignored():
|
||||
"""Verifies that out-of-order advance calls are ignored and do not progress index."""
|
||||
sequence = ['NodeA@1', 'NodeB@1']
|
||||
barrier = ReplaySequenceBarrier(sequence)
|
||||
|
||||
# When second key tries to advance out of order
|
||||
barrier.check_and_advance('NodeB@1')
|
||||
|
||||
# Then state remains unchanged
|
||||
assert barrier.current_index == 0
|
||||
assert not barrier.events['NodeB@1'].is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_barrier_wait_non_existent_key():
|
||||
"""Verifies that waiting on a key not in sequence does not block."""
|
||||
sequence = ['NodeA@1']
|
||||
barrier = ReplaySequenceBarrier(sequence)
|
||||
|
||||
# When a key not in sequence waits, it passes instantly
|
||||
await barrier.wait('NonExistent@1')
|
||||
|
||||
# No blocks, successfully completes!
|
||||
assert True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_barrier_wait_timeout_on_divergence():
|
||||
"""Verifies that waiting on a blocked key raises RuntimeError due to timeout."""
|
||||
# We use a short sequence where NodeB is never unblocked
|
||||
sequence = ['NodeA@1', 'NodeB@1']
|
||||
# Use a fast timeout to keep the test rapid without mocking standard library functions
|
||||
barrier = ReplaySequenceBarrier(sequence, timeout_sec=0.01)
|
||||
|
||||
with pytest.raises(RuntimeError, match='Replay divergence detected'):
|
||||
await barrier.wait('NodeB@1')
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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.workflow._node_state import NodeState
|
||||
from google.adk.workflow._retry_config import RetryConfig
|
||||
from google.adk.workflow.utils._retry_utils import _get_retry_delay
|
||||
import pytest
|
||||
|
||||
|
||||
class TestGetRetryDelay:
|
||||
|
||||
def test_returns_default_delay_without_config(self):
|
||||
"""Returns default delay of 1.0 second when config is missing."""
|
||||
state = NodeState(attempt_count=1)
|
||||
|
||||
result = _get_retry_delay(None, state)
|
||||
|
||||
assert result == 1.0
|
||||
|
||||
def test_returns_initial_delay_on_first_failure(self):
|
||||
"""Returns initial delay on the first failure attempt."""
|
||||
config = RetryConfig(initial_delay=2.0, jitter=0.0)
|
||||
state = NodeState(attempt_count=1)
|
||||
|
||||
result = _get_retry_delay(config, state)
|
||||
|
||||
assert result == 2.0
|
||||
|
||||
def test_applies_exponential_backoff(self):
|
||||
"""Applies exponential backoff for subsequent attempts."""
|
||||
config = RetryConfig(initial_delay=2.0, backoff_factor=2.0, jitter=0.0)
|
||||
state = NodeState(attempt_count=2)
|
||||
|
||||
result = _get_retry_delay(config, state)
|
||||
|
||||
assert result == 4.0
|
||||
|
||||
def test_caps_at_max_delay(self):
|
||||
"""Caps calculated delay at the specified maximum delay."""
|
||||
config = RetryConfig(
|
||||
initial_delay=2.0, backoff_factor=10.0, max_delay=15.0, jitter=0.0
|
||||
)
|
||||
state = NodeState(attempt_count=2)
|
||||
|
||||
result = _get_retry_delay(config, state)
|
||||
|
||||
assert result == 15.0
|
||||
|
||||
def test_adds_jitter_when_enabled(self):
|
||||
"""Adds random jitter to the calculated delay."""
|
||||
config = RetryConfig(initial_delay=10.0, backoff_factor=1.0, jitter=0.5)
|
||||
state = NodeState(attempt_count=1)
|
||||
|
||||
delays = [_get_retry_delay(config, state) for _ in range(10)]
|
||||
|
||||
assert all(5.0 <= d <= 15.0 for d in delays)
|
||||
assert len(set(delays)) > 1
|
||||
@@ -0,0 +1,188 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for agent transfer utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.agents.llm_agent import LlmAgent
|
||||
from google.adk.workflow.utils._transfer_utils import resolve_and_derive_transfer_context
|
||||
import pytest
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_raises_value_error_on_self_transfer():
|
||||
"""resolve_and_derive_transfer_context raises ValueError when target is the current agent."""
|
||||
# Arrange
|
||||
current = LlmAgent(name='current')
|
||||
root = LlmAgent(name='root', sub_agents=[current])
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match='cannot transfer to itself'):
|
||||
resolve_and_derive_transfer_context(
|
||||
'current', current, root, MagicMock(), None
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_returns_child_context():
|
||||
"""resolve_and_derive_transfer_context returns current context as parent context for CHILD transfers."""
|
||||
# Arrange
|
||||
target = LlmAgent(name='target')
|
||||
current = LlmAgent(name='current', sub_agents=[target])
|
||||
root = LlmAgent(name='root', sub_agents=[current])
|
||||
|
||||
curr_ctx = MagicMock()
|
||||
|
||||
# Act
|
||||
resolved_agent, parent_ctx = resolve_and_derive_transfer_context(
|
||||
'target', current, root, curr_ctx, None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert resolved_agent is target
|
||||
assert parent_ctx is curr_ctx
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_returns_sibling_context():
|
||||
"""resolve_and_derive_transfer_context returns parent context for SIBLING transfers."""
|
||||
# Arrange
|
||||
current = LlmAgent(name='current')
|
||||
target = LlmAgent(name='target')
|
||||
root = LlmAgent(name='root', sub_agents=[current, target])
|
||||
|
||||
parent_ctx = MagicMock()
|
||||
|
||||
# Act
|
||||
resolved_agent, derived_ctx = resolve_and_derive_transfer_context(
|
||||
'target', current, root, MagicMock(), parent_ctx
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert resolved_agent is target
|
||||
assert derived_ctx is parent_ctx
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_climbs_parent_context():
|
||||
"""resolve_and_derive_transfer_context climbs context chain to find the target parent's parent context."""
|
||||
# Arrange
|
||||
root_ctx = MagicMock()
|
||||
root_ctx.node = MagicMock()
|
||||
root_ctx.node.name = 'root'
|
||||
root_ctx.parent_ctx = MagicMock()
|
||||
root_ctx.parent_ctx.node = None
|
||||
root_ctx.parent_ctx.parent_ctx = None
|
||||
|
||||
child_ctx = MagicMock()
|
||||
child_ctx.node = MagicMock()
|
||||
child_ctx.node.name = 'child'
|
||||
child_ctx.parent_ctx = root_ctx
|
||||
|
||||
# Target is 'root', current is 'child'
|
||||
child = LlmAgent(name='child')
|
||||
root = LlmAgent(name='root', sub_agents=[child])
|
||||
child.parent_agent = root
|
||||
|
||||
# Act
|
||||
resolved_agent, derived_ctx = resolve_and_derive_transfer_context(
|
||||
'root', child, root, child_ctx, None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert resolved_agent is root
|
||||
assert derived_ctx is root_ctx.parent_ctx
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_returns_root_context_when_parent_bypassed():
|
||||
"""resolve_and_derive_transfer_context returns root context for PARENT transfers when parent was bypassed."""
|
||||
# Arrange
|
||||
root_ctx = MagicMock()
|
||||
root_ctx.node = None
|
||||
root_ctx.parent_ctx = None
|
||||
|
||||
child_ctx = MagicMock()
|
||||
child_ctx.node = MagicMock()
|
||||
child_ctx.node.name = 'child'
|
||||
child_ctx.parent_ctx = root_ctx
|
||||
|
||||
# Target is 'root', current is 'child'
|
||||
child = LlmAgent(name='child')
|
||||
root = LlmAgent(name='root', sub_agents=[child])
|
||||
child.parent_agent = root
|
||||
|
||||
# Act
|
||||
resolved_agent, derived_ctx = resolve_and_derive_transfer_context(
|
||||
'root', child, root, child_ctx, None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert resolved_agent is root
|
||||
assert derived_ctx is root_ctx
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_returns_none_when_agent_not_found():
|
||||
"""resolve_and_derive_transfer_context returns (None, None) when target agent is not found."""
|
||||
# Arrange
|
||||
current = LlmAgent(name='current')
|
||||
root = LlmAgent(name='root', sub_agents=[current])
|
||||
|
||||
# Act
|
||||
resolved_agent, derived_ctx = resolve_and_derive_transfer_context(
|
||||
'target', current, root, MagicMock(), None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert resolved_agent is None
|
||||
assert derived_ctx is None
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_returns_target_and_none_when_no_relationship():
|
||||
"""resolve_and_derive_transfer_context returns (target_agent, None) for unrelated transfers."""
|
||||
# Arrange
|
||||
current = LlmAgent(name='current')
|
||||
target = LlmAgent(name='target')
|
||||
root1 = LlmAgent(name='root1', sub_agents=[current])
|
||||
root2 = LlmAgent(name='root2', sub_agents=[target])
|
||||
|
||||
# Act
|
||||
resolved_agent, derived_ctx = resolve_and_derive_transfer_context(
|
||||
'target', current, root2, MagicMock(), None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert resolved_agent is target
|
||||
assert derived_ctx is None
|
||||
|
||||
|
||||
def test_resolve_and_derive_transfer_context_works_with_cloned_agents():
|
||||
"""resolve_and_derive_transfer_context works correctly when the current agent is cloned (name-based matching)."""
|
||||
# Arrange
|
||||
target = LlmAgent(name='target')
|
||||
current = LlmAgent(name='current', sub_agents=[target])
|
||||
root = LlmAgent(name='root', sub_agents=[current])
|
||||
|
||||
cloned_current = current.clone()
|
||||
assert cloned_current is not current
|
||||
assert cloned_current.name == current.name
|
||||
|
||||
curr_ctx = MagicMock()
|
||||
|
||||
# Act
|
||||
resolved_agent, derived_ctx = resolve_and_derive_transfer_context(
|
||||
'target', cloned_current, root, curr_ctx, None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert resolved_agent is target
|
||||
assert derived_ctx is curr_ctx
|
||||
@@ -0,0 +1,136 @@
|
||||
# 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.agents.llm_agent import LlmAgent
|
||||
from google.adk.tools.base_tool import BaseTool
|
||||
from google.adk.workflow._base_node import BaseNode
|
||||
from google.adk.workflow._base_node import START
|
||||
from google.adk.workflow._function_node import FunctionNode
|
||||
from google.adk.workflow._tool_node import _ToolNode
|
||||
from google.adk.workflow.utils._workflow_graph_utils import build_node
|
||||
from google.adk.workflow.utils._workflow_graph_utils import is_node_like
|
||||
import pytest
|
||||
|
||||
|
||||
class TestIsNodeLike:
|
||||
|
||||
def test_returns_true_for_base_node(self):
|
||||
"""is_node_like returns True for BaseNode instances."""
|
||||
|
||||
class DummyNode(BaseNode):
|
||||
|
||||
async def _run_impl(self, *, ctx, node_input):
|
||||
yield node_input
|
||||
|
||||
node = DummyNode(name="test")
|
||||
|
||||
assert is_node_like(node) is True
|
||||
|
||||
def test_returns_true_for_base_tool(self):
|
||||
"""is_node_like returns True for BaseTool instances."""
|
||||
|
||||
class DummyTool(BaseTool):
|
||||
|
||||
def execute(self, **kwargs):
|
||||
return "done"
|
||||
|
||||
tool = DummyTool(name="test", description="test")
|
||||
|
||||
assert is_node_like(tool) is True
|
||||
|
||||
def test_returns_true_for_callable(self):
|
||||
"""is_node_like returns True for callables."""
|
||||
|
||||
def my_func():
|
||||
pass
|
||||
|
||||
assert is_node_like(my_func) is True
|
||||
|
||||
def test_returns_true_for_start_string(self):
|
||||
"""is_node_like returns True for 'START' string."""
|
||||
assert is_node_like("START") is True
|
||||
|
||||
def test_returns_false_for_invalid_types(self):
|
||||
"""is_node_like returns False for invalid types."""
|
||||
assert is_node_like(123) is False
|
||||
assert is_node_like("NOT_START") is False
|
||||
|
||||
|
||||
class TestBuildNode:
|
||||
|
||||
def test_returns_start_when_node_like_is_start(self):
|
||||
"""build_node returns START sentinel when input is 'START'."""
|
||||
assert build_node("START") == START
|
||||
|
||||
def test_returns_copy_of_base_node_with_overrides(self):
|
||||
"""build_node returns a copy of BaseNode with provided overrides."""
|
||||
|
||||
class DummyNode(BaseNode):
|
||||
|
||||
async def _run_impl(self, *, ctx, node_input):
|
||||
yield node_input
|
||||
|
||||
node = DummyNode(name="original")
|
||||
|
||||
built = build_node(node, name="new_name")
|
||||
|
||||
assert built != node
|
||||
assert built.name == "new_name"
|
||||
|
||||
def test_returns_tool_node_for_base_tool(self):
|
||||
"""build_node wraps BaseTool in a _ToolNode."""
|
||||
|
||||
class DummyTool(BaseTool):
|
||||
|
||||
def execute(self, **kwargs):
|
||||
return "done"
|
||||
|
||||
tool = DummyTool(name="test", description="test")
|
||||
|
||||
built = build_node(tool)
|
||||
|
||||
assert isinstance(built, _ToolNode)
|
||||
|
||||
def test_returns_function_node_for_callable(self):
|
||||
"""build_node wraps callable in a FunctionNode."""
|
||||
|
||||
def my_func(x):
|
||||
return x
|
||||
|
||||
built = build_node(my_func)
|
||||
|
||||
assert isinstance(built, FunctionNode)
|
||||
|
||||
def test_raises_value_error_for_invalid_type(self):
|
||||
"""build_node raises ValueError for invalid types."""
|
||||
with pytest.raises(ValueError, match="Invalid node type"):
|
||||
build_node(123)
|
||||
|
||||
def test_llm_agent_mode_defaults(self):
|
||||
"""build_node sets correct default mode for LlmAgent."""
|
||||
root_agent = LlmAgent(name="root", instruction="test")
|
||||
sub_agent = LlmAgent(name="sub", description="test")
|
||||
# Dynamic subagent attachment without model_post_init normalization
|
||||
sub_agent.parent_agent = root_agent
|
||||
|
||||
# Subagent with parent_agent should default to chat mode
|
||||
built_sub = build_node(sub_agent)
|
||||
assert built_sub.mode == "chat"
|
||||
|
||||
# Standalone agent without parent_agent should default to single_turn
|
||||
standalone = LlmAgent(name="standalone", instruction="test")
|
||||
built_standalone = build_node(standalone)
|
||||
assert built_standalone.mode == "single_turn"
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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
|
||||
|
||||
import json
|
||||
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.events.event import NodeInfo
|
||||
from google.adk.events.request_input import RequestInput
|
||||
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
|
||||
from google.adk.workflow.utils._workflow_hitl_utils import create_auth_request_event
|
||||
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_event
|
||||
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response
|
||||
from google.adk.workflow.utils._workflow_hitl_utils import get_request_input_interrupt_ids
|
||||
from google.adk.workflow.utils._workflow_hitl_utils import has_request_input_function_call
|
||||
from google.adk.workflow.utils._workflow_hitl_utils import REQUEST_CREDENTIAL_FUNCTION_CALL_NAME
|
||||
from google.genai import types
|
||||
|
||||
# --- create_request_input_event ---
|
||||
|
||||
|
||||
class TestCreateRequestInputEvent:
|
||||
|
||||
def test_basic_event(self):
|
||||
ri = RequestInput(
|
||||
interrupt_id="test-id",
|
||||
message="Please approve",
|
||||
)
|
||||
event = create_request_input_event(ri)
|
||||
|
||||
assert event.long_running_tool_ids == {"test-id"}
|
||||
assert event.content is not None
|
||||
assert event.content.role == "model"
|
||||
fc = event.content.parts[0].function_call
|
||||
assert fc.name == "adk_request_input"
|
||||
assert fc.id == "test-id"
|
||||
assert fc.args["message"] == "Please approve"
|
||||
|
||||
def test_with_payload(self):
|
||||
ri = RequestInput(
|
||||
interrupt_id="id-1",
|
||||
payload={"key": "value"},
|
||||
)
|
||||
event = create_request_input_event(ri)
|
||||
fc = event.content.parts[0].function_call
|
||||
assert fc.args["payload"] == {"key": "value"}
|
||||
|
||||
def test_with_response_schema(self):
|
||||
from pydantic import BaseModel
|
||||
|
||||
class MySchema(BaseModel):
|
||||
approved: bool
|
||||
|
||||
ri = RequestInput(
|
||||
interrupt_id="id-2",
|
||||
response_schema=MySchema,
|
||||
)
|
||||
event = create_request_input_event(ri)
|
||||
fc = event.content.parts[0].function_call
|
||||
schema = fc.args["response_schema"]
|
||||
assert "approved" in schema["properties"]
|
||||
assert schema["properties"]["approved"]["type"] == "boolean"
|
||||
|
||||
|
||||
# --- has_request_input_function_call ---
|
||||
|
||||
|
||||
class TestHasRequestInputFunctionCall:
|
||||
|
||||
def test_true_for_request_input_event(self):
|
||||
event = create_request_input_event(
|
||||
RequestInput(interrupt_id="id-1", message="test")
|
||||
)
|
||||
assert has_request_input_function_call(event) is True
|
||||
|
||||
def test_false_for_empty_event(self):
|
||||
assert has_request_input_function_call(Event()) is False
|
||||
|
||||
def test_false_for_non_request_input(self):
|
||||
from google.genai import types
|
||||
|
||||
event = Event(
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
function_call=types.FunctionCall(name="other_tool", args={})
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
assert has_request_input_function_call(event) is False
|
||||
|
||||
|
||||
# --- create_request_input_response ---
|
||||
|
||||
|
||||
class TestCreateRequestInputResponse:
|
||||
|
||||
def test_creates_function_response_part(self):
|
||||
part = create_request_input_response("id-1", {"approved": True})
|
||||
assert part.function_response.id == "id-1"
|
||||
assert part.function_response.name == "adk_request_input"
|
||||
assert part.function_response.response == {"approved": True}
|
||||
|
||||
|
||||
# --- get_request_input_interrupt_ids ---
|
||||
|
||||
|
||||
class TestGetRequestInputInterruptIds:
|
||||
|
||||
def test_extracts_ids(self):
|
||||
event = create_request_input_event(
|
||||
RequestInput(interrupt_id="id-1", message="test")
|
||||
)
|
||||
assert get_request_input_interrupt_ids(event) == ["id-1"]
|
||||
|
||||
def test_empty_for_no_function_calls(self):
|
||||
assert get_request_input_interrupt_ids(Event()) == []
|
||||
|
||||
def test_empty_for_non_request_input(self):
|
||||
from google.genai import types
|
||||
|
||||
event = Event(
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
function_call=types.FunctionCall(
|
||||
name="other_tool", args={}, id="id-1"
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
assert get_request_input_interrupt_ids(event) == []
|
||||
|
||||
|
||||
# --- create_auth_request_event ---
|
||||
|
||||
|
||||
class TestCreateAuthRequestEvent:
|
||||
|
||||
def test_creates_credential_request(self):
|
||||
from fastapi.openapi.models import APIKey
|
||||
from fastapi.openapi.models import APIKeyIn
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_tool import AuthConfig
|
||||
|
||||
auth_config = AuthConfig(
|
||||
auth_scheme=APIKey(**{"in": APIKeyIn.header, "name": "X-Api-Key"}),
|
||||
raw_auth_credential=AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY,
|
||||
api_key="test_key",
|
||||
),
|
||||
credential_key="test_cred",
|
||||
)
|
||||
event = create_auth_request_event(auth_config, "auth-id-1")
|
||||
|
||||
assert event.long_running_tool_ids is not None
|
||||
fc = event.content.parts[0].function_call
|
||||
assert fc.name == REQUEST_CREDENTIAL_FUNCTION_CALL_NAME
|
||||
assert fc.id == "auth-id-1"
|
||||
assert "authConfig" in fc.args
|
||||
|
||||
def test_args_are_json_serializable(self):
|
||||
from fastapi.openapi.models import OAuth2
|
||||
from fastapi.openapi.models import OAuthFlowAuthorizationCode
|
||||
from fastapi.openapi.models import OAuthFlows
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_credential import OAuth2Auth
|
||||
from google.adk.auth.auth_tool import AuthConfig
|
||||
|
||||
auth_config = AuthConfig(
|
||||
auth_scheme=OAuth2(
|
||||
flows=OAuthFlows(
|
||||
authorizationCode=OAuthFlowAuthorizationCode(
|
||||
authorizationUrl=(
|
||||
"https://accounts.google.com/o/oauth2/auth"
|
||||
),
|
||||
tokenUrl="https://oauth2.googleapis.com/token",
|
||||
scopes={
|
||||
"https://www.googleapis.com/auth/calendar": (
|
||||
"See calendars"
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
),
|
||||
raw_auth_credential=AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id="oauth_client_id",
|
||||
client_secret="oauth_client_secret",
|
||||
),
|
||||
),
|
||||
)
|
||||
event = create_auth_request_event(auth_config, "auth-id-1")
|
||||
|
||||
fc = event.content.parts[0].function_call
|
||||
|
||||
# python-mode dump leaves auth_scheme.type a live enum, breaking json.dumps
|
||||
json.dumps(fc.args)
|
||||
assert fc.args["authConfig"]["authScheme"]["type"] == "oauth2"
|
||||
|
||||
|
||||
#
|
||||
Reference in New Issue
Block a user