@runtypelabs/persona 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.global.js +41 -41
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +51 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +15 -0
- package/dist/smart-dom-reader.d.ts +15 -0
- package/dist/theme-editor-preview.cjs +45 -45
- package/dist/theme-editor-preview.d.cts +15 -0
- package/dist/theme-editor-preview.d.ts +15 -0
- package/dist/theme-editor-preview.js +45 -45
- package/dist/theme-editor.cjs +1 -1
- package/dist/theme-editor.d.cts +15 -0
- package/dist/theme-editor.d.ts +15 -0
- package/dist/theme-editor.js +1 -1
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +224 -0
- package/package.json +1 -1
- package/src/components/approval-actions.test.ts +309 -0
- package/src/components/approval-actions.ts +461 -0
- package/src/styles/widget.css +224 -0
- package/src/theme-reference.ts +2 -2
- package/src/types.ts +15 -0
- package/src/ui.ts +19 -3
- package/src/utils/tokens.ts +15 -12
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createBuiltInApprovalPlugin } from "./approval-actions";
|
|
5
|
+
import { approvalDetailsExpansionState } from "./approval-bubble";
|
|
6
|
+
import type {
|
|
7
|
+
AgentWidgetApproval,
|
|
8
|
+
AgentWidgetConfig,
|
|
9
|
+
AgentWidgetMessage,
|
|
10
|
+
} from "../types";
|
|
11
|
+
import type { AgentWidgetPlugin } from "../plugins/types";
|
|
12
|
+
|
|
13
|
+
const makeMessage = (
|
|
14
|
+
approval: Partial<AgentWidgetApproval> = {},
|
|
15
|
+
id = "msg-1"
|
|
16
|
+
): AgentWidgetMessage => ({
|
|
17
|
+
id,
|
|
18
|
+
role: "assistant",
|
|
19
|
+
content: "",
|
|
20
|
+
createdAt: new Date().toISOString(),
|
|
21
|
+
variant: "approval",
|
|
22
|
+
streaming: false,
|
|
23
|
+
approval: {
|
|
24
|
+
id: "appr-1",
|
|
25
|
+
status: "pending",
|
|
26
|
+
agentId: "agent-1",
|
|
27
|
+
executionId: "exec-1",
|
|
28
|
+
toolName: "search_docs",
|
|
29
|
+
description: "Search the docs",
|
|
30
|
+
...approval,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Each widget owns its own plugin instance + teardown (state is per-instance).
|
|
35
|
+
// Track teardowns so afterEach can release any leftover document listeners.
|
|
36
|
+
let teardowns: Array<() => void> = [];
|
|
37
|
+
|
|
38
|
+
const makePlugin = (): { plugin: AgentWidgetPlugin; teardown: () => void } => {
|
|
39
|
+
const handle = createBuiltInApprovalPlugin();
|
|
40
|
+
teardowns.push(handle.teardown);
|
|
41
|
+
return handle;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const renderWith = (
|
|
45
|
+
plugin: AgentWidgetPlugin,
|
|
46
|
+
config: AgentWidgetConfig = {},
|
|
47
|
+
message: AgentWidgetMessage = makeMessage()
|
|
48
|
+
) => {
|
|
49
|
+
const approve = vi.fn();
|
|
50
|
+
const deny = vi.fn();
|
|
51
|
+
const el = plugin.renderApproval!({
|
|
52
|
+
message,
|
|
53
|
+
defaultRenderer: () => document.createElement("div"),
|
|
54
|
+
config,
|
|
55
|
+
approve,
|
|
56
|
+
deny,
|
|
57
|
+
});
|
|
58
|
+
if (el) document.body.appendChild(el);
|
|
59
|
+
return { el, approve, deny };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Convenience for single-render tests: fresh widget instance each call.
|
|
63
|
+
const render = (
|
|
64
|
+
config: AgentWidgetConfig = {},
|
|
65
|
+
message: AgentWidgetMessage = makeMessage()
|
|
66
|
+
) => renderWith(makePlugin().plugin, config, message);
|
|
67
|
+
|
|
68
|
+
const click = (el: Element | null | undefined): void => {
|
|
69
|
+
el?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
teardowns.forEach((t) => t());
|
|
74
|
+
teardowns = [];
|
|
75
|
+
approvalDetailsExpansionState.clear();
|
|
76
|
+
document.body.innerHTML = "";
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("built-in approval — flag off (default)", () => {
|
|
80
|
+
it("renders a single Allow + Deny, no split control", () => {
|
|
81
|
+
const { el } = render();
|
|
82
|
+
expect(el?.querySelector('[data-action="allow"]')?.textContent).toContain("Allow");
|
|
83
|
+
expect(el?.querySelector('[data-action="deny"]')).not.toBeNull();
|
|
84
|
+
expect(el?.querySelector('[data-action="always"]')).toBeNull();
|
|
85
|
+
expect(el?.querySelector('[data-action="toggle-menu"]')).toBeNull();
|
|
86
|
+
expect(el?.textContent).not.toContain("Always allow");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("Allow resolves approved without remember; Deny denies", () => {
|
|
90
|
+
const { el, approve, deny } = render();
|
|
91
|
+
click(el?.querySelector('[data-action="allow"]'));
|
|
92
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(approve.mock.calls[0]).toEqual([]); // no { remember } argument
|
|
94
|
+
click(el?.querySelector('[data-action="deny"]'));
|
|
95
|
+
expect(deny).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders the title from the tool name and an optional source", () => {
|
|
99
|
+
const { el } = render({}, makeMessage({ toolName: "search_docs", toolType: "Runtype" }));
|
|
100
|
+
const title = el?.querySelector(".persona-approval-title");
|
|
101
|
+
expect(title?.textContent).toContain("Search docs");
|
|
102
|
+
expect(title?.textContent).toContain("from");
|
|
103
|
+
expect(title?.textContent).toContain("Runtype");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("honors config.approval.approveLabel / denyLabel", () => {
|
|
107
|
+
const { el } = render({ approval: { approveLabel: "Permit", denyLabel: "Refuse" } });
|
|
108
|
+
expect(el?.querySelector('[data-action="allow"]')?.textContent).toContain("Permit");
|
|
109
|
+
expect(el?.querySelector('[data-action="deny"]')?.textContent).toContain("Refuse");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("built-in approval — flag on (enableAlwaysAllow)", () => {
|
|
114
|
+
const cfg: AgentWidgetConfig = { approval: { enableAlwaysAllow: true } };
|
|
115
|
+
|
|
116
|
+
it("renders the split Always allow + caret + Deny", () => {
|
|
117
|
+
const { el } = render(cfg);
|
|
118
|
+
expect(el?.querySelector('[data-action="always"]')?.textContent).toContain("Always allow");
|
|
119
|
+
expect(el?.querySelector('[data-action="toggle-menu"]')).not.toBeNull();
|
|
120
|
+
expect(el?.querySelector('[data-action="deny"]')).not.toBeNull();
|
|
121
|
+
expect(el?.querySelector('[data-action="allow"]')).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("Always allow resolves with { remember: true }", () => {
|
|
125
|
+
const { el, approve } = render(cfg);
|
|
126
|
+
click(el?.querySelector('[data-action="always"]'));
|
|
127
|
+
expect(approve).toHaveBeenCalledWith({ remember: true });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("Enter triggers Always allow", () => {
|
|
131
|
+
const { approve } = render(cfg);
|
|
132
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
133
|
+
expect(approve).toHaveBeenCalledWith({ remember: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("Escape denies", () => {
|
|
137
|
+
const { deny } = render(cfg);
|
|
138
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
139
|
+
expect(deny).toHaveBeenCalledTimes(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("Cmd/Ctrl+Enter triggers Allow once (no remember)", () => {
|
|
143
|
+
const { approve } = render(cfg);
|
|
144
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }));
|
|
145
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(approve.mock.calls[0]).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("ignores keyboard shortcuts while typing in an editable field", () => {
|
|
150
|
+
const { approve } = render(cfg);
|
|
151
|
+
const input = document.createElement("input");
|
|
152
|
+
document.body.appendChild(input);
|
|
153
|
+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
154
|
+
expect(approve).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("teardown releases the document keydown listener", () => {
|
|
158
|
+
const { plugin, teardown } = makePlugin();
|
|
159
|
+
const { approve } = renderWith(plugin, cfg);
|
|
160
|
+
teardown();
|
|
161
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
162
|
+
expect(approve).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("within one widget, only the latest pending approval owns the keyboard shortcuts", () => {
|
|
166
|
+
const { plugin } = makePlugin();
|
|
167
|
+
const first = renderWith(plugin, cfg, makeMessage({}, "msg-A"));
|
|
168
|
+
const second = renderWith(plugin, cfg, makeMessage({}, "msg-B"));
|
|
169
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
170
|
+
expect(first.approve).not.toHaveBeenCalled();
|
|
171
|
+
expect(second.approve).toHaveBeenCalledWith({ remember: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("promotes an older pending approval to keyboard owner when the latest resolves", () => {
|
|
175
|
+
const { plugin } = makePlugin();
|
|
176
|
+
const first = renderWith(plugin, cfg, makeMessage({}, "msg-A"));
|
|
177
|
+
const second = renderWith(plugin, cfg, makeMessage({}, "msg-B"));
|
|
178
|
+
// Resolve the current owner (B); A should inherit the shortcuts.
|
|
179
|
+
click(second.el?.querySelector('[data-action="deny"]'));
|
|
180
|
+
expect(second.deny).toHaveBeenCalledTimes(1);
|
|
181
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
182
|
+
expect(first.approve).toHaveBeenCalledWith({ remember: true });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("re-rendering an older approval does not steal shortcuts from the newest", () => {
|
|
186
|
+
const { plugin } = makePlugin();
|
|
187
|
+
const first = renderWith(plugin, cfg, makeMessage({}, "msg-A"));
|
|
188
|
+
const second = renderWith(plugin, cfg, makeMessage({}, "msg-B"));
|
|
189
|
+
// Older card (A) re-renders, e.g. via idiomorph — it must NOT claim the
|
|
190
|
+
// shortcuts back from the newer card B at the bottom of the thread.
|
|
191
|
+
const reA = renderWith(plugin, cfg, makeMessage({}, "msg-A"));
|
|
192
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
193
|
+
expect(first.approve).not.toHaveBeenCalled();
|
|
194
|
+
expect(reA.approve).not.toHaveBeenCalled();
|
|
195
|
+
expect(second.approve).toHaveBeenCalledWith({ remember: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("tearing down one widget leaves another widget's approval shortcuts intact", () => {
|
|
199
|
+
const widgetA = makePlugin();
|
|
200
|
+
const widgetB = makePlugin();
|
|
201
|
+
const a = renderWith(widgetA.plugin, cfg, makeMessage({}, "msg-A"));
|
|
202
|
+
const b = renderWith(widgetB.plugin, cfg, makeMessage({}, "msg-B"));
|
|
203
|
+
widgetA.teardown(); // destroy only widget A
|
|
204
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
205
|
+
expect(a.approve).not.toHaveBeenCalled();
|
|
206
|
+
expect(b.approve).toHaveBeenCalledWith({ remember: true });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("built-in approval — parameters disclosure", () => {
|
|
211
|
+
const withParams = () => makeMessage({ parameters: { query: "x", n: 5 } });
|
|
212
|
+
|
|
213
|
+
it("collapses parameters by default and expands on header click", () => {
|
|
214
|
+
const { el } = render({}, withParams());
|
|
215
|
+
const pre = el?.querySelector<HTMLElement>('[data-role="params"]');
|
|
216
|
+
expect(pre).not.toBeNull();
|
|
217
|
+
expect(pre?.hidden).toBe(true);
|
|
218
|
+
click(el?.querySelector(".persona-approval-head"));
|
|
219
|
+
expect(pre?.hidden).toBe(false);
|
|
220
|
+
expect(approvalDetailsExpansionState.get("msg-1")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("detailsDisplay:'expanded' shows params up front", () => {
|
|
224
|
+
const { el } = render({ approval: { detailsDisplay: "expanded" } }, withParams());
|
|
225
|
+
expect(el?.querySelector<HTMLElement>('[data-role="params"]')?.hidden).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("detailsDisplay:'hidden' omits the disclosure and header toggle", () => {
|
|
229
|
+
const { el } = render({ approval: { detailsDisplay: "hidden" } }, withParams());
|
|
230
|
+
expect(el?.querySelector('[data-role="params"]')).toBeNull();
|
|
231
|
+
expect(el?.querySelector('[data-action="toggle-params"]')).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("honors config show/hideDetailsLabel as the toggle's accessible label", () => {
|
|
235
|
+
const { el } = render(
|
|
236
|
+
{ approval: { showDetailsLabel: "Reveal call", hideDetailsLabel: "Hide call" } },
|
|
237
|
+
withParams()
|
|
238
|
+
);
|
|
239
|
+
const head = el?.querySelector<HTMLElement>('[data-action="toggle-params"]');
|
|
240
|
+
expect(head?.getAttribute("aria-label")).toBe("Reveal call");
|
|
241
|
+
click(head);
|
|
242
|
+
expect(head?.getAttribute("aria-label")).toBe("Hide call");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("defaults the toggle's accessible label to Show/Hide details", () => {
|
|
246
|
+
const { el } = render({}, withParams());
|
|
247
|
+
const head = el?.querySelector<HTMLElement>('[data-action="toggle-params"]');
|
|
248
|
+
expect(head?.getAttribute("aria-label")).toBe("Show details");
|
|
249
|
+
click(head);
|
|
250
|
+
expect(head?.getAttribute("aria-label")).toBe("Hide details");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("built-in approval — agent description in disclosure", () => {
|
|
255
|
+
it("surfaces approval.description in the collapsible details even without parameters", () => {
|
|
256
|
+
const { el } = render(
|
|
257
|
+
{},
|
|
258
|
+
makeMessage({ description: "Reads your calendar", parameters: undefined })
|
|
259
|
+
);
|
|
260
|
+
const details = el?.querySelector<HTMLElement>('[data-role="params"]');
|
|
261
|
+
expect(details).not.toBeNull();
|
|
262
|
+
expect(el?.querySelector('[data-action="toggle-params"]')).not.toBeNull();
|
|
263
|
+
expect(details?.querySelector(".persona-approval-desc")?.textContent).toContain(
|
|
264
|
+
"Reads your calendar"
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("shows the description alongside the parameters block", () => {
|
|
269
|
+
const { el } = render(
|
|
270
|
+
{},
|
|
271
|
+
makeMessage({ description: "Reads your calendar", parameters: { when: "today" } })
|
|
272
|
+
);
|
|
273
|
+
const details = el?.querySelector<HTMLElement>('[data-role="params"]');
|
|
274
|
+
expect(details?.querySelector(".persona-approval-desc")?.textContent).toContain(
|
|
275
|
+
"Reads your calendar"
|
|
276
|
+
);
|
|
277
|
+
expect(details?.querySelector(".persona-approval-params")?.textContent).toContain("today");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("detailsDisplay:'hidden' omits the description disclosure too", () => {
|
|
281
|
+
const { el } = render(
|
|
282
|
+
{ approval: { detailsDisplay: "hidden" } },
|
|
283
|
+
makeMessage({ description: "Reads your calendar", parameters: undefined })
|
|
284
|
+
);
|
|
285
|
+
expect(el?.querySelector('[data-role="params"]')).toBeNull();
|
|
286
|
+
expect(el?.querySelector('[data-action="toggle-params"]')).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("built-in approval — resolved states", () => {
|
|
291
|
+
it("approved renders nothing visible (the tool call takes over)", () => {
|
|
292
|
+
const { el } = render({}, makeMessage({ status: "approved" }));
|
|
293
|
+
expect(el?.style.display).toBe("none");
|
|
294
|
+
expect(el?.querySelector("[data-action]")).toBeNull();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("denied renders a subtle one-line trace, no action buttons", () => {
|
|
298
|
+
const { el } = render({}, makeMessage({ status: "denied" }));
|
|
299
|
+
expect(el?.classList.contains("persona-approval-resolved")).toBe(true);
|
|
300
|
+
expect(el?.textContent).toContain("Search docs");
|
|
301
|
+
expect(el?.textContent).toContain("denied");
|
|
302
|
+
expect(el?.querySelector("[data-action]")).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("timeout renders a timed-out trace", () => {
|
|
306
|
+
const { el } = render({}, makeMessage({ status: "timeout" }));
|
|
307
|
+
expect(el?.textContent).toContain("timed out");
|
|
308
|
+
});
|
|
309
|
+
});
|