From da237decedfc7aaedac287c0471073bf8982de9a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 18:57:24 -0700 Subject: [PATCH 01/28] =?UTF-8?q?docs(spec):=20client-tools=20=E2=80=94=20?= =?UTF-8?q?frontend-declared,=20frontend-executed=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for a new client-tools capability: tools declared in the Angular app (name + description + Standard Schema) that the model calls, routed back to the client to execute as an async function, a rendered view, or an interactive (HITL) component. Covers the render-lib component contract (schema/description metadata + typed injectRenderHost result channel), chat-lib tools()/action/ view/ask + executor + Agent.clientTools, both adapters unified on end+re-run (no interrupt()), and a published Python LangGraph middleware. TS LangGraph.js middleware deferred to a fast-follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-08-client-tools-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-client-tools-design.md diff --git a/docs/superpowers/specs/2026-06-08-client-tools-design.md b/docs/superpowers/specs/2026-06-08-client-tools-design.md new file mode 100644 index 000000000..b37103b0c --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-client-tools-design.md @@ -0,0 +1,183 @@ +# Client Tools — Frontend-Declared, Frontend-Executed Tools — Design + +**Date:** 2026-06-08 +**Status:** Draft for review +**Scope:** Add a `client-tools` capability: tools declared in the Angular app (name + description + input schema) that the model can call, where the model's call is routed back to the client to execute — as an async function, a rendered component, or an interactive (HITL) component that emits a result. Spans the render lib, the chat lib, both adapters (`@threadplane/ag-ui`, `@threadplane/langgraph`), a published Python middleware for LangGraph backends, and cockpit examples in both `ag-ui/` and `langgraph/`. + +## Goal + +Let a frontend author declare tools next to the UI and have the model call them — without a matching backend implementation. The client supplies the tool catalog (name, description, JSON Schema) to the model at run start; when the model calls one, the run ends, the client executes the tool, appends the result as a tool message, and re-runs so the model continues. This complements the existing backend-owned `tool-views` pattern (model calls a backend tool, frontend renders the result); here the tool itself lives on the client. + +Three client tool kinds: +1. **Function** — an async handler; its resolved value becomes the tool result. +2. **View** — a component the model fills with props; rendered inline, auto-acknowledged (no user input). +3. **Ask (HITL)** — an interactive component; the value it emits becomes the tool result. + +## Non-goals + +- Not changing the existing `tool-views` (backend tool → frontend render) pattern; client-tools sits alongside it. +- Not using `interrupt()` — that stays reserved for HITL *approvals*. Client tools unify on end + re-run (see Transport). +- TS middleware for **LangGraph.js** is a documented **fast-follow** (its own spec): no cockpit backend is TS, so it ships unit-tested and externally consumable rather than wired into an example here. This spec ships the **Python** middleware only. + +## Background — what exists today + +- The model's tool description + schema come solely from the **backend** `@tool` (docstring + typed signature) via `bind_tools`. The frontend `views()` registry is render-only and invisible to the model. +- **AG-UI has native client tools.** `RunAgentInput.tools` (`{ name, description, parameters, metadata }[]`) is passed per-run via `runAgent({ tools })`; there's a `clientProvided` capability flag. The canonical execution loop is **end run → client executes → append `ToolMessage{role:'tool', toolCallId, content}` via `addMessage()` → re-run** with accumulated history. Backend-agnostic. +- **LangGraph SDK has no client-tools field.** Tools are server-side only; but a thread can be continued by re-running with `input:{ messages:[toolMessage] }` (the `add_messages` reducer appends it), so the *same* end + re-run loop applies — no `interrupt()` needed. +- The **render lib** (`@threadplane/render`) is agent-agnostic. `ViewRegistry` entries carry no metadata. Props bind by name to component `input()`s (filtered to declared inputs). A component can `emit(string)` today, routed via an `a2ui:datamodel:` string protocol and `el.on` handlers, surfaced to the host as a `RenderEvent` (`handler`/`stateChange`/`lifecycle`). There is **no typed "this component produced a value" result event**. + +## Architecture — four layers + +| Layer | Owns | New in this spec | +|---|---|---| +| `@threadplane/render` | component contract: props-schema **in**, result **out** (agent-agnostic) | registry `schema?`+`description?`; typed `injectRenderHost()` (`set`/`emit`/`result`); `RenderResultEvent` | +| `@threadplane/chat` | tool framing + executor + capability type | `tools()` + `action`/`view`/`ask`; the executor; `Agent.clientTools`; JSON-Schema derivation | +| adapters | transport | `clientTools` impl: ship catalog + `resolve` via end+re-run | +| backend | merge catalog → `bind_tools`, end turn on client tool | published Python `client-tools` middleware (LangGraph) | + +## Section 1 — Render lib (`@threadplane/render`) + +Stays agent-agnostic; gains two generic capabilities. + +**1a. Self-describing registry entries.** No backwards-compat constraint. + +```ts +interface RenderViewEntry { + component: Type; + fallback?: Type; + schema?: StandardSchemaV1; // the component's props contract (Zod/Valibot/ArkType) + description?: string; // what this component is (humans + the model) +} +``` + +Render carries and exposes `schema`/`description`; it does not enforce them on mount (chat validates model args). This makes a component reusable for both `tool-views` and `client-tools`. + +**1b. Clean typed output channel (replaces the string `emit`).** The `a2ui:datamodel::` magic-string parsing is removed. A mounted component injects one typed host object: + +```ts +const host = injectRenderHost(); +host.set('/seats', 2); // state write (was a2ui:datamodel:/seats:2) +host.emit('rowClicked', { id }); // named event + typed payload → RenderHandlerEvent +host.result(value); // component produced a value → RenderResultEvent +``` + +`RenderEvent` gains: + +```ts +interface RenderResultEvent { type: 'result'; value: unknown; elementKey?: string } +``` + +`handler`/`stateChange`/`lifecycle` remain but are produced through the typed API. Render still knows nothing about tools — `result(value)` only means "this component emitted a value"; chat maps `RenderResultEvent` → tool result. + +**Migration:** existing components/specs using the `a2ui:datamodel:` string form (a2ui catalog, any json-render demos) are updated to `host.set(...)`. Inventory + port these as part of the work; no compatibility shim. + +## Section 2 — Chat lib (`@threadplane/chat`) + +**2a. Declaration API.** `tools({ name: def })` with three named constructors. The object key is the tool name (plain data; minification-safe). Description is the explicit first arg. Arg/prop types infer from the Standard Schema. + +```ts +import { tools, action, view, ask } from '@threadplane/chat'; + +export const myTools = tools({ + get_weather: action('Look up the weather', WeatherArgs, async (a) => fetchWeather(a)), + weather_card: view ('Show a weather card', WeatherArgs, WeatherCardComponent), + confirm_booking: ask ('Confirm the booking', BookingArgs, ConfirmBookingComponent), +}); +``` + +- `action(desc, schema, handler)` → function tool; `a` inferred from `schema`. +- `view(desc, schema, Component)` → render-only; model fills props; auto-acks. +- `ask(desc, schema, Component)` → HITL; mounts, awaits `host.result(value)`. + +Each constructor returns an opaque `ToolDef`; `tools()` collects them keyed by name. Component constructors (`view`/`ask`) produce a `RenderViewEntry` shape internally so the same component can also feed the `views()` registry. + +**2b. Executor.** A chat-lib runner watches `agent.clientTools.pending` for client tool calls (a catalog tool with no backend result after the run ends) and dispatches by kind: + +- **function** → validate args against schema; `await handler(args)`; resolve with the return value. +- **view** → mount via render (props = args); auto-resolve with `{ shown: true }`; the card persists in the transcript. +- **ask** → mount; await the `RenderResultEvent`; resolve with its value. +- **error paths** — schema-invalid args, handler throw, user cancel → resolve as a tool *error* (`ToolMessage.error`) so the model can recover. +- **parallel** — collect all pending client results for the turn, then trigger one re-run with all tool messages appended. + +**2c. Agent capability.** One optional, transport-agnostic surface: + +```ts +interface Agent { + clientTools?: { + setCatalog(tools: ClientToolSpec[]): void; // ship name+description+JSONSchema at run start + pending: Signal; // calls awaiting a client result + resolve(toolCallId: string, result: ToolResult): void;// return result → continue + }; +} +``` + +`ClientToolSpec = { name; description; parameters: JSONSchema }`. Chat derives `parameters` from the Standard Schema: use Zod's `z.toJSONSchema` when available; allow an explicit `parameters` override for validators without JSON-Schema output. Validation of incoming model args always uses the Standard Schema `validate()`. + +## Section 3 — Transport (both adapters implement `clientTools`) + +Unified on **end + append `ToolMessage` + re-run**. No `interrupt()`. + +| Method | `@threadplane/ag-ui` (any backend) | `@threadplane/langgraph` (direct) | +|---|---|---| +| `setCatalog(tools)` | pass to `runAgent({ tools })` (native `RunAgentInput.tools`) each run | put catalog in run `input`/`config` for the graph middleware to merge | +| `pending` | reducer: catalog tool call with no backend `TOOL_CALL_RESULT` after `RUN_FINISHED` | same — graph ends the turn on a client tool call | +| `resolve(id, result)` | `addMessage({role:'tool',toolCallId,content})` + `runAgent({tools})` | new run with `input:{messages:[toolMessage]}`, same thread | + +AG-UI is backend-agnostic — any compliant backend honoring `tools` + tool-message continuation works with no framework code. The reducer gains handling for "run finished with an unresolved catalog tool call" → surface as `pending`. + +## Section 4 — Backend: published Python `client-tools` middleware + +A real, installable package (not copy-pasted example glue), powering **both** cockpit example backends (the `ag-ui-langgraph`-wrapped one and the direct-LangGraph one — both are Python LangGraph graphs). + +Responsibilities: +1. Read the client catalog from run input. +2. Bind each catalog entry as a **stub tool** (name + description + JSON-schema, no execution) on the model alongside server tools. +3. Add a conditional edge: when the model's last message calls a client (stub) tool, route to `END` — pausing the turn so the client executes and re-runs. On the re-run the appended `ToolMessage` is present; the agent node proceeds normally. + +Distribution: published to PyPI as `threadplane-client-tools` (exact name TBD). **New publishing infra** — the repo publishes no Python package today, so this adds a PyPI trusted-publishing workflow, versioning, and CI. Follow existing release conventions (patch-only at 0.0.x). + +**Fast-follow (separate spec):** a TS middleware targeting **LangGraph.js** with the same responsibilities, for TS/Node LangGraph servers. Not built here. + +## Section 5 — Examples, semantics, deliverables + +**Cockpit examples** (matching existing capability conventions): `cockpit/ag-ui/client-tools/` and `cockpit/langgraph/client-tools/`, each `python/` + `angular/`, each demoing all three kinds — a `function` tool (e.g., browser geolocation or a client-side lookup), a `view` card, and an `ask` HITL component. Both example backends use the Python middleware. e2e fixtures + specs per example (aimock replay). Registry + manifest wiring (`capability-registry.ts`, ports in `cockpit/ports.mjs`, `assemble-examples` is already registry-driven). A docs guide per the `/////` format. + +**Semantics recap:** +- `view` auto-acks (`{ shown: true }`); `ask` resolves via `host.result`; `action` returns its value. +- Args validated against the Standard Schema before invoke; failure → tool error. +- Parallel client tool calls return together in a single re-run. +- A model call to a name that is in the catalog but has no backend impl is what triggers client routing; a name with a backend impl runs server-side as today. + +**Deliverables:** +- `@threadplane/render`: `RenderViewEntry` `schema`/`description`; `injectRenderHost()` (`set`/`emit`/`result`); `RenderResultEvent`; port existing `a2ui:datamodel:` usages; unit tests. +- `@threadplane/chat`: `tools()` + `action`/`view`/`ask`; executor; `Agent.clientTools` type; JSON-Schema derivation; unit tests. +- `@threadplane/ag-ui`: `clientTools` impl (native `tools` + `addMessage`/re-run); reducer `pending` detection; unit tests. +- `@threadplane/langgraph`: `clientTools` impl (catalog via input + `ToolMessage` re-run); unit tests. +- Python `client-tools` middleware package + PyPI publishing infra; unit tests. +- Two cockpit examples (ag-ui + langgraph) with e2e + docs guide + registry/manifest/ports wiring. + +## Testing strategy + +- **Render**: unit tests for registry metadata pass-through, `injectRenderHost` (`set`/`emit`/`result`), `RenderResultEvent` propagation through `RenderSpecComponent.events`; ported `a2ui:datamodel:` cases pass under `host.set`. +- **Chat**: unit tests for `tools()`/constructors (name from key, schema inference), JSON-Schema derivation (Zod + an explicit-override validator), and the executor's four dispatch paths + error/parallel handling against a fake `Agent.clientTools`. +- **Adapters**: unit tests that `setCatalog`/`resolve` produce the right transport calls (AG-UI `runAgent({tools})` + `addMessage`; LangGraph run with appended `messages`); reducer `pending` detection. +- **Python middleware**: unit tests for stub binding + the route-to-END-on-client-tool edge + resume-with-tool-message continuation. +- **e2e**: per example, aimock-replay fixtures covering each of the three kinds end to end through the cockpit shell. + +## Open questions / risks + +- **PyPI publishing infra** is net-new; sequence it early so the middleware can be consumed by the example backends. +- **`view` auto-ack payload** (`{ shown: true }`) is a convention — confirm it reads acceptably to models in practice during the example e2e. +- **Standard Schema → JSON Schema** only has a first-class path for Zod (`z.toJSONSchema`); other validators rely on the explicit `parameters` override. Documented, Zod preferred in samples. +- **Reducer change** in `@threadplane/ag-ui` to surface "unresolved catalog tool call after run end" must not regress existing backend tool-call handling. + +## Phasing (one spec, suggested implementation order) + +1. Render lib contract (`schema`/`description`, `injectRenderHost`, `RenderResultEvent`) + port `a2ui:datamodel:` usages. +2. Chat lib `tools()`/constructors + JSON-Schema derivation + `Agent.clientTools` type + executor (against a fake adapter). +3. Python middleware + PyPI publishing infra. +4. `@threadplane/ag-ui` `clientTools` + reducer `pending`. +5. `@threadplane/langgraph` `clientTools`. +6. cockpit `ag-ui/client-tools` example (python + angular + e2e + docs). +7. cockpit `langgraph/client-tools` example (python + angular + e2e + docs). +8. Registry/manifest/ports wiring + full verification. From 94e8ace0e0fab4cf2dc8227e3bc92d72144f6da3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 19:37:28 -0700 Subject: [PATCH 02/28] docs(plan): client-tools index + plan 01 (render foundation) Decompose client-tools into six dependency-ordered, independently-testable plans. Plan 01 (render foundation) is full TDD detail: RenderViewEntry schema/description, RenderResultEvent, and an element-scoped injectRenderHost() (set/emit/result) added alongside the legacy emit so each step stays green; the a2ui:datamodel: removal + catalog migration is isolated in plan 01b. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-08-client-tools-00-index.md | 20 + ...06-08-client-tools-01-render-foundation.md | 455 ++++++++++++++++++ 2 files changed, 475 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-client-tools-00-index.md create mode 100644 docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md diff --git a/docs/superpowers/plans/2026-06-08-client-tools-00-index.md b/docs/superpowers/plans/2026-06-08-client-tools-00-index.md new file mode 100644 index 000000000..881266518 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-client-tools-00-index.md @@ -0,0 +1,20 @@ +# Client Tools — Plan Index + +> Spec: `docs/superpowers/specs/2026-06-08-client-tools-design.md` + +The spec spans six independently-testable subsystems. Per writing-plans guidance, each is its own plan that builds, tests, and commits on its own. Implement in dependency order: + +| # | Plan | Builds | Depends on | +|---|---|---|---| +| 01 | **Render foundation** | `@threadplane/render`: `RenderViewEntry.schema/description`; `RenderResultEvent`; `injectRenderHost()` (`set`/`emit`/`result`) added **alongside** the existing `emit` (no removal yet) | — | +| 01b | **a2ui catalog migration** | Port the 5 a2ui catalog input components + `emit-binding.ts` off the `a2ui:datamodel:` string protocol onto `injectRenderHost().set()`; then delete the legacy string path (`applyDatamodelWrite`, `A2UI_DATAMODEL_PREFIX`, `coerceValue`, the `emit` input) | 01 | +| 02 | **Chat lib `tools()` + executor** | `tools()` + `action`/`view`/`ask`; JSON-Schema derivation from Standard Schema; `Agent.clientTools` capability type; the executor (against a fake adapter) | 01 | +| 03 | **Python `client-tools` middleware** | Published LangGraph middleware: bind client stubs + route-to-END on a client tool call; PyPI publishing infra | — (parallel) | +| 04 | **`@threadplane/ag-ui` adapter** | `clientTools` impl (native `RunAgentInput.tools` + `addMessage`/re-run); reducer `pending` detection | 02 | +| 05 | **`@threadplane/langgraph` adapter** | `clientTools` impl (catalog via run input + `ToolMessage` re-run) | 02, 03 | +| 06 | **Cockpit examples** | `cockpit/ag-ui/client-tools` + `cockpit/langgraph/client-tools` (python + angular), each demoing function/view/ask; e2e + docs + registry/manifest/ports wiring | 03, 04, 05 | + +**Notes** +- Plan 01 is intentionally *additive* (the legacy `emit` keeps working) so every step stays green; the cross-lib string-protocol removal is isolated in **01b**. This is a small deviation from the spec's "port in one pass" — it sequences the risky cross-lib migration as its own reviewable plan. Flag if you'd rather fold 01b into 01. +- **TS LangGraph.js middleware** remains a separate future spec (not in this set). +- Each plan ends green (build + lint + its own tests) and commits. Patch-only versioning at 0.0.x for any `@threadplane/*` bump. diff --git a/docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md b/docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md new file mode 100644 index 000000000..b729a4fec --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md @@ -0,0 +1,455 @@ +# Client Tools — Plan 01: Render Foundation + +> **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:** Give `@threadplane/render` two agent-agnostic capabilities the client-tools feature needs: self-describing registry entries (`schema` + `description`) and a typed component output channel (`injectRenderHost()` exposing `set`/`emit`/`result`, with a new `RenderResultEvent`). + +**Architecture:** Additive only. `RenderViewEntry` gains optional `schema`/`description`. A new element-scoped `RenderHost` is provided by `RenderElementComponent` and obtained by mounted components via `injectRenderHost()`; `host.result(value)` emits a new `RenderResultEvent` through the existing `RenderSpecComponent.events` output. The legacy string `emit` input stays functional (its removal + the a2ui catalog migration is Plan 01b), so every step builds green. + +**Tech Stack:** Angular 20 (standalone, signals, DI), Vitest + `@angular/core/testing` TestBed, `@json-render/core`, `@standard-schema/spec` (types-only). + +--- + +### Task 1: Add `@standard-schema/spec` and extend `RenderViewEntry` + +**Files:** +- Modify: `libs/render/package.json` (add dependency) +- Modify: `libs/render/src/lib/render.types.ts:27-32` (RenderViewEntry) +- Test: `libs/render/src/lib/views.spec.ts` + +- [ ] **Step 1: Add the types-only Standard Schema dependency** + +Run: `npm install --save --workspace=libs/render @standard-schema/spec@^1.0.0` +Expected: `@standard-schema/spec` appears under `dependencies` in `libs/render/package.json`. (Zero-runtime; types only.) + +- [ ] **Step 2: Write the failing test** + +Add to `libs/render/src/lib/views.spec.ts`: + +```typescript +it('preserves schema and description on object-form entries', () => { + const schema = { '~standard': { version: 1, vendor: 'test', validate: (v: unknown) => ({ value: v }) } } as never; + const reg = views({ + weather_card: { component: CompA, schema, description: 'Show a weather card' }, + }); + const entry = reg['weather_card'] as { component: unknown; schema?: unknown; description?: string }; + expect(entry.component).toBe(CompA); + expect(entry.schema).toBe(schema); + expect(entry.description).toBe('Show a weather card'); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx nx test render -- views.spec` +Expected: FAIL — TypeScript error that `schema`/`description` are not assignable to `RenderViewEntry`. + +- [ ] **Step 4: Extend `RenderViewEntry`** + +In `libs/render/src/lib/render.types.ts`, add the import at the top (after the existing `@json-render/core` import): + +```typescript +import type { StandardSchemaV1 } from '@standard-schema/spec'; +``` + +Replace the `RenderViewEntry` interface (currently lines ~27-32): + +```typescript +export interface RenderViewEntry { + component: Type; + fallback?: Type; + /** Optional props contract for this component (Zod/Valibot/ArkType via + * Standard Schema). Carried + exposed by the render lib but NOT enforced + * on mount; consumers (e.g. client-tools) read it to advertise the + * component to a model and to validate incoming props. */ + schema?: StandardSchemaV1; + /** Optional human/model-facing description of what this component renders. */ + description?: string; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx nx test render -- views.spec` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/package.json libs/render/src/lib/render.types.ts libs/render/src/lib/views.spec.ts +git commit -m "feat(render): RenderViewEntry carries optional schema + description" +``` + +--- + +### Task 2: Add `RenderResultEvent` to the `RenderEvent` union + +**Files:** +- Modify: `libs/render/src/lib/render-event.ts` +- Modify: `libs/render/src/public-api.ts:38-44` (event exports) +- Test: `libs/render/src/lib/render-event.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `libs/render/src/lib/render-event.spec.ts`: + +```typescript +import type { RenderEvent, RenderResultEvent } from './render-event'; + +it('RenderResultEvent is assignable to RenderEvent', () => { + const ev: RenderResultEvent = { type: 'result', value: { ok: true }, elementKey: 'btn1' }; + const wide: RenderEvent = ev; + expect(wide.type).toBe('result'); + if (wide.type === 'result') { + expect(wide.value).toEqual({ ok: true }); + expect(wide.elementKey).toBe('btn1'); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test render -- render-event.spec` +Expected: FAIL — `RenderResultEvent` not exported / not part of the union. + +- [ ] **Step 3: Add the interface and extend the union** + +In `libs/render/src/lib/render-event.ts`, add before the `RenderEvent` union type: + +```typescript +export interface RenderResultEvent { + readonly type: 'result'; + readonly value: unknown; + readonly elementKey?: string; +} +``` + +Then update the union: + +```typescript +export type RenderEvent = + | RenderHandlerEvent + | RenderStateChangeEvent + | RenderLifecycleEvent + | RenderResultEvent; +``` + +- [ ] **Step 4: Export it from the public API** + +In `libs/render/src/public-api.ts`, update the events export block: + +```typescript +export type { + RenderEvent, + RenderHandlerEvent, + RenderStateChangeEvent, + RenderLifecycleEvent, + RenderResultEvent, +} from './lib/render-event'; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx nx test render -- render-event.spec` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/lib/render-event.ts libs/render/src/lib/render-event.spec.ts libs/render/src/public-api.ts +git commit -m "feat(render): add RenderResultEvent to the RenderEvent union" +``` + +--- + +### Task 3: Define `RenderHost`, the `RENDER_HOST` token, and `injectRenderHost()` + +**Files:** +- Create: `libs/render/src/lib/contexts/render-host.ts` +- Modify: `libs/render/src/public-api.ts` (add export) +- Test: `libs/render/src/lib/contexts/render-host.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/render/src/lib/contexts/render-host.spec.ts`: + +```typescript +import { Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { RENDER_HOST, injectRenderHost, type RenderHost } from './render-host'; + +@Component({ standalone: true, template: '' }) +class HostConsumer { + readonly host = injectRenderHost(); +} + +describe('injectRenderHost', () => { + it('returns the provided RENDER_HOST', () => { + const calls: unknown[] = []; + const fake: RenderHost = { + set: (p, v) => calls.push(['set', p, v]), + emit: (e, payload) => calls.push(['emit', e, payload]), + result: (v) => calls.push(['result', v]), + }; + TestBed.configureTestingModule({ + imports: [HostConsumer], + providers: [{ provide: RENDER_HOST, useValue: fake }], + }); + const fx = TestBed.createComponent(HostConsumer); + fx.componentInstance.host.set('/x', 1); + fx.componentInstance.host.result({ ok: true }); + expect(calls).toEqual([['set', '/x', 1], ['result', { ok: true }]]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test render -- render-host.spec` +Expected: FAIL — module `./render-host` does not exist. + +- [ ] **Step 3: Create the host contract + injector** + +Create `libs/render/src/lib/contexts/render-host.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { InjectionToken, inject } from '@angular/core'; + +/** + * The element-scoped host a mounted view component talks back through. + * Agent-agnostic: `result(value)` just means "this component produced a + * value"; the render lib surfaces it as a RenderResultEvent and never + * interprets it. Provided per-element by RenderElementComponent. + */ +export interface RenderHost { + /** Write a value to the render state store at a JSON-Pointer path. */ + set(path: string, value: unknown): void; + /** Fire a named event; routed to the element's `on[event]` handlers. */ + emit(event: string, payload?: Record): void; + /** Announce this component's result value (e.g. a HITL submission). */ + result(value: unknown): void; +} + +export const RENDER_HOST = new InjectionToken('RENDER_HOST'); + +/** Obtain the element-scoped RenderHost from inside a mounted view component. */ +export function injectRenderHost(): RenderHost { + return inject(RENDER_HOST); +} +``` + +- [ ] **Step 4: Export from the public API** + +In `libs/render/src/public-api.ts`, add after the Contexts block: + +```typescript +export { RENDER_HOST, injectRenderHost } from './lib/contexts/render-host'; +export type { RenderHost } from './lib/contexts/render-host'; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx nx test render -- render-host.spec` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/lib/contexts/render-host.ts libs/render/src/lib/contexts/render-host.spec.ts libs/render/src/public-api.ts +git commit -m "feat(render): add RenderHost contract + injectRenderHost()" +``` + +--- + +### Task 4: Provide `RenderHost` per element in `RenderElementComponent` + +**Files:** +- Modify: `libs/render/src/lib/render-element.component.ts` (imports, `@Component.providers`, a `host` field + handler-routing helper) +- Test: `libs/render/src/lib/render-element.component.spec.ts` + +Context: `RenderElementComponent` already injects `RENDER_CONTEXT` as `this.ctx` (with `store` and `emitEvent`), exposes `elementKey` and `element()`, and has a private `emitFn` that routes `el.on[event]` handlers via `runInInjectionContext(this.parentInjector, …)`. We add a `host` field that reuses that routing for `emit`, writes the store for `set`, and emits a `RenderResultEvent` via `this.ctx.emitEvent` for `result`. We provide it element-scoped so `NgComponentOutlet`-mounted children can inject it. + +- [ ] **Step 1: Write the failing test** + +Add to `libs/render/src/lib/render-element.component.spec.ts` (reuse the file's existing imports for `RenderElementComponent`, `RENDER_CONTEXT`, `signalStateStore`, `defineAngularRegistry`; add the ones below): + +```typescript +import { Component as HostCmp, inject as ngInject } from '@angular/core'; +import { injectRenderHost } from './contexts/render-host'; +import type { RenderEvent } from './render-event'; + +@HostCmp({ standalone: true, template: '' }) +class ResultEmitter { + private readonly host = injectRenderHost(); + fire() { this.host.result({ picked: 2 }); } +} + +@HostCmp({ + standalone: true, + imports: [RenderElementComponent], + template: ``, +}) +class ResultHost { + spec = { root: 'w1', elements: { w1: { type: 'emitter', props: {} } } } as Spec; +} + +describe('RenderElementComponent — RenderHost', () => { + it('result(value) reaches the host as a RenderResultEvent', () => { + const events: RenderEvent[] = []; + const store = signalStateStore({}); + TestBed.configureTestingModule({ + imports: [ResultHost], + providers: [{ + provide: RENDER_CONTEXT, + useValue: { + store, + registry: defineAngularRegistry({ emitter: ResultEmitter }), + functions: {}, + handlers: {}, + emitEvent: (e: RenderEvent) => events.push(e), + }, + }], + }); + const fx = TestBed.createComponent(ResultHost); + fx.detectChanges(); + fx.nativeElement.querySelector('button').click(); + expect(events).toContainEqual({ type: 'result', value: { picked: 2 }, elementKey: 'w1' }); + }); + + it('set(path, value) writes the render state store', () => { + const store = signalStateStore({}); + @HostCmp({ standalone: true, template: '' }) + class SetterCmp { + private readonly host = injectRenderHost(); + constructor() { this.host.set('/seats', 3); } + } + @HostCmp({ + standalone: true, + imports: [RenderElementComponent], + template: ``, + }) + class SetHost { spec = { root: 's1', elements: { s1: { type: 'setter', props: {} } } } as Spec; } + TestBed.configureTestingModule({ + imports: [SetHost], + providers: [{ + provide: RENDER_CONTEXT, + useValue: { store, registry: defineAngularRegistry({ setter: SetterCmp }), functions: {}, handlers: {} }, + }], + }); + const fx = TestBed.createComponent(SetHost); + fx.detectChanges(); + expect(store.getSnapshot()).toMatchObject({ seats: 3 }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test render -- render-element.component.spec` +Expected: FAIL — `NullInjectorError: No provider for InjectionToken RENDER_HOST`. + +- [ ] **Step 3: Import the host token + extract a handler-routing helper** + +In `libs/render/src/lib/render-element.component.ts`, add to the imports near `RENDER_CONTEXT`: + +```typescript +import { RENDER_HOST, type RenderHost } from './contexts/render-host'; +``` + +Add a private helper that performs the existing `el.on[event]` routing (factor it out of `emitFn` so both `emitFn` and the host reuse it). Insert it just above `emitFn`: + +```typescript + /** Invokes the element's `on[event]` handler bindings (shared by the + * legacy string `emit` input and the typed RenderHost). */ + private invokeHandlers(event: string, payload?: Record): void { + const el = this.element(); + if (!el?.on) return; + const binding = el.on[event]; + if (!binding) return; + const bindings = Array.isArray(binding) ? binding : [binding]; + for (const b of bindings) { + const handler = this.ctx.handlers?.[b.action]; + if (handler) { + const params = { ...(b.params as Record ?? {}), ...(payload ?? {}) }; + runInInjectionContext(this.parentInjector, () => handler(params)); + } + } + } +``` + +- [ ] **Step 4: Add the `host` field and provide it element-scoped** + +Add the `host` field to the class (near `emitFn`): + +```typescript + /** Element-scoped host injected by mounted view components via + * injectRenderHost(). `set` writes the store; `emit` routes element + * handlers; `result` surfaces a RenderResultEvent for this element. */ + readonly host: RenderHost = { + set: (path: string, value: unknown) => this.ctx.store?.set(path, value), + emit: (event: string, payload?: Record) => this.invokeHandlers(event, payload), + result: (value: unknown) => + this.ctx.emitEvent?.({ type: 'result', value, elementKey: this.elementKey() }), + }; +``` + +In the `@Component({...})` decorator, add a `providers` array (or extend it if one exists) so the mounted child can inject the ancestor element's host: + +```typescript + providers: [ + { provide: RENDER_HOST, useFactory: () => inject(RenderElementComponent).host }, + ], +``` + +> `inject(RenderElementComponent)` runs when the child resolves `RENDER_HOST`, by which point the ancestor `RenderElementComponent` is constructed and its `host` field is set. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test render -- render-element.component.spec` +Expected: PASS (both new tests + all existing element tests still green). + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/lib/render-element.component.ts libs/render/src/lib/render-element.component.spec.ts +git commit -m "feat(render): provide element-scoped RenderHost (set/emit/result)" +``` + +--- + +### Task 5: Full render lib green + barrel verification + +**Files:** +- Test/verify only. + +- [ ] **Step 1: Run the full render unit suite** + +Run: `npx nx test render` +Expected: PASS (all suites). + +- [ ] **Step 2: Lint + typecheck the render lib** + +Run: `npx nx lint render && npx nx run render:build` +Expected: PASS — no type errors; `injectRenderHost`, `RenderHost`, `RenderResultEvent` are exported from the built barrel. + +- [ ] **Step 3: Confirm downstream still builds (chat consumes render)** + +Run: `npx nx run chat:build` +Expected: PASS — the additive changes don't break `@threadplane/chat` (legacy `emit` input untouched). + +- [ ] **Step 4: Commit any incidental fixes** + +```bash +git add -A libs/render +git commit -m "test(render): full suite green for client-tools foundation" --allow-empty +``` + +--- + +## Self-Review + +- **Spec coverage (Section 1):** `RenderViewEntry.schema/description` → Task 1. `RenderResultEvent` → Task 2. `injectRenderHost()` `set`/`emit`/`result` → Tasks 3–4. The `a2ui:datamodel:` removal + catalog port is explicitly deferred to Plan 01b (see index) — Plan 01 keeps the legacy `emit` working so builds stay green. +- **Placeholders:** none — every code step shows full code; tests use the verbatim TestBed pattern from the existing render specs. +- **Type consistency:** `RenderHost` (`set`/`emit`/`result`) is identical across Task 3 (definition), Task 4 (implementation), and the tests. `RenderResultEvent` fields (`type:'result'`, `value`, `elementKey?`) match in Tasks 2 and 4. `emitEvent` is read off `RenderContext` exactly as defined in `contexts/render-context.ts`. +- **Risk:** the `RENDER_HOST` `useFactory: () => inject(RenderElementComponent).host` relies on the child resolving the token under the element's injector (true for `NgComponentOutlet` children). Task 4's first test exercises exactly this path; if it fails with a null injector, switch the provider to `{ provide: RENDER_HOST, useFactory: (el: RenderElementComponent) => el.host, deps: [RenderElementComponent] }`. From cd7176074688b386a7617a05878839a6d3544d12 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 19:48:18 -0700 Subject: [PATCH 03/28] docs(plan): vendor StandardSchemaV1 in plan 01 (avoid lockfile regen) Task 1 now vendors the Standard Schema type instead of npm-installing @standard-schema/spec, per the repo rule against regenerating package-lock.json on macOS (drops Linux @next/swc-* bindings, breaks CI). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06-08-client-tools-01-render-foundation.md | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md b/docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md index b729a4fec..024144b09 100644 --- a/docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md +++ b/docs/superpowers/plans/2026-06-08-client-tools-01-render-foundation.md @@ -10,17 +10,62 @@ --- -### Task 1: Add `@standard-schema/spec` and extend `RenderViewEntry` +### Task 1: Vendor the Standard Schema type and extend `RenderViewEntry` **Files:** -- Modify: `libs/render/package.json` (add dependency) +- Create: `libs/render/src/lib/standard-schema.ts` (vendored types-only interface) - Modify: `libs/render/src/lib/render.types.ts:27-32` (RenderViewEntry) +- Modify: `libs/render/src/public-api.ts` (export `StandardSchemaV1`) - Test: `libs/render/src/lib/views.spec.ts` -- [ ] **Step 1: Add the types-only Standard Schema dependency** +> **Do NOT run `npm install`.** This repo's `package-lock.json` must not be regenerated on macOS (it drops Linux `@next/swc-*` bindings and breaks CI). The Standard Schema spec interface is explicitly designed to be vendored — copy the type in rather than adding a dependency. -Run: `npm install --save --workspace=libs/render @standard-schema/spec@^1.0.0` -Expected: `@standard-schema/spec` appears under `dependencies` in `libs/render/package.json`. (Zero-runtime; types only.) +- [ ] **Step 1: Vendor the Standard Schema interface** + +Create `libs/render/src/lib/standard-schema.ts`: + +```typescript +// SPDX-License-Identifier: MIT +// Vendored from the Standard Schema spec (https://standardschema.dev) — the +// spec is published expressly to be copied in rather than depended on. Zero +// runtime; types only. Lets a RenderViewEntry carry any spec-compliant +// validator (Zod/Valibot/ArkType) without a package dependency. +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + export interface Props { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => Result | Promise>; + readonly types?: Types | undefined; + } + export type Result = SuccessResult | FailureResult; + export interface SuccessResult { + readonly value: Output; + readonly issues?: undefined; + } + export interface FailureResult { + readonly issues: ReadonlyArray; + } + export interface Issue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; + } + export interface PathSegment { + readonly key: PropertyKey; + } + export interface Types { + readonly input: Input; + readonly output: Output; + } + export type InferInput = + NonNullable['input']; + export type InferOutput = + NonNullable['output']; +} +``` - [ ] **Step 2: Write the failing test** @@ -49,7 +94,13 @@ Expected: FAIL — TypeScript error that `schema`/`description` are not assignab In `libs/render/src/lib/render.types.ts`, add the import at the top (after the existing `@json-render/core` import): ```typescript -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { StandardSchemaV1 } from './standard-schema'; +``` + +Also export the vendored type from the public API — in `libs/render/src/public-api.ts`, under the Types block: + +```typescript +export type { StandardSchemaV1 } from './lib/standard-schema'; ``` Replace the `RenderViewEntry` interface (currently lines ~27-32): @@ -76,7 +127,7 @@ Expected: PASS. - [ ] **Step 6: Commit** ```bash -git add libs/render/package.json libs/render/src/lib/render.types.ts libs/render/src/lib/views.spec.ts +git add libs/render/src/lib/standard-schema.ts libs/render/src/lib/render.types.ts libs/render/src/public-api.ts libs/render/src/lib/views.spec.ts git commit -m "feat(render): RenderViewEntry carries optional schema + description" ``` From 50b037068ce1c452dd215f00abc92fac49e9e089 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 19:56:29 -0700 Subject: [PATCH 04/28] feat(render): RenderViewEntry carries optional schema + description --- libs/render/src/lib/render.types.ts | 8 ++++++ libs/render/src/lib/standard-schema.ts | 40 ++++++++++++++++++++++++++ libs/render/src/lib/views.spec.ts | 27 +++++++++++++++++ libs/render/src/public-api.ts | 1 + 4 files changed, 76 insertions(+) create mode 100644 libs/render/src/lib/standard-schema.ts diff --git a/libs/render/src/lib/render.types.ts b/libs/render/src/lib/render.types.ts index 741f5d029..7fc9dbbb1 100644 --- a/libs/render/src/lib/render.types.ts +++ b/libs/render/src/lib/render.types.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { Type } from '@angular/core'; import type { Spec, StateStore, ComputedFunction } from '@json-render/core'; +import type { StandardSchemaV1 } from './standard-schema'; export interface AngularComponentInputs { /** Two-way binding paths: prop name → absolute state path */ @@ -30,6 +31,13 @@ export type AngularComponentRenderer = Type; export interface RenderViewEntry { component: Type; fallback?: Type; + /** Optional props contract for this component (Zod/Valibot/ArkType via + * Standard Schema). Carried + exposed by the render lib but NOT enforced + * on mount; consumers (e.g. client-tools) read it to advertise the + * component to a model and to validate incoming props. */ + schema?: StandardSchemaV1; + /** Optional human/model-facing description of what this component renders. */ + description?: string; } export interface AngularRegistry { diff --git a/libs/render/src/lib/standard-schema.ts b/libs/render/src/lib/standard-schema.ts new file mode 100644 index 000000000..01fd54bb0 --- /dev/null +++ b/libs/render/src/lib/standard-schema.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Vendored from the Standard Schema spec (https://standardschema.dev) — the +// spec is published expressly to be copied in rather than depended on. Zero +// runtime; types only. Lets a RenderViewEntry carry any spec-compliant +// validator (Zod/Valibot/ArkType) without a package dependency. +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + export interface Props { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => Result | Promise>; + readonly types?: Types | undefined; + } + export type Result = SuccessResult | FailureResult; + export interface SuccessResult { + readonly value: Output; + readonly issues?: undefined; + } + export interface FailureResult { + readonly issues: ReadonlyArray; + } + export interface Issue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; + } + export interface PathSegment { + readonly key: PropertyKey; + } + export interface Types { + readonly input: Input; + readonly output: Output; + } + export type InferInput = + NonNullable['input']; + export type InferOutput = + NonNullable['output']; +} diff --git a/libs/render/src/lib/views.spec.ts b/libs/render/src/lib/views.spec.ts index 736161b43..01057047a 100644 --- a/libs/render/src/lib/views.spec.ts +++ b/libs/render/src/lib/views.spec.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { Component } from '@angular/core'; import { views, withViews, withoutViews, toRenderRegistry, overrideViews } from './views'; +import type { RenderViewEntry } from './render.types'; +import type { StandardSchemaV1 } from './standard-schema'; @Component({ selector: 'render-test-a', standalone: true, template: 'A' }) class CompA {} @@ -104,3 +106,28 @@ describe('toRenderRegistry()', () => { expect(renderReg.names()).toContain('b'); }); }); + +describe('RenderViewEntry schema + description', () => { + it('preserves schema and description on object-form entries', () => { + const schema = { '~standard': { version: 1, vendor: 'test', validate: (v: unknown) => ({ value: v }) } } as never; + const reg = views({ + weather_card: { component: CompA, schema, description: 'Show a weather card' }, + }); + const entry = reg['weather_card'] as { component: unknown; schema?: unknown; description?: string }; + expect(entry.component).toBe(CompA); + expect(entry.schema).toBe(schema); + expect(entry.description).toBe('Show a weather card'); + }); + + it('accepts schema + description without a cast (compile-time type gate)', () => { + const schema: StandardSchemaV1 = { + '~standard': { version: 1, vendor: 'test', validate: (v) => ({ value: v }) }, + }; + // This assignment is uncast — if `schema`/`description` were removed from + // RenderViewEntry, tsc would fail here, so the test gates on the interface + // change itself rather than on the runtime spread preserving extra keys. + const entry: RenderViewEntry = { component: CompA, schema, description: 'Test' }; + expect(entry.schema).toBe(schema); + expect(entry.description).toBe('Test'); + }); +}); diff --git a/libs/render/src/public-api.ts b/libs/render/src/public-api.ts index d8c1034c6..385d81c40 100644 --- a/libs/render/src/public-api.ts +++ b/libs/render/src/public-api.ts @@ -7,6 +7,7 @@ export type { AngularRegistry, RenderConfig, } from './lib/render.types'; +export type { StandardSchemaV1 } from './lib/standard-schema'; // Contexts export { RENDER_CONTEXT } from './lib/contexts/render-context'; From af3b59fe58c3d4bc60a355b2dc72748c716f0c3e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 20:31:14 -0700 Subject: [PATCH 05/28] feat(render): add RenderResultEvent to the RenderEvent union Co-Authored-By: Claude Sonnet 4.6 --- libs/render/src/lib/render-event.spec.ts | 11 +++++++++++ libs/render/src/lib/render-event.ts | 9 ++++++++- libs/render/src/public-api.ts | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/libs/render/src/lib/render-event.spec.ts b/libs/render/src/lib/render-event.spec.ts index 3008bef74..612529325 100644 --- a/libs/render/src/lib/render-event.spec.ts +++ b/libs/render/src/lib/render-event.spec.ts @@ -5,6 +5,7 @@ import type { RenderHandlerEvent, RenderStateChangeEvent, RenderLifecycleEvent, + RenderResultEvent, } from './render-event'; describe('RenderEvent types', () => { @@ -76,3 +77,13 @@ describe('RenderEvent types', () => { expect(handlers[0].action).toBe('a'); }); }); + +it('RenderResultEvent is assignable to RenderEvent', () => { + const ev: RenderResultEvent = { type: 'result', value: { ok: true }, elementKey: 'btn1' }; + const wide: RenderEvent = ev; + expect(wide.type).toBe('result'); + if (wide.type === 'result') { + expect(wide.value).toEqual({ ok: true }); + expect(wide.elementKey).toBe('btn1'); + } +}); diff --git a/libs/render/src/lib/render-event.ts b/libs/render/src/lib/render-event.ts index 36e05e0d0..a7242dbd8 100644 --- a/libs/render/src/lib/render-event.ts +++ b/libs/render/src/lib/render-event.ts @@ -22,7 +22,14 @@ export interface RenderLifecycleEvent { readonly elementType?: string; } +export interface RenderResultEvent { + readonly type: 'result'; + readonly value: unknown; + readonly elementKey?: string; +} + export type RenderEvent = | RenderHandlerEvent | RenderStateChangeEvent - | RenderLifecycleEvent; + | RenderLifecycleEvent + | RenderResultEvent; diff --git a/libs/render/src/public-api.ts b/libs/render/src/public-api.ts index 385d81c40..9934998f2 100644 --- a/libs/render/src/public-api.ts +++ b/libs/render/src/public-api.ts @@ -39,6 +39,7 @@ export type { RenderHandlerEvent, RenderStateChangeEvent, RenderLifecycleEvent, + RenderResultEvent, } from './lib/render-event'; // Lifecycle From 306c56a523f038a4c061c4b0e18724737899f7e9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 20:38:18 -0700 Subject: [PATCH 06/28] feat(render): add RenderHost contract + injectRenderHost() Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/contexts/render-host.spec.ts | 27 +++++++++++++++++++ libs/render/src/lib/contexts/render-host.ts | 24 +++++++++++++++++ libs/render/src/public-api.ts | 2 ++ 3 files changed, 53 insertions(+) create mode 100644 libs/render/src/lib/contexts/render-host.spec.ts create mode 100644 libs/render/src/lib/contexts/render-host.ts diff --git a/libs/render/src/lib/contexts/render-host.spec.ts b/libs/render/src/lib/contexts/render-host.spec.ts new file mode 100644 index 000000000..934418353 --- /dev/null +++ b/libs/render/src/lib/contexts/render-host.spec.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { RENDER_HOST, injectRenderHost, type RenderHost } from './render-host'; + +@Component({ standalone: true, template: '' }) +class HostConsumer { + readonly host = injectRenderHost(); +} + +describe('injectRenderHost', () => { + it('returns the provided RENDER_HOST', () => { + const calls: unknown[] = []; + const fake: RenderHost = { + set: (p, v) => calls.push(['set', p, v]), + emit: (e, payload) => calls.push(['emit', e, payload]), + result: (v) => calls.push(['result', v]), + }; + TestBed.configureTestingModule({ + imports: [HostConsumer], + providers: [{ provide: RENDER_HOST, useValue: fake }], + }); + const fx = TestBed.createComponent(HostConsumer); + fx.componentInstance.host.set('/x', 1); + fx.componentInstance.host.result({ ok: true }); + expect(calls).toEqual([['set', '/x', 1], ['result', { ok: true }]]); + }); +}); diff --git a/libs/render/src/lib/contexts/render-host.ts b/libs/render/src/lib/contexts/render-host.ts new file mode 100644 index 000000000..4355e9d23 --- /dev/null +++ b/libs/render/src/lib/contexts/render-host.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +import { InjectionToken, inject } from '@angular/core'; + +/** + * The element-scoped host a mounted view component talks back through. + * Agent-agnostic: `result(value)` just means "this component produced a + * value"; the render lib surfaces it as a RenderResultEvent and never + * interprets it. Provided per-element by RenderElementComponent. + */ +export interface RenderHost { + /** Write a value to the render state store at a JSON-Pointer path. */ + set(path: string, value: unknown): void; + /** Fire a named event; routed to the element's `on[event]` handlers. */ + emit(event: string, payload?: Record): void; + /** Announce this component's result value (e.g. a HITL submission). */ + result(value: unknown): void; +} + +export const RENDER_HOST = new InjectionToken('RENDER_HOST'); + +/** Obtain the element-scoped RenderHost from inside a mounted view component. */ +export function injectRenderHost(): RenderHost { + return inject(RENDER_HOST); +} diff --git a/libs/render/src/public-api.ts b/libs/render/src/public-api.ts index 9934998f2..5eb378a67 100644 --- a/libs/render/src/public-api.ts +++ b/libs/render/src/public-api.ts @@ -14,6 +14,8 @@ export { RENDER_CONTEXT } from './lib/contexts/render-context'; export type { RenderContext } from './lib/contexts/render-context'; export { REPEAT_SCOPE } from './lib/contexts/repeat-scope'; export type { RepeatScope } from './lib/contexts/repeat-scope'; +export { RENDER_HOST, injectRenderHost } from './lib/contexts/render-host'; +export type { RenderHost } from './lib/contexts/render-host'; // Registry export { defineAngularRegistry } from './lib/define-angular-registry'; From d358e0bcb055b8913d0bba60fb93225a56db22ea Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 21:02:33 -0700 Subject: [PATCH 07/28] feat(render): provide element-scoped RenderHost (set/emit/result) Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/render-element.component.spec.ts | 70 +++++++++++++++++++ .../src/lib/render-element.component.ts | 45 ++++++++---- 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/libs/render/src/lib/render-element.component.spec.ts b/libs/render/src/lib/render-element.component.spec.ts index 0ac211154..631f114ae 100644 --- a/libs/render/src/lib/render-element.component.spec.ts +++ b/libs/render/src/lib/render-element.component.spec.ts @@ -490,6 +490,76 @@ describe('RenderElementComponent — fallback gate', () => { }); }); +// --- RenderHost tests (Task 4) --- + +import { Component as HostCmp, inject as hostInject } from '@angular/core'; +import { injectRenderHost } from './contexts/render-host'; +import type { RenderEvent } from './render-event'; + +@HostCmp({ standalone: true, template: '' }) +class ResultEmitter { + private readonly host = injectRenderHost(); + fire() { this.host.result({ picked: 2 }); } +} + +@HostCmp({ + standalone: true, + imports: [RenderElementComponent], + template: ``, +}) +class ResultHost { + spec = { root: 'w1', elements: { w1: { type: 'emitter', props: {} } } } as Spec; +} + +describe('RenderElementComponent — RenderHost', () => { + it('result(value) reaches the host as a RenderResultEvent', () => { + const events: RenderEvent[] = []; + const store = signalStateStore({}); + TestBed.configureTestingModule({ + imports: [ResultHost], + providers: [{ + provide: RENDER_CONTEXT, + useValue: { + store, + registry: defineAngularRegistry({ emitter: ResultEmitter }), + functions: {}, + handlers: {}, + emitEvent: (e: RenderEvent) => events.push(e), + }, + }], + }); + const fx = TestBed.createComponent(ResultHost); + fx.detectChanges(); + fx.nativeElement.querySelector('button').click(); + expect(events).toContainEqual({ type: 'result', value: { picked: 2 }, elementKey: 'w1' }); + }); + + it('set(path, value) writes the render state store', () => { + const store = signalStateStore({}); + @HostCmp({ standalone: true, template: '' }) + class SetterCmp { + private readonly host = injectRenderHost(); + constructor() { this.host.set('/seats', 3); } + } + @HostCmp({ + standalone: true, + imports: [RenderElementComponent], + template: ``, + }) + class SetHost { spec = { root: 's1', elements: { s1: { type: 'setter', props: {} } } } as Spec; } + TestBed.configureTestingModule({ + imports: [SetHost], + providers: [{ + provide: RENDER_CONTEXT, + useValue: { store, registry: defineAngularRegistry({ setter: SetterCmp }), functions: {}, handlers: {} }, + }], + }); + const fx = TestBed.createComponent(SetHost); + fx.detectChanges(); + expect(store.getSnapshot()).toMatchObject({ seats: 3 }); + }); +}); + // --- VIEW_REGISTRY token-fallback tests (Task 2) --- import { RenderSpecComponent } from './render-spec.component'; diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index ed95792e8..0b839d59f 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -24,6 +24,7 @@ import { import type { Spec, UIElement } from '@json-render/core'; import { RENDER_CONTEXT } from './contexts/render-context'; +import { RENDER_HOST, type RenderHost } from './contexts/render-host'; import { REPEAT_SCOPE } from './contexts/repeat-scope'; import type { RepeatScope } from './contexts/repeat-scope'; import { buildPropResolutionContext } from './internals/prop-signal'; @@ -106,6 +107,9 @@ function coerceValue(raw: string): unknown { standalone: true, imports: [NgComponentOutlet], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { provide: RENDER_HOST, useFactory: (el: RenderElementComponent) => el.host, deps: [RenderElementComponent] }, + ], template: ` @if (!element()?.repeat) { @if (visible()) { @@ -235,6 +239,33 @@ export class RenderElementComponent implements OnInit { return evaluateVisibility(el.visible, this.propCtx()); }); + /** Invokes the element's `on[event]` handler bindings (shared by the + * legacy string `emit` input and the typed RenderHost). */ + private invokeHandlers(event: string, payload?: Record): void { + const el = this.element(); + if (!el?.on) return; + const binding = el.on[event]; + if (!binding) return; + const bindings = Array.isArray(binding) ? binding : [binding]; + for (const b of bindings) { + const handler = this.ctx.handlers?.[b.action]; + if (handler) { + const params = { ...(b.params as Record ?? {}), ...(payload ?? {}) }; + runInInjectionContext(this.parentInjector, () => handler(params)); + } + } + } + + /** Element-scoped host injected by mounted view components via + * injectRenderHost(). `set` writes the store; `emit` routes element + * handlers; `result` surfaces a RenderResultEvent for this element. */ + readonly host: RenderHost = { + set: (path: string, value: unknown) => this.ctx.store?.set(path, value), + emit: (event: string, payload?: Record) => this.invokeHandlers(event, payload), + result: (value: unknown) => + this.ctx.emitEvent?.({ type: 'result', value, elementKey: this.elementKey() }), + }; + /** Emit function that delegates to context handlers AND handles the * canonical `a2ui:datamodel::` write-back protocol that * input components (TextField, MultipleChoice, CheckBox, Slider, @@ -257,19 +288,7 @@ export class RenderElementComponent implements OnInit { this.applyDatamodelWrite(event); return; } - const el = this.element(); - if (!el?.on) return; - const binding = el.on[event]; - if (!binding) return; - const bindings = Array.isArray(binding) ? binding : [binding]; - for (const b of bindings) { - const handler = this.ctx.handlers?.[b.action]; - if (handler) { - runInInjectionContext(this.parentInjector, () => - handler(b.params as Record ?? {}), - ); - } - } + this.invokeHandlers(event); }; private applyDatamodelWrite(event: string): void { From 9495bd7aec0d59d3a5da241814480a786aae754c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 21:12:39 -0700 Subject: [PATCH 08/28] fix(render): flatten vendored StandardSchema types to satisfy no-namespace lint The upstream Standard Schema vendoring snippet uses a TS namespace, which this repo forbids (@typescript-eslint/no-namespace, enforced as error). Flatten the nested types to top-level StandardSchema* aliases; the StandardSchemaV1 interface (the only consumed symbol so far) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/render/src/lib/standard-schema.ts | 78 ++++++++++++++++---------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/libs/render/src/lib/standard-schema.ts b/libs/render/src/lib/standard-schema.ts index 01fd54bb0..fbbbafcce 100644 --- a/libs/render/src/lib/standard-schema.ts +++ b/libs/render/src/lib/standard-schema.ts @@ -3,38 +3,54 @@ // spec is published expressly to be copied in rather than depended on. Zero // runtime; types only. Lets a RenderViewEntry carry any spec-compliant // validator (Zod/Valibot/ArkType) without a package dependency. +// +// The upstream spec models the nested types under a `StandardSchemaV1` +// namespace; this repo forbids TS namespaces (@typescript-eslint/no-namespace), +// so the nested types are flattened to top-level `StandardSchema*` aliases. The +// `StandardSchemaV1` interface itself is unchanged from the spec. + export interface StandardSchemaV1 { - readonly '~standard': StandardSchemaV1.Props; + readonly '~standard': StandardSchemaProps; +} + +export interface StandardSchemaProps { + readonly version: 1; + readonly vendor: string; + readonly validate: ( + value: unknown, + ) => StandardSchemaResult | Promise>; + readonly types?: StandardSchemaTypes | undefined; +} + +export type StandardSchemaResult = + | StandardSchemaSuccessResult + | StandardSchemaFailureResult; + +export interface StandardSchemaSuccessResult { + readonly value: Output; + readonly issues?: undefined; +} + +export interface StandardSchemaFailureResult { + readonly issues: ReadonlyArray; +} + +export interface StandardSchemaIssue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; } -export declare namespace StandardSchemaV1 { - export interface Props { - readonly version: 1; - readonly vendor: string; - readonly validate: (value: unknown) => Result | Promise>; - readonly types?: Types | undefined; - } - export type Result = SuccessResult | FailureResult; - export interface SuccessResult { - readonly value: Output; - readonly issues?: undefined; - } - export interface FailureResult { - readonly issues: ReadonlyArray; - } - export interface Issue { - readonly message: string; - readonly path?: ReadonlyArray | undefined; - } - export interface PathSegment { - readonly key: PropertyKey; - } - export interface Types { - readonly input: Input; - readonly output: Output; - } - export type InferInput = - NonNullable['input']; - export type InferOutput = - NonNullable['output']; +export interface StandardSchemaPathSegment { + readonly key: PropertyKey; } + +export interface StandardSchemaTypes { + readonly input: Input; + readonly output: Output; +} + +export type StandardSchemaInferInput = + NonNullable['input']; + +export type StandardSchemaInferOutput = + NonNullable['output']; From 759a24a58b4ad6736b1ad809283243555c0ecf12 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 22:10:19 -0700 Subject: [PATCH 09/28] refactor(chat): a2ui catalog writes via RenderHost.set (drop datamodel string protocol) - Rewrite emitBinding() to accept RenderHost and call host.set(path, value) with the typed value instead of emitting a2ui:datamodel: strings - Update 5 catalog components (text-field, check-box, slider, date-time-input, multiple-choice): inject injectRenderHost(), remove emit input, swap call sites - multiple-choice onCheckChange now passes the actual string[] array to host.set instead of JSON.stringify (no more string coercion needed) - Update all 6 specs (emit-binding + 5 component specs) to assert against host.set([path, value]) pairs with typed values Co-Authored-By: Claude Opus 4.8 (1M context) --- .../a2ui/catalog/check-box.component.spec.ts | 40 +++++++++------ .../lib/a2ui/catalog/check-box.component.ts | 8 +-- .../catalog/date-time-input.component.spec.ts | 37 +++++++++----- .../a2ui/catalog/date-time-input.component.ts | 6 ++- .../src/lib/a2ui/catalog/emit-binding.spec.ts | 51 ++++++++++++------- .../chat/src/lib/a2ui/catalog/emit-binding.ts | 7 +-- .../catalog/multiple-choice.component.spec.ts | 33 ++++++++++-- .../a2ui/catalog/multiple-choice.component.ts | 10 ++-- .../lib/a2ui/catalog/slider.component.spec.ts | 29 +++++++---- .../src/lib/a2ui/catalog/slider.component.ts | 6 ++- .../a2ui/catalog/text-field.component.spec.ts | 47 ++++++++++------- .../lib/a2ui/catalog/text-field.component.ts | 8 +-- 12 files changed, 185 insertions(+), 97 deletions(-) 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 28fec355c..9a377ed62 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 @@ -1,34 +1,44 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; +import type { RenderHost } from '@threadplane/render'; import { emitBinding } from './emit-binding'; +function makeHost(): { host: RenderHost; writes: Array<[string, unknown]> } { + const writes: Array<[string, unknown]> = []; + const host: RenderHost = { + set: (p, v) => writes.push([p, v]), + emit: () => { /* noop */ }, + result: () => { /* noop */ }, + }; + return { host, writes }; +} + describe('A2uiCheckBoxComponent — 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 boolean checked state from event → emit binding. + // of onChange: extract boolean checked state from event → write binding. - it('emits binding with true when checkbox is checked', () => { - const emit = vi.fn(); + it('writes binding with true (boolean) when checkbox is checked', () => { + const { host, writes } = makeHost(); const bindings = { checked: '/agreed' }; - // 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'); + emitBinding(host, bindings, 'checked', val); + expect(writes).toEqual([['/agreed', true]]); }); - it('emits binding with false when checkbox is unchecked', () => { - const emit = vi.fn(); + it('writes binding with false (boolean) when checkbox is unchecked', () => { + const { host, writes } = makeHost(); 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'); + emitBinding(host, bindings, 'checked', val); + expect(writes).toEqual([['/agreed', false]]); }); - it('does not emit when no binding exists for checked', () => { - const emit = vi.fn(); - emitBinding(emit, {}, 'checked', true); - expect(emit).not.toHaveBeenCalled(); + it('does not write when no binding exists for checked', () => { + const { host, writes } = makeHost(); + emitBinding(host, {}, 'checked', true); + expect(writes).toHaveLength(0); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts index 879b6f6fd..1072f4107 100644 --- a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; import type { Spec } from '@json-render/core'; +import { injectRenderHost } from '@threadplane/render'; import { emitBinding } from './emit-binding'; @Component({ @@ -31,13 +32,14 @@ import { emitBinding } from './emit-binding'; `], }) export class A2uiCheckBoxComponent { + private readonly host = injectRenderHost(); + readonly label = input(''); /** v1 canonical prop: boolean checked state. */ readonly value = input(undefined); /** Pre-v1 alias retained for back-compat. */ readonly checked = input(false); readonly _bindings = input>({}); - readonly emit = input<(event: string) => void>(() => { /* noop */ }); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly loading = input(false); @@ -52,9 +54,9 @@ export class A2uiCheckBoxComponent { // `value`; pre-v1 used `checked`. const bound = this._bindings(); if (bound['value']) { - emitBinding(this.emit(), bound, 'value', val); + emitBinding(this.host, bound, 'value', val); } else { - emitBinding(this.emit(), bound, 'checked', val); + emitBinding(this.host, bound, 'checked', val); } } } 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 index bc00fe767..195c3a23d 100644 --- 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 @@ -1,7 +1,18 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; +import type { RenderHost } from '@threadplane/render'; import { emitBinding } from './emit-binding'; +function makeHost(): { host: RenderHost; writes: Array<[string, unknown]> } { + const writes: Array<[string, unknown]> = []; + const host: RenderHost = { + set: (p, v) => writes.push([p, v]), + emit: () => { /* noop */ }, + result: () => { /* noop */ }, + }; + return { host, writes }; +} + describe('A2uiDateTimeInputComponent — v1 protocol', () => { // NOTE: Angular signal-based inputs can't be tested via TestBed without the // angular() vite plugin (NG0303). v1: enableDate + enableTime booleans drive @@ -32,28 +43,28 @@ describe('A2uiDateTimeInputComponent — v1 protocol', () => { }); describe('onChange emit logic', () => { - it('emits binding with date value', () => { - const emit = vi.fn(); + it('writes binding with date value', () => { + const { host, writes } = makeHost(); const bindings = { value: '/appointmentDate' }; 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'); + emitBinding(host, bindings, 'value', val); + expect(writes).toEqual([['/appointmentDate', '2026-04-15']]); }); - it('emits binding with datetime-local value', () => { - const emit = vi.fn(); + it('writes binding with datetime-local value', () => { + const { host, writes } = makeHost(); 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'); + emitBinding(host, bindings, 'value', val); + expect(writes).toEqual([['/scheduledAt', '2026-04-15T14:30']]); }); - it('does not emit when no binding exists for value', () => { - const emit = vi.fn(); - emitBinding(emit, {}, 'value', '2026-04-15'); - expect(emit).not.toHaveBeenCalled(); + it('does not write when no binding exists for value', () => { + const { host, writes } = makeHost(); + emitBinding(host, {}, 'value', '2026-04-15'); + expect(writes).toHaveLength(0); }); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts index 9c0516898..bc451d4a1 100644 --- a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; import type { Spec } from '@json-render/core'; +import { injectRenderHost } from '@threadplane/render'; import { emitBinding } from './emit-binding'; @Component({ @@ -49,6 +50,8 @@ export class A2uiDateTimeInputComponent { private static _idCounter = 0; protected readonly _inputId = `a2ui-date-time-input-${++A2uiDateTimeInputComponent._idCounter}`; + private readonly host = injectRenderHost(); + readonly label = input(''); /** v1 prop: value (resolved DynamicString). */ readonly value = input(''); @@ -57,7 +60,6 @@ export class A2uiDateTimeInputComponent { /** v1 prop: enableTime — include time portion. */ readonly enableTime = input(false); readonly _bindings = input>({}); - readonly emit = input<(event: string) => void>(() => { /* noop */ }); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly loading = input(false); @@ -75,6 +77,6 @@ export class A2uiDateTimeInputComponent { onChange(event: Event): void { const val = (event.target as HTMLInputElement).value; - emitBinding(this.emit(), this._bindings(), 'value', val); + emitBinding(this.host, this._bindings(), 'value', val); } } diff --git a/libs/chat/src/lib/a2ui/catalog/emit-binding.spec.ts b/libs/chat/src/lib/a2ui/catalog/emit-binding.spec.ts index 2ac2070e3..9b0130b01 100644 --- a/libs/chat/src/lib/a2ui/catalog/emit-binding.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/emit-binding.spec.ts @@ -1,35 +1,48 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; +import type { RenderHost } from '@threadplane/render'; import { emitBinding } from './emit-binding'; +function makeHost(): { host: RenderHost; writes: Array<[string, unknown]> } { + const writes: Array<[string, unknown]> = []; + const host: RenderHost = { + set: (p, v) => writes.push([p, v]), + emit: () => { /* noop */ }, + result: () => { /* noop */ }, + }; + return { host, writes }; +} + describe('emitBinding', () => { - it('emits a2ui:datamodel event with path and value', () => { - const emit = vi.fn(); - emitBinding(emit, { value: '/name' }, 'value', 'Alice'); - expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/name:Alice'); + it('writes typed value to host at bound path', () => { + const { host, writes } = makeHost(); + emitBinding(host, { value: '/name' }, 'value', 'Alice'); + expect(writes).toEqual([['/name', 'Alice']]); }); it('does nothing when binding prop is not in bindings map', () => { - const emit = vi.fn(); - emitBinding(emit, {}, 'value', 'Alice'); - expect(emit).not.toHaveBeenCalled(); + const { host, writes } = makeHost(); + emitBinding(host, {}, 'value', 'Alice'); + expect(writes).toHaveLength(0); }); it('does nothing when bindings is undefined', () => { - const emit = vi.fn(); - emitBinding(emit, undefined, 'value', 'Alice'); - expect(emit).not.toHaveBeenCalled(); + const { host, writes } = makeHost(); + emitBinding(host, undefined, 'value', 'Alice'); + expect(writes).toHaveLength(0); }); - it('emits numeric values', () => { - const emit = vi.fn(); - emitBinding(emit, { value: '/count' }, 'value', 42); - expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/count:42'); + it('writes numeric values without string coercion', () => { + const { host, writes } = makeHost(); + emitBinding(host, { value: '/count' }, 'value', 42); + expect(writes).toEqual([['/count', 42]]); + expect(typeof writes[0][1]).toBe('number'); }); - it('emits boolean values', () => { - const emit = vi.fn(); - emitBinding(emit, { checked: '/agreed' }, 'checked', true); - expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/agreed:true'); + it('writes boolean values without string coercion', () => { + const { host, writes } = makeHost(); + emitBinding(host, { checked: '/agreed' }, 'checked', true); + expect(writes).toEqual([['/agreed', true]]); + expect(typeof writes[0][1]).toBe('boolean'); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/emit-binding.ts b/libs/chat/src/lib/a2ui/catalog/emit-binding.ts index 7d9bc60ca..088965988 100644 --- a/libs/chat/src/lib/a2ui/catalog/emit-binding.ts +++ b/libs/chat/src/lib/a2ui/catalog/emit-binding.ts @@ -1,14 +1,15 @@ // SPDX-License-Identifier: MIT +import type { RenderHost } from '@threadplane/render'; -/** Emits a data model binding event if the prop has a binding path. */ +/** Writes a typed value to the render state store if the prop has a binding path. */ export function emitBinding( - emit: (event: string) => void, + host: RenderHost, bindings: Record | undefined, prop: string, value: unknown, ): void { const path = bindings?.[prop]; if (path) { - emit(`a2ui:datamodel:${path}:${value}`); + host.set(path, value); } } diff --git a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts index 7252cc9ed..279cdd835 100644 --- a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts @@ -1,8 +1,19 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; +import type { RenderHost } from '@threadplane/render'; import { A2uiMultipleChoiceComponent } from './multiple-choice.component'; import { emitBinding } from './emit-binding'; +function makeHost(): { host: RenderHost; writes: Array<[string, unknown]> } { + const writes: Array<[string, unknown]> = []; + const host: RenderHost = { + set: (p, v) => writes.push([p, v]), + emit: () => { /* noop */ }, + result: () => { /* noop */ }, + }; + return { host, writes }; +} + describe('A2uiMultipleChoiceComponent', () => { // NOTE: Angular signal-based inputs can't be tested via TestBed without the // angular() vite plugin (NG0303). Tests verify the behavioral contracts for @@ -34,13 +45,13 @@ describe('A2uiMultipleChoiceComponent', () => { }); describe('onSelectChange emit logic (single-select)', () => { - it('emits binding with selected value', () => { - const emit = vi.fn(); + it('writes binding with selected string value', () => { + const { host, writes } = makeHost(); const bindings = { selections: '/department' }; const event = { target: { value: 'Engineering' } } as unknown as Event; const val = (event.target as HTMLSelectElement).value; - emitBinding(emit, bindings, 'selections', val); - expect(emit).toHaveBeenCalledWith('a2ui:datamodel:/department:Engineering'); + emitBinding(host, bindings, 'selections', val); + expect(writes).toEqual([['/department', 'Engineering']]); }); }); @@ -65,5 +76,17 @@ describe('A2uiMultipleChoiceComponent', () => { it('is a no-op when removing a value not in selections', () => { expect(toggle(['a'], 'b', false)).toEqual(['a']); }); + + it('writes binding with actual array (not JSON string)', () => { + const { host, writes } = makeHost(); + const bindings = { selections: '/colors' }; + const current = ['red', 'blue']; + emitBinding(host, bindings, 'selections', current); + expect(writes).toHaveLength(1); + expect(writes[0][0]).toBe('/colors'); + expect(writes[0][1]).toEqual(['red', 'blue']); + // Ensure it's the actual array, not a JSON string + expect(Array.isArray(writes[0][1])).toBe(true); + }); }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts b/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts index 0416fabd7..b4296abcb 100644 --- a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; import type { Spec } from '@json-render/core'; +import { injectRenderHost } from '@threadplane/render'; import { emitBinding } from './emit-binding'; /** Resolved option shape — label and value are plain strings after surface-to-spec resolves them. */ @@ -84,6 +85,8 @@ interface ResolvedOption { `], }) export class A2uiMultipleChoiceComponent { + private readonly host = injectRenderHost(); + readonly label = input(''); /** Resolved current selections from surface-to-spec. Normalized in * `selectionsArray` because LLMs sometimes seed the data model with a @@ -102,7 +105,6 @@ export class A2uiMultipleChoiceComponent { /** When ≤ 1 — render as single-select