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
+13
View File
@@ -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,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,365 @@
# 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 json
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from google.adk.artifacts.base_artifact_service import ArtifactVersion
from google.adk.cli.adk_web_server import RunAgentRequest
from google.adk.cli.conformance.adk_web_server_client import AdkWebServerClient
from google.adk.events.event import Event
from google.adk.sessions.session import Session
from google.genai import types
import pytest
def test_init_default_values():
client = AdkWebServerClient()
assert client.base_url == "http://127.0.0.1:8000"
assert client.timeout == 30.0
def test_init_custom_values():
client = AdkWebServerClient(
base_url="https://custom.example.com/", timeout=60.0
)
assert client.base_url == "https://custom.example.com"
assert client.timeout == 60.0
def test_init_strips_trailing_slash():
client = AdkWebServerClient(base_url="http://test.com/")
assert client.base_url == "http://test.com"
@pytest.mark.asyncio
async def test_get_session():
client = AdkWebServerClient()
# Mock the HTTP response
mock_response = MagicMock()
mock_response.json.return_value = {
"id": "test_session",
"app_name": "test_app",
"user_id": "test_user",
"events": [],
"state": {},
}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client_class.return_value = mock_client
session = await client.get_session(
app_name="test_app", user_id="test_user", session_id="test_session"
)
assert isinstance(session, Session)
assert session.id == "test_session"
mock_client.get.assert_called_once_with(
"/apps/test_app/users/test_user/sessions/test_session"
)
@pytest.mark.asyncio
async def test_create_session():
client = AdkWebServerClient()
# Mock the HTTP response
mock_response = MagicMock()
mock_response.json.return_value = {
"id": "new_session",
"app_name": "test_app",
"user_id": "test_user",
"events": [],
"state": {"key": "value"},
}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client_class.return_value = mock_client
session = await client.create_session(
app_name="test_app", user_id="test_user", state={"key": "value"}
)
assert isinstance(session, Session)
assert session.id == "new_session"
mock_client.post.assert_called_once_with(
"/apps/test_app/users/test_user/sessions",
json={"state": {"key": "value"}},
)
@pytest.mark.asyncio
async def test_delete_session():
client = AdkWebServerClient()
# Mock the HTTP response
mock_response = MagicMock()
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.delete.return_value = mock_response
mock_client_class.return_value = mock_client
await client.delete_session(
app_name="test_app", user_id="test_user", session_id="test_session"
)
mock_client.delete.assert_called_once_with(
"/apps/test_app/users/test_user/sessions/test_session"
)
mock_response.raise_for_status.assert_called_once()
@pytest.mark.asyncio
async def test_update_session():
client = AdkWebServerClient()
# Mock the HTTP response
mock_response = MagicMock()
mock_response.json.return_value = {
"id": "test_session",
"app_name": "test_app",
"user_id": "test_user",
"events": [],
"state": {"key": "updated_value", "new_key": "new_value"},
}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.patch.return_value = mock_response
mock_client_class.return_value = mock_client
state_delta = {"key": "updated_value", "new_key": "new_value"}
session = await client.update_session(
app_name="test_app",
user_id="test_user",
session_id="test_session",
state_delta=state_delta,
)
assert isinstance(session, Session)
assert session.id == "test_session"
assert session.state == {"key": "updated_value", "new_key": "new_value"}
mock_client.patch.assert_called_once_with(
"/apps/test_app/users/test_user/sessions/test_session",
json={"state_delta": state_delta},
)
mock_response.raise_for_status.assert_called_once()
@pytest.mark.asyncio
async def test_run_agent():
client = AdkWebServerClient()
# Create sample events
event1 = Event(
author="test_agent",
invocation_id="test_invocation_1",
content=types.Content(role="model", parts=[types.Part(text="Hello")]),
)
event2 = Event(
author="test_agent",
invocation_id="test_invocation_2",
content=types.Content(role="model", parts=[types.Part(text="World")]),
)
# Mock streaming response
class MockStreamResponse:
def raise_for_status(self):
pass
async def aiter_lines(self):
yield f"data:{json.dumps(event1.model_dump())}"
yield "data:" # Empty line should be ignored
yield f"data:{json.dumps(event2.model_dump())}"
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
def mock_stream(*_args, **_kwargs):
return MockStreamResponse()
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.stream = mock_stream
mock_client_class.return_value = mock_client
request = RunAgentRequest(
app_name="test_app",
user_id="test_user",
session_id="test_session",
new_message=types.Content(
role="user", parts=[types.Part(text="Hello")]
),
)
events = []
async for event in client.run_agent(request):
events.append(event)
assert len(events) == 2
assert all(isinstance(event, Event) for event in events)
assert events[0].invocation_id == "test_invocation_1"
assert events[1].invocation_id == "test_invocation_2"
@pytest.mark.asyncio
async def test_run_agent_raises_on_streamed_error():
client = AdkWebServerClient()
class MockStreamResponse:
def raise_for_status(self):
pass
async def aiter_lines(self):
yield 'data: {"error": "boom"}'
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
def mock_stream(*_args, **_kwargs):
return MockStreamResponse()
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.stream = mock_stream
mock_client_class.return_value = mock_client
request = RunAgentRequest(
app_name="test_app",
user_id="test_user",
session_id="test_session",
new_message=types.Content(role="user", parts=[types.Part(text="Hi")]),
)
with pytest.raises(RuntimeError, match="boom"):
async for _ in client.run_agent(request):
pass
@pytest.mark.asyncio
async def test_get_artifact_version_metadata():
client = AdkWebServerClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"version": 2,
"canonicalUri": (
"artifact://apps/app/users/user/sessions/session/"
"artifacts/report/versions/2"
),
"customMetadata": {"foo": "bar"},
"createTime": 123.4,
"mimeType": "text/plain",
}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client_class.return_value = mock_client
metadata = await client.get_artifact_version_metadata(
app_name="app",
user_id="user",
session_id="session",
artifact_name="report",
version=2,
)
assert isinstance(metadata, ArtifactVersion)
assert metadata.version == 2
assert metadata.custom_metadata == {"foo": "bar"}
mock_client.get.assert_called_once_with(
"/apps/app/users/user/sessions/session/artifacts/report/versions/2/metadata"
)
mock_response.raise_for_status.assert_called_once()
@pytest.mark.asyncio
async def test_list_artifact_versions_metadata():
client = AdkWebServerClient()
mock_response = MagicMock()
mock_response.json.return_value = [
{
"version": 0,
"canonicalUri": "artifact://.../versions/0",
"customMetadata": {},
"createTime": 100.0,
},
{
"version": 1,
"canonicalUri": "artifact://.../versions/1",
"customMetadata": {"foo": "bar"},
"createTime": 200.0,
"mimeType": "application/json",
},
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client_class.return_value = mock_client
metadata_list = await client.list_artifact_versions_metadata(
app_name="app",
user_id="user",
session_id="session",
artifact_name="report",
)
assert len(metadata_list) == 2
assert all(isinstance(item, ArtifactVersion) for item in metadata_list)
assert metadata_list[1].custom_metadata == {"foo": "bar"}
mock_client.get.assert_called_once_with(
"/apps/app/users/user/sessions/session/artifacts/report/versions/metadata"
)
mock_response.raise_for_status.assert_called_once()
@pytest.mark.asyncio
async def test_close():
client = AdkWebServerClient()
# Create a mock client to close
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Force client creation
async with client._get_client():
pass
# Now close should work
await client.close()
mock_client.aclose.assert_called_once()
@pytest.mark.asyncio
async def test_context_manager():
async with AdkWebServerClient() as client:
assert isinstance(client, AdkWebServerClient)
@@ -0,0 +1,50 @@
# 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-isolation guard for adk_web_server.
Importing ``adk_web_server`` must not eagerly pull in the Agent Builder agent
stack. Doing so reaches ``google.adk.agents`` at import time and breaks
downstream consumers that import ``adk_web_server`` while ``google.adk.agents``
is still initializing.
"""
from __future__ import annotations
import subprocess
import sys
def test_importing_adk_web_server_does_not_import_agent_builder():
# Run in a fresh interpreter so the check is not polluted by modules that
# other tests already imported into sys.modules.
code = (
"import google.adk.cli.adk_web_server\n"
"import sys\n"
"forbidden = [\n"
" 'google.adk.cli.built_in_agents.agent',\n"
" 'google.adk.cli.built_in_agents.adk_agent_builder_assistant',\n"
"]\n"
"loaded = [name for name in forbidden if name in sys.modules]\n"
"assert not loaded, loaded\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
@@ -0,0 +1,291 @@
# 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 asyncio
import types
from typing import Any
from fastapi.testclient import TestClient
from google.adk.agents.base_agent import BaseAgent
from google.adk.cli.adk_web_server import AdkWebServer
from google.adk.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
import pytest
from starlette.websockets import WebSocketDisconnect
class _DummyAgent(BaseAgent):
def __init__(self) -> None:
super().__init__(name="dummy_agent")
self.sub_agents = []
class _DummyAgentLoader:
def load_agent(self, app_name: str) -> BaseAgent:
return _DummyAgent()
def list_agents(self) -> list[str]:
return ["test_app"]
def list_agents_detailed(self) -> list[dict[str, Any]]:
return []
class _CapturingRunner:
def __init__(self) -> None:
self.captured_run_config = None
async def run_live(
self,
*,
session,
live_request_queue,
run_config=None,
**unused_kwargs,
):
self.captured_run_config = run_config
yield Event(author="runner")
def test_run_live_applies_run_config_query_options():
session_service = InMemorySessionService()
asyncio.run(
session_service.create_session(
app_name="test_app",
user_id="user",
session_id="session",
state={},
)
)
runner = _CapturingRunner()
adk_web_server = AdkWebServer(
agent_loader=_DummyAgentLoader(),
session_service=session_service,
memory_service=types.SimpleNamespace(),
artifact_service=types.SimpleNamespace(),
credential_service=types.SimpleNamespace(),
eval_sets_manager=types.SimpleNamespace(),
eval_set_results_manager=types.SimpleNamespace(),
agents_dir=".",
)
async def _get_runner_async(_self, _app_name: str):
return runner
adk_web_server.get_runner_async = _get_runner_async.__get__(adk_web_server) # pytype: disable=attribute-error
fast_api_app = adk_web_server.get_fast_api_app(
setup_observer=lambda _observer, _server: None,
tear_down_observer=lambda _observer, _server: None,
)
client = TestClient(fast_api_app)
url = (
"/run_live"
"?app_name=test_app"
"&user_id=user"
"&session_id=session"
"&modalities=TEXT"
"&modalities=AUDIO"
"&proactive_audio=true"
"&enable_affective_dialog=true"
"&enable_session_resumption=true"
"&save_live_blob=true"
"&explicit_vad_signal=true"
)
with client.websocket_connect(url) as ws:
_ = ws.receive_text()
run_config = runner.captured_run_config
assert run_config is not None
assert run_config.response_modalities == ["TEXT", "AUDIO"]
assert run_config.enable_affective_dialog is True
assert run_config.proactivity is not None
assert run_config.proactivity.proactive_audio is True
assert run_config.session_resumption is not None
assert run_config.session_resumption.transparent is True
assert run_config.save_live_blob is True
assert run_config.explicit_vad_signal is True
@pytest.mark.parametrize(
(
"query,expected_enable_affective,expected_proactive_audio,"
"expected_session_resumption_transparent,expected_save_live_blob,"
"expected_explicit_vad_signal"
),
[
("", None, None, None, False, None),
("&proactive_audio=true", None, True, None, False, None),
("&proactive_audio=false", None, False, None, False, None),
("&enable_affective_dialog=true", True, None, None, False, None),
("&enable_affective_dialog=false", False, None, None, False, None),
("&enable_session_resumption=true", None, None, True, False, None),
("&enable_session_resumption=false", None, None, False, False, None),
("&save_live_blob=true", None, None, None, True, None),
("&save_live_blob=false", None, None, None, False, None),
("&explicit_vad_signal=true", None, None, None, False, True),
("&explicit_vad_signal=false", None, None, None, False, False),
],
)
def test_run_live_defaults_and_individual_options(
query: str,
expected_enable_affective: bool | None,
expected_proactive_audio: bool | None,
expected_session_resumption_transparent: bool | None,
expected_save_live_blob: bool,
expected_explicit_vad_signal: bool | None,
):
session_service = InMemorySessionService()
asyncio.run(
session_service.create_session(
app_name="test_app",
user_id="user",
session_id="session",
state={},
)
)
runner = _CapturingRunner()
adk_web_server = AdkWebServer(
agent_loader=_DummyAgentLoader(),
session_service=session_service,
memory_service=types.SimpleNamespace(),
artifact_service=types.SimpleNamespace(),
credential_service=types.SimpleNamespace(),
eval_sets_manager=types.SimpleNamespace(),
eval_set_results_manager=types.SimpleNamespace(),
agents_dir=".",
)
async def _get_runner_async(_self, _app_name: str):
return runner
adk_web_server.get_runner_async = _get_runner_async.__get__(adk_web_server) # pytype: disable=attribute-error
fast_api_app = adk_web_server.get_fast_api_app(
setup_observer=lambda _observer, _server: None,
tear_down_observer=lambda _observer, _server: None,
)
client = TestClient(fast_api_app)
url = (
"/run_live"
"?app_name=test_app"
"&user_id=user"
"&session_id=session"
"&modalities=AUDIO"
f"{query}"
)
with client.websocket_connect(url) as ws:
_ = ws.receive_text()
run_config = runner.captured_run_config
assert run_config is not None
assert run_config.enable_affective_dialog == expected_enable_affective
if expected_proactive_audio is None:
assert run_config.proactivity is None
else:
assert run_config.proactivity is not None
assert run_config.proactivity.proactive_audio is expected_proactive_audio
if expected_session_resumption_transparent is None:
assert run_config.session_resumption is None
else:
assert run_config.session_resumption is not None
assert (
run_config.session_resumption.transparent
is expected_session_resumption_transparent
)
assert run_config.save_live_blob is expected_save_live_blob
assert run_config.explicit_vad_signal is expected_explicit_vad_signal
_WS_BASE_URL = (
"/run_live"
"?app_name=test_app"
"&user_id=user"
"&session_id=session"
"&modalities=AUDIO"
)
def _build_ws_client():
"""Build a TestClient wired to a capturing runner."""
session_service = InMemorySessionService()
asyncio.run(
session_service.create_session(
app_name="test_app",
user_id="user",
session_id="session",
state={},
)
)
runner = _CapturingRunner()
adk_web_server = AdkWebServer(
agent_loader=_DummyAgentLoader(),
session_service=session_service,
memory_service=types.SimpleNamespace(),
artifact_service=types.SimpleNamespace(),
credential_service=types.SimpleNamespace(),
eval_sets_manager=types.SimpleNamespace(),
eval_set_results_manager=types.SimpleNamespace(),
agents_dir=".",
)
async def _get_runner_async(_self, _app_name: str):
return runner
adk_web_server.get_runner_async = _get_runner_async.__get__(adk_web_server) # pytype: disable=attribute-error
fast_api_app = adk_web_server.get_fast_api_app(
setup_observer=lambda _observer, _server: None,
tear_down_observer=lambda _observer, _server: None,
)
return TestClient(fast_api_app)
def test_run_live_rejects_disallowed_origin():
client = _build_ws_client()
with pytest.raises(WebSocketDisconnect) as exc_info:
with client.websocket_connect(
_WS_BASE_URL,
headers={"origin": "https://evil.com"},
) as ws:
ws.receive_text()
assert exc_info.value.code == 1008
def test_run_live_allows_matching_origin():
client = _build_ws_client()
with client.websocket_connect(
_WS_BASE_URL,
headers={"origin": "http://testserver"},
) as ws:
_ = ws.receive_text()
def test_run_live_allows_no_origin_header():
"""Non-browser clients (curl, wscat, SDKs) send no Origin header."""
client = _build_ws_client()
with client.websocket_connect(_WS_BASE_URL) as ws:
_ = ws.receive_text()
@@ -0,0 +1,156 @@
# 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
import asyncio
import json
import os
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from fastapi.testclient import TestClient
from google.adk.cli.fast_api import get_fast_api_app
import pytest
@pytest.fixture
def test_client(tmp_path):
"""Client with a temporary agents directory."""
app = get_fast_api_app(
agents_dir=str(tmp_path),
web=True,
session_service_uri="",
artifact_service_uri="",
memory_service_uri="",
allow_origins=["*"],
a2a=False,
host="127.0.0.1",
port=8000,
)
return TestClient(app)
def test_list_tests_empty(test_client):
response = test_client.get("/dev/apps/test_app/tests")
assert response.status_code == 200
assert response.json() == []
def test_create_test(test_client, tmp_path):
# Create agent dir so it exists
agent_dir = tmp_path / "test_app"
agent_dir.mkdir()
payload = {"session_data": {"events": []}}
response = test_client.put(
"/dev/apps/test_app/tests/my_test.json", json=payload
)
assert response.status_code == 200
assert response.json() == {"status": "success", "file": "my_test.json"}
# Verify file exists
assert (agent_dir / "tests" / "my_test.json").exists()
def test_list_tests_not_empty(test_client, tmp_path):
agent_dir = tmp_path / "test_app"
tests_dir = agent_dir / "tests"
tests_dir.mkdir(parents=True)
(tests_dir / "test1.json").write_text("{}")
(tests_dir / "test2.json").write_text("{}")
response = test_client.get("/dev/apps/test_app/tests")
assert response.status_code == 200
assert response.json() == ["test1.json", "test2.json"]
def test_delete_test(test_client, tmp_path):
agent_dir = tmp_path / "test_app"
tests_dir = agent_dir / "tests"
tests_dir.mkdir(parents=True)
test_file = tests_dir / "test1.json"
test_file.write_text("{}")
response = test_client.delete("/dev/apps/test_app/tests/test1.json")
assert response.status_code == 200
assert response.json() == {"status": "success"}
assert not test_file.exists()
def test_get_test_content(test_client, tmp_path):
agent_dir = tmp_path / "test_app"
tests_dir = agent_dir / "tests"
tests_dir.mkdir(parents=True)
test_file = tests_dir / "test_get.json"
test_file.write_text('{"foo": "bar"}')
response = test_client.get("/dev/apps/test_app/tests/test_get.json")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
def test_get_test_content_not_found(test_client):
response = test_client.get("/dev/apps/test_app/tests/non_existent.json")
assert response.status_code == 404
def test_rebuild_tests(test_client):
with patch("google.adk.cli.dev_server.asyncio.to_thread") as mock_to_thread:
mock_to_thread.return_value = None
response = test_client.post("/dev/apps/test_app/tests/rebuild", json={})
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_to_thread.assert_called_once()
def test_rebuild_single_test(test_client):
with patch("google.adk.cli.dev_server.asyncio.to_thread") as mock_to_thread:
mock_to_thread.return_value = None
response = test_client.post(
"/dev/apps/test_app/tests/rebuild?test_name=my_test.json", json={}
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_to_thread.assert_called_once()
args, kwargs = mock_to_thread.call_args
assert args[1].endswith("tests/my_test.json")
def test_run_tests(test_client):
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
mock_process = MagicMock()
mock_process.stdout.readline = AsyncMock(
side_effect=[b"line1\n", b"line2\n", b""]
)
mock_process.wait = AsyncMock(return_value=0)
with patch(
"google.adk.cli.dev_server.asyncio.create_subprocess_exec",
new_callable=AsyncMock,
) as mock_create_subprocess:
mock_create_subprocess.return_value = mock_process
response = test_client.post("/dev/apps/test_app/tests/run", json={})
assert response.status_code == 200
assert response.headers["content-type"] == "text/plain; charset=utf-8"
# Read stream
content = response.content
assert b"line1\n" in content
assert b"line2\n" in content
@@ -0,0 +1,312 @@
# 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
import click
from click.testing import CliRunner
from google.adk.cli.cli_tools_click import _apply_feature_overrides
from google.adk.cli.cli_tools_click import feature_options
from google.adk.features._feature_registry import _FEATURE_OVERRIDES
from google.adk.features._feature_registry import _WARNED_FEATURES
from google.adk.features._feature_registry import FeatureName
from google.adk.features._feature_registry import is_feature_enabled
from google.adk.features._feature_registry import temporary_feature_override
import pytest
@pytest.fixture(autouse=True)
def reset_feature_overrides():
"""Reset feature overrides and warnings before/after each test."""
_FEATURE_OVERRIDES.clear()
_WARNED_FEATURES.clear()
yield
_FEATURE_OVERRIDES.clear()
_WARNED_FEATURES.clear()
class TestApplyFeatureOverrides:
"""Tests for _apply_feature_overrides helper function."""
def test_single_feature(self):
"""Single feature name is applied correctly."""
_apply_feature_overrides(enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
def test_comma_separated_features(self):
"""Comma-separated feature names are applied correctly."""
_apply_feature_overrides(
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING",)
)
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
assert is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
def test_multiple_flag_values(self):
"""Multiple --enable_features flags are applied correctly."""
_apply_feature_overrides(
enable_features=(
"JSON_SCHEMA_FOR_FUNC_DECL",
"PROGRESSIVE_SSE_STREAMING",
)
)
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
assert is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
def test_whitespace_handling(self):
"""Whitespace around feature names is stripped."""
_apply_feature_overrides(
enable_features=(" JSON_SCHEMA_FOR_FUNC_DECL , COMPUTER_USE ",)
)
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
assert is_feature_enabled(FeatureName.COMPUTER_USE)
def test_empty_string_ignored(self):
"""Empty strings in the list are ignored."""
_apply_feature_overrides(enable_features=("",))
# No error should be raised
def test_unknown_feature_warns(self, capsys):
"""Unknown feature names emit a warning."""
_apply_feature_overrides(enable_features=("UNKNOWN_FEATURE_XYZ",))
captured = capsys.readouterr()
assert "WARNING" in captured.err
assert "UNKNOWN_FEATURE_XYZ" in captured.err
assert "Valid names are:" in captured.err
def test_single_disable_feature(self):
"""Single feature name is disabled correctly."""
# First enable a feature
_apply_feature_overrides(enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
# Then disable it
_apply_feature_overrides(disable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
assert not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
def test_comma_separated_disable_features(self):
"""Comma-separated feature names are disabled correctly."""
# First enable features
_apply_feature_overrides(
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING",)
)
# Then disable them
_apply_feature_overrides(
disable_features=(
"JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING",
)
)
assert not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
assert not is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
def test_disable_overrides_enable(self):
"""Disable is applied after enable, so disable wins for same feature."""
_apply_feature_overrides(
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",),
disable_features=("JSON_SCHEMA_FOR_FUNC_DECL",),
)
# disable_features is processed after enable_features
assert not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
def test_enable_and_disable_different_features(self):
"""Enable and disable can be used together for different features."""
# First enable a feature that we'll disable
_apply_feature_overrides(enable_features=("PROGRESSIVE_SSE_STREAMING",))
_apply_feature_overrides(
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",),
disable_features=("PROGRESSIVE_SSE_STREAMING",),
)
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
assert not is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
class TestFeatureOptionsDecorator:
"""Tests for feature_options decorator."""
def test_decorator_adds_enable_features_option(self):
"""Decorator adds --enable_features option to command."""
@click.command()
@feature_options()
def test_cmd():
pass
runner = CliRunner()
result = runner.invoke(test_cmd, ["--help"])
assert "--enable_features" in result.output
def test_enable_features_applied_before_command(self):
"""Features are enabled before the command function runs."""
feature_was_enabled = []
@click.command()
@feature_options()
def test_cmd():
feature_was_enabled.append(
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
)
runner = CliRunner()
runner.invoke(
test_cmd,
["--enable_features=JSON_SCHEMA_FOR_FUNC_DECL"],
catch_exceptions=False,
)
assert feature_was_enabled == [True]
def test_multiple_enable_features_flags(self):
"""Multiple --enable_features flags work correctly."""
enabled_features = []
@click.command()
@feature_options()
def test_cmd():
enabled_features.append(
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
)
enabled_features.append(
is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
)
runner = CliRunner()
runner.invoke(
test_cmd,
[
"--enable_features=JSON_SCHEMA_FOR_FUNC_DECL",
"--enable_features=PROGRESSIVE_SSE_STREAMING",
],
catch_exceptions=False,
)
assert enabled_features == [True, True]
def test_comma_separated_enable_features(self):
"""Comma-separated feature names work correctly."""
enabled_features = []
@click.command()
@feature_options()
def test_cmd():
enabled_features.append(
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
)
enabled_features.append(
is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
)
runner = CliRunner()
runner.invoke(
test_cmd,
[
"--enable_features=JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING"
],
catch_exceptions=False,
)
assert enabled_features == [True, True]
def test_no_enable_features_flag(self):
"""Command works without --enable_features flag."""
enabled_features = []
with temporary_feature_override(
FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, False
):
@click.command()
@feature_options()
def test_cmd():
enabled_features.append(
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
)
runner = CliRunner()
result = runner.invoke(test_cmd, [], catch_exceptions=False)
assert result.exit_code == 0
assert enabled_features == [False]
def test_preserves_function_metadata(self):
"""Decorator preserves the wrapped function's metadata."""
@click.command()
@feature_options()
def my_test_command():
"""My docstring."""
pass
# The callback should have preserved metadata
assert (
"my_test_command" in my_test_command.name
or my_test_command.callback.__name__ == "my_test_command"
)
def test_decorator_adds_disable_features_option(self):
"""Decorator adds --disable_features option to command."""
@click.command()
@feature_options()
def test_cmd():
pass
runner = CliRunner()
result = runner.invoke(test_cmd, ["--help"])
assert "--disable_features" in result.output
def test_disable_features_applied_before_command(self):
"""Features are disabled before the command function runs."""
# First enable the feature via override
_apply_feature_overrides(enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
feature_was_disabled = []
@click.command()
@feature_options()
def test_cmd():
feature_was_disabled.append(
not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
)
runner = CliRunner()
runner.invoke(
test_cmd,
["--disable_features=JSON_SCHEMA_FOR_FUNC_DECL"],
catch_exceptions=False,
)
assert feature_was_disabled == [True]
def test_enable_and_disable_together(self):
"""Both --enable_features and --disable_features work together."""
feature_states = []
@click.command()
@feature_options()
def test_cmd():
feature_states.append(
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
)
feature_states.append(
is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
)
runner = CliRunner()
runner.invoke(
test_cmd,
[
"--enable_features=JSON_SCHEMA_FOR_FUNC_DECL",
"--disable_features=PROGRESSIVE_SSE_STREAMING",
],
catch_exceptions=False,
)
# JSON_SCHEMA_FOR_FUNC_DECL should be enabled
# PROGRESSIVE_SSE_STREAMING should be disabled
assert feature_states == [True, False]
@@ -0,0 +1,178 @@
# 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.
"""Unit tests to check if any Click options and method parameters mismatch."""
import inspect
from typing import MutableMapping
from typing import Optional
import click
from google.adk.cli.cli_tools_click import cli_api_server
from google.adk.cli.cli_tools_click import cli_create_cmd
from google.adk.cli.cli_tools_click import cli_deploy_agent_engine
from google.adk.cli.cli_tools_click import cli_deploy_cloud_run
from google.adk.cli.cli_tools_click import cli_deploy_gke
from google.adk.cli.cli_tools_click import cli_eval
from google.adk.cli.cli_tools_click import cli_run
from google.adk.cli.cli_tools_click import cli_web
from google.adk.cli.cli_tools_click import deploy
from google.adk.cli.cli_tools_click import main
def _get_command_by_name(
commands: MutableMapping[str, click.Command], name
) -> Optional[click.Command]:
"""Return the command object with the given name from a commands dict."""
return next((cmd for cmd in commands.values() if cmd.name == name), None)
def _get_click_options(command) -> set[str]:
"""Extract Click option names from a command."""
options = []
for param in command.params:
if isinstance(param, (click.Option, click.Argument)):
options.append(param.name)
return set(options)
def _get_method_parameters(func) -> set[str]:
"""Extract parameter names from a method signature."""
sig = inspect.signature(func)
return set(sig.parameters.keys())
def _check_options_in_parameters(
command,
func,
command_name,
ignore_params: Optional[set[str]] = None,
):
"""Check if all Click options are present in method parameters."""
click_options = _get_click_options(command)
method_params = _get_method_parameters(func)
if ignore_params:
click_options -= ignore_params
method_params -= ignore_params
option_only = click_options - method_params
parameter_only = method_params - click_options
assert click_options == method_params, f"""\
Click options and method parameters do not match for command: `{command_name}`.
Click options: {click_options}
Method parameters: {method_params}
Options only: {option_only}
Parameters only: {parameter_only}
"""
def test_adk_create():
"""Test that cli_create_cmd has all required parameters."""
create_command = _get_command_by_name(main.commands, "create")
assert create_command is not None, "Create command not found"
_check_options_in_parameters(
create_command, cli_create_cmd.callback, "create"
)
def test_adk_run():
"""Test that cli_run has all required parameters."""
run_command = _get_command_by_name(main.commands, "run")
assert run_command is not None, "Run command not found"
_check_options_in_parameters(
run_command,
cli_run.callback,
"run",
ignore_params={"verbose", "enable_features", "disable_features"},
)
def test_adk_eval():
"""Test that cli_eval has all required parameters."""
eval_command = _get_command_by_name(main.commands, "eval")
assert eval_command is not None, "Eval command not found"
_check_options_in_parameters(
eval_command,
cli_eval.callback,
"eval",
ignore_params={"enable_features", "disable_features"},
)
def test_adk_web():
"""Test that cli_web has all required parameters."""
web_command = _get_command_by_name(main.commands, "web")
assert web_command is not None, "Web command not found"
_check_options_in_parameters(
web_command,
cli_web.callback,
"web",
ignore_params={"verbose", "enable_features", "disable_features"},
)
def test_adk_api_server():
"""Test that cli_api_server has all required parameters."""
api_server_command = _get_command_by_name(main.commands, "api_server")
assert api_server_command is not None, "API server command not found"
_check_options_in_parameters(
api_server_command,
cli_api_server.callback,
"api_server",
ignore_params={"verbose", "enable_features", "disable_features"},
)
def test_adk_deploy_cloud_run():
"""Test that cli_deploy_cloud_run has all required parameters."""
cloud_run_command = _get_command_by_name(deploy.commands, "cloud_run")
assert cloud_run_command is not None, "Cloud Run deploy command not found"
_check_options_in_parameters(
cloud_run_command,
cli_deploy_cloud_run.callback,
"deploy cloud_run",
ignore_params={"verbose", "ctx"},
)
def test_adk_deploy_agent_engine():
"""Test that cli_deploy_agent_engine has all required parameters."""
agent_engine_command = _get_command_by_name(deploy.commands, "agent_engine")
assert (
agent_engine_command is not None
), "Agent Engine deploy command not found"
_check_options_in_parameters(
agent_engine_command,
cli_deploy_agent_engine.callback,
"deploy agent_engine",
)
def test_adk_deploy_gke():
"""Test that cli_deploy_gke has all required parameters."""
gke_command = _get_command_by_name(deploy.commands, "gke")
assert gke_command is not None, "GKE deploy command not found"
_check_options_in_parameters(
gke_command, cli_deploy_gke.callback, "deploy gke"
)
+182
View File
@@ -0,0 +1,182 @@
# 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 CORS configuration with regex prefix support."""
from unittest import mock
from google.adk.artifacts.base_artifact_service import BaseArtifactService
from google.adk.auth.credential_service.base_credential_service import BaseCredentialService
from google.adk.cli.adk_web_server import _parse_cors_origins
from google.adk.cli.adk_web_server import AdkWebServer
from google.adk.cli.utils.base_agent_loader import BaseAgentLoader
from google.adk.evaluation.eval_set_results_manager import EvalSetResultsManager
from google.adk.evaluation.eval_sets_manager import EvalSetsManager
from google.adk.memory.base_memory_service import BaseMemoryService
from google.adk.sessions.base_session_service import BaseSessionService
import pytest
class MockAgentLoader:
"""Mock agent loader for testing."""
def __init__(self):
pass
def load_agent(self, app_name):
del self, app_name
return mock.MagicMock()
def list_agents(self):
del self
return ["test_app"]
def list_agents_detailed(self):
del self
return []
def create_adk_web_server():
"""Create an AdkWebServer instance for testing."""
return AdkWebServer(
agent_loader=MockAgentLoader(),
session_service=mock.create_autospec(BaseSessionService, instance=True),
memory_service=mock.create_autospec(BaseMemoryService, instance=True),
artifact_service=mock.create_autospec(BaseArtifactService, instance=True),
credential_service=mock.create_autospec(
BaseCredentialService, instance=True
),
eval_sets_manager=mock.create_autospec(EvalSetsManager, instance=True),
eval_set_results_manager=mock.create_autospec(
EvalSetResultsManager, instance=True
),
agents_dir=".",
)
def _get_cors_middleware(app):
"""Extract CORSMiddleware from app's middleware stack.
Returns:
The CORSMiddleware instance, or None if not found.
"""
for middleware in app.user_middleware:
if middleware.cls.__name__ == "CORSMiddleware":
return middleware
return None
CORS_ORIGINS_TEST_CASES = [
# Literal origins only
(
["https://example.com", "https://test.com"],
["https://example.com", "https://test.com"],
None,
),
# Regex patterns only
(
[
"regex:https://.*\\.example\\.com",
"regex:https://.*\\.test\\.com",
],
[],
"https://.*\\.example\\.com|https://.*\\.test\\.com",
),
# Mixed literal and regex
(
[
"https://example.com",
"regex:https://.*\\.subdomain\\.com",
"https://test.com",
"regex:https://tenant-.*\\.myapp\\.com",
],
["https://example.com", "https://test.com"],
"https://.*\\.subdomain\\.com|https://tenant-.*\\.myapp\\.com",
),
# Wildcard origin
(["*"], ["*"], None),
# Single regex
(
["regex:https://.*\\.example\\.com"],
[],
"https://.*\\.example\\.com",
),
]
CORS_ORIGINS_TEST_IDS = [
"literal_only",
"regex_only",
"mixed",
"wildcard",
"single_regex",
]
class TestParseCorsOrigins:
"""Tests for the _parse_cors_origins helper function."""
@pytest.mark.parametrize(
"allow_origins,expected_literal,expected_regex",
CORS_ORIGINS_TEST_CASES,
ids=CORS_ORIGINS_TEST_IDS,
)
def test_parse_cors_origins(
self, allow_origins, expected_literal, expected_regex
):
"""Test parsing of allow_origins into literal and regex components."""
literal_origins, combined_regex = _parse_cors_origins(allow_origins)
assert literal_origins == expected_literal
assert combined_regex == expected_regex
class TestCorsMiddlewareConfiguration:
"""Tests for CORS middleware configuration in AdkWebServer."""
@pytest.mark.parametrize(
"allow_origins,expected_literal,expected_regex",
CORS_ORIGINS_TEST_CASES,
ids=CORS_ORIGINS_TEST_IDS,
)
def test_cors_middleware_configuration(
self, allow_origins, expected_literal, expected_regex
):
"""Test CORS middleware is configured correctly with various origin types."""
server = create_adk_web_server()
app = server.get_fast_api_app(
allow_origins=allow_origins,
setup_observer=lambda _o, _s: None,
tear_down_observer=lambda _o, _s: None,
)
cors_middleware = _get_cors_middleware(app)
assert cors_middleware is not None
assert cors_middleware.kwargs["allow_origins"] == expected_literal
assert cors_middleware.kwargs["allow_origin_regex"] == expected_regex
@pytest.mark.parametrize(
"allow_origins",
[None, []],
ids=["none", "empty_list"],
)
def test_cors_middleware_not_added_when_no_origins(self, allow_origins):
"""Test that no CORS middleware is added when allow_origins is None or empty."""
server = create_adk_web_server()
app = server.get_fast_api_app(
allow_origins=allow_origins,
setup_observer=lambda _o, _s: None,
tear_down_observer=lambda _o, _s: None,
)
cors_middleware = _get_cors_middleware(app)
assert cors_middleware is None
@@ -0,0 +1,196 @@
# 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 DNS-rebinding protection in _OriginCheckMiddleware."""
from google.adk.cli.api_server import _is_loopback_address
from google.adk.cli.api_server import _is_request_origin_allowed
import pytest
class TestIsLoopbackAddress:
"""Unit tests for _is_loopback_address."""
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"localhost",
"::1",
"[::1]",
"127.0.0.1:8000",
"localhost:8000",
"[::1]:8000",
"127.1.2.3", # any 127.x.x.x is loopback
],
)
def test_loopback_hosts(self, host: str):
assert _is_loopback_address(host), f"{host!r} should be loopback"
@pytest.mark.parametrize(
"host",
[
"evil.com",
"127.evil.com",
"0.0.0.0",
"192.168.1.1",
"10.0.0.1",
"128.0.0.1",
"",
],
)
def test_non_loopback_hosts(self, host: str):
assert not _is_loopback_address(host), f"{host!r} should NOT be loopback"
class TestDnsRebindingProtection:
"""Tests that DNS-rebinding attacks are blocked when server is on loopback."""
def _make_scope(
self, server_host: str = "127.0.0.1", host_header: str = "127.0.0.1:8000"
) -> dict:
"""Build a minimal ASGI scope for testing."""
return {
"type": "http",
"method": "POST",
"server": (server_host, 8000),
"headers": [
(b"host", host_header.encode()),
],
"scheme": "http",
}
# --- DNS rebinding scenarios (should be BLOCKED) ---
def test_dns_rebinding_evil_origin_loopback_server_no_configured_origins(
self,
):
"""Attacker page (evil.com) DNS-rebinds to 127.0.0.1 and sends a POST.
Browser sends Origin: http://evil.com, Host: evil.com.
Server is bound to 127.0.0.1.
No explicit allow-origins configured.
Expected: BLOCKED.
"""
scope = self._make_scope(
server_host="127.0.0.1", host_header="evil.com:8000"
)
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert (
not result
), "DNS-rebinding from evil.com should be blocked on loopback server"
def test_dns_rebinding_127_evil_origin(self):
"""Origin header host starts with '127.' but is a hostname (127.evil.com)."""
scope = self._make_scope(
server_host="127.0.0.1", host_header="127.evil.com:8000"
)
result = _is_request_origin_allowed(
origin="http://127.evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert not result
def test_dns_rebinding_localhost_server(self):
"""Same attack, server bound as 'localhost'."""
scope = self._make_scope(server_host="localhost", host_header="evil.com")
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert not result
def test_dns_rebinding_ipv6_loopback_server(self):
"""Same attack, server bound to ::1."""
scope = self._make_scope(server_host="::1", host_header="evil.com")
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert not result
# --- Legitimate same-origin requests (should be ALLOWED) ---
def test_same_origin_localhost_allowed(self):
"""Legitimate browser request from localhost UI to localhost server."""
scope = self._make_scope(
server_host="127.0.0.1", host_header="127.0.0.1:8000"
)
result = _is_request_origin_allowed(
origin="http://127.0.0.1:8000",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert result, "Same-origin localhost request should be allowed"
def test_same_origin_localhost_named(self):
"""Browser opens http://localhost:8000 -> requests to localhost:8000."""
scope = self._make_scope(
server_host="127.0.0.1", host_header="localhost:8000"
)
result = _is_request_origin_allowed(
origin="http://localhost:8000",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert result
# --- Explicit allow-origins configured (allow-list bypasses DNS guard) ---
def test_explicit_allowlist_overrides_dns_rebinding_guard(self):
"""If the developer explicitly allows evil.com, it should be permitted."""
scope = self._make_scope(server_host="127.0.0.1", host_header="evil.com")
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=["http://evil.com"],
allowed_origin_regex=None,
has_configured_allowed_origins=True,
)
assert result, "Explicitly allowed origin should still pass"
# --- Non-loopback server (protection does not apply) ---
def test_non_loopback_server_no_dns_guard(self):
"""Server bound to 0.0.0.0 — DNS guard must not interfere with same-origin check."""
scope = self._make_scope(
server_host="0.0.0.0", host_header="example.com:8000"
)
result = _is_request_origin_allowed(
origin="http://example.com:8000",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert result, "Same-origin on public server should be allowed"
+3504
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
# 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.
"""Path-traversal containment tests for Agent Builder file tools."""
from __future__ import annotations
import os
from pathlib import Path
from unittest import mock
from google.adk.cli.built_in_agents.tools.delete_files import delete_files
from google.adk.cli.built_in_agents.tools.read_files import read_files
from google.adk.cli.built_in_agents.tools.write_files import write_files
from google.adk.cli.built_in_agents.utils.resolve_root_directory import resolve_file_path
import pytest
def _tool_context(root: Path) -> mock.MagicMock:
tool_context = mock.MagicMock()
tool_context._invocation_context.session.state = {"root_directory": str(root)}
return tool_context
def test_resolve_file_path_allows_path_within_root(tmp_path):
resolved = resolve_file_path(
"sub/dir/file.txt", {"root_directory": str(tmp_path)}
)
assert resolved == (tmp_path / "sub" / "dir" / "file.txt").resolve()
def test_resolve_file_path_allows_dot(tmp_path):
resolved = resolve_file_path(".", {"root_directory": str(tmp_path)})
assert resolved == tmp_path.resolve()
def test_resolve_file_path_allows_interior_dotdot_within_root(tmp_path):
resolved = resolve_file_path(
"sub/../file.txt", {"root_directory": str(tmp_path)}
)
assert resolved == (tmp_path / "file.txt").resolve()
def test_resolve_file_path_allows_absolute_within_root(tmp_path):
target = tmp_path / "nested" / "ok.txt"
resolved = resolve_file_path(str(target), {"root_directory": str(tmp_path)})
assert resolved == target.resolve()
def test_resolve_file_path_rejects_relative_traversal(tmp_path):
with pytest.raises(ValueError):
resolve_file_path("../../escape.txt", {"root_directory": str(tmp_path)})
def test_resolve_file_path_rejects_absolute_outside_root(tmp_path):
with pytest.raises(ValueError):
resolve_file_path("/etc/passwd", {"root_directory": str(tmp_path)})
async def test_write_files_blocks_relative_traversal(
tmp_path, tmp_path_factory
):
outside = tmp_path_factory.mktemp("outside")
payload = os.path.relpath(outside / "pwned.txt", tmp_path)
result = await write_files(
files={payload: "PWNED"}, tool_context=_tool_context(tmp_path)
)
assert not result["success"]
assert not (outside / "pwned.txt").exists()
async def test_write_files_blocks_absolute_outside_root(
tmp_path, tmp_path_factory
):
outside = tmp_path_factory.mktemp("outside")
target = outside / "abs.txt"
result = await write_files(
files={str(target): "PWNED"}, tool_context=_tool_context(tmp_path)
)
assert not result["success"]
assert not target.exists()
async def test_write_files_allows_path_within_root(tmp_path):
result = await write_files(
files={"sub/ok.txt": "hello"}, tool_context=_tool_context(tmp_path)
)
assert result["success"]
assert (tmp_path / "sub" / "ok.txt").read_text() == "hello"
async def test_read_files_blocks_relative_traversal(tmp_path, tmp_path_factory):
outside = tmp_path_factory.mktemp("outside")
secret = outside / "secret.txt"
secret.write_text("TOKEN=abc")
payload = os.path.relpath(secret, tmp_path)
result = await read_files(
file_paths=[payload], tool_context=_tool_context(tmp_path)
)
assert not result["success"]
assert all(
"TOKEN=abc" not in info.get("content", "")
for info in result["files"].values()
)
async def test_delete_files_blocks_relative_traversal(
tmp_path, tmp_path_factory
):
outside = tmp_path_factory.mktemp("outside")
victim = outside / "victim.txt"
victim.write_text("bye")
payload = os.path.relpath(victim, tmp_path)
result = await delete_files(
file_paths=[payload],
tool_context=_tool_context(tmp_path),
confirm_deletion=True,
)
assert not result["success"]
assert victim.exists()
@@ -0,0 +1,244 @@
# 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 pathlib import Path
from types import SimpleNamespace
from unittest import mock
from unittest.mock import patch
from google.adk.cli import service_registry
import pytest
@pytest.fixture(autouse=True)
def mock_services():
"""Mock all service implementation classes to avoid real instantiation."""
with (
patch(
"google.adk.sessions.vertex_ai_session_service.VertexAiSessionService"
) as mock_vertex_session,
patch(
"google.adk.sessions.database_session_service.DatabaseSessionService"
) as mock_db_session,
patch(
"google.adk.sessions.sqlite_session_service.SqliteSessionService"
) as mock_sqlite_session,
patch(
"google.adk.artifacts.gcs_artifact_service.GcsArtifactService"
) as mock_gcs_artifact,
patch(
"google.adk.memory.vertex_ai_rag_memory_service.VertexAiRagMemoryService"
) as mock_rag_memory,
patch(
"google.adk.memory.vertex_ai_memory_bank_service.VertexAiMemoryBankService"
) as mock_agentengine_memory,
):
yield {
"vertex_session": mock_vertex_session,
"db_session": mock_db_session,
"sqlite_session": mock_sqlite_session,
"gcs_artifact": mock_gcs_artifact,
"rag_memory": mock_rag_memory,
"agentengine_memory": mock_agentengine_memory,
}
@pytest.fixture
def registry():
from google.adk.cli.service_registry import get_service_registry
return get_service_registry()
# Session Service Tests
def test_create_session_service_sqlite(registry, mock_services):
registry.create_session_service("sqlite:///test.db")
mock_services["sqlite_session"].assert_called_once_with(db_path="test.db")
def test_create_session_service_sqlite_ignores_unsupported_kwargs(
registry, mock_services, caplog
):
"""Test that SqliteSessionService ignores unsupported kwargs and logs warning."""
import logging
with caplog.at_level(logging.WARNING):
registry.create_session_service(
"sqlite:///test.db", pool_size=10, agents_dir="foo"
)
# SqliteSessionService should only receive db_path, not pool_size
mock_services["sqlite_session"].assert_called_once_with(db_path="test.db")
# Verify warning was logged about ignored kwargs
assert (
"SqliteSessionService does not support additional kwargs" in caplog.text
)
assert "pool_size" in caplog.text
def test_create_session_service_postgresql(registry, mock_services):
registry.create_session_service("postgresql://user:pass@host/db")
mock_services["db_session"].assert_called_once_with(
db_url="postgresql://user:pass@host/db"
)
@patch("google.adk.cli.utils.envs.load_dotenv_for_agent")
def test_create_session_service_agentengine_short(
mock_load_dotenv, registry, mock_services, monkeypatch
):
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
registry.create_session_service(
"agentengine://123", agents_dir="/path/to/agents"
)
mock_services["vertex_session"].assert_called_once_with(
project="test-project", location="us-central1", agent_engine_id="123"
)
mock_load_dotenv.assert_called_once_with("", "/path/to/agents")
def test_create_session_service_agentengine_full(registry, mock_services):
uri = "agentengine://projects/p/locations/l/reasoningEngines/123"
registry.create_session_service(uri, agents_dir="/path/to/agents")
mock_services["vertex_session"].assert_called_once_with(
project="p", location="l", agent_engine_id="123"
)
# Artifact Service Tests
def test_create_artifact_service_gcs(registry, mock_services):
registry.create_artifact_service(
"gs://my-bucket/path/prefix", agents_dir="foo", other_kwarg="bar"
)
mock_services["gcs_artifact"].assert_called_once_with(
bucket_name="my-bucket", other_kwarg="bar"
)
def test_file_artifact_factory_normalizes_windows_file_uri(monkeypatch):
monkeypatch.setattr(service_registry, "os", SimpleNamespace(name="nt"))
mocked_url2pathname = mock.Mock(return_value=r"C:\tmp\adk artifacts")
monkeypatch.setattr(service_registry, "url2pathname", mocked_url2pathname)
registry = service_registry.ServiceRegistry()
service_registry._register_builtin_services(registry)
with mock.patch(
"google.adk.artifacts.file_artifact_service.FileArtifactService"
) as mock_file_artifact_service:
registry.create_artifact_service("file:///C:/tmp/adk%20artifacts")
mocked_url2pathname.assert_called_once_with("/C:/tmp/adk artifacts")
mock_file_artifact_service.assert_called_once_with(
root_dir=Path(r"C:\tmp\adk artifacts")
)
def test_file_artifact_factory_rejects_non_local_authority():
registry = service_registry.ServiceRegistry()
service_registry._register_builtin_services(registry)
with pytest.raises(ValueError, match="local filesystem"):
registry.create_artifact_service("file://example.com/tmp/adk_artifacts")
# Memory Service Tests
@patch("google.adk.cli.utils.envs.load_dotenv_for_agent")
def test_create_memory_service_rag(
mock_load_dotenv, registry, mock_services, monkeypatch
):
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
registry.create_memory_service(
"rag://corpus-123", agents_dir="/path/to/agents"
)
mock_services["rag_memory"].assert_called_once_with(
rag_corpus=(
"projects/test-project/locations/us-central1/ragCorpora/corpus-123"
)
)
mock_load_dotenv.assert_called_once_with("", "/path/to/agents")
@patch("google.adk.cli.utils.envs.load_dotenv_for_agent")
def test_create_memory_service_agentengine_short(
mock_load_dotenv, registry, mock_services, monkeypatch
):
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
registry.create_memory_service(
"agentengine://456", agents_dir="/path/to/agents"
)
mock_services["agentengine_memory"].assert_called_once_with(
project="test-project", location="us-central1", agent_engine_id="456"
)
mock_load_dotenv.assert_called_once_with("", "/path/to/agents")
def test_create_memory_service_agentengine_full(registry, mock_services):
uri = "agentengine://projects/p/locations/l/reasoningEngines/456"
registry.create_memory_service(uri, agents_dir="/path/to/agents")
mock_services["agentengine_memory"].assert_called_once_with(
project="p", location="l", agent_engine_id="456"
)
def test_create_memory_service_memory(registry):
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
memory_service = registry.create_memory_service("memory://")
assert isinstance(memory_service, InMemoryMemoryService)
# Task Store Tests
def test_create_task_store_memory(registry):
from a2a.server.tasks import InMemoryTaskStore
task_store = registry._create_task_store_service("memory://")
assert isinstance(task_store, InMemoryTaskStore)
@patch("sqlalchemy.ext.asyncio.create_async_engine")
@patch("a2a.server.tasks.DatabaseTaskStore")
def test_create_task_store_postgresql(
mock_db_task_store, mock_create_engine, registry
):
mock_engine = mock_create_engine.return_value
registry._create_task_store_service("postgresql+asyncpg://user:pass@host/db")
mock_create_engine.assert_called_once_with(
"postgresql+asyncpg://user:pass@host/db"
)
mock_db_task_store.assert_called_once_with(engine=mock_engine)
# General Tests
def test_unsupported_scheme(registry, mock_services):
session_service = registry.create_session_service("unsupported://foo")
artifact_service = registry.create_artifact_service("unsupported://foo")
memory_service = registry.create_memory_service("unsupported://foo")
assert session_service is None
assert artifact_service is None
assert memory_service is None
with pytest.raises(ValueError, match="Unsupported A2A task store URI scheme"):
registry._create_task_store_service("unsupported://foo")
for service in [
"vertex_session",
"db_session",
"gcs_artifact",
"rag_memory",
"agentengine_memory",
]:
mock_services[service].assert_not_called()
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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,91 @@
# 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 unittest import mock
from google.adk.cli.utils import agent_loader
from google.adk.cli.utils.agent_change_handler import AgentChangeEventHandler
from google.adk.cli.utils.shared_value import SharedValue
import pytest
from watchdog.events import FileModifiedEvent
class TestAgentChangeEventHandler:
"""Unit tests for AgentChangeEventHandler file extension filtering."""
@pytest.fixture
def mock_agent_loader(self):
"""Create a mock AgentLoader constrained to the public API."""
return mock.create_autospec(
agent_loader.AgentLoader, instance=True, spec_set=True
)
@pytest.fixture
def handler(self, mock_agent_loader):
"""Create an AgentChangeEventHandler with mocked dependencies."""
runners_to_clean = set()
current_app_name_ref = SharedValue(value="test_agent")
return AgentChangeEventHandler(
agent_loader=mock_agent_loader,
runners_to_clean=runners_to_clean,
current_app_name_ref=current_app_name_ref,
)
@pytest.mark.parametrize(
"file_path",
[
pytest.param("/path/to/agent.py", id="python_file"),
pytest.param("/path/to/config.yaml", id="yaml_file"),
pytest.param("/path/to/config.yml", id="yml_file"),
],
)
def test_on_modified_triggers_reload_for_supported_extensions(
self, handler, mock_agent_loader, file_path
):
"""Verify that .py, .yaml, and .yml files trigger agent reload."""
event = FileModifiedEvent(src_path=file_path)
handler.on_modified(event)
mock_agent_loader.remove_agent_from_cache.assert_called_once_with(
"test_agent"
)
assert (
"test_agent" in handler.runners_to_clean
), f"Expected 'test_agent' in runners_to_clean for {file_path}"
@pytest.mark.parametrize(
"file_path",
[
pytest.param("/path/to/file.json", id="json_file"),
pytest.param("/path/to/file.txt", id="txt_file"),
pytest.param("/path/to/file.md", id="markdown_file"),
pytest.param("/path/to/file.toml", id="toml_file"),
pytest.param("/path/to/.gitignore", id="gitignore_file"),
pytest.param("/path/to/file", id="no_extension"),
],
)
def test_on_modified_ignores_unsupported_extensions(
self, handler, mock_agent_loader, file_path
):
"""Verify that non-py/yaml/yml files do not trigger reload."""
event = FileModifiedEvent(src_path=file_path)
handler.on_modified(event)
mock_agent_loader.remove_agent_from_cache.assert_not_called()
assert not handler.runners_to_clean, (
f"Expected runners_to_clean to be empty for {file_path}, "
f"got {handler.runners_to_clean}"
)
File diff suppressed because it is too large Load Diff
+527
View File
@@ -0,0 +1,527 @@
# 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.
"""Unit tests for utilities in cli."""
from __future__ import annotations
import json
from pathlib import Path
from textwrap import dedent
import types
from typing import Any
from typing import Dict
from typing import List
from typing import Tuple
from unittest import mock
import click
from google.adk.agents.base_agent import BaseAgent
from google.adk.apps.app import App
from google.adk.artifacts.file_artifact_service import FileArtifactService
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
import google.adk.cli.cli as cli
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
from google.adk.cli.utils.service_factory import create_artifact_service_from_options
from google.adk.sessions.in_memory_session_service import InMemorySessionService
import pytest
# Helpers
class _Recorder:
"""Callable that records every invocation."""
def __init__(self) -> None:
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
def __call__(self, *args: Any, **kwargs: Any) -> None:
self.calls.append((args, kwargs))
# Fixtures
@pytest.fixture(autouse=True)
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
"""Silence click output in every test."""
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
@pytest.fixture(autouse=True)
def _patch_types_and_runner(monkeypatch: pytest.MonkeyPatch) -> None:
"""Replace google.genai.types and Runner with lightweight fakes."""
# Dummy Part / Content
class _Part:
def __init__(self, text: str | None = "") -> None:
self.text = text
class _Content:
def __init__(self, role: str, parts: List[_Part]) -> None:
self.role = role
self.parts = parts
monkeypatch.setattr(cli.types, "Part", _Part)
monkeypatch.setattr(cli.types, "Content", _Content)
# Fake Runner yielding a single assistant echo
class _FakeRunner:
def __init__(self, *a: Any, **k: Any) -> None:
...
async def run_async(self, *a: Any, **k: Any):
message = a[2] if len(a) >= 3 else k["new_message"]
text = message.parts[0].text if message.parts else ""
response = _Content("assistant", [_Part(f"echo:{text}")])
ev = types.SimpleNamespace(
author="assistant",
content=response,
node_info=None,
long_running_tool_ids=[],
)
yield ev
async def close(self, *a: Any, **k: Any) -> None:
...
monkeypatch.setattr(cli, "Runner", _FakeRunner)
@pytest.fixture()
def fake_agent(tmp_path: Path):
"""Create a minimal importable agent package and patch importlib."""
parent_dir = tmp_path / "agents"
parent_dir.mkdir()
agent_dir = parent_dir / "fake_agent"
agent_dir.mkdir()
# __init__.py exposes root_agent with .name
(agent_dir / "__init__.py").write_text(dedent("""
from google.adk.agents.base_agent import BaseAgent
class FakeAgent(BaseAgent):
def __init__(self, name):
super().__init__(name=name)
root_agent = FakeAgent(name="fake_root")
"""))
return parent_dir, "fake_agent"
@pytest.fixture()
def fake_app_agent(tmp_path: Path):
"""Create an agent package that exposes an App."""
parent_dir = tmp_path / "agents"
parent_dir.mkdir()
agent_dir = parent_dir / "fake_app_agent"
agent_dir.mkdir()
(agent_dir / "__init__.py").write_text(dedent("""
from google.adk.agents.base_agent import BaseAgent
from google.adk.apps.app import App
class FakeAgent(BaseAgent):
def __init__(self, name):
super().__init__(name=name)
root_agent = FakeAgent(name="fake_root")
app = App(name="custom_cli_app", root_agent=root_agent)
"""))
return parent_dir, "fake_app_agent", "custom_cli_app"
# _run_input_file
@pytest.mark.asyncio
async def test_run_input_file_outputs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_input_file should echo user & assistant messages and return a populated session."""
recorder: List[str] = []
def _echo(msg: str) -> None:
recorder.append(msg)
monkeypatch.setattr(click, "echo", _echo)
input_json = {
"state": {"foo": "bar"},
"queries": ["hello world"],
}
input_path = tmp_path / "input.json"
input_path.write_text(json.dumps(input_json))
artifact_service = InMemoryArtifactService()
session_service = InMemorySessionService()
credential_service = InMemoryCredentialService()
dummy_root = BaseAgent(name="root")
session = await cli.run_input_file(
app_name="app",
user_id="user",
agent_or_app=dummy_root,
artifact_service=artifact_service,
session_service=session_service,
credential_service=credential_service,
input_path=str(input_path),
)
assert session.state["foo"] == "bar"
assert any("[user]:" in line for line in recorder)
assert any("[assistant]:" in line for line in recorder)
# _run_cli (input_file branch)
@pytest.mark.asyncio
async def test_run_cli_with_input_file(fake_agent, tmp_path: Path) -> None:
"""run_cli should process an input file without raising and without saving."""
parent_dir, folder_name = fake_agent
input_json = {"state": {}, "queries": ["ping"]}
input_path = tmp_path / "in.json"
input_path.write_text(json.dumps(input_json))
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=str(input_path),
saved_session_file=None,
save_session=False,
)
@pytest.mark.asyncio
async def test_run_cli_loads_services_module(
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_cli should load custom services from the agents directory."""
parent_dir, folder_name = fake_agent
input_json = {"state": {}, "queries": ["ping"]}
input_path = tmp_path / "input.json"
input_path.write_text(json.dumps(input_json))
loaded_dirs: list[str] = []
monkeypatch.setattr(
cli, "load_services_module", lambda path: loaded_dirs.append(path)
)
agent_root = parent_dir / folder_name
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=str(input_path),
saved_session_file=None,
save_session=False,
)
assert loaded_dirs == [str(agent_root.resolve())]
@pytest.mark.asyncio
async def test_run_cli_app_uses_app_name_for_sessions(
fake_app_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_cli should honor the App-provided name when creating sessions."""
parent_dir, folder_name, app_name = fake_app_agent
created_app_names: List[str] = []
class _SpySessionService(InMemorySessionService):
async def create_session(self, *, app_name: str, **kwargs: Any) -> Any:
created_app_names.append(app_name)
return await super().create_session(app_name=app_name, **kwargs)
spy_session_service = _SpySessionService()
def _session_factory(**_: Any) -> InMemorySessionService:
return spy_session_service
monkeypatch.setattr(
cli, "create_session_service_from_options", _session_factory
)
input_json = {"state": {}, "queries": ["ping"]}
input_path = tmp_path / "input_app.json"
input_path.write_text(json.dumps(input_json))
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=str(input_path),
saved_session_file=None,
save_session=False,
)
assert created_app_names
assert all(name == app_name for name in created_app_names)
# _run_cli (interactive + save session branch)
@pytest.mark.asyncio
async def test_run_cli_save_session(
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_cli should save a session file when save_session=True."""
parent_dir, folder_name = fake_agent
# Simulate user typing 'exit' followed by session id 'sess123'
responses = iter(["exit", "sess123"])
monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(responses))
session_file = Path(parent_dir) / folder_name / "sess123.session.json"
if session_file.exists():
session_file.unlink()
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=None,
saved_session_file=None,
save_session=True,
)
assert session_file.exists()
data = json.loads(session_file.read_text())
# The saved JSON should at least contain id and events keys
assert "id" in data and "events" in data
@pytest.mark.asyncio
async def test_create_artifact_service_isolates_artifacts_per_agent(
tmp_path: Path,
) -> None:
"""Each agent's artifacts should land in its own .adk/artifacts folder."""
(tmp_path / "agent_a").mkdir()
(tmp_path / "agent_b").mkdir()
service = create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(service, PerAgentFileArtifactService)
# Touching each agent provisions its own per-agent .adk/artifacts folder.
for app_name in ("agent_a", "agent_b"):
await service.list_artifact_keys(
app_name=app_name, user_id="user", session_id="session"
)
assert (tmp_path / "agent_a" / ".adk" / "artifacts").exists()
assert (tmp_path / "agent_b" / ".adk" / "artifacts").exists()
assert not (tmp_path / ".adk").exists()
def test_create_artifact_service_respects_memory_uri(tmp_path: Path) -> None:
"""Service factory should honor memory:// URIs."""
service = create_artifact_service_from_options(
base_dir=tmp_path, artifact_service_uri="memory://"
)
assert isinstance(service, InMemoryArtifactService)
def test_create_artifact_service_accepts_file_uri(tmp_path: Path) -> None:
"""Service factory should allow custom local roots via file:// URIs."""
custom_root = tmp_path / "custom_artifacts"
service = create_artifact_service_from_options(
base_dir=tmp_path, artifact_service_uri=custom_root.as_uri()
)
assert isinstance(service, FileArtifactService)
assert service.root_dir == custom_root
assert custom_root.exists()
@pytest.mark.asyncio
async def test_run_cli_accepts_memory_scheme(
fake_agent, tmp_path: Path
) -> None:
"""run_cli should allow configuring in-memory services via memory:// URIs."""
parent_dir, folder_name = fake_agent
input_json = {"state": {}, "queries": []}
input_path = tmp_path / "noop.json"
input_path.write_text(json.dumps(input_json))
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=str(input_path),
saved_session_file=None,
save_session=False,
session_service_uri="memory://",
artifact_service_uri="memory://",
memory_service_uri="memory://",
)
@pytest.mark.asyncio
async def test_run_cli_invalid_memory_uri_surfaces_value_error(
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_cli should let ValueError propagate for invalid memory service URIs."""
parent_dir, folder_name = fake_agent
input_json = {"state": {}, "queries": []}
input_path = tmp_path / "invalid_memory_uri.json"
input_path.write_text(json.dumps(input_json))
def _raise_invalid_memory_uri(
*,
base_dir: Path | str,
memory_service_uri: str | None = None,
) -> object:
del base_dir, memory_service_uri
raise ValueError("Unsupported memory service URI: unknown://x")
monkeypatch.setattr(
cli, "create_memory_service_from_options", _raise_invalid_memory_uri
)
with pytest.raises(ValueError, match="Unsupported memory service URI"):
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=str(input_path),
saved_session_file=None,
save_session=False,
memory_service_uri="unknown://x",
)
@pytest.mark.asyncio
async def test_run_cli_passes_memory_service_to_input_file(
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_cli should construct and pass the configured memory service."""
parent_dir, folder_name = fake_agent
input_json = {"state": {}, "queries": []}
input_path = tmp_path / "memory_input.json"
input_path.write_text(json.dumps(input_json))
memory_service_sentinel = object()
captured_factory_args: dict[str, Any] = {}
captured_memory_service: dict[str, Any] = {}
def _memory_factory(
*,
base_dir: Path | str,
memory_service_uri: str | None = None,
) -> object:
captured_factory_args["base_dir"] = base_dir
captured_factory_args["memory_service_uri"] = memory_service_uri
return memory_service_sentinel
async def _run_input_file(
app_name: str,
user_id: str,
agent_or_app: BaseAgent | App,
artifact_service: Any,
session_service: Any,
credential_service: InMemoryCredentialService,
input_path: str,
memory_service: Any = None,
) -> object:
del app_name, user_id, agent_or_app, artifact_service
del session_service, credential_service, input_path
captured_memory_service["value"] = memory_service
return object()
monkeypatch.setattr(
cli, "create_memory_service_from_options", _memory_factory
)
monkeypatch.setattr(cli, "run_input_file", _run_input_file)
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=str(input_path),
saved_session_file=None,
save_session=False,
memory_service_uri="memory://",
)
assert Path(captured_factory_args["base_dir"]) == parent_dir.resolve()
assert captured_factory_args["memory_service_uri"] == "memory://"
assert captured_memory_service["value"] is memory_service_sentinel
@pytest.mark.asyncio
async def test_run_cli_loads_dotenv_before_memory_service_creation(
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_cli should load agent .env values before creating memory service."""
parent_dir, folder_name = fake_agent
input_json = {"state": {}, "queries": []}
input_path = tmp_path / "dotenv_order_input.json"
input_path.write_text(json.dumps(input_json))
call_order: list[str] = []
def _load_dotenv_for_agent(agent_name: str, agents_dir: str) -> None:
del agent_name, agents_dir
call_order.append("load_dotenv")
def _memory_factory(
*,
base_dir: Path | str,
memory_service_uri: str | None = None,
) -> object:
del base_dir, memory_service_uri
call_order.append("create_memory")
return object()
monkeypatch.setenv("ADK_DISABLE_LOAD_DOTENV", "0")
monkeypatch.setattr(cli.envs, "load_dotenv_for_agent", _load_dotenv_for_agent)
monkeypatch.setattr(
cli, "create_memory_service_from_options", _memory_factory
)
await cli.run_cli(
agent_parent_dir=str(parent_dir),
agent_folder_name=folder_name,
input_file=str(input_path),
saved_session_file=None,
save_session=False,
memory_service_uri="memory://",
)
assert "create_memory" in call_order
assert "load_dotenv" in call_order
assert call_order.index("load_dotenv") < call_order.index("create_memory")
@pytest.mark.asyncio
async def test_run_interactively_whitespace_and_exit(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_interactively should skip blank input, echo once, then exit."""
# make a session that belongs to dummy agent
session_service = InMemorySessionService()
sess = await session_service.create_session(app_name="dummy", user_id="u")
artifact_service = InMemoryArtifactService()
credential_service = InMemoryCredentialService()
root_agent = BaseAgent(name="root")
# fake user input: blank -> 'hello' -> 'exit'
answers = iter([" ", "hello", "exit"])
monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(answers))
# capture assisted echo
echoed: list[str] = []
monkeypatch.setattr(click, "echo", lambda msg: echoed.append(msg))
await cli.run_interactively(
root_agent, artifact_service, sess, session_service, credential_service
)
# verify: assistant echoed once with 'echo:hello'
assert any("echo:hello" in m for m in echoed)
@@ -0,0 +1,560 @@
# 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 utilities in cli_create."""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
from typing import Any
from typing import Dict
from typing import List
from typing import Tuple
import click
import google.adk.cli.cli_create as cli_create
from google.adk.cli.utils import _onboarding
from google.adk.cli.utils import gcp_utils
import pytest
# Helpers
class _Recorder:
"""A callable object that records every invocation."""
def __init__(self) -> None:
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
self.calls.append((args, kwargs))
# Fixtures
@pytest.fixture(autouse=True)
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
"""Silence click output in every test."""
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
@pytest.fixture()
def agent_folder(tmp_path: Path) -> Path:
"""Return a temporary path that will hold generated agent sources."""
return tmp_path / "agent"
# _generate_files
def test_generate_files_with_api_key(agent_folder: Path) -> None:
"""Files should be created with the API-key backend and correct .env flags."""
cli_create._generate_files(
str(agent_folder),
google_api_key="dummy-key",
model="gemini-2.5-flash",
type="code",
)
env_content = (agent_folder / ".env").read_text()
assert "GOOGLE_API_KEY=dummy-key" in env_content
assert "GOOGLE_GENAI_USE_ENTERPRISE=0" in env_content
assert (agent_folder / ".gitignore").read_text() == ".env\n"
assert (agent_folder / "agent.py").exists()
assert (agent_folder / "__init__.py").exists()
def test_generate_files_with_gcp(agent_folder: Path) -> None:
"""Files should be created with Vertex AI backend and correct .env flags."""
cli_create._generate_files(
str(agent_folder),
google_cloud_project="proj",
google_cloud_region="us-central1",
model="gemini-2.5-flash",
type="code",
)
env_content = (agent_folder / ".env").read_text()
assert "GOOGLE_CLOUD_PROJECT=proj" in env_content
assert "GOOGLE_CLOUD_LOCATION=us-central1" in env_content
assert "GOOGLE_GENAI_USE_ENTERPRISE=1" in env_content
def test_generate_files_with_express_mode(agent_folder: Path) -> None:
"""Files should be created with Vertex AI backend when both project and API key are present (Express Mode)."""
cli_create._generate_files(
str(agent_folder),
google_api_key="express-api-key",
google_cloud_project="express-project-id",
google_cloud_region="us-central1",
model="gemini-2.5-flash",
type="code",
)
env_content = (agent_folder / ".env").read_text()
assert "GOOGLE_GENAI_USE_ENTERPRISE=1" in env_content
assert "GOOGLE_API_KEY=express-api-key" in env_content
assert "GOOGLE_CLOUD_PROJECT=express-project-id" in env_content
def test_generate_files_overwrite(agent_folder: Path) -> None:
"""Existing files should be overwritten when generating again."""
agent_folder.mkdir(parents=True, exist_ok=True)
(agent_folder / ".env").write_text("OLD")
cli_create._generate_files(
str(agent_folder),
google_api_key="new-key",
model="gemini-2.5-flash",
type="code",
)
assert "GOOGLE_API_KEY=new-key" in (agent_folder / ".env").read_text()
def test_generate_files_permission_error(
monkeypatch: pytest.MonkeyPatch, agent_folder: Path
) -> None:
"""PermissionError raised by os.makedirs should propagate."""
monkeypatch.setattr(
os, "makedirs", lambda *a, **k: (_ for _ in ()).throw(PermissionError())
)
with pytest.raises(PermissionError):
cli_create._generate_files(
str(agent_folder), model="gemini-2.5-flash", type="code"
)
def test_generate_files_no_params(agent_folder: Path) -> None:
"""No backend parameters → minimal .env file is generated."""
cli_create._generate_files(
str(agent_folder), model="gemini-2.5-flash", type="code"
)
env_content = (agent_folder / ".env").read_text()
for key in (
"GOOGLE_API_KEY",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_GENAI_USE_ENTERPRISE",
):
assert key not in env_content
def test_generate_files_appends_dotenv_to_existing_gitignore(
agent_folder: Path,
) -> None:
"""Existing .gitignore entries should be preserved."""
agent_folder.mkdir(parents=True, exist_ok=True)
(agent_folder / ".gitignore").write_text("__pycache__")
cli_create._generate_files(
str(agent_folder), model="gemini-2.0-flash-001", type="code"
)
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
def test_generate_files_appends_dotenv_to_existing_gitignore_with_newline(
agent_folder: Path,
) -> None:
"""Existing .gitignore entries ending in a newline should not cause extra blank lines."""
agent_folder.mkdir(parents=True, exist_ok=True)
(agent_folder / ".gitignore").write_text("__pycache__\n")
cli_create._generate_files(
str(agent_folder), model="gemini-2.0-flash-001", type="code"
)
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
def test_generate_files_does_not_duplicate_dotenv_gitignore_entry(
agent_folder: Path,
) -> None:
"""Existing .env ignore entries should not be duplicated."""
agent_folder.mkdir(parents=True, exist_ok=True)
(agent_folder / ".gitignore").write_text("__pycache__\n.env\n")
cli_create._generate_files(
str(agent_folder), model="gemini-2.0-flash-001", type="code"
)
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
# run_cmd
def test_run_cmd_overwrite_reject(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""User rejecting overwrite should trigger click.Abort."""
agent_name = "agent"
agent_dir = tmp_path / agent_name
agent_dir.mkdir()
(agent_dir / "dummy.txt").write_text("dummy")
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
monkeypatch.setattr(click, "confirm", lambda *a, **k: False)
with pytest.raises(click.Abort):
cli_create.run_cmd(
agent_name,
model="gemini-2.5-flash",
google_api_key=None,
google_cloud_project=None,
google_cloud_region=None,
type="code",
)
def test_run_cmd_invalid_app_name(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Invalid app names should be rejected before creating any files."""
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
with pytest.raises(click.BadParameter, match="Invalid app name"):
cli_create.run_cmd(
"invalid name",
model="gemini-2.5-flash",
google_api_key=None,
google_cloud_project=None,
google_cloud_region=None,
type="code",
)
def test_run_cmd_with_type_config(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""run_cmd with --type=config should generate YAML config file."""
agent_name = "test_agent"
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
cli_create.run_cmd(
agent_name,
model="gemini-2.5-flash",
google_api_key="test-key",
google_cloud_project=None,
google_cloud_region=None,
type="config",
)
agent_dir = tmp_path / agent_name
assert agent_dir.exists()
# Should create root_agent.yaml instead of agent.py
yaml_file = agent_dir / "root_agent.yaml"
assert yaml_file.exists()
assert not (agent_dir / "agent.py").exists()
# Check YAML content
yaml_content = yaml_file.read_text()
assert "name: root_agent" in yaml_content
assert "model: gemini-2.5-flash" in yaml_content
assert "description: A helpful assistant for user questions." in yaml_content
# Should create empty __init__.py
init_file = agent_dir / "__init__.py"
assert init_file.exists()
assert init_file.read_text().strip() == ""
# Should still create .env file
env_file = agent_dir / ".env"
assert env_file.exists()
assert "GOOGLE_API_KEY=test-key" in env_file.read_text()
assert (agent_dir / ".gitignore").read_text() == ".env\n"
# Prompt helpers
def test_prompt_for_google_cloud(monkeypatch: pytest.MonkeyPatch) -> None:
"""Prompt should return the project input."""
monkeypatch.setattr(click, "prompt", lambda *a, **k: "test-proj")
assert _onboarding.prompt_for_google_cloud(None) == "test-proj"
def test_prompt_for_google_cloud_region(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Prompt should return the region input."""
monkeypatch.setattr(click, "prompt", lambda *a, **k: "asia-northeast1")
assert _onboarding.prompt_for_google_cloud_region(None) == "asia-northeast1"
def test_prompt_for_google_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Prompt should return the API-key input."""
monkeypatch.setattr(click, "prompt", lambda *a, **k: "api-key")
assert _onboarding.prompt_for_google_api_key(None) == "api-key"
def test_prompt_for_model_gemini(monkeypatch: pytest.MonkeyPatch) -> None:
"""Selecting option '1' should return the default Gemini model string."""
monkeypatch.setattr(click, "prompt", lambda *a, **k: "1")
assert cli_create._prompt_for_model() == "gemini-3.5-flash"
def test_prompt_for_model_other(monkeypatch: pytest.MonkeyPatch) -> None:
"""Selecting option '2' should return placeholder and call secho."""
called: Dict[str, bool] = {}
monkeypatch.setattr(click, "prompt", lambda *a, **k: "2")
def _fake_secho(*_a: Any, **_k: Any) -> None:
called["secho"] = True
monkeypatch.setattr(click, "secho", _fake_secho)
assert cli_create._prompt_for_model() == "<FILL_IN_MODEL>"
assert called.get("secho") is True
# Backend selection helper
def test_prompt_to_choose_backend_api(monkeypatch: pytest.MonkeyPatch) -> None:
"""Choosing API-key backend returns (api_key, None, None)."""
monkeypatch.setattr(click, "prompt", lambda *a, **k: "1")
monkeypatch.setattr(
_onboarding, "prompt_for_google_api_key", lambda _v: "api-key"
)
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
assert isinstance(auth_info, _onboarding.GoogleAIAuth)
assert auth_info.api_key == "api-key"
def test_prompt_to_choose_backend_vertex(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Choosing Vertex backend returns (None, project, region)."""
monkeypatch.setattr(click, "prompt", lambda *a, **k: "2")
monkeypatch.setattr(_onboarding, "prompt_for_google_cloud", lambda _v: "proj")
monkeypatch.setattr(
_onboarding, "prompt_for_google_cloud_region", lambda _v: "region"
)
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
assert isinstance(auth_info, _onboarding.VertexAIAuth)
assert auth_info.project_id == "proj"
assert auth_info.region == "region"
def test_prompt_to_choose_backend_login(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Choosing Login with Google returns (api_key, project, region) from handler."""
monkeypatch.setattr(click, "prompt", lambda *a, **k: "3")
monkeypatch.setattr(
_onboarding,
"handle_login_with_google",
lambda: _onboarding.ExpressModeAuth(
api_key="api-key", project_id="proj", region="region"
),
)
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
assert auth_info.api_key == "api-key"
assert auth_info.project_id == "proj"
assert auth_info.region == "region"
def test_handle_login_with_google_existing_express(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Handler should return existing Express project if found."""
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
monkeypatch.setattr(
gcp_utils,
"retrieve_express_project",
lambda: {"api_key": "key", "project_id": "proj", "region": "us-central1"},
)
auth_info = _onboarding.handle_login_with_google()
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
assert auth_info.api_key == "key"
assert auth_info.project_id == "proj"
assert auth_info.region == "us-central1"
def test_handle_login_with_google_select_gcp_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Handler should prompt for project selection if no Express project found."""
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
monkeypatch.setattr(
gcp_utils, "list_gcp_projects", lambda limit: [("p1", "Project 1")]
)
monkeypatch.setattr(click, "prompt", lambda *a, **k: 1)
monkeypatch.setattr(
_onboarding, "prompt_for_google_cloud_region", lambda _v: "us-east1"
)
auth_info = _onboarding.handle_login_with_google()
assert isinstance(auth_info, _onboarding.VertexAIAuth)
assert auth_info.project_id == "p1"
assert auth_info.region == "us-east1"
def test_handle_login_with_google_manual_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Handler should allow manual project ID entry when '0' is selected."""
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
monkeypatch.setattr(
gcp_utils, "list_gcp_projects", lambda limit: [("p1", "Project 1")]
)
prompts = iter([0, "manual-proj", "us-east1"])
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
auth_info = _onboarding.handle_login_with_google()
assert isinstance(auth_info, _onboarding.VertexAIAuth)
assert auth_info.project_id == "manual-proj"
assert auth_info.region == "us-east1"
def test_handle_login_with_google_option_1(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""User selects 1, enters project ID and region."""
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
prompts = iter(["1", "test-proj", "us-east1"])
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
auth_info = _onboarding.handle_login_with_google()
assert isinstance(auth_info, _onboarding.VertexAIAuth)
assert auth_info.project_id == "test-proj"
assert auth_info.region == "us-east1"
def test_handle_login_with_google_option_2(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""User selects 2, goes through express sign up."""
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
monkeypatch.setattr(gcp_utils, "check_express_eligibility", lambda: True)
monkeypatch.setattr(click, "confirm", lambda *a, **k: True)
prompts = iter(["2", "1"])
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
monkeypatch.setattr(
gcp_utils,
"sign_up_express",
lambda location="us-central1": {
"api_key": "new-key",
"project_id": "new-proj",
"region": location,
},
)
auth_info = _onboarding.handle_login_with_google()
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
assert auth_info.api_key == "new-key"
assert auth_info.project_id == "new-proj"
assert auth_info.region == "us-central1"
def test_handle_login_with_google_option_2_unset_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""User selects 2, goes through express sign up, and unsets existing gcloud project."""
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
monkeypatch.setattr(gcp_utils, "check_express_eligibility", lambda: True)
confirms = iter([True, True])
monkeypatch.setattr(click, "confirm", lambda *a, **k: next(confirms))
prompts = iter(["2", "1"])
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
monkeypatch.setattr(
gcp_utils,
"sign_up_express",
lambda location="us-central1": {
"api_key": "new-key",
"project_id": "new-proj",
"region": location,
},
)
monkeypatch.setattr(
_onboarding, "get_gcp_project_from_gcloud", lambda: "old-proj"
)
called = {}
def fake_run(cmd, **kwargs):
if cmd == ["gcloud", "config", "unset", "project"]:
called["unset"] = True
return subprocess.CompletedProcess(args=cmd, returncode=0)
raise ValueError(f"Unexpected command: {cmd}")
monkeypatch.setattr(subprocess, "run", fake_run)
auth_info = _onboarding.handle_login_with_google()
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
assert auth_info.api_key == "new-key"
assert auth_info.project_id == "new-proj"
assert auth_info.region == "us-central1"
assert called.get("unset") is True
def test_handle_login_with_google_option_3(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""User selects 3, aborts."""
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
monkeypatch.setattr(click, "prompt", lambda *a, **k: "3")
with pytest.raises(click.Abort):
_onboarding.handle_login_with_google()
# prompt_str
def test_prompt_str_non_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""prompt_str should retry until a non-blank string is provided."""
responses = iter(["", " ", "valid"])
monkeypatch.setattr(click, "prompt", lambda *_a, **_k: next(responses))
assert _onboarding.prompt_str("dummy") == "valid"
# gcloud fallback helpers
def test_get_gcp_project_from_gcloud_fail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Failure of gcloud project lookup should return empty string."""
monkeypatch.setattr(
subprocess,
"run",
lambda *_a, **_k: (_ for _ in ()).throw(FileNotFoundError()),
)
assert _onboarding.get_gcp_project_from_gcloud() == ""
def test_get_gcp_region_from_gcloud_fail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CalledProcessError should result in empty region string."""
monkeypatch.setattr(
subprocess,
"run",
lambda *_a, **_k: (_ for _ in ()).throw(
subprocess.CalledProcessError(1, "gcloud")
),
)
assert _onboarding.get_gcp_region_from_gcloud() == ""
@@ -0,0 +1,714 @@
# 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 utilities in cli_deploy."""
from __future__ import annotations
import importlib
from pathlib import Path
import shutil
import subprocess
import sys
import types
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Tuple
from unittest import mock
import click
from click.testing import CliRunner
import pytest
import src.google.adk.cli.cli_deploy as cli_deploy
import src.google.adk.cli.cli_tools_click as cli_tools_click
# Helpers
class _Recorder:
"""A callable object that records every invocation."""
def __init__(self) -> None:
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
def __call__(self, *args: Any, **kwargs: Any) -> None:
self.calls.append((args, kwargs))
def get_last_call_args(self) -> Tuple[Any, ...]:
"""Returns the positional arguments of the last call."""
if not self.calls:
raise IndexError("No calls have been recorded.")
return self.calls[-1][0]
def get_last_call_kwargs(self) -> Dict[str, Any]:
"""Returns the keyword arguments of the last call."""
if not self.calls:
raise IndexError("No calls have been recorded.")
return self.calls[-1][1]
# Fixtures
@pytest.fixture(autouse=True)
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
"""Suppress click.echo to keep test output clean."""
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
@pytest.fixture(autouse=True)
def reload_cli_deploy():
"""Reload cli_deploy before each test."""
importlib.reload(cli_deploy)
yield # This allows the test to run after the module has been reloaded.
@pytest.fixture()
def agent_dir(tmp_path: Path) -> Callable[[bool, bool], Path]:
"""
Return a factory that creates a dummy agent directory tree.
"""
def _factory(include_requirements: bool, include_env: bool) -> Path:
base = tmp_path / "agent"
base.mkdir()
(base / "agent.py").write_text(
"# dummy agent\nroot_agent = 'dummy_agent'\n"
)
(base / "__init__.py").touch()
if include_requirements:
(base / "requirements.txt").write_text("pytest\n")
if include_env:
(base / ".env").write_text('TEST_VAR="test_value"\n')
return base
return _factory
# _resolve_project
def test_resolve_project_with_option() -> None:
"""It should return the explicit project value untouched."""
assert cli_deploy._resolve_project("my-project") == "my-project"
def test_resolve_project_from_gcloud(monkeypatch: pytest.MonkeyPatch) -> None:
"""It should fall back to `gcloud config get-value project` when no value supplied."""
monkeypatch.setattr(
subprocess,
"run",
lambda *a, **k: types.SimpleNamespace(stdout="gcp-proj\n"),
)
with mock.patch("click.echo") as mocked_echo:
assert cli_deploy._resolve_project(None) == "gcp-proj"
mocked_echo.assert_called_once()
def test_resolve_project_from_gcloud_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""It should raise an exception if the gcloud command fails."""
monkeypatch.setattr(
subprocess,
"run",
mock.Mock(side_effect=subprocess.CalledProcessError(1, "cmd", "err")),
)
with pytest.raises(subprocess.CalledProcessError):
cli_deploy._resolve_project(None)
@pytest.mark.parametrize(
"adk_version, session_uri, artifact_uri, memory_uri, use_local_storage, "
"expected",
[
(
"1.3.0",
"sqlite://s",
"gs://a",
"rag://m",
None,
(
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
" --memory_service_uri=rag://m"
),
),
(
"1.2.5",
"sqlite://s",
"gs://a",
"rag://m",
None,
(
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
" --memory_service_uri=rag://m"
),
),
(
"0.5.0",
"sqlite://s",
"gs://a",
"rag://m",
None,
(
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
" --memory_service_uri=rag://m"
),
),
(
"1.3.0",
"sqlite://s",
None,
None,
None,
"--session_service_uri=sqlite://s",
),
(
"1.3.0",
None,
"gs://a",
"rag://m",
None,
"--artifact_service_uri=gs://a --memory_service_uri=rag://m",
),
(
"1.2.0",
None,
"gs://a",
None,
None,
"--artifact_service_uri=gs://a",
),
(
"1.21.0",
None,
None,
None,
False,
"--no_use_local_storage",
),
(
"1.21.0",
None,
None,
None,
True,
"--use_local_storage",
),
(
"1.21.0",
"sqlite://s",
"gs://a",
None,
False,
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a",
),
],
)
def test_get_service_option_by_adk_version(
adk_version: str,
session_uri: str | None,
artifact_uri: str | None,
memory_uri: str | None,
use_local_storage: bool | None,
expected: str,
) -> None:
"""It should return the correct service URI flags for a given ADK version."""
actual = cli_deploy._get_service_option_by_adk_version(
adk_version=adk_version,
session_uri=session_uri,
artifact_uri=artifact_uri,
memory_uri=memory_uri,
use_local_storage=use_local_storage,
)
assert actual.rstrip() == expected.rstrip()
def test_print_agent_engine_url() -> None:
"""It should print the correct URL for a fully-qualified resource name."""
with mock.patch("click.secho") as mocked_secho:
cli_deploy._print_agent_engine_url(
"projects/my-project/locations/us-central1/reasoningEngines/123456"
)
mocked_secho.assert_called_once()
call_args = mocked_secho.call_args[0][0]
assert "my-project" in call_args
assert "us-central1" in call_args
assert "123456" in call_args
assert "playground" in call_args
@pytest.mark.parametrize("include_requirements", [True, False])
def test_to_agent_engine_happy_path(
monkeypatch: pytest.MonkeyPatch,
agent_dir: Callable[[bool, bool], Path],
include_requirements: bool,
) -> None:
"""Tests the happy path for the `to_agent_engine` function."""
rmtree_recorder = _Recorder()
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
create_recorder = _Recorder()
fake_vertexai = types.ModuleType("vertexai")
class _FakeAgentEngines:
def create(self, **kwargs: Any) -> Any:
create_recorder(**kwargs)
return types.SimpleNamespace(
api_resource=types.SimpleNamespace(
name="projects/p/locations/l/reasoningEngines/e"
)
)
def update(self, *, name: str, config: Dict[str, Any]) -> None:
del name
del config
class _FakeVertexClient:
def __init__(self, *args: Any, **kwargs: Any) -> None:
del args
del kwargs
self.agent_engines = _FakeAgentEngines()
fake_vertexai.Client = _FakeVertexClient
monkeypatch.setitem(sys.modules, "vertexai", fake_vertexai)
src_dir = agent_dir(include_requirements, False)
tmp_dir = src_dir.parent / "tmp"
cli_deploy.to_agent_engine(
agent_folder=str(src_dir),
temp_folder="tmp",
trace_to_cloud=True,
project="my-gcp-project",
region="us-central1",
display_name="My Test Agent",
description="A test agent.",
adk_version="1.2.0",
)
agent_file = tmp_dir / "Dockerfile"
assert agent_file.is_file()
assert len(create_recorder.calls) == 1
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir)
requirements_file = tmp_dir / "agents" / "agent" / "requirements.txt"
assert requirements_file.is_file()
assert (
"google-cloud-aiplatform[adk,agent_engines]"
in requirements_file.read_text()
)
def test_to_agent_engine_raises_when_explicit_config_file_missing(
monkeypatch: pytest.MonkeyPatch,
agent_dir: Callable[[bool, bool], Path],
tmp_path: Path,
) -> None:
"""It should fail with a clear error when --agent_engine_config_file is missing."""
monkeypatch.setattr(shutil, "rmtree", lambda *a, **k: None)
src_dir = agent_dir(False, False)
missing_config = tmp_path / "no_such_agent_engine_config.json"
expected_abs = str(missing_config.resolve())
with pytest.raises(click.ClickException) as exc_info:
cli_deploy.to_agent_engine(
agent_folder=str(src_dir),
temp_folder="tmp",
trace_to_cloud=True,
project="my-gcp-project",
region="us-central1",
display_name="My Test Agent",
description="A test agent.",
agent_engine_config_file=str(missing_config),
adk_version="1.2.0",
)
assert "Agent Platform config file not found" in str(exc_info.value)
assert expected_abs in str(exc_info.value)
@pytest.mark.parametrize("include_requirements", [True, False])
def test_to_gke_happy_path(
monkeypatch: pytest.MonkeyPatch,
agent_dir: Callable[[bool, bool], Path],
tmp_path: Path,
include_requirements: bool,
) -> None:
"""
Tests the happy path for the `to_gke` function.
"""
src_dir = agent_dir(include_requirements, False)
run_recorder = _Recorder()
rmtree_recorder = _Recorder()
def mock_subprocess_run(*args, **kwargs):
run_recorder(*args, **kwargs)
command_list = args[0]
if command_list and command_list[0:2] == ["kubectl", "apply"]:
fake_stdout = "deployment.apps/gke-svc created\nservice/gke-svc created"
return types.SimpleNamespace(stdout=fake_stdout)
return None
monkeypatch.setattr(subprocess, "run", mock_subprocess_run)
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
cli_deploy.to_gke(
agent_folder=str(src_dir),
project="gke-proj",
region="us-east1",
cluster_name="my-gke-cluster",
service_name="gke-svc",
app_name="agent",
temp_folder=str(tmp_path),
port=9090,
trace_to_cloud=False,
otel_to_cloud=False,
with_ui=True,
log_level="debug",
adk_version="1.2.0",
allow_origins=["http://localhost:3000", "https://my-app.com"],
session_service_uri="sqlite:///",
artifact_service_uri="gs://gke-bucket",
)
dockerfile_path = tmp_path / "Dockerfile"
assert dockerfile_path.is_file()
dockerfile_content = dockerfile_path.read_text()
assert "CMD adk api_server --with_ui --port=9090" in dockerfile_content
assert 'RUN pip install "google-adk[a2a]==1.2.0"' in dockerfile_content
assert len(run_recorder.calls) == 3, "Expected 3 subprocess calls"
build_args = run_recorder.calls[0][0][0]
expected_build_args = [
"gcloud",
"builds",
"submit",
"--tag",
"gcr.io/gke-proj/gke-svc",
"--verbosity",
"debug",
str(tmp_path),
]
assert build_args == expected_build_args
creds_args = run_recorder.calls[1][0][0]
expected_creds_args = [
"gcloud",
"container",
"clusters",
"get-credentials",
"my-gke-cluster",
"--region",
"us-east1",
"--project",
"gke-proj",
]
assert creds_args == expected_creds_args
assert (
"--allow_origins=http://localhost:3000,https://my-app.com"
in dockerfile_content
)
apply_args = run_recorder.calls[2][0][0]
expected_apply_args = ["kubectl", "apply", "-f", str(tmp_path)]
assert apply_args == expected_apply_args
deployment_yaml_path = tmp_path / "deployment.yaml"
assert deployment_yaml_path.is_file()
yaml_content = deployment_yaml_path.read_text()
assert "kind: Deployment" in yaml_content
assert "kind: Service" in yaml_content
assert "name: gke-svc" in yaml_content
assert "image: gcr.io/gke-proj/gke-svc" in yaml_content
assert f"containerPort: 9090" in yaml_content
assert f"targetPort: 9090" in yaml_content
assert "type: ClusterIP" in yaml_content
# 4. Verify cleanup
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path)
# _validate_agent_import tests
class TestValidateAgentImport:
"""Tests for the _validate_agent_import function."""
def test_skips_config_agents(self, tmp_path: Path) -> None:
"""Config agents should skip validation."""
# This should not raise even with no agent.py file
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=True
)
def test_raises_on_missing_agent_module(self, tmp_path: Path) -> None:
"""Should raise when agent.py is missing."""
with pytest.raises(click.ClickException) as exc_info:
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
assert "Agent module not found" in str(exc_info.value)
def test_raises_on_missing_export(self, tmp_path: Path) -> None:
"""Should raise when the expected export is missing."""
agent_file = tmp_path / "agent.py"
agent_file.write_text("some_other_var = 'hello'\n")
(tmp_path / "__init__.py").touch()
with pytest.raises(click.ClickException) as exc_info:
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
assert "does not export 'root_agent'" in str(exc_info.value)
assert "some_other_var" in str(exc_info.value)
def test_success_with_root_agent_export(self, tmp_path: Path) -> None:
"""Should succeed when root_agent is exported."""
agent_file = tmp_path / "agent.py"
agent_file.write_text("root_agent = 'my_agent'\n")
(tmp_path / "__init__.py").touch()
# Should not raise
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
def test_success_with_app_export(self, tmp_path: Path) -> None:
"""Should succeed when app is exported."""
agent_file = tmp_path / "agent.py"
agent_file.write_text("app = 'my_app'\n")
(tmp_path / "__init__.py").touch()
# Should not raise
cli_deploy._validate_agent_import(
str(tmp_path), "app", is_config_agent=False
)
def test_success_with_relative_imports(self, tmp_path: Path) -> None:
"""Should succeed when agent.py uses relative imports."""
(tmp_path / "helper.py").write_text("VALUE = 'my_agent'\n")
(tmp_path / "__init__.py").touch()
(tmp_path / "agent.py").write_text(
"from .helper import VALUE\n\nroot_agent = VALUE\n"
)
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
def test_raises_on_import_error(self, tmp_path: Path) -> None:
"""Should raise with helpful message on ImportError."""
agent_file = tmp_path / "agent.py"
agent_file.write_text("from nonexistent_module import something\n")
(tmp_path / "__init__.py").touch()
with pytest.raises(click.ClickException) as exc_info:
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
assert "Failed to import agent module" in str(exc_info.value)
assert "nonexistent_module" in str(exc_info.value)
def test_raises_on_basellm_import_error(self, tmp_path: Path) -> None:
"""Should provide specific guidance for BaseLlm import errors."""
agent_file = tmp_path / "agent.py"
agent_file.write_text(
"from google.adk.models.base_llm import NonexistentBaseLlm\n"
)
(tmp_path / "__init__.py").touch()
with pytest.raises(click.ClickException) as exc_info:
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
assert "BaseLlm-related error" in str(exc_info.value)
assert "custom LLM" in str(exc_info.value)
def test_raises_on_syntax_error(self, tmp_path: Path) -> None:
"""Should raise on syntax errors in agent.py."""
agent_file = tmp_path / "agent.py"
agent_file.write_text("def invalid syntax here:\n")
(tmp_path / "__init__.py").touch()
with pytest.raises(click.ClickException) as exc_info:
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
assert "Error while loading agent module" in str(exc_info.value)
def test_cleans_up_sys_modules(self, tmp_path: Path) -> None:
"""Should clean up sys.modules after validation."""
agent_file = tmp_path / "agent.py"
agent_file.write_text("root_agent = 'my_agent'\n")
(tmp_path / "__init__.py").touch()
module_name = tmp_path.name
agent_module_key = f"{module_name}.agent"
# Ensure module is not in sys.modules before
assert module_name not in sys.modules
assert agent_module_key not in sys.modules
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
# Ensure module is cleaned up after
assert module_name not in sys.modules
assert agent_module_key not in sys.modules
def test_restores_sys_path(self, tmp_path: Path) -> None:
"""Should restore sys.path after validation."""
agent_file = tmp_path / "agent.py"
agent_file.write_text("root_agent = 'my_agent'\n")
(tmp_path / "__init__.py").touch()
original_path = sys.path.copy()
cli_deploy._validate_agent_import(
str(tmp_path), "root_agent", is_config_agent=False
)
assert sys.path == original_path
def test_to_agent_engine_triggers_onboarding(
monkeypatch: pytest.MonkeyPatch,
agent_dir: Callable[[bool, bool], Path],
) -> None:
"""It should trigger onboarding when credentials are missing."""
mock_handle_login = mock.Mock(
return_value=cli_deploy._onboarding.ExpressModeAuth(
api_key="fake_api_key",
project_id="fake_project",
region="fake_region",
)
)
monkeypatch.setattr(
cli_deploy._onboarding, "handle_login_with_google", mock_handle_login
)
# Mock subprocess.run so `gcloud config get-value project` returns no
# default project; otherwise `_resolve_project` would populate `project`
# and suppress the onboarding flow this test is exercising.
monkeypatch.setattr(
subprocess,
"run",
lambda *a, **k: types.SimpleNamespace(stdout="\n"),
)
fake_vertexai = types.ModuleType("vertexai")
mock_client = mock.Mock()
fake_vertexai.Client = mock.Mock(return_value=mock_client)
mock_agent_engines = mock.Mock()
mock_client.agent_engines = mock_agent_engines
mock_agent_engines.create.return_value = types.SimpleNamespace(
api_resource=types.SimpleNamespace(
name="projects/p/locations/l/reasoningEngines/e"
)
)
mock_agent_engines.delete.return_value = None
mock_agent_engines.update.return_value = None
monkeypatch.setitem(sys.modules, "vertexai", fake_vertexai)
src_dir = agent_dir(False, False)
cli_deploy.to_agent_engine(
agent_folder=str(src_dir),
trace_to_cloud=True,
)
mock_handle_login.assert_called_once()
# Verify vertexai.Client was initialized with correct args
fake_vertexai.Client.assert_called_once()
kwargs = fake_vertexai.Client.call_args.kwargs
assert kwargs.get("project") == "fake_project"
assert kwargs.get("location") == "fake_region"
assert "api_key" not in kwargs or kwargs.get("api_key") is None
def test_cli_deploy_agent_engine_trigger_sources(tmp_path: Path):
"""Tests that --trigger_sources is passed to to_agent_engine."""
agent_dir = tmp_path / "my_agent"
agent_dir.mkdir()
runner = CliRunner()
with mock.patch(
"src.google.adk.cli.cli_deploy.to_agent_engine"
) as mock_to_agent_engine:
result = runner.invoke(
cli_tools_click.main,
[
"deploy",
"agent_engine",
"--trigger_sources=pubsub,eventarc",
str(agent_dir),
],
catch_exceptions=False,
)
assert result.exit_code == 0
mock_to_agent_engine.assert_called_once()
_, kwargs = mock_to_agent_engine.call_args
assert kwargs["trigger_sources"] == "pubsub,eventarc"
def test_cli_deploy_agent_engine_artifact_service_uri(tmp_path: Path):
"""Tests that --artifact_service_uri is passed to to_agent_engine."""
agent_dir = tmp_path / "my_agent"
agent_dir.mkdir()
runner = CliRunner()
with mock.patch(
"src.google.adk.cli.cli_deploy.to_agent_engine"
) as mock_to_agent_engine:
result = runner.invoke(
cli_tools_click.main,
[
"deploy",
"agent_engine",
"--artifact_service_uri=gs://my-bucket",
str(agent_dir),
],
catch_exceptions=False,
)
assert result.exit_code == 0
mock_to_agent_engine.assert_called_once()
_, kwargs = mock_to_agent_engine.call_args
assert kwargs["artifact_service_uri"] == "gs://my-bucket"
def test_ensure_agent_engine_dependency(tmp_path: Path):
"""Tests that _ensure_agent_engine_dependency appends correct extras."""
requirements_file = tmp_path / "requirements.txt"
# Case 1: raises FileNotFoundError when the file doesn't exist
with pytest.raises(FileNotFoundError):
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
# Case 2: appends google-cloud-aiplatform with 'adk' and 'agent_engines'
# extras and the versioned google-adk requirement.
requirements_file.write_text("")
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
content = requirements_file.read_text()
assert "google-cloud-aiplatform[adk,agent_engines]\n" in content
assert f"google-adk[a2a]=={cli_deploy.__version__}\n" in content
# Case 3: does not append duplicate if google-cloud-aiplatform already exists
requirements_file.write_text("google-cloud-aiplatform[adk,agent_engines]\n")
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
content = requirements_file.read_text()
assert content == "google-cloud-aiplatform[adk,agent_engines]\n"
@@ -0,0 +1,219 @@
# 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 ignore file support in cli_deploy."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
from unittest import mock
import click
import pytest
import src.google.adk.cli.cli_deploy as cli_deploy
@pytest.fixture(autouse=True)
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
"""Suppress click.echo to keep test output clean."""
monkeypatch.setattr(click, "echo", lambda *_a, **_k: None)
monkeypatch.setattr(click, "secho", lambda *_a, **_k: None)
def test_to_cloud_run_respects_ignore_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Test that to_cloud_run respects .gitignore and .gcloudignore."""
agent_dir = tmp_path / "agent"
agent_dir.mkdir()
(agent_dir / "agent.py").write_text("# agent")
(agent_dir / "__init__.py").write_text("")
(agent_dir / "ignored_by_git.txt").write_text("ignored")
(agent_dir / "ignored_by_gcloud.txt").write_text("ignored")
(agent_dir / "ignored_rooted.txt").write_text("ignored")
(agent_dir / "not_ignored.txt").write_text("keep")
# Use a root-anchored pattern (leading slash) to ensure it is honored.
(agent_dir / ".gitignore").write_text(
"ignored_by_git.txt\n/ignored_rooted.txt\n"
)
(agent_dir / ".gcloudignore").write_text("ignored_by_gcloud.txt\n")
temp_deploy_dir = tmp_path / "temp_deploy"
# Mock subprocess.run to avoid actual gcloud call
monkeypatch.setattr(subprocess, "run", mock.Mock())
# Mock shutil.rmtree to keep the temp folder for verification
monkeypatch.setattr(
shutil,
"rmtree",
lambda path, **kwargs: None
if "temp_deploy" in str(path)
else shutil.rmtree(path, **kwargs),
)
cli_deploy.to_cloud_run(
agent_folder=str(agent_dir),
project="proj",
region="us-central1",
service_name="svc",
app_name="app",
temp_folder=str(temp_deploy_dir),
port=8080,
trace_to_cloud=False,
otel_to_cloud=False,
with_ui=False,
log_level="info",
verbosity="info",
adk_version="1.0.0",
)
agent_src_path = temp_deploy_dir / "agents" / "app"
assert (agent_src_path / "agent.py").exists()
assert (agent_src_path / "not_ignored.txt").exists()
# These should be ignored
assert not (
agent_src_path / "ignored_by_git.txt"
).exists(), "Should respect .gitignore"
assert not (
agent_src_path / "ignored_by_gcloud.txt"
).exists(), "Should respect .gcloudignore"
assert not (
agent_src_path / "ignored_rooted.txt"
).exists(), "Should respect root-anchored (leading slash) patterns"
def test_to_agent_engine_respects_multiple_ignore_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Test that to_agent_engine respects .gitignore, .gcloudignore and .ae_ignore."""
# We need to be in the project dir for to_agent_engine
project_dir = tmp_path / "project"
project_dir.mkdir()
monkeypatch.chdir(project_dir)
agent_dir = project_dir / "my_agent"
agent_dir.mkdir()
(agent_dir / "agent.py").write_text("root_agent = None")
(agent_dir / "__init__.py").write_text("from . import agent")
(agent_dir / "ignored_by_git.txt").write_text("ignored")
(agent_dir / "ignored_by_ae.txt").write_text("ignored")
(agent_dir / ".gitignore").write_text("ignored_by_git.txt\n")
(agent_dir / ".ae_ignore").write_text("ignored_by_ae.txt\n")
# Mock vertexai.Client and other things to avoid network/complex setup. The
# created agent engine must expose a realistic resource name so the downstream
# console-URL formatting does not choke on a bare Mock.
mock_client = mock.Mock()
mock_client.agent_engines.create.return_value.api_resource.name = (
"projects/proj/locations/us-central1/reasoningEngines/123"
)
monkeypatch.setattr("vertexai.Client", mock.Mock(return_value=mock_client))
# Mock shutil.rmtree to keep the temp folder for verification
original_rmtree = shutil.rmtree
def mock_rmtree(path, **kwargs):
if "_tmp" in str(path):
return None
return original_rmtree(path, **kwargs)
monkeypatch.setattr(shutil, "rmtree", mock_rmtree)
cli_deploy.to_agent_engine(
agent_folder=str(agent_dir),
staging_bucket="gs://test",
adk_app="adk_app",
# Pass project/region explicitly so the function does not fall back to
# the interactive `gcloud auth application-default login` onboarding flow,
# which fails on CI runners without Application Default Credentials.
project="proj",
region="us-central1",
adk_version="1.0.0",
)
# Find the temp folder created by to_agent_engine
temp_folders = [
d for d in project_dir.iterdir() if d.is_dir() and "_tmp" in d.name
]
assert len(temp_folders) == 1
agent_src_path = temp_folders[0]
copied_agent_dir = agent_src_path / "agents" / "my_agent"
assert (copied_agent_dir / "agent.py").exists()
assert not (
copied_agent_dir / "ignored_by_git.txt"
).exists(), "Should respect .gitignore"
assert not (
copied_agent_dir / "ignored_by_ae.txt"
).exists(), "Should respect .ae_ignore"
def test_to_gke_respects_ignore_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Test that to_gke respects ignore files."""
agent_dir = tmp_path / "agent"
agent_dir.mkdir()
(agent_dir / "agent.py").write_text("# agent")
(agent_dir / "__init__.py").write_text("")
(agent_dir / "ignored.txt").write_text("ignored")
(agent_dir / ".gitignore").write_text("ignored.txt\n")
temp_deploy_dir = tmp_path / "temp_deploy"
# Mock subprocess.run to avoid actual gcloud call
mock_run = mock.Mock()
mock_run.return_value.stdout = "deployment created"
monkeypatch.setattr(subprocess, "run", mock_run)
# Mock shutil.rmtree to keep the temp folder for verification
monkeypatch.setattr(
shutil,
"rmtree",
lambda path, **kwargs: None
if "temp_deploy" in str(path)
else shutil.rmtree(path, **kwargs),
)
cli_deploy.to_gke(
agent_folder=str(agent_dir),
project="proj",
region="us-central1",
cluster_name="cluster",
service_name="svc",
app_name="app",
temp_folder=str(temp_deploy_dir),
port=8080,
trace_to_cloud=False,
otel_to_cloud=False,
with_ui=False,
log_level="info",
adk_version="1.0.0",
)
agent_src_path = temp_deploy_dir / "agents" / "app"
assert (agent_src_path / "agent.py").exists()
assert not (
agent_src_path / "ignored.txt"
).exists(), "Should respect .gitignore"
@@ -0,0 +1,349 @@
# 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 to_cloud_run functionality in cli_deploy."""
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
from typing import Any
from typing import Dict
from typing import List
from typing import Protocol
from typing import Tuple
from unittest import mock
import click
import pytest
import src.google.adk.cli.cli_deploy as cli_deploy
class AgentDirFixture(Protocol):
"""Protocol for the agent_dir pytest fixture factory."""
def __call__(self, *, include_requirements: bool, include_env: bool) -> Path:
...
# Helpers
class _Recorder:
"""A callable object that records every invocation."""
def __init__(self) -> None:
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
def __call__(self, *args: Any, **kwargs: Any) -> None:
self.calls.append((args, kwargs))
def get_last_call_args(self) -> Tuple[Any, ...]:
"""Returns the positional arguments of the last call."""
if not self.calls:
raise IndexError("No calls have been recorded.")
return self.calls[-1][0]
def get_last_call_kwargs(self) -> Dict[str, Any]:
"""Returns the keyword arguments of the last call."""
if not self.calls:
raise IndexError("No calls have been recorded.")
return self.calls[-1][1]
# Fixtures
@pytest.fixture(autouse=True)
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
"""Suppress click.echo to keep test output clean."""
monkeypatch.setattr(click, "echo", lambda *_a, **_k: None)
monkeypatch.setattr(click, "secho", lambda *_a, **_k: None)
@pytest.fixture()
def agent_dir(tmp_path: Path) -> AgentDirFixture:
"""
Return a factory that creates a dummy agent directory tree.
"""
def _factory(*, include_requirements: bool, include_env: bool) -> Path:
base = tmp_path / "agent"
base.mkdir()
(base / "agent.py").write_text("# dummy agent")
(base / "__init__.py").write_text("from . import agent")
if include_requirements:
(base / "requirements.txt").write_text("pytest\n")
if include_env:
(base / ".env").write_text('TEST_VAR="test_value"\n')
return base
return _factory
@pytest.mark.parametrize("include_requirements", [True, False])
@pytest.mark.parametrize("with_ui", [True, False])
def test_to_cloud_run_happy_path(
monkeypatch: pytest.MonkeyPatch,
agent_dir: AgentDirFixture,
tmp_path: Path,
include_requirements: bool,
with_ui: bool,
) -> None:
"""
End-to-end execution test for `to_cloud_run`.
"""
src_dir = agent_dir(
include_requirements=include_requirements, include_env=False
)
run_recorder = _Recorder()
monkeypatch.setattr(subprocess, "run", run_recorder)
rmtree_recorder = _Recorder()
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
cli_deploy.to_cloud_run(
agent_folder=str(src_dir),
project="proj",
region="asia-northeast1",
service_name="svc",
app_name="agent",
temp_folder=str(tmp_path),
port=8080,
trace_to_cloud=True,
otel_to_cloud=True,
with_ui=with_ui,
log_level="info",
verbosity="info",
allow_origins=["http://localhost:3000", "https://my-app.com"],
session_service_uri="sqlite://",
artifact_service_uri="gs://bucket",
memory_service_uri="rag://",
adk_version="1.3.0",
)
agent_dest_path = tmp_path / "agents" / "agent"
assert (agent_dest_path / "agent.py").is_file()
assert (agent_dest_path / "__init__.py").is_file()
assert (
agent_dest_path / "requirements.txt"
).is_file() == include_requirements
dockerfile_path = tmp_path / "Dockerfile"
assert dockerfile_path.is_file()
dockerfile_content = dockerfile_path.read_text()
expected_command = "api_server --with_ui" if with_ui else "api_server"
assert f"CMD adk {expected_command} --port=8080" in dockerfile_content
assert "FROM python:3.11-slim" in dockerfile_content
assert (
'RUN adduser --disabled-password --gecos "" myuser' in dockerfile_content
)
assert "USER myuser" in dockerfile_content
assert "ENV GOOGLE_CLOUD_PROJECT=proj" in dockerfile_content
assert "ENV GOOGLE_CLOUD_LOCATION=asia-northeast1" in dockerfile_content
assert 'RUN pip install "google-adk[a2a]==1.3.0"' in dockerfile_content
assert "--trace_to_cloud" in dockerfile_content
assert "--otel_to_cloud" in dockerfile_content
# Check agent dependencies installation based on include_requirements
if include_requirements:
assert (
'RUN pip install -r "/app/agents/agent/requirements.txt"'
in dockerfile_content
)
else:
assert "# No requirements.txt found." in dockerfile_content
assert (
"--allow_origins=http://localhost:3000,https://my-app.com"
in dockerfile_content
)
assert len(run_recorder.calls) == 1
gcloud_args = run_recorder.get_last_call_args()[0]
expected_gcloud_command = [
cli_deploy._GCLOUD_CMD,
"run",
"deploy",
"svc",
"--source",
str(tmp_path),
"--project",
"proj",
"--region",
"asia-northeast1",
"--port",
"8080",
"--verbosity",
"info",
"--sandbox-launcher",
"--labels",
"created-by=adk",
]
assert gcloud_args == expected_gcloud_command
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path)
def test_to_cloud_run_cleans_temp_dir(
monkeypatch: pytest.MonkeyPatch,
agent_dir: AgentDirFixture,
) -> None:
"""`to_cloud_run` should always delete the temporary folder on exit."""
tmp_dir = Path(tempfile.mkdtemp())
src_dir = agent_dir(include_requirements=False, include_env=False)
deleted: Dict[str, Path] = {}
def _fake_rmtree(path: str | Path, *_a: Any, **_k: Any) -> None:
deleted["path"] = Path(path)
monkeypatch.setattr(shutil, "rmtree", _fake_rmtree)
monkeypatch.setattr(subprocess, "run", _Recorder())
cli_deploy.to_cloud_run(
agent_folder=str(src_dir),
project="proj",
region=None,
service_name="svc",
app_name="app",
temp_folder=str(tmp_dir),
port=8080,
trace_to_cloud=False,
otel_to_cloud=False,
with_ui=False,
log_level="info",
verbosity="info",
adk_version="1.0.0",
session_service_uri=None,
artifact_service_uri=None,
memory_service_uri=None,
)
assert deleted["path"] == tmp_dir
def test_to_cloud_run_cleans_temp_dir_on_failure(
monkeypatch: pytest.MonkeyPatch,
agent_dir: AgentDirFixture,
) -> None:
"""`to_cloud_run` should delete the temp folder on exit, even if gcloud fails."""
tmp_dir = Path(tempfile.mkdtemp())
src_dir = agent_dir(include_requirements=False, include_env=False)
rmtree_recorder = _Recorder()
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
monkeypatch.setattr(
subprocess,
"run",
mock.Mock(side_effect=subprocess.CalledProcessError(1, "gcloud")),
)
with pytest.raises(subprocess.CalledProcessError):
cli_deploy.to_cloud_run(
agent_folder=str(src_dir),
project="proj",
region="us-central1",
service_name="svc",
app_name="app",
temp_folder=str(tmp_dir),
port=8080,
trace_to_cloud=False,
otel_to_cloud=False,
with_ui=False,
log_level="info",
verbosity="info",
adk_version="1.0.0",
session_service_uri=None,
artifact_service_uri=None,
memory_service_uri=None,
)
assert rmtree_recorder.calls, "shutil.rmtree should have been called"
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir)
# Label merging tests
@pytest.mark.parametrize(
"extra_gcloud_args, expected_labels",
[
# No user labels - should only have default ADK label
(None, "created-by=adk"),
([], "created-by=adk"),
# Single user label
(["--labels=env=test"], "created-by=adk,env=test"),
# Multiple user labels in same argument
(
["--labels=env=test,team=myteam"],
"created-by=adk,env=test,team=myteam",
),
# User labels mixed with other args
(
["--memory=1Gi", "--labels=env=test", "--cpu=1"],
"created-by=adk,env=test",
),
# Multiple --labels arguments
(
["--labels=env=test", "--labels=team=myteam"],
"created-by=adk,env=test,team=myteam",
),
# Labels with other passthrough args
(
["--timeout=300", "--labels=env=prod", "--max-instances=10"],
"created-by=adk,env=prod",
),
],
)
def test_cloud_run_label_merging(
monkeypatch: pytest.MonkeyPatch,
agent_dir: AgentDirFixture,
tmp_path: Path,
extra_gcloud_args: list[str] | None,
expected_labels: str,
) -> None:
"""Test that user labels are properly merged with the default ADK label."""
src_dir = agent_dir(include_requirements=False, include_env=False)
run_recorder = _Recorder()
monkeypatch.setattr(subprocess, "run", run_recorder)
monkeypatch.setattr(shutil, "rmtree", lambda _x: None)
# Execute the function under test
cli_deploy.to_cloud_run(
agent_folder=str(src_dir),
project="test-project",
region="us-central1",
service_name="test-service",
app_name="test-app",
temp_folder=str(tmp_path),
port=8080,
trace_to_cloud=False,
otel_to_cloud=False,
with_ui=False,
log_level="info",
verbosity="info",
adk_version="1.0.0",
extra_gcloud_args=tuple(extra_gcloud_args) if extra_gcloud_args else None,
)
# Verify that the gcloud command was called
assert len(run_recorder.calls) == 1
gcloud_args = run_recorder.get_last_call_args()[0]
# Find the labels argument
labels_idx = gcloud_args.index("--labels")
actual_labels = gcloud_args[labels_idx + 1]
assert actual_labels == expected_labels
@@ -0,0 +1,51 @@
# 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.
"""Unit tests for utilities in cli_eval."""
from __future__ import annotations
from types import SimpleNamespace
from unittest import mock
def test_get_eval_sets_manager_local(monkeypatch):
mock_local_manager = mock.MagicMock()
monkeypatch.setattr(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager",
lambda *a, **k: mock_local_manager,
)
from google.adk.cli.cli_eval import get_eval_sets_manager
manager = get_eval_sets_manager(eval_storage_uri=None, agents_dir="some/dir")
assert manager == mock_local_manager
def test_get_eval_sets_manager_gcs(monkeypatch):
mock_gcs_manager = mock.MagicMock()
mock_create_gcs = mock.MagicMock()
mock_create_gcs.return_value = SimpleNamespace(
eval_sets_manager=mock_gcs_manager
)
monkeypatch.setattr(
"google.adk.cli.utils.evals.create_gcs_eval_managers_from_uri",
mock_create_gcs,
)
from google.adk.cli.cli_eval import get_eval_sets_manager
manager = get_eval_sets_manager(
eval_storage_uri="gs://bucket", agents_dir="some/dir"
)
assert manager == mock_gcs_manager
mock_create_gcs.assert_called_once_with("gs://bucket")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
# 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 pathlib import Path
from google.adk.cli.utils.dot_adk_folder import dot_adk_folder_for_agent
from google.adk.cli.utils.dot_adk_folder import DotAdkFolder
import pytest
def test_paths_are_relative_to_agent_dir(tmp_path: Path):
agent_dir = tmp_path / "agent_a"
folder = DotAdkFolder(agent_dir)
assert folder.dot_adk_dir == agent_dir.resolve() / ".adk"
assert folder.artifacts_dir == folder.dot_adk_dir / "artifacts"
assert folder.session_db_path == folder.dot_adk_dir / "session.db"
def test_for_agent_validates_app_name(tmp_path: Path):
agents_root = tmp_path / "agents"
agents_root.mkdir()
with pytest.raises(ValueError):
dot_adk_folder_for_agent(
agents_root=agents_root, app_name="../escape_attempt"
)
folder = dot_adk_folder_for_agent(
agents_root=agents_root, app_name="valid_agent"
)
expected_dir = (agents_root / "valid_agent").resolve()
assert folder.agent_dir == expected_dir
def test_for_agent_rejects_prefix_sibling_escape(tmp_path: Path):
agents_root = tmp_path / "agents"
agents_root.mkdir()
# Sibling whose path shares the agents_root string prefix ("agents" ->
# "agents_evil"); a startswith() check would wrongly accept this.
with pytest.raises(ValueError):
dot_adk_folder_for_agent(agents_root=agents_root, app_name="../agents_evil")
+92
View File
@@ -0,0 +1,92 @@
# 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.
"""Unit tests for dotenv loading utilities."""
from __future__ import annotations
import os
from pathlib import Path
import google.adk.cli.utils.envs as envs
import pytest
@pytest.fixture(autouse=True)
def _clear_explicit_env_cache() -> None:
envs._get_explicit_env_keys.cache_clear()
def test_load_dotenv_for_agent_preserves_explicit_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
agents_dir = tmp_path / "agents"
agent_dir = agents_dir / "agent1"
agent_dir.mkdir(parents=True)
explicit_key = "ADK_TEST_EXPLICIT_ENV"
from_dotenv_key = "ADK_TEST_FROM_DOTENV"
monkeypatch.setenv(explicit_key, "explicit")
monkeypatch.delenv(from_dotenv_key, raising=False)
envs._get_explicit_env_keys.cache_clear()
(agent_dir / ".env").write_text(
f"{explicit_key}=from_dotenv\n{from_dotenv_key}=from_dotenv\n"
)
envs.load_dotenv_for_agent("agent1", str(agents_dir))
assert os.environ[explicit_key] == "explicit"
assert os.environ[from_dotenv_key] == "from_dotenv"
def test_load_dotenv_for_agent_overrides_previous_dotenv(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
agents_dir = tmp_path / "agents"
agent1_dir = agents_dir / "agent1"
agent2_dir = agents_dir / "agent2"
agent1_dir.mkdir(parents=True)
agent2_dir.mkdir(parents=True)
key = "ADK_TEST_DOTENV_OVERRIDE"
monkeypatch.delenv(key, raising=False)
(agent1_dir / ".env").write_text(f"{key}=one\n")
envs.load_dotenv_for_agent("agent1", str(agents_dir))
assert os.environ[key] == "one"
(agent2_dir / ".env").write_text(f"{key}=two\n")
envs.load_dotenv_for_agent("agent2", str(agents_dir))
assert os.environ[key] == "two"
def test_load_dotenv_for_agent_respects_disable_flag(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
agents_dir = tmp_path / "agents"
agent_dir = agents_dir / "agent1"
agent_dir.mkdir(parents=True)
key = "ADK_TEST_DISABLE_DOTENV"
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("ADK_DISABLE_LOAD_DOTENV", "1")
envs._get_explicit_env_keys.cache_clear()
(agent_dir / ".env").write_text(f"{key}=from_dotenv\n")
envs.load_dotenv_for_agent("agent1", str(agents_dir))
assert key not in os.environ
+63
View File
@@ -0,0 +1,63 @@
# 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 utilities in eval."""
import os
from unittest import mock
from google.adk.cli.utils import evals
from google.adk.evaluation.gcs_eval_set_results_manager import GcsEvalSetResultsManager
from google.adk.evaluation.gcs_eval_sets_manager import GcsEvalSetsManager
import pytest
@mock.patch.dict(os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'})
@mock.patch(
'google.adk.evaluation.gcs_eval_set_results_manager.GcsEvalSetResultsManager',
autospec=True,
)
@mock.patch(
'google.adk.evaluation.gcs_eval_sets_manager.GcsEvalSetsManager',
autospec=True,
)
def test_create_gcs_eval_managers_from_uri_success(
mock_gcs_eval_sets_manager, mock_gcs_eval_set_results_manager
):
mock_gcs_eval_sets_manager.return_value = mock.MagicMock(
spec=GcsEvalSetsManager
)
mock_gcs_eval_set_results_manager.return_value = mock.MagicMock(
spec=GcsEvalSetResultsManager
)
managers = evals.create_gcs_eval_managers_from_uri('gs://test-bucket')
assert managers is not None
mock_gcs_eval_sets_manager.assert_called_once_with(
bucket_name='test-bucket', project='test-project'
)
mock_gcs_eval_set_results_manager.assert_called_once_with(
bucket_name='test-bucket', project='test-project'
)
assert managers.eval_sets_manager == mock_gcs_eval_sets_manager.return_value
assert (
managers.eval_set_results_manager
== mock_gcs_eval_set_results_manager.return_value
)
def test_create_gcs_eval_managers_from_uri_failure():
with pytest.raises(ValueError):
evals.create_gcs_eval_managers_from_uri('unsupported-uri')
+228
View File
@@ -0,0 +1,228 @@
# 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 gcp_utils."""
import unittest
from unittest import mock
from google.adk.cli.utils import gcp_utils
import google.auth
import google.auth.exceptions
import requests
class TestGcpUtils(unittest.TestCase):
def setUp(self):
super().setUp()
patcher = mock.patch(
"google.adk.cli.utils.gcp_utils._mtls_utils.use_client_cert_effective",
return_value=False,
)
self.mock_use_client_cert_effective = patcher.start()
self.addCleanup(patcher.stop)
@mock.patch("google.auth.default")
def test_check_adc_success(self, mock_auth_default):
mock_auth_default.return_value = (mock.Mock(), "test-project")
self.assertTrue(gcp_utils.check_adc())
@mock.patch("google.auth.default")
def test_check_adc_failure(self, mock_auth_default):
mock_auth_default.side_effect = (
google.auth.exceptions.DefaultCredentialsError()
)
self.assertFalse(gcp_utils.check_adc())
@mock.patch("google.auth.default")
def test_get_access_token(self, mock_auth_default):
mock_creds = mock.Mock()
mock_creds.token = "test-token"
mock_creds.valid = True
mock_auth_default.return_value = (mock_creds, "test-project")
self.assertEqual(gcp_utils.get_access_token(), "test-token")
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
@mock.patch("google.auth.default")
def test_retrieve_express_project_success(
self, mock_auth_default, mock_session_cls
):
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
mock_session = mock.Mock()
mock_session_cls.return_value = mock_session
mock_response = mock.Mock()
mock_response.json.return_value = {
"expressProject": {
"projectId": "test-project",
"defaultApiKey": "test-api-key",
"region": "us-central1",
}
}
mock_session.get.return_value = mock_response
result = gcp_utils.retrieve_express_project()
self.assertEqual(result["project_id"], "test-project")
self.assertEqual(result["api_key"], "test-api-key")
self.assertEqual(result["region"], "us-central1")
mock_session.get.assert_called_once()
args, kwargs = mock_session.get.call_args
self.assertEqual(
args[0],
"https://us-central1-aiplatform.googleapis.com/v1beta1/vertexExpress:retrieveExpressProject",
)
self.assertEqual(kwargs["params"], {"get_default_api_key": True})
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
@mock.patch("google.auth.default")
def test_retrieve_express_project_not_found(
self, mock_auth_default, mock_session_cls
):
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
mock_session = mock.Mock()
mock_session_cls.return_value = mock_session
mock_response = mock.Mock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
response=mock_response
)
mock_session.get.return_value = mock_response
result = gcp_utils.retrieve_express_project()
self.assertIsNone(result)
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
@mock.patch("google.auth.default")
def test_check_express_eligibility(self, mock_auth_default, mock_session_cls):
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
mock_session = mock.Mock()
mock_session_cls.return_value = mock_session
mock_response = mock.Mock()
mock_response.json.return_value = {"eligibility": "IN_SCOPE"}
mock_session.get.return_value = mock_response
self.assertTrue(gcp_utils.check_express_eligibility())
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
@mock.patch("google.auth.default")
def test_sign_up_express(self, mock_auth_default, mock_session_cls):
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
mock_session = mock.Mock()
mock_session_cls.return_value = mock_session
mock_response = mock.Mock()
mock_response.json.return_value = {
"projectId": "new-project",
"defaultApiKey": "new-api-key",
"region": "us-central1",
}
mock_session.post.return_value = mock_response
result = gcp_utils.sign_up_express()
self.assertEqual(result["project_id"], "new-project")
self.assertEqual(result["api_key"], "new-api-key")
args, kwargs = mock_session.post.call_args
self.assertEqual(
args[0],
"https://us-central1-aiplatform.googleapis.com/v1beta1/vertexExpress:signUp",
)
self.assertEqual(
kwargs["json"],
{
"region": "us-central1",
"tos_accepted": True,
"get_default_api_key": True,
},
)
@mock.patch("google.cloud.resourcemanager_v3.ProjectsClient")
def test_list_gcp_projects(self, mock_client_cls):
mock_client = mock.Mock()
mock_client_cls.return_value = mock_client
mock_project1 = mock.Mock()
mock_project1.project_id = "p1"
mock_project1.display_name = "Project 1"
mock_project2 = mock.Mock()
mock_project2.project_id = "p2"
mock_project2.display_name = None
mock_client.search_projects.return_value = [mock_project1, mock_project2]
projects = gcp_utils.list_gcp_projects()
self.assertEqual(len(projects), 2)
self.assertEqual(projects[0], ("p1", "Project 1"))
self.assertEqual(projects[1], ("p2", "p2"))
@mock.patch.dict("sys.modules", {"google.cloud": None})
def test_list_gcp_projects_import_error(self):
with self.assertRaisesRegex(
RuntimeError,
"Listing GCP projects requires the 'gcp' optional dependency",
):
gcp_utils.list_gcp_projects()
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
@mock.patch("google.auth.default")
def test_retrieve_express_project_mtls_enabled(
self, mock_auth_default, mock_session_cls
):
# Enable mtls
self.mock_use_client_cert_effective.return_value = True
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
mock_session = mock.Mock()
mock_session_cls.return_value = mock_session
mock_response = mock.Mock()
mock_response.json.return_value = {
"expressProject": {
"projectId": "test-project",
"defaultApiKey": "test-api-key",
"region": "us-central1",
}
}
mock_session.get.return_value = mock_response
with mock.patch(
"google.adk.cli.utils.gcp_utils._mtls_utils.get_api_endpoint",
return_value=(
"https://us-central1-aiplatform.mtls.googleapis.com/v1beta1"
),
) as mock_get_api_endpoint:
result = gcp_utils.retrieve_express_project()
self.assertEqual(result["project_id"], "test-project")
mock_session.configure_mtls_channel.assert_called_once()
mock_get_api_endpoint.assert_called_once_with(
location="us-central1",
default_template="https://{location}-aiplatform.googleapis.com/v1beta1",
mtls_template=(
"https://{location}-aiplatform.mtls.googleapis.com/v1beta1"
),
)
mock_session.get.assert_called_once()
args, _ = mock_session.get.call_args
self.assertEqual(
args[0],
"https://us-central1-aiplatform.mtls.googleapis.com/v1beta1/vertexExpress:retrieveExpressProject",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,163 @@
# 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 graph_serialization edge handling with routing maps."""
import json
from google.adk.agents import LlmAgent
from google.adk.cli.utils.graph_serialization import serialize_agent
from google.adk.models.lite_llm import LiteLlm
from google.adk.tools.base_toolset import BaseToolset
from google.adk.workflow import START
from google.adk.workflow import Workflow
from tests.unittests.workflow.workflow_testing_utils import TestingNode
def test_serialize_edges_with_routing_map() -> None:
"""Tests that routing map dicts in edges are serialized without error."""
node_a = TestingNode(name='NodeA')
node_b = TestingNode(name='NodeB')
node_c = TestingNode(name='NodeC')
agent = Workflow(
name='test_workflow',
edges=[
(START, node_a),
(node_a, {'route_b': node_b, 'route_c': node_c}),
],
)
result = serialize_agent(agent)
serialized_edges = result['edges']
assert len(serialized_edges) == 2
# First edge: (START, node_a) — serialized as a 2-element list.
assert len(serialized_edges[0]) == 2
# Second edge: (node_a, {route: node}) — serialized as a 2-element list
# where the second element is a dict with string keys.
routing_map_edge = serialized_edges[1]
assert len(routing_map_edge) == 2
assert isinstance(routing_map_edge[1], dict)
assert 'route_b' in routing_map_edge[1]
assert 'route_c' in routing_map_edge[1]
def test_serialize_edges_with_routing_map_int_keys() -> None:
"""Tests that integer routing map keys are serialized as strings."""
node_a = TestingNode(name='NodeA')
node_b = TestingNode(name='NodeB')
agent = Workflow(
name='test_workflow',
edges=[
(START, node_a),
(node_a, {1: node_b}),
],
)
result = serialize_agent(agent)
routing_map_edge = result['edges'][1]
# Integer keys become string keys in the serialized output.
assert '1' in routing_map_edge[1]
def test_serialize_edges_mixed_formats() -> None:
"""Tests serialization of edges mixing tuples, Edge objects, and routing maps."""
from google.adk.workflow import Edge
node_a = TestingNode(name='NodeA')
node_b = TestingNode(name='NodeB')
node_c = TestingNode(name='NodeC')
node_d = TestingNode(name='NodeD')
agent = Workflow(
name='test_workflow',
edges=[
(START, node_a),
(node_a, {'route_b': node_b, 'route_c': node_c}),
(node_b, node_d),
Edge(from_node=node_c, to_node=node_d),
],
)
result = serialize_agent(agent)
serialized_edges = result['edges']
assert len(serialized_edges) == 4
# Tuple edges are lists, Edge objects are dicts with from_node/to_node.
assert isinstance(serialized_edges[0], list) # (START, node_a)
assert isinstance(serialized_edges[1], list) # routing map
assert isinstance(serialized_edges[2], list) # (node_b, node_d)
assert isinstance(serialized_edges[3], dict) # Edge object
assert 'from_node' in serialized_edges[3]
def test_serialize_agent_with_toolset() -> None:
"""Tests that toolsets are serialized using their class name."""
class MockToolset(BaseToolset):
async def get_tools(self, readonly_context=None):
return []
class FakeAgent:
model_fields = {'tools': None}
def __init__(self):
self.tools = [MockToolset()]
agent = FakeAgent()
result = serialize_agent(agent) # type: ignore
assert 'tools' in result
assert len(result['tools']) == 1
assert result['tools'][0]['name'] == 'MockToolset'
assert result['tools'][0]['type'] == 'tool'
def test_serialize_agent_with_litellm_model_is_json_safe() -> None:
agent = LlmAgent(
name='repro',
model=LiteLlm(model='ollama_chat/llama3'),
)
result = serialize_agent(agent)
assert result['model'] == 'ollama_chat/llama3'
json.dumps(result)
def test_serialize_agent_skips_excluded_fields() -> None:
"""Fields marked Field(exclude=True) are omitted from serialization."""
from typing import Any
from google.adk.agents.base_agent import BaseAgent
from pydantic import ConfigDict
from pydantic import Field
class _Agent(BaseAgent):
model_config = ConfigDict(arbitrary_types_allowed=True)
secret: Any = Field(default=None, exclude=True)
agent = _Agent(name='a', secret=lambda: None)
result = serialize_agent(agent)
assert 'secret' not in result
assert result['name'] == 'a'
@@ -0,0 +1,341 @@
# 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 pathlib import Path
from google.adk.artifacts.file_artifact_service import FileArtifactService
from google.adk.cli.utils.local_storage import create_local_artifact_service
from google.adk.cli.utils.local_storage import create_local_database_session_service
from google.adk.cli.utils.local_storage import create_local_session_service
from google.adk.cli.utils.local_storage import PerAgentDatabaseSessionService
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.sessions.sqlite_session_service import SqliteSessionService
from google.genai import types
import pytest
@pytest.mark.asyncio
async def test_per_agent_session_service_creates_scoped_dot_adk(
tmp_path: Path,
) -> None:
agent_a = tmp_path / "agent_a"
agent_b = tmp_path / "agent_b"
agent_a.mkdir()
agent_b.mkdir()
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
await service.create_session(app_name="agent_a", user_id="user_a")
await service.create_session(app_name="agent_b", user_id="user_b")
assert (agent_a / ".adk" / "session.db").exists()
assert (agent_b / ".adk" / "session.db").exists()
agent_a_sessions = await service.list_sessions(app_name="agent_a")
agent_b_sessions = await service.list_sessions(app_name="agent_b")
assert len(agent_a_sessions.sessions) == 1
assert agent_a_sessions.sessions[0].app_name == "agent_a"
assert len(agent_b_sessions.sessions) == 1
assert agent_b_sessions.sessions[0].app_name == "agent_b"
@pytest.mark.asyncio
async def test_per_agent_session_service_respects_app_name_alias(
tmp_path: Path,
) -> None:
folder_name = "agent_folder"
logical_name = "custom_app"
(tmp_path / folder_name).mkdir()
service = create_local_session_service(
base_dir=tmp_path,
per_agent=True,
app_name_to_dir={logical_name: folder_name},
)
try:
session = await service.create_session(
app_name=logical_name,
user_id="user",
)
assert session.app_name == logical_name
assert (tmp_path / folder_name / ".adk" / "session.db").exists()
finally:
if isinstance(service, PerAgentDatabaseSessionService):
await service.close()
@pytest.mark.asyncio
async def test_per_agent_session_service_routes_built_in_agents_to_root_dot_adk(
tmp_path: Path,
) -> None:
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
await service.create_session(app_name="__helper", user_id="user")
assert not (tmp_path / "__helper").exists()
assert (tmp_path / ".adk" / "session.db").exists()
def test_create_local_database_session_service_returns_sqlite(
tmp_path: Path,
) -> None:
service = create_local_database_session_service(base_dir=tmp_path)
assert isinstance(service, SqliteSessionService)
@pytest.mark.asyncio
async def test_per_agent_session_service_get_user_state(tmp_path: Path) -> None:
"""Verifies get_user_state routes to correct agent and returns correct state."""
agent_a = tmp_path / "agent_a"
agent_b = tmp_path / "agent_b"
agent_a.mkdir()
agent_b.mkdir()
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
session_a = await service.create_session(
app_name="agent_a", user_id="user_a"
)
await service.append_event(
session_a,
Event(
author="system",
actions=EventActions(
state_delta={"user:profile": {"name": "Alice"}}
),
),
)
state_a = await service.get_user_state(app_name="agent_a", user_id="user_a")
state_b = await service.get_user_state(app_name="agent_b", user_id="user_b")
assert state_a == {"profile": {"name": "Alice"}}
assert not state_b
@pytest.mark.asyncio
async def test_per_agent_artifact_service_creates_scoped_dot_adk(
tmp_path: Path,
) -> None:
agent_a = tmp_path / "agent_a"
agent_b = tmp_path / "agent_b"
agent_a.mkdir()
agent_b.mkdir()
service = PerAgentFileArtifactService(agents_root=tmp_path)
artifact = types.Part.from_bytes(data=b"data", mime_type="text/plain")
await service.save_artifact(
app_name="agent_a",
user_id="user_a",
session_id="session_a",
filename="file.txt",
artifact=artifact,
)
await service.save_artifact(
app_name="agent_b",
user_id="user_b",
session_id="session_b",
filename="file.txt",
artifact=artifact,
)
assert (agent_a / ".adk" / "artifacts").exists()
assert (agent_b / ".adk" / "artifacts").exists()
assert not (tmp_path / ".adk").exists()
keys_a = await service.list_artifact_keys(
app_name="agent_a", user_id="user_a", session_id="session_a"
)
assert keys_a == ["file.txt"]
# agent_b's store doesn't see agent_a's artifact, even at the same scope.
keys_from_other_agent = await service.list_artifact_keys(
app_name="agent_b", user_id="user_a", session_id="session_a"
)
assert keys_from_other_agent == []
@pytest.mark.asyncio
async def test_per_agent_artifact_service_respects_app_name_alias(
tmp_path: Path,
) -> None:
folder_name = "agent_folder"
logical_name = "custom_app"
(tmp_path / folder_name).mkdir()
service = create_local_artifact_service(
base_dir=tmp_path,
per_agent=True,
app_name_to_dir={logical_name: folder_name},
)
await service.save_artifact(
app_name=logical_name,
user_id="user",
session_id="session",
filename="file.txt",
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
)
assert (tmp_path / folder_name / ".adk" / "artifacts").exists()
assert not (tmp_path / logical_name).exists()
@pytest.mark.asyncio
async def test_per_agent_artifact_service_routes_built_in_agents_to_root_dot_adk(
tmp_path: Path,
) -> None:
service = PerAgentFileArtifactService(agents_root=tmp_path)
await service.save_artifact(
app_name="__helper",
user_id="user",
session_id="session",
filename="file.txt",
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
)
assert not (tmp_path / "__helper").exists()
assert (tmp_path / ".adk" / "artifacts").exists()
def test_create_local_artifact_service_returns_file_service(
tmp_path: Path,
) -> None:
service = create_local_artifact_service(base_dir=tmp_path)
assert isinstance(service, FileArtifactService)
@pytest.mark.asyncio
async def test_per_agent_artifact_service_delegates_all_operations(
tmp_path: Path,
) -> None:
(tmp_path / "agent_a").mkdir()
service = PerAgentFileArtifactService(agents_root=tmp_path)
artifact = types.Part.from_bytes(data=b"data", mime_type="text/plain")
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
version = await service.save_artifact(
filename="file.txt", artifact=artifact, **scope
)
assert version == 0
loaded = await service.load_artifact(filename="file.txt", **scope)
assert loaded is not None
assert loaded.inline_data.data == b"data"
assert await service.list_versions(filename="file.txt", **scope) == [0]
assert (
len(await service.list_artifact_versions(filename="file.txt", **scope))
== 1
)
assert (
await service.get_artifact_version(filename="file.txt", **scope)
) is not None
await service.delete_artifact(filename="file.txt", **scope)
assert await service.list_artifact_keys(**scope) == []
@pytest.mark.asyncio
async def test_per_agent_artifact_service_reads_legacy_shared_root(
tmp_path: Path,
) -> None:
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
# Seed an artifact in the pre-per-agent shared <root>/.adk/artifacts store.
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
await legacy.save_artifact(
filename="legacy.txt",
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
**scope,
)
service = PerAgentFileArtifactService(agents_root=tmp_path)
loaded = await service.load_artifact(filename="legacy.txt", **scope)
assert loaded is not None
assert loaded.inline_data.data == b"old"
assert await service.list_artifact_keys(**scope) == ["legacy.txt"]
assert await service.list_versions(filename="legacy.txt", **scope) == [0]
assert (
await service.get_artifact_version(filename="legacy.txt", **scope)
) is not None
@pytest.mark.asyncio
async def test_per_agent_artifact_service_writes_do_not_touch_legacy_root(
tmp_path: Path,
) -> None:
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
await legacy.save_artifact(
filename="legacy.txt",
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
**scope,
)
service = PerAgentFileArtifactService(agents_root=tmp_path)
await service.save_artifact(
filename="new.txt",
artifact=types.Part.from_bytes(data=b"new", mime_type="text/plain"),
**scope,
)
# New write lands per-agent, not in the legacy shared root.
assert (tmp_path / "agent_a" / ".adk" / "artifacts").exists()
assert await legacy.list_artifact_keys(**scope) == ["legacy.txt"]
# Reads union the new per-agent key with the legacy one.
assert await service.list_artifact_keys(**scope) == ["legacy.txt", "new.txt"]
@pytest.mark.asyncio
async def test_per_agent_artifact_service_no_fallback_without_legacy_dir(
tmp_path: Path,
) -> None:
service = PerAgentFileArtifactService(agents_root=tmp_path)
result = await service.load_artifact(
app_name="agent_a",
user_id="user",
session_id="session",
filename="missing.txt",
)
assert result is None
assert not (tmp_path / ".adk").exists()
@pytest.mark.asyncio
async def test_per_agent_artifact_service_delete_removes_legacy_copy(
tmp_path: Path,
) -> None:
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
await legacy.save_artifact(
filename="legacy.txt",
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
**scope,
)
service = PerAgentFileArtifactService(agents_root=tmp_path)
await service.delete_artifact(filename="legacy.txt", **scope)
# Deleted artifact must not reappear through the legacy read fallback.
assert await service.load_artifact(filename="legacy.txt", **scope) is None
assert await service.list_artifact_keys(**scope) == []
assert await legacy.list_artifact_keys(**scope) == []
@@ -0,0 +1,156 @@
# 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 os
from pathlib import Path
import sys
import tempfile
from google.adk.cli.utils._nested_agent_loader import NestedAgentLoader
import pytest
class TestNestedAgentLoader:
"""Comprehensive tests for NestedAgentLoader focusing on list_agents."""
def test_list_agents_in_single_agent_mode_returns_only_single_agent(self):
"""Single agent mode returns list containing only the single agent's name."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
agent_file = temp_path / "my_agent.py"
agent_file.write_text("")
loader = NestedAgentLoader(str(agent_file))
agents = loader.list_agents()
assert agents == ["my_agent"]
def test_list_agents_with_nonexistent_directory_returns_empty_list(self):
"""Constructing with a nonexistent directory path returns an empty list."""
loader = NestedAgentLoader("/nonexistent/directory/path")
agents = loader.list_agents()
assert agents == []
def test_list_agents_finds_nested_agents_recursively(self):
"""Recursive directory walk discovers all agents with nested namespaces."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create agent_one/agent.py
dir1 = temp_path / "agent_one"
dir1.mkdir()
(dir1 / "agent.py").write_text("")
# Create sub_dir/agent_two/root_agent.yaml
dir2 = temp_path / "sub_dir" / "agent_two"
dir2.mkdir(parents=True)
(dir2 / "root_agent.yaml").write_text("")
# Create sub_dir/sub_sub_dir/agent_three/__init__.py
dir3 = temp_path / "sub_dir" / "sub_sub_dir" / "agent_three"
dir3.mkdir(parents=True)
(dir3 / "__init__.py").write_text("root_agent = None")
loader = NestedAgentLoader(str(temp_path))
agents = loader.list_agents()
assert agents == [
"agent_one",
"sub_dir.agent_two",
"sub_dir.sub_sub_dir.agent_three",
]
def test_list_agents_ignores_hidden_and_special_directories(self):
"""Discovered agents exclude hidden directories, pycache, and temp folders."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Valid agent
dir_valid = temp_path / "valid_agent"
dir_valid.mkdir()
(dir_valid / "agent.py").write_text("")
# Hidden agent (starts with dot)
dir_hidden = temp_path / ".hidden_agent"
dir_hidden.mkdir()
(dir_hidden / "agent.py").write_text("")
# Special directory __pycache__
dir_pycache = temp_path / "__pycache__"
dir_pycache.mkdir()
(dir_pycache / "agent.py").write_text("")
# Special directory tmp
dir_tmp = temp_path / "tmp"
dir_tmp.mkdir()
(dir_tmp / "agent.py").write_text("")
loader = NestedAgentLoader(str(temp_path))
agents = loader.list_agents()
assert agents == ["valid_agent"]
def test_list_agents_excludes_root_directory_itself(self):
"""Root app directory itself is not listed as a nested agent sub-app."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create agent.py at the root itself
(temp_path / "agent.py").write_text("")
# Create a nested valid agent
dir_nested = temp_path / "sub_agent"
dir_nested.mkdir()
(dir_nested / "agent.py").write_text("")
loader = NestedAgentLoader(str(temp_path))
agents = loader.list_agents()
assert agents == ["sub_agent"]
def test_list_agents_sorts_discovered_agents_alphabetically(self):
"""Returned list of discovered agents is sorted alphabetically."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create agents in non-alphabetical order
for name in ["z_agent", "a_agent", "m_agent"]:
agent_dir = temp_path / name
agent_dir.mkdir()
(agent_dir / "agent.py").write_text("")
loader = NestedAgentLoader(str(temp_path))
agents = loader.list_agents()
assert agents == ["a_agent", "m_agent", "z_agent"]
def test_remove_agent_from_cache_clears_module_keys_and_internal_cache(self):
"""remove_agent_from_cache removes agent from internal cache and deletes its sys.modules keys."""
loader = NestedAgentLoader("/dummy/path")
# Arrange: populate internal cache (with slash-separated path) and sys.modules (with dot-separated path)
loader._agent_cache["foo/bar/my_agent"] = "dummy_agent_instance"
sys.modules["foo.bar.my_agent"] = "dummy_module_instance"
sys.modules["foo.bar.my_agent.submodule"] = "dummy_submodule_instance"
# Act: remove the agent from cache
loader.remove_agent_from_cache("foo/bar/my_agent")
# Assert: cleared from loader's internal cache
assert "foo/bar/my_agent" not in loader._agent_cache
# Assert: cleared from sys.modules (both the module and its submodules)
assert "foo.bar.my_agent" not in sys.modules
assert "foo.bar.my_agent.submodule" not in sys.modules
@@ -0,0 +1,568 @@
# 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 service factory helpers."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from unittest import mock
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.cli.service_registry import ServiceRegistry
from google.adk.cli.utils.local_storage import PerAgentDatabaseSessionService
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
import google.adk.cli.utils.service_factory as service_factory
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.sessions.database_session_service import DatabaseSessionService
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
def test_create_session_service_uses_registry(tmp_path: Path, monkeypatch):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
expected = object()
registry.create_session_service.return_value = expected
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
result = service_factory.create_session_service_from_options(
base_dir=tmp_path,
session_service_uri="sqlite:///test.db",
)
assert result is expected
registry.create_session_service.assert_called_once_with(
"sqlite:///test.db",
agents_dir=str(tmp_path),
)
def test_create_session_service_logs_redacted_uri(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
registry.create_session_service.return_value = object()
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
session_service_uri = (
"postgresql://user:supersecret@localhost:5432/dbname?sslmode=require"
)
caplog.set_level(logging.INFO, logger=service_factory.logger.name)
service_factory.create_session_service_from_options(
base_dir=tmp_path,
session_service_uri=session_service_uri,
)
assert "supersecret" not in caplog.text
assert "sslmode=require" not in caplog.text
assert "localhost:5432" in caplog.text
def test_redact_uri_for_log_removes_credentials_with_at_in_password() -> None:
uri = "postgresql://user:super@secret@localhost:5432/dbname"
assert (
service_factory._redact_uri_for_log(uri)
== "postgresql://localhost:5432/dbname"
)
def test_redact_uri_for_log_preserves_host_when_no_credentials() -> None:
uri = "postgresql://localhost:5432/dbname?sslmode=require&password=secret"
redacted = service_factory._redact_uri_for_log(uri)
assert redacted.startswith("postgresql://localhost:5432/dbname?")
assert "require" not in redacted
assert "secret" not in redacted
assert "sslmode=<redacted>" in redacted
assert "password=<redacted>" in redacted
def test_redact_uri_for_log_redacts_when_parse_qsl_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _raise_value_error(*_args, **_kwargs):
raise ValueError("bad query")
monkeypatch.setattr(service_factory, "parse_qsl", _raise_value_error)
uri = "postgresql://user:pass@localhost:5432/dbname?sslmode=require"
redacted = service_factory._redact_uri_for_log(uri)
assert "pass" not in redacted
assert "require" not in redacted
assert redacted.endswith("?<redacted>")
def test_redact_uri_for_log_escapes_crlf() -> None:
uri = (
"postgresql://user:pass@localhost:5432/dbname\rINJECT\nINJECT"
"?sslmode=require"
)
redacted = service_factory._redact_uri_for_log(uri)
assert "\r" not in redacted
assert "\n" not in redacted
assert "\\rINJECT\\nINJECT" in redacted
def test_redact_uri_for_log_returns_scheme_missing_without_separator() -> None:
assert (
service_factory._redact_uri_for_log("user:pass@localhost:5432/dbname")
== "<scheme-missing>"
)
@pytest.mark.asyncio
async def test_create_session_service_defaults_to_per_agent_sqlite(
tmp_path: Path,
) -> None:
agent_dir = tmp_path / "agent_a"
agent_dir.mkdir()
service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(service, PerAgentDatabaseSessionService)
session = await service.create_session(app_name="agent_a", user_id="user")
assert session.app_name == "agent_a"
assert (agent_dir / ".adk" / "session.db").exists()
@pytest.mark.asyncio
async def test_create_session_service_respects_app_name_mapping(
tmp_path: Path,
) -> None:
agent_dir = tmp_path / "agent_folder"
logical_name = "custom_app"
agent_dir.mkdir()
service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
app_name_to_dir={logical_name: "agent_folder"},
use_local_storage=True,
)
assert isinstance(service, PerAgentDatabaseSessionService)
session = await service.create_session(app_name=logical_name, user_id="user")
assert session.app_name == logical_name
assert (agent_dir / ".adk" / "session.db").exists()
@pytest.mark.asyncio
async def test_create_session_service_fallbacks_to_database(
tmp_path: Path, monkeypatch
):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
registry.create_session_service.return_value = None
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
session_service_uri="sqlite+aiosqlite:///:memory:",
session_db_kwargs={"echo": True},
)
assert isinstance(service, DatabaseSessionService)
assert service.db_engine.url.drivername == "sqlite+aiosqlite"
assert service.db_engine.echo is True
registry.create_session_service.assert_called_once_with(
"sqlite+aiosqlite:///:memory:",
agents_dir=str(tmp_path),
echo=True,
)
await service.close()
def test_create_artifact_service_uses_registry(tmp_path: Path, monkeypatch):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
expected = object()
registry.create_artifact_service.return_value = expected
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
result = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
artifact_service_uri="gs://bucket/path",
)
assert result is expected
registry.create_artifact_service.assert_called_once_with(
"gs://bucket/path",
agents_dir=str(tmp_path),
)
def test_create_artifact_service_raises_on_unknown_scheme_when_strict(
tmp_path: Path, monkeypatch
):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
registry.create_artifact_service.return_value = None
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
with pytest.raises(ValueError):
service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
artifact_service_uri="unknown://foo",
strict_uri=True,
)
def test_create_memory_service_uses_registry(tmp_path: Path, monkeypatch):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
expected = object()
registry.create_memory_service.return_value = expected
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
result = service_factory.create_memory_service_from_options(
base_dir=tmp_path,
memory_service_uri="rag://my-corpus",
)
assert result is expected
registry.create_memory_service.assert_called_once_with(
"rag://my-corpus",
agents_dir=str(tmp_path),
)
def test_create_memory_service_defaults_to_in_memory(tmp_path: Path):
service = service_factory.create_memory_service_from_options(
base_dir=tmp_path
)
assert isinstance(service, InMemoryMemoryService)
def test_create_memory_service_supports_memory_uri(tmp_path: Path):
service = service_factory.create_memory_service_from_options(
base_dir=tmp_path,
memory_service_uri="memory://",
)
assert isinstance(service, InMemoryMemoryService)
def test_create_memory_service_raises_on_unknown_scheme(
tmp_path: Path, monkeypatch
):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
registry.create_memory_service.return_value = None
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
with pytest.raises(ValueError):
service_factory.create_memory_service_from_options(
base_dir=tmp_path,
memory_service_uri="unknown://foo",
)
@pytest.mark.asyncio
async def test_create_session_service_defaults_to_in_memory_when_disabled(
tmp_path: Path,
) -> None:
service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
use_local_storage=False,
)
assert isinstance(service, InMemorySessionService)
session = await service.create_session(app_name="agent_a", user_id="user")
assert session.app_name == "agent_a"
assert not (tmp_path / "agent_a" / ".adk").exists()
def test_create_artifact_service_defaults_to_in_memory_when_disabled(
tmp_path: Path,
) -> None:
service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=False,
)
assert isinstance(service, InMemoryArtifactService)
assert not (tmp_path / ".adk").exists()
@pytest.mark.asyncio
async def test_create_artifact_service_defaults_to_per_agent(
tmp_path: Path,
) -> None:
agent_dir = tmp_path / "agent_a"
agent_dir.mkdir()
service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(service, PerAgentFileArtifactService)
await service.save_artifact(
app_name="agent_a",
user_id="user",
session_id="session",
filename="file.txt",
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
)
assert (agent_dir / ".adk" / "artifacts").exists()
assert not (tmp_path / ".adk").exists()
@pytest.mark.asyncio
async def test_create_artifact_service_respects_app_name_mapping(
tmp_path: Path,
) -> None:
agent_dir = tmp_path / "agent_folder"
logical_name = "custom_app"
agent_dir.mkdir()
service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
app_name_to_dir={logical_name: "agent_folder"},
use_local_storage=True,
)
assert isinstance(service, PerAgentFileArtifactService)
await service.save_artifact(
app_name=logical_name,
user_id="user",
session_id="session",
filename="file.txt",
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
)
assert (agent_dir / ".adk" / "artifacts").exists()
def test_create_artifact_service_warns_on_legacy_shared_root(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
(tmp_path / ".adk" / "artifacts").mkdir(parents=True)
caplog.set_level(logging.WARNING, logger=service_factory.logger.name)
service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(service, PerAgentFileArtifactService)
assert "legacy shared artifacts" in caplog.text
def test_create_artifact_service_no_warning_without_legacy_root(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level(logging.WARNING, logger=service_factory.logger.name)
service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert "legacy shared artifacts" not in caplog.text
def test_create_session_service_fallbacks_to_in_memory_on_permission_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _raise_permission_error(*_args, **_kwargs):
raise PermissionError("nope")
monkeypatch.setattr(
service_factory, "create_local_session_service", _raise_permission_error
)
service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(service, InMemorySessionService)
@pytest.mark.skipif(os.name == "nt", reason="chmod behavior differs on Windows")
def test_create_services_default_to_in_memory_when_agents_dir_unwritable(
tmp_path: Path,
) -> None:
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
try:
agents_dir.chmod(0o555)
if os.access(agents_dir, os.W_OK | os.X_OK):
pytest.skip("Test cannot make directory unwritable in this environment.")
session_service = service_factory.create_session_service_from_options(
base_dir=agents_dir,
use_local_storage=True,
)
assert isinstance(session_service, InMemorySessionService)
artifact_service = service_factory.create_artifact_service_from_options(
base_dir=agents_dir,
use_local_storage=True,
)
assert isinstance(artifact_service, InMemoryArtifactService)
finally:
agents_dir.chmod(0o755)
def test_adk_disable_local_storage_env_forces_in_memory(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ADK_DISABLE_LOCAL_STORAGE", "1")
session_service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(session_service, InMemorySessionService)
artifact_service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(artifact_service, InMemoryArtifactService)
def test_cloud_run_env_defaults_to_in_memory(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("K_SERVICE", "adk-service")
session_service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(session_service, InMemorySessionService)
artifact_service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(artifact_service, InMemoryArtifactService)
def test_kubernetes_env_defaults_to_in_memory(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
session_service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(session_service, InMemorySessionService)
artifact_service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(artifact_service, InMemoryArtifactService)
@pytest.mark.asyncio
async def test_adk_force_local_storage_env_overrides_flag(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ADK_FORCE_LOCAL_STORAGE", "1")
agent_dir = tmp_path / "agent_a"
agent_dir.mkdir()
session_service = service_factory.create_session_service_from_options(
base_dir=tmp_path,
use_local_storage=False,
)
assert isinstance(session_service, PerAgentDatabaseSessionService)
await session_service.create_session(app_name="agent_a", user_id="user")
assert (agent_dir / ".adk" / "session.db").exists()
artifact_service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=False,
)
assert isinstance(artifact_service, PerAgentFileArtifactService)
await artifact_service.save_artifact(
app_name="agent_a",
user_id="user",
session_id="session",
filename="file.txt",
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
)
assert (agent_dir / ".adk" / "artifacts").exists()
def test_create_artifact_service_fallbacks_to_in_memory_on_permission_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _raise_permission_error(*_args, **_kwargs):
raise PermissionError("nope")
monkeypatch.setattr(
service_factory, "create_local_artifact_service", _raise_permission_error
)
service = service_factory.create_artifact_service_from_options(
base_dir=tmp_path,
use_local_storage=True,
)
assert isinstance(service, InMemoryArtifactService)
def test_create_task_store_uses_registry(monkeypatch):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
expected = object()
registry._create_task_store_service.return_value = expected
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
result = service_factory._create_task_store_from_options(
task_store_uri="postgresql+asyncpg://user:pass@host/db",
)
assert result is expected
registry._create_task_store_service.assert_called_once_with(
"postgresql+asyncpg://user:pass@host/db",
)
def test_create_task_store_defaults_to_in_memory():
from a2a.server.tasks import InMemoryTaskStore
service = service_factory._create_task_store_from_options()
assert isinstance(service, InMemoryTaskStore)
def test_create_task_store_raises_on_unknown_scheme(monkeypatch):
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
registry._create_task_store_service.side_effect = ValueError("Unsupported")
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
with pytest.raises(ValueError):
service_factory._create_task_store_from_options(
task_store_uri="unknown://foo",
)