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..0b11f5285a 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-creation is disabled. """ 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..1abd14e2ad 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -132,6 +132,12 @@ 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") + return + run_config = run_config or RunConfig() yield _event_1() await asyncio.sleep(0) @@ -1411,5 +1417,23 @@ 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" + 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 dummy runner work together to return success + assert response.status_code == 200 + assert response.json() == [] + + if __name__ == "__main__": pytest.main(["-xvs", __file__])