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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/agent-document-stage.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -94,6 +94,8 @@ export interface ForegroundProjectEventStreamHandle {
}

export interface ForegroundServerTesting {
/** Replaces the optional runtime import for Agent Document route tests. */
readonly loadAgentDocumentRuntime?: () => Promise<AgentDocumentRuntimeModule>;
/** Observes the current stream only after its subscription and close listeners exist. */
readonly onProjectEventStream?: (stream: ForegroundProjectEventStreamHandle) => void;
}
Expand Down Expand Up @@ -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({
Expand Down
69 changes: 67 additions & 2 deletions packages/agent-bundle/src/dev/runtime-routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { IncomingMessage, ServerResponse } from 'node:http';

import type { AgentRenderEvent, AgentRenderLimits } from '@agent-bundle/runtime';

import {
DevRuntimeGenerationConflictError,
DevRuntimeUnavailableError,
Expand All @@ -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<Uint8Array>,
options?: Readonly<{ readonly limits?: Partial<AgentRenderLimits>; readonly signal?: AbortSignal }>,
) => ReadableStream<AgentRenderEvent>;
}

export interface RuntimeRoutesOptions {
readonly authorize: (request: IncomingMessage) => void;
readonly loadAgentDocumentRuntime?: () => Promise<AgentDocumentRuntimeModule>;
readonly runtime?: DevRuntimeSession;
}

let agentDocumentRuntimePromise: Promise<AgentDocumentRuntimeModule> | 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<AgentDocumentRuntimeModule> => {
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(
Expand Down Expand Up @@ -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] });
}
}
Expand Down Expand Up @@ -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<AgentDocumentRuntimeModule>;
readonly #runtime: DevRuntimeSession | undefined;
#closed = false;

constructor(options: RuntimeRoutesOptions) {
this.#authorize = options.authorize;
this.#loadAgentDocumentRuntime = options.loadAgentDocumentRuntime ?? loadAgentDocumentRuntime;
this.#runtime = options.runtime;
}

Expand Down Expand Up @@ -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<Uint8Array>({
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);
}
Comment thread
ScriptedAlchemy marked this conversation as resolved.
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));
Expand Down
145 changes: 142 additions & 3 deletions packages/agent-bundle/tests/runtime-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<void> {}
async invoke(request: Parameters<DevRuntimeSession['invoke']>[0]): Promise<DevRuntimeRun> {
Expand All @@ -87,7 +99,7 @@ class MemoryRuntime implements DevRuntimeSession {
}
async readRunFlight(runId: string): Promise<DevRuntimeAsset | undefined> {
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<void> {}
Expand Down Expand Up @@ -139,13 +151,17 @@ const coordinator = Object.freeze({
status: projectStatus,
});

const start = async (runtime?: DevRuntimeSession) => {
const start = async (
runtime?: DevRuntimeSession,
testing?: Parameters<typeof startForegroundServer>[0]['testing'],
) => {
const options = {
coordinator,
eventHub: new ProjectEventHub(),
port: 0,
runtime,
sessionToken: 'runtime-session-token',
testing,
} as Parameters<typeof startForegroundServer>[0] & { readonly runtime?: DevRuntimeSession };
return startForegroundServer(options);
};
Expand All @@ -164,6 +180,41 @@ interface RuntimeRouteMatrixCase {
readonly queryPath: string;
}

const renderReadyFlight = async (): Promise<Uint8Array> => 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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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' },
Expand Down
Loading
Loading