@ggui-ai/iframe-runtime 0.14.0 → 0.16.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.
- package/dist/channel-transport.d.ts +3 -3
- package/dist/channels/channel-payload.d.ts +1 -1
- package/dist/channels/data.d.ts +1 -1
- package/dist/channels/drain-ack.d.ts +1 -1
- package/dist/channels/props-update.d.ts +1 -1
- package/dist/channels/render-frame.d.ts +1 -1
- package/dist/compiled-validators.d.ts +1 -1
- package/dist/connection.d.ts +34 -12
- package/dist/connection.js +40 -12
- package/dist/context-observer.d.ts +1 -1
- package/dist/context-observer.js +1 -1
- package/dist/events-polling.d.ts +1 -1
- package/dist/host-context-emitter.d.ts +23 -2
- package/dist/host-context-emitter.js +38 -5
- package/dist/host-fonts.d.ts +10 -0
- package/dist/host-fonts.js +113 -0
- package/dist/host-palette-bridge.js +20 -15
- package/dist/iframe-runtime.js +97 -108
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/mcp-app-iframe-host.d.ts +15 -0
- package/dist/mcp-app-iframe-host.js +5 -1
- package/dist/meta-parse.d.ts +9 -0
- package/dist/meta-parse.js +15 -3
- package/dist/observability.d.ts +73 -14
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/react-renderer.d.ts +19 -37
- package/dist/react-renderer.js +64 -72
- package/dist/registry-subscribe.d.ts +1 -1
- package/dist/render-item.d.ts +7 -14
- package/dist/runtime-telemetry.js +1 -1
- package/dist/runtime.d.ts +48 -56
- package/dist/runtime.js +213 -115
- package/dist/system-cards/NoCredentialsCard.js +13 -13
- package/dist/system-cards/ProtocolProbeCard.js +11 -11
- package/dist/system-cards/QrCode.js +1 -1
- package/dist/system-cards/index.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/validation.d.ts +1 -1
- package/dist/validation.js +1 -1
- package/dist/wire-config.d.ts +3 -3
- package/package.json +7 -7
package/dist/runtime.js
CHANGED
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
// FIRST import on purpose — declares zod jitless before any
|
|
28
28
|
// schema-defining dependency initializes (see zod-jitless.ts).
|
|
29
29
|
import './zod-jitless.js';
|
|
30
|
-
import {
|
|
30
|
+
import { runtimeConnectionWriter } from './connection.js';
|
|
31
31
|
import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
32
32
|
import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
|
|
33
|
-
import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
|
|
34
|
-
import { projectHostContext } from '@ggui-ai/protocol';
|
|
33
|
+
import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, reportInvalidAppTheme, } from './meta-parse.js';
|
|
34
|
+
import { projectHostContext } from '@ggui-ai/protocol/wire';
|
|
35
35
|
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
|
|
36
|
-
import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
|
|
36
|
+
import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, subscribeThemeChange as subscribeHostThemeChange, } from './host-context-emitter.js';
|
|
37
37
|
import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
|
|
38
38
|
import { buildRootWireConfig, StreamBus, } from './wire-config.js';
|
|
39
39
|
import { createChannelTransportRouter, } from './channel-transport.js';
|
|
@@ -171,6 +171,11 @@ export function setCurrentApp(app) {
|
|
|
171
171
|
function getCurrentApp() {
|
|
172
172
|
return currentApp;
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* The live theme re-injection's subscription (ggui#987 §4), held so a
|
|
176
|
+
* re-entered boot replaces it instead of stacking a second subscriber.
|
|
177
|
+
*/
|
|
178
|
+
let activeThemeUnsubscribe = null;
|
|
174
179
|
/**
|
|
175
180
|
* The host's announced color mode, read from the connected App's
|
|
176
181
|
* pre-merged hostContext (`ui/initialize` result, kept current by the
|
|
@@ -185,10 +190,11 @@ function getCurrentApp() {
|
|
|
185
190
|
* `color-scheme` via `applyDocumentTheme` — nothing in the token
|
|
186
191
|
* pipeline keys off it, so a dark host painted the light ladder
|
|
187
192
|
* (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
193
|
+
* Under ggui#987 §4 the embedding host OWNS runtime mode: when it
|
|
194
|
+
* announces one, `buildOpts` paints it over every stamped opinion; the
|
|
195
|
+
* stamped layers decide only when the host is silent. Read live from
|
|
196
|
+
* the App's pre-merged hostContext, so a `hostcontextchanged` flip
|
|
197
|
+
* re-resolves on the next `buildOpts`.
|
|
192
198
|
*
|
|
193
199
|
* @internal — exported for unit tests (`setCurrentApp` injects the
|
|
194
200
|
* App); production caller is `buildOpts` inside `bootSequence`.
|
|
@@ -198,36 +204,30 @@ export function hostAnnouncedThemeMode() {
|
|
|
198
204
|
return theme === 'light' || theme === 'dark' ? theme : undefined;
|
|
199
205
|
}
|
|
200
206
|
/**
|
|
201
|
-
* Resolve the mount's
|
|
202
|
-
* the
|
|
203
|
-
* precedence ruling — "slice wins, host is the fallback, absent ≠
|
|
204
|
-
* light"):
|
|
207
|
+
* Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
|
|
208
|
+
* runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
|
|
205
209
|
*
|
|
206
|
-
* stamped `themeMode` > `theme.mode`
|
|
207
|
-
*
|
|
210
|
+
* host-announced theme > stamped `themeMode` > `theme.mode`
|
|
211
|
+
* (the app theme's default) > undefined
|
|
208
212
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* `hostContext.theme`: a light skeleton in a dark skin. The theme
|
|
216
|
-
* object's own `mode` already drove `color-scheme`; now it also
|
|
217
|
-
* selects the ladder.
|
|
213
|
+
* A card that ignored the host's announced mode painted a light
|
|
214
|
+
* skeleton inside a dark chat — the host's word is what the user
|
|
215
|
+
* actually sees around the card. The stamped layers (live provider >
|
|
216
|
+
* per-render override > app default > server default) decide only when
|
|
217
|
+
* the host is silent, which is the adapter-boundary case (a host
|
|
218
|
+
* helper that announces no `hostContext.theme`).
|
|
218
219
|
*
|
|
219
220
|
* `undefined` still means "no opinion anywhere" — the renderer's
|
|
220
|
-
* default applies; never coerced to `'light'
|
|
221
|
+
* default applies; never coerced to `'light'` here (ggui#551).
|
|
221
222
|
*
|
|
222
223
|
* @internal — exported for unit tests; production caller is
|
|
223
224
|
* `buildOpts` inside `bootSequence`.
|
|
224
225
|
*/
|
|
225
226
|
export function resolveMountThemeMode(meta) {
|
|
226
227
|
// The CLIENT projection of the protocol's normative theme-binding
|
|
227
|
-
// total order (@ggui-ai/protocol/integrations/theme-binding
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
// without rank drift.
|
|
228
|
+
// total order (@ggui-ai/protocol/integrations/theme-binding) — the
|
|
229
|
+
// server stamps via the sibling projection and the protocol's
|
|
230
|
+
// composition-law test pins that the two compose without rank drift.
|
|
231
231
|
return effectiveThemeMode({
|
|
232
232
|
stamped: meta.themeMode,
|
|
233
233
|
sessionSidecar: meta.theme?.mode,
|
|
@@ -235,20 +235,14 @@ export function resolveMountThemeMode(meta) {
|
|
|
235
235
|
});
|
|
236
236
|
}
|
|
237
237
|
/**
|
|
238
|
-
* Resolve the mount's
|
|
238
|
+
* Resolve the mount's compiled-ladder id from the slice (ggui#987 D2):
|
|
239
239
|
*
|
|
240
|
-
* stamped `themeId` >
|
|
241
|
-
* > undefined (renderer default ladder)
|
|
240
|
+
* stamped `themeId` > undefined (renderer default ladder)
|
|
242
241
|
*
|
|
243
|
-
* The name
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* vars carry brand" store-frame class). An unregistered name is
|
|
248
|
-
* harmless by construction: `getScopedThemeCss` falls back to the
|
|
249
|
-
* default theme, which is byte-identical to the no-themeId path
|
|
250
|
-
* (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
|
|
251
|
-
* naming its theme "My Custom" renders exactly as before.
|
|
242
|
+
* The app theme's `name` is a LABEL the author's tooling reads back —
|
|
243
|
+
* it never selects a ladder. An app theme carries its own full
|
|
244
|
+
* projection (`overlays`, exactly the manifest minus the floor), so
|
|
245
|
+
* there is no coverage gap for a compiled theme to fill beneath it.
|
|
252
246
|
*
|
|
253
247
|
* @internal — exported for unit tests; production caller is
|
|
254
248
|
* `buildOpts` inside `bootSequence`.
|
|
@@ -256,10 +250,7 @@ export function resolveMountThemeMode(meta) {
|
|
|
256
250
|
export function resolveMountThemeId(meta) {
|
|
257
251
|
// Client projection of the normative theme-binding order — see
|
|
258
252
|
// resolveMountThemeMode above.
|
|
259
|
-
return effectiveThemeId({
|
|
260
|
-
stamped: meta.themeId,
|
|
261
|
-
sidecarName: meta.theme?.name,
|
|
262
|
-
});
|
|
253
|
+
return effectiveThemeId({ stamped: meta.themeId });
|
|
263
254
|
}
|
|
264
255
|
/**
|
|
265
256
|
* The host's announced palette, read from the connected App's
|
|
@@ -421,11 +412,35 @@ export async function bootSequence(opts) {
|
|
|
421
412
|
// covers every boot path; arrivals before a renderer publishes
|
|
422
413
|
// `applyRender` no-op inside the listener.
|
|
423
414
|
installPersistentToolResultListener(app);
|
|
415
|
+
// Fresh boot ⇒ the relay latch and the connection store start
|
|
416
|
+
// aligned (ggui#670): a standing latch from a prior mount in a
|
|
417
|
+
// re-booted document must not outlive the store it was written to.
|
|
418
|
+
// It runs BEFORE the handshake so that every boot passes the close
|
|
419
|
+
// edge before it can latch — the boot-failed write below is then
|
|
420
|
+
// always on-edge (ggui#830 fold): a prior zone ends with its
|
|
421
|
+
// 'cleared' edge and its summary, never buried under a second
|
|
422
|
+
// 'latched' with its tally erased.
|
|
423
|
+
resetRelayLatchForBoot();
|
|
424
424
|
const initResult = await connectApp(app, transport);
|
|
425
425
|
if (!initResult.ok) {
|
|
426
426
|
setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
|
|
427
427
|
retireShellLoadingIndicator(doc);
|
|
428
428
|
emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
|
|
429
|
+
// The maximal dead zone (ggui#830): the host refused the handshake
|
|
430
|
+
// every session begins with, so nothing can be delivered here — the
|
|
431
|
+
// relay latch sets through its one writer (store false, 'latched'
|
|
432
|
+
// edge, standing notice) instead of leaving this document with
|
|
433
|
+
// `isConnected === true`, no explanation and no count. No gesture
|
|
434
|
+
// is needed for that knowledge, and no gesture is suppressed by it:
|
|
435
|
+
// every later tap still attempts (attempt-always) and lands as a
|
|
436
|
+
// counted dead tap. The scope is empty because no bootstrap has
|
|
437
|
+
// named a session yet; it clears the only way a handshake failure
|
|
438
|
+
// can — the next boot's close edge, which this boot itself already
|
|
439
|
+
// passed above, so this write is on-edge by construction. The
|
|
440
|
+
// gesture gate's pre-capture rule ("never latch before the host has
|
|
441
|
+
// been asked") does not apply here: the host HAS been asked — this
|
|
442
|
+
// is its answer to `ui/initialize`, resolved as failure.
|
|
443
|
+
announceRelayIncapability('boot-failed', {});
|
|
429
444
|
return { ok: false, mountedRender };
|
|
430
445
|
}
|
|
431
446
|
// Record what this host said it can do (ggui#440). Earliest valid
|
|
@@ -437,7 +452,7 @@ export async function bootSequence(opts) {
|
|
|
437
452
|
// `tools/call` (dispatchSubmitAction, channel-transport router)
|
|
438
453
|
// routes through `app.callServerTool` instead of the legacy raw
|
|
439
454
|
// postMessage pump. Idempotent: re-boot with the same App is a
|
|
440
|
-
// no-op; a different App throws (the iframe is single-
|
|
455
|
+
// no-op; a different App throws (the iframe is single-app).
|
|
441
456
|
setCurrentApp(app);
|
|
442
457
|
// Slice-meta resolution — spec-canonical primary, no in-house
|
|
443
458
|
// Reading-B (retired Phase 1.19b.3 with the App-class swap; the
|
|
@@ -678,10 +693,6 @@ export async function bootSequence(opts) {
|
|
|
678
693
|
// Fresh boot ⇒ not superseded (matters only for test re-runs that
|
|
679
694
|
// reuse the module; a real iframe boots once).
|
|
680
695
|
mountSuperseded = false;
|
|
681
|
-
// Fresh boot ⇒ the relay latch and the connection store start
|
|
682
|
-
// aligned (ggui#670): a standing latch from a prior mount in a
|
|
683
|
-
// re-booted document must not outlive the store it was written to.
|
|
684
|
-
resetRelayLatchForBoot();
|
|
685
696
|
telemetry?.record('boot.path', JSON.stringify({
|
|
686
697
|
hasStaticContent,
|
|
687
698
|
hasLiveTrio,
|
|
@@ -1336,7 +1347,7 @@ export async function resolveMetaViaReadDoor(app, locator) {
|
|
|
1336
1347
|
const envelope = readGguiShellEnvelope(text);
|
|
1337
1348
|
if (envelope === undefined)
|
|
1338
1349
|
return null;
|
|
1339
|
-
const parsed = parseMcpAppAiGguiRenderMeta(envelope);
|
|
1350
|
+
const parsed = parseMcpAppAiGguiRenderMeta(envelope, { onInvalidTheme: reportInvalidAppTheme });
|
|
1340
1351
|
if (!parsed.ok || parsed.meta === undefined)
|
|
1341
1352
|
return null;
|
|
1342
1353
|
const validated = validateMeta(parsed.meta);
|
|
@@ -1386,7 +1397,7 @@ function awaitToolResultMeta(timeoutMs) {
|
|
|
1386
1397
|
* App instance even across re-mounts (e.g. an agent fires a second
|
|
1387
1398
|
* `ggui_render` and the listener re-applies through the published
|
|
1388
1399
|
* `applyRender`). Keyed by App — not a module boolean — so a fresh App
|
|
1389
|
-
* (a re-booted document in tests; production is single-
|
|
1400
|
+
* (a re-booted document in tests; production is single-app per
|
|
1390
1401
|
* iframe) always gets its own registration, never a stale latch.
|
|
1391
1402
|
*/
|
|
1392
1403
|
const persistentToolResultListenerApps = new WeakSet();
|
|
@@ -1657,7 +1668,11 @@ async function callServerToolSpec(toolName, args) {
|
|
|
1657
1668
|
*
|
|
1658
1669
|
* Unknown codes NEVER confirm — they may be proxy- or server-minted
|
|
1659
1670
|
* (e.g. `-32603` internal error), and classifying them would misbrand
|
|
1660
|
-
* capable mounts. They
|
|
1671
|
+
* capable mounts. They never CONFIRM (they can latch only through
|
|
1672
|
+
* the advert-silent leg, never on an advertising host); off-latch they
|
|
1673
|
+
* stay on the per-gesture transient path, and inside a standing zone
|
|
1674
|
+
* they count as dead taps like every other undelivered outcome
|
|
1675
|
+
* (ggui#826).
|
|
1661
1676
|
*/
|
|
1662
1677
|
const CONFIRMED_RELAY_REFUSAL_CODES = new Set([-32601]);
|
|
1663
1678
|
/**
|
|
@@ -1775,6 +1790,20 @@ function classifySubmitActionResponse(resp) {
|
|
|
1775
1790
|
* carries a result envelope, so neither is evidence the relay worked.
|
|
1776
1791
|
*/
|
|
1777
1792
|
function isRelayShapedFailure(resp) {
|
|
1793
|
+
// "Relay-shaped" IS "undelivered" on this leg, by construction — not
|
|
1794
|
+
// by hope (ggui#832, ruled from the hosts' source): every JSON-RPC
|
|
1795
|
+
// error the iframe sees here is host- or transport-minted. The MCP
|
|
1796
|
+
// SDK server turns a tool handler's throw into an `isError` RESULT and
|
|
1797
|
+
// mints JSON-RPC codes only for its own gate; the guuey view-host
|
|
1798
|
+
// answers a rejecting relay with an in-band unavailable RESULT, never
|
|
1799
|
+
// an error; ggui's embed host proxies over HTTP and mints its OWN
|
|
1800
|
+
// codes for a failed proxy. So a server-side outcome always arrives
|
|
1801
|
+
// as a `CallToolResult` (`ok:true` and `ok:false` alike clear the
|
|
1802
|
+
// latch), and an error envelope always means the call did not round
|
|
1803
|
+
// trip. A third-party host that forwarded a server's JSON-RPC error
|
|
1804
|
+
// verbatim would be indistinguishable by code — that is the
|
|
1805
|
+
// host-helper conformance catalog's business (H3 / R1), not this
|
|
1806
|
+
// predicate's.
|
|
1778
1807
|
// Null-tolerant on `error`, matching `classifySubmitActionResponse`'s
|
|
1779
1808
|
// own check: an `{error:null, result:{...}}` envelope classifies as
|
|
1780
1809
|
// success there, so it must also read as NOT relay-shaped-failure
|
|
@@ -1845,7 +1874,7 @@ function applyFreezeCue(root) {
|
|
|
1845
1874
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
1846
1875
|
fontSize: '11px',
|
|
1847
1876
|
fontWeight: '600',
|
|
1848
|
-
color: 'var(--ggui-color-
|
|
1877
|
+
color: 'var(--ggui-color-onContainer, inherit)',
|
|
1849
1878
|
background: 'rgba(128,128,128,0.16)',
|
|
1850
1879
|
border: '1px solid currentColor',
|
|
1851
1880
|
opacity: '0.85',
|
|
@@ -2282,7 +2311,7 @@ let relayLatchTrigger;
|
|
|
2282
2311
|
let relayLatchScope = {};
|
|
2283
2312
|
function transitionRelayLatch(next, trigger, scope = {}) {
|
|
2284
2313
|
relayIncapabilityAnnounced = next;
|
|
2285
|
-
|
|
2314
|
+
runtimeConnectionWriter().set(!next);
|
|
2286
2315
|
if (next && trigger !== undefined) {
|
|
2287
2316
|
relayLatchedAt = Date.now();
|
|
2288
2317
|
relayDeadTaps = 0;
|
|
@@ -2308,6 +2337,22 @@ function transitionRelayLatch(next, trigger, scope = {}) {
|
|
|
2308
2337
|
relayLatchTrigger = undefined;
|
|
2309
2338
|
relayLatchScope = {};
|
|
2310
2339
|
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Set the latch AND present it — the three steps every latch-set path
|
|
2342
|
+
* takes, in one place: the transition through the ONE writer (store
|
|
2343
|
+
* flip + `'latched'` edge), a fresh dead zone (unspent cue throttles —
|
|
2344
|
+
* the state belongs to THIS notice, so a second latch-set path never
|
|
2345
|
+
* has to know the clear edge already reset it), and the standing
|
|
2346
|
+
* explanation, marked so the per-gesture cue arms on its dismissal.
|
|
2347
|
+
* Callers: the dispatch outcome gate (a gesture's confirmed relay
|
|
2348
|
+
* failure) and the boot sequence (a refused `ui/initialize`, ggui#830).
|
|
2349
|
+
*/
|
|
2350
|
+
function announceRelayIncapability(trigger, scope) {
|
|
2351
|
+
transitionRelayLatch(true, trigger, scope);
|
|
2352
|
+
resetRelayCueThrottles();
|
|
2353
|
+
showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required');
|
|
2354
|
+
markRelayNotice();
|
|
2355
|
+
}
|
|
2311
2356
|
/**
|
|
2312
2357
|
* One {@link RelayDeadTapEvent} per gesture attempted inside a standing
|
|
2313
2358
|
* dead zone that came back UNDELIVERED (ggui#670 Phase 3). Called from
|
|
@@ -2381,6 +2426,10 @@ function isRelayNoticeVisible() {
|
|
|
2381
2426
|
* @internal — exported for unit tests.
|
|
2382
2427
|
*/
|
|
2383
2428
|
export function resetRelayLatchForBoot() {
|
|
2429
|
+
// The runtime's claim on the document's connection writer is taken HERE,
|
|
2430
|
+
// eagerly, before any transition (ggui#843): a second runtime in this
|
|
2431
|
+
// document fails at boot naming itself, never continuing unclaimed.
|
|
2432
|
+
runtimeConnectionWriter();
|
|
2384
2433
|
if (typeof document !== 'undefined') {
|
|
2385
2434
|
const el = document.getElementById(ACTION_TOAST_ID);
|
|
2386
2435
|
if (el !== null && el.hasAttribute(RELAY_NOTICE_ATTR))
|
|
@@ -2391,14 +2440,14 @@ export function resetRelayLatchForBoot() {
|
|
|
2391
2440
|
transitionRelayLatch(false);
|
|
2392
2441
|
}
|
|
2393
2442
|
else {
|
|
2394
|
-
|
|
2443
|
+
runtimeConnectionWriter().set(true);
|
|
2395
2444
|
}
|
|
2396
2445
|
}
|
|
2397
2446
|
/**
|
|
2398
2447
|
* Module-level handle to the CURRENT boot's telemetry sink so
|
|
2399
2448
|
* module-scope paths (the doorbell emitter below) can report without
|
|
2400
2449
|
* threading the sink through every call chain. Per-iframe
|
|
2401
|
-
* single-
|
|
2450
|
+
* single-app scope makes a single slot correct — same justification as
|
|
2402
2451
|
* `getCurrentApp()`.
|
|
2403
2452
|
*/
|
|
2404
2453
|
let currentTelemetrySink = null;
|
|
@@ -2486,7 +2535,7 @@ function resetRelayCueThrottles() {
|
|
|
2486
2535
|
/** @internal — exported for unit tests to reset module state. */
|
|
2487
2536
|
export function __resetRelayNoticeForTest() {
|
|
2488
2537
|
relayIncapabilityAnnounced = false;
|
|
2489
|
-
|
|
2538
|
+
runtimeConnectionWriter().set(true);
|
|
2490
2539
|
relayLatchedAt = undefined;
|
|
2491
2540
|
relayDeadTaps = 0;
|
|
2492
2541
|
relayLatchTrigger = undefined;
|
|
@@ -2891,39 +2940,46 @@ export function dispatchSubmitAction(args) {
|
|
|
2891
2940
|
// helper-minted refusal code, which outranks any positive
|
|
2892
2941
|
// advertisement (the advertises-but-refuses host previously got a
|
|
2893
2942
|
// fresh transient toast per tap, forever). Timeouts and unknown
|
|
2894
|
-
// codes never reach the second leg
|
|
2895
|
-
//
|
|
2896
|
-
//
|
|
2897
|
-
//
|
|
2943
|
+
// codes never reach the second leg — they never CONFIRM (the
|
|
2944
|
+
// advert-silent leg alone can latch on them); inside a standing
|
|
2945
|
+
// zone they are counted like every other undelivered outcome by
|
|
2946
|
+
// the branch below (ggui#826). The un-classify edge for
|
|
2947
|
+
// BOTH legs is the same response-arrival clear guard above
|
|
2948
|
+
// (attempt-always preserved — nothing here suppresses dispatch,
|
|
2949
|
+
// per the #443 doctrine: the attempt is the self-heal sensor).
|
|
2898
2950
|
const confirmedRefusal = isConfirmedRelayRefusal(resp);
|
|
2951
|
+
if (relayIncapabilityAnnounced && isRelayShapedFailure(resp)) {
|
|
2952
|
+
// A gesture whose undelivered outcome landed inside a standing
|
|
2953
|
+
// dead zone — a dead tap, counted for the host (ggui#670 Phase 3);
|
|
2954
|
+
// an in-flight gesture that raced the latching one counts too.
|
|
2955
|
+
// Counted on the OUTCOME and keyed on the LATCH, never on
|
|
2956
|
+
// advertisement (ggui#826): the advertises-but-refuses host
|
|
2957
|
+
// latches by a confirmed refusal, and its later code-less or
|
|
2958
|
+
// timeout failures are exactly the undelivered gestures the
|
|
2959
|
+
// RelayDeadTapEvent contract promises to count. The eligibility
|
|
2960
|
+
// predicate below is the LATCH-SET rule — a timeout or unknown
|
|
2961
|
+
// code never CONFIRMS; it can latch only through the advert-silent
|
|
2962
|
+
// leg, never on an advertising host — and is the wrong key for
|
|
2963
|
+
// counting. Every outcome that
|
|
2964
|
+
// reaches here while the latch stands is relay-shaped by
|
|
2965
|
+
// construction (the response-arrival guard above cleared on any
|
|
2966
|
+
// well-formed result); the conjunct states the contract instead
|
|
2967
|
+
// of trusting that. The latching gesture is the 'latched' edge
|
|
2968
|
+
// and a gesture that heals the zone is the 'cleared' edge —
|
|
2969
|
+
// neither is a dead tap.
|
|
2970
|
+
emitRelayDeadTap(intent, { sessionId, appId });
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2899
2973
|
if (hostCapabilitiesCaptured() &&
|
|
2900
2974
|
((!hostCanRelayToolCalls() && isRelayShapedFailure(resp)) ||
|
|
2901
2975
|
confirmedRefusal)) {
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
// is created: a freshly-shown notice is undismissed, and its
|
|
2910
|
-
// dead zone starts with an unspent throttle. Today every
|
|
2911
|
-
// re-latch already passes through the clear edge above (which
|
|
2912
|
-
// resets both), so this is the same value arriving by a second
|
|
2913
|
-
// route — kept because the state belongs to THIS notice, and a
|
|
2914
|
-
// future second latch-set path shouldn't have to know that.
|
|
2915
|
-
resetRelayCueThrottles();
|
|
2916
|
-
showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required');
|
|
2917
|
-
markRelayNotice();
|
|
2918
|
-
}
|
|
2919
|
-
else {
|
|
2920
|
-
// A gesture attempted inside a standing dead zone came back
|
|
2921
|
-
// undelivered — a dead tap, counted for the host (ggui#670
|
|
2922
|
-
// Phase 3). The latching gesture above is the 'latched' edge,
|
|
2923
|
-
// and a gesture that heals the zone is the 'cleared' edge —
|
|
2924
|
-
// neither is a dead tap.
|
|
2925
|
-
emitRelayDeadTap(intent, { sessionId, appId });
|
|
2926
|
-
}
|
|
2976
|
+
// Transition edge — the paired 'cleared' edge lives at the
|
|
2977
|
+
// response-arrival guard above. NEVER emit off-edge (e.g. per
|
|
2978
|
+
// channel poll tick): the router ticks on an interval and
|
|
2979
|
+
// would spam; the two edges carry the full information. A
|
|
2980
|
+
// standing latch never reaches here: its undelivered outcomes
|
|
2981
|
+
// were counted above, and a delivered one cleared it.
|
|
2982
|
+
announceRelayIncapability(confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
|
|
2927
2983
|
return;
|
|
2928
2984
|
}
|
|
2929
2985
|
showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
|
|
@@ -2932,19 +2988,22 @@ export function dispatchSubmitAction(args) {
|
|
|
2932
2988
|
/**
|
|
2933
2989
|
* Channel-transport router's `tools/call` invoker (ggui#440) —
|
|
2934
2990
|
* iframe-polling transport, `tools/call` against the parent MCP host
|
|
2935
|
-
* via `app.callServerTool` (spec-canonical)
|
|
2936
|
-
*
|
|
2991
|
+
* via `app.callServerTool` (spec-canonical); with no App bound the
|
|
2992
|
+
* call answers a code-less error envelope — never a raw postMessage.
|
|
2937
2993
|
* Direct call (no LLM consent loop). Returns the tool's
|
|
2938
2994
|
* structuredContent (or `content[0]` if that's where the payload
|
|
2939
2995
|
* landed) as a `JsonValue`. On RPC error we throw — the router
|
|
2940
2996
|
* catches and silently retries on the next tick.
|
|
2941
2997
|
*
|
|
2942
|
-
* Once
|
|
2943
|
-
*
|
|
2944
|
-
*
|
|
2945
|
-
* capabilities already captured
|
|
2946
|
-
*
|
|
2947
|
-
*
|
|
2998
|
+
* Once this document has confirmed it cannot relay (the latch above —
|
|
2999
|
+
* set through the ONE writer on exactly three paths: a REAL gesture
|
|
3000
|
+
* that failed relay-shaped on a host that never advertised
|
|
3001
|
+
* `serverTools`, with capabilities already captured (`advert-silent`);
|
|
3002
|
+
* a declared refusal code answered under a positive advertisement
|
|
3003
|
+
* (`confirmed-refusal`); or the host's own failure of the mandatory
|
|
3004
|
+
* `ui/initialize` handshake, before any gesture (`boot-failed`,
|
|
3005
|
+
* ggui#830) — never a well-formed `{ok:false}` result; see
|
|
3006
|
+
* `isRelayShapedFailure`), every subsequent poll will fail identically.
|
|
2948
3007
|
*
|
|
2949
3008
|
* This guard does NOT stop the router's poll loop, and nothing here
|
|
2950
3009
|
* should ever be written so that it could (ggui#443). It does two
|
|
@@ -2970,10 +3029,13 @@ export function dispatchSubmitAction(args) {
|
|
|
2970
3029
|
* (ggui's own embed host pre-Task-2 is exactly this case) — silently
|
|
2971
3030
|
* killing working channel polls forever. That is "absence of a
|
|
2972
3031
|
* capability blocks an attempt", which the fail-safe constraint
|
|
2973
|
-
* forbids. The latch
|
|
2974
|
-
*
|
|
2975
|
-
*
|
|
2976
|
-
*
|
|
3032
|
+
* forbids. The latch is set only by a CONFIRMED OUTCOME — a failed
|
|
3033
|
+
* real gesture (the advert-silent leg, or a confirmed refusal under a
|
|
3034
|
+
* positive advertisement, ggui#599 cycle-2) or a failed handshake
|
|
3035
|
+
* (ggui#830), never by an advertisement's absence — so testing the
|
|
3036
|
+
* latch alone is sufficient: a host that answers its handshake and
|
|
3037
|
+
* never fails a real gesture never latches, and its channels keep
|
|
3038
|
+
* working.
|
|
2977
3039
|
*
|
|
2978
3040
|
* Extracted to a named export (rather than an inline closure inside
|
|
2979
3041
|
* `bootProduction`) so unit tests can drive it directly through the
|
|
@@ -3645,10 +3707,10 @@ async function bootProduction(opts) {
|
|
|
3645
3707
|
// DEFAULT-handlers compositions only (a custom `handlers` list
|
|
3646
3708
|
// drains its own pipe; bridging the channel onto an instance
|
|
3647
3709
|
// nobody drains would buffer gestures into the void — see the
|
|
3648
|
-
// hoist note in server.ts).
|
|
3649
|
-
// caller: on it, a WS gesture lands on the retained ledger only
|
|
3710
|
+
// hoist note in server.ts). A deployment composing its own `handlers`
|
|
3711
|
+
// IS such a caller: on it, a WS gesture lands on the retained ledger only
|
|
3650
3712
|
// and never reaches the agent. Any WS action fallback (ggui#599's
|
|
3651
|
-
// open design leg) therefore requires the server
|
|
3713
|
+
// open design leg) therefore requires the server-side bridge
|
|
3652
3714
|
// FIRST — it is a capability gap on that composition today. The WS pipe otherwise carries
|
|
3653
3715
|
// streamSpec subscriptions (inbound `ggui_emit` fanout +
|
|
3654
3716
|
// `props_update` + `render` + `data` + `drain_ack` +
|
|
@@ -3797,29 +3859,27 @@ async function bootProduction(opts) {
|
|
|
3797
3859
|
!('createdAt' in render)
|
|
3798
3860
|
? { codeModuleUrl: meta.codeModuleUrl }
|
|
3799
3861
|
: {}),
|
|
3800
|
-
//
|
|
3801
|
-
//
|
|
3802
|
-
// registered name binds the full brand base; unregistered
|
|
3803
|
-
// names fall back to the default ladder, byte-identical to
|
|
3804
|
-
// the no-themeId path).
|
|
3862
|
+
// Compiled ladder (see `resolveMountThemeId`): the stamped
|
|
3863
|
+
// themeId alone; absent = the renderer's default ladder.
|
|
3805
3864
|
...(() => {
|
|
3806
3865
|
const themeId = resolveMountThemeId(meta);
|
|
3807
3866
|
return themeId !== undefined ? { themeId } : {};
|
|
3808
3867
|
})(),
|
|
3809
|
-
// Mode
|
|
3810
|
-
// stamped `themeMode` > the
|
|
3811
|
-
//
|
|
3812
|
-
//
|
|
3868
|
+
// Mode (ggui#987 §4 — see `resolveMountThemeMode`): host-
|
|
3869
|
+
// announced theme > stamped `themeMode` > the app theme's
|
|
3870
|
+
// default `mode`. Absent everywhere means "no opinion", not
|
|
3871
|
+
// "light". Re-resolved on every buildOpts, so the live
|
|
3872
|
+
// hostcontextchanged re-injection below picks up a flip.
|
|
3813
3873
|
...(() => {
|
|
3814
3874
|
const themeMode = resolveMountThemeMode(meta);
|
|
3815
3875
|
return themeMode !== undefined ? { themeMode } : {};
|
|
3816
3876
|
})(),
|
|
3817
|
-
// Per-app theme
|
|
3877
|
+
// Per-app theme (ggui#987 v2). Threaded straight from the
|
|
3818
3878
|
// bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
|
|
3819
3879
|
// already injection-validated by the wire parser) onto the mount
|
|
3820
|
-
// options
|
|
3821
|
-
//
|
|
3822
|
-
// cards theme via the SystemCardHost/ThemeProvider path.
|
|
3880
|
+
// options; the renderer injects the effective mode's overlay
|
|
3881
|
+
// into the scoped block + `:root`. THEME IS COMPONENT-ONLY —
|
|
3882
|
+
// system cards theme via the SystemCardHost/ThemeProvider path.
|
|
3823
3883
|
...(meta.theme !== undefined ? { appTheme: meta.theme } : {}),
|
|
3824
3884
|
// Host palette: always threaded when the host announced one —
|
|
3825
3885
|
// unlike mode there is no either/or gate here, because the
|
|
@@ -3845,14 +3905,52 @@ async function bootProduction(opts) {
|
|
|
3845
3905
|
* layer just calls `applyRender(render)` without owning
|
|
3846
3906
|
* lifecycle state.
|
|
3847
3907
|
*/
|
|
3908
|
+
// Every in-place update of the mounted slot goes through ONE
|
|
3909
|
+
// chain: the WS render-frame / props_update path and the live
|
|
3910
|
+
// theme re-injection below are independent callers, and the
|
|
3911
|
+
// handle's `update` has no lock of its own — serializing them
|
|
3912
|
+
// here is what keeps a host flip landing mid-render from
|
|
3913
|
+
// interleaving two updates.
|
|
3914
|
+
let updateChain = Promise.resolve();
|
|
3915
|
+
const queueUpdate = (opts) => {
|
|
3916
|
+
const run = updateChain.then(async () => {
|
|
3917
|
+
if (renderHandle === null)
|
|
3918
|
+
return;
|
|
3919
|
+
await renderHandle.update(opts());
|
|
3920
|
+
});
|
|
3921
|
+
updateChain = run.catch(() => undefined);
|
|
3922
|
+
return run;
|
|
3923
|
+
};
|
|
3848
3924
|
const applyRender = async (render) => {
|
|
3849
3925
|
currentRender = render;
|
|
3850
3926
|
if (renderHandle === null) {
|
|
3851
3927
|
renderHandle = await mountRender(renderInto, buildOpts(render));
|
|
3852
3928
|
return;
|
|
3853
3929
|
}
|
|
3854
|
-
await
|
|
3930
|
+
await queueUpdate(() => buildOpts(render));
|
|
3855
3931
|
};
|
|
3932
|
+
// Live re-injection (ggui#987 §4): the host owns runtime mode and
|
|
3933
|
+
// may flip it mid-session. The trigger is the emitter's THEME
|
|
3934
|
+
// subscription — the raw `theme` / `styles` payload — because the
|
|
3935
|
+
// host-context projection excludes both by design and its
|
|
3936
|
+
// equality gate would never fire for a theme-only flip. The App
|
|
3937
|
+
// pre-merges the new hostContext before the emitter fans out, so
|
|
3938
|
+
// re-running `buildOpts` on the CURRENT render re-resolves mode +
|
|
3939
|
+
// palette and the renderer repaints from the retained other
|
|
3940
|
+
// overlay — no fetch, no remount. A rejection is reported to the
|
|
3941
|
+
// embedding host, never dropped on the floor.
|
|
3942
|
+
activeThemeUnsubscribe?.();
|
|
3943
|
+
activeThemeUnsubscribe = subscribeHostThemeChange(() => {
|
|
3944
|
+
if (renderHandle === null || currentRender === null)
|
|
3945
|
+
return;
|
|
3946
|
+
const render = currentRender;
|
|
3947
|
+
queueUpdate(() => buildOpts(render)).catch((err) => {
|
|
3948
|
+
postObservabilityToParent({
|
|
3949
|
+
kind: 'theme-reinject-failed',
|
|
3950
|
+
message: err instanceof Error ? err.message : String(err),
|
|
3951
|
+
});
|
|
3952
|
+
});
|
|
3953
|
+
});
|
|
3856
3954
|
// Publish `applyRender` module-level. The persistent tool-result
|
|
3857
3955
|
// listener is ALREADY registered — `bootSequence` installs it
|
|
3858
3956
|
// pre-connect (ggui#586) — and reads this slot dynamically, so
|