ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
400 lines
12 KiB
Python
400 lines
12 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Testing utils for the Workflow."""
|
|
|
|
import copy
|
|
import inspect
|
|
from typing import Any
|
|
from typing import AsyncGenerator
|
|
from typing import Callable
|
|
from typing import List
|
|
from typing import Optional
|
|
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from google.adk.agents.context import Context
|
|
from google.adk.agents.invocation_context import InvocationContext
|
|
from google.adk.agents.invocation_context import InvocationContext as BaseInvocationContext
|
|
from google.adk.apps.app import ResumabilityConfig
|
|
from google.adk.events.event import Event
|
|
from google.adk.events.request_input import RequestInput
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
from google.adk.workflow import BaseNode
|
|
from google.adk.workflow._graph import RouteValue
|
|
from google.adk.workflow.utils._workflow_hitl_utils import has_auth_request_function_call
|
|
from google.adk.workflow.utils._workflow_hitl_utils import has_request_input_function_call
|
|
from google.genai import types
|
|
from pydantic import ConfigDict
|
|
from pydantic import Field
|
|
from typing_extensions import override
|
|
|
|
from .testing_utils import END_OF_AGENT
|
|
from .testing_utils import simplify_content
|
|
|
|
|
|
async def run_workflow(wf, message='start'):
|
|
"""Run a Workflow through Runner, return collected events."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name=wf.name, node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name=wf.name, user_id='u')
|
|
msg = types.Content(parts=[types.Part(text=message)], role='user')
|
|
events = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg
|
|
):
|
|
events.append(event)
|
|
return events, ss, session
|
|
|
|
|
|
# Emulates a node that outputs an Event & a route.
|
|
# If output is not None, the output is set as the data field in the event.
|
|
# If route is not None, the route is set in the node output event.
|
|
# The route can be set without the output. This means didn't produce any output
|
|
# but wants to signal a route to take.
|
|
class TestingNode(BaseNode):
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
output: Optional[Any] = None
|
|
route: (
|
|
RouteValue | list[RouteValue] | Callable[[Context, Any], Any] | None
|
|
) = None
|
|
received_inputs: List[Any] = Field(default_factory=list)
|
|
|
|
@override
|
|
async def _run_impl(
|
|
self,
|
|
*,
|
|
ctx: Context,
|
|
node_input: Any,
|
|
) -> AsyncGenerator[Any, None]:
|
|
if self.output is not None or self.route is not None:
|
|
route = None
|
|
if callable(self.route):
|
|
if inspect.iscoroutinefunction(self.route):
|
|
route = await self.route(ctx, node_input)
|
|
else:
|
|
route = self.route(ctx, node_input)
|
|
else:
|
|
route = self.route
|
|
|
|
self.received_inputs.append(node_input)
|
|
yield Event(
|
|
output=self.output,
|
|
route=route,
|
|
)
|
|
|
|
|
|
class TestingNodeWithIntermediateContent(BaseNode):
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
intermediate_content: list[types.Content] = Field(default_factory=list)
|
|
output: Optional[Any] = None
|
|
route: Optional[str] = None
|
|
|
|
@override
|
|
async def _run_impl(
|
|
self,
|
|
*,
|
|
ctx: Context,
|
|
node_input: Any,
|
|
) -> AsyncGenerator[Any, None]:
|
|
for content in self.intermediate_content:
|
|
yield Event(
|
|
author=self.name,
|
|
invocation_id=ctx.invocation_id,
|
|
content=content,
|
|
)
|
|
|
|
if self.output is not None:
|
|
yield Event(
|
|
output=self.output,
|
|
route=self.route,
|
|
)
|
|
|
|
|
|
class InputCapturingNode(BaseNode):
|
|
"""A node that captures the inputs it receives."""
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
received_inputs: List[Any] = Field(default_factory=list)
|
|
|
|
@override
|
|
async def _run_impl(
|
|
self,
|
|
*,
|
|
ctx: Context,
|
|
node_input: Any,
|
|
) -> AsyncGenerator[Any, None]:
|
|
self.received_inputs.append(node_input)
|
|
yield Event(
|
|
output={'received': node_input},
|
|
)
|
|
|
|
|
|
class RequestInputNode(BaseNode):
|
|
"""A simple node that requests input from the user."""
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
message: str = Field(default='')
|
|
response_schema: Optional[dict[str, Any]] = None
|
|
|
|
@override
|
|
async def _run_impl(
|
|
self,
|
|
*,
|
|
ctx: Context,
|
|
node_input: Any,
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield RequestInput(
|
|
message=self.message,
|
|
response_schema=self.response_schema,
|
|
)
|
|
|
|
|
|
async def create_parent_invocation_context(
|
|
test_name: str, agent: BaseAgent, resumable: bool = False
|
|
) -> InvocationContext:
|
|
session_service = InMemorySessionService()
|
|
session = await session_service.create_session(
|
|
app_name='test_app', user_id='test_user'
|
|
)
|
|
return InvocationContext(
|
|
invocation_id=f'{test_name}_invocation_id',
|
|
agent=agent,
|
|
session=session,
|
|
session_service=session_service,
|
|
resumability_config=ResumabilityConfig(is_resumable=resumable),
|
|
)
|
|
|
|
|
|
def simplify_event_with_node(
|
|
event: Event,
|
|
include_state_delta: bool = False,
|
|
) -> Any | None:
|
|
if isinstance(event, Event):
|
|
if (
|
|
'output' not in event.model_fields_set
|
|
and not (include_state_delta and event.actions.state_delta)
|
|
and not event.content
|
|
):
|
|
return None
|
|
|
|
# If the event has content, return the simplified content.
|
|
if event.content:
|
|
return simplify_content(event.content)
|
|
|
|
simplified_event = {}
|
|
|
|
# Also simplify event.output if it contains Content.
|
|
# The tests assume that Content found in event data should be simplified
|
|
# (IDs stripped) just like event.content. This ensures consistent
|
|
# assertion behavior.
|
|
output = event.output
|
|
if isinstance(output, types.Content):
|
|
output = copy.deepcopy(output)
|
|
for part in output.parts:
|
|
if part.function_call and part.function_call.id:
|
|
part.function_call.id = None
|
|
if part.function_response and part.function_response.id:
|
|
part.function_response.id = None
|
|
simplified_event['output'] = output
|
|
|
|
if include_state_delta and event.actions.state_delta:
|
|
simplified_event['state_delta'] = event.actions.state_delta
|
|
|
|
return simplified_event
|
|
elif event.content:
|
|
return simplify_content(event.content)
|
|
|
|
|
|
def simplify_events_with_node(
|
|
events: list[Event],
|
|
*,
|
|
include_state_delta: bool = False,
|
|
include_workflow_output: bool = False,
|
|
) -> list[tuple[str, Any]]:
|
|
results = []
|
|
|
|
# Second pass: Simplify events
|
|
for event in events:
|
|
# Optionally skip top-level workflow output events (events emitted
|
|
# by the Workflow in _finalize_workflow). These events have a
|
|
# top-level node_path (no '/' separator) and carry output data.
|
|
if (
|
|
not include_workflow_output
|
|
and isinstance(event, Event)
|
|
and event.output is not None
|
|
and '/' not in (event.node_info.path or '')
|
|
):
|
|
continue
|
|
|
|
simplified_event = simplify_event_with_node(event, include_state_delta)
|
|
if simplified_event:
|
|
# Map the author to the source node name if it exists.
|
|
if hasattr(event, 'node_info') and event.node_info.path:
|
|
author = event.node_info.path
|
|
else:
|
|
author = event.author
|
|
results.append((author, simplified_event))
|
|
return results
|
|
|
|
|
|
def simplify_events_with_node_and_agent_state(
|
|
events: list[Event],
|
|
*,
|
|
include_state_delta: bool = False,
|
|
include_inputs_and_triggers: bool = False,
|
|
include_resume_inputs: bool = False,
|
|
include_workflow_output: bool = False,
|
|
):
|
|
fields_to_exclude = {'run_id'}
|
|
if not include_inputs_and_triggers:
|
|
fields_to_exclude.add('input')
|
|
if not include_resume_inputs:
|
|
fields_to_exclude.add('resume_inputs')
|
|
|
|
results = []
|
|
|
|
for event in events:
|
|
# Optionally skip top-level workflow output events.
|
|
if (
|
|
not include_workflow_output
|
|
and isinstance(event, Event)
|
|
and event.output is not None
|
|
and '/' not in (event.node_info.path or '')
|
|
):
|
|
continue
|
|
simplified_event = simplify_event_with_node(event, include_state_delta)
|
|
|
|
# Map the author to the source node name if it exists.
|
|
if hasattr(event, 'node_info') and event.node_info.path:
|
|
author = event.node_info.path
|
|
else:
|
|
author = event.author
|
|
|
|
if simplified_event:
|
|
results.append((author, simplified_event))
|
|
elif event.actions.end_of_agent:
|
|
results.append((author, END_OF_AGENT))
|
|
elif event.actions.agent_state is not None:
|
|
agent_state = event.actions.agent_state
|
|
nodes = agent_state.get('nodes', {})
|
|
simplified_nodes = {}
|
|
for node_name, node_state in nodes.items():
|
|
simplified_nodes[node_name] = {
|
|
k: v
|
|
for k, v in node_state.items()
|
|
if k not in fields_to_exclude
|
|
and (k != 'interrupts' or v) # Exclude empty interrupts
|
|
and (k != 'resume_inputs' or v) # Exclude empty resume_inputs
|
|
}
|
|
results.append((author, {'nodes': simplified_nodes}))
|
|
return results
|
|
|
|
|
|
def get_request_input_events(events: list[Any]) -> list[Any]:
|
|
"""Returns a list of request input events from the given list of events."""
|
|
return [e for e in events if has_request_input_function_call(e)]
|
|
|
|
|
|
def get_auth_request_events(events: list[Any]) -> list[Any]:
|
|
"""Returns a list of auth credential request events from the given list."""
|
|
return [e for e in events if has_auth_request_function_call(e)]
|
|
|
|
|
|
def get_output_events(events: list[Any], output: Any = None) -> list[Any]:
|
|
"""Returns a list of events that have output populated."""
|
|
return [
|
|
e
|
|
for e in events
|
|
if isinstance(e, Event)
|
|
and e.output is not None
|
|
and (output is None or e.output == output)
|
|
]
|
|
|
|
|
|
def get_outputs(events: list[Event]) -> list[Any]:
|
|
"""Extracts output values from events, skipping non-output events."""
|
|
return [
|
|
e.output for e in events if isinstance(e, Event) and e.output is not None
|
|
]
|
|
|
|
|
|
def strip_checkpoint_events(
|
|
simplified_events: list[tuple[str, Any]],
|
|
) -> list[tuple[str, Any]]:
|
|
"""Strips agent_state checkpoint and end_of_agent events.
|
|
|
|
In non-resumable mode, the workflow does not emit checkpoint events
|
|
or end_of_agent events. Use this to derive the expected simplified
|
|
output for non-resumable tests from the resumable expected output.
|
|
"""
|
|
return [
|
|
(author, data)
|
|
for author, data in simplified_events
|
|
if not (isinstance(data, dict) and 'nodes' in data)
|
|
and data != END_OF_AGENT
|
|
]
|
|
|
|
|
|
def find_function_call_event(
|
|
events: list[Any], name: str | None = None
|
|
) -> Any | None:
|
|
"""Finds the first event containing a function call."""
|
|
for e in events:
|
|
if hasattr(e, 'content') and e.content and e.content.parts:
|
|
for part in e.content.parts:
|
|
if part.function_call:
|
|
if name is None or part.function_call.name == name:
|
|
return e
|
|
return None
|
|
|
|
|
|
class CustomRetryableError(Exception):
|
|
"""A custom error meant to be retried."""
|
|
|
|
|
|
class CustomNonRetryableError(Exception):
|
|
"""A custom error not meant to be retried."""
|
|
|
|
|
|
class _FlakyNode(BaseNode):
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
message: str = Field(default='')
|
|
succeed_on_iteration: int = Field(default=0)
|
|
tracker: dict[str, Any] = Field(default_factory=dict)
|
|
exception_to_raise: Exception = Field(...)
|
|
|
|
@override
|
|
async def run(
|
|
self,
|
|
*,
|
|
ctx: Context,
|
|
node_input: Any,
|
|
) -> AsyncGenerator[Any, None]:
|
|
iteration_count = self.tracker.get('iteration_count', 0) + 1
|
|
self.tracker['iteration_count'] = iteration_count
|
|
self.tracker.setdefault('attempt_counts', []).append(ctx.attempt_count)
|
|
|
|
if iteration_count < self.succeed_on_iteration:
|
|
raise self.exception_to_raise
|
|
|
|
yield Event(
|
|
output=self.message,
|
|
)
|