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
@@ -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"
#