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

1125 lines
31 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.
import copy
from typing import Any
from typing import AsyncGenerator
import uuid
from google.adk.agents.context import Context
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.events.request_input import RequestInput
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.long_running_tool import LongRunningFunctionTool
from google.adk.workflow import BaseNode
from google.adk.workflow import JoinNode
from google.adk.workflow._base_node import START
from google.adk.workflow._node_status import NodeStatus
from google.adk.workflow._workflow import Workflow
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response
from google.adk.workflow.utils._workflow_hitl_utils import get_request_input_interrupt_ids
from google.adk.workflow.utils._workflow_hitl_utils import has_request_input_function_call
from google.genai import types
from pydantic import ConfigDict
from pydantic import Field
import pytest
from typing_extensions import override
from .. import testing_utils
from .workflow_testing_utils import find_function_call_event
from .workflow_testing_utils import InputCapturingNode
from .workflow_testing_utils import RequestInputNode
from .workflow_testing_utils import simplify_events_with_node
from .workflow_testing_utils import TestingNode
def long_running_tool_func():
"""A test tool that simulates a long-running operation."""
return None
@pytest.mark.asyncio
async def test_nested_workflow_as_node(request: pytest.FixtureRequest):
"""Tests that a Workflow can be used as a node in another Workflow."""
async def nested_func(node_input: types.Content):
return 'I am nested'
nested_agent = Workflow(
name='nested_agent',
edges=[('START', nested_func)],
)
async def output_func(node_input: str):
return 'I am outer'
outer_agent = Workflow(
name='outer_agent',
edges=[('START', nested_agent), (nested_agent, output_func)],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hello'))
simplified_events = simplify_events_with_node(events)
assert simplified_events == [
(
'outer_agent@1/nested_agent@1/nested_func@1',
{
'output': 'I am nested',
},
),
(
'outer_agent@1/output_func@1',
{
'output': 'I am outer',
},
),
]
@pytest.mark.asyncio
async def test_nested_workflow_with_join_node(
request: pytest.FixtureRequest,
):
"""Tests that a nested Workflow with JoinNode works correctly."""
async def nested_node_a():
return {'a': 1}
async def nested_node_b():
return {'b': 2}
async def nested_node_c():
return {'c': 3}
nested_join_node = JoinNode(name='nested_join')
nested_agent = Workflow(
name='nested_agent',
edges=[
('START', nested_node_a),
('START', nested_node_c),
(nested_node_a, nested_join_node),
(nested_node_c, nested_node_b),
(nested_node_b, nested_join_node),
],
)
async def output_func(node_input: dict):
return (
'Joined output:'
f' a={node_input["nested_node_a"]["a"]},'
f' b={node_input["nested_node_b"]["b"]}'
)
outer_agent = Workflow(
name='outer_agent',
edges=[('START', nested_agent), (nested_agent, output_func)],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hello'))
simplified_events = simplify_events_with_node(events)
assert sorted(simplified_events[0:2], key=lambda x: x[0]) == [
(
'outer_agent@1/nested_agent@1/nested_node_a@1',
{'output': {'a': 1}},
),
(
'outer_agent@1/nested_agent@1/nested_node_c@1',
{'output': {'c': 3}},
),
]
assert simplified_events[2:] == [
(
'outer_agent@1/nested_agent@1/nested_node_b@1',
{'output': {'b': 2}},
),
(
'outer_agent@1/nested_agent@1/nested_join@1',
{
'output': {'nested_node_a': {'a': 1}, 'nested_node_b': {'b': 2}},
},
),
(
'outer_agent@1/output_func@1',
{
'output': 'Joined output: a=1, b=2',
},
),
]
@pytest.mark.asyncio
async def test_nested_workflow_updates_state_outer_reads(
request: pytest.FixtureRequest,
):
"""Tests that outer workflow can read state updated by nested workflow."""
async def nested_state_updater(ctx: Context):
yield Event(
state={'my_key': 'my_value'},
)
yield 'nested agent finished'
nested_agent = Workflow(
name='nested_agent',
edges=[('START', nested_state_updater)],
)
def outer_state_reader(my_key: str, node_input: str):
return f'Nested agent output: {node_input}, state value: {my_key}'
outer_agent = Workflow(
name='outer_agent',
edges=[('START', nested_agent), (nested_agent, outer_state_reader)],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hello'))
simplified_events = simplify_events_with_node(events)
assert simplified_events == [
(
'outer_agent@1/nested_agent@1/nested_state_updater@1',
{
'output': 'nested agent finished',
},
),
(
'outer_agent@1/outer_state_reader@1',
{
'output': (
'Nested agent output: nested agent finished, state value:'
' my_value'
),
},
),
]
@pytest.mark.asyncio
async def test_nested_workflow_intermediate_nodes(
request: pytest.FixtureRequest,
):
"""Tests that only the final output of a nested workflow is passed to the outer workflow."""
node_a = TestingNode(name='NodeA', output='Inner Intermediate')
node_b = TestingNode(name='NodeB', output='Inner Final')
nested_agent = Workflow(
name='nested_agent',
edges=[
('START', node_a),
(node_a, node_b),
],
)
output_node = InputCapturingNode(name='OutputNode')
outer_agent = Workflow(
name='outer_agent',
edges=[
('START', nested_agent),
(nested_agent, output_node),
],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('start'))
assert output_node.received_inputs == ['Inner Final']
simplified_events = simplify_events_with_node(events)
assert simplified_events == [
(
'outer_agent@1/nested_agent@1/NodeA@1',
{'output': 'Inner Intermediate'},
),
(
'outer_agent@1/nested_agent@1/NodeB@1',
{'output': 'Inner Final'},
),
(
'outer_agent@1/OutputNode@1',
{'output': {'received': 'Inner Final'}},
),
]
@pytest.mark.asyncio
async def test_nested_workflow_with_hitl(request: pytest.FixtureRequest):
"""Tests that a nested Workflow with HITL works correctly."""
# Given: A nested workflow with an LLM agent that calls a long running tool
llm_agent = LlmAgent(
name='llm_agent',
model=testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name='long_running_tool_func',
args={},
),
types.Part.from_text(text='LLM response after tool'),
]
),
tools=[LongRunningFunctionTool(func=long_running_tool_func)],
)
nested_agent = Workflow(
name='nested_agent',
edges=[('START', llm_agent)],
)
async def output_func(node_input: Any):
return 'I am outer'
outer_agent = Workflow(
name='outer_agent',
edges=[('START', nested_agent), (nested_agent, output_func)],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
# When: Starting the workflow
user_event = testing_utils.get_user_content('start workflow')
events1 = await runner.run_async(user_event)
# Then: It should yield a FunctionCall event and wait for tool execution
ev = find_function_call_event(events1, 'long_running_tool_func')
assert ev is not None
function_call_id = ev.content.parts[0].function_call.id
simplified_events1 = simplify_events_with_node(events1)
assert simplified_events1 == [
(
'outer_agent@1/nested_agent@1/llm_agent@1',
types.Part.from_function_call(name='long_running_tool_func', args={}),
),
]
tool_response = testing_utils.UserContent(
types.Part(
function_response=types.FunctionResponse(
id=function_call_id,
name='long_running_tool_func',
response={'result': 'Final tool output'},
)
)
)
invocation_id = events1[0].invocation_id
events2 = await runner.run_async(
new_message=tool_response,
invocation_id=invocation_id,
)
simplified_events2 = simplify_events_with_node(events2)
assert simplified_events2 == [
('outer_agent@1/nested_agent@1/llm_agent@1', 'LLM response after tool'),
(
'outer_agent@1/output_func@1',
{'output': 'I am outer'},
),
]
@pytest.mark.asyncio
async def test_nested_workflow_with_request_input_event_hitl(
request: pytest.FixtureRequest,
):
"""Nested workflow correctly propagates RequestInput and resumes.
Setup: outer_agent -> nested_agent -> node_hitl.
Act:
- Run 1: start workflow, node_hitl yields RequestInput.
- Run 2: resume with user response.
Assert:
- Run 1: returns RequestInput event.
- Run 2: node_hitl receives input and completes.
"""
class NodeHitl(BaseNode):
model_config = ConfigDict(arbitrary_types_allowed=True)
rerun_on_resume: bool = Field(default=True)
name: str = Field(default='node_hitl')
@override
async def _run_impl(
self,
*,
ctx: Context,
node_input: Any,
) -> AsyncGenerator[Any, None]:
if resume_input := ctx.resume_inputs.get('request_input'):
yield f'Resumed with user input: {resume_input}'
else:
yield RequestInput(
message='requesting input via RequestInputEvent',
interrupt_id='request_input',
)
# Given: A nested workflow where the inner node requests input
node_hitl_instance = NodeHitl()
nested_agent = Workflow(
name='nested_agent',
edges=[('START', node_hitl_instance)],
)
async def output_func():
return 'I am outer'
outer_agent = Workflow(
name='outer_agent',
edges=[('START', nested_agent), (nested_agent, output_func)],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
# When: Starting the workflow
user_event = testing_utils.get_user_content('start workflow')
events1 = await runner.run_async(user_event)
# Then: It should yield a RequestInput event
req_events = [e for e in events1 if has_request_input_function_call(e)]
assert req_events
interrupt_id = get_request_input_interrupt_ids(req_events[0])[0]
assert interrupt_id == 'request_input'
simplified_events1 = simplify_events_with_node(events1)
assert simplified_events1 == [
(
'outer_agent@1/nested_agent@1/node_hitl@1',
testing_utils.simplify_content(copy.deepcopy(req_events[0].content)),
),
]
# When: Resuming with user response
hitl_response = types.Content(
parts=[
create_request_input_response(
interrupt_id, {'response': 'user input for hitl'}
)
],
role='user',
)
invocation_id = events1[0].invocation_id
events2 = await runner.run_async(
new_message=hitl_response,
invocation_id=invocation_id,
)
# Then: It should complete and pass output to the outer workflow
simplified_events2 = simplify_events_with_node(events2)
assert simplified_events2 == [
(
'outer_agent@1/nested_agent@1/node_hitl@1',
{
'output': (
"Resumed with user input: {'response': 'user input for hitl'}"
),
},
),
(
'outer_agent@1/output_func@1',
{'output': 'I am outer'},
),
]
@pytest.mark.asyncio
async def test_nested_agent_with_request_input_piped_to_next_node(
request: pytest.FixtureRequest,
):
"""Tests that user response to RequestInput in nested agent is piped to next node."""
ask_user = RequestInputNode(
name='ask_user',
message='Please provide input',
)
capture_node = InputCapturingNode(name='capture_node')
sub_agent = Workflow(
name='sub_agent',
edges=[
('START', ask_user),
(ask_user, capture_node),
],
)
root_agent = Workflow(
name='root_agent',
edges=[('START', sub_agent)],
)
app = App(
name=request.function.__name__,
root_agent=root_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
user_event = testing_utils.get_user_content('start workflow')
events1 = await runner.run_async(user_event)
req_events = [e for e in events1 if has_request_input_function_call(e)]
assert req_events
interrupt_id = get_request_input_interrupt_ids(req_events[0])[0]
assert interrupt_id
hitl_response_payload = {'response': 'user input for hitl'}
hitl_response = types.Content(
parts=[
create_request_input_response(interrupt_id, hitl_response_payload)
],
role='user',
)
invocation_id = events1[0].invocation_id
await runner.run_async(
new_message=hitl_response,
invocation_id=invocation_id,
)
assert capture_node.received_inputs == [hitl_response_payload]
@pytest.mark.asyncio
async def test_nested_workflow_chain_input_propagation(
request: pytest.FixtureRequest,
):
async def create_output_a():
return 'output of A'
nested_agent_a = Workflow(
name='nested_agent_a',
edges=[('START', create_output_a)],
)
capture_node_b = InputCapturingNode(name='capture_node_b')
nested_agent_b = Workflow(
name='nested_agent_b',
edges=[('START', capture_node_b)],
)
outer_agent = Workflow(
name='outer_agent',
edges=[
('START', nested_agent_a),
(nested_agent_a, nested_agent_b),
],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
await runner.run_async(testing_utils.get_user_content('hello'))
assert capture_node_b.received_inputs == ['output of A']
@pytest.mark.asyncio
async def test_nested_workflow_with_tool_calls(
request: pytest.FixtureRequest,
):
"""Tests that a nested Workflow works correctly with two tool calls."""
tool_call_count = 0
def simple_tool() -> str:
nonlocal tool_call_count
tool_call_count += 1
return f'Tool output {tool_call_count}'
# Given: A nested workflow where the inner node calls a tool
llm_agent = LlmAgent(
name='llm_agent',
model=testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name='simple_tool',
args={},
),
types.Part.from_text(text='LLM response after tools'),
]
),
tools=[simple_tool],
)
nested_agent = Workflow(
name='nested_agent',
edges=[('START', llm_agent)],
)
async def output_func():
return 'I am outer'
outer_agent = Workflow(
name='outer_agent',
edges=[
('START', nested_agent),
(nested_agent, output_func),
],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
# When: Starting the workflow
user_event = testing_utils.get_user_content('start workflow')
events = await runner.run_async(user_event)
# Then: It should call the tool and yield events
assert tool_call_count == 1
simplified_events = simplify_events_with_node(events)
# Extract the dynamically generated function_call_id from events.
ev = find_function_call_event(events, 'simple_tool')
assert ev is not None
function_call_id = ev.content.parts[0].function_call.id
assert simplified_events == [
(
'outer_agent@1/nested_agent@1/llm_agent@1',
types.Part.from_function_call(name='simple_tool', args={}),
),
(
'outer_agent@1/nested_agent@1/llm_agent@1',
types.Part(
function_response=types.FunctionResponse(
name='simple_tool',
response={'result': 'Tool output 1'},
)
),
),
('outer_agent@1/nested_agent@1/llm_agent@1', 'LLM response after tools'),
(
'outer_agent@1/output_func@1',
{
'output': 'I am outer',
},
),
]
@pytest.mark.asyncio
async def test_three_level_nested_workflow(request: pytest.FixtureRequest):
"""Tests a 3-level nested Workflow structure."""
async def inner_func():
return 'I am inner'
inner_agent = Workflow(
name='inner_agent',
edges=[('START', inner_func)],
)
async def middle_func():
return 'I am middle'
middle_agent = Workflow(
name='middle_agent',
edges=[('START', inner_agent), (inner_agent, middle_func)],
)
async def outer_func():
return 'I am outer'
outer_agent = Workflow(
name='outer_agent',
edges=[('START', middle_agent), (middle_agent, outer_func)],
)
app = App(
name=request.function.__name__,
root_agent=outer_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
events = await runner.run_async(testing_utils.get_user_content('hello'))
simplified_events = simplify_events_with_node(events)
assert simplified_events == [
(
'outer_agent@1/middle_agent@1/inner_agent@1/inner_func@1',
{
'output': 'I am inner',
},
),
(
'outer_agent@1/middle_agent@1/middle_func@1',
{
'output': 'I am middle',
},
),
(
'outer_agent@1/outer_func@1',
{
'output': 'I am outer',
},
),
]
@pytest.mark.asyncio
async def test_duplicate_grandchild_workflow_names(
request: pytest.FixtureRequest,
):
"""Tests that grandchild workflow agents with same name can coexist."""
# Given: A workflow hierarchy where grandchild workflows have the same name
async def grandchild_func():
return 'I am grandchild'
grandchild_agent = Workflow(
name='grandchild',
edges=[('START', grandchild_func)],
)
child1_agent = Workflow(
name='child1',
edges=[('START', copy.deepcopy(grandchild_agent))],
)
child2_agent = Workflow(
name='child2',
edges=[('START', copy.deepcopy(grandchild_agent))],
)
root_agent = Workflow(
name='root',
edges=[('START', child1_agent), (child1_agent, child2_agent)],
)
app = App(
name=request.function.__name__,
root_agent=root_agent,
)
runner = testing_utils.InMemoryRunner(app=app)
# When: Starting the workflow
user_content = testing_utils.get_user_content('hello')
events = await runner.run_async(user_content)
# Then: It should execute both grandchildren successfully
simplified_events = simplify_events_with_node(events)
assert simplified_events == [
(
'root@1/child1@1/grandchild@1/grandchild_func@1',
{'output': 'I am grandchild'},
),
(
'root@1/child2@1/grandchild@1/grandchild_func@1',
{'output': 'I am grandchild'},
),
]
@pytest.mark.asyncio
async def test_duplicate_name_in_ancestral_path(
request: pytest.FixtureRequest,
):
"""Tests that agent with same name can exist in ancestral path (A->B->A)."""
async def func_a():
return 'I am A'
agent_a_child = Workflow(
name='A',
edges=[('START', func_a)],
)
agent_b = Workflow(
name='B',
edges=[('START', agent_a_child)],
)
agent_a_root = Workflow(
name='A',
edges=[('START', agent_b)],
)
app = App(
name=request.function.__name__,
root_agent=agent_a_root,
)
runner = testing_utils.InMemoryRunner(app=app)
user_content = testing_utils.get_user_content('hello')
events = await runner.run_async(user_content)
simplified_events = simplify_events_with_node(events)
assert simplified_events == [
(
'A@1/B@1/A@1/func_a@1',
{'output': 'I am A'},
),
]
# --- Helpers moved from test_workflow.py ---
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 _InputCapturingNode(BaseNode):
"""Captures node_input for later assertion."""
model_config = ConfigDict(arbitrary_types_allowed=True)
received_inputs: list[Any] = Field(default_factory=list)
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
self.received_inputs.append(node_input)
yield {'received': node_input}
async def _run_workflow(wf, message='start'):
"""Run a Workflow through Runner, return collected events."""
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
msg = types.Content(parts=[types.Part(text=message)], role='user')
events = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg
):
events.append(event)
return events, ss, session
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 moved from test_workflow.py ---
@pytest.mark.asyncio
async def test_nested_workflow_completes():
"""Inner workflow runs to completion, outer continues downstream."""
inner_node = _OutputNode(name='inner_node', value='inner_result')
inner_wf = Workflow(name='inner_wf', edges=[(START, inner_node)])
before = _OutputNode(name='before', value='before_result')
after = _InputCapturingNode(name='after')
wf = Workflow(name='wf', edges=[(START, before, inner_wf, after)])
events, _, _ = await _run_workflow(wf)
by_node = _output_by_node(events)
assert ('before', 'before_result') in by_node
assert ('inner_node', 'inner_result') in by_node
assert after.received_inputs == ['inner_result']
@pytest.mark.asyncio
async def test_nested_workflow_event_author():
"""Events are authored by the nearest orchestrator (workflow/agent).
Setup: outer_wf → inner_wf → inner_node.
Assert:
- inner_node's events are authored by inner_wf (nearest).
- outer_wf's direct children are authored by outer_wf.
- inner_wf overrides the author for its subtree.
"""
inner_node = _OutputNode(name='inner_node', value='inner_result')
inner_wf = Workflow(name='inner_wf', edges=[(START, inner_node)])
outer_node = _OutputNode(name='outer_node', value='outer_result')
wf = Workflow(
name='outer_wf',
edges=[(START, outer_node, inner_wf)],
)
events, _, _ = await _run_workflow(wf)
# outer_node's events authored by outer_wf (nearest orchestrator).
outer_events = [
e for e in events if e.node_info.path == 'outer_wf@1/outer_node@1'
]
assert outer_events
assert all(e.author == 'outer_wf' for e in outer_events)
# inner_node's events authored by inner_wf (nearest orchestrator),
# NOT outer_wf.
inner_events = [
e
for e in events
if e.node_info.path == 'outer_wf@1/inner_wf@1/inner_node@1'
]
assert inner_events
assert all(e.author == 'inner_wf' for e in inner_events)
@pytest.mark.asyncio
async def test_nested_workflow_interrupt_and_resume():
"""Inner workflow child interrupts, outer resumes on FR."""
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('_nested_fc')
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
ctx.state['_nested_fc'] = None
response = ctx.resume_inputs[fc_id]['answer']
yield f'approved:{response}'
return
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
ctx.state['_nested_fc'] = fc_id
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='inner_tool', args={}, id=fc_id
)
)
]
),
long_running_tool_ids={fc_id},
)
inner_wf = Workflow(
name='inner_wf',
edges=[(START, _InterruptNode(name='approval'))],
)
before = _OutputNode(name='before', value='before_result')
after = _InputCapturingNode(name='after')
wf = Workflow(
name='wf',
edges=[(START, before, inner_wf, after)],
)
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: before completes, inner_wf/approval 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)
# Should have interrupt from inner's child
interrupt_events = [e for e in events1 if e.long_running_tool_ids]
assert len(interrupt_events) == 1
assert interrupt_events[0].long_running_tool_ids is not None
fc_id = list(interrupt_events[0].long_running_tool_ids)[0]
# Workflow-level interrupt events should NOT be persisted
# (they're _adk_internal). Only the leaf child's event at
# 'wf/inner_wf/approval' should have interrupt ids in session.
updated_session = await ss.get_session(
app_name='test', user_id='u', session_id=session.id
)
assert updated_session is not None
wf_interrupt_events = [
e
for e in updated_session.events
if e.long_running_tool_ids and e.node_info.path in ('wf', 'wf/inner_wf')
]
assert wf_interrupt_events == []
# Run 2: resume
msg2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='inner_tool',
id=fc_id,
response={'answer': 'yes'},
)
)
],
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)
# Inner resumed, after should receive inner's output
outputs = [e.output for e in events2 if e.output is not None]
assert 'approved:yes' in outputs
assert after.received_inputs == ['approved:yes']
@pytest.mark.asyncio
async def test_nested_workflow_partial_resume():
"""Partial FR re-runs nested Workflow, resolved child completes while unresolved stays interrupted.
Setup: outer_wf → inner_wf → (child_a, child_b) → join.
Both children interrupt on first run.
Act:
- Run 2: resolve only child_a's FR.
- Run 3: resolve child_b's FR.
Assert:
- Run 2: child_a produces output, invocation still interrupted.
- Run 3: child_b produces output, join completes, no interrupts.
"""
class _InterruptOnce(BaseNode):
"""Interrupts on first run, yields resume response on second."""
rerun_on_resume: bool = True
async def _run_impl(
self, *, ctx: Context, node_input: Any
) -> AsyncGenerator[Any, None]:
fc_id = f'fc-{self.name}'
if ctx.resume_inputs and fc_id in ctx.resume_inputs:
yield f'{self.name}:{ctx.resume_inputs[fc_id]}'
return
yield RequestInput(interrupt_id=fc_id)
child_a = _InterruptOnce(name='child_a')
child_b = _InterruptOnce(name='child_b')
join = JoinNode(name='join', wait_for_output=True)
inner_wf = Workflow(
name='inner_wf',
edges=[
(START, child_a),
(START, child_b),
(child_a, join),
(child_b, join),
],
)
outer_wf = Workflow(
name='outer',
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')
# Run 1: both children interrupt
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)
interrupt_ids = set()
for e in events1:
if e.long_running_tool_ids:
interrupt_ids.update(e.long_running_tool_ids)
assert 'fc-child_a' in interrupt_ids
assert 'fc-child_b' in interrupt_ids
# Run 2: resolve only child_a
msg2 = types.Content(
parts=[create_request_input_response('fc-child_a', {'v': 'a'})],
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)
# child_a should have produced output
child_a_outputs = [
e.output
for e in events2
if e.node_info.path and 'child_a' in e.node_info.path and e.output
]
assert any('child_a:' in str(o) for o in child_a_outputs)
# Run 3: resolve child_b → join completes, workflow finishes
msg3 = types.Content(
parts=[create_request_input_response('fc-child_b', {'v': 'b'})],
role='user',
)
events3: list[Event] = []
async for event in runner.run_async(
user_id='u', session_id=session.id, new_message=msg3
):
events3.append(event)
# child_b should have produced output
child_b_outputs = [
e.output
for e in events3
if e.node_info.path and 'child_b' in e.node_info.path and e.output
]
assert any('child_b:' in str(o) for o in child_b_outputs)
# join should have completed (no more interrupts)
final_interrupts = set()
for e in events3:
if e.long_running_tool_ids:
final_interrupts.update(e.long_running_tool_ids)
assert not final_interrupts