From 12aaddb66d4f29b0c2d119ba0c2601c6a214c53a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 23:36:27 +0000 Subject: [PATCH 1/9] feat(routes): type callers by schema input, judge AB4834 per consuming program (#748, #752) --- .../748-752-route-caller-input-types.md | 6 + docs/diagnostics.md | 21 +- docs/entry-conventions.md | 8 +- packages/agent-bundle/src/app/index.ts | 2 + .../src/routes/typegen-program.ts | 96 ++++--- packages/agent-bundle/src/routes/typegen.ts | 16 +- packages/agent-bundle/src/test/render.ts | 49 +++- .../tests/route-caller-input-types.test.ts | 248 ++++++++++++++++++ .../agent-bundle/tests/route-graph.test.ts | 8 +- .../tests/route-register-typegen.test.ts | 5 +- .../tests/route-types-program.test.ts | 58 +++- .../tests/route-unit/render-route.test.ts | 15 ++ .../templates/cli-tool/README.md | 3 +- .../templates/cli-tool/package_json | 4 +- .../templates/mcp-server/README.md | 3 +- .../templates/mcp-server/package_json | 4 +- rstest.integration-tests.ts | 1 + website/docs/en/guide/authoring/mcp.mdx | 14 +- website/docs/en/guide/development/index.mdx | 12 +- website/docs/en/guide/development/testing.mdx | 13 +- website/docs/zh/guide/authoring/mcp.mdx | 11 +- website/docs/zh/guide/development/index.mdx | 9 +- website/docs/zh/guide/development/testing.mdx | 11 +- 23 files changed, 538 insertions(+), 79 deletions(-) create mode 100644 .changeset/748-752-route-caller-input-types.md create mode 100644 packages/agent-bundle/tests/route-caller-input-types.test.ts diff --git a/.changeset/748-752-route-caller-input-types.md b/.changeset/748-752-route-caller-input-types.md new file mode 100644 index 000000000..f10038db2 --- /dev/null +++ b/.changeset/748-752-route-caller-input-types.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"create-agent-bundle": patch +--- + +Type route callers by schema input and route components by schema output in the generated `.agent-bundle/routes.d.ts`: `createAppClient().call`, `onToolInput`, `renderRoute`, `invokeMcpTool`, and the contract matrix accept what a caller sends (a `.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it), while `ToolRouteProps` keeps the parsed output. A structural schema declaring only `_output` uses it for both. `renderRoute` now parses its input through the route's own `inputSchema` before the component runs and fails with an `invalid-input` harness error on rejected input. `agent-bundle validate` reports `AB4834` once per TypeScript program that imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` and omits the generated declaration — following `references` transitively — instead of accepting any one referenced program that includes it. The `mcp-server` and `cli-tool` starters run `agent-bundle validate` inside `npm run typecheck`, so a clean checkout type-checks against current route declarations. (#PR) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 14cc2b6ce..f438d977b 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1072,13 +1072,18 @@ it: `create-agent-bundle` templates and the `examples/*` projects list because `**/*` never descends into dot-directories), while the file itself stays gitignored. After publishing the declaration, `agent-bundle validate` resolves the root `tsconfig.json` program the way `tsc -p` does — `extends`, -`files`, `include`, `exclude`, and one level of `references` for a -solution-style root — and reports `AB4834` (a **warning**, surfaced by -`validate` only) when the published file is not among its root files. A -project with no root `tsconfig.json`, no published declaration (route-free -and provider-free), or a `tsconfig.json` TypeScript cannot parse gets no -diagnostic: there is no program to be missing from, or `tsc` already -reports the parse failure itself. +`files`, `include`, `exclude` — and every program it `references`, +transitively, and reports `AB4834` (a **warning**, surfaced by `validate` +only) once per program that *consumes* the registration but does not compile +the published file. A program consumes it when one of its source files +imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or +`@agent-bundle/runtime`; a build-only project that imports none of them is +left alone, and a solution whose server project includes the file cannot +hide a browser project that omits it. A project with no root +`tsconfig.json`, no published declaration (route-free and provider-free), or +a `tsconfig.json` TypeScript cannot parse gets no diagnostic: there is no +program to be missing from, or `tsc` already reports the parse failure +itself. Conventional `src/scripts/` routes ship through the same pipeline as explicit `scripts` entries (#102 stage 1): a plain module directly under @@ -1312,7 +1317,7 @@ resolving a provider set the author did not write. | `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. | | `AB4832` | error | A server layout (`src/mcp//layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. | | `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). | -| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. | +| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but a TypeScript program that consumes the registration — the root `tsconfig.json` or any project it `references`, transitively, resolved like `tsc -p` with `extends`, whose source imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` — does not compile it, so that program type-checks route ids as `string` and `input` / `result` / provider values as `unknown`. Reported once per such program, on its tsconfig; never for a program that imports none of those modules, nor for a project without a root `tsconfig.json`. Add the file to that tsconfig's `include` (not `files`: an `include` entry is inert until the first `validate` publishes the file, while a missing `files` entry is a `tsc` error); `validate`, `build`, and `dev` keep the file current and it stays gitignored. | | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | | `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Keep framework calls in a host process: expose an MCP App with `web.apps` and open it from the installed artifact with ` web`; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/web-host`. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 380355e0b..dbc528c6a 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1814,8 +1814,12 @@ injected ports. `generateRouteTypes` (`src/routes/typegen.ts`) emits, for a graph with at least one tool route, `AppToolRouteId` (the `tool:/` subset of `RouteId`), `AgentBundleAppRouteContracts` (one `{ input: RouteInput; -result: RouteResult }` per tool from the module's own `inputSchema` and -`resultSchema` output), and exactly one augmentation: +result: RouteResult }` per tool — `input` from the module's own +`inputSchema` *input* type, what a caller sends before the server parses it, +so a defaulted field is optional and a transformed field is spelled as the +wire carries it; `result` from the `resultSchema` output; a structural schema +declaring only `_output` uses it for both, #752), and exactly one +augmentation: ```ts declare module 'agent-bundle/app' { diff --git a/packages/agent-bundle/src/app/index.ts b/packages/agent-bundle/src/app/index.ts index 05afde5ca..9783d34f5 100644 --- a/packages/agent-bundle/src/app/index.ts +++ b/packages/agent-bundle/src/app/index.ts @@ -62,10 +62,12 @@ export type AppRouteId = unknown extends AppRoutes ? `tool:${string}/${string}` : Extract & string; +/** What a caller sends for one route: the registered `inputSchema` input type (defaults optional, transforms as the wire carries them), before the server parses it. */ export type AppRouteInput = Id extends keyof AppRoutes ? AppRoutes[Id] extends AppRouteContract ? AppRoutes[Id]['input'] : unknown : unknown; +/** The structured result one route resolves with: the registered `resultSchema` output. */ export type AppRouteResult = Id extends keyof AppRoutes ? AppRoutes[Id] extends AppRouteContract ? AppRoutes[Id]['result'] : unknown : unknown; diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts index c6ddc8730..48d24ec7c 100644 --- a/packages/agent-bundle/src/routes/typegen-program.ts +++ b/packages/agent-bundle/src/routes/typegen-program.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; // Aliased: the workspace toolchain is typescript@7 (native compiler, no // single-file parse API), and a plain `typescript` dependency here would @@ -13,11 +13,27 @@ import { routeTypesRelativePath } from './typegen.ts'; /** The file `tsc -p` and every editor read as the project's TypeScript program. */ const projectTsconfigFilename = 'tsconfig.json'; +/** + * The modules whose declarations the generated file augments or narrows: + * a program that imports one of them observes the registration, and one that + * omits the file type-checks those imports with `string` ids and `unknown` + * input/result/provider values, silently. Route modules import + * `@agent-bundle/runtime` (providers), App views `agent-bundle/app`, tests + * `agent-bundle/test` and `agent-bundle/eval`. + */ +const consumerImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"](?:agent-bundle\/(?:app|eval|test)|@agent-bundle\/runtime)(?:\/[^'"]*)?['"]/; + const comparablePath = (path: string): string => { const resolved = resolve(path); return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase(); }; +interface Program { + readonly fileNames: readonly string[]; + readonly references: readonly string[]; + readonly tsconfigPath: string; +} + /** * The root file names of one tsconfig's program, resolved the way `tsc -p` * resolves them (`extends`, `files`, `include`, `exclude`, against the real @@ -26,9 +42,7 @@ const comparablePath = (path: string): string => { * reports that failure itself, and a broken tsconfig has no program to be * missing from. */ -const programRootFiles = ( - tsconfigPath: string, -): { readonly fileNames: readonly string[]; readonly references: readonly string[] } | undefined => { +const program = (tsconfigPath: string): Program | undefined => { const read = ts.readConfigFile(tsconfigPath, ts.sys.readFile); if (read.error !== undefined || read.config === undefined) return undefined; const parsed = ts.parseJsonConfigFileContent( @@ -41,39 +55,61 @@ const programRootFiles = ( return { fileNames: parsed.fileNames, references: (parsed.projectReferences ?? []).map((reference) => ts.resolveProjectReferencePath(reference)), + tsconfigPath, }; }; +/** The root program and every program it references, transitively, each once. */ +const programs = (rootTsconfigPath: string): readonly Program[] => { + const seen = new Set(); + const found: Program[] = []; + const visit = (tsconfigPath: string): void => { + const key = comparablePath(tsconfigPath); + if (seen.has(key) || !existsSync(tsconfigPath)) return; + seen.add(key); + const resolved = program(tsconfigPath); + if (resolved === undefined) return; + found.push(resolved); + resolved.references.forEach(visit); + }; + visit(rootTsconfigPath); + return found; +}; + +/** Whether one of the program's own source files imports a module the generated declaration augments. */ +const consumesRegistration = (fileNames: readonly string[]): boolean => + fileNames.some((fileName) => !fileName.endsWith('.d.ts') && consumerImport.test(ts.sys.readFile(fileName) ?? '')); + /** * AB4834: the generated `.agent-bundle/routes.d.ts` registers the project's - * route contracts and provider keys on `@agent-bundle/runtime`, but only a - * TypeScript program that compiles the file observes them — a program that - * leaves it out type-checks `renderRoute` ids as `string` and `input` / - * `result` as `unknown`, silently. Reported once the declaration has been - * published (a route-free, provider-free project has no file to include) for a - * project whose root `tsconfig.json` program — its own root files or, for a - * solution-style root, one of its referenced projects — does not compile it. - * A project without a root `tsconfig.json` has no program to check. + * route contracts and provider keys on `@agent-bundle/runtime`, + * `agent-bundle/app`, and the harness modules, but only a TypeScript program + * that compiles the file observes them. Reported once the declaration has + * been published (a route-free, provider-free project has no file to include) + * for every program of the root `tsconfig.json` — its own or, for a + * solution-style root, any project it references, transitively — that + * imports one of those modules and does not compile the file. A program that + * imports none of them is not a consumer and is left alone, and a project + * without a root `tsconfig.json` has no program to check. */ export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diagnostic[] => { const routeTypesPath = join(projectRoot, routeTypesRelativePath); - const tsconfigPath = join(projectRoot, projectTsconfigFilename); - if (!existsSync(routeTypesPath) || !existsSync(tsconfigPath)) return []; - const root = programRootFiles(tsconfigPath); - if (root === undefined) return []; + const rootTsconfigPath = join(projectRoot, projectTsconfigFilename); + if (!existsSync(routeTypesPath) || !existsSync(rootTsconfigPath)) return []; const expected = comparablePath(routeTypesPath); - const compiles = (fileNames: readonly string[]): boolean => - fileNames.some((fileName) => comparablePath(fileName) === expected); - if (compiles(root.fileNames)) return []; - for (const reference of root.references) { - const referenced = existsSync(reference) ? programRootFiles(reference) : undefined; - if (referenced !== undefined && compiles(referenced.fileNames)) return []; - } - return [{ - code: 'AB4834', - message: `${projectTsconfigFilename} does not include the generated ${routeTypesRelativePath}, so renderRoute and renderRouteEvents type-check route ids as string and input/result as unknown.`, - recovery: `Add ${JSON.stringify(routeTypesRelativePath)} to the "include" array of ${projectTsconfigFilename}; agent-bundle build, dev, and validate keep the file current, and it stays gitignored.`, - severity: 'warning', - sourcePath: tsconfigPath, - }]; + return programs(rootTsconfigPath) + .filter((candidate) => + !candidate.fileNames.some((fileName) => comparablePath(fileName) === expected) + && consumesRegistration(candidate.fileNames)) + .map((candidate) => { + const tsconfig = relative(projectRoot, candidate.tsconfigPath).replaceAll('\\', '/'); + const include = relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/'); + return { + code: 'AB4834', + message: `${tsconfig} imports agent-bundle/app, agent-bundle/test, agent-bundle/eval, or @agent-bundle/runtime but does not include the generated ${routeTypesRelativePath}, so that program type-checks route ids as string and input/result/provider values as unknown.`, + recovery: `Add ${JSON.stringify(include)} to the "include" array of ${tsconfig}; agent-bundle validate, build, and dev keep the file current, and it stays gitignored.`, + severity: 'warning', + sourcePath: candidate.tsconfigPath, + }; + }); }; diff --git a/packages/agent-bundle/src/routes/typegen.ts b/packages/agent-bundle/src/routes/typegen.ts index a99635f89..cb84fd97c 100644 --- a/packages/agent-bundle/src/routes/typegen.ts +++ b/packages/agent-bundle/src/routes/typegen.ts @@ -75,8 +75,9 @@ const hasToolRoutes = (routes: readonly CompiledAgentRoute[]): boolean => * is the MCP tool subset of `AgentBundleRoutes` — the `tool:/` * ids filtered at the type level, so every route id is spelled once and the * contracts reuse the same type-only route module imports — one - * `{ input, result }` per tool from the module's own `inputSchema` and - * `resultSchema` output. The filter is inlined in the mapped type rather than + * `{ input, result }` per tool from the module's own `inputSchema` input + * (what a caller sends, before the server parses it) and `resultSchema` + * output. The filter is inlined in the mapped type rather than * named first so `keyof` the map (what an App client's route-id parameter * resolves to) prints as the tool ids in a rejection, not as an alias name. * Omitted for graphs without a tool route. @@ -99,7 +100,7 @@ const appDeclarations = (routes: readonly CompiledAgentRoute[]): readonly string * the tool contract map (the same `Register` pattern as the runtime * augmentation below), so `createAppClient().call(id, input)` narrows its * route id to the project's tools, `input` to that tool's `inputSchema` - * output, and its resolved value to the `resultSchema` output. Type-only: + * input, and its resolved value to the `resultSchema` output. Type-only: * the App bundle never loads a route module, Zod, or Node through it. * Omitted with the contract map for graphs without a tool route, so the * augmentation never references a module the project has no reason to @@ -123,7 +124,7 @@ const appAugmentation = (routes: readonly CompiledAgentRoute[]): readonly string * 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 + * modules — a schema route's `inputSchema` input and `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. @@ -166,8 +167,13 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { ...providers.map(providerImport), '', 'type SchemaOutput = Schema extends { readonly _output: infer Output } ? Output : never;', + '// A schema that declares `_input` (Zod) types a caller by it; a structural schema that declares only', + '// `_output` parses without defaults or transforms, so its output is also what a caller sends.', + 'type SchemaInput = Schema extends { readonly _input: infer Input } ? Input : SchemaOutput;', + '// `input` is what a caller sends — the schema\'s own input type, so a defaulted or transformed field is', + '// spelled the way the wire carries it; the route component receives the parsed output instead.', 'export type RouteContract = Readonly<{', - ' input: SchemaOutput;', + ' input: SchemaInput;', ' result: SchemaOutput;', '}>;', 'export type EventRouteContract = Readonly<{', diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 487e3938a..3cf5904ab 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -386,12 +386,13 @@ const componentProps = ( kind: RenderableRouteKind, options: RenderRouteOptions, signal: AbortSignal, + parsedInput: unknown, ): Readonly> => { switch (kind) { case 'prompt': case 'resource': case 'tool': - return { input: (invocation.props as { readonly input?: unknown }).input, signal }; + return { input: parsedInput, signal }; case 'event-route': { // The public event-route contract is `{ canonical, native, signal }`, // and the generated Flight worker unwraps the payload into exactly that. @@ -561,6 +562,16 @@ export interface RouteModuleSchema extends AgentRouteSchema { readonly parse: (value: unknown) => Value; } +/** + * A route's `inputSchema` as the harness sees it: the registration types what + * a caller sends (`RouteTargetInput`), which is the schema's input side; what + * `parse` returns — the component's props after defaults and transforms — is + * not part of the registration, so it stays `unknown` here. + */ +export interface RouteModuleInputSchema extends AgentRouteSchema { + readonly parse: (value: Value | unknown) => unknown; +} + /** * The evaluated module `loadRouteModule` returns: the same object the generated * server, the routed CLI, and `renderRoute` execute, so `inputSchema` and @@ -573,7 +584,7 @@ export interface LoadedRouteModule { readonly [exportName: string]: unknown; readonly config?: unknown; readonly default?: (props: never) => unknown; - readonly inputSchema?: RouteModuleSchema>; + readonly inputSchema?: RouteModuleInputSchema>; readonly resultSchema?: RouteModuleSchema>; } @@ -630,6 +641,35 @@ export const loadRouteModule = async ( * validated by the route's own `resultSchema`. A document that renders but * whose value the route's schema rejects is a route defect, not a pass. */ +/** + * The component's `input` prop: the caller's input parsed by the route's own + * `inputSchema`, exactly where the generated Flight worker parses it (defaults + * filled, transforms applied) — so the registration types what the caller + * sends and the component still sees the schema's output. Rejected before the + * request scope opens, so no provider or component runs on invalid input. + * A module without an `inputSchema` (a script) receives the input as given. + */ +const parsedInput = ( + schema: { readonly parse: (value: unknown) => unknown } | undefined, + input: unknown, + provenance: RenderedRouteProvenance, +): unknown => { + if (schema === undefined) return input; + try { + return schema.parse(input); + } catch (error) { + throw new AgentTestError('invalid-input', "The route's own inputSchema rejected the input.", { + cause: error, + details: [ + `cause: ${error instanceof Error ? error.message : String(error)}`, + `received: ${captured(input)}`, + ], + provenance, + recovery: 'Pass the input the route\'s inputSchema accepts; defaults and transforms are applied by the parse, not by the caller.', + }); + } +}; + const parsedResult = ( schema: { readonly parse: (value: unknown) => unknown }, document: AgentDocument, @@ -1434,6 +1474,9 @@ const prepareRender = async ( const renderer = await loadRenderer(); const surface = executableSurface(resolved.kind, resolved.provenance.routeId, resolved.manifest); const invocation = invocationFor(resolved.kind, resolved.provenance.routeId, surface, options, resolved.provenance); + const input = resolved.kind === 'prompt' || resolved.kind === 'resource' || resolved.kind === 'tool' + ? parsedInput(resolved.module.inputSchema, options.input ?? {}, resolved.provenance) + : undefined; const collected: AgentProgressUpdate[] = []; const context = options.context ?? {}; const signal = options.signal ?? new AbortController().signal; @@ -1445,7 +1488,7 @@ const prepareRender = async ( const dispatcher = createFlightDispatcher({ collected, component: resolved.component, - componentProps: (request) => componentProps(request.invocation, resolved.kind, options, request.signal), + componentProps: (request) => componentProps(request.invocation, resolved.kind, options, request.signal, input), contextProgress: context.progress, layoutRoute: { id: resolved.provenance.routeId, diff --git a/packages/agent-bundle/tests/route-caller-input-types.test.ts b/packages/agent-bundle/tests/route-caller-input-types.test.ts new file mode 100644 index 000000000..03c5760d2 --- /dev/null +++ b/packages/agent-bundle/tests/route-caller-input-types.test.ts @@ -0,0 +1,248 @@ +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +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 { build, validate } 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); +}; + +const equality = [ + 'type Equal =', + ' (() => Value extends Left ? 1 : 2) extends', + ' (() => Value extends Right ? 1 : 2) ? true : false;', + 'type Assert = Value;', +]; + +/** The tool records every invocation it actually ran, so a rejected input provably ran nothing. */ +const tool = (name: string, schema: string, result: string, body: string): string => [ + "import { Agent } from '@agent-bundle/runtime';", + "import type { ToolRouteProps } from 'agent-bundle';", + "import { appendFile } from 'node:fs/promises';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + `export const config = { description: '${name}' };`, + `export const inputSchema = ${schema};`, + `export const resultSchema = ${result};`, + `export default async function Route({ input }: ToolRouteProps) {`, + ` await appendFile(process.env['INVOCATIONS']!, JSON.stringify({ input, tool: '${name}' }) + '\\n');`, + ` const value = ${body};`, + " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, JSON.stringify(value)));", + '}', + '', +].join('\n'); + +const tsconfig = (include: readonly string[], lib: readonly string[]): string => `${JSON.stringify({ + compilerOptions: { + composite: true, + exactOptionalPropertyTypes: true, + lib, + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + skipLibCheck: true, + strict: true, + target: 'ES2022', + types: [], + }, + include, +}, null, 2)}\n`; + +/** + * Type-checks one of the project's real tsconfig programs — the file set + * `tsc -p ` compiles, resolved from the config on disk — optionally + * with one extra entry added the way another `include` line would add it. + */ +const typecheckProgram = (root: string, tsconfigPath: string, extraEntry?: string): readonly string[] => { + const read = ts.readConfigFile(join(root, tsconfigPath), ts.sys.readFile); + if (read.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n')); + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root, undefined, join(root, tsconfigPath)); + const program = ts.createProgram( + [...parsed.fileNames, ...(extraEntry === undefined ? [] : [join(root, extraEntry)])], + { ...parsed.options, composite: false, noEmit: true }, + ); + return ts.getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); +}; + +const callTool = async (client: Client, name: string, input: Record): Promise => { + try { + return await client.callTool({ arguments: input, name }, { signal: AbortSignal.timeout(10_000) }); + } catch (error) { + return error; + } +}; + +/** + * #752: the generated declarations type a caller by each schema's input and + * the component by its output, so a defaulted field is optional to the App + * and a transformed field is spelled as the wire carries it — proved in the + * project's own browser and server tsconfig programs, then at run time + * through the generated MCP server. #748: those programs are the ones + * `validate` judges for AB4834. + */ +it('types callers by schema input and components by schema output in a clean generated project', { timeout: 120_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-caller-input-types-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + const invocations = join(root, 'invocations.ndjson'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { '@agent-bundle/runtime': 'workspace:*', 'agent-bundle': 'workspace:*', react: '19.2.8', zod: '4.4.3' }, + name: 'caller-input-types-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'caller-input-types-fixture', version: '1.0.0' }, targets: ['portable'] });", + '', + ].join('\n')), + // A defaulted field: optional to the caller, present for the component. + writeProjectFile(root, 'src/mcp/curator/tools/page.ts', tool( + 'page', + 'z.object({ limit: z.number().default(10) }).strict()', + 'z.object({ limit: z.number() }).strict()', + '{ limit: input.limit }', + )), + // A transformed field: the caller sends the string the wire carries, the component receives the number. + writeProjectFile(root, 'src/mcp/curator/tools/measure.ts', tool( + 'measure', + 'z.object({ text: z.string().transform((value) => value.trim().length) }).strict()', + 'z.object({ length: z.number() }).strict()', + '{ length: input.text }', + )), + writeProjectFile(root, 'src/mcp/curator/apps/dashboard.html', '
\n'),
+    // The browser program: the App itself, compiled against the generated `AppRegister` augmentation.
+    writeProjectFile(root, 'src/mcp/curator/apps/dashboard.ts', [
+      "import { createAppClient, type AppRegister, type AppRouteInput, type AppRouteResult } from 'agent-bundle/app';",
+      "export const config = { resourceUri: 'ui://caller-input-types-fixture/dashboard.html', template: './dashboard.html' };",
+      ...equality,
+      "export type PageInput = Assert, { limit?: number | undefined }>>;",
+      "export type PageResult = Assert, { limit: number }>>;",
+      "export type MeasureInput = Assert, { text: string }>>;",
+      "export type Registered = Assert>>;",
+      "const client = createAppClient({ appInfo: { name: 'dashboard', version: '1.0.0' } });",
+      '// The opening input is the host\'s payload, before the server parses it: the default may be absent.',
+      "client.onToolInput('tool:curator/page', (input) => { const limit: number | undefined = input.limit; void limit; });",
+      'await client.connect();',
+      "export const defaulted = await client.call('tool:curator/page', {});",
+      "export const explicit = await client.call('tool:curator/page', { limit: 5 });",
+      "export const measured = await client.call('tool:curator/measure', { text: '  hi  ' });",
+      "document.querySelector('#state')!.textContent = String(defaulted.limit + explicit.limit + measured.length);",
+      '',
+    ].join('\n')),
+    // The server program: the component sees the parsed output.
+    writeProjectFile(root, 'src/handler-types.ts', [
+      "import type { ToolRouteProps } from 'agent-bundle';",
+      "import type { inputSchema as measureSchema } from './mcp/curator/tools/measure.js';",
+      "import type { inputSchema as pageSchema } from './mcp/curator/tools/page.js';",
+      ...equality,
+      "export type PageProps = Assert['input'], { limit: number }>>;",
+      "export type MeasureProps = Assert['input'], { text: number }>>;",
+      '',
+    ].join('\n')),
+    // Negative cases, each its own entry so one program reports exactly one rejection.
+    writeProjectFile(root, 'negative/wrong-id.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/missing', {});",
+      '',
+    ].join('\n')),
+    writeProjectFile(root, 'negative/missing-required.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/measure', {});",
+      '',
+    ].join('\n')),
+    writeProjectFile(root, 'negative/wrong-primitive.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/page', { limit: 'ten' });",
+      '',
+    ].join('\n')),
+    // The reverse mismatch: the component's parsed number is not what the caller sends.
+    writeProjectFile(root, 'negative/parsed-as-caller.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/measure', { text: 4 });",
+      '',
+    ].join('\n')),
+    // A solution-style root: one browser program, one server program, each judged on its own.
+    writeProjectFile(root, 'tsconfig.json', `${JSON.stringify({ files: [], references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }] }, null, 2)}\n`),
+    writeProjectFile(root, 'tsconfig.app.json', tsconfig(['src/mcp/**/apps/*.ts', '.agent-bundle/routes.d.ts'], ['DOM', 'ES2022'])),
+    writeProjectFile(root, 'tsconfig.node.json', tsconfig(['src/**/tools/*.ts', 'src/handler-types.ts', '.agent-bundle/routes.d.ts'], ['ES2022'])),
+  ]);
+
+  // The documented entry: `validate` publishes the declaration for a clean checkout, and judges both programs.
+  const validated = await validate({ root });
+  expect(validated.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]);
+  expect(validated.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain('AB4834');
+  const generated = await readFile(join(root, '.agent-bundle', 'routes.d.ts'), 'utf8');
+  expect(generated).toContain('input: SchemaInput;');
+  expect(generated.split('\n').filter((line) => line.startsWith('import')).every((line) => line.startsWith('import type * as '))).toBe(true);
+
+  expect(typecheckProgram(root, 'tsconfig.app.json')).toEqual([]);
+  expect(typecheckProgram(root, 'tsconfig.node.json')).toEqual([]);
+  const wrongId = typecheckProgram(root, 'tsconfig.app.json', 'negative/wrong-id.ts');
+  expect(wrongId).toHaveLength(1);
+  expect(wrongId[0]).toMatch(/tool:curator\/missing/u);
+  const missingRequired = typecheckProgram(root, 'tsconfig.app.json', 'negative/missing-required.ts');
+  expect(missingRequired).toHaveLength(1);
+  expect(missingRequired[0]).toMatch(/Property 'text' is missing/u);
+  const wrongPrimitive = typecheckProgram(root, 'tsconfig.app.json', 'negative/wrong-primitive.ts');
+  expect(wrongPrimitive).toHaveLength(1);
+  expect(wrongPrimitive[0]).toMatch(/Type 'string' is not assignable to type 'number'/u);
+  const parsedAsCaller = typecheckProgram(root, 'tsconfig.app.json', 'negative/parsed-as-caller.ts');
+  expect(parsedAsCaller).toHaveLength(1);
+  expect(parsedAsCaller[0]).toMatch(/Type 'number' is not assignable to type 'string'/u);
+
+  // Dropping the declaration from the browser program alone is reported on that program alone.
+  await writeProjectFile(root, 'tsconfig.app.json', tsconfig(['src/mcp/**/apps/*.ts'], ['DOM', 'ES2022']));
+  const omitted = (await validate({ root })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+  expect(omitted).toHaveLength(1);
+  expect(omitted[0]).toMatchObject({ sourcePath: join(root, 'tsconfig.app.json') });
+  expect(typecheckProgram(root, 'tsconfig.app.json')).not.toEqual([]);
+  await writeProjectFile(root, 'tsconfig.app.json', tsconfig(['src/mcp/**/apps/*.ts', '.agent-bundle/routes.d.ts'], ['DOM', 'ES2022']));
+
+  // The same schema at run time, through the generated server: the default is applied once, the transform once,
+  // and an input the schema rejects never reaches the component.
+  const output = join(root, 'artifact');
+  const compiled = await build({ output, root, targets: ['portable'] });
+  const server = compiled.model.mcpServers[0];
+  if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry');
+  const client = new Client({ name: 'caller-input-types', version: '0.0.0' });
+  const transport = new StdioClientTransport({
+    args: [join(output, server.args[0])],
+    command: process.execPath,
+    env: { ...process.env, INVOCATIONS: invocations },
+    stderr: 'pipe',
+  });
+  await client.connect(transport);
+  try {
+    expect(await callTool(client, 'page', {})).toMatchObject({ structuredContent: { limit: 10 } });
+    expect(await callTool(client, 'page', { limit: 5 })).toMatchObject({ structuredContent: { limit: 5 } });
+    expect(await callTool(client, 'measure', { text: '  hi  ' })).toMatchObject({ structuredContent: { length: 2 } });
+    const rejected = await callTool(client, 'page', { limit: 'ten' });
+    expect(rejected instanceof Error ? rejected.message : JSON.stringify(rejected)).toMatch(/limit|invalid/iu);
+    const missing = await callTool(client, 'measure', {});
+    expect(missing instanceof Error ? missing.message : JSON.stringify(missing)).toMatch(/text|invalid/iu);
+  } finally {
+    await client.close();
+  }
+  expect((await readFile(invocations, 'utf8')).trim().split('\n').map((line) => JSON.parse(line) as unknown)).toEqual([
+    { input: { limit: 10 }, tool: 'page' },
+    { input: { limit: 5 }, tool: 'page' },
+    { input: { text: 2 }, tool: 'measure' },
+  ]);
+});
diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts
index 0cdeecbe5..07eae3572 100644
--- a/packages/agent-bundle/tests/route-graph.test.ts
+++ b/packages/agent-bundle/tests/route-graph.test.ts
@@ -1648,9 +1648,11 @@ it('resolves generated helper types for schema and event route contracts and the
       '',
     ].join('\n'),
     'src/mcp/curator/tools/inspect.ts': [
-      'export interface InspectInput { readonly source: string; }',
+      '// What a caller sends (`_input`: the limit is optional) versus what the component receives (`_output`).',
+      'export interface InspectInput { readonly source: string; readonly limit?: number; }',
+      'export interface InspectParsedInput { readonly source: string; readonly limit: number; }',
       'export interface InspectResult { readonly accepted: boolean; }',
-      'export const inputSchema = {} as { readonly _output: InspectInput };',
+      'export const inputSchema = {} as { readonly _input: InspectInput; readonly _output: InspectParsedInput };',
       'export const resultSchema = {} as { readonly _output: InspectResult };',
       'export default async function Inspect() { return undefined; }',
       '',
@@ -1689,7 +1691,9 @@ it('resolves generated helper types for schema and event route contracts and the
       ...equalityHelpers,
       '',
       "export type Ids = Assert>;",
+      '// A schema with `_input` registers the caller\'s input; one with only `_output` (the prompt) registers its output.',
       "export type SchemaInput = Assert, InspectInput>>;",
+      "export type StructuralInput = Assert, BriefInput>>;",
       "export type SchemaResult = Assert, InspectResult>>;",
       "export type EventInput = Assert, WorkspaceOpenInput>>;",
       "export type EventResult = Assert, WorkspaceOpenResult>>;",
diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts
index 50469e5b5..7dc234ef5 100644
--- a/packages/agent-bundle/tests/route-register-typegen.test.ts
+++ b/packages/agent-bundle/tests/route-register-typegen.test.ts
@@ -252,9 +252,10 @@ 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.',
+      '  // loadRouteModule checks its id the same way. The registration types what a caller sends and what the',
+      '  // resultSchema parses to; what the inputSchema parses to (the component\'s props) is not registered.',
       "  const found_module = await loadRouteModule('tool:curator/find');",
-      "  const parsedQuery: string | undefined = found_module.inputSchema?.parse({ query: 'dune' }).query;",
+      "  const parsedQuery: unknown = found_module.inputSchema?.parse({ query: 'dune' });",
       '  const parsedHits: number | undefined = found_module.resultSchema?.parse({ hits: 1 }).hits;',
       '  const looseModule = await loadRouteModule(dynamic);',
       '  const looseParsed: unknown = looseModule.resultSchema?.parse({});',
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index 6b712a766..6ea8a9709 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -15,14 +15,27 @@ afterEach(async () => {
   await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
 });
 
+// A route module consumes the registration through `@agent-bundle/runtime` (provider values).
 const routeModule = [
+  "import { agent } from '@agent-bundle/runtime';",
   "import { z } from 'zod';",
   'export const inputSchema = z.object({ service: z.string() });',
   'export const resultSchema = z.object({ status: z.string() });',
-  'export default async () => undefined;',
+  'export default async () => { await agent(); return undefined; };',
   '',
 ].join('\n');
 
+// An App view consumes it through `agent-bundle/app`; it lives in the browser program.
+const appModule = [
+  "import { createAppClient } from 'agent-bundle/app';",
+  "export const config = { resourceUri: 'ui://routes-fixture/panel.html', template: './panel.html' };",
+  "void createAppClient().call('tool:status/report', { service: 'compiler' });",
+  '',
+].join('\n');
+
+// A build-only script imports none of the augmented modules: not a consumer.
+const scriptModule = "export const main = async (): Promise => { console.log('build'); };\n";
+
 const tsconfig = (include: readonly string[], extra: Readonly> = {}): string =>
   `${JSON.stringify({ compilerOptions: { module: 'NodeNext', strict: true }, include, ...extra }, null, 2)}\n`;
 
@@ -63,7 +76,7 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     const warnings = result.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
     expect(warnings).toHaveLength(1);
     expect(warnings[0]).toMatchObject({
-      message: expect.stringContaining('tsconfig.json does not include the generated .agent-bundle/routes.d.ts'),
+      message: expect.stringContaining('tsconfig.json imports agent-bundle/app, agent-bundle/test, agent-bundle/eval, or @agent-bundle/runtime but does not include the generated .agent-bundle/routes.d.ts'),
       recovery: expect.stringContaining('Add ".agent-bundle/routes.d.ts" to the "include" array of tsconfig.json'),
       severity: 'warning',
       sourcePath: join(root, 'tsconfig.json'),
@@ -111,6 +124,47 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     expect(codesOf((await validate({ root: wildcardOnly })).diagnostics)).toContain('AB4834');
   });
 
+  it('judges every consuming program of a solution, so the server project cannot hide the browser project', async () => {
+    const solution = {
+      'src/mcp/status/apps/panel.html': '\n',
+      'src/mcp/status/apps/panel.ts': appModule,
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': scriptModule,
+      // Nested: the root references a solution file that references the three projects.
+      'tsconfig.json': `${JSON.stringify({ files: [], references: [{ path: './tsconfig.solution.json' }] }, null, 2)}\n`,
+      'tsconfig.solution.json': `${JSON.stringify({ files: [], references: [{ path: './tsconfig.node.json' }, { path: './config/tsconfig.app.json' }, { path: './tsconfig.scripts.json' }] }, null, 2)}\n`,
+      'tsconfig.node.json': tsconfig(['.agent-bundle/routes.d.ts', 'src/mcp/**/tools/*.ts'], { compilerOptions: { composite: true, module: 'NodeNext' } }),
+      'tsconfig.scripts.json': tsconfig(['src/scripts/*.ts'], { compilerOptions: { composite: true, module: 'NodeNext' } }),
+    };
+    const browserOmits = await createProject({
+      ...solution,
+      // Excluding the declaration is as good as omitting it.
+      'config/tsconfig.app.json': tsconfig(['../src/mcp/**/apps/*.ts', '../.agent-bundle/**/*'], { compilerOptions: { composite: true, lib: ['DOM', 'ES2022'], module: 'NodeNext' }, exclude: ['../.agent-bundle/**/*'] }),
+    });
+    const warnings = (await validate({ root: browserOmits })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    // One warning, on the browser project; the node project includes the file and the scripts project imports nothing that consumes it.
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]).toMatchObject({
+      message: expect.stringContaining('config/tsconfig.app.json imports agent-bundle/app'),
+      recovery: expect.stringContaining('Add "../.agent-bundle/routes.d.ts" to the "include" array of config/tsconfig.app.json'),
+      sourcePath: join(browserOmits, 'config', 'tsconfig.app.json'),
+    });
+
+    const browserIncludes = await createProject({
+      ...solution,
+      'config/tsconfig.app.json': tsconfig(['../src/mcp/**/apps/*.ts', '../.agent-bundle/routes.d.ts'], { compilerOptions: { composite: true, lib: ['DOM', 'ES2022'], module: 'NodeNext' } }),
+    });
+    expect(codesOf((await validate({ root: browserIncludes })).diagnostics)).not.toContain('AB4834');
+
+    // A program that imports none of the augmented modules is never asked to include the file.
+    const buildOnly = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': scriptModule,
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: buildOnly })).diagnostics)).not.toContain('AB4834');
+  });
+
   it('has nothing to report without a tsconfig, without routes, or with an unparsable tsconfig', async () => {
     const noTsconfig = await createProject({ 'src/mcp/status/tools/report.ts': routeModule });
     expect(codesOf((await validate({ root: noTsconfig })).diagnostics)).not.toContain('AB4834');
diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts
index 6499ae838..298b891e1 100644
--- a/packages/agent-bundle/tests/route-unit/render-route.test.ts
+++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts
@@ -569,6 +569,21 @@ describe('layout composition at the route-unit level', () => {
     });
   });
 
+  it('parses the input through the route\'s own inputSchema before the component runs, as the generated worker does', async () => {
+    // The caller omits the defaulted field; the component receives the default (#752).
+    const defaulted = await renderRoute('tool:harness/layout-probe', { input: {} });
+    expect(defaulted.result).toEqual({ label: 'probe' });
+    expectDocument(defaulted).toContainText('probe: probe');
+
+    // Invalid input is rejected before any provider or component runs: no document, one harness diagnostic.
+    const error = await rejection(renderRoute('tool:harness/layout-probe', { input: { label: 1 } as never }));
+    expect(error).toBeInstanceOf(AgentTestError);
+    expect(error.code).toBe('invalid-input');
+    expect(error.message).toContain("The route's own inputSchema rejected the input.");
+    expect(error.message).toContain('received:     {"label":1}');
+    expect(error.message).toContain('route:        tool:harness/layout-probe (tool)');
+  });
+
   it('applies only the root layout to a rendered CLI command', async () => {
     const rendered = await renderRoute('cli:report', { input: { topic: 'layouts' } });
 
diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md
index 7934c866c..003de0f2e 100644
--- a/packages/create-agent-bundle/templates/cli-tool/README.md
+++ b/packages/create-agent-bundle/templates/cli-tool/README.md
@@ -15,7 +15,8 @@ schemas. `src/index.ts` is the library export with declarations, and
 ```sh
 npm run dev              # local workbench with live rebuilds
 npm run build            # dist/ package build + host artifacts in artifact/
-npm run check            # validate + build + typecheck + both test pools
+npm run check            # build + typecheck + both test pools
+npm run typecheck        # validate (writes .agent-bundle/routes.d.ts) + tsc
 npm run test             # plain module tests
 npm run test:projection  # cli-dispatch + script-dispatch pool
 
diff --git a/packages/create-agent-bundle/templates/cli-tool/package_json b/packages/create-agent-bundle/templates/cli-tool/package_json
index cf917cae6..5b39f3d92 100644
--- a/packages/create-agent-bundle/templates/cli-tool/package_json
+++ b/packages/create-agent-bundle/templates/cli-tool/package_json
@@ -14,12 +14,12 @@
   },
   "scripts": {
     "build": "agent-bundle build --json --output artifact",
-    "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:projection",
+    "check": "npm run build && npm run typecheck && npm run test && npm run test:projection",
     "dev": "agent-bundle dev",
     "pack:check": "agent-bundle prepack --json --output artifact",
     "test": "rstest tests --exclude \"tests/projection/**\"",
     "test:projection": "rstest --config rstest.projection.config.ts",
-    "typecheck": "tsc -p tsconfig.json --noEmit",
+    "typecheck": "npm run validate && tsc -p tsconfig.json --noEmit",
     "validate": "agent-bundle validate --json"
   },
   "devDependencies": {
diff --git a/packages/create-agent-bundle/templates/mcp-server/README.md b/packages/create-agent-bundle/templates/mcp-server/README.md
index d13fcf494..0c446e18c 100644
--- a/packages/create-agent-bundle/templates/mcp-server/README.md
+++ b/packages/create-agent-bundle/templates/mcp-server/README.md
@@ -10,7 +10,8 @@ the `status` server; no handwritten server factory or server config is needed.
 ```sh
 npm run dev
 npm run build
-npm run check            # validate + build + typecheck + all three test pools
+npm run check            # build + typecheck + all three test pools
+npm run typecheck        # validate (writes .agent-bundle/routes.d.ts) + tsc
 npm run test             # plain module tests
 npm run test:routes      # route-unit pool
 npm run test:projection  # in-memory MCP projection pool
diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json
index dc12ec721..33172c2f7 100644
--- a/packages/create-agent-bundle/templates/mcp-server/package_json
+++ b/packages/create-agent-bundle/templates/mcp-server/package_json
@@ -15,13 +15,13 @@
   },
   "scripts": {
     "build": "agent-bundle build --json --output artifact",
-    "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:routes && npm run test:projection",
+    "check": "npm run build && npm run typecheck && npm run test && npm run test:routes && npm run test:projection",
     "dev": "agent-bundle dev",
     "pack:check": "agent-bundle prepack --json --output artifact",
     "test": "rstest tests --exclude \"tests/{route-unit,projection}/**\"",
     "test:projection": "rstest --config rstest.projection.config.ts",
     "test:routes": "rstest --config rstest.route-unit.config.ts",
-    "typecheck": "tsc -p tsconfig.json --noEmit",
+    "typecheck": "npm run validate && tsc -p tsconfig.json --noEmit",
     "validate": "agent-bundle validate --json"
   },
   "devDependencies": {
diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts
index 937435136..73eb0ffff 100644
--- a/rstest.integration-tests.ts
+++ b/rstest.integration-tests.ts
@@ -80,6 +80,7 @@ export const integrationTestFiles: readonly string[] = [
   'packages/agent-bundle/tests/publint-gate.test.ts',
   'packages/agent-bundle/tests/route-contract-imports.test.ts',
   'packages/agent-bundle/tests/route-invocation-dev-server.test.ts',
+  'packages/agent-bundle/tests/route-caller-input-types.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',
diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx
index 7afc52c94..15c5f59b2 100644
--- a/website/docs/en/guide/authoring/mcp.mdx
+++ b/website/docs/en/guide/authoring/mcp.mdx
@@ -849,11 +849,15 @@ declare module 'agent-bundle/app' {
 ```
 
 whose members are the project's generated MCP **tool** routes (`AppToolRouteId` in the same
-file) — `input` inferred from each route's `inputSchema`, `result` from its `resultSchema`. Once
-that file is in the project's TypeScript program (the templates list `".agent-bundle/routes.d.ts"`
-in `tsconfig.json` `include`; `AB4834` when a routed project leaves it out), a literal route id is
-checked against the compiled tools, `input` is typed from that route, and the result is that
-route's structured object. The declaration imports the route modules type-only, so no route
+file) — `input` inferred from each route's `inputSchema` **input** type (what a caller sends: a
+`.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it; the
+route component receives the parsed output), `result` from its `resultSchema` output. Once
+that file is in the App's TypeScript program (the templates list `".agent-bundle/routes.d.ts"`
+in `tsconfig.json` `include`; `AB4834` names each program that imports `agent-bundle/app` and
+leaves it out), a literal route id is checked against the compiled tools, `input` is typed from
+that route, and the result is that route's structured object. `onToolInput` delivers the host's
+opening payload with the same input type: it is the message before the server parses it, so a
+default the schema would fill is not promised to be present. The declaration imports the route modules type-only, so no route
 module, schema, or server code enters the App document. Without the augmentation — a handwritten
 server, or a program that omits the file — `call()` still works with `unknown` input and result,
 and a view may declare its own contract map through the same `AppRegister` seam. The
diff --git a/website/docs/en/guide/development/index.mdx b/website/docs/en/guide/development/index.mdx
index 86d448494..f7b2033d0 100644
--- a/website/docs/en/guide/development/index.mdx
+++ b/website/docs/en/guide/development/index.mdx
@@ -38,9 +38,15 @@ as one `AB7103` **warning** on the succeeded build attempt and retries on the ne
 Development also publishes generated route declarations at `.agent-bundle/routes.d.ts` from the
 same compiled graph. Each write goes to a sibling temporary file and is renamed over the prior
 complete declaration atomically, so invalid source keeps the last-good file, and a successful
-route-free preparation removes it. The declaration only types `renderRoute` when the project's
-TypeScript program compiles it: keep `".agent-bundle/routes.d.ts"` in `tsconfig.json` `include`
-(the templates ship it that way; `agent-bundle validate` warns with `AB4834` when it is missing).
+route-free preparation removes it. The declaration only types `renderRoute`, the App client, and
+provider values in a TypeScript program that compiles it: keep `".agent-bundle/routes.d.ts"` in
+the `include` of every tsconfig that imports `agent-bundle/test`, `agent-bundle/app`,
+`agent-bundle/eval`, or `@agent-bundle/runtime` — a solution-style root is judged one referenced
+project at a time, so a server project that includes the file cannot hide a browser project that
+omits it (`agent-bundle validate` warns with `AB4834` per program). `validate` is also the
+lightweight way to publish current declarations before an isolated type check — no dev server or
+production bundle needed — which is why the routed templates' `typecheck` script runs
+`agent-bundle validate && tsc`.
 
 ## The three surfaces
 
diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx
index ec1f71452..7c948fb2d 100644
--- a/website/docs/en/guide/development/testing.mdx
+++ b/website/docs/en/guide/development/testing.mdx
@@ -62,8 +62,9 @@ The compiler's generated `.agent-bundle/routes.d.ts` registers the project's rou
 `@agent-bundle/runtime`'s `Register` interface, the way it already declares provider keys. Once
 that file is part of the project's TypeScript program — `create-agent-bundle` templates list
 `".agent-bundle/routes.d.ts"` in `tsconfig.json` `include` by default, the file itself stays
-gitignored, and `agent-bundle validate` warns with `AB4834` when a project that compiles routes or
-providers leaves it out — a `renderRoute` call written with a string literal is checked against
+gitignored, and `agent-bundle validate` warns with `AB4834` for each TypeScript program that
+imports `agent-bundle/test`, `agent-bundle/app`, `agent-bundle/eval`, or `@agent-bundle/runtime`
+and leaves it out — 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`:
@@ -75,6 +76,14 @@ const { result } = await renderRoute('tool:library/summarize', {
 const chapters: number | undefined = result?.chapters; // no cast
 ```
 
+`input` is what a caller sends — the schema's **input** type, so a field with a `.default()` is
+optional and a `.transform()`ed field is spelled the way the wire carries it — while the route
+component receives the schema's **output**, exactly as the generated server hands it over.
+`renderRoute` parses the input through the route's own `inputSchema` at that same boundary: the
+component sees defaults filled and transforms applied, and an input the schema rejects fails with
+an `invalid-input` harness error before any provider or component runs. `result` is the
+`resultSchema` output.
+
 An event route registers what the harness actually takes and gives back: `input` is the
 `{ canonical, native, preflight? }` payload (the harness supplies `signal` itself), and `result` is
 `undefined`, since event modules export no `resultSchema`. Build that input from a host envelope
diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx
index 450a8f473..bfef983f2 100644
--- a/website/docs/zh/guide/authoring/mcp.mdx
+++ b/website/docs/zh/guide/authoring/mcp.mdx
@@ -754,10 +754,13 @@ declare module 'agent-bundle/app' {
 }
 ```
 
-成员就是项目生成的 MCP **工具**路由:`input` 来自各路由的 `inputSchema`,`result` 来自
-`resultSchema`。该声明以 type-only 方式导入路由模块,所以不会把路由模块、schema 或服务器代码带入 App
-文档。模板已经在 `tsconfig.json` 的 `include` 中列出 `".agent-bundle/routes.d.ts"`;路由项目漏掉它时
-会收到 `AB4834`。没有 augmentation 时——例如手写服务器——`call()` 仍能以 `unknown` 输入与结果工作,视图也可以
+成员就是项目生成的 MCP **工具**路由:`input` 来自各路由 `inputSchema` 的**输入**类型(调用方发送的
+内容:带 `.default()` 的字段是可选的,经 `.transform()` 的字段按 wire 上的写法拼写;路由组件收到的则是
+解析后的输出),`result` 来自 `resultSchema` 的输出。该声明以 type-only 方式导入路由模块,所以不会把路由
+模块、schema 或服务器代码带入 App 文档。模板已经在 `tsconfig.json` 的 `include` 中列出
+`".agent-bundle/routes.d.ts"`;每一个导入了 `agent-bundle/app` 却漏掉它的程序都会收到 `AB4834`。
+`onToolInput` 以同一输入类型交付宿主的 opening 载荷:那是服务器解析之前的消息,因此 schema 会填入的默认值
+并不保证已经存在。没有 augmentation 时——例如手写服务器——`call()` 仍能以 `unknown` 输入与结果工作,视图也可以
 通过同一个结构化 `AppRegister` seam 声明自己的本地契约 map。[MCP App 示例](../../examples/mcp-app.mdx)
 依赖的就是生成文件:它的 App 以路由 schema 声明的输入与结果调用 `tool:status/show-status`。`AppRouteId`、
 `AppRouteInput` 与 `AppRouteResult` 为 wrapper 命名同一套类型表面。
diff --git a/website/docs/zh/guide/development/index.mdx b/website/docs/zh/guide/development/index.mdx
index b30bfa7aa..bee1b420a 100644
--- a/website/docs/zh/guide/development/index.mdx
+++ b/website/docs/zh/guide/development/index.mdx
@@ -32,9 +32,12 @@ npx agent-bundle dev --root .
 
 开发期还会从同一份编译后的路由图,把生成的路由声明发布到 `.agent-bundle/routes.d.ts`。每次写入都先
 写入一个同级临时文件,再原子地重命名覆盖先前那份完整声明,因此无效源码会保留上一份可用文件,而一次
-成功的、不含路由的准备过程会移除它。只有当项目的 TypeScript 程序编译了这份声明,它才会为 `renderRoute`
-提供类型:请把 `".agent-bundle/routes.d.ts"` 保留在 `tsconfig.json` 的 `include` 中(模板默认如此;
-缺失时 `agent-bundle validate` 会以 `AB4834` 警告)。
+成功的、不含路由的准备过程会移除它。只有编译了这份声明的 TypeScript 程序,才会为 `renderRoute`、App
+客户端与 provider 值提供类型:请把 `".agent-bundle/routes.d.ts"` 保留在每一个导入了 `agent-bundle/test`、
+`agent-bundle/app`、`agent-bundle/eval` 或 `@agent-bundle/runtime` 的 tsconfig 的 `include` 中——solution
+式根会按被引用项目逐一判断,因此包含了该文件的服务器项目无法掩盖漏掉它的浏览器项目(`agent-bundle validate`
+会按程序逐一以 `AB4834` 警告)。`validate` 也是在独立类型检查之前发布最新声明的轻量方式——无需 dev
+服务器或生产构建——因此带路由的模板把 `typecheck` 脚本写成 `agent-bundle validate && tsc`。
 
 ## 三个表面
 
diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx
index 462b1c87d..d59ef06d4 100644
--- a/website/docs/zh/guide/development/testing.mdx
+++ b/website/docs/zh/guide/development/testing.mdx
@@ -54,8 +54,9 @@ export const summarizes = async (): Promise => {
 编译器生成的 `.agent-bundle/routes.d.ts` 会把项目的路由契约注册到 `@agent-bundle/runtime` 的
 `Register` 接口上——与它已经声明 provider 键的方式相同。一旦该文件成为项目 TypeScript 程序的一部分
 (`create-agent-bundle` 模板默认就把 `".agent-bundle/routes.d.ts"` 列在 `tsconfig.json` 的 `include`
-中,文件本身仍被 gitignore;当一个编译了路由或 provider 的项目漏掉它时,`agent-bundle validate` 会以
-`AB4834` 警告),用字符串字面量写出的
+中,文件本身仍被 gitignore;每一个导入了 `agent-bundle/test`、`agent-bundle/app`、`agent-bundle/eval`
+或 `@agent-bundle/runtime` 却漏掉该文件的 TypeScript 程序,`agent-bundle validate` 都会以 `AB4834`
+逐一警告),用字符串字面量写出的
 `renderRoute` 调用就会针对编译后的 route id 做检查——编辑器会补全它们,写错时会被拒绝并列出已注册的
 备选项——而 `input` 与 `result` 来自该路由自己的 `inputSchema` 与 `resultSchema`:
 
@@ -66,6 +67,12 @@ const { result } = await renderRoute('tool:library/summarize', {
 const chapters: number | undefined = result?.chapters; // 无需强制类型转换
 ```
 
+`input` 是调用方发送的内容——schema 的**输入**类型:带 `.default()` 的字段是可选的,经
+`.transform()` 的字段按 wire 上的写法拼写——而路由组件收到的是 schema 的**输出**,与生成的服务器交给它的
+完全一致。`renderRoute` 在同一边界用路由自己的 `inputSchema` 解析输入:组件看到的是已填默认值、已应用
+transform 的值;schema 拒绝的输入会在任何 provider 或组件运行之前,以 `invalid-input` 测试错误失败。
+`result` 是 `resultSchema` 的输出。
+
 事件路由注册的是测试工具实际接受与返回的内容:`input` 是 `{ canonical, native, preflight? }` 载荷(`signal` 由测试
 工具自行提供),而 `result` 为 `undefined`,因为事件模块不导出 `resultSchema`。请用
 `createEventRouteInput('tool/after', envelope, { host: 'claude' })` 从宿主信封构造这个输入,而不要手写:它按

From 42be064ed2792f0687b1b11450e8365ffeb90372 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Mon, 7 Sep 2026 23:41:51 +0000
Subject: [PATCH 2/9] deslop: drop redundant input-schema interface; fix
 comment placement; changeset PR number

---
 .../748-752-route-caller-input-types.md       |  2 +-
 packages/agent-bundle/src/test/render.ts      | 23 ++++++-------------
 2 files changed, 8 insertions(+), 17 deletions(-)

diff --git a/.changeset/748-752-route-caller-input-types.md b/.changeset/748-752-route-caller-input-types.md
index f10038db2..73e9bef58 100644
--- a/.changeset/748-752-route-caller-input-types.md
+++ b/.changeset/748-752-route-caller-input-types.md
@@ -3,4 +3,4 @@
 "create-agent-bundle": patch
 ---
 
-Type route callers by schema input and route components by schema output in the generated `.agent-bundle/routes.d.ts`: `createAppClient().call`, `onToolInput`, `renderRoute`, `invokeMcpTool`, and the contract matrix accept what a caller sends (a `.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it), while `ToolRouteProps` keeps the parsed output. A structural schema declaring only `_output` uses it for both. `renderRoute` now parses its input through the route's own `inputSchema` before the component runs and fails with an `invalid-input` harness error on rejected input. `agent-bundle validate` reports `AB4834` once per TypeScript program that imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` and omits the generated declaration — following `references` transitively — instead of accepting any one referenced program that includes it. The `mcp-server` and `cli-tool` starters run `agent-bundle validate` inside `npm run typecheck`, so a clean checkout type-checks against current route declarations. (#PR)
+Type route callers by schema input and route components by schema output in the generated `.agent-bundle/routes.d.ts`: `createAppClient().call`, `onToolInput`, `renderRoute`, `invokeMcpTool`, and the contract matrix accept what a caller sends (a `.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it), while `ToolRouteProps` keeps the parsed output. A structural schema declaring only `_output` uses it for both. `renderRoute` now parses its input through the route's own `inputSchema` before the component runs and fails with an `invalid-input` harness error on rejected input. `agent-bundle validate` reports `AB4834` once per TypeScript program that imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` and omits the generated declaration — following `references` transitively — instead of accepting any one referenced program that includes it. The `mcp-server` and `cli-tool` starters run `agent-bundle validate` inside `npm run typecheck`, so a clean checkout type-checks against current route declarations. (#757)
diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts
index 3cf5904ab..d70e825bf 100644
--- a/packages/agent-bundle/src/test/render.ts
+++ b/packages/agent-bundle/src/test/render.ts
@@ -562,16 +562,6 @@ export interface RouteModuleSchema extends AgentRouteSchema {
   readonly parse: (value: unknown) => Value;
 }
 
-/**
- * A route's `inputSchema` as the harness sees it: the registration types what
- * a caller sends (`RouteTargetInput`), which is the schema's input side; what
- * `parse` returns — the component's props after defaults and transforms — is
- * not part of the registration, so it stays `unknown` here.
- */
-export interface RouteModuleInputSchema extends AgentRouteSchema {
-  readonly parse: (value: Value | unknown) => unknown;
-}
-
 /**
  * The evaluated module `loadRouteModule` returns: the same object the generated
  * server, the routed CLI, and `renderRoute` execute, so `inputSchema` and
@@ -584,7 +574,8 @@ export interface LoadedRouteModule {
   readonly [exportName: string]: unknown;
   readonly config?: unknown;
   readonly default?: (props: never) => unknown;
-  readonly inputSchema?: RouteModuleInputSchema>;
+  /** The registration types what a caller sends, not what `parse` returns (the component's props after defaults and transforms). */
+  readonly inputSchema?: RouteModuleSchema;
   readonly resultSchema?: RouteModuleSchema>;
 }
 
@@ -636,11 +627,6 @@ export const loadRouteModule = async (
   return loaded.module as LoadedRouteModule;
 };
 
-/**
- * The structured result a generated server would return: the document value
- * validated by the route's own `resultSchema`. A document that renders but
- * whose value the route's schema rejects is a route defect, not a pass.
- */
 /**
  * The component's `input` prop: the caller's input parsed by the route's own
  * `inputSchema`, exactly where the generated Flight worker parses it (defaults
@@ -670,6 +656,11 @@ const parsedInput = (
   }
 };
 
+/**
+ * The structured result a generated server would return: the document value
+ * validated by the route's own `resultSchema`. A document that renders but
+ * whose value the route's schema rejects is a route defect, not a pass.
+ */
 const parsedResult = (
   schema: { readonly parse: (value: unknown) => unknown },
   document: AgentDocument,

From 33c37d868e5574c15c492d4a2c170cf73e4e7d4d Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Tue, 8 Sep 2026 00:31:30 +0000
Subject: [PATCH 3/9] review: parse CLI harness input, type parsedInput
 contracts, scanner-based AB4834 consumer detection, include-aware recovery

---
 .../748-752-route-caller-input-types.md       |  3 +-
 .../src/routes/typegen-program.ts             | 34 ++++++++---
 packages/agent-bundle/src/routes/typegen.ts   | 15 ++++-
 packages/agent-bundle/src/test/index.ts       |  1 +
 packages/agent-bundle/src/test/mcp.ts         |  7 ++-
 packages/agent-bundle/src/test/render.ts      | 12 ++--
 .../tests/route-caller-input-types.test.ts    |  5 ++
 .../agent-bundle/tests/route-graph.test.ts    | 11 ++--
 .../tests/route-register-typegen.test.ts      |  6 +-
 .../tests/route-types-program.test.ts         | 58 +++++++++++++++++++
 .../tests/route-unit/render-route.test.ts     | 11 ++++
 .../tests/scaffold-packed.e2e.test.ts         | 22 ++++++-
 packages/rsc-runtime/src/agent-request.ts     | 15 ++++-
 packages/rsc-runtime/src/plugin.ts            |  1 +
 website/docs/en/guide/development/testing.mdx |  4 +-
 website/docs/zh/guide/development/testing.mdx |  4 +-
 16 files changed, 178 insertions(+), 31 deletions(-)

diff --git a/.changeset/748-752-route-caller-input-types.md b/.changeset/748-752-route-caller-input-types.md
index 73e9bef58..5664e0917 100644
--- a/.changeset/748-752-route-caller-input-types.md
+++ b/.changeset/748-752-route-caller-input-types.md
@@ -1,6 +1,7 @@
 ---
 "agent-bundle": minor
+"@agent-bundle/runtime": minor
 "create-agent-bundle": patch
 ---
 
-Type route callers by schema input and route components by schema output in the generated `.agent-bundle/routes.d.ts`: `createAppClient().call`, `onToolInput`, `renderRoute`, `invokeMcpTool`, and the contract matrix accept what a caller sends (a `.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it), while `ToolRouteProps` keeps the parsed output. A structural schema declaring only `_output` uses it for both. `renderRoute` now parses its input through the route's own `inputSchema` before the component runs and fails with an `invalid-input` harness error on rejected input. `agent-bundle validate` reports `AB4834` once per TypeScript program that imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` and omits the generated declaration — following `references` transitively — instead of accepting any one referenced program that includes it. The `mcp-server` and `cli-tool` starters run `agent-bundle validate` inside `npm run typecheck`, so a clean checkout type-checks against current route declarations. (#757)
+Type route callers by schema input and route components by schema output in the generated `.agent-bundle/routes.d.ts`: `createAppClient().call`, `onToolInput`, `renderRoute`, `invokeMcpTool`, and the contract matrix accept what a caller sends (a `.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it), while `ToolRouteProps` keeps the parsed output. The registration carries both sides: `RegisteredRouteInput` and the new `RegisteredRouteParsedInput` (`@agent-bundle/runtime`), the latter typing `loadRouteModule(id).inputSchema.parse`; `agent-bundle/test` exports `RouteTargetParsedInput`. `getMcpPrompt` and `McpInvocationOptions` type their `input` as the caller's side too. A structural schema declaring only `_output` uses it for both. `renderRoute` now parses its input through the route's own `inputSchema` — for MCP and `cli:` routes alike — before the component runs and fails with an `invalid-input` harness error on rejected input. `agent-bundle validate` reports `AB4834` once per TypeScript program that imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` and omits the generated declaration — following `references` transitively — instead of accepting any one referenced program that includes it. The `mcp-server` and `cli-tool` starters run `agent-bundle validate` inside `npm run typecheck`, so a clean checkout type-checks against current route declarations. (#757)
diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts
index 48d24ec7c..2c6d6b357 100644
--- a/packages/agent-bundle/src/routes/typegen-program.ts
+++ b/packages/agent-bundle/src/routes/typegen-program.ts
@@ -14,14 +14,16 @@ import { routeTypesRelativePath } from './typegen.ts';
 const projectTsconfigFilename = 'tsconfig.json';
 
 /**
- * The modules whose declarations the generated file augments or narrows:
+ * The entries whose declarations the generated file augments or narrows:
  * a program that imports one of them observes the registration, and one that
  * omits the file type-checks those imports with `string` ids and `unknown`
  * input/result/provider values, silently. Route modules import
  * `@agent-bundle/runtime` (providers), App views `agent-bundle/app`, tests
- * `agent-bundle/test` and `agent-bundle/eval`.
+ * `agent-bundle/test` and `agent-bundle/eval`. Exact specifiers: no other
+ * entry (`agent-bundle/test/browser`, `agent-bundle/routes`) reads the
+ * registration.
  */
-const consumerImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"](?:agent-bundle\/(?:app|eval|test)|@agent-bundle\/runtime)(?:\/[^'"]*)?['"]/;
+const consumerEntries: ReadonlySet = new Set(['agent-bundle/app', 'agent-bundle/eval', 'agent-bundle/test', '@agent-bundle/runtime']);
 
 const comparablePath = (path: string): string => {
   const resolved = resolve(path);
@@ -30,6 +32,8 @@ const comparablePath = (path: string): string => {
 
 interface Program {
   readonly fileNames: readonly string[];
+  /** Whether the config file itself declares `include` (not inherited through `extends`, not the `**` default). */
+  readonly ownInclude: boolean;
   readonly references: readonly string[];
   readonly tsconfigPath: string;
 }
@@ -45,6 +49,8 @@ interface Program {
 const program = (tsconfigPath: string): Program | undefined => {
   const read = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
   if (read.error !== undefined || read.config === undefined) return undefined;
+  // Read before parsing: the parser writes the inherited `include` back onto the raw config.
+  const ownInclude = Array.isArray((read.config as { readonly include?: unknown }).include);
   const parsed = ts.parseJsonConfigFileContent(
     read.config,
     ts.sys,
@@ -54,6 +60,7 @@ const program = (tsconfigPath: string): Program | undefined => {
   );
   return {
     fileNames: parsed.fileNames,
+    ownInclude,
     references: (parsed.projectReferences ?? []).map((reference) => ts.resolveProjectReferencePath(reference)),
     tsconfigPath,
   };
@@ -76,9 +83,17 @@ const programs = (rootTsconfigPath: string): readonly Program[] => {
   return found;
 };
 
-/** Whether one of the program's own source files imports a module the generated declaration augments. */
+/**
+ * Whether one of the program's root files imports an entry the generated
+ * declaration augments. The scanner's import pre-processing reads static and
+ * dynamic import specifiers only — a specifier in a comment or a string
+ * literal is not an import — and a user's own `.d.ts` counts like any other
+ * root file, since `import type` from a consumer entry reads the registration too.
+ */
 const consumesRegistration = (fileNames: readonly string[]): boolean =>
-  fileNames.some((fileName) => !fileName.endsWith('.d.ts') && consumerImport.test(ts.sys.readFile(fileName) ?? ''));
+  fileNames.some((fileName) =>
+    ts.preProcessFile(ts.sys.readFile(fileName) ?? '', true, false).importedFiles
+      .some((imported) => consumerEntries.has(imported.fileName)));
 
 /**
  * AB4834: the generated `.agent-bundle/routes.d.ts` registers the project's
@@ -103,11 +118,16 @@ export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diag
       && consumesRegistration(candidate.fileNames))
     .map((candidate) => {
       const tsconfig = relative(projectRoot, candidate.tsconfigPath).replaceAll('\\', '/');
-      const include = relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/');
+      const include = JSON.stringify(relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/'));
+      // An `include` array replaces the default (`**/*`) or the inherited
+      // patterns, so a config without its own must keep them when it adds one.
+      const where = candidate.ownInclude
+        ? `Add ${include} to the "include" array of ${tsconfig}`
+        : `Add ${include} to the "include" array of the config ${tsconfig} extends, or declare an "include" array in ${tsconfig} that lists ${include} beside the patterns it compiles today (the default is "**/*")`;
       return {
         code: 'AB4834',
         message: `${tsconfig} imports agent-bundle/app, agent-bundle/test, agent-bundle/eval, or @agent-bundle/runtime but does not include the generated ${routeTypesRelativePath}, so that program type-checks route ids as string and input/result/provider values as unknown.`,
-        recovery: `Add ${JSON.stringify(include)} to the "include" array of ${tsconfig}; agent-bundle validate, build, and dev keep the file current, and it stays gitignored.`,
+        recovery: `${where}; agent-bundle validate, build, and dev keep the file current, and it stays gitignored.`,
         severity: 'warning',
         sourcePath: candidate.tsconfigPath,
       };
diff --git a/packages/agent-bundle/src/routes/typegen.ts b/packages/agent-bundle/src/routes/typegen.ts
index cb84fd97c..e6c99d797 100644
--- a/packages/agent-bundle/src/routes/typegen.ts
+++ b/packages/agent-bundle/src/routes/typegen.ts
@@ -121,7 +121,7 @@ const appAugmentation = (routes: readonly CompiledAgentRoute[]): readonly string
 
 /**
  * The single `@agent-bundle/runtime` augmentation. Its `Register.routes`
- * member registers the thin `{ input, result }` contract map (TanStack
+ * member registers the thin `{ input, parsedInput, 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` input and `resultSchema` output, an event
@@ -174,6 +174,7 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
     '// spelled the way the wire carries it; the route component receives the parsed output instead.',
     'export type RouteContract = Readonly<{',
     '  input: SchemaInput;',
+    '  parsedInput: SchemaOutput;',
     '  result: SchemaOutput;',
     '}>;',
     'export type EventRouteContract = Readonly<{',
@@ -197,6 +198,10 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
     '  Contract extends { readonly input: infer Input } ? Input',
     "    : Contract extends { readonly component: infer Component } ? Omit, 'signal'>",
     '      : never;',
+    'type HarnessParsedInput =',
+    '  Contract extends { readonly parsedInput: infer Parsed } ? Parsed',
+    "    : Contract extends { readonly component: infer Component } ? Omit, 'signal'>",
+    '      : never;',
     'type HarnessResult =',
     '  Contract extends { readonly result: infer Result } ? Result',
     '    : Contract extends { readonly component: unknown } ? undefined',
@@ -212,9 +217,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`. */',
+    '/** The registered harness contract map: one `{ input, parsedInput, result }` per route id, for `@agent-bundle/runtime`\'s `Register`. */',
     'export type AgentBundleRouteContracts = {',
-    '  readonly [Id in RouteId]: Readonly<{ input: HarnessInput; result: HarnessResult }>;',
+    '  readonly [Id in RouteId]: Readonly<{',
+    '    input: HarnessInput;',
+    '    parsedInput: HarnessParsedInput;',
+    '    result: HarnessResult;',
+    '  }>;',
     '};',
     '',
     ...appDeclarations(routes),
diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts
index ab13a8e35..7d1e6884d 100644
--- a/packages/agent-bundle/src/test/index.ts
+++ b/packages/agent-bundle/src/test/index.ts
@@ -80,6 +80,7 @@ export type {
   RenderedRouteEvents,
   RouteTargetInput,
   RouteTargetConstraint,
+  RouteTargetParsedInput,
   RouteTargetResult,
 } from './render.ts';
 export { expectDocument } from './matchers.ts';
diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts
index cbc230c3a..90bfb2535 100644
--- a/packages/agent-bundle/src/test/mcp.ts
+++ b/packages/agent-bundle/src/test/mcp.ts
@@ -149,9 +149,10 @@ export type McpServerConstraint = string extends Server ? string : Regis
 export type McpRouteServer = Server extends RegisteredMcpServerName ? Server : string;
 
 /**
- * Options of one wire invocation. `Input` is the payload the generated server
- * hands the route: `invokeMcpTool` and `getMcpPrompt` bind it to the route's
- * registered input ({@link McpRouteInput}) when the name is a literal, and
+ * Options of one wire invocation. `Input` is the payload a caller sends over
+ * the wire — the route's `inputSchema` input, before the server parses it into
+ * what the route receives: `invokeMcpTool` and `getMcpPrompt` bind it to the
+ * route's registered input ({@link McpRouteInput}) when the name is a literal, and
  * it stays `unknown` — the previous shape — for a dynamic name or an
  * unregistered project. `Server` is the literal `server` option, when given;
  * it selects which server's route the name resolves to.
diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts
index d70e825bf..4ffb54d0f 100644
--- a/packages/agent-bundle/src/test/render.ts
+++ b/packages/agent-bundle/src/test/render.ts
@@ -22,6 +22,7 @@ import type {
   AgentRequestInit,
   RegisteredRouteId,
   RegisteredRouteInput,
+  RegisteredRouteParsedInput,
   RegisteredRouteResult,
 } from '@agent-bundle/runtime';
 import type * as React from 'react';
@@ -114,6 +115,9 @@ export type RouteTargetConstraint = Target extends AgentRouteModule
 /** 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 parsed input of a route target — what its component receives after defaults and transforms; `unknown` when unregistered. */
+export type RouteTargetParsedInput = Target extends RegisteredRouteId ? RegisteredRouteParsedInput : 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;
 
@@ -407,7 +411,7 @@ const componentProps = (
       };
     }
     case 'cli':
-      return { input: options.input ?? {}, signal };
+      return { input: parsedInput, signal };
     case 'script':
       return { argv: (invocation.props as { readonly input?: unknown }).input ?? [], signal };
     default: {
@@ -574,8 +578,8 @@ export interface LoadedRouteModule {
   readonly [exportName: string]: unknown;
   readonly config?: unknown;
   readonly default?: (props: never) => unknown;
-  /** The registration types what a caller sends, not what `parse` returns (the component's props after defaults and transforms). */
-  readonly inputSchema?: RouteModuleSchema;
+  /** `parse` returns the component's input — the caller's input after defaults and transforms. */
+  readonly inputSchema?: RouteModuleSchema>;
   readonly resultSchema?: RouteModuleSchema>;
 }
 
@@ -1465,7 +1469,7 @@ const prepareRender = async (
   const renderer = await loadRenderer();
   const surface = executableSurface(resolved.kind, resolved.provenance.routeId, resolved.manifest);
   const invocation = invocationFor(resolved.kind, resolved.provenance.routeId, surface, options, resolved.provenance);
-  const input = resolved.kind === 'prompt' || resolved.kind === 'resource' || resolved.kind === 'tool'
+  const input = resolved.kind === 'cli' || resolved.kind === 'prompt' || resolved.kind === 'resource' || resolved.kind === 'tool'
     ? parsedInput(resolved.module.inputSchema, options.input ?? {}, resolved.provenance)
     : undefined;
   const collected: AgentProgressUpdate[] = [];
diff --git a/packages/agent-bundle/tests/route-caller-input-types.test.ts b/packages/agent-bundle/tests/route-caller-input-types.test.ts
index 03c5760d2..784f0e486 100644
--- a/packages/agent-bundle/tests/route-caller-input-types.test.ts
+++ b/packages/agent-bundle/tests/route-caller-input-types.test.ts
@@ -149,11 +149,16 @@ it('types callers by schema input and components by schema output in a clean gen
     // The server program: the component sees the parsed output.
     writeProjectFile(root, 'src/handler-types.ts', [
       "import type { ToolRouteProps } from 'agent-bundle';",
+      "import type { RegisteredRouteInput, RegisteredRouteParsedInput } from '@agent-bundle/runtime';",
       "import type { inputSchema as measureSchema } from './mcp/curator/tools/measure.js';",
       "import type { inputSchema as pageSchema } from './mcp/curator/tools/page.js';",
       ...equality,
       "export type PageProps = Assert['input'], { limit: number }>>;",
       "export type MeasureProps = Assert['input'], { text: number }>>;",
+      '// The registration carries both sides: what a caller sends and what the component receives.',
+      "export type PageCaller = Assert, { limit?: number | undefined }>>;",
+      "export type PageParsed = Assert, { limit: number }>>;",
+      "export type MeasureParsed = Assert, { text: number }>>;",
       '',
     ].join('\n')),
     // Negative cases, each its own entry so one program reports exactly one rejection.
diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts
index 07eae3572..c2eee8db2 100644
--- a/packages/agent-bundle/tests/route-graph.test.ts
+++ b/packages/agent-bundle/tests/route-graph.test.ts
@@ -1465,7 +1465,7 @@ it('generates deterministic route-specific types from the compiled graph', () =>
   // 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};');
+  expect(first).toContain('export type AgentBundleRouteContracts = {\n  readonly [Id in RouteId]: Readonly<{\n    input: HarnessInput;\n    parsedInput: HarnessParsedInput;\n    result: HarnessResult;\n  }>;\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('AgentProviderValues');
@@ -1686,7 +1686,7 @@ it('resolves generated helper types for schema and event route contracts and the
       "import type { AgentBundleAppRouteContracts, AppToolRouteId, RouteId, RouteInput, RouteResult } from './.agent-bundle/routes.js';",
       "import type { WorkspaceOpenInput, WorkspaceOpenResult } from './src/events/workspace/open.js';",
       "import type { BriefInput, BriefResult } from './src/mcp/curator/prompts/brief.js';",
-      "import type { InspectInput, InspectResult } from './src/mcp/curator/tools/inspect.js';",
+      "import type { InspectInput, InspectParsedInput, InspectResult } from './src/mcp/curator/tools/inspect.js';",
       '',
       ...equalityHelpers,
       '',
@@ -1701,9 +1701,10 @@ it('resolves generated helper types for schema and event route contracts and the
       'export type AllResults = Assert, BriefResult | 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 }>>>;",
+      '// Both sides of the schema boundary are registered: the caller\'s input and the component\'s parsed input.',
+      "export type RegisteredInspect = Assert>>;",
+      '// An event route registers the harness payload (props without the signal the harness injects) on both sides and no result.',
+      "export type RegisteredEvent = Assert; parsedInput: Omit; result: undefined }>>>;",
       "export type RegisteredEventInput = Assert>;",
       '// The App registration is the MCP tool subset of the same contracts: the prompt and event routes are not',
       '// `tools/call` targets, so an App client cannot name them; the tool keeps its own schema types.',
diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts
index 7dc234ef5..7a3f8db1f 100644
--- a/packages/agent-bundle/tests/route-register-typegen.test.ts
+++ b/packages/agent-bundle/tests/route-register-typegen.test.ts
@@ -252,10 +252,10 @@ 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. The registration types what a caller sends and what the',
-      '  // resultSchema parses to; what the inputSchema parses to (the component\'s props) is not registered.',
+      '  // loadRouteModule checks its id the same way and types the schemas\' parsed values from the registration:',
+      '  // inputSchema.parse returns the component\'s input (the registered parsedInput), not what a caller sends.',
       "  const found_module = await loadRouteModule('tool:curator/find');",
-      "  const parsedQuery: unknown = found_module.inputSchema?.parse({ query: 'dune' });",
+      "  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({});',
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index 6ea8a9709..2fe29c819 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
 import { dirname, join } from 'node:path';
 
 import { afterEach, describe, expect, it } from '@rstest/core';
+import ts from 'typescript-5';
 
 import { inspect, validate } from '../src/api.ts';
 import { routeTypesProgramDiagnostics } from '../src/routes/typegen-program.ts';
@@ -165,6 +166,63 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     expect(codesOf((await validate({ root: buildOnly })).diagnostics)).not.toContain('AB4834');
   });
 
+  it('reads imports with the scanner, not by text: comments and strings are not consumers, import type in a .d.ts is', async () => {
+    const commentOnly = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // The program's own files mention the entries only in prose and a string literal.
+      'src/scripts/build.ts': [
+        "// Consumers import '@agent-bundle/runtime' or 'agent-bundle/test'; this script does neither.",
+        "export const hint = \"import { agent } from '@agent-bundle/runtime'\";",
+        '',
+      ].join('\n'),
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: commentOnly })).diagnostics)).not.toContain('AB4834');
+
+    const subpath = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': "import type { AgentRouteModule } from 'agent-bundle/routes';\nexport type Module = AgentRouteModule;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: subpath })).diagnostics)).not.toContain('AB4834');
+
+    const declaration = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/types.d.ts': "import type { RegisteredRouteId } from '@agent-bundle/runtime';\nexport type Id = RegisteredRouteId;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: declaration })).diagnostics)).toContain('AB4834');
+  });
+
+  it('tailors the recovery to a config without its own include array, whose patterns an include would replace', async () => {
+    const defaults = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // No `include`: the program is the `**/*` default, which never descends into dot-directories.
+      'tsconfig.json': `${JSON.stringify({ compilerOptions: { module: 'NodeNext', strict: true } }, null, 2)}\n`,
+    });
+    const [warning] = (await validate({ root: defaults })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(warning?.recovery).toContain('Add ".agent-bundle/routes.d.ts" to the "include" array of the config tsconfig.json extends, or declare an "include" array in tsconfig.json that lists ".agent-bundle/routes.d.ts" beside the patterns it compiles today (the default is "**/*")');
+
+    const inherited = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'tsconfig.base.json': tsconfig(['agent-bundle.config.ts', 'src/**/*.ts']),
+      'tsconfig.json': '{ "extends": "./tsconfig.base.json" }\n',
+    });
+    const [inheritedWarning] = (await validate({ root: inherited })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(inheritedWarning?.recovery).toContain('the config tsconfig.json extends');
+  });
+
+  it('matches the declaration path the way the host file system does', async () => {
+    const root = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // Case-mismatched: names the file on a case-insensitive host, nothing on a case-sensitive one.
+      'tsconfig.json': tsconfig(['.agent-bundle/Routes.d.ts', 'src/**/*.ts']),
+    });
+    const codes = codesOf((await validate({ root })).diagnostics);
+    if (ts.sys.useCaseSensitiveFileNames) expect(codes).toContain('AB4834');
+    else expect(codes).not.toContain('AB4834');
+  });
+
   it('has nothing to report without a tsconfig, without routes, or with an unparsable tsconfig', async () => {
     const noTsconfig = await createProject({ 'src/mcp/status/tools/report.ts': routeModule });
     expect(codesOf((await validate({ root: noTsconfig })).diagnostics)).not.toContain('AB4834');
diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts
index 298b891e1..113b0f003 100644
--- a/packages/agent-bundle/tests/route-unit/render-route.test.ts
+++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts
@@ -584,6 +584,17 @@ describe('layout composition at the route-unit level', () => {
     expect(error.message).toContain('route:        tool:harness/layout-probe (tool)');
   });
 
+  it('parses a rendered CLI route\'s input the same way, so CliRouteProps sees the schema output', async () => {
+    // The strict schema rejects an empty topic and an unknown mode before the component runs; without the
+    // parse both would render, since the component never reads what it does not use.
+    for (const input of [{ topic: '' }, { mode: 'bogus', topic: 'layouts' }]) {
+      const error = await rejection(renderRoute('cli:report', { input: input as never }));
+      expect(error).toBeInstanceOf(AgentTestError);
+      expect(error.code).toBe('invalid-input');
+      expect(error.message).toContain('route:        cli:report (cli)');
+    }
+  });
+
   it('applies only the root layout to a rendered CLI command', async () => {
     const rendered = await renderRoute('cli:report', { input: { topic: 'layouts' } });
 
diff --git a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts
index b507f2e09..4982a8e05 100644
--- a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts
+++ b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts
@@ -1,4 +1,4 @@
-import { readFile } from 'node:fs/promises';
+import { access, readFile, rm, writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
 
 import { afterAll, expect, it } from '@rstest/core';
@@ -52,6 +52,26 @@ it('scaffolds with independently versioned release tarballs and runs after sourc
     ?.endsWith(`agent-bundle-runtime-${pairing.runtime}.tgz`)).toBe(true);
 
   await installScaffoldedProject(projectRoot);
+
+  // `typecheck` on a clean checkout (#748): nothing has published the
+  // generated declaration yet, the script's `validate` step publishes it, and
+  // the program then consumes the registration — a wrong tool id is a compile
+  // error, not a `string` that type-checks.
+  const routeTypes = join(projectRoot, '.agent-bundle', 'routes.d.ts');
+  await expect(access(routeTypes)).rejects.toMatchObject({ code: 'ENOENT' });
+  await npmRun(projectRoot, 'typecheck');
+  await expect(readFile(routeTypes, 'utf8')).resolves.toContain('"tool:status/report-status"');
+  const wrongId = join(projectRoot, 'tests', 'route-unit', 'wrong-id.test.ts');
+  await writeFile(wrongId, [
+    "import { renderRoute } from 'agent-bundle/test';",
+    "void renderRoute('tool:status/does-not-exist', { input: {} });",
+    '',
+  ].join('\n'));
+  await expect(npmRun(projectRoot, 'typecheck')).rejects.toMatchObject({
+    stdout: expect.stringContaining("'\"tool:status/does-not-exist\"' is not assignable"),
+  });
+  await rm(wrongId);
+
   await npmRun(projectRoot, 'build');
 
   const artifact = join(projectRoot, 'artifact');
diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts
index 6b246d469..7c38f0cb9 100644
--- a/packages/rsc-runtime/src/agent-request.ts
+++ b/packages/rsc-runtime/src/agent-request.ts
@@ -304,7 +304,7 @@ export interface AgentProviderValues {
 /**
  * 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
+ * with `routes: AgentBundleRouteContracts` — a thin `{ input, parsedInput, 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
@@ -329,9 +329,15 @@ export interface AgentProviderValues {
 // 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`). */
+/**
+ * One registered route's harness contract: the `input` a caller sends (the
+ * schema's own input type), the `parsedInput` the component receives after
+ * defaults and transforms (the schema's output type), and the `result` a
+ * render returns (`undefined` for routes without a `resultSchema`).
+ */
 export interface RegisteredRouteContract {
   readonly input: unknown;
+  readonly parsedInput: unknown;
   readonly result: unknown;
 }
 
@@ -348,6 +354,11 @@ export type RegisteredRouteInput = Id extends keyof Registere
   ? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['input'] : unknown
   : unknown;
 
+/** The registered parsed-input type for one route id — what its component receives; `unknown` for an unregistered id. */
+export type RegisteredRouteParsedInput = Id extends keyof RegisteredRoutes
+  ? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['parsedInput'] : 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
diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts
index dc9688a84..b5f93b2f0 100644
--- a/packages/rsc-runtime/src/plugin.ts
+++ b/packages/rsc-runtime/src/plugin.ts
@@ -43,6 +43,7 @@ export type {
   RegisteredRouteContract,
   RegisteredRouteId,
   RegisteredRouteInput,
+  RegisteredRouteParsedInput,
   RegisteredRouteResult,
   RegisteredRoutes,
   AgentRequestCapabilities,
diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx
index 7c948fb2d..a13a6b3c8 100644
--- a/website/docs/en/guide/development/testing.mdx
+++ b/website/docs/en/guide/development/testing.mdx
@@ -82,7 +82,9 @@ component receives the schema's **output**, exactly as the generated server hand
 `renderRoute` parses the input through the route's own `inputSchema` at that same boundary: the
 component sees defaults filled and transforms applied, and an input the schema rejects fails with
 an `invalid-input` harness error before any provider or component runs. `result` is the
-`resultSchema` output.
+`resultSchema` output. Both sides are registered: `RegisteredRouteInput` (from
+`@agent-bundle/runtime`) is the caller's side and `RegisteredRouteParsedInput` the component's,
+which is what `loadRouteModule(id).inputSchema.parse(...)` returns.
 
 An event route registers what the harness actually takes and gives back: `input` is the
 `{ canonical, native, preflight? }` payload (the harness supplies `signal` itself), and `result` is
diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx
index d59ef06d4..9f5d2e55f 100644
--- a/website/docs/zh/guide/development/testing.mdx
+++ b/website/docs/zh/guide/development/testing.mdx
@@ -71,7 +71,9 @@ const chapters: number | undefined = result?.chapters; // 无需强制类型转
 `.transform()` 的字段按 wire 上的写法拼写——而路由组件收到的是 schema 的**输出**,与生成的服务器交给它的
 完全一致。`renderRoute` 在同一边界用路由自己的 `inputSchema` 解析输入:组件看到的是已填默认值、已应用
 transform 的值;schema 拒绝的输入会在任何 provider 或组件运行之前,以 `invalid-input` 测试错误失败。
-`result` 是 `resultSchema` 的输出。
+`result` 是 `resultSchema` 的输出。两侧都会被注册:`RegisteredRouteInput`(来自
+`@agent-bundle/runtime`)是调用方一侧,`RegisteredRouteParsedInput` 是组件一侧,也就是
+`loadRouteModule(id).inputSchema.parse(...)` 的返回类型。
 
 事件路由注册的是测试工具实际接受与返回的内容:`input` 是 `{ canonical, native, preflight? }` 载荷(`signal` 由测试
 工具自行提供),而 `result` 为 `undefined`,因为事件模块不导出 `resultSchema`。请用

From d014fa0870cb4630519afd65087ea1cd7d01a940 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Tue, 8 Sep 2026 00:51:41 +0000
Subject: [PATCH 4/9] examples(host-test): probe test uses a schema-valid
 tickMs now renderRoute parses input

---
 examples/host-test/tests/route-unit/routes.test.ts | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/examples/host-test/tests/route-unit/routes.test.ts b/examples/host-test/tests/route-unit/routes.test.ts
index 8c55f3fad..4eccf17d4 100644
--- a/examples/host-test/tests/route-unit/routes.test.ts
+++ b/examples/host-test/tests/route-unit/routes.test.ts
@@ -91,15 +91,15 @@ it('compiles every canonical event family plus the MCP and CLI surfaces', () =>
 });
 
 it('holds the slow probe open for the requested time, reporting one progress tick per tickMs, and records the call', async () => {
-  const slow = await render('tool:host-test/slow', { holdMs: 120, tickMs: 40 });
+  const slow = await render('tool:host-test/slow', { holdMs: 150, tickMs: 50 });
   expect(slow.document.value).toMatchObject({ ticks: 3 });
-  expect((slow.document.value as { heldMs: number }).heldMs).toBeGreaterThanOrEqual(100);
+  expect((slow.document.value as { heldMs: number }).heldMs).toBeGreaterThanOrEqual(130);
   expect(slow.progress.map((update) => update.completed)).toEqual([1, 2, 3]);
   // The probe is recorded like every other MCP call; the dump that reads it records itself too.
   const dumped = await render('tool:host-test/dump', {});
   expect(dumped.document.value).toMatchObject({
     records: [
-      expect.objectContaining({ event: 'mcp:slow', kind: 'mcp', observed: { holdMs: 120, tickMs: 40, tool: 'slow' } }),
+      expect.objectContaining({ event: 'mcp:slow', kind: 'mcp', observed: { holdMs: 150, tickMs: 50, tool: 'slow' } }),
       expect.objectContaining({ event: 'mcp:dump', kind: 'mcp' }),
     ],
   });

From 8d1754ab5d9112d03d797a661c35acc72313319a Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Tue, 8 Sep 2026 00:58:54 +0000
Subject: [PATCH 5/9] AB4834: judge the program's import closure, not its root
 list (Codex finding)

---
 docs/diagnostics.md                           | 11 ++--
 .../src/routes/typegen-program.ts             | 51 +++++++++++++------
 .../tests/route-types-program.test.ts         | 20 ++++++++
 3 files changed, 61 insertions(+), 21 deletions(-)

diff --git a/docs/diagnostics.md b/docs/diagnostics.md
index f438d977b..c136235ed 100644
--- a/docs/diagnostics.md
+++ b/docs/diagnostics.md
@@ -1072,11 +1072,12 @@ it: `create-agent-bundle` templates and the `examples/*` projects list
 because `**/*` never descends into dot-directories), while the file itself
 stays gitignored. After publishing the declaration, `agent-bundle validate`
 resolves the root `tsconfig.json` program the way `tsc -p` does — `extends`,
-`files`, `include`, `exclude` — and every program it `references`,
-transitively, and reports `AB4834` (a **warning**, surfaced by `validate`
-only) once per program that *consumes* the registration but does not compile
-the published file. A program consumes it when one of its source files
-imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or
+`files`, `include`, `exclude`, then the modules those roots import, so a
+narrow `files: ["src/index.ts"]` still reaches the consumer it imports — and
+every program it `references`, transitively, and reports `AB4834` (a
+**warning**, surfaced by `validate` only) once per program that *consumes*
+the registration but does not compile the published file. A program consumes
+it when one of the project's files in it imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or
 `@agent-bundle/runtime`; a build-only project that imports none of them is
 left alone, and a solution whose server project includes the file cannot
 hide a browser project that omits it. A project with no root
diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts
index 2c6d6b357..b22531163 100644
--- a/packages/agent-bundle/src/routes/typegen-program.ts
+++ b/packages/agent-bundle/src/routes/typegen-program.ts
@@ -31,7 +31,8 @@ const comparablePath = (path: string): string => {
 };
 
 interface Program {
-  readonly fileNames: readonly string[];
+  /** Every file `tsc -p` would compile: the configured roots plus the modules they import, transitively. */
+  readonly sourceFiles: readonly ts.SourceFile[];
   /** Whether the config file itself declares `include` (not inherited through `extends`, not the `**` default). */
   readonly ownInclude: boolean;
   readonly references: readonly string[];
@@ -39,10 +40,27 @@ interface Program {
 }
 
 /**
- * The root file names of one tsconfig's program, resolved the way `tsc -p`
- * resolves them (`extends`, `files`, `include`, `exclude`, against the real
- * file system), plus the referenced projects a solution-style root delegates
- * to. `undefined` when the file cannot be read or parsed as a config: `tsc`
+ * The project's own files in the program `tsc -p` would build from the parsed
+ * config: the roots plus the modules they import, transitively. Installed
+ * packages, the lib files, and automatic `@types` are not the project's
+ * consumers, so the host declines to parse them — a program a few hundred
+ * files smaller than the real one, with the same import closure over the
+ * project's sources.
+ */
+const projectSourceFiles = (parsed: ts.ParsedCommandLine): readonly ts.SourceFile[] => {
+  const options: ts.CompilerOptions = { ...parsed.options, noLib: true, types: [] };
+  const host = ts.createCompilerHost(options);
+  const getSourceFile = host.getSourceFile.bind(host);
+  host.getSourceFile = (fileName, ...rest) => (fileName.includes('/node_modules/') ? undefined : getSourceFile(fileName, ...rest));
+  return ts.createProgram({ host, options, rootNames: parsed.fileNames }).getSourceFiles();
+};
+
+/**
+ * One tsconfig's program, built the way `tsc -p` builds it: the roots from
+ * `extends`, `files`, `include`, and `exclude` against the real file system,
+ * then every module those roots import — a narrow `files: ["src/index.ts"]`
+ * still compiles the consumer it imports — plus the referenced projects a
+ * solution-style root delegates to. `undefined` when the file cannot be read or parsed as a config: `tsc`
  * reports that failure itself, and a broken tsconfig has no program to be
  * missing from.
  */
@@ -59,7 +77,7 @@ const program = (tsconfigPath: string): Program | undefined => {
     tsconfigPath,
   );
   return {
-    fileNames: parsed.fileNames,
+    sourceFiles: projectSourceFiles(parsed),
     ownInclude,
     references: (parsed.projectReferences ?? []).map((reference) => ts.resolveProjectReferencePath(reference)),
     tsconfigPath,
@@ -84,15 +102,16 @@ const programs = (rootTsconfigPath: string): readonly Program[] => {
 };
 
 /**
- * Whether one of the program's root files imports an entry the generated
- * declaration augments. The scanner's import pre-processing reads static and
- * dynamic import specifiers only — a specifier in a comment or a string
- * literal is not an import — and a user's own `.d.ts` counts like any other
- * root file, since `import type` from a consumer entry reads the registration too.
+ * Whether one of the project's files in the program imports an entry the
+ * generated declaration augments. The scanner's import pre-processing
+ * reads static and dynamic import specifiers only — a specifier in a comment
+ * or a string literal is not an import — and a user's own `.d.ts` counts like
+ * any other file, since `import type` from a consumer entry reads the
+ * registration too.
  */
-const consumesRegistration = (fileNames: readonly string[]): boolean =>
-  fileNames.some((fileName) =>
-    ts.preProcessFile(ts.sys.readFile(fileName) ?? '', true, false).importedFiles
+const consumesRegistration = (sourceFiles: readonly ts.SourceFile[]): boolean =>
+  sourceFiles.some((sourceFile) =>
+    ts.preProcessFile(sourceFile.text, true, false).importedFiles
       .some((imported) => consumerEntries.has(imported.fileName)));
 
 /**
@@ -114,8 +133,8 @@ export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diag
   const expected = comparablePath(routeTypesPath);
   return programs(rootTsconfigPath)
     .filter((candidate) =>
-      !candidate.fileNames.some((fileName) => comparablePath(fileName) === expected)
-      && consumesRegistration(candidate.fileNames))
+      !candidate.sourceFiles.some((sourceFile) => comparablePath(sourceFile.fileName) === expected)
+      && consumesRegistration(candidate.sourceFiles))
     .map((candidate) => {
       const tsconfig = relative(projectRoot, candidate.tsconfigPath).replaceAll('\\', '/');
       const include = JSON.stringify(relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/'));
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index 2fe29c819..a7799ecae 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -194,6 +194,26 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     expect(codesOf((await validate({ root: declaration })).diagnostics)).toContain('AB4834');
   });
 
+  it('follows imports from a narrow root, as tsc does: the consumer an entry point imports is in the program', async () => {
+    const narrow = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // The root file imports nothing augmented itself; the module it imports does.
+      'src/index.ts': "export { status } from './status.js';\n",
+      'src/status.ts': "import { invokeMcpTool } from 'agent-bundle/test';\nexport const status = () => invokeMcpTool('report', { input: {} });\n",
+      'tsconfig.json': tsconfig([], { files: ['src/index.ts'] }),
+    });
+    const diagnostics = (await validate({ root: narrow })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(diagnostics.map((diagnostic) => diagnostic.sourcePath)).toEqual([join(narrow, 'tsconfig.json')]);
+
+    // The declaration reached through an import counts as compiled, like any other module.
+    const referenced = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/index.ts': "/// \nimport { invokeMcpTool } from 'agent-bundle/test';\nexport const status = () => invokeMcpTool('report', { input: {} });\n",
+      'tsconfig.json': tsconfig([], { files: ['src/index.ts'] }),
+    });
+    expect(codesOf((await validate({ root: referenced })).diagnostics)).not.toContain('AB4834');
+  });
+
   it('tailors the recovery to a config without its own include array, whose patterns an include would replace', async () => {
     const defaults = await createProject({
       'src/mcp/status/tools/report.ts': routeModule,

From 43d530ed531632919d221171bba2241895275416 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Tue, 8 Sep 2026 01:17:28 +0000
Subject: [PATCH 6/9] AB4834 recovery: spell the include for the config being
 edited only (review finding)

---
 docs/diagnostics.md                                  |  2 +-
 packages/agent-bundle/src/routes/typegen-program.ts  |  4 +++-
 .../agent-bundle/tests/route-types-program.test.ts   | 12 ++++++++----
 3 files changed, 12 insertions(+), 6 deletions(-)

diff --git a/docs/diagnostics.md b/docs/diagnostics.md
index c136235ed..2c645bedc 100644
--- a/docs/diagnostics.md
+++ b/docs/diagnostics.md
@@ -1318,7 +1318,7 @@ resolving a provider set the author did not write.
 | `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. |
 | `AB4832` | error | A server layout (`src/mcp//layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. |
 | `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). |
-| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but a TypeScript program that consumes the registration — the root `tsconfig.json` or any project it `references`, transitively, resolved like `tsc -p` with `extends`, whose source imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` — does not compile it, so that program type-checks route ids as `string` and `input` / `result` / provider values as `unknown`. Reported once per such program, on its tsconfig; never for a program that imports none of those modules, nor for a project without a root `tsconfig.json`. Add the file to that tsconfig's `include` (not `files`: an `include` entry is inert until the first `validate` publishes the file, while a missing `files` entry is a `tsc` error); `validate`, `build`, and `dev` keep the file current and it stays gitignored. |
+| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but a TypeScript program that consumes the registration — the root `tsconfig.json` or any project it `references`, transitively, resolved like `tsc -p` with `extends`, whose source imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` — does not compile it, so that program type-checks route ids as `string` and `input` / `result` / provider values as `unknown`. Reported once per such program, on its tsconfig; never for a program that imports none of those modules, nor for a project without a root `tsconfig.json`. Add the file to that tsconfig's `include`, spelled relative to that tsconfig (not `files`: an `include` entry is inert until the first `validate` publishes the file, while a missing `files` entry is a `tsc` error); a tsconfig without its own `include` array must declare one that also lists the patterns it inherits or the `**/*` default, since an `include` array replaces them; `validate`, `build`, and `dev` keep the file current and it stays gitignored. |
 | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs:  }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. |
 | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. |
 | `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Keep framework calls in a host process: expose an MCP App with `web.apps` and open it from the installed artifact with ` web`; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/web-host`. |
diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts
index b22531163..9f3d24467 100644
--- a/packages/agent-bundle/src/routes/typegen-program.ts
+++ b/packages/agent-bundle/src/routes/typegen-program.ts
@@ -140,9 +140,11 @@ export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diag
       const include = JSON.stringify(relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/'));
       // An `include` array replaces the default (`**/*`) or the inherited
       // patterns, so a config without its own must keep them when it adds one.
+      // The path is spelled for this config; the one it extends may sit in
+      // another directory, so the recovery never points there.
       const where = candidate.ownInclude
         ? `Add ${include} to the "include" array of ${tsconfig}`
-        : `Add ${include} to the "include" array of the config ${tsconfig} extends, or declare an "include" array in ${tsconfig} that lists ${include} beside the patterns it compiles today (the default is "**/*")`;
+        : `Declare an "include" array in ${tsconfig} that lists ${include} beside the patterns it compiles today (an "include" array replaces the inherited or default "**/*" patterns)`;
       return {
         code: 'AB4834',
         message: `${tsconfig} imports agent-bundle/app, agent-bundle/test, agent-bundle/eval, or @agent-bundle/runtime but does not include the generated ${routeTypesRelativePath}, so that program type-checks route ids as string and input/result/provider values as unknown.`,
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index a7799ecae..4e54dd645 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -221,15 +221,19 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
       'tsconfig.json': `${JSON.stringify({ compilerOptions: { module: 'NodeNext', strict: true } }, null, 2)}\n`,
     });
     const [warning] = (await validate({ root: defaults })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
-    expect(warning?.recovery).toContain('Add ".agent-bundle/routes.d.ts" to the "include" array of the config tsconfig.json extends, or declare an "include" array in tsconfig.json that lists ".agent-bundle/routes.d.ts" beside the patterns it compiles today (the default is "**/*")');
+    expect(warning?.recovery).toContain('Declare an "include" array in tsconfig.json that lists ".agent-bundle/routes.d.ts" beside the patterns it compiles today (an "include" array replaces the inherited or default "**/*" patterns)');
 
+    // The inherited patterns live in another directory: the path is spelled
+    // for the config being edited, never for the one it extends.
     const inherited = await createProject({
       'src/mcp/status/tools/report.ts': routeModule,
-      'tsconfig.base.json': tsconfig(['agent-bundle.config.ts', 'src/**/*.ts']),
-      'tsconfig.json': '{ "extends": "./tsconfig.base.json" }\n',
+      'config/tsconfig.base.json': tsconfig(['../agent-bundle.config.ts', '../src/**/*.ts']),
+      'tsconfig.json': tsconfig([], { compilerOptions: { composite: true, module: 'NodeNext' }, files: [], references: [{ path: './config/tsconfig.app.json' }] }),
+      'config/tsconfig.app.json': '{ "extends": "./tsconfig.base.json", "compilerOptions": { "composite": true } }\n',
     });
     const [inheritedWarning] = (await validate({ root: inherited })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
-    expect(inheritedWarning?.recovery).toContain('the config tsconfig.json extends');
+    expect(inheritedWarning?.sourcePath).toBe(join(inherited, 'config/tsconfig.app.json'));
+    expect(inheritedWarning?.recovery).toContain('Declare an "include" array in config/tsconfig.app.json that lists "../.agent-bundle/routes.d.ts" beside the patterns it compiles today');
   });
 
   it('matches the declaration path the way the host file system does', async () => {

From 5ef3f498109045ffdbbc445d18b2a8c0731aede8 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Tue, 8 Sep 2026 01:20:51 +0000
Subject: [PATCH 7/9] AB4834: only the project's own files make a program a
 consumer (workspace-linked declarations do not)

---
 .../src/routes/typegen-program.ts             | 14 ++++++++-----
 .../tests/route-types-program.test.ts         | 21 ++++++++++++++++++-
 2 files changed, 29 insertions(+), 6 deletions(-)

diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts
index 9f3d24467..36a2f1ba1 100644
--- a/packages/agent-bundle/src/routes/typegen-program.ts
+++ b/packages/agent-bundle/src/routes/typegen-program.ts
@@ -102,16 +102,20 @@ const programs = (rootTsconfigPath: string): readonly Program[] => {
 };
 
 /**
- * Whether one of the project's files in the program imports an entry the
- * generated declaration augments. The scanner's import pre-processing
+ * Whether one of the project's own files in the program imports an entry the
+ * generated declaration augments. A declaration the program reached outside
+ * the project — a workspace-linked package's `dist`, which the host could not
+ * decline by path — is not the project's consumer even when it imports the
+ * runtime itself. The scanner's import pre-processing
  * reads static and dynamic import specifiers only — a specifier in a comment
  * or a string literal is not an import — and a user's own `.d.ts` counts like
  * any other file, since `import type` from a consumer entry reads the
  * registration too.
  */
-const consumesRegistration = (sourceFiles: readonly ts.SourceFile[]): boolean =>
+const consumesRegistration = (projectRoot: string, sourceFiles: readonly ts.SourceFile[]): boolean =>
   sourceFiles.some((sourceFile) =>
-    ts.preProcessFile(sourceFile.text, true, false).importedFiles
+    !relative(projectRoot, sourceFile.fileName).startsWith('..')
+    && ts.preProcessFile(sourceFile.text, true, false).importedFiles
       .some((imported) => consumerEntries.has(imported.fileName)));
 
 /**
@@ -134,7 +138,7 @@ export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diag
   return programs(rootTsconfigPath)
     .filter((candidate) =>
       !candidate.sourceFiles.some((sourceFile) => comparablePath(sourceFile.fileName) === expected)
-      && consumesRegistration(candidate.sourceFiles))
+      && consumesRegistration(projectRoot, candidate.sourceFiles))
     .map((candidate) => {
       const tsconfig = relative(projectRoot, candidate.tsconfigPath).replaceAll('\\', '/');
       const include = JSON.stringify(relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/'));
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index 4e54dd645..42f040755 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises';
 import { existsSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { dirname, join } from 'node:path';
@@ -194,6 +194,25 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     expect(codesOf((await validate({ root: declaration })).diagnostics)).toContain('AB4834');
   });
 
+  it('judges the project by its own files: a workspace-linked package that imports the runtime is not the consumer', async () => {
+    // A `workspace:*` link resolves outside the project root, where the host
+    // cannot decline it by path; its declaration imports the runtime, as the
+    // framework's own `agent-bundle/routes` declaration may.
+    const linked = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': "import type { AgentRouteModule } from 'agent-bundle/routes';\nexport type Module = AgentRouteModule;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    const sibling = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-route-types-linked-')));
+    roots.push(sibling);
+    await mkdir(join(sibling, 'agent-bundle'), { recursive: true });
+    await writeFile(join(sibling, 'agent-bundle/package.json'), '{"name":"agent-bundle","type":"module","exports":{"./routes":{"types":"./routes.d.ts"}}}\n');
+    await writeFile(join(sibling, 'agent-bundle/routes.d.ts'), "import type { RegisteredRouteId } from '@agent-bundle/runtime';\nexport interface AgentRouteModule { readonly id: RegisteredRouteId }\n");
+    await mkdir(join(linked, 'node_modules'), { recursive: true });
+    await symlink(join(sibling, 'agent-bundle'), join(linked, 'node_modules/agent-bundle'), 'dir');
+    expect(codesOf((await validate({ root: linked })).diagnostics)).not.toContain('AB4834');
+  });
+
   it('follows imports from a narrow root, as tsc does: the consumer an entry point imports is in the program', async () => {
     const narrow = await createProject({
       'src/mcp/status/tools/report.ts': routeModule,

From c10d9900a2acf0f5b5eb17cac18cc420f9b83481 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Tue, 8 Sep 2026 01:31:02 +0000
Subject: [PATCH 8/9] AB4834: build each program with its project references,
 as tsc -p does (review finding)

---
 .../agent-bundle/src/routes/typegen-program.ts     |  4 +++-
 .../agent-bundle/tests/route-types-program.test.ts | 14 ++++++++++++++
 2 files changed, 17 insertions(+), 1 deletion(-)

diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts
index 36a2f1ba1..51ba8e633 100644
--- a/packages/agent-bundle/src/routes/typegen-program.ts
+++ b/packages/agent-bundle/src/routes/typegen-program.ts
@@ -52,7 +52,9 @@ const projectSourceFiles = (parsed: ts.ParsedCommandLine): readonly ts.SourceFil
   const host = ts.createCompilerHost(options);
   const getSourceFile = host.getSourceFile.bind(host);
   host.getSourceFile = (fileName, ...rest) => (fileName.includes('/node_modules/') ? undefined : getSourceFile(fileName, ...rest));
-  return ts.createProgram({ host, options, rootNames: parsed.fileNames }).getSourceFiles();
+  // With the references, an import into a referenced project reads that
+  // project's emitted declaration, as `tsc -p` does, not its source.
+  return ts.createProgram({ host, options, projectReferences: parsed.projectReferences, rootNames: parsed.fileNames }).getSourceFiles();
 };
 
 /**
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index 42f040755..fc48458c8 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -213,6 +213,20 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     expect(codesOf((await validate({ root: linked })).diagnostics)).not.toContain('AB4834');
   });
 
+  it('reads a referenced project through its emitted declaration, as tsc does, so its source-only imports do not make the parent a consumer', async () => {
+    const solution = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // The child consumes the registration in source; its emitted declaration does not.
+      'child/src/status.ts': "import { invokeMcpTool } from 'agent-bundle/test';\nexport const status = (): Promise => invokeMcpTool('report', { input: {} });\n",
+      'child/dist/status.d.ts': 'export declare const status: () => Promise;\n',
+      'child/tsconfig.json': tsconfig(['src/**/*.ts'], { compilerOptions: { composite: true, declaration: true, module: 'NodeNext', outDir: 'dist', rootDir: 'src' } }),
+      'app/index.ts': "export { status } from '../child/src/status.js';\n",
+      'tsconfig.json': tsconfig([], { files: ['app/index.ts'], references: [{ path: './child' }] }),
+    });
+    const diagnostics = (await validate({ root: solution })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(diagnostics.map((diagnostic) => diagnostic.sourcePath)).toEqual([join(solution, 'child/tsconfig.json')]);
+  });
+
   it('follows imports from a narrow root, as tsc does: the consumer an entry point imports is in the program', async () => {
     const narrow = await createProject({
       'src/mcp/status/tools/report.ts': routeModule,

From f046cf8ca03fea1e6b59a5a2ff590d2bbeb44ae1 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy 
Date: Tue, 8 Sep 2026 01:45:26 +0000
Subject: [PATCH 9/9] AB4834: a file reached through a package import is a
 dependency, not the project's consumer (review finding)

---
 .../src/routes/typegen-program.ts             | 29 +++++++++----------
 .../tests/route-types-program.test.ts         | 13 +++++++++
 2 files changed, 27 insertions(+), 15 deletions(-)

diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts
index 51ba8e633..6c4245d56 100644
--- a/packages/agent-bundle/src/routes/typegen-program.ts
+++ b/packages/agent-bundle/src/routes/typegen-program.ts
@@ -31,8 +31,8 @@ const comparablePath = (path: string): string => {
 };
 
 interface Program {
-  /** Every file `tsc -p` would compile: the configured roots plus the modules they import, transitively. */
-  readonly sourceFiles: readonly ts.SourceFile[];
+  /** The program `tsc -p` would build: the configured roots plus the modules they import, transitively. */
+  readonly program: ts.Program;
   /** Whether the config file itself declares `include` (not inherited through `extends`, not the `**` default). */
   readonly ownInclude: boolean;
   readonly references: readonly string[];
@@ -47,14 +47,14 @@ interface Program {
  * files smaller than the real one, with the same import closure over the
  * project's sources.
  */
-const projectSourceFiles = (parsed: ts.ParsedCommandLine): readonly ts.SourceFile[] => {
+const projectProgram = (parsed: ts.ParsedCommandLine): ts.Program => {
   const options: ts.CompilerOptions = { ...parsed.options, noLib: true, types: [] };
   const host = ts.createCompilerHost(options);
   const getSourceFile = host.getSourceFile.bind(host);
   host.getSourceFile = (fileName, ...rest) => (fileName.includes('/node_modules/') ? undefined : getSourceFile(fileName, ...rest));
   // With the references, an import into a referenced project reads that
   // project's emitted declaration, as `tsc -p` does, not its source.
-  return ts.createProgram({ host, options, projectReferences: parsed.projectReferences, rootNames: parsed.fileNames }).getSourceFiles();
+  return ts.createProgram({ host, options, projectReferences: parsed.projectReferences, rootNames: parsed.fileNames });
 };
 
 /**
@@ -79,7 +79,7 @@ const program = (tsconfigPath: string): Program | undefined => {
     tsconfigPath,
   );
   return {
-    sourceFiles: projectSourceFiles(parsed),
+    program: projectProgram(parsed),
     ownInclude,
     references: (parsed.projectReferences ?? []).map((reference) => ts.resolveProjectReferencePath(reference)),
     tsconfigPath,
@@ -105,18 +105,18 @@ const programs = (rootTsconfigPath: string): readonly Program[] => {
 
 /**
  * Whether one of the project's own files in the program imports an entry the
- * generated declaration augments. A declaration the program reached outside
- * the project — a workspace-linked package's `dist`, which the host could not
- * decline by path — is not the project's consumer even when it imports the
- * runtime itself. The scanner's import pre-processing
+ * generated declaration augments. A file the program reached through a package
+ * import — a workspace-linked package's `dist`, which resolves to a real path
+ * the host could not decline — is a dependency, not the project's consumer,
+ * even when it imports the runtime itself. The scanner's import pre-processing
  * reads static and dynamic import specifiers only — a specifier in a comment
  * or a string literal is not an import — and a user's own `.d.ts` counts like
  * any other file, since `import type` from a consumer entry reads the
  * registration too.
  */
-const consumesRegistration = (projectRoot: string, sourceFiles: readonly ts.SourceFile[]): boolean =>
-  sourceFiles.some((sourceFile) =>
-    !relative(projectRoot, sourceFile.fileName).startsWith('..')
+const consumesRegistration = (program: ts.Program): boolean =>
+  program.getSourceFiles().some((sourceFile) =>
+    !program.isSourceFileFromExternalLibrary(sourceFile)
     && ts.preProcessFile(sourceFile.text, true, false).importedFiles
       .some((imported) => consumerEntries.has(imported.fileName)));
 
@@ -136,11 +136,10 @@ export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diag
   const routeTypesPath = join(projectRoot, routeTypesRelativePath);
   const rootTsconfigPath = join(projectRoot, projectTsconfigFilename);
   if (!existsSync(routeTypesPath) || !existsSync(rootTsconfigPath)) return [];
-  const expected = comparablePath(routeTypesPath);
   return programs(rootTsconfigPath)
     .filter((candidate) =>
-      !candidate.sourceFiles.some((sourceFile) => comparablePath(sourceFile.fileName) === expected)
-      && consumesRegistration(projectRoot, candidate.sourceFiles))
+      candidate.program.getSourceFile(routeTypesPath) === undefined
+      && consumesRegistration(candidate.program))
     .map((candidate) => {
       const tsconfig = relative(projectRoot, candidate.tsconfigPath).replaceAll('\\', '/');
       const include = JSON.stringify(relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/'));
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index fc48458c8..e8d644d1b 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -211,6 +211,19 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     await mkdir(join(linked, 'node_modules'), { recursive: true });
     await symlink(join(sibling, 'agent-bundle'), join(linked, 'node_modules/agent-bundle'), 'dir');
     expect(codesOf((await validate({ root: linked })).diagnostics)).not.toContain('AB4834');
+
+    // A monorepo root links its own package from inside the project root; the
+    // program still reached it through node_modules, so it is a dependency.
+    const monorepo = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': "import type { AgentRouteModule } from 'agent-bundle/routes';\nexport type Module = AgentRouteModule;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+      'packages/agent-bundle/package.json': '{"name":"agent-bundle","type":"module","exports":{"./routes":{"types":"./routes.d.ts"}}}\n',
+      'packages/agent-bundle/routes.d.ts': "import type { RegisteredRouteId } from '@agent-bundle/runtime';\nexport interface AgentRouteModule { readonly id: RegisteredRouteId }\n",
+    });
+    await mkdir(join(monorepo, 'node_modules'), { recursive: true });
+    await symlink(join(monorepo, 'packages/agent-bundle'), join(monorepo, 'node_modules/agent-bundle'), 'dir');
+    expect(codesOf((await validate({ root: monorepo })).diagnostics)).not.toContain('AB4834');
   });
 
   it('reads a referenced project through its emitted declaration, as tsc does, so its source-only imports do not make the parent a consumer', async () => {