From cdf458254e49e3eb119c927b3a3db9292f86ceee Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:19:03 +0000 Subject: [PATCH 1/4] feat(routes): add the conventional shared layout module for rendered routes (#312) --- .changeset/shared-route-layouts.md | 6 + docs/diagnostics.md | 9 +- docs/entry-conventions.md | 83 +++++- docs/framework-mode.md | 8 + examples/audiobook-curator/README.md | 26 +- .../src/cli/audible-search.tsx | 10 +- examples/audiobook-curator/src/cli/audit.tsx | 10 +- .../audiobook-curator/src/cli/convert.tsx | 7 +- .../audiobook-curator/src/cli/inventory.tsx | 10 +- .../src/cli/library-audit.tsx | 9 +- examples/audiobook-curator/src/cli/select.tsx | 10 +- .../src/components/curator-document.tsx | 40 --- examples/audiobook-curator/src/layout.tsx | 26 ++ .../tools/apply_audiobook_chapters.tsx | 8 +- .../tools/apply_audiobook_metadata.tsx | 8 +- .../src/mcp/curator/tools/audit_audiobook.tsx | 10 +- .../src/mcp/curator/tools/audit_library.tsx | 9 +- .../curator/tools/cache_audible_edition.tsx | 7 +- .../mcp/curator/tools/convert_audiobook.tsx | 7 +- .../curator/tools/identify_audible_sample.tsx | 7 +- .../src/mcp/curator/tools/inspect_sources.tsx | 10 +- .../mcp/curator/tools/inventory_sources.tsx | 10 +- .../mcp/curator/tools/prepare_audiobook.tsx | 7 +- .../src/mcp/curator/tools/search_audible.tsx | 10 +- .../curator/tools/select_audible_edition.tsx | 11 +- .../src/mcp/curator/tools/select_sources.tsx | 10 +- .../curator/tools/verify_audible_sample.tsx | 7 +- .../mcp/curator/tools/verify_with_whisper.tsx | 10 +- .../tests/route-unit/routes.test.ts | 24 +- packages/agent-bundle/README.md | 2 + .../fixtures/route-harness/src/layout.tsx | 17 ++ .../route-harness/src/mcp/harness/layout.tsx | 19 ++ .../src/mcp/harness/tools/layout-probe.tsx | 20 ++ packages/agent-bundle/src/build/build.ts | 2 + packages/agent-bundle/src/build/entries.ts | 7 +- .../agent-bundle/src/build/entry-shell.ts | 106 +++++++- .../agent-bundle/src/build/inspect-bundler.ts | 1 + .../agent-bundle/src/build/package-build.ts | 2 + packages/agent-bundle/src/config/normalize.ts | 2 + .../agent-bundle/src/core/project-context.ts | 9 + packages/agent-bundle/src/core/types.ts | 4 +- packages/agent-bundle/src/index.ts | 2 + packages/agent-bundle/src/routes/contract.ts | 30 +- packages/agent-bundle/src/routes/graph.ts | 121 ++++++++- packages/agent-bundle/src/routes/index.ts | 6 + packages/agent-bundle/src/routes/layouts.ts | 80 ++++++ packages/agent-bundle/src/routes/public.ts | 19 ++ packages/agent-bundle/src/routes/types.ts | 24 ++ .../agent-bundle/src/rstest/setup-module.ts | 5 + packages/agent-bundle/src/test/index.ts | 1 + packages/agent-bundle/src/test/layouts.ts | 111 ++++++++ packages/agent-bundle/src/test/manifest.ts | 24 ++ packages/agent-bundle/src/test/mcp.ts | 19 +- packages/agent-bundle/src/test/registry.ts | 18 +- packages/agent-bundle/src/test/render.ts | 40 ++- packages/agent-bundle/src/test/types.ts | 5 + .../agent-bundle/tests/entry-shell.test.ts | 186 ++++++++++++- .../agent-bundle/tests/layout-build.test.ts | 257 ++++++++++++++++++ .../tests/packed-stdio-projection.test.ts | 1 + .../tests/projection/cli-dispatch-mcp.test.ts | 15 + .../tests/projection/cli-dispatch.test.ts | 1 + .../tests/projection/mcp-in-memory.test.ts | 36 ++- .../agent-bundle/tests/route-graph.test.ts | 126 +++++++++ .../tests/route-unit/render-route.test.ts | 61 +++++ .../tests/support/contract-matrix-fixtures.ts | 1 + .../tests/test-harness-manifest.test.ts | 27 ++ packages/rsc-runtime/src/decode-document.ts | 178 +++++++++++- packages/rsc-runtime/src/elements.ts | 39 ++- packages/rsc-runtime/src/index.ts | 3 + packages/rsc-runtime/tests/dispatcher.test.ts | 224 +++++++++++++++ rstest.integration-tests.ts | 1 + 71 files changed, 2074 insertions(+), 187 deletions(-) create mode 100644 .changeset/shared-route-layouts.md delete mode 100644 examples/audiobook-curator/src/components/curator-document.tsx create mode 100644 examples/audiobook-curator/src/layout.tsx create mode 100644 packages/agent-bundle/fixtures/route-harness/src/layout.tsx create mode 100644 packages/agent-bundle/fixtures/route-harness/src/mcp/harness/layout.tsx create mode 100644 packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx create mode 100644 packages/agent-bundle/src/routes/layouts.ts create mode 100644 packages/agent-bundle/src/test/layouts.ts create mode 100644 packages/agent-bundle/tests/layout-build.test.ts diff --git a/.changeset/shared-route-layouts.md b/.changeset/shared-route-layouts.md new file mode 100644 index 000000000..cb023a654 --- /dev/null +++ b/.changeset/shared-route-layouts.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"@agent-bundle/runtime": minor +--- + +Add the conventional shared layout module: `src/layout.{ts,tsx}` default-exports one component receiving `{ children, route, signal }` (`AgentLayoutProps` from `@agent-bundle/runtime`, with `AgentLayoutRoute` and `AgentLayoutRouteKind`) and wraps every rendered route — generated MCP tools, resources, and prompts, rendered `src/cli/**` commands, projected MCP commands, and rendered `src/scripts/*.tsx` — while `src/mcp//layout.{ts,tsx}` nests inside it for one generated server; event routes and App routes are never wrapped, servers pinned to `custom`, `command`, or `remote` skip their layout, and a project without a layout renders byte-identical MCP, CLI, and script output with an unchanged route-graph digest (generated worker source changes as in every release). `agent-bundle inspect --routes` lists layouts, `agent-bundle/test` (`renderRoute`, cli-dispatch, mcp-in-memory) composes the same chain the artifact bakes, and validation fails closed with `AB4830` (layout contract), `AB4831` (duplicate layout scope), and `AB4832` (orphaned server layout). Breaking for typed consumers: `AgentBundleTestManifest` gains the required `layouts` field and the test registry version is now 5. In `@agent-bundle/runtime`, `decodeAgentDocument` now treats an `Agent.Result` without a `value` as a container that adopts the value of the valued result it directly holds and merges plain-JSON object `metadata` with the container winning — a behavior change for documents that previously nested a valued result under a valueless root — so a layout shell leaves the route's result value, `structuredContent`, and content unchanged, while metadata a layout declares is projected as MCP `_meta` (#396) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d808bc684..b13a89d14 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -26,6 +26,7 @@ gate a build, a validation, or a dev rebuild. | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4906`) and commands `src/commands/*.md` (`AB4920`–`AB4926`); see below. | +| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | @@ -450,11 +451,12 @@ canonicalized so the model digest is root-independent. | `AB4925` | error | A command explicitly targets a host whose `commands` capability is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). | Drop that host from the command's `targets`; Cursor and Claude publish command surfaces, Codex and portable do not. | | `AB4926` | error | Two command files share a name. | Rename one file so every command name is unique. | -## Route graph, state, and provider conventions (`AB4800`–`AB4825`, `AB4940`–`AB4942`) +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4832`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, -`src/providers/*`, `src/cli/**`, `src/scripts/**`) into one immutable IR. +`src/providers/*`, `src/cli/**`, `src/scripts/**`) and the shared layout +modules (`src/layout.*`, `src/mcp//layout.*`) into one immutable IR. Discovery is not a packaging choice, so every collision is a hard **error** and the compiler never silently picks a side. Modules that explicit `scripts`, `hooks`, `bin`, `lib`, or `mcp` configuration references are @@ -640,6 +642,9 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4826` | error | A route's static `config` calls `appResourceUri('')` with a reference that matches no App route of the route's own generated server with a static `config.resourceUri`: an unknown name, another server's App (a generated server registers only its own Apps), or a reference from a non-MCP route. The message names the cause and lists the server's known App route ids; reference the App as `''`, `'/'`, `'app:/'`, or a relative module path. | | `AB4827` | error | An MCP App route's `config.template` is ambiguous or missing: both the route-relative and the project-root-relative interpretation name different existing files, or neither exists. The message names both candidate paths; templates resolve relative to the route module, so rewrite the path as `'./.html'` beside the route. | | `AB4828` | error | A generated MCP route advertises `_meta.ui.resourceUri` of an App on its server (through `appResourceUri()` or a literal) that is not built for every target the server ships to, because the App's `config.targets` (or a config-declared App's `targets`) is narrower. Widen the App's targets or restrict `mcp.servers..targets`. | +| `AB4830` | error | A conventional layout module (`src/layout.*`, `src/mcp//layout.*`) does not satisfy the layout contract: its default export is not a function component, it exports the route-only `config`/`inputSchema`/`resultSchema`, or it exports `execute`/`render`. Default-export one component receiving `{ children, route, signal }` that renders `Agent.Result` around `children`. | +| `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. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 9cbe31eb7..e8fb66945 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -83,6 +83,8 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | | `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | +| `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed CLI commands, projected MCP commands, and rendered scripts. Event routes are never wrapped. | Rename to `_layout.tsx` | +| `src/mcp//layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.` to a non-generated mode | Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. @@ -226,6 +228,85 @@ worker, so module-level provider state is shared across the simulated executables of that worker and is only proven cold by the proof levels that spawn the artifact. +### Shared layouts + +A layout is the conventional composition point around every rendered route +of a project — the `layout.tsx` idea from page frameworks, applied to Agent +Documents. `src/layout.{ts,tsx}` wraps every rendered route (generated MCP +tools, resources, and prompts; rendered `src/cli/**` commands; projected MCP +commands; rendered `src/scripts/*.tsx`), and `src/mcp//layout.{ts,tsx}` +nests inside it for one generated server. Composition order is root layout, +then server layout, then the route. Event routes are host protocol responses +rather than documents for a reader, so no layout applies to them; browser +App routes are browser builds and are likewise untouched. + +```tsx +// src/layout.tsx — the whole layout a consumer writes +import { Agent, type AgentLayoutProps } from '@agent-bundle/runtime'; +import React from 'react'; + +export default function Layout({ children, route }: AgentLayoutProps) { + return ( + + {children} + + ); +} +``` + +The layout renders `Agent.Result` around `children`, the route's rendered +element. An `Agent.Result` that declares no `value` is a **container**: when +it directly holds a result that does carry a value — the route's own +`` — the runtime merges the two while decoding the +document. The route's value becomes the document value, its children take the +inner result's place, and `metadata` combines: two JSON objects merge key by +key with the container winning conflicts, any other shape lets the container +win outright, and a container without metadata adopts the inner one. A route +therefore keeps its result value, its `structuredContent`, and its rendered +content whether or not a layout exists; what the layout adds is the shared +shell around it — a heading, a trailing `Agent.Context` note, document +metadata. Because the MCP projector exposes root metadata as the result's +`_meta`, a layout that declares metadata does change the MCP response there; +a layout without metadata leaves `_meta` exactly as the route authored it. +Nested layouts merge bottom-up the same way. Metadata on either side must be +plain JSON: a `Date`, class instance, accessor, or cyclic value fails the +document contract under a layout exactly as it does without one. + +The generated worker resolves the route's element **before** the layout chain +renders, then wraps it. That keeps failure semantics identical with and +without a layout — a route that throws rejects the whole render (CLI exit 1 +with the route's message, MCP transport failure) instead of being downgraded +to a represented `boundary` error beneath the layout's shell. The trade-off is +deliberate: a layout cannot stream a `Suspense` fallback around `children` +while the route is still running, because the route is never a lazily +resolved Flight chunk under the layout. A `Suspense` boundary a layout places +around its **own** content streams as usual. + +`route` is the stable identity baked at compile time: `id` (`tool:curator/ +inspect_sources`), `kind` (`tool`, `resource`, `prompt`, `cli`, `script`), +`name` (the protocol-facing tool/resource/prompt name, the space-joined +command path, or the script name), and `serverId` for MCP kinds. `signal` is +the request abort signal. Layouts render inside the same request scope as the +route, so `await agent()` exposes the invocation, host, session, actor, +workspace, state, and provider axes exactly as it does in a route. The props +type `AgentLayoutProps` ships from `@agent-bundle/runtime` (it carries React's +`ReactNode` children); `agent-bundle` exports the React-free `AgentLayoutRoute` +and `AgentLayoutRouteKind` identity types. + +The compiler validates layouts statically: a layout whose default export is +not a function, or that carries the route contract's `config`/`inputSchema`/ +`resultSchema` exports, fails with `AB4830`; `.ts` and `.tsx` siblings for +one scope fail with `AB4831`; a server layout whose server declares no tool, +resource, or prompt route modules (a missing server, or one with only `apps/`) +fails with `AB4832`, while a server pinned to `custom`, `command`, or `remote` +via `routes.servers.` skips its layout entirely. At run time a layout module that resolves to a +non-function default export fails the request closed before rendering. The +route-unit and projection levels of `agent-bundle/test` compose the same +layout chain the generated workers bake, so `renderRoute('tool:...')` and +`invokeMcpTool(...)` prove the composed document; rendering a module passed +directly to `renderRoute()` composes no layout, because layouts are a compiler +convention rather than a property of the module. + ### Handler request context Conventional route components receive only their surface props, such as @@ -353,7 +434,7 @@ export default async function inspect({ input, signal }: CliRouteProps.js` with the shebang and executable bit through the same Rslib synthesis as every other bin. At run time the shell resolves the diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 2c855b7ea..f9e4fea4e 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -17,6 +17,14 @@ Agent Bundle has one newcomer model: `execute`/`render` split. 4. **Opt in to context.** Call `await agent()` inside that component only when host, session, actor, workspace, capability, or state context is needed. +5. **Share the shell once.** An optional `src/layout.tsx` (and + `src/mcp//layout.tsx` for one server) default-exports a component + receiving `{ children, route, signal }` (`AgentLayoutProps` from + `@agent-bundle/runtime`) and renders `Agent.Result` around + every rendered route; the route keeps its own ``, and the + runtime merges the two so the result value and content are unchanged (layout + metadata surfaces as MCP `_meta`). + See [Shared layouts](entry-conventions.md#shared-layouts). The complete conventional config is usually: diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 7cb7aac7a..117510010 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -67,6 +67,31 @@ The routed CLI under `src/cli/` contains 16 authored commands. The Projected tools accept one optional `--input ''`; tools annotated read-only run directly, while mutation-capable tools require `--yes`. +### `src/layout.tsx` is the shared document shell + +The conventional layout module wraps every rendered route once — the 16 MCP +tools, the catalog resource, the curate prompt, the rendered CLI commands, and +the projected `curator ` commands — so no route imports a wrapper to get +the server's standard document structure. The layout renders a container +`Agent.Result` and the runtime merges each route's own +`` into it: the structured receipt, the MCP +content, and the CLI Markdown are exactly what the route declared, and the +shell contributes document metadata naming the producing route and surface, +which MCP hosts receive as `CallToolResult._meta.curator`. A route therefore +states only its value, its headline, and its report: + +```tsx +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as InventoryReceipt; + return ( + + {inventoryHeadline(receipt)} + + + ); +} +``` + ### `src/components/` is the shared presentation library The route modules perform domain work and compose these report components @@ -74,7 +99,6 @@ instead of maintaining separate MCP and CLI presenters: | Component | MCP composition | Rendered authored CLI composition | | --- | --- | --- | -| `CuratorDocument` and its `CuratorReceipt` union | Wrap the structured receipt and headline for 15 receipt-bearing tools | Wrap `inventory`, `select`, `audible-search`, `convert`, `audit`, and `library-audit` | | `DataList`, `Field`, `Callout`, and `FileList` | Provide atomic report fields, prose callouts, and file-list blocks throughout the component library and directly in the catalog resource, curate prompt, cache route, and library audit | Provide the same primitives through the shared components and directly in `library-audit` | | `FileCard` and `EditionCard`, fed by `view-models` | Render file and edition models in `audit_library`, shelf, and ranking views | Reached through the receipt-specific shelves and ranking components | | `InspectionShelf`, `InventoryShelf`, `AuditShelf`, and `SelectionShelf` | Compose receipt-specific inspection, inventory, audit, and selection reports; the inspection, inventory, and selection shelves are used directly by their MCP routes | `InventoryShelf` and `SelectionShelf` compose `inventory` and `select` | diff --git a/examples/audiobook-curator/src/cli/audible-search.tsx b/examples/audiobook-curator/src/cli/audible-search.tsx index 71cf99cb7..ea3f80853 100644 --- a/examples/audiobook-curator/src/cli/audible-search.tsx +++ b/examples/audiobook-curator/src/cli/audible-search.tsx @@ -1,10 +1,10 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; import type { AudibleSearchReceipt } from '../audible.js'; import { SearchRanking } from '../components/candidate-ranking.js'; -import { CuratorDocument } from '../components/curator-document.js'; import { audibleSearchHeadline } from '../components/headlines.js'; import { audibleOperations, audibleRegionList, defaultAudibleOperations } from '../operations/audible.js'; @@ -43,11 +43,9 @@ export default async function audibleSearch({ input, signal }: CliRouteProps + + {audibleSearchHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/cli/audit.tsx b/examples/audiobook-curator/src/cli/audit.tsx index 523bdd7de..88c50a499 100644 --- a/examples/audiobook-curator/src/cli/audit.tsx +++ b/examples/audiobook-curator/src/cli/audit.tsx @@ -1,9 +1,9 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; import { ChapterOutline, chaptersFromAuditReceipt } from '../components/chapter-outline.js'; -import { CuratorDocument } from '../components/curator-document.js'; import { integrityAuditHeadline } from '../components/headlines.js'; import { IntegrityAuditReport } from '../components/integrity-report.js'; import type { IntegrityAuditReceipt } from '../integrity-audit.js'; @@ -28,12 +28,10 @@ export const resultSchema = operation.resultSchema; export default async function audit({ input, signal }: CliRouteProps) { const receipt = await operation.handler(input, { signal }) as IntegrityAuditReceipt; return ( - + + {integrityAuditHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/cli/convert.tsx b/examples/audiobook-curator/src/cli/convert.tsx index b23330132..14562fcb8 100644 --- a/examples/audiobook-curator/src/cli/convert.tsx +++ b/examples/audiobook-curator/src/cli/convert.tsx @@ -1,9 +1,9 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; import { ChapterOutline, chaptersFromConvertReceipt } from '../components/chapter-outline.js'; -import { CuratorDocument } from '../components/curator-document.js'; import { convertHeadline } from '../components/headlines.js'; import { ConversionIntegrityReport } from '../components/integrity-report.js'; import { ConversionMutation } from '../components/mutation-receipt.js'; @@ -41,10 +41,11 @@ export const resultSchema = operation.resultSchema; export default async function convert({ input, signal }: CliRouteProps) { const receipt = await operation.handler(input, { signal }) as ConvertReceipt; return ( - + + {convertHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/cli/inventory.tsx b/examples/audiobook-curator/src/cli/inventory.tsx index f0aefc6d7..326f5c327 100644 --- a/examples/audiobook-curator/src/cli/inventory.tsx +++ b/examples/audiobook-curator/src/cli/inventory.tsx @@ -1,8 +1,8 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { CuratorDocument } from '../components/curator-document.js'; import { inventoryHeadline } from '../components/headlines.js'; import { InventoryShelf } from '../components/library-shelf.js'; import type { InventoryReceipt } from '../library.js'; @@ -27,11 +27,9 @@ export const resultSchema = operation.resultSchema; export default async function inventory({ input, signal }: CliRouteProps) { const receipt = await operation.handler(input, { signal }) as InventoryReceipt; return ( - + + {inventoryHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/cli/library-audit.tsx b/examples/audiobook-curator/src/cli/library-audit.tsx index 5a4cd9ebf..f684ead56 100644 --- a/examples/audiobook-curator/src/cli/library-audit.tsx +++ b/examples/audiobook-curator/src/cli/library-audit.tsx @@ -3,7 +3,6 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -import { CuratorDocument } from '../components/curator-document.js'; import { libraryAuditCliHeadline } from '../components/headlines.js'; import { LibraryAnalysis } from '../components/library-analysis.js'; import { DataList } from '../components/primitives.js'; @@ -45,10 +44,8 @@ export default async function LibraryAudit({ input, signal }: CliRouteProps + + {libraryAuditCliHeadline(receipt, total)} ## Library audit }> - + ); } diff --git a/examples/audiobook-curator/src/cli/select.tsx b/examples/audiobook-curator/src/cli/select.tsx index 48311bddb..9a74076b8 100644 --- a/examples/audiobook-curator/src/cli/select.tsx +++ b/examples/audiobook-curator/src/cli/select.tsx @@ -1,8 +1,8 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { CuratorDocument } from '../components/curator-document.js'; import { selectionHeadline } from '../components/headlines.js'; import { SelectionShelf } from '../components/library-shelf.js'; import type { SelectionReceipt } from '../library.js'; @@ -24,11 +24,9 @@ export const resultSchema = operation.resultSchema; export default async function select({ input, signal }: CliRouteProps) { const receipt = await operation.handler(input, { signal }) as SelectionReceipt; return ( - + + {selectionHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/components/curator-document.tsx b/examples/audiobook-curator/src/components/curator-document.tsx deleted file mode 100644 index 4d95bd1db..000000000 --- a/examples/audiobook-curator/src/components/curator-document.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Agent } from '@agent-bundle/runtime'; -import React, { type ReactNode } from 'react'; - -import type { AudibleCacheReceipt, AudibleSearchReceipt, AudibleSelectionReceipt } from '../audible.ts'; -import type { ConvertReceipt } from '../conversion.ts'; -import type { InspectionReceipt, PrepareReceipt } from '../curator-core.ts'; -import type { AcousticIdentifyReceipt, AcousticReceipt, WhisperReceipt } from '../evidence.ts'; -import type { IntegrityAuditReceipt } from '../integrity-audit.ts'; -import type { InventoryReceipt, LibraryAuditReceipt, SelectionReceipt } from '../library.ts'; -import type { ChapterReceipt, MetadataReceipt } from '../media-mutation.ts'; - -export type CuratorReceipt = - | AcousticIdentifyReceipt - | AcousticReceipt - | AudibleCacheReceipt - | AudibleSearchReceipt - | AudibleSelectionReceipt - | ChapterReceipt - | ConvertReceipt - | InspectionReceipt - | IntegrityAuditReceipt - | InventoryReceipt - | LibraryAuditReceipt - | MetadataReceipt - | PrepareReceipt - | SelectionReceipt - | WhisperReceipt; - -export interface CuratorDocumentProps { - readonly children: ReactNode; - readonly headline: string; - readonly receipt: CuratorReceipt; -} - -export const CuratorDocument = ({ children, headline, receipt }: CuratorDocumentProps) => ( - - {headline} - {children} - -); diff --git a/examples/audiobook-curator/src/layout.tsx b/examples/audiobook-curator/src/layout.tsx new file mode 100644 index 000000000..53247da6e --- /dev/null +++ b/examples/audiobook-curator/src/layout.tsx @@ -0,0 +1,26 @@ +import { Agent, type AgentLayoutProps } from '@agent-bundle/runtime'; +import React from 'react'; + +/** + * The curator's shared document shell. Every rendered surface — the 16 MCP + * tools, the catalog resource, the curate prompt, the rendered CLI commands, + * and the projected `curator ` commands — composes through this one + * layout, so no route imports a wrapper to obtain the server's standard + * document structure. + * + * The layout renders a container `Agent.Result` (no `value`); the runtime + * merges each route's own `` into it, so the + * structured receipt (`structuredContent`), the MCP content, and the CLI + * Markdown are exactly what the route declared. What the shell adds is stable + * provenance on the document itself: which route produced it and on which + * surface. The MCP projector emits that root metadata as `CallToolResult._meta` + * (so MCP hosts receive `_meta.curator`), and the Workbench document stage and + * `--ndjson` consumers can attribute every document by it. + */ +export default function CuratorLayout({ children, route }: AgentLayoutProps) { + return ( + + {children} + + ); +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx index a6195da7b..ded361c21 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx @@ -1,9 +1,8 @@ -import { agent } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { ChapterOutline, chaptersFromApplyReceipt } from '../../../components/chapter-outline.js'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; import { ChapterIntegrityReport } from '../../../components/integrity-report.js'; import { ChapterMutation } from '../../../components/mutation-receipt.js'; @@ -41,11 +40,12 @@ export default async function Route({ input, signal }: ToolRouteProps ); return ( - + + {headline} {shelf} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx index 4596788df..d6ddb59a5 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx @@ -1,8 +1,7 @@ -import { agent } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; import { MetadataIntegrityReport } from '../../../components/integrity-report.js'; import { MetadataMutation } from '../../../components/mutation-receipt.js'; @@ -40,10 +39,11 @@ export default async function Route({ input, signal }: ToolRouteProps ); return ( - + + {headline} {shelf} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx index 8240f319c..93266445e 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx @@ -1,8 +1,8 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { ChapterOutline, chaptersFromAuditReceipt } from '../../../components/chapter-outline.js'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { integrityAuditHeadline } from '../../../components/headlines.js'; import { IntegrityAuditReport } from '../../../components/integrity-report.js'; import type { IntegrityAuditReceipt } from '../../../integrity-audit.js'; @@ -21,12 +21,10 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as IntegrityAuditReceipt; return ( - + + {integrityAuditHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx index cb2269736..4834c1f52 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx @@ -2,7 +2,6 @@ import { Agent, agent } from '@agent-bundle/runtime'; import React, { Suspense } from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { libraryAuditHeadline } from '../../../components/headlines.js'; import { LibraryAnalysis } from '../../../components/library-analysis.js'; import { AuditFileCards, AuditSummary } from '../../../components/library-shelf.js'; @@ -28,15 +27,13 @@ export default async function Route({ input, signal }: ToolRouteProps + + {libraryAuditHeadline(receipt)} }> - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx index ba78bbfd1..3d2ae5af8 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx @@ -1,8 +1,8 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleCacheReceipt } from '../../../audible.js'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { Callout, DataList } from '../../../components/primitives.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; @@ -19,7 +19,8 @@ export default async function Route({ input, signal }: ToolRouteProps + + {headline} {`Chapter metadata was not cached: ${receipt.chapterError}`}} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx index 67508cf4b..fd3a2906d 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx @@ -1,8 +1,8 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { ChapterOutline, chaptersFromConvertReceipt } from '../../../components/chapter-outline.js'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { convertHeadline } from '../../../components/headlines.js'; import { ConversionIntegrityReport } from '../../../components/integrity-report.js'; import { ConversionMutation } from '../../../components/mutation-receipt.js'; @@ -21,10 +21,11 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as ConvertReceipt; return ( - + + {convertHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx index 8d9839117..eb575754e 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx @@ -1,8 +1,8 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { IdentifyRanking } from '../../../components/candidate-ranking.js'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { IdentifyTrail } from '../../../components/evidence-trail.js'; import type { AcousticIdentifyReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; @@ -23,9 +23,10 @@ export default async function Route({ input, signal }: ToolRouteProps + + {headline} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx index fe77b1d3e..d3d08c800 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx @@ -1,7 +1,7 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { InspectionShelf } from '../../../components/library-shelf.js'; import type { InspectionReceipt } from '../../../curator-core.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; @@ -18,11 +18,9 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as InspectionReceipt; return ( - + + {`Inspected ${receipt.files.length} audio files (${receipt.totalBytes} bytes).`} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx index 0c141eb2c..0440a965b 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx @@ -1,8 +1,8 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { inventoryHeadline } from '../../../components/headlines.js'; import { InventoryShelf } from '../../../components/library-shelf.js'; import type { InventoryReceipt } from '../../../library.js'; @@ -25,11 +25,9 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as InventoryReceipt; return ( - + + {inventoryHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx index 31649aa77..b8ad97228 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx @@ -1,7 +1,7 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { PrepareMutation } from '../../../components/mutation-receipt.js'; import type { PrepareReceipt } from '../../../curator-core.js'; import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; @@ -21,8 +21,9 @@ export default async function Route({ input, signal }: ToolRouteProps + + {headline} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx index 75cfdbba8..5044964e1 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx @@ -1,9 +1,9 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleSearchReceipt } from '../../../audible.js'; import { SearchRanking } from '../../../components/candidate-ranking.js'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { audibleSearchHeadline } from '../../../components/headlines.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; @@ -20,11 +20,9 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as AudibleSearchReceipt; return ( - + + {audibleSearchHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx index f17ffebfe..59c720960 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx @@ -1,10 +1,9 @@ -import { agent } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleSelectionReceipt } from '../../../audible.js'; import { SelectionRanking } from '../../../components/candidate-ranking.js'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; import { CurationShelfStateSchema } from '../../../state.js'; @@ -37,12 +36,10 @@ export default async function Route({ input, signal }: ToolRouteProps ); return ( - + + {`Recorded human-reviewed Audible candidate ${receipt.candidateNumber}.`} {shelf} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx index 1f80aeb4f..132a48f82 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx @@ -1,7 +1,7 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { selectionHeadline } from '../../../components/headlines.js'; import { SelectionShelf } from '../../../components/library-shelf.js'; import type { SelectionReceipt } from '../../../library.js'; @@ -19,11 +19,9 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as SelectionReceipt; return ( - + + {selectionHeadline(receipt)} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx index c318708d5..6b036e8a1 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx @@ -1,7 +1,7 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { AcousticTrail } from '../../../components/evidence-trail.js'; import type { AcousticReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; @@ -22,8 +22,9 @@ export default async function Route({ input, signal }: ToolRouteProps + + {headline} - + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx index 1e6564b4a..c78c62126 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx @@ -1,7 +1,7 @@ +import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorDocument } from '../../../components/curator-document.js'; import { WhisperTrail } from '../../../components/evidence-trail.js'; import type { WhisperReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; @@ -19,11 +19,9 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as WhisperReceipt; return ( - + + {`Collected ${receipt.usableWindows} usable transcript windows; human identity review is required.`} - + ); } diff --git a/examples/audiobook-curator/tests/route-unit/routes.test.ts b/examples/audiobook-curator/tests/route-unit/routes.test.ts index ea380633c..7f5be054f 100644 --- a/examples/audiobook-curator/tests/route-unit/routes.test.ts +++ b/examples/audiobook-curator/tests/route-unit/routes.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it } from '@rstest/core'; -import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; +import { expectDocument, invokeMcpTool, renderRoute, testManifest } from 'agent-bundle/test'; /** * The route-unit proof level for this example: it proves the curator's route @@ -18,6 +18,8 @@ it('compiles the curator routes through the framework test manifest, with no bui expect(manifest.proofLevel).toBe('route-unit'); expect(manifest.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); expect(Object.keys(manifest.routes)).toContain('prompt:curator/curate'); + // The one shared document shell every rendered curator route composes through. + expect(manifest.layouts.map((layout) => layout.id)).toEqual(['layout:root']); }); it('renders the curation prompt route into a final Agent Document', async () => { @@ -36,6 +38,11 @@ it('renders the curation prompt route into a final Agent Document', async () => }], }); expect(rendered.provenance).toMatchObject({ proofLevel: 'route-unit', routeId: 'prompt:curator/curate' }); + // The layout's shell merged with the route's valued result: one root, the + // route's value, plus the shell's provenance metadata. + expect(rendered.document.root.kind === 'result' ? rendered.document.root.metadata : undefined).toEqual({ + curator: { route: 'prompt:curator/curate', server: 'mcp:curator', surface: 'prompt' }, + }); }); it('renders the composed library-audit tool document with its canonical receipt', async () => { @@ -63,6 +70,17 @@ it('renders the composed library-audit tool document with its canonical receipt' operation: 'library-audit', summary: { files: 0 }, }); + + // Over the real protocol the layout leaves `structuredContent` as the + // route's receipt and hands hosts the shell's provenance as `_meta`. + const invocation = await invokeMcpTool('audit_library', { input: { concurrency: 1, sources: [sources] } }); + expect(invocation.isError).toBe(false); + const { generatedAt: _rendered, ...stableReceipt } = receipt as typeof receipt & { readonly generatedAt: string }; + const { generatedAt: _projected, ...stableProjected } = invocation.structuredContent as typeof receipt & { readonly generatedAt: string }; + expect(stableProjected).toEqual(stableReceipt); + expect(invocation._meta).toEqual({ + curator: { route: 'tool:curator/audit_library', server: 'mcp:curator', surface: 'tool' }, + }); } finally { await rm(directory, { force: true, recursive: true }); } @@ -160,6 +178,10 @@ it('renders the library-audit CLI route with in-flight progress and the canonica expect(receipt.operation).toBe('library-audit'); expect(receipt.exitCode).toBe(0); expect(receipt.summary.files).toBe(0); + // The same shell wraps rendered CLI commands; a CLI route has no owning server. + expect(rendered.document.root.kind === 'result' ? rendered.document.root.metadata : undefined).toEqual({ + curator: { route: 'cli:library-audit', server: null, surface: 'cli' }, + }); // The component reported request-scoped progress around the audit. expect(rendered.progress.map((update) => update.completed)).toEqual([0, 1]); // The receipt landed in the requested report file, exactly like the diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 265125564..b3a5a2aa8 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -16,6 +16,8 @@ agent-bundle dev --root . `inspect` reads source configuration; use `validate --artifact artifact` for source-free artifact validation. `dev.runtime.provider` is an advanced optional extension: a normal project starts the dev server and Workbench without loading an RSC provider. +The artifact root defaults to `dist`; set `output: { distPath: 'artifact' }` in `agent-bundle.config.ts` to relocate it (Rsbuild/Rslib naming, string shorthand only) — `--output` still wins per invocation. See [Framework mode](../../docs/framework-mode.md#output). + Generated executables target Node.js 22.12 or newer by default. `runtime: { node: '24.0' }` raises that floor (it can never be lowered), and the selected floor is recorded as `runtime.node` in the artifact manifest. diff --git a/packages/agent-bundle/fixtures/route-harness/src/layout.tsx b/packages/agent-bundle/fixtures/route-harness/src/layout.tsx new file mode 100644 index 000000000..4bb9b08f1 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/layout.tsx @@ -0,0 +1,17 @@ +import { Agent, agent, type AgentLayoutProps } from '@agent-bundle/runtime'; + +/** + * The project-wide layout: every rendered route (generated MCP routes, + * rendered CLI commands, projected MCP commands, rendered scripts) composes + * through it. It renders a container `Agent.Result` — no `value` — so the + * runtime merges the route's own valued result into it, and records the + * request scope it observed to prove layouts run inside `runAgentRequest`. + */ +export default async function Layout({ children, route }: AgentLayoutProps) { + const context = await agent(); + return ( + + {children} + + ); +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/layout.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/layout.tsx new file mode 100644 index 000000000..4606ec0a3 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/layout.tsx @@ -0,0 +1,19 @@ +import { Agent, type AgentLayoutProps } from '@agent-bundle/runtime'; + +/** + * The `harness` server layout, nested inside the root layout. It stamps the + * wrapped route's identity into document metadata for every harness route and + * adds visible protocol content for exactly one route, so the projection + * levels can prove the chain is applied without changing the other routes' + * pinned MCP and CLI output. + */ +export default function HarnessLayout({ children, route }: AgentLayoutProps) { + return ( + + {children} + {route.name === 'layout-probe' + ? {`layout: ${route.kind} ${route.name} via ${route.serverId ?? 'no server'}`} + : undefined} + + ); +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx new file mode 100644 index 000000000..953733434 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/layout-probe.tsx @@ -0,0 +1,20 @@ +import { Agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Renders a bare valued result so the layout chain around it is observable.', + title: 'Layout probe', +}; + +export const inputSchema = z.object({ label: z.string().default('probe') }); + +export const resultSchema = z.object({ label: z.string() }); + +export default async function LayoutProbe({ input }: { readonly input: z.infer }) { + return ( + + {`probe: ${input.label}`} + + ); +} diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 9a9ef0907..64e6430e1 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -410,6 +410,7 @@ export const build = async (options: BuildOptions): Promise => { options.model.scripts.filter((script) => script.targets.includes(target.name)), { cwd: options.projectRoot, + layouts: options.model.layouts ?? [], meta, outDir: target.root, providers: options.model.providers ?? [], @@ -435,6 +436,7 @@ export const build = async (options: BuildOptions): Promise => { eventHooks: target.hookEntries .filter((entry) => entry.hook.eventRoute !== undefined) .map((entry) => entry.hook), + layouts: options.model.layouts ?? [], meta, outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index cfb3626e6..d914e303d 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -38,7 +38,7 @@ import { mcpServerRuntimePath, mcpServerRuntimeSpecifier, } from './entry-shell.ts'; -import { emptyRouteConfig, type CompiledProvider } from '../routes/types.ts'; +import { emptyRouteConfig, type CompiledLayout, type CompiledProvider } from '../routes/types.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; import type { ArtifactOutputKind } from './provenance.ts'; import { buildWithRslib } from './rslib.ts'; @@ -158,6 +158,7 @@ export const compileEntries = async ( entries: readonly NormalizedScript[], options: { readonly cwd: string; + readonly layouts?: readonly CompiledLayout[]; readonly meta: AgentBundleMeta; readonly outDir: string; readonly providers?: readonly CompiledProvider[]; @@ -178,6 +179,7 @@ export const compileEntries = async ( const workerSourceInputs = Object.freeze([...new Set([ ...sourceInputs, ...(options.providers ?? []).map((provider) => provider.source), + ...(options.layouts ?? []).map((layout) => layout.source), ])]); // A rendered script route (#102 stage 3): the entry projects the // dispatcher's render-event stream onto the CLI output contract and @@ -205,6 +207,7 @@ export const compileEntries = async ( source, sourceInputs: workerSourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ + ...(options.layouts === undefined ? {} : { layouts: options.layouts }), ...(options.providers === undefined ? {} : { providers: options.providers }), routes: [{ config: emptyRouteConfig, @@ -317,6 +320,7 @@ export const compileMcpEntries = async ( readonly artifactEpoch: string; readonly cwd: string; readonly eventHooks: readonly NormalizedHook[]; + readonly layouts?: readonly CompiledLayout[]; readonly meta: AgentBundleMeta; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; @@ -369,6 +373,7 @@ export const compileMcpEntries = async ( : generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch(options.plugin), eventRoutes: entry.id === eventHostId ? options.eventHooks : [], + layouts: options.layouts ?? [], providers: options.providers ?? [], routes: server.generatedRoutes, serverName: server.name, diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 07b3d1f69..8c0b17ebe 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -5,8 +5,9 @@ import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapt import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedStateDefinition } from '../core/types.ts'; import { orderedProviders } from '../routes/provider-execution.ts'; +import { layoutChainFor, layoutRouteName } from '../routes/layouts.ts'; import { providerKeyFromName } from '../routes/providers.ts'; -import type { CompiledAgentRoute, CompiledCliCommand, CompiledProvider } from '../routes/types.ts'; +import type { CompiledAgentRoute, CompiledCliCommand, CompiledLayout, CompiledProvider } from '../routes/types.ts'; /** * Generated-entry templates: the framework-provided entry files consumers @@ -379,6 +380,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) }; export interface GeneratedRenderedRouteWorkerOptions { + readonly layouts?: readonly CompiledLayout[]; readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly state?: NormalizedStateDefinition; @@ -386,6 +388,76 @@ export interface GeneratedRenderedRouteWorkerOptions { readonly stateFallback?: GeneratedStateFallback; } +/** + * The layouts one worker imports: only those some route of this worker + * composes through, ordered by id. A server layout for a server this worker + * never renders is left out entirely, so its module-level initialization + * cannot run in — or break — an unrelated CLI, script, or server process. + */ +const workerLayouts = ( + layouts: readonly CompiledLayout[], + routes: readonly Pick[], +): readonly CompiledLayout[] => { + const applicable = new Set(routes.flatMap((route) => layoutChainFor(route, layouts))); + return layouts.filter((layout) => applicable.has(layout)).sort((left, right) => left.id.localeCompare(right.id)); +}; + +const layoutImports = (layouts: readonly CompiledLayout[]): readonly string[] => + layouts.map((layout, index) => `import * as layout${String(index)} from ${JSON.stringify(layout.source)};`); + +const layoutRecords = (layouts: readonly CompiledLayout[]): readonly string[] => + layouts.map((layout, index) => + ` Object.freeze({ id: ${JSON.stringify(layout.id)}, module: layout${String(index)}, source: ${JSON.stringify(layout.provenance.relativePath)} }),`); + +/** The generated `layouts: [...]` record field: indices into the worker's layout table, outermost first. */ +const layoutChainField = (route: CompiledAgentRoute, layouts: readonly CompiledLayout[]): string => { + const chain = layoutChainFor(route, layouts).map((layout) => layouts.indexOf(layout)); + return chain.length === 0 ? '' : `, layouts: Object.freeze(${JSON.stringify(chain)})`; +}; + +const layoutRouteFields = (route: CompiledAgentRoute): string => [ + `id: ${JSON.stringify(route.id)}`, + `kind: ${JSON.stringify(route.kind)}`, + `name: ${JSON.stringify(layoutRouteName(route))}`, + ...(route.serverId === undefined ? [] : [`serverId: ${JSON.stringify(route.serverId)}`]), +].join(', '); + +/** + * The generated layout composition. Without layouts the route component is + * the Flight root, so a layout-free project renders exactly the element it + * rendered before this convention existed (the emitted worker source itself + * changes with every release and is not a compatibility surface). With + * layouts, one root component awaits the route's element first and then + * wraps it in the chain from the + * innermost (server) layout outward — so a throwing route still rejects the + * root and fails the render exactly as it does without a layout, instead of + * degrading into a represented boundary error below the layout's shell. Every + * layout receives the route's stable identity and the request signal; a + * layout module without a function default export fails the request closed. + */ +const composeLayoutsSource = (layouts: readonly CompiledLayout[]): readonly string[] => layouts.length === 0 + ? ['const composeLayouts = (route, props) => createElement(route.module.default, props);'] + : [ + 'const layouts = Object.freeze([', + ...layoutRecords(layouts), + ']);', + 'const composeLayouts = (route, props, signal) => {', + ' const chain = route.layouts ?? [];', + ' if (chain.length === 0) return createElement(route.module.default, props);', + ' return createElement(async () => {', + ' let composed = await route.module.default(props);', + ' for (const index of [...chain].reverse()) {', + ' const layout = layouts[index];', + " if (typeof layout.module.default !== 'function') {", + ' throw new TypeError(`Layout "${layout.id}" (${layout.source}) must default-export a function component.`);', + ' }', + " composed = createElement(layout.module.default, { children: composed, route: { id: route.id, kind: route.kind, name: route.name, ...(route.serverId === undefined ? {} : { serverId: route.serverId }) }, signal });", + ' }', + ' return composed;', + ' });', + '};', + ]; + /** * The react-server worker behind generated CLI executables and rendered * scripts: renders one route's async default component through Flight, @@ -397,6 +469,7 @@ export const generatedRenderedRouteWorkerSource = ( ): string => { const providers = orderedProviders(options.providers ?? []); const stateFallback = options.stateFallback ?? 'cwd'; + const layouts = workerLayouts(options.layouts ?? [], options.routes); return [ "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", @@ -405,6 +478,7 @@ export const generatedRenderedRouteWorkerSource = ( ...generatedStateImports(options.state, stateFallback), ...routeImports(options.routes), ...providerImports(providers), + ...layoutImports(layouts), '', ...generatedStateOwner(options.state, stateFallback), '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', @@ -414,9 +488,10 @@ export const generatedRenderedRouteWorkerSource = ( 'process.stdout.write = process.stderr.write.bind(process.stderr);', 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...providerRegistrySource(providers), + ...composeLayoutsSource(layouts), 'const routes = Object.freeze({', ...options.routes.map((route, index) => - ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), + ` ${JSON.stringify(route.id)}: Object.freeze({ ${layoutRouteFields(route)}, module: route${String(index)}${layoutChainField(route, layouts)} }),`), '});', 'const requests = new Map();', '', @@ -449,7 +524,7 @@ export const generatedRenderedRouteWorkerSource = ( ...(options.state === undefined ? [] : [' state: bindings.state,']), " workspace: available({ root: cwd }, 'derived'),", ' }, async () => {', - ' const flight = renderAgentFlight(createElement(route.module.default, { ...message.props, signal: controller.signal }), { signal: controller.signal });', + ' const flight = renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal), { signal: controller.signal });', ' const reader = flight.getReader();', ' while (true) {', ' const next = await reader.read();', @@ -528,6 +603,7 @@ export interface GeneratedRouteMcpEntryOptions { export interface GeneratedRouteFlightWorkerOptions { readonly artifactEpoch: string; readonly eventRoutes?: readonly NormalizedHook[]; + readonly layouts?: readonly CompiledLayout[]; readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; @@ -548,9 +624,20 @@ const executableMcpRoutes = (routes: readonly CompiledAgentRoute[]): readonly Co const routeImports = (routes: readonly CompiledAgentRoute[]): readonly string[] => routes.map((route, index) => `import * as route${String(index)} from ${JSON.stringify(route.source)};`); -const routeRecords = (routes: readonly CompiledAgentRoute[]): readonly string[] => - routes.map((route, index) => - ` ${JSON.stringify(route.id)}: Object.freeze({ config: ${stableJson(route.config)}, id: ${JSON.stringify(route.id)}, kind: ${JSON.stringify(route.kind)}, module: route${String(index)}, name: ${JSON.stringify(routeProtocolName(route))} }),`); +/** + * The compiled route table. The entry-side table registers routes; the + * worker-side table (`worker` set) additionally carries each route's layout + * chain and owning server so layouts receive stable route identity. + */ +const routeRecords = ( + routes: readonly CompiledAgentRoute[], + worker?: { readonly layouts: readonly CompiledLayout[] }, +): readonly string[] => + routes.map((route, index) => { + const layoutFields = worker === undefined ? '' : layoutChainField(route, worker.layouts); + const serverField = worker === undefined || route.serverId === undefined ? '' : `, serverId: ${JSON.stringify(route.serverId)}`; + return ` ${JSON.stringify(route.id)}: Object.freeze({ config: ${stableJson(route.config)}, id: ${JSON.stringify(route.id)}, kind: ${JSON.stringify(route.kind)}${layoutFields}, module: route${String(index)}, name: ${JSON.stringify(routeProtocolName(route))}${serverField} }),`; + }); const noticeInboxImport = (state: NormalizedStateDefinition | undefined): readonly string[] => state === undefined @@ -648,6 +735,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo const routes = executableMcpRoutes(options.routes); const eventRoutes = options.eventRoutes ?? []; const providers = orderedProviders(options.providers ?? []); + const layouts = workerLayouts(options.layouts ?? [], routes); return [ "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", @@ -658,6 +746,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...routeImports(routes), ...eventRouteImports(eventRoutes, routes.length), ...providerImports(providers), + ...layoutImports(layouts), '', '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', @@ -667,8 +756,9 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...generatedStateOwner(options.state, 'artifact'), ...providerRegistrySource(providers), + ...composeLayoutsSource(layouts), 'const routes = Object.freeze({', - ...routeRecords(routes), + ...routeRecords(routes, { layouts }), ...noticeInboxRecord(options.state), ...eventRouteRecords(eventRoutes, routes.length), '});', @@ -705,7 +795,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo " const props = message.invocation.kind === 'event'", ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })', ' : { input: message.invocation.props.input, signal: controller.signal };', - ' const flight = renderAgentFlight(createElement(route.module.default, props), { signal: controller.signal });', + ' const flight = renderAgentFlight(composeLayouts(route, props, controller.signal), { signal: controller.signal });', ' return new Uint8Array(await new Response(flight).arrayBuffer());', ' });', ' parentPort.postMessage({ bytes, id: message.id, type: \'complete\' }, [bytes.buffer]);', diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 137b070f4..7756a44c3 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -251,6 +251,7 @@ const mcpEntryEntries = async ( sourceInputs: [], virtualSource: generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch({ name: model.metadata.name, version: model.metadata.version }), + layouts: model.layouts ?? [], providers: model.providers ?? [], routes: generatedRoutes, serverName, diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 0f3c1b768..7cf516ad5 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -121,6 +121,7 @@ export const planPackageEntries = async ( const sourceInputs = Object.freeze([...new Set([ bin.provenance.sourcePath, ...bin.generatedCli.routes.map((route) => route.source), + ...(model.layouts ?? []).map((layout) => layout.source), ...(model.providers ?? []).map((provider) => provider.source), ...(model.state === undefined ? [] : [model.state.source]), ])]); @@ -158,6 +159,7 @@ export const planPackageEntries = async ( source: bin.source, sourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ + layouts: model.layouts ?? [], providers: model.providers ?? [], routes: renderedRoutes, ...(model.state === undefined ? {} : { state: model.state }), diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 105c9acff..dd08072ae 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1241,6 +1241,7 @@ export const normalizeProject = async ( const assets = normalizeAssets(loaded, discovered, targetNames); const commands = normalizeCommands(discovered, targetNames); const providers = discovered.routeGraph?.providers ?? []; + const layouts = discovered.routeGraph?.layouts ?? []; const rules = normalizeRules(discovered, targetNames); const state: NormalizedStateDefinition | undefined = discovered.state?.definition === undefined ? undefined @@ -1263,6 +1264,7 @@ export const normalizeProject = async ( ...(hostBins.length === 0 ? {} : { hostBins }), ...(hostOutputStyles.length === 0 ? {} : { hostOutputStyles }), ...(hostWorkflows.length === 0 ? {} : { hostWorkflows }), + ...(layouts.length === 0 ? {} : { layouts }), metadata: { ...(typeof description === 'string' ? { description } : {}), id: `plugin:${loaded.config.plugin.name}`, diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 1d2fe09cc..22a8bbe85 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -266,6 +266,7 @@ const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ model.metadata.provenance.sourcePath, ...(model.assets ?? []).flatMap((asset) => [asset.provenance.sourcePath, asset.source]), ...(model.commands ?? []).flatMap((command) => [command.provenance.sourcePath, command.source]), + ...(model.layouts ?? []).map((layout) => layout.source), ...(model.providers ?? []).map((provider) => provider.source), ...(model.rules ?? []).flatMap((rule) => [rule.provenance.sourcePath, rule.source]), ...Object.values(model.extensions).map((extension) => extension.provenance.sourcePath), @@ -373,6 +374,14 @@ export const canonicalizeNormalizedModel = ( source: canonicalCompilerPath(root, command.source, 'Command source path'), })), }), + ...(detached.layouts === undefined + ? {} + : { + layouts: detached.layouts.map((layout) => ({ + ...layout, + source: canonicalCompilerPath(root, layout.source, 'Layout source path'), + })), + }), ...(detached.providers === undefined ? {} : { diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 5126b6ce6..2fe09fe5a 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -5,7 +5,7 @@ import type { AgentEventRuntimeMode, CanonicalAgentEvent, } from '../routes/public.ts'; -import type { CompiledAgentRoute, CompiledCliCommand, CompiledProvider } from '../routes/types.ts'; +import type { CompiledAgentRoute, CompiledCliCommand, CompiledLayout, CompiledProvider } from '../routes/types.ts'; import type { SkillHostDocument, SkillIr, SkillTreeLayoutDecision } from '../skills/ir.ts'; import type { CapabilityState } from './capabilities.ts'; @@ -649,6 +649,8 @@ export interface NormalizedPlugin { * models predating prebuilt payloads stay valid. */ readonly payloads?: readonly NormalizedPayload[]; + /** Conventional layout modules composed around every rendered route (root first, then the owning server's). */ + readonly layouts?: readonly CompiledLayout[]; /** Conventional context providers executed for every generated render request. */ readonly providers?: readonly CompiledProvider[]; /** diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 59f65544d..71c53605c 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -30,6 +30,8 @@ export type { AgentEventRouteConfig, AgentEventRouteProps, AgentEventRuntimeMode, + AgentLayoutRoute, + AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, AppRouteConfig, diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index b5f7a292a..7b9b58a6f 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -22,7 +22,7 @@ const unwrappedExpression = (expression: ts.Expression): ts.Expression => { }; const diagnostic = ( - code: 'AB4810' | 'AB4811' | 'AB4940', + code: 'AB4810' | 'AB4811' | 'AB4830' | 'AB4940', message: string, sourcePath: string, recovery: string, @@ -174,6 +174,34 @@ export const validateEventRouteModuleContract = ( return Object.freeze(diagnostics); }; +/** + * Validates one conventional layout module without evaluating it: the default + * export must be a function component (sync or async) and the module must + * not carry the route contract's `inputSchema`/`resultSchema`/`config` + * exports — a layout wraps routes, it is not one, and a stray schema export + * usually means a route module was saved under the reserved layout name. + */ +export const validateLayoutModuleContract = ( + moduleText: string, + relativePath: string, + sourcePath: string, +): readonly Diagnostic[] => { + const { defaultFunction, named, splitExport } = scanRouteModuleExports(moduleText, relativePath); + const routeExports = ['config', 'inputSchema', 'resultSchema'].filter((name) => named.has(name)); + const details = [ + ...(defaultFunction ? [] : ['default export is not a function component']), + ...(routeExports.length === 0 ? [] : [`exports route-only ${routeExports.join(', ')}`]), + ...(splitExport ? ['exports execute or render'] : []), + ]; + if (details.length === 0) return Object.freeze([]); + return Object.freeze([diagnostic( + 'AB4830', + `Layout module ${relativePath} does not satisfy the layout contract: ${details.join('; ')}.`, + sourcePath, + 'Default-export one function component receiving { children, route, signal } that renders Agent.Result around children, and keep route schemas in route modules.', + )]); +}; + /** Validates one context provider's default factory export without evaluating the module. */ export const validateProviderModuleContract = ( moduleText: string, diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 71e96e462..31166422c 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -19,10 +19,12 @@ import { } from './config-extract.ts'; import { validateEventRouteModuleContract, + validateLayoutModuleContract, validateProviderModuleContract, validateRouteModuleContract, } from './contract.ts'; import { extractInputSchema } from './input-schema.ts'; +import { isLayoutRouteKind } from './layouts.ts'; import { providerKeyFromName } from './providers.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; @@ -35,6 +37,7 @@ import { type CompiledAgentRoute, type CompiledCliMode, type CompiledCliSurface, + type CompiledLayout, type CompiledProvider, type CompiledRouteGraph, type CompiledRouteKind, @@ -52,6 +55,8 @@ type ProjectIgnoreRules = Awaited>; * flat collection. */ const routeGlobs = [ + 'src/layout.{ts,tsx}', + 'src/mcp/*/layout.{ts,tsx}', 'src/mcp/*/{tools,resources,prompts,apps}/*.{ts,tsx}', 'src/events/*/*.{ts,tsx}', 'src/events/stop.{ts,tsx}', @@ -74,6 +79,24 @@ const safeIdentitySegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; const serverModeOverrides = new Set(['generated', 'custom', 'command', 'remote']); +/** True when a `routes.servers.` override keeps the server's own entry instead of compiling its routes. */ +const isNonGeneratedServerOverride = (override: CompiledServerMode | undefined): boolean => { + switch (override) { + case 'custom': + case 'command': + case 'remote': + return true; + case 'generated': + case 'conflict': + case undefined: + return false; + default: { + const unreachable: never = override; + throw new TypeError(`Unhandled server mode override ${String(unreachable)}.`); + } + } +}; + const conventionalEntryExtensions = ['.ts', '.tsx'] as const; /** @@ -124,6 +147,17 @@ interface DiscoveredProviderModule { readonly surface: 'provider'; } +interface DiscoveredLayoutModule { + readonly id: string; + /** The path-derived name segments; each must satisfy the safe-identity rule. */ + readonly identitySegments: readonly string[]; + readonly relativePath: string; + readonly scope: CompiledLayout['scope']; + readonly serverName?: string; + readonly source: string; + readonly surface: 'layout'; +} + interface DiscoveredRouteModule { readonly event?: CanonicalAgentEvent; readonly id: string; @@ -136,15 +170,39 @@ interface DiscoveredRouteModule { readonly surface: 'route'; } -type DiscoveredModule = DiscoveredProviderModule | DiscoveredRouteModule; +type DiscoveredModule = DiscoveredLayoutModule | DiscoveredProviderModule | DiscoveredRouteModule; const stemOf = (fileName: string): string => fileName.slice(0, -extname(fileName).length); +const layoutStem = 'layout'; + /** Derives kind and identity from one glob-matched route path; the globs guarantee segment shape. */ const classifyModule = (source: string, relativePath: string): DiscoveredModule => { const segments = relativePath.split('/'); const collection = segments[1]!; const stem = stemOf(segments[segments.length - 1]!); + if (segments.length === 2 && stem === layoutStem) { + return { + id: 'layout:root', + identitySegments: [], + relativePath, + scope: 'root', + source, + surface: 'layout', + }; + } + if (collection === 'mcp' && segments.length === 4 && stem === layoutStem) { + const serverName = segments[2]!; + return { + id: `layout:mcp:${serverName}`, + identitySegments: [serverName], + relativePath, + scope: 'server', + serverName, + source, + surface: 'layout', + }; + } if (collection === 'mcp') { const serverName = segments[2]!; const kind = mcpRouteKinds[segments[3]!]!; @@ -532,6 +590,7 @@ export const isEmptyRouteGraph = (graph: CompiledRouteGraph): boolean => graph.cli === undefined && graph.diagnostics.length === 0 && graph.events.length === 0 && + (graph.layouts?.length ?? 0) === 0 && graph.providers.length === 0 && graph.scripts.length === 0 && graph.servers.length === 0; @@ -570,6 +629,17 @@ export const compileRouteGraph = async ( if (claimed.bin.has(source) && !isConventionalScriptPath(relativePath)) continue; if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; const module = classifyModule(source, relativePath); + // The documented opt-out: a server pinned to custom, command, or remote + // keeps its own entry, so its layout never enters the graph — it is not + // duplicate-checked, validated, or retained, because no generated worker + // composes it. + if ( + module.surface === 'layout' && + module.scope === 'server' && + isNonGeneratedServerOverride(overrides.servers.get(module.serverName!)) + ) { + continue; + } if ( module.surface === 'route' && module.kind === 'event-route' && @@ -618,6 +688,15 @@ export const compileRouteGraph = async ( } const existing = modulesById.get(module.id); if (existing !== undefined) { + if (module.surface === 'layout') { + diagnostics.push(routeError( + 'AB4831', + `Layout ${JSON.stringify(module.id)} is declared by both ${existing.relativePath} and ${relativePath}.`, + 'Keep exactly one layout module per scope (one of .ts or .tsx), then inspect again.', + source, + )); + continue; + } diagnostics.push(routeError( 'AB4802', `Route id ${JSON.stringify(module.id)} is declared by both ${existing.relativePath} and ${relativePath}.`, @@ -635,12 +714,31 @@ export const compileRouteGraph = async ( const scripts: CompiledAgentRoute[] = []; const cliRoutes: CompiledAgentRoute[] = []; const providers: CompiledProvider[] = []; + const layouts: CompiledLayout[] = []; const moduleTextBySource = new Map(); // Config extraction runs over the whole tree before any route compiles: // an `appResourceUri()` reference resolves against every App route the // tree declares, wherever the App module sorts relative to its referrer. const pending: { readonly metadata: ExtractedModuleMetadata; readonly module: DiscoveredRouteModule }[] = []; for (const module of modules) { + if (module.surface === 'layout') { + layouts.push({ + id: module.id, + provenance: { kind: 'conventional', relativePath: module.relativePath }, + scope: module.scope, + ...(module.serverName === undefined ? {} : { serverId: `mcp:${module.serverName}` }), + source: module.source, + }); + const layoutText = await readRouteModuleText(module.source); + if (layoutText !== undefined) { + diagnostics.push(...validateLayoutModuleContract( + layoutText, + module.relativePath, + module.source, + )); + } + continue; + } if (module.surface === 'provider') { providers.push({ id: module.id, @@ -804,6 +902,21 @@ export const compileRouteGraph = async ( }); } + for (const layout of layouts) { + if (layout.scope !== 'server') continue; + const server = servers.find((candidate) => candidate.id === layout.serverId); + // App routes are browser builds and never take a layout, so a server that + // declares only apps has nothing for its layout to wrap. + if (server === undefined || !server.routes.some((route) => isLayoutRouteKind(route.kind))) { + diagnostics.push(routeError( + 'AB4832', + `Layout module ${layout.provenance.relativePath} names MCP server ${JSON.stringify(layout.serverId!.slice('mcp:'.length))}, which declares no tool, resource, or prompt route modules.`, + 'Add tools, resources, or prompts under that server directory, move the layout to the server that owns the routes, prefix the file with _ to opt out, or set routes.servers. to custom, command, or remote.', + layout.source, + )); + } + } + const projected = overrides.mcpCommands === undefined ? undefined : compileMcpCliCommands(servers, overrides.mcpCommands); @@ -864,6 +977,11 @@ export const compileRouteGraph = async ( }, }), events: events.map(routeIdentity), + // Layouts join the identity only when declared, so pre-layout projects + // keep their recorded graph digests. + ...(layouts.length === 0 + ? {} + : { layouts: layouts.map((layout) => ({ id: layout.id, relativePath: layout.provenance.relativePath })) }), providers: providers.map((provider) => ({ id: provider.id, relativePath: provider.provenance.relativePath })), scripts: scripts.map(routeIdentity), servers: servers.map((server) => ({ @@ -878,6 +996,7 @@ export const compileRouteGraph = async ( diagnostics, digest: digest(identity), events, + ...(layouts.length === 0 ? {} : { layouts }), providers, scripts, servers, diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index f338af4e2..c7aa9e43c 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -21,6 +21,7 @@ export { appRouteTemplatePath, resolveAppRouteTemplate } from './app-template.ts export type { AppRouteTemplateResolution } from './app-template.ts'; export { inspectRouteGraph } from './inspect.ts'; export type { RouteGraphInspection } from './inspect.ts'; +export { isLayoutRouteKind, layoutChainFor, layoutRouteName } from './layouts.ts'; export { emptyRouteConfig } from './types.ts'; export type { CapabilityEvidence, @@ -30,6 +31,8 @@ export type { CompiledCliMode, CompiledCliOption, CompiledCliSurface, + CompiledLayout, + CompiledLayoutScope, CompiledProvider, CompiledRouteGraph, CompiledRouteKind, @@ -41,6 +44,7 @@ export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './t export { scanRouteModuleExports, validateEventRouteModuleContract, + validateLayoutModuleContract, validateProviderModuleContract, validateRouteModuleContract, } from './contract.ts'; @@ -55,6 +59,8 @@ export type { AgentEventRouteConfig, AgentEventRouteProps, AgentEventRuntimeMode, + AgentLayoutRoute, + AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, AppRouteConfig, diff --git a/packages/agent-bundle/src/routes/layouts.ts b/packages/agent-bundle/src/routes/layouts.ts new file mode 100644 index 000000000..adf69bb2b --- /dev/null +++ b/packages/agent-bundle/src/routes/layouts.ts @@ -0,0 +1,80 @@ +import type { AgentLayoutRouteKind } from './public.ts'; +import type { CompiledAgentRoute, CompiledLayout, CompiledRouteKind } from './types.ts'; + +/** The route shape layout resolution needs: kind, id, and the owning server. */ +export type LayoutRouteTarget = Pick; + +/** + * The layout chain one rendered route composes through, outermost first: the + * root layout, then the owning server's layout. Event routes and browser App + * routes never take a layout — events are host protocol responses and Apps + * are browser builds — so they resolve to an empty chain. Generated workers + * and the `agent-bundle/test` harness share this resolution so a route-unit + * render composes exactly what the artifact composes. + */ +export const layoutChainFor = ( + route: Pick, + layouts: readonly CompiledLayout[], +): readonly CompiledLayout[] => { + switch (route.kind) { + case 'tool': + case 'resource': + case 'prompt': + case 'cli': + case 'script': + return [ + ...layouts.filter((layout) => layout.scope === 'root'), + ...layouts.filter((layout) => layout.scope === 'server' && layout.serverId === route.serverId), + ]; + case 'app': + case 'event-route': + return []; + default: { + const unreachable: never = route.kind; + throw new TypeError(`Unhandled route kind ${String(unreachable)}.`); + } + } +}; + +/** True for the route kinds a layout wraps. */ +export const isLayoutRouteKind = (kind: CompiledRouteKind): kind is AgentLayoutRouteKind => { + switch (kind) { + case 'tool': + case 'resource': + case 'prompt': + case 'cli': + case 'script': + return true; + case 'app': + case 'event-route': + return false; + default: { + const unreachable: never = kind; + throw new TypeError(`Unhandled route kind ${String(unreachable)}.`); + } + } +}; + +/** + * The protocol-facing route name a layout receives: the MCP tool, resource, + * or prompt name, the space-joined CLI command path, or the script name. + */ +export const layoutRouteName = (route: Pick): string => { + const identity = route.id.slice(route.id.indexOf(':') + 1); + switch (route.kind) { + case 'tool': + case 'resource': + case 'prompt': + case 'app': + return identity.slice(identity.lastIndexOf('/') + 1); + case 'cli': + return identity.split('/').join(' '); + case 'script': + case 'event-route': + return identity; + default: { + const unreachable: never = route.kind; + throw new TypeError(`Unhandled route kind ${String(unreachable)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 2d045b109..d84d4cff7 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -98,6 +98,25 @@ export interface AgentProviderContext { /** Default export contract for one `src/providers/.{ts,tsx}` module. */ export type AgentProviderFactory = (context: AgentProviderContext) => unknown | Promise; +/** The route kinds a conventional layout wraps; event routes are host protocol responses and stay unwrapped. */ +export type AgentLayoutRouteKind = 'tool' | 'resource' | 'prompt' | 'cli' | 'script'; + +/** + * Stable identity of the route a layout is wrapping, baked at compile time + * from the route graph. `name` is the protocol-facing name: the MCP tool, + * resource, or prompt name, the space-joined CLI command path, or the script + * name. The layout component's props type, `AgentLayoutProps`, ships from + * `@agent-bundle/runtime` because it carries React's `ReactNode` children and + * this package's root declarations stay React-free for config-only consumers. + */ +export interface AgentLayoutRoute { + readonly id: string; + readonly kind: AgentLayoutRouteKind; + readonly name: string; + /** The owning MCP server id (`mcp:`); MCP route kinds only. */ + readonly serverId?: string; +} + export type AgentEventDelivery = 'immediate'; export type AgentEventRuntimeMode = 'shared' | 'standalone'; export type AgentEventFallbackMode = 'none' | 'standalone'; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index b5173385b..731b86005 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -113,6 +113,28 @@ export interface CompiledProvider { readonly source: string; } +/** + * The scope one conventional layout module wraps: `src/layout.{ts,tsx}` wraps + * every rendered route of the project (generated MCP routes, rendered CLI + * commands, projected MCP commands, and rendered scripts); + * `src/mcp//layout.{ts,tsx}` wraps that server's routes inside the + * root layout. Event routes are host protocol responses, not documents for a + * reader, so no layout applies to them. + */ +export type CompiledLayoutScope = 'root' | 'server'; + +/** One conventional layout module compiled into the immutable route graph. */ +export interface CompiledLayout { + /** `layout:root` or `layout:mcp:`. */ + readonly id: string; + readonly provenance: RouteProvenance; + readonly scope: CompiledLayoutScope; + /** The owning MCP server id (`mcp:`); `server` scope only. */ + readonly serverId?: string; + /** Absolute layout module path. */ + readonly source: string; +} + /** * The packaging mode of one MCP server that owns discovered route modules. * `generated`, `custom`, `command`, and `remote` are explicit or inferred @@ -216,6 +238,8 @@ export interface CompiledRouteGraph { /** sha256 over the graph's project-relative identity. */ readonly digest: string; readonly events: readonly CompiledAgentRoute[]; + /** Conventional layout modules; absent when the project declares none so pre-layout graphs digest unchanged. */ + readonly layouts?: readonly CompiledLayout[]; readonly providers: readonly CompiledProvider[]; readonly scripts: readonly CompiledAgentRoute[]; readonly servers: readonly CompiledServerSurface[]; diff --git a/packages/agent-bundle/src/rstest/setup-module.ts b/packages/agent-bundle/src/rstest/setup-module.ts index 54d6a6da0..a588c69e2 100644 --- a/packages/agent-bundle/src/rstest/setup-module.ts +++ b/packages/agent-bundle/src/rstest/setup-module.ts @@ -39,6 +39,8 @@ export const routeTestSetupSource = (manifest: AgentBundleTestManifest): string .map((route) => ` ${JSON.stringify(route.id)}: () => import(${JSON.stringify(specifier(route.source))}),`); const providerLoaders = (manifest.providers ?? []) .map((provider) => ` ${JSON.stringify(provider.id)}: () => import(${JSON.stringify(specifier(provider.source))}),`); + const layoutLoaders = manifest.layouts + .map((layout) => ` ${JSON.stringify(layout.id)}: () => import(${JSON.stringify(specifier(layout.source))}),`); return [ '// @generated by agent-bundle/rstest. Do not edit: rerun Rstest to regenerate.', '//', @@ -46,6 +48,9 @@ export const routeTestSetupSource = (manifest: AgentBundleTestManifest): string '// inventory here. Test workers read it through a realm global, so the', '// helpers in agent-bundle/test never recompile the project.', `const registry = {`, + ...(layoutLoaders.length === 0 + ? [] + : [' layoutLoaders: {', ...layoutLoaders, ' },']), ' loaders: {', ...loaders, ' },', diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 3d5145f3f..7d77c2cba 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -48,6 +48,7 @@ export type { TestManifestPluginIdentity, TestableAppDescriptor, TestableProviderDescriptor, + TestableLayoutDescriptor, TestableRouteDescriptor, TestableStateDescriptor, } from './manifest.ts'; diff --git a/packages/agent-bundle/src/test/layouts.ts b/packages/agent-bundle/src/test/layouts.ts new file mode 100644 index 000000000..8f0db28cb --- /dev/null +++ b/packages/agent-bundle/src/test/layouts.ts @@ -0,0 +1,111 @@ +import type { AgentLayoutProps } from '@agent-bundle/runtime'; +import type { createElement as CreateElement } from 'react'; + +import type { AgentLayoutRoute } from '../routes/public.ts'; +import { isLayoutRouteKind, layoutChainFor, layoutRouteName } from '../routes/layouts.ts'; +import type { CompiledLayout, CompiledRouteKind } from '../routes/types.ts'; +import { AgentTestError } from './errors.ts'; +import type { AgentBundleTestManifest, TestableLayoutDescriptor } from './manifest.ts'; +import { registeredLayoutLoader } from './registry.ts'; +import type { AgentLayoutModule, RenderedRouteProvenance } from './types.ts'; + +/** One loaded layout in a route's chain, outermost first. */ +export interface LoadedLayout { + readonly descriptor: TestableLayoutDescriptor; + readonly module: AgentLayoutModule; +} + +/** The manifest route a layout chain is resolved for. */ +export interface LayoutChainTarget { + readonly id: string; + readonly kind: CompiledRouteKind; + readonly serverId?: string; +} + +const compiledLayoutOf = (descriptor: TestableLayoutDescriptor): CompiledLayout => ({ + id: descriptor.id, + provenance: { kind: 'conventional', relativePath: descriptor.relativePath }, + scope: descriptor.scope, + ...(descriptor.serverId === undefined ? {} : { serverId: descriptor.serverId }), + source: descriptor.source, +}); + +/** + * Loads the layout chain the manifest declares for one route, through the + * loaders the generated Rstest setup registered. This is the same resolution + * generated workers bake at build time (`layoutChainFor`), so a route-unit or + * projection render composes exactly the artifact's layout chain. A module + * rendered directly (no manifest) has no chain: layouts are a compiler + * convention, not a property of a module. + */ +export const loadLayoutChain = async ( + manifest: AgentBundleTestManifest, + route: LayoutChainTarget, + provenance: RenderedRouteProvenance, +): Promise => { + if (manifest.layouts.length === 0) return []; + const compiled = manifest.layouts.map(compiledLayoutOf); + const chain = layoutChainFor(route, compiled); + return Promise.all(chain.map(async (layout) => { + const descriptor = manifest.layouts.find((candidate) => candidate.id === layout.id)!; + const loader = registeredLayoutLoader(manifest, layout.id); + if (loader === undefined) { + throw new AgentTestError( + 'manifest-unavailable', + `Layout ${layout.id} wraps route ${route.id} but no test-time layout loader is registered for it.`, + { + provenance, + recovery: 'Build the Rstest configuration with agentBundleRstest() so the generated setup registers layout loaders.', + }, + ); + } + const module = await loader(); + if (typeof module.default !== 'function') { + throw new AgentTestError( + 'invalid-route-module', + `Layout ${layout.id} (${descriptor.relativePath}) must default-export a function component.`, + { + details: [`received: default export of type ${typeof module.default}`], + provenance, + recovery: 'Default-export one function component receiving { children, route, signal } that renders Agent.Result around children.', + }, + ); + } + return { descriptor, module }; + })); +}; + +/** + * The Flight root for one route render, composed exactly as the generated + * workers compose it. Without a chain the route component is the root, byte + * for byte as before. With a chain, one root component awaits the route's + * element and wraps it from the innermost layout outward with the route's + * stable identity and the request signal — so a throwing route still rejects + * the root and fails the render, rather than degrading into a represented + * boundary error under the layout's shell. + */ +export const composeLayouts = ( + createElement: typeof CreateElement, + chain: readonly LoadedLayout[], + route: LayoutChainTarget, + component: (props: never) => unknown, + props: Readonly>, + signal: AbortSignal, +): unknown => { + if (chain.length === 0 || !isLayoutRouteKind(route.kind)) return createElement(component as never, props as never); + const identity: AgentLayoutRoute = { + id: route.id, + kind: route.kind, + name: layoutRouteName(route), + ...(route.serverId === undefined ? {} : { serverId: route.serverId }), + }; + const Composed = async (): Promise => { + let composed: unknown = await component(props as never); + for (const layout of [...chain].reverse()) { + const layoutProps: AgentLayoutProps = { children: composed as never, route: identity, signal }; + composed = createElement(layout.module.default as never, layoutProps as never); + } + return composed; + }; + return createElement(Composed as never); +}; diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index b8ca6cda5..2bacfaaf7 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -10,6 +10,7 @@ import type { CompiledAgentRoute, CompiledCliCommand, CompiledProvider, + CompiledLayoutScope, CompiledRouteGraph, CompiledRouteKind, } from '../routes/types.ts'; @@ -148,6 +149,18 @@ export const FALLBACK_PLUGIN_IDENTITY: TestManifestPluginIdentity = Object.freez */ export const isFallbackPluginIdentity = (plugin: TestManifestPluginIdentity): boolean => plugin === FALLBACK_PLUGIN_IDENTITY; +/** One conventional layout module the harness composes around manifest route renders, exactly as generated workers do. */ +export interface TestableLayoutDescriptor { + /** `layout:root` or `layout:mcp:`. */ + readonly id: string; + /** Project-relative POSIX path of the layout module. */ + readonly relativePath: string; + readonly scope: CompiledLayoutScope; + /** The owning MCP server id (`mcp:`); `server` scope only. */ + readonly serverId?: string; + /** Absolute layout module path. */ + readonly source: string; +} /** The conventional state module the generated route-unit registry can load. */ export interface TestableStateDescriptor { @@ -214,6 +227,8 @@ export interface AgentBundleTestManifest { readonly digest: string; /** Generated MCP server that owns the shared event runtime, when event routes and a generated server coexist. */ readonly eventRuntimeServerId?: string; + /** Conventional layouts from the same pass, ordered by id; empty when the project declares none. */ + readonly layouts: readonly TestableLayoutDescriptor[]; /** Plugin name and version, as the generated MCP server reports them in `initialize`. */ readonly plugin: TestManifestPluginIdentity; readonly projectRoot: string; @@ -335,6 +350,15 @@ export const testManifestFromRouteGraph = (input: { diagnostics: [...(input.diagnostics ?? input.graph.diagnostics)], digest: input.graph.digest, ...(eventRuntimeServerId === undefined ? {} : { eventRuntimeServerId }), + layouts: [...(input.graph.layouts ?? [])] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((layout) => ({ + id: layout.id, + relativePath: layout.provenance.relativePath, + scope: layout.scope, + ...(layout.serverId === undefined ? {} : { serverId: layout.serverId }), + source: layout.source, + })), plugin: input.plugin ?? FALLBACK_PLUGIN_IDENTITY, projectRoot: input.projectRoot, proofLevel: ROUTE_UNIT_PROOF_LEVEL, diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index ad31b76c5..508165018 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -21,9 +21,11 @@ import type { AgentStateEventSchemas, } from '@agent-bundle/runtime/state'; import type { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount'; +import type { ReactNode } from 'react'; import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; import { AgentTestError, captured } from './errors.ts'; +import { composeLayouts, loadLayoutChain, type LoadedLayout } from './layouts.ts'; import { MCP_IN_MEMORY_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; import { claimProcessHit, mountProviders } from './providers.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; @@ -268,6 +270,7 @@ export const openInMemoryMcpServer = async < module: { default: (props: never) => unknown; inputSchema?: unknown; resultSchema: { parse: (value: unknown) => unknown } }; name: string; }> = {}; + const layoutsByRoute = new Map(); for (const descriptor of descriptors) { if (descriptor.kind !== 'tool' && descriptor.kind !== 'resource' && descriptor.kind !== 'prompt') continue; const loader = registeredRouteLoader(manifest, descriptor.id); @@ -303,6 +306,10 @@ export const openInMemoryMcpServer = async < module: { ...module, resultSchema: module.resultSchema }, name: descriptor.id.slice(descriptor.id.lastIndexOf('/') + 1), }; + layoutsByRoute.set( + descriptor.id, + await loadLayoutChain(manifest, descriptor, routeProvenance(descriptor, manifest)), + ); } if (options.state !== undefined) { const record = dependencies.noticeInboxRoute.noticeInboxRouteRecord(dependencies.noticeInboxRoute); @@ -373,10 +380,14 @@ export const openInMemoryMcpServer = async < ...(request.progress === undefined ? {} : { progress: request.progress }), signal: request.signal, }, async () => drain(dependencies.renderAgentFlight( - dependencies.createElement(route.module.default as never, { - input: props.input, - signal: request.signal, - } as never), + composeLayouts( + dependencies.createElement, + layoutsByRoute.get(route.id) ?? [], + { id: route.id, kind: route.kind, serverId: `mcp:${serverName}` }, + route.module.default, + { input: props.input, signal: request.signal }, + request.signal, + ) as ReactNode, { signal: request.signal }, )))); } finally { diff --git a/packages/agent-bundle/src/test/registry.ts b/packages/agent-bundle/src/test/registry.ts index 7234b924b..0f7da6ef8 100644 --- a/packages/agent-bundle/src/test/registry.ts +++ b/packages/agent-bundle/src/test/registry.ts @@ -2,7 +2,7 @@ import type { AgentStateDefinition, AgentStateEventSchemas } from '@agent-bundle import { AgentTestError } from './errors.ts'; import type { AgentBundleTestManifest } from './manifest.ts'; -import type { AgentRouteModuleLoader } from './types.ts'; +import type { AgentLayoutModule, AgentRouteModuleLoader } from './types.ts'; /** * The realm bridge between the generated Rstest configuration and the test @@ -20,16 +20,20 @@ const REGISTRY_SYMBOL = Symbol.for(AGENT_TEST_REGISTRY_SYMBOL_KEY); * Bumped whenever the registry layout changes so a setup module and the * helpers reading it never silently disagree about what the registry carries. * 4: `providerLoaders` (conventional context providers mounted by the harness). + * 5: `layoutLoaders` (conventional layouts composed around manifest renders). */ -export const AGENT_TEST_REGISTRY_VERSION = 4; +export const AGENT_TEST_REGISTRY_VERSION = 5; export type AgentStateModuleLoader = () => Promise<{ readonly default: AgentStateDefinition; }>; export type AgentProviderModuleLoader = () => Promise<{ readonly default?: unknown }>; +export type AgentLayoutModuleLoader = () => Promise; export interface AgentTestRouteRegistry { + /** Lazy loaders keyed by compiled layout id (`layout:root`, `layout:mcp:`). */ + readonly layoutLoaders?: Readonly>; /** Lazy loaders keyed by compiled route id, so a test only compiles the routes it renders. */ readonly loaders: Readonly>; readonly manifest: AgentBundleTestManifest; @@ -133,6 +137,16 @@ export const registeredProviderLoader = ( return registry.providerLoaders?.[providerId]; }; +/** The layout-module loader generated beside the registered manifest for one compiled layout id. */ +export const registeredLayoutLoader = ( + manifest: AgentBundleTestManifest, + layoutId: string, +): AgentLayoutModuleLoader | undefined => { + const registry = registered(); + if (registry === undefined || !producedRegisteredLoaders(registry, manifest)) return undefined; + return registry.layoutLoaders?.[layoutId]; +}; + /** The registered manifest's identity, so a loader miss can name the mismatch that caused it. */ export const registeredManifestIdentity = (): { readonly digest: string; readonly projectRoot: string } | undefined => { const registry = registered(); diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 0d838b771..4eda7ce86 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -29,6 +29,7 @@ import type { import { createProviderProcessLifetime, type ProviderProcessLifetime } from '../routes/provider-execution.ts'; import type { CompiledCliCommand } from '../routes/types.ts'; import { AgentTestError, captured } from './errors.ts'; +import { composeLayouts, loadLayoutChain, type LayoutChainTarget, type LoadedLayout } from './layouts.ts'; import { ROUTE_UNIT_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; import { claimProcessHit, mountProviders } from './providers.ts'; import { @@ -303,6 +304,8 @@ const knownRouteIds = (manifest: AgentBundleTestManifest): string => interface ResolvedTarget { readonly component: (props: never) => unknown; readonly kind: RenderableRouteKind; + /** The manifest layout chain, outermost first; empty for a module rendered directly. */ + readonly layouts: readonly LoadedLayout[]; readonly manifest?: AgentBundleTestManifest; readonly module: AgentRouteModule; readonly provenance: RenderedRouteProvenance; @@ -398,7 +401,7 @@ const resolveTarget = async ( source: 'module', targets: [], }); - return { component: componentOf(target, provenance), kind, module: target, provenance }; + return { component: componentOf(target, provenance), kind, layouts: [], module: target, provenance }; } const manifest = options.manifest ?? testManifest(); const descriptor = manifest.routes[target]; @@ -457,9 +460,11 @@ const resolveTarget = async ( ); } const module = await loader(); + const layouts = await loadLayoutChain(manifest, descriptor, { ...provenance, kind }); return { component: componentOf(module, { ...provenance, kind }), kind, + layouts, manifest, module, provenance: { ...provenance, kind }, @@ -609,6 +614,9 @@ interface FlightDispatcherOptions { readonly component: (props: never) => unknown; readonly componentProps: (request: AgentRenderDispatch) => Readonly>; readonly contextProgress?: AgentProgressReporter; + /** The manifest layout chain composed around the route element, exactly as the generated worker composes it. */ + readonly layouts: readonly LoadedLayout[]; + readonly layoutRoute: LayoutChainTarget; readonly limits?: Partial; readonly renderer: Renderer; /** Async so conventional providers execute inside the request, before the scope opens, as generated scopes do. */ @@ -622,10 +630,14 @@ const createFlightDispatcher = (options: FlightDispatcherOptions): AgentRuntime. progress: progressFor(options.collected, options.contextProgress, request.progress), signal: request.signal, }, async () => drain(options.renderer.renderAgentFlight( - options.renderer.createElement( - options.component as never, - options.componentProps(request) as never, - ), + composeLayouts( + options.renderer.createElement, + options.layouts, + options.layoutRoute, + options.component, + options.componentProps(request), + request.signal, + ) as React.ReactNode, { signal: request.signal }, )))), }, options.limits === undefined ? {} : { limits: options.limits }); @@ -666,6 +678,15 @@ export const prepareCliRenderHost = async ( renderer, options.signal, ); + // Rendered commands compose the manifest layout chain of their backing + // route (a projected MCP command keeps its tool route's server layout), + // resolved up front because the generated shell's render factory is sync. + const layoutsByRoute = new Map(); + for (const routeId of options.modules.keys()) { + const descriptor = options.manifest.routes[routeId]; + if (descriptor === undefined) continue; + layoutsByRoute.set(routeId, await loadLayoutChain(options.manifest, descriptor, { ...options.provenance, routeId })); + } return Object.freeze({ close: mounted.close, render: ( @@ -711,11 +732,14 @@ export const prepareCliRenderHost = async ( props: { input: parsed as never, operationId: command.routeId }, }; const collected: AgentProgressUpdate[] = []; + const descriptor = options.manifest.routes[command.routeId]; const dispatcher = createFlightDispatcher({ collected, component: componentOf(module, { ...options.provenance, routeId: command.routeId }), componentProps: (request) => ({ input: parsed, signal: request.signal }), contextProgress: context.progress, + layoutRoute: descriptor ?? { id: command.routeId, kind: command.mcp === undefined ? 'cli' : 'tool' }, + layouts: layoutsByRoute.get(command.routeId) ?? [], renderer, requestInit: async (request) => { const root = process.cwd(); @@ -808,6 +832,12 @@ const prepareRender = async ( component: resolved.component, componentProps: (request) => componentProps(request.invocation, resolved.kind, options, request.signal), contextProgress: context.progress, + layoutRoute: { + id: resolved.provenance.routeId, + kind: resolved.kind, + ...(resolved.provenance.serverId === undefined ? {} : { serverId: resolved.provenance.serverId }), + }, + layouts: resolved.layouts, limits: options.limits, renderer, requestInit: async (request) => ({ diff --git a/packages/agent-bundle/src/test/types.ts b/packages/agent-bundle/src/test/types.ts index 038966c47..3bcce41a6 100644 --- a/packages/agent-bundle/src/test/types.ts +++ b/packages/agent-bundle/src/test/types.ts @@ -26,6 +26,11 @@ export interface AgentRouteModule { export type AgentRouteModuleLoader = () => Promise; +/** One conventional layout module: the default component the harness composes around a route, as generated workers do. */ +export interface AgentLayoutModule { + readonly default: (props: never) => unknown; +} + /** * Where a rendered route came from and what the render proves. Every harness * failure reports this block so a red test names the route, the module, the diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 9e792d65e..255151fd5 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -364,7 +364,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat '"hook:event-route:tool-after": Object.freeze({ event: "tool/after", id: "event:tool/after", kind: \'event-route\'', ); expect(createHash('sha256').update(source).digest('hex')).toBe( - '36f042498df1933c6321bd21e4585599a0d39e5ddb3890657bd660c322f4cc23', + 'e9126849e5ad955dbd1f3d56ccdcb0eabe1293d265f861dd5a10339c1e2a3bfb', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -711,6 +711,190 @@ it('keeps the generated provider loop and the in-process execution helper identi })).rejects.toThrow('Context provider "zeta" (src/providers/zeta.ts) failed: boom'); }); +const layoutFixtures = [ + { + id: 'layout:mcp:curator', + provenance: { kind: 'conventional' as const, relativePath: 'src/mcp/curator/layout.tsx' }, + scope: 'server' as const, + serverId: 'mcp:curator', + source: '/project/src/mcp/curator/layout.tsx', + }, + { + id: 'layout:root', + provenance: { kind: 'conventional' as const, relativePath: 'src/layout.tsx' }, + scope: 'root' as const, + source: '/project/src/layout.tsx', + }, +]; + +it('composes the root and server layout chain around generated MCP routes and never around event routes', () => { + const source = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + eventRoutes: [{ + event: 'afterTool', + eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:tool-after', + name: 'event-route-tool-after', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/after.tsx' }, + source: '/project/src/events/tool/after.tsx', + targets: ['claude'], + tools: [], + }], + layouts: layoutFixtures, + routes: [ + { + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + serverId: 'mcp:curator', + source: '/project/src/mcp/curator/tools/inspect.tsx', + }, + { + config: { uri: 'curator://catalog' }, + id: 'resource:other/catalog', + kind: 'resource', + provenance: { kind: 'conventional', relativePath: 'src/mcp/other/resources/catalog.tsx' }, + serverId: 'mcp:other', + source: '/project/src/mcp/other/resources/catalog.tsx', + }, + ], + serverName: 'curator', + }); + + // Layout imports are ordered by id so the emitted worker is deterministic. + expect(source).toContain('import * as layout0 from "/project/src/mcp/curator/layout.tsx"'); + expect(source).toContain('import * as layout1 from "/project/src/layout.tsx"'); + // Root first, then the owning server's layout — the outer-to-inner chain. + expect(source).toContain('id: "tool:curator/inspect", kind: "tool", layouts: Object.freeze([1,0])'); + expect(source).toContain('serverId: "mcp:curator"'); + // A route of another server takes only the root layout. + expect(source).toContain('id: "resource:other/catalog", kind: "resource", layouts: Object.freeze([1])'); + // Event routes carry no layout chain. + expect(source).toMatch(/"hook:event-route:tool-after": Object\.freeze\(\{ event: "tool\/after", id: "event:tool\/after", kind: 'event-route', module: route2, name: "tool\/after" \}\)/u); + expect(source).toContain('composed = createElement(layout.module.default, { children: composed, route: { id: route.id, kind: route.kind, name: route.name, ...(route.serverId === undefined ? {} : { serverId: route.serverId }) }, signal })'); + expect(source).toContain('must default-export a function component'); + // The route element is awaited by one root component before wrapping, so a + // throwing route still rejects the Flight root exactly as it does without a layout. + expect(source).toContain('let composed = await route.module.default(props);'); + expect(source).toContain('if (chain.length === 0) return createElement(route.module.default, props);'); + expect(source).toContain('renderAgentFlight(composeLayouts(route, props, controller.signal)'); +}); + +it('imports only the layouts some route of the worker composes through, never another server\'s layout', () => { + // The rendered CLI/script worker carries the whole project layout list but + // only root layouts apply to its routes; the curator server layout must not + // be evaluated in that process at all. + const rendered = entryShellModule.generatedRenderedRouteWorkerSource({ + layouts: layoutFixtures, + routes: [ + { + config: {}, + id: 'cli:library/audit', + kind: 'cli', + provenance: { kind: 'conventional', relativePath: 'src/cli/library/audit.tsx' }, + source: '/project/src/cli/library/audit.tsx', + }, + { + config: {}, + id: 'script:rebuild-index', + kind: 'script', + provenance: { kind: 'conventional', relativePath: 'src/scripts/rebuild-index.tsx' }, + source: '/project/src/scripts/rebuild-index.tsx', + }, + ], + }); + expect(rendered).toContain('import * as layout0 from "/project/src/layout.tsx"'); + expect(rendered).not.toContain('/project/src/mcp/curator/layout.tsx'); + expect(rendered).toContain('"cli:library/audit": Object.freeze({ id: "cli:library/audit", kind: "cli", name: "library audit", module: route0, layouts: Object.freeze([0]) })'); + expect(rendered).toContain('"script:rebuild-index": Object.freeze({ id: "script:rebuild-index", kind: "script", name: "rebuild-index", module: route1, layouts: Object.freeze([0]) })'); + + // Another generated server's worker likewise skips the curator layout. + const otherServer = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + layouts: layoutFixtures, + routes: [{ + config: { uri: 'other://catalog' }, + id: 'resource:other/catalog', + kind: 'resource', + provenance: { kind: 'conventional', relativePath: 'src/mcp/other/resources/catalog.tsx' }, + serverId: 'mcp:other', + source: '/project/src/mcp/other/resources/catalog.tsx', + }], + serverName: 'other', + }); + expect(otherServer).toContain('import * as layout0 from "/project/src/layout.tsx"'); + expect(otherServer).not.toContain('/project/src/mcp/curator/layout.tsx'); + expect(otherServer).toContain('id: "resource:other/catalog", kind: "resource", layouts: Object.freeze([0])'); + + // A server layout alone, for a worker whose routes never take it, leaves the + // worker byte-identical to a layout-free build. + const serverOnly = layoutFixtures.filter((layout) => layout.scope === 'server'); + const cliRoutes = [{ + config: {}, + id: 'cli:library/audit', + kind: 'cli' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/cli/library/audit.tsx' }, + source: '/project/src/cli/library/audit.tsx', + }]; + expect(entryShellModule.generatedRenderedRouteWorkerSource({ layouts: serverOnly, routes: cliRoutes })) + .toBe(entryShellModule.generatedRenderedRouteWorkerSource({ routes: cliRoutes })); +}); + +it('emits an identity composition when no layout exists so layout-free workers render exactly the route element', () => { + const source = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + routes: [{ + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + serverId: 'mcp:curator', + source: '/project/src/mcp/curator/tools/inspect.tsx', + }], + serverName: 'curator', + }); + + expect(source).toContain('const composeLayouts = (route, props) => createElement(route.module.default, props);'); + expect(source).not.toContain('import * as layout0'); + expect(source).not.toContain('layouts: Object.freeze('); +}); + +it('hands rendered CLI, projected MCP, and script routes their layout chain and protocol-facing name', () => { + const source = entryShellModule.generatedRenderedRouteWorkerSource({ + layouts: layoutFixtures, + routes: [ + { + config: {}, + id: 'cli:library/audit', + kind: 'cli', + provenance: { kind: 'conventional', relativePath: 'src/cli/library/audit.tsx' }, + source: '/project/src/cli/library/audit.tsx', + }, + { + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + serverId: 'mcp:curator', + source: '/project/src/mcp/curator/tools/inspect.tsx', + }, + { + config: {}, + id: 'script:rebuild-index', + kind: 'script', + provenance: { kind: 'conventional', relativePath: 'src/scripts/rebuild-index.tsx' }, + source: '/project/src/scripts/rebuild-index.tsx', + }, + ], + }); + + expect(source).toContain('"cli:library/audit": Object.freeze({ id: "cli:library/audit", kind: "cli", name: "library audit", module: route0, layouts: Object.freeze([1]) })'); + expect(source).toContain('"tool:curator/inspect": Object.freeze({ id: "tool:curator/inspect", kind: "tool", name: "inspect", serverId: "mcp:curator", module: route1, layouts: Object.freeze([1,0]) })'); + expect(source).toContain('"script:rebuild-index": Object.freeze({ id: "script:rebuild-index", kind: "script", name: "rebuild-index", module: route2, layouts: Object.freeze([1]) })'); + expect(source).toContain('renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal)'); +}); + it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { const route = { config: {}, diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts new file mode 100644 index 000000000..4419e78ed --- /dev/null +++ b/packages/agent-bundle/tests/layout-build.test.ts @@ -0,0 +1,257 @@ +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { execFile as executeFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { build } from '../src/api.ts'; + +const execFile = promisify(executeFile); +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 lookupRoute = [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", + 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', + "export const resultSchema = z.object({ invocation: z.literal('tool'), message: z.string() }).strict();", + 'export default async function Lookup({ input }) {', + ' const context = await agent();', + ' const result = { invocation: context.invocation.kind, message: input.message };', + ' return {`Lookup: ${input.message}`};', + '}', + '', +].join('\n'); + +const explodeRoute = [ + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: true }, description: 'Throws before rendering.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ok: z.boolean() }).strict();', + 'export default async function Explode() {', + " throw new Error('lookup exploded');", + '}', + '', +].join('\n'); + +/** Writes one project with a root layout, a `harness` server layout, an MCP server, a routed CLI, and a rendered script. */ +const writeLayoutProject = async (root: string, layouts: Readonly>): Promise => { + // The audiobook example's installed tree supplies @agent-bundle/runtime, react, and zod. + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + '@modelcontextprotocol/server': '2.0.0', + react: '19.2.8', + zod: '4.4.3', + }, + name: 'layout-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { description: 'Layout fixture.', name: 'layout-fixture', version: '1.0.0' },", + ' routes: { mcpCommands: true },', + " targets: ['portable'],", + '});', + '', + ].join('\n')), + ...Object.entries(layouts).map(([path, contents]) => writeProjectFile(root, path, contents)), + writeProjectFile(root, 'src/mcp/harness/tools/lookup.tsx', lookupRoute), + writeProjectFile(root, 'src/mcp/harness/tools/explode.tsx', explodeRoute), + writeProjectFile(root, 'src/cli/report.tsx', [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { description: 'Render a library report.', positionals: ['root'] };", + 'export const inputSchema = z.object({ root: z.string().min(1) }).strict();', + 'export const resultSchema = z.object({ books: z.number(), root: z.string() }).strict();', + 'export default async function Report({ input }) {', + ' const context = await agent();', + " await context.progress.report({ completed: 1, message: 'scanning', total: 1 });", + ' const result = { books: 2, root: input.root };', + ' return {`Found **2** books under ${input.root}.`};', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/scripts/summarize.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + 'export default async function Summarize({ argv }) {', + ' return {`Summarized ${String(argv.length)} arguments.`};', + '}', + '', + ].join('\n')), + ]); +}; + +const rootLayout = [ + "import { Agent, agent } from '@agent-bundle/runtime';", + 'export default async function Layout({ children, route }) {', + ' const context = await agent();', + ' return (', + ' ', + ' {children}', + ' {`shell: ${route.kind} ${route.name}`}', + ' ', + ' );', + '}', + '', +].join('\n'); + +const serverLayout = [ + "import { Agent } from '@agent-bundle/runtime';", + 'export default function HarnessLayout({ children, route }) {', + ' return (', + ' ', + ' {`server: ${route.serverId}`}', + ' {children}', + ' ', + ' );', + '}', + '', +].join('\n'); + +const connectServer = async (root: string, entry: string): Promise<{ readonly client: Client; readonly close: () => Promise }> => { + const client = new Client({ name: 'layout-build-test', version: '0.0.0' }); + const transport = new StdioClientTransport({ args: [entry], command: process.execPath, stderr: 'pipe' }); + let diagnostics = ''; + transport.stderr?.on('data', (chunk) => { diagnostics += String(chunk); }); + try { + await client.connect(transport); + } catch (error) { + throw new Error(`Generated route server failed to connect: ${diagnostics}`, { cause: error }); + } + return { client, close: async () => { await client.close(); } }; +}; + +/** + * The shared layout convention (#312) at the built-artifact level: the root + * `src/layout.tsx` and the `src/mcp/harness/layout.tsx` server layout compose + * around every rendered surface one build ships — the generated MCP server, + * the routed CLI executable, its projected MCP commands, and a rendered + * script — while every route keeps its own result value and a throwing route + * still fails closed. + */ +it('composes the root and server layouts around every rendered surface of one build', { retry: 2, timeout: 180_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-layout-build-')); + roots.push(root); + await writeLayoutProject(root, { + 'src/layout.tsx': rootLayout, + 'src/mcp/harness/layout.tsx': serverLayout, + }); + + const output = join(root, 'artifact'); + const result = await build({ output, packageOutputs: true, root }); + expect(result.model.layouts?.map((layout) => layout.id)).toEqual(['layout:root', 'layout:mcp:harness']); + // Layout modules are source inputs of every worker that composes them. + const binEvidence = result.packageBuild!.files.find((file) => file.path === 'bin/layout-fixture-flight.mjs'); + expect(binEvidence?.sourceInputs).toEqual(expect.arrayContaining(['src/layout.tsx', 'src/mcp/harness/layout.tsx'])); + + // The generated MCP server composes root + server layout around a tool and + // keeps the route's value as structuredContent. + const server = result.model.mcpServers[0]; + if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); + const session = await connectServer(root, join(output, 'portable', server.args[0])); + try { + const lookup = await session.client.callTool({ arguments: { message: 'wired' }, name: 'lookup' }, { signal: AbortSignal.timeout(20_000) }); + expect(lookup).toMatchObject({ + content: [ + { text: 'server: mcp:harness', type: 'text' }, + { text: 'Lookup: wired', type: 'text' }, + { text: 'shell: tool lookup', type: 'text' }, + ], + structuredContent: { invocation: 'tool', message: 'wired' }, + }); + expect(lookup.isError).toBeFalsy(); + // A throwing route under a layout fails closed exactly like a throwing root. + const exploded = await session.client.callTool({ arguments: {}, name: 'explode' }, { signal: AbortSignal.timeout(20_000) }) + .catch((error: unknown) => error); + const rendered = exploded instanceof Error ? `${exploded.name} ${exploded.message}` : JSON.stringify(exploded); + expect(rendered).toContain('lookup exploded'); + expect(rendered).not.toContain('shell: tool explode'); + } finally { + await session.close(); + } + + // The routed CLI: a rendered command takes only the root layout. + const binPath = join(root, 'dist', 'bin', 'layout-fixture.js'); + const piped = await execFile(binPath, ['report', '/library']); + expect(piped.stdout).toBe('Found **2** books under /library.\n\n> shell: cli report\n'); + const reportJson = await execFile(binPath, ['report', '/library', '--json']); + expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library' }); + const reportEvents = await execFile(binPath, ['report', '/library', '--ndjson']); + const complete = reportEvents.stdout.trimEnd().split('\n') + .map((line) => JSON.parse(line) as { document?: { root: { kind: string; metadata?: unknown }; value?: unknown }; type: string }) + .findLast((event) => event.type === 'complete'); + expect(complete?.document).toMatchObject({ + root: { kind: 'result', metadata: { invocation: 'cli', shell: 'layout-fixture', wrapped: 'cli' } }, + value: { books: 2, root: '/library' }, + }); + + // A projected MCP command keeps its tool route's server layout, and the + // route's own metadata merges beneath both layouts. + const projected = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"projected"}']); + expect(projected.stdout).toBe('server: mcp:harness\n\nLookup: projected\n\n> shell: tool lookup\n'); + const projectedEvents = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"events"}', '--ndjson']); + const projectedComplete = projectedEvents.stdout.trimEnd().split('\n') + .map((line) => JSON.parse(line) as { document?: { root: { metadata?: unknown }; value?: unknown }; type: string }) + .findLast((event) => event.type === 'complete'); + expect(projectedComplete?.document).toMatchObject({ + root: { + metadata: { from: 'route', invocation: 'tool', layout: 'harness', route: 'tool:harness/lookup', shell: 'layout-fixture', wrapped: 'tool' }, + }, + value: { invocation: 'tool', message: 'events' }, + }); + await expect(execFile(binPath, ['harness', 'explode', '--input', '{}'])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining('lookup exploded'), + stdout: '', + }); + + // A rendered script takes the root layout. + const scriptPath = join(output, 'portable', 'scripts', 'summarize.mjs'); + const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); + expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n\n> shell: script summarize\n'); + const scriptJson = await execFile(process.execPath, [scriptPath, 'alpha', '--json']); + expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1 }); +}); + +it('ships byte-identical surfaces when no layout exists and refuses an invalid layout module before building', { retry: 2, timeout: 180_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-layout-free-')); + roots.push(root); + await writeLayoutProject(root, {}); + + const output = join(root, 'artifact'); + const result = await build({ output, packageOutputs: true, root }); + expect(result.model.layouts).toBeUndefined(); + const binPath = join(root, 'dist', 'bin', 'layout-fixture.js'); + const piped = await execFile(binPath, ['report', '/library']); + expect(piped.stdout).toBe('Found **2** books under /library.\n'); + const projected = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"plain"}']); + expect(projected.stdout).toBe('Lookup: plain\n'); + const scriptMarkdown = await execFile(process.execPath, [join(output, 'portable', 'scripts', 'summarize.mjs'), 'alpha']); + expect(scriptMarkdown.stdout).toBe('Summarized 1 arguments.\n'); + + // An invalid layout module is a compile-time error (AB4830), never a runtime surprise. + await writeProjectFile(root, 'src/layout.tsx', 'export default { children: undefined };\n'); + await expect(build({ output, packageOutputs: true, root })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'AB4830', severity: 'error' })], + name: 'DiagnosticError', + }); +}); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 74e91351a..5d4670c25 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -121,6 +121,7 @@ it('serves compiled routes and durable state across packed process restarts', as 'context', 'echo', 'journal', + 'layout-probe', 'lifecycle', 'mutation-probe', 'publish-notice', diff --git a/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts index 84e5003c8..a5d2b782c 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts @@ -31,6 +31,21 @@ describe('projected MCP tools at the CLI dispatch level', () => { expect(run.routeId).toBe('tool:harness/echo'); }); + it('composes the projected tool through its server layout chain, exactly like the MCP server', async () => { + const run = await invokeCli([ + 'harness', + 'layout-probe', + '--input', + '{"label":"projected"}', + ]); + + expect(run.exitCode).toBe(0); + expect(run.stderr).toBe(''); + expect(run.stdout).toBe('probe: projected\n\nlayout: tool layout-probe via mcp:harness\n'); + expect(run.routeId).toBe('tool:harness/layout-probe'); + expect(run.value).toEqual({ label: 'projected' }); + }); + it('emits the canonical tool result under --json', async () => { const run = await invokeCli([ 'harness', diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index d41045e26..714007fa9 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -31,6 +31,7 @@ describe('the CLI dispatch level', () => { 'harness context', 'harness echo', 'harness journal', + 'harness layout-probe', 'harness lifecycle', 'harness mutation-probe', 'harness publish-notice', diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 737d1b24d..c0069af58 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -31,7 +31,7 @@ describe('the in-memory MCP projection level', () => { it('registers every compiled route kind on the real generated server', async () => { const surface = await listMcpSurface(); - expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'journal', 'lifecycle', 'mutation-probe', 'publish-notice', 'strict-report', 'ticket', 'tooling', 'unavailable', 'wait']); + expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'journal', 'layout-probe', 'lifecycle', 'mutation-probe', 'publish-notice', 'strict-report', 'ticket', 'tooling', 'unavailable', 'wait']); expect(surface.prompts).toEqual(['summarize']); expect(surface.resources).toEqual(['harness://notes']); expect(surface.provenance).toMatchObject({ @@ -43,6 +43,7 @@ describe('the in-memory MCP projection level', () => { 'tool:harness/context', 'tool:harness/echo', 'tool:harness/journal', + 'tool:harness/layout-probe', 'tool:harness/lifecycle', 'tool:harness/mutation-probe', 'tool:harness/publish-notice', @@ -56,6 +57,19 @@ describe('the in-memory MCP projection level', () => { }); }); + it('composes the compiled layout chain around a tool while the route keeps its protocol result shape', async () => { + const invocation = await invokeMcpTool('layout-probe', { input: { label: 'wired' } }); + + expect(invocation.isError).toBe(false); + // The route's own text, then the server layout's addition for this route: + // the layout's container result merged with the route's valued result. + expect(invocation.content).toEqual([ + { text: 'probe: wired', type: 'text' }, + { text: 'layout: tool layout-probe via mcp:harness', type: 'text' }, + ]); + expect(invocation.structuredContent).toEqual({ label: 'wired' }); + }); + it('projects a rendered Agent Document into the protocol content the server returns', async () => { const invocation = await invokeMcpTool('echo', { context: { workspace: { source: 'native', state: 'available', value: { root: '/tmp/harness-library' } } as never }, @@ -104,11 +118,27 @@ describe('the in-memory MCP projection level', () => { _meta: { ui: { resourceUri: 'ui://route-harness/panel.html' } }, outputSchema: { type: 'object' }, }); + // The route's own metadata reaches _meta merged with the fixture's root and + // server layout metadata (the layouts are containers, so the route's keys + // sit beside theirs); the metadata-free echo route carries only the + // layouts' keys. A layout-free document with no metadata projects no _meta + // at all — pinned by mcp-projector.test.ts and generated-route-server.test.ts. + const layoutMeta = (routeId: string) => ({ + invocation: 'tool', + layout: 'harness', + route: routeId, + server: 'mcp:harness', + shell: 'route-harness', + wrapped: 'tool', + }); const invocation = await invokeMcpTool('strict-report', { input: { reportId: 'meta-1' } }); - expect(invocation._meta).toEqual({ ui: { resourceUri: 'ui://route-harness/panel.html' } }); + expect(invocation._meta).toEqual({ + ...layoutMeta('tool:harness/strict-report'), + ui: { resourceUri: 'ui://route-harness/panel.html' }, + }); expect(invocation.structuredContent).toEqual({ reportId: 'meta-1', summary: 'summary for meta-1' }); const echo = await invokeMcpTool('echo', { input: { message: 'no metadata' } }); - expect(echo._meta).toBeUndefined(); + expect(echo._meta).toEqual(layoutMeta('tool:harness/echo')); }); it('carries a represented error to the protocol as isError rather than a transport failure', async () => { diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 50293ae78..0415bc0b2 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -611,6 +611,44 @@ it('omits a server\'s routes and silences AB4800 under an explicit custom mode', expect(graph.servers[0]!.routes).toEqual([]); }); +it('skips a server layout entirely when routes.servers pins that server to a non-generated mode', async () => { + const root = await createRoot(); + // The custom-mode layout is deliberately invalid and duplicated across .ts + // and .tsx, and the remote server has no route modules: none of AB4830, + // AB4831, or AB4832 may fire because the opt-out means no generated worker + // will ever compose those layouts. + await writeTree(root, { + 'src/layout.tsx': 'export default ({ children }) => children;\n', + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/layout.ts': 'export default { children: undefined };\n', + 'src/mcp/curator/layout.tsx': 'export default { children: undefined };\n', + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'src/mcp/relay/layout.tsx': 'export default ({ children }) => children;\n', + }); + const graph = await compileRouteGraph(root, fixtureConfig({ + mcp: { + servers: { + curator: { entry: './src/mcp/curator.ts' }, + relay: { url: 'https://example.test/mcp' }, + }, + }, + routes: { servers: { curator: 'custom', relay: 'remote' } }, + })); + + expect(graph.diagnostics).toEqual([]); + expect(graph.layouts!.map((layout) => layout.id)).toEqual(['layout:root']); + expect(graph.servers.map((server) => ({ mode: server.mode, name: server.name }))).toEqual([{ mode: 'custom', name: 'curator' }]); + + // Flipping the same server back to generated re-enables both the duplicate + // and the contract checks. + const generated = await compileRouteGraph(root, fixtureConfig({ + mcp: { servers: { relay: { url: 'https://example.test/mcp' } } }, + routes: { servers: { curator: 'generated', relay: 'remote' } }, + })); + expect(codesOf(generated.diagnostics)).toEqual(['AB4831', 'AB4830']); + expect(generated.layouts!.map((layout) => layout.id)).toEqual(['layout:root', 'layout:mcp:curator']); +}); + it('errors with AB4801 when the conventional CLI entry and command routes both exist', async () => { const root = await createRoot(); await writeTree(root, { @@ -1401,6 +1439,94 @@ it('validates provider default factories with AB4940', async () => { ]); }); +it('discovers the root and per-server layout modules without changing a layout-free graph digest', async () => { + const root = await createRoot(); + await writeTree(root, conventionalTree); + const layoutFree = await compileRouteGraph(root, fixtureConfig()); + expect(layoutFree.layouts).toBeUndefined(); + expect('layouts' in layoutFree).toBe(false); + + await writeTree(root, { + 'src/layout.tsx': 'export default function Layout({ children }) { return children; }\n', + 'src/mcp/curator/layout.tsx': 'export default async ({ children }) => children;\n', + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics).toEqual([]); + expect(graph.layouts).toEqual([ + { + id: 'layout:root', + provenance: { kind: 'conventional', relativePath: 'src/layout.tsx' }, + scope: 'root', + source: join(root, 'src/layout.tsx'), + }, + { + id: 'layout:mcp:curator', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/layout.tsx' }, + scope: 'server', + serverId: 'mcp:curator', + source: join(root, 'src/mcp/curator/layout.tsx'), + }, + ]); + // The layout is never a route: the server's route list and ids are unchanged. + expect(graph.servers[0]!.routes.map((route) => route.id)).toEqual(layoutFree.servers[0]!.routes.map((route) => route.id)); + expect(graph.digest).not.toBe(layoutFree.digest); + expect(isEmptyRouteGraph(graph)).toBe(false); + + // A private layout file opts out of the convention. + const optedOut = await createRoot(); + await writeTree(optedOut, { + ...conventionalTree, + 'src/_layout.tsx': 'export default ({ children }) => children;\n', + 'src/mcp/curator/_layout.tsx': 'export default ({ children }) => children;\n', + }); + const optedOutGraph = await compileRouteGraph(optedOut, fixtureConfig()); + expect(optedOutGraph.layouts).toBeUndefined(); + expect(optedOutGraph.digest).toBe(layoutFree.digest); +}); + +it('validates layout modules with AB4830, duplicate scopes with AB4831, and orphaned server layouts with AB4832', async () => { + const root = await createRoot(); + await writeTree(root, { + ...conventionalTree, + 'src/layout.ts': 'export default ({ children }) => children;\n', + 'src/layout.tsx': 'export default ({ children }) => children;\n', + 'src/mcp/curator/layout.tsx': [ + 'export const inputSchema = {};', + 'export const resultSchema = {};', + 'export default { children: undefined };', + '', + ].join('\n'), + 'src/mcp/ghost/layout.tsx': 'export default ({ children }) => children;\n', + // App routes are browser builds that never take a layout, so a server + // declaring only apps is orphaned for layout purposes too. + 'src/mcp/panel/apps/main.tsx': `export const config = { resourceUri: 'ui://panel/main.html' }; ${moduleSource}`, + 'src/mcp/panel/layout.tsx': 'export default ({ children }) => children;\n', + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics.map(({ code, sourcePath }) => ({ + code, + source: sourcePath?.slice(root.length + 1).replaceAll('\\', '/'), + }))).toEqual([ + { code: 'AB4831', source: 'src/layout.tsx' }, + { code: 'AB4830', source: 'src/mcp/curator/layout.tsx' }, + { code: 'AB4832', source: 'src/mcp/ghost/layout.tsx' }, + { code: 'AB4832', source: 'src/mcp/panel/layout.tsx' }, + ]); + expect(graph.diagnostics[0]!.message).toContain('src/layout.ts'); + expect(graph.diagnostics[0]!.message).toContain('src/layout.tsx'); + expect(graph.diagnostics[1]!.message).toContain('default export is not a function component'); + expect(graph.diagnostics[1]!.message).toContain('exports route-only inputSchema, resultSchema'); + expect(graph.diagnostics[2]!.message).toContain('"ghost"'); + expect(graph.diagnostics[3]!.message).toContain('"panel"'); + expect(graph.diagnostics[3]!.message).toContain('no tool, resource, or prompt route modules'); + // Discovery keeps the modules visible beside the errors; only the duplicate is dropped. + expect(graph.layouts!.map((layout) => layout.id)).toEqual(['layout:root', 'layout:mcp:curator', 'layout:mcp:ghost', 'layout:mcp:panel']); + expect(graph.servers.map((server) => server.name)).toEqual(['curator', 'panel']); +}); + it('rejects provider key collisions and the reserved processLifetime key', async () => { const root = await createRoot(); const provider = 'export default () => undefined;\n'; 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 9343d3a04..28629c344 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -336,6 +336,67 @@ describe('renderRoute through the real renderer', () => { }); }); +describe('layout composition at the route-unit level', () => { + it('composes the root and server layouts around a manifest route without changing its projected nodes or value', async () => { + const rendered = await renderRoute('tool:harness/echo', { + context: { workspace }, + input: { message: 'wrapped' }, + }); + + // Same node kinds and value as the layout-free render: the layouts' + // container results merged into the route's valued result. + expectDocument(rendered) + .toHaveStatus('success') + .toHaveNodeKinds(['result', 'markdown', 'text']) + .toHaveValue({ message: 'wrapped', operationId: 'tool:harness/echo', workspace: '/tmp/harness-library' }); + // Root layout metadata wins conflicts; the server layout contributes its own keys. + expect(rendered.document.root.kind === 'result' ? rendered.document.root.metadata : undefined).toEqual({ + invocation: 'tool', + layout: 'harness', + route: 'tool:harness/echo', + server: 'mcp:harness', + shell: 'route-harness', + wrapped: 'tool', + }); + }); + + it('hands the server layout the route identity and merges route metadata beneath it', async () => { + const rendered = await renderRoute('tool:harness/layout-probe', { input: { label: 'unit' } }); + + expectDocument(rendered) + .toHaveNodeKinds(['result', 'text', 'text']) + .toContainText('probe: unit') + .toContainText('layout: tool layout-probe via mcp:harness') + .toHaveValue({ label: 'unit' }); + expect(rendered.result).toEqual({ label: 'unit' }); + expect(rendered.document.root.kind === 'result' ? rendered.document.root.metadata : undefined).toMatchObject({ + from: 'route', + layout: 'harness', + route: 'tool:harness/layout-probe', + shell: 'route-harness', + }); + }); + + it('applies only the root layout to a rendered CLI command', async () => { + const rendered = await renderRoute('cli:report', { input: { topic: 'layouts' } }); + + expectDocument(rendered).toHaveStatus('success').toHaveValue({ count: 2, stateMounted: true, status: 'ready', topic: 'layouts' }); + expect(rendered.document.root.kind === 'result' ? rendered.document.root.metadata : undefined).toEqual({ + invocation: 'cli', + shell: 'route-harness', + wrapped: 'cli', + }); + }); + + it('composes no layout around a module rendered directly, because layouts are a compiler convention', async () => { + const rendered = await renderRoute({ default: Echo }, { input: { message: 'direct' }, routeId: 'tool:harness/echo' }); + + expectDocument(rendered).toHaveNodeKinds(['result', 'markdown', 'text']); + expect(rendered.document.root.kind === 'result' ? rendered.document.root.metadata : undefined).toBeUndefined(); + expect(rendered.provenance.source).toBe('module'); + }); +}); + describe('route-unit render failures', () => { it('names the route and the abort when the request was already cancelled', async () => { const controller = new AbortController(); diff --git a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts index 0cd1bb063..0c0d36adb 100644 --- a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts +++ b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts @@ -88,6 +88,7 @@ export const routeHarnessContractFixtures = (): Record { 'tool:harness/context', 'tool:harness/echo', 'tool:harness/journal', + 'tool:harness/layout-probe', 'tool:harness/lifecycle', 'tool:harness/mutation-probe', 'tool:harness/publish-notice', @@ -94,6 +95,22 @@ describe('the compiled test manifest', () => { relativePath: 'src/providers/library-tooling.ts', source: resolve(fixtureRoot, 'src/providers/library-tooling.ts'), }]); + // Layouts are never routes; the manifest carries them separately, ordered by id. + expect(manifest.layouts).toEqual([ + { + id: 'layout:mcp:harness', + relativePath: 'src/mcp/harness/layout.tsx', + scope: 'server', + serverId: 'mcp:harness', + source: resolve(fixtureRoot, 'src/mcp/harness/layout.tsx'), + }, + { + id: 'layout:root', + relativePath: 'src/layout.tsx', + scope: 'root', + source: resolve(fixtureRoot, 'src/layout.tsx'), + }, + ]); expect(manifest.routes['tool:harness/echo']).toEqual({ config: { annotations: { readOnlyHint: true }, @@ -222,6 +239,7 @@ describe('the compiled test manifest', () => { projected('context', 'Returns the request identity axes observed by this route.', true), projected('echo', 'Echoes one message back with the observed workspace root.', false), projected('journal', 'Records and reads durable route-harness journal entries.', true), + projected('layout-probe', 'Renders a bare valued result so the layout chain around it is observable.', false), projected('lifecycle', 'Replays a deterministic durable lifecycle through mounted state.', true), projected('mutation-probe', 'Records how many times the mutation probe executed.', true), projected('publish-notice', 'Publishes a durable notice for a later session event.', true), @@ -338,6 +356,15 @@ describe('the generated route registry', () => { expect(routeTestSetupSource({ ...manifest, providers: undefined })).not.toContain('providerLoaders'); }); + it('registers one loader per compiled layout so renders compose the same chain the workers bake', () => { + const layoutLoaders = /layoutLoaders: \{\n(?[\s\S]*?)\n {2}\},/u.exec(source)?.groups?.body ?? ''; + + expect(layoutLoaders).toContain('"layout:root": () => import('); + expect(layoutLoaders).toContain('"layout:mcp:harness": () => import('); + expect(layoutLoaders).toContain('/src/layout.tsx")'); + expect(layoutLoaders).toContain('/src/mcp/harness/layout.tsx")'); + }); + it('carries the manifest and the registry version the helpers require', () => { expect(source).toContain(`version: ${String(AGENT_TEST_REGISTRY_VERSION)}`); expect(source).toContain('globalThis[Symbol.for("agent-bundle/test-route-registry")]'); diff --git a/packages/rsc-runtime/src/decode-document.ts b/packages/rsc-runtime/src/decode-document.ts index cbb48f68f..b84425be9 100644 --- a/packages/rsc-runtime/src/decode-document.ts +++ b/packages/rsc-runtime/src/decode-document.ts @@ -9,8 +9,9 @@ import { type AgentDocument, type AgentDocumentNode, type AgentRenderLimits, + type AgentResultNode, } from './agent-document.js'; -import type { JsonValue } from './lower-mcp.js'; +import { snapshotJsonValue, type JsonSnapshotBudget, type JsonValue } from './lower-mcp.js'; const agentElementTypes = Object.freeze([ 'agent-result', @@ -57,12 +58,157 @@ const textChild = (children: unknown, type: AgentElementType): string => { return values[0]; }; +/** A result's `value` with the depth of the result that declared it; `charged` once it is a budgeted snapshot. */ +interface DeclaredValue { + readonly charged: boolean; + readonly depth: number; + readonly value: JsonValue; +} + interface DecodeState { + bytes: number; + /** Result nodes whose `metadata` is already a budgeted snapshot (the product of a merge), never charged twice. */ + readonly chargedMetadata: WeakSet; + /** Serialized bytes of authored metadata that merging overwrote or dropped, so they never leave the finished document's byte budget. */ + discardedBytes: number; readonly limits: AgentRenderLimits; nodes: number; representedError: boolean; + /** The `value` each decoded result node declared, or adopted from a merged container child. */ + readonly resultValues: WeakMap; } +/** + * The decode pass's JSON budget, charging the same node, depth, and byte + * limits `createAgentDocument` enforces on the finished document. Merging + * removes the inner result and may overwrite its keys, so pre-merge metadata + * is charged here, at its authored depth, before anything is dropped. + */ +const decodeBudget = (state: DecodeState): JsonSnapshotBudget => ({ + addBytes(n) { + state.bytes += n; + if (state.bytes > state.limits.maxDocumentBytes) { + throw new AgentContractError( + 'document-bytes-exceeded', + `Agent Document bytes exceed ${String(state.limits.maxDocumentBytes)}`, + ); + } + }, + addNode() { + admitDocumentNode(state); + }, + checkDepth(depth) { + expectDocumentDepth(depth, state.limits); + }, +}); + +const isJsonObject = (value: JsonValue | undefined): value is Record => + value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value); + +/** + * A declared JSON prop as the plain JSON the document contract admits, + * snapshotted through the same wire boundary and budget `createAgentDocument` + * applies, at the depth it was authored. Merging spreads metadata and lifts a + * value toward the root, so each must be validated first: a `Date`, a class + * instance, an accessor, a cyclic value, or an over-limit object fails closed + * here exactly as it does on a layout-free result instead of being flattened, + * shallowed, or overwritten away. + */ +const budgetedJson = (value: JsonValue, message: string, depth: number, state: DecodeState): JsonValue => { + try { + return snapshotJsonValue(value, message, { depth, limits: decodeBudget(state) }); + } catch (error) { + if (error instanceof AgentContractError) throw error; + throw new AgentContractError('invalid-document', error instanceof Error ? error.message : message, { cause: error }); + } +}; + +const jsonMetadata = (value: JsonValue | undefined, depth: number, state: DecodeState): JsonValue | undefined => + value === undefined ? undefined : budgetedJson(value, 'Agent result metadata must be JSON-serializable', depth, state); + +/** The value a container adopts from its merged child, charged once at the depth of the result that declared it. */ +const adoptedValue = (declared: DeclaredValue, state: DecodeState): DeclaredValue => declared.charged + ? declared + : { + charged: true, + depth: declared.depth, + value: budgetedJson(declared.value, 'Agent Document value must be JSON-serializable', declared.depth, state), + }; + +/** + * Metadata of a merged container: two JSON objects merge key by key with the + * container winning conflicts, so nested layouts and the route each + * contribute their own keys; any other declared shape — including an explicit + * JSON `null` — lets the container win outright. Only a container that + * declares no metadata at all adopts the inner result's. `depth` is the + * container's; the inner result sat one level below it. An inner result that + * is itself a merged container already carries a budgeted snapshot, which is + * carried forward rather than charged again, so nested layouts pay for each + * authored object exactly once. Whatever the merge overwrites or drops is + * recorded in `discardedBytes`: the finished document is measured together + * with it, so splitting a payload between overwritten metadata and retained + * content cannot slip past `maxDocumentBytes`. + */ +const mergedMetadata = ( + container: JsonValue | undefined, + inner: AgentResultNode, + depth: number, + state: DecodeState, +): JsonValue | undefined => { + const outer = jsonMetadata(container, depth, state); + const nested = state.chargedMetadata.has(inner) ? inner.metadata : jsonMetadata(inner.metadata, depth + 1, state); + if (outer === undefined) return nested; + if (nested === undefined) return outer; + if (isJsonObject(outer) && isJsonObject(nested)) { + const merged = { ...nested, ...outer }; + state.discardedBytes += jsonBytes(nested) + jsonBytes(outer) - jsonBytes(merged); + return merged; + } + state.discardedBytes += jsonBytes(nested); + return outer; +}; + +const jsonBytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), 'utf8'); + +/** + * Decodes one `agent-result` element. A result that declares no `value` is a + * container — the shape a conventional layout renders around a route. When a + * container directly holds a result that does carry a value, the two merge: + * the inner result's value becomes the container's, its children take its + * place, and the metadata combine per {@link mergedMetadata}. Only the first + * valued child merges; a container with no valued child stays a plain + * grouping node, exactly as before. + */ +const decodeResult = ( + props: Record, + depth: number, + state: DecodeState, +): AgentResultNode => { + const decoded = Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, depth + 1, state)); + const ownValue = props.value as JsonValue | undefined; + const ownMetadata = props.metadata as JsonValue | undefined; + const mergeIndex = ownValue === undefined + ? decoded.findIndex((child) => child.kind === 'result' && state.resultValues.has(child)) + : -1; + const merged = mergeIndex === -1 ? undefined : decoded[mergeIndex] as AgentResultNode; + const children = merged === undefined + ? decoded + : [...decoded.slice(0, mergeIndex), ...merged.children, ...decoded.slice(mergeIndex + 1)]; + const metadata = merged === undefined ? ownMetadata : mergedMetadata(ownMetadata, merged, depth, state); + const node: AgentResultNode = { + children, + kind: 'result', + ...(metadata === undefined ? {} : { metadata }), + }; + if (merged === undefined) { + if (ownValue !== undefined) state.resultValues.set(node, { charged: false, depth, value: ownValue }); + return node; + } + state.chargedMetadata.add(node); + state.resultValues.set(node, adoptedValue(state.resultValues.get(merged)!, state)); + return node; +}; + const decodeNode = (node: ReactNode, depth: number, state: DecodeState): AgentDocumentNode => { expectDocumentDepth(depth, state.limits); admitDocumentNode(state); @@ -70,11 +216,7 @@ const decodeNode = (node: ReactNode, depth: number, state: DecodeState): AgentDo const { props } = element; switch (element.type) { case 'agent-result': - return { - children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, depth + 1, state)), - kind: 'result', - ...(props.metadata === undefined ? {} : { metadata: props.metadata as JsonValue }), - }; + return decodeResult(props, depth, state); case 'agent-markdown': return { kind: 'markdown', text: textChild(props.children, element.type) }; case 'agent-text': @@ -124,13 +266,31 @@ export const decodeAgentDocument = ( if (root.type !== 'agent-result') { throw new AgentContractError('invalid-document', 'Flight output must have Agent.Result as its root'); } - const state: DecodeState = { limits: resolved, nodes: 0, representedError: false }; + const state: DecodeState = { + bytes: 0, + chargedMetadata: new WeakSet(), + discardedBytes: 0, + limits: resolved, + nodes: 0, + representedError: false, + resultValues: new WeakMap(), + }; const documentRoot = decodeNode(node, 1, state); - return createAgentDocument({ + const value = state.resultValues.get(documentRoot)?.value; + const document = createAgentDocument({ root: documentRoot, status: state.representedError ? 'represented-error' : 'success', - ...(root.props.value === undefined ? {} : { value: root.props.value as JsonValue }), + ...(value === undefined ? {} : { value }), version: 1, }, resolved); + // The authored tree is what the budget bounds: metadata a merge discarded + // still counts alongside the document that replaced it. + if (state.discardedBytes > 0 && state.discardedBytes + jsonBytes(document) > resolved.maxDocumentBytes) { + throw new AgentContractError( + 'document-bytes-exceeded', + `Agent Document bytes exceed ${String(resolved.maxDocumentBytes)}`, + ); + } + return document; }; diff --git a/packages/rsc-runtime/src/elements.ts b/packages/rsc-runtime/src/elements.ts index cc6a26614..b05583b90 100644 --- a/packages/rsc-runtime/src/elements.ts +++ b/packages/rsc-runtime/src/elements.ts @@ -1,4 +1,4 @@ -import { createElement, type PropsWithChildren, type ReactElement } from 'react'; +import { createElement, type PropsWithChildren, type ReactElement, type ReactNode } from 'react'; import type { JsonValue } from './lower-mcp.js'; @@ -41,6 +41,43 @@ export interface AgentErrorProps extends AgentTextProps { readonly code: string; } +/** The route kinds a conventional layout wraps; event routes are host protocol responses and stay unwrapped. */ +export type AgentLayoutRouteKind = 'tool' | 'resource' | 'prompt' | 'cli' | 'script'; + +/** + * Stable identity of the route a layout is wrapping, baked at compile time + * from the agent-bundle route graph. `name` is the protocol-facing name: the + * MCP tool, resource, or prompt name, the space-joined CLI command path, or + * the script name. Mirrors `AgentLayoutRoute` in `agent-bundle`, whose root + * declarations stay React-free. + */ +export interface AgentLayoutRoute { + readonly id: string; + readonly kind: AgentLayoutRouteKind; + readonly name: string; + /** The owning MCP server id (`mcp:`); MCP route kinds only. */ + readonly serverId?: string; +} + +/** + * Props received by a conventional layout module's default component + * (`src/layout.{ts,tsx}` or `src/mcp//layout.{ts,tsx}` in an + * agent-bundle project). + * + * `children` is the route's rendered element. The layout renders an + * `Agent.Result` around it: a result without a `value` is a container, and + * the document decoder merges the route's own valued `Agent.Result` into it, + * so the document keeps the route's result value and protocol projection + * while the layout owns the shared shell. Layouts render inside the same + * request scope as the route, so `await agent()` exposes the invocation, + * host, session, actor, workspace, and provider axes. + */ +export interface AgentLayoutProps { + readonly children: ReactNode; + readonly route: AgentLayoutRoute; + readonly signal: AbortSignal; +} + const AgentResult = ({ children, metadata, value }: AgentResultProps): ReactElement => createElement('agent-result', { metadata, value }, children); diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index b5b2dfdea..1af0f7156 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -2,6 +2,9 @@ export { Agent, Hook, Mcp } from './elements.js'; export type { AgentErrorProps, AgentJsonProps, + AgentLayoutProps, + AgentLayoutRoute, + AgentLayoutRouteKind, AgentMediaProps, AgentProgressProps, AgentResourceProps, diff --git a/packages/rsc-runtime/tests/dispatcher.test.ts b/packages/rsc-runtime/tests/dispatcher.test.ts index 3ffaaa87e..54b6b1c86 100644 --- a/packages/rsc-runtime/tests/dispatcher.test.ts +++ b/packages/rsc-runtime/tests/dispatcher.test.ts @@ -42,6 +42,230 @@ describe('decodeAgentDocument', () => { expect(Object.isFrozen(document)).toBe(true); }); + it('merges a valued result into its valueless container so a layout shell keeps the route value', () => { + const document = decodeAgentDocument(createElement( + 'agent-result', + { metadata: { layout: 'root' } }, + createElement('agent-text', null, 'header'), + createElement( + 'agent-result', + { metadata: { layout: 'route' }, value: { ready: true } }, + createElement('agent-markdown', null, '# Ready'), + ), + createElement('agent-context', null, 'footer'), + )); + + expect(document).toEqual({ + root: { + children: [ + { kind: 'text', text: 'header' }, + { kind: 'markdown', text: '# Ready' }, + { kind: 'context', text: 'footer' }, + ], + kind: 'result', + metadata: { layout: 'root' }, + }, + status: 'success', + value: { ready: true }, + version: 1, + }); + }); + + it('merges nested containers bottom-up, combining object metadata with the outer container winning conflicts', () => { + const document = decodeAgentDocument(createElement( + 'agent-result', + { metadata: { layer: 'root', shell: 'fixture' } }, + createElement( + 'agent-result', + { metadata: { layer: 'server', server: 'harness' } }, + createElement('agent-result', { metadata: { from: 'route', layer: 'route' }, value: 1 }, createElement('agent-text', null, 'leaf')), + ), + )); + + expect(document).toEqual({ + root: { + children: [{ kind: 'text', text: 'leaf' }], + kind: 'result', + metadata: { from: 'route', layer: 'root', server: 'harness', shell: 'fixture' }, + }, + status: 'success', + value: 1, + version: 1, + }); + }); + + it('adopts inner metadata when the container declares none and lets a non-object container metadata win outright', () => { + const adopted = decodeAgentDocument(createElement( + 'agent-result', + null, + createElement('agent-result', { metadata: { from: 'route' }, value: 1 }, createElement('agent-text', null, 'leaf')), + )); + expect(adopted.root).toEqual({ children: [{ kind: 'text', text: 'leaf' }], kind: 'result', metadata: { from: 'route' } }); + + const outright = decodeAgentDocument(createElement( + 'agent-result', + { metadata: 'container' }, + createElement('agent-result', { metadata: { from: 'route' }, value: 1 }, createElement('agent-text', null, 'leaf')), + )); + expect(outright.root).toEqual({ children: [{ kind: 'text', text: 'leaf' }], kind: 'result', metadata: 'container' }); + + // An explicit JSON null is authored metadata, not absence: it wins over the + // inner object exactly like any other non-object container metadata. + const explicitNull = decodeAgentDocument(createElement( + 'agent-result', + { metadata: null }, + createElement('agent-result', { metadata: { from: 'route' }, value: 1 }, createElement('agent-text', null, 'leaf')), + )); + expect(explicitNull.root).toEqual({ children: [{ kind: 'text', text: 'leaf' }], kind: 'result', metadata: null }); + expect(explicitNull.root.metadata).toBeNull(); + }); + + it('rejects non-JSON metadata on either side of a container merge instead of flattening it', () => { + // Without a layout a Date fails the document contract; merging must not + // spread it into `{}` first and let it through. + const leaf = createElement('agent-text', null, 'leaf'); + const valued = (metadata: unknown) => createElement('agent-result', { metadata, value: 1 }, leaf); + expect(() => decodeAgentDocument(valued(new Date(0)))).toThrow(AgentContractError); + + expect(() => decodeAgentDocument(createElement('agent-result', { metadata: new Date(0) }, valued({ from: 'route' })))) + .toThrow(AgentContractError); + expect(() => decodeAgentDocument(createElement('agent-result', { metadata: { shell: 'layout' } }, valued(new Date(0))))) + .toThrow(AgentContractError); + class Tagged { readonly tag = 'instance'; } + expect(() => decodeAgentDocument(createElement('agent-result', { metadata: new Tagged() }, valued({ from: 'route' })))) + .toThrow(AgentContractError); + let getterReads = 0; + const accessor = Object.defineProperty({}, 'lazy', { enumerable: true, get: () => { getterReads += 1; return 'read'; } }); + expect(() => decodeAgentDocument(createElement('agent-result', { metadata: accessor }, valued({ from: 'route' })))) + .toThrow(AgentContractError); + expect(getterReads).toBe(0); + + // Plain JSON objects still merge, and the merged result is a fresh snapshot. + const routeMetadata = { from: 'route' }; + const merged = decodeAgentDocument(createElement('agent-result', { metadata: { shell: 'layout' } }, valued(routeMetadata))); + expect(merged.root.metadata).toEqual({ from: 'route', shell: 'layout' }); + expect(merged.root.metadata).not.toBe(routeMetadata); + }); + + it('charges pre-merge metadata against the document budget at its authored depth', () => { + const leaf = createElement('agent-text', null, 'leaf'); + const valued = (metadata: unknown) => createElement('agent-result', { metadata, value: 1 }, leaf); + + // The container overwrites the oversized key, so the finished document is + // tiny — the authored inner metadata must still be charged. + const oversized = { note: 'x'.repeat(2_000) }; + const overwritten = createElement('agent-result', { metadata: { note: 'short' } }, valued(oversized)); + expect(() => decodeAgentDocument(overwritten, { maxDocumentBytes: 1_024 })).toThrow( + expect.objectContaining({ code: 'document-bytes-exceeded' }), + ); + expect(() => decodeAgentDocument(valued({ note: 'short' }), { maxDocumentBytes: 1_024 })).not.toThrow(); + + // Discarded bytes are measured together with the finished document, so a + // payload split between overwritten inner metadata (~660 bytes, under the + // cap alone) and retained content (~620 bytes, under the cap alone) still + // exceeds a 1,024-byte budget as the authored tree did. + const split = (containerMetadata: unknown) => createElement( + 'agent-result', + { metadata: containerMetadata }, + createElement('agent-result', { metadata: { note: 'i'.repeat(650) }, value: 1 }, createElement('agent-text', null, 't'.repeat(600))), + ); + expect(() => decodeAgentDocument(split({ note: 'o' }), { maxDocumentBytes: 1_024 })).toThrow( + expect.objectContaining({ code: 'document-bytes-exceeded' }), + ); + // Container metadata that wins outright drops the whole inner object; it counts too. + expect(() => decodeAgentDocument(split(null), { maxDocumentBytes: 1_024 })).toThrow( + expect.objectContaining({ code: 'document-bytes-exceeded' }), + ); + expect(() => decodeAgentDocument(split({ note: 'o' }), { maxDocumentBytes: 2_048 })).not.toThrow(); + // Nothing is discarded when the keys are disjoint: the finished document alone decides. + expect(() => decodeAgentDocument(split({ other: 'o' }), { maxDocumentBytes: 1_400 })).not.toThrow(); + + // Inner metadata is measured one level below the container, exactly where + // it was authored, so lifting it into the container cannot buy a level. + const deep = { a: { b: { c: 'leaf' } } }; + // Container at depth 1, inner result at depth 2, metadata object/keys from + // depth 3: the innermost leaf sits at depth 5 while the merged copy would + // sit at depth 4. + const lifted = createElement('agent-result', null, valued(deep)); + expect(() => decodeAgentDocument(lifted, { maxDocumentDepth: 4 })).toThrow( + expect.objectContaining({ code: 'document-depth-exceeded' }), + ); + expect(() => decodeAgentDocument(lifted, { maxDocumentDepth: 5 })).not.toThrow(); + + // JSON nodes of both operands count toward the node budget alongside the + // document nodes, as they do for the finished document. + const wide = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`k${String(index)}`, index])); + expect(() => decodeAgentDocument(createElement('agent-result', { metadata: wide }, valued(wide)), { maxDocumentNodes: 30 })).toThrow( + expect.objectContaining({ code: 'document-node-count-exceeded' }), + ); + + // Nested layouts charge each authored object once: the outer merge carries + // the inner merge's snapshot forward instead of counting it again. Two + // disjoint 2,000-key objects (4,002 JSON nodes plus a handful of document + // nodes) fit a 5,000-node cap; recharging the 4,001-node merged object + // would not. + const fields = (prefix: string) => Object.fromEntries(Array.from({ length: 2_000 }, (_, index) => [`${prefix}${String(index)}`, index])); + const nested = createElement( + 'agent-result', + { metadata: { shell: 'root' } }, + createElement('agent-result', { metadata: fields('server') }, valued(fields('route'))), + ); + const document = decodeAgentDocument(nested, { maxDocumentNodes: 5_000 }); + expect(Object.keys(document.root.metadata as Record)).toHaveLength(4_001); + expect(document.value).toBe(1); + + // An adopted value is charged where its result declared it (depth 2 under + // one container: object 2, `a` 3, `b` 4), not at the document root where + // the finished document validates it (1, 2, 3). + const deepValue = { a: { b: 1 } }; + const liftedValue = createElement( + 'agent-result', + null, + createElement('agent-result', { value: deepValue }, leaf), + ); + expect(() => decodeAgentDocument(createElement('agent-result', { value: deepValue }, leaf), { maxDocumentDepth: 3 })).not.toThrow(); + expect(() => decodeAgentDocument(liftedValue, { maxDocumentDepth: 3 })).toThrow( + expect.objectContaining({ code: 'document-depth-exceeded' }), + ); + expect(decodeAgentDocument(liftedValue, { maxDocumentDepth: 4 }).value).toEqual(deepValue); + + // Two containers charge the adopted value once, at the declaring depth. + const twice = createElement('agent-result', null, createElement('agent-result', null, createElement('agent-result', { value: fields('v') }, leaf))); + expect(decodeAgentDocument(twice, { maxDocumentNodes: 2_100 }).value).toEqual(fields('v')); + }); + + it('keeps a valued root and its nested results exactly as authored', () => { + const document = decodeAgentDocument(createElement( + 'agent-result', + { value: 'outer' }, + createElement('agent-result', { value: 'inner' }, createElement('agent-text', null, 'leaf')), + )); + + expect(document).toEqual({ + root: { + children: [{ children: [{ kind: 'text', text: 'leaf' }], kind: 'result' }], + kind: 'result', + }, + status: 'success', + value: 'outer', + version: 1, + }); + }); + + it('leaves a container with no valued child as a plain grouping node without a document value', () => { + const document = decodeAgentDocument(createElement( + 'agent-result', + null, + createElement('agent-result', null, createElement('agent-text', null, 'leaf')), + )); + + expect(document.value).toBeUndefined(); + expect(document.root).toEqual({ + children: [{ children: [{ kind: 'text', text: 'leaf' }], kind: 'result' }], + kind: 'result', + }); + }); + it('never invokes function components while decoding Flight output', () => { let invoked = false; const Component = () => { diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 8f3ddb77e..9ac020e65 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -45,6 +45,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-install-session.test.ts', 'packages/agent-bundle/tests/installer-entry.test.ts', 'packages/agent-bundle/tests/integration-matrix.test.ts', + 'packages/agent-bundle/tests/layout-build.test.ts', 'packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts', 'packages/agent-bundle/tests/mcp-probe-dev-server.test.ts', 'packages/agent-bundle/tests/mcp-session-service.test.ts', From daffae14310d3368eb33ea3c3a7bb99b7d9e4330 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:37:09 +0000 Subject: [PATCH 2/4] fix(runtime): charge every JSON payload at its authored depth before splicing; README states the CLI artifact default --- packages/agent-bundle/README.md | 2 +- packages/rsc-runtime/src/decode-document.ts | 41 +++++++++---------- packages/rsc-runtime/tests/dispatcher.test.ts | 19 +++++++++ 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index b3a5a2aa8..0d46d294e 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -16,7 +16,7 @@ agent-bundle dev --root . `inspect` reads source configuration; use `validate --artifact artifact` for source-free artifact validation. `dev.runtime.provider` is an advanced optional extension: a normal project starts the dev server and Workbench without loading an RSC provider. -The artifact root defaults to `dist`; set `output: { distPath: 'artifact' }` in `agent-bundle.config.ts` to relocate it (Rsbuild/Rslib naming, string shorthand only) — `--output` still wins per invocation. See [Framework mode](../../docs/framework-mode.md#output). +The artifact root defaults to `artifact` for the `agent-bundle build` and `agent-bundle prepack` commands (they also emit the npm package build into `dist/`) and to `dist` for the programmatic `build()` API; set `output: { distPath: '' }` in `agent-bundle.config.ts` to relocate it (Rsbuild/Rslib naming, string shorthand only) — `--output` still wins per invocation. See [Framework mode](../../docs/framework-mode.md#output). Generated executables target Node.js 22.12 or newer by default. `runtime: { node: '24.0' }` raises that floor (it can never be lowered), and the selected floor is recorded as `runtime.node` in the diff --git a/packages/rsc-runtime/src/decode-document.ts b/packages/rsc-runtime/src/decode-document.ts index b84425be9..2ee1e5fa3 100644 --- a/packages/rsc-runtime/src/decode-document.ts +++ b/packages/rsc-runtime/src/decode-document.ts @@ -67,8 +67,6 @@ interface DeclaredValue { interface DecodeState { bytes: number; - /** Result nodes whose `metadata` is already a budgeted snapshot (the product of a merge), never charged twice. */ - readonly chargedMetadata: WeakSet; /** Serialized bytes of authored metadata that merging overwrote or dropped, so they never leave the finished document's byte budget. */ discardedBytes: number; readonly limits: AgentRenderLimits; @@ -81,8 +79,10 @@ interface DecodeState { /** * The decode pass's JSON budget, charging the same node, depth, and byte * limits `createAgentDocument` enforces on the finished document. Merging - * removes the inner result and may overwrite its keys, so pre-merge metadata - * is charged here, at its authored depth, before anything is dropped. + * removes the inner result, splices its children one level toward the root, + * and may overwrite its keys, so every JSON payload — result metadata, JSON + * node values, and the adopted result value — is charged here at the depth it + * was authored, before anything moves or is dropped. */ const decodeBudget = (state: DecodeState): JsonSnapshotBudget => ({ addBytes(n) { @@ -140,23 +140,19 @@ const adoptedValue = (declared: DeclaredValue, state: DecodeState): DeclaredValu * container winning conflicts, so nested layouts and the route each * contribute their own keys; any other declared shape — including an explicit * JSON `null` — lets the container win outright. Only a container that - * declares no metadata at all adopts the inner result's. `depth` is the - * container's; the inner result sat one level below it. An inner result that - * is itself a merged container already carries a budgeted snapshot, which is - * carried forward rather than charged again, so nested layouts pay for each - * authored object exactly once. Whatever the merge overwrites or drops is - * recorded in `discardedBytes`: the finished document is measured together - * with it, so splitting a payload between overwritten metadata and retained - * content cannot slip past `maxDocumentBytes`. + * declares no metadata at all adopts the inner result's. Both operands are + * already budgeted snapshots (every result charges its metadata where it was + * authored), so nested layouts pay for each authored object exactly once. + * Whatever the merge overwrites or drops is recorded in `discardedBytes`: the + * finished document is measured together with it, so splitting a payload + * between overwritten metadata and retained content cannot slip past + * `maxDocumentBytes`. */ const mergedMetadata = ( - container: JsonValue | undefined, - inner: AgentResultNode, - depth: number, + outer: JsonValue | undefined, + nested: JsonValue | undefined, state: DecodeState, ): JsonValue | undefined => { - const outer = jsonMetadata(container, depth, state); - const nested = state.chargedMetadata.has(inner) ? inner.metadata : jsonMetadata(inner.metadata, depth + 1, state); if (outer === undefined) return nested; if (nested === undefined) return outer; if (isJsonObject(outer) && isJsonObject(nested)) { @@ -186,7 +182,7 @@ const decodeResult = ( ): AgentResultNode => { const decoded = Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, depth + 1, state)); const ownValue = props.value as JsonValue | undefined; - const ownMetadata = props.metadata as JsonValue | undefined; + const ownMetadata = jsonMetadata(props.metadata as JsonValue | undefined, depth, state); const mergeIndex = ownValue === undefined ? decoded.findIndex((child) => child.kind === 'result' && state.resultValues.has(child)) : -1; @@ -194,7 +190,7 @@ const decodeResult = ( const children = merged === undefined ? decoded : [...decoded.slice(0, mergeIndex), ...merged.children, ...decoded.slice(mergeIndex + 1)]; - const metadata = merged === undefined ? ownMetadata : mergedMetadata(ownMetadata, merged, depth, state); + const metadata = merged === undefined ? ownMetadata : mergedMetadata(ownMetadata, merged.metadata, state); const node: AgentResultNode = { children, kind: 'result', @@ -204,7 +200,6 @@ const decodeResult = ( if (ownValue !== undefined) state.resultValues.set(node, { charged: false, depth, value: ownValue }); return node; } - state.chargedMetadata.add(node); state.resultValues.set(node, adoptedValue(state.resultValues.get(merged)!, state)); return node; }; @@ -224,7 +219,10 @@ const decodeNode = (node: ReactNode, depth: number, state: DecodeState): AgentDo case 'agent-context': return { kind: 'context', text: textChild(props.children, element.type) }; case 'agent-json': - return { kind: 'json', value: props.value as JsonValue }; + return { + kind: 'json', + value: budgetedJson(props.value as JsonValue, 'Agent JSON node value must be JSON-serializable', depth, state), + }; case 'agent-progress': return { completed: props.completed as number, @@ -268,7 +266,6 @@ export const decodeAgentDocument = ( } const state: DecodeState = { bytes: 0, - chargedMetadata: new WeakSet(), discardedBytes: 0, limits: resolved, nodes: 0, diff --git a/packages/rsc-runtime/tests/dispatcher.test.ts b/packages/rsc-runtime/tests/dispatcher.test.ts index 54b6b1c86..3fda3a415 100644 --- a/packages/rsc-runtime/tests/dispatcher.test.ts +++ b/packages/rsc-runtime/tests/dispatcher.test.ts @@ -229,6 +229,25 @@ describe('decodeAgentDocument', () => { ); expect(decodeAgentDocument(liftedValue, { maxDocumentDepth: 4 }).value).toEqual(deepValue); + // Splicing moves the merged result's children one level toward the root; + // their JSON payloads — `Agent.Json` values and nested result metadata — + // are still measured where they were authored (node at depth 3: object 3, + // nested object 4, leaf 5), not where the finished tree places them (2–4). + const jsonLeaf = createElement('agent-json', { value: { a: { b: 1 } } }); + expect(() => decodeAgentDocument(createElement('agent-result', { value: 1 }, jsonLeaf), { maxDocumentDepth: 4 })).not.toThrow(); + const splicedJson = createElement('agent-result', null, createElement('agent-result', { value: 1 }, jsonLeaf)); + expect(() => decodeAgentDocument(splicedJson, { maxDocumentDepth: 4 })).toThrow( + expect.objectContaining({ code: 'document-depth-exceeded' }), + ); + expect(() => decodeAgentDocument(splicedJson, { maxDocumentDepth: 5 })).not.toThrow(); + const nestedMetadata = createElement('agent-result', { metadata: { a: { b: 1 } } }, leaf); + expect(() => decodeAgentDocument(createElement('agent-result', { value: 1 }, nestedMetadata), { maxDocumentDepth: 4 })).not.toThrow(); + const splicedMetadata = createElement('agent-result', null, createElement('agent-result', { value: 1 }, nestedMetadata)); + expect(() => decodeAgentDocument(splicedMetadata, { maxDocumentDepth: 4 })).toThrow( + expect.objectContaining({ code: 'document-depth-exceeded' }), + ); + expect(() => decodeAgentDocument(splicedMetadata, { maxDocumentDepth: 5 })).not.toThrow(); + // Two containers charge the adopted value once, at the declaring depth. const twice = createElement('agent-result', null, createElement('agent-result', null, createElement('agent-result', { value: fields('v') }, leaf))); expect(decodeAgentDocument(twice, { maxDocumentNodes: 2_100 }).value).toEqual(fields('v')); From 3bc0e094422d03be447d77b45b5ba69d3aff98ad Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:47:01 +0000 Subject: [PATCH 3/4] fix(build): compose layouts in artifact-hosted routed CLI workers and list them as source inputs --- packages/agent-bundle/src/build/cli-bins.ts | 2 ++ packages/agent-bundle/tests/layout-build.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 3b6f01c99..af32fe626 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -77,6 +77,7 @@ export const planCompiledCliBins = ( const sourceInputs = Object.freeze([...new Set([ bin.provenance.sourcePath, ...cli.routes.map((route) => route.source), + ...(model.layouts ?? []).map((layout) => layout.source), ...(model.providers ?? []).map((provider) => provider.source), ...(model.state === undefined ? [] : [model.state.source]), ])]); @@ -145,6 +146,7 @@ export const cliBinRslibEntries = ( source: entry.source, sourceInputs: entry.sourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ + layouts: model.layouts ?? [], providers: model.providers ?? [], routes: renderedRoutes, ...(model.state === undefined ? {} : { state: model.state }), diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts index 4419e78ed..2b9d376a9 100644 --- a/packages/agent-bundle/tests/layout-build.test.ts +++ b/packages/agent-bundle/tests/layout-build.test.ts @@ -224,6 +224,20 @@ it('composes the root and server layouts around every rendered surface of one bu stdout: '', }); + // The artifact-hosted executable (`/bin/.mjs`) composes the + // same chains as the package-built one, and its worker lists the layouts + // among its source inputs. + const hostedBin = result.build.compiledCliBins.find((bin) => bin.target === 'portable'); + expect(hostedBin?.workerSourceInputs).toEqual(expect.arrayContaining([ + join(root, 'src/layout.tsx'), + join(root, 'src/mcp/harness/layout.tsx'), + ])); + const hostedBinPath = join(output, 'portable', 'bin', 'layout-fixture.mjs'); + const hostedReport = await execFile(process.execPath, [hostedBinPath, 'report', '/library']); + expect(hostedReport.stdout).toBe(piped.stdout); + const hostedProjected = await execFile(process.execPath, [hostedBinPath, 'harness', 'lookup', '--input', '{"message":"projected"}']); + expect(hostedProjected.stdout).toBe(projected.stdout); + // A rendered script takes the root layout. const scriptPath = join(output, 'portable', 'scripts', 'summarize.mjs'); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); From 0087552a9871e6d2541c61f3a746c307449b3eeb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:57:19 +0000 Subject: [PATCH 4/4] fix(runtime): measure everything a layout merge removes, wrappers included, with the finished document bytes --- packages/rsc-runtime/src/decode-document.ts | 38 +++++++++---------- packages/rsc-runtime/tests/dispatcher.test.ts | 17 ++++++++- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/packages/rsc-runtime/src/decode-document.ts b/packages/rsc-runtime/src/decode-document.ts index 2ee1e5fa3..1da59358c 100644 --- a/packages/rsc-runtime/src/decode-document.ts +++ b/packages/rsc-runtime/src/decode-document.ts @@ -67,7 +67,7 @@ interface DeclaredValue { interface DecodeState { bytes: number; - /** Serialized bytes of authored metadata that merging overwrote or dropped, so they never leave the finished document's byte budget. */ + /** Serialized bytes merging removed from the authored tree (inner result wrappers, overwritten or dropped metadata), so they never leave the finished document's byte budget. */ discardedBytes: number; readonly limits: AgentRenderLimits; nodes: number; @@ -143,25 +143,11 @@ const adoptedValue = (declared: DeclaredValue, state: DecodeState): DeclaredValu * declares no metadata at all adopts the inner result's. Both operands are * already budgeted snapshots (every result charges its metadata where it was * authored), so nested layouts pay for each authored object exactly once. - * Whatever the merge overwrites or drops is recorded in `discardedBytes`: the - * finished document is measured together with it, so splitting a payload - * between overwritten metadata and retained content cannot slip past - * `maxDocumentBytes`. */ -const mergedMetadata = ( - outer: JsonValue | undefined, - nested: JsonValue | undefined, - state: DecodeState, -): JsonValue | undefined => { +const mergedMetadata = (outer: JsonValue | undefined, nested: JsonValue | undefined): JsonValue | undefined => { if (outer === undefined) return nested; if (nested === undefined) return outer; - if (isJsonObject(outer) && isJsonObject(nested)) { - const merged = { ...nested, ...outer }; - state.discardedBytes += jsonBytes(nested) + jsonBytes(outer) - jsonBytes(merged); - return merged; - } - state.discardedBytes += jsonBytes(nested); - return outer; + return isJsonObject(outer) && isJsonObject(nested) ? { ...nested, ...outer } : outer; }; const jsonBytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), 'utf8'); @@ -173,7 +159,11 @@ const jsonBytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(v * the inner result's value becomes the container's, its children take its * place, and the metadata combine per {@link mergedMetadata}. Only the first * valued child merges; a container with no valued child stays a plain - * grouping node, exactly as before. + * grouping node, exactly as before. Everything a merge removes from the + * serialized tree — the inner result's wrapper, its metadata label, + * overwritten or dropped metadata — is recorded in `discardedBytes`: the + * finished document is measured together with it, so flattening cannot buy + * room under `maxDocumentBytes` that the authored tree did not have. */ const decodeResult = ( props: Record, @@ -190,7 +180,7 @@ const decodeResult = ( const children = merged === undefined ? decoded : [...decoded.slice(0, mergeIndex), ...merged.children, ...decoded.slice(mergeIndex + 1)]; - const metadata = merged === undefined ? ownMetadata : mergedMetadata(ownMetadata, merged.metadata, state); + const metadata = merged === undefined ? ownMetadata : mergedMetadata(ownMetadata, merged.metadata); const node: AgentResultNode = { children, kind: 'result', @@ -200,6 +190,14 @@ const decodeResult = ( if (ownValue !== undefined) state.resultValues.set(node, { charged: false, depth, value: ownValue }); return node; } + // The node as authored, with the inner result still in place, against the + // node as merged: the difference is exactly what this merge removed. + const authored: AgentResultNode = { + children: decoded, + kind: 'result', + ...(ownMetadata === undefined ? {} : { metadata: ownMetadata }), + }; + state.discardedBytes += jsonBytes(authored) - jsonBytes(node); state.resultValues.set(node, adoptedValue(state.resultValues.get(merged)!, state)); return node; }; @@ -280,7 +278,7 @@ export const decodeAgentDocument = ( ...(value === undefined ? {} : { value }), version: 1, }, resolved); - // The authored tree is what the budget bounds: metadata a merge discarded + // The authored tree is what the budget bounds: whatever merging removed // still counts alongside the document that replaced it. if (state.discardedBytes > 0 && state.discardedBytes + jsonBytes(document) > resolved.maxDocumentBytes) { throw new AgentContractError( diff --git a/packages/rsc-runtime/tests/dispatcher.test.ts b/packages/rsc-runtime/tests/dispatcher.test.ts index 3fda3a415..39eb9658b 100644 --- a/packages/rsc-runtime/tests/dispatcher.test.ts +++ b/packages/rsc-runtime/tests/dispatcher.test.ts @@ -177,8 +177,21 @@ describe('decodeAgentDocument', () => { expect.objectContaining({ code: 'document-bytes-exceeded' }), ); expect(() => decodeAgentDocument(split({ note: 'o' }), { maxDocumentBytes: 2_048 })).not.toThrow(); - // Nothing is discarded when the keys are disjoint: the finished document alone decides. - expect(() => decodeAgentDocument(split({ other: 'o' }), { maxDocumentBytes: 1_400 })).not.toThrow(); + // Disjoint keys discard no payload, only the inner wrapper's few structural bytes. + expect(() => decodeAgentDocument(split({ other: 'o' }), { maxDocumentBytes: 1_500 })).not.toThrow(); + + // Removed result wrappers count too: three metadata-free containers around + // one valued route flatten to the layout-free document, yet the authored + // tree carried three `{"children":[…],"kind":"result"}` shells. + const flat = createElement('agent-result', { value: 1 }, createElement('agent-text', null, 't'.repeat(200))); + const flatBytes = Buffer.byteLength(JSON.stringify(decodeAgentDocument(flat)), 'utf8'); + const shelled = createElement('agent-result', null, createElement('agent-result', null, createElement('agent-result', null, flat))); + expect(decodeAgentDocument(shelled)).toEqual(decodeAgentDocument(flat)); + expect(() => decodeAgentDocument(flat, { maxDocumentBytes: flatBytes + 20 })).not.toThrow(); + expect(() => decodeAgentDocument(shelled, { maxDocumentBytes: flatBytes + 20 })).toThrow( + expect.objectContaining({ code: 'document-bytes-exceeded' }), + ); + expect(() => decodeAgentDocument(shelled, { maxDocumentBytes: flatBytes + 120 })).not.toThrow(); // Inner metadata is measured one level below the container, exactly where // it was authored, so lifting it into the container cannot buy a level.