From 084fcfaba52c4a6075397221dbe7aba2f2acd2d7 Mon Sep 17 00:00:00 2001 From: George Weale Date: Tue, 6 Jan 2026 14:51:03 -0800 Subject: [PATCH] fix: Split SSE events with both content and artifactDelta in ADK Web Server This change modifies the /run_sse endpoint to split events that contain both content and an artifactDelta. The original event is split into two separate SSE events: one containing only the content (with artifactDelta cleared) and another containing only the artifactDelta (with content cleared) Close #4036 Co-authored-by: George Weale PiperOrigin-RevId: 852945249 --- src/google/adk/cli/adk_web_server.py | 33 ++++++++++++---- tests/unittests/cli/test_fast_api.py | 57 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index 96d84163..89e1ac0e 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -1531,14 +1531,31 @@ class AdkWebServer: ) ) as agen: async for event in agen: - # Format as SSE data - sse_event = event.model_dump_json( - exclude_none=True, by_alias=True - ) - logger.debug( - "Generated event in agent run streaming: %s", sse_event - ) - yield f"data: {sse_event}\n\n" + # ADK Web renders artifacts from `actions.artifactDelta` + # during part processing *and* during action processing + # 1) the original event with `artifactDelta` cleared (content) + # 2) a content-less "action-only" event carrying `artifactDelta` + events_to_stream = [event] + if ( + event.actions.artifact_delta + and event.content + and event.content.parts + ): + content_event = event.model_copy(deep=True) + content_event.actions.artifact_delta = {} + artifact_event = event.model_copy(deep=True) + artifact_event.content = None + events_to_stream = [content_event, artifact_event] + + for event_to_stream in events_to_stream: + sse_event = event_to_stream.model_dump_json( + exclude_none=True, + by_alias=True, + ) + logger.debug( + "Generated event in agent run streaming: %s", sse_event + ) + yield f"data: {sse_event}\n\n" except Exception as e: logger.exception("Error in event_generator: %s", e) # You might want to yield an error event here diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 7a5627ff..b7a97730 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -130,6 +130,7 @@ async def dummy_run_async( new_message, state_delta=None, run_config: Optional[RunConfig] = None, + invocation_id: Optional[str] = None, ): run_config = run_config or RunConfig() yield _event_1() @@ -959,6 +960,62 @@ def test_agent_run_passes_state_delta(test_app, create_test_session): assert data[3]["actions"]["stateDelta"] == payload["state_delta"] +def test_agent_run_sse_splits_artifact_delta( + test_app, create_test_session, monkeypatch +): + """Test /run_sse splits artifact deltas to avoid double-rendering in web.""" + info = create_test_session + + async def run_async_with_artifact_delta( + self, + *, + user_id: str, + session_id: str, + invocation_id: Optional[str] = None, + new_message: Optional[types.Content] = None, + state_delta: Optional[dict[str, Any]] = None, + run_config: Optional[RunConfig] = None, + ): + del user_id, session_id, invocation_id, new_message, state_delta, run_config + yield Event( + author="dummy agent", + invocation_id="invocation_id", + content=types.Content( + role="model", parts=[types.Part(text="LLM reply")] + ), + actions=EventActions(artifact_delta={"artifact.txt": 0}), + ) + + monkeypatch.setattr(Runner, "run_async", run_async_with_artifact_delta) + + payload = { + "app_name": info["app_name"], + "user_id": info["user_id"], + "session_id": info["session_id"], + "new_message": {"role": "user", "parts": [{"text": "Hello agent"}]}, + "streaming": True, + } + + response = test_app.post("/run_sse", json=payload) + assert response.status_code == 200 + + sse_events = [ + json.loads(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") + ] + + assert len(sse_events) == 2 + + # First event: content but artifactDelta cleared. + assert sse_events[0]["content"]["parts"][0]["text"] == "LLM reply" + assert sse_events[0]["actions"]["artifactDelta"] == {} + + # Second event: artifactDelta but no content. + assert "content" not in sse_events[1] + assert sse_events[1]["actions"]["artifactDelta"] == {"artifact.txt": 0} + + def test_list_artifact_names(test_app, create_test_session): """Test listing artifact names for a session.""" info = create_test_session