@runtypelabs/persona 3.29.0 → 3.30.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 +55 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CxvHw0X6.d.cts → types-B_Qazlm4.d.cts} +6 -0
- package/dist/animations/{types-CxvHw0X6.d.ts → types-B_Qazlm4.d.ts} +6 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +127 -7
- package/dist/index.d.ts +127 -7
- package/dist/index.global.js +60 -60
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -0
- package/dist/plugin-kit.d.cts +98 -0
- package/dist/plugin-kit.d.ts +98 -0
- package/dist/plugin-kit.js +1 -0
- package/dist/smart-dom-reader.d.cts +113 -4
- package/dist/smart-dom-reader.d.ts +113 -4
- package/dist/theme-editor.cjs +38 -38
- package/dist/theme-editor.d.cts +117 -6
- package/dist/theme-editor.d.ts +117 -6
- package/dist/theme-editor.js +38 -38
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/theme-reference.js +1 -1
- package/package.json +8 -2
- package/src/client.test.ts +40 -0
- package/src/client.ts +18 -4
- package/src/components/approval-bubble.test.ts +268 -0
- package/src/components/approval-bubble.ts +170 -20
- package/src/components/launcher.ts +6 -3
- package/src/components/tool-bubble.test.ts +39 -0
- package/src/components/tool-bubble.ts +4 -0
- package/src/generated/runtype-openapi-contract.ts +12 -0
- package/src/index-core.ts +1 -0
- package/src/plugin-kit.test.ts +230 -0
- package/src/plugin-kit.ts +294 -0
- package/src/plugins/types.ts +49 -2
- package/src/session.test.ts +99 -0
- package/src/session.ts +204 -32
- package/src/session.webmcp.test.ts +326 -6
- package/src/theme-editor/preview-utils.test.ts +10 -0
- package/src/theme-editor/preview-utils.ts +29 -1
- package/src/theme-editor/sections.test.ts +17 -0
- package/src/theme-editor/sections.ts +31 -0
- package/src/theme-reference.ts +1 -1
- package/src/types/theme.ts +2 -0
- package/src/types.ts +59 -2
- package/src/ui.approval-plugin.test.ts +204 -0
- package/src/ui.ts +149 -16
- package/src/utils/theme.test.ts +6 -2
- package/src/utils/theme.ts +0 -8
- package/src/utils/tokens.ts +6 -1
- package/src/webmcp-bridge.test.ts +66 -0
- package/src/webmcp-bridge.ts +49 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Plugin Kit — small, dependency-free utilities for authoring Persona plugins.
|
|
2
|
+
//
|
|
3
|
+
// Plugin render hooks (`renderApproval`, `renderAskUserQuestion`, `renderMessage`,
|
|
4
|
+
// …) return a detached `HTMLElement` that the widget morphs into the transcript.
|
|
5
|
+
// Two needs come up again and again when that element is more than static markup:
|
|
6
|
+
//
|
|
7
|
+
// 1. Injecting the plugin's own CSS so it survives the widget's Shadow-DOM mode
|
|
8
|
+
// (a plain `document.head` <style> does NOT pierce a shadow root).
|
|
9
|
+
// 2. Floating UI — dropdowns, menus, tooltips — that must overlay the rest of
|
|
10
|
+
// the widget and not be clipped by the transcript's scroll container.
|
|
11
|
+
//
|
|
12
|
+
// Both are easy to get subtly wrong, so they live here as a supported, optional
|
|
13
|
+
// subpath: `@runtypelabs/persona/plugin-kit`. Importing it costs nothing unless
|
|
14
|
+
// you use it, and nothing here touches the widget's core bundle.
|
|
15
|
+
//
|
|
16
|
+
// ```ts
|
|
17
|
+
// import { injectStyles, createPopover, isEditableEventTarget } from
|
|
18
|
+
// "@runtypelabs/persona/plugin-kit";
|
|
19
|
+
// ```
|
|
20
|
+
|
|
21
|
+
/* ============================================================
|
|
22
|
+
Shadow-safe style injection
|
|
23
|
+
============================================================ */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the root a node's styles should live in.
|
|
27
|
+
*
|
|
28
|
+
* Returns the node's `ShadowRoot` when it (or an ancestor) is shadowed, the
|
|
29
|
+
* owning `Document` otherwise. A `<style>` placed in this root reaches the node
|
|
30
|
+
* regardless of whether the widget runs in light or shadow DOM. For a detached
|
|
31
|
+
* node (one not yet mounted) this falls back to the owning document.
|
|
32
|
+
*/
|
|
33
|
+
export function getStyleRoot(node: Node): Document | ShadowRoot {
|
|
34
|
+
const root = node.getRootNode?.();
|
|
35
|
+
if (root instanceof ShadowRoot) return root;
|
|
36
|
+
if (root instanceof Document) return root;
|
|
37
|
+
return node.ownerDocument ?? document;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function injectInto(root: Document | ShadowRoot, id: string, css: string): void {
|
|
41
|
+
const container: Document | ShadowRoot | HTMLHeadElement =
|
|
42
|
+
root instanceof Document ? root.head : root;
|
|
43
|
+
const escaped = id.replace(/["\\]/g, "\\$&");
|
|
44
|
+
if (container.querySelector(`style[data-persona-plugin-style="${escaped}"]`)) {
|
|
45
|
+
return; // Already present in this root — idempotent.
|
|
46
|
+
}
|
|
47
|
+
const doc = root instanceof Document ? root : root.ownerDocument ?? document;
|
|
48
|
+
const style = doc.createElement("style");
|
|
49
|
+
style.setAttribute("data-persona-plugin-style", id);
|
|
50
|
+
style.textContent = css;
|
|
51
|
+
container.appendChild(style);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Inject a plugin's CSS once into the correct root — the widget's shadow root
|
|
56
|
+
* when shadowed, the document head otherwise. Idempotent: keyed by `id`, so it
|
|
57
|
+
* is safe to call on every render.
|
|
58
|
+
*
|
|
59
|
+
* Pass the element you're about to return from a render hook. While building, an
|
|
60
|
+
* element is detached and its eventual root is unknown, so this injects into the
|
|
61
|
+
* owning document immediately (covering the default light-DOM case with no
|
|
62
|
+
* flash) and then, on the next microtask — after the widget has mounted the
|
|
63
|
+
* element — re-resolves and also injects into its shadow root if it landed in
|
|
64
|
+
* one. You may also pass an explicit `Document` or `ShadowRoot`.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* renderApproval: ({ message, approve, deny }) => {
|
|
69
|
+
* const card = buildCard(message.approval, approve, deny);
|
|
70
|
+
* injectStyles(card, "my-approval-plugin", CSS);
|
|
71
|
+
* return card;
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function injectStyles(
|
|
76
|
+
target: Node | Document | ShadowRoot,
|
|
77
|
+
id: string,
|
|
78
|
+
css: string
|
|
79
|
+
): void {
|
|
80
|
+
if (target instanceof Document || target instanceof ShadowRoot) {
|
|
81
|
+
injectInto(target, id, css);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const node = target;
|
|
85
|
+
if (node.isConnected) {
|
|
86
|
+
injectInto(getStyleRoot(node), id, css);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Detached (built but not yet mounted): inject into the owning document now,
|
|
90
|
+
// then re-resolve after mount so shadow-DOM widgets also get the <style>.
|
|
91
|
+
const doc = node.ownerDocument ?? document;
|
|
92
|
+
injectInto(doc, id, css);
|
|
93
|
+
queueMicrotask(() => {
|
|
94
|
+
const root = getStyleRoot(node);
|
|
95
|
+
if (root !== doc) injectInto(root, id, css);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ============================================================
|
|
100
|
+
Floating popover
|
|
101
|
+
============================================================ */
|
|
102
|
+
|
|
103
|
+
export type PopoverPlacement =
|
|
104
|
+
| "bottom-start"
|
|
105
|
+
| "bottom-end"
|
|
106
|
+
| "top-start"
|
|
107
|
+
| "top-end";
|
|
108
|
+
|
|
109
|
+
export interface PopoverOptions {
|
|
110
|
+
/** Element the popover is positioned against. */
|
|
111
|
+
anchor: HTMLElement;
|
|
112
|
+
/** The floating element (menu, tooltip, panel). Built detached; mounted on `open`. */
|
|
113
|
+
content: HTMLElement;
|
|
114
|
+
/** Where to place `content` relative to `anchor`. Default `"bottom-start"`. */
|
|
115
|
+
placement?: PopoverPlacement;
|
|
116
|
+
/** Gap in px between anchor and content. Default `6`. */
|
|
117
|
+
offset?: number;
|
|
118
|
+
/** Set `content`'s `min-width` to the anchor's width. Default `false`. */
|
|
119
|
+
matchAnchorWidth?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Inline `z-index` for `content`. Default `2147483000` so it overlays the rest
|
|
122
|
+
* of the widget. Pass `null` to leave z-index to your own CSS.
|
|
123
|
+
*/
|
|
124
|
+
zIndex?: number | null;
|
|
125
|
+
/**
|
|
126
|
+
* Where to mount `content`. Defaults to the anchor's shadow root (when
|
|
127
|
+
* shadowed) or `document.body` — keeping it inside the same style + stacking
|
|
128
|
+
* scope as the anchor while escaping the transcript's scroll clipping.
|
|
129
|
+
*/
|
|
130
|
+
container?: HTMLElement | ShadowRoot;
|
|
131
|
+
onOpen?: () => void;
|
|
132
|
+
/** Fired when the popover closes by itself (outside click or the anchor leaving the DOM). */
|
|
133
|
+
onDismiss?: (reason: "outside" | "anchor-removed") => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface PopoverHandle {
|
|
137
|
+
readonly isOpen: boolean;
|
|
138
|
+
open(): void;
|
|
139
|
+
close(): void;
|
|
140
|
+
toggle(): void;
|
|
141
|
+
/** Recompute position from the current anchor rect (called automatically on scroll/resize). */
|
|
142
|
+
reposition(): void;
|
|
143
|
+
/** Close and release all listeners; the handle is inert afterward. */
|
|
144
|
+
destroy(): void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function defaultContainer(anchor: HTMLElement): HTMLElement | ShadowRoot {
|
|
148
|
+
const root = anchor.getRootNode?.();
|
|
149
|
+
if (root instanceof ShadowRoot) return root;
|
|
150
|
+
return (anchor.ownerDocument ?? document).body;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A floating popover anchored to an element: `fixed`-positioned (so it overlays
|
|
155
|
+
* the rest of the widget and isn't clipped by scroll containers), dismissed on
|
|
156
|
+
* outside pointerdown, repositioned on scroll/resize, and auto-closed if the
|
|
157
|
+
* anchor leaves the DOM. Mount your styles with {@link injectStyles}.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* const popover = createPopover({
|
|
162
|
+
* anchor: splitButton,
|
|
163
|
+
* content: menu,
|
|
164
|
+
* placement: "bottom-start",
|
|
165
|
+
* matchAnchorWidth: true,
|
|
166
|
+
* });
|
|
167
|
+
* caret.addEventListener("click", () => popover.toggle());
|
|
168
|
+
* // on teardown: popover.destroy();
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function createPopover(options: PopoverOptions): PopoverHandle {
|
|
172
|
+
const {
|
|
173
|
+
anchor,
|
|
174
|
+
content,
|
|
175
|
+
placement = "bottom-start",
|
|
176
|
+
offset = 6,
|
|
177
|
+
matchAnchorWidth = false,
|
|
178
|
+
zIndex = 2147483000,
|
|
179
|
+
onOpen,
|
|
180
|
+
onDismiss,
|
|
181
|
+
} = options;
|
|
182
|
+
|
|
183
|
+
const container = options.container ?? defaultContainer(anchor);
|
|
184
|
+
let open = false;
|
|
185
|
+
let detach: (() => void) | null = null;
|
|
186
|
+
|
|
187
|
+
const reposition = (): void => {
|
|
188
|
+
if (!open) return;
|
|
189
|
+
const rect = anchor.getBoundingClientRect();
|
|
190
|
+
content.style.position = "fixed";
|
|
191
|
+
if (matchAnchorWidth) content.style.minWidth = `${rect.width}px`;
|
|
192
|
+
|
|
193
|
+
const top =
|
|
194
|
+
placement === "top-start" || placement === "top-end"
|
|
195
|
+
? rect.top - offset - content.getBoundingClientRect().height
|
|
196
|
+
: rect.bottom + offset;
|
|
197
|
+
|
|
198
|
+
const left =
|
|
199
|
+
placement === "bottom-end" || placement === "top-end"
|
|
200
|
+
? rect.right - content.getBoundingClientRect().width
|
|
201
|
+
: rect.left;
|
|
202
|
+
|
|
203
|
+
content.style.top = `${top}px`;
|
|
204
|
+
content.style.left = `${left}px`;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const close = (): void => {
|
|
208
|
+
if (!open) return;
|
|
209
|
+
open = false;
|
|
210
|
+
if (detach) {
|
|
211
|
+
detach();
|
|
212
|
+
detach = null;
|
|
213
|
+
}
|
|
214
|
+
content.remove();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const doOpen = (): void => {
|
|
218
|
+
if (open) return;
|
|
219
|
+
open = true;
|
|
220
|
+
if (zIndex != null) content.style.zIndex = String(zIndex);
|
|
221
|
+
container.appendChild(content);
|
|
222
|
+
reposition();
|
|
223
|
+
|
|
224
|
+
const ownerWindow = (anchor.ownerDocument ?? document).defaultView ?? window;
|
|
225
|
+
const ownerDocument = anchor.ownerDocument ?? document;
|
|
226
|
+
|
|
227
|
+
const onReposition = (): void => {
|
|
228
|
+
if (!anchor.isConnected) {
|
|
229
|
+
close();
|
|
230
|
+
onDismiss?.("anchor-removed");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
reposition();
|
|
234
|
+
};
|
|
235
|
+
const onOutside = (event: Event): void => {
|
|
236
|
+
const path =
|
|
237
|
+
typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
238
|
+
if (path.includes(content) || path.includes(anchor)) return;
|
|
239
|
+
close();
|
|
240
|
+
onDismiss?.("outside");
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Defer arming so the click that opened the popover doesn't dismiss it.
|
|
244
|
+
const armId = ownerWindow.setTimeout(() => {
|
|
245
|
+
ownerDocument.addEventListener("pointerdown", onOutside, true);
|
|
246
|
+
}, 0);
|
|
247
|
+
ownerWindow.addEventListener("scroll", onReposition, true);
|
|
248
|
+
ownerWindow.addEventListener("resize", onReposition);
|
|
249
|
+
|
|
250
|
+
detach = () => {
|
|
251
|
+
ownerWindow.clearTimeout(armId);
|
|
252
|
+
ownerDocument.removeEventListener("pointerdown", onOutside, true);
|
|
253
|
+
ownerWindow.removeEventListener("scroll", onReposition, true);
|
|
254
|
+
ownerWindow.removeEventListener("resize", onReposition);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
onOpen?.();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
get isOpen() {
|
|
262
|
+
return open;
|
|
263
|
+
},
|
|
264
|
+
open: doOpen,
|
|
265
|
+
close,
|
|
266
|
+
toggle: () => (open ? close() : doOpen()),
|
|
267
|
+
reposition,
|
|
268
|
+
destroy: close,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ============================================================
|
|
273
|
+
Keyboard helpers
|
|
274
|
+
============================================================ */
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether an event originated from an editable element (`<input>`, `<textarea>`,
|
|
278
|
+
* or `contenteditable`). Use it to avoid hijacking keys like Enter/Escape while
|
|
279
|
+
* the user is typing in the composer.
|
|
280
|
+
*
|
|
281
|
+
* Inspects the composed path, so it works for events that cross the widget's
|
|
282
|
+
* Shadow-DOM boundary (where `event.target` is retargeted to the host).
|
|
283
|
+
*/
|
|
284
|
+
export function isEditableEventTarget(event: Event): boolean {
|
|
285
|
+
const path =
|
|
286
|
+
typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
287
|
+
return path.some(
|
|
288
|
+
(el) =>
|
|
289
|
+
el instanceof HTMLElement &&
|
|
290
|
+
(el.tagName === "INPUT" ||
|
|
291
|
+
el.tagName === "TEXTAREA" ||
|
|
292
|
+
el.isContentEditable)
|
|
293
|
+
);
|
|
294
|
+
}
|
package/src/plugins/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AgentWidgetMessage,
|
|
3
3
|
AgentWidgetConfig,
|
|
4
|
+
AgentWidgetApprovalDecisionOptions,
|
|
4
5
|
AskUserQuestionPayload,
|
|
5
6
|
LoadingIndicatorRenderContext,
|
|
6
7
|
IdleIndicatorRenderContext,
|
|
@@ -165,13 +166,59 @@ export interface AgentWidgetPlugin {
|
|
|
165
166
|
}) => HTMLElement | null;
|
|
166
167
|
|
|
167
168
|
/**
|
|
168
|
-
* Custom renderer for approval bubbles
|
|
169
|
-
*
|
|
169
|
+
* Custom renderer for approval bubbles.
|
|
170
|
+
*
|
|
171
|
+
* Return an `HTMLElement` to fully own the approval UI, `defaultRenderer()`
|
|
172
|
+
* to render (or wrap) the built-in bubble, or `null` to fall through to the
|
|
173
|
+
* default. Unlike the built-in bubble — whose Approve/Deny buttons are wired
|
|
174
|
+
* via delegation — a fully custom element resolves the approval by calling
|
|
175
|
+
* the `approve`/`deny` callbacks. Both route through the same path the
|
|
176
|
+
* built-in buttons use (optimistic update, `onDecision`, in-place anchoring).
|
|
177
|
+
*
|
|
178
|
+
* An approval is a single binary gate, so there are exactly two outcomes.
|
|
179
|
+
* Pass `{ remember: true }` to flag a "remember this" affordance (e.g. an
|
|
180
|
+
* "Always allow" button); the current approval resolves identically, but the
|
|
181
|
+
* flag is forwarded to `config.approval.onDecision` so you can persist a
|
|
182
|
+
* don't-ask-again policy for future approvals.
|
|
183
|
+
*
|
|
184
|
+
* `renderApproval` is called again whenever the approval's status changes, so
|
|
185
|
+
* branch on `message.approval?.status` to render the resolved state (and tear
|
|
186
|
+
* down any global listeners you added while pending).
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```typescript
|
|
190
|
+
* // An alternative prompt: "Always allow" / "Allow once" / "Deny".
|
|
191
|
+
* renderApproval: ({ message, approve, deny }) => {
|
|
192
|
+
* const approval = message.approval;
|
|
193
|
+
* if (!approval || approval.status !== "pending") return null; // default renders resolved state
|
|
194
|
+
* const root = document.createElement("div");
|
|
195
|
+
* root.textContent = `${approval.toolName} requires approval`;
|
|
196
|
+
*
|
|
197
|
+
* const always = document.createElement("button");
|
|
198
|
+
* always.textContent = "Always allow";
|
|
199
|
+
* always.addEventListener("click", () => approve({ remember: true }));
|
|
200
|
+
*
|
|
201
|
+
* const once = document.createElement("button");
|
|
202
|
+
* once.textContent = "Allow once";
|
|
203
|
+
* once.addEventListener("click", () => approve());
|
|
204
|
+
*
|
|
205
|
+
* const no = document.createElement("button");
|
|
206
|
+
* no.textContent = "Deny";
|
|
207
|
+
* no.addEventListener("click", () => deny());
|
|
208
|
+
*
|
|
209
|
+
* root.append(always, once, no);
|
|
210
|
+
* return root;
|
|
211
|
+
* }
|
|
212
|
+
* ```
|
|
170
213
|
*/
|
|
171
214
|
renderApproval?: (context: {
|
|
172
215
|
message: AgentWidgetMessage;
|
|
173
216
|
defaultRenderer: () => HTMLElement;
|
|
174
217
|
config: AgentWidgetConfig;
|
|
218
|
+
/** Resolve this approval as approved. Pass `{ remember: true }` for an "Always allow" affordance. */
|
|
219
|
+
approve: (options?: AgentWidgetApprovalDecisionOptions) => void;
|
|
220
|
+
/** Resolve this approval as denied. Pass `{ remember: true }` for an "Always deny" affordance. */
|
|
221
|
+
deny: (options?: AgentWidgetApprovalDecisionOptions) => void;
|
|
175
222
|
}) => HTMLElement | null;
|
|
176
223
|
|
|
177
224
|
/**
|
package/src/session.test.ts
CHANGED
|
@@ -793,6 +793,105 @@ describe('AgentWidgetSession.resolveApproval', () => {
|
|
|
793
793
|
expect(session.isStreaming()).toBe(false);
|
|
794
794
|
expect(errors.length).toBe(1);
|
|
795
795
|
});
|
|
796
|
+
|
|
797
|
+
it('keeps a resolved approval anchored in its original transcript position', async () => {
|
|
798
|
+
const earlier = new Date('2020-01-01T00:00:00.000Z').toISOString();
|
|
799
|
+
const later = new Date('2020-01-01T00:05:00.000Z').toISOString();
|
|
800
|
+
const approval = makeApproval();
|
|
801
|
+
// The bubble id follows the `approval-<approval.id>` convention used by
|
|
802
|
+
// resolveApproval's optimistic upsert.
|
|
803
|
+
const messageId = `approval-${approval.id}`;
|
|
804
|
+
|
|
805
|
+
const session = new AgentWidgetSession(
|
|
806
|
+
{
|
|
807
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
808
|
+
// Resolve locally (return void) so no network round-trip is needed.
|
|
809
|
+
approval: { onDecision: async () => {} },
|
|
810
|
+
initialMessages: [
|
|
811
|
+
{
|
|
812
|
+
id: messageId,
|
|
813
|
+
role: 'assistant',
|
|
814
|
+
content: '',
|
|
815
|
+
createdAt: earlier,
|
|
816
|
+
variant: 'approval',
|
|
817
|
+
approval,
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
id: 'after-1',
|
|
821
|
+
role: 'assistant',
|
|
822
|
+
content: 'A message created after the approval was requested.',
|
|
823
|
+
createdAt: later,
|
|
824
|
+
},
|
|
825
|
+
],
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
onMessagesChanged: () => {},
|
|
829
|
+
onStatusChanged: () => {},
|
|
830
|
+
onStreamingChanged: () => {},
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
await session.resolveApproval(approval, 'approved');
|
|
835
|
+
|
|
836
|
+
const messages = session.getMessages();
|
|
837
|
+
const approvalMsg = messages.find((m) => m.id === messageId);
|
|
838
|
+
const approvalIdx = messages.findIndex((m) => m.id === messageId);
|
|
839
|
+
const afterIdx = messages.findIndex((m) => m.id === 'after-1');
|
|
840
|
+
|
|
841
|
+
expect(approvalMsg?.approval?.status).toBe('approved');
|
|
842
|
+
// createdAt is preserved (not re-stamped to "now"), so the resolved bubble
|
|
843
|
+
// stays before the message that was created after it.
|
|
844
|
+
expect(approvalMsg?.createdAt).toBe(earlier);
|
|
845
|
+
expect(approvalIdx).toBeGreaterThanOrEqual(0);
|
|
846
|
+
expect(approvalIdx).toBeLessThan(afterIdx);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('forwards the decision options (e.g. remember) to onDecision', async () => {
|
|
850
|
+
const onDecision = vi.fn(async () => {});
|
|
851
|
+
const session = new AgentWidgetSession(
|
|
852
|
+
{
|
|
853
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
854
|
+
approval: { onDecision },
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
onMessagesChanged: () => {},
|
|
858
|
+
onStatusChanged: () => {},
|
|
859
|
+
onStreamingChanged: () => {},
|
|
860
|
+
}
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
await session.resolveApproval(makeApproval(), 'approved', { remember: true });
|
|
864
|
+
|
|
865
|
+
expect(onDecision).toHaveBeenCalledTimes(1);
|
|
866
|
+
expect(onDecision).toHaveBeenCalledWith(
|
|
867
|
+
expect.objectContaining({ toolName: 'send_email', approvalId: 'approval-1' }),
|
|
868
|
+
'approved',
|
|
869
|
+
{ remember: true }
|
|
870
|
+
);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('passes undefined options to onDecision when none are given', async () => {
|
|
874
|
+
const onDecision = vi.fn(async () => {});
|
|
875
|
+
const session = new AgentWidgetSession(
|
|
876
|
+
{
|
|
877
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
878
|
+
approval: { onDecision },
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
onMessagesChanged: () => {},
|
|
882
|
+
onStatusChanged: () => {},
|
|
883
|
+
onStreamingChanged: () => {},
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
await session.resolveApproval(makeApproval(), 'denied');
|
|
888
|
+
|
|
889
|
+
expect(onDecision).toHaveBeenCalledWith(
|
|
890
|
+
expect.objectContaining({ toolName: 'send_email' }),
|
|
891
|
+
'denied',
|
|
892
|
+
undefined
|
|
893
|
+
);
|
|
894
|
+
});
|
|
796
895
|
});
|
|
797
896
|
|
|
798
897
|
describe('AgentWidgetSession - dispatch error fallback', () => {
|