From 7bc1dda13e6d358ea7c3030293c15d474156110d Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 15 Aug 2026 05:18:20 -0700 Subject: [PATCH 1/3] fix(cli): exit adk run cleanly when stdin reaches EOF A bare input() at the three adk run prompts let EOFError escape to the process boundary, so Ctrl-D printed a traceback, skipped runner.close() and lost the session that --save_session was meant to write. Each prompt now catches EOFError and ends the loop the way 'exit' does. The save prompt falls back to the live session id, so the session file is still written. --- src/google/adk/cli/cli.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/google/adk/cli/cli.py b/src/google/adk/cli/cli.py index c1d693b03c6..8863f2c924b 100644 --- a/src/google/adk/cli/cli.py +++ b/src/google/adk/cli/cli.py @@ -207,7 +207,13 @@ async def run_interactively( resume_invocation_id = None while True: if next_message is None: - query = input('[user]: ') + try: + query = input('[user]: ') + except EOFError: + # Ctrl-D (or an exhausted stdin) ends the session like 'exit', so + # cleanup and the save prompt still run. + click.echo() + break if not query or not query.strip(): continue if query == 'exit': @@ -256,10 +262,16 @@ async def run_and_print() -> None: # Handle each pending function call. If there are multiple, # collect all responses into a single Content with multiple parts. parts: list[types.Part] = [] - for fc_id, fc_name, args in pending: - response_content = _prompt_for_function_call(fc_id, fc_name, args) - if response_content.parts: - parts.extend(response_content.parts) + try: + for fc_id, fc_name, args in pending: + response_content = _prompt_for_function_call(fc_id, fc_name, args) + if response_content.parts: + parts.extend(response_content.parts) + except EOFError: + # Abandon the turn rather than answering only some of the pending + # calls, then leave the loop like 'exit' does. + click.echo() + break next_message = types.Content(role='user', parts=parts) resume_invocation_id = invocation_id @@ -517,7 +529,14 @@ async def run_cli( ) if save_session: - session_id = session_id or input('Session ID to save: ') + if not session_id: + try: + session_id = input('Session ID to save: ') + except EOFError: + # stdin is already exhausted on the Ctrl-D path; save under the live + # session id rather than losing the session. + click.echo() + session_id = session.id session_path = agent_root / f'{session_id}.session.json' # Fetch the session again to get all the details. From a56602b471cc581e1632347851f2e5e6882ed37b Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 15 Aug 2026 05:18:24 -0700 Subject: [PATCH 2/3] test(cli): cover EOF at the adk run, HITL and save prompts Five tests pin the graceful-exit path: the REPL prompt, the HITL prompt, the --save_session fallback, the --session_id short-circuit, and the unchanged behaviour of an answered HITL prompt. --- tests/unittests/cli/utils/test_cli.py | 227 ++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/tests/unittests/cli/utils/test_cli.py b/tests/unittests/cli/utils/test_cli.py index 479be61fb1c..dc197cd29b5 100644 --- a/tests/unittests/cli/utils/test_cli.py +++ b/tests/unittests/cli/utils/test_cli.py @@ -299,6 +299,55 @@ async def test_run_cli_save_session( assert "id" in data and "events" in data +@pytest.mark.asyncio +async def test_run_cli_save_session_falls_back_to_session_id_on_eof( + fake_agent, monkeypatch: pytest.MonkeyPatch +) -> None: + """An EOF at the save prompt should save under the live session id.""" + parent_dir, folder_name = fake_agent + + # Ctrl-D at the first prompt, so the save prompt also reads an empty stdin. + monkeypatch.setattr("builtins.input", mock.Mock(side_effect=EOFError)) + + 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, + session_id=None, + ) + + saved = list((Path(parent_dir) / folder_name).glob("*.session.json")) + assert len(saved) == 1 + data = json.loads(saved[0].read_text()) + assert saved[0].name == f"{data['id']}.session.json" + + +@pytest.mark.asyncio +async def test_run_cli_save_session_with_explicit_session_id_never_prompts( + fake_agent, monkeypatch: pytest.MonkeyPatch +) -> None: + """An explicit --session_id short-circuits the save prompt.""" + parent_dir, folder_name = fake_agent + + fake_input = mock.Mock(side_effect=EOFError) + monkeypatch.setattr("builtins.input", fake_input) + + 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, + session_id="sess123", + ) + + assert (Path(parent_dir) / folder_name / "sess123.session.json").exists() + # Only the REPL prompt reads stdin; the save prompt is never reached. + assert fake_input.call_count == 1 + + @pytest.mark.asyncio async def test_create_artifact_service_isolates_artifacts_per_agent( tmp_path: Path, @@ -525,3 +574,181 @@ 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) + + +def _eof_after(answers: List[str]): + """Returns an input() stand-in that replays `answers`, then raises EOFError.""" + replies = iter(answers) + + def _fake_input(*_a: Any, **_k: Any) -> str: + try: + return next(replies) + except StopIteration as e: + raise EOFError() from e + + return _fake_input + + +@pytest.mark.asyncio +async def test_run_interactively_exits_on_eof( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ctrl-D at the prompt should leave the loop and close the runner.""" + session_service = InMemorySessionService() + sess = await session_service.create_session(app_name="dummy", user_id="u") + + # fake user input: 'hello' -> Ctrl-D + monkeypatch.setattr("builtins.input", _eof_after(["hello"])) + + closed = {"value": False} + + class _ClosingRunner(cli.Runner): + """Fake runner that records whether cleanup ran.""" + + async def close(self, *a: Any, **k: Any) -> None: + closed["value"] = True + + monkeypatch.setattr(cli, "Runner", _ClosingRunner) + + echoed: List[str] = [] + monkeypatch.setattr( + click, "echo", lambda *a, **k: echoed.append(a[0] if a else "") + ) + + await cli.run_interactively( + BaseAgent(name="root"), + InMemoryArtifactService(), + sess, + session_service, + InMemoryCredentialService(), + ) + + assert sum("echo:hello" in m for m in echoed) == 1 + assert closed["value"] is True + + +@pytest.mark.asyncio +async def test_run_interactively_exits_on_eof_at_hitl_prompt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ctrl-D at a HITL prompt should abandon the turn, not re-dispatch it.""" + session_service = InMemorySessionService() + sess = await session_service.create_session(app_name="dummy", user_id="u") + + # fake user input: 'hello' -> Ctrl-D at the HITL prompt that follows. + monkeypatch.setattr("builtins.input", _eof_after(["hello"])) + + runs: List[Any] = [] + closed: List[bool] = [] + + class _HitlRunner: + """Fake runner whose every turn requests human input.""" + + def __init__(self, *a: Any, **k: Any) -> None: + ... + + async def run_async(self, *a: Any, **k: Any): + runs.append(k) + part = types.SimpleNamespace( + text=None, + function_call=types.SimpleNamespace( + id="fc1", name="adk_request_input", args={"message": "hi"} + ), + ) + yield types.SimpleNamespace( + author="assistant", + content=types.SimpleNamespace(role="assistant", parts=[part]), + node_info=None, + long_running_tool_ids=["fc1"], + ) + + async def close(self, *a: Any, **k: Any) -> None: + closed.append(True) + + monkeypatch.setattr(cli, "Runner", _HitlRunner) + + await cli.run_interactively( + BaseAgent(name="root"), + InMemoryArtifactService(), + sess, + session_service, + InMemoryCredentialService(), + ) + + # The abandoned turn is never sent back to the runner. + assert len(runs) == 1 + assert closed == [True] + + +@pytest.mark.asyncio +async def test_run_interactively_answers_hitl_prompt_then_exits_on_eof( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An answered HITL prompt still resumes the turn; EOF only ends the loop.""" + session_service = InMemorySessionService() + sess = await session_service.create_session(app_name="dummy", user_id="u") + + # fake user input: 'hello' -> 'yes' at the HITL prompt -> Ctrl-D + monkeypatch.setattr("builtins.input", _eof_after(["hello", "yes"])) + + class _Part: + + def __init__(self, text: Any = None, function_response: Any = None) -> None: + self.text = text + self.function_response = function_response + + monkeypatch.setattr(cli.types, "Part", _Part) + + runs: List[Any] = [] + closed: List[bool] = [] + + class _OneHitlTurnRunner: + """Fake runner that requests human input on its first turn only.""" + + def __init__(self, *a: Any, **k: Any) -> None: + ... + + async def run_async(self, *a: Any, **k: Any): + runs.append(k["new_message"]) + if len(runs) > 1: + yield types.SimpleNamespace( + author="assistant", + content=types.SimpleNamespace( + role="assistant", + parts=[types.SimpleNamespace(text="done", function_call=None)], + ), + node_info=None, + long_running_tool_ids=[], + ) + return + part = types.SimpleNamespace( + text=None, + function_call=types.SimpleNamespace( + id="fc1", name="adk_request_input", args={"message": "hi"} + ), + ) + yield types.SimpleNamespace( + author="assistant", + invocation_id="inv1", + content=types.SimpleNamespace(role="assistant", parts=[part]), + node_info=None, + long_running_tool_ids=["fc1"], + ) + + async def close(self, *a: Any, **k: Any) -> None: + closed.append(True) + + monkeypatch.setattr(cli, "Runner", _OneHitlTurnRunner) + + await cli.run_interactively( + BaseAgent(name="root"), + InMemoryArtifactService(), + sess, + session_service, + InMemoryCredentialService(), + ) + + # The answer is dispatched as a second turn carrying the function response. + assert len(runs) == 2 + assert runs[1].parts[0].function_response.response == {"result": "yes"} + assert closed == [True] From cbdb0fcab07af9f71f6301551963b109aada1744 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Sat, 15 Aug 2026 05:36:17 -0700 Subject: [PATCH 3/3] test(cli): use mock.Mock side_effect for the scripted EOF inputs mock.Mock already raises an exception class it finds in a side_effect iterable, so the hand-rolled iterator helper was redundant and made the file inconsistent with the two call sites that already used the idiom. The fixture's fake Part now carries function_response, so the HITL test no longer shadows it with a local class. --- tests/unittests/cli/utils/test_cli.py | 38 +++++++++------------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/tests/unittests/cli/utils/test_cli.py b/tests/unittests/cli/utils/test_cli.py index dc197cd29b5..aab80184ef2 100644 --- a/tests/unittests/cli/utils/test_cli.py +++ b/tests/unittests/cli/utils/test_cli.py @@ -65,8 +65,11 @@ def _patch_types_and_runner(monkeypatch: pytest.MonkeyPatch) -> None: # Dummy Part / Content class _Part: - def __init__(self, text: str | None = "") -> None: + def __init__( + self, text: str | None = "", function_response: Any = None + ) -> None: self.text = text + self.function_response = function_response class _Content: @@ -576,19 +579,6 @@ async def test_run_interactively_whitespace_and_exit( assert any("echo:hello" in m for m in echoed) -def _eof_after(answers: List[str]): - """Returns an input() stand-in that replays `answers`, then raises EOFError.""" - replies = iter(answers) - - def _fake_input(*_a: Any, **_k: Any) -> str: - try: - return next(replies) - except StopIteration as e: - raise EOFError() from e - - return _fake_input - - @pytest.mark.asyncio async def test_run_interactively_exits_on_eof( monkeypatch: pytest.MonkeyPatch, @@ -598,7 +588,9 @@ async def test_run_interactively_exits_on_eof( sess = await session_service.create_session(app_name="dummy", user_id="u") # fake user input: 'hello' -> Ctrl-D - monkeypatch.setattr("builtins.input", _eof_after(["hello"])) + monkeypatch.setattr( + "builtins.input", mock.Mock(side_effect=["hello", EOFError]) + ) closed = {"value": False} @@ -636,7 +628,9 @@ async def test_run_interactively_exits_on_eof_at_hitl_prompt( sess = await session_service.create_session(app_name="dummy", user_id="u") # fake user input: 'hello' -> Ctrl-D at the HITL prompt that follows. - monkeypatch.setattr("builtins.input", _eof_after(["hello"])) + monkeypatch.setattr( + "builtins.input", mock.Mock(side_effect=["hello", EOFError]) + ) runs: List[Any] = [] closed: List[bool] = [] @@ -689,15 +683,9 @@ async def test_run_interactively_answers_hitl_prompt_then_exits_on_eof( sess = await session_service.create_session(app_name="dummy", user_id="u") # fake user input: 'hello' -> 'yes' at the HITL prompt -> Ctrl-D - monkeypatch.setattr("builtins.input", _eof_after(["hello", "yes"])) - - class _Part: - - def __init__(self, text: Any = None, function_response: Any = None) -> None: - self.text = text - self.function_response = function_response - - monkeypatch.setattr(cli.types, "Part", _Part) + monkeypatch.setattr( + "builtins.input", mock.Mock(side_effect=["hello", "yes", EOFError]) + ) runs: List[Any] = [] closed: List[bool] = []