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

Export `loadRouteModule(id)` from `agent-bundle/test`: the evaluated module behind one compiled route id, through the same registered loader `renderRoute` uses, so `inputSchema`, `resultSchema`, `config`, and `default` are the route's own exports by reference and a schema-identity suite can iterate `testManifest().routes` instead of maintaining static route imports. A literal id is checked against the registered route ids and the schemas' parsed values are typed from the registration; outside an `agentBundleRstest()` pool or against a foreign manifest it fails closed with `manifest-unavailable`. Fixes #493. (#499)
8 changes: 7 additions & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,13 @@ its own. `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.
kind, and the module provenance. `loadRouteModule(id)` returns the evaluated
module behind one compiled id through the same registered loader `renderRoute`
uses — the module object itself, so `inputSchema`, `resultSchema`, `config`,
and `default` are the route's own exports by reference — which replaces a
hand-maintained list of static route imports in a schema-identity suite; it
fails closed with `manifest-unavailable` outside an `agentBundleRstest()` pool
or against a manifest the registered loaders did not come from.

The same generated `.agent-bundle/routes.d.ts` registers the route contracts on
`@agent-bundle/runtime`'s `Register` interface. With that file in the project's
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-bundle/src/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*
* | level | helper | what it proves |
* | --- | --- | --- |
* | `route-unit` | `renderRoute`, `renderRouteEvents`, `createTargetCapabilityFixture`, `projectTargetCapabilities` | the route component and its document through the real Agent renderer; explicit target-capability projection through the real MCP projector, without transport or host proof |
* | `route-unit` | `renderRoute`, `renderRouteEvents`, `loadRouteModule`, `createTargetCapabilityFixture`, `projectTargetCapabilities` | the route component and its document through the real Agent renderer (and the evaluated route module itself, by compiled id); explicit target-capability projection through the real MCP projector, without transport or host proof |
* | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface`, `runContractMatrix` | the real generated MCP server's protocol contract, over the SDK's in-memory transport; MCP App routes are not registered and report `not-applicable` |
* | `dev-epoch` | `runDevEpochContractMatrix` | an epoch-pinned generated stdio process opened through the Workbench session service; MCP App routes are covered (surface + `ui://` sweep) and auto-covered without a fixture |
* | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | a compiled plain or rendered CLI command dispatched through the routed CLI's own shell, including rendered output modes, in this process |
Expand Down Expand Up @@ -61,9 +61,13 @@ export { AGENT_TEST_REGISTRY_VERSION, registerTestRoutes, testManifest } from '.
export type { AgentProviderModuleLoader, AgentTestRouteRegistry } from './registry.ts';
export { AgentTestError } from './errors.ts';
export type { AgentTestErrorCode } from './errors.ts';
export { renderRoute, renderRouteEvents } from './render.ts';
export { loadRouteModule, renderRoute, renderRouteEvents } from './render.ts';
export type {
HarnessOptionsArguments,
LoadRouteModuleConstraint,
LoadRouteModuleOptions,
LoadedRouteModule,
RouteModuleSchema,
RenderRouteContext,
RenderRouteContextInit,
RenderRouteOptions,
Expand Down
112 changes: 101 additions & 11 deletions packages/agent-bundle/src/test/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from './registry.ts';
import type {
AgentRouteModule,
AgentRouteSchema,
RenderableRouteKind,
RenderedRouteProvenance,
TestableRouteDescriptor,
Expand Down Expand Up @@ -434,9 +435,37 @@ const resolveTarget = async (
return { component: componentOf(target, provenance), kind, layouts: [], module: target, provenance };
}
const manifest = options.manifest ?? testManifest();
const descriptor = manifest.routes[target];
const loaded = await loadManifestRouteModule(manifest, target);
const layouts = await loadLayoutChain(manifest, loaded.descriptor, loaded.provenance);
return {
component: componentOf(loaded.module, loaded.provenance),
kind: loaded.kind,
layouts,
manifest,
module: loaded.module,
provenance: loaded.provenance,
};
};

interface LoadedManifestRoute {
readonly descriptor: TestableRouteDescriptor;
readonly kind: RenderableRouteKind;
readonly module: AgentRouteModule;
readonly provenance: RenderedRouteProvenance;
}

/**
* Resolves one compiled route id of `manifest` to its evaluated module through
* the loader the generated setup registered — the one path every manifest
* render takes, and the one `loadRouteModule` exposes on its own.
*/
const loadManifestRouteModule = async (
manifest: AgentBundleTestManifest,
routeId: string,
): Promise<LoadedManifestRoute> => {
const descriptor = manifest.routes[routeId];
if (descriptor === undefined) {
throw new AgentTestError('route-not-found', `No compiled route is named ${JSON.stringify(target)}.`, {
throw new AgentTestError('route-not-found', `No compiled route is named ${JSON.stringify(routeId)}.`, {
details: [
`project root: ${manifest.projectRoot}`,
`compiled: ${knownRouteIds(manifest)}`,
Expand Down Expand Up @@ -490,15 +519,76 @@ const resolveTarget = async (
);
}
const module = await loader();
const layouts = await loadLayoutChain(manifest, descriptor, { ...provenance, kind });
return {
component: componentOf(module, { ...provenance, kind }),
kind,
layouts,
manifest,
module,
provenance: { ...provenance, kind },
};
return { descriptor, kind, module, provenance: { ...provenance, kind } };
};

/** A route schema whose parsed value is typed from the route's registration once the id is registered. */
export interface RouteModuleSchema<Value> extends AgentRouteSchema {
readonly parse: (value: unknown) => Value;
}

/**
* The evaluated module `loadRouteModule` returns: the same object the generated
* server, the routed CLI, and `renderRoute` execute, so `inputSchema` and
* `resultSchema` are the route's own schema instances by reference (not copies
* or JSON), `config` is the module's static config export, and `default` its
* component — absent for a plain `src/scripts/*.ts` module, whose contract is
* `main`. Any other named export is reachable through the index signature.
*/
export interface LoadedRouteModule<Target extends string = string> {
readonly [exportName: string]: unknown;
readonly config?: unknown;
readonly default?: (props: never) => unknown;
readonly inputSchema?: RouteModuleSchema<RouteTargetInput<Target>>;
readonly resultSchema?: RouteModuleSchema<RouteTargetResult<Target>>;
}

export interface LoadRouteModuleOptions {
/** Loads against an explicit manifest instead of the one the generated configuration registered. */
readonly manifest?: AgentBundleTestManifest;
}

/**
* The constraint one `loadRouteModule` id must satisfy. Conventional scripts
* are loadable but are not part of the generated route registration
* (`.agent-bundle/routes.d.ts` registers tool, resource, prompt, CLI, and
* event routes), so a `script:` literal is admitted unchecked while every
* other literal is checked against the registered ids exactly as `renderRoute`
* checks its target; a value typed `string` stays legal for dynamic lookups,
* and without a registration every string is legal. The union is spelled
* inline so a rejection lists the registered ids rather than an alias name.
*/
export type LoadRouteModuleConstraint<Target> = string extends Target ? string : RegisteredRouteId | `script:${string}`;

/**
* Loads the evaluated module of one compiled route by its id, through the
* lazy loader the generated Rstest setup registered for it — the loader
* `renderRoute` uses. This is the supported replacement for a hand-maintained
* list of static `import * as m from '../../src/mcp/<server>/tools/<tool>'`
* statements in a schema-identity suite: iterate `testManifest().routes` and
* load each id instead (#493).
*
* The id is checked against the registered route ids exactly as `renderRoute`
* checks its target, so a removed placement is a type error (`script:` ids are
* not registered and pass unchecked; see {@link LoadRouteModuleConstraint}). Outside a pool
* built with `agentBundleRstest()` it throws `manifest-unavailable`, and a
* manifest describing another project rejects with the same mismatch report
* `renderRoute` gives: route loaders are bound to the compilation that
* produced them. Every renderable kind loads — tools, resources, prompts,
* event routes, CLI commands, and scripts; App routes are browser builds and
* are not loadable here (`unsupported-route-kind`).
*/
export const loadRouteModule = async <Target extends string>(
routeId: (Target & LoadRouteModuleConstraint<Target>) | LoadRouteModuleConstraint<Target>,
...[options = {}]: HarnessOptionsArguments<LoadRouteModuleOptions>
): Promise<LoadedRouteModule<Target>> => {
const manifest = options.manifest ?? testManifest();
// The loader returns the module namespace object itself. Its shape is not
// checked here: a plain script legitimately has no default export, and the
// render and dispatch levels already report a module that breaks their own
// contract with the route's provenance.
const loaded = await loadManifestRouteModule(manifest, routeId as string);
return loaded.module as LoadedRouteModule<Target>;
};

/**
Expand Down
20 changes: 20 additions & 0 deletions packages/agent-bundle/tests/route-register-typegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ it('types every route-aware public surface from the generated route registration
' getMcpPrompt,',
' invokeCli,',
' invokeMcpTool,',
' loadRouteModule,',
' renderRoute,',
' renderRouteEvents,',
' runContractMatrix,',
Expand Down Expand Up @@ -223,7 +224,17 @@ it('types every route-aware public surface from the generated route registration
" expectNoMcpCall({ server: 'curator' });",
" expectNoMcpCall({ server: 'github', tool: 'search_issues' });",
" expectMcpCall({ server: dynamic, tool: dynamic });",
' // loadRouteModule checks its id the same way and types the schemas\' parsed values from the registration.',
" const found_module = await loadRouteModule('tool:curator/find');",
" const parsedQuery: string | undefined = found_module.inputSchema?.parse({ query: 'dune' }).query;",
' const parsedHits: number | undefined = found_module.resultSchema?.parse({ hits: 1 }).hits;',
' const looseModule = await loadRouteModule(dynamic);',
' const looseParsed: unknown = looseModule.resultSchema?.parse({});',
' // A conventional script is loadable by literal even though scripts are not registered.',
" const script = await loadRouteModule('script:anything');",
' const scriptParsed: unknown = script.resultSchema?.parse({});',
' void hits; void status; void none; void anything; void packed; void executed; void isReport;',
' void parsedQuery; void parsedHits; void looseParsed; void scriptParsed;',
'};',
'',
].join('\n')),
Expand Down Expand Up @@ -292,6 +303,11 @@ it('types every route-aware public surface from the generated route registration
"export const missing = renderRoute('tool:curator/missing');",
'',
].join('\n')),
writeProjectFile(root, 'wrong-load-id.ts', [
"import { loadRouteModule } from 'agent-bundle/test';",
"export const missing = loadRouteModule('tool:curator/missing');",
'',
].join('\n')),
writeProjectFile(root, 'wrong-input.ts', [
"import { renderRoute } from 'agent-bundle/test';",
"export const mistyped = renderRoute('tool:curator/find', { input: { query: 7 } });",
Expand Down Expand Up @@ -336,6 +352,10 @@ it('types every route-aware public surface from the generated route registration
expect(wrongId).toHaveLength(1);
// The rejection names the registered ids, not `never`.
expect(wrongId[0]).toContain(`Argument of type '"tool:curator/missing"' is not assignable to parameter of type '${registeredIds.map((id) => `"${id}"`).join(' | ')}'`);
const wrongLoadId = typecheck(root, 'wrong-load-id.ts', true);
expect(wrongLoadId).toHaveLength(1);
// Scripts are not registered, so a `script:` literal is admitted beside the registered ids.
expect(wrongLoadId[0]).toContain(`Argument of type '"tool:curator/missing"' is not assignable to parameter of type '${registeredIds.map((id) => `"${id}"`).join(' | ')} | \`script:\${string}\`'`);
const wrongInput = typecheck(root, 'wrong-input.ts', true);
expect(wrongInput).toHaveLength(1);
expect(wrongInput[0]).toContain("Type 'number' is not assignable to type 'string'");
Expand Down
114 changes: 114 additions & 0 deletions packages/agent-bundle/tests/route-unit/load-route-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it } from '@rstest/core';
import { z } from 'zod';

import * as Report from '../../fixtures/route-harness/src/cli/report.tsx';
import * as Catalog from '../../fixtures/route-harness/src/mcp/harness/tools/catalog.tsx';
import * as Summary from '../../fixtures/route-harness/src/scripts/summary.tsx';
import { AgentTestError } from '../../src/test/errors.ts';
import { loadRouteModule, renderRoute } from '../../src/test/render.ts';
import { testManifest } from '../../src/test/registry.ts';

const rejection = async (load: Promise<unknown>): Promise<AgentTestError> => {
try {
await load;
} catch (thrown: unknown) {
return thrown as AgentTestError;
}
throw new Error('The load resolved, so no harness diagnostic was produced.');
};

/**
* `loadRouteModule` (#493) is the supported replacement for a hand-maintained
* list of static route imports in a schema-identity suite: the evaluated
* module comes back through the same registered loader `renderRoute` uses, so
* its schemas are the route's own instances, not copies.
*/
describe('loadRouteModule', () => {
it('returns the evaluated tool module whose schemas are the very instances the route exports', async () => {
const module = await loadRouteModule('tool:harness/catalog');

// Identity, not shape: the same objects the statically imported module holds.
expect(module.inputSchema).toBe(Catalog.inputSchema);
expect(module.resultSchema).toBe(Catalog.resultSchema);
expect(module.default).toBe(Catalog.default);
expect(module.inputSchema).toBeInstanceOf(z.ZodObject);
expect(module.resultSchema).toBeInstanceOf(z.ZodObject);
expect(typeof module.default).toBe('function');
// The static config the compiler extracted into the manifest is the module's.
expect(module.config).toEqual(testManifest().routes['tool:harness/catalog']!.config);
});

it('loads every renderable route id the manifest reports, including CLI commands and scripts', async () => {
const manifest = testManifest();
// Loading evaluates the module exactly as an import does. The fixture's
// scripts include deliberately broken, blank, self-executing, and
// never-settling modules for the script-dispatch level, so scripts are
// loaded by name below and every other renderable kind is swept here.
const loadable = Object.values(manifest.routes)
.filter((route) => route.kind !== 'app' && route.kind !== 'script');
expect(loadable.map((route) => route.kind)).toEqual(expect.arrayContaining(['cli', 'event-route', 'prompt', 'resource', 'tool']));

for (const route of loadable) {
const module = await loadRouteModule(route.id);
expect(typeof module.default, route.id).toBe('function');
// The compiler records `{}` for a module that exports no static config.
expect(module.config ?? {}, route.id).toEqual(route.config);
}

const report = await loadRouteModule('cli:report');
expect(report.inputSchema).toBe(Report.inputSchema);
expect(report.resultSchema).toBe(Report.resultSchema);
expect(report.config).toBe(Report.config);

// A rendered script default-exports its component; a plain `.ts` script's
// contract is `main`, so `default` is not required of it.
const summary = await loadRouteModule('script:summary');
expect(summary.default).toBe(Summary.default);
expect(summary.inputSchema).toBeUndefined();

const checksum = await loadRouteModule('script:checksum');
expect(checksum.default).toBeUndefined();
expect(typeof checksum['main']).toBe('function');
});

it('returns one module instance per route, the one renderRoute renders', async () => {
const [first, second] = await Promise.all([
loadRouteModule('tool:harness/echo'),
loadRouteModule('tool:harness/echo'),
]);
expect(first).toBe(second);

// The rendered document's value parses through the very schema the loaded
// module exports, so the harness and the consumer agree on one contract.
const rendered = await renderRoute('tool:harness/echo', { input: { message: 'identity' } });
expect(first.resultSchema!.parse(rendered.document.value)).toEqual(rendered.result);
});

it('rejects an id the manifest does not compile with the compiled ids', async () => {
const error = await rejection(loadRouteModule('tool:harness/missing'));

expect(error).toBeInstanceOf(AgentTestError);
expect(error.code).toBe('route-not-found');
expect(error.message).toContain('tool:harness/catalog');
});

it('refuses an App route, which is a browser build', async () => {
const appId = Object.values(testManifest().routes).find((route) => route.kind === 'app')?.id;
expect(appId).toBeDefined();

const error = await rejection(loadRouteModule(appId!));
expect(error.code).toBe('unsupported-route-kind');
});

it('fails closed for a manifest whose loaders are not the registered ones', async () => {
const compiled = testManifest();
const foreign = { ...compiled, digest: `${compiled.digest}-foreign`, projectRoot: `${compiled.projectRoot}-sibling` };

const error = await rejection(loadRouteModule('tool:harness/catalog', { manifest: foreign }));

expect(error).toBeInstanceOf(AgentTestError);
expect(error.code).toBe('manifest-unavailable');
expect(error.message).toContain('not the ones registered in this test process');
expect(error.message).toContain(`registered: ${compiled.digest}`);
});
});
Loading
Loading