diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 976674f2..494aefff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: - run: pnpm test + # A2UI conformance: the boundary mapper's surfaces must validate against + # the vendored Google A2UI v1.0 schemas and match their committed goldens. + - run: pnpm conformance + # `next build` typechecks too, and it needs no secrets — verified by # building with no .env file present. - run: pnpm build diff --git a/.gitignore b/.gitignore index a3642441..e0a27ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ mcp/dist/ # Built by mcp/build.mjs (predev/prebuild) — never committed. public/widget-shell.html + diff --git a/package.json b/package.json index 2421704b..7384165f 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,12 @@ "mcp:build": "node mcp/build.mjs", "mcp:serve": "node mcp/server-http.mjs", "predev": "node mcp/build.mjs", - "prebuild": "node mcp/build.mjs" + "prebuild": "node mcp/build.mjs", + "conformance": "node scripts/a2ui-conformance.mjs", + "conformance:update": "node scripts/a2ui-conformance.mjs --update" }, "dependencies": { + "@ag-ui/core": "^0.0.58", "@ai-sdk/amazon-bedrock": "^5.0.66", "@ai-sdk/anthropic": "^4.0.44", "@ai-sdk/openai": "^4.0.50", @@ -49,11 +52,14 @@ "zod": "^4.4.3" }, "devDependencies": { + "@a2ui/web_core": "0.10.6", "@tailwindcss/cli": "^4.3.3", "@tailwindcss/postcss": "^4", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "esbuild": "^0.28.2", "eslint": "^9", "eslint-config-next": "16.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f31d0617..704f731a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ag-ui/core': + specifier: ^0.0.58 + version: 0.0.58 '@ai-sdk/amazon-bedrock': specifier: ^5.0.66 version: 5.0.66(zod@4.4.3) @@ -19,7 +22,7 @@ importers: version: 4.0.50(zod@4.4.3) '@base-ui/react': specifier: ^1.7.0 - version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.0(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -87,6 +90,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@a2ui/web_core': + specifier: 0.10.6 + version: 0.10.6 '@tailwindcss/cli': specifier: ^4.3.3 version: 4.3.3 @@ -102,6 +108,12 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.5(@types/react@19.2.18) + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -126,6 +138,12 @@ importers: packages: + '@a2ui/web_core@0.10.6': + resolution: {integrity: sha512-kTwir93Fk5eupl4FZ6CkhJDrisR2fnQgEIlDCTp06uzx2PvUhItbq1E+h7hFyeHGTu/NMci9Tqn7Is1IVRpufw==} + + '@ag-ui/core@0.0.58': + resolution: {integrity: sha512-XgGb7YmhV+yMBaEmlrpsd5S+nUxq0JgSegss2t4gIFR1j7w3w0ibtKfRgcQHWeMvwZxcT5S28VEEarqtgxYYHw==} + '@ai-sdk/amazon-bedrock@5.0.66': resolution: {integrity: sha512-QyXME04hUaufpXh/lqt4/c8NH1rEtWLr5hDoMMJenJUh4ORJ9epdgqBQ0lC/qFh0GvqpAZKo+doBw05PJBiLnQ==} engines: {node: '>=22'} @@ -991,6 +1009,9 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@rolldown/binding-android-arm-eabi@1.2.6': resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1900,6 +1921,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -4172,6 +4196,17 @@ packages: snapshots: + '@a2ui/web_core@0.10.6': + dependencies: + '@preact/signals-core': 1.14.4 + date-fns: 4.4.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + + '@ag-ui/core@0.0.58': + dependencies: + zod: 3.25.76 + '@ai-sdk/amazon-bedrock@5.0.66(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 4.0.44(zod@4.4.3) @@ -4405,7 +4440,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/react@1.7.0(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@base-ui/utils': 0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4416,6 +4451,7 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 + date-fns: 4.4.0 '@base-ui/utils@0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -4964,6 +5000,8 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 + '@preact/signals-core@1.14.4': {} + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true @@ -5780,6 +5818,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.4.0: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 diff --git a/scripts/a2ui-conformance.mjs b/scripts/a2ui-conformance.mjs new file mode 100644 index 00000000..2a568fae --- /dev/null +++ b/scripts/a2ui-conformance.mjs @@ -0,0 +1,234 @@ +/** + * A2UI conformance: prove the boundary mapper's output against Google's own + * v1.0 schemas, not against our opinion of them. + * + * pnpm conformance validate fixtures + compare golden surfaces + * pnpm conformance:update regenerate the golden surfaces + * + * Four layers of checking, strictest first: + * 1. Input: every fixture spec must parse against the app's real widget + * schema (zod) — the gate proves the mapper handles specs the pipeline + * can actually produce, not hand-written fiction. + * 2. Schema: every emitted message validates against the vendored + * `agent_to_renderer.json` (+ common_types + the basic catalog), and + * must declare the exact catalog it was validated against. + * 3. Structure: component ids are unique, and every child reference — + * including the template form — resolves; the flat-tree invariants the + * schema alone cannot state. + * 4. Drift: output matches the committed golden files, so a mapper change + * shows up in review as a surface diff, never silently. + */ +import { build } from 'esbuild'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { Ajv2020 } from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +// The spec schemas come from @a2ui/web_core — the A2UI project's own +// publication, pinned exactly in package.json; the lockfile's integrity +// hash is the checksum. Nothing of the upstream spec is committed here, +// and our surfaces target the *released* revision, because interop is +// with shipped renderers, not with the spec repo's HEAD. +const specDir = join(root, 'node_modules', '@a2ui', 'web_core', 'src', 'v1_0', 'schemas'); +const fixtureDir = join(root, 'spec', 'a2learn', 'fixtures'); +const update = process.argv.includes('--update'); + +// --- load the mapper AND the widget schema via one in-memory bundle --- +const bundle = await build({ + stdin: { + contents: ` + export { toA2UISurface, A2UI_SUPPORTED_KINDS, A2UI_BASIC_CATALOG } from '@/lib/a2learn/a2ui'; + export { widgetSpec } from '@/lib/pathway/schema'; + `, + resolveDir: root, + loader: 'ts', + }, + bundle: true, + format: 'esm', + write: false, + tsconfig: join(root, 'tsconfig.json'), + logLevel: 'error', +}); +// Imported from a data URL — nothing written to disk, nothing to clean up, +// and two concurrent runs cannot read each other's bundle. +const moduleUrl = `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`; +const { toA2UISurface, A2UI_SUPPORTED_KINDS, A2UI_BASIC_CATALOG, widgetSpec } = await import(moduleUrl); + +// --- assemble the validator from the vendored schemas --- +const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')); +const messageSchema = readJson(join(specDir, 'agent_to_renderer.json')); +const commonTypes = readJson(join(specDir, 'common_types.json')); +const basicCatalog = readJson(join(specDir, 'catalogs', 'basic', 'catalog.json')); + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +addFormats(ajv); +ajv.addSchema(commonTypes); +ajv.addSchema(basicCatalog); +// `agent_to_renderer.json` refs `catalog.json#...` relative to its own $id; +// the surface's actual catalog is bound at runtime. Alias the basic catalog +// to that resolved URL so validation uses the catalog our surfaces target — +// and because the alias makes validation catalog-blind, layer 2 separately +// asserts each surface *declares* the catalog it was validated against. +ajv.addSchema({ ...basicCatalog, $id: 'https://a2ui.org/specification/v1_0/catalog.json' }); +const validate = ajv.compile(messageSchema); + +// --- negative controls: prove each layer can reject before trusting green --- +// The $id alias above is exactly the plumbing that could silently vacuate +// validation; these fail-closed checks run every time, not just in review. +// Empirical note: the upstream schema accepts a createSurface with no +// components at all — only the structural layer catches that, which is why +// both layers get their own control here. +if (validate({})) { + console.error('✗ negative control: schema validator accepted an empty message — validation is vacuous'); + process.exit(1); +} +if (validate({ createSurface: { surfaceId: 's', catalogId: 'c', components: [{ bogus: true }] } })) { + console.error('✗ negative control: schema validator accepted a malformed component — validation is vacuous'); + process.exit(1); +} + +// --- structural invariants the schema cannot express --- +function structuralProblems(message) { + const problems = []; + const components = message.createSurface?.components ?? []; + const ids = new Set(); + for (const c of components) { + if (ids.has(c.id)) problems.push(`duplicate component id: ${c.id}`); + ids.add(c.id); + } + if (!ids.has('root')) problems.push("no 'root' component — createSurface implies Surface{child:'root'}"); + for (const c of components) { + const refs = []; + if (typeof c.child === 'string') refs.push(c.child); + if (Array.isArray(c.children)) refs.push(...c.children); + // ChildList's template form: { componentId, path } — the componentId is + // a reference too, and a typo there is exactly the silent-empty-list bug + // this layer exists to catch. + if (c.children && !Array.isArray(c.children) && typeof c.children === 'object') { + if (typeof c.children.componentId === 'string') refs.push(c.children.componentId); + } + // Tabs nest their child refs one level down: tabs[].child. + if (Array.isArray(c.tabs)) { + for (const tab of c.tabs) { + if (tab && typeof tab.child === 'string') refs.push(tab.child); + } + } + for (const ref of refs) { + if (!ids.has(ref)) problems.push(`${c.id} references missing component: ${ref}`); + } + } + return problems; +} + +// Structural layer: a duplicate id, a dangling child ref, and a missing root +// must all be reported, or layer 3 is a no-op wearing a checkmark. +{ + const problems = structuralProblems({ + createSurface: { + components: [ + { id: 'a', child: 'ghost' }, + { id: 'a' }, + { id: 'b', tabs: [{ title: 'T', child: 'tab-ghost' }] }, + ], + }, + }); + const wants = ['duplicate component id', 'missing component: ghost', 'missing component: tab-ghost', "no 'root'"]; + if (!wants.every((w) => problems.some((p) => p.includes(w)))) { + console.error(`✗ negative control: structural checker missed known defects (got: ${problems.join('; ') || 'nothing'})`); + process.exit(1); + } +} + +// Input layer: the zod gate must reject a spec the pipeline could never produce. +if (widgetSpec.safeParse({ kind: 'no-such-kind' }).success) { + console.error('✗ negative control: widgetSpec accepted an unknown kind — the fixture gate is open'); + process.exit(1); +} + +// --- run every fixture --- +let failed = false; +const covered = new Set(); + +function fail(name, msg) { + console.error(`✗ ${name}: ${msg}`); + failed = true; +} + +const specs = readdirSync(fixtureDir).filter((f) => f.endsWith('.spec.json')); +if (!specs.length) { + console.error(`No fixtures in ${fixtureDir} — add .spec.json widget specs.`); + process.exit(1); +} + +for (const file of specs) { + const name = file.replace(/\.spec\.json$/, ''); + const spec = readJson(join(fixtureDir, file)); + + // Layer 1: the fixture must be a spec the pipeline could actually produce. + const parsed = widgetSpec.safeParse(spec); + if (!parsed.success) { + fail(name, `fixture is not a valid widget spec: ${parsed.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join('; ')}`); + continue; + } + + const surface = toA2UISurface(parsed.data, `a2learn-fixture-${name}`); + + if (!surface) { + fail(name, `kind "${spec.kind}" is not mapped (A2UI_SUPPORTED_KINDS: ${A2UI_SUPPORTED_KINDS.join(', ')})`); + continue; + } + covered.add(spec.kind); + + // Layer 2: upstream schema validity, plus the catalog binding the alias + // above cannot check. + if (!validate(surface)) { + fail(name, 'schema validation failed'); + for (const err of validate.errors ?? []) console.error(` ${err.instancePath} ${err.message}`); + continue; + } + if (surface.createSurface.catalogId !== A2UI_BASIC_CATALOG) { + fail(name, `catalogId "${surface.createSurface.catalogId}" is not the catalog this suite validates against`); + continue; + } + + // Layer 3. + const structural = structuralProblems(surface); + if (structural.length) { + fail(name, structural.join('; ')); + continue; + } + + // Layer 4. + const goldenPath = join(fixtureDir, `${name}.surface.json`); + const rendered = `${JSON.stringify(surface, null, 2)}\n`; + if (update) { + writeFileSync(goldenPath, rendered); + console.log(`✓ ${name}: valid — golden updated`); + } else { + let golden = null; + try { + golden = readFileSync(goldenPath, 'utf8'); + } catch { + fail(name, 'no golden surface — run `pnpm conformance:update` and commit it'); + continue; + } + if (golden !== rendered) { + fail(name, 'surface drifted from golden — review, then `pnpm conformance:update`'); + continue; + } + console.log(`✓ ${name}: spec-valid, schema-valid, structurally sound, matches golden`); + } +} + +// Every claimed kind must have at least one fixture — support is proven, not asserted. +for (const kind of A2UI_SUPPORTED_KINDS) { + if (!covered.has(kind)) { + fail('coverage', `A2UI_SUPPORTED_KINDS claims "${kind}" but no fixture covers it`); + } +} + +process.exit(failed ? 1 : 0); diff --git a/spec/a2learn/fixtures/flashcard.spec.json b/spec/a2learn/fixtures/flashcard.spec.json new file mode 100644 index 00000000..7dffe036 --- /dev/null +++ b/spec/a2learn/fixtures/flashcard.spec.json @@ -0,0 +1,11 @@ +{ + "kind": "flashcard", + "learningComponentId": "lc-fractions-2", + "prompt": "Match each fraction word to what it means.", + "successMessage": "You matched every card — the words are yours now.", + "cards": [ + { "id": "c1", "front": { "text": "numerator", "markdown": null, "imageUrl": null, "imageAlt": null }, "back": { "text": "how many parts you have", "markdown": null, "imageUrl": null, "imageAlt": null } }, + { "id": "c2", "front": { "text": "denominator", "markdown": null, "imageUrl": null, "imageAlt": null }, "back": { "text": "how many equal parts make the whole", "markdown": "- more parts means **smaller** parts", "imageUrl": null, "imageAlt": null } }, + { "id": "c3", "front": { "text": null, "markdown": null, "imageUrl": "https://example.org/fraction-bar.png", "imageAlt": "a bar split into three equal parts" }, "back": { "text": "one third", "markdown": null, "imageUrl": null, "imageAlt": null } } + ] +} diff --git a/spec/a2learn/fixtures/flashcard.surface.json b/spec/a2learn/fixtures/flashcard.surface.json new file mode 100644 index 00000000..2dadd492 --- /dev/null +++ b/spec/a2learn/fixtures/flashcard.surface.json @@ -0,0 +1,201 @@ +{ + "version": "v1.0", + "createSurface": { + "surfaceId": "a2learn-fixture-flashcard", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "prompt", + "deck", + "done", + "success" + ] + }, + { + "id": "prompt", + "component": "Text", + "text": "Match each fraction word to what it means." + }, + { + "id": "deck", + "component": "List", + "direction": "horizontal", + "children": [ + "card-0", + "card-1", + "card-2" + ] + }, + { + "id": "card-0", + "component": "Card", + "child": "card-0-tabs" + }, + { + "id": "card-0-tabs", + "component": "Tabs", + "tabs": [ + { + "title": "Front", + "child": "card-0-front-col" + }, + { + "title": "Back", + "child": "card-0-back-col" + } + ] + }, + { + "id": "card-0-front-col", + "component": "Column", + "children": [ + "card-0-front-text" + ] + }, + { + "id": "card-0-back-col", + "component": "Column", + "children": [ + "card-0-back-text" + ] + }, + { + "id": "card-0-front-text", + "component": "Text", + "text": "numerator" + }, + { + "id": "card-0-back-text", + "component": "Text", + "text": "how many parts you have" + }, + { + "id": "card-1", + "component": "Card", + "child": "card-1-tabs" + }, + { + "id": "card-1-tabs", + "component": "Tabs", + "tabs": [ + { + "title": "Front", + "child": "card-1-front-col" + }, + { + "title": "Back", + "child": "card-1-back-col" + } + ] + }, + { + "id": "card-1-front-col", + "component": "Column", + "children": [ + "card-1-front-text" + ] + }, + { + "id": "card-1-back-col", + "component": "Column", + "children": [ + "card-1-back-text", + "card-1-back-md" + ] + }, + { + "id": "card-1-front-text", + "component": "Text", + "text": "denominator" + }, + { + "id": "card-1-back-text", + "component": "Text", + "text": "how many equal parts make the whole" + }, + { + "id": "card-1-back-md", + "component": "Text", + "text": "- more parts means **smaller** parts" + }, + { + "id": "card-2", + "component": "Card", + "child": "card-2-tabs" + }, + { + "id": "card-2-tabs", + "component": "Tabs", + "tabs": [ + { + "title": "Front", + "child": "card-2-front-col" + }, + { + "title": "Back", + "child": "card-2-back-col" + } + ] + }, + { + "id": "card-2-front-col", + "component": "Column", + "children": [ + "card-2-front-image" + ] + }, + { + "id": "card-2-back-col", + "component": "Column", + "children": [ + "card-2-back-text" + ] + }, + { + "id": "card-2-front-image", + "component": "Image", + "url": "https://example.org/fraction-bar.png", + "description": "a bar split into three equal parts", + "fit": "contain" + }, + { + "id": "card-2-back-text", + "component": "Text", + "text": "one third" + }, + { + "id": "done", + "component": "Button", + "variant": "primary", + "child": "done-label", + "action": { + "event": { + "name": "a2learn.widget_completed", + "context": { + "kind": "flashcard" + } + } + } + }, + { + "id": "done-label", + "component": "Text", + "text": "Done" + }, + { + "id": "success", + "component": "Text", + "text": "When you're done: You matched every card — the words are yours now.", + "variant": "caption" + } + ], + "dataModel": { + "a2learn": { + "kind": "flashcard" + } + } + } +} diff --git a/spec/a2learn/fixtures/markdown-card.spec.json b/spec/a2learn/fixtures/markdown-card.spec.json new file mode 100644 index 00000000..55048a6c --- /dev/null +++ b/spec/a2learn/fixtures/markdown-card.spec.json @@ -0,0 +1,7 @@ +{ + "kind": "markdown-card", + "learningComponentId": "lc-fractions-1", + "title": "Why the denominator matters", + "body": "When you split a whole into **equal parts**, the denominator tells you *how many parts* make the whole.\n\n- 1/3 means the whole is split into 3 equal parts\n- 1/4 means it is split into 4\n\n> More parts means each part is smaller — that is why 1/3 is bigger than 1/4.", + "tip": "Sketch both fractions on the same-size bar before comparing them." +} diff --git a/spec/a2learn/fixtures/markdown-card.surface.json b/spec/a2learn/fixtures/markdown-card.surface.json new file mode 100644 index 00000000..c478c151 --- /dev/null +++ b/spec/a2learn/fixtures/markdown-card.surface.json @@ -0,0 +1,64 @@ +{ + "version": "v1.0", + "createSurface": { + "surfaceId": "a2learn-fixture-markdown-card", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Card", + "child": "layout" + }, + { + "id": "layout", + "component": "Column", + "children": [ + "title", + "body", + "tip", + "done" + ] + }, + { + "id": "title", + "component": "Text", + "text": "Why the denominator matters" + }, + { + "id": "body", + "component": "Text", + "text": "When you split a whole into **equal parts**, the denominator tells you *how many parts* make the whole.\n\n- 1/3 means the whole is split into 3 equal parts\n- 1/4 means it is split into 4\n\n> More parts means each part is smaller — that is why 1/3 is bigger than 1/4." + }, + { + "id": "tip", + "component": "Text", + "text": "Tip: Sketch both fractions on the same-size bar before comparing them.", + "variant": "caption" + }, + { + "id": "done", + "component": "Button", + "variant": "primary", + "child": "done-label", + "action": { + "event": { + "name": "a2learn.widget_completed", + "context": { + "kind": "markdown-card" + } + } + } + }, + { + "id": "done-label", + "component": "Text", + "text": "Got it" + } + ], + "dataModel": { + "a2learn": { + "kind": "markdown-card" + } + } + } +} diff --git a/spec/a2learn/fixtures/step-reveal.spec.json b/spec/a2learn/fixtures/step-reveal.spec.json new file mode 100644 index 00000000..664807ad --- /dev/null +++ b/spec/a2learn/fixtures/step-reveal.spec.json @@ -0,0 +1,25 @@ +{ + "kind": "step-reveal", + "learningComponentId": "lc-fractions-3", + "prompt": "Let's compare 2/6 and 5/6 together, one step at a time.", + "steps": [ + { + "id": "s1", + "title": "Step 1: Check the denominators", + "body": "Both fractions have the same denominator, **6**. That means both wholes are cut into six equal parts — so the pieces are the same size.", + "why": "Comparing only works when the pieces are the same size." + }, + { + "id": "s2", + "title": "Step 2: Compare the numerators", + "body": "Now look at the numerators: 2 and 5. Since every piece is the same size, having **5 pieces** is more than having **2 pieces**.", + "why": null + }, + { + "id": "s3", + "title": "Step 3: Write the comparison", + "body": "So 5/6 is greater than 2/6, and we write it as **5/6 > 2/6**. Same-size pieces, more of them — that's the whole comparison.", + "why": "The symbol always opens toward the bigger amount." + } + ] +} diff --git a/spec/a2learn/fixtures/step-reveal.surface.json b/spec/a2learn/fixtures/step-reveal.surface.json new file mode 100644 index 00000000..7730e571 --- /dev/null +++ b/spec/a2learn/fixtures/step-reveal.surface.json @@ -0,0 +1,155 @@ +{ + "version": "v1.0", + "createSurface": { + "surfaceId": "a2learn-fixture-step-reveal", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "prompt", + "steps", + "done" + ] + }, + { + "id": "prompt", + "component": "Text", + "text": "Let's compare 2/6 and 5/6 together, one step at a time." + }, + { + "id": "steps", + "component": "List", + "direction": "horizontal", + "children": [ + "step-0", + "step-1", + "step-2" + ] + }, + { + "id": "step-0", + "component": "Card", + "child": "step-0-col" + }, + { + "id": "step-0-title", + "component": "Text", + "text": "Step 1: Check the denominators" + }, + { + "id": "step-0-divider", + "component": "Divider" + }, + { + "id": "step-0-body", + "component": "Text", + "text": "Both fractions have the same denominator, **6**. That means both wholes are cut into six equal parts — so the pieces are the same size." + }, + { + "id": "step-0-why", + "component": "Text", + "text": "Why: Comparing only works when the pieces are the same size.", + "variant": "caption" + }, + { + "id": "step-0-col", + "component": "Column", + "children": [ + "step-0-title", + "step-0-divider", + "step-0-body", + "step-0-why" + ] + }, + { + "id": "step-1", + "component": "Card", + "child": "step-1-col" + }, + { + "id": "step-1-title", + "component": "Text", + "text": "Step 2: Compare the numerators" + }, + { + "id": "step-1-divider", + "component": "Divider" + }, + { + "id": "step-1-body", + "component": "Text", + "text": "Now look at the numerators: 2 and 5. Since every piece is the same size, having **5 pieces** is more than having **2 pieces**." + }, + { + "id": "step-1-col", + "component": "Column", + "children": [ + "step-1-title", + "step-1-divider", + "step-1-body" + ] + }, + { + "id": "step-2", + "component": "Card", + "child": "step-2-col" + }, + { + "id": "step-2-title", + "component": "Text", + "text": "Step 3: Write the comparison" + }, + { + "id": "step-2-divider", + "component": "Divider" + }, + { + "id": "step-2-body", + "component": "Text", + "text": "So 5/6 is greater than 2/6, and we write it as **5/6 > 2/6**. Same-size pieces, more of them — that's the whole comparison." + }, + { + "id": "step-2-why", + "component": "Text", + "text": "Why: The symbol always opens toward the bigger amount.", + "variant": "caption" + }, + { + "id": "step-2-col", + "component": "Column", + "children": [ + "step-2-title", + "step-2-divider", + "step-2-body", + "step-2-why" + ] + }, + { + "id": "done", + "component": "Button", + "variant": "primary", + "child": "done-label", + "action": { + "event": { + "name": "a2learn.widget_completed", + "context": { + "kind": "step-reveal" + } + } + } + }, + { + "id": "done-label", + "component": "Text", + "text": "Done" + } + ], + "dataModel": { + "a2learn": { + "kind": "step-reveal" + } + } + } +} diff --git a/spec/a2ui/README.md b/spec/a2ui/README.md new file mode 100644 index 00000000..61439981 --- /dev/null +++ b/spec/a2ui/README.md @@ -0,0 +1,16 @@ +# A2UI v1.0 spec — from the official package + +The conformance target for `src/lib/a2learn/a2ui.ts` is the A2UI spec as +published by the project itself: the exact-pinned `@a2ui/web_core` +devDependency ships the v1.0 JSON schemas, and the lockfile's integrity +hash pins their bytes. Nothing of the upstream spec is committed in this +repo, and `pnpm conformance` reads the schemas from `node_modules`. + +Our surfaces deliberately target the *released* schema revision — interop +is with shipped renderers (`@a2ui/react`, `@a2ui/lit`, and friends), not +with the spec repo's HEAD. Updating the spec version is a normal +dependency bump: raise the pin, run `pnpm conformance`, and let the gate +say what the new revision thinks of our surfaces. + +The fixtures and golden surfaces under `spec/a2learn/` are ours and stay +committed. diff --git a/src/app/api/pathway/agui/route.ts b/src/app/api/pathway/agui/route.ts new file mode 100644 index 00000000..1f57cb42 --- /dev/null +++ b/src/app/api/pathway/agui/route.ts @@ -0,0 +1,156 @@ +import { randomUUID } from 'node:crypto'; + +import { + EventType, + type CustomEvent, + type RunErrorEvent, + type RunFinishedEvent, + type RunStartedEvent, + type StateSnapshotEvent, +} from '@ag-ui/core'; + +import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; +import type { Anchor } from '@/lib/pathway/events'; +import { streamPathway } from '@/lib/pathway/generate'; +import type { PathwayPlan } from '@/lib/pathway/schema'; + +export const maxDuration = 120; + +/** + * The pathway build as an AG-UI run — the standards-conformant transport + * beside the app's own NDJSON at `/api/pathway`. + * + * Same pipeline, different envelope: + * + * - the run brackets as `RUN_STARTED` / `RUN_FINISHED` (or `RUN_ERROR`); + * - every event the pipeline yields crosses as a `CUSTOM` event named + * `a2learn.` with the untouched event as its value — domain + * semantics belong to the a2learn layer, not to the transport; + * - one `STATE_SNAPSHOT` lands before the finish with the assembled + * result, for clients that want the artifact without replaying events. + * + * Two honest scope limits, phase-2 work rather than accidents: this + * transport takes topic + gradeHint only (no student profile, teacher note, + * or lesson-plan context yet), and it does not persist a session — no share + * link comes back. Both converge on the shared `runPathway` collector when + * the build_pathway branch lands, so the two transports stop diverging. + * + * Event shapes are compile-checked against `@ag-ui/core`, so drifting from + * the protocol fails the build rather than a partner's integration. + */ + +function encode(event: unknown): string { + return `${JSON.stringify(event)}\n`; +} + +/** + * Even a rejected request speaks legal AG-UI: standard client middleware + * asserts the first event is RUN_STARTED, so a bare RUN_ERROR would surface + * as a protocol violation in the partner's SDK instead of a message. + */ +function refusalStream(message: string): string { + const runId = randomUUID(); + const started: RunStartedEvent = { type: EventType.RUN_STARTED, threadId: 'a2learn-pathway', runId }; + const error: RunErrorEvent = { type: EventType.RUN_ERROR, message }; + return encode(started) + encode(error); +} + +export async function POST(request: Request) { + let body: { topic?: unknown; gradeHint?: unknown; threadId?: unknown }; + try { + body = await request.json(); + } catch { + return new Response(refusalStream('Expected a JSON body.'), { + status: 400, + headers: { 'Content-Type': 'application/x-ndjson' }, + }); + } + + const topic = typeof body.topic === 'string' ? body.topic.trim() : ''; + if (!topic) { + return new Response(refusalStream('A topic is required.'), { + status: 400, + headers: { 'Content-Type': 'application/x-ndjson' }, + }); + } + + const gradeHint = typeof body.gradeHint === 'string' && body.gradeHint.trim() ? body.gradeHint.trim() : undefined; + const threadId = typeof body.threadId === 'string' && body.threadId ? body.threadId : 'a2learn-pathway'; + const runId = randomUUID(); + const encoder = new TextEncoder(); + + // A cancelled consumer (closed tab, aborted fetch) must produce a quiet + // teardown, not enqueue-after-close throws cascading through catch and + // finally. Generation itself still runs to completion — threading an + // AbortSignal through streamPathway is a repo-wide gap shared with the + // NDJSON route, tracked separately. + let closed = false; + + const stream = new ReadableStream({ + async start(controller) { + const emit = (event: object) => { + if (closed) return; + controller.enqueue(encoder.encode(encode(event))); + }; + + const started: RunStartedEvent = { type: EventType.RUN_STARTED, threadId, runId }; + emit(started); + + try { + let anchor: Anchor | null = null; + let plan: PathwayPlan | null = null; + const stepWidgets: Record = {}; + const rejected: string[] = []; + + // No in-band error branch: the generator throws on failure (it never + // yields an error event), and the catch below turns that into + // RUN_ERROR. If it ever gains recoverable in-band errors, they cross + // like any other event. + for await (const event of streamPathway(topic, gradeHint, null)) { + if (event.type === 'anchor') anchor = event.anchor; + if (event.type === 'plan') plan = event.plan; + if (event.type === 'step-widget') stepWidgets[event.stepIndex] = event.widget; + if (event.type === 'verdict' && !event.resolved) rejected.push(event.code); + + const custom: CustomEvent = { + type: EventType.CUSTOM, + name: `${A2LEARN_EVENT_PREFIX}${event.type}`, + value: event, + }; + emit(custom); + } + + const snapshot: StateSnapshotEvent = { + type: EventType.STATE_SNAPSHOT, + snapshot: { anchor, plan, stepWidgets, rejectedCodes: rejected }, + }; + emit(snapshot); + + const finished: RunFinishedEvent = { type: EventType.RUN_FINISHED, threadId, runId }; + emit(finished); + } catch (error) { + const failed: RunErrorEvent = { + type: EventType.RUN_ERROR, + message: error instanceof Error ? error.message : 'Pathway generation failed.', + }; + emit(failed); + } finally { + if (!closed) { + closed = true; + controller.close(); + } + } + }, + cancel() { + closed = true; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache, no-transform', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/src/app/api/widget/route.ts b/src/app/api/widget/route.ts index 85d5e10d..8786ecc5 100644 --- a/src/app/api/widget/route.ts +++ b/src/app/api/widget/route.ts @@ -1,3 +1,6 @@ +import { randomUUID } from 'node:crypto'; + +import { toA2UISurface, A2UI_SUPPORTED_KINDS } from '@/lib/a2learn/a2ui'; import { buildWidget, WidgetBuildError } from '@/lib/widgets/build'; export const maxDuration = 60; @@ -21,7 +24,7 @@ export async function OPTIONS() { const json = (body: unknown, status = 200) => Response.json(body, { status, headers: CORS }); export async function POST(request: Request) { - let body: { standardCode?: unknown; kind?: unknown; jurisdiction?: unknown }; + let body: { standardCode?: unknown; kind?: unknown; jurisdiction?: unknown; format?: unknown }; try { body = await request.json(); @@ -29,12 +32,38 @@ export async function POST(request: Request) { return json({ error: 'Expected a JSON body.' }, 400); } + // `format: "a2ui"` asks for the built widget's A2UI surface alongside the + // spec. Refuse an unmappable kind before spending a model call on it. + const wantsA2UI = body.format === 'a2ui'; + if (wantsA2UI && body.kind && !A2UI_SUPPORTED_KINDS.includes(String(body.kind) as never)) { + return json( + { + error: `Kind "${String(body.kind)}" has no A2UI mapping yet. Mapped kinds: ${A2UI_SUPPORTED_KINDS.join(', ')}.`, + }, + 422, + ); + } + try { const built = await buildWidget({ standardCode: String(body.standardCode ?? ''), kind: String(body.kind ?? ''), jurisdiction: typeof body.jurisdiction === 'string' ? body.jurisdiction : undefined, }); + if (wantsA2UI) { + const surface = toA2UISurface(built.widget, `a2learn-${randomUUID()}`); + if (!surface) { + // The build chose an unmapped kind (possible when `kind` was omitted). + return json( + { + ...built, + a2ui: null, + a2uiNote: `Kind "${built.widget.kind}" has no A2UI mapping yet. Mapped kinds: ${A2UI_SUPPORTED_KINDS.join(', ')}.`, + }, + ); + } + return json({ ...built, a2ui: surface }); + } return json(built); } catch (error) { if (error instanceof WidgetBuildError) return json({ error: error.message }, error.status); diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts new file mode 100644 index 00000000..72f298f1 --- /dev/null +++ b/src/lib/a2learn/a2ui.ts @@ -0,0 +1,234 @@ +import type { FlashcardSpec, MarkdownCardSpec, StepRevealSpec, WidgetKind } from '@/lib/pathway/schema'; + +import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; + +/** + * The boundary mapper: a widget spec, expressed as a Google A2UI v1.0 + * `createSurface` message against the standard `basic` catalog. + * + * This is where the industry-standard claim becomes checkable: every surface + * this module emits validates against the vendored upstream schemas in + * `spec/a2ui/v1_0/` (`pnpm conformance`, enforced in CI). The app's own wire + * format stays the flat authoring schema; A2UI is produced here, at the + * boundary, per the a2learn format's alignment rule. + * + * Coverage is deliberately incremental and honestly labeled: a kind is + * supported when it has an entry in `MAPPERS` below — `A2UI_SUPPORTED_KINDS` + * is derived from that map, so the list and the dispatch cannot drift, and + * the conformance suite requires a fixture per supported kind. The mapped + * kinds are the reading shapes. Known fidelity limits, stated rather than + * papered over: flashcard's flip becomes front/back Tabs — the reveal + * survives as renderer-local tab switching, the animation does not — and + * the catalog's `Text` scopes itself to simple Markdown without HTML, + * links, or images, so a body leaning on those loses them in any + * spec-faithful renderer. + * Full mechanics for every kind is the a2learn catalog's job (#98), not + * this projection's. The durable home for this mapping is an optional field on the + * widget registry entry — tracked with the registry-owned-semantics + * refactor — so adding a kind stays one file. + */ + +/** The standard catalog every surface here targets. */ +export const A2UI_BASIC_CATALOG = 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json'; + +export type A2UIComponent = { id: string; component: string } & Record; + +export type A2UISurfaceMessage = { + version: 'v1.0'; + createSurface: { + surfaceId: string; + catalogId: string; + components: A2UIComponent[]; + dataModel?: Record; + }; +}; + +/** + * The completion action a surface's done button dispatches. None of the + * mapped kinds assess (`assesses: false` on every one's registry entry), so the context + * deliberately carries no `correct` claim — completion is not mastery, and + * an A2UI consumer must not be told otherwise. When an assessing kind joins + * this file, its action gains a verdict from real checking, gated on the + * registry's `assesses` flag — never asserted by the mapper. + */ +function completedAction(kind: WidgetKind) { + // Deliberately the intersection of released and in-flight spec revisions: + // `name` + `context` only. The released schemas close the event object + // (additionalProperties: false), so anything extra — the newer spec's + // userMessage, for instance — breaks every shipped renderer. Prose for a + // model reader belongs to the MCP Apps channel, not this boundary. + return { + event: { + name: `${A2LEARN_EVENT_PREFIX}widget_completed`, + context: { kind }, + }, + }; +} + +/** The shared envelope, so a catalog or version change lands in one place. */ +function surface(surfaceId: string, kind: WidgetKind, components: A2UIComponent[]): A2UISurfaceMessage { + return { + version: 'v1.0', + createSurface: { + surfaceId, + catalogId: A2UI_BASIC_CATALOG, + components, + dataModel: { a2learn: { kind } }, + }, + }; +} + +function doneButton(kind: WidgetKind, label: string): A2UIComponent[] { + return [ + { id: 'done', component: 'Button', variant: 'primary', child: 'done-label', action: completedAction(kind) }, + { id: 'done-label', component: 'Text', text: label }, + ]; +} + +/** + * One flashcard side as components: text and/or markdown as `Text`, an image + * as `Image` with its alt text — an image-only side is a legal spec per the + * schema and must not degrade to a placeholder dash. Content is emitted + * verbatim in separate components, never joined or wrapped in emphasis + * markers (bold cannot span the blank line a join introduces). + */ +function sideComponents( + prefix: string, + side: FlashcardSpec['cards'][number]['front'], +): { ids: string[]; components: A2UIComponent[] } { + const components: A2UIComponent[] = []; + if (side.text) components.push({ id: `${prefix}-text`, component: 'Text', text: side.text }); + if (side.markdown) components.push({ id: `${prefix}-md`, component: 'Text', text: side.markdown }); + if (side.imageUrl) { + components.push({ + id: `${prefix}-image`, + component: 'Image', + url: side.imageUrl, + description: side.imageAlt ?? '', + fit: 'contain', + }); + } + if (components.length === 0) { + components.push({ id: `${prefix}-empty`, component: 'Text', text: '—', variant: 'caption' }); + } + return { ids: components.map((c) => c.id), components }; +} + +function markdownCardSurface(spec: MarkdownCardSpec, surfaceId: string): A2UISurfaceMessage { + const components: A2UIComponent[] = [ + { id: 'root', component: 'Card', child: 'layout' }, + { + id: 'layout', + component: 'Column', + children: ['title', 'body', ...(spec.tip ? ['tip'] : []), 'done'], + }, + // Title verbatim — no added emphasis markers, which break when the + // model's own text already contains Markdown syntax. + { id: 'title', component: 'Text', text: spec.title }, + { id: 'body', component: 'Text', text: spec.body }, + ...(spec.tip + ? [{ id: 'tip', component: 'Text', text: `Tip: ${spec.tip}`, variant: 'caption' } as A2UIComponent] + : []), + ...doneButton(spec.kind, 'Got it'), + ]; + + return surface(surfaceId, spec.kind, components); +} + +function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMessage { + const cardComponents: A2UIComponent[] = []; + const cardIds: string[] = []; + + spec.cards.forEach((card, i) => { + const front = sideComponents(`card-${i}-front`, card.front); + const back = sideComponents(`card-${i}-back`, card.back); + cardIds.push(`card-${i}`); + // Tabs, not a flat stack: the basic catalog cannot express a flip + // animation, but tab switching is renderer-local, so front/back stays a + // deliberate reveal instead of both sides lying in the open. + cardComponents.push( + { id: `card-${i}`, component: 'Card', child: `card-${i}-tabs` }, + { + id: `card-${i}-tabs`, + component: 'Tabs', + tabs: [ + { title: 'Front', child: `card-${i}-front-col` }, + { title: 'Back', child: `card-${i}-back-col` }, + ], + }, + { id: `card-${i}-front-col`, component: 'Column', children: front.ids }, + { id: `card-${i}-back-col`, component: 'Column', children: back.ids }, + ...front.components, + ...back.components, + ); + }); + + const components: A2UIComponent[] = [ + { id: 'root', component: 'Column', children: ['prompt', 'deck', 'done', 'success'] }, + { id: 'prompt', component: 'Text', text: spec.prompt }, + // A horizontal List is the catalog's word for a deck: renderers page or + // scroll it, so cards present one at a time instead of as a wall. + { id: 'deck', component: 'List', direction: 'horizontal', children: cardIds }, + ...cardComponents, + ...doneButton(spec.kind, 'Done'), + // The author wrote this for the end of the deck; a static surface shows + // it as a caption rather than dropping it. + { id: 'success', component: 'Text', text: `When you're done: ${spec.successMessage}`, variant: 'caption' }, + ]; + + return surface(surfaceId, spec.kind, components); +} + +function stepRevealSurface(spec: StepRevealSpec, surfaceId: string): A2UISurfaceMessage { + // A walk-through is a sequence, and the catalog's word for a sequence is a + // horizontal List — one Card per step, paged by the renderer. What doesn't + // survive projection is the gating: a deck pages freely in both directions, + // where the native widget enforces one-at-a-time disclosure. + const stepIds = spec.steps.map((_, i) => `step-${i}`); + const stepComponents: A2UIComponent[] = spec.steps.flatMap((step, i) => { + const children = [`step-${i}-title`, `step-${i}-divider`, `step-${i}-body`]; + const parts: A2UIComponent[] = [ + { id: `step-${i}`, component: 'Card', child: `step-${i}-col` }, + // Title verbatim — same rule as markdown-card: no added emphasis markers. + { id: `step-${i}-title`, component: 'Text', text: step.title }, + { id: `step-${i}-divider`, component: 'Divider' }, + { id: `step-${i}-body`, component: 'Text', text: step.body }, + ]; + if (step.why) { + children.push(`step-${i}-why`); + parts.push({ id: `step-${i}-why`, component: 'Text', text: `Why: ${step.why}`, variant: 'caption' }); + } + parts.push({ id: `step-${i}-col`, component: 'Column', children }); + return parts; + }); + + const components: A2UIComponent[] = [ + { id: 'root', component: 'Column', children: ['prompt', 'steps', 'done'] }, + { id: 'prompt', component: 'Text', text: spec.prompt }, + { id: 'steps', component: 'List', direction: 'horizontal', children: stepIds }, + ...stepComponents, + ...doneButton(spec.kind, 'Done'), + ]; + + return surface(surfaceId, spec.kind, components); +} + +/** + * The dispatch table IS the support list — `A2UI_SUPPORTED_KINDS` derives + * from it, so claiming a kind without mapping it (or vice versa) is + * impossible by construction. + */ +const MAPPERS: Partial A2UISurfaceMessage>> = { + 'markdown-card': markdownCardSurface, + flashcard: flashcardSurface, + 'step-reveal': stepRevealSurface, +}; + +export const A2UI_SUPPORTED_KINDS = Object.keys(MAPPERS) as WidgetKind[]; + +/** Null for kinds not yet mapped — callers state the gap rather than guess. */ +export function toA2UISurface(spec: unknown, surfaceId: string): A2UISurfaceMessage | null { + const kind = (spec as { kind?: string })?.kind as WidgetKind | undefined; + const mapper = kind ? MAPPERS[kind] : undefined; + return mapper ? mapper(spec as never, surfaceId) : null; +} diff --git a/src/lib/a2learn/manifest.ts b/src/lib/a2learn/manifest.ts new file mode 100644 index 00000000..4b6527f2 --- /dev/null +++ b/src/lib/a2learn/manifest.ts @@ -0,0 +1,90 @@ +import type { WidgetKind } from '@/lib/pathway/schema'; +import type { StandardRef } from '@/lib/standards/types'; + +/** + * The a2learn layer, as code rather than only prose. + * + * An activity travels as two documents: a *surface* (how it renders — see + * `a2ui.ts`, a profile of Google's A2UI) and this *manifest* (what it + * teaches, whether it measures, where it came from, and exactly how to + * build it). The manifest is the half an agent reads before deciding, and + * the half a marketplace listing is made of. + * + * Format-versioned independently of the app: consumers pin `a2learn`, not + * our release number. + */ + +export const A2LEARN_VERSION = '0.1.0'; + +/** Namespace for a2learn event names on any transport (AG-UI custom events, + * A2UI actions): `a2learn.`. Owned here, not in a route. */ +export const A2LEARN_EVENT_PREFIX = 'a2learn.'; + +export type ActivityManifest = { + /** Manifest format version — semver, bumped by RFC, not by refactor. */ + a2learn: string; + title: string; + /** The verified standard this activity teaches — or the honest absence. */ + standard: { + code: string; + verified: boolean; + /** Which graph verified it, e.g. "Learning Commons". */ + source: string; + description?: string; + }; + pedagogy: { + /** Widget kind from the registry. */ + kind: WidgetKind; + /** Whether completing this activity records a real verdict. */ + assesses: boolean; + }; + /** BCP-47. Generation is English-first today; the field exists so the + format never has to break to say otherwise. */ + language: string; + provenance: { + generatedAt: string; + /** Codes proposed and rejected by the graph — kept on the record. */ + rejectedCodes: string[]; + }; + /** The exact call that manufactures this activity — find → invoke. */ + invoke: { + tool: 'show_widget'; + arguments: Record; + }; +}; + +/** + * No producer calls this yet — it gains one in phase 2, when find_activity's + * listing shape converges here. Until then the conformance suite pins the + * A2UI half; this half is pinned by tsc only. `assesses` must equal the + * registry entry's flag for `kind` — the caller that owns the registry + * lookup passes it, so this module stays importable without dragging every + * widget definition into its bundle. + */ +export function buildManifest(input: { + title: string; + standard: StandardRef; + kind: WidgetKind; + assesses: boolean; + language?: string; + rejectedCodes?: string[]; + invokeArguments: Record; +}): ActivityManifest { + return { + a2learn: A2LEARN_VERSION, + title: input.title, + standard: { + code: input.standard.code, + verified: input.standard.verified, + source: input.standard.sourceLabel, + description: input.standard.description, + }, + pedagogy: { kind: input.kind, assesses: input.assesses }, + language: input.language ?? 'en', + provenance: { + generatedAt: new Date().toISOString(), + rejectedCodes: input.rejectedCodes ?? [], + }, + invoke: { tool: 'show_widget', arguments: input.invokeArguments }, + }; +}