@runtypelabs/persona 4.21.1 → 4.22.1

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.
Files changed (129) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-B_JdVK9o.d.cts → types-9Vxm-PPz.d.cts} +136 -6
  4. package/dist/animations/{types-B_JdVK9o.d.ts → types-9Vxm-PPz.d.ts} +136 -6
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +136 -6
  8. package/dist/animations-extra.d.ts +136 -6
  9. package/dist/{approval-ui-RNNBR5R3.js → approval-ui-K7R5Q5OC.js} +1 -1
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +221 -16
  12. package/dist/approval-ui.d.ts +221 -16
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/artifacts-ui-F4N6VMKF.js +7 -0
  15. package/dist/artifacts-ui.cjs +1 -1
  16. package/dist/artifacts-ui.d.cts +221 -16
  17. package/dist/artifacts-ui.d.ts +221 -16
  18. package/dist/artifacts-ui.js +1 -1
  19. package/dist/chunk-NQLDCJAP.js +4 -0
  20. package/dist/codegen.cjs +1 -1
  21. package/dist/codegen.js +1 -1
  22. package/dist/context-mentions.d.cts +221 -16
  23. package/dist/context-mentions.d.ts +221 -16
  24. package/dist/event-stream-view-GL3ADRY2.js +1 -0
  25. package/dist/event-stream-view.cjs +1 -1
  26. package/dist/event-stream-view.d.cts +221 -16
  27. package/dist/event-stream-view.d.ts +221 -16
  28. package/dist/event-stream-view.js +1 -1
  29. package/dist/forms-ui.d.cts +234 -25
  30. package/dist/forms-ui.d.ts +234 -25
  31. package/dist/history-view.d.cts +136 -6
  32. package/dist/history-view.d.ts +136 -6
  33. package/dist/index.cjs +70 -70
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +302 -32
  36. package/dist/index.d.ts +302 -32
  37. package/dist/index.global.js +54 -54
  38. package/dist/index.global.js.map +1 -1
  39. package/dist/index.js +69 -69
  40. package/dist/index.js.map +1 -1
  41. package/dist/launcher.global.js +2 -2
  42. package/dist/launcher.global.js.map +1 -1
  43. package/dist/session-reconnect.d.cts +221 -16
  44. package/dist/session-reconnect.d.ts +221 -16
  45. package/dist/smart-dom-reader.d.cts +221 -16
  46. package/dist/smart-dom-reader.d.ts +221 -16
  47. package/dist/testing.cjs +3 -3
  48. package/dist/testing.d.cts +3 -4
  49. package/dist/testing.d.ts +3 -4
  50. package/dist/testing.js +3 -3
  51. package/dist/theme-editor-preview.cjs +73 -73
  52. package/dist/theme-editor-preview.d.cts +221 -16
  53. package/dist/theme-editor-preview.d.ts +221 -16
  54. package/dist/theme-editor-preview.js +51 -51
  55. package/dist/theme-editor.cjs +4 -4
  56. package/dist/theme-editor.d.cts +221 -16
  57. package/dist/theme-editor.d.ts +221 -16
  58. package/dist/theme-editor.js +2 -2
  59. package/dist/theme-reference.cjs +1 -1
  60. package/dist/theme-reference.d.cts +65 -1
  61. package/dist/theme-reference.d.ts +65 -1
  62. package/dist/theme-reference.js +1 -1
  63. package/dist/voice-runtime.d.cts +6 -2
  64. package/dist/voice-runtime.d.ts +6 -2
  65. package/dist/webmcp-polyfill.js +1 -4
  66. package/dist/webmcp-runtime-JI7T4QYF.js +6 -0
  67. package/dist/webmcp-runtime.cjs +2 -2
  68. package/dist/webmcp-runtime.d.cts +34 -2
  69. package/dist/webmcp-runtime.d.ts +34 -2
  70. package/dist/webmcp-runtime.js +2 -2
  71. package/package.json +2 -2
  72. package/src/ask-user-question-tool.ts +1 -1
  73. package/src/client-history-rest.test.ts +2 -1
  74. package/src/client-visitor-history.test.ts +20 -4
  75. package/src/client.composer-options.test.ts +1 -1
  76. package/src/client.interrupt.test.ts +2 -2
  77. package/src/client.test.ts +477 -915
  78. package/src/client.ts +134 -494
  79. package/src/client.unified-tools.test.ts +76 -0
  80. package/src/components/artifact-preview.ts +5 -0
  81. package/src/components/event-stream-badge-theming.test.ts +162 -0
  82. package/src/components/event-stream-view.test.ts +76 -76
  83. package/src/components/event-stream-view.ts +55 -20
  84. package/src/components/tool-bubble.test.ts +12 -0
  85. package/src/components/tool-bubble.ts +13 -2
  86. package/src/event-stream-view-bundle.test.ts +2 -2
  87. package/src/generated/runtype-openapi-contract.ts +137 -1
  88. package/src/index-core.ts +2 -0
  89. package/src/internal/runtype-history-provider.test.ts +2 -2
  90. package/src/runtime/init.registry.test.ts +56 -0
  91. package/src/runtime/init.ts +17 -0
  92. package/src/session-history.test.ts +1 -1
  93. package/src/session.quote.test.ts +1 -1
  94. package/src/session.resubmit-from.test.ts +1 -1
  95. package/src/session.test.ts +5 -5
  96. package/src/session.ts +95 -36
  97. package/src/suggest-replies-tool.ts +1 -1
  98. package/src/testing/mock-stream.test.ts +37 -16
  99. package/src/testing/mock-stream.ts +14 -9
  100. package/src/types/theme.ts +41 -1
  101. package/src/types.ts +57 -15
  102. package/src/ui.ask-user-question-plugin.test.ts +7 -7
  103. package/src/ui.composer-defer-one.test.ts +6 -34
  104. package/src/ui.composer-draft-persistence.test.ts +1 -1
  105. package/src/ui.composer-quote.test.ts +1 -1
  106. package/src/ui.message-history-actions.test.ts +1 -1
  107. package/src/ui.reasoning-display.test.ts +136 -2
  108. package/src/ui.streaming-coalescing.test.ts +10 -13
  109. package/src/ui.ts +101 -35
  110. package/src/ui.typing-indicator.test.ts +216 -0
  111. package/src/ui.voice-custom-provider.test.ts +211 -0
  112. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1740 -0
  113. package/src/utils/event-stream-buffer.test.ts +13 -13
  114. package/src/utils/event-stream-capture.test.ts +20 -20
  115. package/src/utils/formatting.ts +22 -0
  116. package/src/utils/theme.ts +16 -0
  117. package/src/utils/throughput-tracker.test.ts +100 -142
  118. package/src/utils/throughput-tracker.ts +18 -132
  119. package/src/utils/tokens.ts +59 -0
  120. package/src/utils/voice-support.ts +18 -1
  121. package/src/voice/voice-turn-tracker.ts +18 -0
  122. package/src/webmcp-bridge.test.ts +132 -23
  123. package/src/webmcp-bridge.ts +27 -3
  124. package/src/webmcp-runtime-entry.ts +71 -11
  125. package/dist/artifacts-ui-J6AKUGTS.js +0 -7
  126. package/dist/chunk-HBE7LFNT.js +0 -4
  127. package/dist/event-stream-view-73NWKWVQ.js +0 -1
  128. package/dist/webmcp-runtime-P3CHZRHF.js +0 -6
  129. package/src/utils/__fixtures__/unified-translator.oracle.ts +0 -717
@@ -12,7 +12,7 @@
12
12
  // then prefers exact provider usage (output tokens) when terminal events carry
13
13
  // it. A run starts when the stream starts (or lazily on the first visible
14
14
  // delta), stays "running" across intermediate step/turn completions, and only
15
- // finalizes on terminal `flow_complete` / `agent_complete`. Stream errors mark
15
+ // finalizes on terminal `execution_complete`. Stream errors mark
16
16
  // the metric unavailable rather than leaving it stuck "running".
17
17
 
18
18
  export type ThroughputMetricStatus = "idle" | "running" | "complete" | "error";
@@ -47,47 +47,10 @@ interface ThroughputRunStats {
47
47
  // execution time or whole-request duration instead.
48
48
  const THROUGHPUT_MIN_DURATION_MS = 250;
49
49
 
50
- // Request-level lifecycle events: each marks the beginning of a NEW request.
51
- // The SSE tap fires for every payload type regardless of whether the client has
52
- // a handler for it, so any of these that the server emits starts the run with
53
- // an accurate `startedAt` (capturing time-to-first-token). These RESET any run
54
- // already in progress, so a prior stream that ended without a terminal/error
55
- // frame (e.g. `session.cancel()`) doesn't bleed its tokens into the next one.
56
- const REQUEST_START_EVENTS = new Set([
57
- "flow_start",
58
- "flow_run_start",
59
- "agent_start",
60
- "dispatch_start",
61
- "run_start",
62
- ]);
63
-
64
- // Per-step markers that fire repeatedly WITHIN a single request (a flow emits
65
- // one per step). These only lazily begin a run: they must never reset, or a
66
- // multi-step response would restart the metric between steps. If no request- or
67
- // step-start event is emitted, the first visible delta lazily starts the run.
68
- const STEP_START_EVENTS = new Set(["step_start", "execution_start"]);
69
-
70
- const VISIBLE_DELTA_EVENTS = new Set([
71
- "step_delta",
72
- "step_chunk",
73
- "chunk",
74
- "agent_turn_delta",
75
- ]);
76
-
77
- const INTERMEDIATE_COMPLETE_EVENTS = new Set([
78
- "step_complete",
79
- "agent_turn_complete",
80
- ]);
81
-
82
- const TERMINAL_COMPLETE_EVENTS = new Set(["flow_complete", "agent_complete"]);
83
-
84
- const ERROR_EVENTS = new Set([
85
- "step_error",
86
- "flow_error",
87
- "agent_error",
88
- "dispatch_error",
89
- "error",
90
- ]);
50
+ // Execution starts reset a stale/cancelled run; step and turn starts only
51
+ // lazily initialize it, since they can repeat within the same execution.
52
+ const STEP_START_EVENTS = new Set(["step_start", "turn_start"]);
53
+ const INTERMEDIATE_COMPLETE_EVENTS = new Set(["step_complete", "turn_complete"]);
91
54
 
92
55
  const isRecord = (value: unknown): value is Record<string, unknown> =>
93
56
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -134,91 +97,15 @@ function resolveEventType(
134
97
  return typeof payload.type === "string" ? payload.type : eventType;
135
98
  }
136
99
 
137
- function getTextDelta(payload: Record<string, unknown>): string {
138
- if (typeof payload.text === "string") return payload.text;
139
- if (typeof payload.delta === "string") return payload.delta;
140
- if (typeof payload.content === "string") return payload.content;
141
- if (typeof payload.chunk === "string") return payload.chunk;
142
- return "";
143
- }
144
-
145
- /**
146
- * Only count visible model output.
147
- *
148
- * For `agent_turn_delta`, count only a contentType of exactly `text` as
149
- * visible, matching the client renderer (which appends streaming assistant
150
- * text only when `contentType === "text"`); `thinking`, `tool_input`, any
151
- * other value, and a missing contentType are ignored so throughput never
152
- * includes deltas the chat UI doesn't render.
153
- *
154
- * For `step_delta` / `step_chunk`, skip tool and context steps: those carry
155
- * tool I/O, not model-visible text: mirroring the widget's own renderer.
156
- */
157
- function isVisibleTextDelta(
158
- type: string,
159
- payload: Record<string, unknown>
160
- ): boolean {
161
- if (type === "step_delta" || type === "step_chunk") {
162
- return payload.stepType !== "tool" && payload.executionType !== "context";
163
- }
164
-
165
- if (type !== "agent_turn_delta") return true;
166
-
167
- const contentType =
168
- typeof payload.contentType === "string"
169
- ? payload.contentType
170
- : typeof payload.content_type === "string"
171
- ? payload.content_type
172
- : undefined;
173
-
174
- return contentType === "text";
175
- }
176
-
177
- /** Extract exact output tokens from a variety of usage payload shapes. */
100
+ /** Exact output counts are carried by turn and execution completions. */
178
101
  function getOutputTokens(payload: Record<string, unknown>): number | undefined {
179
- const result = getRecord(payload, "result");
180
- const candidates = [
181
- getRecord(payload, "tokens"),
182
- getRecord(payload, "totalTokens"),
183
- result ? getRecord(result, "tokens") : undefined,
184
- getRecord(payload, "usage"),
185
- result ? getRecord(result, "usage") : undefined,
186
- ];
187
-
188
- for (const candidate of candidates) {
189
- if (!candidate) continue;
190
- const outputTokens =
191
- toFiniteNumber(candidate.output) ??
192
- toFiniteNumber(candidate.outputTokens) ??
193
- toFiniteNumber(candidate.completionTokens);
194
- if (outputTokens !== undefined) return outputTokens;
195
- }
196
-
197
- return (
198
- toFiniteNumber(payload.outputTokens) ??
199
- toFiniteNumber(payload.completionTokens) ??
200
- (result
201
- ? (toFiniteNumber(result.outputTokens) ??
202
- toFiniteNumber(result.completionTokens))
203
- : undefined)
204
- );
102
+ const tokens = getRecord(payload, "tokens") ?? getRecord(payload, "totalTokens");
103
+ return tokens ? toFiniteNumber(tokens.output) : undefined;
205
104
  }
206
105
 
207
- /** Extract provider execution time (ms) from a variety of payload shapes. */
208
- function getExecutionTimeMs(
209
- payload: Record<string, unknown>
210
- ): number | undefined {
211
- const result = getRecord(payload, "result");
212
- return (
213
- toFiniteNumber(payload.executionTime) ??
214
- toFiniteNumber(payload.executionTimeMs) ??
215
- toFiniteNumber(payload.execution_time) ??
216
- toFiniteNumber(payload.duration) ??
217
- (result
218
- ? (toFiniteNumber(result.executionTime) ??
219
- toFiniteNumber(result.executionTimeMs))
220
- : undefined)
221
- );
106
+ /** Current step/execution completions report elapsed milliseconds as durationMs. */
107
+ function getExecutionTimeMs(payload: Record<string, unknown>): number | undefined {
108
+ return toFiniteNumber(payload.durationMs);
222
109
  }
223
110
 
224
111
  function defaultClock(): number {
@@ -286,8 +173,8 @@ export class ThroughputTracker {
286
173
 
287
174
  processEvent(eventType: string, payload: unknown): void {
288
175
  if (!isRecord(payload)) {
289
- // Non-object payloads can still signal lifecycle (e.g. bare "error").
290
- if (ERROR_EVENTS.has(eventType) && this.run) {
176
+ // Non-object payloads can still signal lifecycle (e.g. bare "execution_error").
177
+ if (eventType === "execution_error" && this.run) {
291
178
  this.run = null;
292
179
  this.metric = { status: "error" };
293
180
  }
@@ -297,7 +184,7 @@ export class ThroughputTracker {
297
184
  const type = resolveEventType(eventType, payload);
298
185
  const now = this.now();
299
186
 
300
- if (REQUEST_START_EVENTS.has(type)) {
187
+ if (type === "execution_start") {
301
188
  // New request: start fresh, discarding any incomplete prior run.
302
189
  this.startRun(now);
303
190
  return;
@@ -309,9 +196,8 @@ export class ThroughputTracker {
309
196
  return;
310
197
  }
311
198
 
312
- if (VISIBLE_DELTA_EVENTS.has(type)) {
313
- if (!isVisibleTextDelta(type, payload)) return;
314
- const text = getTextDelta(payload);
199
+ if (type === "text_delta") {
200
+ const text = typeof payload.delta === "string" ? payload.delta : "";
315
201
  if (!text) return;
316
202
 
317
203
  // Lazily start a run if the stream began without a recognized start event.
@@ -367,7 +253,7 @@ export class ThroughputTracker {
367
253
  return;
368
254
  }
369
255
 
370
- if (TERMINAL_COMPLETE_EVENTS.has(type)) {
256
+ if (type === "execution_complete") {
371
257
  if (!this.run) return;
372
258
  const stats = this.run;
373
259
  // Prefer exact output tokens from this terminal event, else accumulated
@@ -396,7 +282,7 @@ export class ThroughputTracker {
396
282
  return;
397
283
  }
398
284
 
399
- if (ERROR_EVENTS.has(type)) {
285
+ if (type === "execution_error" || (type === "error" && payload.recoverable === false)) {
400
286
  if (!this.run) return;
401
287
  this.run = null;
402
288
  this.metric = { status: "error" };
@@ -128,6 +128,32 @@ export const DEFAULT_PALETTE = {
128
128
  900: '#1e3a8a',
129
129
  950: '#172554',
130
130
  },
131
+ purple: {
132
+ 50: '#faf5ff',
133
+ 100: '#f3e8ff',
134
+ 200: '#e9d5ff',
135
+ 300: '#d8b4fe',
136
+ 400: '#c084fc',
137
+ 500: '#a855f7',
138
+ 600: '#9333ea',
139
+ 700: '#7e22ce',
140
+ 800: '#6b21a8',
141
+ 900: '#581c87',
142
+ 950: '#3b0764',
143
+ },
144
+ teal: {
145
+ 50: '#f0fdfa',
146
+ 100: '#ccfbf1',
147
+ 200: '#99f6e4',
148
+ 300: '#5eead4',
149
+ 400: '#2dd4bf',
150
+ 500: '#14b8a6',
151
+ 600: '#0d9488',
152
+ 700: '#0f766e',
153
+ 800: '#115e59',
154
+ 900: '#134e4a',
155
+ 950: '#042f2e',
156
+ },
131
157
  },
132
158
  spacing,
133
159
  typography: {
@@ -472,6 +498,20 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
472
498
  border: 'palette.colors.gray.200',
473
499
  },
474
500
  },
501
+ // Event-stream badge chips: 100-tone fill / dark text per family. tool/agent
502
+ // keep their pre-token literals (#6b21a8 / #115e59), which sit at the 800
503
+ // step of the canonical purple/teal ramps.
504
+ eventStream: {
505
+ badge: {
506
+ flow: { background: 'palette.colors.success.100', foreground: 'palette.colors.success.700' },
507
+ step: { background: 'palette.colors.primary.100', foreground: 'palette.colors.primary.700' },
508
+ reasoning: { background: 'palette.colors.warning.100', foreground: 'palette.colors.warning.700' },
509
+ tool: { background: 'palette.colors.purple.100', foreground: 'palette.colors.purple.800' },
510
+ agent: { background: 'palette.colors.teal.100', foreground: 'palette.colors.teal.800' },
511
+ error: { background: 'palette.colors.error.100', foreground: 'palette.colors.error.700' },
512
+ default: { background: 'palette.colors.gray.100', foreground: 'palette.colors.gray.600' },
513
+ },
514
+ },
475
515
  scrollToBottom: {
476
516
  background: 'components.button.primary.background',
477
517
  foreground: 'components.button.primary.foreground',
@@ -1085,6 +1125,25 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
1085
1125
  ['scroll-to-bottom-gap', 'components-scrollToBottom-gap', '=0.5rem'],
1086
1126
  ['scroll-to-bottom-font-size', 'components-scrollToBottom-fontSize', 'palette-typography-fontSize-sm', '=0.875rem'],
1087
1127
  ['scroll-to-bottom-icon-size', 'components-scrollToBottom-iconSize', '=14px'],
1128
+ // Event-stream badge chips. Materialized in every theme (light defaults in
1129
+ // DEFAULT_COMPONENTS, dark pairs in the built-in dark layer), so the
1130
+ // chip's inline var() refs always resolve inside a themed root and
1131
+ // re-resolve on scheme toggles; the literal terminals only cover unthemed
1132
+ // direct mounts.
1133
+ ['event-badge-flow-bg', 'components-eventStream-badge-flow-background', '=#dcfce7'],
1134
+ ['event-badge-flow-fg', 'components-eventStream-badge-flow-foreground', '=#15803d'],
1135
+ ['event-badge-step-bg', 'components-eventStream-badge-step-background', '=#f5f5f5'],
1136
+ ['event-badge-step-fg', 'components-eventStream-badge-step-foreground', '=#0a0a0a'],
1137
+ ['event-badge-reasoning-bg', 'components-eventStream-badge-reasoning-background', '=#fef9c3'],
1138
+ ['event-badge-reasoning-fg', 'components-eventStream-badge-reasoning-foreground', '=#a16207'],
1139
+ ['event-badge-tool-bg', 'components-eventStream-badge-tool-background', '=#f3e8ff'],
1140
+ ['event-badge-tool-fg', 'components-eventStream-badge-tool-foreground', '=#6b21a8'],
1141
+ ['event-badge-agent-bg', 'components-eventStream-badge-agent-background', '=#ccfbf1'],
1142
+ ['event-badge-agent-fg', 'components-eventStream-badge-agent-foreground', '=#115e59'],
1143
+ ['event-badge-error-bg', 'components-eventStream-badge-error-background', '=#fee2e2'],
1144
+ ['event-badge-error-fg', 'components-eventStream-badge-error-foreground', '=#b91c1c'],
1145
+ ['event-badge-default-bg', 'components-eventStream-badge-default-background', '=#f3f4f6'],
1146
+ ['event-badge-default-fg', 'components-eventStream-badge-default-foreground', '=#4b5563'],
1088
1147
  ['tool-bubble-shadow', 'components-toolBubble-shadow', '=0 5px 15px rgba(15, 23, 42, 0.08)'],
1089
1148
  ['reasoning-bubble-shadow', 'components-reasoningBubble-shadow', '=0 5px 15px rgba(15, 23, 42, 0.08)'],
1090
1149
  ['composer-shadow', 'components-composer-shadow', '=none'],
@@ -1,4 +1,21 @@
1
- import type { VoiceConfig } from "../types";
1
+ import type { AgentWidgetConfig, VoiceConfig } from "../types";
2
+
3
+ /** Changes that require replacing a session's capture and playback provider. */
4
+ export function voiceConnectionChanged(previous: AgentWidgetConfig, next: AgentWidgetConfig): boolean {
5
+ const before = previous.voiceRecognition;
6
+ const after = next.voiceRecognition;
7
+ return (before?.enabled === true) !== (after?.enabled === true) ||
8
+ before?.provider?.type !== after?.provider?.type ||
9
+ before?.provider?.custom !== after?.provider?.custom ||
10
+ before?.provider?.runtype?.createPlaybackEngine !== after?.provider?.runtype?.createPlaybackEngine ||
11
+ JSON.stringify(before?.provider?.runtype) !== JSON.stringify(after?.provider?.runtype) ||
12
+ previous.agentId !== next.agentId || previous.clientToken !== next.clientToken || previous.apiUrl !== next.apiUrl;
13
+ }
14
+
15
+ /** Providers whose microphone lifecycle is owned by ChatSession instead of browser dictation. */
16
+ export function usesSessionVoice(config?: Partial<VoiceConfig>): boolean {
17
+ return config?.type === "runtype" || config?.type === "custom";
18
+ }
2
19
 
3
20
  /**
4
21
  * Eager, construction-free replica of the voice factory's `isVoiceSupported`
@@ -0,0 +1,18 @@
1
+ /** Correlate provider transcripts across cancellation and overlapping turns. */
2
+ export class VoiceTurnTracker {
3
+ private turnId?: string;
4
+ private cancelled = false;
5
+
6
+ start(turnId?: string): void {
7
+ this.turnId = turnId;
8
+ this.cancelled = false;
9
+ }
10
+
11
+ cancel(): void {
12
+ this.cancelled = true;
13
+ }
14
+
15
+ accepts(turnId?: string): boolean {
16
+ return !this.cancelled && (!turnId || !this.turnId || turnId === this.turnId);
17
+ }
18
+ }
@@ -11,7 +11,8 @@ import type {
11
11
  // calls `initializeWebMCPPolyfill()` (idempotent install of document.modelContext).
12
12
  // We stub the install as a no-op and provide `document.modelContext` ourselves,
13
13
  // modeling the strict producer-preview surface the bridge consumes:
14
- // - getTools(): async; inputSchema is a JSON string; NO annotations
14
+ // - getTools(): async; inputSchema in either generation (see
15
+ // `registry.schemaShape`); annotations only when the tool declared them
15
16
  // - executeTool(info, argsJson, { signal }): async; validates+runs execute(),
16
17
  // returns JSON.stringify(rawResult) or null; honors the abort signal.
17
18
  // ---------------------------------------------------------------------------
@@ -45,21 +46,46 @@ type MockTool = {
45
46
  description?: string;
46
47
  inputSchema?: object;
47
48
  title?: string;
49
+ annotations?: Record<string, unknown>;
48
50
  execute: (args: Record<string, unknown>, client: MockClient) => unknown;
49
51
  };
50
52
 
51
- const registry: { tools: MockTool[] } = { tools: [] };
53
+ /**
54
+ * Which generation of the WebMCP `inputSchema` contract the fake emits.
55
+ *
56
+ * `"object"` is what `@mcp-b/webmcp-polyfill` v5 and Chrome 154+ return after
57
+ * `webmcp#241`; `"string"` is the serialized form Chrome 149-153 and 154's
58
+ * same-document tools still emit. Both are live in the field, so the snapshot
59
+ * tests run against both -- pinning this fake to one generation is exactly how
60
+ * the v5 bump landed green while every real dispatch 400'd.
61
+ */
62
+ type SchemaShape = "object" | "string";
63
+
64
+ const registry: { tools: MockTool[]; schemaShape: SchemaShape } = {
65
+ tools: [],
66
+ schemaShape: "object",
67
+ };
52
68
 
53
69
  /** A fake `document.modelContext` exposing the strict consumer surface. */
54
70
  const makeModelContext = () => ({
55
71
  async getTools() {
56
- // Mirrors the real polyfill's getRegisteredToolInfos(): `title` is always
57
- // present, "" when the tool didn't declare one; annotations are absent.
72
+ // Mirrors the real polyfill's getTools(): `title` is always present, ""
73
+ // when the tool didn't declare one; annotations are absent. v5 OMITS
74
+ // `inputSchema` for a tool that registered none, where v4 always emitted a
75
+ // `{"type":"object"}` string.
58
76
  return registry.tools.map((t) => ({
59
77
  name: t.name,
60
78
  description: t.description ?? `mock ${t.name}`,
61
- inputSchema: JSON.stringify(t.inputSchema ?? { type: "object" }),
79
+ ...(t.inputSchema === undefined
80
+ ? {}
81
+ : {
82
+ inputSchema:
83
+ registry.schemaShape === "object"
84
+ ? t.inputSchema
85
+ : JSON.stringify(t.inputSchema),
86
+ }),
62
87
  title: t.title ?? "",
88
+ ...(t.annotations === undefined ? {} : { annotations: t.annotations }),
63
89
  }));
64
90
  },
65
91
  async executeTool(
@@ -110,6 +136,7 @@ beforeEach(() => {
110
136
  vi.clearAllMocks();
111
137
  polyfillMock.initThrows = false;
112
138
  registry.tools = [];
139
+ registry.schemaShape = "object";
113
140
  // location is read by snapshotForDispatch; document.modelContext is the
114
141
  // consumer surface. Both are absent in the Node test environment.
115
142
  vi.stubGlobal("location", { origin: "https://example.test" });
@@ -166,29 +193,111 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
166
193
  expect(bridge.isOperational()).toBe(false);
167
194
  });
168
195
 
169
- it("ships only the JSON-serializable surface (name, description, schema, origin)", async () => {
196
+ // Both `inputSchema` generations must produce the same wire tool: the object
197
+ // form (webmcp#241 / polyfill v5 / Chrome 154+) and the serialized string
198
+ // Chrome 149-153 still emits.
199
+ it.each(["object", "string"] as const)(
200
+ "ships only the JSON-serializable surface (name, description, schema, origin) [%s inputSchema]",
201
+ async (schemaShape) => {
202
+ registry.schemaShape = schemaShape;
203
+ registry.tools = [
204
+ fakeTool({
205
+ name: "search",
206
+ description: "search the shop",
207
+ inputSchema: { type: "object", properties: { q: { type: "string" } } },
208
+ }),
209
+ ];
210
+ const bridge = new WebMcpBridge({ enabled: true });
211
+ const snap = await bridge.snapshotForDispatch();
212
+ expect(snap).toHaveLength(1);
213
+ const tool = snap[0]!;
214
+ expect(tool.name).toBe("search");
215
+ expect(tool.description).toBe("search the shop");
216
+ expect(tool.parametersSchema).toEqual({
217
+ type: "object",
218
+ properties: { q: { type: "string" } },
219
+ });
220
+ expect(tool.origin).toBe("webmcp");
221
+ expect(tool.pageOrigin).toBe("https://example.test");
222
+ expect((tool as unknown as { execute?: unknown }).execute).toBeUndefined();
223
+ // Now that the registry was read, the bridge reports operational.
224
+ expect(bridge.isOperational()).toBe(true);
225
+ },
226
+ );
227
+
228
+ // `parametersSchema` is required by the server's InlineClientToolSchema, and
229
+ // one entry missing it fails the whole DispatchRequestSchema union: a tool
230
+ // with no usable schema must degrade to "takes no arguments", never to an
231
+ // omitted key that 400s the turn.
232
+ it("always sends a parametersSchema, even when the tool declared none", async () => {
233
+ registry.tools = [fakeTool({ name: "ping" })];
234
+ const snap = await new WebMcpBridge({ enabled: true }).snapshotForDispatch();
235
+ expect(snap[0]!.parametersSchema).toEqual({ type: "object", properties: {} });
236
+ });
237
+
238
+ it("forwards annotations and hoists untrustedContentHint to the top level", async () => {
170
239
  registry.tools = [
171
240
  fakeTool({
172
- name: "search",
173
- description: "search the shop",
174
- inputSchema: { type: "object", properties: { q: { type: "string" } } },
241
+ name: "get_reviews",
242
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
175
243
  }),
176
244
  ];
177
- const bridge = new WebMcpBridge({ enabled: true });
178
- const snap = await bridge.snapshotForDispatch();
179
- expect(snap).toHaveLength(1);
180
- const tool = snap[0]!;
181
- expect(tool.name).toBe("search");
182
- expect(tool.description).toBe("search the shop");
183
- expect(tool.parametersSchema).toEqual({
184
- type: "object",
185
- properties: { q: { type: "string" } },
245
+ const snap = await new WebMcpBridge({ enabled: true }).snapshotForDispatch();
246
+ expect(snap[0]!.annotations).toEqual({
247
+ readOnlyHint: true,
248
+ untrustedContentHint: true,
186
249
  });
187
- expect(tool.origin).toBe("webmcp");
188
- expect(tool.pageOrigin).toBe("https://example.test");
189
- expect((tool as unknown as { execute?: unknown }).execute).toBeUndefined();
190
- // Now that the registry was read, the bridge reports operational.
191
- expect(bridge.isOperational()).toBe(true);
250
+ // The server acts on the top-level field, not the nested metadata copy.
251
+ expect(snap[0]!.untrustedContentHint).toBe(true);
252
+ });
253
+
254
+ it("omits annotations and the top-level hint when the tool declared none", async () => {
255
+ registry.tools = [fakeTool({ name: "search" })];
256
+ const snap = await new WebMcpBridge({ enabled: true }).snapshotForDispatch();
257
+ expect(snap[0]!).not.toHaveProperty("annotations");
258
+ expect(snap[0]!).not.toHaveProperty("untrustedContentHint");
259
+ });
260
+
261
+ it("does not hoist untrustedContentHint: false, and drops unknown annotation keys", async () => {
262
+ registry.tools = [
263
+ fakeTool({
264
+ name: "add_to_cart",
265
+ annotations: {
266
+ readOnlyHint: false,
267
+ untrustedContentHint: false,
268
+ destructiveHint: true, // MCP-only today; not in the WebMCP dict
269
+ evil: () => "not JSON",
270
+ },
271
+ }),
272
+ ];
273
+ const snap = await new WebMcpBridge({ enabled: true }).snapshotForDispatch();
274
+ expect(snap[0]!.annotations).toEqual({
275
+ readOnlyHint: false,
276
+ untrustedContentHint: false,
277
+ });
278
+ expect(snap[0]!).not.toHaveProperty("untrustedContentHint");
279
+ });
280
+
281
+ it.each([
282
+ ["unparseable string", "{not json"],
283
+ ["non-object JSON string", '"just a string"'],
284
+ ["JSON array string", "[1,2,3]"],
285
+ ["array object", [1, 2, 3]],
286
+ ["null", null],
287
+ ])("falls back to an empty schema for %s", async (_label, inputSchema) => {
288
+ registry.tools = [fakeTool({ name: "odd" })];
289
+ // Bypass the fake's shape switch: these are malformed values a page or a
290
+ // future runtime could hand us, not either sanctioned generation.
291
+ vi.stubGlobal("document", {
292
+ modelContext: {
293
+ ...makeModelContext(),
294
+ async getTools() {
295
+ return [{ name: "odd", description: "odd", inputSchema, title: "" }];
296
+ },
297
+ },
298
+ });
299
+ const snap = await new WebMcpBridge({ enabled: true }).snapshotForDispatch();
300
+ expect(snap[0]!.parametersSchema).toEqual({ type: "object", properties: {} });
192
301
  });
193
302
 
194
303
  it("applies client-side allowlist glob (`search_*`)", async () => {
@@ -49,8 +49,22 @@ export const WEBMCP_TOOL_PREFIX = "webmcp:";
49
49
  export interface ModelContextToolInfo {
50
50
  name: string;
51
51
  description: string;
52
- /** JSON-encoded JSON Schema for the tool's input. */
53
- inputSchema?: string;
52
+ /**
53
+ * JSON Schema for the tool's input, in EITHER generation of the WebMCP
54
+ * contract. Never read it raw: `normalizeInputSchema` in
55
+ * `webmcp-runtime-entry.ts` collapses both shapes to the object the wire
56
+ * needs.
57
+ *
58
+ * `webmcp#241` changed this from a JSON-encoded string to a JSON Schema
59
+ * object. `@mcp-b/webmcp-polyfill` made the switch in v5, and Chrome ships it
60
+ * from 154.0.8013 (cross-document tools first) -- but Chrome 149-153, most of
61
+ * the Origin Trial population, and 154's same-document tools still emit the
62
+ * string. Both are live in the field, so we accept both.
63
+ *
64
+ * Absent when the tool registered no schema: v5 omits the member, where v4
65
+ * always emitted a `{"type":"object"}` string.
66
+ */
67
+ inputSchema?: object | string;
54
68
  /**
55
69
  * Display title declared on the tool (`ToolDescriptor.title` in the WebMCP
56
70
  * spec). The polyfill returns `""` when the tool didn't declare one. Note:
@@ -59,6 +73,16 @@ export interface ModelContextToolInfo {
59
73
  * channel available to us.
60
74
  */
61
75
  title?: string;
76
+ /**
77
+ * WebMCP `Tool.annotations`. Returned by `getTools()` since
78
+ * `@mcp-b/webmcp-polyfill` v5 (and by Chrome's native implementation);
79
+ * absent on v4 and whenever the tool declared none -- the spec omits the
80
+ * member rather than emitting an empty object.
81
+ */
82
+ annotations?: {
83
+ readOnlyHint?: boolean;
84
+ untrustedContentHint?: boolean;
85
+ };
62
86
  }
63
87
 
64
88
  export interface ModelContextCoreLike {
@@ -208,7 +232,7 @@ export const stripWebMcpPrefix = (name: string): string =>
208
232
 
209
233
  /**
210
234
  * `true` when `wireToolName` carries the `webmcp:` prefix. Used by `client.ts`
211
- * to route `step_await` events.
235
+ * to route `await` events.
212
236
  */
213
237
  export const isWebMcpToolName = (name: string): boolean =>
214
238
  name.startsWith(WEBMCP_TOOL_PREFIX);