From aa408d642eae5b04f4e8b9784bc4f7b57548b965 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:49:08 -0700 Subject: [PATCH 1/6] feat(chat): A2UI envelope-args shape normalizer Accepts four argument shapes observed in the streaming-envelope-tool spike: canonical {envelopes:[...]}, singular {envelope:[...]}, positional keys {0,1,...}, and flat single envelope. Returns a canonical envelope list or null. Shared shape with the Python normalizer in PR 2. --- .../src/lib/a2ui/envelope-normalizer.spec.ts | 37 ++++++++++++++++ libs/chat/src/lib/a2ui/envelope-normalizer.ts | 43 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts create mode 100644 libs/chat/src/lib/a2ui/envelope-normalizer.ts diff --git a/libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts b/libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts new file mode 100644 index 000000000..ac9e305e5 --- /dev/null +++ b/libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts @@ -0,0 +1,37 @@ +// libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { normalizeEnvelopeArgs } from './envelope-normalizer'; + +describe('normalizeEnvelopeArgs', () => { + it('returns the list for the canonical {envelopes: [...]} shape', () => { + const args = { envelopes: [{ surfaceUpdate: { surfaceId: 's', components: [] } }] }; + expect(normalizeEnvelopeArgs(args)).toEqual(args.envelopes); + }); + + it('returns the list for the singular {envelope: [...]} typo shape', () => { + const args = { envelope: [{ beginRendering: { surfaceId: 's', root: 'r' } }] }; + expect(normalizeEnvelopeArgs(args)).toEqual(args.envelope); + }); + + it('unflattens positional {0: ..., 1: ...} keys in numeric order', () => { + const e1 = { surfaceUpdate: { surfaceId: 's', components: [] } }; + const e2 = { beginRendering: { surfaceId: 's', root: 'r' } }; + const args = { 1: e2, 0: e1 }; + expect(normalizeEnvelopeArgs(args)).toEqual([e1, e2]); + }); + + it('wraps a flat single envelope into a one-element array', () => { + const args = { surfaceUpdate: { surfaceId: 's', components: [] } }; + expect(normalizeEnvelopeArgs(args)).toEqual([args]); + }); + + it('returns null for an empty object', () => { + expect(normalizeEnvelopeArgs({})).toBeNull(); + }); + + it('returns null for non-object input', () => { + expect(normalizeEnvelopeArgs(null as unknown as Record)).toBeNull(); + expect(normalizeEnvelopeArgs('x' as unknown as Record)).toBeNull(); + }); +}); diff --git a/libs/chat/src/lib/a2ui/envelope-normalizer.ts b/libs/chat/src/lib/a2ui/envelope-normalizer.ts new file mode 100644 index 000000000..86b3d605f --- /dev/null +++ b/libs/chat/src/lib/a2ui/envelope-normalizer.ts @@ -0,0 +1,43 @@ +// libs/chat/src/lib/a2ui/envelope-normalizer.ts +// SPDX-License-Identifier: MIT + +const ENVELOPE_KEYS = ['surfaceUpdate', 'beginRendering', 'dataModelUpdate', 'deleteSurface'] as const; + +/** + * The parent LLM may emit envelope-tool arguments in four shapes (observed in + * the spike across gpt-5-mini and gpt-5): the canonical {envelopes: [...]}, + * a singular typo {envelope: [...]}, positional keys {0: env, 1: env, ...} + * when the model treats the args as the array, or a flat single envelope. + * This pure function maps all four into a canonical envelope list. + * + * Strict-mode tool binding (OpenAI) should eliminate the non-canonical + * shapes in production, but the normalizer is the safety net. + */ +export function normalizeEnvelopeArgs( + args: Record | null | undefined, +): unknown[] | null { + if (!args || typeof args !== 'object' || Array.isArray(args)) return null; + + // (a) canonical: { envelopes: [...] } + if (Array.isArray((args as { envelopes?: unknown }).envelopes)) { + return (args as { envelopes: unknown[] }).envelopes; + } + // (b) singular typo: { envelope: [...] } + if (Array.isArray((args as { envelope?: unknown }).envelope)) { + return (args as { envelope: unknown[] }).envelope; + } + const keys = Object.keys(args); + if (keys.length === 0) return null; + // (c) positional keys: { 0: env, 1: env, ... } + if (keys.every((k) => /^\d+$/.test(k))) { + return keys + .map((k) => Number(k)) + .sort((a, b) => a - b) + .map((k) => (args as Record)[String(k)]); + } + // (d) flat single envelope: { surfaceUpdate: {...} } | { beginRendering: ... } | etc + if (ENVELOPE_KEYS.some((k) => k in args)) { + return [args]; + } + return null; +} From 36b0bb2f08703e5d53a670a6ebe557a115be2973 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:49:44 -0700 Subject: [PATCH 2/6] feat(chat): A2UI surface-store applyPartialArgs entry point Adds a live-stream feed alongside apply(). Records the tool_call_id so downstream consumers (content classifier) can short-circuit duplicate envelope dispatch when the final wrapped AIMessage arrives carrying the same content the live stream already applied. --- libs/chat/src/lib/a2ui/surface-store.spec.ts | 27 +++++++++++++++++ libs/chat/src/lib/a2ui/surface-store.ts | 32 +++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/lib/a2ui/surface-store.spec.ts b/libs/chat/src/lib/a2ui/surface-store.spec.ts index ed1305701..1503cb47f 100644 --- a/libs/chat/src/lib/a2ui/surface-store.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-store.spec.ts @@ -167,3 +167,30 @@ describe('A2uiSurfaceStore (v1, deferred-apply)', () => { expect(s.styles).toEqual({ primaryColor: '#0A84FF' }); }); }); + +describe('createA2uiSurfaceStore — applyPartialArgs', () => { + test('dispatches each envelope via apply() in order', () => { + const store = setup(); + const envelopes = [ + { surfaceUpdate: { surfaceId: 's1', components: [{ id: 'c', type: 'text', props: {} }] } }, + { beginRendering: { surfaceId: 's1', root: 'c' } }, + ]; + store.applyPartialArgs('tc-1', envelopes); + expect(store.surfaces().get('s1')?.components.has('c')).toBe(true); + }); + + test('records the tool_call_id as live (queryable)', () => { + const store = setup(); + expect(store.isPartialLive('tc-1')).toBe(false); + store.applyPartialArgs('tc-1', [{ surfaceUpdate: { surfaceId: 's1', components: [] } }]); + expect(store.isPartialLive('tc-1')).toBe(true); + }); + + test('ignores invalid envelopes silently', () => { + const store = setup(); + // missing required top-level key — apply() ignores + store.applyPartialArgs('tc-x', [{ junk: 1 } as never]); + expect(store.surfaces().size).toBe(0); + expect(store.isPartialLive('tc-x')).toBe(true); // still tracked + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface-store.ts b/libs/chat/src/lib/a2ui/surface-store.ts index 5f5a70eb6..23e5eb1e5 100644 --- a/libs/chat/src/lib/a2ui/surface-store.ts +++ b/libs/chat/src/lib/a2ui/surface-store.ts @@ -16,6 +16,14 @@ interface SurfaceBuffer { export interface A2uiSurfaceStore { apply(message: A2uiMessage): void; + /** + * Live-stream entry point. Iterates envelopes and feeds each through + * `apply()`. Records the tool_call_id so the wrapped-content classifier + * can short-circuit duplicate dispatch when the final AIMessage arrives. + */ + applyPartialArgs(toolCallId: string, envelopes: readonly A2uiMessage[]): void; + /** True if a tool_call_id has produced live envelopes via applyPartialArgs. */ + isPartialLive(toolCallId: string): boolean; readonly surfaces: Signal>; surface(surfaceId: string): Signal; } @@ -129,5 +137,27 @@ export function createA2uiSurfaceStore(): A2uiSurfaceStore { return computed(() => surfacesSignal().get(surfaceId)); } - return { apply, surfaces: surfacesSignal.asReadonly(), surface }; + const liveTools = new Set(); + + function applyPartialArgs( + toolCallId: string, + envelopes: readonly A2uiMessage[], + ): void { + liveTools.add(toolCallId); + for (const env of envelopes) { + apply(env); + } + } + + function isPartialLive(toolCallId: string): boolean { + return liveTools.has(toolCallId); + } + + return { + apply, + applyPartialArgs, + isPartialLive, + surfaces: surfacesSignal.asReadonly(), + surface, + }; } From 062f5c02ef20c0722457c1ebbebaf0150c4d4042 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:54:00 -0700 Subject: [PATCH 3/6] feat(chat): A2UI partial-args bridge for streaming envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribes to LangGraph custom events ('a2ui-partial') and feeds the A2UI surface store envelope-by-envelope as the parent LLM streams its tool_call.arguments JSON. Uses @cacheplane/partial-json to extract structurally-complete envelopes from the growing args string. Safety net: synthesises beginRendering immediately after the first surfaceUpdate (targeting component id='root' or the first component), so the surface mounts and the per-component fallback gate (PR #252) fires while dataModelUpdates stream in — without waiting for the LLM to emit beginRendering at the end of its envelope list. Repeated beginRendering on the same surface is idempotent in the store. Poison detection: a JSON-prefix validator runs ahead of the partial parser to detect structurally invalid streams (the partial-json parser silently halts on bad input rather than throwing), so malformed arg streams are dropped and subsequent valid pushes are ignored. --- .../src/lib/a2ui/partial-args-bridge.spec.ts | 94 ++++++ libs/chat/src/lib/a2ui/partial-args-bridge.ts | 283 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts create mode 100644 libs/chat/src/lib/a2ui/partial-args-bridge.ts diff --git a/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts b/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts new file mode 100644 index 000000000..7bdf891e0 --- /dev/null +++ b/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts @@ -0,0 +1,94 @@ +// libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { createPartialArgsBridge } from './partial-args-bridge'; +import { createA2uiSurfaceStore, type A2uiSurfaceStore } from './surface-store'; + +function makeStore(): A2uiSurfaceStore { + let store!: A2uiSurfaceStore; + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + store = createA2uiSurfaceStore(); + }); + return store; +} + +describe('createPartialArgsBridge', () => { + let store: A2uiSurfaceStore; + beforeEach(() => { store = makeStore(); }); + + function chunks(...frames: string[]): readonly string[] { + return frames; + } + + it('extracts a surfaceUpdate envelope as soon as it parses, mounts surface via synthetic beginRendering', () => { + const bridge = createPartialArgsBridge(store); + const frames = chunks( + '{"envelopes":[', + '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}', + '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}},', + ); + for (const f of frames) bridge.push('tc-1', f); + // After surfaceUpdate parses and bridge synthesises beginRendering, the surface materialises. + expect(store.surfaces().get('s')?.components.has('root')).toBe(true); + }); + + it('does not synthesise twice if the LLM emits its own beginRendering later', () => { + const bridge = createPartialArgsBridge(store); + const surfaceUpdate = JSON.stringify({ surfaceUpdate: { surfaceId: 's', components: [{ id: 'root', type: 'text', props: {} }] } }); + const beginRendering = JSON.stringify({ beginRendering: { surfaceId: 's', root: 'root' } }); + bridge.push('tc-2', '{"envelopes":[' + surfaceUpdate + ',' + beginRendering + ']}'); + // Same surface, single mount — components map unchanged across the second beginRendering. + const surface = store.surfaces().get('s'); + expect(surface).toBeTruthy(); + expect(surface!.components.size).toBe(1); + }); + + it('handles the singular {envelope:[...]} shape', () => { + const bridge = createPartialArgsBridge(store); + bridge.push('tc-3', '{"envelope":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}]}'); + expect(store.surfaces().get('s')?.components.has('root')).toBe(true); + }); + + it('handles positional keys {0: env, 1: env}', () => { + const bridge = createPartialArgsBridge(store); + const envs = [ + { surfaceUpdate: { surfaceId: 's', components: [{ id: 'root', type: 'text', props: {} }] } }, + { dataModelUpdate: { surfaceId: 's', contents: [{ key: 'msg', valueString: 'hi' }] } }, + ]; + bridge.push('tc-4', JSON.stringify({ 0: envs[0], 1: envs[1] })); + expect(store.surfaces().get('s')?.dataModel).toEqual({ msg: 'hi' }); + }); + + it('marks tool_call_id as live in the store', () => { + const bridge = createPartialArgsBridge(store); + bridge.push('tc-5', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[]}}]}'); + expect(store.isPartialLive('tc-5')).toBe(true); + }); + + it('does not dispatch the same envelope twice across incremental pushes', () => { + const bridge = createPartialArgsBridge(store); + const piece1 = '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}'; + const piece2 = piece1 + ',{"dataModelUpdate":{"surfaceId":"s","contents":[{"key":"k","valueString":"v"}]}}]}'; + bridge.push('tc-6', piece1); + bridge.push('tc-6', piece2); + // The dataModelUpdate appears only in the second push but bridge re-runs the parser + // against the cumulative buffer; the surfaceUpdate envelope must NOT re-dispatch. + expect(store.surfaces().get('s')?.dataModel).toEqual({ k: 'v' }); + }); + + it('marks tool_call_id as poisoned if a chunk is invalid JSON garbage', () => { + const bridge = createPartialArgsBridge(store); + bridge.push('tc-7', '{{{not_json'); + // Subsequent valid pushes are ignored once poisoned. + bridge.push('tc-7', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[]}}]}'); + expect(store.surfaces().size).toBe(0); + }); + + it('synthetic beginRendering picks first component when none has id="root"', () => { + const bridge = createPartialArgsBridge(store); + bridge.push('tc-8', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"only","type":"text","props":{}}]}}]}'); + expect(store.surfaces().get('s')?.components.has('only')).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/a2ui/partial-args-bridge.ts b/libs/chat/src/lib/a2ui/partial-args-bridge.ts new file mode 100644 index 000000000..2ac7a6c3b --- /dev/null +++ b/libs/chat/src/lib/a2ui/partial-args-bridge.ts @@ -0,0 +1,283 @@ +// libs/chat/src/lib/a2ui/partial-args-bridge.ts +// SPDX-License-Identifier: MIT +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; +import type { A2uiMessage, A2uiSurfaceUpdate } from '@ngaf/a2ui'; +import type { A2uiSurfaceStore } from './surface-store'; +import { normalizeEnvelopeArgs } from './envelope-normalizer'; + +export interface PartialArgsBridge { + /** + * Replace the cumulative argument-string buffer for `toolCallId` with + * `argsSoFar` and re-extract any newly-complete envelopes. The args + * string is expected to grow monotonically. + */ + push(toolCallId: string, argsSoFar: string): void; + /** True if a tool_call_id has been poisoned by malformed input. */ + isPoisoned(toolCallId: string): boolean; +} + +interface BridgeState { + parser: ReturnType; + /** Number of envelopes already dispatched to the store. */ + dispatchedCount: number; + /** Have we synthesised a beginRendering for this turn yet? */ + synthesised: boolean; + /** surfaceId the synthesised beginRendering targets (to avoid double-mounting). */ + synthesisedSurfaceId: string | null; + /** Once true, all subsequent pushes are ignored. */ + poisoned: boolean; +} + +/** + * Validate that `s` is a syntactically plausible JSON prefix. We can't + * `JSON.parse` an incomplete string, so we run a lightweight scanner that + * follows the grammar and tolerates only truncation at the right edge. + * Returns false if any character violates JSON syntax mid-stream. + * + * The partial-json parser silently halts on bad input (setting an internal + * error flag that is not exposed through its public API), so we use this + * pre-check to detect poisoned streams. + */ +function isValidJsonPrefix(s: string): boolean { + // Stack of expected closers: '}' for objects, ']' for arrays, + // 'k' (object key expected), 'v' (value expected), ',' or ':'. + // We model JSON with a small state machine. Returns true if the input + // is consumable as a prefix of some valid JSON document. + let i = 0; + const len = s.length; + // Outer state: 'value' (expecting any value), 'object-key' (after `{` + // or `,`), 'after-key' (after a key string, expect `:`), 'after-value' + // (after a value, expect `,` or matching close). + type Frame = { container: 'object' | 'array' }; + const stack: Frame[] = []; + let state: 'value' | 'object-key' | 'after-key' | 'after-value' = 'value'; + + function skipWs(): void { + while (i < len) { + const c = s.charCodeAt(i); + if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i++; + else break; + } + } + + function scanString(): boolean { + // Already at opening quote + if (s[i] !== '"') return false; + i++; + while (i < len) { + const c = s[i]; + if (c === '\\') { + i++; + if (i >= len) return true; // truncated escape + i++; + continue; + } + if (c === '"') { i++; return true; } + i++; + } + // Truncated mid-string is OK for a prefix. + return true; + } + + function scanLiteral(lit: string): boolean { + // Match as much of `lit` as remains in input. Truncation OK. + let j = 0; + while (i < len && j < lit.length) { + if (s[i] !== lit[j]) return false; + i++; j++; + } + return true; + } + + function scanNumber(): boolean { + // Lenient: consume digits, '.', 'e', 'E', '+', '-' starting from current + if (s[i] === '-') i++; + while (i < len) { + const c = s[i]; + if ((c >= '0' && c <= '9') || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-') i++; + else break; + } + return true; + } + + while (i < len) { + skipWs(); + if (i >= len) break; + const c = s[i]; + + if (state === 'value') { + if (c === '{') { + i++; stack.push({ container: 'object' }); state = 'object-key'; + } else if (c === '[') { + i++; stack.push({ container: 'array' }); state = 'value'; + } else if (c === '"') { + if (!scanString()) return false; + state = 'after-value'; + } else if (c === 't') { if (!scanLiteral('true')) return false; state = 'after-value'; } + else if (c === 'f') { if (!scanLiteral('false')) return false; state = 'after-value'; } + else if (c === 'n') { if (!scanLiteral('null')) return false; state = 'after-value'; } + else if (c === '-' || (c >= '0' && c <= '9')) { if (!scanNumber()) return false; state = 'after-value'; } + else if (c === ']' && stack.length > 0 && stack[stack.length - 1].container === 'array') { + // empty array close + i++; stack.pop(); state = 'after-value'; + } else { + return false; + } + } else if (state === 'object-key') { + if (c === '"') { + if (!scanString()) return false; + state = 'after-key'; + } else if (c === '}' && stack.length > 0 && stack[stack.length - 1].container === 'object') { + i++; stack.pop(); state = 'after-value'; + } else { + return false; + } + } else if (state === 'after-key') { + if (c === ':') { i++; state = 'value'; } + else return false; + } else if (state === 'after-value') { + if (stack.length === 0) { + // Trailing content after top-level value is invalid. + return false; + } + const top = stack[stack.length - 1]; + if (c === ',') { + i++; + state = top.container === 'object' ? 'object-key' : 'value'; + } else if (c === '}' && top.container === 'object') { + i++; stack.pop(); state = 'after-value'; + } else if (c === ']' && top.container === 'array') { + i++; stack.pop(); state = 'after-value'; + } else { + return false; + } + } + } + return true; +} + +/** + * Subscribes to LangGraph custom events of name 'a2ui-partial' and feeds + * the surface store envelope-by-envelope as the parent LLM streams its + * tool_call.arguments JSON. Uses @cacheplane/partial-json to extract + * structurally-complete envelope objects from the growing args string. + * + * Synthesis safety net: if the first complete surfaceUpdate arrives and + * no beginRendering has been extracted yet, the bridge synthesises one + * targeted at the surfaceUpdate's first component (preferring id='root' + * if present). This makes the surface mount IMMEDIATELY after the first + * surfaceUpdate parses — without waiting for the LLM to emit beginRendering + * at the end of its envelope list — so the render-element fallback gate + * (PR #252) actually fires while dataModelUpdates flow in. + * + * The store's apply() already treats repeated beginRendering for the same + * surfaceId as idempotent (just re-applies styles), so the LLM's eventual + * beginRendering (if any) is a no-op rather than a conflict. + */ +export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBridge { + const states = new Map(); + + function stateOf(toolCallId: string): BridgeState { + let s = states.get(toolCallId); + if (!s) { + s = { + parser: createPartialJsonParser(), + dispatchedCount: 0, + synthesised: false, + synthesisedSurfaceId: null, + poisoned: false, + }; + states.set(toolCallId, s); + } + return s; + } + + function pickRoot(components: readonly { id: string }[]): string | null { + if (components.length === 0) return null; + const explicitRoot = components.find((c) => c.id === 'root'); + return explicitRoot ? explicitRoot.id : components[0].id; + } + + function push(toolCallId: string, argsSoFar: string): void { + const state = stateOf(toolCallId); + if (state.poisoned) 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); + } catch { + state.poisoned = true; + return; + } + const rootNode = state.parser.getByPath('/'); + if (!rootNode) return; + const materialised = materialize(rootNode) as Record | null; + if (!materialised || typeof materialised !== 'object') return; + const envelopes = normalizeEnvelopeArgs(materialised); + if (!envelopes) return; + + // Dispatch envelopes that haven't been dispatched yet. partial-json + // returns partially-built objects too; skip incomplete ones (those + // missing the discriminator top-level key). + const ready: A2uiMessage[] = []; + let realDispatched = 0; + for (let i = 0; i < envelopes.length; i++) { + const env = envelopes[i] as A2uiMessage; + if (!isStructurallyComplete(env)) continue; + // Only dispatch envelopes BEYOND those already sent. + if (i < state.dispatchedCount) { + // already dispatched; do not re-send. + continue; + } + ready.push(env); + realDispatched++; + // Inject synthesised beginRendering immediately after first surfaceUpdate. + const synth = synthesisedBegin(env, state); + if (synth) ready.push(synth); + } + if (ready.length === 0) return; + + state.dispatchedCount += realDispatched; + store.applyPartialArgs(toolCallId, ready); + } + + function synthesisedBegin(env: A2uiMessage, state: BridgeState): A2uiMessage | null { + if (state.synthesised || !('surfaceUpdate' in env)) return null; + const upd = (env as { surfaceUpdate: A2uiSurfaceUpdate }).surfaceUpdate; + const root = pickRoot(upd.components); + if (!root) return null; + state.synthesised = true; + state.synthesisedSurfaceId = upd.surfaceId; + return { beginRendering: { surfaceId: upd.surfaceId, root } }; + } + + function isPoisoned(toolCallId: string): boolean { + return stateOf(toolCallId).poisoned; + } + + return { push, isPoisoned }; +} + +/** True if the envelope has a recognised discriminator key with an object value. */ +function isStructurallyComplete(env: unknown): env is A2uiMessage { + if (!env || typeof env !== 'object' || Array.isArray(env)) return false; + const obj = env as Record; + for (const k of ['surfaceUpdate', 'beginRendering', 'dataModelUpdate', 'deleteSurface']) { + if (k in obj && typeof obj[k] === 'object' && obj[k] !== null) { + // For surfaceUpdate, also require non-undefined surfaceId + components. + if (k === 'surfaceUpdate') { + const su = obj[k] as { surfaceId?: unknown; components?: unknown }; + return typeof su.surfaceId === 'string' && Array.isArray(su.components); + } + return true; + } + } + return false; +} From 3d401d871e3b499c0d0743e7c9fef702aba30292 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:55:27 -0700 Subject: [PATCH 4/6] feat(chat): wire partial-args bridge to agent.customEvents The chat composition subscribes to LangGraph custom events of name a2ui-partial and forwards the cumulative args strings to the bridge. Effect tracks last-processed index so re-renders don't re-dispatch. Adapters that don't expose a customEvents signal (the runtime-neutral Agent contract makes it optional) are tolerated via feature detection; those continue to rely on the wrapped final-message classifier path. --- .../compositions/chat/chat.component.spec.ts | 54 +++++++++++++++++++ .../lib/compositions/chat/chat.component.ts | 36 +++++++++++++ 2 files changed, 90 insertions(+) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts index 91cc9c30a..8e5d73cfb 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -9,6 +9,8 @@ import { ChatComponent } from './chat.component'; import { messageContent } from '../shared/message-utils'; import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; import { mockAgent } from '../../testing/mock-agent'; +import { createPartialArgsBridge } from '../../a2ui/partial-args-bridge'; +import { createA2uiSurfaceStore } from '../../a2ui/surface-store'; import { signalStateStore } from '@ngaf/render'; import type { AgentEvent } from '../../agent/agent-event'; @@ -405,6 +407,58 @@ describe('ChatComponent — isGenuiTurn', () => { }); }); +describe('ChatComponent — partial-args bridge wiring', () => { + it('feeds the bridge when a2ui-partial custom events arrive on the agent', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + // Mirror the constructor effect's logic: pull customEvents off the + // agent, iterate from last-seen index, forward a2ui-partial events + // through the bridge into the surface store. + const store = createA2uiSurfaceStore(); + const bridge = createPartialArgsBridge(store); + const events = signal<{ name: string; data: unknown }[]>([]); + let lastIndex = 0; + effect(() => { + const evs = events(); + for (let i = lastIndex; i < evs.length; i++) { + const e = evs[i]; + if (e.name !== 'a2ui-partial') continue; + const d = e.data as { tool_call_id?: string; args_so_far?: string } | null; + if (!d || typeof d.tool_call_id !== 'string' || typeof d.args_so_far !== 'string') continue; + bridge.push(d.tool_call_id, d.args_so_far); + } + lastIndex = evs.length; + }); + // Initially no surfaces. + expect(store.surfaces().size).toBe(0); + + events.set([{ + name: 'a2ui-partial', + data: { + tool_call_id: 'tc-1', + args_so_far: '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}]}', + }, + }]); + TestBed.tick(); + + // After effect flushes, surface is materialised via the synthesised beginRendering. + const surface = store.surfaces().get('s'); + expect(surface).toBeTruthy(); + expect(surface!.components.has('root')).toBe(true); + }); + }); + + it('chat.component.ts wires partial-args bridge into the constructor', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const url = await import('node:url'); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const src = fs.readFileSync(path.join(here, 'chat.component.ts'), 'utf8'); + expect(src.includes('createPartialArgsBridge')).toBe(true); + expect(src.includes('a2ui-partial')).toBe(true); + }); +}); + describe('ChatComponent — no bubble-level GenUI skeleton', () => { // Regression for the progressive-GenUI cleanup: the chat composition // template must NOT render from within the diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index abd744b4e..110cc73af 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -31,6 +31,8 @@ import { ChatSelectComponent, type ChatSelectOption } from '../../primitives/cha import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; import { ChatScrollBubbleComponent } from '../../primitives/chat-scroll-bubble/chat-scroll-bubble.component'; import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; +import { createPartialArgsBridge, type PartialArgsBridge } from '../../a2ui/partial-args-bridge'; +import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../../a2ui/surface-store'; import { messageContent } from '../shared/message-utils'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import type { ChatRenderEvent } from './chat-render-event'; @@ -340,6 +342,15 @@ export class ChatComponent { private readonly destroyRef = inject(DestroyRef); private eventsSubscribed = false; + /** + * Shared A2UI surface store fed by the live partial-args bridge. The + * content-classifier path will share this store via tool_call_id + * short-circuit (skipping re-dispatch for live tool_call_ids). + */ + protected readonly liveSurfaceStore: A2uiSurfaceStore = createA2uiSurfaceStore(); + private readonly partialBridge: PartialArgsBridge = createPartialArgsBridge(this.liveSurfaceStore); + private partialEventsLastIndex = 0; + private readonly scrollContainer = viewChild>('scrollContainer'); private readonly messageCount = computed(() => this.agent().messages().length); private prevMessageCount = 0; @@ -410,6 +421,31 @@ export class ChatComponent { } }); + // Subscribe to a2ui-partial custom events from the LangGraph backend. + // Each event delivers a cumulative args string keyed by tool_call_id; + // bridge.push() re-parses and dispatches new envelopes incrementally. + // The runtime-neutral Agent contract does not require a customEvents + // signal, so we feature-detect: adapters that expose it (e.g. + // LangGraphAgent's customEvents signal) light up live streaming; + // others continue to use the wrapped final-message classifier path. + effect(() => { + let agent: ReturnType; + try { agent = this.agent(); } catch { return; } + const customSig = (agent as unknown as { + customEvents?: () => readonly { name: string; data: unknown }[]; + }).customEvents; + if (typeof customSig !== 'function') return; + const events = customSig(); + for (let i = this.partialEventsLastIndex; i < events.length; i++) { + const e = events[i]; + if (e.name !== 'a2ui-partial') continue; + const d = e.data as { tool_call_id?: string; args_so_far?: string } | null; + if (!d || typeof d.tool_call_id !== 'string' || typeof d.args_so_far !== 'string') continue; + this.partialBridge.push(d.tool_call_id, d.args_so_far); + } + this.partialEventsLastIndex = events.length; + }); + effect(() => { // janitor: drop classifiers for messages no longer in the agent's list let liveIds: Set; From d48419dd82164213ae5f473931c6e7ccedad7626 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:56:13 -0700 Subject: [PATCH 5/6] feat(chat): export partial-args bridge + normalizer --- libs/chat/src/public-api.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index dde8d916a..16a6cdf5b 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -163,6 +163,9 @@ export type { ParseTreeStore, ElementAccumulationState } from './lib/streaming/p // A2UI export { createA2uiSurfaceStore } from './lib/a2ui/surface-store'; export type { A2uiSurfaceStore } from './lib/a2ui/surface-store'; +export { createPartialArgsBridge } from './lib/a2ui/partial-args-bridge'; +export type { PartialArgsBridge } from './lib/a2ui/partial-args-bridge'; +export { normalizeEnvelopeArgs } from './lib/a2ui/envelope-normalizer'; export { A2uiSurfaceComponent } from './lib/a2ui/surface.component'; export { surfaceToSpec } from './lib/a2ui/surface-to-spec'; export { buildA2uiActionMessage } from './lib/a2ui/build-action-message'; From ebad09cd362b629dda84016148a3c33f75d90a4e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 14:56:13 -0700 Subject: [PATCH 6/6] chore: regenerate api-docs for partial-args bridge --- .../content/docs/chat/api/api-docs.json | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index c07987a3e..a3b3993fc 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1575,6 +1575,12 @@ "description": "", "optional": false }, + { + "name": "liveSurfaceStore", + "type": "A2uiSurfaceStore", + "description": "Shared A2UI surface store fed by the live partial-args bridge. The\ncontent-classifier path will share this store via tool_call_id\nshort-circuit (skipping re-dispatch for live tool_call_ids).", + "optional": false + }, { "name": "messageContent", "type": "object", @@ -4857,6 +4863,18 @@ "description": "", "optional": false }, + { + "name": "applyPartialArgs", + "type": "unknown", + "description": "", + "optional": false + }, + { + "name": "isPartialLive", + "type": "unknown", + "description": "", + "optional": false + }, { "name": "surface", "type": "unknown", @@ -5742,6 +5760,26 @@ ], "examples": [] }, + { + "name": "PartialArgsBridge", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "isPoisoned", + "type": "unknown", + "description": "", + "optional": false + }, + { + "name": "push", + "type": "unknown", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "ResolvedCitation", "kind": "interface", @@ -6195,6 +6233,25 @@ }, "examples": [] }, + { + "name": "createPartialArgsBridge", + "kind": "function", + "description": "Subscribes to LangGraph custom events of name 'a2ui-partial' and feeds\nthe surface store envelope-by-envelope as the parent LLM streams its\ntool_call.arguments JSON. Uses @cacheplane/partial-json to extract\nstructurally-complete envelope objects from the growing args string.\n\nSynthesis safety net: if the first complete surfaceUpdate arrives and\nno beginRendering has been extracted yet, the bridge synthesises one\ntargeted at the surfaceUpdate's first component (preferring id='root'\nif present). This makes the surface mount IMMEDIATELY after the first\nsurfaceUpdate parses — without waiting for the LLM to emit beginRendering\nat the end of its envelope list — so the render-element fallback gate\n(PR #252) actually fires while dataModelUpdates flow in.\n\nThe store's apply() already treats repeated beginRendering for the same\nsurfaceId as idempotent (just re-applies styles), so the LLM's eventual\nbeginRendering (if any) is a no-op rather than a conflict.", + "signature": "createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBridge", + "params": [ + { + "name": "store", + "type": "A2uiSurfaceStore", + "description": "", + "optional": false + } + ], + "returns": { + "type": "PartialArgsBridge", + "description": "" + }, + "examples": [] + }, { "name": "emitBinding", "kind": "function", @@ -6517,6 +6574,25 @@ }, "examples": [] }, + { + "name": "normalizeEnvelopeArgs", + "kind": "function", + "description": "The parent LLM may emit envelope-tool arguments in four shapes (observed in\nthe spike across gpt-5-mini and gpt-5): the canonical {envelopes: [...]},\na singular typo {envelope: [...]}, positional keys {0: env, 1: env, ...}\nwhen the model treats the args as the array, or a flat single envelope.\nThis pure function maps all four into a canonical envelope list.\n\nStrict-mode tool binding (OpenAI) should eliminate the non-canonical\nshapes in production, but the normalizer is the safety net.", + "signature": "normalizeEnvelopeArgs(args: Record | null | undefined): unknown[] | null", + "params": [ + { + "name": "args", + "type": "Record | null | undefined", + "description": "", + "optional": false + } + ], + "returns": { + "type": "unknown[] | null", + "description": "" + }, + "examples": [] + }, { "name": "provideChat", "kind": "function",