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
28 changes: 28 additions & 0 deletions .changeset/consumer-route-test-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"agent-bundle": minor
---

Ship the consumer route test harness as two new public subpaths.

`agent-bundle/rstest` exposes `agentBundleRstest()`: it runs the same
route-graph compilation the build runs — one compiler pass, through the shared
project service, with no artifact build — and returns a plain Rstest
configuration object that registers the compiled test manifest and the route
loaders, resolves React under the `react-server` condition, and selects the
automatic JSX runtime. `agent-bundle/test` exposes `renderRoute`, which executes
a route by compiled id or by module through the real final-only Flight
dispatcher and the real request store and resolves to the final Agent Document,
plus `expectDocument` matchers over the Agent Document contracts
(`toHaveStatus`, `toContainMarkdown`, `toContainText`, `toHaveValue`,
`toHaveError`, `toHaveNodeKinds`) and `testManifest()` for iterating the route
inventory in process. Failures name the route id, target kind, and module
provenance.

`@rstest/core` and `react` are optional peer dependencies: a project that does
not test routes installs neither, and neither becomes a runtime dependency.
`@agent-bundle/runtime` stays undeclared and is loaded through a dynamic
import, matching how the generated entry shells already import it from the
consumer project.

This is the route-unit proof level, labeled as such. Transport, packed, and
browser levels are not included and are not scaffolded.
5 changes: 3 additions & 2 deletions examples/audiobook-curator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@
},
"scripts": {
"build": "agent-bundle build --output artifact",
"check": "pnpm validate && pnpm build && pnpm typecheck && pnpm test",
"check": "pnpm validate && pnpm build && pnpm typecheck && pnpm test && pnpm test:routes",
"dev": "agent-bundle dev",
"test": "rstest tests",
"test": "rstest tests --exclude 'tests/route-unit/**'",
"test:routes": "rstest --config rstest.route-unit.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"validate": "agent-bundle validate"
},
Expand Down
10 changes: 10 additions & 0 deletions examples/audiobook-curator/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 this
* project's route tests need. The example maintains none of that by hand.
*/
export default defineConfig(await agentBundleRstest());
35 changes: 35 additions & 0 deletions examples/audiobook-curator/tests/route-unit/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, it } from '@rstest/core';
import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test';

/**
* The route-unit proof level for this example: it proves the curator's route
* modules render to the Agent Documents they claim. The manifest below is the
* framework compiler's own route compilation — the same one the build uses —
* delivered without building an artifact. It is not transport, packed-artifact,
* or host proof.
*/
const manifest = testManifest();

it('compiles the curator routes through the framework test manifest, with no build', () => {
expect(manifest.proofLevel).toBe('route-unit');
expect(manifest.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]);
expect(Object.keys(manifest.routes)).toContain('prompt:curator/curate');
});

it('renders the curation prompt route into a final Agent Document', async () => {
const rendered = await renderRoute('prompt:curator/curate', { input: { root: '/library' } });

expectDocument(rendered)
.toHaveStatus('success')
.toContainText('Evidence-first curation prompt ready.')
.toHaveValue({
messages: [{
content: {
text: 'Inspect /library, retain evidence, and require review before mutation.',
type: 'text',
},
role: 'user',
}],
});
expect(rendered.provenance).toMatchObject({ proofLevel: 'route-unit', routeId: 'prompt:curator/curate' });
});
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
"scripts": {
"build": "pnpm --filter agent-bundle build && pnpm --filter @agent-bundle/runtime 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:integration",
"test": "pnpm test:unit && pnpm test:route-unit && pnpm test:integration",
"test:unit": "rstest --config rstest.unit.config.ts",
"test:route-unit": "rstest --config rstest.route-unit.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 .",
"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:integration:run && pnpm lint && pnpm typecheck",
"check": "pnpm build && pnpm test:unit && pnpm test:route-unit && 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
59 changes: 59 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,65 @@ AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 pnpm --filter agent-bundl
`packages/workbench/scripts/dev.mjs` requires that proxy URL. Published `agent-bundle dev` serves
prebuilt assets and project events; it does not run an Rsbuild development server.

## Testing routes

Route modules are tested through the framework, not through a hand-written
bundler configuration. Two subpaths ship that harness, and both are opt-in:
`@rstest/core` and `react` are optional peer dependencies, so a project that
never tests routes installs neither. Rendering also needs
`@agent-bundle/runtime`, which the project already owns whenever it has route
modules — the generated entries import it the same way.

`agent-bundle/rstest` is the configuration helper. It compiles the project once
— the same route-graph compilation the build performs, with no artifact build —
and returns a plain Rstest configuration object carrying the test manifest,
the route loaders, React's `react-server` resolution, and the automatic JSX
runtime:

```ts
// rstest.route-unit.config.ts
import { defineConfig } from '@rstest/core';
import { agentBundleRstest } from 'agent-bundle/rstest';

export default defineConfig(await agentBundleRstest());
```

Route-unit tests default to `tests/route-unit/**/*.test.{ts,tsx}` and need
their own Rstest run, because rendering a route requires Node's `react-server`
condition for the whole worker process. Keep them out of the project's ordinary
`rstest` run.

`agent-bundle/test` holds the helpers. `renderRoute` executes a route — by
compiled route id, or by importing the module directly — through the real
renderer and the real request store, and resolves to the final Agent Document:

```ts
import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test';

const { document } = await renderRoute('tool:library/summarize', {
context: { cwd: '/tmp/library', operationId: 'run-1' },
Comment thread
ScriptedAlchemy marked this conversation as resolved.
input: { title: 'Dune' },
});

expectDocument(document).toHaveStatus('success').toContainMarkdown('Dune').toHaveValue({ chapters: 24 });
```

`renderRoute` accepts `input`, `args` (CLI routes), request-`context`
overrides, a `progress` reporter, render `limits`, and an `abort` signal; it
returns the document, the ordered render events, the resolved provenance, and
the route's own `resultSchema`-parsed value. `testManifest()` exposes the
compiled route inventory, so a suite can iterate every route in process rather
than paying for a build per route. Every failure — an unknown route, a refused
route kind, a rejected input, a render error — names the route id, the target
kind, and the module provenance.

Matchers over the Agent Document contracts: `toHaveStatus`, `toContainMarkdown`,
`toContainText`, `toHaveValue`, `toHaveError`, and `toHaveNodeKinds`.

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.

## Evaluation

Eval suites are typed modules discovered by convention:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// A conventional route project for the route-unit test harness. Plain object
// export, like every other repository fixture: the fixture must compile
// without the package's own built configuration entry.
export default {
plugin: {
description: 'Conventional route modules for the route-unit test harness.',
name: 'route-harness',
version: '1.0.0',
},
targets: ['claude'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Agent, agent } from '@agent-bundle/runtime';

export default async function AfterTool({ event, payload }: { readonly event: string; readonly payload: unknown }) {
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>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// A browser App surface. The route-unit level refuses to render it by name;
// nothing in the Node test bundle ever imports this module.
export const config = { resourceUri: 'ui://harness/panel' };

export default function Panel() {
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Agent } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = { mimeType: 'text/markdown', title: 'Notes', uri: 'harness://notes' };

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

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

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

export const config = {
description: 'Echoes one message back with the observed workspace root.',
title: 'Echo',
};

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

export const resultSchema = z.object({
message: z.string(),
operationId: z.string().nullable(),
workspace: z.string().nullable(),
});

export default async function Echo({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const context = await agent();
await context.progress.report({ completed: 1, message: 'echoing', total: 1 });
const workspace = context.workspace.state === 'available' ? context.workspace.value.root : null;
const message = input.message ?? '(no message)';
return (
<Agent.Result value={{ message, operationId: context.invocation.operationId ?? null, workspace }}>
<Agent.Markdown>{`# Echo\n\n${message}`}</Agent.Markdown>
<Agent.Text>{`workspace: ${workspace ?? 'unavailable'}`}</Agent.Text>
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Agent } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = { title: 'Unavailable' };

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

export const resultSchema = z.object({ available: z.literal(false) });

export default async function Unavailable() {
return (
<Agent.Result value={{ available: false }}>
<Agent.Error code="AB9001">The harness fixture represents this capability as unavailable.</Agent.Error>
</Agent.Result>
);
}
26 changes: 26 additions & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@
"./mcp-entry": {
"types": "./dist/mcp-entry.d.ts",
"import": "./dist/mcp-entry.js"
},
"./rstest": {
"types": "./dist/rstest/index.d.ts",
"import": "./dist/rstest.js"
},
"./test": {
"types": "./dist/test/index.d.ts",
"import": "./dist/test.js"
}
},
"dependencies": {
Expand All @@ -86,9 +94,27 @@
"yaml": "2.9.0"
},
"devDependencies": {
"@agent-bundle/runtime": "workspace:*",
"@modelcontextprotocol/server": "2.0.0",
"@types/react": "19.2.18",
"@types/ws": "8.18.1",
"react": "19.2.8",
"zod": "4.4.3"
},
"peerDependencies": {
"@agent-bundle/runtime": "*",
"@rstest/core": "^0.11.10",
"react": "19.2.8"
},
"peerDependenciesMeta": {
"@agent-bundle/runtime": {
"optional": true
},
"@rstest/core": {
"optional": true
},
"react": {
"optional": true
}
}
}
2 changes: 2 additions & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export default defineConfig({
index: './src/index.ts',
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
rstest: './src/rstest/index.ts',
test: './src/test/index.ts',
},
},
});
Loading
Loading