Files
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

2182 lines
63 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the new Workflow(BaseNode) implementation.
Migrated from test_workflow_agent.py — each test validates the same
workflow behavior through Runner(node=...) instead of agent.run_async().
"""
from collections import Counter
from typing import Any
from typing import AsyncGenerator
import uuid
from google.adk.agents.context import Context
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._base_node import BaseNode
from google.adk.workflow._base_node import START
from google.adk.workflow._join_node import JoinNode
from google.adk.workflow._workflow import Workflow
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response
from google.genai import types
from pydantic import ConfigDict
from pydantic import Field
import pytest
# ---------------------------------------------------------------------------
# Shared helper nodes (used by multiple tests)
# ---------------------------------------------------------------------------
def _make_function_call_interrupt(fc_id: str, name: str = 'approve') -> Event:
"""Helper to create a raw function call interruption event."""
return Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(name=name, args={}, id=fc_id)
)
]
),
long_running_tool_ids={fc_id},
)
class _OutputNode(BaseNode):
"""Yields a fixed output value."""
value: Any = None
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield self.value
class _PassthroughNode(BaseNode):
"""Passes node_input through as output."""
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield node_input
class _RouteNode(BaseNode):
"""Yields output and sets a route."""
value: Any = None
route_value: Any = None
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(output=self.value, route=self.route_value)
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}
class _IntermediateContentNode(BaseNode):
"""Yields intermediate content events before output."""
contents: list[types.Content] = Field(default_factory=list)
value: Any = None
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
for content in self.contents:
yield Event(content=content)
if self.value is not None:
yield self.value
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',
)
# ---------------------------------------------------------------------------
# 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 _outputs(events):
"""Extract non-None outputs from events."""
return [e.output for e in events if e.output is not None]
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 — 1:1 mapping from test_workflow_agent.py
# ---------------------------------------------------------------------------
# 1. test_run_async → sequential A→B
@pytest.mark.asyncio
async def test_sequential_two_nodes():
"""Sequential A->B produces both outputs in order.
Maps to: test_run_async in test_workflow_agent.py.
"""
a = _OutputNode(name='NodeA', value='Hello')
b = _OutputNode(name='NodeB', value='World')
wf = Workflow(name='wf', edges=[(START, a, b)])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ('NodeA', 'Hello') in by_node
assert ('NodeB', 'World') in by_node
assert by_node.index(('NodeA', 'Hello')) < by_node.index(('NodeB', 'World'))
# 2. test_run_async_with_intermediate_content
@pytest.mark.asyncio
async def test_intermediate_content_before_output():
"""Node yields content events before output.
Maps to: test_run_async_with_intermediate_content in test_workflow_agent.py.
"""
a = _IntermediateContentNode(
name='NodeA',
contents=[
types.Content(parts=[types.Part(text='msg1')]),
types.Content(parts=[types.Part(text='msg2')]),
],
value='A output',
)
wf = Workflow(name='wf', edges=[(START, a)])
events, _, _ = await _run_workflow(wf)
texts = [
p.text
for e in events
if e.content and e.content.parts
for p in e.content.parts
if p.text
]
assert texts.index('msg1') < texts.index('msg2') # also asserts presence
assert 'A output' in _outputs(events)
# 3. test_run_async_with_loop_and_break
@pytest.mark.asyncio
async def test_loop_with_conditional_break():
"""Loop iterates 3 times: A,Check repeats, then exits to B.
Maps to: test_run_async_with_loop_and_break in test_workflow_agent.py.
"""
class _IncrementingNode(BaseNode):
model_config = ConfigDict(arbitrary_types_allowed=True)
message: str = ''
_tracker_ref: list = []
def __init__(self, *, name: str, message: str, tracker: dict):
super().__init__(name=name)
object.__setattr__(self, 'message', message)
object.__setattr__(self, '_tracker_ref', [tracker])
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
tracker = self._tracker_ref[0]
count = tracker.get('iteration_count', 0) + 1
tracker['iteration_count'] = count
yield Event(
output=self.message,
route='continue_loop' if count < 3 else 'exit_loop',
)
tracker = {'iteration_count': 0}
node_a = _OutputNode(name='NodeA', value='Looping')
check = _IncrementingNode(
name='CheckNode', message='Checking', tracker=tracker
)
node_b = _OutputNode(name='NodeB', value='Finished')
wf = Workflow(
name='wf',
edges=[
(START, node_a, check),
(check, {'continue_loop': node_a, 'exit_loop': node_b}),
],
)
events, _, _ = await _run_workflow(wf)
assert tracker['iteration_count'] == 3
by_node = _output_by_node(events)
node_names = [name for name, _ in by_node]
# 3 iterations of (NodeA, CheckNode) then NodeB
assert node_names.count('NodeA') == 3
assert node_names.count('CheckNode') == 3
assert 'NodeB' in node_names
# 4. test_resume_behavior
@pytest.mark.asyncio
async def test_resume_after_interrupt():
"""Workflow resumes from HITL interrupt and continues execution.
Maps to: test_resume_behavior in test_workflow_agent.py.
Old test used crash/checkpoint resume. New test uses HITL interrupt/FR.
"""
class _InterruptOnce(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield 'resumed'
return
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
ctx.state['_fc_id'] = fc_id
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='approve', args={}, id=fc_id
)
)
]
),
long_running_tool_ids={fc_id},
)
a = _OutputNode(name='NodeA', value='A')
b = _InterruptOnce(name='NodeB')
c = _OutputNode(name='NodeC', value='C')
wf = Workflow(name='wf', edges=[(START, a, b, c)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# Run 1: A completes, B interrupts
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
# A completed, B interrupted
assert 'A' in [e.output for e in events1 if e.output is not None]
assert any(e.long_running_tool_ids for e in events1)
fc_id = None
for e in events1:
if e.long_running_tool_ids:
fc_id = list(e.long_running_tool_ids)[0]
# Run 2: resume B, then C runs
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='approve', id=fc_id, response={'ok': True}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
outputs = [e.output for e in events2 if e.output is not None]
assert 'resumed' in outputs
assert 'C' in outputs
@pytest.mark.asyncio
async def test_resume_with_schema_validation_failure():
"""Workflow raises ValueError when resume response fails validation."""
class _InterruptWithSchema(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield ctx.resume_inputs[fc_id]
return
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
ctx.state['_fc_id'] = fc_id
from google.adk.events.request_input import RequestInput
yield RequestInput(
interrupt_id=fc_id,
prompt='Enter an integer',
response_schema={'type': 'integer'},
)
a = _InterruptWithSchema(name='NodeA')
wf = Workflow(name='wf', edges=[(START, a)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
fc_id = None
for e in events1:
if e.long_running_tool_ids:
fc_id = list(e.long_running_tool_ids)[0]
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='adk_request_input', id=fc_id, response={'result': 'abc'}
)
)
],
role='user',
)
with pytest.raises(ValueError, match='Validation failed for interrupt'):
async for _ in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
pass
@pytest.mark.asyncio
async def test_resume_with_schema_validation_failure_nested():
"""Workflow raises ValueError when resume response fails validation in nested workflow."""
class _InterruptWithSchema(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield ctx.resume_inputs[fc_id]
return
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
ctx.state['_fc_id'] = fc_id
from google.adk.events.request_input import RequestInput
yield RequestInput(
interrupt_id=fc_id,
prompt='Enter an integer',
response_schema={'type': 'integer'},
)
inner_wf = Workflow(
name='inner_wf', edges=[(START, _InterruptWithSchema(name='NodeA'))]
)
outer_wf = Workflow(name='outer_wf', edges=[(START, inner_wf)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=outer_wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
fc_id = None
for e in events1:
if e.long_running_tool_ids:
fc_id = list(e.long_running_tool_ids)[0]
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='adk_request_input', id=fc_id, response={'result': 'abc'}
)
)
],
role='user',
)
with pytest.raises(ValueError, match='Validation failed for interrupt'):
async for _ in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
pass
@pytest.mark.asyncio
async def test_resume_with_schema_validation_failure_no_rerun():
"""Workflow raises ValueError when resume response fails validation even if rerun_on_resume is False."""
class _InterruptWithSchema(BaseNode):
rerun_on_resume: bool = False
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield ctx.resume_inputs[fc_id]
return
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
ctx.state['_fc_id'] = fc_id
from google.adk.events.request_input import RequestInput
yield RequestInput(
interrupt_id=fc_id,
prompt='Enter an integer',
response_schema={'type': 'integer'},
)
a = _InterruptWithSchema(name='NodeA')
wf = Workflow(name='wf', edges=[(START, a)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
fc_id = None
for e in events1:
if e.long_running_tool_ids:
fc_id = list(e.long_running_tool_ids)[0]
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='adk_request_input', id=fc_id, response={'result': 'abc'}
)
)
],
role='user',
)
with pytest.raises(ValueError, match='Validation failed for interrupt'):
async for _ in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
pass
# 5. test_agent_state_event_recorded
@pytest.mark.asyncio
async def test_internal_interrupt_event_not_persisted():
"""Workflow interrupt event is _adk_internal — not in session.
Maps to: test_agent_state_event_recorded in test_workflow_agent.py.
Old test verified checkpoint events. New test verifies the workflow's
interrupt event is NOT persisted (child's event is sufficient).
"""
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='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
wf = Workflow(name='wf', edges=[(START, _InterruptNode(name='ask'))])
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='go')], 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)
# Caller should see the child's interrupt event
assert any(e.long_running_tool_ids for e in events)
# Session should NOT have the workflow-level interrupt event
updated = await ss.get_session(
app_name='test', user_id='u', session_id=session.id
)
wf_interrupt_events = [
e
for e in updated.events
if e.long_running_tool_ids and e.node_info.path == 'wf@1'
]
assert wf_interrupt_events == []
# But child's interrupt event SHOULD be in session
child_interrupt_events = [
e
for e in updated.events
if e.long_running_tool_ids
and e.node_info.path
and e.node_info.path.endswith('ask@1')
]
assert len(child_interrupt_events) == 1
# 6. test_run_async_with_implicit_graph
@pytest.mark.asyncio
async def test_edge_tuple_syntax():
"""Edges defined via tuples work.
Maps to: test_run_async_with_implicit_graph in test_workflow_agent.py.
"""
a = _OutputNode(name='NodeA', value='Hello')
b = _OutputNode(name='NodeB', value='World')
wf = Workflow(
name='wf',
edges=[
(START, a),
(a, b),
],
)
events, _, _ = await _run_workflow(wf)
outputs = _outputs(events)
assert outputs.index('Hello') < outputs.index(
'World'
) # also asserts presence
# 7. test_run_async_with_string_start
@pytest.mark.asyncio
async def test_string_start_in_edges():
"""'START' string works as edge source.
Maps to: test_run_async_with_string_start in test_workflow_agent.py.
"""
a = _OutputNode(name='NodeA', value='Hello')
wf = Workflow(name='wf', edges=[('START', a)])
events, _, _ = await _run_workflow(wf)
assert 'Hello' in _outputs(events)
# 8. test_run_async_with_implicit_graph_with_edge_combinations
@pytest.mark.asyncio
async def test_mixed_edge_and_tuple_syntax():
"""Mix of Edge objects and tuple syntax in a 3-node chain.
Maps to: test_run_async_with_implicit_graph_with_edge_combinations
in test_workflow_agent.py.
"""
from google.adk.workflow._graph import Edge as GraphEdge
a = _OutputNode(name='NodeA', value='A')
b = _OutputNode(name='NodeB', value='B')
c = _OutputNode(name='NodeC', value='C')
wf = Workflow(
name='wf',
edges=[
(START, a),
GraphEdge(from_node=a, to_node=b),
(b, c),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert by_node.index(('NodeA', 'A')) < by_node.index(
('NodeB', 'B')
) # also asserts presence
assert by_node.index(('NodeB', 'B')) < by_node.index(('NodeC', 'C'))
# 9. test_run_async_with_update_state_event
@pytest.mark.asyncio
async def test_state_update_via_event_persisted():
"""State updates via Event(state=...) are persisted to session.
Maps to: test_run_async_with_update_state_event in test_workflow_agent.py.
"""
class _Node(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(state={'key1': 'value1'})
wf = Workflow(name='wf', edges=[(START, _Node(name='NodeA'))])
events, ss, session = await _run_workflow(wf)
updated = await ss.get_session(
app_name='test', user_id='u', session_id=session.id
)
assert updated.state.get('key1') == 'value1'
# 11. test_run_async_with_raw_output_node
@pytest.mark.asyncio
async def test_raw_output_auto_wrapped():
"""Node yields raw value, auto-wrapped to Event(output=...).
Maps to: test_run_async_with_raw_output_node in test_workflow_agent.py.
"""
a = _OutputNode(name='NodeA', value='raw_data')
wf = Workflow(name='wf', edges=[(START, a)])
events, _, _ = await _run_workflow(wf)
assert 'raw_data' in _outputs(events)
# 12. test_node_output_event_with_content_data
@pytest.mark.asyncio
async def test_content_return_produces_content_event():
"""FunctionNode returning Content yields content event, not output.
Maps to: test_node_output_event_with_content_data
in test_workflow_agent.py.
"""
def content_fn() -> types.Content:
return types.Content(parts=[types.Part(text='hello')])
wf = Workflow(name='wf', edges=[(START, content_fn)])
events, _, _ = await _run_workflow(wf)
fn_events = [
e for e in events if e.node_info.path and 'content_fn' in e.node_info.path
]
assert len(fn_events) == 1
assert fn_events[0].output is None
assert fn_events[0].content == types.Content(parts=[types.Part(text='hello')])
# 13. test_input_propagation_linear
@pytest.mark.asyncio
async def test_input_propagation_linear():
"""Output of A becomes input to B.
Maps to: test_input_propagation_linear in test_workflow_agent.py.
"""
a = _OutputNode(name='NodeA', value='from_a')
b = _InputCapturingNode(name='NodeB')
wf = Workflow(name='wf', edges=[(START, a, b)])
events, _, _ = await _run_workflow(wf)
assert b.received_inputs == ['from_a']
# 14. test_input_propagation_fan_in_sequential
@pytest.mark.asyncio
async def test_input_propagation_fan_in():
"""Fan-in with asymmetric branches: C receives from A and B3.
Maps to: test_input_propagation_fan_in_sequential
in test_workflow_agent.py.
"""
a = _OutputNode(name='NodeA', value='from_a')
b = _OutputNode(name='NodeB', value='from_b')
b2 = _PassthroughNode(name='NodeB2')
b3 = _PassthroughNode(name='NodeB3')
c = _InputCapturingNode(name='NodeC')
wf = Workflow(
name='wf',
edges=[
(START, a),
(START, b, b2, b3),
(a, c),
(b3, c),
],
)
events, _, _ = await _run_workflow(wf)
assert Counter(c.received_inputs) == Counter(['from_a', 'from_b'])
# 15. test_start_node_receives_user_content
@pytest.mark.asyncio
async def test_start_node_receives_user_content():
"""First node receives user message as input.
Maps to: test_start_node_receives_user_content
in test_workflow_agent.py.
"""
a = _InputCapturingNode(name='NodeA')
wf = Workflow(name='wf', edges=[(START, a)])
events, _, _ = await _run_workflow(wf, message='hello')
assert len(a.received_inputs) == 1
assert a.received_inputs[0].parts[0].text == 'hello'
# 18. test_wait_for_output_suppresses_trigger
@pytest.mark.asyncio
async def test_wait_for_output_suppresses_downstream():
"""wait_for_output=True with no output prevents downstream from running.
Maps to: test_wait_for_output_suppresses_trigger
in test_workflow_agent.py.
"""
class _NoOutputNode(BaseNode):
wait_for_output: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(state={'seen': True})
blocker = _NoOutputNode(name='blocker')
downstream = _OutputNode(name='downstream', value='should_not_run')
wf = Workflow(
name='wf',
edges=[
(START, blocker),
(blocker, downstream),
],
)
events, _, _ = await _run_workflow(wf)
assert 'should_not_run' not in _outputs(events)
# 19. test_wait_for_output_retrigger_then_complete
@pytest.mark.asyncio
async def test_wait_for_output_completes_after_all():
"""Gate with wait_for_output opens after 2 triggers, fires downstream.
Maps to: test_wait_for_output_retrigger_then_complete
in test_workflow_agent.py.
"""
class _GateNode(BaseNode):
triggers_needed: int = 2
wait_for_output: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
count = (ctx.state.get(f'{self.name}_count') or 0) + 1
if count >= self.triggers_needed:
yield Event(
output='gate_open',
state={f'{self.name}_count': None},
)
else:
yield Event(state={f'{self.name}_count': count})
gate = _GateNode(name='Gate', triggers_needed=2)
a = _OutputNode(name='NodeA', value='A')
b = _OutputNode(name='NodeB', value='B')
downstream = _OutputNode(name='Downstream', value='done')
wf = Workflow(
name='wf',
edges=[
(START, a, gate, downstream),
(START, b, gate),
],
)
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
# A and B run first (parallel), then Gate opens, then Downstream
assert len(by_node) == 4
first_two = {by_node[0][0], by_node[1][0]}
assert first_two == {'NodeA', 'NodeB'}
assert by_node[2] == ('Gate', 'gate_open')
assert by_node[3] == ('Downstream', 'done')
# 25. test_run_async_with_implicit_graph_chain
@pytest.mark.asyncio
async def test_chain_syntax():
"""(START, a, b, c) creates sequential chain producing all outputs.
Maps to: test_run_async_with_implicit_graph_chain
in test_workflow_agent.py.
"""
a = _OutputNode(name='a', value='A')
b = _OutputNode(name='b', value='B')
c = _OutputNode(name='c', value='C')
wf = Workflow(name='wf', edges=[(START, a, b, c)])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert [name for name, _ in by_node] == ['a', 'b', 'c']
assert [val for _, val in by_node] == ['A', 'B', 'C']
# 26. test_run_async_with_implicit_graph_fan_out
@pytest.mark.asyncio
async def test_fan_out():
"""Fan-out to multiple terminals raises ValueError.
Maps to: test_run_async_with_implicit_graph_fan_out
in test_workflow_agent.py.
"""
a = _OutputNode(name='a', value='A')
b = _InputCapturingNode(name='b')
c = _InputCapturingNode(name='c')
wf = Workflow(
name='wf',
edges=[
(START, a),
(a, (b, c)),
],
)
with pytest.raises(ValueError, match='multiple terminal nodes'):
await _run_workflow(wf)
# 27. test_run_async_with_implicit_graph_fan_in
@pytest.mark.asyncio
async def test_fan_in():
"""((a, b), c) — c triggered by both a and b.
Maps to: test_run_async_with_implicit_graph_fan_in
in test_workflow_agent.py.
"""
a = _OutputNode(name='a', value='A')
b = _OutputNode(name='b', value='B')
c = _InputCapturingNode(name='c')
wf = Workflow(
name='wf',
edges=[
(START, (a, b)),
((a, b), c),
],
)
events, _, _ = await _run_workflow(wf)
assert Counter(c.received_inputs) == Counter(['A', 'B'])
# 28. test_run_async_with_implicit_graph_fan_out_fan_in
@pytest.mark.asyncio
async def test_fan_out_fan_in():
"""S fans out to (A, B), both feed into C.
Maps to: test_run_async_with_implicit_graph_fan_out_fan_in
in test_workflow_agent.py.
"""
s = _OutputNode(name='s', value='S')
a = _PassthroughNode(name='a')
b = _PassthroughNode(name='b')
c = _InputCapturingNode(name='c')
wf = Workflow(
name='wf',
edges=[
(START, s),
(s, (a, b), c),
],
)
events, _, _ = await _run_workflow(wf)
# Both A and B receive S's output, C receives from both
assert Counter(c.received_inputs) == Counter(['S', 'S'])
# 29. test_run_async_parallel_nodes_interleaved_events
@pytest.mark.asyncio
async def test_parallel_events_interleaved():
"""Parallel terminal nodes both producing output raises ValueError.
Maps to: test_run_async_parallel_nodes_interleaved_events
in test_workflow_agent.py.
"""
a = _OutputNode(name='a', value='A')
b = _OutputNode(name='b', value='B')
wf = Workflow(
name='wf',
edges=[
(START, a),
(START, b),
],
)
with pytest.raises(ValueError, match='multiple terminal nodes'):
await _run_workflow(wf)
# 30. test_buffers_events_from_parallel_nodes
@pytest.mark.asyncio
async def test_parallel_events_all_delivered():
"""All events from parallel nodes fan-in to capture node.
Maps to: test_buffers_events_from_parallel_nodes
in test_workflow_agent.py.
"""
a = _OutputNode(name='a', value='A')
b = _OutputNode(name='b', value='B')
capture = _InputCapturingNode(name='capture')
wf = Workflow(
name='wf',
edges=[
(START, a),
(START, b),
(a, capture),
(b, capture),
],
)
events, _, _ = await _run_workflow(wf)
assert Counter(capture.received_inputs) == Counter(['A', 'B'])
# 31. test_run_id_uniqueness
@pytest.mark.asyncio
async def test_run_id_unique_per_node():
"""Each node run gets a unique run_id.
Maps to: test_run_id_uniqueness in test_workflow_agent.py.
"""
a = _OutputNode(name='a', value='A')
b = _OutputNode(name='b', value='B')
wf = Workflow(name='wf', edges=[(START, a, b)])
events, _, _ = await _run_workflow(wf)
paths = [e.node_info.path for e in events if e.node_info.path]
assert len(paths) >= 2
# paths are unique per node run (because they contain @run_id)
assert len(set(paths)) >= 2
# 32. test_run_id_uniqueness_nested
@pytest.mark.asyncio
async def test_run_id_unique_nested():
"""Nested workflow nodes also get unique IDs.
Maps to: test_run_id_uniqueness_nested
in test_workflow_agent.py.
"""
inner_a = _OutputNode(name='inner_a', value='IA')
inner = Workflow(name='inner', edges=[(START, inner_a)])
outer_a = _OutputNode(name='outer_a', value='OA')
wf = Workflow(name='wf', edges=[(START, outer_a, inner)])
events, _, _ = await _run_workflow(wf)
paths = [e.node_info.path for e in events if e.node_info.path]
# paths are unique per node run (because they contain @run_id)
assert len(set(paths)) >= 2
@pytest.mark.asyncio
async def test_run_id_sequential_in_loop():
"""Looping node gets sequential run_ids: 1, 2, 3."""
tracker = {'count': 0}
class _LoopNode(BaseNode):
model_config = ConfigDict(arbitrary_types_allowed=True)
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
tracker['count'] += 1
yield Event(
output=f'iter-{tracker["count"]}',
route='again' if tracker['count'] < 3 else 'done',
)
from google.adk.workflow._graph import Edge as GraphEdge
loop = _LoopNode(name='loop')
wf = Workflow(
name='wf',
edges=[
(START, loop),
GraphEdge(from_node=loop, to_node=loop, route='again'),
],
)
events, _, _ = await _run_workflow(wf)
loop_run_ids = [
e.node_info.path.split('@')[-1]
for e in events
if e.node_info.path and 'loop@' in e.node_info.path
]
assert loop_run_ids == ['1', '2', '3']
# 33. test_resume_with_manual_state_verifies_input_persistence
@pytest.mark.asyncio
async def test_resume_downstream_receives_output():
"""After resume, downstream node receives the resumed node's output.
Maps to: test_resume_with_manual_state_verifies_input_persistence
in test_workflow_agent.py. Old test verified input from checkpoint.
New test verifies output flows to downstream after HITL resume.
"""
class _InterruptNode(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield f'response:{ctx.resume_inputs[fc_id]["value"]}'
return
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
ctx.state['_fc_id'] = fc_id
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='get', args={}, id=fc_id
)
)
]
),
long_running_tool_ids={fc_id},
)
a = _OutputNode(name='NodeA', value='from_a')
b = _InterruptNode(name='NodeB')
c = _InputCapturingNode(name='NodeC')
wf = Workflow(name='wf', edges=[(START, a, b, c)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# Run 1: A completes, B interrupts
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
# A completed, B interrupted
assert 'from_a' in [e.output for e in events1 if e.output is not None]
fc_id = None
for e in events1:
if e.long_running_tool_ids:
fc_id = list(e.long_running_tool_ids)[0]
assert fc_id
# Run 2: resume B with value, C receives B's output
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='get', id=fc_id, response={'value': 42}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
# NodeC should have captured NodeB's resume output
assert c.received_inputs == ['response:42']
# 34. test_run_async_with_multiple_node_outputs_fails
@pytest.mark.asyncio
async def test_multiple_outputs_rejected():
"""Node yielding two outputs raises error.
Maps to: test_run_async_with_multiple_node_outputs_fails
in test_workflow_agent.py.
NodeRunner raises ValueError but Runner swallows it in the
background task. This test is xfail until Runner error propagation
is implemented.
"""
class _Node(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(output='first')
yield Event(output='second')
wf = Workflow(name='wf', edges=[(START, _Node(name='a'))])
with pytest.raises(ValueError, match='at most one output'):
await _run_workflow(wf)
# 38. test_run_async_streaming_behavior
@pytest.mark.asyncio
async def test_streaming_partial_events():
"""Partial events are streamed before final output.
Maps to: test_run_async_streaming_behavior
in test_workflow_agent.py.
"""
class _Node(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(
content=types.Content(parts=[types.Part(text='partial1')]),
partial=True,
)
yield Event(
content=types.Content(parts=[types.Part(text='partial2')]),
partial=True,
)
yield Event(output='final')
wf = Workflow(name='wf', edges=[(START, _Node(name='a'))])
events, _, _ = await _run_workflow(wf)
partials = [e for e in events if e.partial]
assert len(partials) == 2
assert 'final' in _outputs(events)
# 39. test_workflow_agent_unsupported_base_agent_fields
# SKIP — BaseAgent-specific, not applicable to Workflow(BaseNode).
# 40. test_node_path_generation
@pytest.mark.asyncio
async def test_node_path_correct():
"""Events have correct node_info.path.
Maps to: test_node_path_generation in test_workflow_agent.py.
"""
a = _OutputNode(name='NodeA', value='A')
b = _OutputNode(name='NodeB', value='B')
wf = Workflow(name='wf', edges=[(START, a, b)])
events, _, _ = await _run_workflow(wf)
paths = [e.node_info.path for e in events if e.node_info.path]
assert any(p.endswith('NodeA@1') for p in paths)
assert any(p.endswith('NodeB@1') for p in paths)
assert all('None' not in p for p in paths)
# --- Additional: function node auto-wrap ---
@pytest.mark.asyncio
async def test_function_node_auto_wrap():
"""Callable in edge is auto-wrapped to FunctionNode."""
def greet(node_input):
return 'hi'
wf = Workflow(name='wf', edges=[(START, greet)])
events, _, _ = await _run_workflow(wf)
assert 'hi' in _outputs(events)
# --- Additional: empty workflow ---
@pytest.mark.asyncio
async def test_empty_workflow():
"""Workflow with no edges produces no output."""
wf = Workflow(name='wf', edges=[])
events, _, _ = await _run_workflow(wf)
assert _outputs(events) == []
# ---------------------------------------------------------------------------
# use_as_output=True (dynamic output delegation)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_use_as_output_function_to_function():
"""Node A delegates output to dynamic child B via use_as_output=True.
B's output event appears; A's duplicate is suppressed.
"""
from google.adk.workflow._function_node import FunctionNode
def func_b() -> str:
return 'from_b'
async def func_a(ctx: Context) -> str:
return await ctx.run_node(func_b, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf = Workflow(name='wf', edges=[(START, node_a)])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
# func_b emits output; func_a's duplicate is suppressed.
assert ('func_b', 'from_b') in by_node
assert not any(name == 'func_a' for name, _ in by_node)
# output_for includes child, parent, and workflow paths.
# func_a is terminal, so its output also represents the workflow's.
output_event = [
e for e in events if e.node_info.name.split('@')[0] == 'func_b'
][0]
assert output_event.node_info.output_for == [
'wf@1/func_a@1/func_b@1',
'wf@1/func_a@1',
'wf@1',
]
@pytest.mark.asyncio
async def test_use_as_output_function_to_workflow():
"""Node A delegates output to a nested Workflow via use_as_output=True.
The inner Workflow's terminal node output is used as A's output.
"""
from google.adk.workflow._function_node import FunctionNode
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_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 = Workflow(name='wf', edges=[(START, node_a)])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
# step_2 is the terminal; func_a's duplicate is suppressed.
assert ('step_2', 'final:step_1_done') in by_node
assert not any(name == 'func_a' for name, _ in by_node)
@pytest.mark.asyncio
async def test_use_as_output_custom_node():
"""Custom BaseNode delegates output via use_as_output."""
from google.adk.workflow._function_node import FunctionNode
def func_b() -> str:
return 'delegated_output'
class _Delegator(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
await ctx.run_node(func_b, use_as_output=True)
return
yield
wf = Workflow(name='wf', edges=[(START, _Delegator(name='delegator'))])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ('func_b', 'delegated_output') in by_node
assert not any(name == 'delegator' for name, _ in by_node)
@pytest.mark.asyncio
async def test_use_as_output_nested_delegation():
"""Chained delegation: A → B → C, all use_as_output=True.
Only C's output event should appear; A and B are suppressed.
"""
from google.adk.workflow._function_node import FunctionNode
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 = Workflow(name='wf', edges=[(START, node_a)])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ('func_c', 'from_c') in by_node
assert not any(name in ('func_a', 'func_b') for name, _ in by_node)
# output_for includes full ancestor chain plus workflow path.
# func_a is terminal, so the chain extends to the workflow.
output_event = [
e for e in events if e.node_info.name.split('@')[0] == 'func_c'
][0]
assert output_event.node_info.output_for == [
'wf@1/func_a@1/func_b@1/func_c@1',
'wf@1/func_a@1/func_b@1',
'wf@1/func_a@1',
'wf@1',
]
@pytest.mark.asyncio
async def test_use_as_output_with_downstream():
"""Delegated output flows to downstream node via graph edge.
A delegates to B via use_as_output. downstream is after A and
receives B's output as node_input.
"""
from google.adk.workflow._function_node import FunctionNode
def func_b() -> str:
return 'from_b'
async def func_a(ctx: Context) -> str:
return await ctx.run_node(func_b, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
downstream = _InputCapturingNode(name='downstream')
wf = Workflow(name='wf', edges=[(START, node_a, downstream)])
events, _, _ = await _run_workflow(wf)
assert downstream.received_inputs == ['from_b']
@pytest.mark.asyncio
async def test_use_as_output_duplicate_raises():
"""Calling use_as_output=True twice in the same node raises ValueError."""
from google.adk.workflow._function_node import FunctionNode
def func_b() -> str:
return 'from_b'
def func_c() -> str:
return 'from_c'
async def func_a(ctx: Context):
await ctx.run_node(func_b, use_as_output=True)
await ctx.run_node(func_c, use_as_output=True)
node_a = FunctionNode(func=func_a, rerun_on_resume=True)
wf = Workflow(name='wf', edges=[(START, node_a)])
with pytest.raises(ValueError, match='use_as_output'):
await _run_workflow(wf)
@pytest.mark.asyncio
async def test_without_use_as_output_parent_emits_duplicate():
"""Without use_as_output, parent re-emits child's output (duplicate)."""
from google.adk.workflow._function_node import FunctionNode
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 = Workflow(name='wf', edges=[(START, node_a)])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
# Both func_b AND func_a emit output (duplicate).
assert ('func_b', 'from_b') in by_node
assert ('func_a', 'from_b') in by_node
# func_b's output_for is just its own path (no delegation).
b_event = [e for e in events if e.node_info.name.split('@')[0] == 'func_b'][0]
assert b_event.node_info.output_for == ['wf@1/func_a@1/func_b@1']
# func_a is terminal, so its output_for includes the workflow path.
a_event = [
e
for e in events
if e.node_info.name and e.node_info.name.split('@')[0] == 'func_a'
][0]
assert a_event.node_info.output_for == ['wf@1/func_a@1', 'wf@1']
@pytest.mark.asyncio
async def test_terminal_node_output_dedup():
"""Terminal node output is not duplicated by the workflow.
The terminal node's output event includes the workflow path in
output_for, and the workflow does not emit a separate output event.
"""
def step_a(node_input: str) -> str:
return node_input.upper()
def step_b(node_input: str) -> str:
return f'final: {node_input}'
wf = Workflow(name='wf', edges=[(START, step_a, step_b)])
events, _, _ = await _run_workflow(wf, 'hello')
output_events = [e for e in events if e.output is not None]
# step_a is not terminal — its output_for is just its own path.
a_events = [
e
for e in output_events
if e.node_info.name and e.node_info.name.split('@')[0] == 'step_a'
]
assert len(a_events) == 1
assert a_events[0].node_info.output_for == ['wf@1/step_a@1']
# step_b is terminal — its output_for includes the workflow path.
b_events = [
e
for e in output_events
if e.node_info.name and e.node_info.name.split('@')[0] == 'step_b'
]
assert len(b_events) == 1
assert b_events[0].node_info.output_for == ['wf@1/step_b@1', 'wf@1']
assert b_events[0].output == 'final: HELLO'
# No duplicate output event from the workflow itself.
wf_events = [e for e in output_events if e.node_info.path == 'wf@1']
assert len(wf_events) == 0
@pytest.mark.asyncio
async def test_terminal_node_output_dedup_nested():
"""Terminal output dedup works for nested workflows.
Inner workflow's terminal node output propagates to the outer
workflow without duplication.
"""
def inner_node(node_input: str) -> str:
return node_input.upper()
inner_wf = Workflow(name='inner', edges=[(START, inner_node)])
def outer_node(node_input: str) -> str:
return f'wrapped: {node_input}'
outer_wf = Workflow(name='outer', edges=[(START, inner_wf, outer_node)])
events, _, _ = await _run_workflow(outer_wf, 'test')
output_events = [e for e in events if e.output is not None]
# inner_node is terminal in inner_wf — includes inner_wf path.
inner_events = [
e
for e in output_events
if e.node_info.name and e.node_info.name.split('@')[0] == 'inner_node'
]
assert len(inner_events) == 1
assert inner_events[0].node_info.output_for == [
'outer@1/inner@1/inner_node@1',
'outer@1/inner@1',
]
# outer_node is terminal in outer_wf — includes outer_wf path.
outer_events = [
e
for e in output_events
if e.node_info.name and e.node_info.name.split('@')[0] == 'outer_node'
]
assert len(outer_events) == 1
assert outer_events[0].node_info.output_for == [
'outer@1/outer_node@1',
'outer@1',
]
assert outer_events[0].output == 'wrapped: TEST'
# No duplicate output events from the workflows themselves.
wf_output_events = [
e
for e in output_events
if e.node_info.path in ('outer@1', 'outer@1/inner@1')
]
assert len(wf_output_events) == 0
# --- wait_for_output + HITL resume ---
@pytest.mark.asyncio
async def test_wait_for_output_node_preserves_state_across_resume():
"""Wait-for-output node preserves received triggers across workflow resume.
Setup:
START -> NodeA (completes) -> Gate (wait_for_output, needs 2 triggers).
START -> NodeB (interrupts) -> Gate.
Act:
- Turn 1: Start workflow. NodeA completes, NodeB interrupts. Gate waits (1/2).
- Turn 2: Resume with NodeB response. NodeB completes, triggers Gate (2/2).
Assert:
- Gate opens in Turn 2 and produces output.
"""
class _InterruptOnce(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield 'B_done'
return
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
ctx.state['_fc_id'] = fc_id
yield _make_function_call_interrupt(fc_id)
a = _OutputNode(name='NodeA', value='A')
b = _InterruptOnce(name='NodeB')
gate = JoinNode(name='Gate')
downstream = _OutputNode(name='Downstream', value='done')
wf = Workflow(
name='wf',
edges=[
(START, a, gate, downstream),
(START, b, gate),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# Run 1: A completes, B interrupts, Gate triggered once (no output)
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
outputs1 = [e.output for e in events1 if e.output is not None]
assert 'A' in outputs1
assert any(e.long_running_tool_ids for e in events1)
# Gate should NOT have opened (only 1 of 2 triggers received)
assert not any(isinstance(o, dict) and 'NodeA' in o for o in outputs1)
fc_id = None
for e in events1:
if e.long_running_tool_ids:
fc_id = list(e.long_running_tool_ids)[0]
# Run 2: resume B → Gate gets 2nd trigger → opens → Downstream runs
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='approve', id=fc_id, response={'ok': True}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
outputs = [e.output for e in events2 if e.output is not None]
assert 'B_done' in outputs
# Gate opened with both inputs collected
gate_output = [o for o in outputs if isinstance(o, dict)]
assert len(gate_output) == 1
assert 'NodeA' in gate_output[0]
assert 'NodeB' in gate_output[0]
assert 'done' in outputs
@pytest.mark.asyncio
async def test_wait_for_output_node_in_loop_generates_unique_paths():
"""Wait-for-output node in loop generates unique paths across iterations.
Setup:
Workflow with a loop: START -> Process -> [NodeA, NodeB] -> Join -> Handle.
Handle loops back to Process on first iteration, exits on second.
NodeB interrupts on first run of each iteration.
Act:
- Turn 1: Start workflow. NodeA completes, NodeB interrupts. Join waits.
- Turn 2: Resume with NodeB response. Handle loops back. Process runs again.
NodeA completes, NodeB interrupts again.
- Turn 3: Resume with NodeB response again. Join opens.
Assert:
- JoinNode runs with path ending in `Join@2` in the second iteration.
"""
class _InterruptOnce(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield 'B_done'
return
fc_id = 'fc-interrupt'
ctx.state['_fc_id'] = fc_id
yield _make_function_call_interrupt(fc_id)
class _HandleNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
# Loop back if this is the first iteration
count = ctx.state.get('loop_count', 0) + 1
ctx.state['loop_count'] = count
if count == 1:
yield Event(route='loop')
else:
yield Event(route='exit', output='finished')
process = _PassthroughNode(name='Process')
a = _OutputNode(name='NodeA', value='A')
b = _InterruptOnce(name='NodeB')
join = JoinNode(name='Join')
handle = _HandleNode(name='Handle')
exit_node = _PassthroughNode(name='Exit')
wf = Workflow(
name='loop_wf',
edges=[
(START, process),
(process, a),
(process, b),
(a, join),
(b, join),
(join, handle),
(handle, {'loop': process, 'exit': exit_node}),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# Turn 1: process runs, A completes, B interrupts, Join waits
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
# Verify paths in Turn 1
join_events1 = [
e for e in events1 if e.node_info and 'Join' in e.node_info.path
]
# JoinNode does not yield events until all inputs are collected.
# Turn 2: Provide response for NodeB (InterruptNode)
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='approve', id='fc-interrupt', response={'ok': True}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
# Workflow loops back. NodeB interrupts again in the second iteration.
# Turn 3: Provide response for NodeB again!
events3: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events3.append(event)
# JoinNode opens in the second iteration and produces output.
join_events3 = [
e for e in events3 if e.node_info and 'Join@2' in e.node_info.path
]
assert len(join_events3) > 0, 'JoinNode should run again in loop with @2'
# --- run_id reuse on resume ---
@pytest.mark.asyncio
async def test_run_id_reused_on_resume():
"""Resumed node reuses run_id from original interrupted run."""
class _InterruptOnce(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = ctx.state.get('_fc_id')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_fc_id'] = None
yield 'resumed'
return
fc_id = str(uuid.uuid4())
ctx.state['_fc_id'] = fc_id
yield _make_function_call_interrupt(fc_id)
wf = Workflow(
name='wf',
edges=[(START, _InterruptOnce(name='ask'))],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# Run 1: interrupts
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
fc_id = None
original_run_id = None
for e in events1:
if e.long_running_tool_ids:
fc_id = list(e.long_running_tool_ids)[0]
original_run_id = e.node_info.run_id
assert fc_id is not None
assert original_run_id is not None
# Run 2: resume
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='approve', id=fc_id, response={'ok': True}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
# Resumed node should have the same run_id
resumed_events = [
e
for e in events2
if e.node_info.path
and e.node_info.path.endswith('ask@1')
and e.output is not None
]
assert len(resumed_events) == 1
assert resumed_events[0].node_info.run_id == original_run_id
@pytest.mark.asyncio
async def test_route_without_output_triggers_downstream_on_resume():
"""Node with route but no output triggers downstream on resume.
Setup:
START -> NodeA (yields route='next') -> NodeB (interrupts).
Act:
- Turn 1: Run workflow. NodeA completes with route, NodeB interrupts.
- Turn 2: Resume workflow by resolving NodeB's interrupt.
Assert:
- Turn 1: NodeB was triggered (indicated by interrupt).
- Turn 2: NodeB runs and produces output (proving it was triggered).
"""
class _TestRouteNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(route='next')
class _InterruptOnce(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
if ctx.resume_inputs and 'fc-123' in ctx.resume_inputs:
yield Event(output='done')
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='approve', args={}, id='fc-123'
)
)
]
),
long_running_tool_ids={'fc-123'},
)
route_node = _TestRouteNode(name='route_node')
target_node = _InterruptOnce(name='target_node')
wf = Workflow(
name='wf',
edges=[
(START, route_node),
(route_node, {'next': target_node}),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
assert any(e.long_running_tool_ids for e in events1)
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='approve', id='fc-123', response={'ok': True}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
outputs = [e.output for e in events2 if e.output is not None]
assert 'done' in outputs
@pytest.mark.asyncio
async def test_rerun_on_resume_false_preserves_route_on_resume():
"""A rerun_on_resume=False node that sets ctx.route preserves it on resume.
Setup:
START -> RouteAndInterruptNode (sets ctx.route='go', interrupts)
-> {'go': TargetNode}
Act:
- Turn 1: RouteAndInterruptNode sets route and interrupts.
- Turn 2: Resume by resolving the interrupt.
Assert:
- Turn 2: TargetNode fires (proving the route survived the resume)
and produces output 'reached'.
"""
class _RouteAndInterruptNode(BaseNode):
"""Sets ctx.route directly and interrupts. rerun_on_resume=False."""
rerun_on_resume: bool = False
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
ctx.route = 'go'
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='confirm', args={}, id='fc-route-1'
)
)
]
),
long_running_tool_ids={'fc-route-1'},
)
class _TargetNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(output='reached')
route_node = _RouteAndInterruptNode(name='route_node')
target_node = _TargetNode(name='target_node')
wf = Workflow(
name='wf',
edges=[
(START, route_node),
(route_node, {'go': target_node}),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# Turn 1: route_node sets route and interrupts.
msg1 = types.Content(parts=[types.Part(text='start')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
assert any(e.long_running_tool_ids for e in events1)
# Turn 2: resolve the interrupt.
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='confirm', id='fc-route-1', response={'ok': True}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
# target_node should have fired via the 'go' route.
outputs = [e.output for e in events2 if e.output is not None]
assert 'reached' in outputs
@pytest.mark.asyncio
async def test_route_and_output_triggers_downstream_on_resume():
"""Node with route and output triggers downstream on resume.
Setup:
START -> NodeA (yields route='next', output='A') -> NodeB (interrupts).
Act:
- Turn 1: Run workflow. NodeA completes with route and output, NodeB interrupts.
- Turn 2: Resume workflow by resolving NodeB's interrupt.
Assert:
- Turn 1: NodeB was triggered (indicated by interrupt).
- Turn 2: NodeB runs and produces output (proving it was triggered).
"""
class _RouteAndOutputNode(BaseNode):
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
yield Event(route='next', output='A')
class _InterruptOnce(BaseNode):
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
if ctx.resume_inputs and 'fc-123' in ctx.resume_inputs:
assert node_input == 'A'
yield Event(output='done')
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='approve', args={}, id='fc-123'
)
)
]
),
long_running_tool_ids={'fc-123'},
)
route_node = _RouteAndOutputNode(name='route_node')
target_node = _InterruptOnce(name='target_node')
wf = Workflow(
name='wf',
edges=[
(START, route_node),
(route_node, {'next': target_node}),
],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
events1: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg1
):
events1.append(event)
assert any(e.long_running_tool_ids for e in events1)
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='approve', id='fc-123', response={'ok': True}
)
)
],
role='user',
)
events2: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg2
):
events2.append(event)
outputs = [e.output for e in events2 if e.output is not None]
assert 'done' in outputs