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
2182 lines
63 KiB
Python
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
|