Skip to content

Commit 084fcfa

Browse files
GWealecopybara-github
authored andcommitted
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 <gweale@google.com> PiperOrigin-RevId: 852945249
1 parent 1ae0e16 commit 084fcfa

2 files changed

Lines changed: 82 additions & 8 deletions

File tree

src/google/adk/cli/adk_web_server.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,14 +1531,31 @@ async def event_generator():
15311531
)
15321532
) as agen:
15331533
async for event in agen:
1534-
# Format as SSE data
1535-
sse_event = event.model_dump_json(
1536-
exclude_none=True, by_alias=True
1537-
)
1538-
logger.debug(
1539-
"Generated event in agent run streaming: %s", sse_event
1540-
)
1541-
yield f"data: {sse_event}\n\n"
1534+
# ADK Web renders artifacts from `actions.artifactDelta`
1535+
# during part processing *and* during action processing
1536+
# 1) the original event with `artifactDelta` cleared (content)
1537+
# 2) a content-less "action-only" event carrying `artifactDelta`
1538+
events_to_stream = [event]
1539+
if (
1540+
event.actions.artifact_delta
1541+
and event.content
1542+
and event.content.parts
1543+
):
1544+
content_event = event.model_copy(deep=True)
1545+
content_event.actions.artifact_delta = {}
1546+
artifact_event = event.model_copy(deep=True)
1547+
artifact_event.content = None
1548+
events_to_stream = [content_event, artifact_event]
1549+
1550+
for event_to_stream in events_to_stream:
1551+
sse_event = event_to_stream.model_dump_json(
1552+
exclude_none=True,
1553+
by_alias=True,
1554+
)
1555+
logger.debug(
1556+
"Generated event in agent run streaming: %s", sse_event
1557+
)
1558+
yield f"data: {sse_event}\n\n"
15421559
except Exception as e:
15431560
logger.exception("Error in event_generator: %s", e)
15441561
# You might want to yield an error event here

tests/unittests/cli/test_fast_api.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ async def dummy_run_async(
130130
new_message,
131131
state_delta=None,
132132
run_config: Optional[RunConfig] = None,
133+
invocation_id: Optional[str] = None,
133134
):
134135
run_config = run_config or RunConfig()
135136
yield _event_1()
@@ -959,6 +960,62 @@ def test_agent_run_passes_state_delta(test_app, create_test_session):
959960
assert data[3]["actions"]["stateDelta"] == payload["state_delta"]
960961

961962

963+
def test_agent_run_sse_splits_artifact_delta(
964+
test_app, create_test_session, monkeypatch
965+
):
966+
"""Test /run_sse splits artifact deltas to avoid double-rendering in web."""
967+
info = create_test_session
968+
969+
async def run_async_with_artifact_delta(
970+
self,
971+
*,
972+
user_id: str,
973+
session_id: str,
974+
invocation_id: Optional[str] = None,
975+
new_message: Optional[types.Content] = None,
976+
state_delta: Optional[dict[str, Any]] = None,
977+
run_config: Optional[RunConfig] = None,
978+
):
979+
del user_id, session_id, invocation_id, new_message, state_delta, run_config
980+
yield Event(
981+
author="dummy agent",
982+
invocation_id="invocation_id",
983+
content=types.Content(
984+
role="model", parts=[types.Part(text="LLM reply")]
985+
),
986+
actions=EventActions(artifact_delta={"artifact.txt": 0}),
987+
)
988+
989+
monkeypatch.setattr(Runner, "run_async", run_async_with_artifact_delta)
990+
991+
payload = {
992+
"app_name": info["app_name"],
993+
"user_id": info["user_id"],
994+
"session_id": info["session_id"],
995+
"new_message": {"role": "user", "parts": [{"text": "Hello agent"}]},
996+
"streaming": True,
997+
}
998+
999+
response = test_app.post("/run_sse", json=payload)
1000+
assert response.status_code == 200
1001+
1002+
sse_events = [
1003+
json.loads(line.removeprefix("data: "))
1004+
for line in response.text.splitlines()
1005+
if line.startswith("data: ")
1006+
]
1007+
1008+
assert len(sse_events) == 2
1009+
1010+
# First event: content but artifactDelta cleared.
1011+
assert sse_events[0]["content"]["parts"][0]["text"] == "LLM reply"
1012+
assert sse_events[0]["actions"]["artifactDelta"] == {}
1013+
1014+
# Second event: artifactDelta but no content.
1015+
assert "content" not in sse_events[1]
1016+
assert sse_events[1]["actions"]["artifactDelta"] == {"artifact.txt": 0}
1017+
1018+
9621019
def test_list_artifact_names(test_app, create_test_session):
9631020
"""Test listing artifact names for a session."""
9641021
info = create_test_session

0 commit comments

Comments
 (0)