Files
google--adk-python/tests/unittests/workflow/test_node_runner_ctx.py
T
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

659 lines
18 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 NodeRunner → Context as result channel.
Verifies that NodeRunner correctly populates ctx.output, ctx.route,
and ctx.interrupt_ids from yielded events and direct assignment,
and that resume state (prior_output, prior_interrupt_ids) is carried
forward correctly.
"""
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.context import Context
from google.adk.agents.invocation_context import InvocationContext
from google.adk.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._node_runner import NodeRunner
from google.genai import types
import pytest
# --- Helpers ---
def _make_ctx(invocation_id='inv-test', enqueue_events=None, node_path=''):
"""Create a minimal Context mock with IC."""
mock_agent = MagicMock(spec=BaseAgent)
real_session = Session(
id='test_session', app_name='test_app', user_id='test_user'
)
real_session_service = InMemorySessionService()
ic = InvocationContext(
invocation_id=invocation_id,
agent=mock_agent,
session=real_session,
session_service=real_session_service,
)
collected = enqueue_events if enqueue_events is not None else []
async def _enqueue(event):
collected.append(event)
object.__setattr__(ic, '_enqueue_event', AsyncMock(side_effect=_enqueue))
ctx = Context(
invocation_context=ic,
node_path=node_path,
)
return ctx, collected
# =========================================================================
# Context as RESULT — fields populated by NodeRunner after execution
# =========================================================================
# --- ctx.output from yielded events ---
@pytest.mark.asyncio
async def test_yield_value_sets_ctx_output():
"""Yielding a value sets ctx.output on the returned context."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'hello'
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'hello'
@pytest.mark.asyncio
async def test_yield_event_output_sets_ctx_output():
"""Yielding Event(output=X) sets ctx.output."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='from_event')
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'from_event'
@pytest.mark.asyncio
async def test_no_yield_leaves_ctx_output_none():
"""A node that yields nothing leaves ctx.output as None."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
return
yield # noqa: unreachable
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output is None
# --- ctx.output set directly ---
@pytest.mark.asyncio
async def test_ctx_output_set_directly():
"""Setting ctx.output directly produces a deferred output event."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.output = 'direct'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'direct'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 1
assert output_events[0].output == 'direct'
@pytest.mark.asyncio
async def test_ctx_output_direct_with_state_delta():
"""Deferred output bundles pending state deltas onto the same event."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.state['key'] = 'val'
ctx.output = 'result'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'result'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 1
assert output_events[0].actions.state_delta['key'] == 'val'
@pytest.mark.asyncio
async def test_deferred_output_emitted_after_intermediate():
"""ctx.output set directly emits after intermediate content events."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.output = 'deferred'
yield Event(content=types.Content(parts=[types.Part(text='working')]))
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'deferred'
assert len(events) == 2
assert events[0].content.parts[0].text == 'working'
assert events[1].output == 'deferred'
# --- ctx.output validation ---
@pytest.mark.asyncio
async def test_double_output_raises():
"""Setting ctx.output twice raises ValueError."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.output = 'first'
ctx.output = 'second'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='n'), parent_ctx=parent_ctx).run()
error_events = [e for e in events if e.error_code]
assert len(error_events) == 1
assert error_events[0].error_code == 'ValueError'
assert 'already set' in error_events[0].error_message
@pytest.mark.asyncio
async def test_yield_then_ctx_output_raises():
"""Yielding output then setting ctx.output raises ValueError."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'first'
ctx.output = 'second'
parent_ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='n'), parent_ctx=parent_ctx).run()
error_events = [e for e in events if e.error_code]
assert len(error_events) == 1
assert error_events[0].error_code == 'ValueError'
assert 'already set' in error_events[0].error_message
# --- ctx.route ---
@pytest.mark.asyncio
async def test_yield_route_sets_ctx_route():
"""Yielding Event(route=R) sets ctx.route."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='out', route='next')
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'out'
assert child_ctx.route == 'next'
@pytest.mark.asyncio
async def test_ctx_route_set_directly():
"""Setting ctx.route directly is readable after run."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx.route = 'branch_a'
yield 'out'
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.route == 'branch_a'
# --- ctx.interrupt_ids ---
@pytest.mark.asyncio
async def test_interrupt_sets_ctx_interrupt_ids():
"""Yielding an interrupt event populates ctx.interrupt_ids."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.interrupt_ids == {'fc-1'}
assert child_ctx.output is None
@pytest.mark.asyncio
async def test_output_and_interrupt_coexist():
"""Output and interrupt can coexist across separate events."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'result'
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-1'
)
)
]
),
long_running_tool_ids={'fc-1'},
)
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'result'
assert child_ctx.interrupt_ids == {'fc-1'}
@pytest.mark.asyncio
async def test_duplicate_interrupt_ids_deduplicated():
"""Duplicate interrupt IDs are deduplicated (set semantics)."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(long_running_tool_ids={'fc-1', 'fc-2'})
yield Event(long_running_tool_ids={'fc-2', 'fc-3'})
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.interrupt_ids == {'fc-1', 'fc-2', 'fc-3'}
# --- Output delegation (use_as_output) ---
@pytest.mark.asyncio
async def test_delegated_output_not_enqueued():
"""When output is delegated, the output event is not enqueued."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx._output_delegated = True
yield 'delegated_value'
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'delegated_value'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 0
@pytest.mark.asyncio
async def test_delegated_ctx_output_not_emitted():
"""When output is delegated and set via ctx.output, no event emitted."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx._output_delegated = True
ctx.output = 'delegated_direct'
yield # noqa: unreachable
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'delegated_direct'
output_events = [e for e in events if e.output is not None]
assert len(output_events) == 0
@pytest.mark.asyncio
async def test_delegated_output_preserves_event_details():
"""When output is delegated, the event is enqueued but output is suppressed."""
from google.adk.events.event_actions import EventActions
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
ctx._output_delegated = True
yield Event(
output='delegated_value',
actions=EventActions(state_delta={'foo': 'bar'}),
content=types.Content(role='model', parts=[types.Part(text='hello')]),
)
parent_ctx, events = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'), parent_ctx=parent_ctx
).run()
assert child_ctx.output == 'delegated_value'
assert len(events) == 1
event = events[0]
assert event.output is None
assert event.actions.state_delta == {'foo': 'bar'}
assert event.content.parts[0].text == 'hello'
# =========================================================================
# Context as INPUT — resume state provided to NodeRunner at construction
# =========================================================================
@pytest.mark.asyncio
async def test_prior_output_carried_forward():
"""Prior output from a previous run is available on ctx.output."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
return
yield # noqa: unreachable
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
prior_output='cached_result',
).run()
assert child_ctx.output == 'cached_result'
@pytest.mark.asyncio
async def test_prior_interrupt_ids_carried_forward():
"""Prior interrupt IDs from a previous run are on ctx."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
return
yield # noqa: unreachable
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
prior_interrupt_ids={'fc-old'},
).run()
assert 'fc-old' in child_ctx.interrupt_ids
@pytest.mark.asyncio
async def test_prior_and_new_interrupt_ids_merged():
"""New interrupt IDs are merged with prior ones."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name='tool', args={}, id='fc-new'
)
)
]
),
long_running_tool_ids={'fc-new'},
)
parent_ctx, _ = _make_ctx()
child_ctx = await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
prior_interrupt_ids={'fc-old'},
).run()
assert child_ctx.interrupt_ids == {'fc-old', 'fc-new'}
# =========================================================================
# event_author — parent orchestrator overrides event author
# =========================================================================
@pytest.mark.asyncio
async def test_event_author_defaults_to_node_name():
"""Without event_author, events use the node's own name."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'result'
parent_ctx, events = _make_ctx()
await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()
assert events[0].author == 'my_node'
@pytest.mark.asyncio
async def test_event_author_overrides_node_name():
"""When parent sets event_author, events use that instead."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield 'result'
parent_ctx, events = _make_ctx()
parent_ctx.event_author = 'my_workflow'
await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()
assert events[0].author == 'my_workflow'
@pytest.mark.asyncio
async def test_event_author_overrides_preset_author():
"""event_author always wins, even over a pre-set event author."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(author='custom_author', output='result')
parent_ctx, events = _make_ctx()
parent_ctx.event_author = 'my_workflow'
await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()
assert events[0].author == 'my_workflow'
# =========================================================================
# Branch propagation tests
# =========================================================================
@pytest.mark.asyncio
async def test_override_branch_used_in_node_runner():
"""NodeRunner uses override_branch if provided."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result')
parent_ctx, events = _make_ctx()
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
override_branch='custom_branch',
).run()
assert events[0].branch == 'custom_branch'
@pytest.mark.asyncio
async def test_use_sub_branch_appends_segment_to_branch():
"""NodeRunner appends node_name@run_id to branch when use_sub_branch is True."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result')
parent_ctx, events = _make_ctx()
parent_ctx._invocation_context.branch = 'parent_branch'
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
use_sub_branch=True,
run_id='1',
).run()
assert events[0].branch == 'parent_branch.n@1'
@pytest.mark.asyncio
async def test_sequential_branch_propagation():
"""NodeRunner inherits parent branch when use_sub_branch is False."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result')
parent_ctx, events = _make_ctx()
parent_ctx._invocation_context.branch = 'parent_branch'
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
use_sub_branch=False,
).run()
assert events[0].branch == 'parent_branch'
@pytest.mark.asyncio
async def test_child_event_branch_does_not_mutate_parent_ic():
"""A child node altering its branch does not mutate the parent's shared InvocationContext branch."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
yield Event(output='result', branch='new_child_branch')
parent_ctx, events = _make_ctx()
parent_ctx._invocation_context.branch = 'parent_branch'
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
use_sub_branch=False,
).run()
assert events[0].branch == 'new_child_branch'
# The parent's branch must remain unchanged.
assert parent_ctx._invocation_context.branch == 'parent_branch'
@pytest.mark.asyncio
async def test_override_isolation_scope_used_in_node_runner():
"""NodeRunner sets isolation_scope on child context and enriches emitted events."""
class _Node(BaseNode):
async def _run_impl(self, *, ctx, node_input):
assert ctx.isolation_scope == 'task:fc-999'
yield Event(output='result')
parent_ctx, events = _make_ctx()
await NodeRunner(
node=_Node(name='n'),
parent_ctx=parent_ctx,
override_isolation_scope='task:fc-999',
).run()
assert events[0].isolation_scope == 'task:fc-999'