diff --git a/.changeset/final-only-flight-dispatcher.md b/.changeset/final-only-flight-dispatcher.md new file mode 100644 index 000000000..5ca139d1e --- /dev/null +++ b/.changeset/final-only-flight-dispatcher.md @@ -0,0 +1,8 @@ +--- +"@agent-bundle/runtime": minor +--- + +Add React-owned final-only Flight execution behind the public render-dispatcher +and execution-host seam. Decode intrinsic `Agent.*` output into one immutable +Agent Document and propagate request cancellation without changing the existing +synchronous MCP lowerer path. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 5bebb95d1..054a4ee71 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -124,16 +124,19 @@ validation cannot drift between surfaces. Only the last step differs: ## Why `.tsx`, and current renderer status -`@agent-bundle/runtime` does **not yet execute React Server Components or -Flight**. Its current compatibility lowerers do not stream a component tree, -hydrate, or hold server component state. What the MCP projection uses is an **MCP -result DSL**: `render` returns ordinary React elements, and -`lowerMcpResult` walks that tree synchronously — function components are -simply called — to produce the `CallToolResult` the MCP SDK sends. The -package owns no transport, and operations receive no implicit storage: -persistent application state exists only through the opt-in state kernel -subpath (`@agent-bundle/runtime/state`, issue #98), which stateless projects -never import. +The operation model shown above still uses the **synchronous MCP result DSL**: +`render` returns ordinary React elements and `lowerMcpResult` walks that tree +to produce the `CallToolResult` the MCP SDK sends. That compatibility path does +not involve Flight and remains the operative MCP projection. + +Separately, `@agent-bundle/runtime` now exposes a final-only React-owned Flight +dispatcher for generated routes. An execution host supplies Flight bytes, the +dispatcher decodes intrinsic `Agent.*` elements into one immutable +`AgentDocument`, and cancellation follows the request `AbortSignal`. Streaming +Suspense replacement and public filesystem-route authoring are later stages. +Operations receive no implicit storage: persistent application state exists +only through the opt-in `@agent-bundle/runtime/state` kernel, which stateless +projects never import. Operation modules are `.tsx` for exactly one reason: the `render` callback returns JSX. Everything else in an operation — schemas, argv parsing, MCP @@ -150,9 +153,9 @@ For a new reader, in one breath: around them). 3. **Which projection consumes `render`?** Only MCP, though every operation must declare one. The CLI serializes the validated result as JSON. -4. **Is any React Server Components renderer or Flight transport - involved?** No. `lowerMcpResult` is a synchronous element-tree lowering, - not a renderer or transport. +4. **Is Flight involved in this operation projection?** No. + `lowerMcpResult` remains synchronous. The separate generated-route path uses + the final-only `AgentRenderDispatcher` described above. 5. **Why are operation modules `.tsx`?** Only because `render` returns JSX. ## Rendered skills (power tier, never required) diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index cb31748f8..4efebbd04 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -11,11 +11,11 @@ This private, opt-in example shows one React Server Components (RSC) runtime sha | RSC render | Hook and MCP result component trees, lowered from Flight | One request | | MCP App UI | Mounted timeline, Refresh, and recoverable row selection | One UI instance | -Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker, lowers the Flight result, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls. +Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker through the runtime dispatcher seam, projects the final Agent Document, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls. ```tsx -// A Hook JSX route reads request-scoped context. -import { Hook, agent } from '@agent-bundle/runtime'; +// An Agent Document route reads request-scoped context. +import { Agent, agent } from '@agent-bundle/runtime'; import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js'; export async function AfterFileEdit() { @@ -23,11 +23,11 @@ export async function AfterFileEdit() { const edit = context.services.edit as CanonicalPostToolUse; const snapshot = context.services.snapshot as RuntimeSnapshot; return ( - - + + {`Recorded ${edit.path}; ${snapshot.edits.length} edits exist.`} - - + + ); } ``` diff --git a/examples/rsc-agent-runtime/src/dev/invocation-worker.ts b/examples/rsc-agent-runtime/src/dev/invocation-worker.ts index 062bdbe88..4e9a6e609 100644 --- a/examples/rsc-agent-runtime/src/dev/invocation-worker.ts +++ b/examples/rsc-agent-runtime/src/dev/invocation-worker.ts @@ -1,6 +1,6 @@ -import { requestFlightRenderWithFlight } from '../flight/request-render.js'; +import { requestAgentDocumentWithFlight, requestFlightRenderWithFlight } from '../flight/request-render.js'; import { writeSync } from 'node:fs'; -import { lowerHookResult, lowerMcpResult } from '@agent-bundle/runtime'; +import { lowerMcpResult } from '@agent-bundle/runtime'; import type { DevRuntimeInspectionRequest, DevRuntimeInspectionResponse, @@ -9,6 +9,7 @@ import type { RuntimeSnapshot, } from '../runtime/contracts.js'; import { normalizeClaudeHook, normalizeCodexHook } from '../hook/normalize.js'; +import { projectHookDocument } from '../hook/project-document.js'; import { hasInspectionCredential, isInspectionSensitiveKey } from './inspection-security.js'; import { serializeInspection } from './serialize-inspection.js'; @@ -163,41 +164,46 @@ interface InvocationOutput { const invoke = async (signal?: AbortSignal): Promise => { const request = await readRequest(); - const rendered = await requestFlightRenderWithFlight(renderRequestFor(request), { - maximumFlightBytes: maximumInvocationFlightBytes, - signal, - }); + const renderRequest = renderRequestFor(request); if (request.type === 'hook/after-file-edit') { - const native = lowerHookResult(rendered.node); + const rendered = await requestAgentDocumentWithFlight(renderRequest, { + maximumFlightBytes: maximumInvocationFlightBytes, + signal, + }); + const native = projectHookDocument(rendered.document); return Object.freeze({ flight: Buffer.from(rendered.flight), response: Object.freeze({ flightBytes: rendered.flight.byteLength, inspection: serializeInspection({ - agentVisible: native.hookSpecificOutput.additionalContext, - flight: rendered.flight, - native, - node: rendered.node, - stateStoreId: request.stateStoreId, - stateVersion: rendered.stateVersion, + agentVisible: native.hookSpecificOutput.additionalContext, + flight: rendered.flight, + native, + node: rendered.node, + stateStoreId: request.stateStoreId, + stateVersion: rendered.stateVersion, }), }), }); } + const rendered = await requestFlightRenderWithFlight(renderRequest, { + maximumFlightBytes: maximumInvocationFlightBytes, + signal, + }); const protocol = lowerMcpResult(rendered.node); return Object.freeze({ flight: Buffer.from(rendered.flight), response: Object.freeze({ flightBytes: rendered.flight.byteLength, inspection: serializeInspection({ - flight: rendered.flight, - modelVisible: protocol.content, - node: rendered.node, - protocol, - stateStoreId: request.stateStoreId, - stateVersion: rendered.stateVersion, + flight: rendered.flight, + modelVisible: protocol.content, + node: rendered.node, + protocol, + stateStoreId: request.stateStoreId, + stateVersion: rendered.stateVersion, }), }), }); diff --git a/examples/rsc-agent-runtime/src/flight/request-render.ts b/examples/rsc-agent-runtime/src/flight/request-render.ts index 351a8a700..6ce375acf 100644 --- a/examples/rsc-agent-runtime/src/flight/request-render.ts +++ b/examples/rsc-agent-runtime/src/flight/request-render.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'; import { createFromReadableStream } from 'react-server-dom-rspack/client.node'; import type { ReactNode } from 'react'; +import { createAgentRenderDispatcher, type AgentDocument, type AgentRenderInvocation } from '@agent-bundle/runtime'; + import type { RenderRequest } from '../runtime/contracts.js'; import { redactInspectionDiagnostics } from '../dev/inspection-security.js'; @@ -22,6 +24,10 @@ export interface FlightRenderResult { readonly stateVersion: number; } +export interface AgentDocumentFlightRenderResult extends FlightRenderResult { + readonly document: AgentDocument; +} + export interface FlightRenderOptions { readonly maximumFlightBytes?: number; readonly maximumStderrBytes?: number; @@ -188,3 +194,62 @@ export const requestFlightRenderWithFlight = async ( export const requestFlightRender = async (request: RenderRequest): Promise => (await requestFlightRenderWithFlight(request)).node; + +const renderInvocationFor = (request: RenderRequest): AgentRenderInvocation => { + switch (request.type) { + case 'hook/after-file-edit': + return { + kind: 'event', + props: { + event: request.type, + payload: { event: { ...request.event }, stateFile: request.stateFile }, + }, + }; + case 'mcp/render-timeline': + return { + kind: 'tool', + props: { + input: { + snapshot: { + edits: request.snapshot.edits.map((edit) => ({ ...edit })), + ...(request.snapshot.seed === undefined ? {} : { seed: request.snapshot.seed }), + stateVersion: request.snapshot.stateVersion, + }, + stateFile: request.stateFile, + }, + operationId: request.type, + }, + }; + case 'mcp/runtime-status': + return { + kind: 'tool', + props: { input: { stateFile: request.stateFile }, operationId: request.type }, + }; + default: { + const exhaustive: never = request; + return exhaustive; + } + } +}; + +export const requestAgentDocumentWithFlight = async ( + request: RenderRequest, + options: FlightRenderOptions = {}, +): Promise => { + let rendered: FlightRenderResult | undefined; + const signal = options.signal ?? new AbortController().signal; + const dispatcher = createAgentRenderDispatcher({ + execute: async (dispatch) => { + rendered = await requestFlightRenderWithFlight(request, { ...options, signal: dispatch.signal }); + return Readable.toWeb(Readable.from([rendered.flight])) as ReadableStream; + }, + }); + const document = await dispatcher.dispatch({ invocation: renderInvocationFor(request), signal }); + if (rendered === undefined) throw new Error('Flight execution host returned no render result'); + return Object.freeze({ ...rendered, document }); +}; + +export const requestAgentDocument = async ( + request: RenderRequest, + options: FlightRenderOptions = {}, +): Promise => (await requestAgentDocumentWithFlight(request, options)).document; diff --git a/examples/rsc-agent-runtime/src/hook/cli.ts b/examples/rsc-agent-runtime/src/hook/cli.ts index e5ed376de..c7e93dcb6 100644 --- a/examples/rsc-agent-runtime/src/hook/cli.ts +++ b/examples/rsc-agent-runtime/src/hook/cli.ts @@ -1,10 +1,10 @@ import { appendFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { requestFlightRender } from '../flight/request-render.js'; -import { lowerHookResult } from '@agent-bundle/runtime'; +import { requestAgentDocument } from '../flight/request-render.js'; import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js'; import { normalizeClaudeHook, normalizeCodexHook } from './normalize.js'; +import { projectHookDocument } from './project-document.js'; let probeInput: Record | undefined; @@ -59,7 +59,7 @@ const readHost = (): 'claude' | 'codex' => { return host; }; -const run = async (): Promise => { +const run = async (signal: AbortSignal): Promise => { const host = readHost(); const input = await readInput(); probeInput = input; @@ -69,18 +69,26 @@ const run = async (): Promise => { ? await resolveImplicitRuntimeStateFile(event.cwd) : resolve(configuredStateFile); - const result = await requestFlightRender({ + const document = await requestAgentDocument({ event, stateFile, type: 'hook/after-file-edit', - }); - process.stdout.write(`${JSON.stringify(lowerHookResult(result))}\n`); + }, { signal }); + process.stdout.write(`${JSON.stringify(projectHookDocument(document))}\n`); await writeEvalProbe(input, 0); }; -run().catch(async (error: unknown) => { +const controller = new AbortController(); +const abort = (): void => controller.abort(); +process.once('SIGINT', abort); +process.once('SIGTERM', abort); + +run(controller.signal).catch(async (error: unknown) => { if (probeInput !== undefined) await writeEvalProbe(probeInput, 1).catch(() => undefined); const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; +}).finally(() => { + process.removeListener('SIGINT', abort); + process.removeListener('SIGTERM', abort); }); diff --git a/examples/rsc-agent-runtime/src/hook/project-document.ts b/examples/rsc-agent-runtime/src/hook/project-document.ts new file mode 100644 index 000000000..3bd774a65 --- /dev/null +++ b/examples/rsc-agent-runtime/src/hook/project-document.ts @@ -0,0 +1,16 @@ +import type { AgentDocument, NativePostToolUseOutput } from '@agent-bundle/runtime'; + +export const projectHookDocument = (document: AgentDocument): NativePostToolUseOutput => { + if (document.status === 'failed' || document.root.kind !== 'result') { + throw new Error('Hook render requires a successful Agent.Result document'); + } + if (document.root.children.length !== 1 || document.root.children[0]?.kind !== 'text') { + throw new Error('Hook render requires exactly one Agent.Text child'); + } + return { + hookSpecificOutput: { + additionalContext: document.root.children[0].text, + hookEventName: 'PostToolUse', + }, + }; +}; diff --git a/examples/rsc-agent-runtime/src/rsc/components.tsx b/examples/rsc-agent-runtime/src/rsc/components.tsx index 5bda68749..b849efeb0 100644 --- a/examples/rsc-agent-runtime/src/rsc/components.tsx +++ b/examples/rsc-agent-runtime/src/rsc/components.tsx @@ -1,6 +1,6 @@ import { basename } from 'node:path'; -import { Hook, Mcp, agent } from '@agent-bundle/runtime'; +import { Agent, Mcp, agent } from '@agent-bundle/runtime'; import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js'; const hookServices = async (): Promise<{ edit: CanonicalPostToolUse; snapshot: RuntimeSnapshot }> => { @@ -22,11 +22,11 @@ export const AfterFileEdit = async () => { const editNoun = editCount === 1 ? 'edit' : 'edits'; return ( - - + + {`Recorded ${basename(edit.path)} from ${edit.host}. Shared state now contains ${editCount} ${editNoun}.`} - - + + ); }; diff --git a/examples/rsc-agent-runtime/src/rsc/worker.tsx b/examples/rsc-agent-runtime/src/rsc/worker.tsx index 95c5388cc..7d1dc9406 100644 --- a/examples/rsc-agent-runtime/src/rsc/worker.tsx +++ b/examples/rsc-agent-runtime/src/rsc/worker.tsx @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { writeSync } from 'node:fs'; import { available, runAgentRequest } from '@agent-bundle/runtime'; -import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; +import { renderAgentFlight } from '@agent-bundle/runtime/flight/server'; import type { CanonicalPostToolUse, RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js'; import { createFileRuntimeKernel } from '../runtime/state-file.js'; @@ -103,7 +103,7 @@ const readRequest = async (): Promise => { return parseRequest(JSON.parse(contents)); }; -const render = async (): Promise => { +const render = async (signal: AbortSignal): Promise => { const request = await readRequest(); const runtime = createFileRuntimeKernel({ stateFile: request.stateFile }); const snapshot = @@ -120,7 +120,7 @@ const render = async (): Promise => { : await runtime.readSnapshot(); const renderFlight = async (): Promise => { - const flight = renderToReadableStream(renderRoute(request, snapshot)); + const flight = renderAgentFlight(renderRoute(request, snapshot), { signal }); const output = Readable.from(flight); output.pipe(process.stdout, { end: false }); await finished(output); @@ -145,6 +145,7 @@ const render = async (): Promise => { session: available({ sessionId: request.event.sessionId }, 'native'), services: { edit: request.event, snapshot }, workspace: available({ root: request.event.cwd }, 'native'), + signal, }, renderFlight); } else { await runAgentRequest({ @@ -153,13 +154,22 @@ const render = async (): Promise => { surface: request.type, }, services: { snapshot }, + signal, }, renderFlight); } writeSnapshotMetadata(); }; -render().catch((error: unknown) => { +const controller = new AbortController(); +const abort = (): void => controller.abort(); +process.once('SIGINT', abort); +process.once('SIGTERM', abort); + +render(controller.signal).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; +}).finally(() => { + process.removeListener('SIGINT', abort); + process.removeListener('SIGTERM', abort); }); diff --git a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts index eba942718..009349b7b 100644 --- a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts @@ -310,12 +310,12 @@ test('builds a generation-contained inspection entry for Claude, Codex, and MCP ], id: 'node-1', kind: 'element', - label: 'agent-hook-additional-context', + label: 'agent-text', }, ], id: 'node-0', kind: 'element', - label: 'agent-hook-result', + label: 'agent-result', }, ], }); diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index b516ed87d..e6f1ee47f 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -1,18 +1,20 @@ # `@agent-bundle/runtime` -Small React primitives for producing Agent Bundle hook and MCP protocol results with JSX. +Agent Document contracts and React-owned Flight execution for Agent Bundle routes. No npm release is cut yet; install the pkg.pr.new preview of any `main` commit or pull request — see [Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md). -The runtime package does **not yet execute React Server Components or -Flight**. Its current lowerers form a synchronous React-element protocol DSL — -an *MCP result DSL*: `lowerMcpResult` walks an -element tree, calling your function components as it goes, and lowers it -into a plain MCP `CallToolResult`. `lowerHookResult` lowers a `Hook.Result` -tree into a native `PostToolUse` output the same way, except that it -resolves only the `Hook` elements themselves — a hook tree returned from -your own component is rejected. Nothing streams components, hydrates, or -holds server component state. +The runtime now executes route models through React-owned RSC/Flight behind +the `AgentRenderDispatcher` execution-host seam. This first renderer slice is +final-only: it buffers one Flight result, decodes only intrinsic `Agent.*` +protocol elements into one immutable `AgentDocument`, and propagates the +request `AbortSignal` through the host and decoder. Streaming Suspense shell +and replacement events arrive in stage 3. + +The existing lowerers remain synchronous compatibility APIs. `lowerMcpResult` +walks an MCP element tree, calling function components itself, and lowers it +into a plain `CallToolResult`; `lowerHookResult` does the same for `Hook.*`. +They remain the operative MCP path until the later projector migration. ```tsx import { Mcp, lowerMcpResult } from '@agent-bundle/runtime'; @@ -34,9 +36,13 @@ 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. +APIs, the Agent Document contracts, `createAgentRenderDispatcher`, and the +`@agent-bundle/runtime/flight/server` render entry. The Flight-facing versions +are exact compatibility pins: React/React DOM `19.2.8` and +`react-server-dom-rspack` `0.1.0`; the proof example compiles them with +`rsbuild-plugin-rsc` `0.1.1`. The package does not own application state, +persistence, a concrete execution host, or host packaging. 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 17b190bd2..2305531ee 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": "Versioned Agent Document contracts and React protocol primitives for Agent Bundle runtimes.", + "description": "Agent Document contracts and final-only React Flight dispatch for Agent Bundle runtimes.", "license": "MIT", "keywords": [ "agent-bundle", @@ -37,6 +37,10 @@ "types": "./dist/plugin.d.ts", "import": "./dist/plugin.js" }, + "./flight/server": { + "types": "./dist/flight/server.d.ts", + "import": "./dist/flight/server.js" + }, "./state": { "types": "./dist/state/index.d.ts", "import": "./dist/state.js" @@ -48,18 +52,23 @@ "typecheck": "tsc -p tsconfig.build.json --noEmit" }, "peerDependencies": { - "react": "^19.2.0" + "@rspack/core": "^2.2.0-0", + "react": "19.2.8", + "react-dom": "19.2.8" }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/server": "2.0.0", + "react-server-dom-rspack": "0.1.0", "zod": "4.4.3" }, "devDependencies": { "@modelcontextprotocol/client": "2.0.0", "@rslib/core": "0.23.2", + "@rspack/core": "2.2.1", "@rstest/core": "0.11.10", "@types/react": "19.2.18", - "react": "19.2.8" + "react": "19.2.8", + "react-dom": "19.2.8" } } diff --git a/packages/rsc-runtime/rslib.config.ts b/packages/rsc-runtime/rslib.config.ts index 398ce74af..7dad0a79d 100644 --- a/packages/rsc-runtime/rslib.config.ts +++ b/packages/rsc-runtime/rslib.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ root: import.meta.dirname, source: { entry: { + 'flight/server': './src/flight/server.ts', index: './src/index.ts', plugin: './src/plugin.ts', state: './src/state/index.ts', diff --git a/packages/rsc-runtime/src/dispatcher.ts b/packages/rsc-runtime/src/dispatcher.ts new file mode 100644 index 000000000..7f42e0dfc --- /dev/null +++ b/packages/rsc-runtime/src/dispatcher.ts @@ -0,0 +1,172 @@ +import { Children, isValidElement, type ReactNode } from 'react'; +import { createFromReadableStream } from 'react-server-dom-rspack/client.node'; + +import { + AgentContractError, + createAgentDocument, + type AgentDocument, + type AgentDocumentNode, + type AgentRenderLimits, +} from './agent-document.js'; +import type { AgentRenderInvocation } from './agent-request.js'; +import type { JsonValue } from './lower-mcp.js'; + +export interface AgentRenderDispatch { + readonly invocation: AgentRenderInvocation; + readonly signal: AbortSignal; +} + +export interface AgentFlightExecutionHost { + readonly execute: (request: AgentRenderDispatch) => Promise>; +} + +export interface AgentRenderDispatcher { + readonly dispatch: (request: AgentRenderDispatch) => Promise; +} + +export interface AgentRenderDispatcherOptions { + readonly limits?: Partial; +} + +const agentElementTypes = Object.freeze([ + 'agent-result', + 'agent-markdown', + 'agent-text', + 'agent-json', + 'agent-progress', + 'agent-image', + 'agent-audio', + 'agent-resource', + 'agent-error', +] as const); + +type AgentElementType = typeof agentElementTypes[number]; + +interface AgentProtocolElement { + readonly props: Record; + readonly type: AgentElementType; +} + +const isAgentElementType = (value: string): value is AgentElementType => + (agentElementTypes as readonly string[]).includes(value); + +const protocolElement = (node: ReactNode): AgentProtocolElement => { + if ( + !isValidElement(node) || + typeof node.type !== 'string' || + !isAgentElementType(node.type) + ) { + throw new AgentContractError( + 'invalid-document', + 'Flight output must contain only Agent protocol elements; function components and HTML are unsupported', + ); + } + return { props: node.props as Record, type: node.type }; +}; + +const textChild = (children: unknown, type: AgentElementType): string => { + const values = Children.toArray(children as ReactNode); + if (values.length !== 1 || typeof values[0] !== 'string') { + throw new AgentContractError('invalid-document', `${type} requires exactly one string child`); + } + return values[0]; +}; + +interface DecodeState { + representedError: boolean; +} + +const decodeNode = (node: ReactNode, state: DecodeState): AgentDocumentNode => { + const element = protocolElement(node); + const { props } = element; + switch (element.type) { + case 'agent-result': + return { + children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, state)), + kind: 'result', + ...(props.metadata === undefined ? {} : { metadata: props.metadata as JsonValue }), + }; + case 'agent-markdown': + return { kind: 'markdown', text: textChild(props.children, element.type) }; + case 'agent-text': + return { kind: 'text', text: textChild(props.children, element.type) }; + case 'agent-json': + return { kind: 'json', value: props.value as JsonValue }; + case 'agent-progress': + return { + completed: props.completed as number, + kind: 'progress', + ...(props.message === undefined ? {} : { message: props.message as string }), + ...(props.total === undefined ? {} : { total: props.total as number }), + }; + case 'agent-image': + return { data: props.data as string, kind: 'image', mimeType: props.mimeType as string }; + case 'agent-audio': + return { data: props.data as string, kind: 'audio', mimeType: props.mimeType as string }; + case 'agent-resource': + return { + kind: 'resource', + ...(props.mimeType === undefined ? {} : { mimeType: props.mimeType as string }), + name: props.name as string, + uri: props.uri as string, + }; + case 'agent-error': + state.representedError = true; + return { + code: props.code as string, + kind: 'error', + message: textChild(props.children, element.type), + }; + default: { + const exhaustive: never = element.type; + throw new AgentContractError('invalid-document', `Unsupported Agent protocol element: ${String(exhaustive)}`); + } + } +}; + +export const decodeAgentDocument = ( + node: ReactNode, + limits: Partial = {}, +): AgentDocument => { + const root = protocolElement(node); + if (root.type !== 'agent-result') { + throw new AgentContractError('invalid-document', 'Flight output must have Agent.Result as its root'); + } + const state: DecodeState = { representedError: false }; + const documentRoot = decodeNode(node, state); + return createAgentDocument({ + root: documentRoot, + status: state.representedError ? 'represented-error' : 'success', + ...(root.props.value === undefined ? {} : { value: root.props.value as JsonValue }), + version: 1, + }, limits); +}; + +const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError'); + +const withAbortSignal = ( + stream: ReadableStream, + signal: AbortSignal, +): ReadableStream => { + if (signal.aborted) throw abortError(); + return stream.pipeThrough(new TransformStream(), { signal }); +}; + +export const createAgentRenderDispatcher = ( + host: AgentFlightExecutionHost, + options: AgentRenderDispatcherOptions = {}, +): AgentRenderDispatcher => Object.freeze({ + async dispatch(request: AgentRenderDispatch): Promise { + if (request.signal.aborted) throw abortError(); + try { + const flight = await host.execute(request); + if (request.signal.aborted) throw abortError(); + const node = await createFromReadableStream(withAbortSignal(flight, request.signal)); + if (request.signal.aborted) throw abortError(); + return decodeAgentDocument(node, options.limits); + } catch (error) { + if (request.signal.aborted) throw abortError(); + throw error; + } + }, +}); diff --git a/packages/rsc-runtime/src/elements.ts b/packages/rsc-runtime/src/elements.ts index 066555180..c829ebbfb 100644 --- a/packages/rsc-runtime/src/elements.ts +++ b/packages/rsc-runtime/src/elements.ts @@ -4,6 +4,7 @@ import type { JsonValue } from './lower-mcp.js'; export interface AgentResultProps extends PropsWithChildren { readonly metadata?: JsonValue; + readonly value?: JsonValue; } export interface AgentTextProps { @@ -35,8 +36,8 @@ export interface AgentErrorProps extends AgentTextProps { readonly code: string; } -const AgentResult = ({ children, metadata }: AgentResultProps): ReactElement => - createElement('agent-result', { metadata }, children); +const AgentResult = ({ children, metadata, value }: AgentResultProps): ReactElement => + createElement('agent-result', { metadata, value }, children); const AgentMarkdown = ({ children }: AgentTextProps): ReactElement => createElement('agent-markdown', null, children); diff --git a/packages/rsc-runtime/src/flight/server.ts b/packages/rsc-runtime/src/flight/server.ts new file mode 100644 index 000000000..96a220c2b --- /dev/null +++ b/packages/rsc-runtime/src/flight/server.ts @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; +import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; + +export interface AgentFlightRenderOptions { + readonly onError?: (error: unknown) => string | undefined; + readonly signal?: AbortSignal; +} + +const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError'); + +export const renderAgentFlight = ( + model: ReactNode, + options: AgentFlightRenderOptions = {}, +): ReadableStream => { + if (options.signal?.aborted) throw abortError(); + const flight = renderToReadableStream(model, { + ...(options.onError === undefined ? {} : { onError: options.onError }), + }); + if (options.signal === undefined) return flight; + return flight.pipeThrough(new TransformStream(), { signal: options.signal }); +}; diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index 08c077a60..c92c06b68 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -40,6 +40,13 @@ export type { AgentResultNode, AgentTextNode, } from './agent-document.js'; +export { createAgentRenderDispatcher, decodeAgentDocument } from './dispatcher.js'; +export type { + AgentFlightExecutionHost, + AgentRenderDispatch, + AgentRenderDispatcher, + AgentRenderDispatcherOptions, +} from './dispatcher.js'; export { lowerHookResult } from './lower-hook.js'; export type { NativePostToolUseOutput } from './lower-hook.js'; export { lowerMcpResult } from './lower-mcp.js'; diff --git a/packages/rsc-runtime/src/react-server-dom-rspack.d.ts b/packages/rsc-runtime/src/react-server-dom-rspack.d.ts new file mode 100644 index 000000000..fd434a090 --- /dev/null +++ b/packages/rsc-runtime/src/react-server-dom-rspack.d.ts @@ -0,0 +1,16 @@ +declare module 'react-server-dom-rspack/client.node' { + export function createFromReadableStream( + stream: ReadableStream, + options?: Readonly<{ temporaryReferences?: unknown }>, + ): Promise; +} + +declare module 'react-server-dom-rspack/server.node' { + export function renderToReadableStream( + model: unknown, + options?: Readonly<{ + onError?: (error: unknown) => string | undefined; + temporaryReferences?: unknown; + }>, + ): ReadableStream; +} diff --git a/packages/rsc-runtime/tests/dispatcher.test.ts b/packages/rsc-runtime/tests/dispatcher.test.ts new file mode 100644 index 000000000..773aac75c --- /dev/null +++ b/packages/rsc-runtime/tests/dispatcher.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; + +import { + createAgentRenderDispatcher, + decodeAgentDocument, + type AgentFlightExecutionHost, +} from '../src/index.js'; + +describe('decodeAgentDocument', () => { + it('decodes protocol host elements into one immutable final document', () => { + const document = decodeAgentDocument(createElement( + 'agent-result', + { metadata: { source: 'flight' }, value: { ready: true } }, + createElement('agent-markdown', null, '# Ready'), + createElement('agent-error', { code: 'E_REPRESENTED' }, 'Partial result'), + )); + + expect(document).toEqual({ + root: { + children: [ + { kind: 'markdown', text: '# Ready' }, + { code: 'E_REPRESENTED', kind: 'error', message: 'Partial result' }, + ], + kind: 'result', + metadata: { source: 'flight' }, + }, + status: 'represented-error', + value: { ready: true }, + version: 1, + }); + expect(Object.isFrozen(document)).toBe(true); + }); + + it('never invokes function components while decoding Flight output', () => { + let invoked = false; + const Component = () => { + invoked = true; + return createElement('agent-result'); + }; + + expect(() => decodeAgentDocument(createElement(Component))).toThrow('protocol element'); + expect(invoked).toBe(false); + expect(() => decodeAgentDocument(createElement('div'))).toThrow('protocol element'); + }); +}); + +describe('AgentRenderDispatcher', () => { + it('passes the request AbortSignal to the execution host and fails before execution when already aborted', async () => { + let calls = 0; + const host: AgentFlightExecutionHost = { + execute: async () => { + calls += 1; + return new ReadableStream(); + }, + }; + const dispatcher = createAgentRenderDispatcher(host); + const controller = new AbortController(); + controller.abort(); + + await expect(dispatcher.dispatch({ + invocation: { kind: 'event', props: { event: 'tool/after', payload: {} } }, + signal: controller.signal, + })).rejects.toMatchObject({ name: 'AbortError' }); + expect(calls).toBe(0); + }); +}); + + +describe('Flight compatibility pins', () => { + it('keeps the runtime and proof compiler on the exact proven package set', () => { + const runtime = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as { + dependencies: Record; + peerDependencies: Record; + }; + const example = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', '..', 'examples/rsc-agent-runtime/package.json'), 'utf8')) as { + devDependencies: Record; + }; + + expect(runtime.dependencies['react-server-dom-rspack']).toBe('0.1.0'); + expect(runtime.peerDependencies.react).toBe('19.2.8'); + expect(runtime.peerDependencies['react-dom']).toBe('19.2.8'); + expect(example.devDependencies['rsbuild-plugin-rsc']).toBe('0.1.1'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c15d124d..2a4639a97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,6 +281,9 @@ importers: '@modelcontextprotocol/server': specifier: 2.0.0 version: 2.0.0 + react-server-dom-rspack: + specifier: 0.1.0 + version: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) zod: specifier: 4.4.3 version: 4.4.3 @@ -291,6 +294,9 @@ importers: '@rslib/core': specifier: 0.23.2 version: 0.23.2(typescript@7.0.2) + '@rspack/core': + specifier: 2.2.1 + version: 2.2.1(@swc/helpers@0.5.23) '@rstest/core': specifier: 0.11.10 version: 0.11.10 @@ -300,6 +306,9 @@ importers: react: specifier: 19.2.8 version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) packages/workbench: dependencies: