@runtypelabs/persona 3.21.3 → 3.23.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.
Files changed (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
@@ -7,6 +7,7 @@ import {
7
7
  resolveFollowStateFromWheel
8
8
  } from "../utils/auto-follow";
9
9
  import type { EventStreamBuffer } from "../utils/event-stream-buffer";
10
+ import type { ThroughputMetric } from "../utils/throughput-tracker";
10
11
  import type {
11
12
  SSEEventRecord,
12
13
  AgentWidgetConfig,
@@ -151,6 +152,36 @@ function formatEventForCopy(event: SSEEventRecord): string {
151
152
  );
152
153
  }
153
154
 
155
+ // ============================================================================
156
+ // Output Throughput Summary
157
+ // ============================================================================
158
+
159
+ /** Format the headline value, e.g. `23.9 tok/s` or `-- tok/s` when unavailable. */
160
+ function formatThroughputValue(metric: ThroughputMetric): string {
161
+ if (
162
+ metric.tokensPerSecond === undefined ||
163
+ !Number.isFinite(metric.tokensPerSecond)
164
+ ) {
165
+ return "-- tok/s";
166
+ }
167
+ return `${metric.tokensPerSecond.toFixed(1)} tok/s`;
168
+ }
169
+
170
+ /** Compact supporting details: output tokens, duration, and usage/estimate source. */
171
+ function formatThroughputMeta(metric: ThroughputMetric): string {
172
+ const parts: string[] = [];
173
+ if (metric.outputTokens !== undefined) {
174
+ parts.push(`${metric.outputTokens.toLocaleString()} tok`);
175
+ }
176
+ if (metric.durationMs !== undefined) {
177
+ parts.push(`${(metric.durationMs / 1000).toFixed(2)}s`);
178
+ }
179
+ if (metric.source) {
180
+ parts.push(metric.source);
181
+ }
182
+ return parts.join(" · ");
183
+ }
184
+
154
185
  // ============================================================================
155
186
  // Inline Payload Component
156
187
  // ============================================================================
@@ -381,6 +412,12 @@ export type EventStreamViewOptions = {
381
412
  onClose?: () => void;
382
413
  config?: AgentWidgetConfig;
383
414
  plugins?: AgentWidgetPlugin[];
415
+ /**
416
+ * Optional accessor for the current output-throughput metric, derived from
417
+ * the same SSE event stream. When provided, a compact "Output throughput"
418
+ * summary row is rendered and refreshed on each update.
419
+ */
420
+ getThroughput?: () => ThroughputMetric;
384
421
  };
385
422
 
386
423
  export function createEventStreamView(
@@ -396,6 +433,7 @@ export function createEventStreamView(
396
433
  onClose,
397
434
  config,
398
435
  plugins = [],
436
+ getThroughput,
399
437
  } = options;
400
438
  const scrollToBottomConfig = config?.features?.scrollToBottom;
401
439
  const scrollToBottomEnabled = scrollToBottomConfig?.enabled !== false;
@@ -474,11 +512,17 @@ export function createEventStreamView(
474
512
  let copyAllBtn!: HTMLButtonElement;
475
513
  let searchInput!: HTMLInputElement;
476
514
  let searchClearBtn!: HTMLButtonElement;
515
+ // Inline "Throughput <tok/s>" group, rendered into the header bar next to
516
+ // the "Events" count when getThroughput is provided. The detailed
517
+ // breakdown is revealed on hover via the native title tooltip.
518
+ let throughputValueEl: HTMLElement | null = null;
519
+ let throughputContainer: HTMLElement | null = null;
520
+ let throughputTooltipEl: HTMLElement | null = null;
477
521
 
478
522
  function buildDefaultToolbar(): HTMLElement {
479
523
  const toolbarOuter = createElement(
480
524
  "div",
481
- "persona-flex persona-flex-col persona-flex-shrink-0"
525
+ "persona-relative persona-flex persona-flex-col persona-flex-shrink-0"
482
526
  );
483
527
 
484
528
  // --- Header bar ---
@@ -502,6 +546,58 @@ export function createEventStreamView(
502
546
  );
503
547
  countBadge.textContent = "0";
504
548
 
549
+ // Inline throughput group: "Throughput 146.3 tok/s", grouped with the
550
+ // Events count. Hover reveals tokens · duration · source via a custom
551
+ // tooltip (shown instantly, unlike the slow native `title` delay).
552
+ if (getThroughput) {
553
+ throughputContainer = createElement(
554
+ "div",
555
+ "persona-relative persona-flex persona-items-center persona-gap-1.5 persona-whitespace-nowrap persona-ml-1"
556
+ );
557
+ throughputContainer.style.cursor = "help";
558
+ // Label styled to match the "Events" title.
559
+ const throughputLabel = createElement(
560
+ "span",
561
+ "persona-text-sm persona-font-medium persona-text-persona-primary persona-whitespace-nowrap"
562
+ );
563
+ throughputLabel.textContent = "Throughput";
564
+ // Same bounding box + styling as the Events count badge.
565
+ throughputValueEl = createElement(
566
+ "span",
567
+ "persona-text-[11px] persona-font-mono persona-bg-persona-container persona-text-persona-muted persona-px-2 persona-py-0.5 persona-rounded persona-border persona-border-persona-border persona-tabular-nums"
568
+ );
569
+ throughputValueEl.textContent = "-- tok/s";
570
+
571
+ // Custom hover tooltip — appears instantly (no native title delay).
572
+ // Appended to the (non-clipping) toolbar wrapper rather than the header
573
+ // bar, which has overflow-hidden and would clip a dropdown. Position is
574
+ // measured on hover so it sits just under the throughput group.
575
+ throughputTooltipEl = createElement(
576
+ "div",
577
+ "persona-absolute persona-z-50 persona-whitespace-nowrap persona-rounded persona-border persona-border-persona-border persona-bg-persona-container persona-text-persona-primary persona-text-[11px] persona-font-mono persona-px-2 persona-py-1 persona-shadow"
578
+ );
579
+ throughputTooltipEl.style.display = "none";
580
+ throughputTooltipEl.style.pointerEvents = "none";
581
+ const group = throughputContainer;
582
+ const tooltip = throughputTooltipEl;
583
+ const showTooltip = () => {
584
+ if (!tooltip.textContent) return;
585
+ const gRect = group.getBoundingClientRect();
586
+ const pRect = toolbarOuter.getBoundingClientRect();
587
+ tooltip.style.left = `${gRect.left - pRect.left}px`;
588
+ tooltip.style.top = `${gRect.bottom - pRect.top + 4}px`;
589
+ tooltip.style.display = "block";
590
+ };
591
+ const hideTooltip = () => {
592
+ tooltip.style.display = "none";
593
+ };
594
+ throughputContainer.addEventListener("mouseenter", showTooltip);
595
+ throughputContainer.addEventListener("mouseleave", hideTooltip);
596
+
597
+ throughputContainer.appendChild(throughputLabel);
598
+ throughputContainer.appendChild(throughputValueEl);
599
+ }
600
+
505
601
  const headerSpacer = createElement("div", "persona-flex-1");
506
602
 
507
603
  // Filter dropdown
@@ -540,6 +636,7 @@ export function createEventStreamView(
540
636
 
541
637
  headerBar.appendChild(title);
542
638
  headerBar.appendChild(countBadge);
639
+ if (throughputContainer) headerBar.appendChild(throughputContainer);
543
640
  headerBar.appendChild(headerSpacer);
544
641
  headerBar.appendChild(filterSelect);
545
642
  headerBar.appendChild(copyAllBtn);
@@ -592,6 +689,7 @@ export function createEventStreamView(
592
689
 
593
690
  toolbarOuter.appendChild(headerBar);
594
691
  toolbarOuter.appendChild(searchBar);
692
+ if (throughputTooltipEl) toolbarOuter.appendChild(throughputTooltipEl);
595
693
  return toolbarOuter;
596
694
  }
597
695
 
@@ -630,6 +728,28 @@ export function createEventStreamView(
630
728
  );
631
729
  truncationBanner.style.display = "none";
632
730
 
731
+ // Refresh the inline header throughput value + hover tooltip. The elements
732
+ // live in the header bar (built by buildDefaultToolbar); this is a no-op
733
+ // when getThroughput is absent or a plugin replaced the toolbar.
734
+ function updateThroughputSummary(): void {
735
+ if (!getThroughput || !throughputValueEl || !throughputContainer) return;
736
+ const metric = getThroughput();
737
+ throughputValueEl.textContent = formatThroughputValue(metric);
738
+ // Detailed breakdown is revealed on hover via the custom tooltip; mirror
739
+ // it into aria-label for assistive tech. When there's nothing to show,
740
+ // hide the tooltip so an empty box never flashes on hover.
741
+ const meta = formatThroughputMeta(metric);
742
+ if (throughputTooltipEl) {
743
+ throughputTooltipEl.textContent = meta;
744
+ if (!meta) throughputTooltipEl.style.display = "none";
745
+ }
746
+ if (meta) {
747
+ throughputContainer.setAttribute("aria-label", meta);
748
+ } else {
749
+ throughputContainer.removeAttribute("aria-label");
750
+ }
751
+ }
752
+
633
753
  // ========================================================================
634
754
  // Events List (simple DOM, no virtual scroller)
635
755
  // ========================================================================
@@ -804,6 +924,7 @@ export function createEventStreamView(
804
924
  lastRenderTime = Date.now();
805
925
  pendingUpdate = false;
806
926
 
927
+ updateThroughputSummary();
807
928
  updateFilterOptions();
808
929
 
809
930
  // Truncation banner
package/src/index.ts CHANGED
@@ -20,6 +20,9 @@ export type {
20
20
  AgentWidgetStreamParser,
21
21
  AgentWidgetStreamParserResult,
22
22
  AgentWidgetRequestPayload,
23
+ // Context provider types (e.g. for config.contextProviders)
24
+ AgentWidgetContextProvider,
25
+ AgentWidgetContextProviderContext,
23
26
  AgentWidgetCustomFetch,
24
27
  AgentWidgetSSEEventParser,
25
28
  AgentWidgetSSEEventResult,
@@ -78,6 +81,12 @@ export type {
78
81
  // Approval types
79
82
  AgentWidgetApproval,
80
83
  AgentWidgetApprovalConfig,
84
+ // WebMCP — page-discovered tool consumption
85
+ AgentWidgetWebMcpConfig,
86
+ ClientToolDefinition,
87
+ WebMcpConfirmHandler,
88
+ WebMcpConfirmInfo,
89
+ WebMcpToolResult,
81
90
  // Event stream types
82
91
  SSEEventRecord,
83
92
  EventStreamConfig,
@@ -121,6 +130,12 @@ export {
121
130
  } from "./session";
122
131
  export { AgentWidgetClient } from "./client";
123
132
  export type { SSEEventCallback } from "./client";
133
+ export {
134
+ WebMcpBridge,
135
+ WEBMCP_TOOL_PREFIX,
136
+ isWebMcpToolName,
137
+ stripWebMcpPrefix
138
+ } from "./webmcp-bridge";
124
139
  export { createLocalStorageAdapter } from "./utils/storage";
125
140
  export {
126
141
  createActionManager,
@@ -212,6 +227,17 @@ export type {
212
227
  AgentWidgetStreamAnimationPlaceholder,
213
228
  } from "./types";
214
229
 
230
+ // Action system types — needed to type the `actionHandlers` / `actionParsers`
231
+ // config options and to author custom handlers/parsers.
232
+ export type {
233
+ AgentWidgetActionHandler,
234
+ AgentWidgetActionHandlerResult,
235
+ AgentWidgetActionParser,
236
+ AgentWidgetParsedAction,
237
+ AgentWidgetActionContext,
238
+ AgentWidgetActionEventPayload,
239
+ } from "./types";
240
+
215
241
  // Dropdown utility exports
216
242
  export { createDropdownMenu } from "./utils/dropdown";
217
243
  export type { DropdownMenuItem, CreateDropdownOptions, DropdownMenuHandle } from "./utils/dropdown";
@@ -794,3 +794,261 @@ describe('AgentWidgetSession.resolveApproval', () => {
794
794
  expect(errors.length).toBe(1);
795
795
  });
796
796
  });
797
+
798
+ describe('AgentWidgetSession - dispatch error fallback', () => {
799
+ const originalFetch = global.fetch;
800
+
801
+ afterEach(() => {
802
+ global.fetch = originalFetch;
803
+ vi.restoreAllMocks();
804
+ });
805
+
806
+ // Make fetch reject so client.dispatch throws and the session falls back.
807
+ const failFetchWith = (error: Error) => {
808
+ global.fetch = vi.fn().mockRejectedValue(error);
809
+ };
810
+
811
+ const lastAssistantMessage = (
812
+ session: AgentWidgetSession
813
+ ): AgentWidgetMessage | undefined =>
814
+ [...session.getMessages()].reverse().find((m) => m.role === 'assistant');
815
+
816
+ it('shows the default message with the underlying error detail and fires onError', async () => {
817
+ failFetchWith(new Error('Failed to fetch'));
818
+ const errors: Error[] = [];
819
+ const session = new AgentWidgetSession(
820
+ { apiUrl: 'http://example.invalid/chat' },
821
+ {
822
+ onMessagesChanged: () => {},
823
+ onStatusChanged: () => {},
824
+ onStreamingChanged: () => {},
825
+ onError: (e) => errors.push(e),
826
+ }
827
+ );
828
+
829
+ await session.sendMessage('Hello');
830
+
831
+ const assistant = lastAssistantMessage(session);
832
+ expect(assistant?.content).toContain("I couldn't reach the assistant");
833
+ expect(assistant?.content).toContain('_Details: Failed to fetch_');
834
+ expect(errors).toHaveLength(1);
835
+ expect(errors[0].message).toBe('Failed to fetch');
836
+ expect(session.isStreaming()).toBe(false);
837
+ expect(session.getStatus()).toBe('idle');
838
+ });
839
+
840
+ it('uses a static errorMessage override verbatim', async () => {
841
+ failFetchWith(new Error('boom'));
842
+ const session = new AgentWidgetSession(
843
+ {
844
+ apiUrl: 'http://example.invalid/chat',
845
+ errorMessage: 'We are having trouble connecting.',
846
+ },
847
+ {
848
+ onMessagesChanged: () => {},
849
+ onStatusChanged: () => {},
850
+ onStreamingChanged: () => {},
851
+ }
852
+ );
853
+
854
+ await session.sendMessage('Hello');
855
+
856
+ const assistant = lastAssistantMessage(session);
857
+ expect(assistant?.content).toBe('We are having trouble connecting.');
858
+ expect(assistant?.content).not.toContain('Details');
859
+ });
860
+
861
+ it('passes the error to a function errorMessage override', async () => {
862
+ failFetchWith(new Error('Failed to fetch'));
863
+ const seen: Error[] = [];
864
+ const session = new AgentWidgetSession(
865
+ {
866
+ apiUrl: 'http://example.invalid/chat',
867
+ errorMessage: (error) => {
868
+ seen.push(error);
869
+ return error.message.includes('Failed to fetch')
870
+ ? 'You appear to be offline.'
871
+ : 'Something went wrong.';
872
+ },
873
+ },
874
+ {
875
+ onMessagesChanged: () => {},
876
+ onStatusChanged: () => {},
877
+ onStreamingChanged: () => {},
878
+ }
879
+ );
880
+
881
+ await session.sendMessage('Hello');
882
+
883
+ expect(seen).toHaveLength(1);
884
+ expect(seen[0]).toBeInstanceOf(Error);
885
+ expect(lastAssistantMessage(session)?.content).toBe(
886
+ 'You appear to be offline.'
887
+ );
888
+ });
889
+
890
+ it('suppresses the fallback bubble when the override returns an empty string but still fires onError', async () => {
891
+ failFetchWith(new Error('boom'));
892
+ const errors: Error[] = [];
893
+ const session = new AgentWidgetSession(
894
+ {
895
+ apiUrl: 'http://example.invalid/chat',
896
+ errorMessage: () => '',
897
+ },
898
+ {
899
+ onMessagesChanged: () => {},
900
+ onStatusChanged: () => {},
901
+ onStreamingChanged: () => {},
902
+ onError: (e) => errors.push(e),
903
+ }
904
+ );
905
+
906
+ await session.sendMessage('Hello');
907
+
908
+ expect(lastAssistantMessage(session)).toBeUndefined();
909
+ expect(errors).toHaveLength(1);
910
+ expect(session.isStreaming()).toBe(false);
911
+ expect(session.getStatus()).toBe('idle');
912
+ });
913
+
914
+ it('does NOT show a fallback bubble when the dispatch is aborted', async () => {
915
+ const abortErr = new Error('The operation was aborted');
916
+ abortErr.name = 'AbortError';
917
+ failFetchWith(abortErr);
918
+ const errors: Error[] = [];
919
+ const session = new AgentWidgetSession(
920
+ { apiUrl: 'http://example.invalid/chat' },
921
+ {
922
+ onMessagesChanged: () => {},
923
+ onStatusChanged: () => {},
924
+ onStreamingChanged: () => {},
925
+ onError: (e) => errors.push(e),
926
+ }
927
+ );
928
+
929
+ await session.sendMessage('Hello');
930
+
931
+ expect(lastAssistantMessage(session)).toBeUndefined();
932
+ expect(errors).toHaveLength(0);
933
+ });
934
+
935
+ it('applies the override on continueConversation failures too', async () => {
936
+ failFetchWith(new Error('boom'));
937
+ const session = new AgentWidgetSession(
938
+ {
939
+ apiUrl: 'http://example.invalid/chat',
940
+ errorMessage: 'Custom continue error.',
941
+ },
942
+ {
943
+ onMessagesChanged: () => {},
944
+ onStatusChanged: () => {},
945
+ onStreamingChanged: () => {},
946
+ }
947
+ );
948
+
949
+ await session.continueConversation();
950
+
951
+ expect(lastAssistantMessage(session)?.content).toBe('Custom continue error.');
952
+ });
953
+ });
954
+
955
+ describe('AgentWidgetSession - WebMCP native approval gate', () => {
956
+ const makeSession = (
957
+ webmcp?: Record<string, unknown>
958
+ ): { session: AgentWidgetSession; getMessages: () => AgentWidgetMessage[] } => {
959
+ let messages: AgentWidgetMessage[] = [];
960
+ const session = new AgentWidgetSession(
961
+ { apiUrl: 'http://localhost:8000', webmcp: { enabled: true, ...webmcp } },
962
+ {
963
+ onMessagesChanged: (msgs) => { messages = msgs; },
964
+ onStatusChanged: () => {},
965
+ onStreamingChanged: () => {},
966
+ }
967
+ );
968
+ return { session, getMessages: () => messages };
969
+ };
970
+
971
+ it('renders a pending approval bubble and resolves true on approve', async () => {
972
+ const { session, getMessages } = makeSession();
973
+ const promise = session.requestWebMcpApproval({
974
+ toolName: 'add_to_cart',
975
+ args: { sku: 'SHOE-001' },
976
+ reason: 'gate',
977
+ });
978
+
979
+ const bubble = getMessages().find((m) => m.variant === 'approval');
980
+ expect(bubble).toBeDefined();
981
+ expect(bubble?.approval?.status).toBe('pending');
982
+ expect(bubble?.approval?.toolType).toBe('webmcp');
983
+ expect(bubble?.approval?.toolName).toBe('add_to_cart');
984
+ expect(bubble?.approval?.parameters).toEqual({ sku: 'SHOE-001' });
985
+
986
+ session.resolveWebMcpApproval(bubble!.id, 'approved');
987
+ await expect(promise).resolves.toBe(true);
988
+
989
+ const resolved = getMessages().find((m) => m.variant === 'approval');
990
+ expect(resolved?.approval?.status).toBe('approved');
991
+ expect(resolved?.approval?.resolvedAt).toBeDefined();
992
+ });
993
+
994
+ it('resolves false on deny and marks the bubble denied', async () => {
995
+ const { session, getMessages } = makeSession();
996
+ const promise = session.requestWebMcpApproval({
997
+ toolName: 'add_to_cart',
998
+ args: { sku: 'SHOE-002' },
999
+ reason: 'gate',
1000
+ });
1001
+ const bubble = getMessages().find((m) => m.variant === 'approval');
1002
+
1003
+ session.resolveWebMcpApproval(bubble!.id, 'denied');
1004
+ await expect(promise).resolves.toBe(false);
1005
+ expect(
1006
+ getMessages().find((m) => m.variant === 'approval')?.approval?.status
1007
+ ).toBe('denied');
1008
+ });
1009
+
1010
+ it('auto-approves (no bubble) when autoApprove returns true', async () => {
1011
+ const { session, getMessages } = makeSession({
1012
+ autoApprove: (info: { toolName: string }) => info.toolName !== 'add_to_cart',
1013
+ });
1014
+
1015
+ await expect(
1016
+ session.requestWebMcpApproval({
1017
+ toolName: 'search_products',
1018
+ args: { query: 'shoes' },
1019
+ reason: 'gate',
1020
+ })
1021
+ ).resolves.toBe(true);
1022
+ // No approval bubble for an auto-approved (read-only) call.
1023
+ expect(getMessages().some((m) => m.variant === 'approval')).toBe(false);
1024
+ });
1025
+
1026
+ it('still gates a mutating tool when autoApprove excludes it', () => {
1027
+ const { session, getMessages } = makeSession({
1028
+ autoApprove: (info: { toolName: string }) => info.toolName !== 'add_to_cart',
1029
+ });
1030
+ void session.requestWebMcpApproval({
1031
+ toolName: 'add_to_cart',
1032
+ args: { sku: 'SHOE-001' },
1033
+ reason: 'gate',
1034
+ });
1035
+ expect(getMessages().some((m) => m.variant === 'approval')).toBe(true);
1036
+ });
1037
+
1038
+ it('treats a second resolve as a no-op', async () => {
1039
+ const { session, getMessages } = makeSession();
1040
+ const promise = session.requestWebMcpApproval({
1041
+ toolName: 'add_to_cart',
1042
+ args: {},
1043
+ reason: 'gate',
1044
+ });
1045
+ const bubble = getMessages().find((m) => m.variant === 'approval');
1046
+ session.resolveWebMcpApproval(bubble!.id, 'approved');
1047
+ // Second call must not throw or flip the resolved status.
1048
+ expect(() => session.resolveWebMcpApproval(bubble!.id, 'denied')).not.toThrow();
1049
+ await expect(promise).resolves.toBe(true);
1050
+ expect(
1051
+ getMessages().find((m) => m.variant === 'approval')?.approval?.status
1052
+ ).toBe('approved');
1053
+ });
1054
+ });