diff --git a/.changeset/request-store-agent.md b/.changeset/request-store-agent.md
new file mode 100644
index 000000000..3ea8de508
--- /dev/null
+++ b/.changeset/request-store-agent.md
@@ -0,0 +1,10 @@
+---
+"@agent-bundle/rsc-runtime": minor
+---
+
+Add the versioned realm-singleton request store, `await agent()`, and
+Observed identities (#95 Wave 1). MCP and CLI entrypoints install a closed
+request lease so `execute` can read the same `AgentInvocation` (kind
+`tool` | `event` | `cli` | `script` | `workbench`) without a daemon or
+durable state. `state`, `notices`, and `providers` are reserved extension
+slots; a captured handle throws after the request completes.
diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md
index 52067ce11..74be82ea3 100644
--- a/docs/architecture/rsc-runtime-workbench.md
+++ b/docs/architecture/rsc-runtime-workbench.md
@@ -157,7 +157,6 @@ examples/
src/rsc/routes.tsx
src/rsc/worker.tsx
src/runtime/contracts.ts
- src/runtime/request-context.ts
src/runtime/state-file-core.ts
src/runtime/state-file-test-support.ts
src/runtime/state-file.ts
diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md
index 79312e2ad..7db33b02e 100644
--- a/examples/rsc-agent-runtime/README.md
+++ b/examples/rsc-agent-runtime/README.md
@@ -15,16 +15,17 @@ Native hooks are fresh requests: a process normalizes one host event, invokes th
```tsx
// A Hook JSX route reads request-scoped context.
-import { Hook } from '@agent-bundle/rsc-runtime';
-import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js';
+import { Hook, agent } from '@agent-bundle/rsc-runtime';
+import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js';
-export function AfterFileEdit() {
- const edit = useEdit();
- const snapshot = useRuntimeSnapshot();
+export async function AfterFileEdit() {
+ const context = await agent();
+ const edit = context.services.edit as CanonicalPostToolUse;
+ const snapshot = context.services.snapshot as RuntimeSnapshot;
return (
- Recorded {edit.path}; {snapshot.edits.length} edits exist.
+ {`Recorded ${edit.path}; ${snapshot.edits.length} edits exist.`}
);
diff --git a/examples/rsc-agent-runtime/src/rsc/components.tsx b/examples/rsc-agent-runtime/src/rsc/components.tsx
index 78147e3b6..8ad123a2f 100644
--- a/examples/rsc-agent-runtime/src/rsc/components.tsx
+++ b/examples/rsc-agent-runtime/src/rsc/components.tsx
@@ -1,12 +1,23 @@
import { basename } from 'node:path';
-import { Hook, Mcp } from '@agent-bundle/rsc-runtime';
-import type { RuntimeSnapshot } from '../runtime/contracts.js';
-import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js';
+import { Hook, Mcp, agent } from '@agent-bundle/rsc-runtime';
+import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js';
-export const AfterFileEdit = () => {
- const edit = useEdit();
- const snapshot = useRuntimeSnapshot();
+const hookServices = async (): Promise<{ edit: CanonicalPostToolUse; snapshot: RuntimeSnapshot }> => {
+ const context = await agent();
+ const edit = context.services.edit;
+ const snapshot = context.services.snapshot;
+ if (edit === undefined || snapshot === undefined) {
+ throw new Error('Hook render requires edit and snapshot services');
+ }
+ return {
+ edit: edit as CanonicalPostToolUse,
+ snapshot: snapshot as RuntimeSnapshot,
+ };
+};
+
+export const AfterFileEdit = async () => {
+ const { edit, snapshot } = await hookServices();
const editCount = snapshot.stateVersion;
const editNoun = editCount === 1 ? 'edit' : 'edits';
diff --git a/examples/rsc-agent-runtime/src/rsc/worker.tsx b/examples/rsc-agent-runtime/src/rsc/worker.tsx
index 34dc6d6e4..4adb7c5d9 100644
--- a/examples/rsc-agent-runtime/src/rsc/worker.tsx
+++ b/examples/rsc-agent-runtime/src/rsc/worker.tsx
@@ -3,10 +3,10 @@ import { finished } from 'node:stream/promises';
import { resolve } from 'node:path';
import { writeSync } from 'node:fs';
+import { available, runAgentRequest } from '@agent-bundle/rsc-runtime';
import { renderToReadableStream } from 'react-server-dom-rspack/server.node';
import type { CanonicalPostToolUse, RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js';
-import { withRenderContext } from '../runtime/request-context.js';
import { createFileRuntimeKernel } from '../runtime/state-file.js';
import { renderRoute } from './routes.js';
@@ -135,9 +135,25 @@ const render = async (): Promise => {
};
if (request.type === 'hook/after-file-edit') {
- await withRenderContext({ edit: request.event, snapshot }, renderFlight);
+ await runAgentRequest({
+ host: available({ name: request.event.host }, 'native'),
+ invocation: {
+ id: request.event.idempotencyKey,
+ kind: 'event',
+ surface: request.type,
+ },
+ session: available({ sessionId: request.event.sessionId }, 'native'),
+ services: { edit: request.event, snapshot },
+ workspace: available({ root: request.event.cwd }, 'native'),
+ }, renderFlight);
} else {
- await renderFlight();
+ await runAgentRequest({
+ invocation: {
+ kind: 'tool',
+ surface: request.type,
+ },
+ services: { snapshot },
+ }, renderFlight);
}
writeSnapshotMetadata();
};
diff --git a/examples/rsc-agent-runtime/src/runtime/request-context.ts b/examples/rsc-agent-runtime/src/runtime/request-context.ts
deleted file mode 100644
index 4315709dc..000000000
--- a/examples/rsc-agent-runtime/src/runtime/request-context.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { createRscRequestContext } from '@agent-bundle/rsc-runtime';
-
-import type { CanonicalPostToolUse, RuntimeSnapshot } from './contracts.js';
-
-export interface RenderContext {
- edit: CanonicalPostToolUse;
- snapshot: RuntimeSnapshot;
-}
-
-const renderContext = createRscRequestContext('RSC runtime hook');
-
-export const withRenderContext = (context: RenderContext, operation: () => T): T =>
- renderContext.run(context, operation);
-
-export const useEdit = (): CanonicalPostToolUse => renderContext.use().edit;
-
-export const useRuntimeSnapshot = (): RuntimeSnapshot => renderContext.use().snapshot;
diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md
index f9d3e7e12..11e3bd25e 100644
--- a/packages/rsc-runtime/README.md
+++ b/packages/rsc-runtime/README.md
@@ -24,10 +24,18 @@ const result = lowerMcpResult(
);
```
-The package exports `Hook`, `Mcp`, `lowerHookResult`, `lowerMcpResult`, and
-`createRscRequestContext`. It does not own an RSC renderer, application state,
-transport, persistence, or host packaging. React 19 is a peer dependency and Node
-22.19 or newer is required.
+The package exports `Hook`, `Mcp`, `lowerHookResult`, `lowerMcpResult`,
+`createRscRequestContext`, `agent`, `runAgentRequest`, and `AgentRequestError`. It does not own an
+RSC renderer, application state, transport, persistence, or host packaging.
+React 19 is a peer dependency and Node 22.19 or newer is required.
+
+Async server utilities and Server Components read the framework request store
+with `const context = await agent()`. The store is a versioned realm singleton
+installed at MCP and CLI entrypoints (and at any other real invocation via
+`runAgentRequest`). Identities are `Observed` — unavailable host, session,
+actor, or workspace is a typed reason, never a fabricated string. The context
+handle throws after the request completes. `state`, `notices`, and `providers`
+are reserved extension slots; provider discovery and `useAgent()` arrive later.
Structured MCP metadata and content are copied through a strict finite-JSON
boundary before being returned, so later caller mutations do not alter a result.
diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts
new file mode 100644
index 000000000..d5c4e69bb
--- /dev/null
+++ b/packages/rsc-runtime/src/agent-request.ts
@@ -0,0 +1,337 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+export const AGENT_REQUEST_STORE_VERSION = 1;
+
+const STORE_SYMBOL = Symbol.for('@agent-bundle/rsc-runtime/request-store');
+
+export type AgentInvocationKind = 'tool' | 'event' | 'cli' | 'script' | 'workbench';
+
+export type ObservedSource = 'native' | 'receipt' | 'derived';
+
+export type AgentContextUnavailableReason =
+ | 'not-provided'
+ | 'unsupported-surface'
+ | 'host-omitted'
+ | 'unauthenticated';
+
+export type Observed =
+ | { readonly source: ObservedSource; readonly state: 'available'; readonly value: T }
+ | { readonly reason: AgentContextUnavailableReason; readonly state: 'unavailable' };
+
+export interface AgentHostIdentity {
+ readonly name: string;
+}
+
+export interface AgentSessionIdentity {
+ readonly sessionId: string;
+}
+
+export interface AgentActorIdentity {
+ readonly id: string;
+}
+
+export interface AgentWorkspaceIdentity {
+ readonly root: string;
+}
+
+export interface AgentFilesystemAuthority {
+ readonly roots: readonly string[];
+}
+
+export interface AgentCommandAuthority {
+ readonly cwd: string;
+}
+
+export interface AgentNetworkAuthority {
+ readonly allow: readonly string[];
+}
+
+export interface AgentProjectRootAuthority {
+ readonly root: string;
+}
+
+export interface AgentRequestCapabilities {
+ readonly command: Observed;
+ readonly filesystem: Observed;
+ readonly network: Observed;
+ readonly projectRoot: Observed;
+}
+
+export interface AgentProgressUpdate {
+ readonly completed?: number;
+ readonly message: string;
+ readonly total?: number;
+}
+
+export interface AgentProgressReporter {
+ readonly report: (update: AgentProgressUpdate) => Promise;
+}
+
+export type AgentServiceRegistry = Readonly>;
+
+export type AgentProviderValues = Readonly>;
+
+export interface AgentInvocation {
+ readonly artifactEpoch?: string;
+ readonly hostContractRevision?: string;
+ readonly id: string;
+ readonly kind: AgentInvocationKind;
+ readonly operationId?: string;
+ readonly protocolRevision?: string;
+ readonly sourceRevision?: string;
+ readonly startedAt: string;
+ readonly surface?: string;
+}
+
+export type AgentInvocationInput = Pick & Partial>;
+
+export interface AgentRequestContext {
+ readonly invocation: AgentInvocation;
+ readonly host: Observed;
+ readonly session: Observed;
+ readonly actor: Observed;
+ readonly workspace: Observed;
+ readonly capabilities: AgentRequestCapabilities;
+ readonly progress: AgentProgressReporter;
+ readonly signal: AbortSignal;
+ readonly services: AgentServiceRegistry;
+ readonly providers: AgentProviderValues;
+ /** Reserved for the durable state kernel (#98). Wave 1 leaves this undefined. */
+ readonly state: undefined;
+ /** Reserved for recipient-aware notices (#99). Wave 1 leaves this undefined. */
+ readonly notices: undefined;
+}
+
+export interface AgentRequestInit {
+ readonly actor?: Observed;
+ readonly capabilities?: AgentRequestCapabilities;
+ readonly host?: Observed;
+ readonly invocation: AgentInvocationInput;
+ readonly progress?: AgentProgressReporter;
+ readonly providers?: AgentProviderValues;
+ readonly services?: AgentServiceRegistry;
+ readonly session?: Observed;
+ readonly signal?: AbortSignal;
+ readonly workspace?: Observed;
+}
+
+export type AgentRequestErrorCode = 'invalid-invocation' | 'outside-invocation' | 'request-closed' | 'store-version-conflict';
+
+export class AgentRequestError extends Error {
+ readonly code: AgentRequestErrorCode;
+
+ constructor(code: AgentRequestErrorCode, message: string) {
+ super(message);
+ this.code = code;
+ this.name = 'AgentRequestError';
+ }
+}
+
+const freezeValue = (value: T): T => {
+ if (Array.isArray(value)) {
+ return Object.freeze(value.map((item) => freezeValue(item))) as T;
+ }
+ if (value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
+ const copy: Record = {};
+ for (const [key, nested] of Object.entries(value)) {
+ copy[key] = freezeValue(nested);
+ }
+ return Object.freeze(copy) as T;
+ }
+ return value;
+};
+
+export const available = (value: T, source: ObservedSource): Observed => Object.freeze({
+ source,
+ state: 'available',
+ value: freezeValue(value),
+});
+
+export const unavailable = (reason: AgentContextUnavailableReason = 'not-provided'): Observed =>
+ Object.freeze({ reason, state: 'unavailable' });
+
+const snapshotObserved = (observed: Observed): Observed => (
+ observed.state === 'available'
+ ? available(observed.value, observed.source)
+ : unavailable(observed.reason)
+);
+
+const silentProgress: AgentProgressReporter = Object.freeze({
+ report: async () => undefined,
+});
+
+const emptyCapabilities = (): AgentRequestCapabilities => Object.freeze({
+ command: unavailable(),
+ filesystem: unavailable(),
+ network: unavailable(),
+ projectRoot: unavailable(),
+});
+
+const snapshotCapabilities = (capabilities: AgentRequestCapabilities): AgentRequestCapabilities =>
+ Object.freeze({
+ command: snapshotObserved(capabilities.command),
+ filesystem: snapshotObserved(capabilities.filesystem),
+ network: snapshotObserved(capabilities.network),
+ projectRoot: snapshotObserved(capabilities.projectRoot),
+ });
+
+const optionalText = (value: string | undefined): string | undefined => {
+ if (value === undefined) return undefined;
+ if (value.trim() === '') {
+ throw new AgentRequestError('invalid-invocation', 'Agent invocation fields must be non-empty when present');
+ }
+ return value;
+};
+
+const invocationFrom = (input: AgentInvocationInput): AgentInvocation => Object.freeze({
+ id: optionalText(input.id) ?? crypto.randomUUID(),
+ kind: input.kind,
+ startedAt: optionalText(input.startedAt) ?? new Date().toISOString(),
+ ...(input.artifactEpoch === undefined ? {} : { artifactEpoch: optionalText(input.artifactEpoch) }),
+ ...(input.hostContractRevision === undefined ? {} : { hostContractRevision: optionalText(input.hostContractRevision) }),
+ ...(input.operationId === undefined ? {} : { operationId: optionalText(input.operationId) }),
+ ...(input.protocolRevision === undefined ? {} : { protocolRevision: optionalText(input.protocolRevision) }),
+ ...(input.sourceRevision === undefined ? {} : { sourceRevision: optionalText(input.sourceRevision) }),
+ ...(input.surface === undefined ? {} : { surface: optionalText(input.surface) }),
+});
+
+interface FrozenValues {
+ readonly actor: Observed;
+ readonly capabilities: AgentRequestCapabilities;
+ readonly host: Observed;
+ readonly invocation: AgentInvocation;
+ readonly notices: undefined;
+ readonly progress: AgentProgressReporter;
+ readonly providers: AgentProviderValues;
+ readonly services: AgentServiceRegistry;
+ readonly session: Observed;
+ readonly signal: AbortSignal;
+ readonly state: undefined;
+ readonly workspace: Observed;
+}
+
+interface Lease {
+ closed: boolean;
+ handle: AgentRequestContext;
+ readonly values: FrozenValues;
+}
+
+interface RealmStore {
+ readonly storage: AsyncLocalStorage;
+ readonly version: number;
+}
+
+const realm = globalThis as typeof globalThis & {
+ [STORE_SYMBOL]?: RealmStore;
+};
+
+const getStore = (): RealmStore => {
+ const existing = realm[STORE_SYMBOL];
+ if (existing !== undefined) {
+ if (existing.version !== AGENT_REQUEST_STORE_VERSION) {
+ throw new AgentRequestError(
+ 'store-version-conflict',
+ `Incompatible @agent-bundle/rsc-runtime request store version: found ${String(existing.version)}, expected ${String(AGENT_REQUEST_STORE_VERSION)}`,
+ );
+ }
+ return existing;
+ }
+ const created: RealmStore = {
+ storage: new AsyncLocalStorage(),
+ version: AGENT_REQUEST_STORE_VERSION,
+ };
+ realm[STORE_SYMBOL] = created;
+ return created;
+};
+
+const open = (lease: Lease): FrozenValues => {
+ if (lease.closed) {
+ throw new AgentRequestError('request-closed', 'agent() used after the request completed');
+ }
+ return lease.values;
+};
+
+const createHandle = (lease: Lease): AgentRequestContext => Object.freeze({
+ get invocation() {
+ return open(lease).invocation;
+ },
+ get host() {
+ return open(lease).host;
+ },
+ get session() {
+ return open(lease).session;
+ },
+ get actor() {
+ return open(lease).actor;
+ },
+ get workspace() {
+ return open(lease).workspace;
+ },
+ get capabilities() {
+ return open(lease).capabilities;
+ },
+ get progress() {
+ return open(lease).progress;
+ },
+ get signal() {
+ return open(lease).signal;
+ },
+ get services() {
+ return open(lease).services;
+ },
+ get providers() {
+ return open(lease).providers;
+ },
+ get state() {
+ return open(lease).state;
+ },
+ get notices() {
+ return open(lease).notices;
+ },
+});
+
+const currentLease = (): Lease => {
+ const lease = getStore().storage.getStore();
+ if (lease === undefined) {
+ throw new AgentRequestError('outside-invocation', 'agent() used outside a real invocation');
+ }
+ return lease;
+};
+
+export const agent = async (): Promise => {
+ const lease = currentLease();
+ open(lease);
+ return lease.handle;
+};
+
+export const runAgentRequest = async (
+ init: AgentRequestInit,
+ operation: () => T | Promise,
+): Promise => {
+ const values: FrozenValues = Object.freeze({
+ actor: snapshotObserved(init.actor ?? unavailable()),
+ capabilities: snapshotCapabilities(init.capabilities ?? emptyCapabilities()),
+ host: snapshotObserved(init.host ?? unavailable()),
+ invocation: invocationFrom(init.invocation),
+ notices: undefined,
+ progress: init.progress ?? silentProgress,
+ providers: Object.freeze({ ...(init.providers ?? {}) }),
+ services: Object.freeze({ ...(init.services ?? {}) }),
+ session: snapshotObserved(init.session ?? unavailable()),
+ signal: init.signal ?? new AbortController().signal,
+ state: undefined,
+ workspace: snapshotObserved(init.workspace ?? unavailable()),
+ });
+ const lease: Lease = {
+ closed: false,
+ handle: undefined as unknown as AgentRequestContext,
+ values,
+ };
+ lease.handle = createHandle(lease);
+
+ try {
+ return await getStore().storage.run(lease, operation);
+ } finally {
+ lease.closed = true;
+ }
+};
diff --git a/packages/rsc-runtime/src/cli.ts b/packages/rsc-runtime/src/cli.ts
index 176da1f60..b011243a9 100644
--- a/packages/rsc-runtime/src/cli.ts
+++ b/packages/rsc-runtime/src/cli.ts
@@ -1,4 +1,5 @@
import type { RscApplication } from './application.js';
+import { available, runAgentRequest, unavailable } from './agent-request.js';
export interface RscCliOptions {
readonly signal?: AbortSignal;
@@ -25,14 +26,31 @@ export const runRscCli = async (
}
const operation = application.operations.find((candidate) => candidate.cli?.name === commandName);
if (operation?.cli === undefined) throw new Error(`Unknown command: ${commandName}`);
+ const cli = operation.cli;
if (commandArguments.length === 1 && (commandArguments[0] === '--help' || commandArguments[0] === '-h')) {
- write(`${operation.cli.usage}\n\n${operation.cli.summary}\n`);
+ write(`${cli.usage}\n\n${cli.summary}\n`);
return 0;
}
const signal = options.signal ?? new AbortController().signal;
signal.throwIfAborted();
- const result = await operation.execute(operation.cli.parse(commandArguments), { signal });
+ const cwd = process.cwd();
+ const result = await runAgentRequest({
+ capabilities: Object.freeze({
+ command: unavailable(),
+ filesystem: unavailable(),
+ network: unavailable(),
+ projectRoot: available({ root: cwd }, 'derived'),
+ }),
+ host: unavailable('unsupported-surface'),
+ invocation: {
+ kind: 'cli',
+ operationId: operation.id,
+ surface: cli.name,
+ },
+ signal,
+ workspace: available({ root: cwd }, 'derived'),
+ }, async () => operation.execute(cli.parse(commandArguments), { signal }));
signal.throwIfAborted();
write(`${JSON.stringify(result)}\n`);
- return operation.cli.exitCode(result);
+ return cli.exitCode(result);
};
diff --git a/packages/rsc-runtime/src/mcp-server.ts b/packages/rsc-runtime/src/mcp-server.ts
index 74d40517f..a04cc5b03 100644
--- a/packages/rsc-runtime/src/mcp-server.ts
+++ b/packages/rsc-runtime/src/mcp-server.ts
@@ -1,6 +1,7 @@
import { McpServer as ProtocolMcpServer } from '@modelcontextprotocol/server';
import type { RscApplication } from './application.js';
+import { available, runAgentRequest } from './agent-request.js';
import { lowerMcpResult } from './lower-mcp.js';
export const createRscMcpServer = (
@@ -18,25 +19,39 @@ export const createRscMcpServer = (
version: application.version,
});
for (const operation of application.operations) {
- if (operation.mcp?.server !== serverName) continue;
- server.registerTool(operation.mcp.name, {
- ...(operation.mcp._meta === undefined ? {} : { _meta: operation.mcp._meta }),
+ const mcp = operation.mcp;
+ if (mcp?.server !== serverName) continue;
+ server.registerTool(mcp.name, {
+ ...(mcp._meta === undefined ? {} : { _meta: mcp._meta }),
// Emit exactly the hints the author declared: an absent hint carries
// MCP-spec default semantics on the wire, so synthesizing values here
// would rewrite the author's contract.
annotations: {
- ...(operation.mcp.destructive === undefined ? {} : { destructiveHint: operation.mcp.destructive }),
- ...(operation.mcp.idempotent === undefined ? {} : { idempotentHint: operation.mcp.idempotent }),
- ...(operation.mcp.openWorld === undefined ? {} : { openWorldHint: operation.mcp.openWorld }),
- readOnlyHint: operation.mcp.readOnly,
+ ...(mcp.destructive === undefined ? {} : { destructiveHint: mcp.destructive }),
+ ...(mcp.idempotent === undefined ? {} : { idempotentHint: mcp.idempotent }),
+ ...(mcp.openWorld === undefined ? {} : { openWorldHint: mcp.openWorld }),
+ readOnlyHint: mcp.readOnly,
},
- description: operation.mcp.description,
+ description: mcp.description,
inputSchema: operation.inputSchema,
- ...(operation.mcp.title === undefined ? {} : { title: operation.mcp.title }),
- }, async (input, context) => {
+ ...(mcp.title === undefined ? {} : { title: mcp.title }),
+ }, async (input, context) => runAgentRequest({
+ ...(context.http?.authInfo?.clientId === undefined
+ ? {}
+ : { actor: available({ id: context.http.authInfo.clientId }, 'native') }),
+ invocation: {
+ kind: 'tool',
+ operationId: operation.id,
+ surface: mcp.name,
+ },
+ ...(typeof context.sessionId === 'string' && context.sessionId.trim() !== ''
+ ? { session: available({ sessionId: context.sessionId }, 'native') }
+ : {}),
+ signal: context.mcpReq.signal,
+ }, async () => {
const result = await operation.execute(input, { signal: context.mcpReq.signal });
return lowerMcpResult(operation.render(result));
- });
+ }));
}
return server;
};
diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts
index 423b6fbab..1dd77b80a 100644
--- a/packages/rsc-runtime/src/plugin.ts
+++ b/packages/rsc-runtime/src/plugin.ts
@@ -1,3 +1,35 @@
+export {
+ AGENT_REQUEST_STORE_VERSION,
+ AgentRequestError,
+ agent,
+ available,
+ runAgentRequest,
+ unavailable,
+} from './agent-request.js';
+export type {
+ AgentActorIdentity,
+ AgentCommandAuthority,
+ AgentContextUnavailableReason,
+ AgentFilesystemAuthority,
+ AgentHostIdentity,
+ AgentInvocation,
+ AgentInvocationInput,
+ AgentInvocationKind,
+ AgentNetworkAuthority,
+ AgentProgressReporter,
+ AgentProgressUpdate,
+ AgentProjectRootAuthority,
+ AgentProviderValues,
+ AgentRequestCapabilities,
+ AgentRequestContext,
+ AgentRequestErrorCode,
+ AgentRequestInit,
+ AgentServiceRegistry,
+ AgentSessionIdentity,
+ AgentWorkspaceIdentity,
+ Observed,
+ ObservedSource,
+} from './agent-request.js';
export { defineRscApplication } from './application.js';
export type { RscApplication, RscApplicationOptions } from './application.js';
export { runRscCli } from './cli.js';
@@ -11,4 +43,4 @@ export type {
RscOperationContext,
RscOperationDefinition,
RscOperationInput,
-} from './operation.js';
\ No newline at end of file
+} from './operation.js';
diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts
new file mode 100644
index 000000000..6d06aef67
--- /dev/null
+++ b/packages/rsc-runtime/tests/agent-request.test.ts
@@ -0,0 +1,362 @@
+import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
+import { afterAll, describe, expect, it } from '@rstest/core';
+import { createElement } from 'react';
+import { z } from 'zod';
+
+import {
+ AGENT_REQUEST_STORE_VERSION,
+ AgentRequestError,
+ agent,
+ available,
+ createRscMcpServer,
+ defineOperation,
+ defineRscApplication,
+ runAgentRequest,
+ runRscCli,
+ unavailable,
+} from '../src/index.js';
+import {
+ AGENT_REQUEST_STORE_VERSION as pluginStoreVersion,
+ AgentRequestError as PluginAgentRequestError,
+ agent as pluginAgent,
+ runAgentRequest as pluginRunAgentRequest,
+} from '../src/plugin.js';
+
+const STORE_SYMBOL = Symbol.for('@agent-bundle/rsc-runtime/request-store');
+
+const init = (kind: 'tool' | 'event' | 'cli' | 'script' | 'workbench', id?: string) => ({
+ invocation: { ...(id === undefined ? {} : { id }), kind },
+});
+
+describe('agent request store', () => {
+ it('exposes Observed identities, invocation kind, and reserved extension slots', async () => {
+ await runAgentRequest({
+ host: available({ name: 'claude' }, 'native'),
+ invocation: {
+ artifactEpoch: 'epoch-1',
+ id: 'inv-1',
+ kind: 'event',
+ operationId: 'after-file-edit',
+ protocolRevision: '1',
+ sourceRevision: 'src-1',
+ surface: 'hook/after-file-edit',
+ },
+ providers: { gitWorktree: { path: '/tmp/worktree' } },
+ session: available({ sessionId: 'session-1' }, 'native'),
+ services: { snapshot: { stateVersion: 1 } },
+ workspace: available({ root: '/tmp/project' }, 'native'),
+ }, async () => {
+ const context = await agent();
+ expect(context.invocation).toMatchObject({
+ artifactEpoch: 'epoch-1',
+ id: 'inv-1',
+ kind: 'event',
+ operationId: 'after-file-edit',
+ protocolRevision: '1',
+ sourceRevision: 'src-1',
+ surface: 'hook/after-file-edit',
+ });
+ expect(context.host).toEqual({ source: 'native', state: 'available', value: { name: 'claude' } });
+ expect(context.session).toEqual({ source: 'native', state: 'available', value: { sessionId: 'session-1' } });
+ expect(context.actor).toEqual({ reason: 'not-provided', state: 'unavailable' });
+ expect(context.workspace).toEqual({ source: 'native', state: 'available', value: { root: '/tmp/project' } });
+ expect(context.capabilities.filesystem.state).toBe('unavailable');
+ expect(context.capabilities.command.state).toBe('unavailable');
+ expect(context.capabilities.network.state).toBe('unavailable');
+ expect(context.capabilities.projectRoot.state).toBe('unavailable');
+ expect(context.services).toEqual({ snapshot: { stateVersion: 1 } });
+ expect(context.providers).toEqual({ gitWorktree: { path: '/tmp/worktree' } });
+ expect(context.state).toBeUndefined();
+ expect(context.notices).toBeUndefined();
+ expect(Object.hasOwn(context, 'state')).toBe(true);
+ expect(Object.hasOwn(context, 'notices')).toBe(true);
+ expect(Object.hasOwn(context, 'providers')).toBe(true);
+ expect(Object.isFrozen(context)).toBe(true);
+ expect(Object.isFrozen(context.invocation)).toBe(true);
+ expect(Object.isFrozen(context.host)).toBe(true);
+ });
+ });
+
+ it('uses a typed error for invalid invocation fields', async () => {
+ await expect(runAgentRequest({ invocation: { id: ' ', kind: 'tool' } }, () => undefined)).rejects.toMatchObject({
+ code: 'invalid-invocation',
+ });
+ await expect(runAgentRequest({ invocation: { id: ' ', kind: 'tool' } }, () => undefined)).rejects.toBeInstanceOf(
+ AgentRequestError,
+ );
+ });
+
+ it('never fabricates an identity string for a missing principal', async () => {
+ await runAgentRequest(init('tool'), async () => {
+ const context = await agent();
+ expect(context.host).toEqual(unavailable());
+ expect(context.session).toEqual(unavailable());
+ expect(context.actor).toEqual(unavailable());
+ expect(context.workspace).toEqual(unavailable());
+ expect(Object.hasOwn(context.host, 'value')).toBe(false);
+ });
+ });
+
+ it('snapshots nested capability lists so caller mutation cannot leak into the request', async () => {
+ const roots = ['/tmp/project'];
+ const allow = ['example.test'];
+ await runAgentRequest({
+ capabilities: {
+ command: unavailable(),
+ filesystem: available({ roots }, 'native'),
+ network: available({ allow }, 'native'),
+ projectRoot: unavailable(),
+ },
+ invocation: { kind: 'tool' },
+ }, async () => {
+ roots.push('/tmp/other');
+ allow.push('evil.test');
+ const context = await agent();
+ expect(context.capabilities.filesystem).toEqual({
+ source: 'native',
+ state: 'available',
+ value: { roots: ['/tmp/project'] },
+ });
+ expect(context.capabilities.network).toEqual({
+ source: 'native',
+ state: 'available',
+ value: { allow: ['example.test'] },
+ });
+ if (context.capabilities.filesystem.state === 'available') {
+ expect(Object.isFrozen(context.capabilities.filesystem.value.roots)).toBe(true);
+ }
+ if (context.capabilities.network.state === 'available') {
+ expect(Object.isFrozen(context.capabilities.network.value.allow)).toBe(true);
+ }
+ });
+ });
+
+ it('snapshots plain Observed inputs at the request boundary so caller mutation cannot leak', async () => {
+ const hostValue = { name: 'claude' };
+ const host = { state: 'available' as const, source: 'native' as const, value: hostValue };
+ const roots = ['/tmp/project'];
+ const filesystem = { state: 'available' as const, source: 'native' as const, value: { roots } };
+ const command = { state: 'unavailable' as const, reason: 'not-provided' as const };
+ const capabilities = {
+ command,
+ filesystem,
+ network: { state: 'unavailable' as const, reason: 'not-provided' as const },
+ projectRoot: { state: 'unavailable' as const, reason: 'not-provided' as const },
+ };
+
+ await runAgentRequest({
+ capabilities,
+ host,
+ invocation: { kind: 'tool' },
+ }, async () => {
+ hostValue.name = 'mutated';
+ roots.push('/tmp/other');
+ (host as { state: string }).state = 'unavailable';
+ (command as { reason: string }).reason = 'unauthenticated';
+
+ const context = await agent();
+ expect(context.host).toEqual({ source: 'native', state: 'available', value: { name: 'claude' } });
+ expect(context.capabilities.filesystem).toEqual({
+ source: 'native',
+ state: 'available',
+ value: { roots: ['/tmp/project'] },
+ });
+ expect(context.capabilities.command).toEqual({ reason: 'not-provided', state: 'unavailable' });
+ expect(Object.isFrozen(context.host)).toBe(true);
+ if (context.host.state === 'available') {
+ expect(Object.isFrozen(context.host.value)).toBe(true);
+ }
+ if (context.capabilities.filesystem.state === 'available') {
+ expect(Object.isFrozen(context.capabilities.filesystem.value.roots)).toBe(true);
+ }
+ });
+ });
+
+ it('isolates concurrent invocations including identities and provider values', async () => {
+ const barrier = Promise.withResolvers();
+ const first = runAgentRequest({
+ invocation: { id: 'a', kind: 'event' },
+ providers: { edit: 'first' },
+ session: available({ sessionId: 'session-a' }, 'native'),
+ }, async () => {
+ await barrier.promise;
+ const context = await agent();
+ return {
+ id: context.invocation.id,
+ provider: context.providers.edit,
+ session: context.session.state === 'available' ? context.session.value.sessionId : 'missing',
+ };
+ });
+ const second = runAgentRequest({
+ invocation: { id: 'b', kind: 'cli' },
+ providers: { edit: 'second' },
+ session: available({ sessionId: 'session-b' }, 'native'),
+ }, async () => {
+ barrier.resolve();
+ const context = await agent();
+ return {
+ id: context.invocation.id,
+ provider: context.providers.edit,
+ session: context.session.state === 'available' ? context.session.value.sessionId : 'missing',
+ };
+ });
+
+ await expect(Promise.all([first, second])).resolves.toEqual([
+ { id: 'a', provider: 'first', session: 'session-a' },
+ { id: 'b', provider: 'second', session: 'session-b' },
+ ]);
+ });
+
+ it('rejects agent() outside a real invocation', async () => {
+ await expect(agent()).rejects.toMatchObject({
+ code: 'outside-invocation',
+ message: 'agent() used outside a real invocation',
+ });
+ await expect(agent()).rejects.toBeInstanceOf(AgentRequestError);
+ });
+
+ it('rejects a captured handle after the request completes', async () => {
+ let handle: Awaited> | undefined;
+ await runAgentRequest(init('tool', 'closed'), async () => {
+ handle = await agent();
+ expect(handle.invocation.id).toBe('closed');
+ });
+ expect(handle).toBeDefined();
+ expect(() => handle?.invocation).toThrow(AgentRequestError);
+ try {
+ void handle?.invocation;
+ throw new Error('expected captured handle access to throw');
+ } catch (error) {
+ expect(error).toMatchObject({ code: 'request-closed' });
+ }
+ });
+
+ it('rejects escaped continuations after the request completes', async () => {
+ let escaped: Promise | undefined;
+ await runAgentRequest(init('script'), () => {
+ escaped = Promise.resolve().then(async () => {
+ await Promise.resolve();
+ return agent();
+ });
+ void escaped.then(() => undefined, () => undefined);
+ });
+ expect(escaped).toBeDefined();
+ try {
+ await escaped;
+ throw new Error('expected escaped agent() to reject');
+ } catch (error) {
+ expect(error).toBeInstanceOf(AgentRequestError);
+ expect(error).toMatchObject({
+ code: expect.stringMatching(/^(?:request-closed|outside-invocation)$/u),
+ });
+ }
+ });
+
+ it('rejects a conflicting store version planted on the realm singleton', async () => {
+ const globalSymbols = globalThis as typeof globalThis & Record;
+ const previous = globalSymbols[STORE_SYMBOL];
+ globalSymbols[STORE_SYMBOL] = { version: AGENT_REQUEST_STORE_VERSION + 1 };
+ try {
+ await expect(runAgentRequest(init('tool'), () => undefined)).rejects.toBeInstanceOf(AgentRequestError);
+ await expect(runAgentRequest(init('tool'), () => undefined)).rejects.toMatchObject({
+ code: 'store-version-conflict',
+ });
+ } finally {
+ if (previous === undefined) {
+ delete globalSymbols[STORE_SYMBOL];
+ } else {
+ globalSymbols[STORE_SYMBOL] = previous;
+ }
+ }
+ });
+
+ it('survives await inside the request and is absent afterward', async () => {
+ const seen = await runAgentRequest(init('workbench', 'awaited'), async () => {
+ await Promise.resolve();
+ return (await agent()).invocation.id;
+ });
+ expect(seen).toBe('awaited');
+ await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' });
+ });
+
+ it('re-exports the request store from the plugin entry', () => {
+ expect(pluginAgent).toBe(agent);
+ expect(pluginRunAgentRequest).toBe(runAgentRequest);
+ expect(PluginAgentRequestError).toBe(AgentRequestError);
+ expect(pluginStoreVersion).toBe(AGENT_REQUEST_STORE_VERSION);
+ });
+});
+
+describe('entrypoint bindings', () => {
+ const status = defineOperation({
+ cli: {
+ name: 'status',
+ parse: () => ({}),
+ summary: 'Read status.',
+ usage: 'status',
+ },
+ execute: async () => {
+ const context = await agent();
+ return {
+ kind: context.invocation.kind,
+ operationId: context.invocation.operationId,
+ surface: context.invocation.surface,
+ };
+ },
+ id: 'status',
+ inputSchema: z.object({}).strict(),
+ mcp: {
+ description: 'Read status.',
+ name: 'runtime_status',
+ readOnly: true,
+ server: 'runtime',
+ },
+ render: (result) => createElement(
+ 'mcp-result',
+ { structuredContent: result },
+ createElement('mcp-text', null, result.kind),
+ ),
+ resultSchema: z.object({
+ kind: z.enum(['tool', 'event', 'cli', 'script', 'workbench']),
+ operationId: z.string().optional(),
+ surface: z.string().optional(),
+ }).strict(),
+ });
+ const application = defineRscApplication({
+ name: 'runtime',
+ operations: [status],
+ version: '1.0.0',
+ });
+ const openClients: Client[] = [];
+
+ afterAll(async () => {
+ await Promise.allSettled(openClients.map((client) => client.close()));
+ });
+
+ it('installs a cli invocation for runRscCli', async () => {
+ const output: string[] = [];
+ await expect(runRscCli(application, ['status'], { write: (value) => output.push(value) })).resolves.toBe(0);
+ expect(JSON.parse(output.join(''))).toEqual({
+ kind: 'cli',
+ operationId: 'status',
+ surface: 'status',
+ });
+ await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' });
+ });
+
+ it('installs a tool invocation for createRscMcpServer', async () => {
+ const server = createRscMcpServer(application, 'runtime');
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: 'agent-request-test', version: '0.0.0' });
+ openClients.push(client);
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ const result = await client.callTool({ arguments: {}, name: 'runtime_status' });
+ expect(result.structuredContent).toEqual({
+ kind: 'tool',
+ operationId: 'status',
+ surface: 'runtime_status',
+ });
+ await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' });
+ });
+});