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,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",
)