Skip to content

reactor.ui 1.1.0: one wire between a Python host and the page (RU-0) - #27

Merged
echarles merged 12 commits into
feat/apps-mcp-serverfrom
feat/ui-channel
Oct 12, 2026
Merged

echarles merged 12 commits into
feat/apps-mcp-serverfrom
feat/ui-channel

Conversation

@echarles

Copy link
Copy Markdown
Member

Stacked on #26 (feat/apps-mcp-server, which carries the 1.0.5 release): branched from it rather than main so 1.1.0 follows 1.0.5. When #26 merges this retargets to main.

RU-0 of the Reactor UI plan: a typed data plane between a Python host and the page.

Python: reactor.ui

  • wire.py: the envelope {v:1, op, scope, id, body} with eight ops (show|patch|append|remove|state|ask|window|meta), a discriminated union in pydantic. Also DisplayWire, ActionOffer, UiAction, FileRef, and the Display base class: its typed fields go on the wire as data, and text() and model_view() are required.
  • display.py: @display_type(type, version=) with MIME-like types, and DisplayRegistry / DISPLAY_TYPES. Audiences: audience("both"|"page"); SecretStr is in neither view. model_view() / page_view() also work on action payloads.
  • actions.py: ActionRegistry(secret, platform=) with register / offer / authorize / dispatch.
    • HMAC tokens are bound to the session, the display and the action.
    • Unknown action: UnknownUiAction (422). Bad token: 403. Veto: 403. Invalid payload: 422.
    • Idempotency ids: a repeat gets the first answer; a failed press is forgotten.
  • channel.py: the UiChannel protocol, MemoryUiChannel, and a helper for each op.
  • agui.py: an optional encoder. show becomes ACTIVITY_SNAPSHOT; patch and append become ACTIVITY_DELTA; state becomes STATE_DELTA; anything else is CUSTOM reactor.ui. New extra: agui.
  • schema.py and reactor ui schema --out ...; the output is committed as schema/reactor-ui.v1.schema.json.
  • New hooks: provide_display_types(registry) and authorize_ui_action(principal, session, action). Any False vetoes, and so does a hook that raises. PluginPlatform.display_types() and PluginPlatform.authorize_ui_action() call them.

TypeScript: @datalayer/reactor/ui and @datalayer/reactor/ui/react

  • The wire types are written by hand. UI_WIRE_FIELDS is checked against the schema by a drift test. scripts/generate-ui-types.mjs uses json-schema-to-typescript when it is installed; it is not a dependency.
  • Renderers (reactor.ui.renderer: {type, versions, rank, component|load}) and ToolBindings (reactor.ui.toolBinding: {tool: string|RegExp, type}).
  • resolveRenderer: the highest rank wins, then registration order; only renderers whose version range admits the display are considered.
  • resolveToolResult: an embedded _meta["reactor.ui/display"] comes first, then a binding, otherwise undefined.
  • applyUiOp: a pure reducer, RFC 6902 for patch and state, failing fast.
  • <ReactorDisplay display act context fallback loading/> draws the display with a lazy renderer and falls back to its text. useUiChannel({scope, onWindow}) keeps the reducer's model in a component.
  • Package exports: ./ui and ./ui/react.

Docs

docs/docs/ui-channel/.

Tests

  • pytest tests: 154 passed, 23 of them new: display, model_view and audiences; actions (token, veto, unknown → 422 at a route, idempotency); schema drift and the CLI.
  • vitest run src/ui src/react/ui: 21 passed, covering the wire drift, renderer rank, order, version range and disposal, tool resolution order, the reducer, ReactorDisplay and useUiChannel.

Every package is bumped to 1.1.0 with dev/bump_version.py minor. Do not tag yet.

Python `reactor.ui`: the envelope {v:1, op, scope, id, body} with eight ops
(show|patch|append|remove|state|ask|window|meta) as pydantic models; Display
(typed fields as `data`, mandatory text(), model_view()); @display_type with
MIME-like types and versions; audiences (`both`, `page`, secrets for neither);
ActionRegistry (HMAC tokens bound to session + display + action, unknown
action 422, bad token / veto 403, idempotency ids); UiChannel and
MemoryUiChannel; an optional AG-UI encoder; the JSON Schema, committed as
schema/reactor-ui.v1.schema.json and written by `reactor ui schema`. Hooks
provide_display_types and authorize_ui_action (any False or a raise vetoes).

TypeScript `@datalayer/reactor/ui`: wire types held to the schema by a drift
test, the Renderers and ToolBindings contribution points, resolveRenderer
(rank, then registration order, version range), resolveToolResult (embedded
_meta["reactor.ui/display"] first, then a binding), and a pure reducer with
RFC 6902. `@datalayer/reactor/ui/react`: ReactorDisplay (lazy renderer, text
fallback) and useUiChannel.

Docs: docs/docs/ui-channel. Every package bumped to 1.1.0.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved security, wire-validation, AG-UI patching, compilation, and idempotency issues block approval.

9 open findings
What changed in this PR

Introduces Reactor UI’s typed Python-to-page data plane, renderer APIs, React integration, schema tooling, documentation, and release updates.

Changes:

  • Adds Python wire, display, action, channel, AG-UI, schema, and plugin-hook APIs.
  • Adds TypeScript renderers, reducers, React components/hooks, and tests.
  • Publishes the new UI exports and bumps release packages to 1.1.0.
File Description
vitest.config.ts Adds UI package aliases.
tests/​test_ui_schema.py Tests schema drift and CLI output.
tests/​test_ui_display.py Tests displays, audiences, channels, and AG-UI.
tests/​test_ui_actions.py Tests action authorization and idempotency.
src/​ui/​wire.ts Defines TypeScript wire contracts and guards.
src/​ui/​renderers.ts Adds renderers and tool-result resolution.
src/​ui/​reducer.ts Implements UI state and JSON Patch reduction.
src/​ui/​index.ts Exports framework-independent UI APIs.
src/​ui/​__tests__/​wire.test.ts Tests schema drift and guards.
src/​ui/​__tests__/​renderers.test.ts Tests renderer and binding resolution.
src/​ui/​__tests__/​reducer.test.ts Tests reducer and patch behavior.
src/​react/​ui/​useUiChannel.ts Adds the React channel hook.
src/​react/​ui/​ReactorDisplay.tsx Adds pluggable display rendering.
src/​react/​ui/​index.ts Exports React UI APIs.
src/​react/​ui/​__tests__/​ReactorDisplay.test.tsx Tests rendering and channel integration.
scripts/​generate-ui-types.mjs Adds optional schema-to-TypeScript generation.
schema/​reactor-ui.v1.schema.json Commits the v1 wire schema.
reactor/​ui/​wire.py Defines canonical Python wire models.
reactor/​ui/​schema.py Generates the wire schema.
reactor/​ui/​display.py Adds display types and audience filtering.
reactor/​ui/​channel.py Adds channels and operation helpers.
reactor/​ui/​agui.py Encodes operations as AG-UI events.
reactor/​ui/​actions.py Adds signed, authorized UI actions.
reactor/​ui/​__init__.py Exposes the Python UI API.
reactor/​reactor.py Integrates display and authorization hooks.
reactor/​hooks.py Declares new UI plugin hooks.
reactor/​cli.py Adds the UI schema command.
pyproject.toml Adds the optional AG-UI dependency.
plugins/​shell/​package.json Bumps shell to 1.1.0.
plugins/​manager/​package.json Bumps manager to 1.1.0.
plugins/​graph/​package.json Bumps graph to 1.1.0.
plugins/​commands/​package.json Bumps commands to 1.1.0.
package.json Adds UI exports, schema files, and version bump.
docs/​docs/​ui-channel/​index.md Documents the UI channel.
docs/​docs/​ui-channel/​_category_.json Adds the UI documentation category.
docs/​docs/​mcp-server/​_category_.json Reorders MCP documentation.
docs/​docs/​examples/​_category_.json Reorders examples documentation.
docs/​docs/​core-plugins/​_category_.json Reorders plugin documentation.
apps/​mcp-server/​reactor_mcp_server/​__init__.py Updates fallback version.
apps/​mcp-server/​pyproject.toml Updates MCP package and dependency versions.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread reactor/ui/actions.py Outdated
def mint(self, session: str, display_id: str, action: str) -> str:
"""The token that lets this session press this action on this display."""
self.get(action)
message = "\0".join(("reactor.ui/v1", session, display_id, action)).encode()
Comment thread src/ui/reducer.ts Outdated
Comment on lines +64 to +65
} else if (typeof node === 'object' && node !== null && token in node) {
node = (node as Record<string, unknown>)[token];
Comment thread src/ui/renderers.ts Outdated
return result;
}
try {
return JSON.stringify(result);
Comment thread src/ui/wire.ts
Comment on lines +163 to +169
export function isUiOp(value: unknown): value is UiOp {
return (
isRecord(value) &&
value.v === UI_WIRE_VERSION &&
typeof value.scope === 'string' &&
UI_OP_NAMES.includes(value.op as UiOpName)
);
Comment thread reactor/reactor.py
Comment on lines +1109 to +1112
try:
self._run_plugin_call(plugin_name, lambda: provider(registry))
except Exception as error: # noqa: BLE001
logger.warning("Plugin %s failed to provide display types: %s", plugin_name, error)
Comment thread reactor/ui/actions.py Outdated
Comment on lines +215 to +217
self._done[key] = future
while len(self._done) > self._remember:
self._done.popitem(last=False)
Comment thread reactor/ui/agui.py Outdated
Comment on lines +54 to +57
patch=[
{**operation.model_dump(mode="json", by_alias=True, exclude_unset=True), "path": "/display" + operation.path}
for operation in op.body
],
Comment thread reactor/ui/wire.py Outdated
Comment on lines +77 to +80
op: Literal["add", "remove", "replace", "move", "copy", "test"]
path: str
value: Any = None
from_: str | None = Field(default=None, alias="from")
Comment thread src/ui/reducer.ts
Comment on lines +157 to +159
function equal(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
- actions: the token's HMAC message length-prefixes each part (8-byte
  big-endian length + bytes), so one token binds exactly one tuple.
- actions: a running press is never forgotten; only settled ones make room,
  and a press arriving while every remembered press runs is refused with
  UiActionBusy (429).
- display types: each provider declares into its own registry, merged only
  once it succeeded (all-or-nothing, conflicts included).
- agui: move/copy `from` is rebased under /display with `path`.
- wire: a patch operation is an op-discriminated union (PatchAdd, PatchRemove,
  PatchReplace, PatchMove, PatchCopy, PatchTest) with the members RFC 6902
  requires; schema regenerated, TS types and drift fields follow.
- reducer: pointers walk own properties only and refuse __proto__,
  constructor and prototype; `test` compares structurally, not by key order.
- wire.ts: isUiOp checks each op's id and body as the Python models do.
- renderers: textOf falls back to String() when JSON.stringify has no answer.
… (CI installed .[dev] without the agui extra)
…e (PR #27)

docs/docs/ui-channel: an overview (one wire, displays with a text and what a
model sees, renderers keyed by type, actions with tokens) and a page each for
displays and audiences, renderers and tool bindings, the channel ops and the
reducer (JSON Patch rules, pointer safety), actions (tokens, the veto hook,
idempotency, 403/422/429), AG-UI, the schema and types, and security. Linked
from the docs index, both getting-started pages and the examples index.

examples/ui-channel: an approval card from a FastAPI host to a React page and
back -- a @display_type with page-only fields and a secret, a plugin that
declares the type and vetoes a requester deciding their own request, the ops a
decision emits, and a page whose plugin contributes a lazy renderer and a tool
binding, keeps the ops with useUiChannel and presses with the offered token.
A recorded session (fixtures/demo-ops.json) is checked by the Python test and
replayed by the vitest tests, which the root vitest config now includes.
…TOR_UI RU-2) — ACTIVITY_SNAPSHOT/DELTA and CUSTOM reactor.ui back into ops, scope kept per display, anything of the reactor's not its shape refused; one committed example stream holds encoder and decoder together
…b (a UiChannel: ops numbered and kept per scope, handed to every connection on it) and UiSocket (the ASGI endpoint a host mounts): hello first with the credential (never in the URL), welcome, ops replayed after since then live, action and state frames answered by result frames with the registry's status, pings both ways, a bounded queue per connection (4429), every frame checked against the schema (4400), 4401/4403/4408/4410; the frames in reactor.ui.wire and the committed schema. Page: @datalayer/reactor/ui/ws UiSocket (ordering, ops applied once, gap and drop resume from the last op, presses sent again with their idempotency id, refusal stops, heartbeats) and useUiSocket in ui/react. One committed exchange (schema/reactor-ui.v1.ws-examples.json) written by tests/test_ui_ws.py and replayed by src/ui/__tests__/ws.test.ts; two tabs on both tiers. The approval card example moves from polling and an action POST to the socket. Docs ui-channel/websocket
…sh hello, up to the welcome's seq) — drawn again, never acted on as news; useUiSocket raises onWindow for new window ops only (REACTOR_UI RU-W)
…both tiers (REACTOR_UI P5, RU-4): what a display shown by ask offers, the host checking the value against what it asked; docs ui-channel/actions, the page pressing up the socket
….1 and 1.0 order a JSON patch's keys differently (CI on 1.0.0)
…RU-4) and AguiUiDecoder (RU-2) on top of 1.1.0's ui channel
@echarles
echarles merged commit 3ca3b54 into feat/apps-mcp-server Oct 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants