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

1300 lines
40 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 dynamic node scheduling in new Workflow.
Covers the three cases from doc 18 (Dynamic Node Resume via Lazy Scan):
- Case 1: Fresh execution (no prior events)
- Case 2: Completed dedup (return cached output on rerun)
- Case 3: Interrupted resume (rerun with resume_inputs)
Plus edge cases for multiple dynamic nodes, nested dynamic nodes,
and use_as_output delegation.
"""
import asyncio
from typing import Any
from typing import AsyncGenerator
from google.adk.agents.context import Context
from google.adk.events.event import Event
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow import node
from google.adk.workflow import START
from google.adk.workflow._errors import DynamicNodeFailError
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest
# --- Helpers ---
async def _run(
runner: Runner,
ss: InMemorySessionService,
session: Any,
message: str,
) -> list[Event]:
"""Send a text message and collect events."""
msg = types.Content(parts=[types.Part(text=message)], 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)
return events
async def _resume(
runner: Runner,
ss: InMemorySessionService,
session: Any,
fc_id: str,
response: Any,
) -> list[Event]:
"""Send a function response and collect events."""
if not isinstance(response, dict):
response = {'value': response}
msg = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name='tool', id=fc_id, response=response
)
)
],
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)
return events
def _outputs(events: list[Event]) -> list[Any]:
"""Extract non-None outputs."""
return [e.output for e in events if e.output is not None]
def _interrupt_ids(events: list[Event]) -> set[str]:
"""Extract interrupt IDs from events."""
ids: set[str] = set()
for e in events:
if e.long_running_tool_ids:
ids.update(e.long_running_tool_ids)
return ids
# =========================================================================
# Fresh execution (no resume)
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_node_fresh_execution():
"""Dynamic node runs normally on first invocation.
Setup: Parent (rerun_on_resume=True) calls ctx.run_node(Child).
Action: Send a text message to trigger the workflow.
Assert: Parent receives Child's output and yields the combined result.
"""
@node
async def child(*, ctx, node_input):
yield f'child got: {node_input}'
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(child, node_input='hello')
yield f'parent got: {result}'
wf = Workflow(name='wf', edges=[(START, parent)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
events = await _run(runner, ss, session, 'go')
outputs = _outputs(events)
assert 'parent got: child got: hello' in outputs
@pytest.mark.asyncio
async def test_dynamic_node_with_downstream_static():
"""Dynamic child's output flows to a downstream static node.
Setup: Parent calls ctx.run_node(Child). Parent is followed by
a static After node in the graph.
Action: Send a text message.
Assert: After node receives Parent's output (which includes
Child's result) as node_input.
"""
@node
async def child(*, ctx, node_input):
yield f'child: {node_input}'
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(child, node_input='forwarded')
yield result
@node
async def after(*, ctx, node_input):
yield f'after: {node_input}'
wf = Workflow(
name='wf',
edges=[(START, parent, after)],
)
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
events = await _run(runner, ss, session, 'hello')
outputs = _outputs(events)
assert 'after: child: forwarded' in outputs
# =========================================================================
# Single-level resume (interrupt → FR → resume)
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_node_interrupted_resume():
"""Dynamic child interrupts, then resumes with FR on parent rerun.
Setup: Parent calls ctx.run_node(Approver). Approver interrupts
with fc-1.
Action: Send FR for fc-1 with {answer: 'yes'}.
Assert:
- Approver resumes and outputs 'approved: yes'.
- Parent yields the final combined result.
"""
@node(rerun_on_resume=True)
async def approver(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'approved: {ctx.resume_inputs["fc-1"]["answer"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='approve', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(approver)
yield f'final: {result}'
wf = Workflow(name='wf', edges=[(START, parent)])
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
events1 = await _run(runner, ss, session, 'go')
assert 'fc-1' in _interrupt_ids(events1)
# Run 2: resume
events2 = await _resume(runner, ss, session, 'fc-1', {'answer': 'yes'})
outputs = _outputs(events2)
assert 'final: approved: yes' in outputs
@pytest.mark.asyncio
async def test_dynamic_node_completed_dedup_on_resume():
"""Completed dynamic child returns cached output when parent reruns.
Setup: Parent calls ctx.run_node(Completer) then
ctx.run_node(Interrupter). Completer completes, Interrupter
interrupts with fc-1.
Action: Send FR for fc-1 to resume.
Assert:
- Parent reruns (call_count increments to 2).
- Completer is NOT re-executed (dedup returns cached output).
- Interrupter resumes with the FR response.
- Final output combines both results.
"""
@node
async def completer(*, ctx, node_input):
yield 'completed_result'
@node(rerun_on_resume=True)
async def interrupter(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'resumed: {ctx.resume_inputs["fc-1"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
call_count = [0]
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
call_count[0] += 1
r1 = await ctx.run_node(completer)
r2 = await ctx.run_node(interrupter)
yield f'{r1} + {r2}'
wf = Workflow(name='wf', edges=[(START, parent)])
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: completer completes, interrupter interrupts
events1 = await _run(runner, ss, session, 'go')
assert _interrupt_ids(events1)
assert call_count[0] == 1
# Run 2: resume interrupter
events2 = await _resume(runner, ss, session, 'fc-1', 'done')
# Parent should have rerun (call_count=2).
# Completer should NOT re-execute (dedup returns cached).
# Interrupter resumes with FR.
assert call_count[0] == 2
outputs = _outputs(events2)
assert 'completed_result + resumed: done' in outputs
@pytest.mark.asyncio
async def test_dynamic_node_sequential_interrupts():
"""Sequential dynamic children interrupt one at a time.
Setup: Parent calls ctx.run_node(a) then ctx.run_node(b).
Both children interrupt, but sequentially — a interrupts first,
parent never reaches b until a is resolved.
Action: Resume a, then b.
Assert:
- Run 1: only fc-a interrupts (parent didn't reach b).
- Run 2 (resume fc-a): a completes, parent continues to b,
b interrupts with fc-b.
- Run 3 (resume fc-b): b completes, parent yields combined
output.
"""
@node(rerun_on_resume=True)
async def a(*, ctx, node_input):
if ctx.resume_inputs and 'fc-a' in ctx.resume_inputs:
yield f'a: {ctx.resume_inputs["fc-a"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-a'
)
)
]
),
long_running_tool_ids={'fc-a'},
)
@node(rerun_on_resume=True)
async def b(*, ctx, node_input):
if ctx.resume_inputs and 'fc-b' in ctx.resume_inputs:
yield f'b: {ctx.resume_inputs["fc-b"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-b'
)
)
]
),
long_running_tool_ids={'fc-b'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
r1 = await ctx.run_node(a, node_input=node_input)
r2 = await ctx.run_node(b, node_input=node_input)
yield f'{r1} + {r2}'
wf = Workflow(name='wf', edges=[(START, parent)])
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 interrupts, parent never reaches b
events1 = await _run(runner, ss, session, 'go')
ids1 = _interrupt_ids(events1)
assert 'fc-a' in ids1
assert 'fc-b' not in ids1
# Run 2: resume a → a completes, parent reaches b → b interrupts
events2 = await _resume(runner, ss, session, 'fc-a', 'done-a')
ids2 = _interrupt_ids(events2)
assert 'fc-b' in ids2
# Run 3: resume b → b completes, parent yields combined output
events3 = await _resume(runner, ss, session, 'fc-b', 'done-b')
outputs = _outputs(events3)
assert 'a: done-a + b: done-b' in outputs
@pytest.mark.asyncio
async def test_dynamic_node_run_id_reused_on_resume():
"""Resumed dynamic child reuses run_id from original run.
Setup: Parent calls ctx.run_node(Interrupter). Interrupter
interrupts with fc-1.
Action: Send FR for fc-1.
Assert: The resumed Interrupter's output event has the same
run_id as the original interrupt event.
"""
@node(rerun_on_resume=True)
async def interrupter(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield 'resumed'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(interrupter)
yield result
wf = Workflow(name='wf', edges=[(START, parent)])
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
events1 = await _run(runner, ss, session, 'go')
interrupt_event = [e for e in events1 if e.long_running_tool_ids][0]
original_run_id = interrupt_event.node_info.run_id
# Run 2: resume
events2 = await _resume(runner, ss, session, 'fc-1', 'ok')
resumed_output_events = [
e
for e in events2
if e.output == 'resumed' and 'interrupter' in (e.node_info.path or '')
]
assert len(resumed_output_events) == 1
assert resumed_output_events[0].node_info.run_id == original_run_id
# =========================================================================
# Nested workflow + dynamic node combinations
# =========================================================================
@pytest.mark.asyncio
async def test_nested_static_workflow_with_dynamic_interrupt():
"""Static sub-workflow's node schedules a dynamic node that interrupts.
Setup: outer_wf → inner_wf (static). Inside inner_wf, a static
parent node calls ctx.run_node(Approver). Approver interrupts.
Action: Send FR to resume.
Assert: Approver resumes, parent completes, inner_wf completes,
outer_wf completes.
"""
@node(rerun_on_resume=True)
async def approver(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'approved: {ctx.resume_inputs["fc-1"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(approver)
yield f'parent: {result}'
inner_wf = Workflow(
name='inner_wf',
edges=[(START, parent)],
)
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')
# Run 1: dynamic approver interrupts inside inner_wf
events1 = await _run(runner, ss, session, 'go')
assert 'fc-1' in _interrupt_ids(events1)
# Run 2: resume
events2 = await _resume(runner, ss, session, 'fc-1', 'yes')
outputs = _outputs(events2)
assert 'parent: approved: yes' in outputs
@pytest.mark.asyncio
async def test_dynamic_workflow_with_static_interrupt():
"""Dynamic child is a Workflow whose static node interrupts.
Setup: Parent calls ctx.run_node(inner_wf). inner_wf has a
static Interrupter node that interrupts with fc-1.
Action: Send FR to resume.
Assert: Interrupter resumes, inner_wf completes, parent
receives inner_wf's output.
"""
@node(rerun_on_resume=True)
async def step(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'done: {ctx.resume_inputs["fc-1"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
inner_wf = Workflow(
name='inner_wf',
edges=[(START, step)],
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(inner_wf)
yield f'parent: {result}'
outer_wf = Workflow(
name='outer_wf',
edges=[(START, parent)],
)
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: inner_wf's static node interrupts
events1 = await _run(runner, ss, session, 'go')
assert 'fc-1' in _interrupt_ids(events1)
# Run 2: resume
events2 = await _resume(runner, ss, session, 'fc-1', 'ok')
outputs = _outputs(events2)
assert 'parent: done: ok' in outputs
@pytest.mark.asyncio
async def test_dynamic_workflow_with_nested_dynamic_interrupt():
"""Dynamic Workflow's inner node schedules another dynamic node.
Setup: Parent calls ctx.run_node(inner_wf). inner_wf has a
static Orchestrator node that calls ctx.run_node(Approver).
Approver interrupts with fc-1.
Action: Send FR to resume.
Assert: Approver resumes → Orchestrator completes → inner_wf
completes → Parent receives output. Three levels of dynamic
nesting resolved correctly.
"""
@node(rerun_on_resume=True)
async def approver(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'approved: {ctx.resume_inputs["fc-1"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
@node(rerun_on_resume=True)
async def orch(*, ctx, node_input):
result = await ctx.run_node(approver)
yield f'orch: {result}'
inner_wf = Workflow(
name='inner_wf',
edges=[(START, orch)],
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(inner_wf)
yield f'parent: {result}'
outer_wf = Workflow(
name='outer_wf',
edges=[(START, parent)],
)
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: deeply nested approver interrupts
events1 = await _run(runner, ss, session, 'go')
assert 'fc-1' in _interrupt_ids(events1)
# Run 2: resume
events2 = await _resume(runner, ss, session, 'fc-1', 'granted')
outputs = _outputs(events2)
assert 'parent: orch: approved: granted' in outputs
# =========================================================================
# Scoping: parallel parents with same-named dynamic children
# =========================================================================
@pytest.mark.asyncio
async def test_parallel_parents_same_named_dynamic_children():
"""Two static parents schedule dynamic children with the same name.
Setup: outer_wf fans out to parent_a and parent_b (parallel).
Both call ctx.run_node(Child(name='child')). parent_a's child
completes, parent_b's child interrupts.
Action: Send FR to resume parent_b's child.
Assert:
- parent_a's child and parent_b's child are distinct (scoped
by parent_path: wf/parent_a/child vs wf/parent_b/child).
- On resume, parent_a's child returns cached output (dedup),
parent_b's child resumes with FR.
- Both parents complete.
"""
@node(rerun_on_resume=True)
async def child(*, ctx, node_input):
if ctx.resume_inputs and 'fc-b' in ctx.resume_inputs:
yield f'resumed: {ctx.resume_inputs["fc-b"]["value"]}'
return
if node_input == 'interrupt':
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-b'
)
)
]
),
long_running_tool_ids={'fc-b'},
)
else:
yield f'child: {node_input}'
@node(rerun_on_resume=True)
async def parent_a(*, ctx, node_input):
result = await ctx.run_node(child, node_input='complete')
yield f'a: {result}'
@node(rerun_on_resume=True)
async def parent_b(*, ctx, node_input):
result = await ctx.run_node(child, node_input='interrupt')
yield f'b: {result}'
from google.adk.workflow import JoinNode
join = JoinNode(name='join')
wf = Workflow(
name='wf',
edges=[
(START, parent_a, join),
(START, parent_b, join),
],
)
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: parent_a completes, parent_b's child interrupts
events1 = await _run(runner, ss, session, 'go')
assert 'fc-b' in _interrupt_ids(events1)
# parent_a should have completed
a_outputs = [
e.output for e in events1 if e.output and 'a: child' in str(e.output)
]
assert len(a_outputs) == 1
# Run 2: resume parent_b's child
events2 = await _resume(runner, ss, session, 'fc-b', 'done')
outputs = _outputs(events2)
# Both parents completed, join has both results
assert any('b: resumed: done' in str(o) for o in outputs)
# =========================================================================
# use_as_output + interrupt
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_node_use_as_output_with_interrupt():
"""Dynamic child with use_as_output=True interrupts then resumes.
Setup: Parent calls ctx.run_node(child, use_as_output=True).
Child interrupts with fc-1.
Action: Send FR for fc-1.
Assert:
- On resume, child resumes and its output becomes the parent's
output (use_as_output delegation).
- The parent's own output event is suppressed.
"""
@node(rerun_on_resume=True)
async def child(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'child: {ctx.resume_inputs["fc-1"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(child, use_as_output=True)
# Set on ctx so orchestrator reads it. _output_delegated
# suppresses the output Event (child already emitted it).
ctx.output = result
yield # keep as async generator
wf = Workflow(
name='wf',
edges=[(START, parent)],
)
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: child interrupts
events1 = await _run(runner, ss, session, 'go')
assert 'fc-1' in _interrupt_ids(events1)
# Run 2: resume
events2 = await _resume(runner, ss, session, 'fc-1', 'approved')
outputs = _outputs(events2)
assert 'child: approved' in outputs
# Parent's output should be suppressed (use_as_output)
parent_outputs = [
e.output
for e in events2
if e.node_info.path == 'wf/parent' and e.output is not None
]
assert len(parent_outputs) == 0
# =========================================================================
# None-output completion after interrupt
# =========================================================================
@pytest.mark.asyncio
@pytest.mark.xfail(
reason='No completion marker event for None output (event design flaw)'
)
async def test_dynamic_node_none_output_not_rerun():
"""Dynamic child that completed with None output is not re-run.
Setup: Parent calls ctx.run_node(A) then ctx.run_node(B).
A interrupts. On resume, A completes with no output (None).
Parent continues to B, which also interrupts.
Action: Resume B.
Assert:
- On Run 3, A should NOT re-run (it already completed).
- A should return None (cached), B resumes.
- Currently fails because A's None completion leaves no
trace in session events — the lazy scan thinks A still
needs to re-run.
"""
run_count_a = [0]
@node(rerun_on_resume=True)
async def a(*, ctx, node_input):
run_count_a[0] += 1
if ctx.resume_inputs and 'fc-a' in ctx.resume_inputs:
# Complete with no output.
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-a'
)
)
]
),
long_running_tool_ids={'fc-a'},
)
@node(rerun_on_resume=True)
async def b(*, ctx, node_input):
if ctx.resume_inputs and 'fc-b' in ctx.resume_inputs:
yield f'b: {ctx.resume_inputs["fc-b"]["value"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-b'
)
)
]
),
long_running_tool_ids={'fc-b'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
await ctx.run_node(a)
result = await ctx.run_node(b)
yield f'done: {result}'
wf = Workflow(name='wf', edges=[(START, parent)])
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 interrupts
events1 = await _run(runner, ss, session, 'go')
assert 'fc-a' in _interrupt_ids(events1)
assert run_count_a[0] == 1
# Run 2: resume A → A completes (None), parent reaches B → B interrupts
events2 = await _resume(runner, ss, session, 'fc-a', 'ok')
assert 'fc-b' in _interrupt_ids(events2)
assert run_count_a[0] == 2 # A re-ran once for resume
# Run 3: resume B → A should NOT re-run (already completed)
events3 = await _resume(runner, ss, session, 'fc-b', 'done')
assert run_count_a[0] == 2 # A should NOT have run again
outputs = _outputs(events3)
assert any('done:' in str(o) for o in outputs)
# =========================================================================
# rerun_on_resume=False for dynamic node
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_node_rerun_on_resume_false():
"""Dynamic child with rerun_on_resume=False auto-completes on resume.
Setup: Parent calls ctx.run_node(child). Child has
rerun_on_resume=False and interrupts with fc-1.
Action: Send FR for fc-1.
Assert:
- Child does NOT re-execute _run_impl.
- Child auto-completes with the FR response as output.
- Parent receives the auto-completed output.
"""
run_count = [0]
@node(rerun_on_resume=False)
async def child(*, ctx, node_input):
run_count[0] += 1
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(child)
yield f'parent: {result}'
wf = Workflow(
name='wf',
edges=[(START, parent)],
)
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: child interrupts
events1 = await _run(runner, ss, session, 'go')
assert 'fc-1' in _interrupt_ids(events1)
assert run_count[0] == 1
# Run 2: resume
events2 = await _resume(runner, ss, session, 'fc-1', {'answer': 42})
# Child should NOT have re-executed (run_count stays 1).
assert run_count[0] == 1
# Parent receives the FR response as child's output (unwrapped).
outputs = _outputs(events2)
assert "parent: {'answer': 42}" in outputs
# =========================================================================
# Sequential run_id
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_nodes_get_run_id_one():
"""Each distinct dynamic child gets run_id '1' for its first run."""
@node
async def step_a(*, ctx, node_input):
yield f'child: {node_input}'
@node
async def step_b(*, ctx, node_input):
yield f'child: {node_input}'
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
a = await ctx.run_node(step_a, node_input='x')
b = await ctx.run_node(step_b, node_input='y')
yield f'{a},{b}'
wf = Workflow(name='wf', edges=[(START, parent)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
events = await _run(runner, ss, session, 'go')
child_events = [
(e.node_name, e.node_info.path.split('@')[-1])
for e in events
if e.output is not None
and e.node_name
and e.node_name.startswith('step_')
]
# Each dynamic child is a distinct path, each gets run_id '1'.
assert child_events == [('step_a', '1'), ('step_b', '1')]
@pytest.mark.asyncio
async def test_dynamic_node_keeps_run_id_on_resume():
"""A dynamic node that interrupts and resumes keeps the same run_id."""
@node(rerun_on_resume=True)
async def approver(*, ctx, node_input):
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
yield f'approved: {ctx.resume_inputs["fc-1"]["answer"]}'
return
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='approve', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(approver)
yield f'final: {result}'
wf = Workflow(name='wf', edges=[(START, parent)])
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: child interrupts.
events1 = await _run(runner, ss, session, 'go')
approver_run_ids_1 = [
e.node_info.path.split('@')[-1]
for e in events1
if e.node_info.path and 'approver@' in e.node_info.path
]
# Run 2: resume with function response.
events2 = await _resume(runner, ss, session, 'fc-1', {'answer': 'yes'})
approver_run_ids_2 = [
e.node_info.path.split('@')[-1]
for e in events2
if e.node_info.path and 'approver@' in e.node_info.path
]
# Same run_id across interrupt and resume.
assert approver_run_ids_1
assert approver_run_ids_2
assert approver_run_ids_1[0] == approver_run_ids_2[0]
# =========================================================================
# Custom run_id
# =========================================================================
@pytest.mark.asyncio
async def test_custom_run_id_used_on_events():
"""ctx.run_node(run_id=...) sets the custom run_id on child events."""
@node
async def child(*, ctx, node_input):
yield f'done: {node_input}'
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
result = await ctx.run_node(
child, node_input='hello', run_id='my-custom-id'
)
yield result
wf = Workflow(name='wf', edges=[(START, parent)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
events = await _run(runner, ss, session, 'go')
child_events = [
e
for e in events
if e.node_info and e.node_info.path and 'child' in e.node_info.path
]
assert child_events
assert all(
e.node_info.path.split('@')[-1] == 'my-custom-id' for e in child_events
)
# =========================================================================
# Failure handling in dynamic nodes
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_node_failure_handling():
"""Dynamic node throws exception; parent catches it and continues."""
@node
async def failing_node(*, ctx, node_input):
if node_input == 'fail':
raise ValueError('Intentional Failure')
yield f'Processed {node_input}'
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
results = []
try:
await ctx.run_node(failing_node, node_input='fail')
except DynamicNodeFailError as e:
results.append(f'Caught: {str(e.error)}')
res = await ctx.run_node(failing_node, node_input='work')
results.append(f'Success: {res}')
yield results
wf = Workflow(name='wf', edges=[(START, parent)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
events = await _run(runner, ss, session, 'go')
outputs = _outputs(events)
# Find the list output from parent
list_outputs = [o for o in outputs if isinstance(o, list)]
assert len(list_outputs) == 1
results = list_outputs[0]
assert 'Caught: Intentional Failure' in results
assert 'Success: Processed work' in results
@pytest.mark.asyncio
async def test_workflow_resume_does_not_rerun_completed_llm_agent():
"""Completed LlmAgent node is not rerun upon workflow resumption.
Setup: Workflow with LlmAgent node and an interrupting node.
Act:
- Run 1: Start workflow, LlmAgent completes, workflow interrupts.
- Run 2: Resume workflow by resolving interrupt.
Assert:
- LlmAgent does not run again in Run 2.
"""
from google.adk.agents.llm_agent import LlmAgent
from tests.unittests import testing_utils
# Given a workflow with an LlmAgent and a mock model
mock_model = testing_utils.MockModel.create(
responses=['LLM output content', 'Duplicate run output']
)
agent = LlmAgent(name='my_agent', model=mock_model)
@node
async def interrupt_node(*, ctx, node_input):
event = Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', id='interrupt_1', args={}
)
)
]
)
)
event.long_running_tool_ids = {'interrupt_1'}
yield event
yield f'Resumed with {node_input}'
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
res = await ctx.run_node(agent, node_input='go')
res2 = await ctx.run_node(interrupt_node, node_input=res)
yield res2
wf = Workflow(name='wf', edges=[(START, parent)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# When the workflow is run until it interrupts
await _run(runner, ss, session, 'go')
session = await ss.get_session(
app_name='test', user_id='u', session_id=session.id
)
agent_events = [
e for e in session.events if e.node_info.name == 'my_agent' and e.content
]
assert len(agent_events) > 0
agent_event = agent_events[-1]
# Verify that runners.py cleared the output
assert agent_event.output is None
# When the workflow is resumed by resolving the interrupt
resume_events = await _resume(
runner, ss, session, fc_id='interrupt_1', response='done'
)
# Then the LlmAgent should not run again
agent_runs_again = [
e for e in resume_events if e.node_info.name == 'my_agent' and e.content
]
assert (
len(agent_runs_again) == 0
), 'Expected LlmAgent to NOT run again, but it did!'
# =========================================================================
# Parallel execution of dynamic nodes
# =========================================================================
@pytest.mark.asyncio
async def test_dynamic_node_parallel_execution():
"""Three parallel ctx.run_node calls via asyncio.gather return ordered results."""
@node
async def echo_node(*, ctx, node_input):
yield node_input
@node(rerun_on_resume=True)
async def parent_node(*, ctx, node_input):
tasks = [ctx.run_node(echo_node, node_input=f'call_{i}') for i in range(3)]
results = await asyncio.gather(*tasks)
yield results
wf = Workflow(name='wf', edges=[(START, parent_node)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
events = await _run(runner, ss, session, 'go')
outputs = _outputs(events)
# Find the list output from parent
list_outputs = [o for o in outputs if isinstance(o, list)]
assert len(list_outputs) == 1
results = list_outputs[0]
assert results == ['call_0', 'call_1', 'call_2']
@pytest.mark.asyncio
async def test_inner_llm_agent_node_input_survives_tool_round_trip():
"""Inner LlmAgent's node_input reaches every LLM request across a tool round trip.
Setup: Workflow with a single dynamic node whose body invokes an inner
LlmAgent. The agent's mock model emits a function_call on the first
turn and final text on the second turn.
Act: Run the workflow once; the dynamic node passes USER_PHRASE to the
inner agent.
Assert:
- The mock model is called exactly twice (pre- and post-tool).
- USER_PHRASE appears in the user-role text of both LLM requests.
- The second request contains both function_call and function_response
(proving a real tool round trip occurred).
"""
from google.adk.agents.llm_agent import LlmAgent
from google.adk.tools import FunctionTool
from tests.unittests import testing_utils
USER_PHRASE = 'lookup price for token MARKER-7Z9'
def lookup_price(token: str) -> dict:
return {'price': '$9.99'}
def _user_texts(req) -> list[str]:
texts: list[str] = []
for content in req.contents or []:
if content.role != 'user':
continue
for part in content.parts or []:
if part.text and not part.text.startswith('For context:'):
texts.append(part.text)
return texts
def _part_kinds(req) -> list[str]:
kinds: list[str] = []
for content in req.contents or []:
for part in content.parts or []:
if part.function_call:
kinds.append('function_call')
elif part.function_response:
kinds.append('function_response')
else:
kinds.append('text')
return kinds
mock_model = testing_utils.MockModel.create(
responses=[
[
types.Part(
function_call=types.FunctionCall(
id='fc1',
name='lookup_price',
args={'token': 'MARKER-7Z9'},
)
)
],
'The price is $9.99.',
]
)
inner_agent = LlmAgent(
name='inner_agent',
model=mock_model,
tools=[FunctionTool(lookup_price)],
)
@node(rerun_on_resume=True)
async def parent(*, ctx, node_input):
yield await ctx.run_node(inner_agent, node_input=USER_PHRASE)
wf = Workflow(name='wf', edges=[(START, parent)])
ss = InMemorySessionService()
runner = Runner(app_name='test', node=wf, session_service=ss)
session = await ss.create_session(app_name='test', user_id='u')
# When the workflow runs end-to-end through the tool round trip
await _run(runner, ss, session, 'go')
# Then the model is invoked exactly twice
assert len(mock_model.requests) == 2
# And USER_PHRASE survives into both LLM requests
assert any(USER_PHRASE in t for t in _user_texts(mock_model.requests[0]))
assert any(
USER_PHRASE in t for t in _user_texts(mock_model.requests[1])
), 'original node_input was dropped from the second LLM request'
# And the second request reflects a real tool round trip
second_request_kinds = _part_kinds(mock_model.requests[1])
assert 'function_call' in second_request_kinds
assert 'function_response' in second_request_kinds