# 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. """Testings for the FunctionNode.""" import copy from typing import Any from typing import AsyncGenerator from typing import Generator from typing import Optional from typing import Union from unittest import mock from google.adk.agents.context import Context from google.adk.apps.app import App from google.adk.apps.app import ResumabilityConfig from google.adk.events._node_path_builder import _NodePathBuilder from google.adk.events.event import Event from google.adk.events.event import Event as AdkEvent from google.adk.events.request_input import RequestInput from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from google.adk.workflow import FunctionNode from google.adk.workflow import START from google.adk.workflow._node_status import NodeStatus from google.adk.workflow._workflow import Workflow from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response from google.adk.workflow.utils._workflow_hitl_utils import get_request_input_interrupt_ids from google.genai import types from pydantic import BaseModel import pytest from .. import testing_utils from .workflow_testing_utils import create_parent_invocation_context from .workflow_testing_utils import get_output_events from .workflow_testing_utils import get_request_input_events from .workflow_testing_utils import simplify_events_with_node from .workflow_testing_utils import simplify_events_with_node_and_agent_state ANY = mock.ANY from .workflow_testing_utils import run_workflow @pytest.mark.asyncio async def test_various_function_nodes(request: pytest.FixtureRequest): """Tests that Workflow can run with various function nodes.""" async def async_gen_func(ctx: Context) -> AsyncGenerator[Any, None]: yield Event( output='Hello from AsyncGen', ) def sync_func_out(ctx: Context) -> str: return 'Hello from SyncFunc' async def async_func_out(ctx: Context) -> str: return 'Hello from AsyncFunc' def sync_func_no_out(ctx: Context) -> None: return None async def async_func_no_out(ctx: Context) -> None: return None def sync_gen_func( ctx: Context, ) -> Generator[Any, None, None]: yield Event( output='Hello from SyncGen', ) async def async_gen_func_raw_output( ctx: Context, ) -> AsyncGenerator[Any, None]: yield 'Hello from AsyncGenRawOutput' def sync_gen_func_raw_output( ctx: Context, ) -> Generator[Any, None, None]: yield 'Hello from SyncGenRawOutput' agent = Workflow( name='test_workflow_agent_various_function_nodes', edges=[ (START, async_gen_func), (async_gen_func, sync_func_out), (sync_func_out, async_func_out), (async_func_out, sync_func_no_out), (sync_func_no_out, async_func_no_out), (async_func_no_out, sync_gen_func), (sync_gen_func, async_gen_func_raw_output), (async_gen_func_raw_output, sync_gen_func_raw_output), ], ) app_instance = App(name=request.function.__name__, root_agent=agent) runner = testing_utils.InMemoryRunner(app=app_instance) events = await runner.run_async(testing_utils.get_user_content('start')) # Functions with no output (sync_func_no_out, async_func_no_out) # will not produce events. assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_various_function_nodes@1/async_gen_func@1', {'output': 'Hello from AsyncGen'}, ), ( 'test_workflow_agent_various_function_nodes@1/sync_func_out@1', {'output': 'Hello from SyncFunc'}, ), ( 'test_workflow_agent_various_function_nodes@1/async_func_out@1', {'output': 'Hello from AsyncFunc'}, ), ( 'test_workflow_agent_various_function_nodes@1/sync_gen_func@1', {'output': 'Hello from SyncGen'}, ), ( 'test_workflow_agent_various_function_nodes@1/async_gen_func_raw_output@1', { 'output': 'Hello from AsyncGenRawOutput', }, ), ( 'test_workflow_agent_various_function_nodes@1/sync_gen_func_raw_output@1', { 'output': 'Hello from SyncGenRawOutput', }, ), ] @pytest.mark.asyncio async def test_function_node_state_injection(request: pytest.FixtureRequest): """Tests that FunctionNode can inject parameters from workflow state.""" async def set_state_node_fn( ctx: Context, ) -> AsyncGenerator[Any, None]: yield Event( state={'param1': 'value1'}, ) def check_state_node_fn(param1: str, param2: str = 'default2') -> str: return f'param1={param1}, param2={param2}' agent = Workflow( name='test_workflow_agent_state_injection', edges=[ (START, set_state_node_fn), (set_state_node_fn, check_state_node_fn), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_state_injection@1/check_state_node_fn@1', { 'output': 'param1=value1, param2=default2', }, ), ] @pytest.mark.asyncio async def test_function_node_state_injection_missing_param( request: pytest.FixtureRequest, ): """Tests that FunctionNode raises error for missing param.""" def check_state_node_fn(param1: str) -> str: return f'param1={param1}' agent = Workflow( name='test_workflow_agent_state_injection_missing', edges=[ (START, check_state_node_fn), ], ) with pytest.raises(ValueError, match='Missing value for parameter "param1"'): await run_workflow(agent) @pytest.mark.asyncio async def test_function_node_type_checking( request: pytest.FixtureRequest, ): """Tests that FunctionNode performs type checking.""" async def set_state_node_fn( ctx: Context, ) -> AsyncGenerator[Any, None]: yield Event( state={'p1': 'a string'}, ) def check_type_node_fn(p1: int) -> str: return f'p1={p1}' agent = Workflow( name='test_type_checking', edges=[ (START, set_state_node_fn), (set_state_node_fn, check_type_node_fn), ], ) with pytest.raises(ValueError): await run_workflow(agent) @pytest.mark.asyncio async def test_function_node_input_injection(request: pytest.FixtureRequest): """Tests that FunctionNode can inject parameters from node_input.""" def node1_fn() -> dict[str, Any]: return {'p1': 'value1_from_node_input', 'p2': 100} def node2_fn(node_input: dict[str, Any]) -> str: return f"p1={node_input['p1']}, p2={node_input['p2']}" agent = Workflow( name='test_workflow_agent_input_injection_dict', edges=[ (START, node1_fn), (node1_fn, node2_fn), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_input_injection_dict@1/node1_fn@1', { 'output': {'p1': 'value1_from_node_input', 'p2': 100}, }, ), ( 'test_workflow_agent_input_injection_dict@1/node2_fn@1', { 'output': 'p1=value1_from_node_input, p2=100', }, ), ] class MyModel(BaseModel): p1: str p2: int @pytest.mark.asyncio async def test_function_node_input_injection_pydantic( request: pytest.FixtureRequest, ): """Tests that FunctionNode can inject dict as pydantic model into node_input.""" def node1_fn() -> dict[str, Any]: return {'p1': 'value1_from_node_input', 'p2': 100} def node2_fn(node_input: MyModel) -> str: return f'p1={node_input.p1}, p2={node_input.p2}' agent = Workflow( name='test_workflow_agent_input_injection_pydantic', edges=[ (START, node1_fn), (node1_fn, node2_fn), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_input_injection_pydantic@1/node1_fn@1', { 'output': {'p1': 'value1_from_node_input', 'p2': 100}, }, ), ( 'test_workflow_agent_input_injection_pydantic@1/node2_fn@1', { 'output': 'p1=value1_from_node_input, p2=100', }, ), ] @pytest.mark.asyncio async def test_function_node_input_list_wrong_type( request: pytest.FixtureRequest, ): """Tests that FunctionNode raises TypeError for list[T] with non-list input.""" def node1_fn() -> int: return 123 def node2_fn(node_input: list[MyModel]) -> str: return f'p1={node_input[0].p1}' agent = Workflow( name='test_workflow_agent_input_list_wrong_type', edges=[ (START, node1_fn), (node1_fn, node2_fn), ], ) with pytest.raises(ValueError): await run_workflow(agent) @pytest.mark.asyncio async def test_function_node_input_list_no_item_type( request: pytest.FixtureRequest, ): """Tests that FunctionNode handles list without item type.""" def node1_fn() -> list[int]: return [1, 2] def node2_fn(node_input: list) -> str: return f'list={node_input}' agent = Workflow( name='test_workflow_agent_input_list_no_item_type', edges=[ (START, node1_fn), (node1_fn, node2_fn), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_input_list_no_item_type@1/node1_fn@1', { 'output': [1, 2], }, ), ( 'test_workflow_agent_input_list_no_item_type@1/node2_fn@1', { 'output': 'list=[1, 2]', }, ), ] @pytest.mark.asyncio async def test_function_node_input_and_state_injection( request: pytest.FixtureRequest, ): """Tests parameter injection from node_input and state.""" async def nodea_fn(ctx: Context) -> AsyncGenerator[Any, None]: yield Event( state={'p_state': 'value_from_state'}, ) yield 'value_A' def nodeb_fn( node_input: str, p_state: str, p_default: str = 'default2' ) -> str: return f'node_input={node_input}, p_state={p_state}, p_default={p_default}' agent = Workflow( name='test_node_param_injection_single_and_state', edges=[ (START, nodea_fn), (nodea_fn, nodeb_fn), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_node_param_injection_single_and_state@1/nodea_fn@1', {'output': 'value_A'}, ), ( 'test_node_param_injection_single_and_state@1/nodeb_fn@1', { 'output': ( 'node_input=value_A, p_state=value_from_state,' ' p_default=default2' ), }, ), ] @pytest.mark.asyncio async def test_function_node_state_injection_pydantic( request: pytest.FixtureRequest, ): """Tests that FunctionNode can inject dict from state as pydantic model.""" async def node1_fn(ctx: Context) -> AsyncGenerator[Any, None]: yield Event( state={'my_model': {'p1': 'value1_from_state', 'p2': 200}}, ) def node2_fn(my_model: MyModel) -> str: return f'p1={my_model.p1}, p2={my_model.p2}' agent = Workflow( name='test_workflow_agent_state_injection_pydantic', edges=[ (START, node1_fn), (node1_fn, node2_fn), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_workflow_agent_state_injection_pydantic@1/node2_fn@1', { 'output': 'p1=value1_from_state, p2=200', }, ), ] @pytest.mark.asyncio async def test_function_node_hitl(request: pytest.FixtureRequest): """Tests that FunctionNode can trigger HITL. Setup: Workflow with request_input_fn -> process_input_fn. request_input_fn yields RequestInput. Act: - Run 1: start workflow, request_input_fn yields RequestInput. - Run 2: resume with user response. Assert: - Run 1: returns RequestInput event with valid ID. - Run 2: process_input_fn receives input and completes. """ # Given: A workflow where the first node requests input def request_input_fn() -> Generator[Any, None, None]: yield RequestInput(message='Provide input') def process_input_fn(node_input: dict[str, Any]) -> str: return f"received: {node_input['text']}" agent = Workflow( name='test_workflow_agent_hitl', edges=[ (START, request_input_fn), (request_input_fn, process_input_fn), ], ) app = App( name=request.function.__name__, root_agent=agent, resumability_config=ResumabilityConfig(is_resumable=True), ) runner = testing_utils.InMemoryRunner(app=app) # When: Starting the workflow user_event = testing_utils.get_user_content('start workflow') events1 = await runner.run_async(user_event) # Then: It should yield a RequestInput event with a valid ID req_events = get_request_input_events(events1) assert len(req_events) == 1 interrupt_id = get_request_input_interrupt_ids(req_events[0])[0] invocation_id = events1[0].invocation_id # Verify that the FunctionCall object actually has the id set assert req_events[0].content.parts[0].function_call.id == interrupt_id # When: Resuming with user response user_input = create_request_input_response( interrupt_id, {'text': 'Hello from user'} ) events2 = await runner.run_async( new_message=testing_utils.UserContent(user_input), invocation_id=invocation_id, ) # Then: It should complete and pass output to the next node data_events = get_output_events(events2, output='received: Hello from user') assert len(data_events) == 1 @pytest.mark.asyncio async def test_function_node_adk_events(request: pytest.FixtureRequest): """Tests that FunctionNode can emit ADK events.""" def adk_events_fn() -> Generator[Any, None, None]: yield AdkEvent( author='some_agent', content=types.Content(parts=[{'text': 'event 1'}]) ) yield AdkEvent( author='some_agent', content=types.Content(parts=[{'text': 'event 2'}]) ) agent = Workflow( name='test_workflow_agent_adk_events', edges=[ (START, adk_events_fn), ], ) events, _, _ = await run_workflow(agent) assert len(events) == 2 assert isinstance(events[0], AdkEvent) assert events[0].content.parts[0].text == 'event 1' assert isinstance(events[1], AdkEvent) assert events[1].content.parts[0].text == 'event 2' @pytest.mark.asyncio async def test_function_node_list_conversion_pydantic( request: pytest.FixtureRequest, ): """Tests that FunctionNode correctly converts list[dict] to list[BaseModel].""" class Section(BaseModel): section_name: str content: str async def upstream_func(ctx: Context) -> list[dict[str, str]]: return [ {'section_name': 's1', 'content': 'c1'}, {'section_name': 's2', 'content': 'c2'}, ] received_input = None async def aggregate(node_input: list[Section]) -> str: nonlocal received_input received_input = node_input return 'Done' agent = Workflow( name='test_function_node_list_conversion_pydantic', edges=[ (START, upstream_func), (upstream_func, aggregate), ], ) unused_events, _, _ = await run_workflow(agent) # Check if received_input contains Section objects assert isinstance(received_input, list) assert len(received_input) == 2 assert isinstance(received_input[0], Section) assert received_input[0].section_name == 's1' @pytest.mark.asyncio async def test_function_node_dict_conversion_pydantic( request: pytest.FixtureRequest, ): """Tests that FunctionNode correctly converts dict[str, dict] to dict[str, BaseModel].""" class Section(BaseModel): section_name: str content: str async def upstream_func(ctx: Context) -> dict[str, dict[str, str]]: return { 'one': {'section_name': 's1', 'content': 'c1'}, 'two': {'section_name': 's2', 'content': 'c2'}, } received_input = None async def aggregate(node_input: dict[str, Section]) -> str: nonlocal received_input received_input = node_input return 'Done' agent = Workflow( name='test_function_node_dict_conversion_pydantic', edges=[ (START, upstream_func), (upstream_func, aggregate), ], ) unused_events, _, _ = await run_workflow(agent) # Check if received_input contains Section objects assert isinstance(received_input, dict) assert len(received_input) == 2 assert isinstance(received_input['one'], Section) assert received_input['one'].section_name == 's1' assert isinstance(received_input['two'], Section) assert received_input['two'].content == 'c2' @pytest.mark.asyncio async def test_function_node_no_data_returns_none( request: pytest.FixtureRequest, ): """Tests that FunctionNode returns Event with output=None if function returns only control event.""" def func_no_data() -> Event: return Event(route='some_route') agent = Workflow( name='test_function_node_no_data_returns_none', edges=[ (START, func_no_data), ], ) events, _, _ = await run_workflow(agent) assert len(events) == 1 assert events[0].output is None assert events[0].actions.route == 'some_route' @pytest.mark.asyncio async def test_function_node_yield_content( request: pytest.FixtureRequest, ): """Tests that yielding types.Content sets output=None and content=Content.""" def func_yield_content() -> Generator[Any, None, None]: yield types.Content(parts=[types.Part(text='some content')]) agent = Workflow( name='test_function_node_yield_content', edges=[ (START, func_yield_content), ], ) events, _, _ = await run_workflow(agent) assert len(events) == 1 assert events[0].output is None assert events[0].content is not None assert events[0].content.parts[0].text == 'some content' @pytest.mark.asyncio async def test_function_node_yield_event_with_content( request: pytest.FixtureRequest, ): """Tests that yielding Event(content=...) retains output=None and content=... .""" def func_yield_event_with_content() -> Generator[Any, None, None]: yield Event(content=types.Content(parts=[types.Part(text='some content')])) agent = Workflow( name='test_function_node_yield_event_with_content', edges=[ (START, func_yield_event_with_content), ], ) events, _, _ = await run_workflow(agent) assert len(events) == 1 assert events[0].output is None assert events[0].content is not None assert events[0].content.parts[0].text == 'some content' @pytest.mark.asyncio async def test_content_to_str_auto_conversion( request: pytest.FixtureRequest, ): """Tests that Content is auto-converted to str when function expects str.""" received_inputs = [] def record_input(node_input: str) -> str: received_inputs.append(node_input) return f'Hello, {node_input}!' agent = Workflow( name='test_content_to_str', edges=[ (START, record_input), ], ) app = App( name=request.function.__name__, root_agent=agent, ) runner = testing_utils.InMemoryRunner(app=app) user_event = testing_utils.get_user_content('start workflow') await runner.run_async(user_event) assert len(received_inputs) == 1 assert isinstance(received_inputs[0], str) assert received_inputs[0] == 'start workflow' @pytest.mark.asyncio async def test_content_to_str_multi_part( request: pytest.FixtureRequest, ): """Tests Content with multiple text parts is concatenated.""" received_inputs = [] def record_input(node_input: str) -> str: received_inputs.append(node_input) return node_input agent = Workflow( name='test_content_to_str_multi_part', edges=[ (START, record_input), ], ) app = App( name=request.function.__name__, root_agent=agent, ) runner = testing_utils.InMemoryRunner(app=app) user_event = testing_utils.get_user_content( types.Content( parts=[ types.Part(text='Hello '), types.Part(text='World'), ], role='user', ) ) await runner.run_async(user_event) assert len(received_inputs) == 1 assert received_inputs[0] == 'Hello World' @pytest.mark.asyncio async def test_content_to_str_warns_on_non_text( request: pytest.FixtureRequest, caplog, ): """Tests that non-text parts produce a warning during conversion.""" import logging received_inputs = [] def record_input(node_input: str) -> str: received_inputs.append(node_input) return node_input agent = Workflow( name='test_content_to_str_warns', edges=[ (START, record_input), ], ) app = App( name=request.function.__name__, root_agent=agent, ) runner = testing_utils.InMemoryRunner(app=app) user_event = testing_utils.get_user_content( types.Content( parts=[ types.Part(text='Hello'), types.Part( inline_data=types.Blob(data=b'img', mime_type='image/png') ), ], role='user', ) ) with caplog.at_level(logging.WARNING): await runner.run_async(user_event) assert len(received_inputs) == 1 assert received_inputs[0] == 'Hello' assert 'non-text parts' in caplog.text def _produce_list(): return [1, 2, 3] def _produce_dict(): return {'key': 'value'} @pytest.mark.asyncio @pytest.mark.parametrize( 'produce_value, expected', [ (_produce_list, [1, 2, 3]), (_produce_dict, {'key': 'value'}), ], ids=['list', 'dict'], ) async def test_union_type_accepts_matching_member( request: pytest.FixtureRequest, produce_value, expected, ): """Tests that Union type annotation accepts values matching any member.""" received_inputs = [] def record_input(node_input: Union[list, dict]) -> str: received_inputs.append(node_input) return 'ok' agent = Workflow( name='test_union_accept', edges=[ (START, produce_value), (produce_value, record_input), ], ) app = App( name=request.function.__name__, root_agent=agent, ) runner = testing_utils.InMemoryRunner(app=app) await runner.run_async(testing_utils.get_user_content('go')) assert len(received_inputs) == 1 assert received_inputs[0] == expected @pytest.mark.asyncio async def test_optional_str_with_content_auto_conversion( request: pytest.FixtureRequest, ): """Tests that Optional[str] auto-converts Content from START node.""" received_inputs = [] def record_input(node_input: Optional[str]) -> str: received_inputs.append(node_input) return 'ok' agent = Workflow( name='test_optional_content', edges=[ (START, record_input), ], ) app = App( name=request.function.__name__, root_agent=agent, ) runner = testing_utils.InMemoryRunner(app=app) await runner.run_async(testing_utils.get_user_content('hello')) assert len(received_inputs) == 1 assert received_inputs[0] == 'hello' @pytest.mark.asyncio async def test_union_type_rejects_non_matching( request: pytest.FixtureRequest, ): """Tests that Union type annotation rejects values not matching any member.""" def bad_input(node_input: Union[str, int]) -> str: return 'should not reach' def produce_list() -> list: return [1, 2, 3] agent = Workflow( name='test_union_reject', edges=[ (START, produce_list), (produce_list, bad_input), ], ) app = App( name=request.function.__name__, root_agent=agent, ) runner = testing_utils.InMemoryRunner(app=app) with pytest.raises(ValueError): await runner.run_async(testing_utils.get_user_content('go')) @pytest.mark.asyncio async def test_function_node_ctx_state_delta_sync( request: pytest.FixtureRequest, ): """Tests that state set via ctx.state in a sync function is persisted.""" def set_state_via_ctx(ctx: Context) -> str: ctx.state['user_request'] = 'build a tracker app' return 'done' def read_state(user_request: str) -> str: return f'request={user_request}' agent = Workflow( name='test_ctx_state_delta_sync', edges=[ (START, set_state_via_ctx), (set_state_via_ctx, read_state), ], ) events, _, _ = await run_workflow(agent) simplified = simplify_events_with_node(events, include_state_delta=True) assert simplified == [ ( 'test_ctx_state_delta_sync@1/set_state_via_ctx@1', { 'output': 'done', 'state_delta': {'user_request': 'build a tracker app'}, }, ), ( 'test_ctx_state_delta_sync@1/read_state@1', { 'output': 'request=build a tracker app', }, ), ] @pytest.mark.asyncio async def test_function_node_ctx_state_delta_async( request: pytest.FixtureRequest, ): """Tests that state set via ctx.state in an async function is persisted.""" async def set_state_via_ctx(ctx: Context) -> str: ctx.state['counter'] = 42 ctx.state['name'] = 'test' return 'set' def read_state(counter: int, name: str) -> str: return f'counter={counter}, name={name}' agent = Workflow( name='test_ctx_state_delta_async', edges=[ (START, set_state_via_ctx), (set_state_via_ctx, read_state), ], ) events, _, _ = await run_workflow(agent) simplified = simplify_events_with_node(events, include_state_delta=True) assert simplified == [ ( 'test_ctx_state_delta_async@1/set_state_via_ctx@1', { 'output': 'set', 'state_delta': {'counter': 42, 'name': 'test'}, }, ), ( 'test_ctx_state_delta_async@1/read_state@1', { 'output': 'counter=42, name=test', }, ), ] @pytest.mark.asyncio async def test_function_node_ctx_state_delta_none_return( request: pytest.FixtureRequest, ): """Tests that state is persisted even when function returns None.""" def set_state_return_none(ctx: Context) -> None: ctx.state['my_key'] = 'my_value' def read_state(my_key: str) -> str: return f'my_key={my_key}' agent = Workflow( name='test_ctx_state_delta_none_return', edges=[ (START, set_state_return_none), (set_state_return_none, read_state), ], ) events, _, _ = await run_workflow(agent) simplified = simplify_events_with_node(events, include_state_delta=True) assert simplified == [ ( 'test_ctx_state_delta_none_return@1/set_state_return_none@1', { 'state_delta': {'my_key': 'my_value'}, 'output': None, }, ), ( 'test_ctx_state_delta_none_return@1/read_state@1', { 'output': 'my_key=my_value', }, ), ] @pytest.mark.asyncio async def test_function_node_ctx_state_delta_with_event_return( request: pytest.FixtureRequest, ): """Tests that ctx.state changes merge into a returned Event's state_delta.""" def set_state_return_event(ctx: Context) -> Event: ctx.state['from_ctx'] = 'ctx_value' return Event( output='result', state={'from_event': 'event_value'}, ) def read_state(from_ctx: str, from_event: str) -> str: return f'from_ctx={from_ctx}, from_event={from_event}' agent = Workflow( name='test_ctx_state_delta_event_return', edges=[ (START, set_state_return_event), (set_state_return_event, read_state), ], ) events, _, _ = await run_workflow(agent) simplified = simplify_events_with_node(events, include_state_delta=True) assert simplified == [ ( 'test_ctx_state_delta_event_return@1/set_state_return_event@1', { 'output': 'result', 'state_delta': { 'from_event': 'event_value', 'from_ctx': 'ctx_value', }, }, ), ( 'test_ctx_state_delta_event_return@1/read_state@1', { 'output': 'from_ctx=ctx_value, from_event=event_value', }, ), ] @pytest.mark.asyncio async def test_function_node_ctx_state_delta_generator( request: pytest.FixtureRequest, ): """Tests that ctx.state changes are captured in generator yields.""" def gen_with_state(ctx: Context) -> Generator[Any, None, None]: ctx.state['key1'] = 'value1' yield Event(state={'key1': 'value1'}) ctx.state['key2'] = 'value2' yield 'done' def read_state(key1: str, key2: str) -> str: return f'key1={key1}, key2={key2}' agent = Workflow( name='test_ctx_state_delta_generator', edges=[ (START, gen_with_state), (gen_with_state, read_state), ], ) events, _, _ = await run_workflow(agent) simplified = simplify_events_with_node(events, include_state_delta=True) # First yield is a state-only Event, second is the data output with # accumulated state from both ctx.state assignments. assert simplified == [ ( 'test_ctx_state_delta_generator@1/gen_with_state@1', { 'output': None, 'state_delta': {'key1': 'value1'}, }, ), ( 'test_ctx_state_delta_generator@1/gen_with_state@1', { 'output': 'done', 'state_delta': {'key2': 'value2'}, }, ), ( 'test_ctx_state_delta_generator@1/read_state@1', { 'output': 'key1=value1, key2=value2', }, ), ] # ── FunctionNode output_schema ────────────────────────────────────── class _OutputModel(BaseModel): name: str value: int class _OtherModel(BaseModel): name: str value: int extra: str = 'default' @pytest.mark.asyncio async def test_output_schema_inferred_validates_dict( request: pytest.FixtureRequest, ): """Inferred output_schema validates dict return from BaseModel function.""" def produce() -> _OutputModel: return {'name': 'test', 'value': 42} node = FunctionNode(func=produce) assert node.output_schema is _OutputModel agent = Workflow(name='wf', edges=[(START, node)]) events, _, _ = await run_workflow(agent) data_events = [ e for e in events if isinstance(e, Event) and e.output is not None and _NodePathBuilder.from_string(e.node_info.path).is_direct_child_of( _NodePathBuilder.from_string('wf@1') ) ] assert len(data_events) == 1 assert data_events[0].output == {'name': 'test', 'value': 42} @pytest.mark.asyncio async def test_output_schema_inferred_rejects_invalid( request: pytest.FixtureRequest, ): """Inferred output_schema rejects invalid dict.""" def produce() -> _OutputModel: return {'name': 'test'} # missing 'value' node = FunctionNode(func=produce) agent = Workflow(name='wf', edges=[(START, node)]) with pytest.raises(ValueError): await run_workflow(agent) @pytest.mark.asyncio async def test_output_schema_inferred_rejects_wrong_type( request: pytest.FixtureRequest, ): """Inferred output_schema rejects non-dict, non-BaseModel return.""" def produce() -> _OutputModel: return 'not a dict' node = FunctionNode(func=produce) agent = Workflow(name='wf', edges=[(START, node)]) with pytest.raises(ValueError): await run_workflow(agent) @pytest.mark.asyncio async def test_output_schema_generator_rejects_invalid_item( request: pytest.FixtureRequest, ): """A generator that yields an invalid item mid-stream raises.""" def produce_items() -> Generator[_OutputModel, None, None]: yield {'name': 'a', 'value': 1} yield {'name': 'bad'} # missing 'value' node = FunctionNode(func=produce_items) agent = Workflow(name='wf', edges=[(START, node)]) with pytest.raises(ValueError): await run_workflow(agent) @pytest.mark.asyncio async def test_output_schema_inferred_coerces_defaults( request: pytest.FixtureRequest, ): """Inferred output_schema fills in default fields.""" def produce() -> _OtherModel: return {'name': 'test', 'value': 5} node = FunctionNode(func=produce) assert node.output_schema is _OtherModel agent = Workflow(name='wf', edges=[(START, node)]) events, _, _ = await run_workflow(agent) data_events = [ e for e in events if isinstance(e, Event) and e.output is not None and _NodePathBuilder.from_string(e.node_info.path).is_direct_child_of( _NodePathBuilder.from_string('wf@1') ) ] assert len(data_events) == 1 assert data_events[0].output == { 'name': 'test', 'value': 5, 'extra': 'default', } @pytest.mark.asyncio async def test_output_schema_inferred_from_return_hint( request: pytest.FixtureRequest, ): """output_schema is auto-inferred from -> BaseModel return hint.""" def produce() -> _OutputModel: return _OutputModel(name='inferred', value=1) node = FunctionNode(func=produce) assert node.output_schema is _OutputModel agent = Workflow(name='wf', edges=[(START, node)]) events, _, _ = await run_workflow(agent) data_events = [ e for e in events if isinstance(e, Event) and e.output is not None and _NodePathBuilder.from_string(e.node_info.path).is_direct_child_of( _NodePathBuilder.from_string('wf@1') ) ] assert len(data_events) == 1 assert data_events[0].output == {'name': 'inferred', 'value': 1} def test_output_schema_no_inference_for_non_basemodel(): """Non-BaseModel return hints (str, dict, etc.) don't trigger inference.""" def produce() -> dict: return {'any': 'thing'} node = FunctionNode(func=produce) assert node.output_schema is None @pytest.mark.asyncio async def test_output_schema_inferred_type_coercion( request: pytest.FixtureRequest, ): """Pydantic coerces compatible types (str '123' -> int 123).""" def produce() -> _OutputModel: return {'name': 'coerce', 'value': '42'} node = FunctionNode(func=produce) agent = Workflow(name='wf', edges=[(START, node)]) events, _, _ = await run_workflow(agent) data_events = [ e for e in events if isinstance(e, Event) and e.output is not None and _NodePathBuilder.from_string(e.node_info.path).is_direct_child_of( _NodePathBuilder.from_string('wf@1') ) ] assert len(data_events) == 1 assert data_events[0].output == {'name': 'coerce', 'value': 42} @pytest.mark.asyncio async def test_output_schema_none_return(request: pytest.FixtureRequest): """Returning None with inferred output_schema skips validation.""" def produce_none() -> _OutputModel: return None node = FunctionNode(func=produce_none) assert node.output_schema is _OutputModel def downstream(node_input: Any) -> str: return f'got: {node_input}' agent = Workflow(name='wf', edges=[(START, node), (node, downstream)]) events, _, _ = await run_workflow(agent) data_events = [ e for e in events if isinstance(e, Event) and e.output is not None and _NodePathBuilder.from_string(e.node_info.path).is_direct_child_of( _NodePathBuilder.from_string('wf@1') ) ] assert len(data_events) == 1 assert data_events[0].output == 'got: None' @pytest.mark.asyncio async def test_output_schema_validates_returned_event_data( request: pytest.FixtureRequest, ): """When a function returns an Event with data, output_schema validates it.""" def produce() -> _OutputModel: return Event(output={'name': 'evt', 'value': 7}) node = FunctionNode(func=produce) assert node.output_schema is _OutputModel agent = Workflow(name='wf', edges=[(START, node)]) events, _, _ = await run_workflow(agent) data_events = [ e for e in events if isinstance(e, Event) and e.output is not None and _NodePathBuilder.from_string(e.node_info.path).is_direct_child_of( _NodePathBuilder.from_string('wf@1') ) ] assert len(data_events) == 1 assert data_events[0].output == {'name': 'evt', 'value': 7} @pytest.mark.asyncio async def test_output_schema_rejects_invalid_returned_event_data( request: pytest.FixtureRequest, ): """When a function returns an Event with invalid data, validation raises.""" def produce() -> _OutputModel: return Event(output={'wrong_field': 'oops'}) node = FunctionNode(func=produce) agent = Workflow(name='wf', edges=[(START, node)]) with pytest.raises(ValueError): await run_workflow(agent) # ── FunctionNode input_schema ────────────────────────────────────── @pytest.mark.asyncio async def test_input_schema_validates_dict(request: pytest.FixtureRequest): """Dict input is validated and coerced through inferred input_schema.""" received = [] def process(node_input: _OutputModel) -> str: received.append(node_input) return 'ok' def produce() -> dict: return {'name': 'test', 'value': 42} node = FunctionNode(func=process) assert node.input_schema is _OutputModel agent = Workflow(name='wf', edges=[(START, produce), (produce, node)]) await run_workflow(agent) # input_schema validates before FunctionNode converts dict -> BaseModel assert received == [_OutputModel(name='test', value=42)] @pytest.mark.asyncio async def test_input_schema_rejects_invalid_dict( request: pytest.FixtureRequest, ): """Dict missing required fields raises validation error.""" def process(node_input: _OutputModel) -> str: return 'should not reach' def produce() -> dict: return {'name': 'test'} # missing 'value' node = FunctionNode(func=process) agent = Workflow(name='wf', edges=[(START, produce), (produce, node)]) with pytest.raises(ValueError): await run_workflow(agent) @pytest.mark.asyncio async def test_input_schema_coerces_types(request: pytest.FixtureRequest): """Pydantic coerces compatible types in input (str '5' -> int 5).""" received = [] def process(node_input: _OutputModel) -> str: received.append(node_input) return 'ok' def produce() -> dict: return {'name': 'test', 'value': '5'} node = FunctionNode(func=process) agent = Workflow(name='wf', edges=[(START, produce), (produce, node)]) await run_workflow(agent) assert received == [_OutputModel(name='test', value=5)] @pytest.mark.asyncio async def test_input_schema_fills_defaults(request: pytest.FixtureRequest): """Inferred input_schema fills default fields.""" received = [] def process(node_input: _OtherModel) -> str: received.append(node_input) return 'ok' def produce() -> dict: return {'name': 'test', 'value': 1} node = FunctionNode(func=process) assert node.input_schema is _OtherModel agent = Workflow(name='wf', edges=[(START, produce), (produce, node)]) await run_workflow(agent) assert received == [_OtherModel(name='test', value=1, extra='default')] def test_input_schema_no_inference_for_non_basemodel(): """Non-BaseModel node_input hints don't trigger inference.""" def process(node_input: dict) -> str: return 'ok' node = FunctionNode(func=process) assert node.input_schema is None @pytest.mark.asyncio async def test_input_schema_none_passthrough(request: pytest.FixtureRequest): """None input with input_schema skips validation.""" def produce_none() -> None: return None def process(node_input: _OutputModel | None = None) -> str: return f'got: {node_input}' node = FunctionNode(func=process) agent = Workflow( name='wf', edges=[(START, produce_none), (produce_none, node)] ) events, _, _ = await run_workflow(agent) data_events = [ e for e in events if isinstance(e, Event) and e.output is not None and _NodePathBuilder.from_string(e.node_info.path).is_direct_child_of( _NodePathBuilder.from_string('wf@1') ) ] assert any(e.output == 'got: None' for e in data_events) # --------------------------------------------------------------------------- # auth_config tests # --------------------------------------------------------------------------- class TestAuthConfig: """Tests for FunctionNode auth_config behavior.""" def test_raises_without_rerun_on_resume(self): """auth_config raises ValueError when rerun_on_resume is not True.""" from fastapi.openapi.models import APIKey from fastapi.openapi.models import APIKeyIn from google.adk.auth.auth_credential import AuthCredential from google.adk.auth.auth_credential import AuthCredentialTypes from google.adk.auth.auth_tool import AuthConfig auth_config = AuthConfig( auth_scheme=APIKey(**{'in': APIKeyIn.header, 'name': 'X-Api-Key'}), raw_auth_credential=AuthCredential( auth_type=AuthCredentialTypes.API_KEY, api_key='placeholder', ), credential_key='test_key', ) with pytest.raises(ValueError, match='rerun_on_resume=True'): FunctionNode(func=lambda: None, name='n', auth_config=auth_config) def test_no_auth_config_default(self): """auth_config defaults to None.""" node = FunctionNode(func=lambda: None, name='n') assert node.auth_config is None def test_rerun_on_resume_explicit_true_with_auth(self): """Explicit rerun_on_resume=True with auth_config is fine.""" from fastapi.openapi.models import APIKey from fastapi.openapi.models import APIKeyIn from google.adk.auth.auth_credential import AuthCredential from google.adk.auth.auth_credential import AuthCredentialTypes from google.adk.auth.auth_tool import AuthConfig auth_config = AuthConfig( auth_scheme=APIKey(**{'in': APIKeyIn.header, 'name': 'X-Api-Key'}), raw_auth_credential=AuthCredential( auth_type=AuthCredentialTypes.API_KEY, api_key='placeholder', ), credential_key='test_key', ) node = FunctionNode( func=lambda: None, name='n', auth_config=auth_config, rerun_on_resume=True, ) assert node.rerun_on_resume is True # --------------------------------------------------------------------------- # parameter_binding='node_input' tests # --------------------------------------------------------------------------- class TestParameterBindingNodeInput: """Tests for FunctionNode with parameter_binding='node_input'.""" def test_schemas_inferred_from_signature(self): """input_schema and output_schema are inferred from func signature.""" def add(x: int, y: int) -> int: """Add two numbers.""" return x + y node = FunctionNode(func=add, name='add', parameter_binding='node_input') assert node.parameter_binding == 'node_input' assert node.input_schema is not None assert 'properties' in node.input_schema assert 'x' in node.input_schema['properties'] assert 'y' in node.input_schema['properties'] assert node.output_schema == {'type': 'integer'} def test_ctx_param_excluded_from_schema(self): """Context parameter is excluded from input_schema.""" def greet(name: str, ctx: Context) -> str: return f'Hello, {name}!' node = FunctionNode( func=greet, name='greet', parameter_binding='node_input' ) assert node.input_schema is not None assert 'name' in node.input_schema['properties'] assert 'ctx' not in node.input_schema.get('properties', {}) @pytest.mark.asyncio @pytest.mark.parametrize( 'producer_output, add_func, expected_output', [ pytest.param( {'x': 3, 'y': 4}, staticmethod(lambda x, y: x + y), 7, id='all_params_provided', ), pytest.param( {'x': 5}, None, # uses default func defined below 15, id='missing_param_uses_default', ), ], ) async def test_bind_from_node_input( self, request: pytest.FixtureRequest, producer_output: dict, add_func, expected_output: int, ): """Parameters are bound from node_input dict.""" if add_func is None: def add_func(x: int, y: int = 10): return x + y def produce(): return producer_output node = FunctionNode( func=add_func, name='add', parameter_binding='node_input' ) agent = Workflow( name='test_bind_from_node_input', edges=[ (START, produce), (produce, node), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_bind_from_node_input@1/produce@1', {'output': producer_output}, ), ( 'test_bind_from_node_input@1/add@1', {'output': expected_output}, ), ] @pytest.mark.asyncio async def test_bind_from_node_input_missing_required( self, request: pytest.FixtureRequest ): """Missing required param in node_input mode raises ValueError.""" def produce(): return {'x': 5} def add(x: int, y: int): return x + y node = FunctionNode(func=add, name='add', parameter_binding='node_input') agent = Workflow( name='test_bind_node_input_missing', edges=[ (START, produce), (produce, node), ], ) with pytest.raises(ValueError, match='Missing value for parameter "y"'): await run_workflow(agent) @pytest.mark.asyncio async def test_bind_from_node_input_with_ctx( self, request: pytest.FixtureRequest ): """Context parameter is injected alongside node_input params.""" received_ctx = [] def produce(): return {'name': 'Alice'} def greet(name: str, ctx: Context): received_ctx.append(ctx) return f'Hello, {name}!' node = FunctionNode( func=greet, name='greet', parameter_binding='node_input' ) agent = Workflow( name='test_bind_node_input_ctx', edges=[ (START, produce), (produce, node), ], ) events, _, _ = await run_workflow(agent) assert len(received_ctx) == 1 assert isinstance(received_ctx[0], Context) assert simplify_events_with_node(events) == [ ( 'test_bind_node_input_ctx@1/produce@1', {'output': {'name': 'Alice'}}, ), ( 'test_bind_node_input_ctx@1/greet@1', {'output': 'Hello, Alice!'}, ), ] def test_model_copy_preserves_parameter_binding(self): """model_copy preserves parameter_binding and input_schema.""" def add(x: int, y: int) -> int: return x + y node = FunctionNode(func=add, name='add', parameter_binding='node_input') copied = node.model_copy(update={'name': 'add_copy'}) assert copied.parameter_binding == 'node_input' assert copied.input_schema is not None assert 'x' in copied.input_schema['properties'] def test_type_hints_cache(self): """Verifies that type hints are cached and robustly unwrapped.""" from google.adk.workflow._function_node import _get_type_hints_cached from google.adk.workflow._function_node import _get_type_hints_for_unwrapped def my_func(x: int, y: str) -> bool: return True # Clear cache first to have predictable results _get_type_hints_for_unwrapped.cache_clear() hints1 = _get_type_hints_cached(my_func) assert hints1 == {'x': int, 'y': str, 'return': bool} # Call again, should hit cache hints2 = _get_type_hints_cached(my_func) assert hints2 == {'x': int, 'y': str, 'return': bool} assert _get_type_hints_for_unwrapped.cache_info().hits == 1 # Test partial import functools partial_func = functools.partial(my_func, x=1) hints3 = _get_type_hints_cached(partial_func) # Partial should unwrap to my_func and hit cache! assert hints3 == {'x': int, 'y': str, 'return': bool} assert _get_type_hints_for_unwrapped.cache_info().hits == 2 # Test callable object class MyCallable: def __call__(self, z: float) -> None: pass obj = MyCallable() hints4 = _get_type_hints_cached(obj) assert hints4 == {'z': float, 'return': type(None)} @pytest.mark.asyncio async def test_function_node_wrapped_partial(request: pytest.FixtureRequest): """Tests that FunctionNode correctly unwraps functools.partial for async/sync generators and coroutines.""" import functools async def async_gen_fn( prefix: str, ctx: Context ) -> AsyncGenerator[Any, None]: yield Event(output=f'{prefix} from AsyncGen') def sync_gen_fn(prefix: str, ctx: Context) -> Generator[Any, None, None]: yield Event(output=f'{prefix} from SyncGen') async def async_fn(prefix: str, ctx: Context) -> str: return f'{prefix} from AsyncCoro' p_async_gen = functools.partial(async_gen_fn, 'Hello') p_sync_gen = functools.partial(sync_gen_fn, 'Hello') p_async = functools.partial(async_fn, 'Hello') agent = Workflow( name='test_workflow_partial_unwrapping', edges=[ (START, p_async_gen), (p_async_gen, p_sync_gen), (p_sync_gen, p_async), ], ) events, _, _ = await run_workflow(agent) assert simplify_events_with_node(events) == [ ( 'test_workflow_partial_unwrapping@1/async_gen_fn@1', {'output': 'Hello from AsyncGen'}, ), ( 'test_workflow_partial_unwrapping@1/sync_gen_fn@1', {'output': 'Hello from SyncGen'}, ), ( 'test_workflow_partial_unwrapping@1/async_fn@1', {'output': 'Hello from AsyncCoro'}, ), ]