From c7583b8e003b3b582408cb6de6d543e6534b7d63 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 06:43:18 +0000 Subject: [PATCH] feat(runtime): add Agent Document contracts Establish the immutable v1 protocol vocabulary, bounded render-event sequence, and discriminated invocation props that the Flight dispatcher and later projectors can share without disturbing the synchronous lowerers. --- .changeset/agent-document-contracts.md | 7 + packages/rsc-runtime/README.md | 18 +- packages/rsc-runtime/package.json | 2 +- packages/rsc-runtime/src/agent-document.ts | 453 ++++++++++++++++++ packages/rsc-runtime/src/agent-request.ts | 34 ++ packages/rsc-runtime/src/elements.ts | 72 +++ packages/rsc-runtime/src/index.ts | 39 +- packages/rsc-runtime/src/lower-mcp.ts | 18 +- packages/rsc-runtime/src/plugin.ts | 6 + .../rsc-runtime/tests/agent-document.test.ts | 184 +++++++ 10 files changed, 822 insertions(+), 11 deletions(-) create mode 100644 .changeset/agent-document-contracts.md create mode 100644 packages/rsc-runtime/src/agent-document.ts create mode 100644 packages/rsc-runtime/tests/agent-document.test.ts diff --git a/.changeset/agent-document-contracts.md b/.changeset/agent-document-contracts.md new file mode 100644 index 000000000..115c699d4 --- /dev/null +++ b/.changeset/agent-document-contracts.md @@ -0,0 +1,7 @@ +--- +"@agent-bundle/runtime": minor +--- + +Add the versioned immutable Agent Document, bounded sequence-numbered render +events, the protocol-oriented `Agent.*` vocabulary, and discriminated render +invocation props while retaining the existing synchronous MCP and hook lowerers. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index cc8fe472c..fbfc2c9a6 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -24,11 +24,19 @@ const result = lowerMcpResult( ); ``` -The package exports `Hook`, `Mcp`, `lowerHookResult`, `lowerMcpResult`, -`createRscRequestContext`, `agent`, `runAgentRequest`, `available`, -`unavailable`, and `AgentRequestError`. It does not own an -RSC renderer, application state, transport, persistence, or host packaging. -React 19 is a peer dependency and Node 22.19 or newer is required. +The package also exports the protocol-oriented `Agent.*` vocabulary and the +versioned `AgentDocument`/`AgentRenderEvent` contracts. `createAgentDocument` +detaches and freezes a v1 document, enforcing finite depth, node-count, and +byte limits. `createAgentRenderEventSequence` assigns monotonic sequence +numbers to `shell | progress | replace | error | complete`, applies event +bounds, and rejects post-completion writes with a typed `handoff-required` +error. These contracts land beside the existing `Hook`/`Mcp` lowerers; those +synchronous compatibility APIs remain operative. + +The package exports `Hook`, `Mcp`, `Agent`, both lowerers, the request-store +APIs, and the Agent Document contracts. It does not yet own application state, +transport, persistence, or host packaging. React 19 is a peer dependency and +Node 22.19 or newer is required. Async server utilities and Server Components read the framework request store with `const context = await agent()`. The store is a versioned realm singleton diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 1ace0a817..033dbc78b 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@agent-bundle/runtime", "version": "0.0.0", - "description": "React-element result primitives (an MCP result DSL) for Agent Bundle hook and MCP protocol results.", + "description": "Versioned Agent Document contracts and React protocol primitives for Agent Bundle runtimes.", "license": "MIT", "keywords": [ "agent-bundle", diff --git a/packages/rsc-runtime/src/agent-document.ts b/packages/rsc-runtime/src/agent-document.ts new file mode 100644 index 000000000..afcb620a3 --- /dev/null +++ b/packages/rsc-runtime/src/agent-document.ts @@ -0,0 +1,453 @@ +import { Buffer } from 'node:buffer'; + +import { snapshotJsonValue, type JsonValue } from './lower-mcp.js'; + +export const AGENT_DOCUMENT_VERSION = 1 as const; + +export type AgentDocumentStatus = 'success' | 'represented-error' | 'failed'; + +export interface AgentResultNode { + readonly children: readonly AgentDocumentNode[]; + readonly kind: 'result'; + readonly metadata?: JsonValue; +} + +export interface AgentMarkdownNode { + readonly kind: 'markdown'; + readonly text: string; +} + +export interface AgentTextNode { + readonly kind: 'text'; + readonly text: string; +} + +export interface AgentJsonNode { + readonly kind: 'json'; + readonly value: JsonValue; +} + +export interface AgentProgressNode { + readonly completed: number; + readonly kind: 'progress'; + readonly message?: string; + readonly total?: number; +} + +export interface AgentImageNode { + readonly data: string; + readonly kind: 'image'; + readonly mimeType: string; +} + +export interface AgentAudioNode { + readonly data: string; + readonly kind: 'audio'; + readonly mimeType: string; +} + +export interface AgentResourceNode { + readonly kind: 'resource'; + readonly mimeType?: string; + readonly name: string; + readonly uri: string; +} + +export interface AgentErrorNode { + readonly code: string; + readonly kind: 'error'; + readonly message: string; +} + +export type AgentDocumentNode = + | AgentResultNode + | AgentMarkdownNode + | AgentTextNode + | AgentJsonNode + | AgentProgressNode + | AgentImageNode + | AgentAudioNode + | AgentResourceNode + | AgentErrorNode; + +export interface AgentDocument { + readonly root: AgentDocumentNode; + readonly status: AgentDocumentStatus; + readonly value?: JsonValue; + readonly version: typeof AGENT_DOCUMENT_VERSION; +} + +export type AgentDocumentSnapshot = AgentDocument; + +export interface AgentRenderError { + readonly code: string; + readonly data?: JsonValue; + readonly message: string; +} + +export type AgentRenderEvent = + | { readonly document: AgentDocumentSnapshot; readonly sequence: number; readonly type: 'shell' } + | { + readonly completed: number; + readonly message?: string; + readonly sequence: number; + readonly total?: number; + readonly type: 'progress'; + } + | { + readonly boundaryId: string; + readonly document: AgentDocumentSnapshot; + readonly sequence: number; + readonly type: 'replace'; + } + | { + readonly boundaryId?: string; + readonly error: AgentRenderError; + readonly sequence: number; + readonly type: 'error'; + } + | { readonly document: AgentDocumentSnapshot; readonly sequence: number; readonly type: 'complete' }; + +export type AgentRenderEventInput = + | { readonly document: AgentDocument; readonly type: 'shell' } + | { readonly completed: number; readonly message?: string; readonly total?: number; readonly type: 'progress' } + | { readonly boundaryId: string; readonly document: AgentDocument; readonly type: 'replace' } + | { readonly boundaryId?: string; readonly error: AgentRenderError; readonly type: 'error' } + | { readonly document: AgentDocument; readonly type: 'complete' }; + +export interface AgentRenderLimits { + readonly maxDocumentBytes: number; + readonly maxDocumentDepth: number; + readonly maxDocumentNodes: number; + readonly maxEventBytes: number; + readonly maxEvents: number; +} + +export const DEFAULT_AGENT_RENDER_LIMITS: AgentRenderLimits = Object.freeze({ + maxDocumentBytes: 1024 * 1024, + maxDocumentDepth: 64, + maxDocumentNodes: 10_000, + maxEventBytes: 1024 * 1024 + 1024, + maxEvents: 10_000, +}); + +export type AgentContractErrorCode = + | 'invalid-document' + | 'document-depth-exceeded' + | 'document-node-count-exceeded' + | 'document-bytes-exceeded' + | 'event-count-exceeded' + | 'event-bytes-exceeded' + | 'handoff-required'; + +export class AgentContractError extends Error { + readonly code: AgentContractErrorCode; + + constructor(code: AgentContractErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.code = code; + this.name = 'AgentContractError'; + } +} + +const resolveLimits = (overrides: Partial): AgentRenderLimits => { + const limits = { ...DEFAULT_AGENT_RENDER_LIMITS, ...overrides }; + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new AgentContractError('invalid-document', `${name} must be a positive safe integer`); + } + } + return Object.freeze(limits); +}; + +const requiredString = (value: unknown, field: string): string => { + if (typeof value !== 'string' || value.trim() === '') { + throw new AgentContractError('invalid-document', `${field} must be a non-empty string`); + } + return value; +}; + +const text = (value: unknown, field: string): string => { + if (typeof value !== 'string') { + throw new AgentContractError('invalid-document', `${field} must be a string`); + } + return value; +}; + +const optionalString = (value: unknown, field: string): string | undefined => + value === undefined ? undefined : requiredString(value, field); + +const snapshotJson = (value: unknown, message: string): JsonValue => { + try { + return snapshotJsonValue(value, message); + } catch (error) { + throw new AgentContractError('invalid-document', error instanceof Error ? error.message : message, { cause: error }); + } +}; + +const progressNumber = (value: unknown, field: string): number => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new AgentContractError('invalid-document', `${field} must be a finite non-negative number`); + } + return value; +}; + +interface NodeSnapshotState { + readonly ancestors: Set; + readonly limits: AgentRenderLimits; + nodes: number; +} + +const snapshotNode = (node: AgentDocumentNode, depth: number, state: NodeSnapshotState): AgentDocumentNode => { + if (node === null || typeof node !== 'object' || Array.isArray(node)) { + throw new AgentContractError('invalid-document', 'Agent Document nodes must be plain objects'); + } + if (state.ancestors.has(node)) { + throw new AgentContractError('invalid-document', 'Agent Document node tree must not be cyclic'); + } + if (depth > state.limits.maxDocumentDepth) { + throw new AgentContractError( + 'document-depth-exceeded', + `Agent Document depth exceeds ${String(state.limits.maxDocumentDepth)}`, + ); + } + state.nodes += 1; + if (state.nodes > state.limits.maxDocumentNodes) { + throw new AgentContractError( + 'document-node-count-exceeded', + `Agent Document node count exceeds ${String(state.limits.maxDocumentNodes)}`, + ); + } + + state.ancestors.add(node); + try { + switch (node.kind) { + case 'result': { + if (!Array.isArray(node.children)) { + throw new AgentContractError('invalid-document', 'Agent result children must be an array'); + } + const children = Object.freeze(node.children.map((child) => snapshotNode(child, depth + 1, state))); + const metadata = node.metadata === undefined + ? undefined + : snapshotJson(node.metadata, 'Agent result metadata must be JSON-serializable'); + return Object.freeze({ + children, + kind: 'result', + ...(metadata === undefined ? {} : { metadata }), + }); + } + case 'markdown': + return Object.freeze({ kind: 'markdown', text: text(node.text, 'Agent markdown text') }); + case 'text': + return Object.freeze({ kind: 'text', text: text(node.text, 'Agent text') }); + case 'json': + return Object.freeze({ + kind: 'json', + value: snapshotJson(node.value, 'Agent JSON node value must be JSON-serializable'), + }); + case 'progress': { + const completed = progressNumber(node.completed, 'Agent progress completed'); + const total = node.total === undefined ? undefined : progressNumber(node.total, 'Agent progress total'); + if (total !== undefined && completed > total) { + throw new AgentContractError('invalid-document', 'Agent progress completed must not exceed total'); + } + const message = optionalString(node.message, 'Agent progress message'); + return Object.freeze({ + completed, + kind: 'progress', + ...(message === undefined ? {} : { message }), + ...(total === undefined ? {} : { total }), + }); + } + case 'image': + return Object.freeze({ + data: requiredString(node.data, 'Agent image data'), + kind: 'image', + mimeType: requiredString(node.mimeType, 'Agent image mimeType'), + }); + case 'audio': + return Object.freeze({ + data: requiredString(node.data, 'Agent audio data'), + kind: 'audio', + mimeType: requiredString(node.mimeType, 'Agent audio mimeType'), + }); + case 'resource': { + const mimeType = optionalString(node.mimeType, 'Agent resource mimeType'); + return Object.freeze({ + kind: 'resource', + ...(mimeType === undefined ? {} : { mimeType }), + name: requiredString(node.name, 'Agent resource name'), + uri: requiredString(node.uri, 'Agent resource uri'), + }); + } + case 'error': + return Object.freeze({ + code: requiredString(node.code, 'Agent error code'), + kind: 'error', + message: text(node.message, 'Agent error message'), + }); + default: { + const exhaustive: never = node; + throw new AgentContractError( + 'invalid-document', + `Unsupported Agent Document node kind: ${String((exhaustive as { kind?: unknown }).kind)}`, + ); + } + } + } finally { + state.ancestors.delete(node); + } +}; + +const documentStatus = (status: AgentDocumentStatus): AgentDocumentStatus => { + switch (status) { + case 'success': + case 'represented-error': + case 'failed': + return status; + default: { + const exhaustive: never = status; + throw new AgentContractError('invalid-document', `Unsupported Agent Document status: ${String(exhaustive)}`); + } + } +}; + +export const createAgentDocument = ( + input: AgentDocument, + limitOverrides: Partial = {}, +): AgentDocument => { + if (input.version !== AGENT_DOCUMENT_VERSION) { + throw new AgentContractError( + 'invalid-document', + `Unsupported Agent Document version: ${String(input.version)}`, + ); + } + const limits = resolveLimits(limitOverrides); + const root = snapshotNode(input.root, 1, { ancestors: new Set(), limits, nodes: 0 }); + const value = input.value === undefined + ? undefined + : snapshotJson(input.value, 'Agent Document value must be JSON-serializable'); + const document: AgentDocument = Object.freeze({ + root, + status: documentStatus(input.status), + ...(value === undefined ? {} : { value }), + version: AGENT_DOCUMENT_VERSION, + }); + const bytes = Buffer.byteLength(JSON.stringify(document), 'utf8'); + if (bytes > limits.maxDocumentBytes) { + throw new AgentContractError( + 'document-bytes-exceeded', + `Agent Document bytes exceed ${String(limits.maxDocumentBytes)}`, + ); + } + return document; +}; + +const snapshotRenderError = (error: AgentRenderError): AgentRenderError => { + const data = error.data === undefined + ? undefined + : snapshotJson(error.data, 'Agent render error data must be JSON-serializable'); + return Object.freeze({ + code: requiredString(error.code, 'Agent render error code'), + ...(data === undefined ? {} : { data }), + message: text(error.message, 'Agent render error message'), + }); +}; + +const snapshotEvent = ( + input: AgentRenderEventInput, + sequence: number, + limits: AgentRenderLimits, +): AgentRenderEvent => { + switch (input.type) { + case 'shell': + return Object.freeze({ document: createAgentDocument(input.document, limits), sequence, type: 'shell' }); + case 'progress': { + const completed = progressNumber(input.completed, 'Agent render progress completed'); + const total = input.total === undefined ? undefined : progressNumber(input.total, 'Agent render progress total'); + if (total !== undefined && completed > total) { + throw new AgentContractError('invalid-document', 'Agent render progress completed must not exceed total'); + } + const message = optionalString(input.message, 'Agent render progress message'); + return Object.freeze({ + completed, + ...(message === undefined ? {} : { message }), + sequence, + ...(total === undefined ? {} : { total }), + type: 'progress', + }); + } + case 'replace': + return Object.freeze({ + boundaryId: requiredString(input.boundaryId, 'Agent render boundaryId'), + document: createAgentDocument(input.document, limits), + sequence, + type: 'replace', + }); + case 'error': { + const boundaryId = optionalString(input.boundaryId, 'Agent render boundaryId'); + return Object.freeze({ + ...(boundaryId === undefined ? {} : { boundaryId }), + error: snapshotRenderError(input.error), + sequence, + type: 'error', + }); + } + case 'complete': + return Object.freeze({ document: createAgentDocument(input.document, limits), sequence, type: 'complete' }); + default: { + const exhaustive: never = input; + throw new AgentContractError( + 'invalid-document', + `Unsupported Agent render event: ${String((exhaustive as { type?: unknown }).type)}`, + ); + } + } +}; + +export interface AgentRenderEventSequence { + readonly completed: boolean; + readonly nextSequence: number; + readonly emit: (input: AgentRenderEventInput) => AgentRenderEvent; +} + +export const createAgentRenderEventSequence = ( + limitOverrides: Partial = {}, +): AgentRenderEventSequence => { + const limits = resolveLimits(limitOverrides); + let completed = false; + let nextSequence = 0; + return Object.freeze({ + get completed() { + return completed; + }, + emit(input: AgentRenderEventInput): AgentRenderEvent { + if (completed) { + throw new AgentContractError( + 'handoff-required', + 'The render is complete; later work requires a new invocation handoff', + ); + } + if (nextSequence >= limits.maxEvents) { + throw new AgentContractError( + 'event-count-exceeded', + `Agent render event count exceeds ${String(limits.maxEvents)}`, + ); + } + const event = snapshotEvent(input, nextSequence, limits); + const bytes = Buffer.byteLength(JSON.stringify(event), 'utf8'); + if (bytes > limits.maxEventBytes) { + throw new AgentContractError( + 'event-bytes-exceeded', + `Agent render event bytes exceed ${String(limits.maxEventBytes)}`, + ); + } + nextSequence += 1; + if (event.type === 'complete') completed = true; + return event; + }, + get nextSequence() { + return nextSequence; + }, + }); +}; diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 00fcc4663..3962e439a 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -1,11 +1,45 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { JsonValue } from './lower-mcp.js'; + export const AGENT_REQUEST_STORE_VERSION = 1; const STORE_SYMBOL = Symbol.for('@agent-bundle/runtime/request-store'); export type AgentInvocationKind = 'tool' | 'event' | 'cli' | 'script' | 'workbench'; +export interface AgentToolInvocationProps { + readonly input: JsonValue; + readonly operationId: string; +} + +export interface AgentEventInvocationProps { + readonly event: string; + readonly payload: JsonValue; +} + +export interface AgentCliInvocationProps { + readonly args: readonly string[]; + readonly command: string; +} + +export interface AgentScriptInvocationProps { + readonly input?: JsonValue; + readonly name: string; +} + +export interface AgentWorkbenchInvocationProps { + readonly input?: JsonValue; + readonly view: string; +} + +export type AgentRenderInvocation = + | { readonly kind: 'tool'; readonly props: AgentToolInvocationProps } + | { readonly kind: 'event'; readonly props: AgentEventInvocationProps } + | { readonly kind: 'cli'; readonly props: AgentCliInvocationProps } + | { readonly kind: 'script'; readonly props: AgentScriptInvocationProps } + | { readonly kind: 'workbench'; readonly props: AgentWorkbenchInvocationProps }; + export type ObservedSource = 'native' | 'receipt' | 'derived'; export type AgentContextUnavailableReason = diff --git a/packages/rsc-runtime/src/elements.ts b/packages/rsc-runtime/src/elements.ts index feeb81cb6..066555180 100644 --- a/packages/rsc-runtime/src/elements.ts +++ b/packages/rsc-runtime/src/elements.ts @@ -1,5 +1,77 @@ import { createElement, type PropsWithChildren, type ReactElement } from 'react'; +import type { JsonValue } from './lower-mcp.js'; + +export interface AgentResultProps extends PropsWithChildren { + readonly metadata?: JsonValue; +} + +export interface AgentTextProps { + readonly children: string; +} + +export interface AgentJsonProps { + readonly value: JsonValue; +} + +export interface AgentProgressProps { + readonly completed: number; + readonly message?: string; + readonly total?: number; +} + +export interface AgentMediaProps { + readonly data: string; + readonly mimeType: string; +} + +export interface AgentResourceProps { + readonly mimeType?: string; + readonly name: string; + readonly uri: string; +} + +export interface AgentErrorProps extends AgentTextProps { + readonly code: string; +} + +const AgentResult = ({ children, metadata }: AgentResultProps): ReactElement => + createElement('agent-result', { metadata }, children); + +const AgentMarkdown = ({ children }: AgentTextProps): ReactElement => + createElement('agent-markdown', null, children); + +const AgentText = ({ children }: AgentTextProps): ReactElement => createElement('agent-text', null, children); + +const AgentJson = ({ value }: AgentJsonProps): ReactElement => createElement('agent-json', { value }); + +const AgentProgress = ({ completed, message, total }: AgentProgressProps): ReactElement => + createElement('agent-progress', { completed, message, total }); + +const AgentImage = ({ data, mimeType }: AgentMediaProps): ReactElement => + createElement('agent-image', { data, mimeType }); + +const AgentAudio = ({ data, mimeType }: AgentMediaProps): ReactElement => + createElement('agent-audio', { data, mimeType }); + +const AgentResource = ({ mimeType, name, uri }: AgentResourceProps): ReactElement => + createElement('agent-resource', { mimeType, name, uri }); + +const AgentError = ({ children, code }: AgentErrorProps): ReactElement => + createElement('agent-error', { code }, children); + +export const Agent = Object.freeze({ + Audio: AgentAudio, + Error: AgentError, + Image: AgentImage, + Json: AgentJson, + Markdown: AgentMarkdown, + Progress: AgentProgress, + Resource: AgentResource, + Result: AgentResult, + Text: AgentText, +}); + const Result = ({ children }: PropsWithChildren): ReactElement => createElement('agent-hook-result', null, children); diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index b1ce9ff81..33f144bc4 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -1,14 +1,49 @@ -export { Hook, Mcp } from './elements.js'; +export { Agent, Hook, Mcp } from './elements.js'; export type { + AgentErrorProps, + AgentJsonProps, + AgentMediaProps, + AgentProgressProps, + AgentResourceProps, + AgentResultProps, + AgentTextProps, McpDataProps, McpEmbeddedResourceProps, McpResourceLinkProps, McpResultProps, } from './elements.js'; +export { + AGENT_DOCUMENT_VERSION, + AgentContractError, + DEFAULT_AGENT_RENDER_LIMITS, + createAgentDocument, + createAgentRenderEventSequence, +} from './agent-document.js'; +export type { + AgentAudioNode, + AgentContractErrorCode, + AgentDocument, + AgentDocumentNode, + AgentDocumentSnapshot, + AgentDocumentStatus, + AgentErrorNode, + AgentImageNode, + AgentJsonNode, + AgentMarkdownNode, + AgentProgressNode, + AgentRenderError, + AgentRenderEvent, + AgentRenderEventInput, + AgentRenderEventSequence, + AgentRenderLimits, + AgentResourceNode, + AgentResultNode, + AgentTextNode, +} from './agent-document.js'; export { lowerHookResult } from './lower-hook.js'; export type { NativePostToolUseOutput } from './lower-hook.js'; export { lowerMcpResult } from './lower-mcp.js'; -export type { JsonValue } from './lower-mcp.js'; +export type { JsonObject, JsonValue } from './lower-mcp.js'; export { createRscRequestContext } from './request-context.js'; export type { RscRequestContext } from './request-context.js'; export * from './plugin.js'; diff --git a/packages/rsc-runtime/src/lower-mcp.ts b/packages/rsc-runtime/src/lower-mcp.ts index d776dd932..30b7b9a22 100644 --- a/packages/rsc-runtime/src/lower-mcp.ts +++ b/packages/rsc-runtime/src/lower-mcp.ts @@ -44,7 +44,11 @@ const textChild = (children: unknown, message: string): string => { return values[0]; }; -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +export type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject; const isArrayIndex = (key: string, length: number): boolean => { if (key === '0') return length > 0; @@ -108,7 +112,7 @@ const cloneJsonValue = (value: unknown, ancestors: Set, path: string): J } }; -const jsonRecord = (value: unknown, message: string): Record => { +const jsonRecord = (value: unknown, message: string): JsonObject => { try { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error('not a plain object'); @@ -117,7 +121,7 @@ const jsonRecord = (value: unknown, message: string): Record if (Array.isArray(clone) || clone === null || typeof clone !== 'object') { throw new Error('not a plain object'); } - return clone; + return clone as JsonObject; } catch (error) { throw new Error(`${message} (${error instanceof Error ? error.message : String(error)})`, { cause: error }); } @@ -131,6 +135,14 @@ const deepFreezeJson = (value: JsonValue): JsonValue => { return value; }; +export const snapshotJsonValue = (value: unknown, message: string): JsonValue => { + try { + return deepFreezeJson(cloneJsonValue(value, new Set(), '')); + } catch (error) { + throw new Error(`${message} (${error instanceof Error ? error.message : String(error)})`, { cause: error }); + } +}; + /** * Copies declaration-level metadata through the same JSON wire boundary as * MCP results and deep-freezes the copy, so frozen definitions cannot be diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index 1dd77b80a..e99f88442 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -8,6 +8,8 @@ export { } from './agent-request.js'; export type { AgentActorIdentity, + AgentCliInvocationProps, + AgentEventInvocationProps, AgentCommandAuthority, AgentContextUnavailableReason, AgentFilesystemAuthority, @@ -19,13 +21,17 @@ export type { AgentProgressReporter, AgentProgressUpdate, AgentProjectRootAuthority, + AgentRenderInvocation, AgentProviderValues, AgentRequestCapabilities, AgentRequestContext, AgentRequestErrorCode, AgentRequestInit, AgentServiceRegistry, + AgentScriptInvocationProps, AgentSessionIdentity, + AgentToolInvocationProps, + AgentWorkbenchInvocationProps, AgentWorkspaceIdentity, Observed, ObservedSource, diff --git a/packages/rsc-runtime/tests/agent-document.test.ts b/packages/rsc-runtime/tests/agent-document.test.ts new file mode 100644 index 000000000..358e19887 --- /dev/null +++ b/packages/rsc-runtime/tests/agent-document.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from '@rstest/core'; + +import { + Agent, + AgentContractError, + createAgentDocument, + createAgentRenderEventSequence, + type AgentDocumentNode, + type AgentRenderInvocation, +} from '../src/index.js'; + +const root = (): AgentDocumentNode => ({ + children: [ + { kind: 'markdown', text: '# Ready' }, + { kind: 'text', text: 'plain' }, + { kind: 'json', value: { ready: true } }, + { completed: 1, kind: 'progress', message: 'done', total: 1 }, + { data: 'aW1hZ2U=', kind: 'image', mimeType: 'image/png' }, + { data: 'YXVkaW8=', kind: 'audio', mimeType: 'audio/wav' }, + { kind: 'resource', mimeType: 'application/json', name: 'Catalog', uri: 'catalog://root' }, + { code: 'E_DEMO', kind: 'error', message: 'represented' }, + ], + kind: 'result', + metadata: { source: 'test' }, +}); + +const invocationLabel = (invocation: AgentRenderInvocation): string => { + switch (invocation.kind) { + case 'tool': + return invocation.props.operationId; + case 'event': + return invocation.props.event; + case 'cli': + return invocation.props.command; + case 'script': + return invocation.props.name; + case 'workbench': + return invocation.props.view; + default: { + const exhaustive: never = invocation; + return exhaustive; + } + } +}; + +describe('Agent vocabulary', () => { + it('exposes protocol-oriented leaves without DOM elements', () => { + expect(Object.keys(Agent)).toEqual([ + 'Audio', + 'Error', + 'Image', + 'Json', + 'Markdown', + 'Progress', + 'Resource', + 'Result', + 'Text', + ]); + expect(Object.isFrozen(Agent)).toBe(true); + expect(Agent.Markdown({ children: '# Ready' }).type).toBe('agent-markdown'); + expect(Agent.Resource({ name: 'Catalog', uri: 'catalog://root' }).type).toBe('agent-resource'); + expect(Agent.Error({ children: 'represented', code: 'E_DEMO' }).type).toBe('agent-error'); + }); +}); + +describe('AgentDocument', () => { + it('snapshots every v1 node kind into a finite immutable document', () => { + const source = root(); + const document = createAgentDocument({ + root: source, + status: 'represented-error', + value: { nested: ['stable'] }, + version: 1, + }); + + (source as { children: unknown[] }).children.push({ kind: 'text', text: 'late' }); + expect(document).toEqual({ + root: root(), + status: 'represented-error', + value: { nested: ['stable'] }, + version: 1, + }); + expect(Object.isFrozen(document)).toBe(true); + expect(Object.isFrozen(document.root)).toBe(true); + expect(Object.isFrozen((document.root as { children: readonly unknown[] }).children)).toBe(true); + expect(Object.isFrozen(document.value)).toBe(true); + expect(Object.isFrozen((document.root as { metadata?: unknown }).metadata)).toBe(true); + }); + + it('enforces depth, node-count, byte, and JSON-value bounds', () => { + const nested: AgentDocumentNode = { + children: [{ children: [{ kind: 'text', text: 'deep' }], kind: 'result' }], + kind: 'result', + }; + expect(() => createAgentDocument( + { root: nested, status: 'success', version: 1 }, + { maxDocumentDepth: 2 }, + )).toThrow('depth'); + expect(() => createAgentDocument( + { root: root(), status: 'success', version: 1 }, + { maxDocumentNodes: 2 }, + )).toThrow('node count'); + expect(() => createAgentDocument( + { root: root(), status: 'success', version: 1 }, + { maxDocumentBytes: 20 }, + )).toThrow('bytes'); + expect(() => createAgentDocument({ + root: { kind: 'json', value: { ratio: Number.POSITIVE_INFINITY } }, + status: 'success', + version: 1, + })).toThrow('non-finite number'); + expect(() => createAgentDocument({ + root: { kind: 'div' } as never, + status: 'success', + version: 1, + })).toThrow('Unsupported Agent Document node kind: div'); + }); +}); + +describe('Agent render events', () => { + it('assigns sequence numbers, snapshots payloads, and closes on complete', () => { + const events = createAgentRenderEventSequence(); + const shell = events.emit({ + document: { root: root(), status: 'success', version: 1 }, + type: 'shell', + }); + const progress = events.emit({ completed: 1, message: 'done', total: 1, type: 'progress' }); + const replacement = events.emit({ + boundaryId: 'summary', + document: { root: root(), status: 'success', version: 1 }, + type: 'replace', + }); + const representedError = events.emit({ + boundaryId: 'details', + error: { code: 'E_DEMO', data: { retryable: false }, message: 'represented' }, + type: 'error', + }); + const complete = events.emit({ + document: { root: root(), status: 'represented-error', version: 1 }, + type: 'complete', + }); + + expect([ + shell.sequence, + progress.sequence, + replacement.sequence, + representedError.sequence, + complete.sequence, + ]).toEqual([0, 1, 2, 3, 4]); + expect(events.completed).toBe(true); + expect(Object.isFrozen(shell)).toBe(true); + expect(Object.isFrozen(representedError.error)).toBe(true); + expect(Object.isFrozen(representedError.error.data)).toBe(true); + expect(() => events.emit({ completed: 2, type: 'progress' })).toThrow(AgentContractError); + expect(() => events.emit({ completed: 2, type: 'progress' })).toThrow('handoff'); + try { + events.emit({ completed: 2, type: 'progress' }); + throw new Error('expected post-completion emit to fail'); + } catch (error) { + expect(error).toMatchObject({ code: 'handoff-required' }); + } + }); + + it('bounds event count and event bytes', () => { + const countBounded = createAgentRenderEventSequence({ maxEvents: 1 }); + countBounded.emit({ completed: 0, type: 'progress' }); + expect(() => countBounded.emit({ completed: 1, type: 'progress' })).toThrow('event count'); + + const byteBounded = createAgentRenderEventSequence({ maxEventBytes: 20 }); + expect(() => byteBounded.emit({ completed: 0, message: 'too large', type: 'progress' })).toThrow('bytes'); + }); +}); + +describe('AgentRenderInvocation', () => { + it('discriminates typed props for every invocation kind', () => { + expect([ + invocationLabel({ kind: 'tool', props: { input: {}, operationId: 'status' } }), + invocationLabel({ kind: 'event', props: { event: 'tool/after', payload: {} } }), + invocationLabel({ kind: 'cli', props: { args: [], command: 'status' } }), + invocationLabel({ kind: 'script', props: { name: 'check-status' } }), + invocationLabel({ kind: 'workbench', props: { view: 'runtime' } }), + ]).toEqual(['status', 'tool/after', 'status', 'check-status', 'runtime']); + }); +});