Repository navigation
Conversation
There was a problem hiding this comment.
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.
6b8c37f to
b69595f
Compare
gspencergoog
left a comment
There was a problem hiding this comment.
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
88de7d9 to
fcbea88
Compare
fcbea88 to
963d7c7
Compare
…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>
840b98b to
334c1dc
Compare
…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>
334c1dc to
e730a92
Compare
polina-c
left a comment
There was a problem hiding this comment.
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:
- 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
undefinedelement (line 486), so0 in listbecomes true. Both show inJSON.stringifyand in the data model a surface withsendDataModelsends. - When
fnreturns a promise, the transaction rolls back and throwsTypeError, but the promise keeps running (line 256). Messages it applies after its firstawaitare applied outside any frame, and a rejection from it is unhandled. The type blocks this in typed code, so it affects onlyanyand 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
…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
left a comment
There was a problem hiding this comment.
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
validatePayloadchanges - 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:
-
yarn typecheckpasses at the head, because this PR updatesparser.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 ina2ui_agent, 1011 passed inweb_core, and 4 passed intest:timing. -
"Disabling the property restore or the replay" still mentions the replay, which b45ee6d removed.
-
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
…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>
…-agent-renderer-state Signed-off-by: Nan Yu <nanyu@google.com>
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 anupdateComponentsagainst 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.validatePayloadin the TypeScript agent SDK built a new web_coreMessageProcessorfor the surfaces a payload creates, and one more for each candidate catalog of each surface the payload only updates.RendererState, exported from@a2ui/agent. It wraps one web_coreMessageProcessor, created once with the linked catalogs, with dangling references, a missing root and orphans allowed while messages are applied, and unknown components still rejected.apply(payload): string[]checks and applies messages atomically.transaction(fn)extends that to everythingfnapplies. Transactions nest. Its type rejects a callback that returns a promise at compile time, and at run time it rolls back and throwsTypeErrorif one is passed anyway. Until that promise settles, the state takes no changes, so nothing the promise applies after its firstawaitlands outside a frame, and its rejection is handled (transaction).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.A2uiCatalogError(constructor).RendererStatesection describes, instead of copying a surface the first time anapplyor 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 throughprocess, which records what it replaces:updateComponentsrecords 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 newComponentModelat 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_coreSurfaceComponentsModel.insertComponent(index, component). The processor's forward behavior is unchanged.updateDataModelrecords 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).surfaceRestorer).checkTopologyclears (setAssumedtoforgetChanged).validatePayloadruns 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 withSurface 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.compilereturns a block that holds one message as a list of one.checkParserCatalogsmoves tosrc/utils/catalog-checks.ts, sovalidation.tsandrenderer-state.tsdon't import each other.RendererState,validatePayloadand theSurfaceModeltype are exported from the package root.Applying 400 one-component messages to one surface, each in its own transaction, with #3327 merged:
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
renderer-state.test.tsandvalidation.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 laterdeleteSurfaceor type change of the same component in the same frame, which used to move the component last.yarn testintypescript/agents/a2ui_agent: 832 passed, 51 expected fail, 59 skipped.yarn testintypescript/core/web_core: 1011 passed, andyarn test:timing: 4 passed.yarn typecheckpasses ina2ui_agent, andyarn lintreports 0 errors andyarn format:checkis clean in both packages.