From c6d6f61d997d877b582753e6a6c786ab0df9b5b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 24 Sep 2026 15:00:53 -0700 Subject: [PATCH] feat(ag-ui): preserve owned protocol transcripts at request egress --- .../ag-ui/src/runtime/transcript-http.spec.ts | 374 ++++++++++++++++ libs/ag-ui/src/runtime/transcript.spec.ts | 423 ++++++++++++++++++ libs/ag-ui/src/runtime/transcript.ts | 73 +++ scripts/react-parity/baseline.json | 14 +- scripts/react-parity/dispositions.json | 8 + 5 files changed, 887 insertions(+), 5 deletions(-) create mode 100644 libs/ag-ui/src/runtime/transcript-http.spec.ts create mode 100644 libs/ag-ui/src/runtime/transcript.spec.ts create mode 100644 libs/ag-ui/src/runtime/transcript.ts diff --git a/libs/ag-ui/src/runtime/transcript-http.spec.ts b/libs/ag-ui/src/runtime/transcript-http.spec.ts new file mode 100644 index 000000000..e01212bc1 --- /dev/null +++ b/libs/ag-ui/src/runtime/transcript-http.spec.ts @@ -0,0 +1,374 @@ +import { ok } from 'node:assert/strict'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { + EventType, + type Message, + type MessagesSnapshotEvent, + type RunAgentInput, +} from '@ag-ui/client'; +import { describe, expect, it } from 'vitest'; +import { createRun, type RunHandle } from './create-run'; +import { ownTranscript, requestMessages, type Transcript } from './transcript'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} + +async function bounded(promise: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Transcript HTTP milestone timed out')), + 1500 + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +interface Exchange { + body: RunAgentInput; + closed: Promise; + send: (...events: unknown[]) => void; +} + +async function serve() { + const exchanges: Exchange[] = []; + const arrivals = new Map>>(); + const server = createServer(async (request, response) => { + const closed = deferred(); + response.on('close', () => closed.resolve()); + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const exchange: Exchange = { + body: JSON.parse(Buffer.concat(chunks).toString()), + closed: closed.promise, + send: (...events) => { + for (const event of events) + response.write(`data: ${JSON.stringify(event)}\n\n`); + }, + }; + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.flushHeaders(); + exchanges.push(exchange); + arrivals.get(exchanges.length - 1)?.resolve(exchange); + // Deliberately hold the stream open. The run owner must physically close it. + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/events`, + exchanges, + next: (index = 0) => { + if (exchanges[index]) return Promise.resolve(exchanges[index]); + const arrival = arrivals.get(index) ?? deferred(); + arrivals.set(index, arrival); + return bounded(arrival.promise); + }, + close: async () => { + const closed = once(server, 'close'); + server.close(); + server.closeAllConnections(); + await bounded(closed); + }, + }; +} + +function input(runId: string, messages: Message[] = []): RunAgentInput { + return { + threadId: 'thread', + runId, + messages, + state: {}, + tools: [], + context: [], + forwardedProps: {}, + }; +} + +function lifecycle( + exchange: Exchange, + type: EventType.RUN_STARTED | EventType.RUN_FINISHED +) { + return { type, threadId: exchange.body.threadId, runId: exchange.body.runId }; +} + +const snapshotMessages = [ + { + id: 'system', + role: 'system', + content: 'policy', + name: 'system-name', + metadata: { keep: ['system'] }, + }, + { + id: 'developer', + role: 'developer', + content: 'developer policy', + subagentRunId: '', + encryptedValue: 'developer-encrypted', + }, + { + id: 'user', + role: 'user', + content: [ + { type: 'text', text: 'inspect' }, + { + type: 'image', + source: { type: 'data', value: 'aW1hZ2U=', mimeType: 'image/png' }, + metadata: { nested: [{ label: 'image', nullable: null }] }, + }, + { + type: 'audio', + source: { + type: 'url', + value: 'https://example.test/audio', + mimeType: 'audio/wav', + }, + }, + { + type: 'video', + source: { type: 'data', value: 'dmlkZW8=', mimeType: 'video/mp4' }, + }, + { + type: 'document', + source: { type: 'url', value: 'https://example.test/document' }, + }, + { + type: 'binary', + mimeType: 'application/octet-stream', + data: 'YmluYXJ5', + id: 'binary', + filename: 'legacy.bin', + }, + ], + }, + { + id: 'assistant', + role: 'assistant', + name: 'assistant-name', + encryptedValue: 'message-encrypted', + subagentRunId: 'child', + metadata: { nested: [{ keep: 'message', nullable: null }] }, + toolCalls: [ + { + id: 'call', + type: 'function', + function: { name: 'weather', arguments: '{ "city": "Paris" }' }, + encryptedValue: 'call-encrypted', + metadata: { nested: ['call', null] }, + }, + ], + }, + { + id: 'tool', + role: 'tool', + toolCallId: 'call', + content: 'result', + error: 'provider error', + encryptedValue: 'tool-encrypted', + metadata: { nested: { keep: true } }, + }, + { + id: 'reasoning', + role: 'reasoning', + content: 'private reasoning', + encryptedValue: 'reasoning-encrypted', + metadata: { nested: ['reasoning'] }, + }, + { + id: 'activity', + role: 'activity', + activityType: 'progress', + content: { steps: [{ label: 'working' }] }, + metadata: { keep: 'activity' }, + }, +] satisfies Message[]; + +describe('private transcript actual HTTP egress', () => { + it('captures a normalized rich snapshot and sends its complete allowed data in a second operation', async () => { + const server = await serve(); + const factory = createRun({ url: server.url }); + let observed: Message[] | undefined; + let captured: Transcript | undefined; + let second: RunHandle | undefined; + const first = factory.start(input('capture'), (event) => { + if (event.type !== EventType.MESSAGES_SNAPSHOT) return; + observed = (event as MessagesSnapshotEvent).messages; + captured = ownTranscript(observed); + }); + const firstDone = first.done; + try { + const initial = await server.next(); + // Top-level message extensions are stripped by the SDK event parser; + // record metadata remains observed protocol data. Do not invent lost fields. + const wire = snapshotMessages.map((message) => ({ + ...message, + strippedBySdk: 'not observed', + })); + initial.send( + lifecycle(initial, EventType.RUN_STARTED), + { type: EventType.MESSAGES_SNAPSHOT, messages: wire }, + lifecycle(initial, EventType.RUN_FINISHED) + ); + expect(await bounded(firstDone)).toEqual({ outcome: 'success' }); + await bounded(initial.closed); + ok(observed); + ok(captured); + expect(observed).toStrictEqual(snapshotMessages); + expect(captured).toStrictEqual(observed); + const expected = structuredClone( + observed.filter((message) => message.role !== 'activity') + ); + second = factory.start( + input('continue', requestMessages(captured)), + () => undefined + ); + const secondDone = second.done; + const continuation = await server.next(1); + expect(continuation.body.messages).toStrictEqual(expected); + expect(continuation.body.messages.map((message) => message.role)).toEqual( + ['system', 'developer', 'user', 'assistant', 'tool', 'reasoning'] + ); + const assistant = continuation.body.messages.find( + (message) => message.role === 'assistant' + ); + expect(assistant?.toolCalls?.[0].function.arguments).toBe( + '{ "city": "Paris" }' + ); + expect(captured).toStrictEqual(snapshotMessages); + continuation.send( + lifecycle(continuation, EventType.RUN_STARTED), + lifecycle(continuation, EventType.RUN_FINISHED) + ); + expect(await bounded(secondDone)).toEqual({ outcome: 'success' }); + await bounded(continuation.closed); + expect(server.exchanges).toHaveLength(2); + } finally { + first.abort(); + second?.abort(); + await server.close(); + } + }); + + it.each([false, true])( + 'normalizes local null tags and activity-only=%s on the actual request body', + async (activityOnly) => { + const activity = { + id: 'activity', + role: 'activity', + activityType: 'progress', + content: { nested: [null] }, + } satisfies Message; + const local = activityOnly + ? [activity] + : ([ + { + id: 'null', + role: 'assistant', + subagentRunId: null, + metadata: { subagentRunId: null }, + }, + { + id: 'empty', + role: 'developer', + subagentRunId: '', + content: 'keep', + }, + { id: 'absent', role: 'reasoning', content: 'keep' }, + activity, + ] as unknown as Message[]); // Local null is intentionally outside SDK event schemas. + const captured = ownTranscript(local); + const server = await serve(); + const handle = createRun({ url: server.url }).start( + input('local', requestMessages(captured)), + () => undefined + ); + const done = handle.done; + try { + const exchange = await server.next(); + expect(exchange.body.messages).toStrictEqual( + activityOnly + ? [] + : [ + { + id: 'null', + role: 'assistant', + metadata: { subagentRunId: null }, + }, + local[1], + local[2], + ] + ); + expect(captured).toStrictEqual(local); + exchange.send( + lifecycle(exchange, EventType.RUN_STARTED), + lifecycle(exchange, EventType.RUN_FINISHED) + ); + expect(await bounded(done)).toEqual({ outcome: 'success' }); + await bounded(exchange.closed); + expect(server.exchanges).toHaveLength(1); + } finally { + handle.abort(); + await server.close(); + } + } + ); + + it('propagates local ownership failure through the existing callback contract and closes HTTP', async () => { + const retained = ownTranscript(snapshotMessages); + let captured = retained; + let failure: unknown; + const server = await serve(); + const handle = createRun({ url: server.url }).start( + input('failure'), + (event) => { + if (event.type !== EventType.MESSAGES_SNAPSHOT) return; + const local = [ + { + id: 'user', + role: 'user', + content: '', + metadata: { unsupported: new Date() }, + } satisfies Message, + ]; + try { + captured = ownTranscript(local); + } catch (error) { + failure = error; + throw error; + } + } + ); + const done = handle.done; + try { + const exchange = await server.next(); + exchange.send( + lifecycle(exchange, EventType.RUN_STARTED), + { type: EventType.MESSAGES_SNAPSHOT, messages: [] }, + lifecycle(exchange, EventType.RUN_FINISHED) + ); + const result = await bounded(done); + expect(failure).toBeInstanceOf(TypeError); + expect(result).toEqual({ outcome: 'error', error: failure }); + if (result.outcome === 'error') expect(result.error).toBe(failure); + expect(captured).toBe(retained); + await bounded(exchange.closed); + expect(server.exchanges).toHaveLength(1); + } finally { + handle.abort(); + await server.close(); + } + }); +}); diff --git a/libs/ag-ui/src/runtime/transcript.spec.ts b/libs/ag-ui/src/runtime/transcript.spec.ts new file mode 100644 index 000000000..26c8ffd8d --- /dev/null +++ b/libs/ag-ui/src/runtime/transcript.spec.ts @@ -0,0 +1,423 @@ +import { describe, expect, it } from 'vitest'; +import type { Message, RunAgentInput } from '@ag-ui/client'; +import { ownTranscript, requestMessages, type Transcript } from './transcript'; + +function richMessages() { + return [ + { id: 'system', role: 'system', content: 'system', name: 'policy' }, + { id: 'developer', role: 'developer', content: 'developer' }, + { + id: 'user', + role: 'user', + subagentRunId: '', + content: [ + { type: 'text', text: 'look' }, + { + type: 'image', + source: { type: 'data', value: 'aW1hZ2U=', mimeType: 'image/png' }, + metadata: { nested: [null, { label: 'image' }] }, + }, + { + type: 'audio', + source: { + type: 'url', + value: 'https://example.test/audio', + mimeType: 'audio/wav', + }, + }, + { + type: 'video', + source: { type: 'data', value: 'dmlkZW8=', mimeType: 'video/mp4' }, + }, + { + type: 'document', + source: { type: 'url', value: 'https://example.test/document' }, + }, + { + type: 'binary', + data: 'YmluYXJ5', + mimeType: 'application/octet-stream', + id: 'file', + filename: 'file.bin', + }, + ], + }, + { + id: 'assistant', + role: 'assistant', + subagentRunId: 'child', + encryptedValue: 'opaque-message', + content: undefined, + metadata: { + nested: [{ label: 'original', nullable: null, optional: undefined }], + }, + toolCalls: [ + { + id: 'call', + type: 'function', + function: { name: 'weather', arguments: '{ "city": "Paris" }' }, + encryptedValue: 'opaque-call', + metadata: { nested: ['call'] }, + }, + ], + }, + { + id: 'result', + role: 'tool', + toolCallId: 'call', + content: 'result', + error: 'provider-error', + encryptedValue: 'opaque-result', + metadata: { nested: [null] }, + }, + { + id: 'reasoning', + role: 'reasoning', + content: 'reasoning', + encryptedValue: 'opaque-reasoning', + }, + { + id: 'activity', + role: 'activity', + activityType: 'progress', + content: { steps: [{ label: 'working' }] }, + }, + ] satisfies Message[]; +} + +function expectFrozen(value: unknown): void { + if (value === null || typeof value !== 'object') return; + expect(Object.isFrozen(value)).toBe(true); + for (const child of Object.values(value)) expectFrozen(child); +} + +describe('private transcript ownership', () => { + it('owns all roles and fields without aliasing caller data', () => { + const source = richMessages(); + const expected = structuredClone(source); + const owned = ownTranscript(source); + expect(owned).toStrictEqual(expected); + expectFrozen(owned); + const assistant = source.find((message) => message.role === 'assistant'); + if (!assistant) throw new Error('fixture assistant missing'); + assistant.metadata.nested[0].label = 'changed'; + assistant.toolCalls[0].function.arguments = '{}'; + source.reverse(); + expect(owned).toStrictEqual(expected); + }); + + it('detaches even externally frozen parents with mutable children', () => { + const nested = { value: 'before' }; + const source = Object.freeze([ + { + id: 'user', + role: 'user', + content: 'hello', + metadata: { nested }, + } satisfies Message, + ]); + Object.freeze(source[0]); + const owned = ownTranscript(source); + expect(Object.isFrozen(nested)).toBe(false); + nested.value = 'after'; + expect(owned[0].metadata).toEqual({ nested: { value: 'before' } }); + expectFrozen(owned); + }); + + it('returns independent mutable requests with exact protocol data', () => { + const source = richMessages(); + const owned = ownTranscript(source); + const first = requestMessages(owned); + const second = requestMessages(owned); + const expected = structuredClone( + source.filter((message) => message.role !== 'activity') + ); + expect(first).toStrictEqual(expected); + expect(second).toStrictEqual(expected); + const assistant = first.find((message) => message.role === 'assistant'); + if (!assistant?.toolCalls || !assistant.metadata) + throw new Error('fixture missing'); + expect(assistant.toolCalls[0].function.arguments).toBe( + '{ "city": "Paris" }' + ); + assistant.toolCalls[0].function.arguments = 'mutable'; + assistant.metadata.nested[0].label = 'mutable'; + first.reverse(); + expect(second).toStrictEqual(expected); + expect(owned).toStrictEqual(source); + }); + + it('removes only activity and top-level null child tags', () => { + const source = [ + { + id: 'absent', + role: 'developer', + content: 'keep', + metadata: { subagentRunId: null }, + }, + { id: 'empty', role: 'reasoning', content: 'keep', subagentRunId: '' }, + { id: 'null', role: 'assistant', subagentRunId: null }, + { id: 'undefined', role: 'assistant', subagentRunId: undefined }, + { id: 'child', role: 'assistant', subagentRunId: 'child' }, + { + id: 'activity', + role: 'activity', + activityType: 'progress', + content: {}, + }, + ] as unknown as Message[]; // SDK event schemas reject local null tags. + const owned = ownTranscript(source); + expect(requestMessages(owned)).toStrictEqual([ + source[0], + source[1], + { id: 'null', role: 'assistant' }, + source[3], + source[4], + ]); + expect(owned).toStrictEqual(source); + }); + + it('captures repeatedly without changing older snapshots or trusting them by identity', () => { + const source = richMessages(); + const first = ownTranscript(source); + const again = ownTranscript(first); + source[0].content = 'later'; + const later = ownTranscript(source); + expect(again).toStrictEqual(first); + expect(again).not.toBe(first); + expect(again[0]).not.toBe(first[0]); + expect(first[0].content).toBe('system'); + expect(later[0].content).toBe('later'); + }); + + it('supports empty and activity-only inputs', () => { + const empty = ownTranscript([]); + expectFrozen(empty); + expect(requestMessages(empty)).toEqual([]); + expect(requestMessages(empty)).not.toBe(requestMessages(empty)); + const activity = ownTranscript([richMessages()[6]]); + expect(requestMessages(activity)).toEqual([]); + expect(activity).toHaveLength(1); + }); + + it('preserves null prototypes, dangerous own keys and optional undefined', () => { + const data = Object.create(null); + Object.defineProperties(data, { + constructor: { value: { name: 'data' }, enumerable: true }, + optional: { value: undefined, enumerable: true }, + }); + Object.defineProperty(data, '__proto__', { + value: { polluted: true }, + enumerable: true, + }); + const source = [ + { + id: 'user', + role: 'user', + content: 'hello', + metadata: data, + } satisfies Message, + ]; + const owned = ownTranscript(source); + const output = requestMessages(owned)[0].metadata; + expect(Object.getPrototypeOf(owned[0].metadata)).toBe(null); + expect(Object.getPrototypeOf(output)).toBe(null); + expect(Object.keys(output ?? {})).toEqual([ + 'constructor', + 'optional', + '__proto__', + ]); + expect(output?.['__proto__']).toEqual({ polluted: true }); + expect(output?.constructor).toEqual({ name: 'data' }); + expect(Object.prototype).not.toHaveProperty('polluted'); + expectFrozen(owned); + }); + + it('preserves sparse array shape and enumerable array extensions as data', () => { + const data = new Array(3); + data[1] = { value: undefined }; + Object.defineProperty(data, 'extra', { + value: { label: 'extra' }, + enumerable: true, + }); + Object.defineProperty(data, '__proto__', { + value: { label: 'safe' }, + enumerable: true, + }); + const source = [ + { + id: 'user', + role: 'user', + content: 'hello', + metadata: { data }, + } satisfies Message, + ]; + const owned = ownTranscript(source); + const output = requestMessages(owned)[0].metadata?.data; + expect(output).toStrictEqual(data); + expect(output).toHaveLength(3); + expect(Object.keys(output)).toEqual(['1', 'extra', '__proto__']); + expect(0 in output).toBe(false); + expect(2 in output).toBe(false); + expect(output.extra).not.toBe( + Object.getOwnPropertyDescriptor(data, 'extra')?.value + ); + expect(Object.getPrototypeOf(output)).toBe(Array.prototype); + expectFrozen(owned); + }); + + it('accepts shared acyclic data and owns each occurrence', () => { + const shared = { values: [1, null, undefined] }; + const source = [ + { + id: 'user', + role: 'user', + content: '', + metadata: { left: shared, right: shared }, + } satisfies Message, + ]; + const owned = ownTranscript(source); + const output = requestMessages(owned)[0].metadata; + expect(output).toEqual({ left: shared, right: shared }); + expect(output?.left).not.toBe(shared); + expect(output?.right).not.toBe(shared); + expectFrozen(owned); + }); + + it.each([ + ['date', new Date()], + ['map', new Map()], + ['set', new Set()], + ['typed array', new Uint8Array([1])], + ['function', () => undefined], + ['symbol', Symbol('data')], + ['bigint', BigInt(1)], + [ + 'class', + new (class Data { + value = 1; + })(), + ], + ])( + 'rejects nonportable %s without changing input or earlier snapshots', + (_name, invalid) => { + const earlier = ownTranscript(richMessages()); + const valid = { mutable: ['before'] }; + const source = [ + { + id: 'user', + role: 'user', + content: '', + metadata: { valid, invalid }, + } satisfies Message, + ]; + expect(() => ownTranscript(source)).toThrow(TypeError); + expect(Object.isFrozen(valid)).toBe(false); + expect(Object.isFrozen(valid.mutable)).toBe(false); + valid.mutable.push('after'); + expect(earlier).toStrictEqual(richMessages()); + } + ); + + it('rejects ancestor cycles but leaves caller data mutable', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const source = [ + { + id: 'user', + role: 'user', + content: '', + metadata: cyclic, + } satisfies Message, + ]; + expect(() => ownTranscript(source)).toThrow(TypeError); + expect(Object.isFrozen(cyclic)).toBe(false); + const array: unknown[] = []; + array.push(array); + source[0].metadata = { array }; + expect(() => ownTranscript(source)).toThrow(TypeError); + }); + + it('propagates accessor failures atomically and ignores nonenumerable fields', () => { + const error = { opaque: 'getter failed' }; + const valid = { mutable: [] }; + const metadata = { valid }; + Object.defineProperty(metadata, 'hidden', { + get: () => { + throw error; + }, + }); + const source = [ + { id: 'user', role: 'user', content: '', metadata } satisfies Message, + ]; + expect(ownTranscript(source)[0].metadata).toEqual({ valid }); + Object.defineProperty(metadata, 'failure', { + enumerable: true, + get: () => { + throw error; + }, + }); + let caught: unknown; + try { + ownTranscript(source); + } catch (failure) { + caught = failure; + } + expect(caught).toBe(error); + expect(Object.isFrozen(valid)).toBe(false); + expect(Object.isFrozen(valid.mutable)).toBe(false); + }); + + it('owns unknown own fields without interpreting roles, required fields or arguments', () => { + const extension = { nested: [null, undefined] }; + const source = [ + { role: 'future', extension }, + { + id: 'duplicate', + role: 'assistant', + toolCalls: [{ function: { arguments: '{ unfinished' } }], + }, + { id: 'duplicate' }, + ] as unknown as Message[]; + expect(requestMessages(ownTranscript(source))).toStrictEqual(source); + expectFrozen(ownTranscript(source)); + }); + + it('retains the display-only reconstruction negative control', () => { + const source = richMessages(); + const displayOnly = source.map(({ id, role, content }) => ({ + id, + role, + content, + })); + expect(displayOnly).not.toStrictEqual(source); + expect(requestMessages(ownTranscript(source))).toStrictEqual( + source.filter((message) => message.role !== 'activity') + ); + }); +}); + +// Compile-only checks exercise SDK unknown/any metadata through core DeepReadonly. +function readonlyContract(transcript: Transcript) { + // @ts-expect-error The transcript array is readonly. + transcript.push({ id: 'new', role: 'user', content: '' }); + const message = transcript[0]; + // @ts-expect-error Message fields are readonly. + message.content = 'changed'; + if (message.metadata) { + // @ts-expect-error Metadata cannot be assigned through the readonly record. + message.metadata['nested'] = {}; + const nested = message.metadata['nested']; + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + // @ts-expect-error Nested metadata remains readonly. + nested['value'] = 'changed'; + } + } + if (message.role === 'user' && typeof message.content !== 'string') { + // @ts-expect-error Nested content is readonly. + message.content[0].type = 'text'; + } + const outgoing: RunAgentInput['messages'] = requestMessages(transcript); + outgoing.push({ id: 'new', role: 'user', content: '' }); +} +void readonlyContract; diff --git a/libs/ag-ui/src/runtime/transcript.ts b/libs/ag-ui/src/runtime/transcript.ts new file mode 100644 index 000000000..d69c1c289 --- /dev/null +++ b/libs/ag-ui/src/runtime/transcript.ts @@ -0,0 +1,73 @@ +import type { Message } from '@ag-ui/client'; +import type { DeepReadonly } from '@threadplane/core'; + +export type Transcript = readonly DeepReadonly[]; + +/** Copies portable data by occurrence, with linear cost in the traversed graph. + * Admission does not trust frozen external parents or retain identity caches. */ +function copyData( + value: unknown, + freeze: boolean, + ancestors = new Set() +): unknown { + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) + return value; + if (typeof value !== 'object') + throw new TypeError('Transcript data must be portable plain data'); + const array = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if ( + array + ? prototype !== Array.prototype + : prototype !== Object.prototype && prototype !== null + ) { + throw new TypeError('Transcript data must use plain objects or arrays'); + } + if (ancestors.has(value)) + throw new TypeError('Transcript data must be acyclic'); + ancestors.add(value); + try { + const copy = array ? new Array(value.length) : Object.create(prototype); + for (const key of Object.keys(value)) { + // Define data properties directly: __proto__ and constructor are payload. + Object.defineProperty(copy, key, { + value: copyData( + (value as Record)[key], + freeze, + ancestors + ), + enumerable: true, + writable: true, + configurable: true, + }); + } + return freeze ? Object.freeze(copy) : copy; + } finally { + ancestors.delete(value); + } +} + +/** Captures full observed protocol data without interpreting or normalizing it. */ +export function ownTranscript( + messages: readonly Message[] | Transcript +): Transcript { + return copyData(messages, true) as Transcript; +} + +/** Explicit SDK preparation policy; each request owns its mutable data graph. */ +export function requestMessages(transcript: Transcript): Message[] { + const messages = copyData( + transcript.filter((message) => message.role !== 'activity'), + false + ) as Message[]; + for (const message of messages) { + if (message.subagentRunId === null) delete message.subagentRunId; + } + return messages; +} diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index a2e4fdd3e..49aa81384 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,12 +1,10 @@ { "schemaVersion": 1, - "baselineHead": "7b64fcb51e06bbf96c3ca0f33a861fd0b2527436", + "baselineHead": "3e7da956bd7e12927412a7a533045f69fcf5c27b", "sourceState": { - "modified": [ - "libs/ag-ui/eslint.config.mjs" - ], + "modified": [], "untracked": [ - "libs/ag-ui/src/runtime/create-run.ts" + "libs/ag-ui/src/runtime/transcript.ts" ] }, "scope": { @@ -10624,6 +10622,12 @@ "path": "libs/ag-ui/src/runtime/create-run.ts", "sha256": "c830bfa2f0832f4c0ea3dfcc5c64360bfbe067227b60b3b86140dd5a686013cb" }, + { + "id": "source:libs/ag-ui/src/runtime/transcript.ts", + "kind": "source", + "path": "libs/ag-ui/src/runtime/transcript.ts", + "sha256": "b35b5af35621c891f9309f3cc6702a71a8b264d959333de45df9334455c56d53" + }, { "id": "source:libs/ag-ui/src/test-setup.ts", "kind": "source", diff --git a/scripts/react-parity/dispositions.json b/scripts/react-parity/dispositions.json index ce8993508..428537d86 100644 --- a/scripts/react-parity/dispositions.json +++ b/scripts/react-parity/dispositions.json @@ -8674,6 +8674,14 @@ "reason": "Private domain run authority only; excluded from public exports and legacy Angular reachability.", "note": "Composes physical HTTP ownership with captured caller identity, synchronous cancellation and first terminal outcome. Framework-neutral session projection, transcript fidelity and public parity remain separate migration work. Child-attributed RUN_* events are unsupported when delivered by the SDK; protobuf decoding can erase attribution." }, + { + "id": "source:libs/ag-ui/src/runtime/transcript.ts", + "taskIds": ["T12", "T13"], + "treatment": "internal", + "status": "in-progress", + "reason": "Private immutable AG-UI transcript ownership and explicit mutable request projection; excluded from public exports and legacy Angular reachability.", + "note": "Preserves complete observed portable protocol data, excludes activity and null top-level child attribution only at request egress. Session snapshot replacement, stream reduction, provider normalization and public parity remain separate migration work." + }, { "id": "source:libs/ag-ui/src/test-setup.ts", "taskIds": [