Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, unknown> | null | undefined): unknown[] | null",
"params": [
{
"name": "args",
"type": "Record<string, unknown> | null | undefined",
"description": "",
"optional": false
}
],
"returns": {
"type": "unknown[] | null",
"description": ""
},
"examples": []
},
{
"name": "provideChat",
"kind": "function",
Expand Down
37 changes: 37 additions & 0 deletions libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)).toBeNull();
expect(normalizeEnvelopeArgs('x' as unknown as Record<string, unknown>)).toBeNull();
});
});
43 changes: 43 additions & 0 deletions libs/chat/src/lib/a2ui/envelope-normalizer.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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, unknown>)[String(k)]);
}
// (d) flat single envelope: { surfaceUpdate: {...} } | { beginRendering: ... } | etc
if (ENVELOPE_KEYS.some((k) => k in args)) {
return [args];
}
return null;
}
94 changes: 94 additions & 0 deletions libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading