Skip to content

feat(dart/agent)!: make the Parser stateless; add ResponseStream; bind RendererState to A2uiRequestProcessor - #3277

Merged
nan-yu merged 2 commits into
mainfrom
feat/dart-agent-response-stream
Oct 20, 2026
Merged

nan-yu merged 2 commits into
mainfrom
feat/dart-agent-response-stream

Conversation

@nan-yu

@nan-yu nan-yu commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #3276 (RendererState with an undo log). Part of #3262 and #3263.

The Dart DirectJsonParser documented that "a parser holds the state of one streamed response", and streamed responses only got per-message checks: the checks that need a whole surface ran only in A2uiRequestProcessor.parseResponse. This PR mirrors the Python change in #3269 and #3270, and the TypeScript change in #3279.

Parser

  1. Parser is stateless and has a new abstract catalogs getter. Breaking for custom parsers.
  2. compile only translates. DirectJsonParser.compile still repairs JSON, reads typed v0.9 messages and rejects undeclared envelope or body fields, but no longer checks components, functions, themes or catalog ids. Breaking.
  3. parseResponse(content, {wrapped, state}) compiles every block and applies it inside a transaction on the given state, or a new one, then checks the trees of the surfaces it touched. Without a state, an update to a surface the response does not create is rejected. Breaking.
  4. openStream({wrapped, state}) returns a ResponseStream with state, feed and close; the direct JSON version is DirectJsonResponseStream. parseChunk and the stream fields on the parser are removed. Breaking.
    1. Each message is applied to the state as it is emitted, so streamed responses now get the whole-surface checks.
    2. createSurface and deleteSurface are held until their object closes, and a message for a surface that doesn't exist yet is held back.
    3. close() emits held-back text, throws A2uiParseError if a block is still open, and checks an unwrapped stream. After an error, feed throws StateError and close returns nothing.
  5. Express output goes through the same checks.
  6. RendererState.markChecked(surfaceIds) stops holding a surface's changed components against later checks; a failed stream calls it for what it emitted. Like any other change, it is undone when its transaction fails.

Cost of a stream

DirectJsonResponseStream reads each chunk once, as the blueprint's "Cost of a response" requires and as Python (#3269) and TypeScript (#3279) do. It keeps #3314's incremental reads, which main gained after this PR was first written:

  1. The close-tag scan (BlockScanner) and the read of the open block (readPartialMessages with a PartialReadCache) go on from where the previous chunk stopped.
  2. A message that has closed is read, applied and checked once (_emitReady).
  3. A growing updateComponents applies only the components it gained, and the last one again if it grew before v1.0, and rejects an id repeated across its chunks (_checkAdded).
  4. A growing updateDataModel is checked for the lists and maps its value gained, emitted as its value changes, and applied again once it closes (_checkGrown).
  5. Only updateComponents and updateDataModel are emitted while they grow.
  6. At block close the read goes on from the cache, and the block is compiled once so that it throws as compile does (_finishBlock).

conformance/agent/direct_json/scaling.yaml (400 against 100, limit 8), before and after this rework of the stream:

Case Before After
test_scaling_stream_components_v10 15.6 3.9
test_scaling_stream_components_healed_v09 failed (surface not created) 4.4
test_scaling_stream_single_component_messages_v10 passed 4.6
test_scaling_stream_data_model_v10 9.9 3.9
test_scaling_stream_over_compile_v10 (limit 10) 5280 3.6

Request processor

A2uiRequestProcessor reuses one parser (parser), adds createState(), an optional bound state (constructor parameter and A2uiGenerator.createProcessor(..., state:)), and openStream. parseResponse and openStream default to the bound state; with neither, each call uses a new empty state. A state for other catalogs throws A2uiCatalogError.

Conformance, evaluation and docs

  1. The conformance runner reads parse_response and streaming cases as one chat turn against a new state, and drives streaming cases through openStream, feed and close. Compile and round-trip cases check the compiled block on its own with validatePayload, as the Python runner does. Eight streaming and three Express known gaps now pass and are removed, with the two update-before-create streaming gaps. test_stream_cut_surrogate_pair_v09 is still a gap; it comes from how the JSON reader heals a cut surrogate pair.
  2. The shared conformance cases for a stateless parser come with this PR, identical to feat(python/agent)!: make the Parser stateless; always validate; add ResponseStream #3269's: compile cases (direct JSON and Express compiler.yaml / decompiler.yaml) assume a surface a block only updates, response_parser.yaml cases create the surfaces they update, and response_streaming.yaml holds back an update to a surface the response has not created. Without them, 43 Dart conformance cases fail. If feat(python/agent)!: make the Parser stateless; always validate; add ResponseStream #3269 merges first, these files drop out of this diff. Two cases, test_stream_update_before_create_surface_fails_v09 and test_stream_data_model_before_create_surface_fails_v09, stay at main's version, because the Python SDK on main still emits a message for a surface that does not exist yet; the Dart harness skips them until feat(python/agent)!: make the Parser stateless; always validate; add ResponseStream #3269 updates them. With the updated cases, seven TypeScript streaming cases pass on main's TypeScript SDK, so they leave its known failures here, as in feat(ts/agent)!: make the Parser stateless; add ResponseStream; bind RendererState to A2uiRequestProcessor #3279.
  3. The scaling harness streams through openStream into a state that holds the surfaces a payload updates before creating them, and times compile as parseResponse(payload, wrapped: false), as the Python harness does.
  4. The evaluation runner checks a parsed response against a RendererState instead of a strict MessageProcessor.
  5. The README gains a "Chat and canvas" section, and the example and the e2e streaming test use the new API.

Testing

  1. New renderer_state_parsing_test.dart.
  2. Fix performance and create conformance tests. #3314's two streaming data model tests in direct_json_test.dart run through openStream; the state reports the core's data error as an A2uiValidationError whose cause is the A2uiDataError.
  3. In dart/a2ui_agent with Flutter's Dart 3.13: dart format --output=none --set-exit-if-changed ., flutter analyze --fatal-infos (no issues), flutter test --test-randomize-ordering-seed=random (1107 passed, 71 skipped), dart test -P performance (12 passed). e2e_test formats and analyzes clean.
  4. uv run pytest tests/test_dart_bridge.py in eval/: 45 passed, including the tests that build and run the Dart runner.
  5. The e2e tests were analyzed but not run; they need a Gemini API key.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the A2UI agent parsing and streaming architecture by introducing a stateless Parser design and a dedicated ResponseStream API for handling chunk-by-chunk LLM responses. It also adds support for binding a persistent RendererState to the processor, enabling canvas-based agents to maintain UI state across multiple turns. The feedback recommends a Dart-specific improvement in response_stream.dart to check is Map and cast to Map<String, Object?> instead of checking the generic type directly, which prevents potential runtime failures when dealing with untyped maps.

Comment thread dart/a2ui_agent/lib/src/inference_formats/direct_json/response_stream.dart Outdated

@gspencergoog gspencergoog left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change integrates RendererState across Parser, DirectJsonResponseStream, A2uiRequestProcessor, A2uiGenerator, and the Dart eval runner. It separates stateless syntax compilation (Parser.compile) from stateful catalog and tree-topology checks (parseResponse and openStream), and replaces Parser.parseChunk with a per-response ResponseStream.

🤖 Agent-generated

@nan-yu
nan-yu force-pushed the feat/dart-agent-response-stream branch 2 times, most recently from 32c0c4e to 671c522 Compare October 16, 2026 23:07
@nan-yu
nan-yu force-pushed the feat/dart-agent-response-stream branch from 32443a2 to 27fed27 Compare October 19, 2026 18:42
nan-yu added a commit that referenced this pull request Oct 19, 2026
… SDKs

Addresses the review of #3283, checking each rule against the Python
(#3270), TypeScript (#3279) and Dart (#3277) pull requests:

1. `compile` takes `is_final`; a cut block is applied and its surfaces
   skip `check_topology` and are marked checked, and a cut Direct JSON
   block raises. `parse_response` errors carry `partial_results`.
2. Language-neutral wording for named arguments, the unsupported-stream
   error, and the callback form of `transaction` (returns the callback's
   result, must be synchronous).
3. A failing `feed` undoes only what that chunk applied; a failed stream
   marks the surfaces it emitted to as checked.
4. `RendererState.mark_checked`, and what `check_topology` does with
   duplicate, unknown, deleted and assumed ids.
5. The undo log records the state's own bookkeeping, copies the
   properties an in-place update replaces, keeps each component's
   `catalogId` and the v0.8 buffer when replaying a deleted surface, and
   treats a nested transaction as a position in the log.
6. How a v0.8 state buffers updates before `beginRendering`.
7. How `validate_payload` picks the catalog of an assumed surface, and
   that it accepts one message on its own.
8. The three linear-cost rules are requirements for every SDK.
9. `A2uiRequestProcessor.accepts_state`, used in the code example.
10. The conformance plan says what `parse_chunk` runs and that no suite
    covers RendererState, canvas turns or validate_payload yet.
11. A "Where the SDKs differ" list of the gaps the SDKs still have.

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu
nan-yu removed this pull request from stack #3280 October 20, 2026 18:12
@nan-yu
nan-yu force-pushed the feat/dart-agent-response-stream branch from 27fed27 to 608f639 Compare October 20, 2026 18:35
@nan-yu
nan-yu added this pull request to stack #3343 October 20, 2026 18:39
@nan-yu
nan-yu force-pushed the feat/dart-agent-response-stream branch from bf85955 to e65ad60 Compare October 20, 2026 18:50
Base automatically changed from feat/dart-agent-renderer-state to main October 20, 2026 19:21
…d RendererState to A2uiRequestProcessor

- A parser holds no state. Parser.parseChunk is removed: openStream returns
  a ResponseStream (DirectJsonResponseStream for direct JSON) whose feed and
  close read one response against a RendererState, applying each message as
  it is emitted and checking the trees a block touched when it closes. A
  failed stream keeps what it emitted and marks it checked
  (RendererState.markChecked, undone with a failed transaction like any
  other change).
- Parser.compile only translates; parseResponse checks a response as a whole
  against a RendererState. A2uiRequestProcessor binds a RendererState and
  gains acceptsState.
- DirectJsonResponseStream reads each chunk once: the close-tag scan and the
  read of the open block resume from the previous chunk (#3314's
  PartialReadCache), a closed message is applied once, a growing
  updateComponents applies only the components it gained (and the last one
  again if it grew) and rejects ids repeated across its chunks, and a
  growing updateDataModel is checked for the lists and maps its value gained
  and applied again once it closes. Only updateComponents and
  updateDataModel are emitted while they grow.
- The shared conformance cases for the stateless parser and stream, as in
  #3269: compile cases assume the surfaces a block only updates, response
  parser cases create the surfaces they update, and the streaming cases hold
  back an update to a surface the response has not created. The Dart known
  gaps for those cases are removed.
- The scaling conformance harness streams through openStream into a state
  that holds the surfaces a payload updates first, and times compile as
  parseResponse(payload, wrapped: false), as the Python harness does.

Signed-off-by: Nan Yu <nanyu@google.com>
…ases

The shared conformance cases this PR carries from #3269 failed CI for the
SDKs on main:

- test_stream_update_before_create_surface_fails_v09 and
  test_stream_data_model_before_create_surface_fails_v09 now expect a
  message for a surface that does not exist yet to be held back, which the
  Python SDK on main does not do. They stay as main has them until #3269
  lands, and the Dart harness skips them until then, since this stream
  already holds the message back.
- With the updated cases, seven TypeScript streaming cases pass, so they
  leave the TypeScript harness's known failures, as #3279 does.

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu
nan-yu force-pushed the feat/dart-agent-response-stream branch from e65ad60 to 8012557 Compare October 20, 2026 19:21
@nan-yu
nan-yu merged commit e9c2552 into main Oct 20, 2026
45 checks passed
@nan-yu
nan-yu deleted the feat/dart-agent-response-stream branch October 20, 2026 19:42
@github-project-automation github-project-automation Bot moved this from Todo to Done in A2UI Oct 20, 2026
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