From 224c1895ed63ebe37234554290ca49f51002f420 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 15 Aug 2026 05:04:58 -0700 Subject: [PATCH 1/2] fix: honour a whitespace-padded exit at the adk run prompt run_interactively already normalised the prompt input with .strip() for its blank-input skip, but compared the raw string against the quit word. So 'exit ' and ' exit' were sent to the model as a turn instead of ending the session, and the banner's 'type exit to exit' promise did not hold. --- src/google/adk/cli/cli.py | 2 +- tests/unittests/cli/utils/test_cli.py | 48 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/google/adk/cli/cli.py b/src/google/adk/cli/cli.py index c1d693b03c6..a6de8cb2e34 100644 --- a/src/google/adk/cli/cli.py +++ b/src/google/adk/cli/cli.py @@ -210,7 +210,7 @@ async def run_interactively( query = input('[user]: ') if not query or not query.strip(): continue - if query == 'exit': + if query.strip() == 'exit': break next_message = types.Content(role='user', parts=[types.Part(text=query)]) diff --git a/tests/unittests/cli/utils/test_cli.py b/tests/unittests/cli/utils/test_cli.py index 479be61fb1c..11328b1f3f4 100644 --- a/tests/unittests/cli/utils/test_cli.py +++ b/tests/unittests/cli/utils/test_cli.py @@ -525,3 +525,51 @@ async def test_run_interactively_whitespace_and_exit( # verify: assistant echoed once with 'echo:hello' assert any("echo:hello" in m for m in echoed) + + +@pytest.mark.asyncio +async def test_run_interactively_exits_on_padded_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_interactively should exit on 'exit' that carries whitespace.""" + 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: ' exit ' first, then a bare 'exit' that bounds the loop so + # an unfixed comparison fails the assertion below instead of hanging. + answers = iter([" exit ", "exit"]) + monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(answers, "exit")) + + runs: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = [] + closed: List[bool] = [] + + class _RecordingRunner: + """Runner fake that records every turn instead of calling a model.""" + + def __init__(self, *a: Any, **k: Any) -> None: + ... + + async def run_async(self, *a: Any, **k: Any): + runs.append((a, k)) + return + yield # keeps run_async an async generator for Aclosing + + async def close(self, *a: Any, **k: Any) -> None: + closed.append(True) + + monkeypatch.setattr(cli, "Runner", _RecordingRunner) + + 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: no turn reached the runner, nothing was printed, runner closed + assert runs == [] + assert echoed == [] + assert closed == [True] From 3eed473b8557719511c28a968903e96f53717ea1 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 15 Aug 2026 05:18:19 -0700 Subject: [PATCH 2/2] test: assert the padded exit through the shared runner fake The test defined a second runner fake that yielded no events, so two of its three assertions held with the fix reverted. The autouse fixture's fake echoes every turn it receives, so one assertion on the echo output now carries the regression on its own. --- tests/unittests/cli/utils/test_cli.py | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/tests/unittests/cli/utils/test_cli.py b/tests/unittests/cli/utils/test_cli.py index 11328b1f3f4..743539049b4 100644 --- a/tests/unittests/cli/utils/test_cli.py +++ b/tests/unittests/cli/utils/test_cli.py @@ -541,27 +541,9 @@ async def test_run_interactively_exits_on_padded_exit( # fake user input: ' exit ' first, then a bare 'exit' that bounds the loop so # an unfixed comparison fails the assertion below instead of hanging. answers = iter([" exit ", "exit"]) - monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(answers, "exit")) - - runs: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = [] - closed: List[bool] = [] - - class _RecordingRunner: - """Runner fake that records every turn instead of calling a model.""" - - def __init__(self, *a: Any, **k: Any) -> None: - ... - - async def run_async(self, *a: Any, **k: Any): - runs.append((a, k)) - return - yield # keeps run_async an async generator for Aclosing - - async def close(self, *a: Any, **k: Any) -> None: - closed.append(True) - - monkeypatch.setattr(cli, "Runner", _RecordingRunner) + monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(answers)) + # capture assisted echo echoed: list[str] = [] monkeypatch.setattr(click, "echo", lambda msg: echoed.append(msg)) @@ -569,7 +551,5 @@ async def close(self, *a: Any, **k: Any) -> None: root_agent, artifact_service, sess, session_service, credential_service ) - # verify: no turn reached the runner, nothing was printed, runner closed - assert runs == [] + # verify: ' exit ' never reached the runner, so nothing was echoed assert echoed == [] - assert closed == [True]