Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

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,
)