From fe24e722ca6dd25f6a595d052c0e03aad62a7745 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 17 Jan 2026 09:25:15 +0530 Subject: [PATCH 01/25] fix(web): allow session resume without new message (#4100) Signed-off-by: Akshat Kumar --- src/google/adk/cli/adk_web_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index b97932d042..570da8a97d 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -202,7 +202,7 @@ class RunAgentRequest(common.BaseModel): app_name: str user_id: str session_id: str - new_message: types.Content + new_message: Optional[types.Content] = None streaming: bool = False state_delta: Optional[dict[str, Any]] = None # for resume long-running functions From 1f4f0aa049923bb2418e62eeb4b26446daae05ec Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 17 Jan 2026 10:14:50 +0530 Subject: [PATCH 02/25] fix(web): adds a unit test verifying that the /run endpoint accepts requests for session resumption without providing new content. Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 0c69605349..904492a6fe 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1411,5 +1411,29 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists() +def test_agent_run_resume_without_message(test_app, create_test_session): + """Test that /run allows resuming a session without providing a new message.""" + info = create_test_session + url = "/run" + # Note: 'new_message' is intentionally OMITTED here to test the fix + payload = { + "app_name": info["app_name"], + "user_id": info["user_id"], + "session_id": info["session_id"], + "streaming": False, + } + + response = test_app.post(url, json=payload) + + # Before your fix, this would return 422 (Validation Error) + # Now it should return 200 (Success) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Our mock runner (dummy_run_async) returns 3 events by default + assert len(data) == 3 + assert data[0]["author"] == "dummy agent" + + logger.info("Agent run resume without message test passed") if __name__ == "__main__": pytest.main(["-xvs", __file__]) From 929fe2e98f8d53bbefc0f7da1456c2d582fd4b26 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 17 Jan 2026 11:07:58 +0530 Subject: [PATCH 03/25] fix: allow agent session resumption without new message Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 8 +++----- tests/unittests/cli/test_fast_api.py | 26 +++++++++++++++----------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 3aaa54e257..730f489fae 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -498,11 +498,9 @@ async def _run_with_trace( user_id=user_id, session_id=session_id ) if not invocation_id and not new_message: - raise ValueError( - 'Running an agent requires either a new_message or an ' - 'invocation_id to resume a previous invocation. ' - f'Session: {session_id}, User: {user_id}' - ) + # If nothing is provided, this is a no-op resume. We return early + # without yielding any events. + return if invocation_id: if ( diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 904492a6fe..236dccf553 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1411,11 +1411,19 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists() -def test_agent_run_resume_without_message(test_app, create_test_session): +def test_agent_run_resume_without_message(test_app, create_test_session, monkeypatch): """Test that /run allows resuming a session without providing a new message.""" info = create_test_session url = "/run" - # Note: 'new_message' is intentionally OMITTED here to test the fix + + # We simulate the NEW behavior of the real runner (returning no events) + async def mock_run_empty(*args, **kwargs): + if False: yield # Tells Python this is an async generator + return + + # Apply the mock to the Runner class + monkeypatch.setattr(Runner, "run_async", mock_run_empty) + payload = { "app_name": info["app_name"], "user_id": info["user_id"], @@ -1425,15 +1433,11 @@ def test_agent_run_resume_without_message(test_app, create_test_session): response = test_app.post(url, json=payload) - # Before your fix, this would return 422 (Validation Error) - # Now it should return 200 (Success) + # Verify the web server accepts it and the runner returns a clean empty list assert response.status_code == 200 - data = response.json() - assert isinstance(data, list) - # Our mock runner (dummy_run_async) returns 3 events by default - assert len(data) == 3 - assert data[0]["author"] == "dummy agent" - - logger.info("Agent run resume without message test passed") + assert response.json() == [] + + logger.info("Agent run resume without message test passed gracefully") + if __name__ == "__main__": pytest.main(["-xvs", __file__]) From 1afb89aac3e9283433b8e9a9ffb6bd3c58fb19a9 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 17 Jan 2026 11:49:29 +0530 Subject: [PATCH 04/25] Style: cleanup unit test by removing unnecessary logging and blank lines Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 236dccf553..650ffd11fb 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1415,10 +1415,9 @@ def test_agent_run_resume_without_message(test_app, create_test_session, monkeyp """Test that /run allows resuming a session without providing a new message.""" info = create_test_session url = "/run" - # We simulate the NEW behavior of the real runner (returning no events) async def mock_run_empty(*args, **kwargs): - if False: yield # Tells Python this is an async generator + if False: yield # Tells Python this is an async generator return # Apply the mock to the Runner class @@ -1437,7 +1436,5 @@ async def mock_run_empty(*args, **kwargs): assert response.status_code == 200 assert response.json() == [] - logger.info("Agent run resume without message test passed gracefully") - if __name__ == "__main__": pytest.main(["-xvs", __file__]) From d10c1d319b7b43bfc8bb3244d37525f553227801 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 17 Jan 2026 12:13:21 +0530 Subject: [PATCH 05/25] fix: add observability logging for no-op resume and cleanup tests Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 6 ++++++ tests/unittests/cli/test_fast_api.py | 10 ++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 730f489fae..cb57b821e5 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -497,7 +497,13 @@ async def _run_with_trace( session = await self._get_or_create_session( user_id=user_id, session_id=session_id ) + if not invocation_id and not new_message: + logger.info( + 'Performing no-op resume for session %s: no new_message or ' + 'invocation_id.', + session_id, + ) # If nothing is provided, this is a no-op resume. We return early # without yielding any events. return diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 650ffd11fb..0565a04a9b 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1415,26 +1415,20 @@ def test_agent_run_resume_without_message(test_app, create_test_session, monkeyp """Test that /run allows resuming a session without providing a new message.""" info = create_test_session url = "/run" - # We simulate the NEW behavior of the real runner (returning no events) async def mock_run_empty(*args, **kwargs): - if False: yield # Tells Python this is an async generator + if False: yield return - # Apply the mock to the Runner class monkeypatch.setattr(Runner, "run_async", mock_run_empty) - payload = { "app_name": info["app_name"], "user_id": info["user_id"], "session_id": info["session_id"], "streaming": False, } - response = test_app.post(url, json=payload) - - # Verify the web server accepts it and the runner returns a clean empty list assert response.status_code == 200 assert response.json() == [] - + if __name__ == "__main__": pytest.main(["-xvs", __file__]) From 00b0f203fbb7662667196ccc51bd82a5564d9f4c Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 17 Jan 2026 12:32:35 +0530 Subject: [PATCH 06/25] fix: align dummy runner logic with real runner and update docstring Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 3 +-- tests/unittests/cli/test_fast_api.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index cb57b821e5..9a871f0945 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -481,8 +481,7 @@ async def run_async( The events generated by the agent. Raises: - ValueError: If the session is not found; If both invocation_id and - new_message are None. + If both invocation_id and new_message are None, a no-op resume is performed. """ run_config = run_config or RunConfig() diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 0565a04a9b..6e05114d38 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -132,6 +132,10 @@ async def dummy_run_async( run_config: Optional[RunConfig] = None, invocation_id: Optional[str] = None, ): + + if not invocation_id and not new_message: + return + run_config = run_config or RunConfig() yield _event_1() await asyncio.sleep(0) @@ -1411,22 +1415,21 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists() -def test_agent_run_resume_without_message(test_app, create_test_session, monkeypatch): +def test_agent_run_resume_without_message(test_app, create_test_session): """Test that /run allows resuming a session without providing a new message.""" info = create_test_session url = "/run" - async def mock_run_empty(*args, **kwargs): - if False: yield - return - - monkeypatch.setattr(Runner, "run_async", mock_run_empty) + # We no longer mock the runner. This tests the real logic in runners.py payload = { "app_name": info["app_name"], "user_id": info["user_id"], "session_id": info["session_id"], "streaming": False, } + response = test_app.post(url, json=payload) + + # Verify the web server and real runner work together to return success assert response.status_code == 200 assert response.json() == [] From 29879319f073c83e0da18dd547ee707956b9a8b2 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 17 Jan 2026 12:38:16 +0530 Subject: [PATCH 07/25] docs: remove misleading comment in unit test per bot feedback Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 2 +- tests/unittests/cli/test_fast_api.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 9a871f0945..f9e5b4eb0d 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -481,7 +481,7 @@ async def run_async( The events generated by the agent. Raises: - If both invocation_id and new_message are None, a no-op resume is performed. + ValueError: If the session is not found and auto-creation is disabled. """ run_config = run_config or RunConfig() diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 6e05114d38..34bb72b363 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1419,7 +1419,6 @@ def test_agent_run_resume_without_message(test_app, create_test_session): """Test that /run allows resuming a session without providing a new message.""" info = create_test_session url = "/run" - # We no longer mock the runner. This tests the real logic in runners.py payload = { "app_name": info["app_name"], "user_id": info["user_id"], @@ -1429,7 +1428,7 @@ def test_agent_run_resume_without_message(test_app, create_test_session): response = test_app.post(url, json=payload) - # Verify the web server and real runner work together to return success + # Verify the web server and dummy runner work together to return success assert response.status_code == 200 assert response.json() == [] From c2511c597df6641de7a03bb561cf60aa91eacae8 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 18 Jan 2026 22:11:21 +0530 Subject: [PATCH 08/25] fix: handle state_delta warning in no-op resume and cleanup dummy runner Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 12 +++++++++--- tests/unittests/cli/test_fast_api.py | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index f9e5b4eb0d..0b11f5285a 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -498,10 +498,16 @@ async def _run_with_trace( ) if not invocation_id and not new_message: + if state_delta: + logger.warning( + 'state_delta provided without new_message or invocation_id for ' + 'session %s. The state_delta will be ignored.', + session_id, + ) logger.info( - 'Performing no-op resume for session %s: no new_message or ' - 'invocation_id.', - session_id, + 'Performing no-op resume for session %s: no new_message or ' + 'invocation_id.', + session_id, ) # If nothing is provided, this is a no-op resume. We return early # without yielding any events. diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 34bb72b363..c0ade12b3a 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -134,6 +134,8 @@ async def dummy_run_async( ): if not invocation_id and not new_message: + if state_delta: + logger.warning("state_delta ignored in no-op resume") return run_config = run_config or RunConfig() From ac9f90447cbc39d463f16e45b059b4eae4fe47e9 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Wed, 21 Jan 2026 19:21:53 +0530 Subject: [PATCH 09/25] fix: the formatting Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index c0ade12b3a..1abd14e2ad 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -132,7 +132,7 @@ async def dummy_run_async( run_config: Optional[RunConfig] = None, invocation_id: Optional[str] = None, ): - + if not invocation_id and not new_message: if state_delta: logger.warning("state_delta ignored in no-op resume") @@ -1434,5 +1434,6 @@ def test_agent_run_resume_without_message(test_app, create_test_session): assert response.status_code == 200 assert response.json() == [] + if __name__ == "__main__": pytest.main(["-xvs", __file__]) From 1fdf7efd15f070c82937671093e6383ad9d9b304 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sat, 24 Jan 2026 23:16:50 +0530 Subject: [PATCH 10/25] fix: finalize session resumption logic and test coverage Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 3 ++- tests/unittests/cli/test_fast_api.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 0b11f5285a..8a0203bbb9 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -481,7 +481,8 @@ async def run_async( The events generated by the agent. Raises: - ValueError: If the session is not found and auto-creation is disabled. + None: if both invocation_id and new_message are None, a no-op resume + is performed. """ run_config = run_config or RunConfig() diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 1abd14e2ad..bd8d1879a6 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1434,6 +1434,27 @@ def test_agent_run_resume_without_message(test_app, create_test_session): assert response.status_code == 200 assert response.json() == [] +def test_agent_run_resume_without_message_with_state_delta( + test_app, create_test_session, caplog +): + """Test that /run with no message ignores state_delta and logs a warning.""" + info = create_test_session + url = "/run" + payload = { + "app_name": info["app_name"], + "user_id": info["user_id"], + "session_id": info["session_id"], + "streaming": False, + "state_delta": {"some_key": "some_value"}, + } + + caplog.set_level(logging.WARNING) + response = test_app.post(url, json=payload) + + assert response.status_code == 200 + assert response.json() == [] + # Verifies the warning you added to runners.py + assert "state_delta ignored in no-op resume" in caplog.text if __name__ == "__main__": pytest.main(["-xvs", __file__]) From 5af99c7a0e5be4202c5300c831fb3551a1c09f4b Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 00:31:27 +0530 Subject: [PATCH 11/25] fix: align mock warning with production and update docstring Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 3 +-- tests/unittests/cli/test_fast_api.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 8a0203bbb9..057df62563 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -481,8 +481,7 @@ async def run_async( The events generated by the agent. Raises: - None: if both invocation_id and new_message are None, a no-op resume - is performed. + ValueError: If the session is not found and `auto_create_session` is False. """ run_config = run_config or RunConfig() diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index bd8d1879a6..70ddc0a4d0 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -135,7 +135,11 @@ async def dummy_run_async( if not invocation_id and not new_message: if state_delta: - logger.warning("state_delta ignored in no-op resume") + logger.warning( + "state_delta provided without new_message or invocation_id for " + "session %s. The state_delta will be ignored.", + session_id, + ) return run_config = run_config or RunConfig() @@ -1434,6 +1438,7 @@ def test_agent_run_resume_without_message(test_app, create_test_session): assert response.status_code == 200 assert response.json() == [] + def test_agent_run_resume_without_message_with_state_delta( test_app, create_test_session, caplog ): @@ -1454,7 +1459,10 @@ def test_agent_run_resume_without_message_with_state_delta( assert response.status_code == 200 assert response.json() == [] # Verifies the warning you added to runners.py - assert "state_delta ignored in no-op resume" in caplog.text + assert ( + "state_delta provided without new_message or invocation_id" in caplog.text + ) + if __name__ == "__main__": pytest.main(["-xvs", __file__]) From e3ed2692cd177e5ed9d63b2613a5eca91acc26aa Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 00:40:08 +0530 Subject: [PATCH 12/25] test: improve robustness of no-op resume warning assertion Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 70ddc0a4d0..9321ba8b1e 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1458,9 +1458,13 @@ def test_agent_run_resume_without_message_with_state_delta( assert response.status_code == 200 assert response.json() == [] - # Verifies the warning you added to runners.py + + # Robust log verification as requested by the code review + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "WARNING" assert ( - "state_delta provided without new_message or invocation_id" in caplog.text + "state_delta provided without new_message or invocation_id" + in caplog.records[0].message ) From d8d340148607d8172f1777e6f3ef72d117a8b4ac Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 00:46:08 +0530 Subject: [PATCH 13/25] test: improve robustness of no-op resume warning assertion Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 9321ba8b1e..b636ec4c9e 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -140,6 +140,11 @@ async def dummy_run_async( "session %s. The state_delta will be ignored.", session_id, ) + logger.info( + "Performing no-op resume for session %s: no new_message or " + "invocation_id.", + session_id, + ) return run_config = run_config or RunConfig() From c5ac29afb5ed9e38b0c29de423a9c848c4789d81 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 01:42:27 +0530 Subject: [PATCH 14/25] fix: implement graceful session resumption and separate test concerns Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 14 +++-------- tests/unittests/test_runners.py | 37 +++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index b636ec4c9e..dd453022d5 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1445,9 +1445,9 @@ def test_agent_run_resume_without_message(test_app, create_test_session): def test_agent_run_resume_without_message_with_state_delta( - test_app, create_test_session, caplog + test_app, create_test_session ): - """Test that /run with no message ignores state_delta and logs a warning.""" + """Test that /run with no message accepts the request even with state_delta.""" info = create_test_session url = "/run" payload = { @@ -1458,20 +1458,12 @@ def test_agent_run_resume_without_message_with_state_delta( "state_delta": {"some_key": "some_value"}, } - caplog.set_level(logging.WARNING) response = test_app.post(url, json=payload) + # Only verify the HTTP layer (FastAPI accepts the request) assert response.status_code == 200 assert response.json() == [] - # Robust log verification as requested by the code review - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "WARNING" - assert ( - "state_delta provided without new_message or invocation_id" - in caplog.records[0].message - ) - if __name__ == "__main__": pytest.main(["-xvs", __file__]) diff --git a/tests/unittests/test_runners.py b/tests/unittests/test_runners.py index 62b8d7334b..a9a0a4bce8 100644 --- a/tests/unittests/test_runners.py +++ b/tests/unittests/test_runners.py @@ -11,13 +11,14 @@ # 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 importlib +import logging from pathlib import Path import sys import textwrap from typing import AsyncGenerator from typing import Optional +from unittest import mock from unittest.mock import AsyncMock from google.adk.agents.base_agent import BaseAgent @@ -1321,5 +1322,39 @@ def test_infer_agent_origin_detects_mismatch_for_user_agent( assert "actual_name" in runner._app_name_alignment_hint +@pytest.mark.asyncio +async def test_run_async_no_op_resume_logging(caplog): + """Verifies that the actual Runner logic logs a warning when state_delta is ignored.""" + from google.adk.runners import Runner + + # 1. Setup dependencies + mock_agent = mock.MagicMock() + mock_agent.name = "test_agent" + + # Added app_name="test_app" to satisfy validation + runner = Runner( + app_name="test_app", + agent=mock_agent, + session_service=mock.AsyncMock(), + ) + + # 2. Capture logs while running the actual logic in runners.py + with caplog.at_level(logging.WARNING): + # Call run_async without message or invocation_id, but WITH state_delta + async for _ in runner.run_async( + user_id="user", + session_id="session", + new_message=None, + state_delta={"test": 1}, + ): + pass + + # 3. This verifies the REAL warning logic in src/google/adk/runners.py + assert any( + "state_delta provided without new_message" in r.message + for r in caplog.records + ) + + if __name__ == "__main__": pytest.main([__file__]) From a397ea3a54fbacc4fa0a4bd9b9a9f111c42b848c Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 02:06:36 +0530 Subject: [PATCH 15/25] refactor: use targeted mocks for session resumption tests per bot feedback Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index dd453022d5..b01b249d75 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1426,8 +1426,19 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists() -def test_agent_run_resume_without_message(test_app, create_test_session): +async def _noop_run_async(*args, **kwargs): + """A mock that does nothing and yields no events for no-op resume tests.""" + if False: + yield + + +def test_agent_run_resume_without_message( + test_app, create_test_session, monkeypatch +): """Test that /run allows resuming a session without providing a new message.""" + # Override the global mock with a specific no-op mock for this test + monkeypatch.setattr("google.adk.runners.Runner.run_async", _noop_run_async) + info = create_test_session url = "/run" payload = { @@ -1439,15 +1450,18 @@ def test_agent_run_resume_without_message(test_app, create_test_session): response = test_app.post(url, json=payload) - # Verify the web server and dummy runner work together to return success + # Verify the web server handles the empty message and returns success assert response.status_code == 200 assert response.json() == [] def test_agent_run_resume_without_message_with_state_delta( - test_app, create_test_session + test_app, create_test_session, monkeypatch ): """Test that /run with no message accepts the request even with state_delta.""" + # Override the global mock with a specific no-op mock for this test + monkeypatch.setattr("google.adk.runners.Runner.run_async", _noop_run_async) + info = create_test_session url = "/run" payload = { From 222cd2d7f2fe62170154654a264f46762543ba94 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 02:20:17 +0530 Subject: [PATCH 16/25] refactor: remove logic duplication in global mock per bot feedback Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index b01b249d75..9baf95035c 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -132,21 +132,7 @@ async def dummy_run_async( run_config: Optional[RunConfig] = None, invocation_id: Optional[str] = None, ): - - if not invocation_id and not new_message: - if state_delta: - logger.warning( - "state_delta provided without new_message or invocation_id for " - "session %s. The state_delta will be ignored.", - session_id, - ) - logger.info( - "Performing no-op resume for session %s: no new_message or " - "invocation_id.", - session_id, - ) - return - + run_config = run_config or RunConfig() yield _event_1() await asyncio.sleep(0) From 1faac6f27f354bc81df819fa58d6bf7297049e22 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 02:31:04 +0530 Subject: [PATCH 17/25] refactor: use parameterized tests for session resumption scenarios Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 39 +++++++++------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 9baf95035c..df940bea35 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -132,7 +132,7 @@ async def dummy_run_async( run_config: Optional[RunConfig] = None, invocation_id: Optional[str] = None, ): - + run_config = run_config or RunConfig() yield _event_1() await asyncio.sleep(0) @@ -1418,8 +1418,16 @@ async def _noop_run_async(*args, **kwargs): yield +@pytest.mark.parametrize( + "extra_payload", + [ + {}, + {"state_delta": {"some_key": "some_value"}}, + ], + ids=["no_state_delta", "with_state_delta"], +) def test_agent_run_resume_without_message( - test_app, create_test_session, monkeypatch + test_app, create_test_session, monkeypatch, extra_payload ): """Test that /run allows resuming a session without providing a new message.""" # Override the global mock with a specific no-op mock for this test @@ -1432,35 +1440,12 @@ def test_agent_run_resume_without_message( "user_id": info["user_id"], "session_id": info["session_id"], "streaming": False, + **extra_payload, } response = test_app.post(url, json=payload) - # Verify the web server handles the empty message and returns success - assert response.status_code == 200 - assert response.json() == [] - - -def test_agent_run_resume_without_message_with_state_delta( - test_app, create_test_session, monkeypatch -): - """Test that /run with no message accepts the request even with state_delta.""" - # Override the global mock with a specific no-op mock for this test - monkeypatch.setattr("google.adk.runners.Runner.run_async", _noop_run_async) - - info = create_test_session - url = "/run" - payload = { - "app_name": info["app_name"], - "user_id": info["user_id"], - "session_id": info["session_id"], - "streaming": False, - "state_delta": {"some_key": "some_value"}, - } - - response = test_app.post(url, json=payload) - - # Only verify the HTTP layer (FastAPI accepts the request) + # Verify the web server handles the request and returns success assert response.status_code == 200 assert response.json() == [] From 74bb3dbd7f2712170b3db50ee37263f6ac9982ef Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Sun, 25 Jan 2026 02:36:15 +0530 Subject: [PATCH 18/25] style: use explicit empty loop for async generator mock per bot feedback Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index df940bea35..58e4307c13 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1414,8 +1414,8 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): async def _noop_run_async(*args, **kwargs): """A mock that does nothing and yields no events for no-op resume tests.""" - if False: - yield + for item in []: + yield item @pytest.mark.parametrize( From cfbbd307aef3c9688995da96471a75d589dee39f Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 30 Jan 2026 18:59:48 +0530 Subject: [PATCH 19/25] fix: resolve web server validation and 12+ core mypy errors Signed-off-by: Akshat Kumar --- src/google/adk/cli/adk_web_server.py | 10 ++++---- src/google/adk/runners.py | 20 +++++++-------- tests/unittests/cli/test_fast_api.py | 14 +++-------- tests/unittests/test_runners.py | 37 +--------------------------- 4 files changed, 20 insertions(+), 61 deletions(-) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index 570da8a97d..8ee8e2b2a6 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -369,7 +369,7 @@ def _otel_env_vars_enabled() -> bool: def _setup_gcp_telemetry( - internal_exporters: list[SpanProcessor] = None, + internal_exporters: list[SpanProcessor] | None = None, ): if typing.TYPE_CHECKING: from ..telemetry.setup import OTelHooks @@ -411,7 +411,7 @@ def _setup_gcp_telemetry( def _setup_telemetry_from_env( - internal_exporters: list[SpanProcessor] = None, + internal_exporters: list[SpanProcessor] | None = None, ): from ..telemetry.setup import maybe_set_otel_providers @@ -507,7 +507,7 @@ def __init__( # Internal properties we want to allow being modified from callbacks. self.runners_to_clean: set[str] = set() self.current_app_name_ref: SharedValue[str] = SharedValue(value="") - self.runner_dict = {} + self.runner_dict: dict[str, Runner] = {} self.url_prefix = url_prefix async def get_runner_async(self, app_name: str) -> Runner: @@ -707,8 +707,8 @@ def get_fast_api_app( A FastAPI app instance. """ # Properties we don't need to modify from callbacks - trace_dict = {} - session_trace_dict = {} + trace_dict: dict[str, Any] = {} + session_trace_dict: dict[str, list[int]] = {} # Set up a file system watcher to detect changes in the agents directory. observer = Observer() setup_observer(observer, self) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 057df62563..a27b0b9b4f 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -23,6 +23,7 @@ from typing import Any from typing import AsyncGenerator from typing import Callable +from typing import cast from typing import Generator from typing import List from typing import Optional @@ -414,7 +415,7 @@ def run( The events generated by the agent. """ run_config = run_config or RunConfig() - event_queue = queue.Queue() + event_queue: asyncio.Queue[Event] = asyncio.Queue() async def _invoke_run_async(): try: @@ -498,12 +499,11 @@ async def _run_with_trace( ) if not invocation_id and not new_message: - if state_delta: - logger.warning( - 'state_delta provided without new_message or invocation_id for ' - 'session %s. The state_delta will be ignored.', - session_id, - ) + raise ValueError( + 'Running an agent requires either a new_message or an ' + 'invocation_id to resume a previous invocation. ' + f'Session: {session_id}, User: {user_id}' + ) logger.info( 'Performing no-op resume for session %s: no new_message or ' 'invocation_id.', @@ -1011,7 +1011,7 @@ async def run_live( ) if not session: session = await self._get_or_create_session( - user_id=user_id, session_id=session_id + user_id=cast(str, user_id), session_id=cast(str, session_id) ) invocation_context = self._new_invocation_context_for_live( session, @@ -1330,7 +1330,7 @@ async def _setup_context_for_resumed_invocation( # Step 1: Maybe retrieve a previous user message for the invocation. user_message = new_message or self._find_user_message_for_invocation( - session.events, invocation_id + session.events, cast(str, invocation_id) ) if not user_message: raise ValueError( @@ -1548,7 +1548,7 @@ async def close(self): else: from typing import Self # pylint: disable=g-import-not-at-top - async def __aenter__(self) -> Self: + async def __aenter__(self) -> 'Runner': """Async context manager entry.""" return self diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 58e4307c13..ea4daf1271 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1426,28 +1426,22 @@ async def _noop_run_async(*args, **kwargs): ], ids=["no_state_delta", "with_state_delta"], ) -def test_agent_run_resume_without_message( - test_app, create_test_session, monkeypatch, extra_payload +def test_agent_run_resume_without_message_success( + test_app, create_test_session, extra_payload ): - """Test that /run allows resuming a session without providing a new message.""" - # Override the global mock with a specific no-op mock for this test - monkeypatch.setattr("google.adk.runners.Runner.run_async", _noop_run_async) - + """Test that /run allows resuming a session with only an invocation_id.""" info = create_test_session url = "/run" payload = { "app_name": info["app_name"], "user_id": info["user_id"], "session_id": info["session_id"], + "invocation_id": "test_invocation_id", "streaming": False, **extra_payload, } - response = test_app.post(url, json=payload) - - # Verify the web server handles the request and returns success assert response.status_code == 200 - assert response.json() == [] if __name__ == "__main__": diff --git a/tests/unittests/test_runners.py b/tests/unittests/test_runners.py index a9a0a4bce8..62b8d7334b 100644 --- a/tests/unittests/test_runners.py +++ b/tests/unittests/test_runners.py @@ -11,14 +11,13 @@ # 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 importlib -import logging from pathlib import Path import sys import textwrap from typing import AsyncGenerator from typing import Optional -from unittest import mock from unittest.mock import AsyncMock from google.adk.agents.base_agent import BaseAgent @@ -1322,39 +1321,5 @@ def test_infer_agent_origin_detects_mismatch_for_user_agent( assert "actual_name" in runner._app_name_alignment_hint -@pytest.mark.asyncio -async def test_run_async_no_op_resume_logging(caplog): - """Verifies that the actual Runner logic logs a warning when state_delta is ignored.""" - from google.adk.runners import Runner - - # 1. Setup dependencies - mock_agent = mock.MagicMock() - mock_agent.name = "test_agent" - - # Added app_name="test_app" to satisfy validation - runner = Runner( - app_name="test_app", - agent=mock_agent, - session_service=mock.AsyncMock(), - ) - - # 2. Capture logs while running the actual logic in runners.py - with caplog.at_level(logging.WARNING): - # Call run_async without message or invocation_id, but WITH state_delta - async for _ in runner.run_async( - user_id="user", - session_id="session", - new_message=None, - state_delta={"test": 1}, - ): - pass - - # 3. This verifies the REAL warning logic in src/google/adk/runners.py - assert any( - "state_delta provided without new_message" in r.message - for r in caplog.records - ) - - if __name__ == "__main__": pytest.main([__file__]) From f496242899605b01e653a44ba532255e524d12c4 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 30 Jan 2026 19:59:25 +0530 Subject: [PATCH 20/25] fix: resolve mypy type mismatches in test_fast_api.py Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index ea4daf1271..af5963aaec 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -155,9 +155,9 @@ class _MockEvalCaseResult(BaseModel): user_id: str session_id: str eval_set_file: str - eval_metric_results: list = {} - overall_eval_metric_results: list = ({},) - eval_metric_result_per_invocation: list = {} + eval_metric_results: list = [] + overall_eval_metric_results: list = [] + eval_metric_result_per_invocation: list = [] ################################################# From 8de205a53da8d903bb6edf866d56d2184dcb50a1 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 30 Jan 2026 20:28:03 +0530 Subject: [PATCH 21/25] fix: revert no-op logic and resolve remaining mypy errors in runners.py Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index a27b0b9b4f..32870f6d74 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -495,7 +495,7 @@ async def _run_with_trace( ) -> AsyncGenerator[Event, None]: with tracer.start_as_current_span('invocation'): session = await self._get_or_create_session( - user_id=user_id, session_id=session_id + user_id=cast(str, user_id), session_id=cast(str, session_id) ) if not invocation_id and not new_message: @@ -504,14 +504,6 @@ async def _run_with_trace( 'invocation_id to resume a previous invocation. ' f'Session: {session_id}, User: {user_id}' ) - logger.info( - 'Performing no-op resume for session %s: no new_message or ' - 'invocation_id.', - session_id, - ) - # If nothing is provided, this is a no-op resume. We return early - # without yielding any events. - return if invocation_id: if ( From 892834eada895531f96934a052ff5e5dd261a15b Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 30 Jan 2026 20:34:47 +0530 Subject: [PATCH 22/25] fix: revert to thread-safe queue and remove unreachable logic per review Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 2 +- tests/unittests/cli/test_fast_api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 32870f6d74..d5c41659c8 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -415,7 +415,7 @@ def run( The events generated by the agent. """ run_config = run_config or RunConfig() - event_queue: asyncio.Queue[Event] = asyncio.Queue() + event_queue: queue.Queue[Optional[Event]] = queue.Queue() async def _invoke_run_async(): try: diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index af5963aaec..42d4741d0a 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1414,8 +1414,8 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): async def _noop_run_async(*args, **kwargs): """A mock that does nothing and yields no events for no-op resume tests.""" - for item in []: - yield item + if False: + yield @pytest.mark.parametrize( From 1e0654860cf3e0c788c7bce6731fd073adb6aa98 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 30 Jan 2026 21:16:23 +0530 Subject: [PATCH 23/25] fix: address all final maintainer and bot review comments Signed-off-by: Akshat Kumar --- src/google/adk/runners.py | 8 ++------ tests/unittests/cli/test_fast_api.py | 7 ++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index d5c41659c8..948c6527c9 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -482,7 +482,8 @@ async def run_async( The events generated by the agent. Raises: - ValueError: If the session is not found and `auto_create_session` is False. + ValueError: If the session is not found and `auto_create_session` is False, + or if both `invocation_id` and `new_message` are `None`. """ run_config = run_config or RunConfig() @@ -1535,11 +1536,6 @@ async def close(self): logger.info('Runner closed.') - if sys.version_info < (3, 11): - Self = 'Runner' # pylint: disable=invalid-name - else: - from typing import Self # pylint: disable=g-import-not-at-top - async def __aenter__(self) -> 'Runner': """Async context manager entry.""" return self diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 42d4741d0a..a56e375b1e 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -48,6 +48,7 @@ from google.adk.sessions.state import State from google.genai import types from pydantic import BaseModel +from pydantic import Field import pytest # Configure logging to help diagnose server startup issues @@ -155,9 +156,9 @@ class _MockEvalCaseResult(BaseModel): user_id: str session_id: str eval_set_file: str - eval_metric_results: list = [] - overall_eval_metric_results: list = [] - eval_metric_result_per_invocation: list = [] + eval_metric_results: list = Field(default_factory=list) + overall_eval_metric_results: list = Field(default_factory=list) + eval_metric_result_per_invocation: list = Field(default_factory=list) ################################################# From 74f2a14205060e7328d29c77a50afb672fa639b1 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 30 Jan 2026 21:59:37 +0530 Subject: [PATCH 24/25] fix: resolve mypy narrowing error and pass invocation_id in web server Signed-off-by: Akshat Kumar --- src/google/adk/cli/adk_web_server.py | 1 + src/google/adk/runners.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index 8ee8e2b2a6..1cc811a325 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -1498,6 +1498,7 @@ async def run_agent(req: RunAgentRequest) -> list[Event]: session_id=req.session_id, new_message=req.new_message, state_delta=req.state_delta, + invocation_id=req.invocation_id, ) ) as agen: events = [event async for event in agen] diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 948c6527c9..72f84fa3d3 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -496,7 +496,7 @@ async def _run_with_trace( ) -> AsyncGenerator[Event, None]: with tracer.start_as_current_span('invocation'): session = await self._get_or_create_session( - user_id=cast(str, user_id), session_id=cast(str, session_id) + user_id=user_id, session_id=session_id ) if not invocation_id and not new_message: From ef8f62a55b614ccee89408589513bf6bdc1dec6c Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 30 Jan 2026 22:07:21 +0530 Subject: [PATCH 25/25] chore: remove unused no-op mock and finalize PR logic Signed-off-by: Akshat Kumar --- tests/unittests/cli/test_fast_api.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index a56e375b1e..8354060f0f 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1413,12 +1413,6 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists() -async def _noop_run_async(*args, **kwargs): - """A mock that does nothing and yields no events for no-op resume tests.""" - if False: - yield - - @pytest.mark.parametrize( "extra_payload", [