@runtypelabs/persona 3.33.0 → 3.34.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.33.0",
3
+ "version": "3.34.1",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -60,9 +60,9 @@
60
60
  ],
61
61
  "dependencies": {
62
62
  "@mcp-b/webmcp-polyfill": "^3.0.0",
63
- "dompurify": "^3.3.3",
63
+ "dompurify": "^3.4.10",
64
64
  "idiomorph": "^0.7.4",
65
- "lucide": "^0.552.0",
65
+ "lucide": "^1.18.0",
66
66
  "marked": "^12.0.2",
67
67
  "partial-json": "^0.1.7",
68
68
  "zod": "^3.22.4"
@@ -4121,3 +4121,58 @@ describe('AgentWidgetClient - non-client-token paths always send full clientTool
4121
4121
  }
4122
4122
  });
4123
4123
  });
4124
+
4125
+ describe('AgentWidgetClient - Feedback request builder', () => {
4126
+ const INIT_RESPONSE = {
4127
+ sessionId: 'sess_123',
4128
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
4129
+ flow: {},
4130
+ config: { welcomeMessage: 'hi', placeholder: 'type…', theme: {} },
4131
+ };
4132
+
4133
+ function mockInitAndFeedback() {
4134
+ const feedbackBodies: Array<Record<string, unknown>> = [];
4135
+ global.fetch = vi.fn().mockImplementation(async (url: string, options: { body: string }) => {
4136
+ if (typeof url === 'string' && url.endsWith('/v1/client/feedback')) {
4137
+ feedbackBodies.push(JSON.parse(options.body));
4138
+ return { ok: true, status: 200, json: async () => ({}) };
4139
+ }
4140
+ // init request
4141
+ return { ok: true, status: 200, json: async () => INIT_RESPONSE };
4142
+ });
4143
+ return feedbackBodies;
4144
+ }
4145
+
4146
+ it('includes the client token in the feedback request body', async () => {
4147
+ const feedbackBodies = mockInitAndFeedback();
4148
+ const client = new AgentWidgetClient({
4149
+ apiUrl: 'http://localhost:8000',
4150
+ clientToken: 'ct_live_abc123',
4151
+ });
4152
+ await client.initSession();
4153
+
4154
+ await client.sendFeedback({ sessionId: 'sess_123', messageId: 'm1', type: 'upvote' });
4155
+
4156
+ expect(feedbackBodies).toHaveLength(1);
4157
+ expect(feedbackBodies[0]).toEqual({
4158
+ sessionId: 'sess_123',
4159
+ messageId: 'm1',
4160
+ type: 'upvote',
4161
+ token: 'ct_live_abc123',
4162
+ });
4163
+ });
4164
+
4165
+ it('sends the token on csat/nps submissions too', async () => {
4166
+ const feedbackBodies = mockInitAndFeedback();
4167
+ const client = new AgentWidgetClient({
4168
+ apiUrl: 'http://localhost:8000',
4169
+ clientToken: 'ct_live_xyz789',
4170
+ });
4171
+ await client.initSession();
4172
+
4173
+ await client.sendFeedback({ sessionId: 'sess_123', type: 'csat', rating: 5 });
4174
+
4175
+ expect(feedbackBodies[0].token).toBe('ct_live_xyz789');
4176
+ expect(feedbackBodies[0].rating).toBe(5);
4177
+ });
4178
+ });
package/src/client.ts CHANGED
@@ -482,12 +482,21 @@ export class AgentWidgetClient {
482
482
  console.debug("[AgentWidgetClient] sending feedback", feedback);
483
483
  }
484
484
 
485
+ // Scope the feedback request to the caller's client token, sourced the same
486
+ // way as the chat/init requests. sendFeedback is client-token-mode only
487
+ // (guarded above), so clientToken is always present here and an API key can
488
+ // never leak into the body. Left undefined only when the embed has none.
489
+ const requestBody = {
490
+ ...feedback,
491
+ ...(this.config.clientToken && { token: this.config.clientToken }),
492
+ };
493
+
485
494
  const response = await fetch(this.getFeedbackApiUrl(), {
486
495
  method: 'POST',
487
496
  headers: {
488
497
  'Content-Type': 'application/json',
489
498
  },
490
- body: JSON.stringify(feedback),
499
+ body: JSON.stringify(requestBody),
491
500
  });
492
501
 
493
502
  if (!response.ok) {
@@ -17,20 +17,6 @@ describe("codegen subpath", () => {
17
17
  expect(fromSubpath).toBe(fromInternal);
18
18
  });
19
19
 
20
- it("produces identical output via the subpath for every format", () => {
21
- const formats = [
22
- "esm",
23
- "script-installer",
24
- "script-manual",
25
- "script-advanced",
26
- "react-component",
27
- "react-advanced",
28
- ] as const;
29
- for (const format of formats) {
30
- expect(fromSubpath(config, format)).toBe(fromInternal(config, format));
31
- }
32
- });
33
-
34
20
  it("pins the installer CDN url to the package version (not @latest)", () => {
35
21
  const code = fromSubpath(config, "script-installer");
36
22
  expect(code).toContain(`@runtypelabs/persona@${VERSION}/dist/install.global.js`);
@@ -108,7 +108,7 @@ describe("createWidgetHostLayout docked", () => {
108
108
  layout.destroy();
109
109
  });
110
110
 
111
- it("push reveal translates a track; main column width stays fixed in px", () => {
111
+ it("push reveal offsets a track with margin (no transform); main column width stays fixed in px", () => {
112
112
  const parent = document.createElement("div");
113
113
  parent.style.width = "800px";
114
114
  document.body.appendChild(parent);
@@ -132,15 +132,20 @@ describe("createWidgetHostLayout docked", () => {
132
132
  expect(shell.dataset.personaDockReveal).toBe("push");
133
133
  expect(contentSlot?.style.width).toBe("800px");
134
134
  expect(pushTrack?.style.width).toBe("1120px");
135
- expect(pushTrack?.style.transform).toBe("translateX(0)");
135
+ expect(pushTrack?.style.marginLeft).toBe("0px");
136
+ // The track must NOT carry a transform: a transformed ancestor becomes the
137
+ // containing block for `position: fixed` host chrome and pushes it
138
+ // off-screen (regression guard, see host-layout.ts push branch).
139
+ expect(pushTrack?.style.transform).toBe("");
136
140
 
137
141
  layout.syncWidgetState({ open: true, launcherEnabled: true });
138
- expect(pushTrack?.style.transform).toBe("translateX(-320px)");
142
+ expect(pushTrack?.style.marginLeft).toBe("-320px");
143
+ expect(pushTrack?.style.transform).toBe("");
139
144
 
140
145
  layout.destroy();
141
146
  });
142
147
 
143
- it("push reveal on the left uses negative translate when closed", () => {
148
+ it("push reveal on the left uses negative margin when closed", () => {
144
149
  const parent = document.createElement("div");
145
150
  parent.style.width = "600px";
146
151
  document.body.appendChild(parent);
@@ -159,10 +164,12 @@ describe("createWidgetHostLayout docked", () => {
159
164
  layout.updateConfig({ launcher: dockConfig });
160
165
 
161
166
  const pushTrack = shell.querySelector<HTMLElement>('[data-persona-dock-role="push-track"]');
162
- expect(pushTrack?.style.transform).toBe("translateX(-200px)");
167
+ expect(pushTrack?.style.marginLeft).toBe("-200px");
168
+ expect(pushTrack?.style.transform).toBe("");
163
169
 
164
170
  layout.syncWidgetState({ open: true, launcherEnabled: true });
165
- expect(pushTrack?.style.transform).toBe("translateX(0)");
171
+ expect(pushTrack?.style.marginLeft).toBe("0px");
172
+ expect(pushTrack?.style.transform).toBe("");
166
173
 
167
174
  layout.destroy();
168
175
  });
@@ -219,9 +226,9 @@ describe("createWidgetHostLayout docked", () => {
219
226
  }
220
227
  });
221
228
 
222
- it("clamps push and overlay dock slots without sticky (transform/absolute contexts)", () => {
223
- // push: the slot lives inside the translated track, where a transformed
224
- // ancestor defeats sticky — cap only. overlay: keeps absolute positioning.
229
+ it("clamps push and overlay dock slots without sticky (in-flow/absolute contexts)", () => {
230
+ // push: the slot is an in-flow `position: relative` column max-height cap
231
+ // only, no sticky. overlay: keeps absolute positioning.
225
232
  const cases = [
226
233
  { reveal: "push" as const, position: "relative" },
227
234
  { reveal: "overlay" as const, position: "absolute" },
@@ -390,6 +397,41 @@ describe("createWidgetHostLayout docked", () => {
390
397
  });
391
398
  });
392
399
 
400
+ it("resets the push track margin when a desktop push offset enters mobile fullscreen", () => {
401
+ const parent = document.createElement("div");
402
+ parent.style.width = "800px";
403
+ document.body.appendChild(parent);
404
+ const target = document.createElement("div");
405
+ parent.appendChild(target);
406
+
407
+ const layout = createWidgetHostLayout(target, {
408
+ launcher: {
409
+ mountMode: "docked",
410
+ autoExpand: false,
411
+ dock: { width: "320px", reveal: "push" },
412
+ },
413
+ });
414
+ const pushTrack = layout.shell?.querySelector<HTMLElement>(
415
+ '[data-persona-dock-role="push-track"]',
416
+ );
417
+
418
+ // Desktop, expanded: track carries a negative margin offset.
419
+ withInnerWidth(800, () => {
420
+ layout.syncWidgetState({ open: true, launcherEnabled: true });
421
+ });
422
+ expect(pushTrack?.style.marginLeft).toBe("-320px");
423
+
424
+ // Same layout falls into mobile fullscreen (viewport resize): the stale
425
+ // margin must be cleared or the width:100% track renders 320px off-screen.
426
+ withInnerWidth(500, () => {
427
+ window.dispatchEvent(new Event("resize"));
428
+ });
429
+ expect(layout.shell?.dataset.personaDockMobileFullscreen).toBe("true");
430
+ expect(pushTrack?.style.marginLeft).toBe("0px");
431
+
432
+ layout.destroy();
433
+ });
434
+
393
435
  it("does not use fixed fullscreen above mobile breakpoint", () => {
394
436
  withInnerWidth(800, () => {
395
437
  const parent = document.createElement("div");
@@ -53,9 +53,10 @@ const applyDockSlotMaxHeight = (
53
53
  * when the surrounding page is taller than the screen (e.g. a missing height
54
54
  * chain or a deliberately scrolling page). With a properly sized shell it
55
55
  * behaves exactly like the previous `position: relative`. Not used for push:
56
- * there the slot lives inside the translated push track, and that transformed
57
- * ancestor defeats sticky (verified in Chromium) push gets the max-height
58
- * cap only, like overlay.
56
+ * push gets the max-height cap only, like overlay, since the dock slot is an
57
+ * in-flow `position: relative` column there rather than a viewport-pinned one.
58
+ * (The push track itself is offset with `margin-left`, not a transform, so it
59
+ * no longer establishes a containing block for fixed/sticky descendants.)
59
60
  */
60
61
  const applyInFlowDockSlotPosition = (
61
62
  dockSlot: HTMLElement,
@@ -151,6 +152,7 @@ const clearPushTrackStyles = (pushTrack: HTMLElement): void => {
151
152
  pushTrack.style.alignItems = "";
152
153
  pushTrack.style.transition = "";
153
154
  pushTrack.style.transform = "";
155
+ pushTrack.style.marginLeft = "";
154
156
  };
155
157
 
156
158
  const resetContentSlotFlexSizing = (contentSlot: HTMLElement): void => {
@@ -294,6 +296,11 @@ const applyDockStyles = (
294
296
  pushTrack.style.flex = "1 1 auto";
295
297
  pushTrack.style.alignItems = "stretch";
296
298
  pushTrack.style.transform = "none";
299
+ // Reset the desktop push offset: this fullscreen path applies styles
300
+ // inline without going through clearPushTrackStyles, so a stale negative
301
+ // marginLeft from a prior expanded desktop render would shift the now
302
+ // width:100% track off-screen on mobile.
303
+ pushTrack.style.marginLeft = "0";
297
304
  pushTrack.style.transition = "none";
298
305
  contentSlot.style.flex = "1 1 auto";
299
306
  contentSlot.style.width = "100%";
@@ -359,15 +366,24 @@ const applyDockStyles = (
359
366
 
360
367
  const panelPx = parseDockWidthToPx(dock.width, shell.clientWidth);
361
368
  const contentPx = Math.max(0, shell.clientWidth);
362
- const dockTransition = dock.animate ? "transform 180ms ease" : "none";
363
- const translate =
369
+ const dockTransition = dock.animate ? "margin-left 180ms ease" : "none";
370
+ // Slide the wide track with a negative left margin rather than a CSS
371
+ // transform. A `transform` (even `translateX(0)`) turns the push track into
372
+ // the containing block for any `position: fixed` descendant, so host pages
373
+ // that render viewport-fixed chrome inside the pushed content (e.g. the
374
+ // dashboard editor's `fixed top-0 right-0` toolbar) resolve `right: 0`
375
+ // against the track's right edge — `panelPx` past the viewport, off-screen.
376
+ // `margin-left` produces the identical visual offset (the track is clipped
377
+ // by the overflow:hidden shell) without establishing a containing block for
378
+ // fixed OR absolutely-positioned descendants.
379
+ const marginOffsetPx =
364
380
  dock.side === "right"
365
381
  ? expanded
366
- ? `translateX(-${panelPx}px)`
367
- : "translateX(0)"
382
+ ? -panelPx
383
+ : 0
368
384
  : expanded
369
- ? "translateX(0)"
370
- : `translateX(-${panelPx}px)`;
385
+ ? 0
386
+ : -panelPx;
371
387
 
372
388
  pushTrack.style.display = "flex";
373
389
  pushTrack.style.flexDirection = "row";
@@ -378,7 +394,10 @@ const applyDockStyles = (
378
394
  pushTrack.style.height = "100%";
379
395
  pushTrack.style.width = `${contentPx + panelPx}px`;
380
396
  pushTrack.style.transition = dockTransition;
381
- pushTrack.style.transform = translate;
397
+ pushTrack.style.marginLeft = `${marginOffsetPx}px`;
398
+ // Defensively clear any transform a previous reveal mode may have set —
399
+ // leaving one would re-establish the fixed-position containing block.
400
+ pushTrack.style.transform = "";
382
401
 
383
402
  contentSlot.style.flex = "0 0 auto";
384
403
  contentSlot.style.flexGrow = "0";
@@ -424,11 +424,11 @@ describe("initAgentWidget docked mode", () => {
424
424
  const panelSlot = shell.querySelector<HTMLElement>('[data-persona-dock-role="panel"]')!;
425
425
  expect(pushTrack).not.toBeNull();
426
426
  expect(panelSlot.style.width).toBe("400px");
427
- expect(pushTrack?.style.transform).toBe("translateX(-400px)");
427
+ expect(pushTrack?.style.marginLeft).toBe("-400px");
428
428
 
429
429
  handle.close();
430
430
  expect(panelSlot.style.width).toBe("400px");
431
- expect(pushTrack?.style.transform).toBe("translateX(0)");
431
+ expect(pushTrack?.style.marginLeft).toBe("0px");
432
432
 
433
433
  handle.destroy();
434
434
  wrapper.remove();
package/src/types.ts CHANGED
@@ -145,12 +145,41 @@ export type AgentToolsConfig = {
145
145
  mcpServers?: Array<Record<string, unknown>>;
146
146
  /** Maximum number of tool invocations per execution */
147
147
  maxToolCalls?: number;
148
+ /** How the model is steered toward tools: let it decide, force a call, or disable */
149
+ toolCallStrategy?: "auto" | "required" | "none";
150
+ /** Per-tool invocation limits / requirements keyed by tool name */
151
+ perToolLimits?: Record<string, { maxCalls?: number; required?: boolean }>;
148
152
  /** Tool approval configuration for human-in-the-loop workflows */
149
153
  approval?: {
150
154
  /** Tool names/patterns to require approval for, or true for all tools */
151
155
  require: string[] | boolean;
152
156
  /** Approval timeout in milliseconds (default: 300000 / 5 minutes) */
153
157
  timeout?: number;
158
+ /** Ask the agent to state its intent alongside approval requests (default: true) */
159
+ requestReason?: boolean;
160
+ };
161
+ /**
162
+ * Enables the synthesized `spawn_subagent` tool: the model can spin up
163
+ * ad-hoc child agents at runtime, restricted to `toolPool` (tool IDs /
164
+ * runtime-tool names already granted to the parent agent).
165
+ */
166
+ subagentConfig?: {
167
+ toolPool: string[];
168
+ defaultMaxTurns?: number;
169
+ maxTurnsLimit?: number;
170
+ maxSpawnsPerRun?: number;
171
+ defaultModel?: string;
172
+ allowNesting?: boolean;
173
+ defaultTimeoutMs?: number;
174
+ };
175
+ /**
176
+ * Enables the synthesized `code_mode` tool: the model writes JS that calls
177
+ * pool tools inside a sandbox instead of issuing individual tool calls.
178
+ */
179
+ codeModeConfig?: {
180
+ toolPool: string[];
181
+ description?: string;
182
+ timeoutMs?: number;
154
183
  };
155
184
  };
156
185
 
@@ -286,11 +286,11 @@ describe("createAgentExperience docked mode", () => {
286
286
  const closeButton = mount.querySelector<HTMLButtonElement>('[aria-label="Close chat"]');
287
287
 
288
288
  expect(pushTrack).not.toBeNull();
289
- expect(pushTrack?.style.transform).toBe("translateX(-420px)");
289
+ expect(pushTrack?.style.marginLeft).toBe("-420px");
290
290
 
291
291
  closeButton!.click();
292
292
 
293
- expect(pushTrack?.style.transform).toBe("translateX(0)");
293
+ expect(pushTrack?.style.marginLeft).toBe("0px");
294
294
 
295
295
  openUnsub();
296
296
  closeUnsub();
@@ -32,58 +32,7 @@ function mockBrowserSupport(supported: boolean) {
32
32
  }
33
33
  }
34
34
 
35
- // Note: TypeScript interfaces don't exist at runtime, so we can't test them directly
36
- // We test the concrete implementations instead
37
-
38
- describe('RuntypeVoiceProvider', () => {
39
- it('should create instance with valid config', () => {
40
- const config = {
41
- agentId: 'test-agent',
42
- clientToken: 'test-token',
43
- host: 'localhost:8787',
44
- voiceId: 'rachel'
45
- };
46
-
47
- const provider = new RuntypeVoiceProvider(config);
48
- expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
49
- expect(provider.type).toBe('runtype');
50
- });
51
-
52
- it('should have correct methods', () => {
53
- const config = {
54
- agentId: 'test-agent',
55
- clientToken: 'test-token'
56
- };
57
-
58
- const provider = new RuntypeVoiceProvider(config);
59
- expect(typeof provider.connect).toBe('function');
60
- expect(typeof provider.disconnect).toBe('function');
61
- expect(typeof provider.startListening).toBe('function');
62
- expect(typeof provider.stopListening).toBe('function');
63
- expect(typeof provider.onResult).toBe('function');
64
- expect(typeof provider.onError).toBe('function');
65
- expect(typeof provider.onStatusChange).toBe('function');
66
- });
67
- });
68
-
69
35
  describe('BrowserVoiceProvider', () => {
70
- it('should create instance with default config', () => {
71
- const provider = new BrowserVoiceProvider();
72
- expect(provider).toBeInstanceOf(BrowserVoiceProvider);
73
- expect(provider.type).toBe('browser');
74
- });
75
-
76
- it('should have correct methods', () => {
77
- const provider = new BrowserVoiceProvider();
78
- expect(typeof provider.connect).toBe('function');
79
- expect(typeof provider.disconnect).toBe('function');
80
- expect(typeof provider.startListening).toBe('function');
81
- expect(typeof provider.stopListening).toBe('function');
82
- expect(typeof provider.onResult).toBe('function');
83
- expect(typeof provider.onError).toBe('function');
84
- expect(typeof provider.onStatusChange).toBe('function');
85
- });
86
-
87
36
  it('should check browser support', () => {
88
37
  // Test supported
89
38
  mockBrowserSupport(true);
@@ -1,38 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
-
3
- describe("prototype pollution guard", () => {
4
- it("strips __proto__, constructor, and prototype from parsed config", () => {
5
- // Simulates the destructuring pattern used in install.ts
6
- const malicious = JSON.parse(
7
- '{"config":{"apiUrl":"http://localhost"},"__proto__":{"polluted":true},"constructor":{"polluted":true},"prototype":{"polluted":true}}'
8
- );
9
-
10
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
11
- const { __proto__: _a, constructor: _b, prototype: _c, ...safeConfig } = malicious;
12
-
13
- const target: Record<string, unknown> = {};
14
- Object.assign(target, safeConfig);
15
-
16
- // The dangerous keys should not be on the target
17
- expect(Object.keys(target)).toEqual(["config"]);
18
- expect((target as any).__proto__).toBe(Object.prototype); // normal prototype, not polluted
19
- expect((target as any).constructor).toBe(Object); // normal constructor
20
-
21
- // Global prototype should not be polluted
22
- expect(({} as any).polluted).toBeUndefined();
23
- });
24
-
25
- it("preserves safe config properties", () => {
26
- const parsed = JSON.parse(
27
- '{"config":{"apiUrl":"http://localhost"},"clientToken":"tok_123","flowId":"flow-1"}'
28
- );
29
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
30
- const { __proto__: _a, constructor: _b, prototype: _c, ...safeConfig } = parsed;
31
-
32
- expect(safeConfig).toEqual({
33
- config: { apiUrl: "http://localhost" },
34
- clientToken: "tok_123",
35
- flowId: "flow-1",
36
- });
37
- });
38
- });