Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/386-meta-rstest-alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Alias `agent-bundle/meta` automatically in `agentBundleRstest()` and `agentBundleBrowserRstest()` (`agent-bundle/rstest`), so a source module that imports `{ name, version, packageName, packageVersion }` loads under unit, route-unit, `renderRoute`, and `invokeCli` tests with the identity `package.json` and `agent-bundle.config.ts` declare — the same values a build stamps — instead of failing at import. Throw the new `AB4760` diagnostic from the published `agent-bundle/meta` module when it is reached outside every compiled surface and outside those presets; its `code` and `recovery` name the fix (run the pool through the preset, or alias the specifier in a custom runner). (#416)
33 changes: 33 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ gate a build, a validation, or a dev rebuild.
| `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. |
| `AB473x` | Migration nudges (informational; see below). |
| `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). |
| `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (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. |
| `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). |
Expand Down Expand Up @@ -289,6 +290,38 @@ simply not been built yet is a validation **warning** that only
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |

## Build-time identity outside the compiler (`AB4760`)

`agent-bundle/meta` (see `docs/entry-conventions.md`) is a reserved specifier
the compiler replaces in every compiled surface with the project's exact
`{ name, packageName, packageVersion, version }`. The published
`dist/meta.js` module behind that specifier therefore never carries an
identity of its own: every binding — `name`, `version`, `packageName`,
`packageVersion`, `meta`, and the default export — throws this diagnostic at
module evaluation, so a module that reaches it fails on import rather than
observing a fabricated identity. The thrown value is an `Error` named
`AgentBundleMetaUnavailableError` whose `code`, `recovery`, and structured
`diagnostic` fields carry the same data the message prints, so a bare `node`
process and a test runner both show the fix. The importing module is not
observable from a module evaluated through ESM linking, so the message names
the situation, not a file; the runner's own "failed to load" line names the
file.

Unit tests are the common way to reach it (issue #386): a plain Rstest pool
imports a source module that imports `agent-bundle/meta`, no compiled surface
replaced the specifier, and every test that touches that module fails at
import. `agentBundleRstest()` and `agentBundleBrowserRstest()` prevent this by
aliasing the specifier to `.agent-bundle/test/meta.mjs`, generated from the
same compiler pass. When that pass produced no plugin model (the configuration
could not be loaded or normalized) there is no identity to stamp, so the
aliased module throws the same `AB4760` naming the compiler diagnostics and
the recovery "fix them, then rerun Rstest" — the manifest's placeholder
identity is never served as a real one.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
| `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module with the named exports `{ name, packageName, packageVersion, version, meta }` — `meta` the frozen object of the other four, exported as both the named binding and the default export — computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version; the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. |

## Config beside a route-generated MCP server (`AB4340`)

A `mcp.servers.<id>` block for a server the route graph compiles in
Expand Down
22 changes: 17 additions & 5 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,11 +464,23 @@ module is a reserved specifier, so the `tools` hatch cannot externalize it,
and no emitted bundle can still carry an unresolved import of it.

Types ship with the package export, so no generated declaration file is
involved. Outside Agent Bundle compilation the published module throws rather
than reporting a fabricated identity — a plugin slug exists only in the
config, and a runtime guess at it would silently disagree with the artifact.
A release build refuses a project with no release version at all (`AB4013`),
so a compiled artifact never carries the development fallback.
involved. Outside Agent Bundle compilation the published module throws the
`AB4760` diagnostic rather than reporting a fabricated identity — a plugin
slug exists only in the config, and a runtime guess at it would silently
disagree with the artifact. A release build refuses a project with no release
version at all (`AB4013`), so a compiled artifact never carries the
development fallback.

Tests are not outside the compiler: `agentBundleRstest()` and
`agentBundleBrowserRstest()` (`agent-bundle/rstest`) alias the specifier to
`.agent-bundle/test/meta.mjs`, a module generated by the same
`generatedMetaModuleSource` the build injects, fed from the same compiler
pass's plugin identity. A source module importing `agent-bundle/meta` therefore
loads under any Rstest pool built from the preset — plain unit tests, the
route-unit level, `renderRoute`, and `invokeCli` alike — with the identity
`package.json` and `agent-bundle.config.ts` declare. A custom runner that
does not use the preset must add the same alias; the `AB4760` recovery text
spells it out (see [Diagnostics](diagnostics.md#build-time-identity-outside-the-compiler-ab4760)).

## Prebuilt payloads — package what you compiled yourself

Expand Down
24 changes: 24 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,30 @@ results and never renders JSX. Routed `src/cli/**` commands and
Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts`
is plain.

## Release identity in source: `agent-bundle/meta`

Plugin source reads its own identity from the framework instead of
maintaining a hand-written `src/lib/version.ts`:

```ts
import { name, version } from 'agent-bundle/meta';
```

The compiler replaces the specifier in every compiled surface with the exact
`{ name, packageName, packageVersion, version }` the artifact manifests,
`inspect`, and dev status report (see
[Entry conventions](entry-conventions.md#agent-bundlemeta--build-time-release-identity)).

Unit tests need no build to load such a module: `agentBundleRstest()` and
`agentBundleBrowserRstest()` (`agent-bundle/rstest`) alias `agent-bundle/meta`
to a generated module carrying the same identity, written from the same
compiler pass to `.agent-bundle/test/meta.mjs`. Run every pool that reaches
that source — plain unit tests included — through the preset (pass `include`
to point it at the pool's files), and `renderRoute`, `invokeCli`, and direct
imports all observe the package identity. Outside the compiler and outside
those presets the published module raises `AB4760`, whose recovery names the
alias a custom runner must add; it never reports a fabricated identity.

## Skills: convention, override, and rendered documents

`src/skills/<name>/SKILL.md` ships with no declaration. Config wins,
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@ their own Rstest run, because rendering a route requires Node's `react-server`
condition for the whole worker process. Keep them out of the project's ordinary
`rstest` run.

The same configuration aliases `agent-bundle/meta` to a generated module
carrying the project identity the compiler pass reported (`name`,
`packageName`, `packageVersion`, `version`, exactly what a build stamps), so
source that imports it loads under the pool without a build. A pool that is
not built from `agentBundleRstest()` (or `agentBundleBrowserRstest()`) has no
such alias, and importing that source raises `AB4760`; build the pool that
reaches it from the preset too — `agentBundleRstest({ include: ['tests/unit/**/*.test.ts'] })`
— or add the alias the diagnostic names.

`agent-bundle/test` holds the helpers. `renderRoute` executes a route — by
compiled route id, or by importing the module directly — through the real
renderer and the real request store, and resolves to the final Agent Document:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// A project whose source imports `agent-bundle/meta` (issue #386). Plain
// object export, like every other repository fixture. `plugin.version` is
// omitted on purpose: the resolved version, `packageName`, and
// `packageVersion` all derive from the sibling package.json, which is the
// identity the Rstest presets must hand to source under test.
export default {
plugin: {
description: 'Source that reads its own release identity from agent-bundle/meta.',
name: 'meta-consumer',
},
targets: ['claude'],
};
7 changes: 7 additions & 0 deletions packages/agent-bundle/fixtures/meta-consumer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "meta-consumer-fixture",
"version": "3.4.5",
"private": true,
"type": "module",
"description": "Fixture project whose source imports agent-bundle/meta; its release identity comes from this file (issue #386)."
}
14 changes: 14 additions & 0 deletions packages/agent-bundle/fixtures/meta-consumer/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from '@rstest/core';

import { agentBundleRstest } from '../../src/rstest/index.ts';

/**
* The consumer's ordinary unit pool, built from the shipped preset so a plain
* test that imports a source module reading `agent-bundle/meta` loads it with
* the project identity (issue #386). A repository fixture reaches the preset
* through source; a consumer imports it from `agent-bundle/rstest`.
*/
export default defineConfig(await agentBundleRstest({
include: ['tests/unit/**/*.test.ts'],
root: import.meta.dirname,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from '@rstest/core';

/**
* The pool issue #386 reported: the same unit tests without the preset, so
* `agent-bundle/meta` resolves to the published throwing module. The
* repository test drives this configuration only to prove that failure is
* the `AB4760` diagnostic — it is not a configuration a consumer should copy.
*/
export default defineConfig({
include: ['tests/unit/**/*.test.ts'],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from '@rstest/core';

import { agentBundleRstest } from '../../src/rstest/index.ts';

/** The consumer's route-unit pool: the preset's defaults over this fixture root. */
export default defineConfig(await agentBundleRstest({ root: import.meta.dirname }));
21 changes: 21 additions & 0 deletions packages/agent-bundle/fixtures/meta-consumer/src/cli/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { CliRouteConfig } from 'agent-bundle';
import { z } from 'zod';

import { identity } from '../lib/identity.ts';

export const config = {
description: 'Prints the identity agent-bundle/meta resolved to.',
} satisfies CliRouteConfig;

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

export const resultSchema = z.object({
name: z.string(),
packageName: z.string().optional(),
packageVersion: z.string().optional(),
version: z.string(),
}).strict();

export default async function version() {
return { ...identity };
}
14 changes: 14 additions & 0 deletions packages/agent-bundle/fixtures/meta-consumer/src/lib/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import meta, { name, packageName, packageVersion, version } from 'agent-bundle/meta';

/**
* The pattern the framework-mode guidance recommends: no hand-written
* `src/lib/version.ts`, the module reads identity from the framework. Every
* binding is read at module evaluation, so importing this module outside a
* compiled surface or an aliasing test pool is exactly the failure #386
* reports.
*/
export const identity = Object.freeze({ name, packageName, packageVersion, version });

export const frozenMeta = meta;

export const banner = `${name} ${version}`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Agent } from '@agent-bundle/runtime';
import { createElement } from 'react';
import { z } from 'zod';

import { banner, identity } from '../../../lib/identity.ts';

export const config = {
annotations: { readOnlyHint: true },
description: 'Reports the identity agent-bundle/meta resolved to.',
title: 'Identity',
};

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

export const resultSchema = z.object({
banner: z.string(),
name: z.string(),
packageName: z.string().optional(),
packageVersion: z.string().optional(),
version: z.string(),
});

export default async function Identity() {
// The document value is JSON; the optional npm axes are omitted when absent
// rather than carried as `undefined`.
const value = {
banner,
name: identity.name,
...(identity.packageName === undefined ? {} : { packageName: identity.packageName }),
...(identity.packageVersion === undefined ? {} : { packageVersion: identity.packageVersion }),
version: identity.version,
};
return createElement(Agent.Result, { value }, createElement(Agent.Text, null, banner));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, it } from '@rstest/core';

import { cliJson, invokeCli } from '../../../../src/test/cli.ts';
import { expectDocument } from '../../../../src/test/matchers.ts';
import { renderRoute } from '../../../../src/test/render.ts';
import { testManifest } from '../../../../src/test/registry.ts';

const expected = {
name: 'meta-consumer',
packageName: 'meta-consumer-fixture',
packageVersion: '3.4.5',
version: '3.4.5',
};

it('renderRoute reaches a route whose module imports agent-bundle/meta', async () => {
const manifest = testManifest();
const rendered = await renderRoute('tool:consumer/identity');

expectDocument(rendered).toHaveStatus('success').toContainText('meta-consumer 3.4.5');
expect(rendered.result).toEqual({ banner: 'meta-consumer 3.4.5', ...expected });
// The identity the route observed is the one the compiler pass reported.
expect(manifest.plugin).toEqual(expected);
});

it('invokeCli reaches a command whose module imports agent-bundle/meta', async () => {
const run = await invokeCli(['version']);

expect(run.exitCode).toBe(0);
expect(run.stderr).toBe('');
expect(cliJson(run)).toEqual(expected);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect, it } from '@rstest/core';

import { banner, frozenMeta, identity } from '../../src/lib/identity.ts';

// The values the sibling package.json and agent-bundle.config.ts declare; the
// spawning repository test cross-checks them against the files themselves.
const expected = {
name: 'meta-consumer',
packageName: 'meta-consumer-fixture',
packageVersion: '3.4.5',
version: '3.4.5',
};

it('loads a source module importing agent-bundle/meta with the package identity', () => {
expect(identity).toEqual(expected);
expect(banner).toBe('meta-consumer 3.4.5');
});

it('serves the frozen aggregate the published type declares', () => {
expect(frozenMeta).toEqual(expected);
expect(Object.isFrozen(frozenMeta)).toBe(true);
});
15 changes: 13 additions & 2 deletions packages/agent-bundle/src/build/meta.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { join } from 'node:path';

import type { AgentBundleMeta } from '../meta.ts';
import type { NormalizedMetadata } from '../core/types.ts';

/**
* The reserved namespace (under each build's output root) whose paths
Expand Down Expand Up @@ -33,8 +32,20 @@ export const metaModuleSpecifier = 'agent-bundle/meta';
export const generatedMetaModulePath = (outputRoot: string): string =>
join(outputRoot, generatedModulesDirname, 'meta.mjs');

/**
* The identity axes {@link projectMeta} reads. Normalized project metadata
* satisfies it directly, and so does the test manifest's plugin identity, so
* the build and the Rstest presets stamp one identity through one function.
*/
export interface ProjectMetaSource {
readonly name: string;
readonly packageName?: string | undefined;
readonly packageVersion?: string | undefined;
readonly version: string;
}

/** The exact identity a build stamps into every compiled surface. */
export const projectMeta = (metadata: NormalizedMetadata): AgentBundleMeta => Object.freeze({
export const projectMeta = (metadata: ProjectMetaSource): AgentBundleMeta => Object.freeze({
name: metadata.name,
packageName: metadata.packageName,
packageVersion: metadata.packageVersion,
Expand Down
Loading
Loading