@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.
- package/README.md +67 -0
- package/dist/animations/glyph-cycle.cjs +2 -262
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -235
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.cjs +2 -72
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations/wipe.js +2 -45
- package/dist/index.cjs +52 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +107 -97
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +52 -45
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +23 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +23 -0
- package/dist/testing.cjs +3 -84
- package/dist/testing.js +3 -55
- package/dist/theme-editor.cjs +57 -22501
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +57 -22503
- package/package.json +16 -6
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/event-stream-view.ts +122 -1
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +151 -8
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/src/utils/throughput-tracker.test.ts +366 -0
- package/src/utils/throughput-tracker.ts +427 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- 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";
|
package/src/session.test.ts
CHANGED
|
@@ -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
|
+
});
|