From cb4a4464908463399b17aae353c60d65dce6e92a Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 10:16:43 +0530 Subject: [PATCH] fix: extract interaction_id from SSE events for Interactions API chaining SSE streaming events carry the interaction ID in different attributes than the non-streaming Interaction response: - InteractionStartEvent/CompleteEvent: event.interaction.id - InteractionStatusUpdate: event.interaction_id The code only checked event.id which doesn't exist on SSE event types, so current_interaction_id was never set during streaming. This caused _find_previous_interaction_id() to fail, breaking function calling with StreamingMode.SSE + Interactions API. Fixes #5169 --- src/google/adk/models/interactions_utils.py | 15 ++- .../models/test_interactions_utils.py | 99 +++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/google/adk/models/interactions_utils.py b/src/google/adk/models/interactions_utils.py index add8da0c54..ce2c839ca5 100644 --- a/src/google/adk/models/interactions_utils.py +++ b/src/google/adk/models/interactions_utils.py @@ -1013,9 +1013,18 @@ async def generate_content_via_interactions( # Log the streaming event logger.debug(build_interactions_event_log(event)) - # Extract interaction ID from event if available - if hasattr(event, 'id') and event.id: - current_interaction_id = event.id + # Extract interaction ID from event if available. + # SSE events carry the ID in different attributes depending on type: + # - InteractionStartEvent/CompleteEvent: event.interaction.id + # - InteractionStatusUpdate: event.interaction_id + if ( + hasattr(event, 'interaction') + and hasattr(event.interaction, 'id') + and event.interaction.id + ): + current_interaction_id = event.interaction.id + elif hasattr(event, 'interaction_id') and event.interaction_id: + current_interaction_id = event.interaction_id llm_response = convert_interaction_event_to_llm_response( event, aggregated_parts, current_interaction_id ) diff --git a/tests/unittests/models/test_interactions_utils.py b/tests/unittests/models/test_interactions_utils.py index 93dced0f21..a2ae96c092 100644 --- a/tests/unittests/models/test_interactions_utils.py +++ b/tests/unittests/models/test_interactions_utils.py @@ -955,3 +955,102 @@ def test_unknown_event_type_returns_none(self): assert result is None assert not aggregated_parts + + +class TestSSEInteractionIdExtraction: + """Tests for interaction_id extraction from SSE events. + + SSE events carry the interaction ID in different attributes: + - InteractionStartEvent/CompleteEvent: event.interaction.id + - InteractionStatusUpdate: event.interaction_id + - ContentDelta/ContentStop: no interaction ID + + The call_interactions_api function must extract the ID from these + locations so it can be propagated to LlmResponse objects and + ultimately stored in session events for chaining. + """ + + def test_interaction_start_event_carries_id(self): + """InteractionStartEvent has interaction.id — should be found.""" + event = MagicMock() + event.event_type = 'interaction.start' + event.interaction = MagicMock() + event.interaction.id = 'int_start_abc' + # Should NOT have direct interaction_id + del event.interaction_id + + # Verify the extraction logic matches what call_interactions_api does + current_id = None + if ( + hasattr(event, 'interaction') + and hasattr(event.interaction, 'id') + and event.interaction.id + ): + current_id = event.interaction.id + elif hasattr(event, 'interaction_id') and event.interaction_id: + current_id = event.interaction_id + + assert current_id == 'int_start_abc' + + def test_status_update_event_carries_interaction_id(self): + """InteractionStatusUpdate has interaction_id — should be found.""" + event = MagicMock(spec=['event_type', 'interaction_id', 'status']) + event.event_type = 'interaction.status_update' + event.interaction_id = 'int_status_xyz' + event.status = 'requires_action' + + current_id = None + if ( + hasattr(event, 'interaction') + and hasattr(event.interaction, 'id') + and event.interaction.id + ): + current_id = event.interaction.id + elif hasattr(event, 'interaction_id') and event.interaction_id: + current_id = event.interaction_id + + assert current_id == 'int_status_xyz' + + def test_content_delta_has_no_interaction_id(self): + """ContentDelta events don't carry interaction ID.""" + event = MagicMock(spec=['event_type', 'delta', 'index', 'event_id']) + event.event_type = 'content.delta' + + current_id = None + if ( + hasattr(event, 'interaction') + and hasattr(event.interaction, 'id') + and event.interaction.id + ): + current_id = event.interaction.id + elif hasattr(event, 'interaction_id') and event.interaction_id: + current_id = event.interaction_id + + assert current_id is None + + def test_interaction_id_propagated_to_status_update_response(self): + """When interaction_id is extracted from earlier events, it should + be passed to convert_interaction_event_to_llm_response and appear + in the resulting LlmResponse for status_update events.""" + event = MagicMock() + event.event_type = 'interaction.status_update' + event.status = 'requires_action' + + # Function call was aggregated earlier + aggregated_parts = [ + types.Part( + function_call=types.FunctionCall( + id='call_1', name='get_weather', args={'city': 'Tokyo'} + ) + ) + ] + + # The interaction_id should have been extracted from an earlier + # InteractionStartEvent and passed here + result = interactions_utils.convert_interaction_event_to_llm_response( + event, aggregated_parts, interaction_id='int_from_start' + ) + + assert result is not None + assert result.interaction_id == 'int_from_start' + assert result.turn_complete is True