Skip to content

Commit 5fc98a1

Browse files
committed
feat(conformance): implement live mode for conformance testing
Enable `adk conformance test --mode=live` to run agent with live model calls and compare against recorded baselines. This catches regressions when model behavior changes while maintaining the same comparison logic as replay mode. Live mode runs actual model calls (no replay config) but still validates against existing recordings, making it suitable for regression detection when model behavior may have changed. Fixes #7290
1 parent 044a1ec commit 5fc98a1

3 files changed

Lines changed: 211 additions & 19 deletions

File tree

‎src/google/adk/cli/cli_tools_click.py‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ def cli_conformance_record(
646646
show_default=True,
647647
help=(
648648
"Test mode: 'replay' verifies against recorded interactions, 'live'"
649-
" runs evaluation-based verification."
649+
" runs against live model responses."
650650
),
651651
)
652652
@click.option(
@@ -683,20 +683,23 @@ def cli_conformance_test(
683683
"""Run conformance tests to verify agent behavior consistency.
684684
685685
Validates that agents produce consistent outputs by comparing against recorded
686-
interactions or evaluating live execution results.
686+
interactions or checking against live model responses.
687687
688688
PATHS can be any number of folder paths. Each folder can either:
689689
- Contain a spec.yaml file directly (single test case)
690690
- Contain subdirectories with spec.yaml files (multiple test cases)
691691
692692
If no paths are provided, defaults to searching for the 'tests' folder.
693693
694-
TEST MODES:
694+
MODE DIFFERENCES:
695695
696-
\b
697-
replay : Verifies agent interactions match previously recorded behaviors
698-
exactly. Compares LLM requests/responses and tool calls/results.
699-
live : Runs evaluation-based verification (not yet implemented)
696+
Use 'adk conformance test --mode=replay' for fast, deterministic verification
697+
against previously recorded interactions. This is ideal for CI/CD pipelines
698+
and ensuring changes don't break existing behavior.
699+
700+
Use 'adk conformance test --mode=live' to verify against live model responses
701+
and catch regressions when model behavior may have changed. This runs actual
702+
model calls but still compares against recorded baselines.
700703
701704
DIRECTORY STRUCTURE:
702705
@@ -706,10 +709,13 @@ def cli_conformance_test(
706709
category/
707710
test_name/
708711
spec.yaml # Test specification
709-
generated-recordings.yaml # Recorded interactions (replay mode)
710-
generated-session.yaml # Session data (replay mode)
711-
generated-recordings-sse.yaml # Recorded SSE interactions (replay mode)
712-
generated-session-sse.yaml # SSE Session data (replay mode)
712+
generated-recordings.yaml # Recorded interactions (required for both modes)
713+
generated-session.yaml # Session data (required for both modes)
714+
generated-recordings-sse.yaml # Recorded SSE interactions (SSE mode)
715+
generated-session-sse.yaml # SSE Session data (SSE mode)
716+
717+
NOTE: Both replay and live modes require existing recordings. Use
718+
'adk conformance record' to generate baseline recordings first.
713719
714720
REPORT GENERATION:
715721
@@ -731,7 +737,7 @@ def cli_conformance_test(
731737
adk conformance test tests/core/description_001
732738
733739
\b
734-
# Run in live mode (when available)
740+
# Run in live mode to check against live model responses
735741
adk conformance test --mode=live tests/core
736742
737743
\b

‎src/google/adk/cli/conformance/cli_test.py‎

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ def _discover_test_cases(self) -> list[TestCase]:
103103
else:
104104
raise ValueError(f"Unsupported streaming mode: {self.streaming_mode}")
105105

106-
# Skip if recordings missing in replay mode
107-
if self.mode == "replay" and not recordings_file.exists():
106+
# Skip if recordings missing in replay or live mode (need baseline for comparison)
107+
if not recordings_file.exists():
108108
click.secho(
109-
f"Skipping {category}/{name}: no recordings",
109+
f"Skipping {category}/{name}: no recordings found for comparison",
110110
fg="yellow",
111111
err=True,
112112
)
@@ -129,7 +129,7 @@ async def _run_user_messages(
129129
session_id: str,
130130
test_case: TestCase,
131131
) -> None:
132-
"""Run all user messages for a test case."""
132+
"""Run all user messages for a test case in replay mode."""
133133
function_call_name_to_id_map = {}
134134
for user_message_index, user_message in enumerate(
135135
test_case.test_spec.user_messages
@@ -178,7 +178,7 @@ async def _run_user_messages(
178178
state_delta=user_message.state_delta,
179179
)
180180

181-
# Run the agent but don't collect events here
181+
# Run the agent in replay mode
182182
async for event in self.client.run_agent(
183183
request,
184184
mode="replay",
@@ -289,6 +289,114 @@ async def _run_test_case_replay(self, test_case: TestCase) -> _TestResult:
289289
description=test_case.test_spec.description,
290290
)
291291

292+
async def _run_user_messages_live(
293+
self,
294+
session_id: str,
295+
test_case: TestCase,
296+
) -> None:
297+
"""Run all user messages for a test case in live mode."""
298+
function_call_name_to_id_map = {}
299+
for user_message_index, user_message in enumerate(
300+
test_case.test_spec.user_messages
301+
):
302+
# Create content from UserMessage object
303+
if user_message.content is not None:
304+
content = user_message.content.model_copy(deep=True)
305+
306+
# If the user provides a function response, it means this is for
307+
# long-running tool. Replace the function call ID with the actual
308+
# function call ID. This is needed because the function call ID is not
309+
# known when writing the test case.
310+
if content.parts:
311+
for part in content.parts:
312+
if part.function_response:
313+
name = part.function_response.name
314+
if not name:
315+
raise ValueError(
316+
"FunctionResponse part is missing a 'name' field."
317+
)
318+
if (
319+
name not in function_call_name_to_id_map
320+
or not function_call_name_to_id_map[name]
321+
):
322+
raise ValueError(
323+
"Function response for"
324+
f" {name} does not match any pending function call."
325+
)
326+
part.function_response.id = function_call_name_to_id_map[
327+
name
328+
].pop(0)
329+
elif user_message.text is not None:
330+
content = types.UserContent(parts=[types.Part(text=user_message.text)])
331+
else:
332+
raise ValueError(
333+
f"UserMessage at index {user_message_index} has neither text nor"
334+
" content"
335+
)
336+
337+
request = RunAgentRequest(
338+
app_name=test_case.test_spec.agent,
339+
user_id=self.user_id,
340+
session_id=session_id,
341+
new_message=content,
342+
streaming=self.streaming_mode == StreamingMode.SSE,
343+
state_delta=user_message.state_delta,
344+
)
345+
346+
# Run the agent with live model calls (no mode parameter)
347+
async for event in self.client.run_agent(request):
348+
if getattr(event, "partial", False):
349+
continue
350+
if event.content and event.content.parts:
351+
for part in event.content.parts:
352+
if part.function_call:
353+
function_call_name_to_id_map.setdefault(
354+
part.function_call.name, []
355+
).append(part.function_call.id)
356+
357+
async def _run_test_case_live(self, test_case: TestCase) -> _TestResult:
358+
"""Run a single test case in live mode."""
359+
try:
360+
# Create session
361+
session = await self.client.create_session(
362+
app_name=test_case.test_spec.agent,
363+
user_id=self.user_id,
364+
state=test_case.test_spec.initial_state,
365+
)
366+
367+
# Run each user message with live model calls
368+
try:
369+
await self._run_user_messages_live(session.id, test_case)
370+
except Exception as e:
371+
return _TestResult(
372+
category=test_case.category,
373+
name=test_case.name,
374+
success=False,
375+
error_message=f"Live execution failed: {e}",
376+
description=test_case.test_spec.description,
377+
)
378+
379+
# Validate results against existing recordings
380+
result = await self._validate_test_results(session.id, test_case)
381+
382+
# Clean up session
383+
await self.client.delete_session(
384+
app_name=test_case.test_spec.agent,
385+
user_id=self.user_id,
386+
session_id=session.id,
387+
)
388+
389+
return result
390+
391+
except Exception as e:
392+
return _TestResult(
393+
category=test_case.category,
394+
name=test_case.name,
395+
success=False,
396+
error_message=f"Test setup failed: {e}",
397+
description=test_case.test_spec.description,
398+
)
399+
292400
async def run_all_tests(self) -> _ConformanceTestSummary:
293401
"""Run all discovered test cases."""
294402
test_cases = self._discover_test_cases()
@@ -311,13 +419,14 @@ async def run_all_tests(self) -> _ConformanceTestSummary:
311419
click.echo(f"Running {test_case.category}/{test_case.name}...", nl=False)
312420
if self.mode == "replay":
313421
result = await self._run_test_case_replay(test_case)
422+
elif self.mode == "live":
423+
result = await self._run_test_case_live(test_case)
314424
else:
315-
# TODO: Implement live mode
316425
result = _TestResult(
317426
category=test_case.category,
318427
name=test_case.name,
319428
success=False,
320-
error_message="Live mode is not implemented yet",
429+
error_message=f"Unsupported mode: {self.mode}",
321430
description=test_case.test_spec.description,
322431
)
323432
results.append(result)

‎tests/unittests/cli/conformance/test_cli_test.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
"""Tests for cli_test.py."""
1616

17+
from __future__ import annotations
18+
1719
from typing import Optional
1820
from unittest.mock import MagicMock
1921

@@ -377,3 +379,78 @@ def test_summary_succeeds_when_every_streaming_mode_passes():
377379

378380
assert result.exit_code == 0
379381
assert result.output.count("All tests passed!") == 2
382+
383+
384+
@pytest.mark.asyncio
385+
async def test_run_user_messages_live_mode():
386+
"""Test that live mode runs agent without replay config."""
387+
client = MagicMock()
388+
captured_requests = []
389+
390+
async def fake_run_agent(req, **kwargs):
391+
captured_requests.append(req)
392+
# Live mode should not have mode parameter
393+
assert "mode" not in kwargs
394+
yield Event(partial=False, content=types.Content(parts=[types.Part.from_text("response")]))
395+
396+
client.run_agent = fake_run_agent
397+
398+
runner = ConformanceTestRunner([], client, mode="live")
399+
test_case = TestCase(
400+
category="cat",
401+
name="tc",
402+
dir=None,
403+
test_spec=TestSpec(
404+
description="test live mode",
405+
agent="agent",
406+
user_messages=[UserMessage(text="test message")],
407+
),
408+
)
409+
await runner._run_user_messages_live("sess1", test_case)
410+
411+
assert len(captured_requests) == 1
412+
assert captured_requests[0].new_message.parts[0].text == "test message"
413+
414+
415+
@pytest.mark.asyncio
416+
async def test_run_test_case_live():
417+
"""Test complete live mode test case execution."""
418+
client = MagicMock()
419+
session = MagicMock()
420+
session.id = "test-session"
421+
422+
async def fake_create_session(**kwargs):
423+
return session
424+
425+
async def fake_get_session(**kwargs):
426+
return MagicMock(events=[])
427+
428+
async def fake_delete_session(**kwargs):
429+
pass
430+
431+
client.create_session = fake_create_session
432+
client.get_session = fake_get_session
433+
client.delete_session = fake_delete_session
434+
435+
runner = ConformanceTestRunner([], client, mode="live")
436+
test_case = TestCase(
437+
category="cat",
438+
name="tc",
439+
dir=None,
440+
test_spec=TestSpec(
441+
description="test live mode",
442+
agent="agent",
443+
user_messages=[UserMessage(text="test message")],
444+
),
445+
)
446+
447+
# Mock the user messages execution
448+
runner._run_user_messages_live = MagicMock(return_value=None)
449+
450+
result = await runner._run_test_case_live(test_case)
451+
452+
assert result.category == "cat"
453+
assert result.name == "tc"
454+
# Should fail because no recordings exist for comparison
455+
assert not result.success
456+
assert "No recorded session found" in result.error_message

0 commit comments

Comments
 (0)