diff --git a/.changeset/attachables-core-remove-dead-onaction.md b/.changeset/attachables-core-remove-dead-onaction.md new file mode 100644 index 0000000..e712039 --- /dev/null +++ b/.changeset/attachables-core-remove-dead-onaction.md @@ -0,0 +1,8 @@ +--- +"@mobile-reality/mdma-attachables-core": patch +--- + +Remove the unused `onAction` methods from the core attachable handlers (form, button, tasklist, +table, callout, approval-gate, webhook). They were never invoked — renderers dispatch store actions +directly — so this is a dead-code cleanup with no behavioral change. Each handler's `definition` and +`initialize` are unchanged. diff --git a/.changeset/config.json b/.changeset/config.json index fce1c26..05c00c1 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,8 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/document-store-initial-state.md b/.changeset/document-store-initial-state.md new file mode 100644 index 0000000..3af6f7c --- /dev/null +++ b/.changeset/document-store-initial-state.md @@ -0,0 +1,12 @@ +--- +"@mobile-reality/mdma-runtime": minor +"@mobile-reality/mdma-agui": minor +--- + +Add an `initialState` option to `createDocumentStore` for hydrating component values at store +creation — e.g. restoring a persisted conversation fetched from a backend. Keyed by component id → +its `values` map (symmetric with `getState()`), it overlays AST defaults **without emitting audit +events or marking fields `touched`**, and applies only to freshly-created components so a streaming +re-parse never clobbers in-flight edits. `mdma-agui` threads `initialState` through `parseMdma`, +the bridge, and `MdmaAgentView`/`useMdmaAgentStream`, so re-opened conversations render +pre-populated. diff --git a/.changeset/mdma-agui-initial.md b/.changeset/mdma-agui-initial.md new file mode 100644 index 0000000..b6968c2 --- /dev/null +++ b/.changeset/mdma-agui-initial.md @@ -0,0 +1,9 @@ +--- +"@mobile-reality/mdma-agui": minor +--- + +Add `@mobile-reality/mdma-agui`: a bridge that renders MDMA interactive documents streamed over +the AG-UI protocol and routes user actions (submit / approve / deny) back into the agent run. +Ships a headless core (`createMdmaAgentBridge`) plus an optional React layer +(`useMdmaAgentStream`, `MdmaAgentView`). AG-UI coupling is isolated to a minimal structural agent +interface, so any `@ag-ui/client` `HttpAgent` works without a hard dependency. diff --git a/.changeset/parser-streaming-unknown-type.md b/.changeset/parser-streaming-unknown-type.md new file mode 100644 index 0000000..2309f24 --- /dev/null +++ b/.changeset/parser-streaming-unknown-type.md @@ -0,0 +1,11 @@ +--- +"@mobile-reality/mdma-parser": patch +"@mobile-reality/mdma-agui": patch +--- + +Stop flashing "Unknown component type" while a block is still streaming. When an `mdma` fence is +not yet closed, a valid-YAML-but-unknown type (e.g. a half-streamed `approval-gat` before +`approval-gate` finishes) is now left as a pending block (loading skeleton) instead of being +rendered as an unknown-type error. Once the fence closes, a genuinely unknown type still surfaces +the error as before. Known valid types continue to render live during streaming. The `mdma-agui` +adapter now threads the source into `unified.run()` so the parser can see the raw fences. diff --git a/.changeset/tasklist-webhook-routable-events.md b/.changeset/tasklist-webhook-routable-events.md new file mode 100644 index 0000000..1bb4ba4 --- /dev/null +++ b/.changeset/tasklist-webhook-routable-events.md @@ -0,0 +1,10 @@ +--- +"@mobile-reality/mdma-renderer-react": minor +"@mobile-reality/mdma-agui": minor +--- + +Support tasklist completion and webhook triggers as routable events. The tasklist renderer now +emits `ACTION_TRIGGERED` (its `onComplete` action) on the transition into all-items-checked, and +the webhook renderer gains a trigger button that emits `INTEGRATION_CALLED`. The `mdma-agui` +bridge routes both back into the agent run — alongside form submit, button, and approve/deny — so +completing a checklist or firing a webhook resumes the AG-UI conversation. diff --git a/.changeset/updateast-retype-on-type-change.md b/.changeset/updateast-retype-on-type-change.md new file mode 100644 index 0000000..0bd1bb9 --- /dev/null +++ b/.changeset/updateast-retype-on-type-change.md @@ -0,0 +1,9 @@ +--- +"@mobile-reality/mdma-runtime": patch +--- + +Fix `DocumentStore.updateAst` freezing a component's `type` for the lifetime of its id. During +streaming, an early partial parse can produce a placeholder/truncated type (e.g. `approval-gat` +before `approval-gate` finishes streaming); `updateAst` now re-initializes a component when its +type changes between parses, while still preserving in-flight state (values, touched) when the +type is unchanged. diff --git a/README.md b/README.md index a0d8b09..9d82c1d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ fields: type: textarea label: "Reason for Visit" required: true +onSubmit: submit-intake ``` ```mdma @@ -189,7 +190,7 @@ Each cell shows the pass rate of the model-specialized MDMA_FIXER prompt variant | Component | Type key | Description | |-----------|----------|-------------| -| **Form** | `form` | Multi-field forms with text, email, number, select, textarea, checkbox, datetime, and file fields. Supports validation, required fields, default values, and sensitive (PII) flags. | +| **Form** | `form` | Multi-field forms with text, number, email, date, select, checkbox, textarea, and file fields. Supports validation, required fields, default values, and sensitive (PII) flags. | | **Button** | `button` | Action buttons with `primary`, `secondary`, and `danger` variants. | | **Tasklist** | `tasklist` | Interactive checkbox task items with labels. | | **Table** | `table` | Data tables with typed columns and row data. | @@ -321,6 +322,7 @@ fields: - name: actual type: textarea label: "Actual Behavior" +onSubmit: submit-bug-report \`\`\``, }); @@ -367,7 +369,6 @@ function App({ ast, store }) { | `@mobile-reality/mdma-validator` | Static analysis engine with 17 lint rules covering YAML correctness, schema conformance, ID uniqueness, binding syntax, action references, PII sensitivity, expected component verification, and flow ordering. Includes 6 auto-fix strategies and fuzzy type/ID suggestions. Powers programmatic validation in CI pipelines and custom tooling. | | `@mobile-reality/mdma-cli` | Interactive CLI tool for creating custom MDMA prompts. Opens a local web app where you visually select components, configure fields, set domain rules and trigger conditions, then an LLM generates a tailored `customPrompt` for use with `buildSystemPrompt()`. Also includes a `validate` command for static document analysis. | | `@mobile-reality/mdma-mcp` | MCP (Model Context Protocol) server that exposes MDMA spec, prompts, and tooling to AI assistants. Tools: `get-spec`, `get-prompt` (with optional `variantId` for model-optimised prompts), `list-prompt-variants`, `build-system-prompt`, `validate-prompt`, `list-packages`. Works with Claude Desktop, VS Code, Cursor, and any MCP-compatible client. | -| `@mobile-reality/mdma-evals` | LLM evaluation suite built on promptfoo with 4 test suites: base generation quality (25 tests), custom prompt compliance (10 tests), multi-turn conversation handling (11 conversations, 25 turns), and prompt builder verification (25 tests). Validates that AI-generated MDMA documents are structurally correct and semantically appropriate. | ## Architecture @@ -381,7 +382,6 @@ function App({ ast, store }) { └── @mobile-reality/mdma-renderer-react React components @mobile-reality/mdma-cli CLI prompt builder + validation @mobile-reality/mdma-mcp MCP server for AI assistants -@mobile-reality/mdma-evals LLM evaluation suite (promptfoo) ``` ## Getting Started @@ -453,7 +453,7 @@ const result = validate(markdown, { | `duplicate-ids` | error | yes | All component IDs are unique. Auto-fix appends `-1`, `-2` suffixes. | | `id-format` | warning | yes | IDs follow kebab-case (`my-component-id`). Auto-fix converts camelCase, snake_case, PascalCase and updates all references. | | `binding-syntax` | error/warning | yes | `{{binding}}` expressions are well-formed. Catches empty `{{ }}`, extra whitespace `{{ path }}`, and single-brace `{path}`. | -| `action-references` | warning | yes | `onSubmit`, `onAction`, `onComplete`, `onApprove`, `onDeny`, `trigger` reference existing component IDs. Suggests near-matches for typos. | +| `form-submit-action` | error | -- | Every `type: form` component declares a non-empty `onSubmit` action. | | `sensitive-flags` | warning | yes | Form fields and table columns with PII-like names (email, phone, ssn, address, etc.) have `sensitive: true`. Supports custom PII patterns. | | `required-markers` | info | -- | Suggests `required: true` for fields named `name`, `email`, `title`, `summary`. | | `thinking-block` | warning/info | -- | If a thinking block is present, it should be the first component and only one should exist. | @@ -461,18 +461,19 @@ const result = validate(markdown, { | `select-options` | warning | -- | `type: select` fields have `options` defined as `[{label, value}]` objects. | | `chart-validation` | warning | -- | Chart CSV data has headers + data rows. `xAxis`/`yAxis` reference actual CSV column headers. | | `placeholder-content` | info | -- | Catches `TODO`, `TBD`, `FIXME`, `...`, `lorem ipsum` in content fields. | -| `flow-ordering` | error/info | -- | Forward-only action references, no circular refs, one interactive component type per message. Detects regenerated components from prior conversation turns. | +| `flow-ordering` | warning | -- | Forward-only action references (targets defined later in the document), no circular refs, and multi-step flows flagged to be split across messages. | +| `single-interactive-component` | warning | -- | At most one interactive component (form, button, webhook, approval-gate, tasklist) per message. | | `expected-components` | error | -- | Verifies that components present in the message match their expected types, form fields, and table columns. Components not in the message are silently skipped — useful for multi-turn flows where you pass all expected components upfront. | ### Auto-fix Pipeline When `autoFix: true` (default), 6 fix strategies run in strict dependency order: -1. **id-format** — normalize IDs to kebab-case, update all cross-references -2. **duplicate-ids** — deduplicate after normalization -3. **binding-syntax** — fix `{x}` -> `{{x}}`, strip whitespace -4. **sensitive-flags** — add `sensitive: true` to PII fields -5. **action-references** — remove invalid references +1. **thinking-block** — merge stray thinking blocks into one and move it to the top +2. **id-format** — normalize IDs to kebab-case, update all cross-references +3. **duplicate-ids** — deduplicate after normalization +4. **binding-syntax** — fix `{x}` -> `{{x}}`, strip whitespace +5. **sensitive-flags** — add `sensitive: true` to PII fields 6. **schema-conformance** — patch missing labels/headers/content, infer field types, wrap bare bindings, re-validate with Zod ### Expected Components diff --git a/demo/src/docs/DocsView.tsx b/demo/src/docs/DocsView.tsx index 91d6352..b1c5118 100644 --- a/demo/src/docs/DocsView.tsx +++ b/demo/src/docs/DocsView.tsx @@ -10,11 +10,13 @@ import { Packages } from './sections/Packages.js'; import { PromptMatrix } from './sections/PromptMatrix.js'; import { Integrations, INTEGRATIONS } from './sections/Integrations.js'; import { IntegrationLangchain } from './sections/IntegrationLangchain.js'; -import { Usage } from './sections/Usage.js'; +import { IntegrationAgui } from './sections/IntegrationAgui.js'; +import { Usage, UsageHydrationPreview } from './sections/Usage.js'; import { Validator } from './sections/Validator.js'; const INTEGRATION_COMPONENTS: Record = { langchain: IntegrationLangchain, + 'ag-ui': IntegrationAgui, }; interface Section { @@ -54,6 +56,7 @@ function navigateDocs(slug: string) { export function DocsView() { const [active, setActiveState] = useState(getDocsSlug); const [selectedComponent, setSelectedComponent] = useState('form'); + const [usageExampleOpen, setUsageExampleOpen] = useState(false); useEffect(() => { function sync() { @@ -68,7 +71,9 @@ export function DocsView() { setActiveState(slug); } - const showPreview = active === 'components'; + const showComponentsPreview = active === 'components'; + const showUsagePreview = active === 'usage' && usageExampleOpen; + const showPreview = showComponentsPreview || showUsagePreview; const previewEntry = COMPONENTS.find((c) => c.type === selectedComponent) ?? COMPONENTS[0]; const isPackagesActive = active === 'packages' || active.startsWith('packages/'); @@ -91,8 +96,15 @@ export function DocsView() { const SectionContent = section?.component ?? null; function renderContent() { - if (showPreview) + if (showComponentsPreview) return ; + if (active === 'usage') + return ( + setUsageExampleOpen((v) => !v)} + /> + ); if (activePackage) return ; if (active === 'packages') return ; if (ActiveIntegration) return ; @@ -157,7 +169,11 @@ export function DocsView() { {showPreview && ( )} diff --git a/demo/src/docs/sections/IntegrationAgui.tsx b/demo/src/docs/sections/IntegrationAgui.tsx new file mode 100644 index 0000000..138f1de --- /dev/null +++ b/demo/src/docs/sections/IntegrationAgui.tsx @@ -0,0 +1,94 @@ +import { Code } from '../Code.js'; + +export function IntegrationAgui() { + return ( + <> +

AG-UI Protocol

+

+ Stream MDMA documents from an{' '} + + AG-UI + {' '} + agent and route the user's decisions back into the run. AG-UI is the{' '} + transport (suspend/resume via its interrupt primitive); MDMA is + the payload (validated, audited, PII-aware components).{' '} + @mobile-reality/mdma-agui is the seam between them — a community-maintained + adapter, not a framework integration. +

+ +

Install

+ + { + 'npm install @mobile-reality/mdma-agui @ag-ui/client @ag-ui/core @mobile-reality/mdma-parser @mobile-reality/mdma-runtime @mobile-reality/mdma-spec @mobile-reality/mdma-attachables-core\n# React layer only:\nnpm install @mobile-reality/mdma-renderer-react react' + } + +

+ All AG-UI, MDMA, and React packages are peer dependencies — you bring the + versions your app already uses. @mobile-reality/mdma-renderer-react and{' '} + react are optional (the headless core works without them). +

+ +

React usage

+ {`import { HttpAgent } from '@ag-ui/client'; +import { MdmaAgentView } from '@mobile-reality/mdma-agui/react'; +import '@mobile-reality/mdma-renderer-react/styles.css'; + +const agent = new HttpAgent({ url: '/api/agent' }); + +// Renders every MDMA document the agent streams; +// form submits and approvals resume the run automatically. +export function Chat() { + return ; +}`} + +

For finer control, use the hook:

+ {`import { useMdmaAgentStream } from '@mobile-reality/mdma-agui/react'; +import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; + +function Chat({ agent }) { + const { documents } = useMdmaAgentStream(agent, { + // Return false to resume the run yourself (e.g. resolve an AG-UI interrupt). + onAction: async (action, message) => { + console.log('user decided', action.type, 'in', message.messageId); + }, + }); + return documents.map((d) => ); +}`} + +

Headless usage

+

No React required — subscribe and drive rendering yourself:

+ {`import { createMdmaAgentBridge } from '@mobile-reality/mdma-agui'; + +const bridge = createMdmaAgentBridge(agent, { + onDocument: (message) => renderSomewhere(message.ast, message.store), +}); + +// later +bridge.dispose();`} + +

How it works

+

+ Stream → render. On each streamed content event the bridge reads the + accumulated buffer, gates on a cheap mdma-fence check, throttles re-parsing + (~150 ms), and feeds the AST into a document store. The store is created{' '} + once per message and updated in place with store.updateAst(), + so in-flight form edits and focus survive streaming. +

+

+ Action → resume. The bridge listens for the decision events —{' '} + ACTION_TRIGGERED (button, form submit, tasklist completion),{' '} + APPROVAL_GRANTED / APPROVAL_DENIED (approval-gate), and{' '} + INTEGRATION_CALLED (webhook trigger). By default it packages the decision as a + user turn and calls agent.addMessage() + agent.runAgent(). Return{' '} + false from onAction to take over — e.g. resolve AG-UI's native + interrupt so the parked run resumes with state intact. +

+

+ A tasklist resumes the run only on the transition into all items checked (its{' '} + onComplete action), not on every toggle — individual FIELD_CHANGED{' '} + edits are ignored, the same way in-progress form typing is. A webhook routes its trigger and + request shape (real HTTP execution is handled by your agent or the webhook engine). +

+ + ); +} diff --git a/demo/src/docs/sections/Integrations.tsx b/demo/src/docs/sections/Integrations.tsx index e09e49d..fbc785c 100644 --- a/demo/src/docs/sections/Integrations.tsx +++ b/demo/src/docs/sections/Integrations.tsx @@ -8,6 +8,12 @@ const INTEGRATIONS = [ label: 'LangChain.js', description: 'Use MDMA inside a LangChain chain or agent — backend Node.js service.', }, + { + slug: 'ag-ui', + label: 'AG-UI', + description: + 'Stream MDMA over the AG-UI protocol and resume the agent run on user actions — human-in-the-loop.', + }, ]; export function Integrations({ onNavigate }: IntegrationsProps) { diff --git a/demo/src/docs/sections/Usage.tsx b/demo/src/docs/sections/Usage.tsx index ece777f..4da6181 100644 --- a/demo/src/docs/sections/Usage.tsx +++ b/demo/src/docs/sections/Usage.tsx @@ -1,6 +1,18 @@ +import { useEffect, useRef, useState } from 'react'; +import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; +import { createDocumentStore, type DocumentStore } from '@mobile-reality/mdma-runtime'; +import type { MdmaRoot } from '@mobile-reality/mdma-spec'; +import { parseMarkdown } from '../../chat/parse-markdown.js'; import { Code } from '../Code.js'; -export function Usage() { +export interface UsageProps { + /** Whether the right-side hydration preview is open (owned by DocsView). */ + exampleOpen?: boolean; + /** Toggle the hydration preview panel. */ + onToggleExample?: () => void; +} + +export function Usage({ exampleOpen, onToggleExample }: UsageProps = {}) { return ( <>

Basic Usage

@@ -34,6 +46,51 @@ store.dispatch({ value: 'Jane Doe', });`} +

Restoring State

+

+ When you reload a past conversation, the MDMA documents re-parse from scratch, so their + forms, approvals, and checklists come back empty. To render them pre-populated with what the + user previously entered, snapshot the state to your backend and pass it back in via the{' '} + initialState option — a {'{ [componentId]: values }'} map, + symmetric with getState(). +

+

+ Hydration overlays the AST defaults without emitting audit events or marking fields{' '} + touched, so a restore never looks like fresh user activity in the + tamper-evident log. It applies only to freshly-created components, so a later streamed + re-parse never clobbers an in-flight edit. +

+ {`// 1. Persist — snapshot each component's values on the way out +const snapshot = Object.fromEntries( + [...store.getState().components].map(([id, c]) => [id, c.values]), +); +// → { 'intake-form': { 'patient-name': 'Jane Doe' }, 'approve-1': { status: 'approved' } } +await fetch('/api/conversations/42/state', { + method: 'PUT', + body: JSON.stringify(snapshot), +}); + +// 2. Restore — fetch the snapshot on reload and seed the store +const initialState = await fetch('/api/conversations/42/state').then((r) => r.json()); + +const store = createDocumentStore(ast, { + documentId: 'my-doc', + initialState, // component values are hydrated during store creation +});`} + {onToggleExample && ( + + )} +

+ Using the AG-UI adapter? Pass the same map to <MdmaAgentView initialState={'{…}'} + /> — each replayed message hydrates only the component ids it contains. +

+

In a Chat

{`import { buildSystemPrompt, getAuthorPromptVariant } from '@mobile-reality/mdma-prompt-pack'; @@ -213,3 +270,128 @@ function AgentView() { ); } + +// ─── Restoring-state live preview (rendered in the right-side panel by DocsView) ────────────── + +const HYDRATION_EXAMPLE = `\`\`\`mdma +type: form +id: intake-form +onSubmit: submit-intake +fields: + - name: full-name + type: text + label: "Full Name" + - name: email + type: email + label: "Email" + sensitive: true + - name: reason + type: textarea + label: "Reason for Visit" +\`\`\``; + +/** Snapshot as it would come back from a backend for a re-opened conversation. */ +const HYDRATION_SNAPSHOT = { + 'intake-form': { + 'full-name': 'Jane Doe', + email: 'jane@clinic.example', + reason: 'Annual check-up — mild recurring headaches.', + }, +}; + +type HydrationPhase = 'empty' | 'loading' | 'hydrated'; + +export function UsageHydrationPreview() { + const [ast, setAst] = useState(null); + const [store, setStore] = useState(null); + const [phase, setPhase] = useState('empty'); + const cancelRef = useRef(false); + const timerRef = useRef | null>(null); + + // The conversation re-opens with an EMPTY store — its values live in the backend. + useEffect(() => { + cancelRef.current = false; + parseMarkdown(HYDRATION_EXAMPLE).then((result) => { + if (cancelRef.current) return; + setAst(result.ast); + setStore(result.store); + }); + return () => { + cancelRef.current = true; + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + function fetchState() { + if (!ast || phase === 'loading') return; + setPhase('loading'); + // Simulate the backend round-trip, then rebuild the store hydrated from the snapshot — the + // exact call the docs describe: createDocumentStore(ast, { initialState }). + timerRef.current = setTimeout(() => { + setStore(createDocumentStore(ast, { initialState: HYDRATION_SNAPSHOT })); + setPhase('hydrated'); + }, 2000); + } + + function reset() { + if (!ast) return; + if (timerRef.current) clearTimeout(timerRef.current); + setStore(createDocumentStore(ast)); + setPhase('empty'); + } + + const status: Record = { + empty: 'Form is empty — its values live in your backend.', + loading: 'GET /api/conversations/42/state …', + hydrated: 'Applied via initialState — no forged audit events.', + }; + + return ( + <> +
+ Re-opened conversation + initialState +
+
+
+ + {status[phase]} +
+ +
+
+
AI
+
+

Welcome back! Here's the intake form from our last chat:

+
+ {store && ast ? ( + + ) : ( + Loading… + )} +
+
+
+ +
+
Yep, those are my details — thanks!
+
You
+
+
+
+ + ); +} diff --git a/demo/src/styles.css b/demo/src/styles.css index 1acb0b4..e4181ea 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -5430,6 +5430,125 @@ body { overflow-y: auto; } +.docs-example-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 4px 0 8px; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + color: #3730a3; + background: #e0e7ff; + border: 1px solid #c7d2fe; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease; +} + +.docs-example-toggle:hover { + background: #c7d2fe; +} + +.docs-example-toggle:disabled { + opacity: 0.6; + cursor: default; +} + +.docs-example-toggle--active { + background: #3730a3; + color: #fff; + border-color: #3730a3; +} + +/* ── Restoring-state conversation preview ── */ +.docs-convo-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; + flex-shrink: 0; +} + +.docs-convo-status { + font-size: 12px; + color: #6b7280; +} + +.docs-convo-thread { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.docs-convo-msg { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.docs-convo-msg--user { + justify-content: flex-end; +} + +.docs-convo-avatar { + flex-shrink: 0; + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + background: #3730a3; + color: #fff; +} + +.docs-convo-avatar--user { + background: #e5e7eb; + color: #374151; +} + +.docs-convo-bubble { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 12px 14px; + font-size: 13px; + color: #111827; + min-width: 0; +} + +.docs-convo-msg--user .docs-convo-bubble { + background: #eef2ff; + border-color: #c7d2fe; +} + +.docs-convo-bubble > p { + margin: 0 0 8px; +} + +.docs-convo-form--flash { + animation: docs-convo-flash 1.3s ease; + border-radius: 8px; +} + +@keyframes docs-convo-flash { + 0% { + box-shadow: 0 0 0 2px #a5b4fc; + background: #eef2ff; + } + 100% { + box-shadow: 0 0 0 0 transparent; + background: transparent; + } +} + .docs-preview-panel-header { display: flex; align-items: center; diff --git a/mdma-agui-integration-plan.md b/mdma-agui-integration-plan.md new file mode 100644 index 0000000..c578690 --- /dev/null +++ b/mdma-agui-integration-plan.md @@ -0,0 +1,209 @@ +# MDMA × AG-UI Integration Plan + +**Status:** Draft for validation · **Version:** 0.2 · **License of all artifacts:** MIT + +A proposal to make [MDMA](https://github.com/MobileReality/mdma) a first-class generative-UI +layer for the [AG-UI protocol](https://github.com/ag-ui-protocol/ag-ui), delivered as a small +adapter package plus a docs entry and a dojo demo — *not* as a framework integration. + +This document exists to be **validated before significant work begins**, per AG-UI's +contributing guidance ("please PLEASE reach out to us first"). It states the thesis, the chosen +approach, the rejected alternatives, the concrete deliverables, and an explicit list of +questions the maintainers need to answer. + +> **v0.2 changelog (post-verification).** Corrected against the live AG-UI repo and the MDMA +> codebase: (1) AG-UI has **no ecosystem category literally named "Generative UI"** — it is a +> *building block*; integration listings are tiered "Direct to LLM" / "Agent Framework – +> Partnerships / 1st Party / Community". Placement is therefore an open question, not a settled +> fact. (2) Approve/deny in MDMA emit **`APPROVAL_GRANTED` / `APPROVAL_DENIED`**, not +> `ACTION_TRIGGERED` (only form-submit and buttons emit `ACTION_TRIGGERED`); the adapter listens +> via `onAny` and switches on all of them. (3) No pre-drafted scaffold existed — this revision +> ships one at `packages/agui/`. (4) Onboarding is GitHub-issue-first + Discord `#-💎-contributing`; +> there is no Calendly "new integration" call. + +--- + +## 1. Goal + +Let an AG-UI agent stream MDMA interactive documents (forms, tables, approval gates) to a +frontend, have them render live, and route the user's actions back into the agent run — +closing the human-in-the-loop — with a minimal, community-maintained adapter. + +## 2. Background & thesis + +The two projects operate at different layers and are **complementary, not competing**: + +- **AG-UI is transport.** A wire protocol: a stream of ~16 core event categories (text deltas, + tool calls, state patches, lifecycle, interrupt; the current `EventType` enum is larger, ~30+) + between an agent backend and a frontend, over SSE / WebSockets / HTTP. +- **MDMA is payload.** A content spec: Markdown extended with fenced ` ```mdma ` blocks that + describe validated, renderable components. It has no agent and no transport; it is a thing + an agent *emits*. + +**Thesis:** MDMA is a concrete implementation of **generative UI** (an AG-UI *building block*) +that can ride on top of an AG-UI event stream. It is not a **framework / agent runtime** +(LangGraph, CrewAI, ADK, …) and should not be listed as one. Exactly *where* it belongs in +AG-UI's ecosystem listing is an open question for the maintainers (see §8 Q1) — there is no +single "Generative UI" bucket to drop it into. + +### The human-in-the-loop split (why this matters) + +HITL is two things, and MDMA only owns one: + +1. **The decision surface** — presenting the choice and capturing it as validated, audited, + PII-aware data. MDMA owns this (approval gates, forms, policy engine, audit log). +2. **The control primitive** — suspending the agent run, waiting, and resuming with state + intact. MDMA does *not* own this; AG-UI's **interrupt** building block does + (`agent.pendingInterrupts`, the `@ag-ui/client` `interrupts/` helpers). + +The adapter is the seam between the two: MDMA renders and validates the decision; AG-UI +suspends and resumes the run. Neither half closes the loop alone. + +## 3. Chosen approach + +**Keep the adapter in the MDMA repo as `@mobile-reality/mdma-agui` (flat `packages/agui/`). +Contribute to AG-UI only an ecosystem docs entry plus a dojo demo that references it.** + +Rationale: +- AG-UI policy states community integrations are **maintained by the contributor**. Housing + the code in the MDMA org keeps maintenance where the domain knowledge is. +- MDMA is small and single-vendor; asking the AG-UI core team to adopt a third-party rendering + spec is the most likely thing to stall a PR. +- This still gets MDMA listed in AG-UI's ecosystem (the visibility goal) and gives users a + runnable demo, at the lowest coordination cost. + +### Rejected alternatives (decision log) + +| Option | Why not (for now) | +|---|---| +| First-class `integrations/mdma/` framework entry | MDMA isn't a framework/agent runtime; the integration guide assumes a running agent server to register in the dojo. Wrong shape. | +| Fork `@mobile-reality/mdma-renderer-react` to bake in AG-UI | Couples two release cycles; the skill's own anti-patterns warn against forking the renderer. Adapter stays external. | +| PR the adapter directly into `ag-ui` core | Adds maintenance burden to the AG-UI team for a spec they don't own; higher bar, slower merge. | +| Do nothing in AG-UI, ship adapter only in MDMA | Loses the ecosystem listing/visibility that motivates the work. | + +## 4. Adapter design (`mdma-agui`) + +A headless core plus an optional React layer. The headless core is defined against a **minimal +structural agent interface** (the small slice of `@ag-ui/client`'s `AbstractAgent` we touch), so +it compiles with no hard AG-UI dependency and any real `HttpAgent` satisfies it. All AG-UI +coupling is isolated to one `types.ts`. Two directions: + +**Stream → render.** Subscribe to the agent. On `onTextMessageContentEvent`, use the provided +`textMessageBuffer` (the *accumulated* message text — no manual delta bookkeeping), gate on a +cheap "contains an `mdma` fence?" check, throttle-reparse (~150 ms), and feed the AST to the +MDMA store. Create the store **once** per message; every later pass calls `store.updateAst()` +so in-flight form edits and focus survive. "Latest content wins" guards async parse ordering. +A `tool` transport path handles agents that emit MDMA as a tool-call payload instead. + +**Action → resume.** Listen on `store.getEventBus().onAny()` and switch on the action events: +`ACTION_TRIGGERED` (button / form submit), `APPROVAL_GRANTED`, and `APPROVAL_DENIED` +(approval-gate approve / deny). Default behavior packages the decision and re-runs the agent +(`addMessage` + `runAgent`). If the backend uses AG-UI's native interrupt, the host passes +`onAction` returning `false` and resolves the interrupt itself, so the parked run resumes with +state intact rather than starting a fresh turn. + +**Dependencies:** all peer deps — `@ag-ui/client`, `@ag-ui/core`, `@mobile-reality/mdma-parser`, +`-runtime`, `-spec`, `-attachables-core`, `-renderer-react` (React layer only), plus `unified` +and `remark-parse`. + +### Dataflow + +``` +Agent backend ──emits AG-UI events──▶ @ag-ui/client HttpAgent + │ (typed event stream) + ▼ + mdma-agui bridge + · containsMdma() gate + · throttle ~150ms → parse + · create once, then updateAst + │ + ▼ + MDMA document store ───▶ MdmaDocument (React) + ▲ forms, approval gates + │ │ + └── ACTION_TRIGGERED / ─────┘ + APPROVAL_GRANTED / + APPROVAL_DENIED + (bridge resumes run / resolves interrupt) +``` + +## 5. Deliverables + +1. **`@mobile-reality/mdma-agui` package** — headless bridge + React + (`useMdmaAgentStream`, `MdmaAgentView`). Lives flat at `packages/agui/`, modeled on the + existing `packages/mcp/` (build config, changeset, README shape); no new grouping folder. + **(Scaffolded in this revision.)** +2. **Example AG-UI backend** — a minimal agent that streams an `approval-gate` MDMA document + and handles the resume, riding an existing framework (built-in agent or LangGraph) rather + than shipping a new one. +3. **AG-UI dojo demo** — implements the dojo's `human_in_the_loop` (and optionally + `tool_based_generative_ui`) feature using MDMA as the rendering layer, with the required + end-to-end tests. +4. **AG-UI docs/ecosystem entry** — MDMA listed as a generative-UI integration, pointing at the + package. Exact placement per §8 Q1. +5. **MDMA-side docs** — a short "using MDMA over AG-UI" section referencing the adapter. + +## 6. Process (aligned to AG-UI CONTRIBUTING) + +1. **Validate first (this document).** Open a GitHub issue proposing MDMA as a generative-UI + integration; raise it in Discord `#-💎-contributing`. Get explicit direction on home + shape + before coding the demo. +2. **Build & prove the adapter** against a real agent (approval-gate round trip). +3. **Open the dojo-demo PR** once greenlit: example under the chosen framework, `menu.ts` + feature entry, and **e2e tests for every feature listed** (non-negotiable per AG-UI — + "Without tests, your PR will not be considered ready"), plus the CI matrix update in + `.github/workflows/dojo-e2e.yml`. +4. **Land the docs entry.** File the docs issue, then PR the ecosystem listing. + +## 7. Acceptance criteria (definition of done for the demo) + +- An AG-UI agent streams an `approval-gate` MDMA document to the frontend. +- It renders live; partial streaming does not wipe user input. +- Approve/deny resumes the agent run (or resolves the interrupt) with state intact. +- An end-to-end test covers the flow and passes locally **and** in CI. +- MDMA appears in AG-UI's ecosystem docs with a working link. + +## 8. Open questions — for validation + +**For AG-UI maintainers** +1. Where should MDMA be listed? There is no ecosystem category literally named "Generative UI" + (it's a building block); listings are tiered "Direct to LLM" / "Agent Framework – + Partnerships / 1st Party / Community". What is the right home for a rendering-spec integration? +2. Is a **dojo demo riding an existing backend** the right vehicle, versus a new + `integrations/` folder? +3. How is **interrupt / suspend-resume** currently modeled in the dojo's `human_in_the_loop` + and `interrupt` examples — what contract should the adapter target (`buildResumeArray`, + `agent.pendingInterrupts`)? +4. Preferred transport to showcase: **text-embedded MDMA** or a **tool call** + (`tool_based_generative_ui`)? +5. Client API: is persistent `agent.subscribe(subscriber)` preferred over per-run + `runAgent(params, subscriber)` on the current `@ag-ui/client` line? (Note `runAgent` takes + `RunAgentParameters`, not a raw input object.) + +**For MDMA maintainers** +6. Adapter home — **resolved:** flat `packages/agui/` as `@mobile-reality/mdma-agui`, + mirroring `packages/mcp/` (which is itself an integration and sits flat, no `adapters/` + folder). A grouping folder (`integrations/`) is deferred until the flat list grows unwieldy + (~10+ packages). Confirm this matches maintainer preference. +7. Confirm exact runtime signatures the adapter relies on (all **verified present**): + `store.updateAst(ast)`, `store.getEventBus().onAny(fn)`, and the action event shapes — + `ACTION_TRIGGERED { componentId, actionId, payload? }`, `APPROVAL_GRANTED { componentId, actor }`, + `APPROVAL_DENIED { componentId, actor, reason }`. +8. Should `mdma-prompt-pack` gain an AG-UI-oriented note (which transport to emit)? + +## 9. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Streaming delivers partial/invalid MDMA YAML | Throttle + `updateAst` + latest-wins; never re-create the store mid-stream. | +| API drift between library versions (the `any` casts) | Structural agent interface isolates coupling to one file; pin peer ranges; `tsc --noEmit` against installed versions. | +| Maintenance burden on AG-UI | Adapter lives in MDMA org; AG-UI gets only docs + demo (contributor-maintained). | +| Maintainers prefer a different home/shape | Resolved by Section 8 **before** building the dojo PR — don't pre-build. | +| MDMA project maturity (small, early v0.x) | Scope is a demo + adapter, not a core dependency; low blast radius either way. | + +## 10. Milestones + +1. **M0 — Validation.** Issue + Discord; answers to Section 8. *Gate.* +2. **M1 — Adapter.** `mdma-agui` published; approval-gate round-trip proven locally. +3. **M2 — Demo + tests.** Dojo demo PR with e2e tests + CI matrix. +4. **M3 — Docs.** Ecosystem listing in AG-UI; MDMA-side usage docs. diff --git a/packages/agui/README.md b/packages/agui/README.md new file mode 100644 index 0000000..69a0288 --- /dev/null +++ b/packages/agui/README.md @@ -0,0 +1,125 @@ +# @mobile-reality/mdma-agui + +Bridge [MDMA](https://github.com/MobileReality/mdma) interactive documents onto the +[AG-UI protocol](https://github.com/ag-ui-protocol/ag-ui). An AG-UI agent streams MDMA +(forms, tables, approval gates) as message text or a tool-call payload; this package renders it +live and routes the user's actions — submit, approve, deny — back into the agent run, closing the +human-in-the-loop. + +MDMA owns the **decision surface** (validated, audited, PII-aware components); AG-UI owns the +**control primitive** (suspend/resume via its `interrupt` building block). This adapter is the seam. + +> **Layering.** AG-UI is transport; MDMA is payload. This package is a community-maintained +> adapter, not a framework integration — see [`mdma-agui-integration-plan.md`](../../mdma-agui-integration-plan.md). + +## Install + +```bash +npm install @mobile-reality/mdma-agui \ + @ag-ui/client @ag-ui/core \ + @mobile-reality/mdma-parser @mobile-reality/mdma-runtime \ + @mobile-reality/mdma-spec @mobile-reality/mdma-attachables-core +# React layer only: +npm install @mobile-reality/mdma-renderer-react react +``` + +All AG-UI, MDMA, and React packages are **peer dependencies** — you bring the versions your app +already uses. `@mobile-reality/mdma-renderer-react` and `react` are optional (headless core works +without them). + +## React usage + +```tsx +import { HttpAgent } from '@ag-ui/client'; +import { MdmaAgentView } from '@mobile-reality/mdma-agui/react'; +import '@mobile-reality/mdma-renderer-react/styles.css'; + +const agent = new HttpAgent({ url: '/api/agent' }); + +export function Chat() { + // Streams every MDMA document the agent emits; approvals/forms resume the run automatically. + return ; +} +``` + +For finer control, use the hook: + +```tsx +import { useMdmaAgentStream } from '@mobile-reality/mdma-agui/react'; +import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; + +function Chat({ agent }) { + const { documents } = useMdmaAgentStream(agent, { + // Return false to resume the run yourself (e.g. resolve an AG-UI interrupt). + onAction: async (action, message) => { + console.log('user decided', action.type, 'in', message.messageId); + }, + }); + return documents.map((d) => ); +} +``` + +## Headless usage + +No React required — subscribe and drive rendering yourself: + +```ts +import { createMdmaAgentBridge } from '@mobile-reality/mdma-agui'; + +const bridge = createMdmaAgentBridge(agent, { + onDocument: (message) => renderSomewhere(message.ast, message.store), +}); + +// later +bridge.dispose(); +``` + +## How it works + +**Stream → render.** On each `onTextMessageContentEvent`, the bridge reads the *accumulated* +`textMessageBuffer` (no delta bookkeeping), gates on a cheap `containsMdma()` fence check, +throttles re-parsing (~150 ms), and feeds the AST into a document store. The store is created +**once per message** and updated in place with `store.updateAst()` afterward, so in-flight form +edits and focus survive streaming. "Latest content wins" guards async parse ordering. + +**Action → resume.** The bridge listens on `store.getEventBus().onAny()` and switches on the +decision events — `ACTION_TRIGGERED` (button / form submit), `APPROVAL_GRANTED`, and +`APPROVAL_DENIED` (approval-gate). By default it packages the decision as a user turn and calls +`agent.addMessage()` + `agent.runAgent()`. Return `false` from `onAction` to take over — e.g. +resolve AG-UI's native interrupt so the parked run resumes with state intact. + +## API + +| Export | Description | +|---|---| +| `createMdmaAgentBridge(agent, options)` | Headless bridge. Returns `{ documents, flush, dispose }`. | +| `parseMdma(markdown, { existingStore?, createRegistry? })` | Parse text → `{ ast, store }`, reusing a store when given. | +| `containsMdma(text)` | Cheap gate: does the buffer contain an `mdma` fence? | +| `useMdmaAgentStream(agent, options)` *(./react)* | React hook → `{ documents, bridge }`. | +| `MdmaAgentView` *(./react)* | Drop-in component rendering every streamed document. | + +### `options` (both `createMdmaAgentBridge` and the hook/view) + +- `throttleMs?` — re-parse debounce window (default `150`). +- `createRegistry?` — attachable registry factory (defaults to the core attachables). +- `onDocument?` — fires when a message's store is created/updated (render hook). +- `onAction?` — fires on a user decision; return `false` to suppress the default resume. +- `resume?` — fully replace the default `addMessage` + `runAgent` resume. + +## AG-UI coupling + +The headless core is written against a **minimal structural agent interface** in +[`src/types.ts`](src/types.ts) — the small slice of `@ag-ui/client`'s `AbstractAgent` / +`AgentSubscriber` and `@ag-ui/core`'s `Message` it touches (`subscribe`, `runAgent`, `addMessage`, +`onTextMessageContentEvent`). A real `HttpAgent` satisfies it by shape, so there is no hard build +dependency on AG-UI and all coupling is isolated to that one file. + +It is **not a blind shim**: [`tests/agui-conformance.ts`](tests/agui-conformance.ts) asserts at +type-check time (against the installed `@ag-ui/*`) that a real `AbstractAgent` is assignable to +our `AguiAgent` and our subscriber is accepted by the real `AgentSubscriber`. If AG-UI's API +drifts, `pnpm typecheck` fails there — turning silent runtime drift into a build error. The +conformance file is excluded from the published build. + +## License + +MIT diff --git a/packages/agui/package.json b/packages/agui/package.json new file mode 100644 index 0000000..4b3d04e --- /dev/null +++ b/packages/agui/package.json @@ -0,0 +1,81 @@ +{ + "name": "@mobile-reality/mdma-agui", + "version": "0.1.0", + "description": "Bridge that renders MDMA interactive documents streamed over the AG-UI protocol and routes user actions back into the agent run.", + "keywords": [ + "mdma", + "markdown", + "ai", + "llm", + "interactive-document", + "ag-ui", + "agent", + "generative-ui", + "human-in-the-loop" + ], + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./react": { + "import": "./dist/react/index.js", + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "lint": "biome lint src/", + "typecheck": "tsc -p tsconfig.typecheck.json" + }, + "dependencies": { + "remark-parse": "^11.0.0", + "unified": "^11.0.0" + }, + "peerDependencies": { + "@ag-ui/client": ">=0.0.30 <0.1.0", + "@ag-ui/core": ">=0.0.30 <0.1.0", + "@mobile-reality/mdma-attachables-core": "*", + "@mobile-reality/mdma-parser": "*", + "@mobile-reality/mdma-renderer-react": "*", + "@mobile-reality/mdma-runtime": "*", + "@mobile-reality/mdma-spec": "*", + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@mobile-reality/mdma-renderer-react": { + "optional": true + }, + "react": { + "optional": true + } + }, + "devDependencies": { + "@ag-ui/client": "^0.0.57", + "@ag-ui/core": "^0.0.57", + "@mobile-reality/mdma-attachables-core": "workspace:*", + "@mobile-reality/mdma-parser": "workspace:*", + "@mobile-reality/mdma-renderer-react": "workspace:*", + "@mobile-reality/mdma-runtime": "workspace:*", + "@mobile-reality/mdma-spec": "workspace:*", + "@types/react": "^19.0.0", + "react": "^19.0.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MobileReality/mdma.git", + "directory": "packages/agui" + } +} diff --git a/packages/agui/src/bridge.ts b/packages/agui/src/bridge.ts new file mode 100644 index 0000000..de5ec39 --- /dev/null +++ b/packages/agui/src/bridge.ts @@ -0,0 +1,270 @@ +import type { AttachableRegistry, DocumentStore } from '@mobile-reality/mdma-runtime'; +import type { MdmaRoot, StoreAction } from '@mobile-reality/mdma-spec'; +import { containsMdma } from './contains-mdma.js'; +import { parseMdma } from './parse.js'; +import type { AguiAgent, AguiSubscriber, AguiSubscription } from './types.js'; + +/** The store actions that represent a user decision worth routing back to the agent. */ +export type MdmaActionEvent = Extract< + StoreAction, + { type: 'ACTION_TRIGGERED' | 'APPROVAL_GRANTED' | 'APPROVAL_DENIED' | 'INTEGRATION_CALLED' } +>; + +const ACTION_TYPES = new Set([ + 'ACTION_TRIGGERED', // button click, form submit, tasklist completion + 'APPROVAL_GRANTED', + 'APPROVAL_DENIED', + 'INTEGRATION_CALLED', // webhook trigger / execution result +]); + +/** Live render state for a single streamed assistant message. */ +export interface MdmaMessageState { + messageId: string; + content: string; + ast: MdmaRoot; + store: DocumentStore; +} + +export interface MdmaAgentBridgeOptions { + /** Debounce window between re-parses of a streaming message. Default 150ms. */ + throttleMs?: number; + /** Registry factory for the document store (defaults to the core attachables). */ + createRegistry?: () => AttachableRegistry; + /** + * Seed component values when a message's store is first created — e.g. restoring a persisted + * conversation fetched from a backend so its forms/approvals/tasklists render pre-populated. + * Keyed by component id → its `values` map (the shape under `getState().components`). Applies + * only to freshly-created components across the whole conversation; ids absent from a given + * message are ignored. + */ + initialState?: Record>; + /** + * Called whenever a message's store is created or updated from newly parsed MDMA — the hook + * point for a UI to (re)render. Fires with the same store instance across a message's lifetime. + */ + onDocument?: (message: MdmaMessageState) => void; + /** + * Called when the user triggers a decision (form submit / button / tasklist completion / + * approve / deny / webhook trigger) inside a rendered document. Return `false` to take over + * resumption yourself (e.g. resolve an AG-UI interrupt + * so the parked run continues with state intact); return `true` or nothing to let the bridge + * perform its default resume (`addMessage` + `runAgent`). + */ + onAction?: ( + action: MdmaActionEvent, + message: MdmaMessageState, + ) => boolean | void | Promise | Promise; + /** + * Override the default resume behavior. When provided it fully replaces `addMessage`+`runAgent`. + */ + resume?: ( + action: MdmaActionEvent, + message: MdmaMessageState, + agent: AguiAgent, + ) => void | Promise; + /** Injectable clock (ms). Defaults to `Date.now`; overridden in tests. */ + now?: () => number; +} + +export interface MdmaAgentBridge { + /** Message states keyed by AG-UI message id. */ + readonly documents: ReadonlyMap; + /** Force an immediate re-parse of a message's latest buffered content (or all pending). */ + flush(messageId?: string): Promise; + /** Detach from the agent and drop all per-message stores/subscriptions. */ + dispose(): void; +} + +interface PendingBuffer { + latest: string; + lastParseAt: number; + timer: ReturnType | null; + /** Monotonic parse request id — guards "latest content wins" across async parses. */ + seq: number; + applied: number; +} + +/** + * Bridge an AG-UI agent to MDMA rendering. Subscribes to the agent's streamed text, parses any + * embedded MDMA into a per-message document store (created once, then `updateAst`-ed), and routes + * the resulting user actions back into the agent run. + */ +export function createMdmaAgentBridge( + agent: AguiAgent, + options: MdmaAgentBridgeOptions = {}, +): MdmaAgentBridge { + const throttleMs = options.throttleMs ?? 150; + const now = options.now ?? Date.now; + + const documents = new Map(); + const pending = new Map(); + const actionUnsubs = new Map void>(); + let disposed = false; + + async function reparse(messageId: string): Promise { + const buf = pending.get(messageId); + if (!buf || disposed) return; + + const content = buf.latest; + const requestSeq = ++buf.seq; + buf.lastParseAt = now(); + + const existing = documents.get(messageId); + let result: { ast: MdmaRoot; store: DocumentStore }; + try { + result = await parseMdma(content, { + existingStore: existing?.store, + createRegistry: options.createRegistry, + initialState: options.initialState, + }); + } catch { + // Partial/invalid MDMA mid-stream is expected; keep the last good render. + return; + } + + // Latest-content-wins: a newer parse for this message already landed — drop this one. + if (disposed || requestSeq <= buf.applied) return; + buf.applied = requestSeq; + + const state: MdmaMessageState = { messageId, content, ast: result.ast, store: result.store }; + const isNewStore = !existing || existing.store !== result.store; + documents.set(messageId, state); + + if (isNewStore) attachActions(state); + options.onDocument?.(state); + } + + function attachActions(state: MdmaMessageState): void { + actionUnsubs.get(state.messageId)?.(); + const unsub = state.store.getEventBus().onAny((action) => { + if (!ACTION_TYPES.has(action.type)) return; + void handleAction(action as MdmaActionEvent, state.messageId); + }); + actionUnsubs.set(state.messageId, unsub); + } + + async function handleAction(action: MdmaActionEvent, messageId: string): Promise { + const state = documents.get(messageId); + if (!state || disposed) return; + + const decision = await options.onAction?.(action, state); + if (decision === false) return; // host takes over (e.g. resolves an interrupt) + + if (options.resume) { + await options.resume(action, state, agent); + return; + } + // Default resume: hand the decision back as a user turn and re-run the agent. + // `id` is required by AG-UI's Message schema, so always mint one. + agent.addMessage({ + id: crypto.randomUUID(), + role: 'user', + content: JSON.stringify(serializeAction(action)), + }); + await agent.runAgent(); + } + + function ingest(messageId: string, content: string, immediate: boolean): void { + if (disposed || !containsMdma(content)) return; + + let buf = pending.get(messageId); + if (!buf) { + buf = { latest: content, lastParseAt: 0, timer: null, seq: 0, applied: 0 }; + pending.set(messageId, buf); + } else { + buf.latest = content; + } + + if (immediate) { + if (buf.timer) { + clearTimeout(buf.timer); + buf.timer = null; + } + void reparse(messageId); + return; + } + + const elapsed = now() - buf.lastParseAt; + if (elapsed >= throttleMs) { + void reparse(messageId); + } else if (!buf.timer) { + buf.timer = setTimeout(() => { + buf!.timer = null; + void reparse(messageId); + }, throttleMs - elapsed); + } + } + + const subscriber: AguiSubscriber = { + // During streaming, AG-UI's content buffer lags one delta behind — good enough for a live + // (slightly-behind) render, throttled to avoid re-parsing every token. + onTextMessageContentEvent: (params) => ingest(params.event.messageId, params.textMessageBuffer, false), + // The end buffer is the COMPLETE message, so parse it immediately — this is what guarantees + // the final render isn't a truncated tail (content events never carry the last delta). + onTextMessageEndEvent: (params) => ingest(params.event.messageId, params.textMessageBuffer, true), + // Belt-and-suspenders: a run that ends without a text-end (e.g. tool transport) still flushes. + onRunFinishedEvent: () => flush(), + onRunFailedEvent: () => flush(), + }; + + const subscription: AguiSubscription = agent.subscribe(subscriber); + + async function flush(messageId?: string): Promise { + const ids = messageId ? [messageId] : [...pending.keys()]; + await Promise.all( + ids.map((id) => { + const buf = pending.get(id); + if (buf?.timer) { + clearTimeout(buf.timer); + buf.timer = null; + } + return reparse(id); + }), + ); + } + + function dispose(): void { + if (disposed) return; + disposed = true; + subscription.unsubscribe(); + for (const buf of pending.values()) { + if (buf.timer) clearTimeout(buf.timer); + } + for (const unsub of actionUnsubs.values()) unsub(); + actionUnsubs.clear(); + pending.clear(); + documents.clear(); + } + + return { documents, flush, dispose }; +} + +/** Flatten a store action into a plain, serializable decision payload for the resume message. */ +function serializeAction(action: MdmaActionEvent): Record { + switch (action.type) { + case 'ACTION_TRIGGERED': + return { + kind: 'action', + componentId: action.componentId, + actionId: action.actionId, + payload: action.payload, + }; + case 'APPROVAL_GRANTED': + return { kind: 'approval', decision: 'granted', componentId: action.componentId, actor: action.actor }; + case 'APPROVAL_DENIED': + return { + kind: 'approval', + decision: 'denied', + componentId: action.componentId, + actor: action.actor, + reason: action.reason, + }; + case 'INTEGRATION_CALLED': + return { + kind: 'integration', + componentId: action.componentId, + integrationId: action.integrationId, + result: action.result, + }; + } +} diff --git a/packages/agui/src/contains-mdma.ts b/packages/agui/src/contains-mdma.ts new file mode 100644 index 0000000..bbd52c9 --- /dev/null +++ b/packages/agui/src/contains-mdma.ts @@ -0,0 +1,10 @@ +/** + * Cheap gate run on every streamed content event before the (comparatively expensive) + * markdown parse. Returns true only if the buffer plausibly contains an opening ```mdma + * fence, so plain prose messages never touch the parser. + */ +const MDMA_FENCE = /(^|\n)[ \t]*(`{3,}|~{3,})[ \t]*mdma\b/; + +export function containsMdma(text: string): boolean { + return MDMA_FENCE.test(text); +} diff --git a/packages/agui/src/index.ts b/packages/agui/src/index.ts new file mode 100644 index 0000000..e780447 --- /dev/null +++ b/packages/agui/src/index.ts @@ -0,0 +1,18 @@ +export { + createMdmaAgentBridge, + type MdmaAgentBridge, + type MdmaAgentBridgeOptions, + type MdmaMessageState, + type MdmaActionEvent, +} from './bridge.js'; +export { parseMdma, createDefaultRegistry } from './parse.js'; +export { containsMdma } from './contains-mdma.js'; +export type { + AguiAgent, + AguiSubscriber, + AguiSubscription, + AguiMessage, + AguiTextMessageContentEvent, + AguiTextMessageContentParams, + AguiTextMessageEndParams, +} from './types.js'; diff --git a/packages/agui/src/parse.ts b/packages/agui/src/parse.ts new file mode 100644 index 0000000..0a11cb1 --- /dev/null +++ b/packages/agui/src/parse.ts @@ -0,0 +1,51 @@ +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import { remarkMdma } from '@mobile-reality/mdma-parser'; +import { + createDocumentStore, + AttachableRegistry, + type DocumentStore, +} from '@mobile-reality/mdma-runtime'; +import { registerAllCoreAttachables } from '@mobile-reality/mdma-attachables-core'; +import type { MdmaRoot } from '@mobile-reality/mdma-spec'; + +const processor = unified().use(remarkParse).use(remarkMdma, {}); + +/** Build a registry with the core attachables (form/button/approval-gate/…) registered. */ +export function createDefaultRegistry(): AttachableRegistry { + const registry = new AttachableRegistry(); + registerAllCoreAttachables(registry); + return registry; +} + +/** + * Parse a markdown string into an MDMA AST and either seed a fresh document store or, when an + * `existingStore` is passed, update it in place via `store.updateAst()` — preserving in-flight + * form values, focus, and audit log across streamed re-parses. Mirrors the CLI preview pipeline. + */ +export async function parseMdma( + markdown: string, + options: { + existingStore?: DocumentStore; + createRegistry?: () => AttachableRegistry; + /** Seed fresh component values (e.g. restoring a persisted conversation from a backend). */ + initialState?: Record>; + } = {}, +): Promise<{ ast: MdmaRoot; store: DocumentStore }> { + const tree = processor.parse(markdown); + // Pass the source to run() so the mdma transform can see the raw fences — it needs them to tell a + // still-streaming (unterminated) block from a complete one and avoid flashing "Unknown component". + const ast = (await processor.run(tree, markdown)) as MdmaRoot; + if (options.existingStore) { + options.existingStore.updateAst(ast); + return { ast, store: options.existingStore }; + } + const createRegistry = options.createRegistry ?? createDefaultRegistry; + return { + ast, + store: createDocumentStore(ast, { + registry: createRegistry(), + initialState: options.initialState, + }), + }; +} diff --git a/packages/agui/src/react/MdmaAgentView.tsx b/packages/agui/src/react/MdmaAgentView.tsx new file mode 100644 index 0000000..a48f29f --- /dev/null +++ b/packages/agui/src/react/MdmaAgentView.tsx @@ -0,0 +1,33 @@ +import { MdmaDocument, type MdmaRenderCustomizations } from '@mobile-reality/mdma-renderer-react'; +import type { MdmaAgentBridgeOptions } from '../bridge.js'; +import type { AguiAgent } from '../types.js'; +import { useMdmaAgentStream } from './use-mdma-agent-stream.js'; + +export interface MdmaAgentViewProps extends MdmaAgentBridgeOptions { + /** The AG-UI agent to stream from (e.g. an `@ag-ui/client` `HttpAgent`). */ + agent: AguiAgent; + /** Rendering customizations forwarded to each `MdmaDocument`. */ + customizations?: MdmaRenderCustomizations; + /** Class applied to the wrapper around all rendered documents. */ + className?: string; +} + +/** + * Drop-in view: subscribes to an AG-UI agent and renders every MDMA document it streams, with + * approvals/forms wired back into the run. For finer control, use {@link useMdmaAgentStream}. + */ +export function MdmaAgentView({ agent, customizations, className, ...options }: MdmaAgentViewProps) { + const { documents } = useMdmaAgentStream(agent, options); + return ( +
+ {documents.map((doc) => ( + + ))} +
+ ); +} diff --git a/packages/agui/src/react/index.ts b/packages/agui/src/react/index.ts new file mode 100644 index 0000000..2fa3ac1 --- /dev/null +++ b/packages/agui/src/react/index.ts @@ -0,0 +1,5 @@ +export { + useMdmaAgentStream, + type UseMdmaAgentStreamResult, +} from './use-mdma-agent-stream.js'; +export { MdmaAgentView, type MdmaAgentViewProps } from './MdmaAgentView.js'; diff --git a/packages/agui/src/react/use-mdma-agent-stream.ts b/packages/agui/src/react/use-mdma-agent-stream.ts new file mode 100644 index 0000000..3d4b92c --- /dev/null +++ b/packages/agui/src/react/use-mdma-agent-stream.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from 'react'; +import { + createMdmaAgentBridge, + type MdmaAgentBridge, + type MdmaAgentBridgeOptions, + type MdmaMessageState, +} from '../bridge.js'; +import type { AguiAgent } from '../types.js'; + +export interface UseMdmaAgentStreamResult { + /** Rendered documents in first-seen message order. */ + documents: MdmaMessageState[]; + /** The underlying bridge (for `flush`, imperative access, tests). */ + bridge: MdmaAgentBridge | null; +} + +/** + * Subscribe a React component to an AG-UI agent's MDMA output. Creates one bridge per `agent` + * instance and re-renders as streamed documents are (re)parsed. `options` may change between + * renders without tearing down the bridge — the latest callbacks are always used. + */ +export function useMdmaAgentStream( + agent: AguiAgent, + options: MdmaAgentBridgeOptions = {}, +): UseMdmaAgentStreamResult { + const [documents, setDocuments] = useState([]); + const [bridge, setBridge] = useState(null); + + // Keep the freshest options without re-subscribing on every render. + const optionsRef = useRef(options); + optionsRef.current = options; + + useEffect(() => { + const order: string[] = []; + + const b = createMdmaAgentBridge(agent, { + // Bridge-level config that shouldn't change per render — read once. + throttleMs: optionsRef.current.throttleMs, + createRegistry: optionsRef.current.createRegistry, + initialState: optionsRef.current.initialState, + now: optionsRef.current.now, + onDocument: (message) => { + if (!order.includes(message.messageId)) order.push(message.messageId); + optionsRef.current.onDocument?.(message); + setDocuments((prev) => { + const next = prev.filter((m) => m.messageId !== message.messageId); + next.push(message); + next.sort((a, c) => order.indexOf(a.messageId) - order.indexOf(c.messageId)); + return next; + }); + }, + onAction: (action, message) => optionsRef.current.onAction?.(action, message), + resume: optionsRef.current.resume + ? (action, message, a) => optionsRef.current.resume!(action, message, a) + : undefined, + }); + + setBridge(b); + setDocuments([]); + return () => { + b.dispose(); + setBridge(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on agent only + }, [agent]); + + return { documents, bridge }; +} diff --git a/packages/agui/src/types.ts b/packages/agui/src/types.ts new file mode 100644 index 0000000..c38b808 --- /dev/null +++ b/packages/agui/src/types.ts @@ -0,0 +1,74 @@ +/** + * The **entire** slice of `@ag-ui/client`'s `AbstractAgent` / `AgentSubscriber` and + * `@ag-ui/core`'s `Message` that this adapter touches, expressed as narrow structural + * interfaces. A real `HttpAgent` (or any `AbstractAgent` subclass) satisfies them by shape, so + * the bridge needs no compile-time dependency on AG-UI — the coupling lives only here. + * + * These are **not blind shims**: `tests/agui-conformance.ts` asserts (at type-check time, + * against the installed `@ag-ui/*`) that a real `AbstractAgent` is assignable to {@link AguiAgent} + * and our {@link AguiSubscriber} is assignable to the real `AgentSubscriber`. If AG-UI's API + * drifts, `pnpm typecheck` fails here instead of at runtime. Keep this file the single source of + * that coupling; when the shapes below diverge from upstream, fix them (and the conformance test + * will confirm the fix). + */ + +/** + * The user turn the bridge hands back on resume — a structural subset of `@ag-ui/core`'s + * `UserMessage`. `id` and `content` are **required** upstream (messages are zod-validated), so + * the bridge always supplies both. The bridge only ever emits user messages; hosts that need + * other roles drive their own agent via the `resume` option. + */ +export interface AguiMessage { + id: string; + role: 'user'; + content: string; +} + +/** The raw `TEXT_MESSAGE_CONTENT` event payload — we only read `messageId`. */ +export interface AguiTextMessageContentEvent { + messageId: string; + delta?: string; +} + +/** + * Params handed to `onTextMessageContentEvent`. `textMessageBuffer` is the accumulated message + * text — but note AG-UI passes it *before* appending the current delta, so during streaming it + * lags one delta behind. The complete text only arrives via {@link AguiTextMessageEndParams}. + */ +export interface AguiTextMessageContentParams { + event: AguiTextMessageContentEvent; + textMessageBuffer: string; +} + +/** + * Params handed to `onTextMessageEndEvent`. Here `textMessageBuffer` is the **complete** message + * text (every delta applied), so the bridge parses it to guarantee the final render is whole. + */ +export interface AguiTextMessageEndParams { + event: { messageId: string }; + textMessageBuffer: string; +} + +/** + * The subset of `AgentSubscriber` the bridge implements. Callbacks return `void | Promise`, + * a subset of upstream's `MaybePromise`. The run-lifecycle params are + * `unknown` because the bridge reads nothing from them — it only flushes the final parse. + */ +export interface AguiSubscriber { + onTextMessageContentEvent?(params: AguiTextMessageContentParams): void | Promise; + onTextMessageEndEvent?(params: AguiTextMessageEndParams): void | Promise; + onRunFinishedEvent?(params: unknown): void | Promise; + onRunFailedEvent?(params: unknown): void | Promise; +} + +/** Return value of `agent.subscribe(...)`. */ +export interface AguiSubscription { + unsubscribe(): void; +} + +/** The subset of `AbstractAgent` the bridge calls. */ +export interface AguiAgent { + subscribe(subscriber: AguiSubscriber): AguiSubscription; + runAgent(params?: unknown, subscriber?: AguiSubscriber): Promise; + addMessage(message: AguiMessage): void; +} diff --git a/packages/agui/tests/agui-conformance.ts b/packages/agui/tests/agui-conformance.ts new file mode 100644 index 0000000..7ef84d7 --- /dev/null +++ b/packages/agui/tests/agui-conformance.ts @@ -0,0 +1,22 @@ +/** + * Compile-time conformance guard — **type-only, never executed**. This file is checked by + * `pnpm typecheck` (via `tsconfig.typecheck.json`) against the installed `@ag-ui/*` packages, + * and is excluded from the published build. If AG-UI's API drifts such that our narrow types in + * `../src/types.ts` no longer line up with the real ones, one of the assignments below stops + * compiling and `typecheck` fails — turning silent runtime drift into a build error. + */ +import type { AbstractAgent, AgentSubscriber } from '@ag-ui/client'; +import type { Message } from '@ag-ui/core'; +import type { AguiAgent, AguiMessage, AguiSubscriber } from '../src/types.js'; + +// A real AbstractAgent must be usable wherever the bridge expects an AguiAgent. This also +// verifies (via addMessage's param) that our AguiMessage is a valid input to the real agent. +const _agent: AguiAgent = null as unknown as AbstractAgent; + +// Our subscriber object must be acceptable to the real `agent.subscribe(...)`. +const _subscriber: AgentSubscriber = null as unknown as AguiSubscriber; + +// The user message the bridge builds must be a valid AG-UI `Message`. +const _message: Message = null as unknown as AguiMessage; + +export type _Conformance = [typeof _agent, typeof _subscriber, typeof _message]; diff --git a/packages/agui/tests/bridge.test.ts b/packages/agui/tests/bridge.test.ts new file mode 100644 index 0000000..316dc84 --- /dev/null +++ b/packages/agui/tests/bridge.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest'; +import { createMdmaAgentBridge, type MdmaMessageState } from '../src/bridge.js'; +import { parseMdma } from '../src/parse.js'; +import type { + AguiAgent, + AguiMessage, + AguiSubscriber, + AguiSubscription, +} from '../src/types.js'; + +const FENCE = '```'; +const APPROVAL_DOC = [ + 'Please review the deploy.', + '', + `${FENCE}mdma`, + 'id: gate1', + 'type: approval-gate', + 'title: Approve production deploy', + FENCE, + '', +].join('\n'); + +const FORM_DOC = [ + 'Here is your form.', + '', + `${FENCE}mdma`, + 'id: form1', + 'type: form', + 'fields:', + ' - name: email', + ' type: email', + ' label: Email', + 'onSubmit: submit-form1', + FENCE, + '', +].join('\n'); + +/** Minimal in-memory AG-UI agent for exercising the bridge. */ +class FakeAgent implements AguiAgent { + subscriber: AguiSubscriber | null = null; + added: AguiMessage[] = []; + runs = 0; + + subscribe(subscriber: AguiSubscriber): AguiSubscription { + this.subscriber = subscriber; + return { unsubscribe: () => (this.subscriber = null) }; + } + async runAgent(): Promise { + this.runs += 1; + return undefined; + } + addMessage(message: AguiMessage): void { + this.added.push(message); + } + + emitContent(messageId: string, textMessageBuffer: string): void | Promise { + return this.subscriber?.onTextMessageContentEvent?.({ + event: { messageId }, + textMessageBuffer, + }); + } + + emitEnd(messageId: string, textMessageBuffer: string): void | Promise { + return this.subscriber?.onTextMessageEndEvent?.({ event: { messageId }, textMessageBuffer }); + } +} + +function astBlockTypes(state: MdmaMessageState | undefined): string[] { + if (!state) return []; + return state.ast.children + .filter((c): c is { type: 'mdmaBlock'; component: { type: string } } => + (c as { type?: string }).type === 'mdmaBlock', + ) + .map((c) => c.component.type); +} + +describe('parseMdma', () => { + it('parses an mdma document into an ast + store and reuses the store on update', async () => { + const first = await parseMdma(APPROVAL_DOC); + expect(first.store.getComponentState('gate1')?.type).toBe('approval-gate'); + + const second = await parseMdma(`${APPROVAL_DOC}\nmore streamed text`, { + existingStore: first.store, + }); + // Same store instance is updated in place — not recreated. + expect(second.store).toBe(first.store); + }); +}); + +describe('createMdmaAgentBridge', () => { + it('renders streamed MDMA into a per-message store', async () => { + const agent = new FakeAgent(); + const seen: MdmaMessageState[] = []; + const bridge = createMdmaAgentBridge(agent, { throttleMs: 0, onDocument: (m) => seen.push(m) }); + + agent.emitContent('m1', APPROVAL_DOC); + await bridge.flush(); + + expect(bridge.documents.has('m1')).toBe(true); + expect(seen.at(-1)?.store.getComponentState('gate1')?.type).toBe('approval-gate'); + bridge.dispose(); + }); + + it('parses the complete text on message-end (AG-UI content buffers lag one delta)', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { throttleMs: 0 }); + + // Simulate AG-UI: content events carry the buffer *before* the latest delta, so the last + // chunk (here the tail of `approval-gate` + the rest of the doc) never arrives via content. + const lagging = APPROVAL_DOC.slice(0, APPROVAL_DOC.indexOf('approval-gate') + 'approval-gat'.length); + agent.emitContent('m1', lagging); + await bridge.flush(); + + // Mid-stream the fence is still open, so the truncated block stays pending (no bogus block is + // rendered — this is what prevents the "Unknown component type" flash). + expect(astBlockTypes(bridge.documents.get('m1'))).toHaveLength(0); + + // TEXT_MESSAGE_END carries the complete text → the final render is whole. + agent.emitEnd('m1', APPROVAL_DOC); + await bridge.flush(); + expect(astBlockTypes(bridge.documents.get('m1'))).toContain('approval-gate'); + bridge.dispose(); + }); + + it('never creates a document for a message without an mdma fence', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { throttleMs: 0 }); + + agent.emitContent('m1', 'Just a normal assistant reply, no components here.'); + await bridge.flush(); + + expect(bridge.documents.size).toBe(0); + bridge.dispose(); + }); + + it('routes an approval decision back into the agent by default (addMessage + runAgent)', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { throttleMs: 0 }); + + agent.emitContent('m1', APPROVAL_DOC); + await bridge.flush(); + + const store = bridge.documents.get('m1')!.store; + store.dispatch({ type: 'APPROVAL_GRANTED', componentId: 'gate1', actor: { id: 'user-42' } }); + await Promise.resolve(); + + expect(agent.runs).toBe(1); + expect(agent.added).toHaveLength(1); + const payload = JSON.parse(agent.added[0].content as string); + expect(payload).toMatchObject({ kind: 'approval', decision: 'granted', componentId: 'gate1' }); + bridge.dispose(); + }); + + it('hydrates a streamed message store from initialState', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { + throttleMs: 0, + initialState: { form1: { email: 'restored@b.com' } }, + }); + + agent.emitContent('m1', FORM_DOC); + await bridge.flush(); + + const store = bridge.documents.get('m1')!.store; + expect(store.getComponentState('form1')?.values.email).toBe('restored@b.com'); + bridge.dispose(); + }); + + it('routes a webhook INTEGRATION_CALLED back into the agent by default', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { throttleMs: 0 }); + + agent.emitContent('m1', APPROVAL_DOC); + await bridge.flush(); + + const store = bridge.documents.get('m1')!.store; + store.dispatch({ + type: 'INTEGRATION_CALLED', + componentId: 'hook1', + integrationId: 'webhook', + result: { status: 'triggered' }, + }); + await Promise.resolve(); + + expect(agent.runs).toBe(1); + expect(agent.added).toHaveLength(1); + const payload = JSON.parse(agent.added[0].content as string); + expect(payload).toMatchObject({ + kind: 'integration', + componentId: 'hook1', + integrationId: 'webhook', + }); + bridge.dispose(); + }); + + it('routes a tasklist completion (ACTION_TRIGGERED) back into the agent by default', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { throttleMs: 0 }); + + agent.emitContent('m1', APPROVAL_DOC); + await bridge.flush(); + + const store = bridge.documents.get('m1')!.store; + store.dispatch({ type: 'ACTION_TRIGGERED', componentId: 'checklist1', actionId: 'done' }); + await Promise.resolve(); + + expect(agent.runs).toBe(1); + const payload = JSON.parse(agent.added[0].content as string); + expect(payload).toMatchObject({ kind: 'action', componentId: 'checklist1', actionId: 'done' }); + bridge.dispose(); + }); + + it('lets the host take over resumption when onAction returns false', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { + throttleMs: 0, + onAction: () => false, // e.g. host resolves an AG-UI interrupt itself + }); + + agent.emitContent('m1', APPROVAL_DOC); + await bridge.flush(); + + const store = bridge.documents.get('m1')!.store; + store.dispatch({ type: 'APPROVAL_DENIED', componentId: 'gate1', actor: { id: 'u' }, reason: 'no' }); + await Promise.resolve(); + + expect(agent.runs).toBe(0); + expect(agent.added).toHaveLength(0); + bridge.dispose(); + }); + + it('stops routing after dispose', async () => { + const agent = new FakeAgent(); + const bridge = createMdmaAgentBridge(agent, { throttleMs: 0 }); + + agent.emitContent('m1', APPROVAL_DOC); + await bridge.flush(); + const store = bridge.documents.get('m1')!.store; + bridge.dispose(); + + store.dispatch({ type: 'APPROVAL_GRANTED', componentId: 'gate1', actor: { id: 'u' } }); + await Promise.resolve(); + expect(agent.runs).toBe(0); + expect(agent.subscriber).toBeNull(); + }); +}); diff --git a/packages/agui/tests/contains-mdma.test.ts b/packages/agui/tests/contains-mdma.test.ts new file mode 100644 index 0000000..b64aa46 --- /dev/null +++ b/packages/agui/tests/contains-mdma.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { containsMdma } from '../src/contains-mdma.js'; + +const FENCE = '```'; + +describe('containsMdma', () => { + it('detects a backtick mdma fence', () => { + expect(containsMdma(`intro\n\n${FENCE}mdma\nid: a\ntype: button\n${FENCE}`)).toBe(true); + }); + + it('detects a tilde mdma fence', () => { + expect(containsMdma('~~~mdma\nid: a\ntype: button\n~~~')).toBe(true); + }); + + it('detects an indented fence inside a list', () => { + expect(containsMdma(`- item\n ${FENCE}mdma\n id: a\n`)).toBe(true); + }); + + it('ignores plain prose', () => { + expect(containsMdma('Here is some ordinary text about mdma without a fence.')).toBe(false); + }); + + it('ignores a non-mdma fenced block', () => { + expect(containsMdma(`${FENCE}json\n{"a":1}\n${FENCE}`)).toBe(false); + }); +}); diff --git a/packages/agui/tsconfig.json b/packages/agui/tsconfig.json new file mode 100644 index 0000000..f93df5f --- /dev/null +++ b/packages/agui/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/agui/tsconfig.typecheck.json b/packages/agui/tsconfig.typecheck.json new file mode 100644 index 0000000..508201b --- /dev/null +++ b/packages/agui/tsconfig.typecheck.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests"] +} diff --git a/packages/agui/vitest.config.ts b/packages/agui/vitest.config.ts new file mode 100644 index 0000000..4a58023 --- /dev/null +++ b/packages/agui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/packages/attachables-core/src/approval-gate/approval-gate-handler.ts b/packages/attachables-core/src/approval-gate/approval-gate-handler.ts index 9890975..6d91a4b 100644 --- a/packages/attachables-core/src/approval-gate/approval-gate-handler.ts +++ b/packages/attachables-core/src/approval-gate/approval-gate-handler.ts @@ -25,25 +25,4 @@ export const approvalGateHandler: AttachableHandler = { disabled: false, }; }, - - async onAction(ctx: AttachableContext, actionId: string, payload: unknown) { - const data = payload as { actor: { id: string; role?: string }; reason?: string }; - - if (actionId === 'approve') { - ctx.policy.enforce('approval_grant'); - ctx.dispatch({ - type: 'APPROVAL_GRANTED', - componentId: ctx.componentId, - actor: data.actor, - }); - } else if (actionId === 'deny') { - ctx.policy.enforce('approval_deny'); - ctx.dispatch({ - type: 'APPROVAL_DENIED', - componentId: ctx.componentId, - actor: data.actor, - reason: data.reason ?? '', - }); - } - }, }; diff --git a/packages/attachables-core/src/button/button-handler.ts b/packages/attachables-core/src/button/button-handler.ts index 05a3662..d8abab1 100644 --- a/packages/attachables-core/src/button/button-handler.ts +++ b/packages/attachables-core/src/button/button-handler.ts @@ -25,12 +25,4 @@ export const buttonHandler: AttachableHandler = { disabled: false, }; }, - - async onAction(ctx: AttachableContext, actionId: string) { - ctx.dispatch({ - type: 'ACTION_TRIGGERED', - componentId: ctx.componentId, - actionId, - }); - }, }; diff --git a/packages/attachables-core/src/callout/callout-handler.ts b/packages/attachables-core/src/callout/callout-handler.ts index be3e269..f6a355c 100644 --- a/packages/attachables-core/src/callout/callout-handler.ts +++ b/packages/attachables-core/src/callout/callout-handler.ts @@ -25,15 +25,4 @@ export const calloutHandler: AttachableHandler = { disabled: false, }; }, - - async onAction(ctx: AttachableContext, actionId: string) { - if (actionId === 'dismiss') { - ctx.dispatch({ - type: 'FIELD_CHANGED', - componentId: ctx.componentId, - field: 'dismissed', - value: true, - }); - } - }, }; diff --git a/packages/attachables-core/src/form/form-handler.ts b/packages/attachables-core/src/form/form-handler.ts index 90bd31a..8b5ced4 100644 --- a/packages/attachables-core/src/form/form-handler.ts +++ b/packages/attachables-core/src/form/form-handler.ts @@ -29,14 +29,4 @@ export const formHandler: AttachableHandler = { disabled: false, }; }, - - async onAction(ctx: AttachableContext, actionId: string) { - if (actionId === 'submit') { - ctx.dispatch({ - type: 'ACTION_TRIGGERED', - componentId: ctx.componentId, - actionId: 'submit', - }); - } - }, }; diff --git a/packages/attachables-core/src/table/table-handler.ts b/packages/attachables-core/src/table/table-handler.ts index be94889..7ef0d55 100644 --- a/packages/attachables-core/src/table/table-handler.ts +++ b/packages/attachables-core/src/table/table-handler.ts @@ -29,22 +29,4 @@ export const tableHandler: AttachableHandler = { disabled: false, }; }, - - async onAction(ctx: AttachableContext, actionId: string, payload: unknown) { - if (actionId === 'sort') { - const { column, direction } = payload as { column: string; direction: string }; - ctx.dispatch({ - type: 'FIELD_CHANGED', - componentId: ctx.componentId, - field: 'sortColumn', - value: column, - }); - ctx.dispatch({ - type: 'FIELD_CHANGED', - componentId: ctx.componentId, - field: 'sortDirection', - value: direction, - }); - } - }, }; diff --git a/packages/attachables-core/src/tasklist/tasklist-handler.ts b/packages/attachables-core/src/tasklist/tasklist-handler.ts index 78ba1c1..b6e8739 100644 --- a/packages/attachables-core/src/tasklist/tasklist-handler.ts +++ b/packages/attachables-core/src/tasklist/tasklist-handler.ts @@ -29,16 +29,4 @@ export const tasklistHandler: AttachableHandler = { disabled: false, }; }, - - async onAction(ctx: AttachableContext, actionId: string, payload: unknown) { - if (actionId === 'toggle') { - const { itemId, checked } = payload as { itemId: string; checked: boolean }; - ctx.dispatch({ - type: 'FIELD_CHANGED', - componentId: ctx.componentId, - field: itemId, - value: checked, - }); - } - }, }; diff --git a/packages/attachables-core/src/webhook/webhook-handler.ts b/packages/attachables-core/src/webhook/webhook-handler.ts index 81d5938..9dd0a3a 100644 --- a/packages/attachables-core/src/webhook/webhook-handler.ts +++ b/packages/attachables-core/src/webhook/webhook-handler.ts @@ -25,16 +25,4 @@ export const webhookHandler: AttachableHandler = { disabled: false, }; }, - - async onAction(ctx: AttachableContext, actionId: string) { - if (actionId === 'execute') { - ctx.policy.enforce('webhook_call'); - ctx.dispatch({ - type: 'INTEGRATION_CALLED', - componentId: ctx.componentId, - integrationId: 'webhook', - result: { status: 'mocked', message: 'Webhook execution placeholder' }, - }); - } - }, }; diff --git a/packages/attachables-core/tests/handlers.test.ts b/packages/attachables-core/tests/handlers.test.ts index 4588efc..7a55552 100644 --- a/packages/attachables-core/tests/handlers.test.ts +++ b/packages/attachables-core/tests/handlers.test.ts @@ -41,17 +41,6 @@ describe('formHandler', () => { expect(state.values.email).toBe(''); expect(state.values.agree).toBe(false); }); - - it('dispatches on submit action', async () => { - const dispatch = vi.fn(); - const ctx = makeContext({ componentId: 'f1', dispatch }); - await formHandler.onAction?.(ctx, 'submit', undefined); - expect(dispatch).toHaveBeenCalledWith({ - type: 'ACTION_TRIGGERED', - componentId: 'f1', - actionId: 'submit', - }); - }); }); describe('buttonHandler', () => { @@ -65,17 +54,6 @@ describe('buttonHandler', () => { }); expect(state.type).toBe('button'); }); - - it('dispatches action on click', async () => { - const dispatch = vi.fn(); - const ctx = makeContext({ componentId: 'btn1', dispatch }); - await buttonHandler.onAction?.(ctx, 'go', undefined); - expect(dispatch).toHaveBeenCalledWith({ - type: 'ACTION_TRIGGERED', - componentId: 'btn1', - actionId: 'go', - }); - }); }); describe('tasklistHandler', () => { @@ -92,18 +70,6 @@ describe('tasklistHandler', () => { expect(state.values.i1).toBe(false); expect(state.values.i2).toBe(true); }); - - it('dispatches toggle action', async () => { - const dispatch = vi.fn(); - const ctx = makeContext({ componentId: 'tl1', dispatch }); - await tasklistHandler.onAction?.(ctx, 'toggle', { itemId: 'i1', checked: true }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'FIELD_CHANGED', - componentId: 'tl1', - field: 'i1', - value: true, - }); - }); }); describe('tableHandler', () => { @@ -131,18 +97,6 @@ describe('calloutHandler', () => { }); expect(state.values.dismissed).toBe(false); }); - - it('dispatches dismiss action', async () => { - const dispatch = vi.fn(); - const ctx = makeContext({ componentId: 'c1', dispatch }); - await calloutHandler.onAction?.(ctx, 'dismiss', undefined); - expect(dispatch).toHaveBeenCalledWith({ - type: 'FIELD_CHANGED', - componentId: 'c1', - field: 'dismissed', - value: true, - }); - }); }); describe('approvalGateHandler', () => { @@ -155,35 +109,6 @@ describe('approvalGateHandler', () => { }); expect(state.values.status).toBe('pending'); }); - - it('dispatches approval with policy check', async () => { - const dispatch = vi.fn(); - const enforce = vi.fn(); - const ctx = makeContext({ componentId: 'gate1', dispatch, policy: { enforce } }); - await approvalGateHandler.onAction?.(ctx, 'approve', { actor: { id: 'u1', role: 'manager' } }); - expect(enforce).toHaveBeenCalledWith('approval_grant'); - expect(dispatch).toHaveBeenCalledWith({ - type: 'APPROVAL_GRANTED', - componentId: 'gate1', - actor: { id: 'u1', role: 'manager' }, - }); - }); - - it('dispatches denial with reason', async () => { - const dispatch = vi.fn(); - const enforce = vi.fn(); - const ctx = makeContext({ componentId: 'gate1', dispatch, policy: { enforce } }); - await approvalGateHandler.onAction?.(ctx, 'deny', { - actor: { id: 'u2' }, - reason: 'Not ready', - }); - expect(dispatch).toHaveBeenCalledWith({ - type: 'APPROVAL_DENIED', - componentId: 'gate1', - actor: { id: 'u2' }, - reason: 'Not ready', - }); - }); }); describe('webhookHandler', () => { @@ -197,15 +122,6 @@ describe('webhookHandler', () => { }); expect(state.values.status).toBe('idle'); }); - - it('enforces policy before execution', async () => { - const enforce = vi.fn(); - const dispatch = vi.fn(); - const ctx = makeContext({ componentId: 'wh1', dispatch, policy: { enforce } }); - await webhookHandler.onAction?.(ctx, 'execute', undefined); - expect(enforce).toHaveBeenCalledWith('webhook_call'); - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'INTEGRATION_CALLED' })); - }); }); describe('registerAllCoreAttachables', () => { diff --git a/packages/cli/app/src/lib/parse-markdown.ts b/packages/cli/app/src/lib/parse-markdown.ts index 04d1a96..16110a2 100644 --- a/packages/cli/app/src/lib/parse-markdown.ts +++ b/packages/cli/app/src/lib/parse-markdown.ts @@ -22,7 +22,9 @@ export async function parseMarkdown( existingStore?: DocumentStore, ): Promise<{ ast: MdmaRoot; store: DocumentStore }> { const tree = processor.parse(markdown); - const ast = (await processor.run(tree)) as MdmaRoot; + // Pass the source to run() so the mdma transform can distinguish a still-streaming (unterminated) + // fence from a complete block and avoid flashing "Unknown component type" mid-stream. + const ast = (await processor.run(tree, markdown)) as MdmaRoot; if (existingStore) { existingStore.updateAst(ast); return { ast, store: existingStore }; diff --git a/packages/parser/src/transform/extract-mdma-blocks.ts b/packages/parser/src/transform/extract-mdma-blocks.ts index 5deace8..34aa950 100644 --- a/packages/parser/src/transform/extract-mdma-blocks.ts +++ b/packages/parser/src/transform/extract-mdma-blocks.ts @@ -14,6 +14,7 @@ export interface ExtractOptions { export function extractMdmaBlocks(tree: Root, file: VFile, options: ExtractOptions = {}): MdmaRoot { const ids = new Set(); + const source = typeof file.value === 'string' ? file.value : String(file.value ?? ''); visit(tree, 'code', (node: Code, index, parent) => { if (node.lang !== MDMA_LANG_TAG) return; @@ -41,6 +42,15 @@ export function extractMdmaBlocks(tree: Root, file: VFile, options: ExtractOptio return; } + // 2b. While the fence is still open (streaming), a valid-YAML-but-unknown-type block is almost + // always a half-streamed type name (e.g. `approval-gat` before `approval-gate` completes). + // Leave it as a pending code node so the renderer shows a loading skeleton instead of flashing + // "Unknown component type". Once the fence closes, a still-unknown type falls through and + // renders the proper error. + if (validation.unknownType && !isFenceTerminated(node, source)) { + return; + } + // 3. Check for duplicate IDs const id = validation.component.id; if (ids.has(id)) { @@ -63,3 +73,25 @@ export function extractMdmaBlocks(tree: Root, file: VFile, options: ExtractOptio return tree as unknown as MdmaRoot; } + +/** + * Is the fenced code block terminated by a closing fence? During streaming, remark auto-closes an + * open fence at EOF, so the mdast `code` node exists before its closing ``` has arrived. We detect + * that by slicing the original source for this node and checking whether its last non-empty line is + * a bare fence. Defaults to `true` when position offsets are unavailable, so non-streaming parses + * (and any consumer without position tracking) are unaffected. + */ +function isFenceTerminated(node: Code, source: string): boolean { + const start = node.position?.start?.offset; + const end = node.position?.end?.offset; + if (start == null || end == null || !source) return true; + + const block = source.slice(start, end); + const lines = block.split('\n'); + for (let i = lines.length - 1; i >= 1; i--) { + const trimmed = lines[i].trim(); + if (trimmed === '') continue; + return /^(`{3,}|~{3,})$/.test(trimmed); + } + return false; +} diff --git a/packages/parser/src/transform/validate-component.ts b/packages/parser/src/transform/validate-component.ts index bb9a198..d7d38ed 100644 --- a/packages/parser/src/transform/validate-component.ts +++ b/packages/parser/src/transform/validate-component.ts @@ -9,7 +9,7 @@ import { ErrorCodes } from '../errors/error-codes.js'; import type { Point } from 'unist'; export type ValidateComponentResult = - | { ok: true; component: MdmaComponent } + | { ok: true; component: MdmaComponent; unknownType?: boolean } | { ok: false; errors: MdmaParseError[] }; export function validateComponent( @@ -58,6 +58,7 @@ export function validateComponent( if (!componentSchemaRegistry.has(type)) { return { ok: true, + unknownType: true, component: { id: typeof data.id === 'string' ? data.id : `unknown-${type}`, type, diff --git a/packages/parser/tests/plugin.test.ts b/packages/parser/tests/plugin.test.ts index 29b3fe1..08e8ea5 100644 --- a/packages/parser/tests/plugin.test.ts +++ b/packages/parser/tests/plugin.test.ts @@ -117,6 +117,36 @@ describe('remarkMdma plugin', () => { }); }); + describe('streaming (unterminated fence)', () => { + const FENCE = '```'; + + it('keeps an unknown-type block pending while its fence is still open', () => { + // Mid-stream: `approval-gat` is a truncated `approval-gate` and the closing fence hasn't + // arrived yet. It must NOT become a block (which would flash "Unknown component type"). + const streaming = `intro\n\n${FENCE}mdma\nid: g\ntype: approval-gat`; + const { root } = parse(streaming); + expect(getMdmaBlocks(root)).toHaveLength(0); + expect(root.children.some((c) => (c as { type: string }).type === 'code')).toBe(true); + }); + + it('converts the unknown-type block once the fence closes (real error surfaces)', () => { + const done = `intro\n\n${FENCE}mdma\nid: g\ntype: totally-unknown\n${FENCE}\n`; + const { root } = parse(done); + const blocks = getMdmaBlocks(root); + expect(blocks).toHaveLength(1); + expect(blocks[0].component.type).toBe('totally-unknown'); + }); + + it('still renders a KNOWN valid type live, even with the fence open', () => { + // We only withhold unknown types — valid known types keep rendering live during streaming. + const streaming = `${FENCE}mdma\nid: b\ntype: button\ntext: Go\nonAction: go`; + const { root } = parse(streaming); + const blocks = getMdmaBlocks(root); + expect(blocks).toHaveLength(1); + expect(blocks[0].component.type).toBe('button'); + }); + }); + describe('edge cases', () => { it('ignores non-mdma code blocks', () => { const md = '```javascript\nconsole.log("hello");\n```'; diff --git a/packages/renderer-react/src/components/TasklistRenderer.tsx b/packages/renderer-react/src/components/TasklistRenderer.tsx index d80fb5f..dc72ecd 100644 --- a/packages/renderer-react/src/components/TasklistRenderer.tsx +++ b/packages/renderer-react/src/components/TasklistRenderer.tsx @@ -18,14 +18,32 @@ export const TasklistRenderer = memo(function TasklistRenderer({ + onChange={(e) => { + const checked = e.target.checked; dispatch({ type: 'FIELD_CHANGED', componentId: component.id, field: item.id, - value: e.target.checked, - }) - } + value: checked, + }); + // Fire onComplete only on the transition into all-items-checked, mirroring + // how FormRenderer emits ACTION_TRIGGERED on submit. + if (component.onComplete) { + const wasComplete = component.items.every((it) => + Boolean(componentState?.values[it.id]), + ); + const isComplete = component.items.every((it) => + it.id === item.id ? checked : Boolean(componentState?.values[it.id]), + ); + if (!wasComplete && isComplete) { + dispatch({ + type: 'ACTION_TRIGGERED', + componentId: component.id, + actionId: component.onComplete, + }); + } + } + }} /> {item.text} diff --git a/packages/renderer-react/src/components/WebhookRenderer.tsx b/packages/renderer-react/src/components/WebhookRenderer.tsx index e2b7841..9cda9b5 100644 --- a/packages/renderer-react/src/components/WebhookRenderer.tsx +++ b/packages/renderer-react/src/components/WebhookRenderer.tsx @@ -1,10 +1,12 @@ -import { memo } from 'react'; +import { memo, useState } from 'react'; import type { MdmaBlockRendererProps } from '../renderers/renderer-registry.js'; export const WebhookRenderer = memo(function WebhookRenderer({ component, componentState, + dispatch, }: MdmaBlockRendererProps) { + const [triggered, setTriggered] = useState(false); if (component.type !== 'webhook') return null; const status = (componentState?.values.status as string) ?? 'idle'; @@ -13,8 +15,27 @@ export const WebhookRenderer = memo(function WebhookRenderer({
{component.label && {component.label}} - Webhook: {status} + Webhook: {triggered ? 'triggered' : status} + {!triggered && status === 'idle' && ( + + )}
); }); diff --git a/packages/runtime/src/core/document-store.ts b/packages/runtime/src/core/document-store.ts index 4229a09..66e97f9 100644 --- a/packages/runtime/src/core/document-store.ts +++ b/packages/runtime/src/core/document-store.ts @@ -18,6 +18,14 @@ export interface DocumentStoreOptions { environment?: string; policy?: import('@mobile-reality/mdma-spec').Policy; registry?: AttachableRegistry; + /** + * Seed component values when the store is created (e.g. restoring a persisted conversation + * fetched from a backend). Keyed by component id → its `values` map, mirroring the shape under + * each entry of `getState().components`. Overlays onto AST defaults without emitting audit + * events, and is applied only to freshly-created components, so it never clobbers in-flight + * edits during streaming re-parses. + */ + initialState?: Record>; } export interface DocumentStore { @@ -51,6 +59,28 @@ export function createDocumentStore( components: new Map(), }; + const initialState = options.initialState; + + /** + * Overlay hydrated values (e.g. restored from a persisted conversation) onto a freshly-built + * component, seeding both its `values` and the matching bindings the same way a `FIELD_CHANGED` + * would — but without emitting an audit event or marking the component `touched`. + */ + function applyInitialState(compState: ComponentState) { + const hydrated = initialState?.[compState.id]; + if (!hydrated) return; + if (!state.bindings[compState.id] || typeof state.bindings[compState.id] !== 'object') { + state.bindings[compState.id] = {}; + } + const nested = state.bindings[compState.id] as Record; + for (const [key, value] of Object.entries(hydrated)) { + compState.values[key] = value; + nested[key] = value; + // Flat binding is legacy back-compat; don't clobber a name another component already claimed. + if (!(key in state.bindings)) state.bindings[key] = value; + } + } + // Build redaction context from AST const redactionCtx: RedactionContext = { sensitiveComponents: new Set(), @@ -92,6 +122,7 @@ export function createDocumentStore( } } + applyInitialState(compState); state.components.set(comp.id, compState); } } @@ -268,10 +299,15 @@ export function createDocumentStore( if (!isMdmaBlock(child)) continue; const comp = child.component; - // If this component already exists, keep its state - if (state.components.has(comp.id)) continue; + // If this component already exists with the same type, keep its state — this preserves + // in-flight values/touched/focus across streamed re-parses. If the type changed, an + // earlier partial parse produced a placeholder/truncated type (e.g. `approval-gat` before + // the streamed `approval-gate` completed), so fall through and re-initialize from scratch. + const existing = state.components.get(comp.id); + if (existing && existing.type === comp.type) continue; + redactionCtx.sensitiveComponents.delete(comp.id); - // New component — initialize with defaults + // New (or retyped) component — initialize with defaults const compState: ComponentState = { id: comp.id, type: comp.type, @@ -308,6 +344,7 @@ export function createDocumentStore( } } + applyInitialState(compState); state.components.set(comp.id, compState); } diff --git a/packages/runtime/tests/document-store.test.ts b/packages/runtime/tests/document-store.test.ts index 12e327a..f693f6b 100644 --- a/packages/runtime/tests/document-store.test.ts +++ b/packages/runtime/tests/document-store.test.ts @@ -33,6 +33,54 @@ describe('DocumentStore', () => { expect(state.components.get('form1')?.type).toBe('form'); }); + it('hydrates component values from initialState without forging audit events', () => { + const ast = makeAst([ + { + id: 'form1', + type: 'form', + sensitive: false, + disabled: false, + visible: true, + fields: [ + { name: 'email', type: 'email', label: 'Email' }, + { name: 'name', type: 'text', label: 'Name' }, + ], + }, + ]); + + const store = createDocumentStore(ast, { + initialState: { form1: { email: 'a@b.com', name: 'Alice' } }, + }); + + const comp = store.getComponentState('form1'); + expect(comp?.values.email).toBe('a@b.com'); + expect(comp?.values.name).toBe('Alice'); + // Hydration is a restore, not a user interaction — no touched flag, no forged events. + expect(comp?.touched).toBe(false); + expect(store.resolveBinding('{{email}}')).toBe('a@b.com'); + expect(store.getEventLog().entries()).toHaveLength(0); + }); + + it('preserves hydrated values across a streaming updateAst re-parse', () => { + const comps = [ + { + id: 'form1', + type: 'form', + sensitive: false, + disabled: false, + visible: true, + fields: [{ name: 'email', type: 'email', label: 'Email' }], + }, + ]; + const store = createDocumentStore(makeAst(comps), { + initialState: { form1: { email: 'a@b.com' } }, + }); + + // A later streamed re-parse of the same document must not wipe the hydrated value. + store.updateAst(makeAst(comps)); + expect(store.getComponentState('form1')?.values.email).toBe('a@b.com'); + }); + it('dispatches FIELD_CHANGED and updates state', () => { const ast = makeAst([ { @@ -196,3 +244,28 @@ describe('DocumentStore', () => { expect(comp?.values.deniedReason).toBe('Not ready'); }); }); + +describe('DocumentStore.updateAst', () => { + it('preserves in-flight state when a component keeps the same id and type', () => { + const store = createDocumentStore( + makeAst([{ id: 'f', type: 'form', fields: [{ name: 'x', type: 'text', label: 'X' }] }]), + ); + store.dispatch({ type: 'FIELD_CHANGED', componentId: 'f', field: 'x', value: 'typed' }); + + // Re-parse of the same component (e.g. a later streamed chunk) must not wipe user input. + store.updateAst( + makeAst([{ id: 'f', type: 'form', fields: [{ name: 'x', type: 'text', label: 'X changed' }] }]), + ); + expect(store.getComponentState('f')?.values.x).toBe('typed'); + }); + + it('re-initializes a component when its type changes between parses (streaming placeholder)', () => { + // Mimics streaming: an early partial parse yields a truncated/unknown type for `deploy-gate`. + const store = createDocumentStore(makeAst([{ id: 'deploy-gate', type: 'approval-gat' }])); + expect(store.getComponentState('deploy-gate')?.type).toBe('approval-gat'); + + // A later parse resolves the real type — the store must adopt it, not freeze the placeholder. + store.updateAst(makeAst([{ id: 'deploy-gate', type: 'approval-gate', title: 'Approve deploy' }])); + expect(store.getComponentState('deploy-gate')?.type).toBe('approval-gate'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6d79c0..98cc62b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,49 @@ importers: specifier: ^4.19.0 version: 4.21.0 + packages/agui: + dependencies: + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + unified: + specifier: ^11.0.0 + version: 11.0.5 + devDependencies: + '@ag-ui/client': + specifier: ^0.0.57 + version: 0.0.57 + '@ag-ui/core': + specifier: ^0.0.57 + version: 0.0.57 + '@mobile-reality/mdma-attachables-core': + specifier: workspace:* + version: link:../attachables-core + '@mobile-reality/mdma-parser': + specifier: workspace:* + version: link:../parser + '@mobile-reality/mdma-renderer-react': + specifier: workspace:* + version: link:../renderer-react + '@mobile-reality/mdma-runtime': + specifier: workspace:* + version: link:../runtime + '@mobile-reality/mdma-spec': + specifier: workspace:* + version: link:../spec + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: ^19.0.0 + version: 19.2.4 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + packages/attachables-core: dependencies: '@mobile-reality/mdma-runtime': @@ -380,6 +423,18 @@ packages: '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@ag-ui/client@0.0.57': + resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==} + + '@ag-ui/core@0.0.57': + resolution: {integrity: sha512-gho1OWjNE6E3Rl7ZEZ1wr2CEpUHjLFU0FqzCZZk439TicLu+BfLCMkMokB07bMGlRmbJ60hM6LW60iOVauCx+Q==} + + '@ag-ui/encoder@0.0.57': + resolution: {integrity: sha512-ifD9NctR4xyPDR58xF9GK1bj/S8oECFkTeDfuYD8tXdbcOstIJ2TOqU2zhiCKnw7Vw+zR9Qv3TbsM9E7Gi9X3Q==} + + '@ag-ui/proto@0.0.57': + resolution: {integrity: sha512-pPENOZt0P6ibH8sCTgq05wLYXi5t3P9B5r/1bWYehXjUxtyOdnukSlWM++SsCIwUXsQdm/b3aBgGjEeTF7RenA==} + '@ai-sdk/gateway@3.0.110': resolution: {integrity: sha512-sbv8+1L9/BRKydn8dMNwoMQKupA4iLJ9N+yvxgW6wMQ/94UepDf3FeYWMj/dLdzolAHZ6izRUP4s5WqQkmJ2Zg==} engines: {node: '>=18'} @@ -920,6 +975,9 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + '@cacheable/utils@2.4.1': resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} @@ -1923,6 +1981,10 @@ packages: '@posthog/core@1.23.1': resolution: {integrity: sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==} + '@protobuf-ts/protoc@2.11.1': + resolution: {integrity: sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==} + hasBin: true + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -3113,6 +3175,9 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + complex.js@2.4.3: resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==} @@ -3681,6 +3746,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-patch@3.1.1: + resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -5280,6 +5348,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -5781,6 +5852,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + untruncate-json@0.0.1: + resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==} + unzipper@0.12.3: resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==} @@ -5809,6 +5883,10 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@13.0.2: resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} hasBin: true @@ -6067,6 +6145,34 @@ snapshots: '@acemir/cssom@0.9.31': optional: true + '@ag-ui/client@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/encoder': 0.0.57 + '@ag-ui/proto': 0.0.57 + '@types/uuid': 10.0.0 + compare-versions: 6.1.1 + fast-json-patch: 3.1.1 + rxjs: 7.8.1 + untruncate-json: 0.0.1 + uuid: 11.1.1 + zod: 3.25.76 + + '@ag-ui/core@0.0.57': + dependencies: + zod: 3.25.76 + + '@ag-ui/encoder@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/proto': 0.0.57 + + '@ag-ui/proto@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@bufbuild/protobuf': 2.12.1 + '@protobuf-ts/protoc': 2.11.1 + '@ai-sdk/gateway@3.0.110(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -7254,6 +7360,8 @@ snapshots: css-tree: 3.1.0 optional: true + '@bufbuild/protobuf@2.12.1': {} + '@cacheable/utils@2.4.1': dependencies: hashery: 1.5.1 @@ -8233,6 +8341,8 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@protobuf-ts/protoc@2.11.1': {} + '@protobufjs/aspromise@1.1.2': optional: true @@ -9083,8 +9193,7 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@types/uuid@10.0.0': - optional: true + '@types/uuid@10.0.0': {} '@types/webidl-conversions@7.0.3': optional: true @@ -9514,6 +9623,8 @@ snapshots: commander@5.1.0: {} + compare-versions@6.1.1: {} + complex.js@2.4.3: {} compressible@2.0.18: @@ -10035,6 +10146,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-patch@3.1.1: {} + fast-safe-stringify@2.1.1: {} fast-string-truncated-width@3.0.3: {} @@ -12062,6 +12175,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -12626,6 +12743,8 @@ snapshots: unpipe@1.0.0: {} + untruncate-json@0.0.1: {} + unzipper@0.12.3: dependencies: bluebird: 3.7.2 @@ -12658,6 +12777,8 @@ snapshots: uuid@10.0.0: optional: true + uuid@11.1.1: {} + uuid@13.0.2: optional: true