chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
@@ -0,0 +1,71 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for BaseAgent acting as a workflow node."""
from __future__ import annotations
from typing import AsyncGenerator
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.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
import pytest
class MockAgent(BaseAgent):
"""A mock agent that yields predefined events."""
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(author=self.name)
yield Event(author="sub_agent")
@pytest.mark.asyncio
async def test_base_agent_as_node_run():
"""Tests that BaseAgent runs as a node and preserves event authors."""
agent = MockAgent(name="mock_agent")
# Setup minimal context
session = Session(app_name="test", user_id="user", id="session")
session_service = InMemorySessionService()
ic = InvocationContext(
invocation_id="inv",
session=session,
session_service=session_service,
)
ctx = Context(ic, node_path="wf")
events = []
async for event in agent.run(ctx=ctx, node_input=None):
events.append(event)
assert len(events) == 2
# First event from mock_agent
assert events[0].author == "mock_agent"
assert events[0].node_info.path == "wf"
# Second event from sub_agent
assert events[1].author == "sub_agent"
# Path should not be set by BaseAgent for sub_agent if author doesn't match agent name
assert not events[1].node_info.path
# Also check if ctx.event_author was updated to preserve author for NodeRunner
assert ctx.event_author == "sub_agent"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,798 @@
# 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 DynamicNodeScheduler.
Verifies the three scheduling cases (fresh, dedup, resume) and the
lazy event scan that reconstructs dynamic node state.
"""
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from google.adk.agents.context import Context
from google.adk.events.event import Event
from google.adk.events.event import NodeInfo
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._dynamic_node_scheduler import DynamicNodeRun
from google.adk.workflow._dynamic_node_scheduler import DynamicNodeScheduler
from google.adk.workflow._dynamic_node_scheduler import DynamicNodeState
from google.adk.workflow._node_state import NodeState
from google.adk.workflow._node_status import NodeStatus
from google.adk.workflow._workflow import _LoopState
from pydantic import BaseModel
from pydantic import ValidationError
import pytest
# --- Fixtures ---
def _make_parent_ctx(events=None):
"""Create a minimal parent Context with mock IC."""
ic = MagicMock()
ic.invocation_id = 'inv-1'
ic.session = MagicMock()
ic.session.state = {}
ic.session.events = events or []
ic.run_config = None
collected = []
async def _enqueue(event):
collected.append(event)
ic._enqueue_event = AsyncMock(side_effect=_enqueue)
ctx = MagicMock(spec=Context)
ctx._invocation_context = ic
ctx.node_path = 'wf/parent'
ctx.run_id = 'run-parent'
ctx.event_author = 'wf'
ctx._workflow_scheduler = None
ctx._output_for_ancestors = []
ctx._output_delegated = False
ctx._child_run_counters = {}
return ctx, collected
def _make_event(
path='',
output=None,
interrupt_ids=None,
run_id=None,
author='node',
invocation_id='inv-1',
output_for=None,
):
"""Create a minimal Event for session event lists."""
event = MagicMock(spec=Event)
event.invocation_id = invocation_id
event.author = author
event.output = output
event.partial = False
event.node_info = MagicMock(spec=NodeInfo)
event.node_info.path = path
event.node_info.output_for = output_for
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
def _make_fr_event(fc_id, response, invocation_id='inv-1'):
"""Create a user FR event."""
event = MagicMock(spec=Event)
event.invocation_id = invocation_id
event.author = 'user'
event.output = None
event.node_info = MagicMock(spec=NodeInfo)
event.node_info.path = ''
event.node_info.message_as_output = None
event.branch = None
event.isolation_scope = None
event.long_running_tool_ids = None
fr = MagicMock()
fr.id = fc_id
fr.response = response
part = MagicMock()
part.function_response = fr
content = MagicMock()
content.parts = [part]
event.content = content
return event
# =========================================================================
# _rehydrate_from_events — lazy scan
# =========================================================================
@pytest.mark.asyncio
async def test_rehydrate_finds_completed_node():
"""Scan finds output event → node marked COMPLETED."""
events = [
_make_event(
path='wf/parent/child@r-1',
output='result',
),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-1')
assert 'wf/parent/child@r-1' in ls.runs
run = ls.runs['wf/parent/child@r-1']
assert run.recovered_state is not None
assert run.recovered_state.output == 'result'
@pytest.mark.asyncio
async def test_rehydrate_ignores_events_from_different_invocation():
"""Scan ignores events with a different invocation_id."""
events = [
_make_event(
path='wf/parent/child@r-1',
output='result',
invocation_id='inv-different',
),
]
ctx, _ = _make_parent_ctx(events=events)
ctx._invocation_context.invocation_id = 'inv-current'
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-1')
assert 'wf/parent/child@r-1' not in ls.runs
@pytest.mark.asyncio
async def test_rehydrate_finds_interrupted_node():
"""Scan finds interrupt event → node marked WAITING."""
events = [
_make_event(
path='wf/parent/child@r-1',
interrupt_ids=['fc-1'],
),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-1')
assert 'wf/parent/child@r-1' in ls.runs
run = ls.runs['wf/parent/child@r-1']
assert run.recovered_state is not None
assert 'fc-1' in run.recovered_state.interrupt_ids
@pytest.mark.asyncio
async def test_rehydrate_with_target_run_id_skips_others():
"""Scan with unique path only rehydrates that specific run."""
events = [
_make_event(
path='wf/parent/child@r-1',
output='result-1',
),
_make_event(
path='wf/parent/child@r-2',
output='result-2',
),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
# When targeting r-2
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-2')
# Then only r-2 is in state
assert 'wf/parent/child@r-2' in ls.runs
assert 'wf/parent/child@r-1' not in ls.runs
run = ls.runs['wf/parent/child@r-2']
assert run.recovered_state is not None
assert run.recovered_state.output == 'result-2'
@pytest.mark.asyncio
async def test_rehydrate_includes_delegated():
"""Scan includes events delegated to that run."""
events = [
_make_event(
path='wf/parent/child@r-target/inner@r-inner',
output='delegated-val',
output_for=['wf/parent/child@r-target'],
),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-target')
assert 'wf/parent/child@r-target' in ls.runs
run = ls.runs['wf/parent/child@r-target']
assert run.recovered_state is not None
assert run.recovered_state.output == 'delegated-val'
@pytest.mark.asyncio
async def test_rehydrate_resolves_interrupt_with_fr():
"""Scan finds interrupt + FR → all resolved, ready to re-run."""
events = [
_make_event(
path='wf/parent/child@r-1',
interrupt_ids=['fc-1'],
),
_make_fr_event('fc-1', {'approved': True}),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-1')
run = ls.runs['wf/parent/child@r-1']
assert run.recovered_state is not None
assert 'fc-1' in run.recovered_state.resolved_ids
@pytest.mark.asyncio
async def test_rehydrate_no_events_does_nothing():
"""Scan with no matching events does not populate dynamic_nodes."""
events = [
_make_event(path='wf/other/node', output='x'),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-1')
assert 'wf/parent/child@r-1' not in ls.runs
@pytest.mark.asyncio
async def test_rehydrate_subtree_interrupt():
"""Interrupts from nested descendants are collected."""
events = [
_make_event(
path='wf/parent/child@r-1/inner@r-inner',
interrupt_ids=['fc-deep'],
),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-1')
assert 'wf/parent/child@r-1' in ls.runs
run = ls.runs['wf/parent/child@r-1']
assert run.recovered_state is not None
assert 'fc-deep' in run.recovered_state.interrupt_ids
@pytest.mark.asyncio
async def test_rehydrate_parallel_worker_interrupts():
"""Interrupts from parallel child nodes sharing the parent's path."""
events = [
_make_event(
# Child has exact same path as parent
path='wf/parent/parallel',
interrupt_ids=['fc-1'],
run_id='r-child-1',
),
_make_event(
path='wf/parent/parallel',
interrupt_ids=['fc-2'],
run_id='r-child-2',
),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
# Rehydrate the parent which has run_id 'r-parent'
scheduler._rehydrate_from_events(ctx, 'wf/parent/parallel')
assert 'wf/parent/parallel' in ls.runs
run = ls.runs['wf/parent/parallel']
assert run.recovered_state is not None
assert 'fc-1' in run.recovered_state.interrupt_ids
assert 'fc-2' in run.recovered_state.interrupt_ids
@pytest.mark.asyncio
async def test_rehydrate_output_for_delegation():
"""Output via output_for delegation is recognized."""
events = [
_make_event(
path='wf/parent/child@r-1/inner@r-inner',
output='delegated',
output_for=['wf/parent/child@r-1'],
),
]
ctx, _ = _make_parent_ctx(events=events)
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
scheduler._rehydrate_from_events(ctx, 'wf/parent/child@r-1')
run = ls.runs['wf/parent/child@r-1']
assert run.recovered_state is not None
assert run.recovered_state.output == 'delegated'
# =========================================================================
# __call__ — dispatch logic
# =========================================================================
# =========================================================================
# DefaultNodeScheduler — standalone scheduler
# =========================================================================
@pytest.mark.asyncio
async def test_fresh_execution_runs_node():
"""DefaultNodeScheduler runs a fresh node just like DynamicNodeScheduler."""
class _Child(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield f'ct: {node_input}'
ctx, _ = _make_parent_ctx()
tracker = DynamicNodeScheduler(state=DynamicNodeState())
mock_child_ctx = MagicMock(spec=Context)
mock_child_ctx.error = None
mock_child_ctx.interrupt_ids = set()
mock_child_ctx.output = 'ct: data'
mock_child_ctx.actions = MagicMock()
mock_child_ctx.actions.transfer_to_agent = None
ctx._run_node_standalone = AsyncMock(return_value=mock_child_ctx)
child_ctx = await tracker(
ctx,
_Child(name='child'),
'data',
node_name='child',
run_id='1',
)
assert child_ctx.output == 'ct: data'
@pytest.mark.asyncio
async def test_completed_dedup_returns_cached():
"""DefaultNodeScheduler returns cached output for completed nodes."""
ctx, _ = _make_parent_ctx()
tracker = DynamicNodeScheduler(state=DynamicNodeState())
# Pre-populate state as if node already completed.
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
tracker._state.runs['wf/parent/child@r-1'] = DynamicNodeRun(
state=NodeState(run_id='r-1'),
recovered_state=_ChildScanState(
run_id='r-1',
output='cached',
),
)
child_ctx = await tracker(
ctx,
BaseNode(name='child'),
'input',
node_name='child',
run_id='r-1',
)
assert child_ctx.output == 'cached'
@pytest.mark.asyncio
async def test_concurrent_dedup_returns_running_task():
"""Scheduler deduplicates concurrent executions of the same running task."""
import asyncio
ctx, _ = _make_parent_ctx()
tracker = DynamicNodeScheduler(state=DynamicNodeState())
# Mock an active running task (not done yet!)
running_task = asyncio.Future()
tracker._state.runs['wf/parent/child@r-1'] = DynamicNodeRun(
state=NodeState(run_id='r-1'),
task=running_task,
)
# Dispatch the scheduler in the background
scheduler_task = asyncio.create_task(
tracker(
ctx,
BaseNode(name='child'),
'input',
node_name='child',
run_id='r-1',
)
)
# Let the event loop run one tick to execute the scheduler interception
await asyncio.sleep(0)
# Resolve the running task dynamically
mock_context = MagicMock(spec=Context)
running_task.set_result(mock_context)
res_ctx = await scheduler_task
assert res_ctx is mock_context
@pytest.mark.asyncio
async def test_waiting_resolved_resumes_node():
"""DefaultNodeScheduler re-runs nodes with resolved interrupts."""
class _Resumable(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(self, *, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'resumed: {ctx.resume_inputs["fc-1"]}'
return
yield 'should not reach here'
ctx, _ = _make_parent_ctx()
tracker = DynamicNodeScheduler(state=DynamicNodeState())
# Pre-populate state as if node interrupted and was resolved.
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
tracker._state.runs['wf/parent/child@r-1'] = DynamicNodeRun(
state=NodeState(run_id='r-1'),
recovered_state=_ChildScanState(
run_id='r-1',
interrupt_ids={'fc-1'},
resolved_ids={'fc-1'},
resolved_responses={'fc-1': 'approved'},
),
)
mock_child_ctx = MagicMock(spec=Context)
mock_child_ctx.error = None
mock_child_ctx.interrupt_ids = set()
mock_child_ctx.output = 'resumed: approved'
mock_child_ctx.actions = MagicMock()
mock_child_ctx.actions.transfer_to_agent = None
ctx._run_node_standalone = AsyncMock(return_value=mock_child_ctx)
child_ctx = await tracker(
ctx,
_Resumable(name='child'),
'input',
node_name='child',
run_id='r-1',
)
assert child_ctx.output == 'resumed: approved'
@pytest.mark.asyncio
async def test_waiting_unresolved_propagates_interrupts():
"""DefaultNodeScheduler propagates unresolved interrupts."""
ctx, _ = _make_parent_ctx()
tracker = DynamicNodeScheduler(state=DynamicNodeState())
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
tracker._state.runs['wf/parent/child@r-1'] = DynamicNodeRun(
state=NodeState(run_id='r-1'),
recovered_state=_ChildScanState(
run_id='r-1',
interrupt_ids={'fc-1'},
),
)
child_ctx = await tracker(
ctx,
BaseNode(name='child'),
'input',
node_name='child',
run_id='r-1',
)
assert child_ctx.interrupt_ids == {'fc-1'}
assert 'fc-1' in tracker._state.interrupt_ids
@pytest.mark.asyncio
async def test_calling_waiting_node_without_rerun_raises_value_error():
"""Calling a dynamic node that is waiting for output with rerun_on_resume=False raises ValueError."""
# Given a dynamic node waiting for output with rerun_on_resume=False
class _WaitingNode(BaseNode):
wait_for_output: bool = True
async def _run_impl(self, *, ctx, node_input):
yield 'should not reach here'
ctx, _ = _make_parent_ctx()
ls = _LoopState()
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
ls.runs['wf/parent/child@r-1'] = DynamicNodeRun(
state=NodeState(run_id='r-1'),
recovered_state=_ChildScanState(
run_id='r-1',
interrupt_ids={'pause_req'},
resolved_ids={'pause_req'},
),
)
scheduler = DynamicNodeScheduler(state=ls)
# When it is called again
# Then it raises ValueError
with pytest.raises(
ValueError, match='is waiting for output but was called again'
):
await scheduler(
ctx,
_WaitingNode(name='child'),
'input',
node_name='child',
run_id='r-1',
)
def test_get_dynamic_tasks_excludes_done_tasks():
"""get_dynamic_tasks should not return completed tasks (regression for #6082)."""
import asyncio
loop = asyncio.new_event_loop()
try:
async def _done():
return None
done_task = loop.run_until_complete(
asyncio.ensure_future(_done(), loop=loop)
)
running_coro = asyncio.sleep(9999)
running_task = loop.create_task(running_coro)
state = DynamicNodeState()
state.runs['path/done@r-1'] = DynamicNodeRun(
state=NodeState(run_id='r-1'),
task=done_task,
)
state.runs['path/running@r-2'] = DynamicNodeRun(
state=NodeState(run_id='r-2'),
task=running_task,
)
state.runs['path/no-task@r-3'] = DynamicNodeRun(
state=NodeState(run_id='r-3'),
task=None,
)
tasks = state.get_dynamic_tasks()
assert tasks == [running_task]
running_task.cancel()
finally:
loop.close()
class _ModelA(BaseModel):
x: int
@pytest.mark.asyncio
async def test_runtime_schema_validation_passes():
"""Tests that runtime schema validation passes when input matches schema."""
ctx, _ = _make_parent_ctx()
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
node = BaseNode(name='child', input_schema=_ModelA)
# We mock _run_node_internal to avoid full execution, we only care about validation in __call__
scheduler._run_node_internal = AsyncMock(return_value=MagicMock(spec=Context))
await scheduler(
ctx,
node,
{'x': 1},
node_name='child',
run_id='1',
)
# Should not raise
@pytest.mark.asyncio
async def test_runtime_schema_validation_raises():
"""Tests that runtime schema validation raises when input mismatches schema."""
ctx, _ = _make_parent_ctx()
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
node = BaseNode(name='child', input_schema=_ModelA)
with pytest.raises(ValidationError):
await scheduler(
ctx,
node,
{'x': 'string'}, # Invalid type for x
node_name='child',
run_id='1',
)
@pytest.mark.asyncio
async def test_runtime_schema_validation_missing_schema_passes():
"""Tests that runtime schema validation passes when no schema is defined."""
ctx, _ = _make_parent_ctx()
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
node = BaseNode(name='child') # No input schema
scheduler._run_node_internal = AsyncMock(return_value=MagicMock(spec=Context))
await scheduler(
ctx,
node,
{'x': 1},
node_name='child',
run_id='1',
)
# Should not raise
@pytest.mark.asyncio
async def test_runtime_schema_validation_content_fallback():
"""Tests that runtime schema validation handles Content objects by extraction."""
ctx, _ = _make_parent_ctx()
ls = _LoopState()
scheduler = DynamicNodeScheduler(state=ls)
node = BaseNode(name='child', input_schema=_ModelA)
scheduler._run_node_internal = AsyncMock(return_value=MagicMock(spec=Context))
from google.genai import types
msg = types.Content(parts=[types.Part(text='{"x": 1}')], role='user')
await scheduler(
ctx,
node,
msg,
node_name='child',
run_id='1',
)
# Should not raise
# =========================================================================
# Replay Sequence Ordering preservation for Dynamic Nodes
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_node_replay_ordering_preserved(
request: pytest.FixtureRequest,
):
"""Test that parallel dynamic nodes maintain their chronological completion order during replay."""
import asyncio
from google.adk.events.request_input import RequestInput
from google.adk.workflow import node
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
from .. import testing_utils
execution_order = []
recorded_winner_vals = []
@node
async def source_a(*, ctx, node_input):
await asyncio.sleep(0.1)
execution_order.append('source_a_executed')
yield 'result_a'
@node
async def source_b(*, ctx, node_input):
# No sleep, completes immediately in Run 1
execution_order.append('source_b_executed')
yield 'result_b'
@node(rerun_on_resume=True)
async def hitl_node(*, ctx, node_input):
if 'req_h' not in ctx.resume_inputs:
yield RequestInput(interrupt_id='req_h', message='input h')
return
execution_order.append(f'hitl_resumed_with_{node_input}')
yield f'h_{node_input}'
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
completed_order = []
async def run_and_record(node_func, run_id):
res = await ctx.run_node(node_func, run_id=run_id)
completed_order.append(res)
return res
task_a = asyncio.create_task(run_and_record(source_a, 'a'))
task_b = asyncio.create_task(run_and_record(source_b, 'b'))
await asyncio.wait([task_a, task_b], return_when=asyncio.ALL_COMPLETED)
winner_val = completed_order[0]
recorded_winner_vals.append(winner_val)
await ctx.run_node(hitl_node, node_input=winner_val, run_id='h')
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, parent)])
runner = testing_utils.InMemoryRunner(node=agent)
# Run 1: source_b finishes first, source_a finishes second. hitl_node interrupts.
events1 = await runner.run_async(testing_utils.get_user_content('start'))
req_events = [e for e in events1 if e.long_running_tool_ids]
assert len(req_events) == 1
assert execution_order == ['source_b_executed', 'source_a_executed']
invocation_id = events1[0].invocation_id
# Clear execution order to track replay/resume behavior accurately
execution_order.clear()
recorded_winner_vals.clear()
# Run 2: Resume with response
resume_payload = types.Content(
role='user',
parts=[
types.Part(
function_response=types.FunctionResponse( # type: ignore[call-arg] # Third-party SDK signature
id='req_h',
name='user_input',
response={'text': 'response_h'},
)
),
],
)
await runner.run_async(
new_message=resume_payload, invocation_id=invocation_id
)
# Assert source_a and source_b were replayed from cache in exact historical order,
# ensuring winner_val correctly resolves to 'result_b' without re-execution.
assert recorded_winner_vals == ['result_b']
@@ -0,0 +1,588 @@
# 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 ctx.run_node(use_as_output=True) dynamic terminal paths."""
from __future__ import annotations
import asyncio
from typing import Any
from typing import AsyncGenerator
from google.adk.agents.context import Context
from google.adk.apps.app import App
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.workflow import BaseNode
from google.adk.workflow import FunctionNode
from google.adk.workflow import node
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import ConfigDict
import pytest
from typing_extensions import override
from .. import testing_utils
from .workflow_testing_utils import get_outputs as _get_outputs
def _make_app(name: str, agent: Workflow, resumable: bool) -> App:
return App(
name=name,
root_agent=agent,
resumability_config=(
ResumabilityConfig(is_resumable=True) if resumable else None
),
)
# ---------------------------------------------------------------------------
# FunctionNode delegates to FunctionNode
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('resumable', [True, False])
@pytest.mark.asyncio
async def test_use_as_output_function_to_function(
request: pytest.FixtureRequest, resumable: bool
):
"""Node A delegates output to dynamic child B via use_as_output=True.
Only B's output event should appear; A should not emit a duplicate.
"""
def func_b() -> str:
return 'from_b'
node_b = FunctionNode(func=func_b)
async def func_a(ctx: Context) -> str:
return await ctx.run_node(node_b, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
# Only one output event (from B). A's output is suppressed.
outputs = _get_outputs(events)
assert outputs == ['from_b']
# ---------------------------------------------------------------------------
# FunctionNode delegates to Workflow
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('resumable', [True, False])
@pytest.mark.asyncio
async def test_use_as_output_function_to_workflow(
request: pytest.FixtureRequest, resumable: bool
):
"""Node A delegates output to a Workflow B via use_as_output=True.
The Workflow's terminal node output should be used as A's output.
"""
def step_1() -> str:
return 'step_1_done'
def step_2(node_input: str) -> str:
return f'final:{node_input}'
inner_wf = Workflow(
name='inner_wf',
edges=[
(START, step_1),
(step_1, step_2),
],
)
async def func_a(ctx: Context):
return await ctx.run_node(inner_wf, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
# step_2 is the terminal node; its output is func_a's output.
# step_1's output is intermediate (not terminal), so only step_2 appears.
outputs = _get_outputs(events)
assert 'final:step_1_done' in outputs
# func_a should NOT have a duplicate output event.
assert outputs.count('final:step_1_done') == 1
# ---------------------------------------------------------------------------
# Custom BaseNode delegates to FunctionNode
# ---------------------------------------------------------------------------
class _DelegatingNode(BaseNode):
"""A custom BaseNode that delegates output via use_as_output."""
model_config = ConfigDict(arbitrary_types_allowed=True)
delegate: BaseNode
@override
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
await ctx.run_node(self.delegate, use_as_output=True)
# Intentionally yield nothing — output comes from delegate.
return
yield # Make this an async generator
@pytest.mark.parametrize('resumable', [True, False])
@pytest.mark.asyncio
async def test_use_as_output_custom_node_to_function(
request: pytest.FixtureRequest, resumable: bool
):
"""Custom BaseNode delegates output to dynamic child via use_as_output."""
def func_b() -> str:
return 'delegated_output'
node_b = FunctionNode(func=func_b)
node_a = _DelegatingNode(
name='delegator', delegate=node_b, rerun_on_resume=True
)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
outputs = _get_outputs(events)
assert outputs == ['delegated_output']
# ---------------------------------------------------------------------------
# Multiple use_as_output=True calls (fan-out delegation)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('resumable', [True, False])
@pytest.mark.asyncio
async def test_use_as_output_multiple_disallowed(
request: pytest.FixtureRequest, resumable: bool
):
"""V2 engine forbids calling use_as_output=True multiple times from the same node."""
def func_b() -> str:
return 'from_b'
def func_c() -> str:
return 'from_c'
node_b = FunctionNode(func=func_b)
node_c = FunctionNode(func=func_c)
async def func_a(ctx: Context):
await ctx.run_node(node_b, use_as_output=True)
# V2 engine should throw ValueError on second call
with pytest.raises(
ValueError, match='already has a use_as_output delegate'
):
await ctx.run_node(node_c, use_as_output=True)
return 'failure_as_expected'
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
outputs = _get_outputs(events)
# func_a's output is suppressed because it successfully delegated to func_b
# before failing on func_c. So only func_b's output appears.
assert outputs == ['from_b']
# ---------------------------------------------------------------------------
# Nested dynamic delegation (A → B → C, all use_as_output=True)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('resumable', [True, False])
@pytest.mark.asyncio
async def test_use_as_output_nested_delegation(
request: pytest.FixtureRequest, resumable: bool
):
"""Chained delegation: A delegates to B, B delegates to C.
Only C's output should appear; both A's and B's outputs are suppressed.
"""
def func_c() -> str:
return 'from_c'
node_c = FunctionNode(func=func_c)
async def func_b(ctx: Context) -> str:
return await ctx.run_node(node_c, use_as_output=True)
node_b = FunctionNode(func=func_b, rerun_on_resume=True)
async def func_a(ctx: Context) -> str:
return await ctx.run_node(node_b, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
outputs = _get_outputs(events)
assert outputs == ['from_c']
@pytest.mark.parametrize('resumable', [True, False])
@pytest.mark.asyncio
async def test_use_as_output_nested_delegation_with_downstream(
request: pytest.FixtureRequest, resumable: bool
):
"""Chained delegation with a downstream node that consumes the output.
A delegates to B, B delegates to C. D is downstream of A and should
receive C's output as its node_input.
"""
def func_c() -> str:
return 'from_c'
node_c = FunctionNode(func=func_c)
async def func_b(ctx: Context) -> str:
return await ctx.run_node(node_c, use_as_output=True)
node_b = FunctionNode(func=func_b, rerun_on_resume=True)
async def func_a(ctx: Context) -> str:
return await ctx.run_node(node_b, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
def func_d(node_input: str) -> str:
return f'received:{node_input}'
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(
name=wf_name,
edges=[(START, node_a), (node_a, func_d)],
)
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
outputs = _get_outputs(events)
assert 'received:from_c' in outputs
# ---------------------------------------------------------------------------
# use_as_output=False (default) still emits both events
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('resumable', [True, False])
@pytest.mark.asyncio
async def test_without_use_as_output_emits_both(
request: pytest.FixtureRequest, resumable: bool
):
"""Without use_as_output, both parent and child emit output events."""
def func_b() -> str:
return 'from_b'
node_b = FunctionNode(func=func_b)
async def func_a(ctx: Context) -> str:
return await ctx.run_node(node_b)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
# Both B and A emit output events (no dedup).
outputs = _get_outputs(events)
assert outputs == ['from_b', 'from_b']
# ---------------------------------------------------------------------------
# use_as_output with Workflow containing HITL
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_use_as_output_workflow_with_hitl(
request: pytest.FixtureRequest,
):
"""Dynamic Workflow child with HITL node pauses and resumes correctly.
Requires resumable=True because the inner Workflow's internal node state
(hitl_step in WAITING) must be persisted across invocations. Without
resumability, re-spawning the dynamic Workflow starts its graph fresh.
Flow:
1. func_a calls ctx.run_node(inner_wf, use_as_output=True)
2. inner_wf's hitl_step yields RequestInput → workflow pauses
3. User responds → workflow resumes
4. inner_wf completes, func_a re-runs with cached result
5. Only inner_wf's terminal node output appears
"""
resumable = True
async def hitl_step():
yield RequestInput(
interrupt_id='wf_req1',
message='enter value',
)
def final_step(node_input: Any) -> str:
text = node_input['text'] if isinstance(node_input, dict) else node_input
return f'final:{text}'
inner_wf = Workflow(
name='inner_wf',
edges=[
(START, hitl_step),
(hitl_step, final_step),
],
)
async def func_a(ctx: Context):
return await ctx.run_node(inner_wf, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
app = _make_app(wf_name, agent, resumable)
runner = testing_utils.InMemoryRunner(app=app)
# Run 1: Should pause at hitl_step.
events1 = await runner.run_async(testing_utils.get_user_content('start'))
outputs1 = _get_outputs(events1)
assert outputs1 == []
# Resume with user response.
invocation_id = events1[0].invocation_id
resume_payload = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id='wf_req1',
name='user_input',
response={'text': 'hello'},
)
)
)
events2 = await runner.run_async(
new_message=resume_payload, invocation_id=invocation_id
)
outputs2 = _get_outputs(events2)
assert 'final:hello' in outputs2
assert outputs2.count('final:hello') == 1
@pytest.mark.asyncio
async def test_use_as_output_static_node_not_rerun_on_resume(
request: pytest.FixtureRequest,
):
"""Static node delegating output to dynamic child is not re-run on resume.
Setup:
- wf with node_a (FunctionNode) and hitl_step.
- node_a calls ctx.run_node(node_b, use_as_output=True).
- hitl_step yields RequestInput to pause.
Act:
- Run 1: Run workflow, node_a executes, hitl_step pauses.
- Run 2: Resume with user response for hitl_step.
Assert:
- Run 1: node_b output is emitted as node_a's output.
- Run 2: node_a is NOT re-run (run_count remains 1).
"""
run_count = 0
@node
def node_b() -> str:
return 'from_b'
@node(rerun_on_resume=True)
async def node_a(ctx: Context):
nonlocal run_count
run_count += 1
return await ctx.run_node(node_b, use_as_output=True)
node_a.wait_for_output = True
@node
async def hitl_step():
yield RequestInput(
interrupt_id='pause_req',
message='pause',
)
@node
def final_step(node_input: Any) -> None:
return None
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(
name=wf_name,
edges=[
(START, node_a),
(START, hitl_step),
(hitl_step, final_step),
],
)
runner = testing_utils.InMemoryRunner(root_agent=agent)
events1 = await runner.run_async(testing_utils.get_user_content('start'))
assert run_count == 1
outputs1 = [e.output for e in events1 if e.output]
assert 'from_b' in outputs1
invocation_id = events1[0].invocation_id
resume_payload = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id='pause_req',
name='user_input',
response={'text': 'continue'},
)
)
)
events2 = await runner.run_async(
new_message=resume_payload, invocation_id=invocation_id
)
assert run_count == 1
@pytest.mark.asyncio
async def test_use_as_output_instance_isolation(request: pytest.FixtureRequest):
"""Output delegation is isolated to specific dynamic instances.
Setup:
- wf with node_a calling node_b twice in parallel with different run_ids ('b1', 'b2').
- node_b yields RequestInput to pause.
Act:
- Run 1: Run workflow, both node_b instances pause.
- Run 2: Resume only the 'b1' instance.
Assert:
- Run 1: Both instances run (run_count_b is 2).
- Run 2: Only instance 'b1' completes and delegates output.
Instance 'b2' remains paused and does not emit output.
"""
run_count_b = 0
@node
def node_c() -> str:
return 'from_c'
@node(rerun_on_resume=True)
async def node_b(ctx: Context):
nonlocal run_count_b
interrupt_id = f'pause_{ctx.node_path}'
if ctx.resume_inputs and interrupt_id in ctx.resume_inputs:
response = ctx.resume_inputs[interrupt_id]
if (
response
and isinstance(response, dict)
and response.get('text') == 'continue'
):
await ctx.run_node(node_c, use_as_output=True)
return
run_count_b += 1
yield RequestInput(
interrupt_id=interrupt_id,
message='pause',
)
@node(rerun_on_resume=True)
async def node_a(ctx: Context):
task1 = ctx.run_node(node_b, run_id='b1')
task2 = ctx.run_node(node_b, run_id='b2')
await asyncio.gather(task1, task2)
wf_name = request.node.name.replace('[', '_').replace(']', '')
agent = Workflow(name=wf_name, edges=[(START, node_a)])
runner = testing_utils.InMemoryRunner(root_agent=agent)
# Given the workflow is started with parallel instances of node_b
events1 = await runner.run_async(testing_utils.get_user_content('start'))
# Then both instances execute and pause
assert run_count_b == 2
# Find the interrupt ID for instance b1
interrupt_id_b1 = None
for event in events1:
if event.long_running_tool_ids:
for interrupt_id in event.long_running_tool_ids:
if 'b1' in interrupt_id:
interrupt_id_b1 = interrupt_id
break
assert interrupt_id_b1 is not None
# When resuming only instance b1
invocation_id = events1[0].invocation_id
resume_payload = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=interrupt_id_b1,
name='user_input',
response={'text': 'continue'},
)
)
)
events2 = await runner.run_async(
new_message=resume_payload, invocation_id=invocation_id
)
# Then node_b is not re-run, and only one output is emitted from node_c
assert run_count_b == 2
outputs2 = [e.output for e in events2 if e.output]
assert outputs2.count('from_c') == 1
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
# 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 and routing."""
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 .workflow_testing_utils import TestingNode
def test_valid_graph() -> None:
"""Tests that a valid graph passes validation."""
node_a = TestingNode(name='NodeA')
graph = Graph(
edges=[
Edge(from_node=START, to_node=node_a),
],
)
graph.validate_graph() # Should not raise
def test_get_next_pending_nodes() -> None:
"""Tests that get_next_pending_nodes returns correct nodes based on routes."""
node_a = TestingNode(name='NodeA')
node_b = TestingNode(name='NodeB')
node_c = TestingNode(name='NodeC')
node_d = TestingNode(name='NodeD')
graph = Graph(
edges=[
Edge(from_node=node_a, to_node=node_b), # Unconditional
Edge(from_node=node_a, to_node=node_c, route='route1'), # Conditional
Edge(
from_node=node_a, to_node=node_d, route=DEFAULT_ROUTE
), # Default
],
)
# Test unconditional edge triggered
next_nodes = graph.get_next_pending_nodes('NodeA', routes_to_match=None)
assert set(next_nodes) == {'NodeB', 'NodeD'}
# Test specific route matched
next_nodes = graph.get_next_pending_nodes('NodeA', routes_to_match='route1')
assert set(next_nodes) == {'NodeB', 'NodeC'}
# Test unmatched route falls back to default
next_nodes = graph.get_next_pending_nodes(
'NodeA', routes_to_match='unknown_route'
)
assert set(next_nodes) == {'NodeB', 'NodeD'}
# Test list of routes to match
next_nodes = graph.get_next_pending_nodes(
'NodeA', routes_to_match=['route1', 'unknown_route']
)
assert set(next_nodes) == {'NodeB', 'NodeC'}
def test_get_next_pending_nodes_unmatched_route_warning(caplog) -> None:
"""Tests that a warning is logged when a route is unmatched and there's no DEFAULT_ROUTE."""
node_a = TestingNode(name='NodeA')
node_c = TestingNode(name='NodeC')
graph = Graph(
edges=[
Edge(from_node=node_a, to_node=node_c, route='route1'),
],
)
with caplog.at_level(logging.WARNING):
next_nodes = graph.get_next_pending_nodes(
'NodeA', routes_to_match='unknown_route'
)
assert not next_nodes
assert any(
'has conditional/DEFAULT edges but none were matched' in record.message
for record in caplog.records
)
+277
View File
@@ -0,0 +1,277 @@
# 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.
"""Testings for the JoinNode."""
from google.adk import workflow
from google.adk.apps import app
from google.adk.workflow import _base_node as base_node
from google.adk.workflow import _graph as workflow_graph
from google.adk.workflow import _join_node as join_node
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from pydantic import BaseModel
import pytest
from . import workflow_testing_utils
from .. import testing_utils
def _build_join_node_workflow(
request: pytest.FixtureRequest,
) -> tuple[
workflow_testing_utils.InputCapturingNode, testing_utils.InMemoryRunner
]:
"""Builds a workflow with a JoinNode."""
node_a = workflow_testing_utils.TestingNode(
name='NodeA', output={'a': 1, 'b': 1}
)
node_b = workflow_testing_utils.TestingNode(name='NodeB', output={'b': 2})
node_join = join_node.JoinNode(name='NodeJoin')
node_capture = workflow_testing_utils.InputCapturingNode(name='NodeCapture')
agent = workflow.Workflow(
name='test_join_node',
edges=[
workflow_graph.Edge(from_node=base_node.START, to_node=node_a),
workflow_graph.Edge(from_node=base_node.START, to_node=node_b),
workflow_graph.Edge(from_node=node_a, to_node=node_join),
workflow_graph.Edge(from_node=node_b, to_node=node_join),
workflow_graph.Edge(from_node=node_join, to_node=node_capture),
],
)
app_instance = app.App(
name=request.function.__name__,
root_agent=agent,
)
return node_capture, testing_utils.InMemoryRunner(app=app_instance)
def test_get_common_branch_prefix():
"""Tests _get_common_branch_prefix."""
assert join_node._get_common_branch_prefix(['A@1', 'A@2']) == ''
assert join_node._get_common_branch_prefix(['A@1.B@1', 'A@1.B@2']) == 'A@1'
assert join_node._get_common_branch_prefix(['A@1', 'A@1']) == 'A@1'
assert join_node._get_common_branch_prefix(['A@1', '']) == ''
assert join_node._get_common_branch_prefix(['', '']) == ''
assert join_node._get_common_branch_prefix([]) == ''
@pytest.mark.asyncio
async def test_join_node_waits_for_all_inputs(request: pytest.FixtureRequest):
"""Tests JoinNode with fan-in."""
node_capture, runner = _build_join_node_workflow(request)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert node_capture.received_inputs == [{
'NodeA': {'a': 1, 'b': 1},
'NodeB': {'b': 2},
}]
@pytest.mark.asyncio
async def test_join_node_with_none_state(request: pytest.FixtureRequest):
"""Tests JoinNode with fan-in when node state is None."""
node_capture, runner = _build_join_node_workflow(request)
# Run once to set state to None
await runner.run_async(testing_utils.get_user_content('start'))
# Run again to trigger join_node with state=None
await runner.run_async(testing_utils.get_user_content('start'))
assert node_capture.received_inputs == [
{'NodeA': {'a': 1, 'b': 1}, 'NodeB': {'b': 2}},
{'NodeA': {'a': 1, 'b': 1}, 'NodeB': {'b': 2}},
]
@pytest.mark.asyncio
async def test_join_node_with_none_inputs(request: pytest.FixtureRequest):
"""Tests JoinNode with fan-in when incoming edges have None output."""
node_a = workflow_testing_utils.TestingNode(
name='NodeA', output=None, route='NodeJoin'
)
node_b = workflow_testing_utils.TestingNode(
name='NodeB', output=None, route='NodeJoin'
)
node_join = join_node.JoinNode(name='NodeJoin')
node_capture = workflow_testing_utils.InputCapturingNode(name='NodeCapture')
agent = workflow.Workflow(
name='test_join_node_none_inputs',
edges=[
workflow_graph.Edge(from_node=base_node.START, to_node=node_a),
workflow_graph.Edge(from_node=base_node.START, to_node=node_b),
workflow_graph.Edge(from_node=node_a, to_node=node_join),
workflow_graph.Edge(from_node=node_b, to_node=node_join),
workflow_graph.Edge(from_node=node_join, to_node=node_capture),
],
)
app_instance = app.App(
name=request.function.__name__,
root_agent=agent,
)
runner = testing_utils.InMemoryRunner(app=app_instance)
await runner.run_async(testing_utils.get_user_content('start'))
assert node_capture.received_inputs == [
{'NodeA': None, 'NodeB': None},
]
# ── JoinNode input_schema ──────────────────────────────────────
# input_schema on JoinNode validates each trigger input individually
# (each predecessor's output), not the joined dict.
class _TriggerInput(BaseModel):
key: str
value: int
@pytest.mark.asyncio
async def test_join_node_input_schema_validates_per_trigger(
request: pytest.FixtureRequest,
):
"""JoinNode input_schema validates each trigger input individually."""
def node_a() -> dict:
return {'key': 'a', 'value': 1}
def node_b() -> dict:
return {'key': 'b', 'value': 2}
join = join_node.JoinNode(name='join', input_schema=_TriggerInput)
capture = workflow_testing_utils.InputCapturingNode(name='capture')
agent = Workflow(
name='wf',
edges=[
(START, node_a),
(START, node_b),
(node_a, join),
(node_b, join),
(join, capture),
],
)
app_instance = app.App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app_instance)
await runner.run_async(testing_utils.get_user_content('start'))
assert capture.received_inputs == [{
'node_a': {'key': 'a', 'value': 1},
'node_b': {'key': 'b', 'value': 2},
}]
@pytest.mark.asyncio
async def test_join_node_input_schema_rejects_invalid_trigger(
request: pytest.FixtureRequest,
):
"""JoinNode input_schema rejects invalid trigger input early."""
def node_a() -> dict:
return {'key': 'a', 'value': 1}
def node_b() -> dict:
return {'wrong': 'shape'} # missing required fields
join = join_node.JoinNode(name='join', input_schema=_TriggerInput)
capture = workflow_testing_utils.InputCapturingNode(name='capture')
agent = Workflow(
name='wf',
edges=[
(START, node_a),
(START, node_b),
(node_a, join),
(node_b, join),
(join, capture),
],
)
app_instance = app.App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app_instance)
with pytest.raises(Exception):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_join_node_input_schema_none_trigger_passes(
request: pytest.FixtureRequest,
):
"""JoinNode input_schema skips validation for None trigger input."""
# Given
node_a_fn = workflow_testing_utils.TestingNode(
name='NodeA', output=None, route='join'
)
node_b_fn = workflow_testing_utils.TestingNode(
name='NodeB', output={'key': 'b', 'value': 2}
)
join = join_node.JoinNode(name='join', input_schema=_TriggerInput)
capture = workflow_testing_utils.InputCapturingNode(name='capture')
agent = Workflow(
name='wf',
edges=[
(START, node_a_fn),
(START, node_b_fn),
(node_a_fn, join),
(node_b_fn, join),
(join, capture),
],
)
app_instance = app.App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app_instance)
# When
await runner.run_async(testing_utils.get_user_content('start'))
# Then
assert capture.received_inputs == [{
'NodeA': None,
'NodeB': {'key': 'b', 'value': 2},
}]
@pytest.mark.asyncio
async def test_join_node_computes_common_branch_prefix(
request: pytest.FixtureRequest,
):
"""Tests JoinNode computes common branch prefix for final output."""
node_capture, runner = _build_join_node_workflow(request)
events = await runner.run_async(testing_utils.get_user_content('start'))
# Find the final output event from JoinNode
join_events = [
e
for e in events
if 'NodeJoin' in e.node_info.path and e.output is not None
]
assert len(join_events) == 1
join_event = join_events[0]
# NodeA and NodeB run in parallel, so they should have branches like 'NodeA@1' and 'NodeB@1'.
a_events = [e for e in events if 'NodeA' in e.node_info.path]
b_events = [e for e in events if 'NodeB' in e.node_info.path]
assert any('NodeA@' in e.branch for e in a_events if e.branch)
assert any('NodeB@' in e.branch for e in b_events if e.branch)
# The common prefix of 'NodeA@1' and 'NodeB@1' is empty string.
# So JoinNode should set branch to empty string (which is converted to None).
assert join_event.branch is None
# The node after JoinNode (NodeCapture) should also have branch=None
capture_events = [e for e in events if 'NodeCapture' in e.node_info.path]
assert len(capture_events) > 0
for e in capture_events:
assert e.branch is None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,658 @@
# 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 NodeRunner → Context as result channel.
Verifies that NodeRunner correctly populates ctx.output, ctx.route,
and ctx.interrupt_ids from yielded events and direct assignment,
and that resume state (prior_output, prior_interrupt_ids) is carried
forward correctly.
"""
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
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.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._node_runner import NodeRunner
from google.genai import types
import pytest
# --- Helpers ---
def _make_ctx(invocation_id='inv-test', enqueue_events=None, node_path=''):
"""Create a minimal Context mock with IC."""
mock_agent = MagicMock(spec=BaseAgent)
real_session = Session(
id='test_session', app_name='test_app', user_id='test_user'
)
real_session_service = InMemorySessionService()
ic = InvocationContext(
invocation_id=invocation_id,
agent=mock_agent,
session=real_session,
session_service=real_session_service,
)
collected = enqueue_events if enqueue_events is not None else []
async def _enqueue(event):
collected.append(event)
object.__setattr__(ic, '_enqueue_event', AsyncMock(side_effect=_enqueue))
ctx = Context(
invocation_context=ic,
node_path=node_path,
)
return ctx, collected
# =========================================================================
# Context as RESULT — fields populated by NodeRunner after execution
# =========================================================================
# --- ctx.output from yielded events ---
@pytest.mark.asyncio
async def test_yield_value_sets_ctx_output():
"""Yielding a value sets ctx.output on the returned context."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'hello'
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'hello'
@pytest.mark.asyncio
async def test_yield_event_output_sets_ctx_output():
"""Yielding Event(output=X) sets ctx.output."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='from_event')
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'from_event'
@pytest.mark.asyncio
async def test_no_yield_leaves_ctx_output_none():
"""A node that yields nothing leaves ctx.output as None."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
return
yield # noqa: unreachable
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output is None
# --- ctx.output set directly ---
@pytest.mark.asyncio
async def test_ctx_output_set_directly():
"""Setting ctx.output directly produces a deferred output event."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.output = 'direct'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'direct'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 1
assert output_events[0].output == 'direct'
@pytest.mark.asyncio
async def test_ctx_output_direct_with_state_delta():
"""Deferred output bundles pending state deltas onto the same event."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.state['key'] = 'val'
ctx.output = 'result'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'result'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 1
assert output_events[0].actions.state_delta['key'] == 'val'
@pytest.mark.asyncio
async def test_deferred_output_emitted_after_intermediate():
"""ctx.output set directly emits after intermediate content events."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.output = 'deferred'
yield Event(content=types.Content(parts=[types.Part(text='working')]))
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'deferred'
assert len(events) == 2
assert events[0].content.parts[0].text == 'working'
assert events[1].output == 'deferred'
# --- ctx.output validation ---
@pytest.mark.asyncio
async def test_double_output_raises():
"""Setting ctx.output twice raises ValueError."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.output = 'first'
ctx.output = 'second'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='n'), parent_ctx=parent_ctx).run()
error_events = [e for e in events if e.error_code]
assert len(error_events) == 1
assert error_events[0].error_code == 'ValueError'
assert 'already set' in error_events[0].error_message
@pytest.mark.asyncio
async def test_yield_then_ctx_output_raises():
"""Yielding output then setting ctx.output raises ValueError."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'first'
ctx.output = 'second'
parent_ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='n'), parent_ctx=parent_ctx).run()
error_events = [e for e in events if e.error_code]
assert len(error_events) == 1
assert error_events[0].error_code == 'ValueError'
assert 'already set' in error_events[0].error_message
# --- ctx.route ---
@pytest.mark.asyncio
async def test_yield_route_sets_ctx_route():
"""Yielding Event(route=R) sets ctx.route."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='out', route='next')
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'out'
assert child_ctx.route == 'next'
@pytest.mark.asyncio
async def test_ctx_route_set_directly():
"""Setting ctx.route directly is readable after run."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.route = 'branch_a'
yield 'out'
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.route == 'branch_a'
# --- ctx.interrupt_ids ---
@pytest.mark.asyncio
async def test_interrupt_sets_ctx_interrupt_ids():
"""Yielding an interrupt event populates ctx.interrupt_ids."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.interrupt_ids == {'fc-1'}
assert child_ctx.output is None
@pytest.mark.asyncio
async def test_output_and_interrupt_coexist():
"""Output and interrupt can coexist across separate events."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'result'
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'result'
assert child_ctx.interrupt_ids == {'fc-1'}
@pytest.mark.asyncio
async def test_duplicate_interrupt_ids_deduplicated():
"""Duplicate interrupt IDs are deduplicated (set semantics)."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(long_running_tool_ids={'fc-1', 'fc-2'})
yield Event(long_running_tool_ids={'fc-2', 'fc-3'})
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.interrupt_ids == {'fc-1', 'fc-2', 'fc-3'}
# --- Output delegation (use_as_output) ---
@pytest.mark.asyncio
async def test_delegated_output_not_enqueued():
"""When output is delegated, the output event is not enqueued."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx._output_delegated = True
yield 'delegated_value'
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'delegated_value'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 0
@pytest.mark.asyncio
async def test_delegated_ctx_output_not_emitted():
"""When output is delegated and set via ctx.output, no event emitted."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx._output_delegated = True
ctx.output = 'delegated_direct'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'delegated_direct'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 0
@pytest.mark.asyncio
async def test_delegated_output_preserves_event_details():
"""When output is delegated, the event is enqueued but output is suppressed."""
from google.adk.events.event_actions import EventActions
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx._output_delegated = True
yield Event(
output='delegated_value',
actions=EventActions(state_delta={'foo': 'bar'}),
content=types.Content(role='model', parts=[types.Part(text='hello')]),
)
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'delegated_value'
assert len(events) == 1
event = events[0]
assert event.output is None
assert event.actions.state_delta == {'foo': 'bar'}
assert event.content.parts[0].text == 'hello'
# =========================================================================
# Context as INPUT — resume state provided to NodeRunner at construction
# =========================================================================
@pytest.mark.asyncio
async def test_prior_output_carried_forward():
"""Prior output from a previous run is available on ctx.output."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
return
yield # noqa: unreachable
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
prior_output='cached_result',
).run()
assert child_ctx.output == 'cached_result'
@pytest.mark.asyncio
async def test_prior_interrupt_ids_carried_forward():
"""Prior interrupt IDs from a previous run are on ctx."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
return
yield # noqa: unreachable
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
prior_interrupt_ids={'fc-old'},
).run()
assert 'fc-old' in child_ctx.interrupt_ids
@pytest.mark.asyncio
async def test_prior_and_new_interrupt_ids_merged():
"""New interrupt IDs are merged with prior ones."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-new'
)
)
]
),
long_running_tool_ids={'fc-new'},
)
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
prior_interrupt_ids={'fc-old'},
).run()
assert child_ctx.interrupt_ids == {'fc-old', 'fc-new'}
# =========================================================================
# event_author — parent orchestrator overrides event author
# =========================================================================
@pytest.mark.asyncio
async def test_event_author_defaults_to_node_name():
"""Without event_author, events use the node's own name."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'result'
parent_ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()
assert events[0].author == 'my_node'
@pytest.mark.asyncio
async def test_event_author_overrides_node_name():
"""When parent sets event_author, events use that instead."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'result'
parent_ctx, events = _make_ctx()
parent_ctx.event_author = 'my_workflow'
await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()
assert events[0].author == 'my_workflow'
@pytest.mark.asyncio
async def test_event_author_overrides_preset_author():
"""event_author always wins, even over a pre-set event author."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(author='custom_author', output='result')
parent_ctx, events = _make_ctx()
parent_ctx.event_author = 'my_workflow'
await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()
assert events[0].author == 'my_workflow'
# =========================================================================
# Branch propagation tests
# =========================================================================
@pytest.mark.asyncio
async def test_override_branch_used_in_node_runner():
"""NodeRunner uses override_branch if provided."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result')
parent_ctx, events = _make_ctx()
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
override_branch='custom_branch',
).run()
assert events[0].branch == 'custom_branch'
@pytest.mark.asyncio
async def test_use_sub_branch_appends_segment_to_branch():
"""NodeRunner appends node_name@run_id to branch when use_sub_branch is True."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result')
parent_ctx, events = _make_ctx()
parent_ctx._invocation_context.branch = 'parent_branch'
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
use_sub_branch=True,
run_id='1',
).run()
assert events[0].branch == 'parent_branch.n@1'
@pytest.mark.asyncio
async def test_sequential_branch_propagation():
"""NodeRunner inherits parent branch when use_sub_branch is False."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result')
parent_ctx, events = _make_ctx()
parent_ctx._invocation_context.branch = 'parent_branch'
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
use_sub_branch=False,
).run()
assert events[0].branch == 'parent_branch'
@pytest.mark.asyncio
async def test_child_event_branch_does_not_mutate_parent_ic():
"""A child node altering its branch does not mutate the parent's shared InvocationContext branch."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result', branch='new_child_branch')
parent_ctx, events = _make_ctx()
parent_ctx._invocation_context.branch = 'parent_branch'
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
use_sub_branch=False,
).run()
assert events[0].branch == 'new_child_branch'
# The parent's branch must remain unchanged.
assert parent_ctx._invocation_context.branch == 'parent_branch'
@pytest.mark.asyncio
async def test_override_isolation_scope_used_in_node_runner():
"""NodeRunner sets isolation_scope on child context and enriches emitted events."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
assert ctx.isolation_scope == 'task:fc-999'
yield Event(output='result')
parent_ctx, events = _make_ctx()
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
override_isolation_scope='task:fc-999',
).run()
assert events[0].isolation_scope == 'task:fc-999'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,539 @@
# 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 NodeRunner ↔ node integration.
Verifies that NodeRunner correctly drives BaseNode.run(), enriches
events, flushes state/artifact deltas, and delivers events to the
session.
"""
from typing import Any
from typing import AsyncGenerator
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
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.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._node_runner import NodeRunner
from google.genai import types
import pytest
# --- Test helper nodes ---
class _EchoNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield node_input
class _EmptyNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
return
yield
class _MultiEventNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(author='step1')
yield Event(author='step2')
yield Event(author='step3')
class _InterruptNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='long_tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
class _InterruptThenMoreNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='long_tool', args={}, id='fc-2'
)
)
]
),
long_running_tool_ids={'fc-2'},
)
yield Event(author='after_interrupt_1')
yield Event(author='after_interrupt_2')
class _ErrorNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
raise RuntimeError('node failure')
yield # pylint: disable=unreachable
class _OutputWithRouteNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(output='routed_output', route='next')
class _StateMutatingNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
ctx.state['key1'] = 'value1'
ctx.state['key2'] = 42
yield 'done'
class _ResumeInputReadingNode(BaseNode):
captured: list[Any] = []
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
self.captured.append(ctx.resume_inputs)
yield 'resumed'
class _ArtifactSavingNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
ctx.actions.artifact_delta['doc.txt'] = 1
yield 'saved'
# --- Helpers ---
def _make_ctx(invocation_id='inv-test', enqueue_events=None, node_path=''):
"""Create a minimal Context mock with IC."""
mock_agent = MagicMock(spec=BaseAgent)
real_session = Session(
id='test_session', app_name='test_app', user_id='test_user'
)
real_session_service = InMemorySessionService()
ic = InvocationContext(
invocation_id=invocation_id,
agent=mock_agent,
session=real_session,
session_service=real_session_service,
)
collected = enqueue_events if enqueue_events is not None else []
async def _enqueue(event):
collected.append(event)
object.__setattr__(ic, '_enqueue_event', AsyncMock(side_effect=_enqueue))
ctx = Context(
invocation_context=ic,
node_path=node_path,
)
return ctx, collected
# --- Tests ---
@pytest.mark.asyncio
async def test_node_output_returned_in_result():
"""Running a node that produces output returns it in the result."""
node = _EchoNode(name='echo')
ctx, _ = _make_ctx()
result = await NodeRunner(node=node, parent_ctx=ctx).run(node_input='hello')
assert result.output == 'hello'
assert result.interrupt_ids == set()
@pytest.mark.asyncio
async def test_no_output_returns_none():
"""Running a node that produces no output returns None."""
node = _EmptyNode(name='empty')
ctx, _ = _make_ctx()
result = await NodeRunner(node=node, parent_ctx=ctx).run()
assert result.output is None
assert result.interrupt_ids == set()
@pytest.mark.asyncio
async def test_event_author_is_node_name():
"""Events are authored by the node's name."""
node = _EchoNode(name='my_node')
ctx, events = _make_ctx()
await NodeRunner(node=node, parent_ctx=ctx).run(node_input='data')
output_events = [e for e in events if e.output is not None]
assert output_events[0].author == 'my_node'
@pytest.mark.asyncio
async def test_event_path_contains_node_name():
"""Event node_info.path includes the node name and execution context."""
node = _EchoNode(name='path_test')
ctx, events = _make_ctx(invocation_id='inv-123')
runner = NodeRunner(node=node, parent_ctx=ctx, run_id='exec-456')
await runner.run(node_input='data')
output_events = [e for e in events if e.output is not None]
event = output_events[0]
assert event.node_info.path == 'path_test@exec-456'
assert event.invocation_id == 'inv-123'
@pytest.mark.asyncio
async def test_interrupt_captured_in_result():
"""A node that signals an interrupt reports it in the result."""
node = _InterruptNode(name='interrupt_node')
ctx, _ = _make_ctx()
result = await NodeRunner(node=node, parent_ctx=ctx).run()
assert 'fc-1' in result.interrupt_ids
@pytest.mark.asyncio
async def test_node_continues_after_interrupt():
"""A node that interrupts can still produce more events before finishing."""
node = _InterruptThenMoreNode(name='flag_finish')
ctx, events = _make_ctx()
result = await NodeRunner(node=node, parent_ctx=ctx).run()
assert 'fc-2' in result.interrupt_ids
assert len(events) >= 3
@pytest.mark.asyncio
async def test_state_mutations_emitted_as_delta():
"""State changes made by a node are delivered as a separate event."""
node = _StateMutatingNode(name='state_node')
ctx, events = _make_ctx()
await NodeRunner(node=node, parent_ctx=ctx).run()
all_deltas = {}
for e in events:
if e.actions.state_delta:
all_deltas.update(e.actions.state_delta)
assert all_deltas.get('key1') == 'value1'
assert all_deltas.get('key2') == 42
@pytest.mark.asyncio
async def test_artifact_delta_emitted():
"""Artifact saves made by a node are delivered as a delta event."""
node = _ArtifactSavingNode(name='artifact_node')
ctx, events = _make_ctx()
await NodeRunner(node=node, parent_ctx=ctx).run()
artifact_deltas = {}
for e in events:
if e.actions.artifact_delta:
artifact_deltas.update(e.actions.artifact_delta)
assert 'doc.txt' in artifact_deltas
@pytest.mark.asyncio
async def test_events_enqueued_in_yield_order():
"""Multiple events from a node arrive in the order they were produced."""
node = _MultiEventNode(name='multi')
ctx, events = _make_ctx()
await NodeRunner(node=node, parent_ctx=ctx).run()
# All 3 events enqueued, authored by node name (framework overrides).
assert len(events) == 3
assert all(e.author == 'multi' for e in events)
@pytest.mark.asyncio
async def test_node_exception_propagates():
"""A node that raises an error surfaces it to the caller."""
node = _ErrorNode(name='error_node')
ctx, events = _make_ctx()
child_ctx = await NodeRunner(node=node, parent_ctx=ctx).run()
# Verify error is recorded on returned context
assert child_ctx.error is not None
assert isinstance(child_ctx.error, RuntimeError)
assert str(child_ctx.error) == 'node failure'
# Verify error event was enqueued
error_events = [e for e in events if e.error_code]
assert len(error_events) == 1
assert error_events[0].error_code == 'RuntimeError'
assert 'node failure' in error_events[0].error_message
@pytest.mark.asyncio
async def test_resume_inputs_available_on_context():
"""Resume inputs are accessible to the node during execution."""
node = _ResumeInputReadingNode(name='resume_node')
node.captured = []
ctx, _ = _make_ctx()
resume = {'int-1': 'user_response'}
await NodeRunner(node=node, parent_ctx=ctx).run(resume_inputs=resume)
assert node.captured[0] == resume
@pytest.mark.asyncio
async def test_node_path_includes_parent():
"""A child node's node_path is parent_node_path/child_name."""
node = _EchoNode(name='child')
ctx, events = _make_ctx(node_path='parent_path')
runner = NodeRunner(node=node, parent_ctx=ctx)
await runner.run(node_input='x')
output_events = [e for e in events if e.output is not None]
assert output_events[0].node_info.path == 'parent_path/child@1'
@pytest.mark.asyncio
async def test_run_id_generated_when_omitted():
"""Each node run gets a unique execution ID by default."""
node = _EchoNode(name='auto_id')
ctx, _ = _make_ctx()
runner = NodeRunner(node=node, parent_ctx=ctx)
assert runner.run_id
assert isinstance(runner.run_id, str)
@pytest.mark.asyncio
async def test_explicit_run_id_used():
"""A caller-specified execution ID is used on the runner and events."""
node = _EchoNode(name='explicit_id')
ctx, events = _make_ctx()
runner = NodeRunner(node=node, parent_ctx=ctx, run_id='my-exec-id')
assert runner.run_id == 'my-exec-id'
await runner.run(node_input='data')
assert events[0].node_info.path == 'explicit_id@my-exec-id'
@pytest.mark.asyncio
async def test_route_captured_in_result():
"""A node's routing decision is available in the result."""
node = _OutputWithRouteNode(name='route_node')
ctx, _ = _make_ctx()
result = await NodeRunner(node=node, parent_ctx=ctx).run()
assert result.output == 'routed_output'
assert result.route == 'next'
@pytest.mark.asyncio
async def test_preset_author_overridden_by_framework():
"""Framework always sets author — preset author is overridden."""
node = _MultiEventNode(name='multi')
ctx, events = _make_ctx()
await NodeRunner(node=node, parent_ctx=ctx).run()
# All events get node name, not the preset 'step1'/'step2'/'step3'.
assert all(e.author == 'multi' for e in events)
class _MultiOutputNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(output='first')
yield Event(output='second')
@pytest.mark.asyncio
async def test_multiple_outputs_raises():
"""A node that produces more than one output is rejected."""
node = _MultiOutputNode(name='multi_out')
ctx, events = _make_ctx()
await NodeRunner(node=node, parent_ctx=ctx).run()
error_events = [e for e in events if e.error_code]
assert len(error_events) == 1
assert error_events[0].error_code == 'ValueError'
assert 'at most one output' in error_events[0].error_message
@pytest.mark.asyncio
async def test_all_events_delivered():
"""All events from a node are delivered to the session."""
node = _EchoNode(name='enqueue_test')
ctx, events = _make_ctx()
await NodeRunner(node=node, parent_ctx=ctx).run(node_input='data')
assert len(events) >= 1
# --- Delta flushing tests ---
@pytest.mark.asyncio
async def test_state_delta_bundled_with_output_event():
"""State deltas set before yield are flushed onto the output event."""
class _Node(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
ctx.state['color'] = 'blue'
ctx.state['count'] = 7
yield 'result'
ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='bundled'), parent_ctx=ctx).run()
assert len(events) == 1
assert events[0].output == 'result'
assert events[0].actions.state_delta.get('color') == 'blue'
assert events[0].actions.state_delta.get('count') == 7
@pytest.mark.asyncio
async def test_state_after_last_yield_emitted_separately():
"""State set after the last yield is emitted as a separate event."""
class _Node(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield 'early'
ctx.state['late_key'] = 'late_value'
ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='late_state'), parent_ctx=ctx).run()
assert events[0].output == 'early'
assert events[1].actions.state_delta.get('late_key') == 'late_value'
@pytest.mark.asyncio
async def test_deltas_skip_partial_events():
"""Partial events carry no deltas — deltas flush to next non-partial."""
class _Node(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
ctx.state['before_partial'] = True
yield Event(
content=types.Content(parts=[types.Part(text='streaming...')]),
partial=True,
)
ctx.state['after_partial'] = True
yield 'final'
ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='partial_skip'), parent_ctx=ctx).run()
assert events[0].partial is True
assert not events[0].actions or not events[0].actions.state_delta
assert events[1].output == 'final'
assert events[1].actions.state_delta.get('before_partial') is True
assert events[1].actions.state_delta.get('after_partial') is True
@pytest.mark.asyncio
async def test_artifact_and_state_bundled_together():
"""Both state and artifact deltas are flushed onto the same event."""
class _Node(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
ctx.state['s1'] = 'v1'
ctx.actions.artifact_delta['file.txt'] = 1
yield 'done'
ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='both_deltas'), parent_ctx=ctx).run()
assert len(events) == 1
assert events[0].output == 'done'
assert events[0].actions.state_delta.get('s1') == 'v1'
assert events[0].actions.artifact_delta.get('file.txt') == 1
@pytest.mark.asyncio
async def test_node_input_schema_validation():
"""NodeRunner fails if node input does not match input_schema."""
from pydantic import BaseModel
from pydantic import ValidationError
class _MyInput(BaseModel):
name: str
age: int
node = _EchoNode(name='schema_node', input_schema=_MyInput)
ctx, _ = _make_ctx()
# Valid input (dict that matches schema)
result = await NodeRunner(node=node, parent_ctx=ctx).run(
node_input={'name': 'Alice', 'age': 30}
)
# _validate_schema converts model instances to dicts!
assert isinstance(result.output, dict)
assert result.output['name'] == 'Alice'
assert result.output['age'] == 30
# Invalid input (missing field)
result = await NodeRunner(node=node, parent_ctx=ctx).run(
node_input={'name': 'Alice'}
)
assert result.error is not None
assert isinstance(result.error, ValidationError)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,424 @@
# 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 Workflow state_schema runtime enforcement."""
from __future__ import annotations
from typing import Optional
from google.adk.agents.context import Context
from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.sessions.state import State
from google.adk.sessions.state import StateSchemaError
from google.adk.workflow import FunctionNode
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from pydantic import BaseModel
import pytest
from .. import testing_utils
from .workflow_testing_utils import create_parent_invocation_context
# ── Schema models for testing ────────────────────────────────────────
class _PipelineSchema(BaseModel):
counter: int
name: str
optional_field: Optional[str] = None
class _NodeSchema(BaseModel):
x: int
y: int
# ── Unit tests: State validation ─────────────────────────────────────
def test_state_rejects_unknown_key() -> None:
"""State with schema raises on unknown key."""
state = State(value={}, delta={}, schema=_PipelineSchema)
with pytest.raises(StateSchemaError, match='bad_key'):
state['bad_key'] = 'value'
def test_state_accepts_declared_key() -> None:
"""State with schema accepts keys that exist in the schema."""
state = State(value={}, delta={}, schema=_PipelineSchema)
state['counter'] = 5
state['name'] = 'hello'
assert state['counter'] == 5
assert state['name'] == 'hello'
def test_state_rejects_wrong_type() -> None:
"""State with schema raises when value type doesn't match annotation."""
state = State(value={}, delta={}, schema=_PipelineSchema)
with pytest.raises(StateSchemaError, match='counter'):
state['counter'] = 'not_an_int'
def test_state_accepts_optional_none() -> None:
"""Optional fields accept None."""
state = State(value={}, delta={}, schema=_PipelineSchema)
state['optional_field'] = None
assert state['optional_field'] is None
def test_state_allows_prefixed_keys() -> None:
"""Prefixed keys (app:, user:, temp:) bypass schema validation."""
state = State(value={}, delta={}, schema=_PipelineSchema)
state['app:anything'] = 'value'
state['user:pref'] = 42
state['temp:cache'] = [1, 2, 3]
assert state['app:anything'] == 'value'
def test_state_update_validates_all_keys() -> None:
"""State.update validates each key-value pair."""
state = State(value={}, delta={}, schema=_PipelineSchema)
with pytest.raises(StateSchemaError, match='unknown'):
state.update({'counter': 1, 'unknown': 'x'})
def test_state_no_schema_allows_all() -> None:
"""Without schema, any key/value is accepted (backward compat)."""
state = State(value={}, delta={})
state['anything'] = 'goes'
state['whatever'] = 42
assert state['anything'] == 'goes'
# ── Startup validation tests ─────────────────────────────────────────
def test_startup_rejects_mismatched_param() -> None:
"""FunctionNode param not in state_schema raises at construction."""
def node_with_bad_param(ctx: Context, unknown_param: str) -> str:
return 'done'
with pytest.raises(StateSchemaError, match='unknown_param'):
Workflow(
name='wf',
edges=[(START, node_with_bad_param)],
state_schema=_PipelineSchema,
)
def test_startup_accepts_matching_params() -> None:
"""FunctionNode params matching schema fields pass construction."""
def node_with_good_params(ctx: Context, counter: int, name: str) -> str:
return 'done'
wf = Workflow(
name='wf',
edges=[(START, node_with_good_params)],
state_schema=_PipelineSchema,
)
assert wf.state_schema is _PipelineSchema
def test_startup_skips_ctx_and_node_input() -> None:
"""Framework params (ctx, node_input) are not checked against schema."""
def node_with_framework_params(ctx: Context, node_input: str) -> str:
return 'done'
Workflow(
name='wf',
edges=[(START, node_with_framework_params)],
state_schema=_PipelineSchema,
)
def test_startup_no_validation_when_schema_none() -> None:
"""No startup validation when state_schema is not set."""
def node_with_any_param(ctx: Context, anything: str) -> str:
return 'done'
Workflow(
name='wf',
edges=[(START, node_with_any_param)],
)
def test_workflow_state_schema_field_exists() -> None:
"""Workflow accepts a state_schema parameter."""
def produce_done():
return Event(output='done')
wf = Workflow(
name='wf',
edges=[(START, produce_done)],
state_schema=_PipelineSchema,
)
assert wf.state_schema is _PipelineSchema
def test_workflow_state_schema_defaults_to_none() -> None:
"""state_schema defaults to None when not provided."""
def produce_done():
return Event(output='done')
wf = Workflow(
name='wf',
edges=[(START, produce_done)],
)
assert wf.state_schema is None
# ── Runtime enforcement tests (workflow execution) ───────────────────
@pytest.mark.asyncio
async def test_workflow_valid_state_writes_succeed(
request: pytest.FixtureRequest,
) -> None:
"""A workflow with valid state writes runs without errors."""
def write_state(ctx: Context) -> str:
ctx.state['counter'] = 5
ctx.state['name'] = 'hello'
return 'done'
wf = Workflow(
name='wf',
edges=[(START, write_state)],
state_schema=_PipelineSchema,
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_workflow_rejects_unknown_key_via_ctx_state(
request: pytest.FixtureRequest,
) -> None:
"""ctx.state write with unknown key raises StateSchemaError."""
def write_bad_key(ctx: Context) -> str:
ctx.state['unknown_key'] = 'value'
return 'done'
wf = Workflow(
name='wf',
edges=[(START, write_bad_key)],
state_schema=_PipelineSchema,
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(StateSchemaError, match='unknown_key'):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_workflow_rejects_unknown_key_via_event_state(
request: pytest.FixtureRequest,
) -> None:
"""Event(state={...}) with unknown key raises StateSchemaError."""
def emit_bad_state() -> Event:
return Event(state={'arbitrary_key': 'value'}, output='done')
wf = Workflow(
name='wf',
edges=[(START, emit_bad_state)],
state_schema=_PipelineSchema,
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(StateSchemaError, match='arbitrary_key'):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_workflow_accepts_valid_event_state(
request: pytest.FixtureRequest,
) -> None:
"""Event(state={...}) with valid keys succeeds."""
def emit_valid_state() -> Event:
return Event(state={'counter': 10, 'name': 'ok'}, output='done')
wf = Workflow(
name='wf',
edges=[(START, emit_valid_state)],
state_schema=_PipelineSchema,
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_workflow_allows_prefixed_keys_at_runtime(
request: pytest.FixtureRequest,
) -> None:
"""Prefixed keys bypass schema validation during workflow execution."""
def write_prefixed(ctx: Context) -> str:
ctx.state['temp:debug'] = True
ctx.state['app:config'] = 'val'
return 'done'
wf = Workflow(
name='wf',
edges=[(START, write_prefixed)],
state_schema=_PipelineSchema,
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_workflow_without_schema_allows_anything(
request: pytest.FixtureRequest,
) -> None:
"""When state_schema=None, any key/value is accepted (backward compat)."""
def write_anything(ctx: Context) -> str:
ctx.state['any_key'] = 'any_value'
ctx.state['another'] = 42
return 'done'
wf = Workflow(
name='wf',
edges=[(START, write_anything)],
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
# ── Per-node state_schema tests ────────────────────────────────────
@pytest.mark.asyncio
async def test_node_level_schema_validates_writes(
request: pytest.FixtureRequest,
) -> None:
"""A FunctionNode with its own state_schema validates state writes."""
def write_bad_key(ctx: Context) -> str:
ctx.state['bad_key'] = 'value'
return 'done'
node = FunctionNode(
name='guarded',
func=write_bad_key,
state_schema=_NodeSchema,
)
wf = Workflow(
name='wf',
edges=[(START, node)],
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(StateSchemaError, match='bad_key'):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_node_level_schema_accepts_valid_writes(
request: pytest.FixtureRequest,
) -> None:
"""A FunctionNode with its own state_schema accepts declared keys."""
def write_good_keys(ctx: Context) -> str:
ctx.state['x'] = 1
ctx.state['y'] = 2
return 'done'
node = FunctionNode(
name='guarded',
func=write_good_keys,
state_schema=_NodeSchema,
)
wf = Workflow(
name='wf',
edges=[(START, node)],
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_node_schema_overrides_workflow_schema(
request: pytest.FixtureRequest,
) -> None:
"""Node-level state_schema takes precedence over workflow-level schema."""
def write_node_key(ctx: Context) -> str:
ctx.state['x'] = 10
return 'done'
node = FunctionNode(
name='guarded',
func=write_node_key,
state_schema=_NodeSchema,
)
wf = Workflow(
name='wf',
edges=[(START, node)],
state_schema=_PipelineSchema,
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
# 'x' is in _NodeSchema but NOT in _PipelineSchema — should succeed
# because node schema overrides workflow schema
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_node_without_schema_inherits_workflow_schema(
request: pytest.FixtureRequest,
) -> None:
"""Node without state_schema inherits validation from parent workflow."""
def write_bad_key(ctx: Context) -> str:
ctx.state['unknown'] = 'value'
return 'done'
wf = Workflow(
name='wf',
edges=[(START, write_bad_key)],
state_schema=_PipelineSchema,
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(StateSchemaError, match='unknown'):
await runner.run_async(testing_utils.get_user_content('start'))
@@ -0,0 +1,517 @@
# 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.
"""End-to-end tests for the Task Delegation API matrix.
Covers the complete cross-product of dispatch-shape × hierarchy-depth so
the chat-coordinator wrapper, the workflow-node task path, and the
nested-delegation path are all exercised:
* LlmAgent root → single task sub-agent (basic FC delegation).
* LlmAgent root → multiple task sub-agents (sequential delegation).
* LlmAgent root → task sub-agent → nested task sub-agent (chained).
* Workflow with a task-mode node (no FC delegation).
* Workflow with a task-mode node that itself has a task sub-agent.
* Dynamic node case (task agent dispatched via ``ctx.run_node``).
"""
from __future__ import annotations
from typing import Any
from typing import AsyncGenerator
from google.adk.agents.context import Context
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.workflow import node
from google.adk.workflow import START
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import BaseModel
import pytest
from tests.unittests import testing_utils
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
def _delegate_part(target_name: str, request_text: str) -> types.Part:
"""LLM response calling a task sub-agent (the _TaskAgentTool FC)."""
return types.Part.from_function_call(
name=target_name, args={'request': request_text}
)
def _finish_part(args: dict[str, Any]) -> types.Part:
"""LLM response calling finish_task with the given args."""
return types.Part.from_function_call(name='finish_task', args=args)
def _text_part(text: str) -> types.Part:
return types.Part.from_text(text=text)
def _make_task_agent(
name: str,
responses: list,
*,
sub_agents: list[LlmAgent] | None = None,
) -> LlmAgent:
return LlmAgent(
name=name,
model=testing_utils.MockModel.create(responses=responses),
mode='task',
sub_agents=sub_agents or [],
)
def _collect_finish_outputs(events: list[Event]) -> list[Any]:
"""Pull out finish_task FC arg dicts in chronological order."""
out = []
for e in events:
for fc in e.get_function_calls():
if fc.name == 'finish_task':
out.append(dict(fc.args or {}))
return out
def _get_text_responses(events: list[Event]) -> list[str]:
"""Concatenate text responses from all model events."""
texts = []
for e in events:
if not e.content or not e.content.parts:
continue
for p in e.content.parts:
if p.text and not p.thought:
texts.append(p.text)
return texts
# ---------------------------------------------------------------------------
# 1. LlmAgent root → single task sub-agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_chat_root_with_single_task_sub_agent(
request: pytest.FixtureRequest,
):
"""Chat coordinator delegates to one task sub-agent and reports its output."""
child = _make_task_agent(
name='child',
responses=[_finish_part({'result': 'child output'})],
)
root = LlmAgent(
name='root',
model=testing_utils.MockModel.create(
responses=[
_delegate_part('child', 'do the thing'),
'All done: child output.',
]
),
sub_agents=[child],
)
app = App(name=request.function.__name__, root_agent=root)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hi'))
finish_args = _collect_finish_outputs(events)
assert finish_args == [{'result': 'child output'}]
assert any(
'All done: child output.' in t for t in _get_text_responses(events)
)
# ---------------------------------------------------------------------------
# 2. LlmAgent root → multiple task sub-agents (sequential)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_chat_root_with_two_task_sub_agents_sequential(
request: pytest.FixtureRequest,
):
"""Chat coordinator delegates to two task sub-agents in one turn."""
collector = _make_task_agent(
name='collector',
responses=[_finish_part({'result': 'collected'})],
)
payer = _make_task_agent(
name='payer',
responses=[_finish_part({'result': 'paid'})],
)
root = LlmAgent(
name='root',
model=testing_utils.MockModel.create(
responses=[
_delegate_part('collector', 'collect'),
_delegate_part('payer', 'pay'),
'Order placed.',
]
),
sub_agents=[collector, payer],
)
app = App(name=request.function.__name__, root_agent=root)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('place order'))
finish_args = _collect_finish_outputs(events)
assert finish_args == [{'result': 'collected'}, {'result': 'paid'}]
assert any('Order placed.' in t for t in _get_text_responses(events))
# ---------------------------------------------------------------------------
# 3. LlmAgent root → task sub-agent → nested task sub-agent
# ---------------------------------------------------------------------------
@pytest.mark.xfail(
reason=(
'Task-mode wrapper does not dispatch task-delegation FCs (only the '
'chat-mode wrapper does), so a task-mode middle agent cannot delegate '
'to its task sub-agent. Documented limitation.'
),
strict=True,
)
@pytest.mark.asyncio
async def test_chat_root_with_nested_task_delegation(
request: pytest.FixtureRequest,
):
"""Task agent itself has a task sub-agent and delegates further."""
grandchild = _make_task_agent(
name='grandchild',
responses=[_finish_part({'result': 'leaf'})],
)
child = LlmAgent(
name='child',
model=testing_utils.MockModel.create(
responses=[
_delegate_part('grandchild', 'leaf work'),
_finish_part({'result': 'middle wraps leaf'}),
]
),
mode='task',
sub_agents=[grandchild],
)
root = LlmAgent(
name='root',
model=testing_utils.MockModel.create(
responses=[
_delegate_part('child', 'do the thing'),
'Top-level done.',
]
),
sub_agents=[child],
)
app = App(name=request.function.__name__, root_agent=root)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hi'))
finish_args = _collect_finish_outputs(events)
# grandchild fires first (deepest), then child.
assert finish_args == [
{'result': 'leaf'},
{'result': 'middle wraps leaf'},
]
assert any('Top-level done.' in t for t in _get_text_responses(events))
# ---------------------------------------------------------------------------
# 4. Workflow with a single task-mode node (no FC delegation)
# ---------------------------------------------------------------------------
class _CaptureNode(BaseNode):
"""Records its node_input for assertion."""
received: list[Any] = []
async def _run_impl(self, *, ctx, node_input):
type(self).received.append(node_input)
yield Event(output=node_input)
@pytest.mark.asyncio
async def test_workflow_accepts_task_mode_graph_node():
"""A mode='task' LlmAgent can be used as a static workflow graph node."""
intake = _make_task_agent(name='intake', responses=[])
capture = _CaptureNode(name='capture')
wf = Workflow(name='wf', edges=[(START, intake), (intake, capture)])
assert wf is not None
# ---------------------------------------------------------------------------
# 6. Dynamic node: function node that dispatches a task agent via ctx.run_node
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dynamic_dispatch_of_task_agent(
request: pytest.FixtureRequest,
):
"""A custom function node can dispatch a task agent and consume its output."""
task_agent = _make_task_agent(
name='task_agent',
responses=[_finish_part({'result': 'dynamic output'})],
)
@node(rerun_on_resume=True)
async def driver(*, ctx: Context, node_input: Any):
output = await ctx.run_node(task_agent, node_input='go')
yield Event(output=f'wrapped: {output}')
wf = Workflow(name='wf', edges=[(START, driver)])
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
outputs = [e.output for e in events if e.output]
assert any(
isinstance(o, str) and 'dynamic output' in o for o in outputs
), f'expected wrapped dynamic output, got: {outputs}'
# ---------------------------------------------------------------------------
# 7. Validation error -> retry: wrapper yields the error FR and lets the LLM
# emit a corrected finish_task on the next round.
# ---------------------------------------------------------------------------
class _StrictOutput(BaseModel):
name: str
age: int
@pytest.mark.asyncio
async def test_task_validation_error_drives_retry(
request: pytest.FixtureRequest,
):
"""Bad finish_task args produce an error FR; the LLM gets a retry."""
# First finish_task call has wrong types (age as string), second is correct.
child_model = testing_utils.MockModel.create(
responses=[
_finish_part({'name': 'Jane', 'age': 'thirty'}),
_finish_part({'name': 'Jane', 'age': 30}),
]
)
child = LlmAgent(
name='child',
model=child_model,
mode='task',
output_schema=_StrictOutput,
)
root = LlmAgent(
name='root',
model=testing_utils.MockModel.create(
responses=[
_delegate_part('child', 'gather identity'),
'All set.',
]
),
sub_agents=[child],
)
app = App(name=request.function.__name__, root_agent=root)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hi'))
# The mock LLM was called twice for the child (the bad attempt + the
# corrected one), proving the wrapper looped instead of terminating
# on the first finish_task.
assert child_model.response_index == 1
finish_args = _collect_finish_outputs(events)
assert finish_args == [
{'name': 'Jane', 'age': 'thirty'},
{'name': 'Jane', 'age': 30},
]
# The validation-error FR should be present in session for the LLM
# to see on its retry round.
error_frs = [
fr.response
for e in events
for fr in e.get_function_responses()
if fr.name == 'finish_task'
and isinstance(fr.response, dict)
and 'error' in fr.response
]
assert len(error_frs) == 1, f'expected one error FR, got {error_frs}'
# ---------------------------------------------------------------------------
# 8. Cross-turn resumption: an unresolved task FC from a prior turn is
# re-dispatched by the chat coordinator on the next user turn, before
# the LLM is called.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_chat_coordinator_resumes_unresolved_task_fc(
request: pytest.FixtureRequest,
):
"""Pending task FC from a prior turn is dispatched before the new LLM call."""
child_model = testing_utils.MockModel.create(
responses=[_finish_part({'result': 'finished after resume'})]
)
child = LlmAgent(name='child', model=child_model, mode='task')
root_model = testing_utils.MockModel.create(
responses=[
# Only response needed: post-resume continuation after the
# pre-LLM scan dispatches the pending task and synthesizes its FR.
'Resumed and done.',
]
)
root = LlmAgent(
name='root',
model=root_model,
sub_agents=[child],
)
# Seed the session with an unresolved task delegation FC authored by
# root from a "prior turn". No matching FR exists.
from google.adk.sessions.in_memory_session_service import InMemorySessionService
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name=request.function.__name__,
user_id='u',
)
await session_service.append_event(
session=session,
event=Event(
invocation_id='prior-inv',
author='root',
content=types.Content(
role='model',
parts=[
types.Part(
function_call=types.FunctionCall(
id='fc-pending',
name='child',
args={'request': 'leftover work'},
)
)
],
),
),
)
from google.adk.runners import Runner
app = App(name=request.function.__name__, root_agent=root)
runner = Runner(app=app, session_service=session_service)
events = []
async for ev in runner.run_async(
user_id='u',
session_id=session.id,
new_message=testing_utils.get_user_content('continue'),
):
events.append(ev)
# The child must have been dispatched once (resuming the pending FC).
assert (
child_model.response_index == 0
), 'child LLM should have been called exactly once for the resumed task'
finish_args = _collect_finish_outputs(events)
assert {
'result': 'finished after resume'
} in finish_args, f'expected resumed task to finish; got {finish_args}'
# ---------------------------------------------------------------------------
# 9. Strict isolation filtering: a stranger event with a foreign
# isolation_scope must NOT appear in the task agent's LLM context.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_strict_isolation_filter_excludes_foreign_scope(
request: pytest.FixtureRequest,
):
"""Garbage-scoped events are excluded from the task agent's view."""
child_model = testing_utils.MockModel.create(
responses=[_finish_part({'result': 'ok'})]
)
child = LlmAgent(name='child', model=child_model, mode='task')
root = LlmAgent(
name='root',
model=testing_utils.MockModel.create(
responses=[
_delegate_part('child', 'do the thing'),
'Done.',
]
),
sub_agents=[child],
)
from google.adk.sessions.in_memory_session_service import InMemorySessionService
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name=request.function.__name__,
user_id='u',
)
# Seed a stranger event with a different scope.
stranger = Event(
invocation_id='stranger-inv',
author='someone_else',
content=types.Content(
role='user',
parts=[types.Part(text='SECRET-SHOULD-NOT-LEAK')],
),
)
stranger.isolation_scope = 'garbage-scope'
session.events.append(stranger)
from google.adk.runners import Runner
app = App(name=request.function.__name__, root_agent=root)
runner = Runner(app=app, session_service=session_service)
async for _ in runner.run_async(
user_id='u',
session_id=session.id,
new_message=testing_utils.get_user_content('go'),
):
pass
# Inspect the child's LLM request: SECRET text must not appear.
child_request = child_model.requests[0]
rendered = '\n'.join(
p.text or '' for c in child_request.contents or [] for p in c.parts or []
)
assert (
'SECRET-SHOULD-NOT-LEAK' not in rendered
), 'stranger event leaked across isolation_scope filter'
+141
View File
@@ -0,0 +1,141 @@
# 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 ToolNode input parsing and execution."""
from typing import Any
from google.adk.events.event import Event
from google.adk.tools.base_tool import BaseTool
from google.adk.workflow import START
from google.adk.workflow._tool_node import _ToolNode as ToolNode
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest
from . import workflow_testing_utils
from .. import testing_utils
class MockTool(BaseTool):
"""A mock tool that returns the args it was called with."""
def __init__(self, name="mock_tool", description="Mock tool"):
super().__init__(name=name, description=description)
async def run_async(self, *, args: dict[str, Any], tool_context) -> Any:
return args
async def _run_tool_node_wf(node_input: Any) -> list[Any]:
"""Runs a workflow with a ToolNode that receives node_input."""
tool_node = ToolNode(tool=MockTool())
def start_node():
return Event(output=node_input)
wf = Workflow(
name="tool_node_test_wf",
edges=[
(START, start_node),
(start_node, tool_node),
],
)
app_instance = testing_utils.App(name="test_app", root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app_instance)
events = await runner.run_async("start")
return workflow_testing_utils.simplify_events_with_node(events)
@pytest.mark.asyncio
async def test_tool_node_accepts_dict():
"""Tests that ToolNode accepts a dict as input and passes it to the tool."""
input_dict = {"param_a": 1, "param_b": "value"}
simplified = await _run_tool_node_wf(input_dict)
assert (
"tool_node_test_wf@1/mock_tool@1",
{"output": input_dict},
) in simplified
@pytest.mark.asyncio
async def test_tool_node_accepts_none():
"""Tests that ToolNode accepts None, converting it to an empty dict."""
simplified = await _run_tool_node_wf(None)
assert ("tool_node_test_wf@1/mock_tool@1", {"output": {}}) in simplified
@pytest.mark.asyncio
@pytest.mark.parametrize("empty_input", ["", " ", "\n\t"])
async def test_tool_node_accepts_empty_string(empty_input):
"""Tests that ToolNode treats an empty/whitespace string as no arguments."""
simplified = await _run_tool_node_wf(empty_input)
assert ("tool_node_test_wf@1/mock_tool@1", {"output": {}}) in simplified
@pytest.mark.asyncio
async def test_tool_node_accepts_json_string():
"""Tests that ToolNode accepts a valid JSON string representing a dict."""
json_str = '{"param_a": 1, "param_b": "value"}'
simplified = await _run_tool_node_wf(json_str)
assert (
"tool_node_test_wf@1/mock_tool@1",
{"output": {"param_a": 1, "param_b": "value"}},
) in simplified
@pytest.mark.asyncio
async def test_tool_node_accepts_content_with_json_string():
"""Tests that ToolNode accepts a types.Content containing a JSON string."""
json_str = '{"param_a": 1, "param_b": "value"}'
content = types.Content(
parts=[types.Part.from_text(text=json_str)], role="user"
)
simplified = await _run_tool_node_wf(content)
assert (
"tool_node_test_wf@1/mock_tool@1",
{"output": {"param_a": 1, "param_b": "value"}},
) in simplified
@pytest.mark.asyncio
async def test_tool_node_rejects_non_dict_json_string():
"""Tests that ToolNode raises TypeError if JSON string represents a non-dict (e.g. list)."""
json_str = "[1, 2, 3]"
with pytest.raises(
TypeError, match="The input to ToolNode must be a dictionary"
):
await _run_tool_node_wf(json_str)
@pytest.mark.asyncio
async def test_tool_node_rejects_invalid_json_string():
"""Tests that ToolNode raises TypeError if string input is not valid JSON."""
invalid_str = "not a json"
with pytest.raises(
TypeError, match="The input to ToolNode must be a dictionary"
):
await _run_tool_node_wf(invalid_str)
@pytest.mark.asyncio
async def test_tool_node_rejects_non_dict_content():
"""Tests that ToolNode raises TypeError if Content contains non-dict text."""
content = types.Content(
parts=[types.Part.from_text(text="not a json")], role="user"
)
with pytest.raises(
TypeError, match="The input to ToolNode must be a dictionary"
):
await _run_tool_node_wf(content)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
# 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 BaseAgent instances used as nodes in a Workflow."""
from typing import AsyncGenerator
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext as BaseInvocationContext
from google.adk.events.event import Event
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest
from .workflow_testing_utils import InputCapturingNode
from .workflow_testing_utils import simplify_events_with_node
class SimpleAgent(BaseAgent):
"""A simple agent for testing."""
message: str = ''
async def _run_async_impl(
self, ctx: BaseInvocationContext
) -> AsyncGenerator[Event, None]:
"""Yields a single event with a message."""
yield Event(
author=self.name,
invocation_id=ctx.invocation_id,
content=types.Content(parts=[types.Part(text=self.message)]),
)
@pytest.mark.asyncio
async def test_run_async_with_agent_nodes(request: pytest.FixtureRequest):
"""BaseAgent nodes emit content events through the workflow."""
agent_a = SimpleAgent(name='AgentA', message='Hello')
agent_b = SimpleAgent(name='AgentB', message='World')
wf = Workflow(
name='wf_with_agents',
edges=[
(START, agent_a),
(agent_a, agent_b),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='start')], role='user')
events: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
assert simplify_events_with_node(events) == [
('wf_with_agents@1/AgentA@1', 'Hello'),
('wf_with_agents@1/AgentB@1', 'World'),
]
@pytest.mark.asyncio
async def test_run_async_with_agent_node_piping_data(
request: pytest.FixtureRequest,
):
"""AgentNode content is not piped as output to the next node."""
agent_a = SimpleAgent(name='AgentA', message='Hello')
node_b = InputCapturingNode(name='NodeB')
wf = Workflow(
name='wf_with_agent_piping',
edges=[
(START, agent_a),
(agent_a, node_b),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='start')], role='user')
async for _ in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
pass
# AgentNode does not record content as node output, so the next node
# receives None as input.
assert node_b.received_inputs == [None]
@@ -0,0 +1,137 @@
# 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 Workflow handling of bytes and serialization."""
from __future__ import annotations
from typing import Any
from typing import AsyncGenerator
from google.adk.agents.context import Context
from google.adk.events.event import Event
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 import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import ConfigDict
from pydantic import Field
import pytest
# --- Helpers ---
class _BytesOutputNode(BaseNode):
"""Yields bytes content or raw bytes."""
raw_bytes: bool = False
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
data = b"\x89PNG\r\n\x1a\n"
if self.raw_bytes:
yield data
else:
yield Event(
content=types.Content(
parts=[types.Part.from_bytes(data=data, mime_type="image/png")]
),
output="bytes_sent",
)
class _InputCapturingNode(BaseNode):
"""Captures node_input for later assertion."""
model_config = ConfigDict(arbitrary_types_allowed=True)
received_inputs: list[Any] = Field(default_factory=list)
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
self.received_inputs.append(node_input)
yield {"received": node_input}
async def _run_workflow(wf, message="start"):
"""Run a Workflow through Runner, return collected events."""
ss = InMemorySessionService()
runner = Runner(app_name="test", node=wf, session_service=ss)
session = await ss.create_session(app_name="test", 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
# --- Tests ---
@pytest.mark.asyncio
async def test_bytes_in_content_output():
"""Content with bytes propagates to downstream node."""
a = _BytesOutputNode(name="a", raw_bytes=False)
b = _InputCapturingNode(name="b")
wf = Workflow(name="wf", edges=[(START, a, b)])
events, _, _ = await _run_workflow(wf)
assert b.received_inputs == ["bytes_sent"]
@pytest.mark.asyncio
async def test_raw_bytes_output():
"""Raw bytes output propagates to downstream node."""
a = _BytesOutputNode(name="a", raw_bytes=True)
b = _InputCapturingNode(name="b")
wf = Workflow(name="wf", edges=[(START, a, b)])
events, _, _ = await _run_workflow(wf)
assert len(b.received_inputs) == 1
assert isinstance(b.received_inputs[0], bytes)
@pytest.mark.xfail(reason="Checkpoint/resume not yet in new Workflow.")
@pytest.mark.asyncio
async def test_bytes_in_node_input_serialization():
"""Bytes in node input survive checkpoint/resume."""
assert False, "TODO"
@pytest.mark.xfail(reason="Checkpoint/resume not yet in new Workflow.")
@pytest.mark.asyncio
async def test_bytes_in_typed_model_input():
"""Bytes in Pydantic model input survive round-trip."""
assert False, "TODO"
@pytest.mark.xfail(reason="Checkpoint/resume not yet in new Workflow.")
@pytest.mark.asyncio
async def test_bytes_in_trigger_buffer():
"""Bytes in trigger buffer survive serialization."""
assert False, "TODO"
@pytest.mark.xfail(reason="Checkpoint/resume not yet in new Workflow.")
@pytest.mark.asyncio
async def test_bytes_full_workflow_resume():
"""Full resume with bytes data end-to-end."""
assert False, "TODO"
@@ -0,0 +1,132 @@
# 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.
import asyncio
from typing import Any
from typing import AsyncGenerator
from google.adk.agents.context import Context
from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.workflow import BaseNode
from google.adk.workflow import START
from google.adk.workflow._parallel_worker import _ParallelWorker as ParallelWorker
from google.adk.workflow._workflow import Workflow
from pydantic import Field
import pytest
from typing_extensions import override
from .. import testing_utils
from .workflow_testing_utils import create_parent_invocation_context
@pytest.mark.asyncio
async def test_max_concurrency_limits_running_nodes(
request: pytest.FixtureRequest,
):
"""Max concurrency limits the number of parallel graph-scheduled nodes.
Setup:
Workflow with 4 parallel nodes and max_concurrency=2.
Act:
- Start workflow in background.
- Release nodes one by one.
Assert:
- Initially only 2 nodes start.
- Releasing one node allows another to start.
- All nodes eventually complete.
"""
class ConcurrencyWorkerNode(BaseNode):
"""A node that signals when it starts and waits for a signal to finish."""
started_event: asyncio.Event
finish_event: asyncio.Event
@override
async def _run_impl(
self,
*,
ctx: Context,
node_input: Any,
) -> AsyncGenerator[Any, None]:
self.started_event.set()
await self.finish_event.wait()
yield f'{self.name}_done'
class TerminalNode(BaseNode):
@override
async def _run_impl(
self, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield 'workflow_done'
# Given a workflow with 4 parallel nodes and max_concurrency=2
num_nodes = 4
max_concurrency = 2
started_events = [asyncio.Event() for _ in range(num_nodes)]
finish_events = [asyncio.Event() for _ in range(num_nodes)]
nodes = [
ConcurrencyWorkerNode(
name=f'Node{i}',
started_event=started_events[i],
finish_event=finish_events[i],
)
for i in range(num_nodes)
]
terminal_node = TerminalNode(name='Terminal')
edges = [(START, tuple(nodes), terminal_node)]
agent = Workflow(
name='concurrency_agent',
max_concurrency=max_concurrency,
edges=edges,
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
# When the workflow is run in background
async def run_agent():
return await runner.run_async(testing_utils.get_user_content('start'))
run_task = asyncio.create_task(run_agent())
# Then initially only max_concurrency nodes should start
await asyncio.sleep(0.1)
started_count = sum(1 for e in started_events if e.is_set())
assert started_count == max_concurrency
# When one node is released
for i in range(num_nodes):
if started_events[i].is_set():
finish_events[i].set()
break
# Then another node should start, bringing total to max_concurrency + 1
await asyncio.sleep(0.1)
started_count = sum(1 for e in started_events if e.is_set())
assert started_count == max_concurrency + 1
# When all remaining nodes are released
for e in finish_events:
e.set()
# Then all nodes should eventually complete
await run_task
started_count = sum(1 for e in started_events if e.is_set())
assert started_count == num_nodes
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
# 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 FunctionTool nodes in a Workflow."""
from google.adk.events.event import Event
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.function_tool import FunctionTool
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest
from .workflow_testing_utils import simplify_events_with_node
def _produce_input() -> None:
"""Absorbs user content so downstream ToolNodes receive None."""
return None
def _func_a() -> dict[str, str]:
"""Returns a value from function A."""
return {'val': 'Hello'}
def _func_b(val: str) -> str:
"""Returns a value incorporating input from A."""
return f'{val}_world'
@pytest.mark.asyncio
async def test_run_async_with_function_tools():
"""FunctionTool output is piped as input to the next FunctionTool."""
tool_a = FunctionTool(_func_a)
tool_b = FunctionTool(_func_b)
wf = Workflow(
name='wf_with_tools',
edges=[
(START, _produce_input, tool_a, tool_b),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='start')], role='user')
events: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
assert simplify_events_with_node(events) == [
(
'wf_with_tools@1/_func_a@1',
{'output': {'val': 'Hello'}},
),
(
'wf_with_tools@1/_func_b@1',
{'output': 'Hello_world'},
),
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,653 @@
# 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.
import asyncio
from typing import Any
from typing import AsyncGenerator
from google.adk.agents.context import Context
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.agents.llm_agent import LlmAgent
from google.adk.events.event import Event
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._base_node import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import Field
import pytest
from . import testing_utils
# --- Mock Nodes and Agents for Testing Live Mode Design ---
class _MockNonLiveNode(BaseNode):
"""A standard non-live node whose signature does NOT accept live_request_queue."""
called: bool = False
actual_input: Any = None
shared_state: dict[str, Any] = Field(default_factory=dict)
def __init__(self, *, name: str):
super().__init__(name=name)
async def _run_impl(
self,
*,
ctx: Context,
node_input: Any,
) -> AsyncGenerator[Any, None]:
self.called = True
self.actual_input = node_input
self.shared_state["called"] = True
self.shared_state["actual_input"] = node_input
yield Event(output=f"{self.name}_output")
class _ConstantNode(BaseNode):
"""A node that outputs a constant value."""
output_value: Any = None
def __init__(self, *, name: str, output_value: Any):
super().__init__(name=name)
self.output_value = output_value
async def _run_impl(
self,
*,
ctx: Context,
node_input: Any,
) -> AsyncGenerator[Any, None]:
yield Event(output=self.output_value)
# --- Live Workflow Unit Tests (TDD) ---
@pytest.mark.asyncio
async def test_hybrid_live_non_live_nodes():
"""CUJ 1: A workflow has hybrid live & non-live nodes."""
mock_model1 = testing_utils.MockModel.create(
responses=[
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: node1_start")]
)
),
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: node1_end")]
)
),
LlmResponse(
content=testing_utils.ModelContent([
types.Part.from_function_call(
name="finish_task",
args={"result": "LiveNode1_output"},
)
])
),
]
)
live_node1 = LlmAgent(
name="LiveNode1",
model=mock_model1,
mode="task",
instruction="Handle live interaction 1.",
)
non_live_node = _MockNonLiveNode(name="NonLiveNode")
mock_model2 = testing_utils.MockModel.create(
responses=[
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: node2_start")]
)
),
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: node2_end")]
)
),
LlmResponse(
content=testing_utils.ModelContent([
types.Part.from_function_call(
name="finish_task",
args={"result": "LiveNode2_output"},
)
])
),
]
)
live_node2 = LlmAgent(
name="LiveNode2",
model=mock_model2,
mode="task",
instruction="Handle live interaction 2.",
)
wf = Workflow(
name="hybrid_workflow",
edges=[
(START, live_node1),
(live_node1, non_live_node),
(non_live_node, live_node2),
],
)
live_queue = LiveRequestQueue()
# Pre-seed first live node's requests
live_queue.send_realtime(
types.Blob(data=b"node1_start", mime_type="audio/pcm")
)
live_queue.send_realtime(types.Blob(data=b"node1_end", mime_type="audio/pcm"))
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")
events = []
async for event in runner.run_live(
user_id="u",
session_id=session.id,
live_request_queue=live_queue,
run_config=testing_utils.RunConfig(
get_session_config={"state_delta": {"__START__": "start_input"}}
),
):
events.append(event)
if event.output == "NonLiveNode_output":
# First live node and non-live node completed! Now feed the second live node's requests:
live_queue.send_realtime(
types.Blob(data=b"node2_start", mime_type="audio/pcm")
)
live_queue.send_realtime(
types.Blob(data=b"node2_end", mime_type="audio/pcm")
)
# 1. Assert exact outputs sequence
outputs = [e.output for e in events if e.output is not None]
assert outputs == [
{"result": "LiveNode1_output"},
"NonLiveNode_output",
{"result": "LiveNode2_output"},
]
assert non_live_node.shared_state.get("actual_input") == {
"result": "LiveNode1_output"
}
# 2. Assert intermediate content events (conversational turns)
content_texts = [
p.text
for e in events
if e.content and e.content.parts and e.output is None
for p in e.content.parts
if p.text
]
assert content_texts == [
"Acknowledged: node1_start",
"Acknowledged: node1_end",
"Acknowledged: node2_start",
"Acknowledged: node2_end",
]
# 3. Assert live requests fed to the models
assert [b.data for b in mock_model1.live_blobs] == [
b"node1_start",
b"node1_end",
]
assert [b.data for b in mock_model2.live_blobs] == [
b"node2_start",
b"node2_end",
]
@pytest.mark.asyncio
async def test_nested_workflow_has_live_node():
"""CUJ 2: A nested workflow has a live node."""
mock_model = testing_utils.MockModel.create(
responses=[
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: inner_start")]
)
),
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: inner_end")]
)
),
LlmResponse(
content=testing_utils.ModelContent([
types.Part.from_function_call(
name="finish_task",
args={"result": "InnerLiveNode_output"},
)
])
),
]
)
live_node = LlmAgent(
name="InnerLiveNode",
model=mock_model,
mode="task",
instruction="Handle inner live interaction.",
)
inner_wf = Workflow(name="inner_wf", edges=[(START, live_node)])
outer_wf = Workflow(name="outer_wf", edges=[(START, inner_wf)])
live_queue = LiveRequestQueue()
live_queue.send_realtime(
types.Blob(data=b"inner_start", mime_type="audio/pcm")
)
live_queue.send_realtime(types.Blob(data=b"inner_end", mime_type="audio/pcm"))
ss = InMemorySessionService()
runner = Runner(app_name=outer_wf.name, node=outer_wf, session_service=ss)
session = await ss.create_session(app_name=outer_wf.name, user_id="u")
events = []
async for event in runner.run_live(
user_id="u",
session_id=session.id,
live_request_queue=live_queue,
run_config=testing_utils.RunConfig(
get_session_config={"state_delta": {"__START__": "start_input"}}
),
):
events.append(event)
# Assert exact outputs sequence
outputs = [e.output for e in events if e.output is not None]
assert outputs == [{"result": "InnerLiveNode_output"}]
# Assert content events
content_texts = [
p.text
for e in events
if e.content and e.content.parts and e.output is None
for p in e.content.parts
if p.text
]
assert content_texts == [
"Acknowledged: inner_start",
"Acknowledged: inner_end",
]
assert [b.data for b in mock_model.live_blobs] == [
b"inner_start",
b"inner_end",
]
@pytest.mark.asyncio
async def test_nested_live_node_and_outer_live_node():
"""CUJ 3: A nested workflow has live node & outer workflow then has a live node."""
mock_model_inner = testing_utils.MockModel.create(
responses=[
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: inner_start")]
)
),
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: inner_end")]
)
),
LlmResponse(
content=testing_utils.ModelContent([
types.Part.from_function_call(
name="finish_task",
args={"result": "InnerLiveNode_output"},
)
])
),
]
)
inner_live = LlmAgent(
name="InnerLiveNode",
model=mock_model_inner,
mode="task",
instruction="Handle inner live interaction.",
)
inner_wf = Workflow(name="inner_wf", edges=[(START, inner_live)])
mock_model_outer = testing_utils.MockModel.create(
responses=[
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: outer_start")]
)
),
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: outer_end")]
)
),
LlmResponse(
content=testing_utils.ModelContent([
types.Part.from_function_call(
name="finish_task",
args={"result": "OuterLiveNode_output"},
)
])
),
]
)
outer_live = LlmAgent(
name="OuterLiveNode",
model=mock_model_outer,
mode="task",
instruction="Handle outer live interaction.",
)
prep_node = _ConstantNode(name="PrepNode", output_value="prep_output")
wf = Workflow(
name="nested_sequential_live",
edges=[
(START, inner_wf),
(inner_wf, prep_node),
(prep_node, outer_live),
],
)
live_queue = LiveRequestQueue()
live_queue.send_realtime(
types.Blob(data=b"inner_start", mime_type="audio/pcm")
)
live_queue.send_realtime(types.Blob(data=b"inner_end", mime_type="audio/pcm"))
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")
events = []
async for event in runner.run_live(
user_id="u",
session_id=session.id,
live_request_queue=live_queue,
run_config=testing_utils.RunConfig(
get_session_config={"state_delta": {"__START__": "start_input"}}
),
):
events.append(event)
if event.output == "prep_output":
# Inner live node and prep node completed! Feed outer node's requests:
live_queue.send_realtime(
types.Blob(data=b"outer_start", mime_type="audio/pcm")
)
live_queue.send_realtime(
types.Blob(data=b"outer_end", mime_type="audio/pcm")
)
# Assert exact outputs sequence
outputs = [e.output for e in events if e.output is not None]
assert outputs == [
{"result": "InnerLiveNode_output"},
"prep_output",
{"result": "OuterLiveNode_output"},
]
# Assert content events
content_texts = [
p.text
for e in events
if e.content and e.content.parts and e.output is None
for p in e.content.parts
if p.text
]
assert content_texts == [
"Acknowledged: inner_start",
"Acknowledged: inner_end",
"Acknowledged: outer_start",
"Acknowledged: outer_end",
]
assert [b.data for b in mock_model_inner.live_blobs] == [
b"inner_start",
b"inner_end",
]
assert [b.data for b in mock_model_outer.live_blobs] == [
b"outer_start",
b"outer_end",
]
@pytest.mark.asyncio
async def test_dynamic_node_scheduling_of_live_node():
"""CUJ 4: A node in workflow dynamically schedules a live node using ctx.run_node()."""
class _DynamicLiveSchedulerNode(BaseNode):
"""A node that dynamically schedules a child live node using ctx.run_node()."""
child_node: BaseNode | None = None
child_output: Any = None
shared_state: dict[str, Any] = Field(default_factory=dict)
def __init__(self, *, name: str, child_node: BaseNode):
super().__init__(name=name, rerun_on_resume=True)
self.child_node = child_node
async def _run_impl(
self,
*,
ctx: Context,
node_input: Any,
) -> AsyncGenerator[Any, None]:
if self.child_node:
output = await ctx.run_node(self.child_node, node_input=node_input)
self.child_output = output
self.shared_state["child_output"] = output
yield Event(output=f"{self.name}_output")
mock_model = testing_utils.MockModel.create(
responses=[
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: dynamic_start")]
)
),
LlmResponse(
content=testing_utils.ModelContent(
[types.Part.from_text(text="Acknowledged: dynamic_end")]
)
),
LlmResponse(
content=testing_utils.ModelContent([
types.Part.from_function_call(
name="finish_task",
args={"result": "DynamicLiveNode_output"},
)
])
),
]
)
live_node = LlmAgent(
name="DynamicLiveNode",
model=mock_model,
mode="task",
instruction="Handle dynamic live interaction.",
)
scheduler_node = _DynamicLiveSchedulerNode(
name="SchedulerNode", child_node=live_node
)
wf = Workflow(name="dynamic_wf", edges=[(START, scheduler_node)])
live_queue = LiveRequestQueue()
live_queue.send_realtime(
types.Blob(data=b"dynamic_start", mime_type="audio/pcm")
)
live_queue.send_realtime(
types.Blob(data=b"dynamic_end", mime_type="audio/pcm")
)
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")
events = []
async for event in runner.run_live(
user_id="u",
session_id=session.id,
live_request_queue=live_queue,
run_config=testing_utils.RunConfig(
get_session_config={"state_delta": {"__START__": "start_input"}}
),
):
events.append(event)
# Assert exact outputs sequence
outputs = [e.output for e in events if e.output is not None]
assert outputs == [
{"result": "DynamicLiveNode_output"},
"SchedulerNode_output",
]
assert scheduler_node.shared_state.get("child_output") == {
"result": "DynamicLiveNode_output"
}
# Assert content events
content_texts = [
p.text
for e in events
if e.content and e.content.parts and e.output is None
for p in e.content.parts
if p.text
]
assert content_texts == [
"Acknowledged: dynamic_start",
"Acknowledged: dynamic_end",
]
assert [b.data for b in mock_model.live_blobs] == [
b"dynamic_start",
b"dynamic_end",
]
@pytest.mark.asyncio
async def test_live_node_output_passed_to_downstream():
"""CUJ 5: Dedicated test verifying output of a live node is passed to the next node."""
mock_model = testing_utils.MockModel.create(
responses=[
LlmResponse(
content=testing_utils.ModelContent([
types.Part.from_function_call(
name="finish_task",
args={"result": "LiveNode_output"},
)
])
),
]
)
live_node = LlmAgent(
name="LiveNode",
model=mock_model,
mode="task",
instruction="Handle live interaction.",
)
non_live_node = _MockNonLiveNode(name="NonLiveNode")
wf = Workflow(
name="dataflow_workflow",
edges=[
(START, live_node),
(live_node, non_live_node),
],
)
live_queue = LiveRequestQueue()
live_queue.send_realtime(types.Blob(data=b"start_msg", mime_type="audio/pcm"))
live_queue.send_realtime(types.Blob(data=b"end_msg", mime_type="audio/pcm"))
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")
events = []
async for event in runner.run_live(
user_id="u",
session_id=session.id,
live_request_queue=live_queue,
run_config=testing_utils.RunConfig(
get_session_config={"state_delta": {"__START__": "start_input"}}
),
):
events.append(event)
outputs = [e.output for e in events if e.output is not None]
assert outputs == [{"result": "LiveNode_output"}, "NonLiveNode_output"]
assert non_live_node.shared_state.get("actual_input") == {
"result": "LiveNode_output"
}, "The downstream node must receive the live node's exact output"
assert [b.data for b in mock_model.live_blobs] == [b"start_msg", b"end_msg"]
@pytest.mark.asyncio
async def test_single_turn_agent_runs_as_non_live_in_live_session():
"""CUJ 6: A single_turn LlmAgent in a live session runs as non-live and consumes node_input."""
mock_model = testing_utils.MockModel.create(
responses=[
"SingleTurn_output",
]
)
prep_node = _ConstantNode(
name="ConstantNode", output_value="initial_text_input"
)
single_turn_node = LlmAgent(
name="SingleTurnNode",
model=mock_model,
mode="single_turn",
instruction="Summarize the input.",
)
capture = _MockNonLiveNode(name="capture")
wf = Workflow(
name="single_turn_live_wf",
edges=[
(START, prep_node),
(prep_node, single_turn_node),
(single_turn_node, capture),
],
)
live_queue = LiveRequestQueue()
live_queue.send_realtime(
types.Blob(data=b"ignored_audio", mime_type="audio/pcm")
)
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")
events = []
async for event in runner.run_live(
user_id="u",
session_id=session.id,
live_request_queue=live_queue,
):
events.append(event)
outputs = [e.output for e in events if e.output is not None]
assert outputs == ["initial_text_input", "capture_output"]
assert capture.shared_state.get("actual_input") == "SingleTurn_output"
# Verify that the model received the initial_text_input (node_input) and NOT the live queue audio
assert len(mock_model.requests) == 1
assert (
mock_model.requests[0].contents[0].parts[0].text == "initial_text_input"
)
assert mock_model.live_blobs == []
@@ -0,0 +1,933 @@
# 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 asyncio
import copy
from typing import Any
from typing import AsyncGenerator
from google.adk.agents import LlmAgent
from google.adk.agents.context import Context
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.run_config import RunConfig
from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.tools.long_running_tool import LongRunningFunctionTool
from google.adk.tools.tool_context import ToolContext
from google.adk.workflow import Edge
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest
from tests.unittests import testing_utils
from tests.unittests.workflow import workflow_testing_utils
def long_running_tool_func():
"""A test tool that simulates a long-running operation."""
return None
@pytest.mark.asyncio
async def test_workflow_pause_and_resume_simple(
request: pytest.FixtureRequest,
):
"""Tests that a workflow can pause and resume with a single LlmAgent node."""
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name='long_running_tool_func',
args={},
),
types.Part.from_text(text='LLM response after tool'),
]
)
# 1. Create agent with LRO tool
node_a = LlmAgent(
name='my_agent',
model=mock_model,
tools=[LongRunningFunctionTool(func=long_running_tool_func)],
)
# 2. Create workflow with single node
wf = Workflow(
name='test_workflow_hitl',
edges=[
(START, node_a),
],
)
app = App(
name=request.function.__name__,
root_agent=wf,
)
runner = testing_utils.InMemoryRunner(app=app)
# 3. First run: should pause on the long-running function call.
user_event = testing_utils.get_user_content('start workflow')
events1 = await runner.run_async(user_event)
# Verify it paused on LRO
assert any(e.long_running_tool_ids for e in events1)
invocation_id = events1[0].invocation_id
fc_event = workflow_testing_utils.find_function_call_event(
events1, 'long_running_tool_func'
)
assert fc_event is not None
function_call_id = fc_event.content.parts[0].function_call.id
# 4. Prepare resume message
tool_response = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=function_call_id,
name='long_running_tool_func',
response={'result': 'Final tool output'},
)
)
)
# 5. Resume with tool output.
events2 = await runner.run_async(
new_message=tool_response,
invocation_id=invocation_id,
)
# 6. Verify completion
content_texts = [
p.text
for e in events2
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert any('LLM response after tool' in t for t in content_texts)
@pytest.mark.asyncio
async def test_workflow_pause_and_resume_task_mode(
request: pytest.FixtureRequest,
):
"""Tests that a workflow can pause and resume with a single LlmAgent node in task mode."""
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name='long_running_tool_func',
args={},
),
types.Part.from_text(text='LLM response after tool in task mode'),
]
)
# 1. Create agent with LRO tool and mode='task'
node_a = LlmAgent(
name='my_task_agent',
model=mock_model,
tools=[LongRunningFunctionTool(func=long_running_tool_func)],
mode='task',
)
# 2. Create workflow with single node
wf = Workflow(
name='test_workflow_task_hitl',
edges=[
(START, node_a),
],
)
app = App(
name=request.function.__name__,
root_agent=wf,
)
runner = testing_utils.InMemoryRunner(app=app)
# 3. First run: should pause on the long-running function call.
user_event = testing_utils.get_user_content('start workflow')
events1 = await runner.run_async(user_event)
# Verify it paused on LRO
assert any(e.long_running_tool_ids for e in events1)
invocation_id = events1[0].invocation_id
fc_event = workflow_testing_utils.find_function_call_event(
events1, 'long_running_tool_func'
)
assert fc_event is not None
function_call_id = fc_event.content.parts[0].function_call.id
# 4. Prepare resume message
tool_response = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=function_call_id,
name='long_running_tool_func',
response={'result': 'Final tool output'},
)
)
)
# 5. Resume with tool output.
events2 = await runner.run_async(
new_message=tool_response,
invocation_id=invocation_id,
)
# 6. Verify completion
content_texts = [
p.text
for e in events2
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert any('LLM response after tool in task mode' in t for t in content_texts)
@pytest.mark.asyncio
async def test_workflow_pause_and_resume_tool_confirmation(
request: pytest.FixtureRequest,
):
"""Tests that a workflow can pause and resume with a tool requiring confirmation.
Setup: Workflow with a single LlmAgent node having a tool requiring confirmation.
Act:
- Run 1: Start workflow, tool requests confirmation.
- Run 2: Send confirmation response.
Assert:
- Run 1: Workflow pauses and yields confirmation request.
- Run 2: Workflow resumes and completes with LLM response.
"""
from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
from google.adk.tools.function_tool import FunctionTool
# Given a tool that requires confirmation and a mock model
def _simple_tool_func():
return {'result': 'tool executed'}
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name='_simple_tool_func',
args={},
),
types.Part.from_text(text='LLM response after confirmation'),
]
)
node_a = LlmAgent(
name='my_agent',
model=mock_model,
tools=[FunctionTool(func=_simple_tool_func, require_confirmation=True)],
)
wf = Workflow(
name='test_workflow_confirmation',
edges=[(START, node_a)],
)
app = App(
name=request.function.__name__,
root_agent=wf,
)
runner = testing_utils.InMemoryRunner(app=app)
# When the workflow is started
user_event = testing_utils.get_user_content('start workflow')
events1 = await runner.run_async(user_event)
# Then it should request confirmation
fc_event = None
for e in events1:
if e.content and e.content.parts:
for p in e.content.parts:
if (
p.function_call
and p.function_call.name == REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
):
fc_event = e
break
assert fc_event is not None, 'Did not find confirmation request event'
ask_for_confirmation_function_call_id = fc_event.content.parts[
0
].function_call.id
invocation_id = events1[0].invocation_id
# When the user confirms the tool call
user_confirmation = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=ask_for_confirmation_function_call_id,
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
response={'confirmed': True},
)
)
)
events2 = await runner.run_async(
new_message=user_confirmation,
invocation_id=invocation_id,
)
# Then the workflow completes with the LLM response
content_texts = [
p.text
for e in events2
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert any('LLM response after confirmation' in t for t in content_texts)
@pytest.mark.asyncio
async def test_workflow_pause_and_resume_auth_node(
request: pytest.FixtureRequest,
):
"""Workflow pauses on missing credentials and resumes when provided.
Setup: Workflow with a single node requiring auth.
Act:
- Run 1: Start workflow without credentials.
- Run 2: Provide credentials via FunctionResponse.
Assert:
- Run 1: Workflow returns adk_request_credential request.
- Run 2: Workflow completes and yields event with credential.
"""
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
from google.adk.workflow import FunctionNode
# Given a workflow with a node requiring auth
auth_config = AuthConfig(
auth_scheme=APIKey(**{'in': APIKeyIn.header, 'name': 'X-Api-Key'}),
credential_key='my_key',
)
def fetch_weather(ctx):
cred = ctx.get_auth_response(auth_config)
api_key = cred.api_key if cred else 'unknown'
from google.adk import Event
yield Event(message=f'authed with {api_key}')
node_a = FunctionNode(
func=fetch_weather, auth_config=auth_config, rerun_on_resume=True
)
wf = Workflow(
name='test_workflow_auth_node',
edges=[(START, node_a)],
)
app = App(
name=request.function.__name__,
root_agent=wf,
)
runner = testing_utils.InMemoryRunner(app=app)
# When the workflow is started without credentials
events1 = await runner.run_async(testing_utils.get_user_content('start'))
# Then it should pause and request credentials
auth_fc_events = [
e
for e in events1
if e.content
and e.content.parts
and e.content.parts[0].function_call
and e.content.parts[0].function_call.name == 'adk_request_credential'
]
assert len(auth_fc_events) == 1
auth_fc_id = auth_fc_events[0].content.parts[0].function_call.id
invocation_id = events1[0].invocation_id
# When the user provides the credentials
auth_response = AuthConfig(
auth_scheme=auth_config.auth_scheme,
credential_key=auth_config.credential_key,
exchanged_auth_credential=AuthCredential(
auth_type=AuthCredentialTypes.API_KEY,
api_key='secret_key',
),
)
user_credential_response = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=auth_fc_id,
name='adk_request_credential',
response=auth_response.model_dump(
exclude_none=True, by_alias=True
),
),
),
)
# When the workflow is resumed
events2 = await runner.run_async(
new_message=user_credential_response,
invocation_id=invocation_id,
)
# Then the workflow should resume and complete
content_texts = [
p.text
for e in events2
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert any('authed with secret_key' in t for t in content_texts)
@pytest.mark.asyncio
async def test_workflow_pause_and_resume_parent_interruption(
request: pytest.FixtureRequest,
):
"""Tests multi-agent workflow where parent produces an interruption."""
# Child agent (does nothing special)
child_agent = LlmAgent(
name='child_agent',
model=testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name='finish_task',
args={'result': 'Child done'},
)
]
),
mode='task',
)
# Parent agent calls LRO tool first, then delegates to child
fc = types.Part.from_function_call(name='long_running_tool_func', args={})
call_child = types.Part.from_function_call(
name='child_agent',
args={'request': 'Start child task'},
)
parent_model = testing_utils.MockModel.create(
responses=[
fc, # First call LRO
call_child, # Then call child
'Parent all done', # Finally finish
]
)
parent_agent = LlmAgent(
name='parent_agent',
model=parent_model,
tools=[
LongRunningFunctionTool(func=long_running_tool_func),
],
sub_agents=[child_agent],
mode='task',
)
wf = Workflow(
name='test_workflow_parent_hitl',
edges=[
(START, parent_agent),
],
)
app = App(
name=request.function.__name__,
root_agent=wf,
)
runner = testing_utils.InMemoryRunner(app=app)
# Run 1: Should pause on LRO
events1 = await runner.run_async(testing_utils.get_user_content('start'))
assert any(e.long_running_tool_ids for e in events1)
invocation_id = events1[0].invocation_id
fc_event = workflow_testing_utils.find_function_call_event(
events1, 'long_running_tool_func'
)
assert fc_event is not None
function_call_id = fc_event.content.parts[0].function_call.id
# Resume with tool output
tool_response = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=function_call_id,
name='long_running_tool_func',
response={'result': 'LRO done'},
)
)
)
events2 = await runner.run_async(
new_message=tool_response,
invocation_id=invocation_id,
)
# Verify completion
content_texts = [
p.text
for e in events2
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert any('Parent all done' in t for t in content_texts)
@pytest.mark.xfail(reason='Task agents cannot have sub-agents in workflow')
@pytest.mark.asyncio
async def test_workflow_pause_and_resume_child_interruption(
request: pytest.FixtureRequest,
):
"""Tests multi-agent workflow where child produces an interruption."""
# Child agent calls LRO tool
fc = types.Part.from_function_call(name='long_running_tool_func', args={})
child_model = testing_utils.MockModel.create(
responses=[
fc,
types.Part.from_function_call(
name='finish_task',
args={'result': 'Child done after tool'},
),
]
)
child_agent = LlmAgent(
name='child_agent',
model=child_model,
tools=[LongRunningFunctionTool(func=long_running_tool_func)],
mode='task',
)
# Parent agent delegates to child first
call_child = types.Part.from_function_call(
name='child_agent',
args={'request': 'Start child task'},
)
parent_model = testing_utils.MockModel.create(
responses=[
call_child,
call_child,
'Parent all done',
]
)
parent_agent = LlmAgent(
name='parent_agent',
model=parent_model,
sub_agents=[child_agent],
mode='task',
)
wf = Workflow(
name='test_workflow_child_hitl',
edges=[
(START, parent_agent),
],
)
app = App(
name=request.function.__name__,
root_agent=wf,
)
runner = testing_utils.InMemoryRunner(app=app)
# Run 1: Should enter parent, then child, then child pauses on LRO!
events1 = await runner.run_async(testing_utils.get_user_content('start'))
assert any(
p.function_call and p.function_call.name == 'child_agent'
for e in events1
if e.content
for p in e.content.parts
)
invocation_id = events1[0].invocation_id
fc_event = workflow_testing_utils.find_function_call_event(
events1, 'long_running_tool_func'
)
assert fc_event is not None
function_call_id = fc_event.content.parts[0].function_call.id
# Resume with tool output
tool_response = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=function_call_id,
name='long_running_tool_func',
response={'result': 'LRO done'},
)
)
)
events2 = await runner.run_async(
new_message=tool_response,
invocation_id=invocation_id,
)
# Verify completion
content_texts = [
p.text
for e in events2
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert any('Parent all done' in t for t in content_texts)
def _append_function_response(
session, invocation_id, branch, fc_id, func_name, response
):
"""Helper to append a FunctionResponse event to a session."""
session.events.append(
Event(
invocation_id=invocation_id,
author='user',
branch=branch,
content=types.Content(
role='user',
parts=[
types.Part(
function_response=types.FunctionResponse(
id=fc_id,
name=func_name,
response=response,
)
)
],
),
)
)
@pytest.mark.asyncio
async def test_workflow_resume_inputs_fallback_branch(monkeypatch):
"""Resume inputs find function name in a different branch.
Setup: Session contains a FunctionCall in branch_A.
Act: Run the wrapper with resume_inputs for that FunctionCall in branch_B.
Assert: The wrapper successfully finds the function name and completes.
"""
# Arrange
mock_model = testing_utils.MockModel.create(
responses=[types.Part.from_text(text='I am done')]
)
agent = LlmAgent(name='test_agent', model=mock_model)
# Create a dummy context and session
session = Session(id='test_session', appName='test_app', userId='test_user')
# Add an event with function call in branch 'branch_A'
session.events.append(
Event(
invocation_id='test_inv',
branch='branch_A',
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
id='fc_123',
name='my_target_func',
args={},
)
)
]
),
)
)
# Create invocation context with branch 'branch_B'
session_service = InMemorySessionService()
ic = InvocationContext(
session=session,
branch='branch_B',
session_service=session_service,
invocation_id='test_inv',
run_config=RunConfig(),
agent=agent,
)
# Create context with resume_inputs
ctx = Context(
ic,
node_path='test_agent',
run_id='1',
resume_inputs={'fc_123': {'result': 'ok'}},
)
# Mock prepare functions
class DummyAgentCtx:
def __init__(self, ic):
self._ic = ic
def get_invocation_context(self):
return self._ic
from google.adk.workflow import _llm_agent_wrapper
monkeypatch.setattr(
_llm_agent_wrapper,
'prepare_llm_agent_context',
lambda a, c: DummyAgentCtx(ic),
)
monkeypatch.setattr(
_llm_agent_wrapper, 'prepare_llm_agent_input', lambda a, c, i: None
)
# Simulate Runner adding the event to the correct branch!
_append_function_response(
session,
invocation_id='test_inv',
branch='branch_A',
fc_id='fc_123',
func_name='my_target_func',
response={'result': 'ok'},
)
# Act - Run the function directly
from google.adk.workflow._llm_agent_wrapper import run_llm_agent_as_node
gen = run_llm_agent_as_node(agent, ctx=ctx, node_input='start')
try:
await gen.__anext__()
except StopAsyncIteration:
pass
# Assert - Verify that the event is there with correct branch
# Initial events had 1 item + 1 simulated by Runner = 2!
assert len(session.events) == 2
event = session.events[-1] # The newly injected event!
assert (
event.branch == 'branch_A'
) # Injected in the branch where call was made!
assert event.content.parts[0].function_response.name == 'my_target_func'
@pytest.mark.asyncio
async def test_workflow_resume_inputs_multiple_branches(monkeypatch):
"""Resume inputs handle multiple items targeting different branches.
Setup: Session contains FunctionCalls in branch_A and branch_B.
Act: Run the wrapper with resume_inputs for both in branch_C.
Assert: The wrapper successfully finds function names for both.
"""
# Arrange
mock_model = testing_utils.MockModel.create(
responses=[types.Part.from_text(text='I am done')]
)
agent = LlmAgent(name='test_agent', model=mock_model)
session = Session(id='test_session', appName='test_app', userId='test_user')
# Add event 1 in branch_A
session.events.append(
Event(
invocation_id='test_inv',
branch='branch_A',
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
id='fc_A',
name='func_A',
args={},
)
)
]
),
)
)
# Add event 2 in branch_B
session.events.append(
Event(
invocation_id='test_inv',
branch='branch_B',
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
id='fc_B',
name='func_B',
args={},
)
)
]
),
)
)
session_service = InMemorySessionService()
# Current branch is branch_C
ic = InvocationContext(
session=session,
branch='branch_C',
session_service=session_service,
invocation_id='test_inv',
run_config=RunConfig(),
agent=agent,
)
ctx = Context(
ic,
node_path='test_agent',
run_id='1',
resume_inputs={'fc_A': {'result': 'ok_A'}, 'fc_B': {'result': 'ok_B'}},
)
class DummyAgentCtx:
def __init__(self, ic):
self._ic = ic
def get_invocation_context(self):
return self._ic
from google.adk.workflow import _llm_agent_wrapper
monkeypatch.setattr(
_llm_agent_wrapper,
'prepare_llm_agent_context',
lambda a, c: DummyAgentCtx(ic),
)
monkeypatch.setattr(
_llm_agent_wrapper, 'prepare_llm_agent_input', lambda a, c, i: None
)
# Simulate Runner adding the events to the correct branches!
_append_function_response(
session,
invocation_id='test_inv',
branch='branch_A',
fc_id='fc_A',
func_name='func_A',
response={'result': 'ok_A'},
)
_append_function_response(
session,
invocation_id='test_inv',
branch='branch_B',
fc_id='fc_B',
func_name='func_B',
response={'result': 'ok_B'},
)
# Act - Run the function directly
from google.adk.workflow._llm_agent_wrapper import run_llm_agent_as_node
gen = run_llm_agent_as_node(agent, ctx=ctx, node_input='start')
try:
await gen.__anext__()
except StopAsyncIteration:
pass
# Assert - Verify that the events are there with correct branches
# Initial events had 2 items + 2 simulated by Runner = 4!
assert len(session.events) == 4
# Check both exist in the injected events
injected_events = session.events[2:]
branches = [e.branch for e in injected_events]
names = [e.content.parts[0].function_response.name for e in injected_events]
assert 'branch_A' in branches
assert 'branch_B' in branches
assert 'func_A' in names
assert 'func_B' in names
@pytest.mark.asyncio
async def test_workflow_task_mode_plain_text_resume_auto_routing(
request: pytest.FixtureRequest,
):
"""Tests that task mode agent can pause for text and resume with text without explicit invocation_id."""
# LLM behavior:
# Round 1: Outputs text asking for info.
# Round 2: After user replies with text, calls finish_task with result.
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_text(text='Please provide the secret code:'),
types.Part.from_function_call(
name='finish_task',
args={'result': 'Success with code'},
),
]
)
node_a = LlmAgent(
name='my_task_agent',
model=mock_model,
mode='task',
)
wf = Workflow(
name='test_workflow_plain_text_resume',
edges=[
(START, node_a),
],
)
app = App(
name=request.function.__name__,
root_agent=wf,
)
runner = testing_utils.InMemoryRunner(app=app)
# 1. First run: should yield the prompt and pause.
events1 = await runner.run_async('start workflow')
# Verify it yielded the prompt
texts1 = [
p.text
for e in events1
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert 'Please provide the secret code:' in texts1
# 2. Second run: resume with plain text, NOT passing invocation_id!
# It should automatically resolve invocation_id and isolation_scope.
events2 = await runner.run_async('my_secret_code_123')
# Verify completion
# The last event should have output set from finish_task args
assert any(e.output == {'result': 'Success with code'} for e in events2)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,450 @@
# 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 @node decorator and behavior."""
from __future__ import annotations
from unittest import mock
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps import App
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.base_tool import BaseTool
from google.adk.workflow import FunctionNode
from google.adk.workflow import START
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._node import node
from google.adk.workflow._node import Node
from google.adk.workflow._parallel_worker import _ParallelWorker as ParallelWorker
from google.adk.workflow._retry_config import RetryConfig
from google.adk.workflow._tool_node import _ToolNode as ToolNode
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest
from .. import testing_utils
ANY = mock.ANY
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _run_workflow(wf, message="start"):
"""Run a Workflow through Runner, return collected events."""
ss = InMemorySessionService()
runner = Runner(app_name="test", node=wf, session_service=ss)
session = await ss.create_session(app_name="test", 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
def _output_by_node(events):
"""Extract (node_name_from_path, output) for child node events."""
results = []
for e in events:
if e.output is not None and e.node_info.path and "/" in e.node_info.path:
node_name = e.node_info.path.rsplit("/", 1)[-1]
if "@" in node_name:
node_name = node_name.rsplit("@", 1)[0]
results.append((node_name, e.output))
return results
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_node_decorator():
"""Tests that @node decorator can wrap a function and override its name."""
@node(name="decorated_node")
def my_func():
return "Hello from decorated_func"
assert my_func.name == "decorated_node"
wf = Workflow(
name="test_agent",
edges=[
(START, my_func),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ("decorated_node", "Hello from decorated_func") in by_node
def test_node_parallel_worker_instance():
"""Tests that node() can wrap a node in ParallelWorker."""
@node(parallel_worker=True)
def my_func(node_input):
return node_input
assert isinstance(my_func, ParallelWorker)
assert my_func.name == "my_func"
def other_func(x):
return x
parallel_node = node(other_func, parallel_worker=True)
assert isinstance(parallel_node, ParallelWorker)
assert parallel_node.name == "other_func"
@pytest.mark.asyncio
async def test_node_parallel_worker_execution():
"""Tests that a node with parallel_worker=True correctly processes inputs."""
@node(parallel_worker=True)
async def my_func(node_input):
return node_input * 2
async def producer_func() -> list[int]:
return [1, 2, 3]
wf = Workflow(
name="test_agent",
edges=[
(START, producer_func),
(producer_func, my_func),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ("producer_func", [1, 2, 3]) in by_node
assert ("my_func", [2, 4, 6]) in by_node
def test_node_decorator_rerun_on_resume():
"""Tests that @node decorator can override rerun_on_resume."""
@node(name="decorated_node", rerun_on_resume=True)
def my_func():
return "Hello from decorated_func"
assert isinstance(my_func, FunctionNode)
assert my_func.rerun_on_resume
@node()
def my_func2():
return "Hello from decorated_func2"
assert isinstance(my_func2, FunctionNode)
assert not my_func2.rerun_on_resume
def test_node_decorator_parameter_binding():
"""Tests that @node decorator can configure parameter_binding."""
@node(parameter_binding="node_input")
def my_func(foo: str):
return f"Hello {foo}"
assert isinstance(my_func, FunctionNode)
assert my_func.parameter_binding == "node_input"
@pytest.mark.asyncio
async def test_node_decorator_parameter_binding_execution():
"""Tests execution of a node with parameter_binding='node_input'."""
async def producer_func() -> dict[str, Any]:
return {"foo": "hello", "bar": 42}
@node(parameter_binding="node_input")
def my_func(foo: str, bar: int):
return f"{foo}:{bar}"
wf = Workflow(
name="test_agent",
edges=[
(START, producer_func),
(producer_func, my_func),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ("my_func", "hello:42") in by_node
def test_node_function_with_base_node():
"""Tests that node() function returns a copied node when given a BaseNode."""
@node(name="original")
def original():
pass
wrapped = node(original, name="overridden", rerun_on_resume=True)
assert isinstance(wrapped, FunctionNode)
assert wrapped is not original
assert wrapped.name == "overridden"
assert wrapped.rerun_on_resume
class MyTool(BaseTool):
name = "tool"
description = "desc"
async def _run_async_impl(self):
return "done"
def test_node_no_unnecessary_wrap():
"""Tests that node() does not wrap LlmAgent, Agent, Tool, or func in OverridingNode."""
llm_agent = LlmAgent(name="llm")
llm_node = node(llm_agent, name="overridden_llm")
assert isinstance(llm_node, LlmAgent)
assert llm_node.name == "overridden_llm"
assert llm_node.mode == "single_turn"
agent = BaseAgent(name="agent")
agent_node_inst = node(agent, name="overridden_agent", rerun_on_resume=True)
assert isinstance(agent_node_inst, BaseAgent)
assert agent_node_inst.name == "overridden_agent"
assert agent_node_inst.rerun_on_resume
tool_inst = MyTool(name="tool", description="desc")
t_node = node(tool_inst, name="overridden_tool")
assert isinstance(t_node, ToolNode)
assert t_node.name == "overridden_tool"
def my_func():
pass
f_node = node(my_func, name="overridden_func", rerun_on_resume=True)
assert isinstance(f_node, FunctionNode)
assert f_node.name == "overridden_func"
assert f_node.rerun_on_resume
class StatefulTool(BaseTool):
"""A tool that modifies state via tool_context."""
async def run_async(self, *, args, tool_context):
tool_context.state["tool_key"] = "tool_value"
tool_context.state["tool_count"] = 10
return {"status": "ok"}
from .workflow_testing_utils import simplify_events_with_node
@pytest.mark.asyncio
async def test_tool_node_state_delta():
"""Tests that state set via tool_context.state in ToolNode is persisted."""
tool_node = ToolNode(
tool=StatefulTool(name="stateful_tool", description="Sets state values"),
)
def read_state(tool_key: str, tool_count: int) -> str:
return f"tool_key={tool_key}, tool_count={tool_count}"
def start_node():
return {}
wf = Workflow(
name="test_tool_node_state_delta",
edges=[
(START, start_node),
(start_node, tool_node),
(tool_node, read_state),
],
)
events, _, _ = await _run_workflow(wf)
simplified = simplify_events_with_node(
events, include_workflow_output=True, include_state_delta=True
)
assert (
"test_tool_node_state_delta@1/stateful_tool@1",
{"output": {"status": "ok"}},
) in [(e[0], {"output": e[1].get("output")}) for e in simplified]
assert (
"test_tool_node_state_delta@1/read_state@1",
{
"output": "tool_key=tool_value, tool_count=10",
},
) in [(e[0], {"output": e[1].get("output")}) for e in simplified]
class _CustomNode(Node):
custom_val: str = "hello"
rerun_on_resume: bool = True
async def run_node_impl(self, *, ctx, node_input):
yield f"subclass: {self.custom_val} -> {node_input}"
def test_node_subclassing_model_copy_preserves_identity():
"""Tests that Node.model_copy preserves the subclass class identity."""
node_inst = _CustomNode(
name="subclass", parallel_worker=True, custom_val="barrier"
)
assert node_inst.parallel_worker is True
cloned = node_inst.model_copy()
assert isinstance(cloned, _CustomNode)
assert cloned.custom_val == "barrier"
assert cloned.parallel_worker is True
assert isinstance(cloned._inner_node, ParallelWorker)
# Confirm inner node wraps a clone of _CustomNode preserving identity!
assert isinstance(cloned._inner_node._node, _CustomNode)
assert cloned._inner_node._node.custom_val == "barrier"
assert cloned._inner_node._node.parallel_worker is False
@pytest.mark.asyncio
async def test_node_subclassing_execution_with_parallel_worker():
"""Tests that a subclassed Node with parallel_worker=True executes successfully."""
subclass_node = _CustomNode(
name="subclass", parallel_worker=True, custom_val="workflow"
)
async def producer():
return ["input1", "input2"]
wf = Workflow(
name="test_agent",
edges=[
(START, producer),
(producer, subclass_node),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ("producer", ["input1", "input2"]) in by_node
assert (
"subclass",
["subclass: workflow -> input1", "subclass: workflow -> input2"],
) in by_node
def test_node_decorator_parallel_worker_max_parallel_workers():
"""Tests that node() correctly sets max_parallel_workers on ParallelWorker."""
@node(parallel_worker=True, max_parallel_workers=3)
def my_func(node_input):
return node_input
assert isinstance(my_func, ParallelWorker)
assert my_func.max_parallel_workers == 3
def test_node_decorator_invalid_max_parallel_workers():
"""Tests that node() raises ValueError if max_parallel_workers is set without parallel_worker."""
with pytest.raises(
ValueError,
match="max_parallel_workers can only be set when parallel_worker is True",
):
@node(parallel_worker=False, max_parallel_workers=3)
def my_func(node_input):
return node_input
def test_node_subclass_invalid_max_parallel_workers():
"""Tests that Node subclass raises ValidationError if max_parallel_workers is set without parallel_worker."""
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
_CustomNode(name="subclass", parallel_worker=False, max_parallel_workers=3)
assert (
"max_parallel_workers can only be set when parallel_worker is True"
in str(exc_info.value)
)
def test_node_subclassing_model_copy_preserves_max_parallel_workers():
"""Tests that Node.model_copy preserves max_parallel_workers."""
node_inst = _CustomNode(
name="subclass",
parallel_worker=True,
max_parallel_workers=5,
custom_val="barrier",
)
assert node_inst.parallel_worker is True
assert node_inst.max_parallel_workers == 5
assert node_inst._inner_node.max_parallel_workers == 5
cloned = node_inst.model_copy()
assert isinstance(cloned, _CustomNode)
assert cloned.parallel_worker is True
assert cloned.max_parallel_workers == 5
assert isinstance(cloned._inner_node, ParallelWorker)
assert cloned._inner_node.max_parallel_workers == 5
assert cloned._inner_node._node.parallel_worker is False
def test_node_decorator_invalid_max_parallel_workers_less_than_one():
"""Tests that node() raises ValueError if max_parallel_workers is less than 1."""
with pytest.raises(
ValueError,
match="max_parallel_workers must be greater than or equal to 1",
):
@node(parallel_worker=True, max_parallel_workers=0)
def my_func(node_input):
return node_input
def test_node_subclass_invalid_max_parallel_workers_less_than_one():
"""Tests that Node subclass raises ValidationError if max_parallel_workers is less than 1."""
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
_CustomNode(name="subclass", parallel_worker=True, max_parallel_workers=0)
assert "max_parallel_workers must be greater than or equal to 1" in str(
exc_info.value
)
def test_parallel_worker_invalid_max_parallel_workers_less_than_one():
"""Tests that ParallelWorker constructor raises ValueError if max_parallel_workers is less than 1."""
def dummy_func(x):
return x
with pytest.raises(
ValueError,
match="max_parallel_workers must be greater than or equal to 1",
):
ParallelWorker(node=dummy_func, max_parallel_workers=0)
@@ -0,0 +1,204 @@
# 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 node timeout behavior."""
from __future__ import annotations
import asyncio
from google.adk.apps import App
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow import START
from google.adk.workflow._errors import NodeTimeoutError
from google.adk.workflow._node import node
from google.adk.workflow._retry_config import RetryConfig
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _run_workflow(wf, message='start'):
"""Run a Workflow through Runner, return collected events."""
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', 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
def _output_by_node(events):
"""Extract (node_name_from_path, output) for child node events."""
results = []
for e in events:
if e.output is not None and e.node_info.path and '/' in e.node_info.path:
node_name = e.node_info.path.rsplit('/', 1)[-1]
if '@' in node_name:
node_name = node_name.rsplit('@', 1)[0]
results.append((node_name, e.output))
return results
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_node_completes_within_timeout():
"""A node that finishes before the timeout should succeed normally."""
@node(timeout=5.0)
async def my_slow_node():
await asyncio.sleep(0.01)
return 'done'
wf = Workflow(
name='test_workflow',
edges=[
(START, my_slow_node),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ('my_slow_node', 'done') in by_node
@pytest.mark.asyncio
async def test_node_exceeds_timeout():
"""A node that exceeds its timeout should fail."""
from google.adk.workflow import FunctionNode
async def raw_slow_func():
await asyncio.sleep(1.0)
return 'done'
my_too_slow_node = FunctionNode(
name='my_too_slow_node', func=raw_slow_func, timeout=0.05
)
wf = Workflow(
name='test_workflow',
edges=[
(START, my_too_slow_node),
],
)
with pytest.raises(NodeTimeoutError) as exc_info:
await _run_workflow(wf)
assert 'my_too_slow_node' in str(exc_info.value)
@pytest.mark.asyncio
async def test_node_no_timeout():
"""A node with timeout=None should run without any time limit."""
@node(timeout=None)
async def my_no_timeout_node():
await asyncio.sleep(0.01)
return 'done'
wf = Workflow(
name='test_workflow',
edges=[
(START, my_no_timeout_node),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ('my_no_timeout_node', 'done') in by_node
@pytest.mark.asyncio
async def test_node_timeout_with_retry():
"""A timed-out node should be retried if retry_config is set."""
run_count = 0
@node(
timeout=0.05,
retry_config=RetryConfig(max_attempts=3, initial_delay=0.0, jitter=0.0),
)
async def node_a():
nonlocal run_count
run_count += 1
if run_count == 1:
await asyncio.sleep(1.0)
return 'success'
wf = Workflow(
name='test_workflow',
edges=[
(START, node_a),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
# Verify that the final result was successfully obtained.
assert ('node_a', 'success') in by_node
# Verify that the node was actually executed more than once (i.e., retried).
assert run_count == 2, f'Expected run_count == 2, got {run_count}'
@pytest.mark.asyncio
async def test_nested_workflow_timeout():
"""A nested workflow that exceeds its timeout in the outer workflow should fail.
Setup: outer_wf -> inner_wf -> slow_node. inner_wf has timeout=0.05.
Act: Run the outer workflow.
Assert: Execution raises NodeTimeoutError referencing inner_wf.
"""
import sys
if sys.version_info < (3, 11):
pytest.skip('asyncio.timeout requires Python 3.11+')
# Given an outer workflow containing a slow inner workflow with a timeout
@node()
async def slow_node():
await asyncio.sleep(1.0)
return 'done'
inner_wf = Workflow(
name='inner_wf',
edges=[(START, slow_node)],
timeout=0.05,
)
outer_wf = Workflow(
name='outer_wf',
edges=[(START, inner_wf)],
)
# When the outer workflow is executed
# Then it should raise NodeTimeoutError referencing the inner workflow
with pytest.raises(NodeTimeoutError) as exc_info:
await _run_workflow(outer_wf)
assert 'inner_wf' in str(exc_info.value)
@@ -0,0 +1,161 @@
# 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 nested workflow output event deduplication.
Verifies that nested workflows produce only leaf terminal events and
resolve output via terminal path resolution from the graph structure.
"""
from typing import Any
from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.workflow._workflow import Workflow
import pytest
from . import testing_utils
def _is_checkpoint(event: Event) -> bool:
"""Returns True if event is an agent state checkpoint or end_of_agent."""
if event.actions.agent_state is not None:
return True
if event.actions.end_of_agent:
return True
return False
def _output_events(events: list[Event]) -> list[Event]:
"""Returns only events with output data (not checkpoint/state)."""
return [e for e in events if not _is_checkpoint(e) and e.output is not None]
async def test_two_level_nesting_deduplicates(
request,
):
"""Two-level nesting emits only the leaf's output event, not finalize events.
Setup: outer → inner → leaf.
Assert: exactly 1 output event from 'outer/inner/leaf', no
duplicate finalize events from inner or outer.
"""
async def leaf(node_input: Any):
return 'leaf_data'
inner = Workflow(name='inner', edges=[('START', leaf)])
outer = Workflow(name='outer', edges=[('START', inner)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hi'))
out_events = _output_events(events)
assert len(out_events) == 1
assert out_events[0].output == 'leaf_data'
assert out_events[0].node_info.path == 'outer@1/inner@1/leaf@1'
async def test_nested_with_output_schema_validates_at_read_time(
request,
):
"""Nested workflow with output_schema validates without emitting extra events.
Setup: outer → inner(output_schema=str) → leaf.
Assert: 1 output event with validated data, no extra finalize event.
"""
async def leaf(node_input: Any):
return 'raw_data'
inner = Workflow(
name='inner',
edges=[('START', leaf)],
output_schema=str,
)
outer = Workflow(name='outer', edges=[('START', inner)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hi'))
out_events = _output_events(events)
assert len(out_events) == 1
assert out_events[0].output == 'raw_data'
async def test_multiple_terminals_in_nested_workflow_raises(
request,
):
"""Fan-out with no join raises ValueError for multiple terminal outputs.
Setup: outer → inner → (branch_a, branch_b).
Assert: ValueError because inner has two terminal nodes producing output.
"""
async def branch_a(node_input: Any):
return 'a_out'
async def branch_b(node_input: Any):
return 'b_out'
inner = Workflow(
name='inner',
edges=[('START', (branch_a, branch_b))],
)
outer = Workflow(name='outer', edges=[('START', inner)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(ValueError, match='multiple terminal nodes'):
await runner.run_async(testing_utils.get_user_content('hi'))
async def test_non_terminal_output_not_exposed_as_workflow_output(
request,
):
"""Downstream node receives terminal output, not intermediate node output.
Setup: outer → inner(step_a → step_b) → consume.
Assert: consume receives step_b's 'final' (terminal), not step_a's
'intermediate'.
"""
async def step_a(node_input: Any):
return 'intermediate'
async def step_b(node_input: str):
return 'final'
inner = Workflow(name='inner', edges=[('START', step_a, step_b)])
async def consume(node_input: str):
return f'got: {node_input}'
outer = Workflow(name='outer', edges=[('START', inner, consume)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hi'))
out_events = _output_events(events)
consume_events = [
e
for e in out_events
if e.node_info.name and e.node_info.name.split('@')[0] == 'consume'
]
assert len(consume_events) == 1
assert consume_events[0].output == 'got: final'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,685 @@
# 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.
"""Testings for the Workflow routes."""
from typing import Any
from google.adk.agents.context import Context
from google.adk.apps.app import App
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._join_node import JoinNode
from google.adk.workflow._workflow import Workflow
import pytest
from .. import testing_utils
from .workflow_testing_utils import simplify_events_with_node
from .workflow_testing_utils import TestingNode
@pytest.mark.asyncio
async def test_run_async_with_edge_routes(request: pytest.FixtureRequest):
route_holder = {'route': 'route_b'}
def dynamic_router(_ctx: Context, _node_input: Any):
return route_holder['route']
node_a = TestingNode(name='NodeA', output='A', route=dynamic_router)
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
graph = Graph(
edges=[
Edge(from_node=START, to_node=node_a),
Edge(
from_node=node_a,
to_node=node_b,
route='route_b',
),
Edge(
from_node=node_a,
to_node=node_c,
route='route_c',
),
],
)
agent = Workflow(
name='test_workflow_agent',
graph=graph,
)
# Test case for route_b
route_holder['route'] = 'route_b'
app = App(name=request.function.__name__ + '_b', root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events_b = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events_b) == [
('test_workflow_agent@1/NodeA@1', {'output': 'A'}),
('test_workflow_agent@1/NodeB@1', {'output': 'B'}),
]
# Test case for route_c
route_holder['route'] = 'route_c'
app_c = App(name=request.function.__name__ + '_c', root_agent=agent)
runner_c = testing_utils.InMemoryRunner(app=app_c)
events_c = await runner_c.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events_c) == [
('test_workflow_agent@1/NodeA@1', {'output': 'A'}),
('test_workflow_agent@1/NodeC@1', {'output': 'C'}),
]
@pytest.mark.asyncio
async def test_output_route_int(request: pytest.FixtureRequest):
node_a = TestingNode(name='NodeA', route=1)
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
agent = Workflow(
name='test_workflow_agent_route_int',
edges=[
(START, node_a),
(node_a, {1: node_b, 2: node_c}),
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events) == [
(
'test_workflow_agent_route_int@1/NodeA@1',
{'output': None},
),
(
'test_workflow_agent_route_int@1/NodeB@1',
{'output': 'B'},
),
]
@pytest.mark.asyncio
async def test_output_route_bool(request: pytest.FixtureRequest):
node_a = TestingNode(name='NodeA', route=True)
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
agent = Workflow(
name='test_workflow_agent_route_bool',
edges=[
(START, node_a),
(node_a, {True: node_b, False: node_c}),
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events) == [
(
'test_workflow_agent_route_bool@1/NodeA@1',
{'output': None},
),
(
'test_workflow_agent_route_bool@1/NodeB@1',
{'output': 'B'},
),
]
@pytest.mark.asyncio
async def test_wait_for_output_with_route_only_completes_successfully(
request: pytest.FixtureRequest,
):
"""A node with wait_for_output=True that yields only a route should complete and continue the workflow."""
node_a = TestingNode(name='NodeA', route='go_next', wait_for_output=True)
node_b = TestingNode(name='NodeB', output='B_done')
agent = Workflow(
name='test_wait_for_output_route',
edges=[
(START, node_a),
(node_a, {'go_next': node_b}),
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
# NodeA should yield no output, and NodeB should yield its output.
events = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events) == [
(
'test_wait_for_output_route@1/NodeA@1',
{'output': None},
),
(
'test_wait_for_output_route@1/NodeB@1',
{'output': 'B_done'},
),
]
@pytest.mark.asyncio
async def test_output_route_no_data(request: pytest.FixtureRequest):
node_a = TestingNode(name='NodeA', route='route_b')
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
graph = Graph(
edges=[
Edge(from_node=START, to_node=node_a),
Edge(
from_node=node_a,
to_node=node_b,
route='route_b',
),
Edge(
from_node=node_a,
to_node=node_c,
route='route_c',
),
],
)
agent = Workflow(
name='test_workflow_agent_route_no_data',
graph=graph,
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events) == [
(
'test_workflow_agent_route_no_data@1/NodeA@1',
{'output': None},
),
(
'test_workflow_agent_route_no_data@1/NodeB@1',
{'output': 'B'},
),
]
@pytest.mark.asyncio
async def test_run_async_with_list_of_routes(request: pytest.FixtureRequest):
node_a = TestingNode(name='NodeA', output='A', route=['route_b', 'route_c'])
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
node_d = TestingNode(name='NodeD', output='D')
graph = Graph(
edges=[
Edge(from_node=START, to_node=node_a),
Edge(
from_node=node_a,
to_node=node_b,
route='route_b',
),
Edge(
from_node=node_a,
to_node=node_c,
route='route_c',
),
Edge(
from_node=node_a,
to_node=node_d,
route='route_d',
),
],
)
agent = Workflow(
name='test_workflow_agent_list_routes',
graph=graph,
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
simplified_events = simplify_events_with_node(events)
assert len(simplified_events) == 3
assert simplified_events[0] == (
'test_workflow_agent_list_routes@1/NodeA@1',
{'output': 'A'},
)
# Check that the other two events are from NodeB and NodeC, in any order.
other_events = simplified_events[1:]
expected_other_events = [
(
'test_workflow_agent_list_routes@1/NodeB@1',
{'output': 'B'},
),
(
'test_workflow_agent_list_routes@1/NodeC@1',
{'output': 'C'},
),
]
assert len(other_events) == len(expected_other_events)
assert all(item in other_events for item in expected_other_events)
@pytest.mark.asyncio
async def test_run_async_with_default_route(request: pytest.FixtureRequest):
node_a = TestingNode(name='NodeA', output='A', route='unmatched_route')
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
node_d = TestingNode(name='NodeD', output='D')
graph = Graph(
edges=[
Edge(from_node=START, to_node=node_a),
Edge(
from_node=node_a,
to_node=node_b,
route='route_b',
),
# This edge has the DEFAULT_ROUTE tag.
Edge(
from_node=node_a,
to_node=node_c,
route=DEFAULT_ROUTE,
),
# This edge has no route tag, so it should always be triggered.
Edge(
from_node=node_a,
to_node=node_d,
),
],
)
agent = Workflow(
name='test_workflow_agent_default_route',
graph=graph,
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
simplified_events = simplify_events_with_node(events)
assert len(simplified_events) == 3
assert simplified_events[0] == (
'test_workflow_agent_default_route@1/NodeA@1',
{'output': 'A'},
)
# Check that NodeC (default route) and NodeD (untagged) are triggered.
other_events = simplified_events[1:]
expected_other_events = [
(
'test_workflow_agent_default_route@1/NodeC@1',
{'output': 'C'},
),
(
'test_workflow_agent_default_route@1/NodeD@1',
{'output': 'D'},
),
]
assert len(other_events) == len(expected_other_events)
assert all(item in other_events for item in expected_other_events)
@pytest.mark.asyncio
async def test_run_async_default_route_not_triggered_if_match(
request: pytest.FixtureRequest,
):
node_a = TestingNode(name='NodeA', output='A', route='route_b')
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
graph = Graph(
edges=[
Edge(from_node=START, to_node=node_a),
Edge(
from_node=node_a,
to_node=node_b,
route='route_b',
),
# This edge has the DEFAULT_ROUTE tag.
Edge(
from_node=node_a,
to_node=node_c,
route=DEFAULT_ROUTE,
),
],
)
agent = Workflow(
name='test_workflow_agent_default_route_not_triggered',
graph=graph,
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
simplified_events = simplify_events_with_node(events)
assert simplified_events == [
(
'test_workflow_agent_default_route_not_triggered@1/NodeA@1',
{'output': 'A'},
),
(
'test_workflow_agent_default_route_not_triggered@1/NodeB@1',
{'output': 'B'},
),
]
@pytest.mark.asyncio
async def test_run_async_with_untagged_edges(request: pytest.FixtureRequest):
node_a = TestingNode(name='NodeA', output='A', route='route_b')
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
node_d = TestingNode(name='NodeD', output='D')
graph = Graph(
edges=[
Edge(from_node=START, to_node=node_a),
Edge(
from_node=node_a,
to_node=node_b,
route='route_b',
),
Edge(
from_node=node_a,
to_node=node_c,
route='route_c',
),
# This edge has no route tag, so it should always be triggered.
Edge(
from_node=node_a,
to_node=node_d,
),
],
)
agent = Workflow(
name='test_workflow_agent_untagged_edges',
graph=graph,
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
simplified_events = simplify_events_with_node(events)
assert len(simplified_events) == 3
assert simplified_events[0] == (
'test_workflow_agent_untagged_edges@1/NodeA@1',
{'output': 'A'},
)
# Check that NodeB and NodeD are triggered.
other_events = simplified_events[1:]
expected_other_events = [
(
'test_workflow_agent_untagged_edges@1/NodeB@1',
{'output': 'B'},
),
(
'test_workflow_agent_untagged_edges@1/NodeD@1',
{'output': 'D'},
),
]
assert len(other_events) == len(expected_other_events)
assert all(item in other_events for item in expected_other_events)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'emitted_route, expected_target',
[
('route_a', 'Target'),
('route_b', 'Target'),
('route_c', 'Other'),
],
ids=['route_a_matches_list', 'route_b_matches_list', 'route_c_single'],
)
async def test_edge_with_multiple_routes(
request: pytest.FixtureRequest, emitted_route, expected_target
):
"""Tests that an edge with a list of routes matches any of them."""
node_router = TestingNode(name='Router', output='R', route=emitted_route)
node_target = TestingNode(name='Target', output='T')
node_other = TestingNode(name='Other', output='O')
agent = Workflow(
name='test_multi_route',
edges=[
(START, node_router),
Edge(
from_node=node_router,
to_node=node_target,
route=['route_a', 'route_b'],
),
(node_router, {'route_c': node_other}),
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events) == [
('test_multi_route@1/Router@1', {'output': 'R'}),
(
f'test_multi_route@1/{expected_target}@1',
{
'output': 'T' if expected_target == 'Target' else 'O',
},
),
]
# --- Routing map integration tests ---
@pytest.mark.asyncio
async def test_routing_map_with_default_route(
request: pytest.FixtureRequest,
):
"""Tests that DEFAULT_ROUTE works as a fallback in routing maps."""
node_a = TestingNode(name='NodeA', output='A', route='unmatched_route')
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
agent = Workflow(
name='test_routing_map_default',
edges=[
(START, node_a),
(node_a, {'route_b': node_b, DEFAULT_ROUTE: node_c}),
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events) == [
(
'test_routing_map_default@1/NodeA@1',
{'output': 'A'},
),
(
'test_routing_map_default@1/NodeC@1',
{'output': 'C'},
),
]
@pytest.mark.asyncio
async def test_routing_map_mixed_with_other_formats(
request: pytest.FixtureRequest,
):
"""Tests that routing maps coexist with tuples and Edge objects."""
node_a = TestingNode(name='NodeA', output='A', route='route_b')
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
node_d = TestingNode(name='NodeD', output='D')
agent = Workflow(
name='test_routing_map_mixed',
edges=[
(START, node_a),
(node_a, {'route_b': node_b, 'route_c': node_c}),
(node_b, node_d), # unconditional 2-tuple
Edge(from_node=node_c, to_node=node_d), # Edge object
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert simplify_events_with_node(events) == [
(
'test_routing_map_mixed@1/NodeA@1',
{'output': 'A'},
),
(
'test_routing_map_mixed@1/NodeB@1',
{'output': 'B'},
),
(
'test_routing_map_mixed@1/NodeD@1',
{'output': 'D'},
),
]
@pytest.mark.asyncio
async def test_routing_map_fan_out_runs_both_targets(
request: pytest.FixtureRequest,
):
"""Tests that fan-out in routing maps triggers both targets at runtime."""
node_a = TestingNode(name='NodeA', output='A', route='route_x')
node_b = TestingNode(name='NodeB', output='B')
node_c = TestingNode(name='NodeC', output='C')
gate = JoinNode(name='Gate')
agent = Workflow(
name='test_routing_map_fan_out',
edges=[
(START, node_a),
(node_a, {'route_x': (node_b, node_c)}),
(node_b, gate),
(node_c, gate),
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
simplified = simplify_events_with_node(events)
# NodeB and NodeC should both be triggered, in any order.
# Gate node also produces output combining the two.
outputs = [
e
for e in simplified
if isinstance(e[1], dict) and e[1].get('output') is not None
]
assert len(outputs) == 4
assert outputs[0] == (
'test_routing_map_fan_out@1/NodeA@1',
{'output': 'A'},
)
# Gate should be last
assert outputs[-1] == (
'test_routing_map_fan_out@1/Gate@1',
{'output': {'NodeB': 'B', 'NodeC': 'C'}},
)
other = outputs[1:3]
expected = [
(
'test_routing_map_fan_out@1/NodeB@1',
{'output': 'B'},
),
(
'test_routing_map_fan_out@1/NodeC@1',
{'output': 'C'},
),
]
assert len(other) == len(expected)
assert all(item in other for item in expected)
@pytest.mark.asyncio
async def test_fan_in_with_route(request: pytest.FixtureRequest):
"""Fan-in with conditional routes — both route to same target."""
a = TestingNode(name='a', output='A', route='route1')
b = TestingNode(name='b', output='B', route='route1')
c = TestingNode(name='c', output='C')
wf = Workflow(
name='wf',
edges=[
(START, a),
(START, b),
((a, b), {'route1': c}),
],
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
simplified = simplify_events_with_node(events)
# 'c' should receive from both 'a' and 'b' and run twice.
c_events = [e for e in simplified if e[0].split('/')[-1].split('@')[0] == 'c']
assert len(c_events) == 2
@pytest.mark.asyncio
async def test_fan_out_with_route(request: pytest.FixtureRequest):
"""Fan-out via route to multiple terminals raises ValueError."""
router = TestingNode(name='r', output='R', route='route1')
b = TestingNode(name='b', output='B')
c = TestingNode(name='c', output='C')
wf = Workflow(
name='wf',
edges=[
(START, router),
(router, {'route1': (b, c)}),
],
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(ValueError, match='multiple terminal nodes'):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_fan_in_out_with_route(request: pytest.FixtureRequest):
"""Fan-in/out with routes to multiple terminals raises ValueError."""
a = TestingNode(name='a', output='A', route='route1')
b = TestingNode(name='b', output='B', route='route1')
c = TestingNode(name='c', output='C')
d = TestingNode(name='d', output='D')
wf = Workflow(
name='wf',
edges=[
(START, a),
(START, b),
((a, b), {'route1': (c, d)}),
],
)
app = App(name=request.function.__name__, root_agent=wf)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(ValueError, match='multiple terminal nodes'):
await runner.run_async(testing_utils.get_user_content('start'))
@@ -0,0 +1,841 @@
# 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 Workflow schema validation (input_schema, output_schema)."""
from __future__ import annotations
from google.adk.apps.app import App
from google.adk.events.event import Event
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 import JoinNode
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import BaseModel
from pydantic import ValidationError
import pytest
from .. import testing_utils
from .workflow_testing_utils import create_parent_invocation_context
class _OutputModel(BaseModel):
name: str
value: int
class _OtherModel(BaseModel):
name: str
value: int
extra: str = 'default'
@pytest.mark.asyncio
async def test_workflow_output_schema_validates_terminal(
request: pytest.FixtureRequest,
):
"""Workflow.output_schema validates when downstream reads the output."""
def produce() -> dict:
return {'name': 'result', 'value': 10}
def consume(node_input: dict) -> str:
return f"got {node_input['name']}"
inner = Workflow(
name='wf',
edges=[(START, produce)],
output_schema=_OutputModel,
)
outer = Workflow(
name='outer',
edges=[(START, inner, consume)],
)
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'got result' for e in data_events)
@pytest.mark.asyncio
async def test_workflow_output_schema_rejects_invalid(
request: pytest.FixtureRequest,
):
"""Workflow.output_schema rejects invalid output when downstream reads."""
def produce_bad() -> dict:
return {'wrong_field': 'oops'}
def consume(node_input: dict) -> str:
return 'should not reach'
inner = Workflow(
name='wf',
edges=[(START, produce_bad)],
output_schema=_OutputModel,
)
outer = Workflow(
name='outer',
edges=[(START, inner, consume)],
)
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(ValueError):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_workflow_output_schema_coerces_defaults(
request: pytest.FixtureRequest,
):
"""Workflow.output_schema coerces terminal output (fills defaults)."""
def produce() -> dict:
return {'name': 'x', 'value': 1}
def consume(node_input: dict) -> dict:
return node_input
inner = Workflow(
name='wf',
edges=[(START, produce)],
output_schema=_OtherModel,
)
outer = Workflow(
name='outer',
edges=[(START, inner, consume)],
)
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
consume_events = [e for e in data_events if e.node_info.name == 'consume']
assert len(consume_events) == 1
assert consume_events[0].output == {
'name': 'x',
'value': 1,
'extra': 'default',
}
@pytest.mark.asyncio
async def test_nested_workflow_output_schema(
request: pytest.FixtureRequest,
):
"""Nested workflow's output_schema validates before passing to parent."""
def inner_produce() -> dict:
return {'name': 'inner', 'value': 3}
inner_workflow = Workflow(
name='inner_wf',
edges=[(START, inner_produce)],
output_schema=_OutputModel,
)
def outer_consume(node_input: dict) -> str:
return f"got {node_input['name']}"
outer_workflow = Workflow(
name='outer_wf',
edges=[
(START, inner_workflow),
(inner_workflow, outer_consume),
],
)
app = App(name=request.function.__name__, root_agent=outer_workflow)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'got inner' for e in data_events)
@pytest.mark.asyncio
async def test_workflow_output_schema_validates_multiple_terminals(
request: pytest.FixtureRequest,
):
"""Each terminal output is validated when downstream reads."""
class _JoinedModel(BaseModel):
branch_a: _OtherModel
branch_b: _OtherModel
def branch_a() -> dict:
return {'name': 'from_a', 'value': 1}
def branch_b() -> dict:
return {'name': 'from_b', 'value': 2}
join = JoinNode(name='join')
inner = Workflow(
name='wf',
edges=[
(START, branch_a),
(START, branch_b),
(branch_a, join),
(branch_b, join),
],
output_schema=_JoinedModel,
)
def consume(node_input: dict) -> dict:
return node_input
outer = Workflow(
name='outer',
edges=[(START, inner, consume)],
)
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
consume_events = [
e
for e in events
if isinstance(e, Event) and e.output and e.node_info.name == 'consume'
]
assert len(consume_events) == 1
# Both terminal outputs should have 'extra' filled by _OtherModel default.
output = consume_events[0].output
assert output['branch_a'] == {
'name': 'from_a',
'value': 1,
'extra': 'default',
}
assert output['branch_b'] == {
'name': 'from_b',
'value': 2,
'extra': 'default',
}
@pytest.mark.asyncio
async def test_workflow_output_schema_rejects_invalid_among_multiple_terminals(
request: pytest.FixtureRequest,
):
"""One invalid terminal among multiple raises validation error."""
class _JoinedModel(BaseModel):
branch_good: _OutputModel
branch_bad: _OutputModel
def branch_good() -> dict:
return {'name': 'ok', 'value': 1}
def branch_bad() -> dict:
return {'wrong_field': 'oops'}
join = JoinNode(name='join')
inner = Workflow(
name='wf',
edges=[
(START, branch_good),
(START, branch_bad),
(branch_good, join),
(branch_bad, join),
],
output_schema=_JoinedModel,
)
def consume(node_input: dict) -> str:
return 'should not reach'
outer = Workflow(
name='outer',
edges=[(START, inner, consume)],
)
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(ValueError):
await runner.run_async(testing_utils.get_user_content('start'))
# ── Primitive and generic type output_schema ─────────────────────────
@pytest.mark.asyncio
async def test_workflow_output_schema_int_coerces(
request: pytest.FixtureRequest,
):
"""Workflow output_schema=int coerces string to int at read time."""
def produce() -> str:
return '42'
def consume(node_input: int) -> int:
return node_input
inner = Workflow(
name='wf',
edges=[(START, produce)],
output_schema=int,
)
outer = Workflow(name='outer', edges=[(START, inner, consume)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
consume_events = [
e
for e in events
if isinstance(e, Event)
and e.output is not None
and e.node_info.name == 'consume'
]
assert len(consume_events) == 1
assert consume_events[0].output == 42
@pytest.mark.asyncio
async def test_workflow_output_schema_int_rejects_invalid(
request: pytest.FixtureRequest,
):
"""Workflow output_schema=int rejects non-coercible value at read time."""
def produce() -> dict:
return {'key': 'value'}
def consume(node_input: int) -> int:
return node_input
inner = Workflow(
name='wf',
edges=[(START, produce)],
output_schema=int,
)
outer = Workflow(name='outer', edges=[(START, inner, consume)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(ValueError):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_workflow_output_schema_list_of_str(
request: pytest.FixtureRequest,
):
"""Workflow output_schema=list[str] validates list output at read time."""
def produce() -> list:
return ['a', 'b', 'c']
def consume(node_input: list) -> list:
return node_input
inner = Workflow(
name='wf',
edges=[(START, produce)],
output_schema=list[str],
)
outer = Workflow(name='outer', edges=[(START, inner, consume)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
consume_events = [
e
for e in events
if isinstance(e, Event)
and e.output is not None
and e.node_info.name == 'consume'
]
assert len(consume_events) == 1
assert consume_events[0].output == ['a', 'b', 'c']
@pytest.mark.asyncio
async def test_workflow_output_schema_list_of_basemodel(
request: pytest.FixtureRequest,
):
"""Workflow output_schema=list[BaseModel] validates and serializes."""
def produce() -> list:
return [
{'name': 'x', 'value': 1},
{'name': 'y', 'value': 2},
]
def consume(node_input: list) -> list:
return node_input
inner = Workflow(
name='wf',
edges=[(START, produce)],
output_schema=list[_OutputModel],
)
outer = Workflow(name='outer', edges=[(START, inner, consume)])
app = App(name=request.function.__name__, root_agent=outer)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
consume_events = [
e
for e in events
if isinstance(e, Event)
and e.output is not None
and e.node_info.name == 'consume'
]
assert len(consume_events) == 1
assert consume_events[0].output == [
{'name': 'x', 'value': 1},
{'name': 'y', 'value': 2},
]
# ── End-to-end: input_schema + output_schema combined ──────────────
class _TaskOutput(BaseModel):
result: str
score: int
class _ReviewResult(BaseModel):
result: str
score: int
reviewer: str = 'auto'
@pytest.mark.asyncio
async def test_e2e_input_and_output_schema_pipeline(
request: pytest.FixtureRequest,
):
"""output_schema on producer + input_schema on consumer validates both."""
def produce() -> _TaskOutput:
return {'result': 'done', 'score': 88}
def consume(node_input: _TaskOutput) -> str:
return f'result={node_input.result}, score={node_input.score}'
agent = Workflow(
name='wf',
edges=[(START, produce), (produce, consume)],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'result=done, score=88' for e in data_events)
@pytest.mark.asyncio
async def test_e2e_output_schema_fails_before_input_schema(
request: pytest.FixtureRequest,
):
"""Producer output_schema failure prevents consumer from running."""
def produce_bad() -> _TaskOutput:
return {'wrong': 'shape'}
def consume(node_input: _TaskOutput) -> str:
return 'should not reach'
agent = Workflow(
name='wf',
edges=[(START, produce_bad), (produce_bad, consume)],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
with pytest.raises(ValueError):
await runner.run_async(testing_utils.get_user_content('start'))
@pytest.mark.asyncio
async def test_e2e_fan_out_join_with_schemas(
request: pytest.FixtureRequest,
):
"""Fan-out -> join -> consume with input/output schemas."""
class _JoinedResult(BaseModel):
analyzer: dict
summarizer: dict
def analyzer() -> _TaskOutput:
return {'result': 'analysis complete', 'score': 92}
def summarizer() -> _TaskOutput:
return {'result': 'summary complete', 'score': 78}
join = JoinNode(name='join')
def reviewer(node_input: dict) -> _ReviewResult:
a = node_input['analyzer']
s = node_input['summarizer']
return {
'result': f"{a['result']} + {s['result']}",
'score': (a['score'] + s['score']) // 2,
}
agent = Workflow(
name='wf',
edges=[
(START, analyzer),
(START, summarizer),
(analyzer, join),
(summarizer, join),
(join, reviewer),
],
)
app = App(name=request.function.__name__, root_agent=agent)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
terminal = [
e
for e in events
if isinstance(e, Event)
and e.output is not None
and e.node_info.name == 'reviewer'
]
assert len(terminal) == 1
assert terminal[0].output == {
'result': 'analysis complete + summary complete',
'score': 85,
'reviewer': 'auto',
}
assert 'wf' in terminal[0].node_info.path
assert 'reviewer' in terminal[0].node_info.path
@pytest.mark.asyncio
async def test_start_node_with_str_input_schema():
"""input_schema=str parses user text."""
class _AssertingNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
assert node_input == 'hello'
yield 'done'
node = _AssertingNode(name='node', input_schema=str)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='hello')], role='user')
events = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_start_node_with_int_input_schema():
"""input_schema=int parses user text to int."""
class _AssertingNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
assert node_input == 42
yield 'done'
node = _AssertingNode(name='node', input_schema=int)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='42')], role='user')
events = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_start_node_with_int_list_input_schema():
"""input_schema=list[int] parses JSON list."""
class _AssertingNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
assert node_input == [1, 2, 3]
yield 'done'
node = _AssertingNode(name='node', input_schema=list[int])
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='[1, 2, 3]')], role='user')
events = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_start_node_with_invalid_input_schema():
"""Invalid input against schema raises error."""
class _MyModel(BaseModel):
age: int
class _AssertingNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield 'done'
node = _AssertingNode(name='node', input_schema=_MyModel)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# Pass invalid input (Content instead of dict with age)
msg = types.Content(parts=[types.Part(text='hello')], role='user')
# We expect it to raise ValidationError
with pytest.raises(ValidationError):
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
pass
@pytest.mark.asyncio
async def test_start_node_receives_parsed_user_content_with_schema():
"""Parsed input replaces raw Content for first node."""
class _AssertingNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
assert isinstance(node_input, str)
assert node_input == 'hello'
yield 'done'
node = _AssertingNode(name='node', input_schema=str)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='hello')], role='user')
events = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert any(e.output == 'done' for e in data_events)
@pytest.mark.asyncio
async def test_workflow_with_invalid_output_schema():
"""Workflow raises ValidationError if terminal output doesn't match output_schema."""
from pydantic import ValidationError
class _MyModel(BaseModel):
name: str
class _MyNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield {'age': 10}
node = _MyNode(name='node')
wf = Workflow(name='wf', edges=[(START, node)], output_schema=_MyModel)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='hello')], role='user')
with pytest.raises(ValidationError):
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
pass
@pytest.mark.asyncio
async def test_node_returns_content_json_parsed():
"""Node output as types.Content containing JSON is parsed if output_schema is defined."""
class _MyModel(BaseModel):
name: str
age: int
class _MyNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield types.Content(
parts=[types.Part(text='{"name": "Alice", "age": 30}')]
)
node = _MyNode(name='node', output_schema=_MyModel)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='hello')], role='user')
events = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert len(data_events) == 1
assert data_events[0].output == {'name': 'Alice', 'age': 30}
@pytest.mark.asyncio
async def test_node_returns_raw_string_not_parsed():
"""Node output as raw JSON string is NOT parsed if output_schema is defined."""
from pydantic import ValidationError
class _MyModel(BaseModel):
name: str
age: int
class _MyNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
# This should fail validation because it's a string, not a dict/model
yield '{"name": "Alice", "age": 30}'
node = _MyNode(name='node', output_schema=_MyModel)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='hello')], role='user')
with pytest.raises(ValidationError):
async for _ in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
pass
@pytest.mark.asyncio
async def test_output_schema_enforced_by_runtime_without_manual_validation():
"""Runtime enforces output_schema even when _run_impl doesn't call _validate_output_data."""
from pydantic import ValidationError
class _MyModel(BaseModel):
name: str
age: int
class _NaiveNode(BaseNode):
"""Node that yields raw data without any manual validation."""
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield {'color': 'red'} # Does NOT match _MyModel
node = _NaiveNode(name='node', output_schema=_MyModel)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='hello')], role='user')
with pytest.raises(ValidationError):
async for _ in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
pass
@pytest.mark.asyncio
async def test_output_schema_enforced_for_valid_raw_yield():
"""Runtime validates and coerces valid raw yields against output_schema."""
class _MyModel(BaseModel):
name: str
age: int
class _NaiveNode(BaseNode):
"""Node that yields valid raw data without manual validation."""
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield {'name': 'Alice', 'age': 30}
node = _NaiveNode(name='node', output_schema=_MyModel)
wf = Workflow(name='wf', edges=[(START, node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text='hello')], role='user')
events = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
data_events = [e for e in events if isinstance(e, Event) and e.output]
assert len(data_events) == 1
assert data_events[0].output == {'name': 'Alice', 'age': 30}
+507
View File
@@ -0,0 +1,507 @@
# 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.
import asyncio
import contextlib
import copy
from typing import Any
from typing import AsyncGenerator
from typing import Generator
from typing import Optional
from google.adk.agents.context import Context as WorkflowContext
from google.adk.agents.invocation_context import InvocationContext as BaseInvocationContext
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.agents.llm_agent import Agent
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.run_config import RunConfig
from google.adk.apps.app import App
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.events.event import Event
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.models.base_llm import BaseLlm
from google.adk.models.base_llm_connection import BaseLlmConnection
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.plugins.base_plugin import BasePlugin
from google.adk.plugins.plugin_manager import PluginManager
from google.adk.runners import InMemoryRunner as AfInMemoryRunner
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.utils.context_utils import Aclosing
from google.genai import types
from google.genai.types import Part
from typing_extensions import override
def create_test_agent(name: str = 'test_agent') -> LlmAgent:
"""Create a simple test agent for use in unit tests.
Args:
name: The name of the test agent.
Returns:
A configured LlmAgent instance suitable for testing.
"""
return LlmAgent(name=name)
class UserContent(types.Content):
def __init__(self, text_or_part: str):
parts = [
types.Part.from_text(text=text_or_part)
if isinstance(text_or_part, str)
else text_or_part
]
super().__init__(role='user', parts=parts)
class ModelContent(types.Content):
def __init__(self, parts: list[types.Part]):
super().__init__(role='model', parts=parts)
async def create_invocation_context(
agent: Agent,
user_content: str = '',
run_config: RunConfig = None,
plugins: list[BasePlugin] = [],
):
invocation_id = 'test_id'
artifact_service = InMemoryArtifactService()
session_service = InMemorySessionService()
memory_service = InMemoryMemoryService()
invocation_context = BaseInvocationContext(
artifact_service=artifact_service,
session_service=session_service,
memory_service=memory_service,
plugin_manager=PluginManager(plugins=plugins),
invocation_id=invocation_id,
agent=agent,
session=await session_service.create_session(
app_name='test_app', user_id='test_user'
),
user_content=types.Content(
role='user', parts=[types.Part.from_text(text=user_content)]
),
run_config=run_config or RunConfig(),
)
if user_content:
append_user_content(
invocation_context, [types.Part.from_text(text=user_content)]
)
return invocation_context
async def create_workflow_context(
agent,
user_content='',
) -> WorkflowContext:
"""Create a WorkflowContext for isolated node testing.
Constructs the minimal InvocationContext and wraps it in a
WorkflowContext so that individual nodes can be tested in
isolation without running the full _SingleLlmAgent pipeline.
"""
invocation_context = await create_invocation_context(agent, user_content)
return WorkflowContext(
invocation_context=invocation_context,
node_path='test',
run_id='test-execution',
)
def append_user_content(
invocation_context: BaseInvocationContext, parts: list[types.Part]
) -> Event:
session = invocation_context.session
event = Event(
invocation_id=invocation_context.invocation_id,
author='user',
content=types.Content(role='user', parts=parts),
)
session.events.append(event)
return event
# Extracts the contents from the events and transform them into a list of
# (author, simplified_content) tuples.
def simplify_events(events: list[Event]) -> list[tuple[str, types.Part]]:
res = []
for event in events:
if event.content:
author = event.author
res.append((author, simplify_content(event.content)))
return res
END_OF_AGENT = 'end_of_agent'
# Extracts the contents from the events and transform them into a list of
# (author, simplified_content OR AgentState OR "end_of_agent") tuples.
#
# Could be used to compare events for testing resumability.
def simplify_resumable_app_events(
events: list[Event],
) -> list[(str, types.Part | str)]:
results = []
for event in events:
if event.content:
results.append((event.author, simplify_content(event.content)))
elif event.actions.end_of_agent:
results.append((event.author, END_OF_AGENT))
elif event.actions.agent_state is not None:
agent_state = event.actions.agent_state
if isinstance(agent_state, dict):
nodes = agent_state.get('nodes', {})
agent_state = {
'node_states': {
node_name: node_state.get('status')
for node_name, node_state in nodes.items()
}
}
results.append((event.author, agent_state))
return results
# Simplifies the contents into a list of (author, simplified_content) tuples.
def simplify_contents(contents: list[types.Content]) -> list[(str, types.Part)]:
return [(content.role, simplify_content(content)) for content in contents]
# Simplifies the content so it's easier to assert.
# - If there is only one part, return part
# - If the only part is pure text, return stripped_text
# - If there are multiple parts, return parts
# - remove function_call_id if it exists
def simplify_content(
content: types.Content,
) -> str | types.Part | list[types.Part]:
content = copy.deepcopy(content)
for part in content.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
if len(content.parts) == 1:
if content.parts[0].text:
return content.parts[0].text.strip()
else:
return content.parts[0]
return content.parts
def get_user_content(message: types.ContentUnion) -> types.Content:
return message if isinstance(message, types.Content) else UserContent(message)
class TestInMemoryRunner(AfInMemoryRunner):
"""InMemoryRunner that is tailored for tests, features async run method.
app_name is hardcoded as InMemoryRunner in the parent class.
"""
async def run_async_with_new_session(
self, new_message: types.ContentUnion
) -> list[Event]:
collected_events: list[Event] = []
async for event in self.run_async_with_new_session_agen(new_message):
collected_events.append(event)
return collected_events
async def run_async_with_new_session_agen(
self, new_message: types.ContentUnion
) -> AsyncGenerator[Event, None]:
session = await self.session_service.create_session(
app_name='InMemoryRunner', user_id='test_user'
)
agen = self.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=get_user_content(new_message),
)
async with Aclosing(agen):
async for event in agen:
yield event
class InMemoryRunner:
"""InMemoryRunner that is tailored for tests."""
def __init__(
self,
root_agent: Optional[Agent | LlmAgent] = None,
response_modalities: list[str] = None,
plugins: list[BasePlugin] = [],
app: Optional[App] = None,
node: Any = None,
):
"""Initializes the InMemoryRunner.
Args:
root_agent: The root agent to run, won't be used if app is provided.
response_modalities: The response modalities of the runner.
plugins: The plugins to use in the runner, won't be used if app is
provided.
app: The app to use in the runner.
node: The root node to run.
"""
self._app = app
if node:
self.app_name = node.name
self.root_agent = None
self.runner = Runner(
node=node,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
plugins=plugins,
)
elif not app:
self.app_name = 'test_app'
self.root_agent = root_agent
self.runner = Runner(
app_name='test_app',
agent=root_agent,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
plugins=plugins,
)
else:
self.app_name = app.name
self.root_agent = app.root_agent
self.runner = Runner(
app=app,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
self.session_id = None
@property
def session(self) -> Session:
if not self.session_id:
session = self.runner.session_service.create_session_sync(
app_name=self.app_name, user_id='test_user'
)
self.session_id = session.id
return session
return self.runner.session_service.get_session_sync(
app_name=self.app_name, user_id='test_user', session_id=self.session_id
)
def run(self, new_message: types.ContentUnion) -> list[Event]:
return list(
self.runner.run(
user_id=self.session.user_id,
session_id=self.session.id,
new_message=get_user_content(new_message),
)
)
@property
def is_resumable(self) -> bool:
"""Returns whether the app is configured for resumable HITL."""
if hasattr(self, '_app') and self._app:
cfg = getattr(self._app, 'resumability_config', None)
return cfg is not None and cfg.is_resumable
return False
async def run_async(
self,
new_message: Optional[types.ContentUnion] = None,
invocation_id: Optional[str] = None,
) -> list[Event]:
# For non-resumable apps, don't reuse invocation_id on resume.
# State reconstruction relies on scanning events from *previous*
# invocations, so the resume call must get a fresh invocation_id.
if invocation_id and not self.is_resumable:
invocation_id = None
events = []
async for event in self.runner.run_async(
user_id=self.session.user_id,
session_id=self.session.id,
invocation_id=invocation_id,
new_message=get_user_content(new_message) if new_message else None,
):
events.append(event)
return events
def run_live(
self, live_request_queue: LiveRequestQueue, run_config: RunConfig = None
) -> list[Event]:
collected_responses = []
async def consume_responses(session: Session):
run_res = self.runner.run_live(
session=session,
live_request_queue=live_request_queue,
run_config=run_config or RunConfig(),
)
async for response in run_res:
collected_responses.append(response)
# When we have enough response, we should return
if len(collected_responses) >= 1:
return
try:
session = self.session
asyncio.run(consume_responses(session))
except asyncio.TimeoutError:
print('Returning any partial results collected so far.')
return collected_responses
class MockModel(BaseLlm):
model: str = 'mock'
requests: list[LlmRequest] = []
live_blobs: list[types.Blob] = []
live_contents: list[types.Content] = []
responses: list[LlmResponse]
error: Exception | None = None
response_index: int = -1
# Whether the mock model should wait for realtime input (blobs or content)
# to be sent before yielding pre-defined responses in live mode.
wait_for_realtime_input: bool = False
@classmethod
def create(
cls,
responses: (
list[types.Part]
| list[LlmResponse]
| list[str]
| list[list[types.Part]]
),
error: Exception | None = None,
wait_for_realtime_input: bool = False,
):
if error and not responses:
return cls(
responses=[],
error=error,
wait_for_realtime_input=wait_for_realtime_input,
)
if not responses:
return cls(responses=[], wait_for_realtime_input=wait_for_realtime_input)
elif isinstance(responses[0], LlmResponse):
# responses is list[LlmResponse]
return cls(
responses=responses, wait_for_realtime_input=wait_for_realtime_input
)
else:
responses = [
LlmResponse(content=ModelContent(item))
if isinstance(item, list) and isinstance(item[0], types.Part)
# responses is list[list[Part]]
else LlmResponse(
content=ModelContent(
# responses is list[str] or list[Part]
[Part(text=item) if isinstance(item, str) else item]
)
)
for item in responses
if item
]
return cls(
responses=responses, wait_for_realtime_input=wait_for_realtime_input
)
@classmethod
@override
def supported_models(cls) -> list[str]:
return ['mock']
def generate_content(
self, llm_request: LlmRequest, stream: bool = False
) -> Generator[LlmResponse, None, None]:
if self.error is not None:
raise self.error
# Increasement of the index has to happen before the yield.
self.response_index += 1
self.requests.append(llm_request)
# yield LlmResponse(content=self.responses[self.response_index])
yield self.responses[self.response_index]
@override
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
if self.error is not None:
raise self.error
# Increasement of the index has to happen before the yield.
self.response_index += 1
self.requests.append(llm_request)
yield self.responses[self.response_index]
@contextlib.asynccontextmanager
async def connect(self, llm_request: LlmRequest) -> BaseLlmConnection:
"""Creates a live connection to the LLM."""
self.requests.append(llm_request)
yield MockLlmConnection(
self.responses,
self,
wait_for_realtime_input=self.wait_for_realtime_input,
)
class MockLlmConnection(BaseLlmConnection):
def __init__(
self,
llm_responses: list[LlmResponse],
mock_model: MockModel,
wait_for_realtime_input: bool = False,
):
self.llm_responses = llm_responses
self.mock_model = mock_model
self.wait_for_realtime_input = wait_for_realtime_input
self._input_event = asyncio.Event()
async def send_history(self, history: list[types.Content]):
pass
async def send_content(self, content: types.Content):
self.mock_model.live_contents.append(content)
self._input_event.set()
async def send(self, data):
pass
async def send_realtime(self, blob: types.Blob):
self.mock_model.live_blobs.append(blob)
self._input_event.set()
async def receive(self) -> AsyncGenerator[LlmResponse, None]:
"""Yield each of the pre-defined LlmResponses."""
if self.wait_for_realtime_input:
await self._input_event.wait()
for response in self.llm_responses:
yield response
async def close(self):
pass
@@ -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"
#
@@ -0,0 +1,399 @@
# 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,
)