diff --git a/apps/cockpit/e2e/all-examples-smoke.spec.ts b/apps/cockpit/e2e/all-examples-smoke.spec.ts index 8aa8f8e20..0b90b2e87 100644 --- a/apps/cockpit/e2e/all-examples-smoke.spec.ts +++ b/apps/cockpit/e2e/all-examples-smoke.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; /** * Smoke test that verifies every capability example's Angular app is running - * and can render the chat interface. Requires all 14 Angular apps to be served. + * and can render the chat interface. Requires all 15 Angular apps to be served. * * Run with: npx playwright test apps/cockpit/e2e/all-examples-smoke.spec.ts * @@ -26,6 +26,7 @@ const EXAMPLES = [ { name: 'da-memory', port: 4313, selector: 'app-da-memory' }, { name: 'skills', port: 4314, selector: 'app-skills' }, { name: 'sandboxes', port: 4315, selector: 'app-sandboxes' }, + { name: 'c-a2ui', port: 4511, selector: 'app-a2ui' }, ] as const; test.describe('All Examples Smoke Test', () => { diff --git a/apps/cockpit/scripts/capability-registry.ts b/apps/cockpit/scripts/capability-registry.ts index c504b7a6d..a5691aaea 100644 --- a/apps/cockpit/scripts/capability-registry.ts +++ b/apps/cockpit/scripts/capability-registry.ts @@ -45,6 +45,7 @@ export const capabilities: readonly Capability[] = [ { id: 'c-generative-ui', product: 'chat', topic: 'generative-ui', angularProject: 'cockpit-chat-generative-ui-angular', port: 4508, pythonDir: 'cockpit/chat/generative-ui/python', graphName: 'c-generative-ui' }, { id: 'c-debug', product: 'chat', topic: 'debug', angularProject: 'cockpit-chat-debug-angular', port: 4509, pythonDir: 'cockpit/chat/debug/python', graphName: 'c-debug' }, { id: 'c-theming', product: 'chat', topic: 'theming', angularProject: 'cockpit-chat-theming-angular', port: 4510, pythonDir: 'cockpit/chat/theming/python', graphName: 'c-theming' }, + { id: 'c-a2ui', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' }, ] as const; export function findCapability(id: string): Capability | undefined { diff --git a/apps/cockpit/src/lib/route-resolution.ts b/apps/cockpit/src/lib/route-resolution.ts index da020cf30..8585863e0 100644 --- a/apps/cockpit/src/lib/route-resolution.ts +++ b/apps/cockpit/src/lib/route-resolution.ts @@ -33,6 +33,7 @@ import { chatTimelinePythonModule } from '../../../../cockpit/chat/timeline/pyth import { chatGenerativeUiPythonModule } from '../../../../cockpit/chat/generative-ui/python/src/index'; import { chatDebugPythonModule } from '../../../../cockpit/chat/debug/python/src/index'; import { chatThemingPythonModule } from '../../../../cockpit/chat/theming/python/src/index'; +import { chatA2uiPythonModule } from '../../../../cockpit/chat/a2ui/python/src/index'; export interface ResolveCockpitEntryOptions { manifest: CockpitManifestEntry[]; @@ -102,6 +103,7 @@ const capabilityModules = [ chatGenerativeUiPythonModule, chatDebugPythonModule, chatThemingPythonModule, + chatA2uiPythonModule, ]; export const toCockpitPath = (entry: CockpitManifestEntry): string => diff --git a/apps/website/content/docs/render/a2ui/catalog.mdx b/apps/website/content/docs/render/a2ui/catalog.mdx index bd546c76e..68ea3fec1 100644 --- a/apps/website/content/docs/render/a2ui/catalog.mdx +++ b/apps/website/content/docs/render/a2ui/catalog.mdx @@ -134,9 +134,9 @@ For data-driven lists, use the `A2uiChildTemplate` form instead of static `child ## Interactive Components -Interactive components support **two-way data binding** and **button actions**. They use two special props: +Interactive components support **two-way data binding** and **button actions**. They receive two special injected props: -- `_bindings` — a `Record` mapping prop names to JSON Pointer paths in the data model. When the user changes the value, the component emits an `a2ui:datamodel:` event to update the model. +- `_bindings` — a `Record` auto-populated by `surfaceToSpec()` from path references in the component definition. Maps prop names to JSON Pointer paths. When the user changes a bound value, the component emits an `a2ui:datamodel:` event. Agents do **not** write `_bindings` directly — they use path references (e.g., `{"path": "/name"}`) and the render pipeline extracts bindings automatically. - `emit` — injected by the render engine; components call it to dispatch events back to the chat. ### Button @@ -183,10 +183,10 @@ A single-line text input with optional label and placeholder. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Input label | -| `value` | `string` | Current value (bind via `_bindings`) | +| `value` | `string` | Current value (resolved from path reference) | | `placeholder` | `string` | Placeholder text | | `validationResult` | `A2uiValidationResult` | Validation state — shows errors below input when invalid | -| `_bindings` | `Record` | Bind `value` to a data model path | +| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | | `emit` | injected | Event emitter provided by the render engine | ```json @@ -194,11 +194,12 @@ A single-line text input with optional label and placeholder. "id": "name-field", "component": "TextField", "label": "Your name", - "value": {"path": "/name"}, - "_bindings": {"value": "/name"} + "value": {"path": "/name"} } ``` +The path reference `{"path": "/name"}` is resolved by `surfaceToSpec()`, which also populates `_bindings` automatically. + ### CheckBox A labeled checkbox with two-way binding for its checked state. @@ -210,9 +211,9 @@ A labeled checkbox with two-way binding for its checked state. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Checkbox label | -| `checked` | `boolean` | Current checked state (bind via `_bindings`) | +| `checked` | `boolean` | Current checked state (resolved from path reference) | | `validationResult` | `A2uiValidationResult` | Validation state — shows errors below checkbox when invalid | -| `_bindings` | `Record` | Bind `checked` to a data model path | +| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | | `emit` | injected | Event emitter provided by the render engine | ### ChoicePicker @@ -227,9 +228,9 @@ A dropdown select control with a list of string options. |------|------|-------------| | `label` | `string` | Select label | | `options` | `string[]` | List of available options | -| `selected` | `string` | Currently selected value (bind via `_bindings`) | +| `selected` | `string` | Currently selected value (resolved from path reference) | | `validationResult` | `A2uiValidationResult` | Validation state — shows errors below dropdown when invalid | -| `_bindings` | `Record` | Bind `selected` to a data model path | +| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | | `emit` | injected | Event emitter provided by the render engine | ### DateTimeInput @@ -243,12 +244,12 @@ A date, time, or datetime input with two-way binding. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Input label | -| `value` | `string` | Current value (bind via `_bindings`) | +| `value` | `string` | Current value (resolved from path reference) | | `inputType` | `'date' \| 'time' \| 'datetime-local'` | HTML input type. Defaults to `'date'` | | `min` | `string` | Minimum allowed value | | `max` | `string` | Maximum allowed value | | `validationResult` | `A2uiValidationResult` | Validation state — shows errors below input when invalid | -| `_bindings` | `Record` | Bind `value` to a data model path | +| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | | `emit` | injected | Event emitter provided by the render engine | ```json @@ -257,8 +258,7 @@ A date, time, or datetime input with two-way binding. "component": "DateTimeInput", "label": "Appointment date", "value": {"path": "/appointmentDate"}, - "inputType": "date", - "_bindings": {"value": "/appointmentDate"} + "inputType": "date" } ``` @@ -278,7 +278,7 @@ A range slider input with two-way binding. | `max` | `number` | Maximum value | | `step` | `number` | Step increment | | `validationResult` | `A2uiValidationResult` | Validation state — shows errors below slider when invalid | -| `_bindings` | `Record` | Bind `value` to a data model path | +| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | | `emit` | injected | Event emitter provided by the render engine | ```json @@ -289,8 +289,7 @@ A range slider input with two-way binding. "value": {"path": "/volume"}, "min": 0, "max": 100, - "step": 1, - "_bindings": {"value": "/volume"} + "step": 1 } ``` @@ -308,7 +307,7 @@ A tabbed container that shows one child panel at a time based on the selected ta |------|------|-------------| | `tabs` | `{label: string, childKeys: string[]}[]` | Tab definitions with labels and child component IDs | | `selected` | `number` | Currently selected tab index. Defaults to `0` | -| `_bindings` | `Record` | Bind `selected` to a data model path | +| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | | `spec` | `Spec` | Injected automatically by the render engine | | `emit` | injected | Event emitter provided by the render engine | @@ -320,8 +319,7 @@ A tabbed container that shows one child panel at a time based on the selected ta {"label": "Overview", "childKeys": ["overview-content"]}, {"label": "Details", "childKeys": ["detail-list"]} ], - "selected": 0, - "_bindings": {"selected": "/activeTab"} + "selected": {"path": "/activeTab"} } ``` @@ -350,8 +348,7 @@ A dialog overlay that renders child content when open. Supports an optional titl "title": "Confirm Action", "open": {"path": "/showConfirm"}, "childKeys": ["confirm-message", "confirm-buttons"], - "dismissible": true, - "_bindings": {"open": "/showConfirm"} + "dismissible": true } ``` diff --git a/cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts b/cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts index 4a5920756..13b9f4451 100644 --- a/cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts +++ b/cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts @@ -3,10 +3,15 @@ import { expect, test } from '@playwright/test'; test.describe('A2UI Example', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:4511'); - await page.waitForSelector('app-a2ui', { state: 'attached' }); + await page.waitForSelector('app-a2ui', { state: 'attached', timeout: 10000 }); }); test('renders the chat interface', async ({ page }) => { - await expect(page.locator('chat')).toBeVisible(); + await expect(page.locator('chat')).toBeVisible({ timeout: 5000 }); + }); + + test('displays input and send button', async ({ page }) => { + await expect(page.locator('input[name="prompt"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('button[type="submit"]')).toBeVisible({ timeout: 5000 }); }); }); diff --git a/cockpit/chat/a2ui/angular/src/environments/environment.development.ts b/cockpit/chat/a2ui/angular/src/environments/environment.development.ts index 75086c387..f5c053266 100644 --- a/cockpit/chat/a2ui/angular/src/environments/environment.development.ts +++ b/cockpit/chat/a2ui/angular/src/environments/environment.development.ts @@ -1,5 +1,5 @@ export const environment = { production: false, - langGraphApiUrl: 'http://localhost:4311/api', - a2uiAssistantId: 'a2ui_form', + langGraphApiUrl: 'http://localhost:4511/api', + a2uiAssistantId: 'c-a2ui', }; diff --git a/cockpit/chat/a2ui/angular/src/environments/environment.ts b/cockpit/chat/a2ui/angular/src/environments/environment.ts index f23ee1ad2..2919bf6d0 100644 --- a/cockpit/chat/a2ui/angular/src/environments/environment.ts +++ b/cockpit/chat/a2ui/angular/src/environments/environment.ts @@ -1,5 +1,5 @@ export const environment = { production: true, langGraphApiUrl: '/api', - a2uiAssistantId: 'a2ui_form', + a2uiAssistantId: 'c-a2ui', }; diff --git a/cockpit/chat/a2ui/python/docs/guide.md b/cockpit/chat/a2ui/python/docs/guide.md index 88c034f88..f9115b9df 100644 --- a/cockpit/chat/a2ui/python/docs/guide.md +++ b/cockpit/chat/a2ui/python/docs/guide.md @@ -75,12 +75,17 @@ Components bind to the data model using path references: ```json {"id": "name_field", "component": "TextField", - "label": "Name", "value": {"path": "/name"}, - "_bindings": {"value": "/name"}} + "label": "Name", "value": {"path": "/name"}} ``` -When the user types in the field, the value at `/name` in the data model -updates automatically via the render-lib StateStore. +The `surfaceToSpec` function auto-detects path references and populates +`_bindings` for each input component — agents do not write `_bindings` +directly. When the user changes a bound input, the component emits a +data model update event. + +**Known limitation:** Data model updates from user input do not currently +reflect to other components in real time. The agent can refresh state by +sending a new `updateDataModel` message. diff --git a/cockpit/chat/a2ui/python/langgraph.json b/cockpit/chat/a2ui/python/langgraph.json index 91e3ce740..2bc543406 100644 --- a/cockpit/chat/a2ui/python/langgraph.json +++ b/cockpit/chat/a2ui/python/langgraph.json @@ -1,6 +1,6 @@ { "graphs": { - "a2ui_form": "./src/graph.py:graph" + "c-a2ui": "./src/graph.py:graph" }, "dependencies": ["."], "python_version": "3.12", diff --git a/cockpit/chat/a2ui/python/prompts/a2ui.md b/cockpit/chat/a2ui/python/prompts/a2ui.md index 910a90c85..8d73b4e0b 100644 --- a/cockpit/chat/a2ui/python/prompts/a2ui.md +++ b/cockpit/chat/a2ui/python/prompts/a2ui.md @@ -1,62 +1,145 @@ -# A2UI Form Assistant +# A2UI Assistant -You are an assistant that demonstrates the A2UI (Agent-to-UI) protocol. +You are an assistant that builds interactive UIs using the A2UI (Agent-to-UI) protocol. -When the user asks you to create a form, contact card, or any interactive UI, -respond with A2UI JSONL — a sequence of newline-delimited JSON messages prefixed -with `---a2ui_JSON---`. +When the user asks you to create a form, dashboard, or any interactive UI, respond with A2UI JSONL — newline-delimited JSON messages prefixed with `---a2ui_JSON---`. -## A2UI JSONL Format +When the user sends a JSON message with `"version": "v0.9"` and an `"action"` field, that is a form submission event. Read the `action.context` object to see the submitted values and respond conversationally (in plain text/markdown, not A2UI). -Your entire response must start with the prefix line, then one JSON message per line: +## Response Format + +Your entire response must start with the prefix, then one JSON message per line: ``` ---a2ui_JSON--- -{"type":"createSurface","surfaceId":"contact","catalogId":"basic"} -{"type":"updateDataModel","surfaceId":"contact","value":{"name":"","email":"","department":"Engineering","consent":false}} -{"type":"updateComponents","surfaceId":"contact","components":[...]} +{"createSurface":{"surfaceId":"s1","catalogId":"basic","sendDataModel":true}} +{"updateDataModel":{"surfaceId":"s1","value":{"name":"","email":""}}} +{"updateComponents":{"surfaceId":"s1","components":[...]}} ``` +## Message Types + +| Message | Purpose | +|---------|---------| +| `createSurface` | Initialize a surface. Set `sendDataModel: true` to receive the full data model with form submissions. | +| `updateDataModel` | Set initial data model values at `/` (root). | +| `updateComponents` | Define the component tree. Each component has `id`, `component` type, and type-specific props. | + ## Available Components -| Component | Props | -|---------------|-----------------------------------------------------------------| -| Column | children (string[]) | -| Row | children (string[]), gap (string) | -| Card | title (string), children (string[]) | -| Text | content (string), variant ("body"\|"caption"\|"heading") | -| TextField | label (string), value (string/path), placeholder (string) | -| ChoicePicker | label (string), options (string[]), selected (string/path) | -| CheckBox | label (string), checked (boolean/path) | -| Button | label (string), variant ("primary"\|"borderless"), action | -| Divider | *(none)* | -| Image | url (string), alt (string) | -| Icon | name (string) | -| List | children (string[]) | -| Tabs | tabs ({label,childKeys}[]), selected (number) | -| Modal | title (string), open (boolean), children (string[]) | -| Video | url (string), poster (string), controls (boolean) | -| AudioPlayer | url (string), controls (boolean) | -| DateTimeInput | label (string), value (string/path), type (date\|time\|datetime-local) | -| Slider | label (string), value (number/path), min, max, step | +### Display + +| Component | Props | +|-----------|-------| +| `Text` | `text` (string) | +| `Image` | `url` (string), `alt` (string) | +| `Icon` | `name` (string — use emoji like "✓" or "⚠️") | +| `Divider` | *(none)* | + +### Layout + +| Component | Props | +|-----------|-------| +| `Column` | `children` (string[] of component IDs) | +| `Row` | `children` (string[] of component IDs) | +| `Card` | `title` (string), `children` (string[] of component IDs) | +| `List` | `children` (string[] of component IDs) | +| `Tabs` | `tabs` (array of `{label, childKeys}`), `selected` (number or path ref) | +| `Modal` | `title` (string), `open` (boolean or path ref), `children` (string[]), `dismissible` (boolean) | + +### Input + +| Component | Props | +|-----------|-------| +| `TextField` | `label` (string), `value` (string or path ref), `placeholder` (string) | +| `CheckBox` | `label` (string), `checked` (boolean or path ref) | +| `ChoicePicker` | `label` (string), `options` (string[]), `selected` (string or path ref) | +| `DateTimeInput` | `label` (string), `value` (string or path ref), `inputType` (`"date"` or `"time"` or `"datetime-local"`), `min` (string), `max` (string) | +| `Slider` | `label` (string), `value` (number or path ref), `min` (number), `max` (number), `step` (number) | + +### Interactive + +| Component | Props | +|-----------|-------| +| `Button` | `label` (string), `variant` (`"primary"` or `"borderless"`), `disabled` (boolean), `action` (Action object), `checks` (CheckRule[]) | + +### Media + +| Component | Props | +|-----------|-------| +| `Video` | `url` (string), `poster` (string), `autoplay` (boolean), `controls` (boolean) | +| `AudioPlayer` | `url` (string), `autoplay` (boolean), `controls` (boolean) | ## Data Model Binding -Use `{"path": "/form/fieldName"}` as a prop value to bind it to the data model. -When the user changes an input, the value at that path updates automatically. +Use `{"path": "/fieldName"}` as a prop value to bind it to the data model. When the user changes an input, the value at that path updates automatically. + +```json +{"id": "name", "component": "TextField", "label": "Name", "value": {"path": "/name"}} +``` + +Do NOT include a `_bindings` prop — the renderer generates bindings automatically from path references. ## Actions -Buttons can have an event action that sends data back to the agent: +Buttons can have an event action that sends data back to you: + +```json +{ + "action": { + "event": { + "name": "formSubmit", + "context": { + "name": {"path": "/name"}, + "email": {"path": "/email"} + } + } + } +} +``` + +Context values can be path references (resolved at click time) or literal values. + +## Validation (checks) + +Input components and buttons can have a `checks` array for client-side validation. Each check has a `condition` and an error `message`. If any check fails, the button is disabled and error messages display. + +```json +{ + "checks": [ + { + "condition": {"call": "required", "args": {"value": {"path": "/name"}}}, + "message": "Name is required" + } + ] +} +``` + +Built-in validation functions: `required`, `email`, `regex`, `length`, `numeric`. + +Compose with `and`, `or`, `not`: ```json -{"action": {"event": {"name": "formSubmit", "context": {"formId": "contact"}}}} +{ + "condition": { + "call": "and", + "args": { + "values": [ + {"call": "required", "args": {"value": {"path": "/name"}}}, + {"call": "email", "args": {"value": {"path": "/email"}}} + ] + } + }, + "message": "Name and valid email required" +} ``` ## Rules 1. Always start with `---a2ui_JSON---` on the first line. 2. One JSON message per line, no trailing commas or extra whitespace. -3. Always include `createSurface` first, then `updateDataModel`, then `updateComponents`. +3. Always send `createSurface` first, then `updateDataModel`, then `updateComponents`. 4. Every component referenced in `children` must have a matching `id` in the components array. 5. The root component must have `id: "root"`. +6. Do NOT include `_bindings` in component definitions. +7. When responding to a form submission (v0.9 action message), respond in plain markdown — do NOT emit A2UI JSONL. diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index 07f1e5613..8db85fc66 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -1,120 +1,36 @@ """ -A2UI Contact Form Graph +A2UI Chat Graph -Demonstrates the A2UI (Agent-to-UI) protocol by streaming JSONL that -builds an interactive contact form on the Angular frontend. +A LangGraph StateGraph that generates A2UI JSONL responses using an LLM. +The Angular frontend detects the ---a2ui_JSON--- prefix and renders +interactive surfaces from the streamed component definitions. """ -import json +from pathlib import Path from langgraph.graph import StateGraph, MessagesState, END -from langchain_core.messages import AIMessage +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage -A2UI_PREFIX = "---a2ui_JSON---" - -CONTACT_FORM_JSONL = A2UI_PREFIX + "\n" + "\n".join([ - json.dumps({"type": "createSurface", "surfaceId": "contact", "catalogId": "basic", "sendDataModel": True}), - json.dumps({"type": "updateDataModel", "surfaceId": "contact", "value": { - "name": "", "email": "", "department": "Engineering", "consent": False, - }}), - json.dumps({"type": "updateComponents", "surfaceId": "contact", "components": [ - {"id": "root", "component": "Column", "children": ["card"]}, - {"id": "card", "component": "Card", "title": "Contact Us", "children": [ - "name_field", "email_field", "dept_picker", "consent_check", "divider", "submit_btn", - ]}, - {"id": "name_field", "component": "TextField", - "label": "Name", "value": {"path": "/name"}, "placeholder": "Your full name", - "_bindings": {"value": "/name"}, - "checks": [ - {"condition": {"call": "required", "args": {"value": {"path": "/name"}}}, - "message": "Name is required"}, - ]}, - {"id": "email_field", "component": "TextField", - "label": "Email", "value": {"path": "/email"}, "placeholder": "you@company.com", - "_bindings": {"value": "/email"}, - "checks": [ - {"condition": {"call": "required", "args": {"value": {"path": "/email"}}}, - "message": "Email is required"}, - {"condition": {"call": "email", "args": {"value": {"path": "/email"}}}, - "message": "Must be a valid email address"}, - ]}, - {"id": "dept_picker", "component": "ChoicePicker", - "label": "Department", - "options": ["Engineering", "Sales", "Support", "Marketing"], - "selected": {"path": "/department"}, - "_bindings": {"selected": "/department"}}, - {"id": "consent_check", "component": "CheckBox", - "label": "I agree to be contacted", "checked": {"path": "/consent"}, - "_bindings": {"checked": "/consent"}}, - {"id": "divider", "component": "Divider"}, - {"id": "submit_btn", "component": "Button", - "label": "Submit", - "checks": [ - {"condition": {"call": "and", "args": {"values": [ - {"call": "required", "args": {"value": {"path": "/name"}}}, - {"call": "email", "args": {"value": {"path": "/email"}}}, - {"path": "/consent"}, - ]}}, - "message": "Complete all required fields and agree to be contacted"}, - ], - "action": {"event": {"name": "formSubmit", "context": { - "name": {"path": "/name"}, - "email": {"path": "/email"}, - "department": {"path": "/department"}, - }}}}, - ]}), -]) +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" def build_a2ui_graph(): """ - Two-node graph: - - create_form: emits the A2UI contact form surface - - handle_event: responds to form submission events + Single-node graph that invokes an LLM with the A2UI system prompt. + The LLM generates A2UI JSONL that builds interactive surfaces. """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) - async def create_form(state: MessagesState) -> dict: - last = state["messages"][-1] - - # If this is a v0.9 action message, route to event handling - try: - payload = json.loads(last.content) - if isinstance(payload, dict) and payload.get("version") == "v0.9" and "action" in payload: - return await handle_event(state, payload) - except (json.JSONDecodeError, AttributeError): - pass - - # First message — emit the contact form - return {"messages": [AIMessage(content=CONTACT_FORM_JSONL)]} - - async def handle_event(state: MessagesState, payload: dict) -> dict: - action = payload["action"] - context = action.get("context", {}) - name = context.get("name", "Unknown") - email = context.get("email", "not provided") - department = context.get("department", "not specified") - - # Full data model is available via metadata when sendDataModel is true. - # Use it when you need values beyond what context provides. - data_model = ( # noqa: F841 - payload.get("metadata", {}) - .get("a2uiClientDataModel", {}) - .get("surfaces", {}) - .get(action["surfaceId"], {}) - ) - - return {"messages": [AIMessage( - content=( - f"Thanks **{name}**! We received your submission:\n\n" - f"- **Email:** {email}\n" - f"- **Department:** {department}\n\n" - f"We'll be in touch soon." - ), - )]} + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "a2ui.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} graph = StateGraph(MessagesState) - graph.add_node("create_form", create_form) - graph.set_entry_point("create_form") - graph.add_edge("create_form", END) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) return graph.compile() diff --git a/docs/superpowers/plans/2026-04-10-a2ui-cockpit-quality.md b/docs/superpowers/plans/2026-04-10-a2ui-cockpit-quality.md new file mode 100644 index 000000000..5c49faa59 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-a2ui-cockpit-quality.md @@ -0,0 +1,650 @@ +# A2UI Cockpit Example — Production Quality Pass + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the A2UI cockpit example production-ready: register in cockpit, fix library issues, convert to LLM-backed agent, ensure e2e coverage. + +**Architecture:** Add `_bindings` to surfaceToSpec's RESERVED_KEYS (library fix). Register A2UI in the cockpit's four integration points (manifest, capability registry, route resolution, e2e smoke). Convert the Python graph from hardcoded JSONL to LLM-backed generation with a corrected system prompt. Fix port/naming to match conventions. + +**Tech Stack:** Angular 19, Vitest, Playwright, LangGraph, LangChain/OpenAI, Python 3.12 + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `libs/chat/src/lib/a2ui/surface-to-spec.ts` | Modify (line 6) | Add `_bindings` to RESERVED_KEYS | +| `libs/chat/src/lib/a2ui/surface-to-spec.spec.ts` | Modify (add test) | Verify agent-authored `_bindings` is filtered | +| `libs/cockpit-registry/src/lib/manifest.ts` | Modify (line 47) | Add `'a2ui'` to APPROVED_TOPICS | +| `apps/cockpit/scripts/capability-registry.ts` | Modify (line 47) | Add c-a2ui capability entry | +| `apps/cockpit/src/lib/route-resolution.ts` | Modify (imports + array) | Import and register chatA2uiPythonModule | +| `apps/cockpit/e2e/all-examples-smoke.spec.ts` | Modify (line 29) | Add c-a2ui to EXAMPLES array | +| `cockpit/chat/a2ui/angular/src/environments/environment.ts` | Modify | Fix assistantId to `c-a2ui` | +| `cockpit/chat/a2ui/angular/src/environments/environment.development.ts` | Modify | Fix port to 4511, assistantId to `c-a2ui` | +| `cockpit/chat/a2ui/python/langgraph.json` | Modify | Rename graph to `c-a2ui` | +| `cockpit/chat/a2ui/python/src/graph.py` | Rewrite | LLM-backed graph matching generative-ui pattern | +| `cockpit/chat/a2ui/python/prompts/a2ui.md` | Rewrite | Fix inaccurate prop names, add validation docs | +| `cockpit/chat/a2ui/python/docs/guide.md` | Modify | Remove `_bindings` from example, fix StateStore claim | +| `cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts` | Rewrite | Match all-examples-smoke pattern | +| `cockpit/chat/a2ui/angular/src/index.ts` | Modify (if needed) | Verify module shape matches conventions | +| `cockpit/chat/a2ui/python/src/index.ts` | Modify (if needed) | Verify module shape matches conventions | + +--- + +### Task 1: Add `_bindings` to RESERVED_KEYS in surfaceToSpec + +**Files:** +- Modify: `libs/chat/src/lib/a2ui/surface-to-spec.ts:6` +- Modify: `libs/chat/src/lib/a2ui/surface-to-spec.spec.ts` (add test at end) + +- [ ] **Step 1: Write the failing test** + +Add this test to the end of `libs/chat/src/lib/a2ui/surface-to-spec.spec.ts`, inside the `surfaceToSpec — binding tracking` describe block (after line 327): + +```typescript + it('filters out agent-authored _bindings and uses auto-detected bindings', () => { + const surface = makeSurface( + [{ + id: 'root', component: 'TextField', label: 'Name', + value: { path: '/name' } as any, + _bindings: { value: '/name' }, + } as any], + { name: 'Alice' }, + ); + const spec = surfaceToSpec(surface)!; + // _bindings should come from auto-detection, not from agent input + expect(spec.elements['root'].props['_bindings']).toEqual({ value: '/name' }); + // The agent-authored _bindings object should not leak as a separate resolved prop + // (it would be { value: '/name' } as a literal if not filtered) + }); +``` + +- [ ] **Step 2: Run test to verify it passes (it currently passes by coincidence)** + +Run: `npx nx test chat --testPathPattern='surface-to-spec' --reporter=verbose` + +Expected: PASS (the auto-detected bindings overwrite the leaked prop). This test documents the correct behavior; the real fix prevents the leak. + +- [ ] **Step 3: Add `_bindings` to RESERVED_KEYS** + +In `libs/chat/src/lib/a2ui/surface-to-spec.ts`, change line 6 from: + +```typescript +const RESERVED_KEYS = new Set(['id', 'component', 'children', 'action', 'checks']); +``` + +to: + +```typescript +const RESERVED_KEYS = new Set(['id', 'component', 'children', 'action', 'checks', '_bindings']); +``` + +- [ ] **Step 4: Run all chat tests to verify nothing breaks** + +Run: `npx nx test chat --reporter=verbose` + +Expected: All tests pass (225+) + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/a2ui/surface-to-spec.ts libs/chat/src/lib/a2ui/surface-to-spec.spec.ts +git commit -m "fix(chat): add _bindings to surfaceToSpec RESERVED_KEYS + +Prevents agent-authored _bindings from leaking through as a regular +resolved prop. Only auto-detected bindings from path references are set." +``` + +--- + +### Task 2: Fix Port, Graph Name, and Environment Config + +**Files:** +- Modify: `cockpit/chat/a2ui/angular/src/environments/environment.ts` +- Modify: `cockpit/chat/a2ui/angular/src/environments/environment.development.ts` +- Modify: `cockpit/chat/a2ui/python/langgraph.json` + +- [ ] **Step 1: Fix production environment** + +Replace the entire content of `cockpit/chat/a2ui/angular/src/environments/environment.ts` with: + +```typescript +export const environment = { + production: true, + langGraphApiUrl: '/api', + a2uiAssistantId: 'c-a2ui', +}; +``` + +- [ ] **Step 2: Fix development environment** + +Replace the entire content of `cockpit/chat/a2ui/angular/src/environments/environment.development.ts` with: + +```typescript +export const environment = { + production: false, + langGraphApiUrl: 'http://localhost:4511/api', + a2uiAssistantId: 'c-a2ui', +}; +``` + +- [ ] **Step 3: Fix LangGraph graph name** + +Replace the entire content of `cockpit/chat/a2ui/python/langgraph.json` with: + +```json +{ + "graphs": { + "c-a2ui": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} +``` + +- [ ] **Step 4: Run the Angular smoke test to verify module shape** + +Run: `npx nx smoke cockpit-chat-a2ui-angular` + +Expected: PASS (verifies module exports correctly) + +- [ ] **Step 5: Commit** + +```bash +git add cockpit/chat/a2ui/angular/src/environments/environment.ts cockpit/chat/a2ui/angular/src/environments/environment.development.ts cockpit/chat/a2ui/python/langgraph.json +git commit -m "fix(cockpit): fix A2UI port conflict and graph name convention + +Change dev port from 4311 (conflicts with filesystem) to 4511. +Rename graph from a2ui_form to c-a2ui matching chat convention." +``` + +--- + +### Task 3: Convert graph.py to LLM-Backed + +**Files:** +- Rewrite: `cockpit/chat/a2ui/python/src/graph.py` + +- [ ] **Step 1: Rewrite graph.py** + +Replace the entire content of `cockpit/chat/a2ui/python/src/graph.py` with: + +```python +""" +A2UI Chat Graph + +A LangGraph StateGraph that generates A2UI JSONL responses using an LLM. +The Angular frontend detects the ---a2ui_JSON--- prefix and renders +interactive surfaces from the streamed component definitions. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_a2ui_graph(): + """ + Single-node graph that invokes an LLM with the A2UI system prompt. + The LLM generates A2UI JSONL that builds interactive surfaces. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "a2ui.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_a2ui_graph() +``` + +- [ ] **Step 2: Commit** + +```bash +git add cockpit/chat/a2ui/python/src/graph.py +git commit -m "feat(cockpit): convert A2UI graph to LLM-backed generation + +Replace hardcoded form JSONL with ChatOpenAI invocation using system +prompt, matching the generative-ui example pattern." +``` + +--- + +### Task 4: Rewrite A2UI System Prompt + +**Files:** +- Rewrite: `cockpit/chat/a2ui/python/prompts/a2ui.md` + +- [ ] **Step 1: Rewrite the prompt with correct component API** + +Replace the entire content of `cockpit/chat/a2ui/python/prompts/a2ui.md` with: + +```markdown +# A2UI Assistant + +You are an assistant that builds interactive UIs using the A2UI (Agent-to-UI) protocol. + +When the user asks you to create a form, dashboard, or any interactive UI, respond with A2UI JSONL — newline-delimited JSON messages prefixed with `---a2ui_JSON---`. + +When the user sends a JSON message with `"version": "v0.9"` and an `"action"` field, that is a form submission event. Read the `action.context` object to see the submitted values and respond conversationally (in plain text/markdown, not A2UI). + +## Response Format + +Your entire response must start with the prefix, then one JSON message per line: + +``` +---a2ui_JSON--- +{"createSurface":{"surfaceId":"s1","catalogId":"basic","sendDataModel":true}} +{"updateDataModel":{"surfaceId":"s1","value":{"name":"","email":""}}} +{"updateComponents":{"surfaceId":"s1","components":[...]}} +``` + +## Message Types + +| Message | Purpose | +|---------|---------| +| `createSurface` | Initialize a surface. Set `sendDataModel: true` to receive the full data model with form submissions. | +| `updateDataModel` | Set initial data model values at `/` (root). | +| `updateComponents` | Define the component tree. Each component has `id`, `component` type, and type-specific props. | + +## Available Components + +### Display + +| Component | Props | +|-----------|-------| +| `Text` | `text` (string) | +| `Image` | `url` (string), `alt` (string) | +| `Icon` | `name` (string — use emoji like "✓" or "⚠️") | +| `Divider` | *(none)* | + +### Layout + +| Component | Props | +|-----------|-------| +| `Column` | `children` (string[] of component IDs) | +| `Row` | `children` (string[] of component IDs) | +| `Card` | `title` (string), `children` (string[] of component IDs) | +| `List` | `children` (string[] of component IDs) | +| `Tabs` | `tabs` (array of `{label, childKeys}`), `selected` (number or path ref) | +| `Modal` | `title` (string), `open` (boolean or path ref), `children` (string[]), `dismissible` (boolean) | + +### Input + +| Component | Props | +|-----------|-------| +| `TextField` | `label` (string), `value` (string or path ref), `placeholder` (string) | +| `CheckBox` | `label` (string), `checked` (boolean or path ref) | +| `ChoicePicker` | `label` (string), `options` (string[]), `selected` (string or path ref) | +| `DateTimeInput` | `label` (string), `value` (string or path ref), `inputType` (`"date"` or `"time"` or `"datetime-local"`), `min` (string), `max` (string) | +| `Slider` | `label` (string), `value` (number or path ref), `min` (number), `max` (number), `step` (number) | + +### Interactive + +| Component | Props | +|-----------|-------| +| `Button` | `label` (string), `variant` (`"primary"` or `"borderless"`), `disabled` (boolean), `action` (Action object), `checks` (CheckRule[]) | + +### Media + +| Component | Props | +|-----------|-------| +| `Video` | `url` (string), `poster` (string), `autoplay` (boolean), `controls` (boolean) | +| `AudioPlayer` | `url` (string), `autoplay` (boolean), `controls` (boolean) | + +## Data Model Binding + +Use `{"path": "/fieldName"}` as a prop value to bind it to the data model. When the user changes an input, the value at that path updates automatically. + +```json +{"id": "name", "component": "TextField", "label": "Name", "value": {"path": "/name"}} +``` + +Do NOT include a `_bindings` prop — the renderer generates bindings automatically from path references. + +## Actions + +Buttons can have an event action that sends data back to you: + +```json +{ + "action": { + "event": { + "name": "formSubmit", + "context": { + "name": {"path": "/name"}, + "email": {"path": "/email"} + } + } + } +} +``` + +Context values can be path references (resolved at click time) or literal values. + +## Validation (checks) + +Input components and buttons can have a `checks` array for client-side validation. Each check has a `condition` and an error `message`. If any check fails, the button is disabled and error messages display. + +```json +{ + "checks": [ + { + "condition": {"call": "required", "args": {"value": {"path": "/name"}}}, + "message": "Name is required" + } + ] +} +``` + +Built-in validation functions: `required`, `email`, `regex`, `length`, `numeric`. + +Compose with `and`, `or`, `not`: + +```json +{ + "condition": { + "call": "and", + "args": { + "values": [ + {"call": "required", "args": {"value": {"path": "/name"}}}, + {"call": "email", "args": {"value": {"path": "/email"}}} + ] + } + }, + "message": "Name and valid email required" +} +``` + +## Rules + +1. Always start with `---a2ui_JSON---` on the first line. +2. One JSON message per line, no trailing commas or extra whitespace. +3. Always send `createSurface` first, then `updateDataModel`, then `updateComponents`. +4. Every component referenced in `children` must have a matching `id` in the components array. +5. The root component must have `id: "root"`. +6. Do NOT include `_bindings` in component definitions. +7. When responding to a form submission (v0.9 action message), respond in plain markdown — do NOT emit A2UI JSONL. +``` + +- [ ] **Step 2: Commit** + +```bash +git add cockpit/chat/a2ui/python/prompts/a2ui.md +git commit -m "docs(cockpit): rewrite A2UI system prompt with correct component API + +Fix inaccurate prop names (Text.content→text, remove Row.gap), +add validation/checks docs, add sendDataModel, clarify _bindings +is auto-populated." +``` + +--- + +### Task 5: Fix guide.md + +**Files:** +- Modify: `cockpit/chat/a2ui/python/docs/guide.md` + +- [ ] **Step 1: Fix the data model binding step** + +In `cockpit/chat/a2ui/python/docs/guide.md`, replace lines 73-84 (the Step 4 content about data model binding): + +Find: +```markdown + + +Components bind to the data model using path references: + +```json +{"id": "name_field", "component": "TextField", + "label": "Name", "value": {"path": "/name"}, + "_bindings": {"value": "/name"}} +``` + +When the user types in the field, the value at `/name` in the data model +updates automatically via the render-lib StateStore. + + +``` + +Replace with: +```markdown + + +Components bind to the data model using path references: + +```json +{"id": "name_field", "component": "TextField", + "label": "Name", "value": {"path": "/name"}} +``` + +The `surfaceToSpec` function auto-detects path references and populates +`_bindings` for each input component — agents do not write `_bindings` +directly. When the user changes a bound input, the component emits a +data model update event. + +**Known limitation:** Data model updates from user input do not currently +reflect to other components in real time. The agent can refresh state by +sending a new `updateDataModel` message. + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add cockpit/chat/a2ui/python/docs/guide.md +git commit -m "docs(cockpit): fix A2UI guide — remove _bindings from example, clarify StateStore limitation" +``` + +--- + +### Task 6: Register A2UI in Cockpit + +**Files:** +- Modify: `libs/cockpit-registry/src/lib/manifest.ts:47` +- Modify: `apps/cockpit/scripts/capability-registry.ts:47` +- Modify: `apps/cockpit/src/lib/route-resolution.ts` (imports + array) +- Modify: `apps/cockpit/e2e/all-examples-smoke.spec.ts:29` + +- [ ] **Step 1: Add 'a2ui' to APPROVED_TOPICS in manifest.ts** + +In `libs/cockpit-registry/src/lib/manifest.ts`, find the chat core-capabilities array (line 47): + +```typescript + 'theming', + ], +``` + +Replace with: + +```typescript + 'theming', + 'a2ui', + ], +``` + +- [ ] **Step 2: Add c-a2ui to capability-registry.ts** + +In `apps/cockpit/scripts/capability-registry.ts`, after the c-theming entry (line 47), add: + +```typescript + { id: 'c-a2ui', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' }, +``` + +- [ ] **Step 3: Import and register chatA2uiPythonModule in route-resolution.ts** + +In `apps/cockpit/src/lib/route-resolution.ts`, add this import after line 35 (the chatThemingPythonModule import): + +```typescript +import { chatA2uiPythonModule } from '../../../../cockpit/chat/a2ui/python/src/index'; +``` + +Then add `chatA2uiPythonModule` to the `capabilityModules` array after `chatThemingPythonModule` (after line 104): + +```typescript + chatA2uiPythonModule, +``` + +- [ ] **Step 4: Add c-a2ui to all-examples-smoke.spec.ts** + +In `apps/cockpit/e2e/all-examples-smoke.spec.ts`, add this entry to the EXAMPLES array after the sandboxes entry (after line 28): + +```typescript + // Render capabilities +``` + +Wait — the render capabilities come after. Add the A2UI entry within the chat section. Find line 29 (end of the current EXAMPLES array, before the `] as const;`). Looking at the file structure, the chat capabilities aren't listed yet. Add after sandboxes but before the closing: + +Actually, the EXAMPLES array only has 14 entries (langgraph + deep-agents). Chat and render examples are missing. Add A2UI alongside the other chat examples that should be added. For now, add just the A2UI entry at the end of the array: + +After line 28 (`{ name: 'sandboxes', port: 4315, selector: 'app-sandboxes' },`), add: + +```typescript + { name: 'c-a2ui', port: 4511, selector: 'app-a2ui' }, +``` + +Also update the comment at line 12 from "14" to "15" to reflect the new count. + +- [ ] **Step 5: Run cockpit build to verify registration** + +Run: `npx nx build cockpit --skip-nx-cache` + +Expected: Build succeeds with the new A2UI module imported + +- [ ] **Step 6: Commit** + +```bash +git add libs/cockpit-registry/src/lib/manifest.ts apps/cockpit/scripts/capability-registry.ts apps/cockpit/src/lib/route-resolution.ts apps/cockpit/e2e/all-examples-smoke.spec.ts +git commit -m "feat(cockpit): register A2UI in manifest, capability registry, routes, and e2e + +Add a2ui to APPROVED_TOPICS, capability-registry (port 4511), +route-resolution imports, and all-examples-smoke test suite." +``` + +--- + +### Task 7: Rewrite A2UI E2E Test + +**Files:** +- Rewrite: `cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts` + +- [ ] **Step 1: Rewrite e2e test matching all-examples-smoke pattern** + +Replace the entire content of `cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts` with: + +```typescript +import { expect, test } from '@playwright/test'; + +test.describe('A2UI Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4511'); + await page.waitForSelector('app-a2ui', { state: 'attached', timeout: 10000 }); + }); + + test('renders the chat interface', async ({ page }) => { + await expect(page.locator('chat')).toBeVisible({ timeout: 5000 }); + }); + + test('displays input and send button', async ({ page }) => { + await expect(page.locator('input[name="prompt"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('button[type="submit"]')).toBeVisible({ timeout: 5000 }); + }); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts +git commit -m "test(cockpit): rewrite A2UI e2e test matching smoke test pattern + +Verify chat renders, input and send button visible on port 4511." +``` + +--- + +### Task 8: Verify Module Exports Match Conventions + +**Files:** +- Modify (if needed): `cockpit/chat/a2ui/angular/src/index.ts` +- Modify (if needed): `cockpit/chat/a2ui/python/src/index.ts` + +- [ ] **Step 1: Check Angular module export** + +Read `cockpit/chat/a2ui/angular/src/index.ts`. Verify that: +- The `id` field is `'chat-a2ui-angular'` +- The `manifestIdentity.topic` is `'a2ui'` +- The `title` is `'Chat A2UI (Angular)'` +- The smoke test in `project.json` matches these values + +No changes needed if these are already correct (they are based on exploration). + +- [ ] **Step 2: Check Python module export** + +Read `cockpit/chat/a2ui/python/src/index.ts`. Verify that: +- The `manifestIdentity.topic` is `'a2ui'` +- The `runtimeUrl` is `'chat/a2ui'` +- The `devPort` is `4511` + +No changes needed if correct (they are based on exploration). + +- [ ] **Step 3: Run Angular build to verify everything compiles** + +Run: `npx nx build cockpit-chat-a2ui-angular --configuration=production --skip-nx-cache` + +Expected: Build succeeds + +- [ ] **Step 4: Run all chat lib tests** + +Run: `npx nx test chat --reporter=verbose` + +Expected: All tests pass (225+) + +- [ ] **Step 5: Run all a2ui lib tests** + +Run: `npx nx test a2ui --reporter=verbose` + +Expected: All tests pass (91+) + +--- + +### Task 9: Final Verification + +- [ ] **Step 1: Run the full cockpit build** + +Run: `npx nx build cockpit --skip-nx-cache` + +Expected: Build succeeds, A2UI module resolved + +- [ ] **Step 2: Run the cockpit e2e smoke tests (headless)** + +Run: `npx nx e2e cockpit --skip-nx-cache` + +Expected: All existing smoke tests pass + +- [ ] **Step 3: Verify assemble-examples includes A2UI** + +Run: `grep -n 'a2ui' scripts/assemble-examples.ts` + +Expected: A2UI is already listed at line 50. No changes needed. + +- [ ] **Step 4: Commit any remaining changes and verify clean tree** + +Run: `git status` + +Expected: Clean working tree, all changes committed. diff --git a/docs/superpowers/specs/2026-04-10-a2ui-cockpit-quality-design.md b/docs/superpowers/specs/2026-04-10-a2ui-cockpit-quality-design.md new file mode 100644 index 000000000..572b07aca --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-a2ui-cockpit-quality-design.md @@ -0,0 +1,97 @@ +# A2UI Cockpit Example — Production Quality Pass + +## Goal + +Make the A2UI cockpit example production-ready: register it in the cockpit system, fix library and documentation issues, convert to LLM-backed agent, ensure e2e coverage. + +## Current State + +The A2UI example exists at `cockpit/chat/a2ui/` with Angular frontend and Python backend, but is not wired into the cockpit's routing, navigation, serve infrastructure, or e2e test suite. The graph hardcodes form JSONL instead of using an LLM. The system prompt has inaccurate prop names. The agent sends `_bindings` explicitly (should be auto-populated by `surfaceToSpec`). + +## Changes + +### 1. Library Fix: `surfaceToSpec` RESERVED_KEYS + +**File:** `libs/chat/src/lib/a2ui/surface-to-spec.ts` + +Add `_bindings` to `RESERVED_KEYS` set. Currently agent-authored `_bindings` leaks through as a regular prop before being overwritten by auto-detected bindings. With this fix, `_bindings` from the agent is filtered out — only the auto-detected bindings are set. + +Update existing test in `surface-to-spec.spec.ts` to verify `_bindings` from agent input is not passed through as a regular prop. + +### 2. Register A2UI in Cockpit + +Four registration points, following the pattern of the 30 existing capabilities: + +- **`libs/cockpit-registry/src/lib/manifest.ts`** — add `'a2ui'` to `APPROVED_TOPICS.chat['core-capabilities']` +- **`apps/cockpit/scripts/capability-registry.ts`** — add `{ id: 'c-a2ui', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' }` +- **`apps/cockpit/src/lib/route-resolution.ts`** — import `chatA2uiPythonModule` from `cockpit/chat/a2ui/python/src/index` and add to `capabilityModules` array +- **`apps/cockpit/e2e/all-examples-smoke.spec.ts`** — add `{ name: 'c-a2ui', port: 4511, selector: 'app-a2ui' }` + +### 3. Fix Port & Graph Name Convention + +The dev environment uses port 4311 (conflicts with `filesystem`). The graph name `a2ui_form` doesn't follow the `c-{topic}` convention used by all other chat capabilities. + +- **`environment.development.ts`** — change `langGraphApiUrl` to `http://localhost:4511/api` +- **`environment.ts`** — change `a2uiAssistantId` to `c-a2ui` +- **`environment.development.ts`** — change `a2uiAssistantId` to `c-a2ui` +- **`langgraph.json`** — rename graph key from `a2ui_form` to `c-a2ui` + +### 4. LLM-Backed Graph + +Replace hardcoded JSONL with LLM-backed generation, matching the `generative-ui` pattern: + +```python +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + +async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "a2ui.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} +``` + +Single `generate` node → END. The system prompt provides the A2UI protocol reference so the LLM generates valid A2UI JSONL. + +### 5. Fix System Prompt (a2ui.md) + +Correct all inaccuracies against actual component implementations: + +| Current (wrong) | Correct | +|-----------------|---------| +| `Text.content`, `Text.variant` | `Text.text` (no variant) | +| `Row.gap` | Remove (no gap prop) | +| Missing validation/checks | Add `checks` array documentation | +| Missing `sendDataModel` | Add to `createSurface` docs | +| No `_bindings` instruction | Agents must NOT send `_bindings` — auto-populated | +| `DateTimeInput.type` | `DateTimeInput.inputType` with values `date`, `time`, `datetime-local` | + +### 6. Fix guide.md + +- Remove `_bindings` from the JSON example in step 4 +- Fix the "updates automatically via StateStore" claim — clarify this is a known limitation (data model updates from user input do not reflect to other components in real time) + +### 7. E2E Test + +Expand `cockpit/chat/a2ui/angular/e2e/a2ui.spec.ts` to match `all-examples-smoke.spec.ts` pattern: +- Verify `app-a2ui` selector attached +- Verify `chat` component visible +- Verify input and send button exist +- Use port 4511 + +## Out of Scope + +- Adding angular() vite plugin for TestBed tests (known infrastructure limitation) +- StateStore integration for data model binding (Phase 3+) +- Production smoke tests (separate CI concern) + +## Success Criteria + +- A2UI appears in cockpit navigation under Chat > Core Capabilities > A2UI +- `serve-example.ts --capability=c-a2ui` works +- `all-examples-smoke.spec.ts` includes A2UI +- Graph uses LLM to generate A2UI JSONL dynamically +- System prompt matches actual component API +- `_bindings` is in RESERVED_KEYS — library correctness +- All existing tests pass (300+) diff --git a/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts index 0811f398b..24dba240c 100644 --- a/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts @@ -1,11 +1,30 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { describe, it, expect, vi } from 'vitest'; - -describe('A2uiButtonComponent — handleClick logic', () => { - it('should call emit with click event', () => { - // Button.handleClick() calls this.emit()('click') - const emit = vi.fn(); - emit('click'); - expect(emit).toHaveBeenCalledWith('click'); +import { describe, it, expect } from 'vitest'; +import { A2uiButtonComponent } from './button.component'; + +describe('A2uiButtonComponent', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). handleClick() dispatches 'click' via the + // emit signal, which requires a working Angular injection context to test. + + it('exports the component class', () => { + expect(A2uiButtonComponent).toBeDefined(); + }); + + it('has handleClick method', () => { + expect(A2uiButtonComponent.prototype.handleClick).toBeInstanceOf(Function); + }); + + it('template disables button when disabled or validation fails', () => { + // Verified from template: [disabled]="disabled() || !validationResult().valid" + // This is a documentation test — the actual binding requires template compilation. + // The button is disabled when: + // - disabled input is true, OR + // - validationResult().valid is false + const disabledWhen = (disabled: boolean, valid: boolean) => disabled || !valid; + expect(disabledWhen(false, true)).toBe(false); + expect(disabledWhen(true, true)).toBe(true); + expect(disabledWhen(false, false)).toBe(true); + expect(disabledWhen(true, false)).toBe(true); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/check-box.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/check-box.component.spec.ts index dd1cf037d..7fcfa4fc9 100644 --- a/libs/chat/src/lib/a2ui/catalog/check-box.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/check-box.component.spec.ts @@ -3,14 +3,30 @@ import { describe, it, expect, vi } from 'vitest'; import { emitBinding } from './emit-binding'; describe('A2uiCheckBoxComponent — onChange logic', () => { - it('should emit binding event for checked state', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). These tests verify the behavioral contract + // of onChange: extract boolean checked state from event → emit binding. + + it('emits binding with true when checkbox is checked', () => { const emit = vi.fn(); const bindings = { checked: '/agreed' }; - emitBinding(emit, bindings, 'checked', true); + // Mirrors onChange: const val = (event.target as HTMLInputElement).checked; + const event = { target: { checked: true } } as unknown as Event; + const val = (event.target as HTMLInputElement).checked; + emitBinding(emit, bindings, 'checked', val); expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/agreed:true'); }); - it('should not emit when no binding exists', () => { + it('emits binding with false when checkbox is unchecked', () => { + const emit = vi.fn(); + const bindings = { checked: '/agreed' }; + const event = { target: { checked: false } } as unknown as Event; + const val = (event.target as HTMLInputElement).checked; + emitBinding(emit, bindings, 'checked', val); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/agreed:false'); + }); + + it('does not emit when no binding exists for checked', () => { const emit = vi.fn(); emitBinding(emit, {}, 'checked', true); expect(emit).not.toHaveBeenCalled(); diff --git a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.spec.ts index cee21f090..cf165bc97 100644 --- a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.spec.ts @@ -3,14 +3,30 @@ import { describe, it, expect, vi } from 'vitest'; import { emitBinding } from './emit-binding'; describe('A2uiChoicePickerComponent — onChange logic', () => { - it('should emit binding event on selection', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). These tests verify the behavioral contract + // of onChange: extract selected value from select event → emit binding for 'selected'. + + it('emits binding with selected option value', () => { const emit = vi.fn(); const bindings = { selected: '/department' }; - emitBinding(emit, bindings, 'selected', 'Engineering'); + // Mirrors onChange: const val = (event.target as HTMLSelectElement).value; + const event = { target: { value: 'Engineering' } } as unknown as Event; + const val = (event.target as HTMLSelectElement).value; + emitBinding(emit, bindings, 'selected', val); expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/department:Engineering'); }); - it('should not emit when no binding exists', () => { + it('emits binding with empty string when selection is cleared', () => { + const emit = vi.fn(); + const bindings = { selected: '/department' }; + const event = { target: { value: '' } } as unknown as Event; + const val = (event.target as HTMLSelectElement).value; + emitBinding(emit, bindings, 'selected', val); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/department:'); + }); + + it('does not emit when no binding exists for selected', () => { const emit = vi.fn(); emitBinding(emit, {}, 'selected', 'Engineering'); expect(emit).not.toHaveBeenCalled(); diff --git a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.spec.ts new file mode 100644 index 000000000..329cd58da --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.spec.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { emitBinding } from './emit-binding'; + +describe('A2uiDateTimeInputComponent — onChange logic', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). These tests verify the behavioral contract + // of onChange: extract value from date/time/datetime input → emit binding. + + it('emits binding with date value', () => { + const emit = vi.fn(); + const bindings = { value: '/appointmentDate' }; + // Mirrors onChange: const val = (event.target as HTMLInputElement).value; + const event = { target: { value: '2026-04-15' } } as unknown as Event; + const val = (event.target as HTMLInputElement).value; + emitBinding(emit, bindings, 'value', val); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/appointmentDate:2026-04-15'); + }); + + it('emits binding with datetime-local value', () => { + const emit = vi.fn(); + const bindings = { value: '/scheduledAt' }; + const event = { target: { value: '2026-04-15T14:30' } } as unknown as Event; + const val = (event.target as HTMLInputElement).value; + emitBinding(emit, bindings, 'value', val); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/scheduledAt:2026-04-15T14:30'); + }); + + it('emits binding with time value', () => { + const emit = vi.fn(); + const bindings = { value: '/startTime' }; + const event = { target: { value: '09:00' } } as unknown as Event; + const val = (event.target as HTMLInputElement).value; + emitBinding(emit, bindings, 'value', val); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/startTime:09:00'); + }); + + it('does not emit when no binding exists for value', () => { + const emit = vi.fn(); + emitBinding(emit, {}, 'value', '2026-04-15'); + expect(emit).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts index db96ac2fc..7c6598d65 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts @@ -1,10 +1,13 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { describe, it, expect } from 'vitest'; +import { A2uiIconComponent } from './icon.component'; describe('A2uiIconComponent', () => { - it('is a display-only component with no behavioral logic', () => { - // A2uiIconComponent renders name() input as a span. - // No methods, no events, no bindings — purely declarative. - expect(true).toBe(true); + // Display-only component: renders name() input as a . + // No methods, events, or bindings — purely declarative. + // Signal-based inputs require the angular() vite plugin for TestBed tests. + + it('exports the component class', () => { + expect(A2uiIconComponent).toBeDefined(); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts index 3bc82eef1..d58f53258 100644 --- a/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts @@ -1,10 +1,13 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { describe, it, expect } from 'vitest'; +import { A2uiImageComponent } from './image.component'; describe('A2uiImageComponent', () => { - it('is a display-only component with no behavioral logic', () => { - // A2uiImageComponent renders url() and alt() as an . - // No methods, no events, no bindings — purely declarative. - expect(true).toBe(true); + // Display-only component: renders url() and alt() as an . + // No methods, events, or bindings — purely declarative. + // Signal-based inputs require the angular() vite plugin for TestBed tests. + + it('exports the component class', () => { + expect(A2uiImageComponent).toBeDefined(); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts index 7268cf73b..e9507cc2a 100644 --- a/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts @@ -3,24 +3,34 @@ import { describe, it, expect, vi } from 'vitest'; import { emitBinding } from './emit-binding'; describe('A2uiModalComponent — onBackdropClick logic', () => { - it('should emit binding to close modal when dismissible', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). These tests verify the behavioral contract + // of onBackdropClick: guard on dismissible, then emit open=false binding. + + it('emits open=false binding when dismissible is true', () => { const emit = vi.fn(); const bindings = { open: '/showModal' }; - // Simulates: if (!this.dismissible()) return; emitBinding(...) + // Mirrors onBackdropClick: if (!this.dismissible()) return; emitBinding(...) const dismissible = true; - if (dismissible) { - emitBinding(emit, bindings, 'open', false); - } + if (!dismissible) return; + emitBinding(emit, bindings, 'open', false); expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/showModal:false'); }); - it('should not emit when not dismissible', () => { + it('does not emit when dismissible is false', () => { const emit = vi.fn(); const bindings = { open: '/showModal' }; const dismissible = false; - if (dismissible) { - emitBinding(emit, bindings, 'open', false); - } + if (!dismissible) return; + emitBinding(emit, bindings, 'open', false); + expect(emit).not.toHaveBeenCalled(); + }); + + it('does not emit when no binding exists for open', () => { + const emit = vi.fn(); + const dismissible = true; + if (!dismissible) return; + emitBinding(emit, {}, 'open', false); expect(emit).not.toHaveBeenCalled(); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/slider.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/slider.component.spec.ts index f706870a0..358b4b88a 100644 --- a/libs/chat/src/lib/a2ui/catalog/slider.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/slider.component.spec.ts @@ -3,10 +3,31 @@ import { describe, it, expect, vi } from 'vitest'; import { emitBinding } from './emit-binding'; describe('A2uiSliderComponent — onInput logic', () => { - it('should emit binding event with numeric value', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). These tests verify the behavioral contract + // of onInput: extract value from range input, coerce to Number, emit binding. + + it('emits binding with numeric value from range input', () => { const emit = vi.fn(); const bindings = { value: '/rating' }; - emitBinding(emit, bindings, 'value', 75); + // Mirrors onInput: const val = Number((event.target as HTMLInputElement).value); + const event = { target: { value: '75' } } as unknown as Event; + const val = Number((event.target as HTMLInputElement).value); + emitBinding(emit, bindings, 'value', val); expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/rating:75'); }); + + it('coerces string range value to number', () => { + // Slider's onInput uses Number() to coerce — verify the coercion + const event = { target: { value: '42.5' } } as unknown as Event; + const val = Number((event.target as HTMLInputElement).value); + expect(val).toBe(42.5); + expect(typeof val).toBe('number'); + }); + + it('does not emit when no binding exists for value', () => { + const emit = vi.fn(); + emitBinding(emit, {}, 'value', 50); + expect(emit).not.toHaveBeenCalled(); + }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/tabs.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/tabs.component.spec.ts index 8644f29a2..b94cb5c33 100644 --- a/libs/chat/src/lib/a2ui/catalog/tabs.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/tabs.component.spec.ts @@ -2,25 +2,56 @@ import { describe, it, expect, vi } from 'vitest'; import { emitBinding } from './emit-binding'; -describe('A2uiTabsComponent — selectTab logic', () => { - it('should emit binding event on tab selection', () => { - const emit = vi.fn(); - const bindings = { selected: '/activeTab' }; - emitBinding(emit, bindings, 'selected', 2); - expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/activeTab:2'); +describe('A2uiTabsComponent', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). These tests verify the behavioral contracts: + // - selectTab: sets active index and emits binding + // - activeChildKeys: returns childKeys for the active tab index + + describe('selectTab logic', () => { + it('emits binding event with selected tab index', () => { + const emit = vi.fn(); + const bindings = { selected: '/activeTab' }; + // Mirrors selectTab: this.activeIndex.set(index); emitBinding(...) + emitBinding(emit, bindings, 'selected', 2); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/activeTab:2'); + }); + + it('emits index 0 when first tab is selected', () => { + const emit = vi.fn(); + const bindings = { selected: '/activeTab' }; + emitBinding(emit, bindings, 'selected', 0); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/activeTab:0'); + }); }); - it('should compute active child keys from tab index', () => { + describe('activeChildKeys computed logic', () => { + // Mirrors the computed signal: if (idx >= 0 && idx < allTabs.length) return allTabs[idx].childKeys; else return []; + const getActiveChildKeys = (tabs: { label: string; childKeys: string[] }[], index: number) => + index >= 0 && index < tabs.length ? tabs[index].childKeys : []; + const tabs = [ - { label: 'Tab 1', childKeys: ['a', 'b'] }, - { label: 'Tab 2', childKeys: ['c'] }, + { label: 'Overview', childKeys: ['overview-text', 'overview-chart'] }, + { label: 'Details', childKeys: ['detail-list'] }, + { label: 'Settings', childKeys: ['settings-form', 'settings-actions'] }, ]; - // Simulates activeChildKeys computed signal logic - const getActiveChildKeys = (index: number) => - index >= 0 && index < tabs.length ? tabs[index].childKeys : []; - expect(getActiveChildKeys(0)).toEqual(['a', 'b']); - expect(getActiveChildKeys(1)).toEqual(['c']); - expect(getActiveChildKeys(5)).toEqual([]); + it('returns childKeys for the selected tab', () => { + expect(getActiveChildKeys(tabs, 0)).toEqual(['overview-text', 'overview-chart']); + expect(getActiveChildKeys(tabs, 1)).toEqual(['detail-list']); + expect(getActiveChildKeys(tabs, 2)).toEqual(['settings-form', 'settings-actions']); + }); + + it('returns empty array for out-of-bounds positive index', () => { + expect(getActiveChildKeys(tabs, 5)).toEqual([]); + }); + + it('returns empty array for negative index', () => { + expect(getActiveChildKeys(tabs, -1)).toEqual([]); + }); + + it('returns empty array when tabs list is empty', () => { + expect(getActiveChildKeys([], 0)).toEqual([]); + }); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/text-field.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/text-field.component.spec.ts index 25d1c5c14..516b47eeb 100644 --- a/libs/chat/src/lib/a2ui/catalog/text-field.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/text-field.component.spec.ts @@ -3,16 +3,30 @@ import { describe, it, expect, vi } from 'vitest'; import { emitBinding } from './emit-binding'; describe('A2uiTextFieldComponent — onInput logic', () => { - it('should emit binding event via emitBinding', () => { + // NOTE: Angular signal-based inputs can't be tested via TestBed without the + // angular() vite plugin (NG0303). These tests verify the behavioral contract + // of onInput: extract string value from event → emit binding for 'value' prop. + + it('emits binding with string value extracted from input event', () => { const emit = vi.fn(); const bindings = { value: '/name' }; - // Simulates what onInput does: extract value, call emitBinding - const val = 'Alice'; + // Mirrors onInput: const val = (event.target as HTMLInputElement).value; + const event = { target: { value: 'Alice' } } as unknown as Event; + const val = (event.target as HTMLInputElement).value; emitBinding(emit, bindings, 'value', val); expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/name:Alice'); }); - it('should not emit when no binding exists', () => { + it('emits empty string for cleared input', () => { + const emit = vi.fn(); + const bindings = { value: '/name' }; + const event = { target: { value: '' } } as unknown as Event; + const val = (event.target as HTMLInputElement).value; + emitBinding(emit, bindings, 'value', val); + expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/name:'); + }); + + it('does not emit when no binding exists for value', () => { const emit = vi.fn(); emitBinding(emit, {}, 'value', 'Alice'); expect(emit).not.toHaveBeenCalled(); diff --git a/libs/chat/src/lib/a2ui/catalog/text.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/text.component.spec.ts index e12eb75fd..590588d24 100644 --- a/libs/chat/src/lib/a2ui/catalog/text.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/text.component.spec.ts @@ -1,10 +1,13 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { describe, it, expect } from 'vitest'; +import { A2uiTextComponent } from './text.component'; describe('A2uiTextComponent', () => { - it('is a display-only component with no behavioral logic', () => { - // A2uiTextComponent renders text() input as a span. - // No methods, no events, no bindings — purely declarative. - expect(true).toBe(true); + // Display-only component: renders text() input as a . + // No methods, events, or bindings — purely declarative. + // Signal-based inputs require the angular() vite plugin for TestBed tests. + + it('exports the component class', () => { + expect(A2uiTextComponent).toBeDefined(); }); }); diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts index 118a926de..823242c6c 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts @@ -325,4 +325,17 @@ describe('surfaceToSpec — binding tracking', () => { const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].props['_bindings']).toBeUndefined(); }); + + it('filters out agent-authored _bindings and uses auto-detected bindings', () => { + const surface = makeSurface( + [{ + id: 'root', component: 'TextField', label: 'Name', + value: { path: '/name' } as any, + _bindings: { value: '/name' }, + } as any], + { name: 'Alice' }, + ); + const spec = surfaceToSpec(surface)!; + expect(spec.elements['root'].props['_bindings']).toEqual({ value: '/name' }); + }); }); diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.ts index 6b6d3292c..986bad68f 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.ts @@ -3,7 +3,7 @@ import type { Spec, UIElement } from '@json-render/core'; import type { A2uiSurface, A2uiChildTemplate } from '@cacheplane/a2ui'; import { resolveDynamic, getByPointer, evaluateCheckRules, isPathRef } from '@cacheplane/a2ui'; -const RESERVED_KEYS = new Set(['id', 'component', 'children', 'action', 'checks']); +const RESERVED_KEYS = new Set(['id', 'component', 'children', 'action', 'checks', '_bindings']); /** * Converts an A2UI surface to a json-render Spec by: diff --git a/libs/cockpit-registry/src/lib/manifest.ts b/libs/cockpit-registry/src/lib/manifest.ts index d75b2c1be..ef4c89bdd 100644 --- a/libs/cockpit-registry/src/lib/manifest.ts +++ b/libs/cockpit-registry/src/lib/manifest.ts @@ -54,6 +54,7 @@ const APPROVED_TOPICS = { 'generative-ui', 'debug', 'theming', + 'a2ui', ], }, } as const;