cortena-ui 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * cortena-ui A2UI entry point.
3
+ *
4
+ * Layer 1 of `cortenaUI&Design/05-agent-ui.md`: the declarative vocabulary an
5
+ * agent draws with, bound to this package, plus the React renderer that turns
6
+ * an A2UI payload into cortena-ui components and user events back into
7
+ * structured actions.
8
+ *
9
+ * ```tsx
10
+ * import { A2UIRenderer, defaultCatalogue } from "cortena-ui";
11
+ *
12
+ * <A2UIRenderer
13
+ * message={customEvent.value} // the AG-UI CUSTOM event payload
14
+ * catalogue={defaultCatalogue}
15
+ * onAction={(action) => run.send(action)}
16
+ * resolveDataSource={(id) => tableSources[id]}
17
+ * />
18
+ * ```
19
+ */
20
+
21
+ export {
22
+ A2UI_CATALOGUE_ID,
23
+ catalogueNames,
24
+ defaultCatalogue,
25
+ } from "./catalogue";
26
+ export type { A2UICatalogue, A2UICatalogueEntry, A2UIExample, A2UIGroup } from "./catalogue";
27
+ export { buildCatalogueDoc, catalogueMarkdown, exampleMessages } from "./catalogue-doc";
28
+ export type { A2UICatalogueDoc, A2UIComponentDoc, JsonSchema } from "./catalogue-doc";
29
+ export {
30
+ A2UI_DEFAULT_SURFACE_ID,
31
+ A2UI_VERSION,
32
+ foldMessages,
33
+ getAtPointer,
34
+ isDataBinding,
35
+ parseA2UIMessages,
36
+ pointerSegments,
37
+ readAction,
38
+ readChildren,
39
+ readComponent,
40
+ resolvePointer,
41
+ resolveValue,
42
+ setAtPointer,
43
+ } from "./message";
44
+ export type {
45
+ A2UIAction,
46
+ A2UIActionSpec,
47
+ A2UIBeginRendering,
48
+ A2UIChildList,
49
+ A2UIChildren,
50
+ A2UIComponentSpec,
51
+ A2UICreateSurface,
52
+ A2UIDataBinding,
53
+ A2UIDeleteSurface,
54
+ A2UIFoldResult,
55
+ A2UIMessage,
56
+ A2UIMessageInput,
57
+ A2UIPropertyValue,
58
+ A2UISurface,
59
+ A2UIUpdateComponents,
60
+ A2UIUpdateDataModel,
61
+ } from "./message";
62
+ export { A2UIRenderer } from "./renderer";
63
+ export type { A2UIRendererProps } from "./renderer";
64
+ export { A2UIErrorCard } from "./views";
65
+ export type {
66
+ A2UIColumn,
67
+ A2UINodeInfo,
68
+ A2UIResolvedAction,
69
+ A2UIRow,
70
+ A2UITableSource,
71
+ A2UIView,
72
+ A2UIViewProps,
73
+ } from "./views";
@@ -0,0 +1,590 @@
1
+ /**
2
+ * A2UI message layer.
3
+ *
4
+ * The wire format cortena-ui accepts, and the fold from a stream of messages
5
+ * onto a set of surfaces. Deliberately free of React so it can be reused by a
6
+ * canvas host, a test fixture, or the catalogue doc generator.
7
+ *
8
+ * ## Two dialects, one fold
9
+ *
10
+ * **v0.9** is the current A2UI protocol (`createSurface`, `updateComponents`,
11
+ * `updateDataModel`, `deleteSurface`) and is what an agent should emit.
12
+ *
13
+ * **v0.8** is what Cortena already speaks: core bundles Google's A2UI Lit
14
+ * renderer at `src/canvas-host/a2ui/` and its `A2uiMessageProcessor` handles
15
+ * `beginRendering`, `surfaceUpdate`, `dataModelUpdate` and `deleteSurface`.
16
+ * The `canvas` agent tool pushes exactly those, as JSON lines, through
17
+ * `canvas.a2ui.pushJSONL`. Accepting both means one renderer serves the chat,
18
+ * the web canvas panel and every payload already written against the native
19
+ * nodes.
20
+ *
21
+ * ## Transport
22
+ *
23
+ * Per `05-agent-ui.md`, an A2UI block travels as an AG-UI `CUSTOM` event. The
24
+ * event value is a message, an array of messages, or the JSONL text the canvas
25
+ * tool sends; {@link parseA2UIMessages} takes all three.
26
+ */
27
+
28
+ /** Surface id used when a message omits one, matching core's Lit processor. */
29
+ export const A2UI_DEFAULT_SURFACE_ID = "@default";
30
+
31
+ /** Protocol version this package speaks. */
32
+ export const A2UI_VERSION = "v0.9";
33
+
34
+ /** A reference to a value in the surface's data model, as a JSON pointer. */
35
+ export interface A2UIDataBinding {
36
+ path: string;
37
+ }
38
+
39
+ /**
40
+ * A property value as an agent writes it: a literal, a data binding, or one of
41
+ * the v0.8 literal wrappers the Lit renderer accepts.
42
+ */
43
+ export type A2UIPropertyValue =
44
+ | string
45
+ | number
46
+ | boolean
47
+ | null
48
+ | A2UIDataBinding
49
+ | { literal: unknown }
50
+ | { literalString: string }
51
+ | readonly A2UIPropertyValue[]
52
+ | { readonly [key: string]: unknown };
53
+
54
+ /**
55
+ * A component's children. v0.9 uses a plain id array or a `{ componentId, path }`
56
+ * template; v0.8 wraps the same two in `explicitList` / `template`.
57
+ */
58
+ export type A2UIChildList =
59
+ | readonly string[]
60
+ | { componentId: string; path: string }
61
+ | { explicitList: readonly string[] }
62
+ | { template: { componentId: string; dataBinding: string } };
63
+
64
+ /** An action an agent attaches to an interactive property. */
65
+ export type A2UIActionSpec =
66
+ | string
67
+ | { event: { name: string; context?: Record<string, unknown> } }
68
+ | { name: string; context?: Record<string, unknown> };
69
+
70
+ /**
71
+ * One component in a surface.
72
+ *
73
+ * v0.9 is flat — `{ id, component: "Text", text: "hi" }` — and v0.8 wraps the
74
+ * properties under the type name — `{ id, component: { Text: { text: "hi" } } }`.
75
+ */
76
+ export interface A2UIComponentSpec {
77
+ id: string;
78
+ component: string | Record<string, Record<string, unknown>>;
79
+ children?: A2UIChildList;
80
+ /** v0.8 flex weight; carried through but not used for layout here. */
81
+ weight?: number | string;
82
+ [property: string]: unknown;
83
+ }
84
+
85
+ export interface A2UICreateSurface {
86
+ surfaceId?: string;
87
+ catalogId?: string;
88
+ /** Not in the v0.9 schema; accepted so a single message can seed a surface. */
89
+ root?: string;
90
+ sendDataModel?: boolean;
91
+ }
92
+
93
+ export interface A2UIUpdateComponents {
94
+ surfaceId?: string;
95
+ components: A2UIComponentSpec[];
96
+ root?: string;
97
+ }
98
+
99
+ export interface A2UIUpdateDataModel {
100
+ surfaceId?: string;
101
+ path?: string;
102
+ value?: unknown;
103
+ /** v0.8 spelling of `value`. */
104
+ contents?: unknown;
105
+ }
106
+
107
+ export interface A2UIBeginRendering {
108
+ surfaceId?: string;
109
+ root?: string;
110
+ styles?: Record<string, unknown>;
111
+ }
112
+
113
+ export interface A2UIDeleteSurface {
114
+ surfaceId?: string;
115
+ }
116
+
117
+ /** One message on the wire. Exactly one operation key is read per message. */
118
+ export interface A2UIMessage {
119
+ version?: string;
120
+ createSurface?: A2UICreateSurface;
121
+ updateComponents?: A2UIUpdateComponents;
122
+ updateDataModel?: A2UIUpdateDataModel;
123
+ deleteSurface?: A2UIDeleteSurface;
124
+ /** v0.8 */
125
+ beginRendering?: A2UIBeginRendering;
126
+ /** v0.8 */
127
+ surfaceUpdate?: A2UIUpdateComponents;
128
+ /** v0.8 */
129
+ dataModelUpdate?: A2UIUpdateDataModel;
130
+ }
131
+
132
+ /**
133
+ * Everything the renderer's `message` prop takes: one message, a list of them
134
+ * (the AG-UI `CUSTOM` payload), or JSONL / a JSON array as text (the canvas
135
+ * tool's `a2ui_push`).
136
+ */
137
+ export type A2UIMessageInput = A2UIMessage | readonly A2UIMessage[] | string | null | undefined;
138
+
139
+ /** A folded surface: what the renderer draws. */
140
+ export interface A2UISurface {
141
+ id: string;
142
+ /** Id of the root component; `undefined` until a message names one. */
143
+ root: string | undefined;
144
+ components: ReadonlyMap<string, A2UIComponentSpec>;
145
+ dataModel: Record<string, unknown>;
146
+ styles: Record<string, unknown>;
147
+ }
148
+
149
+ export interface A2UIFoldResult {
150
+ surfaces: A2UISurface[];
151
+ /** One line per malformed message; the renderer shows these in an error card. */
152
+ errors: string[];
153
+ }
154
+
155
+ function isRecord(value: unknown): value is Record<string, unknown> {
156
+ return typeof value === "object" && value !== null && !Array.isArray(value);
157
+ }
158
+
159
+ /** True when a value is a `{ path }` data binding rather than a literal object. */
160
+ export function isDataBinding(value: unknown): value is A2UIDataBinding {
161
+ return isRecord(value) && typeof value.path === "string" && Object.keys(value).length === 1;
162
+ }
163
+
164
+ /**
165
+ * Parse whatever arrived into a message list.
166
+ *
167
+ * A string is tried as JSON first (an array or a single message) and then as
168
+ * JSONL, which is the form `canvas.a2ui.pushJSONL` sends. A line that does not
169
+ * parse is reported rather than dropped: silently rendering three of four
170
+ * pushed messages is the failure mode this returns errors for.
171
+ */
172
+ export function parseA2UIMessages(input: A2UIMessageInput): {
173
+ messages: A2UIMessage[];
174
+ errors: string[];
175
+ } {
176
+ if (input === null || input === undefined) {
177
+ return { messages: [], errors: [] };
178
+ }
179
+ if (typeof input === "string") {
180
+ const trimmed = input.trim();
181
+ if (!trimmed) {
182
+ return { messages: [], errors: [] };
183
+ }
184
+ if (trimmed.startsWith("[")) {
185
+ try {
186
+ const parsed: unknown = JSON.parse(trimmed);
187
+ if (Array.isArray(parsed)) {
188
+ return collect(parsed);
189
+ }
190
+ return { messages: [], errors: ["Expected an array of A2UI messages."] };
191
+ } catch (error) {
192
+ return { messages: [], errors: [`Malformed JSON: ${errorText(error)}`] };
193
+ }
194
+ }
195
+ const messages: A2UIMessage[] = [];
196
+ const errors: string[] = [];
197
+ const lines = trimmed.split("\n");
198
+ for (let index = 0; index < lines.length; index += 1) {
199
+ const line = (lines[index] ?? "").trim();
200
+ if (!line) {
201
+ continue;
202
+ }
203
+ try {
204
+ const parsed: unknown = JSON.parse(line);
205
+ const one = collect([parsed]);
206
+ messages.push(...one.messages);
207
+ errors.push(...one.errors.map((text) => `Line ${index + 1}: ${text}`));
208
+ } catch (error) {
209
+ errors.push(`Line ${index + 1}: malformed JSON (${errorText(error)}).`);
210
+ }
211
+ }
212
+ return { messages, errors };
213
+ }
214
+ return collect(Array.isArray(input) ? input : [input]);
215
+ }
216
+
217
+ function collect(values: readonly unknown[]): { messages: A2UIMessage[]; errors: string[] } {
218
+ const messages: A2UIMessage[] = [];
219
+ const errors: string[] = [];
220
+ for (let index = 0; index < values.length; index += 1) {
221
+ const value = values[index];
222
+ if (!isRecord(value)) {
223
+ errors.push(`Message ${index + 1} is not an object.`);
224
+ continue;
225
+ }
226
+ messages.push(value as A2UIMessage);
227
+ }
228
+ return { messages, errors };
229
+ }
230
+
231
+ function errorText(error: unknown): string {
232
+ return error instanceof Error ? error.message : String(error);
233
+ }
234
+
235
+ interface MutableSurface {
236
+ id: string;
237
+ root: string | undefined;
238
+ components: Map<string, A2UIComponentSpec>;
239
+ dataModel: Record<string, unknown>;
240
+ styles: Record<string, unknown>;
241
+ }
242
+
243
+ /**
244
+ * Fold a message stream onto surfaces.
245
+ *
246
+ * Updates accumulate: a later `updateComponents` replaces the components it
247
+ * names and leaves the rest, and a later `updateDataModel` writes at its path
248
+ * only. That is what makes the JSONL push form work — the agent sends the
249
+ * shell, then the rows, then a correction, and each one folds onto the same
250
+ * surface.
251
+ */
252
+ export function foldMessages(input: A2UIMessageInput): A2UIFoldResult {
253
+ const { messages, errors } = parseA2UIMessages(input);
254
+ const surfaces = new Map<string, MutableSurface>();
255
+ const order: string[] = [];
256
+
257
+ const surfaceFor = (id: string | undefined): MutableSurface => {
258
+ const key = id && id.trim() ? id : A2UI_DEFAULT_SURFACE_ID;
259
+ let surface = surfaces.get(key);
260
+ if (!surface) {
261
+ surface = { id: key, root: undefined, components: new Map(), dataModel: {}, styles: {} };
262
+ surfaces.set(key, surface);
263
+ order.push(key);
264
+ }
265
+ return surface;
266
+ };
267
+
268
+ const applyComponents = (payload: A2UIUpdateComponents | undefined, index: number): void => {
269
+ if (!payload) {
270
+ return;
271
+ }
272
+ const surface = surfaceFor(payload.surfaceId);
273
+ if (!Array.isArray(payload.components)) {
274
+ errors.push(`Message ${index + 1}: components must be an array.`);
275
+ return;
276
+ }
277
+ for (const spec of payload.components) {
278
+ if (!isRecord(spec) || typeof spec.id !== "string" || !spec.id) {
279
+ errors.push(`Message ${index + 1}: a component is missing a string id.`);
280
+ continue;
281
+ }
282
+ surface.components.set(spec.id, spec as A2UIComponentSpec);
283
+ }
284
+ if (typeof payload.root === "string" && payload.root) {
285
+ surface.root = payload.root;
286
+ }
287
+ };
288
+
289
+ const applyData = (payload: A2UIUpdateDataModel | undefined): void => {
290
+ if (!payload) {
291
+ return;
292
+ }
293
+ const surface = surfaceFor(payload.surfaceId);
294
+ const value = payload.value !== undefined ? payload.value : payload.contents;
295
+ const path = payload.path && payload.path.trim() ? payload.path : "/";
296
+ surface.dataModel = setAtPointer(surface.dataModel, path, value);
297
+ };
298
+
299
+ for (let index = 0; index < messages.length; index += 1) {
300
+ const message = messages[index];
301
+ if (!message) {
302
+ continue;
303
+ }
304
+ if (message.createSurface) {
305
+ const surface = surfaceFor(message.createSurface.surfaceId);
306
+ if (typeof message.createSurface.root === "string" && message.createSurface.root) {
307
+ surface.root = message.createSurface.root;
308
+ }
309
+ }
310
+ if (message.beginRendering) {
311
+ const surface = surfaceFor(message.beginRendering.surfaceId);
312
+ if (typeof message.beginRendering.root === "string" && message.beginRendering.root) {
313
+ surface.root = message.beginRendering.root;
314
+ }
315
+ if (isRecord(message.beginRendering.styles)) {
316
+ surface.styles = { ...surface.styles, ...message.beginRendering.styles };
317
+ }
318
+ }
319
+ applyComponents(message.updateComponents, index);
320
+ applyComponents(message.surfaceUpdate, index);
321
+ applyData(message.updateDataModel);
322
+ applyData(message.dataModelUpdate);
323
+ if (message.deleteSurface) {
324
+ const key = message.deleteSurface.surfaceId ?? A2UI_DEFAULT_SURFACE_ID;
325
+ surfaces.delete(key);
326
+ const at = order.indexOf(key);
327
+ if (at !== -1) {
328
+ order.splice(at, 1);
329
+ }
330
+ }
331
+ const known =
332
+ message.createSurface ??
333
+ message.updateComponents ??
334
+ message.updateDataModel ??
335
+ message.deleteSurface ??
336
+ message.beginRendering ??
337
+ message.surfaceUpdate ??
338
+ message.dataModelUpdate;
339
+ if (!known) {
340
+ errors.push(
341
+ `Message ${index + 1}: no known operation (createSurface, updateComponents, updateDataModel, deleteSurface, beginRendering, surfaceUpdate, dataModelUpdate).`,
342
+ );
343
+ }
344
+ }
345
+
346
+ const result: A2UISurface[] = [];
347
+ for (const key of order) {
348
+ const surface = surfaces.get(key);
349
+ if (!surface) {
350
+ continue;
351
+ }
352
+ result.push({
353
+ id: surface.id,
354
+ root: surface.root ?? defaultRoot(surface.components),
355
+ components: surface.components,
356
+ dataModel: surface.dataModel,
357
+ styles: surface.styles,
358
+ });
359
+ }
360
+ return { surfaces: result, errors };
361
+ }
362
+
363
+ /** No explicit root: prefer the conventional `root` id, else the first component. */
364
+ function defaultRoot(components: ReadonlyMap<string, A2UIComponentSpec>): string | undefined {
365
+ if (components.has("root")) {
366
+ return "root";
367
+ }
368
+ for (const id of components.keys()) {
369
+ return id;
370
+ }
371
+ return undefined;
372
+ }
373
+
374
+ /** Split a JSON pointer into its unescaped segments. */
375
+ export function pointerSegments(pointer: string): string[] {
376
+ const trimmed = pointer.trim();
377
+ if (!trimmed || trimmed === "/" || trimmed === ".") {
378
+ return [];
379
+ }
380
+ const body = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
381
+ return body
382
+ .split("/")
383
+ .filter((segment) => segment !== "")
384
+ .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
385
+ }
386
+
387
+ /** Resolve a possibly relative pointer against a data context. */
388
+ export function resolvePointer(path: string, contextPath: string): string {
389
+ if (path.startsWith("/")) {
390
+ return path;
391
+ }
392
+ const relative = path === "." || path === "" ? "" : path.replace(/^\.?\//, "");
393
+ const base = contextPath === "/" || !contextPath ? "" : contextPath.replace(/\/$/, "");
394
+ if (!relative) {
395
+ return base || "/";
396
+ }
397
+ return `${base}/${relative}`;
398
+ }
399
+
400
+ /** Read a JSON pointer out of the data model. */
401
+ export function getAtPointer(model: unknown, pointer: string): unknown {
402
+ let current: unknown = model;
403
+ for (const segment of pointerSegments(pointer)) {
404
+ if (Array.isArray(current)) {
405
+ const index = Number(segment);
406
+ current = Number.isInteger(index) ? current[index] : undefined;
407
+ } else if (isRecord(current)) {
408
+ current = current[segment];
409
+ } else {
410
+ return undefined;
411
+ }
412
+ if (current === undefined) {
413
+ return undefined;
414
+ }
415
+ }
416
+ return current;
417
+ }
418
+
419
+ /**
420
+ * Write a JSON pointer into the data model, returning a new model.
421
+ *
422
+ * Immutable because the renderer keeps the model in React state: a mutation in
423
+ * place would not re-render, which is the kind of silent nothing-happens bug
424
+ * that is hard to see in a browser test.
425
+ */
426
+ export function setAtPointer(
427
+ model: Record<string, unknown>,
428
+ pointer: string,
429
+ value: unknown,
430
+ ): Record<string, unknown> {
431
+ const segments = pointerSegments(pointer);
432
+ if (segments.length === 0) {
433
+ return isRecord(value) ? { ...value } : {};
434
+ }
435
+ const clone: Record<string, unknown> = { ...model };
436
+ let cursor: Record<string, unknown> | unknown[] = clone;
437
+ for (let index = 0; index < segments.length - 1; index += 1) {
438
+ const segment = segments[index] ?? "";
439
+ const nextSegment = segments[index + 1] ?? "";
440
+ const wantsArray = /^\d+$/.test(nextSegment);
441
+ const existing: unknown = Array.isArray(cursor)
442
+ ? cursor[Number(segment)]
443
+ : (cursor as Record<string, unknown>)[segment];
444
+ let next: Record<string, unknown> | unknown[];
445
+ if (Array.isArray(existing)) {
446
+ next = [...existing];
447
+ } else if (isRecord(existing)) {
448
+ next = { ...existing };
449
+ } else {
450
+ next = wantsArray ? [] : {};
451
+ }
452
+ if (Array.isArray(cursor)) {
453
+ cursor[Number(segment)] = next;
454
+ } else {
455
+ (cursor as Record<string, unknown>)[segment] = next;
456
+ }
457
+ cursor = next;
458
+ }
459
+ const last = segments[segments.length - 1] ?? "";
460
+ if (Array.isArray(cursor)) {
461
+ cursor[Number(last)] = value;
462
+ } else {
463
+ (cursor as Record<string, unknown>)[last] = value;
464
+ }
465
+ return clone;
466
+ }
467
+
468
+ /** Normalise the children of a component spec into a single description. */
469
+ export type A2UIChildren =
470
+ | { kind: "list"; ids: readonly string[] }
471
+ | { kind: "template"; componentId: string; path: string }
472
+ | { kind: "none" };
473
+
474
+ export function readChildren(spec: A2UIComponentSpec, properties: Record<string, unknown>): A2UIChildren {
475
+ const raw = (spec.children ?? properties.children) as A2UIChildList | undefined;
476
+ if (!raw) {
477
+ return { kind: "none" };
478
+ }
479
+ if (Array.isArray(raw)) {
480
+ return { kind: "list", ids: raw.filter((id): id is string => typeof id === "string") };
481
+ }
482
+ if (isRecord(raw)) {
483
+ const record: Record<string, unknown> = raw;
484
+ const explicitList = record.explicitList;
485
+ if (Array.isArray(explicitList)) {
486
+ return {
487
+ kind: "list",
488
+ ids: explicitList.filter((id): id is string => typeof id === "string"),
489
+ };
490
+ }
491
+ const template = record.template;
492
+ if (isRecord(template)) {
493
+ const { componentId, dataBinding } = template;
494
+ if (typeof componentId === "string" && typeof dataBinding === "string") {
495
+ return { kind: "template", componentId, path: dataBinding };
496
+ }
497
+ }
498
+ if (typeof record.componentId === "string" && typeof record.path === "string") {
499
+ return { kind: "template", componentId: record.componentId, path: record.path };
500
+ }
501
+ }
502
+ return { kind: "none" };
503
+ }
504
+
505
+ /**
506
+ * Split a component spec into its type name and its raw (unresolved) properties,
507
+ * across both dialects. Returns `undefined` when the spec names no type.
508
+ */
509
+ export function readComponent(
510
+ spec: A2UIComponentSpec,
511
+ ): { name: string; properties: Record<string, unknown> } | undefined {
512
+ if (typeof spec.component === "string") {
513
+ const { id: _id, component: _component, children: _children, weight: _weight, ...rest } = spec;
514
+ return { name: spec.component, properties: rest };
515
+ }
516
+ if (isRecord(spec.component)) {
517
+ const name = Object.keys(spec.component)[0];
518
+ if (!name) {
519
+ return undefined;
520
+ }
521
+ const properties = (spec.component as Record<string, unknown>)[name];
522
+ return { name, properties: isRecord(properties) ? properties : {} };
523
+ }
524
+ return undefined;
525
+ }
526
+
527
+ /**
528
+ * Resolve one property value against the data model.
529
+ *
530
+ * `{ path }` reads the model, `{ literal }` / `{ literalString }` unwrap, and
531
+ * everything else is returned as written, recursing through arrays and objects
532
+ * so a binding nested inside a prop object still resolves.
533
+ */
534
+ export function resolveValue(
535
+ value: unknown,
536
+ model: Record<string, unknown>,
537
+ contextPath: string,
538
+ ): unknown {
539
+ if (Array.isArray(value)) {
540
+ return value.map((item) => resolveValue(item, model, contextPath));
541
+ }
542
+ if (isRecord(value)) {
543
+ if (isDataBinding(value)) {
544
+ return getAtPointer(model, resolvePointer(value.path, contextPath));
545
+ }
546
+ if ("literalString" in value && typeof value.literalString === "string") {
547
+ return value.literalString;
548
+ }
549
+ if ("literal" in value && Object.keys(value).length === 1) {
550
+ return value.literal;
551
+ }
552
+ const out: Record<string, unknown> = {};
553
+ for (const [key, item] of Object.entries(value)) {
554
+ out[key] = resolveValue(item, model, contextPath);
555
+ }
556
+ return out;
557
+ }
558
+ return value;
559
+ }
560
+
561
+ /** A user action leaving the renderer, shaped like A2UI's client-to-server action. */
562
+ export interface A2UIAction {
563
+ surfaceId: string;
564
+ componentId: string;
565
+ name: string;
566
+ payload: Record<string, unknown>;
567
+ }
568
+
569
+ /**
570
+ * Normalise an action property into `{ name, context }`.
571
+ *
572
+ * Accepts v0.9's `{ event: { name, context } }`, the bare `{ name, context }`
573
+ * shape, and a plain string, which is what an agent writes most often.
574
+ */
575
+ export function readAction(
576
+ value: unknown,
577
+ ): { name: string; context: Record<string, unknown> } | undefined {
578
+ if (typeof value === "string" && value.trim()) {
579
+ return { name: value, context: {} };
580
+ }
581
+ if (!isRecord(value)) {
582
+ return undefined;
583
+ }
584
+ const event = isRecord(value.event) ? value.event : value;
585
+ const name = event.name;
586
+ if (typeof name !== "string" || !name) {
587
+ return undefined;
588
+ }
589
+ return { name, context: isRecord(event.context) ? event.context : {} };
590
+ }