Skip to content

fix(streaming): preserve split Unicode surrogate pairs - #7490

Open
BichengWang wants to merge 2 commits into
google:mainfrom
BichengWang:fix/streaming-unicode-surrogates
Open

BichengWang wants to merge 2 commits into
google:mainfrom
BichengWang:fix/streaming-unicode-surrogates

Conversation

@BichengWang

@BichengWang BichengWang commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

No existing issue: the bug report below follows the issue-template structure permitted by CONTRIBUTING.md.

Problem / Describe the Bug: When streamed JSON tool arguments split an escaped Unicode surrogate pair between chunks, the tracker decodes the high surrogate alone. It emits a string that cannot be serialized to UTF-8, then repeats the preceding text when the low surrogate arrives. This affects the shared tracker used by the OpenAI, Anthropic, LiteLLM, and Apigee integrations.

Steps to Reproduce:

  1. Check out upstream 15486db and install the test dependencies.
  2. Run this offline reproduction:
from google.adk.utils.streaming_utils import _JsonPathTracker

tracker = _JsonPathTracker()
fragments = []
for chunk in ['{"text":"hello ', r'\ud83d', r'\ude00', ' world"}']:
    for arg in tracker.handle_chunk(chunk):
        fragments.append(arg.string_value or '')
        arg.model_dump_json()
assert ''.join(fragments) == 'hello 😀 world'

Expected Behavior: Each partial argument is serializable, and its text fragments form exactly "hello 😀 world".

Observed Behavior / Logs:

PydanticSerializationError: Error serializing to JSON:
UnicodeEncodeError: 'utf-8' codec can't encode character '\ud83d':
surrogates not allowed

Without the serialization call, the joined value is "hello \ud83dhello 😀 world".

Environment Details: ADK 2.11.0 at the upstream commit above; macOS 27.0.1 arm64; Python 3.11.17 for the reproduction.

Model Information: LiteLLM: no for the reproduction. The Runner E2E check used OpenAILlm with a loopback OpenAI-compatible SSE server and the model label gpt-4o; it made no provider requests.

Regression / Frequency: Reproduced consistently for these chunk boundaries on current upstream. Earlier releases were not checked.

Solution: Defer partial JSON completion while an open string ends with an unescaped high-surrogate escape, allowing the low surrogate to arrive before decoding. Literal escaped backslashes continue streaming immediately.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

New regression coverage includes split surrogate pairs, uppercase escapes, a split low-surrogate escape, and literal backslashes. On unmodified upstream, three new parameter cases failed with corrupted text; with the fix, all five new cases passed.

  • Streaming utilities and OpenAI/Anthropic/Apigee consumers: 548 passed on each of Python 3.11, 3.12, 3.13, and 3.14, including all five new regression cases.
  • Changed-file pre-commit hooks: passed.
  • Baseline versus changed-file mypy, with separate caches: no new errors; the same pre-existing assignment error appears in both runs.
  • Full required matrix, run with tox -p 4: all four environments passed.
    • Python 3.11: 18390 passed, 90 skipped, 24 xfailed, 2 xpassed.
    • Python 3.12: 18381 passed, 91 skipped, 24 xfailed, 2 xpassed.
    • Python 3.13: 18381 passed, 91 skipped, 24 xfailed, 2 xpassed.
    • Python 3.14: 18381 passed, 91 skipped, 24 xfailed, 2 xpassed.

Manual End-to-End (E2E) Tests:

Executed a real Runner with a string-valued echo tool, OpenAILlm, RunConfig(streaming_mode=StreamingMode.SSE), and a local HTTP server emitting the four argument fragments above as Chat Completions SSE chunks. Serialized every yielded Event with model_dump_json(), checked the streamed text, and checked the argument actually received by the tool. Upstream fails at Event serialization with the error above; the fix passes:

Serialized 8 Runner events; streamed and executed argument: 'hello 😀 world'

The wheel build also succeeded, and the same offline flow passed after installing the wheel with the OpenAI dependency into a clean Python 3.11 environment.

The command used for that clean-wheel Runner check was:

/tmp/adk-surrogate-20261009.27MsFm/wheel-venv/bin/python /tmp/adk-surrogate-20261009.27MsFm/e2e.py

The temporary e2e.py implemented the loopback server and Runner setup described above; the script and environment were removed after validation.

To repeat the argument-boundary check locally:

pytest tests/unittests/utils/test_streaming_utils.py -k surrogate -q

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules. (N/A)

Additional context

The exact diff was reviewed for surrogate decoding, literal-backslash handling, streaming state, and API compatibility. This change adds no public API or dependent-module changes. Fresh issue and PR searches found no existing fix for this defect; the only open PR touching this utility was #7418, which addresses an unrelated type-check error.

Defer partial JSON completion when an open string ends with an escaped
high surrogate. Decoding it before the low surrogate arrives emits text
that cannot be serialized to UTF-8 and repeats the argument's prefix.

Cover split pairs, uppercase escapes, incomplete low escapes, and
literal backslashes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants