diff --git a/.changeset/agent-document-stage.md b/.changeset/agent-document-stage.md new file mode 100644 index 000000000..561dfb0d6 --- /dev/null +++ b/.changeset/agent-document-stage.md @@ -0,0 +1,16 @@ +--- +"agent-bundle": minor +--- + +Add the Workbench Agent Document stage (#105 stage 2). The dev server gains a +read-only `GET /api/runtime/runs/:id/document` route that decodes a succeeded +run's stored Flight through the optional `@agent-bundle/runtime` peer's +bounded render-event decoder — Flight bytes never reach the browser — with +honest diagnostics when the peer is absent (AB8207) or the payload is not an +Agent Document (AB8208). The Workbench decodes the event stream with its own +strict schemas and renders it in a shared stage: Markdown through the audited +shared projector, text/context/json/progress/image/audio/resource/error +nodes, accumulated render diagnostics, live progress, final status, and an +inspectable event timeline, surfaced as a new Document view in the Runtime +Playground inspector. MCP protocol results deliberately keep showing the +lowered projection the server actually returned. diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index c7e4d559a..f3f3a3083 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -17,7 +17,7 @@ import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app import { McpSessionRoutes } from './mcp-session/mcp-session-routes.ts'; import type { McpSessionService } from './mcp-session/mcp-session-service.ts'; import { RuntimeMcpRoutes } from './runtime-mcp-routes.ts'; -import { RuntimeRoutes } from './runtime-routes.ts'; +import { RuntimeRoutes, type AgentDocumentRuntimeModule } from './runtime-routes.ts'; import type { DevRuntimeSession } from './runtime-provider.ts'; import { PlaygroundRoutes, type PlaygroundRouteService } from './playground/playground-routes.ts'; import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/route-manifest-routes.ts'; @@ -94,6 +94,8 @@ export interface ForegroundProjectEventStreamHandle { } export interface ForegroundServerTesting { + /** Replaces the optional runtime import for Agent Document route tests. */ + readonly loadAgentDocumentRuntime?: () => Promise; /** Observes the current stream only after its subscription and close listeners exist. */ readonly onProjectEventStream?: (stream: ForegroundProjectEventStreamHandle) => void; } @@ -502,6 +504,9 @@ export class ForegroundServer { }); this.#runtimeRoutes = new RuntimeRoutes({ authorize: (request) => this.#assertMutationSession(request), + ...(options.testing?.loadAgentDocumentRuntime === undefined + ? {} + : { loadAgentDocumentRuntime: options.testing.loadAgentDocumentRuntime }), ...(options.runtime === undefined ? {} : { runtime: options.runtime }), }); this.#hookPlaygroundRoutes = new HookPlaygroundRoutes({ diff --git a/packages/agent-bundle/src/dev/runtime-routes.ts b/packages/agent-bundle/src/dev/runtime-routes.ts index d388705ac..b21151677 100644 --- a/packages/agent-bundle/src/dev/runtime-routes.ts +++ b/packages/agent-bundle/src/dev/runtime-routes.ts @@ -1,5 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { AgentRenderEvent, AgentRenderLimits } from '@agent-bundle/runtime'; + import { DevRuntimeGenerationConflictError, DevRuntimeUnavailableError, @@ -26,14 +28,42 @@ interface RequestDiagnostic { type Route = | Readonly<{ readonly kind: 'status' | 'surfaces' | 'runs' | 'state-reset' }> - | Readonly<{ readonly id: string; readonly kind: 'run' | 'flight' | 'replay' }> + | Readonly<{ readonly id: string; readonly kind: 'run' | 'document' | 'flight' | 'replay' }> | Readonly<{ readonly generation: string; readonly kind: 'asset'; readonly path: readonly string[]; readonly surfaceId: string }>; +export interface AgentDocumentRuntimeModule { + readonly DEFAULT_AGENT_RENDER_LIMITS: AgentRenderLimits; + readonly decodeAgentFlightStream: ( + flight: ReadableStream, + options?: Readonly<{ readonly limits?: Partial; readonly signal?: AbortSignal }>, + ) => ReadableStream; +} + export interface RuntimeRoutesOptions { readonly authorize: (request: IncomingMessage) => void; + readonly loadAgentDocumentRuntime?: () => Promise; readonly runtime?: DevRuntimeSession; } +let agentDocumentRuntimePromise: Promise | undefined; + +/** + * Agent Document projection is loaded only for its read route. The runtime is + * an optional peer, so importing the dev server must remain safe without it. + */ +const loadAgentDocumentRuntime = async (): Promise => { + agentDocumentRuntimePromise ??= import('@agent-bundle/runtime') + .then((runtime) => Object.freeze({ + DEFAULT_AGENT_RENDER_LIMITS: runtime.DEFAULT_AGENT_RENDER_LIMITS, + decodeAgentFlightStream: runtime.decodeAgentFlightStream, + })) + .catch((error: unknown) => { + agentDocumentRuntimePromise = undefined; + throw error; + }); + return agentDocumentRuntimePromise; +}; + const diagnostic = (code: string, message: string, status: number): RequestDiagnostic => ({ code, message, status }); const requestError = (value: RequestDiagnostic): RequestDiagnostic & Error => Object.assign( @@ -151,7 +181,7 @@ const route = (requestTarget: string | undefined): Route | undefined => { if (segments.length === 2 && segments[0] === 'state' && segments[1] === 'reset') return Object.freeze({ kind: 'state-reset' }); if (segments[0] === 'runs' && segments[1] !== undefined) { if (segments.length === 2) return Object.freeze({ id: segments[1], kind: 'run' }); - if (segments.length === 3 && (segments[2] === 'flight' || segments[2] === 'replay')) { + if (segments.length === 3 && (segments[2] === 'document' || segments[2] === 'flight' || segments[2] === 'replay')) { return Object.freeze({ id: segments[1], kind: segments[2] }); } } @@ -287,11 +317,13 @@ const responseAsset = (response: ServerResponse, asset: DevRuntimeAsset, cacheCo /** Fixed runtime browser contract; it never accepts executable provider routing or endpoints. */ export class RuntimeRoutes { readonly #authorize: (request: IncomingMessage) => void; + readonly #loadAgentDocumentRuntime: () => Promise; readonly #runtime: DevRuntimeSession | undefined; #closed = false; constructor(options: RuntimeRoutesOptions) { this.#authorize = options.authorize; + this.#loadAgentDocumentRuntime = options.loadAgentDocumentRuntime ?? loadAgentDocumentRuntime; this.#runtime = options.runtime; } @@ -368,6 +400,39 @@ export class RuntimeRoutes { if (asset === undefined) throw new DevRuntimeUnavailableError('Runtime run is not available.'); return responseAsset(response, { ...asset, contentType: 'application/octet-stream' }, 'no-store'); } + if (parsed.kind === 'document') { + onlyQuery(request.url, undefined); + if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + const run = assertRunOwned(session, session.run(parsed.id)); + if (run.status !== 'succeeded') throw new DevRuntimeUnavailableError('Runtime run is not available.'); + const asset = await session.readRunFlight(run.id); + if (asset === undefined) throw new DevRuntimeUnavailableError('Runtime run is not available.'); + let runtime: AgentDocumentRuntimeModule; + try { + runtime = await this.#loadAgentDocumentRuntime(); + } catch { + throw requestError(diagnostic( + 'AB8207', + 'Agent Document decoding requires the optional @agent-bundle/runtime peer.', + 503, + )); + } + try { + const flight = new ReadableStream({ + start(controller) { + controller.enqueue(asset.body); + controller.close(); + }, + }); + const events: AgentRenderEvent[] = []; + for await (const event of runtime.decodeAgentFlightStream(flight, { limits: runtime.DEFAULT_AGENT_RENDER_LIMITS })) { + events.push(event); + } + return responseJson(response, { events }); + } catch { + throw requestError(diagnostic('AB8208', 'Stored Flight could not be decoded as an Agent Document.', 409)); + } + } if (parsed.kind === 'replay') { onlyQuery(request.url, undefined); if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); diff --git a/packages/agent-bundle/tests/runtime-routes.test.ts b/packages/agent-bundle/tests/runtime-routes.test.ts index 7a9b23b44..13cb3a126 100644 --- a/packages/agent-bundle/tests/runtime-routes.test.ts +++ b/packages/agent-bundle/tests/runtime-routes.test.ts @@ -1,5 +1,12 @@ +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; + import { expect, it } from '@rstest/core'; +import { + DEFAULT_AGENT_RENDER_LIMITS, + decodeAgentFlightStream, +} from '@agent-bundle/runtime'; import { ProjectEventHub, startForegroundServer, @@ -69,8 +76,13 @@ class MemoryRuntime implements DevRuntimeSession { readonly mcpRegistry = {} as DevRuntimeSession['mcpRegistry']; readonly invocations: unknown[] = []; readonly providerSessionId: string = 'provider-a'; + readonly #flight: Uint8Array; #run: DevRuntimeRun = succeededRun; + constructor(flight: Uint8Array = Uint8Array.from([70, 76, 73, 71, 72, 84])) { + this.#flight = flight; + } + clientSurface(): undefined { return undefined; } async close(): Promise {} async invoke(request: Parameters[0]): Promise { @@ -87,7 +99,7 @@ class MemoryRuntime implements DevRuntimeSession { } async readRunFlight(runId: string): Promise { return runId === this.#run.id - ? { body: new Uint8Array([70, 76, 73, 71, 72, 84]), contentType: 'application/octet-stream' } + ? { body: this.#flight, contentType: 'application/octet-stream' } : undefined; } async reconcilePreparedRuntime(): Promise {} @@ -139,13 +151,17 @@ const coordinator = Object.freeze({ status: projectStatus, }); -const start = async (runtime?: DevRuntimeSession) => { +const start = async ( + runtime?: DevRuntimeSession, + testing?: Parameters[0]['testing'], +) => { const options = { coordinator, eventHub: new ProjectEventHub(), port: 0, runtime, sessionToken: 'runtime-session-token', + testing, } as Parameters[0] & { readonly runtime?: DevRuntimeSession }; return startForegroundServer(options); }; @@ -164,6 +180,41 @@ interface RuntimeRouteMatrixCase { readonly queryPath: string; } +const renderReadyFlight = async (): Promise => new Promise((resolve, reject) => { + const worker = spawn( + process.execPath, + ['--conditions=react-server', join(import.meta.dirname, '../../rsc-runtime/tests/flight-render-worker.mjs')], + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + const chunks: Buffer[] = []; + let error = ''; + worker.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); + worker.stderr.on('data', (chunk: Buffer) => { error += chunk.toString('utf8'); }); + worker.once('error', reject); + worker.once('close', (code) => { + if (code !== 0) { + reject(new Error(`Flight render worker exited with ${String(code)}: ${error}`)); + return; + } + resolve(new Uint8Array(Buffer.concat(chunks))); + }); + worker.stdin.end(`${JSON.stringify({ fixture: 'ready' })}\n`); +}); + +const realAgentDocumentRuntime = async () => ({ + DEFAULT_AGENT_RENDER_LIMITS, + decodeAgentFlightStream, +}); + +const emptyAgentDocumentRuntime = async () => ({ + DEFAULT_AGENT_RENDER_LIMITS, + decodeAgentFlightStream: () => new ReadableStream({ + start(controller) { + controller.close(); + }, + }), +}); + it('keeps public runtime capability summaries empty when the optional runtime is absent', async () => { const server = await start(); try { @@ -220,11 +271,98 @@ it('requires the foreground session capability for every runtime input, trace, a expect(flight.headers.get('cache-control')).toBe('no-store'); expect(flight.headers.get('content-type')).toBe('application/octet-stream'); await expect(flight.arrayBuffer()).resolves.toEqual(Uint8Array.from([70, 76, 73, 71, 72, 84]).buffer); + + const document = await fetch(`${server.url}/api/runtime/runs/run-a/document`); + expect(document.status).toBe(403); + } finally { + await server.close(); + } +}); + +it('decodes stored Flight into bounded Agent Document events in the foreground process', async () => { + const server = await start(new MemoryRuntime(await renderReadyFlight()), { + loadAgentDocumentRuntime: realAgentDocumentRuntime, + }); + try { + const response = await fetch(`${server.url}/api/runtime/runs/run-a/document`, { headers: authenticated(server) }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8'); + await expect(response.json()).resolves.toEqual({ + events: [ + { + document: { + root: { + children: [{ kind: 'markdown', text: '# Ready' }], + kind: 'result', + }, + status: 'success', + value: { ready: true }, + version: 1, + }, + sequence: 0, + type: 'shell', + }, + { + document: { + root: { + children: [{ kind: 'markdown', text: '# Ready' }], + kind: 'result', + }, + status: 'success', + value: { ready: true }, + version: 1, + }, + sequence: 1, + type: 'complete', + }, + ], + }); } finally { await server.close(); } }); +it('returns honest diagnostics when the Agent runtime is absent or stored Flight cannot decode', async () => { + const absent = await start(new MemoryRuntime(), { + loadAgentDocumentRuntime: async () => { + throw new Error('Cannot find package @agent-bundle/runtime'); + }, + }); + try { + const response = await fetch(`${absent.url}/api/runtime/runs/run-a/document`, { headers: authenticated(absent) }); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8207', + message: 'Agent Document decoding requires the optional @agent-bundle/runtime peer.', + }, + }); + } finally { + await absent.close(); + } + + const invalid = await start(new MemoryRuntime(), { + loadAgentDocumentRuntime: async () => ({ + DEFAULT_AGENT_RENDER_LIMITS, + decodeAgentFlightStream: () => { + throw new Error('invalid Flight'); + }, + }), + }); + try { + const response = await fetch(`${invalid.url}/api/runtime/runs/run-a/document`, { headers: authenticated(invalid) }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8208', + message: 'Stored Flight could not be decoded as an Agent Document.', + }, + }); + } finally { + await invalid.close(); + } +}); + it('rejects malformed, stale, undeclared, and excessive runtime inputs at the fixed boundary', async () => { const server = await start(new MemoryRuntime()); try { @@ -269,7 +407,7 @@ it('rejects malformed, stale, undeclared, and excessive runtime inputs at the fi }); it('accepts only the literal method and query matrix for every runtime route', async () => { - const server = await start(new MemoryRuntime()); + const server = await start(new MemoryRuntime(), { loadAgentDocumentRuntime: emptyAgentDocumentRuntime }); const privateHeaders = authenticated(server); const jsonHeaders = { ...privateHeaders, 'content-type': 'application/json' }; const root = server.url; @@ -280,6 +418,7 @@ it('accepts only the literal method and query matrix for every runtime route', a { acceptedMethod: 'GET', acceptedPath: '/api/runtime/runs?limit=1', headers: privateHeaders, invalidMethod: 'PATCH', queryPath: '/api/runtime/runs?limit=1&limit=2' }, { acceptedMethod: 'GET', acceptedPath: '/api/runtime/runs/run-a', headers: privateHeaders, invalidMethod: 'POST', queryPath: '/api/runtime/runs/run-a?extra=1&extra=2' }, { acceptedMethod: 'GET', acceptedPath: '/api/runtime/runs/run-a/flight', headers: privateHeaders, invalidMethod: 'POST', queryPath: '/api/runtime/runs/run-a/flight?extra=1&extra=2' }, + { acceptedMethod: 'GET', acceptedPath: '/api/runtime/runs/run-a/document', headers: privateHeaders, invalidMethod: 'POST', queryPath: '/api/runtime/runs/run-a/document?extra=1&extra=2' }, { acceptedMethod: 'POST', acceptedPath: '/api/runtime/runs/run-a/replay', body: JSON.stringify({ mode: 'exact', runId: 'run-a' }), headers: jsonHeaders, invalidMethod: 'GET', queryPath: '/api/runtime/runs/run-a/replay?extra=1&extra=2' }, { acceptedMethod: 'POST', acceptedPath: '/api/runtime/state/reset', body: JSON.stringify({ stateStoreId: 'state-a' }), headers: jsonHeaders, invalidMethod: 'GET', queryPath: '/api/runtime/state/reset?extra=1&extra=2' }, { acceptedMethod: 'GET', acceptedPath: '/api/runtime/assets/hook.after-edit/main.js?generation=g1', headers: privateHeaders, invalidMethod: 'HEAD', queryPath: '/api/runtime/assets/hook.after-edit/main.js?generation=g1&generation=g2' }, diff --git a/packages/workbench/src/runtime-client.ts b/packages/workbench/src/runtime-client.ts index 2a988efa2..c27851d11 100644 --- a/packages/workbench/src/runtime-client.ts +++ b/packages/workbench/src/runtime-client.ts @@ -19,6 +19,11 @@ import type { RuntimeVector, } from '../../agent-bundle/src/contracts/runtime.ts'; import { ForegroundRouteClient, ForegroundRouteClientError } from './mcp/mcp-route-client.ts'; +import { + AgentDocumentClient, + AgentDocumentClientError, + type AgentRenderEvent, +} from './runtime/agent-document-client.ts'; export type RuntimeBootstrap = | Readonly<{ readonly kind: 'unavailable' }> @@ -403,6 +408,9 @@ const invalid = (message: string): RuntimeClientError => new RuntimeClientError( const runtimeError = (error: unknown): RuntimeClientError => { if (error instanceof RuntimeClientError) return error; + if (error instanceof AgentDocumentClientError) { + return new RuntimeClientError({ code: error.code, message: error.message }); + } if (error instanceof ForegroundRouteClientError) { return new RuntimeClientError({ code: error.code, @@ -416,10 +424,12 @@ const runtimeError = (error: unknown): RuntimeClientError => { /** Typed immutable browser client for the provider-owned runtime routes. */ export class RuntimeClient { + readonly #agentDocuments: AgentDocumentClient; readonly #foreground: ForegroundRouteClient; #providerSessionId: string | undefined; constructor(foreground: ForegroundRouteClient) { + this.#agentDocuments = new AgentDocumentClient({ foreground }); this.#foreground = foreground; } @@ -489,6 +499,15 @@ export class RuntimeClient { } } + async readRunDocument(runId: string, signal?: AbortSignal): Promise { + this.#requireProvider(); + try { + return await this.#agentDocuments.events(runId, signal); + } catch (error) { + throw runtimeError(error); + } + } + async readAsset(request: DevRuntimeAssetRequest): Promise { this.#requireProvider(); if (request.path.length === 0) throw invalid('Runtime asset path is not valid.'); diff --git a/packages/workbench/src/runtime-inspector.tsx b/packages/workbench/src/runtime-inspector.tsx index 082227dcd..66702ebe2 100644 --- a/packages/workbench/src/runtime-inspector.tsx +++ b/packages/workbench/src/runtime-inspector.tsx @@ -1,10 +1,13 @@ -import React, { useRef, useState, type KeyboardEvent } from 'react'; +import React, { useEffect, useRef, useState, type KeyboardEvent } from 'react'; import type { DevRuntimeDiagnostic, DevRuntimeRun, DevRuntimeStatus, DevRuntimeSurface, DevRuntimeTreeNode } from '../../agent-bundle/src/contracts/runtime.ts'; import { RuntimeEvidence } from './runtime-evidence.tsx'; import type { RuntimeInspectorTab } from './runtime-model.ts'; +import type { AgentRenderEvent } from './runtime/agent-document-client.ts'; +import { AgentDocumentStage } from './runtime/agent-document-stage.tsx'; export interface RuntimeInspectorProps { + readonly loadDocumentEvents?: (runId: string, signal?: AbortSignal) => Promise; readonly onDownloadFlight?: (run: DevRuntimeRun) => void; readonly onTabChange?: (tab: RuntimeInspectorTab) => void; /** Presentation-only span disclosure; trace details always render when absent. */ @@ -21,6 +24,7 @@ export interface RuntimeInspectorProps { const tabs: readonly Readonly<{ readonly id: RuntimeInspectorTab; readonly label: string }>[] = [ { id: 'tree', label: 'Tree' }, { id: 'result', label: 'Result' }, + { id: 'document', label: 'Document' }, { id: 'flight', label: 'Flight' }, { id: 'protocol', label: 'Protocol' }, { id: 'state', label: 'State' }, @@ -51,7 +55,51 @@ const resultDiagnostics = (run: DevRuntimeRun | undefined, status: DevRuntimeSta ...(run?.status === 'failed' ? run.diagnostics : []), ]; -export const RuntimeInspector = ({ onDownloadFlight, onTabChange, run, status, surface, tab, traceExpansion }: RuntimeInspectorProps): React.ReactNode => { +type RuntimeDocumentState = + | Readonly<{ readonly phase: 'idle' }> + | Readonly<{ readonly phase: 'loading'; readonly runId: string }> + | Readonly<{ readonly events: readonly AgentRenderEvent[]; readonly phase: 'ready'; readonly runId: string }> + | Readonly<{ readonly message: string; readonly phase: 'error'; readonly runId: string }>; + +const RuntimeDocumentPanel = ({ loadDocumentEvents, run }: Pick): React.ReactNode => { + const [state, setState] = useState({ phase: 'idle' }); + const selected = run?.status === 'succeeded' ? run.result : undefined; + const canLoad = run?.status === 'succeeded' && selected?.flight !== undefined && loadDocumentEvents !== undefined; + + useEffect(() => { + if (!canLoad || run === undefined || loadDocumentEvents === undefined) { + setState({ phase: 'idle' }); + return; + } + const controller = new AbortController(); + setState({ phase: 'loading', runId: run.id }); + void loadDocumentEvents(run.id, controller.signal).then( + (events) => { + if (!controller.signal.aborted) setState({ events, phase: 'ready', runId: run.id }); + }, + (error: unknown) => { + if (!controller.signal.aborted) { + setState({ + message: error instanceof Error ? error.message : 'Agent Document request could not be completed.', + phase: 'error', + runId: run.id, + }); + } + }, + ); + return () => controller.abort(); + }, [canLoad, loadDocumentEvents, run]); + + if (run === undefined) return

Select a runtime run to inspect its Agent Document.

; + if (run.status !== 'succeeded') return

This run did not succeed, so it has no decodable Agent Document.

; + if (selected?.flight === undefined) return

This run has no stored Flight payload to decode as an Agent Document.

; + if (loadDocumentEvents === undefined) return

Agent Document loading is not available in this Workbench session.

; + if (state.phase === 'idle' || state.runId !== run.id || state.phase === 'loading') return

Loading Agent Document…

; + if (state.phase === 'error') return

{state.message}

; + return ; +}; + +export const RuntimeInspector = ({ loadDocumentEvents, onDownloadFlight, onTabChange, run, status, surface, tab, traceExpansion }: RuntimeInspectorProps): React.ReactNode => { const [internalTab, setInternalTab] = useState('tree'); const [treeExpanded, setTreeExpanded] = useState(true); const [showProps, setShowProps] = useState(false); @@ -105,6 +153,7 @@ export const RuntimeInspector = ({ onDownloadFlight, onTabChange, run, status, s

Result

{selected === undefined ?

No result is available.

:
{display({ agentVisible: selected.agentVisible, modelVisible: selected.modelVisible, native: selected.native, protocol: surface?.kind === 'mcp-tool' || surface?.kind === 'mcp-resource' || surface?.kind === 'mcp-app' ? undefined : selected.protocol })}
} : undefined} + {selectedTab === 'document' ? : undefined} {selectedTab === 'flight' ? <>

Flight

{selected?.flight === undefined ?

No Flight payload is available.

: <>

{selected.flight.bytes} bytes{selected.flight.truncated ? ' (preview truncated)' : ''}

{selected.flight.preview}
{selected.flight.downloadPath === undefined || onDownloadFlight === undefined || run === undefined ? undefined : }} diff --git a/packages/workbench/src/runtime-model.ts b/packages/workbench/src/runtime-model.ts index 306d522bf..28e509e0b 100644 --- a/packages/workbench/src/runtime-model.ts +++ b/packages/workbench/src/runtime-model.ts @@ -12,7 +12,7 @@ import type { import type { JsonValue, ProjectEventMessage, ProjectReplayGap } from '../../agent-bundle/src/contracts/runtime.ts'; import type { RuntimeBootstrap } from './runtime-client.ts'; -export type RuntimeInspectorTab = 'tree' | 'result' | 'flight' | 'protocol' | 'state' | 'diagnostics'; +export type RuntimeInspectorTab = 'tree' | 'result' | 'document' | 'flight' | 'protocol' | 'state' | 'diagnostics'; export interface RuntimePendingEffect { readonly id: string; @@ -160,7 +160,7 @@ const emptyHistory = Object.freeze([]) as readonly DevRuntimeRun[]; const emptySurfaces = Object.freeze([]) as readonly DevRuntimeSurface[]; const emptyProfiles = Object.freeze([]) as readonly RuntimeProfileOption[]; const emptyCounts = Object.freeze(Object.create(null)) as Readonly>; -const runtimeTabs = new Set(['tree', 'result', 'flight', 'protocol', 'state', 'diagnostics']); +const runtimeTabs = new Set(['tree', 'result', 'document', 'flight', 'protocol', 'state', 'diagnostics']); const isPlainRecord = (value: object): value is Record => { const prototype = Object.getPrototypeOf(value); diff --git a/packages/workbench/src/runtime-playground.tsx b/packages/workbench/src/runtime-playground.tsx index 66543a62f..c31530a6d 100644 --- a/packages/workbench/src/runtime-playground.tsx +++ b/packages/workbench/src/runtime-playground.tsx @@ -20,6 +20,7 @@ import { type RuntimeModelAction, type RuntimeProfileOption, } from './runtime-model.ts'; +import type { AgentRenderEvent } from './runtime/agent-document-client.ts'; import type { RuntimeAppPreviewRenderer, RuntimeLiveMcpPageAdapter, @@ -32,6 +33,7 @@ export type RuntimePlaygroundClient = Readonly<{ bootstrap(): Promise; createRun(request: DevRuntimeInvocationRequest): Promise; readRun(runId: string): Promise; + readRunDocument(runId: string, signal?: AbortSignal): Promise; readRunFlight(runId: string): Promise; replayRun(request: DevRuntimeReplayRequest): Promise; resetState(request: DevRuntimeStateResetRequest): Promise; @@ -41,6 +43,7 @@ export interface RuntimePlaygroundController { close(): void; dispatch(action: RuntimeModelAction): void; downloadRunFlight(runId: string): Promise; + readRunDocument(runId: string, signal?: AbortSignal): Promise; readonly error: string | undefined; receive(event: ProjectEventMessage): Promise; readonly model: RuntimeModel; @@ -140,6 +143,10 @@ class RuntimePlaygroundControllerImpl implements RuntimePlaygroundController { return this.#client.readRunFlight(runId); } + readRunDocument(runId: string, signal?: AbortSignal): Promise { + return this.#client.readRunDocument(runId, signal); + } + close(): void { this.#mounted = false; this.#listeners.clear(); @@ -648,6 +655,7 @@ export const RuntimePlayground = ({ controller, liveMcpPageAdapter = runtimePlay /> {flightDownloadError === undefined ? undefined :

{flightDownloadError}

} controller.readRunDocument(runId, signal)} onDownloadFlight={downloadFlight} onTabChange={(tab) => controller.dispatch({ tab, type: 'selection.tab' })} run={run} diff --git a/packages/workbench/src/runtime/agent-document-client.ts b/packages/workbench/src/runtime/agent-document-client.ts new file mode 100644 index 000000000..7fea9800f --- /dev/null +++ b/packages/workbench/src/runtime/agent-document-client.ts @@ -0,0 +1,181 @@ +import { z } from 'zod'; + +import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; + +export type AgentDocumentJsonValue = + | null + | boolean + | number + | string + | readonly AgentDocumentJsonValue[] + | Readonly<{ readonly [key: string]: AgentDocumentJsonValue }>; + +export interface AgentResultNode { + readonly children: readonly AgentDocumentNode[]; + readonly kind: 'result'; + readonly metadata?: AgentDocumentJsonValue; +} + +export type AgentDocumentNode = + | AgentResultNode + | Readonly<{ readonly kind: 'markdown'; readonly text: string }> + | Readonly<{ readonly kind: 'text'; readonly text: string }> + | Readonly<{ readonly kind: 'context'; readonly text: string }> + | Readonly<{ readonly kind: 'json'; readonly value: AgentDocumentJsonValue }> + | Readonly<{ readonly completed: number; readonly kind: 'progress'; readonly message?: string; readonly total?: number }> + | Readonly<{ readonly data: string; readonly kind: 'image'; readonly mimeType: string }> + | Readonly<{ readonly data: string; readonly kind: 'audio'; readonly mimeType: string }> + | Readonly<{ readonly kind: 'resource'; readonly mimeType?: string; readonly name: string; readonly uri: string }> + | Readonly<{ readonly code: string; readonly kind: 'error'; readonly message: string }>; + +export interface AgentDocument { + readonly root: AgentDocumentNode; + readonly status: 'success' | 'represented-error' | 'failed'; + readonly value?: AgentDocumentJsonValue; + readonly version: 1; +} + +export interface AgentRenderError { + readonly code: string; + readonly data?: AgentDocumentJsonValue; + readonly message: string; +} + +export type AgentRenderEvent = + | Readonly<{ readonly document: AgentDocument; readonly sequence: number; readonly type: 'shell' }> + | Readonly<{ readonly completed: number; readonly message?: string; readonly sequence: number; readonly total?: number; readonly type: 'progress' }> + | Readonly<{ readonly boundaryId: string; readonly document: AgentDocument; readonly sequence: number; readonly type: 'replace' }> + | Readonly<{ readonly boundaryId?: string; readonly error: AgentRenderError; readonly sequence: number; readonly type: 'error' }> + | Readonly<{ readonly document: AgentDocument; readonly sequence: number; readonly type: 'complete' }>; + +export interface AgentDocumentClientOptions { + readonly foreground: ForegroundRequestAuthority; +} + +const nonemptyStringSchema = z.string().min(1); +const progressNumberSchema = z.number().finite().nonnegative(); + +const progressFieldsSchema = z.strictObject({ + completed: progressNumberSchema, + message: nonemptyStringSchema.optional(), + total: progressNumberSchema.optional(), +}).refine((progress) => progress.total === undefined || progress.completed <= progress.total); + +const agentDocumentNodeSchema: z.ZodType = z.lazy(() => z.discriminatedUnion('kind', [ + z.strictObject({ + children: z.array(agentDocumentNodeSchema), + kind: z.literal('result'), + metadata: z.json().optional(), + }), + z.strictObject({ kind: z.literal('markdown'), text: z.string() }), + z.strictObject({ kind: z.literal('text'), text: z.string() }), + z.strictObject({ kind: z.literal('context'), text: z.string() }), + z.strictObject({ kind: z.literal('json'), value: z.json() }), + progressFieldsSchema.extend({ kind: z.literal('progress') }), + z.strictObject({ data: nonemptyStringSchema, kind: z.literal('image'), mimeType: nonemptyStringSchema }), + z.strictObject({ data: nonemptyStringSchema, kind: z.literal('audio'), mimeType: nonemptyStringSchema }), + z.strictObject({ + kind: z.literal('resource'), + mimeType: nonemptyStringSchema.optional(), + name: nonemptyStringSchema, + uri: nonemptyStringSchema, + }), + z.strictObject({ code: nonemptyStringSchema, kind: z.literal('error'), message: z.string() }), +])); + +const agentDocumentSchema: z.ZodType = z.strictObject({ + root: agentDocumentNodeSchema, + status: z.enum(['success', 'represented-error', 'failed']), + value: z.json().optional(), + version: z.literal(1), +}); + +const sequenceSchema = z.number().int().nonnegative(); +const renderErrorSchema: z.ZodType = z.strictObject({ + code: nonemptyStringSchema, + data: z.json().optional(), + message: z.string(), +}); + +const agentRenderEventSchema: z.ZodType = z.discriminatedUnion('type', [ + z.strictObject({ document: agentDocumentSchema, sequence: sequenceSchema, type: z.literal('shell') }), + progressFieldsSchema.extend({ sequence: sequenceSchema, type: z.literal('progress') }), + z.strictObject({ + boundaryId: nonemptyStringSchema, + document: agentDocumentSchema, + sequence: sequenceSchema, + type: z.literal('replace'), + }), + z.strictObject({ + boundaryId: nonemptyStringSchema.optional(), + error: renderErrorSchema, + sequence: sequenceSchema, + type: z.literal('error'), + }), + z.strictObject({ document: agentDocumentSchema, sequence: sequenceSchema, type: z.literal('complete') }), +]); + +const eventsResponseSchema = z.strictObject({ events: z.array(agentRenderEventSchema) }); +const diagnosticResponseSchema = z.strictObject({ + diagnostic: z.strictObject({ + code: z.string(), + message: z.string(), + }), +}); + +export class AgentDocumentClientError extends Error { + readonly code: string; + readonly status: number | undefined; + + constructor(code: string, message: string, status?: number) { + super(message); + this.name = 'AgentDocumentClientError'; + this.code = code; + this.status = status; + } +} + +export const decodeAgentDocumentEvents = (value: unknown): readonly AgentRenderEvent[] => { + const result = eventsResponseSchema.safeParse(value); + if (!result.success) { + throw new AgentDocumentClientError('AB8209', 'Agent Document route returned an invalid response.'); + } + return Object.freeze(result.data.events); +}; + +const opaqueRunId = (value: string): string => { + if ( + value.length === 0 || value === '.' || value === '..' || + value.includes('/') || value.includes('\\') || value.includes('\0') + ) { + throw new AgentDocumentClientError('AB8209', 'Runtime run ID is not a valid opaque segment.'); + } + return encodeURIComponent(value); +}; + +const responseError = (value: unknown, status: number): AgentDocumentClientError => { + const decoded = diagnosticResponseSchema.safeParse(value); + if (decoded.success) { + return new AgentDocumentClientError(decoded.data.diagnostic.code, decoded.data.diagnostic.message, status); + } + return new AgentDocumentClientError('AB8209', `Agent Document request failed with HTTP ${String(status)}.`, status); +}; + +/** Reads the server-decoded Agent Document stream; Flight bytes never enter the browser. */ +export class AgentDocumentClient { + readonly #foreground: ForegroundRequestAuthority; + + constructor(options: AgentDocumentClientOptions) { + this.#foreground = options.foreground; + } + + async events(runId: string, signal?: AbortSignal): Promise { + const response = await this.#foreground.protectedRequest( + `/api/runtime/runs/${opaqueRunId(runId)}/document`, + signal === undefined ? {} : { signal }, + ); + const body: unknown = await response.json().catch(() => undefined); + if (!response.ok) throw responseError(body, response.status); + return decodeAgentDocumentEvents(body); + } +} diff --git a/packages/workbench/src/runtime/agent-document-stage.tsx b/packages/workbench/src/runtime/agent-document-stage.tsx new file mode 100644 index 000000000..0e731909f --- /dev/null +++ b/packages/workbench/src/runtime/agent-document-stage.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from 'react'; + +import { MarkdownProjector } from '../skill-markdown.tsx'; +import type { + AgentDocument, + AgentDocumentNode, + AgentRenderEvent, +} from './agent-document-client.ts'; + +export type AgentDocumentProgress = Extract; +export type AgentDocumentStreamError = Extract; + +export interface AgentDocumentFold { + readonly document?: AgentDocument; + readonly errors: readonly AgentDocumentStreamError[]; + readonly finalStatus?: AgentDocument['status']; + readonly progress?: AgentDocumentProgress; +} + +/** Applies the already-ordered render events without replaying Suspense boundaries. */ +export const foldAgentDocumentEvents = (events: readonly AgentRenderEvent[]): AgentDocumentFold => { + let document: AgentDocument | undefined; + let finalStatus: AgentDocument['status'] | undefined; + let progress: AgentDocumentProgress | undefined; + const errors: AgentDocumentStreamError[] = []; + + for (const event of events) { + switch (event.type) { + case 'shell': + case 'replace': + document = event.document; + break; + case 'progress': + progress = event; + break; + case 'error': + errors.push(event); + break; + case 'complete': + document = event.document; + finalStatus = event.document.status; + break; + default: { + const exhaustive: never = event; + return exhaustive; + } + } + } + + return Object.freeze({ + ...(document === undefined ? {} : { document }), + errors: Object.freeze(errors), + ...(finalStatus === undefined ? {} : { finalStatus }), + ...(progress === undefined ? {} : { progress }), + }); +}; + +const display = (value: unknown): string => { + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return '[Unserializable Agent Document value]'; + } +}; + +const progressLabel = (progress: Readonly<{ + readonly completed: number; + readonly message?: string; + readonly total?: number; +}>): string => { + const amount = progress.total === undefined + ? String(progress.completed) + : `${String(progress.completed)} / ${String(progress.total)}`; + return progress.message === undefined ? amount : `${progress.message} · ${amount}`; +}; + +const AgentDocumentNodeView = ({ node, path }: Readonly<{ + readonly node: AgentDocumentNode; + readonly path: string; +}>): React.ReactNode => { + switch (node.kind) { + case 'result': + return
+ {node.metadata === undefined ? undefined :
Result metadata
{display(node.metadata)}
} +
+ {node.children.map((child, index) => + )} +
+
; + case 'markdown': + return ; + case 'text': + return

{node.text}

; + case 'context': + return
+

Additional context

+

{node.text}

+
; + case 'json': + return
{display(node.value)}
; + case 'progress': + return

{progressLabel(node)}

; + case 'image': + return
+ Agent-rendered image +
{node.mimeType}
+
; + case 'audio': + return
+
; + case 'resource': + return
+
Name
{node.name}
+
URI
{node.uri}
+ {node.mimeType === undefined ? undefined :
MIME type
{node.mimeType}
} +
; + case 'error': + return

{node.code} · {node.message}

; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +const eventLabel = (event: AgentRenderEvent): string => { + switch (event.type) { + case 'shell': + return `Shell · #${String(event.sequence)}`; + case 'progress': + return `Progress · #${String(event.sequence)} · ${progressLabel(event)}`; + case 'replace': + return `Replace · #${String(event.sequence)} · ${event.boundaryId}`; + case 'error': + return `Error · #${String(event.sequence)} · ${event.error.code}`; + case 'complete': + return `Complete · #${String(event.sequence)} · ${event.document.status}`; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +}; + +export interface AgentDocumentStageProps { + readonly events: readonly AgentRenderEvent[]; +} + +/** Shared Agent Document projection with an inspectable as-of-event timeline. */ +export const AgentDocumentStage = ({ events }: AgentDocumentStageProps): React.ReactNode => { + const [selectedIndex, setSelectedIndex] = useState(undefined); + useEffect(() => setSelectedIndex(undefined), [events]); + const visibleEvents = selectedIndex === undefined ? events : events.slice(0, selectedIndex + 1); + const folded = foldAgentDocumentEvents(visibleEvents); + const status = folded.finalStatus ?? folded.document?.status; + + return
+
+
+

Agent Document

+

{folded.document === undefined ? 'No document snapshot is available.' : `Version ${String(folded.document.version)} · ${status}`}

+
+ {folded.progress === undefined ? undefined :

{progressLabel(folded.progress)}

} +
+ {folded.errors.length === 0 ? undefined :
+

Render diagnostics

+
    {folded.errors.map((event) =>
  • + {event.error.code} · {event.error.message} + {event.boundaryId === undefined ? undefined : <> · boundary {event.boundaryId}} + {event.error.data === undefined ? undefined :
    {display(event.error.data)}
    } +
  • )}
+
} + {folded.document === undefined ?

No Agent Document was produced by this event.

: <> + + {folded.document.value === undefined ? undefined :
+ Document value +
{display(folded.document.value)}
+
} + } + +
; +}; diff --git a/packages/workbench/src/skill-markdown.tsx b/packages/workbench/src/skill-markdown.tsx index f0814f170..73a37de7a 100644 --- a/packages/workbench/src/skill-markdown.tsx +++ b/packages/workbench/src/skill-markdown.tsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense, type ComponentPropsWithoutRef, type ReactNode } from 'react'; +import React, { lazy, Suspense, type ComponentProps, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -18,6 +18,11 @@ export interface SkillMarkdownProps { readonly resources: readonly string[]; } +export interface MarkdownProjectorProps { + readonly body: string; + readonly components?: ComponentProps['components']; +} + type MarkdownElementProps = ComponentPropsWithoutRef & { readonly node?: unknown }; @@ -90,19 +95,18 @@ const SkillImage = ({ return {alt; }; -/** Renders only the server-provided Markdown body and explicit typed resource base. */ -export const SkillMarkdown = ({ base, body, resources }: SkillMarkdownProps) => ( +/** The audited inert-HTML/GFM projector shared by Skills and Agent Documents. */ +export const MarkdownProjector = ({ body, components }: MarkdownProjectorProps) => (
, code: SkillCode, h1: ({ node: _node, ...properties }) =>

, h2: ({ node: _node, ...properties }) =>

, h3: ({ node: _node, ...properties }) =>

, - img: (properties) => , pre: ({ children }) => <>{children}, table: ({ node: _node, ...properties }) =>
, + ...components, }} remarkPlugins={[remarkGfm, inertHtml]} > @@ -110,3 +114,14 @@ export const SkillMarkdown = ({ base, body, resources }: SkillMarkdownProps) => ); + +/** Renders only the server-provided Markdown body and explicit typed resource base. */ +export const SkillMarkdown = ({ base, body, resources }: SkillMarkdownProps) => ( + , + img: (properties) => , + }} + /> +); diff --git a/packages/workbench/tests/agent-document-client.test.ts b/packages/workbench/tests/agent-document-client.test.ts new file mode 100644 index 000000000..71e17e672 --- /dev/null +++ b/packages/workbench/tests/agent-document-client.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from '@rstest/core'; + +import { + AgentDocumentClient, + AgentDocumentClientError, + decodeAgentDocumentEvents, + type AgentDocument, +} from '../src/runtime/agent-document-client.ts'; + +const document: AgentDocument = { + root: { + children: [ + { kind: 'markdown', text: '# Rendered heading' }, + { kind: 'text', text: 'Plain text' }, + { kind: 'context', text: 'Additional context' }, + { kind: 'json', value: { accepted: true } }, + { completed: 1, kind: 'progress', message: 'Halfway', total: 2 }, + { data: 'iVBORw0KGgo=', kind: 'image', mimeType: 'image/png' }, + { data: 'UklGRg==', kind: 'audio', mimeType: 'audio/wav' }, + { kind: 'resource', mimeType: 'text/plain', name: 'Evidence', uri: 'agent://evidence/1' }, + { code: 'DOC_ERROR', kind: 'error', message: 'Represented failure' }, + { children: [{ kind: 'text', text: 'Nested result' }], kind: 'result', metadata: { source: 'nested' } }, + ], + kind: 'result', + metadata: { route: 'tool/status' }, + }, + status: 'success', + value: { final: true }, + version: 1, +}; + +const events = [ + { document, sequence: 0, type: 'shell' }, + { completed: 1, message: 'Working', sequence: 1, total: 2, type: 'progress' }, + { boundaryId: 'boundary-a', document, sequence: 2, type: 'replace' }, + { + boundaryId: 'boundary-b', + error: { code: 'BOUNDARY_FAILED', data: { retryable: false }, message: 'Boundary failed' }, + sequence: 3, + type: 'error', + }, + { document, sequence: 4, type: 'complete' }, +] as const; + +describe('Agent Document client contract', () => { + it('decodes every document node and render event variant', () => { + const decoded = decodeAgentDocumentEvents({ events }); + + expect(decoded).toHaveLength(5); + expect(decoded.map((event) => event.type)).toEqual(['shell', 'progress', 'replace', 'error', 'complete']); + expect(decoded[0]).toMatchObject({ document, sequence: 0, type: 'shell' }); + }); + + it('rejects unknown fields at the envelope, event, document, and recursive node boundaries', () => { + expect(() => decodeAgentDocumentEvents({ events, unknown: true })).toThrow(AgentDocumentClientError); + expect(() => decodeAgentDocumentEvents({ + events: [{ ...events[0], unknown: true }], + })).toThrow(AgentDocumentClientError); + expect(() => decodeAgentDocumentEvents({ + events: [{ + ...events[0], + document: { ...document, unknown: true }, + }], + })).toThrow(AgentDocumentClientError); + expect(() => decodeAgentDocumentEvents({ + events: [{ + ...events[0], + document: { + ...document, + root: { + ...document.root, + children: [{ kind: 'text', text: 'Strict', unknown: true }], + }, + }, + }], + })).toThrow(AgentDocumentClientError); + }); + + it('uses the protected foreground authority and preserves structured diagnostics', async () => { + const requests: string[] = []; + const client = new AgentDocumentClient({ + foreground: { + protectedRequest: async (path) => { + requests.push(path); + return new Response(JSON.stringify({ events }), { + headers: { 'content-type': 'application/json' }, + status: 200, + }); + }, + }, + }); + + await expect(client.events('run-a')).resolves.toEqual(decodeAgentDocumentEvents({ events })); + await expect(client.events('run/a')).rejects.toMatchObject({ code: 'AB8209' }); + expect(requests).toEqual(['/api/runtime/runs/run-a/document']); + + const failed = new AgentDocumentClient({ + foreground: { + protectedRequest: async () => new Response(JSON.stringify({ + diagnostic: { code: 'AB8208', message: 'Stored Flight could not be decoded as an Agent Document.' }, + }), { status: 409 }), + }, + }); + await expect(failed.events('run-a')).rejects.toMatchObject({ + code: 'AB8208', + message: 'Stored Flight could not be decoded as an Agent Document.', + status: 409, + }); + }); +}); diff --git a/packages/workbench/tests/agent-document-stage.test.ts b/packages/workbench/tests/agent-document-stage.test.ts new file mode 100644 index 000000000..84c37cb02 --- /dev/null +++ b/packages/workbench/tests/agent-document-stage.test.ts @@ -0,0 +1,113 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { describe, expect, it } from '@rstest/core'; + +import type { AgentDocument, AgentRenderEvent } from '../src/runtime/agent-document-client.ts'; +import { + AgentDocumentStage, + foldAgentDocumentEvents, +} from '../src/runtime/agent-document-stage.tsx'; + +const document = ( + label: string, + status: AgentDocument['status'] = 'success', +): AgentDocument => ({ + root: { + children: [{ kind: 'text', text: label }], + kind: 'result', + }, + status, + version: 1, +}); + +describe('Agent Document event fold', () => { + it('keeps ordered replace snapshots, latest progress, accumulated errors, and complete status', () => { + const events: readonly AgentRenderEvent[] = [ + { document: document('Shell'), sequence: 0, type: 'shell' }, + { completed: 1, message: 'Started', sequence: 1, total: 3, type: 'progress' }, + { boundaryId: 'a', document: document('Replacement'), sequence: 2, type: 'replace' }, + { completed: 2, message: 'Almost done', sequence: 3, total: 3, type: 'progress' }, + { + boundaryId: 'b', + error: { code: 'BOUNDARY_FAILED', data: { retryable: false }, message: 'Boundary failed' }, + sequence: 4, + type: 'error', + }, + { document: document('Complete', 'represented-error'), sequence: 5, type: 'complete' }, + ]; + + const beforeComplete = foldAgentDocumentEvents(events.slice(0, 5)); + expect(beforeComplete.document?.root).toMatchObject({ + children: [{ kind: 'text', text: 'Replacement' }], + }); + expect(beforeComplete.finalStatus).toBeUndefined(); + + const folded = foldAgentDocumentEvents(events); + expect(folded.document?.root).toMatchObject({ + children: [{ kind: 'text', text: 'Complete' }], + }); + expect(folded.progress).toEqual({ + completed: 2, + message: 'Almost done', + sequence: 3, + total: 3, + type: 'progress', + }); + expect(folded.errors).toEqual([events[4]]); + expect(folded.finalStatus).toBe('represented-error'); + }); +}); + +describe('Agent Document stage', () => { + it('renders every node kind through the shared projector and rich media data URIs', () => { + const rich: AgentDocument = { + root: { + children: [ + { kind: 'markdown', text: '# Projected heading\n\n**Rendered Markdown**' }, + { kind: 'text', text: 'Plain output' }, + { kind: 'context', text: 'Context output' }, + { kind: 'json', value: { answer: 42 } }, + { completed: 1, kind: 'progress', message: 'In-document progress', total: 2 }, + { data: 'iVBORw0KGgo=', kind: 'image', mimeType: 'image/png' }, + { data: 'UklGRg==', kind: 'audio', mimeType: 'audio/wav' }, + { kind: 'resource', mimeType: 'text/plain', name: 'Evidence', uri: 'agent://evidence/1' }, + { code: 'REPRESENTED', kind: 'error', message: 'Represented error node' }, + ], + kind: 'result', + metadata: { route: 'status' }, + }, + status: 'represented-error', + value: { final: true }, + version: 1, + }; + const events: readonly AgentRenderEvent[] = [ + { document: rich, sequence: 0, type: 'shell' }, + { completed: 1, message: 'Live progress', sequence: 1, total: 2, type: 'progress' }, + { + error: { code: 'STREAM_ERROR', message: 'Visible stream diagnostic' }, + sequence: 2, + type: 'error', + }, + { document: rich, sequence: 3, type: 'complete' }, + ]; + + const markup = renderToStaticMarkup(createElement(AgentDocumentStage, { events })); + + expect(markup).toContain('class="skill-heading skill-heading--one"'); + expect(markup).toContain('Rendered Markdown'); + expect(markup).toContain('Additional context'); + expect(markup).toContain('"answer": 42'); + expect(markup).toContain('In-document progress'); + expect(markup).toContain('src="data:image/png;base64,iVBORw0KGgo="'); + expect(markup).toContain('src="data:audio/wav;base64,UklGRg=="'); + expect(markup).toContain('agent://evidence/1'); + expect(markup).toContain('REPRESENTED'); + expect(markup).toContain('STREAM_ERROR'); + expect(markup).toContain('Live progress'); + expect(markup).toContain('represented-error'); + expect(markup).toContain('Version 1'); + expect(markup).toContain('Shell · #0'); + expect(markup).toContain('Complete · #3'); + }); +}); diff --git a/packages/workbench/tests/runtime-client.test.ts b/packages/workbench/tests/runtime-client.test.ts index e4b0ad6cb..2530a7f9b 100644 --- a/packages/workbench/tests/runtime-client.test.ts +++ b/packages/workbench/tests/runtime-client.test.ts @@ -92,6 +92,7 @@ const deferred = (): Deferred => { const runtimeFetch = (options: { readonly asset?: Response; + readonly document?: Response; readonly flight?: Response; readonly runs?: readonly DevRuntimeRun[]; readonly status?: DevRuntimeStatus | null; @@ -112,6 +113,19 @@ const runtimeFetch = (options: { if (url === '/api/runtime/runs/run%20a/flight' && init?.method === undefined) { return options.flight ?? new Response(new Uint8Array([70, 76]), { headers: { 'content-type': 'application/octet-stream' } }); } + if (url === '/api/runtime/runs/run%20a/document' && init?.method === undefined) { + return options.document ?? json({ + events: [{ + document: { + root: { children: [{ kind: 'text', text: 'Ready' }], kind: 'result' }, + status: 'success', + version: 1, + }, + sequence: 0, + type: 'complete', + }], + }); + } if (url === '/api/runtime/assets/app-weather/assets/weather%20app.js?generation=generation%20a') { return options.asset ?? new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'application/javascript' } }); } @@ -463,6 +477,17 @@ it('rejects Flight reads before an authoritative provider bootstrap', async () = await expect(client.readRunFlight('run a')).rejects.toMatchObject({ code: 'AB8201' }); }); +it('reads strict Agent Document events through the protected provider authority', async () => { + const fixture = runtimeFetch(); + const client = new RuntimeClient(new ForegroundRouteClient({ fetch: fixture.fetch })); + await client.bootstrap(); + + await expect(client.readRunDocument('run a')).resolves.toMatchObject([ + { sequence: 0, type: 'complete' }, + ]); + expect(fixture.requests.at(-1)?.url).toBe('/api/runtime/runs/run%20a/document'); +}); + it('preserves own __proto__ keys as immutable prototype-inert JSON snapshots', async () => { const foreground = new ForegroundRouteClient({ fetch: async (input) => String(input) === '/public' diff --git a/packages/workbench/tests/runtime-contract-compile.test.ts b/packages/workbench/tests/runtime-contract-compile.test.ts index b5aeb0dbd..720572ea6 100644 --- a/packages/workbench/tests/runtime-contract-compile.test.ts +++ b/packages/workbench/tests/runtime-contract-compile.test.ts @@ -213,6 +213,7 @@ const runtimePlaygroundController = createRuntimePlaygroundController({ bootstrap: async () => runtimeBootstrap, createRun: async () => run, readRun: async () => run, + readRunDocument: async () => [], readRunFlight: async () => new Blob(['flight'], { type: 'application/octet-stream' }), replayRun: async () => run, resetState: async () => state, diff --git a/packages/workbench/tests/runtime-inspector.test.ts b/packages/workbench/tests/runtime-inspector.test.ts index 7f0b420b4..283fc5f2b 100644 --- a/packages/workbench/tests/runtime-inspector.test.ts +++ b/packages/workbench/tests/runtime-inspector.test.ts @@ -55,7 +55,19 @@ const fixtureSource = (root: string): string => ` startedAt: '2026-08-15T12:00:00.000Z', status: 'succeeded', surfaceId: 'tool/customer', target: 'portable', vector: { providerSessionId: 'provider', runtimeGenerationId: 'generation', sourceRevision: 'source', stateStoreId: 'state-customer', stateVersion: 2 }, } as const; - createRoot(document.getElementById('root')!).render(); + const agentDocument = { + root: { children: [{ kind: 'markdown', text: '# Customer document' }], kind: 'result' }, + status: 'success', version: 1, + } as const; + createRoot(document.getElementById('root')!).render( [ + { document: agentDocument, sequence: 0, type: 'shell' }, + { completed: 1, message: 'Loaded', sequence: 1, total: 1, type: 'progress' }, + { document: agentDocument, sequence: 2, type: 'complete' }, + ]} + run={run} + surface={surface} + />); `; describe('Runtime inspector', () => { @@ -84,7 +96,7 @@ describe('Runtime inspector', () => { await page.waitForTimeout(250); if (errors.length > 0) throw new Error(errors.join('\n')); await page.getByText('Decoded React tree', { exact: true }).waitFor({ timeout: 5_000 }); - expect(await page.getByRole('tab').allTextContents()).toEqual(['Tree', 'Result', 'Flight', 'Protocol', 'State', 'Diagnostics']); + expect(await page.getByRole('tab').allTextContents()).toEqual(['Tree', 'Result', 'Document', 'Flight', 'Protocol', 'State', 'Diagnostics']); expect(await page.locator('[role="tree"]').count()).toBe(1); expect(await page.locator('[role="treeitem"]').count()).toBe(2); expect(await page.locator('[role="treeitem"]').first().getAttribute('aria-level')).toBe('1'); @@ -99,6 +111,11 @@ describe('Runtime inspector', () => { await page.getByRole('button', { name: 'Expand all' }).click(); expect(await page.locator('[role="treeitem"]').count()).toBe(2); + await page.getByRole('tab', { name: 'Document' }).click(); + await page.getByRole('heading', { name: 'Customer document' }).waitFor({ timeout: 5_000 }); + expect(await page.getByLabel('Agent Document').textContent()).toContain('Version 1 · success'); + expect(await page.getByLabel('Agent Document').textContent()).toContain('Loaded · 1 / 1'); + await page.getByRole('tab', { name: 'Protocol' }).click(); await page.getByText('Provider MCP protocol', { exact: true }).waitFor({ timeout: 5_000 }); expect(await page.getByText('tools/call', { exact: false }).count()).toBeGreaterThan(0); diff --git a/packages/workbench/tests/runtime-playground.browser.test.tsx b/packages/workbench/tests/runtime-playground.browser.test.tsx index 014e1058b..da0f7650e 100644 --- a/packages/workbench/tests/runtime-playground.browser.test.tsx +++ b/packages/workbench/tests/runtime-playground.browser.test.tsx @@ -13,6 +13,7 @@ import type { DevRuntimeSurface, } from '../../agent-bundle/src/dev/runtime-protocol.ts'; import type { ProjectEventMessage } from '../../agent-bundle/src/dev/types.ts'; +import type { AgentRenderEvent } from '../src/runtime/agent-document-client.ts'; import { createRuntimePlaygroundController, RuntimePlayground, type RuntimePlaygroundClient } from '../src/runtime-playground.tsx'; import type { RuntimeProfileOption } from '../src/runtime-model.ts'; import type { RuntimeBootstrap } from '../src/runtime-client.ts'; @@ -115,6 +116,7 @@ const client = (resetState: () => Promise): RuntimePlay bootstrap: async () => bootstrap(), createRun: async (_request: DevRuntimeInvocationRequest) => run('created'), readRun: async (id) => run(id), + readRunDocument: async () => [], readRunFlight: async () => new Blob(['flight'], { type: 'application/octet-stream' }), replayRun: async (_request: DevRuntimeReplayRequest) => run('replayed'), resetState: async (_request: DevRuntimeStateResetRequest) => resetState(), @@ -182,6 +184,7 @@ test('mounts Runtime controls in a supported browser and fences reset interactio }); test('downloads the selected Flight payload through the authenticated client and toggles trace span details', { timeout: 15_000 }, async () => { + const documentRequests: string[] = []; const flightRequests: string[] = []; let rejectDownload = true; const controller = createRuntimePlaygroundController({ @@ -194,6 +197,22 @@ test('downloads the selected Flight payload through the authenticated client and }), client: { ...client(async () => Object.freeze({ stateStoreId: 'browser-state', stateVersion: 2 })), + readRunDocument: async (id) => { + documentRequests.push(id); + const document = { + root: { + children: [{ kind: 'markdown' as const, text: '# Browser document' }], + kind: 'result' as const, + }, + status: 'success' as const, + version: 1 as const, + }; + return [ + { document, sequence: 0, type: 'shell' as const }, + { completed: 1, message: 'Rendered', sequence: 1, total: 1, type: 'progress' as const }, + { document, sequence: 2, type: 'complete' as const }, + ] satisfies readonly AgentRenderEvent[]; + }, readRunFlight: async (id) => { flightRequests.push(id); if (rejectDownload) throw new Error('The Flight payload is unavailable.'); @@ -216,6 +235,12 @@ test('downloads the selected Flight payload through the authenticated client and await expect.element(page.locator('.runtime-request-error[role="alert"]')).not.toBeVisible(); expect(flightRequests).toEqual(['evidence', 'evidence']); + await page.getByRole('tab', { name: 'Document', exact: true }).click(); + await expect.element(page.getByRole('heading', { name: 'Browser document' })).toBeVisible(); + await expect.element(page.getByLabel('Agent Document')).toContainText('Rendered · 1 / 1'); + await expect.element(page.getByLabel('Agent Document')).toContainText('Version 1 · success'); + expect(documentRequests).toEqual(['evidence']); + await page.getByRole('tab', { name: 'Diagnostics', exact: true }).click(); const toggle = page.getByRole('button', { name: 'Show span details' }); await expect.element(toggle).toBeVisible(); diff --git a/packages/workbench/tests/runtime-playground.e2e.test.ts b/packages/workbench/tests/runtime-playground.e2e.test.ts index d042897df..b7987d13d 100644 --- a/packages/workbench/tests/runtime-playground.e2e.test.ts +++ b/packages/workbench/tests/runtime-playground.e2e.test.ts @@ -147,6 +147,24 @@ e2e('renders the capability-gated Runtime sibling in the real RSC workbench', { await page.keyboard.press('Enter'); await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBeGreaterThan(historyBeforeRun); + // #105 stage 2: the hook run's stored Flight decodes server-side into a + // real Agent Document rendered as elements, while the MCP-element status + // run keeps an honest decode diagnostic instead of a fabricated document. + const documentTab = page.getByRole('tab', { name: 'Document', exact: true }); + await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(3); + await history.nth(1).getByRole('button').first().click(); + await documentTab.click(); + await expect(documentTab).toHaveAttribute('aria-selected', 'true'); + const stage = page.getByLabel('Agent Document', { exact: true }); + await expect(stage).toContainText('Version 1 · success', { timeout: browserTimeout }); + await expect(stage.locator('.agent-document-text')) + .toContainText('Recorded runtime-playground.txt from claude.', { timeout: browserTimeout }); + await expect(page.getByLabel('Agent Document event timeline').getByRole('button', { name: /^Complete/u })) + .toBeVisible({ timeout: browserTimeout }); + await history.nth(0).getByRole('button').first().click(); + await expect(page.getByRole('tabpanel')) + .toContainText('Stored Flight could not be decoded as an Agent Document.', { timeout: browserTimeout }); + const reset = page.getByRole('button', { name: 'Reset fixture state' }); const stateVersionBeforeReset = await identity.getAttribute('data-runtime-state-version'); await reset.click(); @@ -179,7 +197,7 @@ e2e('renders the capability-gated Runtime sibling in the real RSC workbench', { await expect(page.locator('.runtime-status')).toBeFocused({ timeout: browserTimeout }); const tabs = page.getByRole('tab'); - await expect(tabs).toHaveCount(6); + await expect(tabs).toHaveCount(7); const stateTab = page.getByRole('tab', { name: 'State', exact: true }); await stateTab.click(); await expect(stateTab).toHaveAttribute('aria-selected', 'true'); diff --git a/packages/workbench/tests/runtime-playground.test.ts b/packages/workbench/tests/runtime-playground.test.ts index e0ec39d01..f225acdbf 100644 --- a/packages/workbench/tests/runtime-playground.test.ts +++ b/packages/workbench/tests/runtime-playground.test.ts @@ -118,6 +118,7 @@ const clientFor = (overrides: Partial = {}): RuntimePla bootstrap: async () => bootstrap(), createRun: async (request) => { requests.push(request); return run('created'); }, readRun: async (id) => { requests.push(id); return run(id); }, + readRunDocument: async (id) => { requests.push(`document:${id}`); return []; }, readRunFlight: async (id) => { requests.push(`flight:${id}`); return new Blob(['flight'], { type: 'application/octet-stream' }); }, replayRun: async (request) => { requests.push(request); return run('replayed'); }, requests,