From 6bb1f0ba227607d72c0496e2e05fb72022534453 Mon Sep 17 00:00:00 2001 From: Alpinist <199138677+Epsilon006@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:35:51 +0800 Subject: [PATCH 1/2] fix(events): exclude state-only events from final responses A callback state mutation is persisted as a content-less framework event. Exclude that state-only event from final-response detection while preserving legitimate content-less agent responses. Fixes #7078 --- src/google/adk/events/event.py | 9 +++++ tests/unittests/agents/test_base_agent.py | 48 +++++++++++++++++++++++ tests/unittests/events/test_event.py | 10 +++++ 3 files changed, 67 insertions(+) diff --git a/src/google/adk/events/event.py b/src/google/adk/events/event.py index 5833b756526..c61435dd410 100644 --- a/src/google/adk/events/event.py +++ b/src/google/adk/events/event.py @@ -302,6 +302,15 @@ def is_final_response(self) -> bool: and not self.get_function_calls() ): return True + # A state-only event persists framework state; it is not an agent response. + # Keep empty Content events final because a model may legitimately complete + # a turn without producing any parts. + if ( + self.content is None + and self.output is None + and self.actions.state_delta + ): + return False return ( not self.get_function_calls() and not self.get_function_responses() diff --git a/tests/unittests/agents/test_base_agent.py b/tests/unittests/agents/test_base_agent.py index 698520cd7ae..1d28cab3696 100644 --- a/tests/unittests/agents/test_base_agent.py +++ b/tests/unittests/agents/test_base_agent.py @@ -329,6 +329,30 @@ async def test_run_async_before_agent_callback_bypass_agent( assert events[0].content.parts[0].text == 'agent run is bypassed.' +@pytest.mark.asyncio +async def test_run_async_before_agent_callback_state_delta_is_not_final( + request: pytest.FixtureRequest, +): + def update_state(callback_context: CallbackContext) -> None: + callback_context.state['callback_state'] = 'before' + + agent = _TestingAgent( + name=f'{request.function.__name__}_test_agent', + before_agent_callback=update_state, + ) + parent_ctx = await _create_parent_invocation_context( + request.function.__name__, agent + ) + + events = [event async for event in agent.run_async(parent_ctx)] + + assert len(events) == 2 + assert events[0].content is None + assert events[0].actions.state_delta == {'callback_state': 'before'} + assert events[0].is_final_response() is False + assert events[1].is_final_response() is True + + @pytest.mark.asyncio async def test_run_async_with_async_before_agent_callback_bypass_agent( request: pytest.FixtureRequest, @@ -694,6 +718,30 @@ async def test_run_async_after_agent_callback_append_reply( ) +@pytest.mark.asyncio +async def test_run_async_after_agent_callback_state_delta_is_not_final( + request: pytest.FixtureRequest, +): + def update_state(callback_context: CallbackContext) -> None: + callback_context.state['callback_state'] = 'after' + + agent = _TestingAgent( + name=f'{request.function.__name__}_test_agent', + after_agent_callback=update_state, + ) + parent_ctx = await _create_parent_invocation_context( + request.function.__name__, agent + ) + + events = [event async for event in agent.run_async(parent_ctx)] + + assert len(events) == 2 + assert events[0].is_final_response() is True + assert events[1].content is None + assert events[1].actions.state_delta == {'callback_state': 'after'} + assert events[1].is_final_response() is False + + @pytest.mark.asyncio async def test_run_async_with_async_after_agent_callback_append_reply( request: pytest.FixtureRequest, diff --git a/tests/unittests/events/test_event.py b/tests/unittests/events/test_event.py index 4a5b56f70e5..b907274dd2b 100644 --- a/tests/unittests/events/test_event.py +++ b/tests/unittests/events/test_event.py @@ -69,6 +69,16 @@ def test_is_final_response_empty_event_is_final(): assert event.is_final_response() is True +def test_is_final_response_state_delta_only_event_is_not_final(): + event = _event(actions=EventActions(state_delta={'key': 'value'})) + assert event.is_final_response() is False + + +def test_is_final_response_empty_content_with_state_delta_is_final(): + event = _event(parts=[], actions=EventActions(state_delta={'key': 'value'})) + assert event.is_final_response() is True + + def test_is_final_response_with_function_call_is_not_final(): event = _event(parts=[_text_part(), _function_call_part()]) assert event.is_final_response() is False From db9872cbf5a58948c79b1e3ae1237038bb9b3525 Mon Sep 17 00:00:00 2001 From: Epsilon006 <199138677+Epsilon006@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:49:35 +0800 Subject: [PATCH 2/2] test(workflow): cover terminal state-only flush --- tests/unittests/workflow/test_workflow.py | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unittests/workflow/test_workflow.py b/tests/unittests/workflow/test_workflow.py index e04bda3797a..9ab16203943 100644 --- a/tests/unittests/workflow/test_workflow.py +++ b/tests/unittests/workflow/test_workflow.py @@ -179,6 +179,44 @@ def _output_by_node(events): # --------------------------------------------------------------------------- +@pytest.mark.asyncio +@pytest.mark.parametrize('output', [None, 'done']) +async def test_terminal_node_state_flush_final_response(output): + """A terminal state flush is final only when it carries deferred output.""" + + class _StateFlushingNode(BaseNode): + + async def _run_impl( + self, *, ctx: Context, node_input: Any + ) -> AsyncGenerator[Any, None]: + ctx.state['node_state'] = 'updated' + ctx.output = output + return + yield # Keep this an async generator without emitting an event. + + node = _StateFlushingNode(name='state_node') + wf = Workflow(name='wf', edges=[(START, node)]) + + events, session_service, session = await _run_workflow(wf) + + # Inspect the entire public stream: no later workflow event replaces the + # terminal node's flush in a non-resumable workflow. + assert len(events) == 1 + event = events[-1] + assert event.author == 'wf' + assert event.node_info.path == 'wf@1/state_node@1' + assert event.content is None + assert event.output == output + assert event.actions.state_delta == {'node_state': 'updated'} + assert event.is_final_response() is (output is not None) + + persisted_session = await session_service.get_session( + app_name=session.app_name, user_id=session.user_id, session_id=session.id + ) + assert persisted_session.state['node_state'] == 'updated' + assert persisted_session.events[-1].id == event.id + + # 1. test_run_async → sequential A→B @pytest.mark.asyncio async def test_sequential_two_nodes():