@adia-ai/a2ui 0.8.39 → 0.8.41

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.
package/renderer.js CHANGED
@@ -41,23 +41,85 @@ export class A2UIRenderer {
41
41
  #flush() {
42
42
  this.#rafId = null;
43
43
  const batch = this.#queue.splice(0);
44
- for (const msg of batch) this.#processOne(msg);
44
+ // Per-message error isolation (gh#1364 review finding): #flush runs
45
+ // inside a requestAnimationFrame callback with no caller to propagate
46
+ // to — a hard protocol error from ONE message (e.g. REQ-002's
47
+ // DUPLICATE_SURFACE_ID throw) must not silently drop every remaining
48
+ // message in the batch, nor escape uncaught where nothing can observe
49
+ // it. Non-batching `process()` keeps throwing straight to its caller
50
+ // (a real call site that can catch it) — this isolation is specific
51
+ // to the queued/rAF path, which <a2ui-root> reaches whenever `batch`
52
+ // is set.
53
+ for (const msg of batch) {
54
+ try {
55
+ this.#processOne(msg);
56
+ } catch (err) {
57
+ console.error('A2UI: message failed during batch flush', err);
58
+ this.#dispatchDiagnostic(null, 'a2ui-message-error', {
59
+ error: err, messageType: msg?.type || msg?.messageType,
60
+ });
61
+ }
62
+ }
45
63
  }
46
64
 
65
+ // Message kinds that buffer inside an open beginSurfaceUpdate bracket
66
+ // (spec-a2ui-surface-lifecycle.md). `deleteSurface` is deliberately
67
+ // absent — it "never buffers... tears down immediately, cancelling any
68
+ // pending generation" (spec, state-machine section); `createSurface`
69
+ // targeting an id already in the map always hits the REQ-002 hard-error
70
+ // branch instead, pending or not.
71
+ static #BUFFERABLE_KINDS = new Set([
72
+ 'updateComponents', 'updateDataModel', 'wireComponents', 'updateStyles', 'removeStyles',
73
+ ]);
74
+
47
75
  #processOne(message) {
76
+ const kind = message.type || message.messageType;
77
+ const surfaceId = message.surfaceId;
78
+ if (surfaceId && A2UIRenderer.#BUFFERABLE_KINDS.has(kind)) {
79
+ const surface = this.#surfaces.get(surfaceId);
80
+ if (surface && !surface.synthetic && this.#isSurfacePending(surface)) {
81
+ surface.lifecycle.buffer.push(message);
82
+ return;
83
+ }
84
+ }
85
+ this.#dispatchMessage(message);
86
+ }
87
+
88
+ // The raw switch — no lifecycle/buffering guard. Called directly by
89
+ // #processOne for an immediate apply, and by #commitSurfaceUpdate to
90
+ // replay a bracket's buffer (which must bypass the pending check above,
91
+ // since the surface is still `pending-*` while its own buffer replays).
92
+ //
93
+ // Deliberately NOT declared `async`: a hard synchronous throw here (e.g.
94
+ // REQ-002's DUPLICATE_SURFACE_ID) must propagate to the caller as a real
95
+ // synchronous exception — #flush's per-message try/catch (gh#1364 review
96
+ // finding) and process()'s own direct-throw contract both depend on
97
+ // that. `async` would silently convert that throw into a rejected
98
+ // Promise instead. The `wireComponents` branch RETURNS `#wireComponents`'s
99
+ // own promise (that method is itself `async`) without awaiting it here —
100
+ // #processOne's call site drops it (fire-and-forget, same as `process()`
101
+ // always did for wireComponents); #commitSurfaceUpdate's buffer replay
102
+ // is the one caller that DOES await this return value, so a bracket's
103
+ // wiring finishes docking (and #wiringEngine is guaranteed initialized)
104
+ // before #sweepSurvivors runs.
105
+ #dispatchMessage(message) {
48
106
  switch (message.type || message.messageType) {
49
- case 'createSurface': this.#createSurface(message); break;
50
- case 'updateComponents': this.#updateComponents(message); break;
51
- case 'updateDataModel': this.#updateDataModel(message); break;
52
- case 'wireComponents': this.#wireComponents(message); break;
53
- case 'deleteSurface': this.#deleteSurface(message); break;
54
- case 'updateStyles': this.#updateStyles(message); break;
55
- case 'removeStyles': this.#removeStyles(message); break;
56
- case 'meta': break; // LLM self-critique — not renderable
57
- default: console.warn('A2UI: unknown message type', message.type);
107
+ case 'createSurface': this.#createSurface(message); return undefined;
108
+ case 'updateComponents': this.#updateComponents(message); return undefined;
109
+ case 'updateDataModel': this.#updateDataModel(message); return undefined;
110
+ case 'wireComponents': return this.#wireComponents(message);
111
+ case 'deleteSurface': this.#deleteSurface(message); return undefined;
112
+ case 'updateStyles': this.#updateStyles(message); return undefined;
113
+ case 'removeStyles': this.#removeStyles(message); return undefined;
114
+ case 'meta': return undefined; // LLM self-critique — not renderable
115
+ default: console.warn('A2UI: unknown message type', message.type); return undefined;
58
116
  }
59
117
  }
60
118
 
119
+ #isSurfacePending(surface) {
120
+ return surface.lifecycle.state === 'pending-first' || surface.lifecycle.state === 'pending-stale';
121
+ }
122
+
61
123
  // ── wireComponents (lazy-loaded) ──
62
124
 
63
125
  #wiringEngine = null;
@@ -85,7 +147,28 @@ export class A2UIRenderer {
85
147
  // ── createSurface ──
86
148
 
87
149
  #createSurface({ surfaceId, catalogId, root: rootId }) {
88
- if (this.#surfaces.has(surfaceId)) return;
150
+ if (this.#surfaces.has(surfaceId)) {
151
+ // REQ-002 (spec-a2ui-v1-conformance.md): v1.0 Candidate makes a
152
+ // duplicate surfaceId a HARD error. Promoted from the P0
153
+ // diagnostic-only no-op (gh#1350) now that the surface lifecycle API
154
+ // ships below (gh#1364, ADR-0061's interlock): hosts have a
155
+ // sanctioned regeneration path — beginSurfaceUpdate → applyTo →
156
+ // commitSurfaceUpdate (spec-a2ui-surface-lifecycle.md) — so a second
157
+ // createSurface for an existing id is no longer anyone's only route.
158
+ // The diagnostic event still fires first (observability parity with
159
+ // the P0 shape) before the throw.
160
+ const existing = this.#surfaces.get(surfaceId);
161
+ this.#dispatchDiagnostic(existing.root, 'a2ui-duplicate-surface-id', { surfaceId });
162
+ const err = new Error(
163
+ `A2UI: duplicate createSurface for surfaceId "${surfaceId}" — REQ-002 ` +
164
+ `(spec-a2ui-v1-conformance.md) makes this a hard error. Regenerate an ` +
165
+ `existing surface with renderer.beginSurfaceUpdate(surfaceId) → applyTo ` +
166
+ `→ commitSurfaceUpdate instead (spec-a2ui-surface-lifecycle.md).`
167
+ );
168
+ err.code = 'DUPLICATE_SURFACE_ID';
169
+ err.surfaceId = surfaceId;
170
+ throw err;
171
+ }
89
172
 
90
173
  const root = document.createElement('div');
91
174
  root.setAttribute('data-a2ui-surface', surfaceId);
@@ -106,6 +189,19 @@ export class A2UIRenderer {
106
189
  // Populated by #updateStyles; cleared by #removeStyles / #deleteSurface.
107
190
  // See .claude/docs/specs/genui-css-channel.md §5.4.
108
191
  adoptedSheets: new Map(),
192
+ // Surface lifecycle (spec-a2ui-surface-lifecycle.md, ADR-0061,
193
+ // gh#1364): pending/stale/healing state machine, one per real
194
+ // (createSurface'd) surface. `synthetic: false` marks that the
195
+ // lifecycle contract applies — see #beginSurfaceUpdate below.
196
+ synthetic: false,
197
+ lifecycle: {
198
+ state: 'empty',
199
+ generationId: null,
200
+ buffer: null,
201
+ mode: 'replace',
202
+ reason: null,
203
+ hadError: false,
204
+ },
109
205
  });
110
206
  }
111
207
 
@@ -125,6 +221,14 @@ export class A2UIRenderer {
125
221
  bindings: new Map(),
126
222
  // CSS channel parity with #createSurface (see spec §5.4).
127
223
  adoptedSheets: new Map(),
224
+ // Surface lifecycle (gh#1364): synthetic surfaces share the
225
+ // renderer's #container as their root — there is no per-surface
226
+ // element to carry per-surface lifecycle state, so they are
227
+ // outside the lifecycle contract by ruling
228
+ // (spec-a2ui-surface-lifecycle.md's state-machine section).
229
+ // #beginSurfaceUpdate no-ops with a console warn for these.
230
+ synthetic: true,
231
+ lifecycle: null,
128
232
  };
129
233
  this.#surfaces.set(surfaceId, surface);
130
234
  // CSS channel: tag the container so the @scope wrapper has an anchor
@@ -414,6 +518,11 @@ export class A2UIRenderer {
414
518
  #deleteSurface({ surfaceId }) {
415
519
  const surface = this.#surfaces.get(surfaceId);
416
520
  if (!surface) return;
521
+ // Surface lifecycle (gh#1364): deleteSurface is not a BUFFERABLE_KIND
522
+ // (see #processOne) — it always runs immediately, per spec: "never
523
+ // buffers... tears down immediately, cancelling any pending
524
+ // generation." The whole record (lifecycle included) is deleted below,
525
+ // so any in-flight bracket is simply gone — no separate cancel step.
417
526
  this.#wiringEngine?.teardown(surfaceId);
418
527
  for (const [id] of surface.elements) this.#elements.delete(id);
419
528
  // CSS channel: splice any adopted stylesheets out of the document.
@@ -428,6 +537,288 @@ export class A2UIRenderer {
428
537
  this.#surfaces.delete(surfaceId);
429
538
  }
430
539
 
540
+ // ── Surface lifecycle (spec-a2ui-surface-lifecycle.md, ADR-0061, gh#1364) ──
541
+ //
542
+ // Per-surface pending/stale/healing state machine, driven by a public
543
+ // host API: beginSurfaceUpdate → applyTo (buffers) → commitSurfaceUpdate
544
+ // (or abortSurfaceUpdate). Strictly additive (REQ-011): a surface whose
545
+ // begin is never called stays in `empty` forever and every existing
546
+ // call path (process/processStream/applyTo-while-live) behaves exactly
547
+ // as before.
548
+
549
+ #genCounter = 0;
550
+
551
+ #mintGenerationId() {
552
+ this.#genCounter += 1;
553
+ return `g${this.#genCounter}-${Date.now().toString(36)}`;
554
+ }
555
+
556
+ #setLifecycleAttr(surface, state) {
557
+ surface.root?.setAttribute?.('data-a2ui-lifecycle', state);
558
+ }
559
+
560
+ // Dispatch a bubbling CustomEvent for a surface lifecycle transition
561
+ // (REQ-010) — surface-pending / surface-committed / surface-aborted.
562
+ // Same fail-silent shape as #dispatchStylesEvent / #dispatchDiagnostic.
563
+ #dispatchLifecycleEvent(target, eventName, detail) {
564
+ const node = target || this.#container;
565
+ if (!node || typeof CustomEvent === 'undefined') return;
566
+ try {
567
+ node.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true, composed: false }));
568
+ } catch {
569
+ // Hard fail-silent — event dispatch should never break the renderer.
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Begin a regeneration bracket for `surfaceId` (REQ-002/006/008).
575
+ * `empty`/`error-empty` → `pending-first`; `live`/`error-stale` →
576
+ * `pending-stale`. A begin on an already-pending surface supersedes the
577
+ * older generation (REQ-008) — its buffer is discarded. No-op (console
578
+ * warn) for a missing or synthetic surface (state-machine ruling).
579
+ *
580
+ * @param {string} surfaceId
581
+ * @param {{generationId?: string, reason?: 'refine'|'revalidate'|'regenerate', mode?: 'replace'|'patch'}} [opts]
582
+ * @returns {string|null} the minted/echoed generationId, or null on no-op
583
+ */
584
+ beginSurfaceUpdate(surfaceId, { generationId, reason = null, mode = 'replace' } = {}) {
585
+ const surface = this.#surfaces.get(surfaceId);
586
+ if (!surface) {
587
+ console.warn(`A2UI: beginSurfaceUpdate — no such surface "${surfaceId}"`);
588
+ return null;
589
+ }
590
+ if (surface.synthetic) {
591
+ console.warn(
592
+ `A2UI: beginSurfaceUpdate — surfaceId "${surfaceId}" is synthetic ` +
593
+ `(no prior createSurface); lifecycle is a no-op here. Send createSurface first.`
594
+ );
595
+ return null;
596
+ }
597
+
598
+ const lc = surface.lifecycle;
599
+ const wasError = lc.state === 'error-empty' || lc.state === 'error-stale';
600
+ const hadContent = lc.state === 'live' || lc.state === 'error-stale';
601
+ const nextState = hadContent ? 'pending-stale' : 'pending-first';
602
+
603
+ lc.state = nextState;
604
+ lc.generationId = typeof generationId === 'string' && generationId ? generationId : this.#mintGenerationId();
605
+ lc.buffer = [];
606
+ lc.mode = mode === 'patch' ? 'patch' : 'replace';
607
+ lc.reason = reason;
608
+ lc.hadError = wasError;
609
+
610
+ this.#setLifecycleAttr(surface, nextState);
611
+ this.#dispatchLifecycleEvent(surface.root, 'surface-pending', {
612
+ surfaceId, generationId: lc.generationId, reason, firstRender: nextState === 'pending-first',
613
+ });
614
+ return lc.generationId;
615
+ }
616
+
617
+ /**
618
+ * Apply messages targeting `surfaceId`. While the surface is pending
619
+ * (inside a beginSurfaceUpdate bracket), messages buffer and apply
620
+ * atomically at commitSurfaceUpdate (REQ-003). Outside a bracket,
621
+ * applies immediately — identical to calling process() for that
622
+ * message, so legacy callers that never touch the lifecycle API see no
623
+ * difference (REQ-011).
624
+ *
625
+ * @param {string} surfaceId
626
+ * @param {object|object[]} messages
627
+ */
628
+ applyTo(surfaceId, messages) {
629
+ const list = Array.isArray(messages) ? messages : [messages];
630
+ for (const msg of list) this.#processOne({ ...msg, surfaceId });
631
+ }
632
+
633
+ /**
634
+ * Commit the in-flight generation for `surfaceId`: applies the buffered
635
+ * bracket atomically (REQ-003). `replace` mode (default) sweeps
636
+ * components the new answer no longer declares — the survivor set is
637
+ * the union of component ids across every buffered updateComponents
638
+ * batch (REQ-004); buffered `updateStyles` replace the surface's
639
+ * `adoptedSheets` set the same way (any styleId not re-declared this
640
+ * bracket is dropped). `patch` mode applies with today's upsert-only
641
+ * semantics for both. A commit whose `generationId` doesn't match the
642
+ * current in-flight generation is ignored as stale (REQ-008).
643
+ *
644
+ * `async` (gh#1364 review finding): the buffer replay AWAITS each
645
+ * dispatched message — specifically so a buffered `wireComponents`
646
+ * finishes docking (and `#wiringEngine` is guaranteed initialized)
647
+ * before the sweep runs; every other message kind resolves its await
648
+ * immediately (`#dispatchMessage` returns a bare value for them, not a
649
+ * promise it made you wait on).
650
+ *
651
+ * @param {string} surfaceId
652
+ * @param {string} [generationId] — when passed, must match the surface's
653
+ * current in-flight generation.
654
+ * @returns {Promise<boolean>} true if the commit applied
655
+ */
656
+ async commitSurfaceUpdate(surfaceId, generationId) {
657
+ const surface = this.#surfaces.get(surfaceId);
658
+ if (!surface || surface.synthetic) {
659
+ console.warn(`A2UI: commitSurfaceUpdate — no such lifecycle surface "${surfaceId}"`);
660
+ return false;
661
+ }
662
+ const lc = surface.lifecycle;
663
+ if (!this.#isSurfacePending(surface)) {
664
+ console.warn(`A2UI: commitSurfaceUpdate — surface "${surfaceId}" has no in-flight generation to commit`);
665
+ return false;
666
+ }
667
+ if (generationId != null && generationId !== lc.generationId) {
668
+ return false; // REQ-008 — a superseded generationId is ignored, not an error.
669
+ }
670
+
671
+ const buffer = lc.buffer || [];
672
+ const committedGenerationId = lc.generationId;
673
+ const healed = lc.hadError;
674
+
675
+ let swept = [];
676
+ let stylesSwept = [];
677
+ if (lc.mode === 'replace') {
678
+ const survivors = new Set();
679
+ let sawComponents = false;
680
+ const survivorStyleIds = new Set();
681
+ let sawStyles = false;
682
+ for (const msg of buffer) {
683
+ const kind = msg.type || msg.messageType;
684
+ if (kind === 'updateComponents') {
685
+ sawComponents = true;
686
+ for (const comp of msg.components || []) {
687
+ if (comp.id != null) survivors.add(comp.id);
688
+ }
689
+ } else if (kind === 'updateStyles') {
690
+ sawStyles = true;
691
+ if (typeof msg.styleId === 'string') survivorStyleIds.add(msg.styleId);
692
+ }
693
+ }
694
+ for (const msg of buffer) await this.#dispatchMessage(msg);
695
+ // Only sweep when this bracket actually declared a component/style
696
+ // set — a bracket carrying only e.g. updateDataModel sweeps
697
+ // neither, since it never named a survivor set at all.
698
+ if (sawComponents) swept = this.#sweepSurvivors(surfaceId, survivors);
699
+ if (sawStyles) stylesSwept = this.#sweepStyles(surfaceId, survivorStyleIds);
700
+ } else {
701
+ for (const msg of buffer) await this.#dispatchMessage(msg);
702
+ }
703
+
704
+ lc.state = 'live';
705
+ lc.buffer = null;
706
+ lc.generationId = null;
707
+ lc.reason = null;
708
+ lc.hadError = false;
709
+
710
+ this.#setLifecycleAttr(surface, 'live');
711
+ this.#dispatchLifecycleEvent(surface.root, 'surface-committed', {
712
+ surfaceId, generationId: committedGenerationId, healed, swept, stylesSwept,
713
+ });
714
+ return true;
715
+ }
716
+
717
+ // Remove every adoptedSheets entry in `surfaceId` outside
718
+ // `survivorStyleIds` — buffered `updateStyles` replace the surface's
719
+ // style set the same way replace-mode replaces its component set
720
+ // (REQ-004's style-sweep half). Same removal path as #removeStyles,
721
+ // including its `styles-removed` event per dropped styleId.
722
+ #sweepStyles(surfaceId, survivorStyleIds) {
723
+ const surface = this.#surfaces.get(surfaceId);
724
+ if (!surface || !surface.adoptedSheets || surface.adoptedSheets.size === 0) return [];
725
+ const swept = [];
726
+ for (const styleId of [...surface.adoptedSheets.keys()]) {
727
+ if (survivorStyleIds.has(styleId)) continue;
728
+ const entry = surface.adoptedSheets.get(styleId);
729
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(s => s !== entry.sheet);
730
+ surface.adoptedSheets.delete(styleId);
731
+ this.#dispatchStylesEvent(surface.root, 'styles-removed', { surfaceId, styleId });
732
+ swept.push(styleId);
733
+ }
734
+ return swept;
735
+ }
736
+
737
+ // Remove every element id in `surfaceId`'s element map outside
738
+ // `survivors` — DOM, elements, bindings, #prevProps, and any docked
739
+ // wiring for those ids (existing per-id undock path, surface.js:107-113,
740
+ // reached via WiringEngine#undockIds). REQ-004.
741
+ #sweepSurvivors(surfaceId, survivors) {
742
+ const surface = this.#surfaces.get(surfaceId);
743
+ if (!surface) return [];
744
+ const swept = [];
745
+ for (const id of [...surface.elements.keys()]) {
746
+ if (survivors.has(id)) continue;
747
+ const el = surface.elements.get(id);
748
+ el?.remove();
749
+ surface.elements.delete(id);
750
+ this.#elements.delete(id);
751
+ surface.bindings.delete(id);
752
+ this.#prevProps.delete(id);
753
+ swept.push(id);
754
+ }
755
+ if (swept.length > 0) this.#wiringEngine?.undockIds(surfaceId, swept);
756
+ return swept;
757
+ }
758
+
759
+ /**
760
+ * Abort the in-flight generation for `surfaceId` — the old answer (if
761
+ * any) stays visible (REQ-007). `pending-first` → `error-empty`;
762
+ * `pending-stale` → `error-stale`. A superseded `generationId` is
763
+ * ignored (REQ-008), same as commit.
764
+ *
765
+ * @param {string} surfaceId
766
+ * @param {string} [generationId]
767
+ * @param {object} [error]
768
+ * @returns {boolean} true if the abort applied
769
+ */
770
+ abortSurfaceUpdate(surfaceId, generationId, error) {
771
+ const surface = this.#surfaces.get(surfaceId);
772
+ if (!surface || surface.synthetic) {
773
+ console.warn(`A2UI: abortSurfaceUpdate — no such lifecycle surface "${surfaceId}"`);
774
+ return false;
775
+ }
776
+ const lc = surface.lifecycle;
777
+ if (!this.#isSurfacePending(surface)) {
778
+ console.warn(`A2UI: abortSurfaceUpdate — surface "${surfaceId}" has no in-flight generation to abort`);
779
+ return false;
780
+ }
781
+ if (generationId != null && generationId !== lc.generationId) {
782
+ return false; // REQ-008 — stale abort ignored
783
+ }
784
+
785
+ const abortedGenerationId = lc.generationId;
786
+ const nextState = lc.state === 'pending-first' ? 'error-empty' : 'error-stale';
787
+
788
+ lc.state = nextState;
789
+ lc.buffer = null;
790
+ lc.generationId = null;
791
+ lc.reason = null;
792
+ lc.hadError = true;
793
+
794
+ this.#setLifecycleAttr(surface, nextState);
795
+ this.#dispatchLifecycleEvent(surface.root, 'surface-aborted', {
796
+ surfaceId, generationId: abortedGenerationId, error: error ?? null,
797
+ });
798
+ return true;
799
+ }
800
+
801
+ /** Read-only lifecycle snapshot for `surfaceId`, or null (missing/synthetic). */
802
+ getLifecycle(surfaceId) {
803
+ const surface = this.#surfaces.get(surfaceId);
804
+ return surface && !surface.synthetic ? surface.lifecycle : null;
805
+ }
806
+
807
+ /** True while `surfaceId` has an in-flight generation (pending-first/pending-stale). */
808
+ isPending(surfaceId) {
809
+ const lc = this.getLifecycle(surfaceId);
810
+ return lc?.state === 'pending-first' || lc?.state === 'pending-stale';
811
+ }
812
+
813
+ /** IDs of every surface currently pending — hosts reflect this as one boolean (a2ui-root's `pending`). */
814
+ get pendingSurfaces() {
815
+ const out = [];
816
+ for (const [id, s] of this.#surfaces) {
817
+ if (!s.synthetic && this.#isSurfacePending(s)) out.push(id);
818
+ }
819
+ return out;
820
+ }
821
+
431
822
  // ── CSS channel (updateStyles / removeStyles) ──
432
823
  // Spec: .claude/docs/specs/genui-css-channel.md
433
824
  // Implements Phase 1 of the gen-ui parallel-channels initiative.
@@ -676,6 +1067,24 @@ export class A2UIRenderer {
676
1067
  }
677
1068
  }
678
1069
 
1070
+ // Dispatch a bubbling CustomEvent for a non-fatal protocol diagnostic
1071
+ // (REQ-002's duplicate-surfaceId case today). Same fail-silent shape as
1072
+ // #dispatchStylesEvent but a distinct, generically-named method — this
1073
+ // has nothing to do with the CSS channel.
1074
+ #dispatchDiagnostic(target, eventName, detail) {
1075
+ const node = target || this.#container;
1076
+ if (!node || typeof CustomEvent === 'undefined') return;
1077
+ try {
1078
+ node.dispatchEvent(new CustomEvent(eventName, {
1079
+ detail,
1080
+ bubbles: true,
1081
+ composed: false,
1082
+ }));
1083
+ } catch {
1084
+ // Hard fail-silent — event dispatch should never break the renderer.
1085
+ }
1086
+ }
1087
+
679
1088
  // ── Public ──
680
1089
 
681
1090
  getSurface(id) { return this.#surfaces.get(id); }
package/stream.js CHANGED
@@ -145,12 +145,20 @@ export function mcpStream(url, options = {}) {
145
145
 
146
146
  /**
147
147
  * Helper: extract A2UI messages from a JSON-RPC `result.content` array
148
- * and push them into a queue/resolver pair.
148
+ * and push them into a queue/resolver pair. Exported (underscore-prefixed,
149
+ * still internal) so stream.test.js can exercise the MIME-flip logic
150
+ * directly without standing up a fake MCP transport.
149
151
  */
150
- function _extractA2UIFromRpc(rpc, push) {
152
+ export function _extractA2UIFromRpc(rpc, push) {
151
153
  if (rpc.result?.content) {
152
154
  for (const block of rpc.result.content) {
153
- if (block.type === 'resource' && block.resource?.mimeType === 'application/json+a2ui') {
155
+ if (
156
+ block.type === 'resource' &&
157
+ // v1.0 Candidate MIME (REQ-001); the pre-Candidate form is still
158
+ // accepted on read for the transition (spec-a2ui-v1-conformance).
159
+ (block.resource?.mimeType === 'application/a2ui+json' ||
160
+ block.resource?.mimeType === 'application/json+a2ui')
161
+ ) {
154
162
  try {
155
163
  const messages = JSON.parse(block.resource.text);
156
164
  for (const msg of (Array.isArray(messages) ? messages : [messages])) push(msg);
@@ -44,6 +44,13 @@ export declare class WiringEngine {
44
44
  */
45
45
  teardown(surfaceId: string): void;
46
46
 
47
+ /**
48
+ * Undock specific dockable ids for a surface without tearing down the
49
+ * whole surface (called by the renderer's replace-mode lifecycle commit
50
+ * sweep — spec-a2ui-surface-lifecycle.md REQ-004, gh#1364).
51
+ */
52
+ undockIds(surfaceId: string, ids: string[]): void;
53
+
47
54
  /**
48
55
  * Tear down all wired surfaces.
49
56
  */
package/wiring-engine.js CHANGED
@@ -93,6 +93,21 @@ export class WiringEngine {
93
93
  this.#surfaces.delete(surfaceId);
94
94
  }
95
95
 
96
+ /**
97
+ * Undock specific dockable ids for a surface without tearing down the
98
+ * whole surface — used by the renderer's replace-mode commit sweep
99
+ * (spec-a2ui-surface-lifecycle.md REQ-004, gh#1364): swept component ids
100
+ * lose their wiring too, but the surface itself (and its other docked
101
+ * dockables) survive the commit.
102
+ * @param {string} surfaceId
103
+ * @param {string[]} ids
104
+ */
105
+ undockIds(surfaceId, ids) {
106
+ const surface = this.#surfaces.get(surfaceId);
107
+ if (!surface) return;
108
+ surface.undockMany(ids);
109
+ }
110
+
96
111
  /**
97
112
  * Tear down everything.
98
113
  */