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 diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 3aaa54e257..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: - ValueError: If the session is not found; If both invocation_id and - new_message are None. + ValueError: If the session is not found and `auto_create_session` is False. """ run_config = run_config or RunConfig() @@ -497,12 +496,22 @@ 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: - 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 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, ) + # 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 0c69605349..58e4307c13 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -132,6 +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) @@ -1411,5 +1412,43 @@ 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.""" + for item in []: + yield item + + +@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, 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) + + info = create_test_session + url = "/run" + payload = { + "app_name": info["app_name"], + "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 request and returns success + assert response.status_code == 200 + assert response.json() == [] + + 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__])