From d53425f477803dee492e38f03e8fbb36687539b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 18:54:02 -0700 Subject: [PATCH] perf(chat): parse cumulative A2UI arguments incrementally --- .../partial-args-bridge.performance.spec.ts | 41 +++++++++++++++++++ libs/chat/src/lib/a2ui/partial-args-bridge.ts | 16 +++++--- 2 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 libs/chat/src/lib/a2ui/partial-args-bridge.performance.spec.ts diff --git a/libs/chat/src/lib/a2ui/partial-args-bridge.performance.spec.ts b/libs/chat/src/lib/a2ui/partial-args-bridge.performance.spec.ts new file mode 100644 index 000000000..6062b6d34 --- /dev/null +++ b/libs/chat/src/lib/a2ui/partial-args-bridge.performance.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { createPartialArgsBridge } from './partial-args-bridge'; +import { createA2uiSurfaceStore } from './surface-store'; + +const parsed = vi.hoisted(() => ({ bytes: 0 })); +vi.mock('@cacheplane/partial-json', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createPartialJsonParser: (...args: Parameters) => { + const parser = original.createPartialJsonParser(...args); + const push = parser.push.bind(parser); + parser.push = (text: string) => { + parsed.bytes += text.length; + return push(text); + }; + return parser; + }, + }; +}); + +describe('partial argument parsing work', () => { + it('parses each streamed character once, including duplicate cumulative updates', () => { + TestBed.configureTestingModule({}); + const store = TestBed.runInInjectionContext(() => createA2uiSurfaceStore()); + const bridge = createPartialArgsBridge(store); + const args = JSON.stringify({ envelopes: [ + { version: 'v0.9', createSurface: { surfaceId: 'report', catalogId: 'basic' } }, + { version: 'v0.9', updateComponents: { surfaceId: 'report', components: [{ id: 'root', component: 'Text', text: 'Cleanup complete' }] } }, + ] }); + parsed.bytes = 0; + for (let end = 1; end <= args.length; end++) { + bridge.push('research-report', args.slice(0, end)); + bridge.push('research-report', args.slice(0, end)); + } + expect(parsed.bytes).toBe(args.length); + expect(store.surfaces().get('report')?.components.has('root')).toBe(true); + expect(bridge.isPoisoned('research-report')).toBe(false); + }); +}); diff --git a/libs/chat/src/lib/a2ui/partial-args-bridge.ts b/libs/chat/src/lib/a2ui/partial-args-bridge.ts index 98d473cc9..ba4825cd1 100644 --- a/libs/chat/src/lib/a2ui/partial-args-bridge.ts +++ b/libs/chat/src/lib/a2ui/partial-args-bridge.ts @@ -17,6 +17,7 @@ export interface PartialArgsBridge { interface BridgeState { parser: ReturnType; + args: string; /** Number of envelopes already dispatched to the store. */ dispatchedCount: number; /** Surface ids for which a createSurface (real or synthesised) has been @@ -179,6 +180,7 @@ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBri if (!s) { s = { parser: createPartialJsonParser(), + args: '', dispatchedCount: 0, createDispatched: new Set(), poisoned: false, @@ -191,17 +193,21 @@ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBri function push(toolCallId: string, argsSoFar: string): void { const state = stateOf(toolCallId); if (state.poisoned) return; + if (argsSoFar === state.args) return; // Pre-check: poison if the args string isn't a valid JSON prefix. if (!isValidJsonPrefix(argsSoFar)) { state.poisoned = true; return; } try { - // Reset the parser to a fresh state and feed the entire cumulative - // string. The parser is monotonic — same input always yields the - // same tree — so re-parsing is safe and avoids delta-tracking bugs. - state.parser = createPartialJsonParser(); - state.parser.push(argsSoFar); + // Cumulative stream updates normally append. Replaying every previous + // character on each update makes a fast-forward seek quadratic. + if (!argsSoFar.startsWith(state.args)) { + state.parser = createPartialJsonParser(); + state.args = ''; + } + state.parser.push(argsSoFar.slice(state.args.length)); + state.args = argsSoFar; } catch { state.poisoned = true; return;