diff --git a/.changeset/typed-route-register.md b/.changeset/typed-route-register.md new file mode 100644 index 000000000..a661492df --- /dev/null +++ b/.changeset/typed-route-register.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Type `renderRoute` and `renderRouteEvents` (`agent-bundle/test`) against the project's own routes: the generated `.agent-bundle/routes.d.ts` registers each route's harness contract on the new `Register` interface of `@agent-bundle/runtime`, so a string-literal route id is checked against the compiled ids, `input` is typed from the route's `inputSchema` (an event route's `{ canonical, native }` payload), and `result` from its `resultSchema` (`undefined` for event routes). Add `Register`, `RegisteredRoutes`, `RegisteredRouteContract`, `RegisteredRouteId`, `RegisteredRouteInput`, and `RegisteredRouteResult` to `@agent-bundle/runtime`, and `RouteTargetConstraint`, `RouteTargetInput`, and `RouteTargetResult` to `agent-bundle/test`; a program without the generated file keeps the previous `string` / `unknown` types. (#456) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 85e2ad00e..33d5f39de 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -164,6 +164,22 @@ else supplies providers) requires `providers` outright: a handler typed against [harness section](../packages/agent-bundle/README.md#testing-routes) for the module-evaluation caveat that applies to provider-level state. +The same file registers the route contracts themselves. One generated +`AgentBundleRouteContracts` — `{ input, result }` per route id, inferred from +each module's own `inputSchema`/`resultSchema`; an event route registers the +`{ canonical, native }` payload the harness accepts (it supplies `signal` +itself) and an `undefined` result — is registered on `@agent-bundle/runtime`'s +`Register` interface in that one `declare module` block, so no per-route +declaration file is emitted. +`renderRoute('tool:library/summarize', { input })` then checks its id against +the compiled ids, types `input` from the route's input schema, and types +`result` from its result schema; `RegisteredRouteId`, `RegisteredRouteInput`, +and `RegisteredRouteResult` name that surface for a wrapper of your own. The +seam is inert without the file: an id typed `string` stays legal for dynamic +lookups, a directly imported module target is unaffected, and a project that +excludes the generated declarations (or has not built yet) sees the +unregistered types — any string, `unknown` input, `unknown` result. + ### What reaches the MCP wire The final Agent Document of a tool route lowers to one `CallToolResult`: diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 69b91c54e..60d7cda05 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -489,6 +489,18 @@ 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. +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 +TypeScript program (add it to `tsconfig.json` `include`), a string-literal route +id is checked against the compiled ids, `input` is typed from the route's +`inputSchema`, and `result` from its `resultSchema` (an event route's `input` +is its `{ canonical, native }` payload and its `result` `undefined`); +`RegisteredRouteId`, +`RegisteredRouteInput`, and `RegisteredRouteResult` from `@agent-bundle/runtime` +name that surface for wrappers. A value typed `string`, a module target, or a +program without the generated file sees the previous types — any id, `unknown` +input and result. + Conventional request context providers (`src/providers/*`, see [entry conventions](../../docs/entry-conventions.md#request-context-providers-power-tier)) are mounted automatically for every manifest-backed helper — `renderRoute`, diff --git a/packages/agent-bundle/src/routes/typegen.ts b/packages/agent-bundle/src/routes/typegen.ts index 0e5d08db8..0a1a97cc7 100644 --- a/packages/agent-bundle/src/routes/typegen.ts +++ b/packages/agent-bundle/src/routes/typegen.ts @@ -40,10 +40,8 @@ const providerMember = (provider: CompiledProvider, index: number): string => /** * The provider half of the generated declarations: `AgentBundleProviders` - * maps each camel-cased key to its factory's awaited return type, and the - * `@agent-bundle/runtime` augmentation makes `(await agent()).providers.` - * observe that type. Omitted for provider-free graphs so the augmentation - * never references a module the project has no reason to depend on. + * maps each camel-cased key to its factory's awaited return type. Omitted for + * provider-free graphs. */ const providerDeclarations = (providers: readonly CompiledProvider[]): readonly string[] => providers.length === 0 @@ -58,10 +56,42 @@ const providerDeclarations = (providers: readonly CompiledProvider[]): readonly 'export type ProviderKey = keyof AgentBundleProviders;', 'export type ProviderValue = AgentBundleProviders[Key];', '', + ]; + +/** + * The single `@agent-bundle/runtime` augmentation. Its `Register.routes` + * member registers the thin `{ input, result }` contract map (TanStack + * Router's `Register` pattern), so `agent-bundle/test`'s `renderRoute` narrows + * its route-id parameter, `input`, and `result` from the project's own route + * modules — a schema route's `inputSchema`/`resultSchema` output, an event + * route's `{ canonical, native }` payload with no result; its + * `AgentProviderValues` members make + * `(await agent()).providers.` observe each factory's resolved type. + * Omitted for graphs with neither, so the augmentation never references a + * module the project has no reason to depend on. + */ +const runtimeAugmentation = ( + routes: readonly CompiledAgentRoute[], + providers: readonly CompiledProvider[], +): readonly string[] => + routes.length === 0 && providers.length === 0 + ? [] + : [ "declare module '@agent-bundle/runtime' {", - ' interface AgentProviderValues {', - ...providers.map(providerMember).map((line) => ` ${line}`), - ' }', + ...(routes.length === 0 + ? [] + : [ + ' interface Register {', + ' readonly routes: AgentBundleRouteContracts;', + ' }', + ]), + ...(providers.length === 0 + ? [] + : [ + ' interface AgentProviderValues {', + ...providers.map(providerMember).map((line) => ` ${line}`), + ' }', + ]), '}', '', ]; @@ -94,6 +124,17 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { ' Contract extends { readonly result: infer Result } ? Result', ' : Contract extends { readonly component: infer Component } ? ComponentResult', ' : never;', + '// What the `agent-bundle/test` harness accepts and returns for one route: a schema route\'s own', + '// input and result; for an event route the `{ canonical, native }` payload (the harness supplies', + '// `signal` itself) and no result, since event modules export no `resultSchema`.', + 'type HarnessInput =', + ' Contract extends { readonly input: infer Input } ? Input', + " : Contract extends { readonly component: infer Component } ? Omit, 'signal'>", + ' : never;', + 'type HarnessResult =', + ' Contract extends { readonly result: infer Result } ? Result', + ' : Contract extends { readonly component: unknown } ? undefined', + ' : never;', '', 'export interface AgentBundleRoutes {', ...routes.map((route, index) => @@ -105,8 +146,13 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { 'export type RouteId = keyof AgentBundleRoutes;', 'export type RouteInput = ContractInput;', 'export type RouteResult = ContractResult;', + '/** The registered harness contract map: one `{ input, result }` per route id, for `@agent-bundle/runtime`\'s `Register`. */', + 'export type AgentBundleRouteContracts = {', + ' readonly [Id in RouteId]: Readonly<{ input: HarnessInput; result: HarnessResult }>;', + '};', '', ...providerDeclarations(providers), + ...runtimeAugmentation(routes, providers), ].join('\n'); }; diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index df295a33d..3bfb6ee68 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -71,6 +71,9 @@ export type { RenderRouteTarget, RenderedRoute, RenderedRouteEvents, + RouteTargetInput, + RouteTargetConstraint, + RouteTargetResult, } from './render.ts'; export { expectDocument } from './matchers.ts'; export type { diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 1a9d0b5ae..1432b704b 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -17,6 +17,9 @@ import type { AgentRenderInvocation, AgentRenderLimits, AgentRequestInit, + RegisteredRouteId, + RegisteredRouteInput, + RegisteredRouteResult, } from '@agent-bundle/runtime'; import type * as React from 'react'; @@ -76,11 +79,39 @@ export type RenderRouteContext = Omit = Target extends AgentRouteModule + ? AgentRouteModule + : string extends Target ? string : RegisteredRouteId; + + +/** The registered input type of a route target; `unknown` for a module target, a dynamic string, or an unregistered project. */ +export type RouteTargetInput = Target extends RegisteredRouteId ? RegisteredRouteInput : unknown; + +/** The registered result type of a route target; `unknown` for a module target, a dynamic string, or an unregistered project. */ +export type RouteTargetResult = Target extends RegisteredRouteId ? RegisteredRouteResult : unknown; + +export interface RenderRouteOptionsBase { /** CLI route arguments; `cli` routes only. */ readonly args?: readonly string[]; - /** The route's input: tool input, event payload, or script input. */ - readonly input?: unknown; + /** The route's input: tool input, event payload, or script input — typed from the route's own schema once the id is registered. */ + readonly input?: RouteTargetInput; /** Overrides the route kind when a module is rendered directly; ignored for manifest routes. */ readonly kind?: RenderableRouteKind; readonly limits?: Partial; @@ -91,7 +122,7 @@ export interface RenderRouteOptionsBase { readonly signal?: AbortSignal; } -export type RenderRouteOptions = RenderRouteOptionsBase & RenderRouteContextInit; +export type RenderRouteOptions = RenderRouteOptionsBase & RenderRouteContextInit; /** * The trailing options parameter of every harness entry point. It is always @@ -100,19 +131,17 @@ export type RenderRouteOptions = RenderRouteOptionsBase & RenderRouteContextInit */ export type HarnessOptionsArguments = readonly [options?: Options]; -export interface RenderedRoute { +export interface RenderedRoute { /** The final Agent Document the real renderer produced. */ readonly document: AgentDocument; readonly invocation: AgentRenderInvocation; - /** The document value parsed by the route's own `resultSchema`; absent when the module exports none. */ - readonly result?: unknown; + /** The document value parsed by the route's own `resultSchema`, typed from that schema once the id is registered; absent when the module exports none. */ + readonly result?: RouteTargetResult; /** Request-scoped progress the route reported. These are not render events; the final-only dispatcher emits no event stream. */ readonly progress: readonly AgentProgressUpdate[]; readonly provenance: RenderedRouteProvenance; } -export type RenderRouteTarget = AgentRouteModule | string; - interface Renderer { readonly available: typeof AgentRuntime.available; readonly createAgentRenderDispatcher: typeof AgentRuntime.createAgentRenderDispatcher; @@ -1128,10 +1157,10 @@ const renderFailure = ( * This is the route-unit proof level: no transport is opened, no browser * surface is compiled, and no host artifact is built. */ -export const renderRoute = async ( - target: RenderRouteTarget, - ...[options = {}]: HarnessOptionsArguments -): Promise => { +export const renderRoute = async ( + target: (Target & RouteTargetConstraint) | RouteTargetConstraint, + ...[options = {}]: HarnessOptionsArguments> +): Promise> => { const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options); try { const document = await dispatcher.dispatch({ invocation, signal }); @@ -1142,7 +1171,8 @@ export const renderRoute = async ( provenance: resolved.provenance, ...(resolved.module.resultSchema === undefined ? {} - : { result: parsedResult(resolved.module.resultSchema, document, resolved.provenance) }), + // The value was parsed by the same `resultSchema` the registration types it from. + : { result: parsedResult(resolved.module.resultSchema, document, resolved.provenance) as RouteTargetResult }), }); } catch (error) { throw renderFailure(error, invocation, resolved.provenance); @@ -1151,7 +1181,7 @@ export const renderRoute = async ( } }; -export interface RenderedRouteEvents extends RenderedRoute { +export interface RenderedRouteEvents extends RenderedRoute { /** Every render event the runtime emitted, in the order it emitted them. */ readonly events: readonly AgentRenderEvent[]; } @@ -1163,10 +1193,10 @@ export interface RenderedRouteEvents extends RenderedRoute { * {@link renderRoute}: this drains the dispatcher's public event stream * rather than its final-only entry point. */ -export const renderRouteEvents = async ( - target: RenderRouteTarget, - ...[options = {}]: HarnessOptionsArguments -): Promise => { +export const renderRouteEvents = async ( + target: (Target & RouteTargetConstraint) | RouteTargetConstraint, + ...[options = {}]: HarnessOptionsArguments> +): Promise> => { const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options); const events: AgentRenderEvent[] = []; const reader = dispatcher.stream({ invocation, signal }).getReader(); @@ -1199,6 +1229,6 @@ export const renderRouteEvents = async ( provenance: resolved.provenance, ...(resolved.module.resultSchema === undefined ? {} - : { result: parsedResult(resolved.module.resultSchema, complete.document, resolved.provenance) }), + : { result: parsedResult(resolved.module.resultSchema, complete.document, resolved.provenance) as RouteTargetResult }), }); }; diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index b6bf8f50d..b9dde67ac 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -1348,9 +1348,14 @@ it('generates deterministic route-specific types from the compiled graph', () => expect(first).toContain('type ContractResult ='); expect(first).toContain('export type RouteInput = ContractInput;'); expect(first).toContain('export type RouteResult = ContractResult;'); - // A provider-free graph declares no provider surface and never augments the runtime. + // The registered map is the harness contract: an event route registers its `{ canonical, native }` payload and no result. + expect(first).toContain("type HarnessInput =\n Contract extends { readonly input: infer Input } ? Input\n : Contract extends { readonly component: infer Component } ? Omit, 'signal'>\n : never;"); + expect(first).toContain('type HarnessResult =\n Contract extends { readonly result: infer Result } ? Result\n : Contract extends { readonly component: unknown } ? undefined\n : never;'); + expect(first).toContain('export type AgentBundleRouteContracts = {\n readonly [Id in RouteId]: Readonly<{ input: HarnessInput; result: HarnessResult }>;\n};'); + // A provider-free graph declares no provider surface; the runtime augmentation carries only the route registration. expect(first).not.toContain('AgentBundleProviders'); - expect(first).not.toContain("declare module '@agent-bundle/runtime'"); + expect(first).not.toContain('AgentProviderValues'); + expect(first).toContain("declare module '@agent-bundle/runtime' {\n interface Register {\n readonly routes: AgentBundleRouteContracts;\n }\n}"); }); it('generates provider declarations and the runtime augmentation in execution order', () => { @@ -1399,7 +1404,9 @@ it('generates provider declarations and the runtime augmentation in execution or expect(first).toContain(' readonly "zeta": ProviderValueOf;'); expect(first).toContain('export type ProviderKey = keyof AgentBundleProviders;'); expect(first).toContain('export type ProviderValue = AgentBundleProviders[Key];'); - expect(first).toContain("declare module '@agent-bundle/runtime' {\n interface AgentProviderValues {\n readonly \"projectAuth\": ProviderValueOf;\n readonly \"zeta\": ProviderValueOf;\n }\n}"); + // One augmentation block registers routes and declares providers together. + expect(first).toContain("declare module '@agent-bundle/runtime' {\n interface Register {\n readonly routes: AgentBundleRouteContracts;\n }\n interface AgentProviderValues {\n readonly \"projectAuth\": ProviderValueOf;\n readonly \"zeta\": ProviderValueOf;\n }\n}"); + expect(first.match(/declare module '@agent-bundle\/runtime'/g)).toHaveLength(1); expect(first.indexOf('AgentBundleRoutes')).toBeLessThan(first.indexOf('AgentBundleProviders')); }); @@ -1433,7 +1440,10 @@ it('resolves generated helper types for schema and event route contracts', async expect(graph.diagnostics).toEqual([]); await writeTree(root, { '.agent-bundle/routes.d.ts': routesModule.generateRouteTypes(graph), + // A stand-in for the runtime's empty `Register`, so the augmentation has a declaration to merge into. + 'runtime-stub.d.ts': 'export interface Register {}\n', 'assertions.ts': [ + "import type { Register } from '@agent-bundle/runtime';", "import type { RouteId, RouteInput, RouteResult } from './.agent-bundle/routes.js';", "import type { WorkspaceOpenInput, WorkspaceOpenResult } from './src/events/workspace/open.js';", "import type { InspectInput, InspectResult } from './src/mcp/curator/tools/inspect.js';", @@ -1449,6 +1459,12 @@ it('resolves generated helper types for schema and event route contracts', async "export type EventResult = Assert, WorkspaceOpenResult>>;", 'export type AllInputs = Assert, InspectInput | WorkspaceOpenInput>>;', 'export type AllResults = Assert, InspectResult | WorkspaceOpenResult>>;', + '// The augmentation registers the same contracts on the runtime, keyed by route id.', + "export type RegisteredIds = Assert>;", + "export type RegisteredInspect = Assert>>;", + '// An event route registers the harness payload (props without the signal the harness injects) and no result.', + "export type RegisteredEvent = Assert; result: undefined }>>>;", + "export type RegisteredEventInput = Assert>;", '', ].join('\n'), }); @@ -1457,6 +1473,7 @@ it('resolves generated helper types for schema and event route contracts', async module: ts.ModuleKind.NodeNext, moduleResolution: ts.ModuleResolutionKind.NodeNext, noEmit: true, + paths: { '@agent-bundle/runtime': [join(root, 'runtime-stub.d.ts')] }, skipLibCheck: false, strict: true, target: ts.ScriptTarget.ES2022, diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts new file mode 100644 index 000000000..214b9dfd1 --- /dev/null +++ b/packages/agent-bundle/tests/route-register-typegen.test.ts @@ -0,0 +1,189 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; +import ts from 'typescript-5'; + +import { inspect } from '../src/api.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +/** + * Type-checks one fixture entry against the real published `agent-bundle/test` + * and `@agent-bundle/runtime` declarations. `registered` adds the generated + * `.agent-bundle/routes.d.ts` to the program the way a project's `tsconfig.json` + * `include` would; omitting it is the degraded, unregistered program. + */ +const typecheck = (root: string, entry: string, registered: boolean): readonly string[] => { + const program = ts.createProgram( + [join(root, entry), ...(registered ? [join(root, '.agent-bundle', 'routes.d.ts')] : [])], + { + exactOptionalPropertyTypes: true, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + }, + ); + return ts.getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); +}; + +const equalityHelpers = [ + 'type Equal =', + ' (() => Value extends Left ? 1 : 2) extends', + ' (() => Value extends Right ? 1 : 2) ? true : false;', + 'type Assert = Value;', +]; + +/** + * The generated declarations register the project's route contracts on + * `@agent-bundle/runtime`'s `Register` (TanStack Router's registration + * pattern), so `renderRoute` narrows its id, `input`, and `result` from the + * route modules' own schemas with no per-route declaration file — and the + * same program without the generated file degrades to `string` / `unknown`. + */ +it('types renderRoute ids, inputs, and results from the generated route registration', { timeout: 60_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-register-')); + roots.push(root); + // The audiobook example's installed tree supplies the built agent-bundle, @agent-bundle/runtime, and zod. + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { '@agent-bundle/runtime': 'workspace:*', 'agent-bundle': 'workspace:*', zod: '4.4.3' }, + name: 'route-register-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { name: 'route-register-fixture', version: '1.0.0' },", + " targets: ['claude'],", + '});', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/curator/tools/status.ts', [ + "import { z } from 'zod';", + 'export const inputSchema = z.object({}).strict();', + "export const resultSchema = z.object({ status: z.literal('ready') }).strict();", + "export default async function Status() { return { status: 'ready' as const }; }", + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/curator/tools/find.ts', [ + "import { z } from 'zod';", + 'export const inputSchema = z.object({ query: z.string() }).strict();', + 'export const resultSchema = z.object({ hits: z.number() }).strict();', + 'export default async function Find() { return { hits: 1 }; }', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/tool/after.ts', [ + "import type { AgentEventRouteProps } from 'agent-bundle';", + 'export default async function ToolAfter(props: AgentEventRouteProps) { return props.canonical.event; }', + '', + ].join('\n')), + writeProjectFile(root, 'assertions.ts', [ + "import type { AgentEventCanonicalIdentity, AgentEventNativePayload } from 'agent-bundle';", + "import type { RegisteredRouteId, RegisteredRouteInput, RegisteredRouteResult } from '@agent-bundle/runtime';", + "import { renderRoute, renderRouteEvents } from 'agent-bundle/test';", + '', + ...equalityHelpers, + '', + "export type Ids = Assert>;", + "export type FindInput = Assert, { query: string }>>;", + "export type FindResult = Assert, { hits: number }>>;", + "export type Unregistered = Assert, unknown>>;", + '// An event route registers the harness payload — the component props without the `signal` the harness', + '// injects — and no result, since event modules export no resultSchema.', + "export type EventInput = Assert, 'canonical' | 'native'>>;", + "export type EventCanonical = Assert['canonical'], AgentEventCanonicalIdentity>>;", + "export type EventNative = Assert['native'], AgentEventNativePayload>>;", + "export type EventResult = Assert, undefined>>;", + '', + 'export const typed = async (canonical: AgentEventCanonicalIdentity, native: AgentEventNativePayload): Promise => {', + " const found = await renderRoute('tool:curator/find', { input: { query: 'dune' } });", + ' // `result` is the route\'s own resultSchema output, no cast.', + ' const hits: number | undefined = found.result?.hits;', + " const streamed = await renderRouteEvents('tool:curator/status');", + " const status: 'ready' | undefined = streamed.result?.status;", + ' // A valid event-route call carries exactly `{ canonical, native }`; the harness supplies the signal.', + " const after = await renderRoute('event:tool/after', { input: { canonical, native } });", + ' const none: undefined = after.result;', + ' // A value typed string stays legal for dynamic lookups and observes unknown.', + " const dynamic: string = ['tool:curator/status'].join('');", + ' const loose = await renderRoute(dynamic);', + ' const anything: unknown = loose.result;', + ' void hits; void status; void none; void anything;', + '};', + '', + ].join('\n')), + writeProjectFile(root, 'wrong-event-input.ts', [ + "import { renderRoute } from 'agent-bundle/test';", + "export const mistyped = renderRoute('event:tool/after', { input: { canonical: 'tool/after', native: {} } });", + '', + ].join('\n')), + writeProjectFile(root, 'wrong-id.ts', [ + "import { renderRoute } from 'agent-bundle/test';", + "export const missing = renderRoute('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 } });", + '', + ].join('\n')), + writeProjectFile(root, 'wrong-result.ts', [ + "import { renderRoute } from 'agent-bundle/test';", + "export const narrowed = async (): Promise => (await renderRoute('tool:curator/find')).result?.hits;", + '', + ].join('\n')), + writeProjectFile(root, 'unregistered.ts', [ + "import type { RegisteredRouteId, RegisteredRouteResult } from '@agent-bundle/runtime';", + "import { renderRoute } from 'agent-bundle/test';", + '', + ...equalityHelpers, + '', + '// Without the generated file in the program, ids are string and contracts are unknown.', + 'export type Ids = Assert>;', + "export type Result = Assert, unknown>>;", + "export const anyId = renderRoute('tool:curator/missing', { input: { query: 7 } });", + '', + ].join('\n')), + ]); + + const result = await inspect({ root }); + expect(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`)).toEqual([]); + expect(result.state).toBe('ready'); + const declarations = await readFile(join(root, '.agent-bundle', 'routes.d.ts'), 'utf8'); + expect(declarations).toContain("declare module '@agent-bundle/runtime' {\n interface Register {\n readonly routes: AgentBundleRouteContracts;\n }\n}"); + + expect(typecheck(root, 'assertions.ts', true)).toEqual([]); + const wrongId = typecheck(root, 'wrong-id.ts', true); + 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 \'"event:tool/after" | "tool:curator/find" | "tool:curator/status"\''); + const wrongInput = typecheck(root, 'wrong-input.ts', true); + expect(wrongInput).toHaveLength(1); + expect(wrongInput[0]).toContain("Type 'number' is not assignable to type 'string'"); + const wrongEventInput = typecheck(root, 'wrong-event-input.ts', true); + expect(wrongEventInput).toHaveLength(1); + expect(wrongEventInput[0]).toContain("Type 'string' is not assignable to type 'AgentEventCanonicalIdentity'"); + const wrongResult = typecheck(root, 'wrong-result.ts', true); + expect(wrongResult).toHaveLength(1); + expect(wrongResult[0]).toContain("Type 'number | undefined' is not assignable to type 'string | undefined'"); + + expect(typecheck(root, 'unregistered.ts', false)).toEqual([]); +}); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index ea48d0132..64881b346 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -182,6 +182,47 @@ export interface AgentProviderValues { readonly processLifetime?: AgentProcessLifetime; } +/** + * The project-registration seam, after TanStack Router's `Register`. It is + * empty here; the compiler's generated `.agent-bundle/routes.d.ts` augments it + * with `routes: AgentBundleRouteContracts` — a thin `{ input, result }` map + * keyed by route id: what the `agent-bundle/test` harness accepts and returns + * for that route, inferred from each schema route's own `inputSchema` and + * `resultSchema`, and for an event route its `{ canonical, native }` payload + * with an `undefined` result — in the same `declare module + * '@agent-bundle/runtime'` block that declares + * provider keys on {@link AgentProviderValues}. Route-aware types such as + * {@link RegisteredRouteId} read through it and degrade to their unregistered + * shape (`string`, `unknown`) when the file is absent or excluded from the + * program, so nothing here is required for a project to type-check. + */ +// rslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration-merge extension point +export interface Register {} + +/** One registered route's harness contract: the `input` a render accepts and the `result` it returns (`undefined` for routes without a `resultSchema`). */ +export interface RegisteredRouteContract { + readonly input: unknown; + readonly result: unknown; +} + +/** Every registered route contract keyed by route id; `unknown` until a project registers. */ +export type RegisteredRoutes = Register extends { readonly routes: infer Routes extends Record } + ? Routes + : unknown; + +/** The registered route ids, or `string` when no project has registered. */ +export type RegisteredRouteId = unknown extends RegisteredRoutes ? string : keyof RegisteredRoutes & string; + +/** The registered input type for one route id; `unknown` for an unregistered id. */ +export type RegisteredRouteInput = Id extends keyof RegisteredRoutes + ? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['input'] : unknown + : unknown; + +/** The registered result type for one route id; `unknown` for an unregistered id. */ +export type RegisteredRouteResult = Id extends keyof RegisteredRoutes + ? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['result'] : unknown + : unknown; + export interface AgentInvocation { readonly artifactEpoch?: string; readonly hostContractRevision?: string; diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index 478505ae4..a1f7f658e 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -28,6 +28,12 @@ export type { AgentProjectRootAuthority, AgentRenderInvocation, AgentProviderValues, + Register, + RegisteredRouteContract, + RegisteredRouteId, + RegisteredRouteInput, + RegisteredRouteResult, + RegisteredRoutes, AgentRequestCapabilities, AgentRequestContext, AgentRequestErrorCode, diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 858a59293..c1193d30e 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -56,6 +56,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/prepack.test.ts', 'packages/agent-bundle/tests/provider-typegen.test.ts', 'packages/agent-bundle/tests/public-api.test.ts', + 'packages/agent-bundle/tests/route-register-typegen.test.ts', 'packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts', 'packages/agent-bundle/tests/rstest-meta-consumer.test.ts', 'packages/agent-bundle/tests/script-playground-service.test.ts', diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index bc4821194..c4c91a03f 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -56,6 +56,32 @@ request-scoped progress the route reported, the resolved provenance, and the rou `resultSchema`-parsed value. Progress is recorded whether or not the caller supplies a reporter of its own. +### Typed route ids, inputs, and results + +The compiler's generated `.agent-bundle/routes.d.ts` registers the project's route contracts on +`@agent-bundle/runtime`'s `Register` interface, the way it already declares provider keys. Once +that file is part of the project's TypeScript program (add `".agent-bundle/routes.d.ts"` to +`tsconfig.json` `include`), a `renderRoute` call written with a string literal is checked against +the compiled route ids — the editor completes them, and a typo is rejected naming the registered +alternatives — while `input` and `result` come from that route's own `inputSchema` and +`resultSchema`: + +```ts +const { result } = await renderRoute('tool:library/summarize', { input: { title: 'Dune' } }); +const chapters: number | undefined = result?.chapters; // no cast +``` + +An event route registers what the harness actually takes and gives back: `input` is the +`{ canonical, native }` payload (the harness supplies `signal` itself), and `result` is +`undefined`, since event modules export no `resultSchema`. + +Nothing about it is required. A target typed `string` rather than a literal stays legal for +dynamic lookups, a directly imported module target is unaffected, and a project that does not +include the generated file — or has not run a build or `agent-bundle dev` yet — sees the previous +types: any string id, `unknown` input, `unknown` result. `RegisteredRouteId`, +`RegisteredRouteInput`, and `RegisteredRouteResult` from `@agent-bundle/runtime` name the +registered surface directly, for a wrapper of your 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 diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index 5f7c40977..677ff51f2 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -49,6 +49,28 @@ export const summarizes = async (): Promise => { 上报器)、渲染 `limits` 以及一个 `signal`。它返回文档、路由上报的请求作用域进度、解析出的 provenance, 以及由路由自己的 `resultSchema` 解析后的取值。无论调用方是否提供自己的上报器,进度都会被记录。 +### 带类型的 route id、输入与结果 + +编译器生成的 `.agent-bundle/routes.d.ts` 会把项目的路由契约注册到 `@agent-bundle/runtime` 的 +`Register` 接口上——与它已经声明 provider 键的方式相同。一旦该文件成为项目 TypeScript 程序的一部分 +(把 `".agent-bundle/routes.d.ts"` 加入 `tsconfig.json` 的 `include`),用字符串字面量写出的 +`renderRoute` 调用就会针对编译后的 route id 做检查——编辑器会补全它们,写错时会被拒绝并列出已注册的 +备选项——而 `input` 与 `result` 来自该路由自己的 `inputSchema` 与 `resultSchema`: + +```ts +const { result } = await renderRoute('tool:library/summarize', { input: { title: 'Dune' } }); +const chapters: number | undefined = result?.chapters; // 无需强制类型转换 +``` + +事件路由注册的是测试工具实际接受与返回的内容:`input` 是 `{ canonical, native }` 载荷(`signal` 由测试 +工具自行提供),而 `result` 为 `undefined`,因为事件模块不导出 `resultSchema`。 + +这一切都不是必需的。目标若类型为 `string` 而非字面量,仍然合法,可用于动态查找;直接导入模块作为目标 +不受影响;而没有把生成文件纳入程序的项目——或尚未运行过一次构建或 `agent-bundle dev` 的项目——看到的 +仍是先前的类型:任意字符串 id、`unknown` 输入、`unknown` 结果。`@agent-bundle/runtime` 的 +`RegisteredRouteId`、`RegisteredRouteInput` 与 `RegisteredRouteResult` 直接命名这一注册表面,便于 +你自己封装。 + `testManifest()` 暴露编译后的路由清单,因此一个测试套件可以在进程内遍历每个路由,而不必为每个路由付出 一次构建的代价。任何失败——未知路由、被拒绝的路由种类、被拒的输入、渲染错误——都会指明 route id、target 种类与模块 provenance。