chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,420 @@
# 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.
from __future__ import annotations
from google.adk.evaluation import conversation_scenarios
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulator
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
from google.adk.evaluation.simulation.user_simulator import Status
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from google.adk.events.event import Event
from google.genai import types
from pydantic import ValidationError
import pytest
_INPUT_EVENTS = [
Event(
author="user",
content=types.Content(
parts=[types.Part(text="Can you help me?")], role="user"
),
invocation_id="inv1",
),
Event(
author="helpful_assistant",
content=types.Content(
parts=[
types.Part(
text="I'll get the user's name and greet them first.",
thought=True,
),
types.Part(
function_call=types.FunctionCall(name="get_user_name")
),
types.Part(
function_response=types.FunctionResponse(
name="get_user_name",
response={"name": "John Doe"},
)
),
types.Part(text="Hi John, what can I do for you?"),
],
role="model",
),
invocation_id="inv1",
),
]
_INPUT_EVENTS_LONG = _INPUT_EVENTS + [
Event(
author="user",
content=types.Content(
parts=[types.Part(text="I need to book a flight.")], role="user"
),
invocation_id="inv2",
),
Event(
author="helpful_assistant",
content=types.Content(
parts=[
types.Part(
text="Sure, what is your departure date and destination?",
),
],
role="model",
),
invocation_id="inv2",
),
]
_EXPECTED_REWRITTEN_DIALOGUE = """user: Can you help me?
helpful_assistant: Hi John, what can I do for you?"""
_EXPECTED_REWRITTEN_DIALOGUE_LONG = _EXPECTED_REWRITTEN_DIALOGUE + """
user: I need to book a flight.
helpful_assistant: Sure, what is your departure date and destination?"""
def test_llm_backed_user_simulator_config_validation():
"""Tests for LlmBackedUserSimulatorConfig."""
config = LlmBackedUserSimulatorConfig(custom_instructions=None)
assert config.custom_instructions is None
valid_instructions = (
"{{ stop_signal }} {{ conversation_plan }} {{ conversation_history }}"
)
config = LlmBackedUserSimulatorConfig(custom_instructions=valid_instructions)
assert config.custom_instructions == valid_instructions
invalid_instructions = "Instructions with missing formatting placeholders"
with pytest.raises(ValidationError):
LlmBackedUserSimulatorConfig(custom_instructions=invalid_instructions)
class TestHelperMethods:
"""Test cases for LlmBackedUserSimulator helper methods."""
def test_convert_conversation_to_user_sim_pov(self):
"""Tests _convert_conversation_to_user_sim_pov method."""
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
_INPUT_EVENTS
)
assert rewritten_dialogue == _EXPECTED_REWRITTEN_DIALOGUE
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
_INPUT_EVENTS_LONG
)
assert rewritten_dialogue == _EXPECTED_REWRITTEN_DIALOGUE_LONG
def test_summarize_conversation_with_function_calls(self):
"""Tests _summarize_conversation with include_function_calls=True."""
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
_INPUT_EVENTS, include_function_calls=True
)
expected = (
"user: Can you help me?\n\n"
"helpful_assistant called tool 'get_user_name' with args: None\n\n"
"Tool 'get_user_name' returned: {'name': 'John Doe'}\n\n"
"helpful_assistant: Hi John, what can I do for you?"
)
assert rewritten_dialogue == expected
async def to_async_iter(items):
for item in items:
yield item
@pytest.fixture
def mock_llm_agent(mocker):
"""Provides a mock LLM agent."""
mock_llm_registry_cls = mocker.patch(
"google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry",
autospec=True,
)
mock_llm_registry = mocker.MagicMock()
mock_llm_registry_cls.return_value = mock_llm_registry
mock_agent = mocker.MagicMock()
mock_llm_registry.resolve.return_value.return_value = mock_agent
return mock_agent
@pytest.fixture
def conversation_scenario():
"""Provides a test conversation scenario."""
return conversation_scenarios.ConversationScenario(
starting_prompt="Hello", conversation_plan="test plan"
)
@pytest.fixture
def user_persona():
"""Provides a test user persona."""
return UserPersona(
id="test_persona",
description="A test persona",
behaviors=[
UserBehavior(
name="polite",
description="is polite",
behavior_instructions=["Always say please and thank you."],
violation_rubrics=["is rude"],
)
],
)
@pytest.fixture
def conversation_scenario_with_persona(user_persona):
"""Provides a test conversation scenario with a user persona."""
return conversation_scenarios.ConversationScenario(
starting_prompt="Hello",
conversation_plan="test plan with persona",
user_persona=user_persona,
)
@pytest.fixture
def simulator(mock_llm_agent, conversation_scenario):
"""Provides an LlmBackedUserSimulator instance for testing."""
config = LlmBackedUserSimulatorConfig(
model="test-model",
)
sim = LlmBackedUserSimulator(
config=config, conversation_scenario=conversation_scenario
)
sim._invocation_count = 1 # Bypass starting prompt by default for tests
return sim
@pytest.fixture
def simulator_with_persona(mock_llm_agent, conversation_scenario_with_persona):
"""Provides an LlmBackedUserSimulator instance for testing."""
config = LlmBackedUserSimulatorConfig(
model="test-model",
)
sim = LlmBackedUserSimulator(
config=config, conversation_scenario=conversation_scenario_with_persona
)
sim._invocation_count = 1 # Bypass starting prompt by default for tests
return sim
class TestLlmBackedUserSimulator:
"""Test cases for LlmBackedUserSimulator main methods."""
@pytest.mark.asyncio
async def test_get_llm_response_return_value(
self, simulator, mock_llm_agent, mocker
):
"""Tests that _get_llm_response returns the full response correctly."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[
types.Part(text="some thought", thought=True),
types.Part(text="Hello world!"),
]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
response, error_reason = await simulator._get_llm_response(
rewritten_dialogue=""
)
assert response == "Hello world!"
assert error_reason is None
@pytest.mark.asyncio
async def test_get_next_user_message_first_invocation(
self, simulator, mock_llm_agent, conversation_scenario
):
"""Tests that the first invocation returns the starting prompt."""
simulator._invocation_count = 0 # override testing default
next_user_message = await simulator.get_next_user_message(events=[])
expected_user_message = types.Content(
parts=[types.Part(text=conversation_scenario.starting_prompt)],
role="user",
)
assert next_user_message.status == Status.SUCCESS
assert next_user_message.user_message == expected_user_message
mock_llm_agent.generate_content_async.assert_not_called()
@pytest.mark.asyncio
async def test_turn_limit_reached(self, conversation_scenario):
"""Tests get_next_user_message when the turn limit is reached."""
config = LlmBackedUserSimulatorConfig(
max_allowed_invocations=1,
)
simulator = LlmBackedUserSimulator(
config=config, conversation_scenario=conversation_scenario
)
simulator._invocation_count = 1
next_user_message = await simulator.get_next_user_message(
events=_INPUT_EVENTS
)
assert next_user_message.status == Status.TURN_LIMIT_REACHED
assert next_user_message.user_message is None
@pytest.mark.asyncio
async def test_stop_signal_detected(self, simulator, mock_llm_agent, mocker):
"""Tests get_next_user_message when the stop signal is detected."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[types.Part(text="Thanks! Bye!</finished>")]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
next_user_message = await simulator.get_next_user_message(
events=_INPUT_EVENTS
)
assert next_user_message.status == Status.STOP_SIGNAL_DETECTED
assert next_user_message.user_message is None
@pytest.mark.asyncio
async def test_no_message_generated_empty_response(
self, simulator, mock_llm_agent
):
"""Tests get_next_user_message when no message is generated (empty stream)."""
mock_llm_agent.generate_content_async.return_value = to_async_iter([])
with pytest.raises(
RuntimeError,
match="Failed to generate a user message: LLM returned empty response",
):
await simulator.get_next_user_message(events=_INPUT_EVENTS)
@pytest.mark.asyncio
async def test_get_next_user_message_safety_blocked(
self, simulator, mock_llm_agent, mocker
):
"""Tests get_next_user_message when response is safety blocked."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.content = None
mock_llm_response.error_code = "SAFETY"
mock_llm_response.error_message = "Blocked by safety"
mock_llm_response.parts = []
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
with pytest.raises(
RuntimeError,
match=(
"Failed to generate a user message: safety filters or other error"
" \\(code=SAFETY\\)"
),
):
await simulator.get_next_user_message(events=_INPUT_EVENTS)
@pytest.mark.asyncio
async def test_get_next_user_message_thinking_only(
self, simulator, mock_llm_agent, mocker
):
"""Tests get_next_user_message when response contains only thinking tokens."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.content = types.Content(
parts=[
types.Part(text="thinking...", thought=True),
]
)
mock_llm_response.error_code = None
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
with pytest.raises(
RuntimeError,
match=(
"Failed to generate a user message: LLM returned only thinking"
" tokens"
),
):
await simulator.get_next_user_message(events=_INPUT_EVENTS)
@pytest.mark.asyncio
async def test_get_next_user_message_success(
self, simulator, mock_llm_agent, mocker
):
"""Tests get_next_user_message when the user message is generated successfully."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[types.Part(text="I need to book a flight.")]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
next_user_message = await simulator.get_next_user_message(
events=_INPUT_EVENTS
)
expected_user_message = types.Content(
parts=[types.Part(text="I need to book a flight.")], role="user"
)
assert next_user_message.status == Status.SUCCESS
assert next_user_message.user_message == expected_user_message
@pytest.mark.asyncio
async def test_get_next_user_message_with_persona_success(
self, simulator_with_persona, mock_llm_agent, mocker
):
"""Tests get_next_user_message when the user message is generated successfully."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[types.Part(text="I need to book a flight.")]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
next_user_message = await simulator_with_persona.get_next_user_message(
events=_INPUT_EVENTS
)
expected_user_message = types.Content(
parts=[types.Part(text="I need to book a flight.")], role="user"
)
assert next_user_message.status == Status.SUCCESS
assert next_user_message.user_message == expected_user_message
@@ -0,0 +1,280 @@
# 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.
import textwrap
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _get_user_simulator_instructions_template
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import get_llm_backed_user_simulator_prompt
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import is_valid_user_simulator_template
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from jinja2.exceptions import SecurityError
import pytest
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
Default template
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Stop signal
{{stop_signal}}
""").strip()
_MOCK_PERSONA_TEMPLATE = textwrap.dedent("""\
Persona template
# Persona Description
{{persona.description}}
{% for b in persona.behaviors %}
## {{ b.name }}
{{ b.description }}
Instructions:
{{ b.get_behavior_instructions_str() }}
{% endfor %}
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Stop signal
{{stop_signal}}
""").strip()
class TestGetUserSimulatorInstructionsTemplate:
"""Test cases for _get_user_simulator_instructions_template."""
def test_get_user_simulator_instructions_template_default(self):
assert (
_get_user_simulator_instructions_template()
== _DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE
)
def test_get_user_simulator_instructions_template_with_custom_instructions(
self,
):
custom_instructions = "custom instructions"
assert (
_get_user_simulator_instructions_template(
custom_instructions=custom_instructions
)
== custom_instructions
)
def test_get_user_simulator_instructions_template_with_persona(self):
user_persona = UserPersona(
id="test_persona", description="Test persona", behaviors=[]
)
assert (
_get_user_simulator_instructions_template(user_persona=user_persona)
== _USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE
)
def test_get_user_simulator_instructions_template_with_bad_custom_instructions_raises_error(
self,
):
custom_instructions = "custom instructions"
user_persona = UserPersona(
id="test_persona", description="Test persona", behaviors=[]
)
with pytest.raises(ValueError):
_get_user_simulator_instructions_template(
custom_instructions=custom_instructions, user_persona=user_persona
)
sample_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="Test behavior",
description="Test behavior description",
behavior_instructions=["instruction 1", "instruction 2"],
violation_rubrics=["rubric 1"],
)
],
)
class TestGetLlmBackedUserSimulatorPrompt:
"""Test cases for get_llm_backed_user_simulator_prompt."""
def test_get_llm_backed_user_simulator_prompt_default(self, mocker):
mocker.patch(
"google.adk.evaluation.simulation.llm_backed_user_simulator_prompts._DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE",
_MOCK_DEFAULT_TEMPLATE,
)
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
)
expected_prompt = textwrap.dedent("""\
Default template
# Conversation Plan
test plan
# Conversation History
test history
# Stop signal
test stop""").strip()
assert prompt == expected_prompt
def test_get_llm_backed_user_simulator_prompt_with_custom_instructions(self):
custom_instructions = textwrap.dedent("""\
Custom instructions:
# Past history
{{conversation_plan}}
# Plan
{{conversation_plan}}
# Finished!
{{stop_signal}}""").strip()
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
custom_instructions=custom_instructions,
)
expected_prompt = textwrap.dedent("""\
Custom instructions:
# Past history
test plan
# Plan
test plan
# Finished!
test stop""").strip()
assert prompt == expected_prompt
def test_get_llm_backed_user_simulator_prompt_with_persona(self, mocker):
mocker.patch(
"google.adk.evaluation.simulation.llm_backed_user_simulator_prompts._USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE",
_MOCK_PERSONA_TEMPLATE,
)
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=sample_persona,
)
expected_prompt = textwrap.dedent("""\
Persona template
# Persona Description
Test persona description
## Test behavior
Test behavior description
Instructions:
* instruction 1
* instruction 2
# Conversation Plan
test plan
# Conversation History
test history
# Stop signal
test stop""").strip()
assert prompt == expected_prompt
def test_get_llm_backed_user_simulator_prompt_renders_persona_templates_in_sandbox(
self,
):
user_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="Behavior {{ stop_signal }}",
description="Description {{ stop_signal }}",
behavior_instructions=["instruction {{ stop_signal }}"],
violation_rubrics=["rubric 1"],
)
],
)
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=user_persona,
)
assert "## Behavior test stop" in prompt
assert "Description test stop" in prompt
assert " * instruction test stop" in prompt
def test_get_llm_backed_user_simulator_prompt_blocks_unsafe_persona_templates(
self,
):
user_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="{{ ''.__class__.__mro__ }}",
description="Test behavior description",
behavior_instructions=["instruction 1"],
violation_rubrics=["rubric 1"],
)
],
)
with pytest.raises(SecurityError):
get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=user_persona,
)
class TestIsValidUserSimulatorTemplate:
"""Test cases for is_valid_user_simulator_template."""
def test_valid_template(self):
template = "Hello {{ name }}"
params = ["name"]
assert is_valid_user_simulator_template(template, params) is True
def test_invalid_syntax(self):
template = "Hello {{ name"
params = ["name"]
assert is_valid_user_simulator_template(template, params) is False
def test_missing_parameter(self):
template = "Hello"
params = ["name"]
assert is_valid_user_simulator_template(template, params) is False
@@ -0,0 +1,239 @@
# 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.
import textwrap
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _get_latest_turn_user_simulator_quality_prompt_template
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import get_per_turn_user_simulator_quality_prompt
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from jinja2.exceptions import SecurityError
import pytest
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
Default template
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Generated User Response
{{generated_user_response}}
# Stop signal
{{stop_signal}}
""").strip()
_MOCK_PERSONA_TEMPLATE = textwrap.dedent("""\
Persona template
# Persona Description
{{persona.description}}
{% for b in persona.behaviors %}
## Criteria: {{ b.name | render_string_filter}}
{{ b.description | render_string_filter}}
Mark as FAIL if any of the following Violations occur:
{{ b.get_violation_rubrics_str() | render_string_filter}}
{% endfor %}
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Generated User Response
{{generated_user_response}}
# Stop signal
{{stop_signal}}
""").strip()
class TestGetLatestTurnUserSimulatorQualityPrompt:
"""Test cases for get_latest_turn_user_simulator_quality_prompt."""
def test_get_get_latest_turn_user_simulator_quality_prompt_template_default(
self,
):
prompt = _get_latest_turn_user_simulator_quality_prompt_template(
user_persona=None
)
assert prompt == _LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE
def test_get_latest_turn_user_simulator_quality_prompt_template_with_persona(
self,
):
"""Tests that the correct prompt is returned when a persona is provided."""
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
],
)
prompt = _get_latest_turn_user_simulator_quality_prompt_template(
user_persona=persona
)
assert (
prompt
== _LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE
)
class TestGetPerTurnUserSimulatorQualityPrompt:
"""Test cases for get_per_turn_user_simulator_quality_prompt."""
def test_get_per_turn_user_simulator_quality_prompt_default(self, mocker):
"""Tests that the correct prompt is returned when no persona is provided."""
mocker.patch(
"google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts._LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE",
_MOCK_DEFAULT_TEMPLATE,
)
prompt = get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=None,
)
expected_prompt = textwrap.dedent("""\
Default template
# Conversation Plan
plan
# Conversation History
history
# Generated User Response
response
# Stop signal
stop""").strip()
assert prompt == expected_prompt
def test_get_per_turn_user_simulator_quality_prompt_with_persona(
self, mocker
):
"""Tests that the correct prompt is returned when a persona is provided."""
mocker.patch(
"google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts._LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE",
_MOCK_PERSONA_TEMPLATE,
)
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
],
)
prompt = get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)
expected_prompt = textwrap.dedent("""\
Persona template
# Persona Description
Test persona description.
## Criteria: test_behavior
Test behavior description.
Mark as FAIL if any of the following Violations occur:
* violation1
# Conversation Plan
plan
# Conversation History
history
# Generated User Response
response
# Stop signal
stop""").strip()
assert prompt == expected_prompt
def test_get_per_turn_user_simulator_quality_prompt_renders_persona_templates_in_sandbox(
self,
):
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="criteria {{ stop_signal }}",
description="Test behavior {{ stop_signal }}.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation {{ stop_signal }}"],
)
],
)
prompt = get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)
assert "## Criteria: criteria stop" in prompt
assert "Test behavior stop." in prompt
assert " * violation stop" in prompt
def test_get_per_turn_user_simulator_quality_prompt_blocks_unsafe_persona_templates(
self,
):
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="{{ ''.__class__.__mro__ }}",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
],
)
with pytest.raises(SecurityError):
get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)
@@ -0,0 +1,698 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.eval_case import ConversationScenario
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import EvalStatus
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import LlmBackedUserSimulatorCriterion
from google.adk.evaluation.evaluator import PerInvocationResult
from google.adk.evaluation.llm_as_judge import AutoRaterScore
from google.adk.evaluation.llm_as_judge_utils import Label
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import _format_conversation_history
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import _parse_llm_response
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import PerTurnUserSimulatorQualityV1
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from google.adk.models.llm_response import LlmResponse
from google.genai import types
from google.genai import types as genai_types
import pytest
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid_undefined_key": True
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": "undefined label",
}
```""",
],
)
def test_parse_llm_response_label_not_found(response_text):
label = _parse_llm_response(response_text)
assert label == Label.NOT_FOUND
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": True
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": "true"
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": "valid"
}
```""",
],
)
def test_parse_llm_response_label_valid(response_text):
label = _parse_llm_response(response_text)
assert label == Label.VALID
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": False
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "false",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "invalid",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "almost",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "partially_valid",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "partially valid",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "partially",
}
```""",
],
)
def test_parse_llm_response_label_invalid(response_text):
label = _parse_llm_response(response_text)
assert label == Label.INVALID
def create_test_template() -> str:
return """This is a test template with stop signal: `{{stop_signal}}`.
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Generated User Response
{{generated_user_response}}
""".strip()
def _create_test_evaluator(
threshold: float = 1.0, stop_signal: str = "test stop signal"
) -> PerTurnUserSimulatorQualityV1:
evaluator = PerTurnUserSimulatorQualityV1(
EvalMetric(
metric_name="test_per_turn_user_simulator_quality_v1",
threshold=threshold,
criterion=LlmBackedUserSimulatorCriterion(
threshold=threshold,
stop_signal=stop_signal,
judge_model_options=JudgeModelOptions(
judge_model="gemini-2.5-flash",
judge_model_config=genai_types.GenerateContentConfig(),
num_samples=3,
),
),
),
)
return evaluator
def _create_test_conversation_scenario(
conversation_plan: str = "test conversation plan",
starting_prompt: str = "test starting prompt",
user_persona: UserPersona = None,
) -> ConversationScenario:
"""Returns a ConversationScenario."""
return ConversationScenario(
starting_prompt=starting_prompt,
conversation_plan=conversation_plan,
user_persona=user_persona,
)
def _create_test_invocation(
invocation_id: str,
user_content: str = "user content",
model_content: str = "model content",
) -> Invocation:
return Invocation(
invocation_id=invocation_id,
user_content=genai_types.Content(
parts=[genai_types.Part(text=user_content)],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=model_content)],
role="model",
),
)
def _create_test_invocations(
conversation_history: list[str],
) -> list[Invocation]:
conversation_length = len(conversation_history)
assert conversation_length % 2 == 0
invocations = []
for i in range(conversation_length // 2):
user_message = conversation_history[2 * i]
model_message = conversation_history[2 * i + 1]
invocations.append(
_create_test_invocation(
"turn {i}", user_content=user_message, model_content=model_message
)
)
return invocations
def test_format_llm_prompt_raises_error_if_previous_invocations_is_none():
evaluator = _create_test_evaluator()
with pytest.raises(
ValueError, match="Previous invocations should have a set value"
):
evaluator._format_llm_prompt(
invocation=_create_test_invocation("1"),
conversation_scenario=_create_test_conversation_scenario(),
previous_invocations=None,
)
def test_format_llm_prompt_raises_error_if_conversation_scenario_is_none():
evaluator = _create_test_evaluator()
with pytest.raises(
ValueError, match="Conversation scenario should have a set value"
):
evaluator._format_llm_prompt(
invocation=_create_test_invocation("1"),
conversation_scenario=None,
previous_invocations=[],
)
def test_convert_llm_response_to_score_pass():
evaluator = _create_test_evaluator()
auto_rater_response = """```json
{
"is_valid": True,
}
```"""
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=auto_rater_response)],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore(score=1.0)
def test_convert_llm_response_to_score_failure():
evaluator = _create_test_evaluator()
auto_rater_response = """```json
{
"is_valid": False,
}
```"""
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=auto_rater_response)],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore(score=0.0)
def test_convert_llm_response_to_score_invalid_json():
evaluator = _create_test_evaluator()
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="invalid json")],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore()
def test_convert_llm_response_to_score_missing_key():
evaluator = _create_test_evaluator()
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="{}")],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore()
def test_aggregate_samples_not_evaluated():
evaluator = _create_test_evaluator()
samples = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
]
aggregation = evaluator._aggregate_samples(samples)
assert aggregation == samples[0]
def test_aggregate_samples_pass():
evaluator = _create_test_evaluator()
# The majority of results should be positive.
samples = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
]
aggregation_result = evaluator._aggregate_samples(samples)
assert aggregation_result.score == 1.0
assert aggregation_result.eval_status == EvalStatus.PASSED
def test_aggregate_samples_failure():
evaluator = _create_test_evaluator()
# The majority of results should be negative.
samples = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
]
aggregation_result = evaluator._aggregate_samples(samples)
assert aggregation_result.score == 0.0
assert aggregation_result.eval_status == EvalStatus.FAILED
def test_format_conversation_history_with_none_values():
"""Tests that _format_conversation_history handles None values."""
invocations = [
Invocation(
invocation_id="1",
user_content=types.Content(),
final_response=None,
)
]
formatted_history = _format_conversation_history(invocations)
assert formatted_history == ""
def test_format_conversation_history():
conversation_history = [
"first user prompt.",
"first agent response.",
"second user prompt.",
"second agent response.",
]
invocation_history = _create_test_invocations(conversation_history)
formatted_history = _format_conversation_history(invocation_history)
assert formatted_history == """user: first user prompt.
model: first agent response.
user: second user prompt.
model: second agent response."""
def test_evaluate_first_turn_pass():
evaluator = _create_test_evaluator(
threshold=0.8, stop_signal="test stop signal"
)
conversation_scenario = _create_test_conversation_scenario(
conversation_plan="plan",
starting_prompt="test starting prompt",
)
invocation = _create_test_invocation("1", user_content="test starting prompt")
result = evaluator._evaluate_first_turn(invocation, conversation_scenario)
assert result.score == 1.0
assert result.eval_status == EvalStatus.PASSED
def test_evaluate_first_turn_failure():
evaluator = _create_test_evaluator(
threshold=1.0, stop_signal="test stop signal"
)
conversation_scenario = _create_test_conversation_scenario(
conversation_plan="plan",
starting_prompt="test starting prompt",
)
invocation = _create_test_invocation("1", "wrong starting prompt")
result = evaluator._evaluate_first_turn(invocation, conversation_scenario)
assert result.score == 0.0
assert result.eval_status == EvalStatus.FAILED
def test_aggregate_conversation_results_all_pass_produces_pass():
evaluator = _create_test_evaluator()
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 1.0
assert aggregation.overall_eval_status == EvalStatus.PASSED
def test_aggregate_conversation_results_percentage_above_threshold_produces_pass():
evaluator = _create_test_evaluator(threshold=0.7)
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 0.75
assert aggregation.overall_eval_status == EvalStatus.PASSED
def test_aggregate_conversation_results_all_failures_produces_failure():
evaluator = _create_test_evaluator()
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 0.0
assert aggregation.overall_eval_status == EvalStatus.FAILED
def test_aggregate_conversation_percentage_below_threshold_produces_failure():
evaluator = _create_test_evaluator(threshold=1.0)
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 0.75
assert aggregation.overall_eval_status == EvalStatus.FAILED
@pytest.mark.asyncio
async def test_evaluate_invocations_all_pass():
evaluator = _create_test_evaluator()
async def sample_llm_valid(*args, **kwargs): # pylint: disable=unused-argument
return AutoRaterScore(score=1.0)
evaluator._sample_llm = sample_llm_valid # pylint: disable=protected-access
starting_prompt = "first user prompt."
conversation_scenario = _create_test_conversation_scenario(
starting_prompt=starting_prompt
)
invocations = _create_test_invocations(
[starting_prompt, "model 1.", "user 2.", "model 2."]
)
result = await evaluator.evaluate_invocations(
actual_invocations=invocations,
expected_invocations=None,
conversation_scenario=conversation_scenario,
)
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
assert len(result.per_invocation_results) == 2
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[1].score == 1.0
@pytest.mark.asyncio
async def test_evaluate_invocations_none_judge_model_config():
"""Tests evaluation when judge_model_config is None."""
evaluator = PerTurnUserSimulatorQualityV1(
EvalMetric(
metric_name="test_per_turn_user_simulator_quality_v1",
threshold=1.0,
criterion=LlmBackedUserSimulatorCriterion(
threshold=1.0,
stop_signal="test stop signal",
judge_model_options=JudgeModelOptions(
judge_model="gemini-2.5-flash",
judge_model_config=None,
num_samples=1,
),
),
),
)
async def sample_llm_valid(*args, **kwargs): # pylint: disable=unused-argument
return AutoRaterScore(score=1.0)
evaluator._sample_llm = sample_llm_valid # pylint: disable=protected-access
starting_prompt = "first user prompt."
conversation_scenario = _create_test_conversation_scenario(
starting_prompt=starting_prompt
)
invocations = _create_test_invocations(
[starting_prompt, "model 1.", "user 2.", "model 2."]
)
result = await evaluator.evaluate_invocations(
actual_invocations=invocations,
expected_invocations=None,
conversation_scenario=conversation_scenario,
)
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
@@ -0,0 +1,20 @@
# 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.
from google.adk.evaluation.simulation.pre_built_personas import get_default_persona_registry
def test_get_default_persona_registry():
"""Tests that the default persona registry can be loaded."""
assert get_default_persona_registry() is not None
@@ -0,0 +1,54 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.simulation import static_user_simulator
from google.adk.evaluation.simulation import user_simulator
from google.genai import types
import pytest
class TestStaticUserSimulator:
"""Test cases for StaticUserSimulator."""
@pytest.mark.asyncio
async def test_get_next_user_message(self):
"""Tests that the provided messages are returned in order followed by the stop signal."""
conversation = [
Invocation(
invocation_id="inv1",
user_content=types.Content(parts=[types.Part(text="message 1")]),
),
Invocation(
invocation_id="inv2",
user_content=types.Content(parts=[types.Part(text="message 2")]),
),
]
simulator = static_user_simulator.StaticUserSimulator(
static_conversation=conversation
)
next_message_1 = await simulator.get_next_user_message(events=[])
assert user_simulator.Status.SUCCESS == next_message_1.status
assert "message 1" == next_message_1.user_message.parts[0].text
next_message_2 = await simulator.get_next_user_message(events=[])
assert user_simulator.Status.SUCCESS == next_message_2.status
assert "message 2" == next_message_2.user_message.parts[0].text
next_message_3 = await simulator.get_next_user_message(events=[])
assert user_simulator.Status.STOP_SIGNAL_DETECTED == next_message_3.status
assert next_message_3.user_message is None
@@ -0,0 +1,106 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.simulation import user_simulator as user_simulator_module
from google.adk.evaluation.simulation.user_simulator import BaseUserSimulatorConfig
from google.adk.evaluation.simulation.user_simulator import NextUserMessage
from google.adk.evaluation.simulation.user_simulator import register_user_simulator
from google.adk.evaluation.simulation.user_simulator import Status
from google.adk.evaluation.simulation.user_simulator import UserSimulator
from google.genai.types import Content
from pydantic import Field
import pytest
from typing_extensions import Literal
def test_next_user_message_validation():
"""Tests post-init validation of NextUserMessage."""
with pytest.raises(
ValueError,
match=(
"A user_message should be provided if and only if the status is"
" SUCCESS"
),
):
NextUserMessage(status=Status.SUCCESS)
with pytest.raises(
ValueError,
match=(
"A user_message should be provided if and only if the status is"
" SUCCESS"
),
):
NextUserMessage(status=Status.TURN_LIMIT_REACHED, user_message=Content())
# these two should not cause exceptions
NextUserMessage(status=Status.SUCCESS, user_message=Content())
NextUserMessage(status=Status.TURN_LIMIT_REACHED)
# -----------------------------------------------------------------------------
# `register_user_simulator` -- the extension-point API in `user_simulator.py`.
# End-to-end dispatch through `UserSimulatorProvider` is covered separately in
# `test_user_simulator_provider.py`.
# -----------------------------------------------------------------------------
class _FakeConfig(BaseUserSimulatorConfig):
"""Test-only config subclass with a unique Literal discriminator."""
type: Literal["fake_sim"] = Field(default="fake_sim")
class _FakeSimulator(UserSimulator):
"""Test-only simulator; internals do not matter, only its type identity."""
def test_register_user_simulator_writes_to_shared_registry():
"""`register_user_simulator(config_type, simulator_type)` must write the
mapping into `_SIMULATOR_BY_CONFIG_TYPE` so that any consumer -- including
`UserSimulatorProvider` in another module -- can look it up.
"""
try:
register_user_simulator(_FakeConfig, _FakeSimulator)
assert (
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(_FakeConfig)
is _FakeSimulator
)
finally:
# Clean up so we don't leak state into other tests.
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.pop(_FakeConfig, None)
def test_register_user_simulator_overwrites_existing_entry():
"""Re-registering the same config type must overwrite the previous entry.
This lets a test or an out-of-tree extension swap in an alternative
implementation for the same config type without having to unregister first.
"""
class _AlternativeFakeSimulator(UserSimulator):
pass
try:
register_user_simulator(_FakeConfig, _FakeSimulator)
register_user_simulator(_FakeConfig, _AlternativeFakeSimulator)
assert (
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(_FakeConfig)
is _AlternativeFakeSimulator
)
finally:
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.pop(_FakeConfig, None)
@@ -0,0 +1,133 @@
# 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.
from __future__ import annotations
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from google.adk.evaluation.simulation.user_simulator_personas import UserPersonaRegistry
import pytest
class TestUserBehavior:
"""Test cases for UserBehavior."""
def test_create_user_behavior(self):
"""Tests UserBehavior creation."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1", "instruction2"],
violation_rubrics=["violation1", "violation2"],
)
assert behavior.name == "test_behavior"
assert behavior.description == "Test behavior description."
assert behavior.behavior_instructions == ["instruction1", "instruction2"]
assert behavior.violation_rubrics == ["violation1", "violation2"]
def test_get_behavior_instructions_str(self):
"""Tests get_behavior_instructions_str method."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1", "instruction2"],
violation_rubrics=[],
)
assert (
behavior.get_behavior_instructions_str()
== " * instruction1\n * instruction2"
)
def test_get_violation_rubrics_str(self):
"""Tests get_violation_rubrics_str method."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=[],
violation_rubrics=["violation1", "violation2"],
)
assert (
behavior.get_violation_rubrics_str() == " * violation1\n * violation2"
)
class TestUserPersona:
"""Test cases for UserPersona."""
def test_create_user_persona(self):
"""Tests UserPersona creation."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[behavior],
)
assert persona.id == "test_persona"
assert persona.description == "Test persona description."
assert persona.behaviors == [behavior]
class TestUserPersonaRegistry:
"""Test cases for UserPersonaRegistry."""
def test_register_and_get_persona(self):
"""Tests register_persona and get_persona methods."""
registry = UserPersonaRegistry()
persona = UserPersona(
id="test_persona", description="Test persona", behaviors=[]
)
registry.register_persona("persona1", persona)
assert registry.get_persona("persona1") == persona
def test_get_persona_not_found(self):
"""Tests get_persona for a non-existent persona."""
registry = UserPersonaRegistry()
with pytest.raises(NotFoundError, match="persona2 not found in registry."):
registry.get_persona("persona2")
def test_update_persona(self):
"""Tests updating an existing persona in the registry."""
registry = UserPersonaRegistry()
persona1 = UserPersona(
id="test_persona1", description="Test persona 1", behaviors=[]
)
persona2 = UserPersona(
id="test_persona2", description="Test persona 2", behaviors=[]
)
registry.register_persona("persona1", persona1)
assert registry.get_persona("persona1") == persona1
registry.register_persona("persona1", persona2)
assert registry.get_persona("persona1") == persona2
def test_get_registered_personas(self):
"""Tests get_registered_personas method."""
registry = UserPersonaRegistry()
persona1 = UserPersona(
id="test_persona1", description="Test persona 1", behaviors=[]
)
persona2 = UserPersona(
id="test_persona2", description="Test persona 2", behaviors=[]
)
registry.register_persona("persona1", persona1)
registry.register_persona("persona2", persona2)
registered_personas = registry.get_registered_personas()
assert len(registered_personas) == 2
assert persona1 in registered_personas
assert persona2 in registered_personas
@@ -0,0 +1,200 @@
# 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.
from __future__ import annotations
from google.adk.evaluation import conversation_scenarios
from google.adk.evaluation import eval_case
from google.adk.evaluation.simulation import user_simulator as user_simulator_module
from google.adk.evaluation.simulation import user_simulator_provider
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulator
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
from google.adk.evaluation.simulation.static_user_simulator import StaticUserSimulator
from google.adk.evaluation.simulation.user_simulator import BaseUserSimulatorConfig
from google.genai import types
from pydantic import Field
import pytest
from typing_extensions import Literal
_TEST_CONVERSATION = [
eval_case.Invocation(
invocation_id='inv1',
user_content=types.Content(parts=[types.Part(text='Hello!')]),
),
]
_TEST_CONVERSATION_SCENARIO = conversation_scenarios.ConversationScenario(
starting_prompt='Hello!', conversation_plan='test plan'
)
class TestUserSimulatorProvider:
"""Test cases for the UserSimulatorProvider."""
def test_provide_static_user_simulator(self):
"""Tests the case when a StaticUserSimulator should be provided."""
provider = user_simulator_provider.UserSimulatorProvider()
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation=_TEST_CONVERSATION,
)
simulator = provider.provide(test_eval_case)
assert isinstance(simulator, StaticUserSimulator)
assert simulator.static_conversation == _TEST_CONVERSATION
def test_provide_llm_backed_user_simulator(self, mocker):
"""Tests the case when a LlmBackedUserSimulator should be provided."""
mock_llm_registry = mocker.patch(
'google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry',
autospec=True,
)
mock_llm_registry.return_value.resolve.return_value = mocker.Mock()
# Test case 1: No config in provider.
provider = user_simulator_provider.UserSimulatorProvider()
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
)
simulator = provider.provide(test_eval_case)
assert isinstance(simulator, LlmBackedUserSimulator)
assert simulator._conversation_scenario == _TEST_CONVERSATION_SCENARIO
# Test case 2: Config in provider.
llm_config = LlmBackedUserSimulatorConfig(
model='test_model',
)
provider = user_simulator_provider.UserSimulatorProvider(
user_simulator_config=llm_config
)
simulator = provider.provide(test_eval_case)
assert isinstance(simulator, LlmBackedUserSimulator)
assert simulator._conversation_scenario == _TEST_CONVERSATION_SCENARIO
assert simulator._config.model == 'test_model'
# ---------------------------------------------------------------------------
# Backward-compat + discriminator + registry
# ---------------------------------------------------------------------------
def test_init_accepts_bare_base_config_but_provide_raises(self):
"""A bare `BaseUserSimulatorConfig` is a valid *instance* of the base,
so `__init__` accepts it -- but the base has no registered simulator,
so `provide()` should raise a clear error pointing the caller at the
fix. Callers wanting the default should pass `None` instead.
"""
provider = user_simulator_provider.UserSimulatorProvider(
user_simulator_config=BaseUserSimulatorConfig()
)
# __init__ stores the bare base as-is; no silent up-conversion.
assert type(provider._user_simulator_config) is BaseUserSimulatorConfig
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
)
with pytest.raises(
ValueError,
match=(
r'No UserSimulator registered for config type'
r' `BaseUserSimulatorConfig`'
),
):
provider.provide(test_eval_case)
def test_init_rejects_non_config_argument(self):
"""Passing something that isn't a `BaseUserSimulatorConfig` should raise
a clear ValueError.
"""
with pytest.raises(
ValueError,
match=r'Expect config of type `.*BaseUserSimulatorConfig.*`\.',
):
user_simulator_provider.UserSimulatorProvider(
user_simulator_config='not a config' # type: ignore[arg-type]
)
# NOTE: The "both / neither of conversation, conversation_scenario"
# checks in `provide()` are defensive; `EvalCase` itself enforces the same
# invariant at construction time via a model_validator, so those branches
# in the provider are effectively unreachable and don't warrant a unit
# test at this layer.
def test_base_config_type_defaults_to_none(self):
"""The base `BaseUserSimulatorConfig.type` must default to `None` -- the
base class must not hard-code a specific subclass's discriminator value.
Concrete subclasses supply their own `Literal[...]` default.
"""
base = BaseUserSimulatorConfig()
assert base.type is None
def test_llm_backed_config_has_locked_type_literal(self):
"""The `type` discriminator on `LlmBackedUserSimulatorConfig` must be a
Literal locked to `"llm_backed"`, so future subclasses can dispatch
correctly via pydantic's discriminated union.
"""
config = LlmBackedUserSimulatorConfig()
assert config.type == 'llm_backed'
# Attempting to construct with a different `type` value must fail
# validation (Literal constraint).
with pytest.raises(Exception):
LlmBackedUserSimulatorConfig(type='something_else')
def test_llm_backed_user_simulator_registered_by_provider_module(self):
"""Importing `user_simulator_provider` must wire the built-in
`LlmBackedUserSimulator` into the shared registry. This is the "batteries
included" contract callers rely on: they can `UserSimulatorProvider()`
without ever touching `register_user_simulator(...)`. If the registration
line at the top of the provider module is removed, this test catches it
immediately -- otherwise dispatch would silently fall through to the
"unregistered config type" error path.
"""
assert (
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(
LlmBackedUserSimulatorConfig
)
is LlmBackedUserSimulator
)
def test_provide_raises_for_unregistered_config_type(self, mocker):
"""If the caller supplies a config subclass that no one has registered,
provide() must raise a clear error naming the offending type.
"""
mocker.patch(
'google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry',
autospec=True,
)
class _UnregisteredConfig(BaseUserSimulatorConfig):
type: Literal['unregistered'] = Field(default='unregistered')
provider = user_simulator_provider.UserSimulatorProvider(
user_simulator_config=_UnregisteredConfig()
)
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
)
with pytest.raises(
ValueError,
match=(
r'No UserSimulator registered for config type'
r' `_UnregisteredConfig`'
),
):
provider.provide(test_eval_case)