Skip to content

feat(ts/agent): add RendererState; build validatePayload on it - #3278

Open
nan-yu wants to merge 6 commits into
perf/ts-core-incremental-component-checksfrom
feat/ts-agent-renderer-state
Open

nan-yu wants to merge 6 commits into
perf/ts-core-incremental-component-checksfrom
feat/ts-agent-renderer-state

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Stacked on #3327 (incremental component checks in web_core).

Part of #3263. It mirrors the Python change in #3268.

It builds on the web_core change in #3327 (perf/ts-core-incremental-component-checks), which checks an updateComponents against the components it changes rather than the whole surface. Without it, the undo log below still removes the surface copies, but each message still costs the size of its surface inside the core.

validatePayload in the TypeScript agent SDK built a new web_core MessageProcessor for the surfaces a payload creates, and one more for each candidate catalog of each surface the payload only updates.

  1. New RendererState, exported from @a2ui/agent. It wraps one web_core MessageProcessor, created once with the linked catalogs, with dangling references, a missing root and orphans allowed while messages are applied, and unknown components still rejected.
    1. apply(payload): string[] checks and applies messages atomically.
    2. transaction(fn) extends that to everything fn applies. Transactions nest. Its type rejects a callback that returns a promise at compile time, and at run time it rolls back and throws TypeError if one is passed anyway. Until that promise settles, the state takes no changes, so nothing the promise applies after its first await lands outside a frame, and its rejection is handled (transaction).
    3. checkTopology(surfaceIds) checks the root and references of each surface, and that every component added or replaced since the last check is reachable. Components an update detached are accepted.
    4. An update to a surface the state never saw created is rejected. The TypeScript SDK doesn't support v0.8, so there is no v0.8 buffering, and the constructor rejects v0.8 catalogs with A2uiCatalogError (constructor).
  2. Rollback uses an undo log, as the blueprint's RendererState section describes, instead of copying a surface the first time an apply or transaction changes it. A frame is a position in the log, and a failure reverts the records after it, latest first, so a failed inner transaction undoes only what it applied. If one record throws, the others still run and the error that started the rollback propagates. Each message is run through process, which records what it replaces:
    1. An updateComponents records the components it adds or replaces (componentsRestorer). The core updates a component in place when its type and catalog stay the same, so the record holds a copy of the previous properties and metadata. A component replaced by one of another type or catalog comes back as a new ComponentModel at its old position: the core puts the replacement last, so for a message that changes a type or catalog the record also holds the old positions (one pass over the surface, only for such a message), and the undo puts the old components back with the new web_core SurfaceComponentsModel.insertComponent(index, component). The processor's forward behavior is unchanged.
    2. An updateDataModel records the value at the first segment of its path that holds no container yet, and a list's length when the write pads it. A hole in a list stays a hole, and a key a message deleted goes back to its place among its object's keys (dataModelRestorer).
    3. A deleted surface is recorded whole and restored straight into the model, not replayed through the processor, which could reject as one message what it accepted one message at a time (such as a data model deeper than one message may be). Each component keeps its own catalog and its position, so a v1.0 surface whose components come from several catalogs comes back as it was, and the data model keeps its root, whatever JSON value that is (surfaceRestorer).
    4. The state's own bookkeeping is logged too: the assumed surfaces, and the components changed since the last tree check, which checkTopology clears (setAssumed to forgetChanged).
  3. validatePayload runs on a short-lived subclass that assumes a surface the payload only updates exists, and tries the candidate catalogs. Results are unchanged with three exceptions, which match Python: (a) a created surface may name a child that a later message of the same payload adds; the old code checked each message strictly and rejected this. (b) The tree of a surface that a later message of the same payload deletes is not checked, though each of its components still is (test). (c) A surface the payload updates, deletes and then creates again is taken to have existed before the payload, so the first update is accepted; the old code rejected it with Surface not found (test). It also accepts one message on its own, as the blueprint and Python do, where it used to reject anything but a list. DirectJsonParser.compile returns a block that holds one message as a list of one.
  4. checkParserCatalogs moves to src/utils/catalog-checks.ts, so validation.ts and renderer-state.ts don't import each other.
  5. RendererState, validatePayload and the SurfaceModel type are exported from the package root.

Applying 400 one-component messages to one surface, each in its own transaction, with #3327 merged:

  • before (surface copies): 201.0 ms
  • after (undo log): 22.2 ms

From 100 to 400 messages, the time grows 9.1 times before (22.1, 61.2 and 201.0 ms for 100, 200 and 400) and 2.2 times after (10.0, 15.1 and 22.2 ms).

Testing

  1. New renderer-state.test.ts and validation.test.ts. The rollback cases cover a failed inner transaction inside one that goes on, an in-place update undone on the same objects, a type change, a surface deleted and created again, data model writes that create containers and pad lists, the changes a tree check cleared, and a v1.0 surface from two catalogs. Disabling the restore of a component updated in place fails nine of the file's tests, and disabling the restore of a deleted surface fails seven. Review follow-up (tests): a primitive, falsy or list data model root after an undone delete, a data model deeper than one message may be, component order after an undone delete and after undone type and v1.0 catalog changes, a deleted key's place, a hole in a padded list, a promise-returning transaction, an undo that throws, and v0.8 catalogs. Each failed before the fix except the deep data model and the order after an undone delete, which already passed and now guard the direct restore. Two more cover an update in place undone after a later deleteSurface or type change of the same component in the same frame, which used to move the component last.
  2. yarn test in typescript/agents/a2ui_agent: 832 passed, 51 expected fail, 59 skipped. yarn test in typescript/core/web_core: 1011 passed, and yarn test:timing: 4 passed. yarn typecheck passes in a2ui_agent, and yarn lint reports 0 errors and yarn format:check is clean in both packages.

@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 RendererState, which mirrors the surfaces a renderer holds and wraps a MessageProcessor to check outbound messages. It also refactors validatePayload to use a short-lived RendererState internally, simplifying the validation logic. Additionally, helper functions and types are exported, and comprehensive unit tests are added. The reviewer suggested improving the transaction method signature in RendererState to leverage TypeScript's type system to prevent passing asynchronous functions at compile-time rather than relying solely on runtime checks.

Comment thread typescript/agents/a2ui_agent/src/utils/renderer-state.ts Outdated
@nan-yu
nan-yu force-pushed the feat/ts-agent-renderer-state branch 2 times, most recently from 6b8c37f to b69595f Compare October 16, 2026 19:56
@nan-yu
nan-yu added this pull request to stack #3281 October 16, 2026 19:57

@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 pull request adds RendererState to @a2ui/agent, rewrites validatePayload as a thin wrapper around a short-lived PayloadState, moves validateEnvelope and checkParserCatalogs into dedicated modules, and removes legacy streaming envelope defaults. The implementation is well-structured and well-tested, with two medium-severity issues in RendererState around candidate catalog selection for assumed surfaces and unreachable version error handling in checkMessage. Flags: default.

🤖 Agent-generated

Comment thread typescript/agents/a2ui_agent/src/utils/renderer-state.ts Outdated
Comment thread typescript/agents/a2ui_agent/src/utils/renderer-state.ts Outdated
@nan-yu
nan-yu force-pushed the feat/ts-agent-renderer-state branch from 88de7d9 to fcbea88 Compare October 16, 2026 22:57
@nan-yu
nan-yu force-pushed the feat/ts-agent-renderer-state branch from fcbea88 to 963d7c7 Compare October 16, 2026 23:07
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 changed the base branch from feat/python-agent-response-stream to perf/ts-core-incremental-component-checks October 20, 2026 17:54
@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-renderer-state branch from 840b98b to 334c1dc Compare October 20, 2026 18:04
…load on it

RendererState wraps one web_core MessageProcessor, created once with the
linked catalogs, with dangling references, a missing root and orphans
allowed while messages are applied.

- apply(payload) checks and applies messages atomically, and transaction(fn)
  extends that to everything fn applies. Transactions nest. A callback that
  returns a promise is rejected at compile time and at run time.
- checkTopology(surfaceIds) checks the root and references of each surface,
  and that every component added or replaced since the last check is
  reachable.
- Rollback uses an undo log rather than copying a surface. Each message
  records what it replaced: the components an updateComponents adds or
  replaces (with a copy of the properties of one updated in place), the
  value an updateDataModel overwrites, and a deleted surface, which is
  restored by replaying it with each component's own catalogId. The state's
  bookkeeping is logged too. A transaction is a position in the log, so a
  failed inner transaction undoes only what it applied.
- validatePayload runs on a short-lived subclass that assumes a surface the
  payload only updates exists, and accepts one message on its own.

Applying 400 one-component messages to one surface, each in a transaction,
takes 2.2 times as long as 100 (9.1 with surface copies).

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

@polina-c polina-c 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.

I found four bugs, which the inline comments explain. Each one reproduces at the head. The repro file is below; test 4 imports the base's validation.ts, saved as src/utils/zz-old-validation.ts, so it can compare the two versions.

At the head, yarn test in typescript/agents/a2ui_agent passes (810 passed, 58 expected fail, 59 skipped). I also fuzzed rollback with random transactions that fail at the end, on v0.9 and two-catalog v1.0 surfaces, and compared the state deeply with the state before each rollback. Apart from the comments below, properties, catalog ids, metadata, the data model and the assumed and changed sets always matched. A differential run of 20,000 random v0.9 payloads through the old and new validatePayload found no payload that the old code accepts and the new one rejects. The linear time holds: n one-component updates, each in its own transaction, took 1.1, 1.8, 3.9 and 9.8 ms for n = 100, 200, 400 and 800.

Two smaller things:

  1. A rolled-back delete of a data model key puts the key back at the end of its object (line 491), and a rolled-back write through a hole in a padded list turns the hole into an undefined element (line 486), so 0 in list becomes true. Both show in JSON.stringify and in the data model a surface with sendDataModel sends.
  2. When fn returns a promise, the transaction rolls back and throws TypeError, but the promise keeps running (line 256). Messages it applies after its first await are applied outside any frame, and a rejection from it is unhandled. The type blocks this in typed code, so it affects only any and JavaScript callers.
Repro file (copy it into tests/unit/utils/ and run npx vitest run tests/unit/utils/zz-review.test.ts --silent=false --reporter=verbose)
import {it} from 'vitest';

import {RendererState, validatePayload} from '../../../src/index.js';
import {Catalog} from '../../../src/internal/web-core.js';
import {validatePayload as oldValidatePayload} from '../../../src/utils/zz-old-validation.js';
import {BASIC_CATALOG_IDS, loadBasicCatalog} from '../../helpers/basic-catalogs.js';

type Message = Record<string, unknown>;
type Component = Record<string, unknown>;

const BASIC = loadBasicCatalog('v0.9');
const CREATE: Message = {
  version: 'v0.9',
  createSurface: {surfaceId: 's', catalogId: BASIC_CATALOG_IDS['v0.9']},
};
const DELETE: Message = {version: 'v0.9', deleteSurface: {surfaceId: 's'}};
const update = (...components: Component[]): Message => ({
  version: 'v0.9',
  updateComponents: {surfaceId: 's', components},
});
const data = (path: string, value?: unknown): Message => ({
  version: 'v0.9',
  updateDataModel: {surfaceId: 's', path, ...(value === undefined ? {} : {value})},
});
const column = (children: string[], id = 'root'): Component => ({id, component: 'Column', children});

/** Runs `fn` in a transaction that then fails, so everything `fn` applied is rolled back. */
function rolledBack(state: RendererState, fn: () => void): void {
  try {
    state.transaction(() => {
      fn();
      throw new Error('the response is rejected');
    });
  } catch {
    // Rolled back.
  }
}

function outcome(fn: () => unknown): string {
  try {
    fn();
    return 'accepted';
  } catch (e) {
    return `${(e as Error).constructor.name}: ${(e as Error).message}`;
  }
}

it('1: undoing a deleteSurface loses a primitive or empty-list root', () => {
  for (const root of ['hello', 42, false, []]) {
    const state = new RendererState([BASIC]);
    state.apply([CREATE, data('/', root)]);
    rolledBack(state, () => state.apply(DELETE));
    console.log(
      `root before ${JSON.stringify(root)}, after rollback ${JSON.stringify(state.getSurface('s')!.dataModel.get('/'))}`,
    );
  }
  const untouched = new RendererState([BASIC]);
  untouched.apply([CREATE, data('/', 'hello')]);
  console.log('write /a, never rolled back:', outcome(() => untouched.apply(data('/a', 1))));
  const state = new RendererState([BASIC]);
  state.apply([CREATE, data('/', 'hello')]);
  rolledBack(state, () => state.apply(DELETE));
  console.log('write /a, after rollback:   ', outcome(() => state.apply(data('/a', 1))));
});

it('2: undoing a type change moves the component to the end', () => {
  const state = new RendererState([BASIC]);
  state.apply([
    CREATE,
    update(column(['a', 'b']), column(['x'], 'a'), column(['y'], 'b')),
  ]);
  const order = () => [...state.getSurface('s')!.componentsModel.keys];
  console.log('order before', order(), '| checkTopology:', outcome(() => state.checkTopology(['s'])));
  rolledBack(state, () => state.apply(update({id: 'a', component: 'Card', child: 'b'})));
  console.log('order after ', order(), '| checkTopology:', outcome(() => state.checkTopology(['s'])));
});

it('4: validatePayload accepts payloads the base rejected', () => {
  const cases: Record<string, Message[]> = {
    'dangling, then deleted': [CREATE, update(column(['missing'])), DELETE],
    'orphan, then deleted': [CREATE, update(column([]), column([], 'orphan')), DELETE],
    'no root, then deleted': [CREATE, update(column([], 'other')), DELETE],
    'update, delete, create': [update(column([])), DELETE, CREATE],
  };
  for (const [name, payload] of Object.entries(cases)) {
    console.log(`${name}: old ${outcome(() => oldValidatePayload([BASIC], payload)).slice(0, 80)} | new ${outcome(() => validatePayload([BASIC], payload))}`);
  }
});

it('5: a v0.8 catalog throws TypeError', () => {
  const v08 = new Catalog('urn:x', 'v0.8', []);
  console.log('constructor:', outcome(() => new RendererState([v08])));
  console.log(
    'validatePayload:',
    outcome(() => validatePayload([v08], [{version: 'v0.8', deleteSurface: {surfaceId: 's'}}])),
  );
});

🤖 Agent-generated

Comment thread typescript/agents/a2ui_agent/src/utils/renderer-state.ts Outdated
Comment thread typescript/agents/a2ui_agent/src/utils/renderer-state.ts
Comment thread typescript/agents/a2ui_agent/src/utils/validation.ts
Comment thread typescript/agents/a2ui_agent/src/utils/renderer-state.ts
…oles exactly

- Restore a deleted surface straight into the model instead of replaying it
  through the processor, keeping any data model root (primitive, [] or {})
  and the order of its components.
- Put a component whose type or catalog change is undone back at its old
  position, using the new SurfaceComponentsModel.insertComponent.
- Put a deleted data model key back at its place, and keep a hole in a padded
  list a hole.
- Keep undoing when one undo throws, and rethrow the error that started the
  rollback.
- While a promise a transaction function returned is running, refuse changes
  and handle its rejection.
- Reject catalogs of an unsupported protocol version, such as v0.8, with
  A2uiCatalogError.
- Document the validatePayload cases that now accept what the old code
  rejected.

Signed-off-by: Nan Yu <nanyu@google.com>
…-agent-renderer-state

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

# Conflicts:
#	typescript/core/web_core/CHANGELOG.md
…-agent-renderer-state

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

@polina-c polina-c 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.

Thanks for the fixes. I checked each of my earlier comments at ef96b63:

  • primitive and falsy data model roots
  • the position after an undone type change
  • the documented validatePayload changes
  • v0.8 catalogs
  • key order and list holes
  • the promise-returning transaction

My earlier repro file now gives the expected result for each.

I also ran a rollback fuzz. It applies random transactions of up to six messages over two v0.9 surfaces, rolls them back, and compares the state before and after: component order, properties and metadata, the data model with key order and holes, the assumed and changed sets, and the first problem checkTopology reports. One bug remains, explained inline. A rollback moves a component to the end when an update changed it in place and a later message in the same frame deleted the surface or changed that component's type. Dart's #3347 fixes the same case.

About the PR description:

  1. yarn typecheck passes at the head, because this PR updates parser.test.ts, so the note about the type error and "the next PR" can go. The test counts have also changed: 829 passed and 51 expected fail in a2ui_agent, 1011 passed in web_core, and 4 passed in test:timing.

  2. "Disabling the property restore or the replay" still mentions the replay, which b45ee6d removed.

  3. Could the timings be a line that names the measurement, followed by a before/after list? For example:

    Applying 400 one-component messages to one surface, each in its own transaction, with #3327 merged:

    • before (surface copies): 201.0 ms
    • after (undo log): 22.2 ms

🤖 Agent-generated

Comment thread typescript/agents/a2ui_agent/src/utils/renderer-state.ts Outdated
Comment thread typescript/core/web_core/src/state/surface-components-model.ts Outdated
…acement

Undoing an update in place now restores the component in place whenever the
current component has the old type and catalog, as Python's _same_kind and
Dart do, so it keeps its position when a later deleteSurface or type change
in the same frame replaced the object. SurfaceComponentsModel.insertComponent
takes (index, component), matching Python and Dart. Add a test and a
CHANGELOG line for DirectJsonParser.compile returning one message as a list
of one.

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu
nan-yu requested a review from polina-c October 20, 2026 22:30
…-agent-renderer-state

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.

3 participants