Skip to content

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

Open
nan-yu wants to merge 4 commits into
feat/ts-agent-renderer-statefrom
feat/ts-agent-response-stream
Open

nan-yu wants to merge 4 commits into
feat/ts-agent-renderer-statefrom
feat/ts-agent-response-stream

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Part of #3262 and #3263. Stacked on #3278, which is stacked on #3327: the core checks an updateComponents against the components it changes, and RendererState rolls back with an undo log. A stream needs both to take time in proportion to the response.

The TypeScript DirectJsonParser forwarded parseChunk to a stream processor that held one response's buffers and its own copy of surface state, checked streamed messages only when a validationConfig was set, and A2uiRequestProcessor ran every parsed message through a second, long-lived MessageProcessor with the tree checks turned off. This PR mirrors the Python change in #3269 and #3270.

Parser

  1. Parser is stateless, and subclasses provide catalogs. compile only translates.
  2. parseResponse(content, {wrapped?, state?}) replaces the positional wrapped argument. It applies every block inside a transaction on the given state, or a new one, and checks the trees once all blocks are applied. Breaking.
  3. openStream({wrapped?, state?}) returns a ResponseStream with state, feed and close. parseChunk and parseStream are removed. Breaking.
  4. DirectJsonResponseStream is a port of the Python stream, with the block reading in blocks.ts and the partial JSON reader in json-reader.ts. It applies each message as it is emitted, emits only updateComponents and updateDataModel while they grow and holds every other message, such as createSurface, deleteSurface and callRendererFunction, until its object closes, keeps no copy of surface state, and always validates. It reads each chunk once; see "Linear streaming". DirectJsonStreamProcessorImpl, streaming.ts and streaming-types.ts are removed. Breaking.
  5. The progressive keys default to ['text'] in DirectJsonFormat and DirectJsonFormatFactory, and to none in DirectJsonParser, as in Python. literalString was v0.8-only.
  6. DirectJsonParser.unwrap follows Python: a response without tags is text, an unterminated block has isFinal: false, and tags may carry attributes.
  7. Express output goes through the base parseResponse checks. A block without a root needs a state that holds its surface.

Request processor

A2uiRequestProcessor drops the private _messageProcessor and MODEL_OUTPUT_VALIDATION, and gains a cached parser, createState(), a state getter, a {state} constructor option, and openStream. A2uiGenerator.createProcessor takes {state} too, and checks examples with validatePayload. parseResponse and openStream default to the bound state; with none, each call uses a new empty state. A state for other catalogs throws A2uiCatalogError. acceptsState compares the catalogs' ids and release lines (catalogsKey), so a state built from the v0.9.1 spelling of a v0.9 catalog is accepted, as RendererState treats the two versions as one.

Linear streaming

The stream follows requirement 3 of "Cost of a response" in the agent blueprint: a stream reads each chunk once.

  1. BlockScanner resumes the close-tag scan where the last chunk left it. payloadStart and trailingFenceStart find the payload by offset instead of cutting a new string and running a trailing-fence regular expression over it.
  2. readPartialMessages reads through a PartialReadCache, ported from the Dart reader in Fix performance and create conformance tests. #3314: a list or map that closed is kept by offset (closedOr), a list still open goes on after its last closed item, and a list of messages goes on after the last message that closed, which is not returned again.
  3. The stream keeps the closed messages and applies each once (emitReady, applyMessage). A growing updateComponents is applied for the components it gained, and the last one again if it grew (applyAddedComponents); an id repeated across chunks of one message is rejected, as the core rejects one repeated within a batch. A growing updateDataModel is applied when it first reads, checked for the lists and maps its value gains, and applied again once it closes (checkGrownValue).
  4. A closed message the state rejects fails the same way on every later chunk, so the stream stops emitting the block's messages and the block throws when it closes, instead of retrying the message on every chunk.
  5. tests/timing/scaling_test.mjs runs conformance/agent/direct_json/scaling.yaml and conformance/agent/express/scaling.yaml through web_core's timing helper (typescript/core/web_core/tests/timing/timing.mjs, imported by relative path), with yarn test:timing, which yarn test leaves out. The ci-timing job in web_ci.yml runs it for @a2ui/agent. compile cases run parseResponse(payload, {wrapped: false}), which compiles and checks the block, as compiling does in the other SDKs. The v0.9 healed case updates a surface an earlier response created, so each run starts on a state holding that surface with a root that lists the payload's components.

Streaming in 40-character chunks, median of five runs:

Payload n = 100 n = 200 n = 400 400 / 100
One updateComponents of n components, before 439 ms 1528 ms 7001 ms 16.0
One updateComponents of n components, now 34 ms 66 ms 134 ms 3.9
n messages of one component, before 290 ms 929 ms 3234 ms 11.2
n messages of one component, now 49 ms 102 ms 174 ms 3.6
A data model of n items, healed, before 124 ms 419 ms 1572 ms 12.7
A data model of n items, healed, now 11 ms 20 ms 40 ms 3.8

Before this change the direct JSON timing suite failed four of its stream cases with ratios of 13.6 to 14.5 (8 allowed), and streaming 400 components took 214 times as long as compiling them (10 allowed). Now every case passes: the stream cases have ratios of 3.1 to 3.9, and streaming takes 5.3 times as long as compiling.

Conformance, evaluation, sample and docs

  1. The conformance runners read parse_response cases and drive streaming cases through openStream, feed and close against a new, empty state. Compile and round-trip cases check the compiled block on its own with validatePayload, as the Python runner does (compileChecked in suite-helpers.ts). All streaming and four direct JSON unwrap known failures pass and are removed. The legacy process_chunk action and three legacy parse_full cases contradict response_parser.yaml and are marked superseded.
  2. The shared cases under conformance/agent/direct_json/ and conformance/agent/express/ are updated to the ones the Python SDK runs in feat(python/agent)!: make the Parser stateless; always validate; add ResponseStream #3269 (args.history is dropped, and the compile and streaming cases are brought in line with the stateless parser). 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 here, because the Python and Dart SDKs on main still emit a message for a surface that does not exist yet; the TypeScript harness lists them as known failures until feat(python/agent)!: make the Parser stateless; always validate; add ResponseStream #3269 updates them. The rest is identical to feat(python/agent)!: make the Parser stateless; always validate; add ResponseStream #3269.
  3. ts_runner.mjs mirrors the Python bridge: with validate it calls parseResponse; otherwise it only unwraps and compiles.
  4. The Node restaurant_finder sample creates a processor per attempt, streams through processor.openStream(), and drops its per-conversation cache of stream processors.
  5. The README, CHANGELOG and KNOWN_GAPS.md are updated, and conformance/README.md says which TypeScript packages run the timing suites.

Testing

  1. New renderer-state-parsing.test.ts and response-stream.test.ts; streaming.test.ts is removed with the code it tested. The new stream cases cover a callRendererFunction held until it closes, a growing updateComponents emitted whole as it gains components, an id repeated across chunks, an updateDataModel applied again once it closes, and a block that stops emitting after a rejected closed message. renderer-state.test.ts covers markChecked undone by a failed transaction, and renderer-state-parsing.test.ts a v0.9.1 state accepted by a v0.9 processor.
  2. In typescript/agents/a2ui_agent: yarn test 897 passed, 1 expected fail (an unrelated catalog resolver gap); yarn typecheck clean; yarn lint 0 errors; yarn format:check clean; yarn test:timing 10 of 10 passed.
  3. Node sample: 42 tests passed; lint and format clean (unchanged by the merge commits).
  4. uv run pytest tests/test_bridges.py -k ts in eval/: 40 passed. These tests skip unless typescript/a2ui_agent/dist exists, because eval/tests/conftest.py still checks the package's path from before it moved under typescript/agents/.

@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 introduces a major refactoring of the A2UI agent parser and streaming architecture, making the parser stateless and decoupling validation from translation. It replaces the legacy DirectJsonStreamProcessorImpl with a new DirectJsonResponseStream and a custom json-reader to fully align with the streaming conformance suite, using RendererState to track and validate surface updates incrementally. The feedback highlights several performance optimization opportunities in the newly added parsing code (blocks.ts and json-reader.ts), specifically recommending limiting the length of string slices when scanning for tags, words, or escape sequences to avoid unnecessary memory allocations.

Comment thread typescript/agents/a2ui_agent/src/inference-formats/direct-json/blocks.ts Outdated
Comment thread typescript/agents/a2ui_agent/src/inference-formats/direct-json/json-reader.ts Outdated
@nan-yu
nan-yu force-pushed the feat/ts-agent-response-stream branch 2 times, most recently from 2d7feb2 to 5e7bed1 Compare October 16, 2026 19:56
@nan-yu
nan-yu added this pull request to stack #3281 October 16, 2026 19:57
@nan-yu
nan-yu force-pushed the feat/ts-agent-response-stream branch from 5e7bed1 to 30e87e1 Compare October 16, 2026 20:06

@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 refactors @a2ui/agent so Parser is stateless, moves incremental parsing into a per-response ResponseStream (openStream, feed, close), and binds A2uiRequestProcessor to an optional RendererState instead of an internal MessageProcessor. It also ports Python's Direct JSON block scanner (blocks.ts), tolerant JSON reader (json-reader.ts), and DirectJsonResponseStream (response-stream.ts), allowing all v0.9 and v1.0 Direct JSON streaming and unwrap conformance cases to pass.

🤖 Agent-generated

@nan-yu
nan-yu force-pushed the feat/ts-agent-response-stream branch 2 times, most recently from 0f7ba28 to d6bbd83 Compare October 16, 2026 23:07
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 added a commit that referenced this pull request Oct 20, 2026
…te where the SDKs differ

- a2ui_core: RendererState's atomic application undoes a rejected message from
  an undo log rather than rolling back to a copy. Requirement 4 now states only
  what a processor must do under a ValidationConfig that defers the graph
  checks, with no SDK status.
- a2ui_core: the agent example shows the agent using RendererState (apply, then
  checkTopology) and, separately, the MessageProcessor calls RendererState makes
  inside, since an agent does not call MessageProcessor itself.
- a2ui_agent: "Where the SDKs differ" assumes the pending Python (#3326,
  #3268-#3271) and TypeScript (#3327, #3278, #3279) pull requests. Both meet the
  three requirements of "Cost of a response"; the remaining entries are Dart's,
  plus Python being the only SDK with a v0.8 state.

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu
nan-yu removed this pull request from stack #3281 October 20, 2026 17:51
@nan-yu
nan-yu added this pull request to stack #3342 October 20, 2026 17:57
@nan-yu
nan-yu force-pushed the feat/ts-agent-response-stream branch from e488ff2 to 76fb327 Compare October 20, 2026 18:04
…ach chunk once

The parser holds no state. parseResponse and openStream check a response
against a RendererState, a new one per call unless the caller passes one
(or binds one to A2uiRequestProcessor) for a canvas conversation.
A2uiRequestProcessor gains createState and acceptsState, which compares the
catalogs' ids and release lines.

DirectJsonResponseStream reads each chunk once:

1. BlockScanner resumes the close-tag scan where the last chunk left it.
2. readPartialMessages reads through a PartialReadCache, ported from the
   Dart reader: a list or map that closed is kept by offset, and a list of
   messages goes on after the last message that closed.
3. The stream keeps the closed messages and applies each once. Only
   updateComponents and updateDataModel are emitted while they grow; any
   other message, callRendererFunction included, waits for its object to
   close. A growing updateComponents is applied for the components it
   gained, with ids repeated across chunks rejected. A growing
   updateDataModel is checked for the lists and maps its value gained, and
   applied again once it closes.
4. tests/timing/scaling_test.mjs runs conformance/agent/direct_json/
   scaling.yaml and conformance/agent/express/scaling.yaml with
   `yarn test:timing`, using web_core's timing helper, and the ci-timing job
   runs it for @a2ui/agent.

Streaming one updateComponents of 400 components in 40-character chunks
drops from 7.0 s to 0.13 s (ratio 400/100 from 16.0 to 3.9).

The Node restaurant finder sample and the eval TypeScript bridge use the new
API.

The conformance cases under conformance/agent/ are updated to the shared
cases this SDK and the Python SDK (#3269) run: args.history is dropped, and
the streaming cases this stream now passes leave the known failures.
conformance/README.md says the TypeScript agent SDK runs the agent timing
suites with `yarn test:timing`.

Signed-off-by: Nan Yu <nanyu@google.com>
…until #3269

#3269 changes test_stream_update_before_create_surface_fails_v09 and
test_stream_data_model_before_create_surface_fails_v09 to hold back a
message for a surface that does not exist yet. The Python and Dart SDKs on
main still emit it, so carrying the change here failed their CI. The cases
stay as main has them, and the TypeScript harness lists them as known
failures until #3269 lands.

Signed-off-by: Nan Yu <nanyu@google.com>
nan-yu added a commit that referenced this pull request Oct 20, 2026
…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 added a commit that referenced this pull request Oct 20, 2026
…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 added a commit that referenced this pull request Oct 20, 2026
…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 added a commit that referenced this pull request Oct 20, 2026
…d RendererState to A2uiRequestProcessor (#3277)

* feat(dart/agent)!: make the Parser stateless; add ResponseStream; bind 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>

* test(conformance): keep main's SDKs green with the shared streaming cases

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>

---------

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

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

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

This branch has not been deployed

No deployments
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