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
19 changes: 19 additions & 0 deletions .changeset/generated-route-jsx-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'agent-bundle': patch
---

Fix generated executables crashing on any route authored with JSX.

Route entries were bundled without the React plugin, so Rslib lowered JSX to
the classic `React.createElement` factory — which no generated entry or Flight
worker has in scope. Every documented `.tsx` route (the contract's own example
shape) therefore failed at run time with `React is not defined`, while builds
and route-unit tests stayed green because the test transform selects the
automatic runtime. Route entries now build with the automatic JSX runtime, so
emitted modules import `react/jsx-runtime` themselves — under the
`react-server` condition for worker entries.

The defect survived because every build-level test authored its routes with an
explicit `createElement` import; the generated-route server test now authors
its tool route as JSX instead, which is what surfaced this from the new
`packed-stdio` proof level.
45 changes: 45 additions & 0 deletions .changeset/projection-proof-levels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'agent-bundle': minor
---

Add the projection-contract proof levels to `agent-bundle/test` (#103 stage 2).

Three levels join `route-unit`, each labeled in its result provenance and in
every failure message, because a pass at one level is never a receipt for
another:

- `mcp-in-memory` — `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`,
`getMcpPrompt`, and `listMcpSurface` drive the real generated MCP server with
a real MCP client over the SDK's in-memory transport pair. Protocol-contract
proof only: no process, no stdio framing, no packed artifact.
- `cli-dispatch` — `invokeCli` runs an argv vector through the routed CLI's own
shell (#102 stage 2) over the compiled command graph the manifest now
carries, in-process. Command resolution, argv projection, help, `--version`,
and the exit-code policy are the product's; the harness supplies only the
`execute` bridge, and it mirrors the one the generated executable inlines.
`cliJson` reads the canonical stdout line.
- `packed-stdio` — `openPackedMcpServer` spawns a built artifact's generated
stdio entry and connects a real MCP client to it. This is the only level here
that is process evidence.

`renderRouteEvents` returns the ordered render-event stream alongside the final
document, and `expectEvents` asserts over it. The default matcher
(`toContainSequence`) is sequence-tolerant so a legitimate extra `progress` or
`replace` frame cannot turn a passing render red, while a missing frame, a
reordering, or a regressed ordinal still fails.

The test manifest gains `cliCommands`, the compiled routed-CLI command graph
from the same compiler pass, so the dispatch level never recompiles it.
`expectDocument` gains `toContainContext` for the context nodes an event route
returns to its host.

Event routes now render with the props the public contract defines —
`{ canonical, native, signal }`, the same unwrapping the generated Flight
worker performs — instead of the raw invocation payload. A route written
against `AgentEventRouteProps` previously received `undefined` for both.

Internally, the generated MCP server's warm Flight host, route registration,
and MCP projection move out of the entry template into the shared
`agent-bundle/mcp-server-runtime` module the generated entry aliases, so the
in-memory level exercises the artifact's own code rather than a second copy of
it. Generated-entry behaviour is unchanged.
3 changes: 2 additions & 1 deletion examples/rsc-agent-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
"scripts": {
"build": "rsbuild build --mode production && agent-bundle build --json --output dist/plugins",
"test": "rstest --config rstest.config.ts",
"test:routes": "rstest --config rstest.route-unit.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"validate": "agent-bundle validate",
"check": "pnpm validate && pnpm build && pnpm typecheck && pnpm test",
"check": "pnpm validate && pnpm build && pnpm typecheck && pnpm test && pnpm test:routes",
"eval:hosts": "node scripts/eval-hosts.mjs",
"capture:widget": "node scripts/capture-widget.mjs"
},
Expand Down
10 changes: 10 additions & 0 deletions examples/rsc-agent-runtime/rstest.route-unit.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from '@rstest/core';
import { agentBundleRstest } from 'agent-bundle/rstest';

/**
* The framework-generated route-unit configuration. One Agent Bundle compiler
* pass runs here — no artifact build — and it supplies the route manifest, the
* TypeScript transform, and the React Server Components conditions the demo's
* event route needs. The example maintains none of that by hand.
*/
export default defineConfig(await agentBundleRstest());
68 changes: 68 additions & 0 deletions examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

import { afterEach, beforeEach, expect, it } from '@rstest/core';
import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test';

/**
* The route-unit proof level for the demo's PostToolUse migration: the hook is
* a compiled `src/events/tool/after.tsx` route, and it renders through the same
* renderer and request scope every other route uses. Native wrapper delivery
* and the host response projection are proven by the artifact suites; this is
* not host or process evidence.
*/
const manifest = testManifest();
const fixture = resolve(import.meta.dirname, '../fixtures/events/claude-post-tool-use.json');

let workspace: string;
let previousStateFile: string | undefined;

beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-event-route-'));
previousStateFile = process.env.AGENT_RUNTIME_STATE_FILE;
process.env.AGENT_RUNTIME_STATE_FILE = join(workspace, 'state.json');
});

afterEach(async () => {
if (previousStateFile === undefined) delete process.env.AGENT_RUNTIME_STATE_FILE;
else process.env.AGENT_RUNTIME_STATE_FILE = previousStateFile;
await rm(workspace, { force: true, recursive: true });
});

it('compiles the PostToolUse hook as a real event route rather than configuration', () => {
expect(manifest.proofLevel).toBe('route-unit');
expect(manifest.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]);
expect(manifest.routes['event:tool/after']).toMatchObject({
kind: 'event-route',
relativePath: 'src/events/tool/after.tsx',
});
});

it('renders a native Claude PostToolUse envelope into the document the host projects from', async () => {
const native = JSON.parse(await readFile(fixture, 'utf8')) as Record<string, unknown>;
const rendered = await renderRoute('event:tool/after', {
input: {
canonical: {
event: 'tool/after',
idempotencyKey: 'route-unit-claude-write',
observedAt: '2026-09-01T00:00:00.000Z',
provenance: {
host: 'claude',
hostContractRevision: 'route-unit',
nativeEvent: 'PostToolUse',
source: 'native',
},
sequence: 1,
},
native: { ...native, cwd: workspace },
},
});

expect(rendered.invocation.kind).toBe('event');
expectDocument(rendered)
.toHaveStatus('success')
.toHaveNodeKinds(['result', 'context'])
.toContainContext('Recorded claude-note.txt from claude. Shared state now contains 1 edit.');
expect(rendered.provenance).toMatchObject({ kind: 'event-route', proofLevel: 'route-unit' });
});
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@
"scripts": {
"build": "pnpm --filter @agent-bundle/runtime build && pnpm --filter agent-bundle build && pnpm --filter create-agent-bundle build",
"lint:package": "publint packages/agent-bundle && publint packages/rsc-runtime && publint packages/create-agent-bundle",
"test": "pnpm test:unit && pnpm test:route-unit && pnpm test:integration",
"test": "pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration",
"test:unit": "rstest --config rstest.unit.config.ts",
"test:route-unit": "rstest --config rstest.route-unit.config.ts",
"test:projection": "rstest --config rstest.projection.config.ts",
"test:integration": "pnpm build && pnpm test:integration:run",
"test:integration:run": "AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.integration.config.ts",
"test:evidence": "pnpm build && AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.evidence.config.ts",
"test:watch": "rstest --config rstest.config.ts --watch",
"lint": "rslint .",
"bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs",
"typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json",
"check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:integration:run && pnpm lint && pnpm typecheck",
"check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck",
"check:local-ci": "node scripts/local-ci.mjs",
"docs:runtime-topology": "node scripts/rsc-runtime-topology.mjs --root . --output docs/architecture/rsc-runtime-workbench.md",
"eval:spot": "pnpm build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec rstest run tests/micro-eval.spot.test.ts --config rstest.config.ts",
Expand Down
37 changes: 37 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,43 @@ This is the route-unit proof level, and only that: it proves a route module
renders to the document it claims. It is not evidence about the MCP transport,
a packed artifact, or a browser surface.

### Proof levels

The levels are separate on purpose. Each helper stamps the level it carried
into its provenance and prints it in every failure, because a pass at one level
is never a receipt for another.

| level | helpers | what it proves |
| --- | --- | --- |
| `route-unit` | `renderRoute`, `renderRouteEvents` | a route module renders to the document (and render-event stream) it claims |
| `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface` | the real generated MCP server's protocol contract, over the SDK's in-memory transport |
| `cli-dispatch` | `invokeCli`, `cliJson` | an argv vector resolved and run through the routed CLI's own shell, in-process |
| `packed-stdio` | `openPackedMcpServer` | a built artifact's generated entry running as a real process over stdio |

```ts
import { cliJson, expectEvents, invokeCli, invokeMcpTool } from 'agent-bundle/test';

// mcp-in-memory: the generated server projects the document to protocol content.
const call = await invokeMcpTool('summarize', { input: { title: 'Dune' } });
expect(call.result.structuredContent).toEqual({ chapters: 24 });
Comment on lines +234 to +235

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read structured content from the documented return shape

The newly documented example cannot type-check because invokeMcpTool returns McpToolInvocation, whose structuredContent field is directly on call; there is no result property. Users copying the primary example for this new public helper receive Property 'result' does not exist, so the assertion should use call.structuredContent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #206 (merged as 7dbfacf): the README example now reads call.structuredContent, matching McpToolInvocation.


// cli-dispatch: the routed CLI resolves the command, parses argv, and maps the exit code.
const run = await invokeCli(['library', 'audit', './books', '--max-files', '8']);
expect(run.exitCode).toBe(0);
expect(cliJson(run)).toMatchObject({ scanned: 8 });
```

`expectEvents` asserts over a render-event stream. `toContainSequence` is
sequence-tolerant — an extra `progress` or `replace` frame is legal and cannot
turn a passing render red — while a missing frame, a reordering, or a regressed
ordinal still fails; `toHaveMonotonicSequence`, `toCompleteOnce`,
`toHaveProgress`, and `toHaveNoErrors` cover the rest of the contract.

Only `packed-stdio` is process evidence, and it is deliberately expensive: pack
once, install once, spawn once, and iterate every per-route assertion inside
that one session. Browser-App surfaces and deleted-source artifact proofs are
later stages; nothing here stands in for them.

## Evaluation

Eval suites are typed modules discovered by convention:
Expand Down
29 changes: 29 additions & 0 deletions packages/agent-bundle/fixtures/route-harness/src/cli/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { z } from 'zod';

/**
* Nested one level below the CLI root, so the dispatch level exercises path
* nesting (`db migrate`) rather than a single flat command, and carries the
* `result` exit-code policy so the harness proves that mapping too.
*/
export const config = {
description: 'Applies pending harness migrations.',
exitCode: 'result',
} satisfies CliRouteConfig;

export const inputSchema = z.object({
dryRun: z.boolean().default(false),
}).strict();

export const resultSchema = z.object({
applied: z.number().int(),
dryRun: z.boolean(),
exitCode: z.number().int(),
}).strict();

export default async function migrate({ input }: CliRouteProps<typeof inputSchema>) {
// A dry run reports pending work and exits non-zero without applying it.
return input.dryRun
? { applied: 0, dryRun: true, exitCode: 3 }
: { applied: 2, dryRun: false, exitCode: 0 };
}
34 changes: 34 additions & 0 deletions packages/agent-bundle/fixtures/route-harness/src/cli/inventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { agent } from '@agent-bundle/runtime';
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { z } from 'zod';

export const config = {
aliases: ['inv'],
description: 'Lists the harness library inventory.',
positionals: ['shelf'],
} satisfies CliRouteConfig;

export const inputSchema = z.object({
format: z.enum(['json', 'text']).default('text'),
limit: z.number().int().min(1).max(8).optional(),
shelf: z.string().min(1),
}).strict();

export const resultSchema = z.object({
format: z.string(),
shelf: z.string(),
titles: z.array(z.string()),
}).strict();

const shelves: Readonly<Record<string, readonly string[]>> = {
fiction: ['Piranesi', 'Solaris'],
history: ['SPQR'],
};

export default async function inventory({ input }: CliRouteProps<typeof inputSchema>) {
const context = await agent();
await context.progress.report({ completed: 1, message: 'reading inventory', total: 2 });
const titles = (shelves[input.shelf] ?? []).slice(0, input.limit ?? 8);
await context.progress.report({ completed: 2, message: 'inventory ready', total: 2 });
return { format: input.format, shelf: input.shelf, titles: [...titles] };
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Agent, agent } from '@agent-bundle/runtime';
import type { AgentEventRouteProps } from 'agent-bundle';

export default async function AfterTool({ event, payload }: { readonly event: string; readonly payload: unknown }) {
export default async function AfterTool({ canonical, native }: AgentEventRouteProps) {
const context = await agent();
return (
<Agent.Result value={{ event, invocationKind: context.invocation.kind, payload: payload as never }}>
<Agent.Markdown>{`Observed ${event}.`}</Agent.Markdown>
<Agent.Result value={{
event: canonical.event,
invocationKind: context.invocation.kind,
tool: typeof native['tool_name'] === 'string' ? native['tool_name'] : 'unknown',
}}
>
<Agent.Markdown>{`Observed ${canonical.event} from ${canonical.provenance.host}.`}</Agent.Markdown>
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Agent } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = { description: 'Summarizes one harness note.', title: 'Summarize' };

export const inputSchema = z.object({ note: z.string() });

/** The generated server returns a prompt route's result as the protocol's `GetPromptResult`. */
export const resultSchema = z.object({
messages: z.array(z.object({
content: z.object({ text: z.string(), type: z.literal('text') }),
role: z.literal('user'),
})),
});

export default async function Summarize({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const messages = [{ content: { text: `Summarize ${input.note}`, type: 'text' as const }, role: 'user' as const }];
return (
<Agent.Result value={{ messages }}>
<Agent.Text>{`prompt ready for ${input.note}`}</Agent.Text>
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ export const config = { mimeType: 'text/markdown', title: 'Notes', uri: 'harness

export const inputSchema = z.object({ uri: z.string() });

export const resultSchema = z.object({ uri: z.string() });
/** The generated server returns a resource route's result as the protocol's `ReadResourceResult`. */
export const resultSchema = z.object({
contents: z.array(z.object({ mimeType: z.string(), text: z.string(), uri: z.string() })),
});

export default async function Notes({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const text = `# Notes for ${input.uri}`;
return (
<Agent.Result value={{ uri: input.uri }}>
<Agent.Markdown>{`# Notes for ${input.uri}`}</Agent.Markdown>
<Agent.Result value={{ contents: [{ mimeType: 'text/markdown', text, uri: input.uri }] }}>
<Agent.Markdown>{text}</Agent.Markdown>
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Agent } from '@agent-bundle/runtime';
import { Suspense } from 'react';
import { z } from 'zod';

export const config = {
description: 'Streams the harness catalog behind one Suspense boundary.',
title: 'Catalog',
};

export const inputSchema = z.object({ genre: z.string().optional() });

export const resultSchema = z.object({ genre: z.string(), titles: z.array(z.string()) });

const titles = ['Piranesi', 'Solaris'];

/** Resolves after the shell, so the render has a boundary to replace. */
const Titles = async ({ genre }: { readonly genre: string }) => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1);
});
return <Agent.Markdown>{`## ${genre}\n\n${titles.map((title) => `- ${title}`).join('\n')}`}</Agent.Markdown>;
};

export default async function Catalog({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const genre = input.genre ?? 'all';
return (
<Agent.Result value={{ genre, titles }}>
<Agent.Text>{`catalog: ${genre}`}</Agent.Text>
<Suspense fallback={<Agent.Progress completed={0} message={`loading ${genre}`} total={titles.length} />}>
<Titles genre={genre} />
</Suspense>
</Agent.Result>
);
}
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default defineConfig({
index: './src/index.ts',
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
'mcp-server-runtime': './src/mcp-server-runtime.ts',
rstest: './src/rstest/index.ts',
test: './src/test/index.ts',
},
Expand Down
Loading
Loading