Skip to content

Commit d46c6f9

Browse files
authored
fix(server): preserve non-ASCII characters in streaming JSON responses (#1080)
# Description Pass ensure_ascii=False to json.dumps() at the 5 streaming call sites. ## Reasoning - Consistency. Matches Starlette's JSONResponse (which uses ensure_ascii=False), so the SDK now emits the same wire format for unary and streaming responses. - Smaller payloads. CJK/Arabic/Cyrillic/Devanagari/emoji content drops to ~half the byte size. - Better debuggability. Logs, curl, devtools, and tcpdump show actual text instead of escape soup. - Inclusivity. Non-Latin scripts become first-class on the wire instead of being silently doubled in size. - [Spec-compliant](https://a2a-protocol.org/latest/specification/#1411-applicationa2ajson): "UTF-8 encoding MUST be used for JSON text" Fixes #1078 🦕
1 parent 5c88793 commit d46c6f9

8 files changed

Lines changed: 189 additions & 11 deletions

File tree

src/a2a/compat/v0_3/rest_adapter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import functools
2-
import json
32
import logging
43

54
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
@@ -37,6 +36,7 @@
3736
DefaultServerCallContextBuilder,
3837
ServerCallContextBuilder,
3938
)
39+
from a2a.utils import json_utils
4040
from a2a.utils.error_handlers import (
4141
rest_error_handler,
4242
rest_stream_error_handler,
@@ -94,7 +94,7 @@ async def event_generator(
9494
stream: AsyncIterable[Any],
9595
) -> AsyncIterator[str]:
9696
async for item in stream:
97-
yield json.dumps(item)
97+
yield json_utils.dumps(item)
9898

9999
return EventSourceResponse(
100100
event_generator(method(request, call_context))

src/a2a/server/routes/jsonrpc_dispatcher.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
Task,
4242
TaskPushNotificationConfig,
4343
)
44-
from a2a.utils import constants, proto_utils
44+
from a2a.utils import constants, json_utils, proto_utils
4545
from a2a.utils.errors import (
4646
A2AError,
4747
TaskNotFoundError,
@@ -573,7 +573,7 @@ async def event_generator(
573573
try:
574574
async for item in stream:
575575
event: dict[str, str] = {
576-
'data': json.dumps(item),
576+
'data': json_utils.dumps(item),
577577
}
578578
if 'error' in item:
579579
event['event'] = 'error'
@@ -592,7 +592,7 @@ async def event_generator(
592592
)
593593
yield {
594594
'event': 'error',
595-
'data': json.dumps(error_response),
595+
'data': json_utils.dumps(error_response),
596596
}
597597

598598
return EventSourceResponse(event_generator(handler_result)) # ty:ignore[invalid-argument-type]

src/a2a/server/routes/rest_dispatcher.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import json
21
import logging
32

43
from collections.abc import AsyncIterator, Awaitable, Callable
@@ -18,7 +17,7 @@
1817
GetTaskPushNotificationConfigRequest,
1918
SubscribeToTaskRequest,
2019
)
21-
from a2a.utils import constants, proto_utils
20+
from a2a.utils import constants, json_utils, proto_utils
2221
from a2a.utils.error_handlers import (
2322
build_rest_error_payload,
2423
rest_error_handler,
@@ -140,14 +139,14 @@ async def _handle_streaming(
140139
return EventSourceResponse(iter([]))
141140

142141
async def event_generator() -> AsyncIterator[ServerSentEvent]:
143-
yield ServerSentEvent(data=json.dumps(first_item))
142+
yield ServerSentEvent(data=json_utils.dumps(first_item))
144143
try:
145144
async for item in stream:
146-
yield ServerSentEvent(data=json.dumps(item))
145+
yield ServerSentEvent(data=json_utils.dumps(item))
147146
except Exception as e:
148147
logger.exception('Error during REST SSE stream')
149148
yield ServerSentEvent(
150-
data=json.dumps(build_rest_error_payload(e)),
149+
data=json_utils.dumps(build_rest_error_payload(e)),
151150
event='error',
152151
)
153152

src/a2a/utils/json_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""JSON serialization helpers for the A2A Python SDK."""
2+
3+
import json
4+
5+
from typing import Any
6+
7+
8+
def dumps(obj: Any) -> str:
9+
r"""Serialize ``obj`` to a JSON-formatted ``str`` with UTF-8 defaults.
10+
11+
Use this in SSE/streaming code paths where payloads are serialized
12+
manually before being written to the wire. Unary HTTP responses do
13+
not need it because Starlette's ``JSONResponse.render`` already calls
14+
``json.dumps(content, ensure_ascii=False, ...)`` internally; this
15+
helper makes the streaming paths behave identically so non-ASCII
16+
characters (CJK, emoji, etc.) reach clients as raw UTF-8 rather than
17+
escape sequences.
18+
"""
19+
return json.dumps(obj, ensure_ascii=False)

tests/compat/v0_3/test_rest_routes_compat.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

3-
from unittest.mock import MagicMock
3+
from collections.abc import AsyncIterator
4+
from unittest.mock import AsyncMock, MagicMock
45

56
import pytest
67

@@ -22,6 +23,8 @@
2223
from google.protobuf import json_format
2324
from httpx import ASGITransport, AsyncClient
2425
from starlette.applications import Starlette
26+
from starlette.datastructures import Headers
27+
from starlette.requests import Request
2528

2629

2730
logger = logging.getLogger(__name__)
@@ -200,3 +203,42 @@ async def test_cancel_task_v03(
200203
actual_response = a2a_v0_3_pb2.Task()
201204
json_format.Parse(response.text, actual_response)
202205
assert expected_response == actual_response
206+
207+
208+
@pytest.mark.anyio
209+
async def test_v03_streaming_does_not_ascii_escape_non_ascii(
210+
request_handler: RequestHandler,
211+
) -> None:
212+
"""v0.3 REST streaming must emit raw UTF-8 for non-ASCII characters.
213+
214+
Regression test for https://github.com/a2aproject/a2a-python/issues/1078.
215+
"""
216+
adapter = REST03Adapter(http_handler=request_handler)
217+
non_ascii_text = '你好'
218+
219+
async def stream_with_non_ascii(
220+
request: Request, context: object
221+
) -> AsyncIterator[dict]:
222+
yield {'msg': {'text': non_ascii_text}}
223+
224+
mock_req = MagicMock(spec=Request)
225+
mock_req.body = AsyncMock(return_value=b'{}')
226+
mock_req.headers = Headers({'a2a-version': '0.3'})
227+
mock_req.user = MagicMock(is_authenticated=False)
228+
mock_req.auth = None
229+
mock_req.scope = {}
230+
231+
response = await adapter._handle_streaming_request(
232+
stream_with_non_ascii, mock_req
233+
)
234+
chunks = []
235+
async for chunk in response.body_iterator:
236+
chunks.append(chunk)
237+
238+
assert len(chunks) == 1
239+
chunk = chunks[0]
240+
payload = getattr(chunk, 'data', chunk)
241+
if isinstance(payload, bytes):
242+
payload = payload.decode('utf-8')
243+
assert non_ascii_text in payload
244+
assert '\\u4f60\\u597d' not in payload

tests/server/routes/test_jsonrpc_dispatcher.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,65 @@ async def stream_generator():
591591
call_context = handler.on_subscribe_to_task.call_args[0][1]
592592
assert call_context.state['method'] == 'SubscribeToTask'
593593

594+
@pytest.mark.asyncio
595+
async def test_streaming_response_does_not_ascii_escape_non_ascii(
596+
self, handler, agent_card
597+
):
598+
"""Non-ASCII characters (e.g. CJK) must be emitted as raw UTF-8."""
599+
non_ascii_text = '你好'
600+
601+
async def stream_generator():
602+
yield TaskArtifactUpdateEvent(
603+
artifact=Artifact(
604+
artifact_id='a1',
605+
name='result',
606+
parts=[Part(text=non_ascii_text)],
607+
),
608+
task_id='task1',
609+
context_id='ctx1',
610+
append=False,
611+
last_chunk=True,
612+
)
613+
614+
handler.on_message_send_stream = MagicMock(
615+
return_value=stream_generator()
616+
)
617+
618+
jsonrpc_routes = create_jsonrpc_routes(
619+
request_handler=handler,
620+
rpc_url='/',
621+
)
622+
from starlette.applications import Starlette
623+
624+
app = Starlette(routes=jsonrpc_routes)
625+
client = TestClient(app, headers={'A2A-Version': '1.0'})
626+
627+
try:
628+
with client.stream(
629+
'POST',
630+
'/',
631+
json=_make_jsonrpc_request(
632+
'SendStreamingMessage',
633+
{
634+
'message': {
635+
'messageId': '1',
636+
'role': 'ROLE_USER',
637+
'parts': [{'text': non_ascii_text}],
638+
}
639+
},
640+
),
641+
) as response:
642+
assert response.status_code == 200
643+
content = b''
644+
for chunk in response.iter_bytes():
645+
content += chunk
646+
finally:
647+
client.close()
648+
await asyncio.sleep(0.1)
649+
650+
assert non_ascii_text.encode('utf-8') in content
651+
assert b'\\u4f60\\u597d' not in content
652+
594653

595654
if __name__ == '__main__':
596655
pytest.main([__file__])

tests/server/routes/test_rest_dispatcher.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,38 @@ async def test_on_message_send_stream_handler_error(self, mock_handler):
288288

289289
response = await dispatcher.on_message_send_stream(req)
290290
assert response.status_code == 400
291+
292+
async def test_streaming_does_not_ascii_escape_non_ascii(
293+
self, rest_dispatcher_instance
294+
):
295+
"""Non-ASCII characters in SSE payloads must pass through as raw UTF-8.
296+
297+
Regression test for https://github.com/a2aproject/a2a-python/issues/1078.
298+
"""
299+
non_ascii_text = '你好'
300+
301+
async def stream_with_non_ascii(
302+
context: ServerCallContext,
303+
) -> AsyncIterator[dict]:
304+
yield {'msg': {'text': non_ascii_text}}
305+
yield {'msg': {'text': f'echo: {non_ascii_text}'}}
306+
307+
# Drive _handle_streaming directly to assert on the serialized bytes
308+
# produced by json.dumps, independent of protobuf encoding details.
309+
req = make_mock_request(method='POST')
310+
response = await rest_dispatcher_instance._handle_streaming(
311+
req, stream_with_non_ascii
312+
)
313+
assert response.status_code == 200
314+
315+
chunks = []
316+
async for chunk in response.body_iterator:
317+
chunks.append(chunk)
318+
319+
assert len(chunks) == 2
320+
for chunk in chunks:
321+
payload = getattr(chunk, 'data', chunk)
322+
if isinstance(payload, bytes):
323+
payload = payload.decode('utf-8')
324+
assert non_ascii_text in payload
325+
assert '\\u4f60\\u597d' not in payload

tests/utils/test_json_utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Tests for a2a.utils.json_utils module."""
2+
3+
import json
4+
5+
from a2a.utils import json_utils
6+
7+
8+
def test_dumps_emits_raw_utf8_for_non_ascii() -> None:
9+
"""Non-ASCII characters must serialize as raw UTF-8, not \\uXXXX escapes."""
10+
out = json_utils.dumps({'text': '你好'})
11+
assert '你好' in out
12+
assert '\\u4f60\\u597d' not in out
13+
14+
15+
def test_dumps_emits_emoji_as_raw_utf8() -> None:
16+
"""Emoji (outside the BMP) must also pass through unescaped."""
17+
out = json_utils.dumps({'emoji': '🎉'})
18+
assert '🎉' in out
19+
20+
21+
def test_dumps_round_trips_through_json_loads() -> None:
22+
"""Wrapper output must remain a valid JSON document."""
23+
payload = {'msg': '你好', 'list': ['a', 'é', '日本語'], 'n': 1}
24+
assert json.loads(json_utils.dumps(payload)) == payload

0 commit comments

Comments
 (0)