diff --git a/.changeset/440-441-rendered-skill-loader.md b/.changeset/440-441-rendered-skill-loader.md new file mode 100644 index 000000000..d457e44af --- /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 (#527) 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..7aab6c686 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 { 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'; @@ -253,6 +255,13 @@ 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). 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, @@ -346,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)), + skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules, meta === undefined ? {} : { 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..826f1d33f --- /dev/null +++ b/packages/agent-bundle/src/config/plugin-identity.ts @@ -0,0 +1,61 @@ +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'; + +/** + * 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), + }); +}; + +/** + * {@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/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..c87e6ef76 100644 --- a/packages/agent-bundle/tests/rendered-skills.test.ts +++ b/packages/agent-bundle/tests/rendered-skills.test.ts @@ -231,6 +231,96 @@ 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' })]); + + // 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 () => { + // 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`。