# 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 interactions_utils.py conversion functions."""

import asyncio
import base64
from collections.abc import AsyncGenerator
from collections.abc import Callable
from datetime import datetime
from datetime import timezone
import json
import logging
from unittest.mock import MagicMock

from google.adk.models import interactions_utils
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import interactions
from google.genai import types
from google.genai.interactions import CodeExecutionResultStep
from google.genai.interactions import FunctionCallStep
from google.genai.interactions import FunctionResultStep
from google.genai.interactions import ImageContent
from google.genai.interactions import Interaction
from google.genai.interactions import InteractionCompletedEvent
from google.genai.interactions import InteractionCreatedEvent
from google.genai.interactions import InteractionSseEventInteraction
from google.genai.interactions import ModelOutputStep
from google.genai.interactions import StepDelta
from google.genai.interactions import StepStart
from google.genai.interactions import StepStop
from google.genai.interactions import TextContent
from google.genai.interactions import ThoughtStep
from google.genai.interactions import Usage
import pytest


class _MockAsyncIterator:
  """Simple async iterator for streaming interaction events."""

  def __init__(self, sequence: list[object]):
    self._iterator = iter(sequence)

  def __aiter__(self):
    return self

  async def __anext__(self):
    try:
      return next(self._iterator)
    except StopIteration as exc:
      raise StopAsyncIteration from exc


class _FakeInteractions:
  """Fake interactions resource for create() tests.

  Records each create() call's kwargs (including the ``stream`` flag) so tests
  can assert verbatim forwarding. Streaming calls (``stream`` truthy) return an
  async iterator over the configured events; non-streaming calls return the
  configured Interaction. ``_create_interactions`` always passes ``stream``
  explicitly, so there is no need to distinguish "unset" from ``stream=False``.
  """

  def __init__(
      self,
      events: list[object] | None = None,
      *,
      interaction: Interaction | None = None,
  ):
    self._events = events or []
    self._interaction = interaction
    self.create_calls: list[dict[str, object]] = []

  async def create(self, **kwargs):
    self.create_calls.append(kwargs)
    if kwargs.get('stream'):
      return _MockAsyncIterator(self._events)
    return self._interaction


class _FakeAio:
  """Namespace matching the expected api_client.aio shape."""

  def __init__(
      self,
      events: list[object] | None = None,
      *,
      interaction: Interaction | None = None,
  ):
    self.interactions = _FakeInteractions(events, interaction=interaction)


class _FakeApiClient:
  """Minimal fake API client for interactions create() tests.

  Streaming calls return an async iterator over the configured events;
  non-streaming calls return the configured Interaction. ``create_calls``
  exposes the recorded kwargs of each ``interactions.create`` call.
  """

  def __init__(
      self,
      events: list[object] | None = None,
      *,
      interaction: Interaction | None = None,
  ):
    self.aio = _FakeAio(events, interaction=interaction)

  @property
  def create_calls(self) -> list[dict[str, object]]:
    return self.aio.interactions.create_calls


def _build_llm_request() -> LlmRequest:
  """Build a minimal request for interactions streaming tests."""
  return LlmRequest(
      model='gemini-2.5-flash',
      contents=[
          types.Content(
              role='user',
              parts=[types.Part(text='Weather in Tokyo?')],
          )
      ],
      config=types.GenerateContentConfig(),
  )


@pytest.fixture
def fc_step() -> FunctionCallStep:
  """Fixture providing a basic FunctionCallStep."""
  return FunctionCallStep(
      type='function_call',
      id='call_1',
      name='get_weather',
      arguments={'city': 'Tokyo'},
  )


def _build_lifecycle_streamed_events(fc_step: FunctionCallStep) -> list[object]:
  """Build streamed events with lifecycle updates carrying the ID."""
  now = datetime.now(timezone.utc).isoformat()

  interaction = InteractionSseEventInteraction(
      id='interaction_123',
      created=now,
      updated=now,
      status='requires_action',
      steps=[fc_step],
  )

  return [
      InteractionCreatedEvent(
          event_type='interaction.created',
          interaction=interaction,
      ),
      InteractionCompletedEvent(
          event_type='interaction.completed',
          interaction=interaction,
      ),
  ]


def _build_complete_streamed_events(fc_step: FunctionCallStep) -> list[object]:
  """Build streamed events with the ID on an interaction.complete event."""
  now = datetime.now(timezone.utc).isoformat()

  interaction = InteractionSseEventInteraction(
      id='interaction_complete_123',
      created=now,
      updated=now,
      status='requires_action',
      steps=[fc_step],
  )

  return [
      InteractionCompletedEvent(
          event_type='interaction.completed',
          interaction=interaction,
      ),
  ]


def _build_legacy_streamed_events(fc_step: FunctionCallStep) -> list[object]:
  """Build streamed events with the ID on the legacy interaction event."""
  now = datetime.now(timezone.utc).isoformat()

  interaction = Interaction(
      id='interaction_legacy_123',
      created=now,
      updated=now,
      status='requires_action',
      steps=[fc_step],
  )

  return [
      interaction,
  ]


async def _collect_function_call_interaction_ids(
    streamed_events: list[object],
) -> list[str | None]:
  """Collect non-partial function call interaction IDs from streamed events."""
  responses = [
      response
      async for response in (
          interactions_utils.generate_content_via_interactions(
              api_client=_FakeApiClient(streamed_events),
              llm_request=_build_llm_request(),
              stream=True,
          )
      )
  ]

  return [
      response.interaction_id
      for response in responses
      if response.partial is not True
      and response.content is not None
      and response.content.parts
      and response.content.parts[0].function_call is not None
  ]


class TestConvertPartToInteractionContent:
  """Tests for _convert_part_to_interaction_content."""

  def test_text_part(self):
    """Test converting a text Part."""
    part = types.Part(text='Hello, world!')
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'user_input',
        'content': [{'type': 'text', 'text': 'Hello, world!'}],
    }

  def test_text_part_model_role(self):
    """Test converting a text Part for model role."""
    part = types.Part(text='Hello, user!')
    result = interactions_utils._convert_part_to_interaction_content(
        part, role='model'
    )
    assert result == {
        'type': 'model_output',
        'content': [{'type': 'text', 'text': 'Hello, user!'}],
    }

  def test_function_call_part(self):
    """Test converting a function call Part."""
    part = types.Part(
        function_call=types.FunctionCall(
            id='call_123',
            name='get_weather',
            args={'city': 'London'},
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'function_call',
        'id': 'call_123',
        'name': 'get_weather',
        'arguments': {'city': 'London'},
    }

  def test_function_call_part_no_id(self):
    """Test converting a function call Part without id."""
    part = types.Part(
        function_call=types.FunctionCall(
            name='get_weather',
            args={'city': 'London'},
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result['id'] == ''
    assert result['name'] == 'get_weather'

  def test_function_call_part_thought_signature_dropped(self):
    """Thought signatures are not sent on interactions function call steps."""
    part = types.Part(
        function_call=types.FunctionCall(
            id='call_456',
            name='my_tool',
            args={'doc': 'content'},
        ),
        thought_signature=b'test_signature_bytes',
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'function_call',
        'id': 'call_456',
        'name': 'my_tool',
        'arguments': {'doc': 'content'},
    }
    assert 'signature' not in result

  def test_function_call_part_without_thought_signature(self):
    """Test converting a function call Part without thought_signature."""
    part = types.Part(
        function_call=types.FunctionCall(
            id='call_789',
            name='other_tool',
            args={},
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result['type'] == 'function_call'
    # signature should not be present
    assert 'signature' not in result

  def test_function_response_dict(self):
    """Test converting a function response Part with dict response."""
    part = types.Part(
        function_response=types.FunctionResponse(
            id='call_123',
            name='get_weather',
            response={'temperature': 20, 'condition': 'sunny'},
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result['type'] == 'function_result'
    assert result['call_id'] == 'call_123'
    assert result['name'] == 'get_weather'
    # Dict should be passed through directly (not JSON-serialized)
    assert result['result'] == {
        'temperature': 20,
        'condition': 'sunny',
    }

  def test_function_response_simple(self):
    """Test converting a function response Part with simple response."""
    part = types.Part(
        function_response=types.FunctionResponse(
            id='call_123',
            name='check_weather',
            response={'message': 'Weather is sunny'},
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result['type'] == 'function_result'
    assert result['call_id'] == 'call_123'
    assert result['name'] == 'check_weather'
    # Dict should be JSON serialized
    assert result['result'] == {'message': 'Weather is sunny'}

  def test_convert_part_to_interaction_content_function_response_error(self):
    part = types.Part(
        function_response=types.FunctionResponse(
            name='my_function',
            id='call_123',
            response={'error': 'something went wrong'},
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == interactions.FunctionResultStepParam(
        type='function_result',
        name='my_function',
        call_id='call_123',
        result={'error': 'something went wrong'},
        is_error=True,
    )

  def test_function_response_dict_not_double_serialized(self):
    """Regression test: avoid double-serializing bash tool outputs.

    Bash tool responses contain JSON structures (stdout/stderr). When these
    dict responses were json.dumps()'d before being sent to the Interactions
    API, the API's own serialization would escape the already-escaped content,
    producing unreadable output like:
      {"result":"\\\"{\\\\\\\"error\\\\\\\":\\\\\\\"...\\\\\\\"}\\\""
    """
    bash_response = {
        'stdout': '{"name": "test", "version": "1.0"}\n',
        'stderr': '',
    }
    part = types.Part(
        function_response=types.FunctionResponse(
            id='call_bash',
            name='bash',
            response=bash_response,
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    # The result value must be the dict itself, NOT a JSON string.
    assert isinstance(result['result'], dict)
    assert result['result'] == bash_response
    # Verify there's no double-escaping: if result were a JSON string,
    # serializing it again would add backslashes before the internal quotes.
    wire_json = json.dumps(result)
    assert '\\\\' not in wire_json

  def test_inline_data_image(self):
    """Test converting an inline image Part."""
    part = types.Part(
        inline_data=types.Blob(
            data=b'image_data',
            mime_type='image/png',
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'user_input',
        'content': [{
            'type': 'image',
            'data': (
                'aW1hZ2VfZGF0YQ=='
            ),  # base64.b64encode(b'image_data').decode('utf-8')
            'mime_type': 'image/png',
        }],
    }

  def test_inline_data_audio(self):
    """Test converting an inline audio Part."""
    part = types.Part(
        inline_data=types.Blob(
            data=b'audio_data',
            mime_type='audio/mp3',
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'user_input',
        'content': [{
            'type': 'audio',
            'data': (
                'YXVkaW9fZGF0YQ=='
            ),  # base64.b64encode(b'audio_data').decode('utf-8')
            'mime_type': 'audio/mp3',
        }],
    }

  def test_inline_data_video(self):
    """Test converting an inline video Part."""
    part = types.Part(
        inline_data=types.Blob(
            data=b'video_data',
            mime_type='video/mp4',
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'user_input',
        'content': [{
            'type': 'video',
            'data': (
                'dmlkZW9fZGF0YQ=='
            ),  # base64.b64encode(b'video_data').decode('utf-8')
            'mime_type': 'video/mp4',
        }],
    }

  def test_inline_data_document(self):
    """Test converting an inline document Part."""
    part = types.Part(
        inline_data=types.Blob(
            data=b'doc_data',
            mime_type='application/pdf',
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'user_input',
        'content': [{
            'type': 'document',
            'data': (
                'ZG9jX2RhdGE='
            ),  # base64.b64encode(b'doc_data').decode('utf-8')
            'mime_type': 'application/pdf',
        }],
    }

  def test_file_data_image(self):
    """Test converting a file data image Part."""
    part = types.Part(
        file_data=types.FileData(
            file_uri='gs://bucket/image.png',
            mime_type='image/png',
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'user_input',
        'content': [{
            'type': 'image',
            'uri': 'gs://bucket/image.png',
            'mime_type': 'image/png',
        }],
    }

  def test_text_with_thought_flag(self):
    """Test converting a text Part with thought=True flag."""
    # In types.Part, thought is a boolean flag on text content
    # When text is present, the convert function returns text type (not thought)
    # because text check comes before thought check in the implementation
    part = types.Part(text='Let me think about this...', thought=True)
    result = interactions_utils._convert_part_to_interaction_content(part)
    # Text content is returned as-is (thought flag not represented in output)
    assert result == {
        'type': 'user_input',
        'content': [{'type': 'text', 'text': 'Let me think about this...'}],
    }

  def test_thought_only_part(self):
    """Test converting a thought-only Part with signature."""
    signature_bytes = b'test-thought-signature'
    part = types.Part(thought=True, thought_signature=signature_bytes)
    result = interactions_utils._convert_part_to_interaction_content(part)
    expected_signature = base64.b64encode(signature_bytes).decode('utf-8')
    assert result == {'type': 'thought', 'signature': expected_signature}

  def test_thought_only_part_without_signature(self):
    """Test converting a thought-only Part without signature."""
    part = types.Part(thought=True)
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {'type': 'thought'}

  def test_code_execution_result(self):
    """Test converting a code execution result Part."""
    part = types.Part(
        code_execution_result=types.CodeExecutionResult(
            output='Hello from code',
            outcome=types.Outcome.OUTCOME_OK,
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'code_execution_result',
        'call_id': '',
        'result': 'Hello from code',
        'is_error': False,
    }

  def test_code_execution_result_with_error(self):
    """Test converting a failed code execution result Part."""
    part = types.Part(
        code_execution_result=types.CodeExecutionResult(
            output='Error: something went wrong',
            outcome=types.Outcome.OUTCOME_FAILED,
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'code_execution_result',
        'call_id': '',
        'result': 'Error: something went wrong',
        'is_error': True,
    }

  def test_code_execution_result_deadline_exceeded(self):
    """Test converting a deadline exceeded code execution result Part."""
    part = types.Part(
        code_execution_result=types.CodeExecutionResult(
            output='Timeout',
            outcome=types.Outcome.OUTCOME_DEADLINE_EXCEEDED,
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'code_execution_result',
        'call_id': '',
        'result': 'Timeout',
        'is_error': True,
    }

  def test_executable_code(self):
    """Test converting an executable code Part."""
    part = types.Part(
        executable_code=types.ExecutableCode(
            code='print("hello")',
            language='PYTHON',
        )
    )
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result == {
        'type': 'code_execution_call',
        'id': '',
        'arguments': {
            'code': 'print("hello")',
            'language': 'PYTHON',
        },
    }

  def test_empty_part(self):
    """Test converting an empty Part returns None."""
    part = types.Part()
    result = interactions_utils._convert_part_to_interaction_content(part)
    assert result is None


class TestConvertContentToStep:
  """Tests for _convert_content_to_step."""

  def test_user_content(self):
    """Test converting user content."""
    content = types.Content(
        role='user',
        parts=[types.Part(text='Hello!')],
    )
    result = interactions_utils._convert_content_to_step(content)
    assert result == [{
        'type': 'user_input',
        'content': [{'type': 'text', 'text': 'Hello!'}],
    }]

  def test_model_content(self):
    """Test converting model content."""
    content = types.Content(
        role='model',
        parts=[types.Part(text='Hi there!')],
    )
    result = interactions_utils._convert_content_to_step(content)
    assert result == [{
        'type': 'model_output',
        'content': [{'type': 'text', 'text': 'Hi there!'}],
    }]

  def test_multiple_parts(self):
    """Test converting content with multiple parts."""
    content = types.Content(
        role='user',
        parts=[
            types.Part(text='Look at this:'),
            types.Part(
                inline_data=types.Blob(data=b'img', mime_type='image/png')
            ),
        ],
    )
    result = interactions_utils._convert_content_to_step(content)
    assert len(result) == 2
    assert result[0]['type'] == 'user_input'
    assert result[0]['content'][0] == {'type': 'text', 'text': 'Look at this:'}
    assert result[1]['type'] == 'user_input'
    assert result[1]['content'][0]['type'] == 'image'

  def test_interleaved_parts(self):
    """Test converting content with interleaved text and media parts."""
    content = types.Content(
        role='user',
        parts=[
            types.Part(text='First:'),
            types.Part(
                inline_data=types.Blob(data=b'img1', mime_type='image/png')
            ),
            types.Part(text='Second:'),
            types.Part(
                inline_data=types.Blob(data=b'img2', mime_type='image/jpeg')
            ),
            types.Part(text='End'),
        ],
    )
    result = interactions_utils._convert_content_to_step(content)
    assert len(result) == 5
    assert result[0]['type'] == 'user_input'
    assert result[0]['content'][0] == {'type': 'text', 'text': 'First:'}
    assert result[1]['type'] == 'user_input'
    assert result[1]['content'][0]['type'] == 'image'
    assert result[2]['type'] == 'user_input'
    assert result[2]['content'][0] == {'type': 'text', 'text': 'Second:'}
    assert result[3]['type'] == 'user_input'
    assert result[3]['content'][0]['type'] == 'image'
    assert result[4]['type'] == 'user_input'
    assert result[4]['content'][0] == {'type': 'text', 'text': 'End'}

  def test_default_role(self):
    """Test that default role is 'user' when not specified."""
    content = types.Content(parts=[types.Part(text='Hi')])
    result = interactions_utils._convert_content_to_step(content)
    assert result[0]['type'] == 'user_input'


class TestConvertContentsToSteps:
  """Tests for convert_contents_to_steps."""

  def test_single_content(self):
    """Test converting a list with single content."""
    contents = [
        types.Content(role='user', parts=[types.Part(text='What is 2+2?')]),
    ]
    result = interactions_utils._convert_contents_to_steps(contents)
    assert len(result) == 1
    assert result[0]['type'] == 'user_input'
    assert result[0]['content'][0]['text'] == 'What is 2+2?'

  def test_multi_turn_conversation(self):
    """Test converting a multi-turn conversation."""
    contents = [
        types.Content(role='user', parts=[types.Part(text='Hi')]),
        types.Content(role='model', parts=[types.Part(text='Hello!')]),
        types.Content(role='user', parts=[types.Part(text='How are you?')]),
    ]
    result = interactions_utils._convert_contents_to_steps(contents)
    assert len(result) == 3
    assert result[0]['type'] == 'user_input'
    assert result[1]['type'] == 'model_output'
    assert result[2]['type'] == 'user_input'

  def test_empty_content_skipped(self):
    """Test that empty contents are skipped."""
    contents = [
        types.Content(role='user', parts=[types.Part(text='Hi')]),
        types.Content(role='model', parts=[]),  # Empty parts
    ]
    result = interactions_utils._convert_contents_to_steps(contents)
    # Only the first content should be included
    assert len(result) == 1


class TestConvertToolsConfig:
  """Tests for _convert_tools_config_to_interactions_format."""

  def test_function_declaration(self):
    """Test converting function declarations."""
    config = types.GenerateContentConfig(
        tools=[
            types.Tool(
                function_declarations=[
                    types.FunctionDeclaration(
                        name='get_weather',
                        description='Get weather for a city',
                        parameters=types.Schema(
                            type='OBJECT',
                            properties={
                                'city': types.Schema(type='STRING'),
                            },
                            required=['city'],
                        ),
                    )
                ]
            )
        ]
    )
    result = interactions_utils.convert_tools_config_to_interactions_format(
        config
    )
    assert len(result) == 1
    assert result[0]['type'] == 'function'
    assert result[0]['name'] == 'get_weather'
    assert result[0]['description'] == 'Get weather for a city'
    assert 'parameters' in result[0]

  def test_google_search_tool(self):
    """Test converting google search tool."""
    config = types.GenerateContentConfig(
        tools=[types.Tool(google_search=types.GoogleSearch())]
    )
    result = interactions_utils.convert_tools_config_to_interactions_format(
        config
    )
    assert result == [{'type': 'google_search'}]

  def test_code_execution_tool(self):
    """Test converting code execution tool."""
    config = types.GenerateContentConfig(
        tools=[types.Tool(code_execution=types.ToolCodeExecution())]
    )
    result = interactions_utils.convert_tools_config_to_interactions_format(
        config
    )
    assert result == [{'type': 'code_execution'}]

  def test_no_tools(self):
    """Test handling config with no tools."""
    config = types.GenerateContentConfig()
    result = interactions_utils.convert_tools_config_to_interactions_format(
        config
    )
    assert result == []


class TestConvertInteractionOutputToParts:
  """Tests for convert_interaction_output_to_parts."""

  def test_text_output(self):
    """Test converting text output."""
    output = ModelOutputStep(
        type='model_output', content=[TextContent(type='text', text='Hello!')]
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.text == 'Hello!'

  def test_function_call_output(self):
    """Test converting function call output."""
    output = FunctionCallStep(
        type='function_call',
        id='call_123',
        name='get_weather',
        arguments={'city': 'London'},
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.function_call.id == 'call_123'
    assert result.function_call.name == 'get_weather'
    assert result.function_call.args == {'city': 'London'}

  def test_function_call_output_without_thought_signature(self):
    """Test converting function call output without thought_signature."""
    output = FunctionCallStep(
        type='function_call',
        id='call_no_sig',
        name='regular_tool',
        arguments={},
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.function_call.id == 'call_no_sig'
    assert result.function_call.name == 'regular_tool'
    # thought_signature should be None
    assert result.thought_signature is None

  def test_function_result_output(self):
    """Test converting function result output."""
    output = FunctionResultStep(
        type='function_result',
        call_id='call_123',
        result={'weather': 'Sunny'},
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.function_response.id == 'call_123'
    assert result.function_response.response == {'weather': 'Sunny'}

  def test_function_result_output_preserves_none_values(self):
    """None values in a dict result must not be dropped."""
    output = FunctionResultStep(
        type='function_result',
        call_id='call_none',
        result={'data': None, 'ok': True},
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.function_response.response == {'data': None, 'ok': True}

  def test_function_result_output_string(self):
    """A plain string result is wrapped under a 'result' key."""
    output = FunctionResultStep(
        type='function_result',
        call_id='call_str',
        result='plain text',
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.function_response.response == {'result': 'plain text'}

  def test_function_result_output_list(self):
    """A list result of content blocks is wrapped under a 'result' key."""
    output = FunctionResultStep(
        type='function_result',
        call_id='call_list',
        result=[{'type': 'text', 'text': 'hi'}],
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    wrapped = result.function_response.response['result']
    assert wrapped[0]['type'] == 'text'
    assert wrapped[0]['text'] == 'hi'

  def test_image_output_with_data(self):
    """Test converting image output with inline data."""
    output = ModelOutputStep(
        type='model_output',
        content=[
            ImageContent(
                type='image',
                data=base64.b64encode(b'image_bytes').decode('utf-8'),
                mime_type='image/png',
            )
        ],
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.inline_data.data == b'image_bytes'
    assert result.inline_data.mime_type == 'image/png'

  def test_image_output_with_uri(self):
    """Test converting image output with URI."""
    output = ModelOutputStep(
        type='model_output',
        content=[
            ImageContent(
                type='image',
                uri='gs://bucket/image.png',
                mime_type='image/png',
            )
        ],
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.file_data.file_uri == 'gs://bucket/image.png'
    assert result.file_data.mime_type == 'image/png'

  def test_code_execution_result_output(self):
    """Test converting code execution result output."""
    output = CodeExecutionResultStep(
        type='code_execution_result',
        call_id='',
        result='Output from code',
        is_error=False,
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.code_execution_result.output == 'Output from code'
    assert result.code_execution_result.outcome == types.Outcome.OUTCOME_OK

  def test_code_execution_result_error_output(self):
    """Test converting code execution result output with error."""
    output = CodeExecutionResultStep(
        type='code_execution_result',
        call_id='',
        result='Error: division by zero',
        is_error=True,
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result.code_execution_result.output == 'Error: division by zero'
    assert result.code_execution_result.outcome == types.Outcome.OUTCOME_FAILED

  def test_thought_output_returns_empty(self):
    """Test that thought output returns empty list (not exposed as Part)."""
    output = ThoughtStep(type='thought', signature='thinking...')
    result = interactions_utils._convert_interaction_step_to_parts(output)
    assert result == []

  def test_no_type_attribute(self):
    """Test handling output without type attribute."""
    output = MagicMock(spec=[])  # No 'type' attribute
    result = interactions_utils._convert_interaction_step_to_parts(output)
    assert result == []

  def test_code_execution_call_output_uppercase_python(self):
    """Test converting code execution call output with uppercase PYTHON."""
    from google.genai.interactions import CodeExecutionCallStep

    mock_args = MagicMock()
    mock_args.code = 'print("hello")'
    mock_args.language = 'PYTHON'

    output = CodeExecutionCallStep.model_construct(
        type='code_execution_call',
        id='',
        arguments=mock_args,
    )
    result_list = interactions_utils._convert_interaction_step_to_parts(output)
    result = result_list[0] if result_list else None
    assert result is not None
    assert result.executable_code.code == 'print("hello")'
    assert result.executable_code.language == types.Language.PYTHON


class TestConvertInteractionToLlmResponse:
  """Tests for convert_interaction_to_llm_response."""

  def test_successful_text_response(self):
    """Test converting a successful text response."""
    interaction = Interaction(
        id='interaction_123',
        status='completed',
        created=datetime.now(timezone.utc).isoformat(),
        updated=datetime.now(timezone.utc).isoformat(),
        steps=[
            ModelOutputStep(
                type='model_output',
                content=[TextContent(type='text', text='The answer is 4.')],
            )
        ],
        usage=Usage(total_input_tokens=10, total_output_tokens=5),
    )
    result = interactions_utils.convert_interaction_to_llm_response(interaction)

    assert result.interaction_id == 'interaction_123'
    assert result.content.parts[0].text == 'The answer is 4.'
    assert result.usage_metadata.prompt_token_count == 10
    assert result.usage_metadata.candidates_token_count == 5
    assert result.finish_reason == types.FinishReason.STOP
    assert result.turn_complete is True

  def test_failed_response(self):
    """Test converting a failed response."""
    interaction = Interaction(
        id='interaction_123',
        status='failed',
        created=datetime.now(timezone.utc).isoformat(),
        updated=datetime.now(timezone.utc).isoformat(),
        steps=[],
    )
    interaction.error = MagicMock(code='INVALID_REQUEST', message='Bad request')

    result = interactions_utils.convert_interaction_to_llm_response(interaction)

    assert result.interaction_id == 'interaction_123'
    assert result.error_code == 'INVALID_REQUEST'
    assert result.error_message == 'Bad request'

  def test_requires_action_response(self):
    """Test converting a requires_action response (function call)."""
    interaction = Interaction(
        id='interaction_123',
        status='requires_action',
        created=datetime.now(timezone.utc).isoformat(),
        updated=datetime.now(timezone.utc).isoformat(),
        steps=[
            FunctionCallStep(
                type='function_call',
                id='call_1',
                name='get_weather',
                arguments={'city': 'Paris'},
            )
        ],
    )
    result = interactions_utils.convert_interaction_to_llm_response(interaction)

    assert result.interaction_id == 'interaction_123'
    assert result.content.parts[0].function_call.name == 'get_weather'
    assert result.finish_reason == types.FinishReason.STOP
    assert result.turn_complete is True


class TestBuildGenerationConfig:
  """Tests for build_generation_config."""

  def test_all_parameters(self):
    """Test building config with all parameters."""
    config = types.GenerateContentConfig(
        temperature=0.7,
        top_p=0.9,
        top_k=40,
        max_output_tokens=100,
        stop_sequences=['END'],
        presence_penalty=0.5,
        frequency_penalty=0.3,
    )
    result = interactions_utils.build_generation_config(config)
    assert result == {
        'temperature': 0.7,
        'top_p': 0.9,
        'top_k': 40,
        'max_output_tokens': 100,
        'stop_sequences': ['END'],
        'presence_penalty': 0.5,
        'frequency_penalty': 0.3,
    }

  def test_partial_parameters(self):
    """Test building config with partial parameters."""
    config = types.GenerateContentConfig(
        temperature=0.5,
        max_output_tokens=50,
    )
    result = interactions_utils.build_generation_config(config)
    assert result == {
        'temperature': 0.5,
        'max_output_tokens': 50,
    }

  def test_empty_config(self):
    """Test building config with no parameters."""
    config = types.GenerateContentConfig()
    result = interactions_utils.build_generation_config(config)
    assert result == {}


class TestExtractSystemInstruction:
  """Tests for extract_system_instruction."""

  def test_string_instruction(self):
    """Test extracting string system instruction."""
    config = types.GenerateContentConfig(
        system_instruction='You are a helpful assistant.'
    )
    result = interactions_utils.extract_system_instruction(config)
    assert result == 'You are a helpful assistant.'

  def test_content_instruction(self):
    """Test extracting Content system instruction."""
    config = types.GenerateContentConfig(
        system_instruction=types.Content(
            parts=[
                types.Part(text='Be helpful.'),
                types.Part(text='Be concise.'),
            ]
        )
    )
    result = interactions_utils.extract_system_instruction(config)
    assert result == 'Be helpful.\nBe concise.'

  def test_no_instruction(self):
    """Test extracting when no system instruction."""
    config = types.GenerateContentConfig()
    result = interactions_utils.extract_system_instruction(config)
    assert result is None


class TestLlmRequestPreviousInteractionId:
  """Tests for previous_interaction_id field in LlmRequest."""

  def test_previous_interaction_id_default_none(self):
    """Test that previous_interaction_id defaults to None."""
    request = LlmRequest(model='gemini-2.5-flash', contents=[])
    assert request.previous_interaction_id is None

  def test_previous_interaction_id_can_be_set(self):
    """Test that previous_interaction_id can be set."""
    request = LlmRequest(
        model='gemini-2.5-flash',
        contents=[],
        previous_interaction_id='interaction_abc',
    )
    assert request.previous_interaction_id == 'interaction_abc'


class TestLlmResponseInteractionId:
  """Tests for interaction_id field in LlmResponse."""

  def test_interaction_id_in_response(self):
    """Test that interaction_id is properly set in LlmResponse."""
    from google.adk.models.llm_response import LlmResponse

    response = LlmResponse(
        content=types.Content(role='model', parts=[types.Part(text='Hi')]),
        interaction_id='interaction_xyz',
    )
    assert response.interaction_id == 'interaction_xyz'

  def test_interaction_id_default_none(self):
    """Test that interaction_id defaults to None."""
    from google.adk.models.llm_response import LlmResponse

    response = LlmResponse(
        content=types.Content(role='model', parts=[types.Part(text='Hi')]),
    )
    assert response.interaction_id is None


class TestGetLatestUserContents:
  """Tests for _get_latest_user_contents."""

  def test_empty_contents(self):
    """Test with empty contents list."""
    result = interactions_utils._get_latest_user_contents([])
    assert result == []

  def test_single_user_message(self):
    """Test with a single user message."""
    contents = [
        types.Content(role='user', parts=[types.Part(text='Hello')]),
    ]
    result = interactions_utils._get_latest_user_contents(contents)
    assert len(result) == 1
    assert result[0].parts[0].text == 'Hello'

  def test_consecutive_user_messages(self):
    """Test with multiple consecutive user messages at the end."""
    contents = [
        types.Content(role='model', parts=[types.Part(text='Response')]),
        types.Content(role='user', parts=[types.Part(text='First')]),
        types.Content(role='user', parts=[types.Part(text='Second')]),
    ]
    result = interactions_utils._get_latest_user_contents(contents)
    assert len(result) == 2
    assert result[0].parts[0].text == 'First'
    assert result[1].parts[0].text == 'Second'

  def test_stops_at_model_message(self):
    """Test that it stops when encountering a model message."""
    contents = [
        types.Content(role='user', parts=[types.Part(text='First user')]),
        types.Content(role='model', parts=[types.Part(text='Model response')]),
        types.Content(role='user', parts=[types.Part(text='Second user')]),
    ]
    result = interactions_utils._get_latest_user_contents(contents)
    assert len(result) == 1
    assert result[0].parts[0].text == 'Second user'

  def test_all_model_messages(self):
    """Test with only model messages returns empty list."""
    contents = [
        types.Content(role='model', parts=[types.Part(text='Response 1')]),
        types.Content(role='model', parts=[types.Part(text='Response 2')]),
    ]
    result = interactions_utils._get_latest_user_contents(contents)
    assert result == []

  def test_full_conversation(self):
    """Test with a full conversation, returns only latest user turn."""
    contents = [
        types.Content(role='user', parts=[types.Part(text='Hi')]),
        types.Content(role='model', parts=[types.Part(text='Hello!')]),
        types.Content(role='user', parts=[types.Part(text='How are you?')]),
        types.Content(role='model', parts=[types.Part(text='I am fine.')]),
        types.Content(role='user', parts=[types.Part(text='Great')]),
        types.Content(role='user', parts=[types.Part(text='Tell me more')]),
    ]
    result = interactions_utils._get_latest_user_contents(contents)
    assert len(result) == 2
    assert result[0].parts[0].text == 'Great'
    assert result[1].parts[0].text == 'Tell me more'


class TestConvertInteractionEventToLlmResponse:
  """Tests for convert_interaction_event_to_llm_response."""

  def test_text_delta_event(self):
    """Test converting a text delta event."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={'type': 'text', 'text': 'Hello world'},
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_123'
    )

    assert result is not None
    assert result.partial
    assert result.content.parts[0].text == 'Hello world'
    assert result.interaction_id == 'int_123'
    assert len(state.parts) == 1

  def test_image_delta_with_data(self):
    """Test converting an image delta with inline data."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'image',
            'data': base64.b64encode(b'image_bytes').decode('utf-8'),
            'mime_type': 'image/png',
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_img'
    )

    assert result is not None
    assert result.partial
    assert result.content.parts[0].inline_data.data == b'image_bytes'
    assert len(state.parts) == 1

  def test_thought_summary_delta(self):
    """thought_summary delta becomes a thought part."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'thought_summary',
            'content': {'type': 'text', 'text': 'Let me think...'},
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_t'
    )
    assert result is not None
    assert result.partial is True
    part = result.content.parts[0]
    assert part.text == 'Let me think...'
    assert part.thought is True
    assert len(state.parts) == 1

  def test_thought_signature_delta_attaches_to_last_thought(self):
    """thought_signature mutates the last thought part and emits no event."""
    state = interactions_utils._StreamState()
    interactions_utils.convert_interaction_event_to_llm_response(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={
                'type': 'thought_summary',
                'content': {'type': 'text', 'text': 'reasoning'},
            },
        ),
        state,
        interaction_id='int_ts',
    )
    sig_b64 = base64.b64encode(b'sig-bytes').decode('utf-8')
    result = interactions_utils.convert_interaction_event_to_llm_response(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={'type': 'thought_signature', 'signature': sig_b64},
        ),
        state,
        interaction_id='int_ts',
    )
    assert result is None
    assert state.parts[-1].thought_signature == b'sig-bytes'

  def test_audio_delta_with_data(self):
    """audio delta becomes an inline_data part via the shared media handler."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'audio',
            'data': base64.b64encode(b'audio_bytes').decode('utf-8'),
            'mime_type': 'audio/wav',
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_a'
    )
    assert result is not None
    assert result.partial is True
    assert result.content.parts[0].inline_data.data == b'audio_bytes'
    assert result.content.parts[0].inline_data.mime_type == 'audio/wav'
    assert len(state.parts) == 1

  def test_code_execution_call_delta(self):
    """code_execution_call delta becomes an executable_code part."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'code_execution_call',
            'arguments': {'code': 'print(1)', 'language': 'python'},
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_c'
    )
    assert result is not None
    part = result.content.parts[0]
    assert part.executable_code.code == 'print(1)'
    assert part.executable_code.language == types.Language.PYTHON
    assert len(state.parts) == 1

  def test_code_execution_result_delta(self):
    """code_execution_result delta becomes a code_execution_result part."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'code_execution_result',
            'result': '1\n',
            'is_error': False,
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_cr'
    )
    assert result is not None
    part = result.content.parts[0]
    assert part.code_execution_result.output == '1\n'
    assert part.code_execution_result.outcome == types.Outcome.OUTCOME_OK
    assert len(state.parts) == 1

  def test_code_execution_result_error_delta(self):
    """code_execution_result with is_error maps to OUTCOME_FAILED."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'code_execution_result',
            'result': 'Traceback (most recent call last): ...',
            'is_error': True,
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_cr_err'
    )
    assert result is not None
    part = result.content.parts[0]
    assert (
        part.code_execution_result.output
        == 'Traceback (most recent call last): ...'
    )
    assert part.code_execution_result.outcome == types.Outcome.OUTCOME_FAILED
    assert len(state.parts) == 1

  def test_google_search_call_delta(self):
    """google_search_call delta emits partial grounding web_search_queries."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'google_search_call',
            'arguments': {'queries': ['rocky project hail mary']},
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_s'
    )
    assert result is not None
    assert result.partial is True
    assert result.content is None
    assert result.grounding_metadata.web_search_queries == [
        'rocky project hail mary'
    ]
    assert state.web_search_queries == ['rocky project hail mary']

  def test_google_search_result_delta(self):
    """google_search_result delta emits a partial search_entry_point."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'google_search_result',
            'result': [{'search_suggestions': '<div>suggestions</div>'}],
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_sr'
    )
    assert result is not None
    assert result.partial is True
    assert (
        result.grounding_metadata.search_entry_point.rendered_content
        == '<div>suggestions</div>'
    )
    assert state.search_entry_point is not None

  def test_text_annotation_delta(self):
    """url_citation annotations become grounding chunks + supports."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'text_annotation_delta',
            'annotations': [{
                'type': 'url_citation',
                'url': 'https://example.com',
                'title': 'Example',
                'start_index': 0,
                'end_index': 5,
            }],
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_an'
    )
    assert result is not None
    assert result.partial is True
    chunk = result.grounding_metadata.grounding_chunks[0]
    assert chunk.web.uri == 'https://example.com'
    assert chunk.web.title == 'Example'
    support = result.grounding_metadata.grounding_supports[0]
    assert support.grounding_chunk_indices == [0]
    assert support.segment.start_index == 0
    assert support.segment.end_index == 5
    assert len(state.grounding_chunks) == 1
    assert len(state.grounding_supports) == 1

  def test_function_result_delta(self):
    """function_result delta becomes a function_response part."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={
            'type': 'function_result',
            'call_id': 'call_9',
            'result': {'temp': 72},
        },
    )
    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_fr'
    )
    assert result is not None
    part = result.content.parts[0]
    assert part.function_response.id == 'call_9'
    assert part.function_response.response == {'temp': 72}
    assert len(state.parts) == 1

  def test_grounding_accumulated_into_final_event(self):
    """Grounding from partial deltas is reattached to the final event."""
    state = interactions_utils._StreamState()
    conv = interactions_utils.convert_interaction_event_to_llm_response
    conv(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={
                'type': 'google_search_call',
                'arguments': {'queries': ['q1']},
            },
        ),
        state,
        interaction_id='int_f',
    )
    conv(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={
                'type': 'google_search_result',
                'result': [{'search_suggestions': '<div>s</div>'}],
            },
        ),
        state,
        interaction_id='int_f',
    )
    conv(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={'type': 'text', 'text': 'Mark Watney.'},
        ),
        state,
        interaction_id='int_f',
    )
    conv(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={
                'type': 'text_annotation_delta',
                'annotations': [{
                    'type': 'url_citation',
                    'url': 'https://e.com',
                    'title': 'E',
                    'start_index': 0,
                    'end_index': 4,
                }],
            },
        ),
        state,
        interaction_id='int_f',
    )
    final = conv(
        InteractionCompletedEvent(
            event_type='interaction.completed',
            interaction=InteractionSseEventInteraction(
                id='int_f', status='completed', steps=[]
            ),
        ),
        state,
        interaction_id='int_f',
    )
    assert final is not None
    assert final.partial is False
    assert final.turn_complete is True
    assert final.content.parts[0].text == 'Mark Watney.'
    gm = final.grounding_metadata
    assert gm.web_search_queries == ['q1']
    assert gm.search_entry_point.rendered_content == '<div>s</div>'
    assert gm.grounding_chunks[0].web.uri == 'https://e.com'
    assert gm.grounding_supports[0].grounding_chunk_indices == [0]

  def test_final_event_includes_usage_metadata(self):
    """The streaming final event carries usage_metadata from the interaction."""
    state = interactions_utils._StreamState()
    conv = interactions_utils.convert_interaction_event_to_llm_response
    conv(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={'type': 'text', 'text': 'Answer.'},
        ),
        state,
        interaction_id='int_u1',
    )
    final = conv(
        InteractionCompletedEvent(
            event_type='interaction.completed',
            interaction=InteractionSseEventInteraction(
                id='int_u1',
                status='completed',
                steps=[],
                usage=Usage(total_input_tokens=12, total_output_tokens=7),
            ),
        ),
        state,
        interaction_id='int_u1',
    )
    assert final is not None
    assert final.partial is False
    assert final.usage_metadata is not None
    assert final.usage_metadata.prompt_token_count == 12
    assert final.usage_metadata.candidates_token_count == 7
    assert final.usage_metadata.total_token_count == 19

  def test_final_event_without_usage_has_no_usage_metadata(self):
    """No interaction.usage -> final event has usage_metadata None."""
    state = interactions_utils._StreamState()
    conv = interactions_utils.convert_interaction_event_to_llm_response
    conv(
        StepDelta(
            event_type='step.delta',
            index=0,
            delta={'type': 'text', 'text': 'Answer.'},
        ),
        state,
        interaction_id='int_u2',
    )
    final = conv(
        InteractionCompletedEvent(
            event_type='interaction.completed',
            interaction=InteractionSseEventInteraction(
                id='int_u2', status='completed', steps=[]
            ),
        ),
        state,
        interaction_id='int_u2',
    )
    assert final is not None
    assert final.partial is False
    assert final.usage_metadata is None

  def test_known_unhandled_delta_type_logs_debug_and_drops(self, caplog):
    """A known but unhandled delta type logs at debug and emits no event."""
    # 'url_context_call' is a recognized genai delta variant that ADK does not
    # handle yet, so it must fall through to the debug branch (not a warning).
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={'type': 'url_context_call', 'arguments': {}},
    )
    state = interactions_utils._StreamState()
    with caplog.at_level(logging.DEBUG, logger=interactions_utils.logger.name):
      result = interactions_utils.convert_interaction_event_to_llm_response(
          event, state, interaction_id='int_u'
      )
    assert result is None
    assert not state.parts
    debug_records = [
        r
        for r in caplog.records
        if r.levelno == logging.DEBUG
        and 'unhandled step delta type' in r.message
    ]
    assert len(debug_records) == 1
    assert not [r for r in caplog.records if r.levelno >= logging.WARNING]

  def test_unrecognized_delta_logs_raw_warning_and_drops(self, caplog):
    """A truly-unrecognized delta logs a warning preserving its raw payload."""
    event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={'type': 'totally_made_up_xyz', 'foo': 'bar'},
    )
    state = interactions_utils._StreamState()
    with caplog.at_level(
        logging.WARNING, logger=interactions_utils.logger.name
    ):
      result = interactions_utils.convert_interaction_event_to_llm_response(
          event, state, interaction_id='int_u2'
      )
    assert result is None
    assert not state.parts
    warnings = [
        r
        for r in caplog.records
        if r.levelno == logging.WARNING
        and 'unrecognized step delta' in r.message
    ]
    assert len(warnings) == 1
    assert 'foo' in warnings[0].message
    # The full raw payload (not just delta.type='UNKNOWN') is preserved.
    assert warnings[0].args == {'type': 'totally_made_up_xyz', 'foo': 'bar'}

  def test_unknown_event_type_returns_none(self):
    """Test that unknown event types return None."""
    event = MagicMock()
    event.event_type = 'some_unknown_event'  # Unknown event type

    state = interactions_utils._StreamState()
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event, state, interaction_id='int_other'
    )

    assert result is None
    assert not state.parts

  def test_completed_event_failed_partial_interaction(self):
    """A failed lifecycle event with a partial interaction does not crash."""
    event = InteractionCompletedEvent(
        event_type='interaction.completed',
        interaction=InteractionSseEventInteraction(
            id='int_failed',
            status='failed',
            steps=[],
        ),
    )
    result = interactions_utils.convert_interaction_event_to_llm_response(
        event,
        state=interactions_utils._StreamState(),
        interaction_id='int_failed',
    )
    assert result is not None
    assert result.error_code == 'UNKNOWN_ERROR'
    assert result.interaction_id == 'int_failed'

  def test_function_call_streaming_flow(self):
    """Test the complete streaming flow for function calls (Start, Delta, Stop)."""
    # 1. StepStart
    start_event = StepStart(
        event_type='step.start',
        index=0,
        step=FunctionCallStep(
            type='function_call',
            id='call_1',
            name='get_weather',
            arguments={},
        ),
    )
    state = interactions_utils._StreamState()
    result1 = interactions_utils.convert_interaction_event_to_llm_response(
        start_event, state, interaction_id='int_123'
    )

    assert result1 is not None
    assert result1.partial is True
    assert len(state.parts) == 1
    fc = state.parts[-1].function_call
    assert fc
    assert fc.name == 'get_weather'
    assert fc.id == 'call_1'
    assert fc.partial_args == []

    # 2. StepDelta
    delta_event1 = StepDelta(
        event_type='step.delta',
        index=0,
        delta={'type': 'arguments_delta', 'arguments': '{"city": '},
    )
    result2 = interactions_utils.convert_interaction_event_to_llm_response(
        delta_event1, state, interaction_id='int_123'
    )

    assert result2 is not None
    assert result2.partial is True
    assert (
        result2.content.parts[0].function_call.partial_args[0].string_value
        == '{"city": '
    )

    delta_event2 = StepDelta(
        event_type='step.delta',
        index=0,
        delta={'type': 'arguments_delta', 'arguments': '"Paris"}'},
    )
    result3 = interactions_utils.convert_interaction_event_to_llm_response(
        delta_event2, state, interaction_id='int_123'
    )

    assert result3 is not None
    assert len(state.parts[0].function_call.partial_args) == 2

    # 3. StepStop
    stop_event = StepStop(
        event_type='step.stop',
        index=0,
    )
    result4 = interactions_utils.convert_interaction_event_to_llm_response(
        stop_event, state, interaction_id='int_123'
    )

    assert result4 is None
    assert state.parts[0].function_call.args == {'city': 'Paris'}
    assert state.parts[0].function_call.partial_args is None

  def test_function_call_streaming_json_parse_error(self, caplog):
    """Test function call streaming returns an error response on JSON parse error."""
    # 1. StepStart
    start_event = StepStart(
        event_type='step.start',
        index=0,
        step=FunctionCallStep(
            type='function_call',
            id='call_err',
            name='bad_json_tool',
            arguments={},
        ),
    )
    state = interactions_utils._StreamState()
    interactions_utils.convert_interaction_event_to_llm_response(
        start_event, state, interaction_id='int_err'
    )

    # 2. StepDelta (invalid JSON)
    delta_event = StepDelta(
        event_type='step.delta',
        index=0,
        delta={'type': 'arguments_delta', 'arguments': '{"broken": "json'},
    )
    interactions_utils.convert_interaction_event_to_llm_response(
        delta_event, state, interaction_id='int_err'
    )

    # 3. StepStop
    stop_event = StepStop(
        event_type='step.stop',
        index=0,
    )
    result = interactions_utils.convert_interaction_event_to_llm_response(
        stop_event, state, interaction_id='int_err'
    )

    # Assert an error LlmResponse is returned
    assert result is not None
    assert result.error_code == 'JSON_PARSE_ERROR'
    assert result.error_message == 'Failed to parse function call arguments'
    assert result.turn_complete is True
    assert result.interaction_id == 'int_err'

    # The logging check can remain to ensure the raw exception is still logged.
    assert 'Failed to parse function call args' in caplog.text


@pytest.mark.parametrize(
    ('streamed_events_factory', 'expected_ids'),
    [
        pytest.param(
            _build_lifecycle_streamed_events,
            ['interaction_123'],
            id='lifecycle-events',
        ),
        pytest.param(
            _build_complete_streamed_events,
            ['interaction_complete_123'],
            id='complete-event',
        ),
        pytest.param(
            _build_legacy_streamed_events,
            ['interaction_legacy_123'],
            id='legacy-event',
        ),
    ],
)
def test_generate_content_via_interactions_stream_extracts_interaction_id(
    streamed_events_factory: Callable[[FunctionCallStep], list[object]],
    expected_ids: list[str],
    fc_step: FunctionCallStep,
):
  """Streamed interaction IDs should be preserved across event variants."""
  streamed_events = streamed_events_factory(fc_step)

  assert (
      asyncio.run(_collect_function_call_interaction_ids(streamed_events))
      == expected_ids
  )


def _build_simple_text_stream() -> list[object]:
  """A minimal streamed interaction: created -> text delta -> completed."""
  now = datetime.now(timezone.utc).isoformat()
  created = InteractionCreatedEvent(
      event_type='interaction.created',
      interaction=InteractionSseEventInteraction(
          id='interaction_xyz',
          created=now,
          updated=now,
          status='requires_action',
          steps=[],
      ),
  )
  step_start = StepStart(
      event_type='step.start',
      index=0,
      step=ModelOutputStep(type='model_output'),
  )
  step_delta = StepDelta(
      event_type='step.delta',
      index=0,
      delta={'type': 'text', 'text': 'Sunny in Tokyo.'},
  )
  step_stop = StepStop(event_type='step.stop', index=0)
  completed = InteractionCompletedEvent(
      event_type='interaction.completed',
      interaction=InteractionSseEventInteraction(
          id='interaction_xyz',
          created=now,
          updated=now,
          status='completed',
          steps=[
              ModelOutputStep(
                  type='model_output',
                  content=[TextContent(type='text', text='Sunny in Tokyo.')],
              )
          ],
      ),
  )
  return [created, step_start, step_delta, step_stop, completed]


async def _collect_stream_responses(events: list[object]):
  api_client = _FakeApiClient(events)
  llm_request = _build_llm_request()
  responses = []
  async for resp in interactions_utils.generate_content_via_interactions(
      api_client, llm_request, stream=True
  ):
    responses.append(resp)
  return responses


async def test_generate_content_via_interactions_stream_characterization():
  """Streaming yields text responses carrying the interaction id."""
  responses = await _collect_stream_responses(_build_simple_text_stream())

  assert responses, 'expected at least one streamed LlmResponse'
  assert all(r.interaction_id == 'interaction_xyz' for r in responses)
  joined = ''.join(
      part.text
      for r in responses
      if r.content and r.content.parts
      for part in r.content.parts
      if part.text
  )
  assert 'Sunny in Tokyo.' in joined


def _build_non_streaming_interaction() -> Interaction:
  """A completed non-streaming Interaction with a single text output."""
  now = datetime.now(timezone.utc).isoformat()
  return Interaction(
      id='interaction_ns',
      status='completed',
      created=now,
      updated=now,
      steps=[
          ModelOutputStep(
              type='model_output',
              content=[TextContent(type='text', text='Sunny in Tokyo.')],
          )
      ],
  )


async def _drain(
    responses: AsyncGenerator[LlmResponse, None],
) -> list[LlmResponse]:
  """Collect all responses yielded by an async generator."""
  return [resp async for resp in responses]


async def test_create_interactions_streaming_forwards_kwargs_and_converts():
  """Streaming forwards create_kwargs verbatim (plus stream) and converts."""
  # Arrange.
  api_client = _FakeApiClient(_build_simple_text_stream())
  create_kwargs = {
      'model': 'gemini-2.5-flash',
      'input': [{
          'type': 'user_input',
          'content': [{'type': 'text', 'text': 'Weather in Tokyo?'}],
      }],
      'previous_interaction_id': None,
  }

  # Act.
  responses = await _drain(
      interactions_utils._create_interactions(
          api_client, create_kwargs=create_kwargs, stream=True
      )
  )

  # Assert: exactly one create() call forwarding kwargs plus the stream flag.
  assert len(api_client.create_calls) == 1
  assert api_client.create_calls[0] == {
      **create_kwargs,
      'stream': True,
      'extra_headers': None,
  }

  # Assert: the streamed events are converted into text responses.
  assert responses, 'expected at least one streamed LlmResponse'
  assert all(r.interaction_id == 'interaction_xyz' for r in responses)
  joined = ''.join(
      part.text
      for r in responses
      if r.content and r.content.parts
      for part in r.content.parts
      if part.text
  )
  assert 'Sunny in Tokyo.' in joined


async def test_create_interactions_non_streaming_forwards_kwargs_and_yields_single_response():
  """Non-streaming forwards kwargs verbatim and yields a single response."""
  # Arrange.
  interaction = _build_non_streaming_interaction()
  api_client = _FakeApiClient(interaction=interaction)
  create_kwargs = {
      'model': 'gemini-2.5-flash',
      'input': [{
          'type': 'user_input',
          'content': [{'type': 'text', 'text': 'Weather in Tokyo?'}],
      }],
      'previous_interaction_id': None,
  }

  # Act.
  responses = await _drain(
      interactions_utils._create_interactions(
          api_client, create_kwargs=create_kwargs, stream=False
      )
  )

  # Assert: exactly one create() call forwarding kwargs plus the stream flag.
  assert len(api_client.create_calls) == 1
  assert api_client.create_calls[0] == {
      **create_kwargs,
      'stream': False,
      'extra_headers': None,
  }

  # Assert: a single converted LlmResponse carrying the interaction output.
  assert len(responses) == 1
  assert responses[0].interaction_id == 'interaction_ns'
  assert responses[0].content.parts[0].text == 'Sunny in Tokyo.'


async def test_generate_content_via_interactions_non_streaming_yields_single_response():
  """The public function yields a single response on the non-streaming path."""
  # Arrange.
  api_client = _FakeApiClient(interaction=_build_non_streaming_interaction())

  # Act.
  responses = await _drain(
      interactions_utils.generate_content_via_interactions(
          api_client, _build_llm_request(), stream=False
      )
  )

  # Assert: a single end-to-end converted LlmResponse with the expected text.
  assert len(responses) == 1
  assert responses[0].interaction_id == 'interaction_ns'
  assert responses[0].content.parts[0].text == 'Sunny in Tokyo.'


def _build_stream_with_environment() -> list[object]:
  """A streamed interaction whose completed event carries an environment id."""
  now = datetime.now(timezone.utc).isoformat()
  created = InteractionCreatedEvent(
      event_type='interaction.created',
      interaction=InteractionSseEventInteraction(
          id='interaction_env',
          created=now,
          updated=now,
          status='requires_action',
          steps=[],
      ),
  )
  step_start = StepStart(
      event_type='step.start',
      index=0,
      step=ModelOutputStep(type='model_output'),
  )
  step_delta = StepDelta(
      event_type='step.delta',
      index=0,
      delta={'type': 'text', 'text': 'hi'},
  )
  step_stop = StepStop(event_type='step.stop', index=0)
  completed = InteractionCompletedEvent(
      event_type='interaction.completed',
      interaction=InteractionSseEventInteraction(
          id='interaction_env',
          created=now,
          updated=now,
          status='completed',
          environment_id='env_xyz',
          steps=[
              ModelOutputStep(
                  type='model_output',
                  content=[TextContent(type='text', text='hi')],
              )
          ],
      ),
  )
  return [created, step_start, step_delta, step_stop, completed]


def test_create_interactions_surfaces_environment_id():
  api_client = _FakeApiClient(_build_stream_with_environment())

  async def _collect():
    out = []
    async for r in interactions_utils._create_interactions(
        api_client,
        create_kwargs={'agent': 'agents/a', 'input': []},
        stream=True,
    ):
      out.append(r)
    return out

  responses = asyncio.run(_collect())
  assert responses, 'expected streamed responses'
  # The env id arrives only on the completed event, so earlier partial
  # responses carry no environment id.
  assert responses[0].environment_id is None
  assert responses[-1].environment_id == 'env_xyz'


class _FakeNonStreamInteractions:
  """Fake interactions resource returning a full Interaction (non-streaming)."""

  def __init__(self, interaction: Interaction):
    self._interaction = interaction

  async def create(self, **_kwargs):
    return self._interaction


class _FakeNonStreamAio:
  """Namespace matching the expected api_client.aio shape (non-streaming)."""

  def __init__(self, interaction: Interaction):
    self.interactions = _FakeNonStreamInteractions(interaction)


class _FakeNonStreamApiClient:
  """Minimal fake API client whose create() returns a full Interaction."""

  def __init__(self, interaction: Interaction):
    self.aio = _FakeNonStreamAio(interaction)


def test_create_interactions_surfaces_environment_id_non_stream():
  interaction = Interaction(
      id='interaction_ns',
      status='completed',
      created=datetime.now(timezone.utc).isoformat(),
      updated=datetime.now(timezone.utc).isoformat(),
      environment_id='env_ns',
      steps=[
          ModelOutputStep(
              type='model_output',
              content=[TextContent(type='text', text='hi')],
          )
      ],
  )
  api_client = _FakeNonStreamApiClient(interaction)

  async def _collect():
    out = []
    async for r in interactions_utils._create_interactions(
        api_client,
        create_kwargs={'agent': 'agents/a', 'input': []},
        stream=False,
    ):
      out.append(r)
    return out

  responses = asyncio.run(_collect())
  assert len(responses) == 1
  assert responses[-1].environment_id == 'env_ns'


class TestBuildMcpServerParam:
  """Tests for _build_mcp_server_param."""

  def _server(self, **kwargs):
    from google.adk.tools._remote_mcp_server import RemoteMcpServer

    kwargs.setdefault('url', 'https://mcp.example.com/mcp')
    return RemoteMcpServer(**kwargs)

  def test_minimal_url_only(self):
    param = interactions_utils._build_mcp_server_param(self._server(), {})
    assert param == {
        'type': 'mcp_server',
        'url': 'https://mcp.example.com/mcp',
    }

  def test_with_name(self):
    param = interactions_utils._build_mcp_server_param(
        self._server(name='maps'), {}
    )
    assert param['name'] == 'maps'

  def test_with_headers(self):
    param = interactions_utils._build_mcp_server_param(
        self._server(), {'X-Goog-Api-Key': 'k'}
    )
    assert param['headers'] == {'X-Goog-Api-Key': 'k'}

  def test_with_allowed_tools(self):
    param = interactions_utils._build_mcp_server_param(
        self._server(allowed_tools=['search_places']), {}
    )
    assert param['allowed_tools'] == [{'tools': ['search_places']}]

  def test_omits_unset_fields(self):
    param = interactions_utils._build_mcp_server_param(self._server(), {})
    assert 'name' not in param
    assert 'headers' not in param
    assert 'allowed_tools' not in param


async def test_create_interactions_forwards_extra_headers_streaming():
  """extra_headers is forwarded to interactions.create on the streaming path."""
  api_client = _FakeApiClient(_build_simple_text_stream())
  create_kwargs = {
      'model': 'gemini-2.5-flash',
      'input': [],
      'previous_interaction_id': None,
  }

  await _drain(
      interactions_utils._create_interactions(
          api_client,
          create_kwargs=create_kwargs,
          stream=True,
          extra_headers={'x-custom': 'v'},
      )
  )

  assert api_client.create_calls[0]['extra_headers'] == {'x-custom': 'v'}


async def test_create_interactions_forwards_extra_headers_non_streaming():
  """extra_headers is forwarded to interactions.create on the non-stream path."""
  api_client = _FakeApiClient(interaction=_build_non_streaming_interaction())
  create_kwargs = {
      'model': 'gemini-2.5-flash',
      'input': [],
      'previous_interaction_id': None,
  }

  await _drain(
      interactions_utils._create_interactions(
          api_client,
          create_kwargs=create_kwargs,
          stream=False,
          extra_headers={'x-custom': 'v'},
      )
  )

  assert api_client.create_calls[0]['extra_headers'] == {'x-custom': 'v'}


async def test_generate_content_via_interactions_forwards_request_headers():
  """User headers on the request reach interactions.create as extra_headers."""
  api_client = _FakeApiClient(_build_simple_text_stream())
  llm_request = _build_llm_request()
  llm_request.config.http_options = types.HttpOptions(headers={'x-custom': 'v'})

  await _drain(
      interactions_utils.generate_content_via_interactions(
          api_client, llm_request, stream=True
      )
  )

  extra_headers = api_client.create_calls[0]['extra_headers']
  assert extra_headers['x-custom'] == 'v'
  assert 'google-adk/' in extra_headers['x-goog-api-client']


async def test_generate_content_via_interactions_sends_tracking_headers_without_config_headers():
  """With no request headers, tracking headers are still forwarded."""
  from google.adk.utils._google_client_headers import get_tracking_headers

  api_client = _FakeApiClient(_build_simple_text_stream())

  await _drain(
      interactions_utils.generate_content_via_interactions(
          api_client, _build_llm_request(), stream=True
      )
  )

  assert api_client.create_calls[0]['extra_headers'] == get_tracking_headers()
