@runtypelabs/persona 3.35.0 → 3.37.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.
@@ -4891,6 +4891,16 @@ type AgentWidgetConfig = {
4891
4891
  * ```
4892
4892
  */
4893
4893
  parseSSEEvent?: AgentWidgetSSEEventParser;
4894
+ /**
4895
+ * Wire vocabulary requested from the dispatch endpoint.
4896
+ * - `'legacy'` (default): the current `agent_*` / `flow_*` SSE events.
4897
+ * - `'unified'`: opt into the API's neutral unified event vocabulary by sending
4898
+ * `?events=unified`; incoming frames are transparently bridged back to the same
4899
+ * handlers, so rendering is unchanged. Requires an upstream that honors the
4900
+ * param — the widget detects the actual wire mode from the first stream frame
4901
+ * and falls back to legacy automatically if the param was ignored.
4902
+ */
4903
+ events?: "legacy" | "unified";
4894
4904
  /**
4895
4905
  * Called for every parsed SSE frame (after JSON parse), before native handling.
4896
4906
  * Use for lightweight side effects (e.g. telemetry). Does not replace native
@@ -4891,6 +4891,16 @@ type AgentWidgetConfig = {
4891
4891
  * ```
4892
4892
  */
4893
4893
  parseSSEEvent?: AgentWidgetSSEEventParser;
4894
+ /**
4895
+ * Wire vocabulary requested from the dispatch endpoint.
4896
+ * - `'legacy'` (default): the current `agent_*` / `flow_*` SSE events.
4897
+ * - `'unified'`: opt into the API's neutral unified event vocabulary by sending
4898
+ * `?events=unified`; incoming frames are transparently bridged back to the same
4899
+ * handlers, so rendering is unchanged. Requires an upstream that honors the
4900
+ * param — the widget detects the actual wire mode from the first stream frame
4901
+ * and falls back to legacy automatically if the param was ignored.
4902
+ */
4903
+ events?: "legacy" | "unified";
4894
4904
  /**
4895
4905
  * Called for every parsed SSE frame (after JSON parse), before native handling.
4896
4906
  * Use for lightweight side effects (e.g. telemetry). Does not replace native
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.35.0",
3
+ "version": "3.37.0",
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",
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { AgentWidgetClient, preferFinalStructuredContent } from './client';
3
3
  import { AgentWidgetEvent, AgentWidgetMessage } from './types';
4
4
  import { createJsonStreamParser } from './utils/formatting';
5
+ import { VERSION } from './version';
5
6
 
6
7
  describe('AgentWidgetClient - Empty Message Filtering', () => {
7
8
  let client: AgentWidgetClient;
@@ -4176,3 +4177,144 @@ describe('AgentWidgetClient - Feedback request builder', () => {
4176
4177
  expect(feedbackBodies[0].rating).toBe(5);
4177
4178
  });
4178
4179
  });
4180
+
4181
+ describe('AgentWidgetClient - version header', () => {
4182
+ function captureHeaders() {
4183
+ const headers: Array<Record<string, string>> = [];
4184
+ global.fetch = vi.fn().mockImplementation(async (_url: string, options: { headers: Record<string, string> }) => {
4185
+ headers.push(options.headers);
4186
+ const encoder = new TextEncoder();
4187
+ const stream = new ReadableStream({
4188
+ start(c) {
4189
+ c.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
4190
+ c.close();
4191
+ },
4192
+ });
4193
+ return { ok: true, body: stream };
4194
+ });
4195
+ return headers;
4196
+ }
4197
+
4198
+ const msg = () => ({
4199
+ messages: [{ id: 'u1', role: 'user' as const, content: 'hi', createdAt: '2025-01-01T00:00:00.000Z' }],
4200
+ });
4201
+
4202
+ it('broadcasts X-Persona-Version on the dispatch request', async () => {
4203
+ const headers = captureHeaders();
4204
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
4205
+
4206
+ await client.dispatch(msg(), () => undefined);
4207
+
4208
+ expect(headers[0]['X-Persona-Version']).toBe(VERSION);
4209
+ });
4210
+
4211
+ it('lets an explicit config header override the version', async () => {
4212
+ const headers = captureHeaders();
4213
+ const client = new AgentWidgetClient({
4214
+ apiUrl: 'http://localhost:8000',
4215
+ headers: { 'X-Persona-Version': 'override' },
4216
+ });
4217
+
4218
+ await client.dispatch(msg(), () => undefined);
4219
+
4220
+ expect(headers[0]['X-Persona-Version']).toBe('override');
4221
+ });
4222
+ });
4223
+
4224
+ describe('AgentWidgetClient - Unified event vocabulary (events: "unified")', () => {
4225
+ const unifiedTextStream = (execId: string) => [
4226
+ sseEvent('execution_start', { kind: 'agent', executionId: execId, agentId: 'virtual', agentName: 'Test', maxTurns: 1, startedAt: new Date().toISOString(), seq: 1 }),
4227
+ sseEvent('turn_start', { executionId: execId, id: 'turn_1', iteration: 1, role: 'assistant', seq: 2 }),
4228
+ sseEvent('text_start', { executionId: execId, id: 'text_1', role: 'assistant', seq: 3 }),
4229
+ sseEvent('text_delta', { executionId: execId, id: 'text_1', delta: 'Hello', seq: 4 }),
4230
+ sseEvent('text_delta', { executionId: execId, id: 'text_1', delta: ' World', seq: 5 }),
4231
+ sseEvent('text_complete', { executionId: execId, id: 'text_1', seq: 6 }),
4232
+ sseEvent('turn_complete', { executionId: execId, id: 'turn_1', iteration: 1, role: 'assistant', completedAt: new Date().toISOString(), seq: 7 }),
4233
+ sseEvent('execution_complete', { kind: 'agent', executionId: execId, success: true, completedAt: new Date().toISOString(), seq: 8 }),
4234
+ ];
4235
+
4236
+ it('appends ?events=unified to the dispatch URL when opted in', async () => {
4237
+ let capturedUrl = '';
4238
+ global.fetch = vi.fn().mockImplementation(async (url: string) => {
4239
+ capturedUrl = url;
4240
+ const encoder = new TextEncoder();
4241
+ const stream = new ReadableStream({
4242
+ start(c) {
4243
+ for (const e of unifiedTextStream('exec_u')) c.enqueue(encoder.encode(e));
4244
+ c.close();
4245
+ },
4246
+ });
4247
+ return { ok: true, body: stream };
4248
+ });
4249
+
4250
+ const client = new AgentWidgetClient({
4251
+ apiUrl: 'http://localhost:8000',
4252
+ events: 'unified',
4253
+ agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
4254
+ });
4255
+ await client.dispatch(
4256
+ { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4257
+ () => {}
4258
+ );
4259
+ expect(capturedUrl).toContain('events=unified');
4260
+ });
4261
+
4262
+ it('renders a unified stream by bridging it back to the legacy handlers', async () => {
4263
+ const events: AgentWidgetEvent[] = [];
4264
+ const execId = 'exec_u1';
4265
+ global.fetch = createAgentStreamFetch(unifiedTextStream(execId));
4266
+
4267
+ const client = new AgentWidgetClient({
4268
+ apiUrl: 'http://localhost:8000',
4269
+ events: 'unified',
4270
+ agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
4271
+ });
4272
+ await client.dispatch(
4273
+ { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4274
+ (e) => events.push(e)
4275
+ );
4276
+
4277
+ const messageEvents = events.filter((e) => e.type === 'message');
4278
+ expect(messageEvents.length).toBeGreaterThan(0);
4279
+ const last = messageEvents[messageEvents.length - 1];
4280
+ expect(last.type).toBe('message');
4281
+ if (last.type === 'message') {
4282
+ expect(last.message.content).toBe('Hello World');
4283
+ expect(last.message.streaming).toBe(false);
4284
+ expect(last.message.role).toBe('assistant');
4285
+ expect(last.message.agentMetadata?.executionId).toBe(execId);
4286
+ }
4287
+ });
4288
+
4289
+ it('auto-falls-back to legacy when "unified" was requested but the API streamed legacy frames', async () => {
4290
+ const events: AgentWidgetEvent[] = [];
4291
+ const execId = 'exec_legacy_fallback';
4292
+ // Same content, LEGACY vocabulary — the API ignored ?events=unified.
4293
+ global.fetch = createAgentStreamFetch([
4294
+ sseEvent('agent_start', { executionId: execId, agentId: 'virtual', agentName: 'Test', maxTurns: 1, startedAt: new Date().toISOString(), seq: 1 }),
4295
+ sseEvent('agent_turn_start', { executionId: execId, iteration: 1, turnId: 'turn_1', role: 'assistant', seq: 2 }),
4296
+ sseEvent('agent_turn_delta', { executionId: execId, iteration: 1, delta: 'Hello', contentType: 'text', turnId: 'turn_1', seq: 3 }),
4297
+ sseEvent('agent_turn_delta', { executionId: execId, iteration: 1, delta: ' World', contentType: 'text', turnId: 'turn_1', seq: 4 }),
4298
+ sseEvent('agent_turn_complete', { executionId: execId, iteration: 1, turnId: 'turn_1', completedAt: new Date().toISOString(), seq: 5 }),
4299
+ sseEvent('agent_complete', { executionId: execId, success: true, stopReason: 'max_iterations', completedAt: new Date().toISOString(), seq: 6 }),
4300
+ ]);
4301
+
4302
+ const client = new AgentWidgetClient({
4303
+ apiUrl: 'http://localhost:8000',
4304
+ events: 'unified',
4305
+ agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
4306
+ });
4307
+ await client.dispatch(
4308
+ { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4309
+ (e) => events.push(e)
4310
+ );
4311
+
4312
+ const messageEvents = events.filter((e) => e.type === 'message');
4313
+ const last = messageEvents[messageEvents.length - 1];
4314
+ expect(last.type).toBe('message');
4315
+ if (last.type === 'message') {
4316
+ expect(last.message.content).toBe('Hello World');
4317
+ expect(last.message.streaming).toBe(false);
4318
+ }
4319
+ });
4320
+ });
package/src/client.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  createXmlParser
34
34
  } from "./utils/formatting";
35
35
  import { SequenceReorderBuffer } from "./utils/sequence-buffer";
36
+ import { UnifiedToLegacyBridge, isUnifiedLifecycleStart } from "./utils/unified-event-bridge";
37
+ import { VERSION } from "./version";
36
38
  // artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
37
39
  // artifact events are always processed here regardless of config.
38
40
 
@@ -188,6 +190,7 @@ export class AgentWidgetClient {
188
190
  this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
189
191
  this.headers = {
190
192
  "Content-Type": "application/json",
193
+ "X-Persona-Version": VERSION,
191
194
  ...config.headers
192
195
  };
193
196
  this.debug = Boolean(config.debug);
@@ -290,6 +293,17 @@ export class AgentWidgetClient {
290
293
  return !!this.config.agent;
291
294
  }
292
295
 
296
+ /**
297
+ * Append `?events=unified` when the widget opted into the unified vocabulary,
298
+ * preserving any existing query string. No-op for the default legacy mode.
299
+ * The server only switches vocabularies when it sees the param; the widget
300
+ * still auto-detects the actual wire mode from the first stream frame.
301
+ */
302
+ private withEventsParam(url: string): string {
303
+ if (this.config.events !== "unified") return url;
304
+ return url + (url.includes("?") ? "&" : "?") + "events=unified";
305
+ }
306
+
293
307
  /**
294
308
  * Get the appropriate API URL based on mode
295
309
  */
@@ -355,6 +369,7 @@ export class AgentWidgetClient {
355
369
  method: 'POST',
356
370
  headers: {
357
371
  'Content-Type': 'application/json',
372
+ 'X-Persona-Version': VERSION,
358
373
  },
359
374
  body: JSON.stringify(requestBody),
360
375
  });
@@ -495,6 +510,7 @@ export class AgentWidgetClient {
495
510
  method: 'POST',
496
511
  headers: {
497
512
  'Content-Type': 'application/json',
513
+ 'X-Persona-Version': VERSION,
498
514
  },
499
515
  body: JSON.stringify(requestBody),
500
516
  });
@@ -678,10 +694,11 @@ export class AgentWidgetClient {
678
694
  console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
679
695
  }
680
696
 
681
- response = await fetch(this.getClientApiUrl('chat'), {
697
+ response = await fetch(this.withEventsParam(this.getClientApiUrl('chat')), {
682
698
  method: 'POST',
683
699
  headers: {
684
700
  'Content-Type': 'application/json',
701
+ 'X-Persona-Version': VERSION,
685
702
  },
686
703
  body: JSON.stringify(chatRequest),
687
704
  signal: controller.signal,
@@ -797,7 +814,7 @@ export class AgentWidgetClient {
797
814
  if (this.customFetch) {
798
815
  try {
799
816
  response = await this.customFetch(
800
- this.apiUrl,
817
+ this.withEventsParam(this.apiUrl),
801
818
  {
802
819
  method: "POST",
803
820
  headers,
@@ -812,7 +829,7 @@ export class AgentWidgetClient {
812
829
  throw err;
813
830
  }
814
831
  } else {
815
- response = await fetch(this.apiUrl, {
832
+ response = await fetch(this.withEventsParam(this.apiUrl), {
816
833
  method: "POST",
817
834
  headers,
818
835
  body: JSON.stringify(payload),
@@ -873,7 +890,7 @@ export class AgentWidgetClient {
873
890
  if (this.customFetch) {
874
891
  try {
875
892
  response = await this.customFetch(
876
- this.apiUrl,
893
+ this.withEventsParam(this.apiUrl),
877
894
  {
878
895
  method: "POST",
879
896
  headers,
@@ -888,7 +905,7 @@ export class AgentWidgetClient {
888
905
  throw err;
889
906
  }
890
907
  } else {
891
- response = await fetch(this.apiUrl, {
908
+ response = await fetch(this.withEventsParam(this.apiUrl), {
892
909
  method: "POST",
893
910
  headers,
894
911
  body: JSON.stringify(payload),
@@ -991,9 +1008,11 @@ export class AgentWidgetClient {
991
1008
  options?: { streamResponse?: boolean; signal?: AbortSignal }
992
1009
  ): Promise<Response> {
993
1010
  const isClientToken = this.isClientTokenMode();
994
- const url = isClientToken
995
- ? this.getClientApiUrl('resume')
996
- : `${this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE}/resume`;
1011
+ const url = this.withEventsParam(
1012
+ isClientToken
1013
+ ? this.getClientApiUrl('resume')
1014
+ : `${this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE}/resume`
1015
+ );
997
1016
 
998
1017
  // The client-token resume route authenticates the session, not a Bearer
999
1018
  // key. A WebMCP approval can sit awaiting user input for a long time, so by
@@ -1828,6 +1847,17 @@ export class AgentWidgetClient {
1828
1847
  seqReadyQueue.push({ payloadType, payload });
1829
1848
  scheduleReadyQueueDrain();
1830
1849
  });
1850
+ // Unified-event consumer (opt-in; see utils/unified-event-bridge.ts). When the
1851
+ // API streams the neutral unified vocabulary, each frame is transduced back to
1852
+ // the legacy events the dispatch chain below already renders. Seed the mode from
1853
+ // the requested vocabulary so /resume (whose stream continues mid-run with no
1854
+ // `execution_start`) bridges correctly; a leading lifecycle frame is then
1855
+ // authoritative and can flip it (e.g. an upstream that ignored `?events=unified`
1856
+ // streams `agent_start`). The unified stream is single-connection and in order,
1857
+ // so bridged events bypass the reorder buffer.
1858
+ const unifiedBridge = new UnifiedToLegacyBridge();
1859
+ let unifiedMode = this.config.events === "unified";
1860
+ let wireModeResolved = false;
1831
1861
  // Agent execution state tracking
1832
1862
  let agentExecution: AgentExecutionState | null = null;
1833
1863
  // Track assistant messages per agent iteration for 'separate' mode
@@ -3368,6 +3398,31 @@ export class AgentWidgetClient {
3368
3398
  if (handled) continue; // Skip default handling if custom handler processed it
3369
3399
  }
3370
3400
 
3401
+ // Unified vocabulary (opt-in): resolve the wire mode from the first
3402
+ // lifecycle frame (the flag only requests the param; the frame is
3403
+ // authoritative), then transduce unified → legacy, bypassing the reorder
3404
+ // buffer (the unified stream is single-connection and already in order).
3405
+ if (!wireModeResolved) {
3406
+ if (isUnifiedLifecycleStart(payloadType)) {
3407
+ unifiedMode = true;
3408
+ wireModeResolved = true;
3409
+ } else if (
3410
+ payloadType === "agent_start" ||
3411
+ payloadType === "flow_start" ||
3412
+ payloadType === "step_start"
3413
+ ) {
3414
+ unifiedMode = false;
3415
+ wireModeResolved = true;
3416
+ }
3417
+ }
3418
+ if (unifiedMode) {
3419
+ for (const legacyEvent of unifiedBridge.push(payloadType, payload)) {
3420
+ seqReadyQueue.push(legacyEvent);
3421
+ }
3422
+ drainReadyQueue();
3423
+ continue;
3424
+ }
3425
+
3371
3426
  // Push through the sequence reorder buffer
3372
3427
  seqBuffer.push(payloadType, payload);
3373
3428
  drainReadyQueue();
@@ -1190,6 +1190,7 @@ export type RuntypeClientChatRequest = {
1190
1190
  type: "object";
1191
1191
  [key: string]: unknown;
1192
1192
  };
1193
+ untrustedContentHint?: boolean;
1193
1194
  }>;
1194
1195
  clientToolsFingerprint?: string;
1195
1196
  inputs?: Record<string, unknown>;
package/src/install.ts CHANGED
@@ -18,6 +18,9 @@ interface SiteAgentInstallConfig {
18
18
  clientToken?: string;
19
19
  flowId?: string;
20
20
  apiUrl?: string;
21
+ /** Wire vocabulary requested from the dispatch endpoint; 'unified' opts into the
22
+ * API's neutral event schema (transparently bridged back to the same rendering). */
23
+ events?: "legacy" | "unified";
21
24
  // Optional query param key that gates widget installation in preview mode
22
25
  previewQueryParam?: string;
23
26
  // Shadow DOM option (defaults to false for better CSS compatibility)
@@ -377,6 +380,7 @@ declare global {
377
380
  if (config.apiUrl && !widgetConfig.apiUrl) widgetConfig.apiUrl = config.apiUrl;
378
381
  if (config.clientToken && !widgetConfig.clientToken) widgetConfig.clientToken = config.clientToken;
379
382
  if (config.flowId && !widgetConfig.flowId) widgetConfig.flowId = config.flowId;
383
+ if (config.events && !widgetConfig.events) widgetConfig.events = config.events;
380
384
 
381
385
  const hasApiConfig = !!(widgetConfig.apiUrl || widgetConfig.clientToken);
382
386
  return { target, widgetConfig, hasApiConfig };
package/src/types.ts CHANGED
@@ -4056,6 +4056,16 @@ export type AgentWidgetConfig = {
4056
4056
  * ```
4057
4057
  */
4058
4058
  parseSSEEvent?: AgentWidgetSSEEventParser;
4059
+ /**
4060
+ * Wire vocabulary requested from the dispatch endpoint.
4061
+ * - `'legacy'` (default): the current `agent_*` / `flow_*` SSE events.
4062
+ * - `'unified'`: opt into the API's neutral unified event vocabulary by sending
4063
+ * `?events=unified`; incoming frames are transparently bridged back to the same
4064
+ * handlers, so rendering is unchanged. Requires an upstream that honors the
4065
+ * param — the widget detects the actual wire mode from the first stream frame
4066
+ * and falls back to legacy automatically if the param was ignored.
4067
+ */
4068
+ events?: "legacy" | "unified";
4059
4069
  /**
4060
4070
  * Called for every parsed SSE frame (after JSON parse), before native handling.
4061
4071
  * Use for lightweight side effects (e.g. telemetry). Does not replace native