# 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 Runner(node=...). Verifies that Runner can execute standalone BaseNode instances, persist events to session, handle resume (HITL), and yield events correctly. """ from __future__ import annotations import asyncio from typing import Any from typing import AsyncGenerator from google.adk.agents.callback_context import CallbackContext from google.adk.agents.context import Context from google.adk.agents.invocation_context import InvocationContext from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.run_config import RunConfig from google.adk.apps.app import App from google.adk.events.event import Event from google.adk.plugins.base_plugin import BasePlugin 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._base_node import BaseNode from google.adk.workflow._base_node import START from google.adk.workflow._errors import DynamicNodeFailError from google.adk.workflow._workflow import Workflow from google.genai import types import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class _EchoNode(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: text = node_input.parts[0].text if node_input else 'empty' yield f'Echo: {text}' def _user_message(text: str = 'hello') -> types.Content: return types.Content(parts=[types.Part(text=text)], role='user') async def _run_node(node, message='hello'): """Run a BaseNode via Runner(node=...) and return (events, ss, session).""" ss = InMemorySessionService() runner = Runner(app_name='test', node=node, 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 _make_interrupt_event(fc_name='get_input', fc_id='fc-1'): """Create an interrupt Event with a long-running function call.""" return Event( content=types.Content( parts=[ types.Part( function_call=types.FunctionCall( name=fc_name, args={}, id=fc_id ) ) ] ), long_running_tool_ids={fc_id}, ) def _make_resume_message(fc_name='get_input', fc_id='fc-1', response=None): """Create a user message with a function response for resuming.""" return types.Content( parts=[ types.Part( function_response=types.FunctionResponse( name=fc_name, id=fc_id, response=response or {}, ) ) ], role='user', ) async def _run_two_turns(node, msg1_text, resume_msg): """Run a node for two turns: initial message then resume.""" ss = InMemorySessionService() runner = Runner(app_name='test', node=node, session_service=ss) session = await ss.create_session(app_name='test', user_id='u') msg1 = types.Content(parts=[types.Part(text=msg1_text)], role='user') events1 = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=msg1 ): events1.append(event) events2 = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=resume_msg ): events2.append(event) return events1, events2, runner, ss, session # --------------------------------------------------------------------------- # Basic execution # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_simple_node_output(): """Runner yields output from a simple BaseNode.""" events, _, _ = await _run_node(_EchoNode(name='echo'), message='hi') output_events = [e for e in events if e.output is not None] assert [e.output for e in output_events] == ['Echo: hi'] assert output_events[0].node_info.path == 'echo@1' @pytest.mark.asyncio async def test_intermediate_events_yielded(): """Runner yields intermediate events (e.g. state), not just output.""" class _Node(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield Event(state={'step': 'processing'}) yield 'final_result' events, _, _ = await _run_node(_Node(name='steps')) state_events = [e for e in events if e.actions.state_delta] assert len(state_events) >= 1 assert [e.output for e in events if e.output is not None] == ['final_result'] @pytest.mark.asyncio async def test_event_author_defaults_to_node_name(): """Events are attributed to the node's name by default.""" events, _, _ = await _run_node(_EchoNode(name='my_node'), message='hi') output_events = [e for e in events if e.output is not None] assert output_events[0].author == 'my_node' @pytest.mark.asyncio async def test_node_error_propagates(): """A node that raises propagates the exception to the caller.""" class _Node(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: raise RuntimeError('node failure') yield # pylint: disable=unreachable with pytest.raises(RuntimeError, match='node failure'): await _run_node(_Node(name='error')) @pytest.mark.asyncio async def test_node_yielding_none_produces_no_output(): """A node that yields None produces no output event.""" class _Node(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield None events, _, _ = await _run_node(_Node(name='nil')) assert [e.output for e in events if e.output is not None] == [] @pytest.mark.asyncio async def test_workflow_node_output(): """Runner drives a Workflow and yields its terminal output.""" def upper(node_input: str) -> str: return node_input.upper() wf = Workflow(name='wf', edges=[(START, upper)]) events, _, _ = await _run_node(wf, message='hi') output_events = [e for e in events if e.output == 'HI'] assert len(output_events) == 1 assert output_events[0].node_info.path == 'wf@1/upper@1' # --------------------------------------------------------------------------- # Session persistence # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_events_persisted_to_session(): """Non-partial events are persisted to the session.""" _, ss, session = await _run_node(_EchoNode(name='echo'), message='hi') updated = await ss.get_session( app_name='test', user_id='u', session_id=session.id ) session_outputs = [e.output for e in updated.events if e.output is not None] assert 'Echo: hi' in session_outputs @pytest.mark.asyncio async def test_multiple_invocations_accumulate_events(): """Each invocation appends events; session accumulates across runs.""" node = _EchoNode(name='echo') ss = InMemorySessionService() runner = Runner(app_name='test', node=node, session_service=ss) session = await ss.create_session(app_name='test', user_id='u') for msg_text in ['first', 'second', 'third']: async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=types.Content( parts=[types.Part(text=msg_text)], role='user' ), ): pass updated = await ss.get_session( app_name='test', user_id='u', session_id=session.id ) outputs = [e.output for e in updated.events if e.output is not None] assert outputs == ['Echo: first', 'Echo: second', 'Echo: third'] @pytest.mark.asyncio async def test_run_config_custom_metadata_stamps_user_event(): """The node path stamps the user event with run-level custom_metadata.""" ss = InMemorySessionService() runner = Runner( app_name='test', node=_EchoNode(name='echo'), session_service=ss ) session = await ss.create_session(app_name='test', user_id='u') async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=_user_message('hi'), run_config=RunConfig(custom_metadata={'turn_id': 't-1'}), ): pass updated = await ss.get_session( app_name='test', user_id='u', session_id=session.id ) user_event = next(e for e in updated.events if e.author == 'user') assert user_event.custom_metadata == {'turn_id': 't-1'} # --------------------------------------------------------------------------- # yield_user_message # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_yield_user_message_true(): """When yield_user_message=True, user event is yielded before node events.""" ss = InMemorySessionService() runner = Runner( app_name='test', node=_EchoNode(name='echo'), session_service=ss ) session = await ss.create_session(app_name='test', user_id='u') msg = types.Content(parts=[types.Part(text='hi')], role='user') events: list[Event] = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=msg, yield_user_message=True, ): events.append(event) user_events = [e for e in events if e.author == 'user'] assert len(user_events) == 1 assert user_events[0].content.parts[0].text == 'hi' assert events[0].author == 'user' @pytest.mark.asyncio async def test_yield_user_message_false_by_default(): """By default, user event is not yielded to the caller.""" events, _, _ = await _run_node(_EchoNode(name='echo'), message='hi') user_events = [e for e in events if e.author == 'user'] assert user_events == [] @pytest.mark.asyncio async def test_node_runner_applies_state_delta_before_base_node_runs(): """A BaseNode sees run_async state_delta as session state.""" class _StateReaderNode(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield f'state:{ctx.state["test_state"]}' session_service = InMemorySessionService() runner = Runner( app_name='test', node=_StateReaderNode(name='reader'), session_service=session_service, ) session = await session_service.create_session(app_name='test', user_id='u') events: list[Event] = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=_user_message(), state_delta={'test_state': 'must_change'}, ): events.append(event) updated = await session_service.get_session( app_name='test', user_id='u', session_id=session.id ) user_events = [event for event in updated.events if event.author == 'user'] assert [event.output for event in events if event.output is not None] == [ 'state:must_change' ] assert updated.state['test_state'] == 'must_change' assert user_events[0].actions.state_delta == {'test_state': 'must_change'} @pytest.mark.asyncio async def test_node_runner_yields_user_event_with_state_delta(): """yield_user_message=True yields the user event with state_delta.""" class _NoopNode(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield 'done' session_service = InMemorySessionService() runner = Runner( app_name='test', node=_NoopNode(name='noop'), session_service=session_service, ) session = await session_service.create_session(app_name='test', user_id='u') events: list[Event] = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=_user_message(), state_delta={'test_state': 'must_change'}, yield_user_message=True, ): events.append(event) assert events[0].author == 'user' assert events[0].actions.state_delta == {'test_state': 'must_change'} @pytest.mark.asyncio async def test_node_runner_applies_state_delta_before_llm_agent_runs(): """An LlmAgent callback sees run_async state_delta before model execution.""" captured_state_value = None def _before_agent_callback( callback_context: CallbackContext, ) -> types.Content: nonlocal captured_state_value captured_state_value = callback_context.state['test_state'] return types.Content( role='model', parts=[types.Part(text=f'state:{captured_state_value}')], ) session_service = InMemorySessionService() agent = LlmAgent( name='state_agent', before_agent_callback=_before_agent_callback, ) runner = Runner(app_name='test', agent=agent, session_service=session_service) session = await session_service.create_session(app_name='test', user_id='u') events: list[Event] = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=_user_message(), state_delta={'test_state': 'must_change'}, ): events.append(event) updated = await session_service.get_session( app_name='test', user_id='u', session_id=session.id ) user_events = [event for event in updated.events if event.author == 'user'] response_texts = [ part.text for event in events if event.content for part in event.content.parts if part.text ] assert captured_state_value == 'must_change' assert 'state:must_change' in response_texts assert user_events[0].actions.state_delta == {'test_state': 'must_change'} # --------------------------------------------------------------------------- # Resume (HITL) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_standalone_node_resume(): """A standalone node resumes with resume_inputs from function response.""" class _Node(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs: yield f'result: {ctx.resume_inputs["fc-1"]["value"]}' return yield _make_interrupt_event() events1, events2, _, _, _ = await _run_two_turns( _Node(name='standalone'), 'go', _make_resume_message(response={'value': 42}), ) assert any(e.long_running_tool_ids for e in events1) outputs = [e.output for e in events2 if e.output is not None] assert 'result: 42' in outputs @pytest.mark.asyncio async def test_resume_preserves_original_user_content(): """On resume, Runner passes the original text as node_input, not the FR.""" class _Node(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs: text = ( node_input.parts[0].text if node_input and hasattr(node_input, 'parts') else str(node_input) ) yield f'original:{text}' return yield _make_interrupt_event(fc_name='tool') events1, events2, _, _, _ = await _run_two_turns( _Node(name='node'), 'my original input', _make_resume_message(fc_name='tool', response={'v': 1}), ) outputs = [e.output for e in events2 if e.output is not None] assert 'original:my original input' in outputs @pytest.mark.asyncio async def test_resume_populates_invocation_user_content(): """On resume via a function response, ic.user_content is the original turn.""" seen: list[Any] = [] class _Node(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs: user_content = ctx.get_invocation_context().user_content seen.append(user_content.parts[0].text if user_content else None) yield 'resumed' return yield _make_interrupt_event(fc_name='tool') await _run_two_turns( _Node(name='node'), 'remember me', _make_resume_message(fc_name='tool', response={'v': 1}), ) assert seen == ['remember me'] @pytest.mark.asyncio async def test_resume_by_invocation_id_populates_user_content(): """Resuming by invocation_id alone recovers the original user_content.""" seen: list[Any] = [] class _Node(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: user_content = ctx.get_invocation_context().user_content seen.append(user_content.parts[0].text if user_content else None) yield _make_interrupt_event(fc_name='tool') ss = InMemorySessionService() runner = Runner(app_name='test', node=_Node(name='node'), session_service=ss) session = await ss.create_session(app_name='test', user_id='u') async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=types.Content( parts=[types.Part(text='original text')], role='user' ), ): pass updated = await ss.get_session( app_name='test', user_id='u', session_id=session.id ) invocation_id = updated.events[0].invocation_id async for _ in runner.run_async( user_id='u', session_id=session.id, invocation_id=invocation_id ): pass assert seen == ['original text', 'original text'] @pytest.mark.asyncio async def test_plain_text_does_not_trigger_resume(): """Sending plain text (no FR) starts fresh, does not enter resume path.""" node = _EchoNode(name='echo') ss = InMemorySessionService() runner = Runner(app_name='test', node=node, session_service=ss) session = await ss.create_session(app_name='test', user_id='u') # Run 1 async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=types.Content(parts=[types.Part(text='first')], role='user'), ): pass # Run 2: plain text — should start fresh events2: list[Event] = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=types.Content(parts=[types.Part(text='second')], role='user'), ): events2.append(event) outputs = [e.output for e in events2 if e.output is not None] assert outputs == ['Echo: second'] # --------------------------------------------------------------------------- # Resume validation # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_resume_raises_on_unmatched_fr(): """Runner raises when function response has no matching FC in session.""" ss = InMemorySessionService() runner = Runner( app_name='test', node=_EchoNode(name='echo'), session_service=ss ) session = await ss.create_session(app_name='test', user_id='u') msg = _make_resume_message(fc_name='unknown', fc_id='no-such-fc') with pytest.raises(ValueError, match='Function call not found'): async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=msg ): pass @pytest.mark.asyncio async def test_resume_raises_on_multi_invocation_fr(): """Runner raises when FRs resolve to different invocations.""" call_count = [0] class _InterruptNode(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: call_count[0] += 1 fc_id = f'fc-{call_count[0]}' yield _make_interrupt_event(fc_name='tool', fc_id=fc_id) 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') # Run 1: interrupts with fc-1 async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=types.Content(parts=[types.Part(text='go')], role='user'), ): pass # Run 2: interrupts with fc-2 (different invocation) async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=types.Content( parts=[types.Part(text='go again')], role='user' ), ): pass # Run 3: send FRs for both fc-1 and fc-2 (different invocations) msg3 = types.Content( parts=[ types.Part( function_response=types.FunctionResponse( name='tool', id='fc-1', response={'r': 1} ) ), types.Part( function_response=types.FunctionResponse( name='tool', id='fc-2', response={'r': 2} ) ), ], role='user', ) with pytest.raises(ValueError, match='resolve to multiple invocations'): async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=msg3 ): pass @pytest.mark.asyncio async def test_mixed_fr_and_text_raises(): """Message with both function responses and text is rejected.""" ss = InMemorySessionService() runner = Runner( app_name='test', node=_EchoNode(name='echo'), session_service=ss ) session = await ss.create_session(app_name='test', user_id='u') msg = types.Content( parts=[ types.Part(text='some text'), types.Part( function_response=types.FunctionResponse( name='tool', id='fc-1', response={'v': 1} ) ), ], role='user', ) with pytest.raises(ValueError, match='cannot contain both'): async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=msg ): pass # --------------------------------------------------------------------------- # Default scheduler & ctx.create_task cleanup # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_run_node_works_without_workflow(): """ctx.run_node() works in a standalone BaseNode (default scheduler).""" class _ChildNode(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield f'child got: {node_input}' class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: result = await ctx.run_node(_ChildNode(name='child'), 'hello') yield f'parent got: {result}' events, _, _ = await _run_node(_ParentNode(name='parent'), message='go') outputs = [e.output for e in events if e.output is not None] assert 'parent got: child got: hello' in outputs @pytest.mark.asyncio async def test_run_node_propagates_error_without_workflow(): """A standalone node propagates errors raised by its dynamically executed child nodes.""" class _ChildNode(BaseNode): """A helper child node that fails.""" async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: raise ValueError('child failure') yield class _ParentNode(BaseNode): """A helper parent node that calls the child.""" rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: try: await ctx.run_node(_ChildNode(name='child'), 'hello') except DynamicNodeFailError as e: yield f'parent caught: {type(e).__name__}' raise yield 'parent got success' # Arrange ss = InMemorySessionService() runner = Runner( app_name='test', node=_ParentNode(name='parent'), 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 = [] # Act # The runner unwraps DynamicNodeFailError to the original ValueError with pytest.raises(ValueError, match='child failure'): async for event in runner.run_async( user_id='u', session_id=session.id, new_message=msg ): events.append(event) # Assert # Verify that parent node caught DynamicNodeFailError before propagating parent_caught_events = [ e.output for e in events if isinstance(e.output, str) and 'parent caught' in e.output ] assert parent_caught_events == ['parent caught: DynamicNodeFailError'] @pytest.mark.asyncio async def test_run_node_use_as_output_attributes_child_output_to_parent(): """Child output with use_as_output=True is attributed to the parent node.""" class _ChildNode(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield 'child result' class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: result = await ctx.run_node( _ChildNode(name='child'), 'hello', use_as_output=True ) yield f'parent got: {result}' events, _, _ = await _run_node(_ParentNode(name='parent'), message='go') # The child's output event should list the parent's path in output_for. # With use_as_output=True, the parent's own yield is suppressed — # only the child's output (attributed to the parent) is emitted. child_output = next(e for e in events if e.output == 'child result') assert 'parent@1/child@1' in child_output.node_info.path assert any( 'parent' in p and 'child' not in p for p in child_output.node_info.output_for ) @pytest.mark.asyncio async def test_run_node_wait_for_output(): """Dynamic node with wait_for_output=True re-runs on resume if no output. Setup: ParentNode calls MockNode (wait_for_output=True). MockNode yields no output on first call, output on second call. Act: - Turn 1: Run parent. Child yields no output and waits. Parent interrupts. - Turn 2: Resume parent. Child runs again and produces output. Assert: - Parent receives child's output in Turn 2. """ # Arrange calls = [0] class _MockNode(BaseNode): wait_for_output: bool = True rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: calls[0] += 1 if calls[0] == 2: yield 'success' class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: res = await ctx.run_node(_MockNode(name='child')) if res == 'success': yield 'completed' return yield _make_interrupt_event(fc_name='ask', fc_id='fc-1') # Act events1, events2, _, _, _ = await _run_two_turns( _ParentNode(name='parent'), 'go', _make_resume_message(fc_name='ask', fc_id='fc-1', response={}), ) # Assert outputs = [e.output for e in events2 if e.output is not None] assert 'completed' in outputs # --------------------------------------------------------------------------- # DefaultNodeScheduler — dynamic child resume via ctx.run_node() # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_run_node_child_resume_via_default_scheduler(): """Completed children are cached on resume; interrupted child re-runs. Setup: ParentNode calls ChildA and ChildB in sequence. ChildA completes on first run. ChildB yields an interrupt on first run. Act: - Turn 1: Run parent. ChildA completes, ChildB interrupts. - Turn 2: Resume with response for ChildB's interrupt. Assert: - Turn 1: ChildB's interrupt is propagated. - Turn 2: Parent completes with combined output using cached ChildA result. """ class _ChildA(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield 'child_a_output' class _ChildB(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-1' in ctx.resume_inputs: yield f'resumed: {ctx.resume_inputs["fc-1"]["answer"]}' return yield _make_interrupt_event(fc_name='ask', fc_id='fc-1') class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: a = await ctx.run_node(_ChildA(name='a'), 'input_a') b = await ctx.run_node(_ChildB(name='b'), 'input_b') yield f'{a} + {b}' events1, events2, _, _, _ = await _run_two_turns( _ParentNode(name='parent'), 'go', _make_resume_message( fc_name='ask', fc_id='fc-1', response={'answer': 42} ), ) assert any(e.long_running_tool_ids for e in events1) outputs = [e.output for e in events2 if e.output is not None] assert 'child_a_output + resumed: 42' in outputs @pytest.mark.asyncio async def test_run_node_default_scheduler_caches_by_call_count(): """Only interrupted children re-run; completed children are skipped. Setup: Parent calls ChildA, ChildB, and ChildC in sequence. A and B complete on first run. C yields an interrupt on first run. Act: - Turn 1: Run parent. A & B complete, C interrupts. - Turn 2: Resume with response for C's interrupt. Assert: - Turn 1: C's interrupt is propagated. - Turn 2: Parent completes. Call counts verify A and B were not re-run on resume. """ call_counts = {'a': 0, 'b': 0, 'c': 0} class _CountingChild(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: call_counts[self.name] += 1 if self.name == 'c': if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs: yield 'c_resumed' return yield _make_interrupt_event(fc_name='tool', fc_id='fc-1') return yield f'{self.name}_out' class _Parent(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: a = await ctx.run_node(_CountingChild(name='a'), 'x') b = await ctx.run_node(_CountingChild(name='b'), 'y') c = await ctx.run_node(_CountingChild(name='c'), 'z') yield f'{a},{b},{c}' events1, events2, _, _, _ = await _run_two_turns( _Parent(name='p'), 'go', _make_resume_message(fc_name='tool', fc_id='fc-1', response={}), ) assert any(e.long_running_tool_ids for e in events1) outputs = [e.output for e in events2 if e.output is not None] assert 'a_out,b_out,c_resumed' in outputs assert call_counts == {'a': 1, 'b': 1, 'c': 2} @pytest.mark.asyncio async def test_run_node_use_as_output_with_resume(): """use_as_output child resumes correctly; child output is attributed to parent.""" class _Child(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-1' in ctx.resume_inputs: yield f'approved: {ctx.resume_inputs["fc-1"]["ok"]}' return yield _make_interrupt_event(fc_name='approve', fc_id='fc-1') class _Parent(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: result = await ctx.run_node( _Child(name='child'), 'data', use_as_output=True ) yield f'parent saw: {result}' events1, events2, _, _, _ = await _run_two_turns( _Parent(name='parent'), 'go', _make_resume_message( fc_name='approve', fc_id='fc-1', response={'ok': True} ), ) assert any(e.long_running_tool_ids for e in events1) outputs = [e.output for e in events2 if e.output is not None] assert any('approved: True' in o for o in outputs) @pytest.mark.asyncio async def test_run_node_nested_ctx_run_node_resume(): """Nested ctx.run_node(): outer → middle → inner; inner interrupts and resumes.""" call_counts = {'outer': 0, 'middle': 0, 'inner': 0} class _Inner(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: call_counts['inner'] += 1 if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs: yield f'inner_resumed:{ctx.resume_inputs["fc-1"]["v"]}' return yield _make_interrupt_event(fc_name='ask', fc_id='fc-1') class _Middle(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: call_counts['middle'] += 1 inner_out = await ctx.run_node(_Inner(name='inner'), 'go') yield f'middle({inner_out})' class _Outer(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: call_counts['outer'] += 1 mid_out = await ctx.run_node(_Middle(name='middle'), 'start') yield f'outer({mid_out})' events1, events2, _, _, _ = await _run_two_turns( _Outer(name='top'), 'go', _make_resume_message(fc_name='ask', fc_id='fc-1', response={'v': 99}), ) # Turn 1: inner interrupts, propagated through middle and outer. assert any(e.long_running_tool_ids for e in events1) # Turn 2: inner resumes, middle and outer produce final output. outputs = [e.output for e in events2 if e.output is not None] assert 'outer(middle(inner_resumed:99))' in outputs # Outer and middle re-run on resume; inner runs twice (interrupt + resume). assert call_counts == {'outer': 2, 'middle': 2, 'inner': 2} @pytest.mark.asyncio async def test_run_node_use_as_output_nested_delegation(): """Nested use_as_output delegates all the way up with run_ids.""" class _Inner(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield 'inner_val' class _Middle(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: await ctx.run_node(_Inner(name='inner'), 'go', use_as_output=True) if False: yield class _Outer(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: await ctx.run_node(_Middle(name='middle'), 'start', use_as_output=True) if False: yield # When events, _, _ = await _run_node(_Outer(name='outer'), message='go') # Then inner_output = next(e for e in events if e.output == 'inner_val') output_for = inner_output.node_info.output_for paths = output_for assert len(output_for) == 3 assert any('middle' in p for p in paths) assert any('outer' in p for p in paths) assert any('inner' in p for p in paths) for p in output_for: assert '@' in p @pytest.mark.asyncio async def test_run_node_auto_increments_run_id(): """ctx.run_node() auto-increments run_id for the same node name.""" class _ChildNode(BaseNode): async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield f'run:{ctx.run_id}' class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: r1 = await ctx.run_node(_ChildNode(name='child')) r2 = await ctx.run_node(_ChildNode(name='child')) yield f'{r1},{r2}' events, _, _ = await _run_node(_ParentNode(name='parent'), message='go') outputs = [e.output for e in events if e.output is not None] assert 'run:1,run:2' in outputs @pytest.mark.asyncio async def test_run_node_parallel_interrupts(): """Parallel ctx.run_node() calls that both interrupt and then resume. Setup: ParentNode calls two instances of InterruptChild in parallel. Both children yield interrupts on the first turn with unique IDs. Act: - Turn 1: Run parent. Both children interrupt. - Turn 2: Resume with responses for both children in a single message. Assert: - Turn 1: Two unique interrupts are yielded. - Turn 2: Both children resume, find their inputs, and complete. Parent produces combined output. """ class _InterruptChild(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: target_id = f'fc-{ctx.run_id}' if ctx.resume_inputs and target_id in ctx.resume_inputs: yield f"resumed:{ctx.resume_inputs[target_id]['v']}" return yield _make_interrupt_event(fc_name='ask', fc_id=target_id) class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: t1 = ctx.run_node(_InterruptChild(name='child')) t2 = ctx.run_node(_InterruptChild(name='child')) r1, r2 = await asyncio.gather(t1, t2) yield f'{r1},{r2}' events1, ss, session = await _run_node( _ParentNode(name='parent'), message='go' ) interrupts = [e for e in events1 if e.long_running_tool_ids] assert len(interrupts) == 2 fc_ids = [] for e in interrupts: fc_ids.extend(e.long_running_tool_ids) assert len(set(fc_ids)) == 2 # Should be unique resume_msg = types.Content( parts=[ types.Part( function_response=types.FunctionResponse( name='ask', id=fc_ids[0], response={'v': 10} ) ), types.Part( function_response=types.FunctionResponse( name='ask', id=fc_ids[1], response={'v': 20} ) ), ], role='user', ) events2 = [] runner = Runner( app_name='test', node=_ParentNode(name='parent'), session_service=ss ) async for event in runner.run_async( user_id='u', session_id=session.id, new_message=resume_msg ): events2.append(event) outputs = [e.output for e in events2 if e.output is not None] assert ( 'resumed:10,resumed:20' in outputs or 'resumed:20,resumed:10' in outputs ) @pytest.mark.asyncio async def test_run_node_parallel_deterministic_ids(): """Parallel ctx.run_node() calls within the same process receive deterministic IDs. Setup: ParentNode calls two instances of ChildNode in parallel. Act: Run parent. Assert: Outputs confirm both children received distinct, auto-incremented run_ids (1 and 2). """ class _ChildNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield f'run:{ctx.run_id}' class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: t1 = ctx.run_node(_ChildNode(name='child')) t2 = ctx.run_node(_ChildNode(name='child')) r1, r2 = await asyncio.gather(t1, t2) yield f'{r1},{r2}' events, _, _ = await _run_node(_ParentNode(name='parent'), message='go') outputs = [e.output for e in events if e.output is not None] assert 'run:1,run:2' in outputs or 'run:2,run:1' in outputs @pytest.mark.asyncio async def test_run_node_custom_numeric_id_raises_value_error(): """Passing a completely numeric explicit run_id is immediately rejected. Setup: ParentNode calls ChildNode with a custom numeric run_id="5". Act: Run parent. Assert: ValueError is raised with a message about collision prevention. """ class _ChildNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield f'run:{ctx.run_id}' class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: await ctx.run_node(_ChildNode(name='child'), run_id='5') yield 'should not reach' with pytest.raises(ValueError, match='must contain non-numeric characters'): await _run_node(_ParentNode(name='parent'), message='go') @pytest.mark.asyncio async def test_run_node_custom_non_numeric_id_accepted(): """Passing an explicit run_id containing non-numeric characters is safely accepted. Setup: ParentNode calls ChildNode with a custom run_id="user-123". Act: Run parent. Assert: Output reflects the custom run_id without errors. """ class _ChildNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: yield f'run:{ctx.run_id}' class _ParentNode(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: r1 = await ctx.run_node(_ChildNode(name='child'), run_id='user-123') yield r1 events, _, _ = await _run_node(_ParentNode(name='parent'), message='go') outputs = [e.output for e in events if e.output is not None] assert 'run:user-123' in outputs @pytest.mark.asyncio async def test_run_node_isolation_across_invocations(): """Verify that a new invocation ignores events from a previous invocation for dynamic nodes.""" call_counts = {'child': 0} @node(name='child') async def counting_child(ctx: Context, node_input: Any): call_counts['child'] += 1 yield f"child_out_{call_counts['child']}" class _Parent(BaseNode): rerun_on_resume: bool = True async def _run_impl( self, *, ctx: Context, node_input: Any ) -> AsyncGenerator[Any, None]: res = await ctx.run_node(counting_child, 'x') yield res ss = InMemorySessionService() runner = Runner(app_name='test', node=_Parent(name='p'), session_service=ss) session = await ss.create_session(app_name='test', user_id='u') # Invocation 1 msg1 = types.Content(parts=[types.Part(text='go 1')], role='user') events1 = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=msg1 ): events1.append(event) assert call_counts['child'] == 1 outputs1 = [e.output for e in events1 if e.output is not None] assert 'child_out_1' in outputs1 # Invocation 2 (New invocation in SAME session) msg2 = types.Content(parts=[types.Part(text='go 2')], role='user') events2 = [] async for event in runner.run_async( user_id='u', session_id=session.id, new_message=msg2 ): events2.append(event) # If isolation works, CounterNode should run AGAIN! assert call_counts['child'] == 2 outputs2 = [e.output for e in events2 if e.output is not None] assert 'child_out_2' in outputs2 # --------------------------------------------------------------------------- # Plugin lifecycle on the node path # --------------------------------------------------------------------------- class _AfterRunCountingPlugin(BasePlugin): """Counts how many times after_run_callback is dispatched.""" def __init__(self) -> None: super().__init__(name='after_run_counter') self.after_run_calls = 0 async def after_run_callback( self, *, invocation_context: InvocationContext ) -> None: self.after_run_calls += 1 @pytest.mark.asyncio async def test_after_run_callback_dispatched_on_workflow_root(): """Runner dispatches plugin after_run_callback on a Workflow(BaseNode) root.""" def terminal(node_input: str) -> str: return node_input.upper() plugin = _AfterRunCountingPlugin() workflow = Workflow(name='wf', edges=[(START, terminal)]) app = App(name='test', root_agent=workflow, plugins=[plugin]) ss = InMemorySessionService() runner = Runner(app=app, session_service=ss) session = await ss.create_session(app_name='test', user_id='u') async for _ in runner.run_async( user_id='u', session_id=session.id, new_message=_user_message('hi') ): pass assert plugin.after_run_calls == 1