From 9a3abdd6367b0f6d3132f60a902e578482d3cfa4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:40:26 +0000 Subject: [PATCH 1/3] fix(skills): serve agent-bundle/meta to rendered skills and compile their JSX against the loader's element factory Rendered SKILL.tsx modules evaluate through jiti during discovery, outside the compiler's specifier replacement, so 'agent-bundle/meta' fell through to the published module and threw AB4760 inside AB3003 (#440). The loader now aliases the specifier to the same generated identity module the build stamps, fed from one pluginIdentity() derivation normalization also uses for model.metadata. The skill's JSX compiled against the consumer's react/jsx-runtime, which jiti resolves without the process's --conditions while React's own inner require('react') honours them; under the route-unit pool's react-server condition the client jsx runtime met the server React and threw (recentlyCreatedOwnerStacks) so inspectWorkbenchSurface failed with AB3005 (#441). The loader now aliases react/jsx-runtime and react/jsx-dev-runtime to its own plain element factory: the Markdown renderer never needed React. Fixes #440 Fixes #441 --- .changeset/440-441-rendered-skill-loader.md | 5 ++ docs/diagnostics.md | 9 ++ docs/entry-conventions.md | 8 ++ packages/agent-bundle/src/config/discover.ts | 8 +- packages/agent-bundle/src/config/normalize.ts | 32 ++----- .../src/config/plugin-identity.ts | 41 +++++++++ .../agent-bundle/src/config/rendered-skill.ts | 85 ++++++++++++++++++- packages/agent-bundle/src/config/skill.ts | 10 ++- .../src/dev/skill-document-service.ts | 31 ++++--- .../tests/rendered-skills.test.ts | 80 +++++++++++++++++ .../workbench-surface-rendered-skill.test.ts | 78 +++++++++++++++++ website/docs/en/guide/authoring/skills.mdx | 9 ++ website/docs/zh/guide/authoring/skills.mdx | 7 ++ 13 files changed, 362 insertions(+), 41 deletions(-) create mode 100644 .changeset/440-441-rendered-skill-loader.md create mode 100644 packages/agent-bundle/src/config/plugin-identity.ts create mode 100644 packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts diff --git a/.changeset/440-441-rendered-skill-loader.md b/.changeset/440-441-rendered-skill-loader.md new file mode 100644 index 000000000..4eebf99e1 --- /dev/null +++ b/.changeset/440-441-rendered-skill-loader.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Let a rendered Skill (`src/skills//SKILL.tsx`) import `agent-bundle/meta` and evaluate independently of the process's `react` resolution. The skill loader now aliases `agent-bundle/meta` to the same generated identity module the compiler stamps into every built surface — `{ name, packageName, packageVersion, version }` derived from `plugin.name`, `package.json`, and the resolved plugin version — under `validate`, `build`, `inspect`, dev, the Workbench's source Skill documents, and `inspectWorkbenchSurface`, instead of failing with `AB3003` wrapping `AB4760`. The skill's JSX compiles against the loader's own element factory rather than the project's `react/jsx-runtime`, so `inspectWorkbenchSurface` no longer fails with `AB3005` (`recentlyCreatedOwnerStacks`) on a project with a rendered skill when the test runs under the `react-server` condition the `agentBundleRstest()` route-unit pool sets. Fixes #440 and #441 (#PR) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 24d2921a2..9583d905e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -439,6 +439,15 @@ 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. +Rendered skills (`src/skills//SKILL.tsx`) evaluate during discovery, +and the skill loader aliases the specifier to the same generated module fed +from the identity normalization stamps into the model, so a skill importing +`agent-bundle/meta` compiles under `validate`, `build`, `inspect`, dev, and +`inspectWorkbenchSurface` (#440). Only a direct `parseSkill` call without a +project identity leaves the specifier to resolve as the project resolves +`agent-bundle`; the published module then raises this diagnostic inside the +skill's `AB3003`. + | 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. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 61cec058f..e5afdf1ed 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -914,6 +914,14 @@ route-unit level, `renderRoute`, and `invokeCli` alike — with the identity 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)). +Rendered skills are not outside it either (#440): `src/skills//SKILL.tsx` +evaluates during discovery, before any bundle exists, and the skill loader +aliases the specifier to the same generated module fed from the identity +normalization is about to stamp into the model (`plugin.name`, the +`package.json` axes, the resolved version). A skill that prints `version` +prints the one its artifact manifest reports, under `validate`, `build`, +`inspect`, dev, the Workbench's source documents, and `inspectWorkbenchSurface`. + ## Prebuilt payloads — package what you compiled yourself Some projects legitimately own their compilation — a coordinated diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index f95fa7d0f..5b307ba81 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -3,6 +3,7 @@ import { basename, dirname, relative, resolve } from 'node:path'; import fastGlob from 'fast-glob'; +import { projectMeta } from '../build/meta.ts'; import { isErrno } from '../core/errors.ts'; import { isInside } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; @@ -11,6 +12,7 @@ import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts'; import type { CompiledRouteGraph } from '../routes/types.ts'; import { parseCommand, type CommandDocument } from './command.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; +import { pluginIdentity } from './plugin-identity.ts'; import { isRenderedSkillSourceName } from './rendered-skill.ts'; import { parseRule, type RuleDocument } from './rule.ts'; import { parseSkill, type SkillDocument } from './skill.ts'; @@ -253,6 +255,10 @@ export const discoverProject = async ( ): Promise => { const projectRoot = resolve(root); const rules = await readProjectIgnoreRules(projectRoot); + // Rendered skills evaluate during discovery, before normalization stamps + // the same identity into the model; `agent-bundle/meta` serves it to them + // here so a skill documents the version its plugin ships (#440). + const meta = projectMeta(pluginIdentity(projectRoot, config)); const configuredSkills = config.skills; const conventionalSources = (await fastGlob('src/skills/*/SKILL.{md,ts,tsx}', { absolute: true, @@ -346,7 +352,7 @@ export const discoverProject = async ( ...(discoveredRules.length === 0 ? {} : { rules: discoveredRules }), ...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }), skills: await Promise.all( - skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)), + skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules, { meta })), ), ...(state === undefined ? {} : { state }), }; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 2218b4c02..117e0fb4a 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -15,9 +15,9 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; -import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts'; import { isRecord } from '../core/strict-json.ts'; import { conventionalEntryAt } from './conventional-entry.ts'; +import { pluginIdentity } from './plugin-identity.ts'; import { canonicalHookEvents, isPrebuiltEntryInput, @@ -1201,22 +1201,6 @@ const normalizeRules = ( }; }); -/** - * The one plugin version every surface agrees on (issue #94 stage 3): an - * authored `plugin.version` still wins so a legacy declaration never changes - * meaning mid-migration (a disagreement with package.json is the AB4008 - * warning), an omitted one derives the release version from package.json, - * and a project with neither carries the development fallback that - * `agent-bundle build` refuses to package (AB4013). - */ -const resolvePluginVersion = ( - authored: unknown, - packageVersion: string | undefined, -): string => - (typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined) - ?? packageVersion - ?? developmentFallbackVersion; - /** Selects the generated-executable floor; invalid raises fall back to the default the validator rejected. */ const normalizeRuntime = (loaded: LoadedConfig): NormalizedRuntime => { const node = loaded.config.runtime?.node; @@ -1272,9 +1256,9 @@ export const normalizeProject = async ( const logo = normalizePluginLogo(loaded); // The npm package axes are derived, never authored in config: package.json // is authoritative for release identity (issue #94), while plugin.version - // remains the host-facing declared version during the migration. - const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot); - const version = resolvePluginVersion(loaded.config.plugin.version, packageIdentity.packageVersion); + // remains the host-facing declared version during the migration. The same + // derivation serves `agent-bundle/meta` to rendered skills at discovery. + const identity = pluginIdentity(loaded.context.projectRoot, loaded.config); const hostBins = await normalizeHostBins(loaded, targetNames, registry); const hostOutputStyles = await normalizeHostPayloadDirectories( loaded, @@ -1323,11 +1307,11 @@ export const normalizeProject = async ( ...(typeof description === 'string' ? { description } : {}), id: `plugin:${loaded.config.plugin.name}`, ...(logo === undefined ? {} : { logo }), - name: loaded.config.plugin.name, - ...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }), - ...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }), + name: identity.name, + ...(identity.packageName === undefined ? {} : { packageName: identity.packageName }), + ...(identity.packageVersion === undefined ? {} : { packageVersion: identity.packageVersion }), provenance: configProvenance, - version, + version: identity.version, }, mcpApps: normalizeMcpApps(loaded, discovered, mcpServers), mcpServers, diff --git a/packages/agent-bundle/src/config/plugin-identity.ts b/packages/agent-bundle/src/config/plugin-identity.ts new file mode 100644 index 000000000..766417e6d --- /dev/null +++ b/packages/agent-bundle/src/config/plugin-identity.ts @@ -0,0 +1,41 @@ +import type { ProjectMetaSource } from '../build/meta.ts'; +import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts'; +import type { AgentBundleConfig } from '../core/types.ts'; + +/** + * The one plugin version every surface agrees on (issue #94 stage 3): an + * authored `plugin.version` still wins so a legacy declaration never changes + * meaning mid-migration (a disagreement with package.json is the AB4008 + * warning), an omitted one derives the release version from package.json, + * and a project with neither carries the development fallback that + * `agent-bundle build` refuses to package (AB4013). + */ +export const resolvePluginVersion = ( + authored: unknown, + packageVersion: string | undefined, +): string => + (typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined) + ?? packageVersion + ?? developmentFallbackVersion; + +/** + * The plugin identity axes a project carries before normalization: the + * host-native slug from `plugin.name`, the npm axes derived from + * `/package.json` (never authored in config), and the resolved plugin + * version. `normalizeProject` stamps exactly these into `model.metadata`, and + * the rendered-skill loader serves them as `agent-bundle/meta` while + * discovery evaluates `SKILL.tsx` — one derivation, so a skill that prints + * the plugin version prints the one the artifact manifest reports. + */ +export const pluginIdentity = ( + projectRoot: string, + config: Pick, +): ProjectMetaSource => { + const packageIdentity = snapshotPackageIdentity(projectRoot); + return Object.freeze({ + name: config.plugin.name, + packageName: packageIdentity.packageName, + packageVersion: packageIdentity.packageVersion, + version: resolvePluginVersion(config.plugin.version, packageIdentity.packageVersion), + }); +}; diff --git a/packages/agent-bundle/src/config/rendered-skill.ts b/packages/agent-bundle/src/config/rendered-skill.ts index 0e7059762..4c2a07e0b 100644 --- a/packages/agent-bundle/src/config/rendered-skill.ts +++ b/packages/agent-bundle/src/config/rendered-skill.ts @@ -1,12 +1,16 @@ import { existsSync, statSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createJiti } from 'jiti'; import { stringify as stringifyYaml } from 'yaml'; +import { generatedMetaModuleSource, metaModuleSpecifier } from '../build/meta.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { errorMessage } from '../core/errors.ts'; import { isPlainRecord } from '../core/strict-json.ts'; +import type { AgentBundleMeta } from '../meta.ts'; import { MarkdownRenderError, renderElementToMarkdown } from './render-markdown.ts'; /** @@ -52,16 +56,89 @@ const failure = (code: string, message: string, sourcePath: string): RenderedSki status: 'failed', }); +/** What the loader serves to a rendered skill module beyond the project's own code. */ +export interface RenderedSkillLoaderOptions { + /** + * The project identity `agent-bundle/meta` resolves to while the module + * evaluates — the same constants the compiler stamps into every built + * surface. Without it the specifier resolves to the published module, + * which throws `AB4760` (no compiler, no identity). + */ + readonly meta?: AgentBundleMeta; +} + +/** + * The element factory a rendered skill's JSX compiles against. It builds the + * plain `{ type, props, key }` objects `renderElementToMarkdown` walks, with + * React's fragment symbol, and never touches React: the consumer's `react` + * resolves by process condition (`--conditions=react-server` in the + * route-unit pool selects the server build) while jiti resolves package + * subpaths without those conditions, so binding `react/jsx-runtime` from + * one build to `react` from the other throws inside React (#441). The skill + * renderer needs no React internals at all, so it does not resolve any. + */ +const jsxRuntimeModuleSource = [ + '// Generated by agent-bundle for one rendered-skill evaluation. Do not edit.', + "const elementType = Symbol.for('react.transitional.element');", + "export const Fragment = Symbol.for('react.fragment');", + 'const element = (type, props, key) => ({', + ' $$typeof: elementType,', + ' key: key === undefined ? null : String(key),', + ' props,', + ' ref: null,', + ' type,', + '});', + 'export const jsx = element;', + 'export const jsxs = element;', + 'export const jsxDEV = element;', + '', +].join('\n'); + +const reactJsxRuntimeSpecifiers = ['react/jsx-runtime', 'react/jsx-dev-runtime'] as const; + +/** + * Writes the generated modules one evaluation aliases into a private + * temporary directory and returns jiti's alias record. Nothing is written + * under the project: discovery runs during `validate`, which never touches + * the tree it validates. + */ +const writeLoaderModules = async ( + directory: string, + options: RenderedSkillLoaderOptions, +): Promise> => { + const jsxRuntimePath = join(directory, 'jsx-runtime.mjs'); + const writes = [writeFile(jsxRuntimePath, jsxRuntimeModuleSource, 'utf8')]; + const alias: Record = Object.fromEntries( + reactJsxRuntimeSpecifiers.map((specifier) => [specifier, jsxRuntimePath]), + ); + if (options.meta !== undefined) { + const metaPath = join(directory, 'meta.mjs'); + writes.push(writeFile(metaPath, generatedMetaModuleSource(options.meta), 'utf8')); + alias[metaModuleSpecifier] = metaPath; + } + await Promise.all(writes); + return alias; +}; + /** * Loads and compiles one rendered skill source to its Markdown document. The * module executes through the same jiti pipeline that already runs consumer - * TypeScript at config-load time, with the automatic JSX runtime resolved - * from the consumer project. + * TypeScript at config-load time. Its JSX compiles against the loader's own + * element factory rather than the consumer's `react/jsx-runtime`, and + * `agent-bundle/meta` resolves to the project identity when the caller + * supplies one; project code the skill imports resolves from the project as + * usual. */ -export const compileRenderedSkill = async (source: string): Promise => { +export const compileRenderedSkill = async ( + source: string, + options: RenderedSkillLoaderOptions = {}, +): Promise => { let moduleExports: Record; + const loaderDirectory = await mkdtemp(join(tmpdir(), 'agent-bundle-rendered-skill-')); try { + const alias = await writeLoaderModules(loaderDirectory, options); const jiti = createJiti(source, { + alias, interopDefault: true, jsx: { runtime: 'automatic' }, moduleCache: false, @@ -70,6 +147,8 @@ export const compileRenderedSkill = async (source: string): Promise>(source); } catch (error) { return failure('AB3003', `Rendered Skill module failed to load: ${errorMessage(error)}`, source); + } finally { + await rm(loaderDirectory, { force: true, recursive: true }); } const component = moduleExports.default; diff --git a/packages/agent-bundle/src/config/skill.ts b/packages/agent-bundle/src/config/skill.ts index 9f70d839f..9a336acf2 100644 --- a/packages/agent-bundle/src/config/skill.ts +++ b/packages/agent-bundle/src/config/skill.ts @@ -14,10 +14,14 @@ import { import { compileRenderedSkill, isRenderedSkillSourceName, + type RenderedSkillLoaderOptions, renderedSkillSourceAt, } from './rendered-skill.ts'; import { parseSkillMarkdown } from './skill-references.ts'; +/** What a rendered skill module observes while it evaluates; see {@link RenderedSkillLoaderOptions}. */ +export type ParseSkillOptions = RenderedSkillLoaderOptions; + export interface SkillResource { bytes: number; relativePath: string; @@ -124,8 +128,9 @@ const parseRenderedSkill = async ( dir: string, renderedSource: string, resources: SkillResource[], + options: ParseSkillOptions, ): Promise => { - const compiled = await compileRenderedSkill(renderedSource); + const compiled = await compileRenderedSkill(renderedSource, options); if (compiled.status === 'failed') { return { body: '', @@ -158,6 +163,7 @@ export const parseSkill = async ( projectRoot?: string, /** Reuses the caller's compiled ignore rules; discovery parses many skills under one root. */ projectIgnoreRules?: Ignore, + options: ParseSkillOptions = {}, ): Promise => { const dir = resolve(skillDir); const source = join(dir, 'SKILL.md'); @@ -171,7 +177,7 @@ export const parseSkill = async ( markdown = await readFile(source, 'utf8'); } catch (error: unknown) { if (renderedSource !== undefined && isErrno(error, 'ENOENT')) { - return parseRenderedSkill(dir, renderedSource, resources); + return parseRenderedSkill(dir, renderedSource, resources, options); } return { body: '', diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index 4f5539839..e6376ffcd 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -1,11 +1,12 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; import { extname, join, resolve } from 'node:path'; +import { projectMeta } from '../build/meta.ts'; import { parseSkill, type SkillDocument, type SkillResource } from '../config/skill.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { CodedError, isErrno } from '../core/errors.ts'; -import type { NormalizedSkill, SourceProvenance } from '../core/types.ts'; +import type { NormalizedPlugin, NormalizedSkill, SourceProvenance } from '../core/types.ts'; import { EpochStore } from './epoch-store.ts'; import { ProjectService } from './project-service.ts'; import { isInsideOrEqual } from '../core/paths.ts'; @@ -243,21 +244,24 @@ export class SkillDocumentService { async sourceTree(): Promise { const prepared = await this.#projectService.prepare('inspect'); + const model = prepared.model; return Object.freeze({ diagnostics: freezeDiagnostics(prepared.diagnostics), - skills: Object.freeze(await Promise.all((prepared.model?.skills ?? []).map((skill) => this.#sourceDocument(skill)))), + skills: Object.freeze(model === undefined + ? [] + : await Promise.all(model.skills.map((skill) => this.#sourceDocument(skill, model)))), }); } async source(skillId: string): Promise { - const skill = await this.#sourceSkill(skillId); - return this.#sourceDocument(skill); + const { model, skill } = await this.#sourceSkill(skillId); + return this.#sourceDocument(skill, model); } async sourceResource(skillId: string, segments: readonly string[]): Promise { let skill: NormalizedSkill; try { - skill = await this.#sourceSkill(skillId); + ({ skill } = await this.#sourceSkill(skillId)); } catch (error) { if (error instanceof SkillDocumentError && error.code === 'SKILL_DOCUMENT_UNAVAILABLE') { throw new SkillDocumentError('SKILL_RESOURCE_UNAVAILABLE', 'Skill resource is not available from this document base.'); @@ -298,17 +302,22 @@ export class SkillDocumentService { }); } - async #sourceSkill(skillId: string): Promise { + async #sourceSkill(skillId: string): Promise<{ readonly model: NormalizedPlugin; readonly skill: NormalizedSkill }> { const prepared = await this.#projectService.prepare('inspect'); - const skill = prepared.model?.skills.find((candidate) => candidate.id === skillId); - if (skill === undefined) { + const model = prepared.model; + const skill = model?.skills.find((candidate) => candidate.id === skillId); + if (model === undefined || skill === undefined) { throw new SkillDocumentError('SKILL_DOCUMENT_UNAVAILABLE', 'Source Skill is not available from the current normalized project.'); } - return skill; + return { model, skill }; } - async #sourceDocument(skill: NormalizedSkill): Promise { - const document = await parseSkill(skill.dir, this.#root); + /** + * Re-parses the source document. A rendered skill evaluates again here and + * observes the same `agent-bundle/meta` identity discovery served it. + */ + async #sourceDocument(skill: NormalizedSkill, model: NormalizedPlugin): Promise { + const document = await parseSkill(skill.dir, this.#root, undefined, { meta: projectMeta(model.metadata) }); if (document.diagnostics.some((entry) => entry.code === 'AB3000')) { throw new SkillDocumentError('SKILL_DOCUMENT_UNAVAILABLE', 'Source Skill Markdown is not available.'); } diff --git a/packages/agent-bundle/tests/rendered-skills.test.ts b/packages/agent-bundle/tests/rendered-skills.test.ts index 8d2fbd7e8..ace97406a 100644 --- a/packages/agent-bundle/tests/rendered-skills.test.ts +++ b/packages/agent-bundle/tests/rendered-skills.test.ts @@ -231,6 +231,86 @@ describe('rendered skill compilation', () => { ].join('\n')); }); + it('serves agent-bundle/meta to a rendered skill with the identity the model stamps (#440)', async () => { + const skillSource = [ + "import { meta, name, packageName, packageVersion, version } from 'agent-bundle/meta';", + '', + "export const frontmatter = { description: 'Prints the plugin identity.', name: 'identity' };", + 'export default () => (', + ' <>', + '

{name}

', + '

Version {version}; package {String(packageName)}@{String(packageVersion)}.

', + '

Aggregate: {JSON.stringify(meta)}

', + ' ', + ');', + '', + ].join('\n'); + const root = await projectRoot({ + 'package.json': '{ "name": "@acme/identity-plugin", "version": "3.4.5", "type": "module" }\n', + 'src/skills/identity/SKILL.tsx': skillSource, + }); + const loaded = loadedProject({ plugin: { name: 'identity-plugin' } }, root); + + const discovered = await discoverProject(root, loaded.config); + const [skill] = discovered.skills; + expect(skill?.diagnostics).toEqual([]); + expect(skill?.body).toBe([ + '# identity-plugin', + '', + 'Version `3.4.5`; package `@acme/identity-plugin@3.4.5`.', + '', + 'Aggregate: `{"name":"identity-plugin","packageName":"@acme/identity-plugin","packageVersion":"3.4.5","version":"3.4.5"}`', + '', + ].join('\n')); + + // The skill observed exactly the identity normalization stamps. + const model = await normalizeProject(loaded, discovered, registry); + expect(model.metadata).toMatchObject({ + name: 'identity-plugin', + packageName: '@acme/identity-plugin', + packageVersion: '3.4.5', + version: '3.4.5', + }); + + // An authored plugin.version wins over package.json, for the skill too. + const authored = await discoverProject(root, { plugin: { name: 'identity-plugin', version: '9.0.0' } }); + expect(authored.skills[0]?.body).toContain('Version `9.0.0`; package `@acme/identity-plugin@3.4.5`.'); + + // Without a caller-supplied identity the reserved specifier is not aliased: + // it resolves however the project resolves `agent-bundle` — here, not at all. + const direct = await parseSkill(join(root, 'src', 'skills', 'identity'), root); + expect(direct.diagnostics).toEqual([expect.objectContaining({ code: 'AB3003' })]); + }); + + it('compiles JSX against the loader element factory, not the consumer react/jsx-runtime (#441)', async () => { + // A `react` whose jsx runtime throws stands in for the react-server + // condition mismatch: the loader never resolves the consumer's runtime. + const root = await projectRoot({ + 'node_modules/react/index.js': 'module.exports = { Fragment: Symbol.for("react.fragment") };\n', + 'node_modules/react/jsx-dev-runtime.js': "throw new Error('consumer jsx-dev-runtime resolved');\n", + 'node_modules/react/jsx-runtime.js': "throw new Error('consumer jsx-runtime resolved');\n", + 'node_modules/react/package.json': '{ "name": "react", "version": "0.0.0-test", "main": "index.js" }\n', + 'src/skills/keyed/SKILL.tsx': [ + "import React from 'react';", + '', + "export const frontmatter = { description: 'Keyed list items.', name: 'keyed' };", + "const items = ['one', 'two'];", + 'export default () => (', + ' <>', + '

Keyed

', + '
    {items.map((item) =>
  • {item}
  • )}
', + '

{React.Fragment === Symbol.for("react.fragment") ? "react resolved" : "react missing"}

', + ' ', + ');', + '', + ].join('\n'), + }); + + const skill = await parseSkill(join(root, 'src', 'skills', 'keyed'), root); + expect(skill.diagnostics).toEqual([]); + expect(skill.body).toBe('# Keyed\n\n- one\n- two\n\nreact resolved\n'); + }); + it('reports AB3003 for a module that fails to load', async () => { const root = await projectRoot({ 'src/skills/broken/SKILL.ts': "throw new Error('boom');\nexport default () => null;\n", diff --git a/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts b/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts new file mode 100644 index 000000000..26233aba1 --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts @@ -0,0 +1,78 @@ +import { mkdir, rm, symlink } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +import { afterAll, expect, it } from '@rstest/core'; + +import { inspectWorkbenchSurface } from '../../src/test/index.ts'; +import { createProjectFixture } from '../helpers/project-fixture.ts'; + +/** + * The route-unit pool runs under `--conditions=react-server`, so a rendered + * `SKILL.tsx` evaluated by the compiler pass used to bind the consumer's + * client `react/jsx-runtime` to the server `react` build and throw inside + * React (#441). The Workbench surface must inspect such a project from this + * pool like any other harness level. + */ +const reactPackageRoot = dirname(createRequire(import.meta.url).resolve('react/package.json')); + +const roots: string[] = []; + +afterAll(async () => { + await Promise.all(roots.map((root) => rm(root, { force: true, recursive: true }))); +}); + +it('inspects the Workbench surface of a project with a rendered skill under the react-server condition (#441)', async () => { + expect(process.execArgv).toContain('react-server'); + + const project = await createProjectFixture({ + config: [ + 'export default {', + " plugin: { description: 'Rendered skill under react-server.', name: 'rendered-skill-surface' },", + " targets: ['claude'],", + '};', + '', + ].join('\n'), + files: { + 'package.json': '{ "name": "rendered-skill-surface", "version": "1.2.3", "type": "module" }\n', + 'src/mcp/demo/tools/ping.tsx': [ + "import React from 'react';", + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: true }, description: 'Ping.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ok: z.literal(true) }).strict();', + 'export default async function Ping() {', + ' return pong;', + '}', + '', + ].join('\n'), + 'src/skills/demo/SKILL.tsx': [ + "import { version } from 'agent-bundle/meta';", + "import React from 'react';", + '', + "export const frontmatter = { description: 'Demo rendered skill.', name: 'demo' };", + 'export default () => (', + ' <>', + '

Demo

', + '

Hello world from {version}.

', + ' ', + ');', + '', + ].join('\n'), + }, + prefix: 'agent-bundle-rendered-skill-surface-', + }); + roots.push(project.root); + // The skill imports `react`; the fixture resolves it to the real package so + // the consumer-side resolution the pool condition affects is exercised. + await mkdir(join(project.root, 'node_modules'), { recursive: true }); + await symlink(reactPackageRoot, join(project.root, 'node_modules', 'react'), 'dir'); + + const surface = await inspectWorkbenchSurface({ root: project.root }); + + expect(surface.catalog.diagnostics).toEqual([]); + expect(surface.manifest.diagnostics).toEqual([]); + expect(surface.counts).toMatchObject({ mcpServers: 1, skills: 1 }); + expect(surface.provenance).toMatchObject({ proofLevel: 'workbench-surface', targets: ['claude'] }); +}); diff --git a/website/docs/en/guide/authoring/skills.mdx b/website/docs/en/guide/authoring/skills.mdx index 6c462cd3d..e9bc1a0bb 100644 --- a/website/docs/en/guide/authoring/skills.mdx +++ b/website/docs/en/guide/authoring/skills.mdx @@ -134,6 +134,15 @@ Six `Skill.*` members emit canonical tokens: `Skill.Arguments`, `Skill.PluginDat `Skill.Resource` renders a Markdown link (`[path](path)`), not a token. Host syntax is applied during lowering, never in the component. +A rendered source may import project code, so the document is computed from the same sources the +plugin ships, and it may import `agent-bundle/meta`: the loader serves the same +`{ name, packageName, packageVersion, version }` the compiler stamps into every built surface, so a +skill can print the version it documents. Its JSX compiles against the loader's own element +factory rather than the project's `react/jsx-runtime` — the renderer walks plain element objects +and needs no React internals — so the evaluation does not depend on how the process resolves +`react` (the `react-server` condition of the route-unit pool included), and `inspectWorkbenchSurface` +inspects a project with rendered skills from any pool. + A hand-authored `SKILL.md` in the same directory always wins — an authored file beats a generated one — and the shadowed component reports the informational `AB4735` nudge. A rendered module that fails to load reports `AB3003`; one that does not default-export a component diff --git a/website/docs/zh/guide/authoring/skills.mdx b/website/docs/zh/guide/authoring/skills.mdx index 1d37ef18e..7aa6e624a 100644 --- a/website/docs/zh/guide/authoring/skills.mdx +++ b/website/docs/zh/guide/authoring/skills.mdx @@ -126,6 +126,13 @@ export default () => ( `Skill.ProjectRoot`、`Skill.SessionIdentity` 与 `Skill.SkillRoot`。`Skill.Resource` 渲染的是 Markdown 链接(`[path](path)`),不是 token。宿主语法在降级阶段应用,绝不在组件里应用。 +渲染源可以导入项目代码,因此文档由插件所发布的同一批源码计算得出;它也可以导入 +`agent-bundle/meta`:加载器提供的 `{ name, packageName, packageVersion, version }` 与编译器烙印到 +每个构建面上的完全相同,所以 Skill 能打印出它所描述的版本。它的 JSX 针对加载器自己的元素工厂编译, +而不是项目的 `react/jsx-runtime`——渲染器只遍历普通元素对象,不需要任何 React 内部实现——因此求值 +不依赖进程如何解析 `react`(包括 route-unit 池的 `react-server` 条件),`inspectWorkbenchSurface` +可以从任意池检查带有渲染 Skill 的项目。 + 同目录下手写的 `SKILL.md` 总是胜出——手写文件胜过生成文件——被遮蔽的组件会报告信息级的 `AB4735` 提示。渲染模块加载失败报告 `AB3003`;未默认导出组件函数、或未导出 `frontmatter` 记录,则报告 `AB3004`。 From af5710e1e44cdddc7770083526b3adc628c2a75c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:41:04 +0000 Subject: [PATCH 2/3] chore: reference PR number in changeset --- .changeset/440-441-rendered-skill-loader.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/440-441-rendered-skill-loader.md b/.changeset/440-441-rendered-skill-loader.md index 4eebf99e1..d457e44af 100644 --- a/.changeset/440-441-rendered-skill-loader.md +++ b/.changeset/440-441-rendered-skill-loader.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Let a rendered Skill (`src/skills//SKILL.tsx`) import `agent-bundle/meta` and evaluate independently of the process's `react` resolution. The skill loader now aliases `agent-bundle/meta` to the same generated identity module the compiler stamps into every built surface — `{ name, packageName, packageVersion, version }` derived from `plugin.name`, `package.json`, and the resolved plugin version — under `validate`, `build`, `inspect`, dev, the Workbench's source Skill documents, and `inspectWorkbenchSurface`, instead of failing with `AB3003` wrapping `AB4760`. The skill's JSX compiles against the loader's own element factory rather than the project's `react/jsx-runtime`, so `inspectWorkbenchSurface` no longer fails with `AB3005` (`recentlyCreatedOwnerStacks`) on a project with a rendered skill when the test runs under the `react-server` condition the `agentBundleRstest()` route-unit pool sets. Fixes #440 and #441 (#PR) +Let a rendered Skill (`src/skills//SKILL.tsx`) import `agent-bundle/meta` and evaluate independently of the process's `react` resolution. The skill loader now aliases `agent-bundle/meta` to the same generated identity module the compiler stamps into every built surface — `{ name, packageName, packageVersion, version }` derived from `plugin.name`, `package.json`, and the resolved plugin version — under `validate`, `build`, `inspect`, dev, the Workbench's source Skill documents, and `inspectWorkbenchSurface`, instead of failing with `AB3003` wrapping `AB4760`. The skill's JSX compiles against the loader's own element factory rather than the project's `react/jsx-runtime`, so `inspectWorkbenchSurface` no longer fails with `AB3005` (`recentlyCreatedOwnerStacks`) on a project with a rendered skill when the test runs under the `react-server` condition the `agentBundleRstest()` route-unit pool sets. Fixes #440 and #441 (#527) From 6d164f5a36ed63a54d3496edd016d257847feca9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:55:49 +0000 Subject: [PATCH 3/3] fix(config): keep a malformed plugin block the validator's AB4000 when discovery derives the skill identity Review: discovery dereferenced config.plugin.name before validateSource ran, turning {} or plugin: null into a generic AB7000. declaredPluginIdentity() returns undefined for an unusable plugin.name; the rendered skill then gets no identity (AB3003) and validation reports AB4000 as before. --- packages/agent-bundle/src/config/discover.ts | 11 ++++++---- .../src/config/plugin-identity.ts | 20 +++++++++++++++++++ .../tests/rendered-skills.test.ts | 10 ++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 5b307ba81..7aab6c686 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -12,7 +12,7 @@ import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts'; import type { CompiledRouteGraph } from '../routes/types.ts'; import { parseCommand, type CommandDocument } from './command.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; -import { pluginIdentity } from './plugin-identity.ts'; +import { declaredPluginIdentity } from './plugin-identity.ts'; import { isRenderedSkillSourceName } from './rendered-skill.ts'; import { parseRule, type RuleDocument } from './rule.ts'; import { parseSkill, type SkillDocument } from './skill.ts'; @@ -257,8 +257,11 @@ export const discoverProject = async ( const rules = await readProjectIgnoreRules(projectRoot); // Rendered skills evaluate during discovery, before normalization stamps // the same identity into the model; `agent-bundle/meta` serves it to them - // here so a skill documents the version its plugin ships (#440). - const meta = projectMeta(pluginIdentity(projectRoot, config)); + // here so a skill documents the version its plugin ships (#440). A config + // without a usable `plugin.name` is the validator's AB4000, not a crash + // here, and such a skill gets no identity rather than a fabricated one. + const identity = declaredPluginIdentity(projectRoot, config as Readonly>); + const meta = identity === undefined ? undefined : projectMeta(identity); const configuredSkills = config.skills; const conventionalSources = (await fastGlob('src/skills/*/SKILL.{md,ts,tsx}', { absolute: true, @@ -352,7 +355,7 @@ export const discoverProject = async ( ...(discoveredRules.length === 0 ? {} : { rules: discoveredRules }), ...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }), skills: await Promise.all( - skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules, { meta })), + skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules, meta === undefined ? {} : { meta })), ), ...(state === undefined ? {} : { state }), }; diff --git a/packages/agent-bundle/src/config/plugin-identity.ts b/packages/agent-bundle/src/config/plugin-identity.ts index 766417e6d..826f1d33f 100644 --- a/packages/agent-bundle/src/config/plugin-identity.ts +++ b/packages/agent-bundle/src/config/plugin-identity.ts @@ -1,5 +1,6 @@ import type { ProjectMetaSource } from '../build/meta.ts'; import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts'; +import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; /** @@ -39,3 +40,22 @@ export const pluginIdentity = ( version: resolvePluginVersion(config.plugin.version, packageIdentity.packageVersion), }); }; + +/** + * {@link pluginIdentity} for a configuration that has not been validated yet + * (discovery runs before `validateSource`): undefined when `plugin.name` is + * not a nonempty string, so a malformed `plugin` block stays the validator's + * `AB4000` to report rather than a crash here, and no fabricated identity is + * ever served in its place. + */ +export const declaredPluginIdentity = ( + projectRoot: string, + config: Readonly>, +): ProjectMetaSource | undefined => { + const plugin = config.plugin; + if (!isRecord(plugin) || typeof plugin.name !== 'string' || plugin.name.trim().length === 0) return undefined; + // `resolvePluginVersion` already treats a non-string `version` as absent. + return pluginIdentity(projectRoot, { + plugin: { name: plugin.name, ...(typeof plugin.version === 'string' ? { version: plugin.version } : {}) }, + }); +}; diff --git a/packages/agent-bundle/tests/rendered-skills.test.ts b/packages/agent-bundle/tests/rendered-skills.test.ts index ace97406a..c87e6ef76 100644 --- a/packages/agent-bundle/tests/rendered-skills.test.ts +++ b/packages/agent-bundle/tests/rendered-skills.test.ts @@ -280,6 +280,16 @@ describe('rendered skill compilation', () => { // it resolves however the project resolves `agent-bundle` — here, not at all. const direct = await parseSkill(join(root, 'src', 'skills', 'identity'), root); expect(direct.diagnostics).toEqual([expect.objectContaining({ code: 'AB3003' })]); + + // A config with no usable plugin.name is the validator's AB4000: discovery + // still completes, and the skill is served no fabricated identity. + for (const malformed of [{}, { plugin: null }, { plugin: { name: '' } }, { plugin: 'x' }]) { + const config = malformed as unknown as AgentBundleConfig; + const withoutIdentity = await discoverProject(root, config); + expect(withoutIdentity.skills[0]?.diagnostics).toEqual([expect.objectContaining({ code: 'AB3003' })]); + expect(validateSource(loadedProject(config, root), withoutIdentity, registry).map(({ code }) => code)) + .toContain('AB4000'); + } }); it('compiles JSX against the loader element factory, not the consumer react/jsx-runtime (#441)', async () => {