|
| 1 | +# Copyright 2025 Google LLC |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Unit tests for utilities in cli.""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import click |
| 20 | +import json |
| 21 | +import pytest |
| 22 | +import sys |
| 23 | +import types |
| 24 | + |
| 25 | +import google.adk.cli.cli as cli |
| 26 | + |
| 27 | +from pathlib import Path |
| 28 | +from typing import Any, Dict, List, Tuple |
| 29 | + |
| 30 | +# Helpers |
| 31 | +class _Recorder: |
| 32 | + """Callable that records every invocation.""" |
| 33 | + |
| 34 | + def __init__(self) -> None: |
| 35 | + self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = [] |
| 36 | + |
| 37 | + def __call__(self, *args: Any, **kwargs: Any) -> None: |
| 38 | + self.calls.append((args, kwargs)) |
| 39 | + |
| 40 | + |
| 41 | +# Fixtures |
| 42 | +@pytest.fixture(autouse=True) |
| 43 | +def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None: |
| 44 | + """Silence click output in every test.""" |
| 45 | + monkeypatch.setattr(click, "echo", lambda *a, **k: None) |
| 46 | + monkeypatch.setattr(click, "secho", lambda *a, **k: None) |
| 47 | + |
| 48 | + |
| 49 | +@pytest.fixture(autouse=True) |
| 50 | +def _patch_types_and_runner(monkeypatch: pytest.MonkeyPatch) -> None: |
| 51 | + """Replace google.genai.types and Runner with lightweight fakes.""" |
| 52 | + |
| 53 | + # Dummy Part / Content |
| 54 | + class _Part: |
| 55 | + def __init__(self, text: str | None = "") -> None: |
| 56 | + self.text = text |
| 57 | + |
| 58 | + class _Content: |
| 59 | + def __init__(self, role: str, parts: List[_Part]) -> None: |
| 60 | + self.role = role |
| 61 | + self.parts = parts |
| 62 | + |
| 63 | + monkeypatch.setattr(cli.types, "Part", _Part) |
| 64 | + monkeypatch.setattr(cli.types, "Content", _Content) |
| 65 | + |
| 66 | + # Fake Runner yielding a single assistant echo |
| 67 | + class _FakeRunner: |
| 68 | + def __init__(self, *a: Any, **k: Any) -> None: ... |
| 69 | + |
| 70 | + async def run_async(self, *a: Any, **k: Any): |
| 71 | + message = a[2] if len(a) >= 3 else k["new_message"] |
| 72 | + text = message.parts[0].text if message.parts else "" |
| 73 | + response = _Content("assistant", [_Part(f"echo:{text}")]) |
| 74 | + yield types.SimpleNamespace(author="assistant", content=response) |
| 75 | + |
| 76 | + monkeypatch.setattr(cli, "Runner", _FakeRunner) |
| 77 | + |
| 78 | + |
| 79 | +@pytest.fixture() |
| 80 | +def fake_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): |
| 81 | + """Create a minimal importable agent package and patch importlib.""" |
| 82 | + |
| 83 | + parent_dir = tmp_path / "agents" |
| 84 | + parent_dir.mkdir() |
| 85 | + agent_dir = parent_dir / "fake_agent" |
| 86 | + agent_dir.mkdir() |
| 87 | + # __init__.py exposes root_agent with .name |
| 88 | + (agent_dir / "__init__.py").write_text( |
| 89 | + "from types import SimpleNamespace\n" |
| 90 | + "root_agent = SimpleNamespace(name='fake_root')\n" |
| 91 | + ) |
| 92 | + |
| 93 | + # Ensure importable via sys.path |
| 94 | + sys.path.insert(0, str(parent_dir)) |
| 95 | + |
| 96 | + import importlib |
| 97 | + |
| 98 | + module = importlib.import_module("fake_agent") |
| 99 | + fake_module = types.SimpleNamespace(agent=module) |
| 100 | + |
| 101 | + monkeypatch.setattr(importlib, "import_module", lambda n: fake_module) |
| 102 | + monkeypatch.setattr(cli.envs, "load_dotenv_for_agent", lambda *a, **k: None) |
| 103 | + |
| 104 | + yield parent_dir, "fake_agent" |
| 105 | + |
| 106 | + # Cleanup |
| 107 | + sys.path.remove(str(parent_dir)) |
| 108 | + del sys.modules["fake_agent"] |
| 109 | + |
| 110 | + |
| 111 | +# _run_input_file |
| 112 | +@pytest.mark.asyncio |
| 113 | +async def test_run_input_file_outputs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 114 | + """run_input_file should echo user & assistant messages and return a populated session.""" |
| 115 | + recorder: List[str] = [] |
| 116 | + |
| 117 | + def _echo(msg: str) -> None: |
| 118 | + recorder.append(msg) |
| 119 | + |
| 120 | + monkeypatch.setattr(click, "echo", _echo) |
| 121 | + |
| 122 | + input_json = { |
| 123 | + "state": {"foo": "bar"}, |
| 124 | + "queries": ["hello world"], |
| 125 | + } |
| 126 | + input_path = tmp_path / "input.json" |
| 127 | + input_path.write_text(json.dumps(input_json)) |
| 128 | + |
| 129 | + artifact_service = cli.InMemoryArtifactService() |
| 130 | + session_service = cli.InMemorySessionService() |
| 131 | + dummy_root = types.SimpleNamespace(name="root") |
| 132 | + |
| 133 | + session = await cli.run_input_file( |
| 134 | + app_name="app", |
| 135 | + user_id="user", |
| 136 | + root_agent=dummy_root, |
| 137 | + artifact_service=artifact_service, |
| 138 | + session_service=session_service, |
| 139 | + input_path=str(input_path), |
| 140 | + ) |
| 141 | + |
| 142 | + assert session.state["foo"] == "bar" |
| 143 | + assert any("[user]:" in line for line in recorder) |
| 144 | + assert any("[assistant]:" in line for line in recorder) |
| 145 | + |
| 146 | + |
| 147 | +# _run_cli (input_file branch) |
| 148 | +@pytest.mark.asyncio |
| 149 | +async def test_run_cli_with_input_file(fake_agent, tmp_path: Path) -> None: |
| 150 | + """run_cli should process an input file without raising and without saving.""" |
| 151 | + parent_dir, folder_name = fake_agent |
| 152 | + input_json = {"state": {}, "queries": ["ping"]} |
| 153 | + input_path = tmp_path / "in.json" |
| 154 | + input_path.write_text(json.dumps(input_json)) |
| 155 | + |
| 156 | + await cli.run_cli( |
| 157 | + agent_parent_dir=str(parent_dir), |
| 158 | + agent_folder_name=folder_name, |
| 159 | + input_file=str(input_path), |
| 160 | + saved_session_file=None, |
| 161 | + save_session=False, |
| 162 | + ) |
| 163 | + |
| 164 | + |
| 165 | +# _run_cli (interactive + save session branch) |
| 166 | +@pytest.mark.asyncio |
| 167 | +async def test_run_cli_save_session(fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 168 | + """run_cli should save a session file when save_session=True.""" |
| 169 | + parent_dir, folder_name = fake_agent |
| 170 | + |
| 171 | + # Simulate user typing 'exit' followed by session id 'sess123' |
| 172 | + responses = iter(["exit", "sess123"]) |
| 173 | + monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(responses)) |
| 174 | + |
| 175 | + session_file = Path(parent_dir) / folder_name / "sess123.session.json" |
| 176 | + if session_file.exists(): |
| 177 | + session_file.unlink() |
| 178 | + |
| 179 | + await cli.run_cli( |
| 180 | + agent_parent_dir=str(parent_dir), |
| 181 | + agent_folder_name=folder_name, |
| 182 | + input_file=None, |
| 183 | + saved_session_file=None, |
| 184 | + save_session=True, |
| 185 | + ) |
| 186 | + |
| 187 | + assert session_file.exists() |
| 188 | + data = json.loads(session_file.read_text()) |
| 189 | + # The saved JSON should at least contain id and events keys |
| 190 | + assert "id" in data and "events" in data |
| 191 | + |
| 192 | + |
| 193 | +@pytest.mark.asyncio |
| 194 | +async def test_run_interactively_whitespace_and_exit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 195 | + """run_interactively should skip blank input, echo once, then exit.""" |
| 196 | + # make a session that belongs to dummy agent |
| 197 | + svc = cli.InMemorySessionService() |
| 198 | + sess = svc.create_session(app_name="dummy", user_id="u") |
| 199 | + artifact_service = cli.InMemoryArtifactService() |
| 200 | + root_agent = types.SimpleNamespace(name="root") |
| 201 | + |
| 202 | + # fake user input: blank -> 'hello' -> 'exit' |
| 203 | + answers = iter([" ", "hello", "exit"]) |
| 204 | + monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(answers)) |
| 205 | + |
| 206 | + # capture assisted echo |
| 207 | + echoed: list[str] = [] |
| 208 | + monkeypatch.setattr(click, "echo", lambda msg: echoed.append(msg)) |
| 209 | + |
| 210 | + await cli.run_interactively(root_agent, artifact_service, sess, svc) |
| 211 | + |
| 212 | + # verify: assistant echoed once with 'echo:hello' |
| 213 | + assert any("echo:hello" in m for m in echoed) |
| 214 | + |
| 215 | + |
| 216 | +# run_cli (resume branch) |
| 217 | +@pytest.mark.asyncio |
| 218 | +async def test_run_cli_resume_saved_session(tmp_path: Path, fake_agent, monkeypatch: pytest.MonkeyPatch) -> None: |
| 219 | + """run_cli should load previous session, print its events, then re-enter interactive mode.""" |
| 220 | + parent_dir, folder = fake_agent |
| 221 | + |
| 222 | + # stub Session.model_validate_json to return dummy session with two events |
| 223 | + user_content = types.SimpleNamespace(parts=[types.SimpleNamespace(text="hi")]) |
| 224 | + assistant_content = types.SimpleNamespace(parts=[types.SimpleNamespace(text="hello!")]) |
| 225 | + dummy_session = types.SimpleNamespace( |
| 226 | + id="sess", |
| 227 | + app_name=folder, |
| 228 | + user_id="u", |
| 229 | + events=[ |
| 230 | + types.SimpleNamespace(author="user", content=user_content, partial=False), |
| 231 | + types.SimpleNamespace(author="assistant", content=assistant_content, partial=False), |
| 232 | + ], |
| 233 | + ) |
| 234 | + monkeypatch.setattr(cli.Session, "model_validate_json", staticmethod(lambda _s: dummy_session)) |
| 235 | + monkeypatch.setattr(cli.InMemorySessionService, "append_event", lambda *_a, **_k: None) |
| 236 | + # interactive inputs: immediately 'exit' |
| 237 | + monkeypatch.setattr("builtins.input", lambda *_a, **_k: "exit") |
| 238 | + |
| 239 | + # collect echo output |
| 240 | + captured: list[str] = [] |
| 241 | + monkeypatch.setattr(click, "echo", lambda m: captured.append(m)) |
| 242 | + |
| 243 | + saved_path = tmp_path / "prev.session.json" |
| 244 | + saved_path.write_text("{}") # contents not used – patched above |
| 245 | + |
| 246 | + await cli.run_cli( |
| 247 | + agent_parent_dir=str(parent_dir), |
| 248 | + agent_folder_name=folder, |
| 249 | + input_file=None, |
| 250 | + saved_session_file=str(saved_path), |
| 251 | + save_session=False, |
| 252 | + ) |
| 253 | + |
| 254 | + # ④ ensure both historical messages were printed |
| 255 | + assert any("[user]: hi" in m for m in captured) |
| 256 | + assert any("[assistant]: hello!" in m for m in captured) |
0 commit comments