@runtypelabs/persona 3.15.0 → 3.15.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/dist/widget.css CHANGED
@@ -1778,10 +1778,6 @@
1778
1778
  margin-top: 0.5rem;
1779
1779
  padding: 0.25rem 0.5rem;
1780
1780
  border-top: none;
1781
- border-radius: var(--persona-radius-md, 0.75rem);
1782
- background-color: var(--persona-surface, #ffffff);
1783
- border: 1px solid var(--persona-divider, #f1f5f9);
1784
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
1785
1781
  }
1786
1782
 
1787
1783
  /* Pill alignment in always-visible mode (block flow: use margin to position) */
@@ -1826,10 +1822,6 @@
1826
1822
  padding: 0.25rem;
1827
1823
  border-top: none;
1828
1824
  width: fit-content;
1829
- background-color: var(--persona-surface, #ffffff);
1830
- border: 1px solid var(--persona-divider, #f1f5f9);
1831
- border-radius: var(--persona-radius-md, 0.75rem);
1832
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
1833
1825
  }
1834
1826
 
1835
1827
  /* Pill layout - position based on alignment */
@@ -1932,6 +1924,17 @@
1932
1924
  opacity: 0.9;
1933
1925
  }
1934
1926
 
1927
+ /* Vote pop animation */
1928
+ @keyframes persona-vote-pop {
1929
+ 0% { transform: scale(1); }
1930
+ 40% { transform: scale(1.25); }
1931
+ 100% { transform: scale(1); }
1932
+ }
1933
+
1934
+ .persona-message-action-btn.persona-message-action-pop {
1935
+ animation: persona-vote-pop 0.3s ease;
1936
+ }
1937
+
1935
1938
  /* Success state (after copy) */
1936
1939
  .persona-message-action-btn.persona-message-action-success {
1937
1940
  background-color: #10b981;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.15.0",
3
+ "version": "3.15.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",
@@ -2130,6 +2130,59 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
2130
2130
  expect(lastFinal.message.content).toBe('Hello beautiful world!');
2131
2131
  });
2132
2132
 
2133
+ it('repairs a delayed step_delta that arrives after the gap-timeout flush', async () => {
2134
+ vi.useFakeTimers();
2135
+ const events: AgentWidgetEvent[] = [];
2136
+
2137
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2138
+
2139
+ global.fetch = vi.fn().mockImplementation(async () => {
2140
+ const encoder = new TextEncoder();
2141
+ const stream = new ReadableStream({
2142
+ start(controller) {
2143
+ const e = (data: any) => {
2144
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2145
+ };
2146
+
2147
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2148
+ e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2149
+ e({ type: 'text_start', partId: 'text_0', messageId: 'msg_1', seq: 1 });
2150
+ e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', messageId: 'msg_1', seq: 2 });
2151
+ // seq=3 is delayed long enough for the reorder buffer to flush seq=4 and seq=5.
2152
+ e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', messageId: 'msg_1', seq: 4 });
2153
+ e({ type: 'text_end', partId: 'text_0', messageId: 'msg_1', seq: 5 });
2154
+
2155
+ setTimeout(() => {
2156
+ e({ type: 'step_delta', id: 's1', text: 'b', partId: 'text_0', messageId: 'msg_1', seq: 3 });
2157
+ }, 60);
2158
+
2159
+ setTimeout(() => {
2160
+ e({ type: 'flow_complete', success: true });
2161
+ controller.close();
2162
+ }, 70);
2163
+ },
2164
+ });
2165
+ return { ok: true, body: stream };
2166
+ });
2167
+
2168
+ const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
2169
+ await vi.advanceTimersByTimeAsync(80);
2170
+ await dispatchPromise;
2171
+ vi.useRealTimers();
2172
+
2173
+ const messageEvents = events.filter(
2174
+ (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2175
+ );
2176
+ const assistantMessages = messageEvents
2177
+ .filter((e) => e.message.role === 'assistant' && !e.message.variant)
2178
+ .map((e) => e.message);
2179
+ expect(assistantMessages.length).toBeGreaterThan(0);
2180
+
2181
+ const repairedMessage = assistantMessages[assistantMessages.length - 1];
2182
+ expect(repairedMessage.content).toBe('abc');
2183
+ expect(repairedMessage.partId).toBe('text_0');
2184
+ });
2185
+
2133
2186
  it('should reorder reason_delta chunks by sequenceIndex', async () => {
2134
2187
  const events: AgentWidgetEvent[] = [];
2135
2188
 
@@ -2176,6 +2229,62 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
2176
2229
  expect(fullReasoning).toBe('I think about this.');
2177
2230
  });
2178
2231
 
2232
+ it('repairs a delayed reason_delta that arrives after the gap-timeout flush', async () => {
2233
+ vi.useFakeTimers();
2234
+ const events: AgentWidgetEvent[] = [];
2235
+
2236
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2237
+
2238
+ global.fetch = vi.fn().mockImplementation(async () => {
2239
+ const encoder = new TextEncoder();
2240
+ const stream = new ReadableStream({
2241
+ start(controller) {
2242
+ const e = (data: any) => {
2243
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2244
+ };
2245
+
2246
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2247
+ e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2248
+ e({ type: 'reason_start', reasoningId: 'r1', hidden: false, done: false, sequenceIndex: 1 });
2249
+ e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'a', hidden: false, done: false, sequenceIndex: 2 });
2250
+ // sequenceIndex=3 is delayed long enough for the gap-timeout to flush sequenceIndex=4
2251
+ e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'c', hidden: false, done: false, sequenceIndex: 4 });
2252
+
2253
+ setTimeout(() => {
2254
+ // Late arrival after gap-timeout flush
2255
+ e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'b', hidden: false, done: false, sequenceIndex: 3 });
2256
+ }, 60);
2257
+
2258
+ setTimeout(() => {
2259
+ e({ type: 'reason_complete', reasoningId: 'r1', hidden: false, done: true, sequenceIndex: 5 });
2260
+ e({ type: 'step_delta', id: 's1', text: 'Result', partId: 'text_0', sequenceIndex: 6 });
2261
+ e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2262
+ e({ type: 'flow_complete', success: true });
2263
+ controller.close();
2264
+ }, 70);
2265
+ },
2266
+ });
2267
+ return { ok: true, body: stream };
2268
+ });
2269
+
2270
+ const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
2271
+ await vi.advanceTimersByTimeAsync(80);
2272
+ await dispatchPromise;
2273
+ vi.useRealTimers();
2274
+
2275
+ const messageEvents = events.filter(
2276
+ (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2277
+ );
2278
+ const reasoningMsgs = messageEvents.filter(
2279
+ (e) => e.message.reasoning && e.message.reasoning.chunks.length > 0
2280
+ );
2281
+ expect(reasoningMsgs.length).toBeGreaterThan(0);
2282
+
2283
+ const lastReasoning = reasoningMsgs[reasoningMsgs.length - 1];
2284
+ const fullReasoning = lastReasoning.message.reasoning!.chunks.join('');
2285
+ expect(fullReasoning).toBe('abc');
2286
+ });
2287
+
2179
2288
  it('should handle step_delta without seq gracefully (no reordering)', async () => {
2180
2289
  const events: AgentWidgetEvent[] = [];
2181
2290
 
@@ -2213,5 +2322,257 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
2213
2322
  const lastFinal = finalMessages[finalMessages.length - 1];
2214
2323
  expect(lastFinal.message.content).toBe('Hello world!');
2215
2324
  });
2325
+
2326
+ it('should handle leading-gap arrival (first event is not seq=1)', async () => {
2327
+ const events: AgentWidgetEvent[] = [];
2328
+
2329
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2330
+
2331
+ global.fetch = vi.fn().mockImplementation(async () => {
2332
+ const encoder = new TextEncoder();
2333
+ const stream = new ReadableStream({
2334
+ start(controller) {
2335
+ const e = (data: any) => {
2336
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2337
+ };
2338
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2339
+ e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2340
+ // seq=3 arrives first (leading gap — seq 1 and 2 arrive later)
2341
+ e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', seq: 3 });
2342
+ e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', seq: 1 });
2343
+ e({ type: 'step_delta', id: 's1', text: 'b', partId: 'text_0', seq: 2 });
2344
+ e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2345
+ e({ type: 'flow_complete', success: true });
2346
+ controller.close();
2347
+ },
2348
+ });
2349
+ return { ok: true, body: stream };
2350
+ });
2351
+
2352
+ await client.dispatch({ messages: [] }, (event) => events.push(event));
2353
+
2354
+ const messageEvents = events.filter(
2355
+ (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2356
+ );
2357
+ const finalMessages = messageEvents.filter((e) => !e.message.streaming);
2358
+ expect(finalMessages.length).toBeGreaterThan(0);
2359
+
2360
+ const lastFinal = finalMessages[finalMessages.length - 1];
2361
+ // Must be in seq order, not arrival order
2362
+ expect(lastFinal.message.content).toBe('abc');
2363
+ });
2364
+
2365
+ it('should handle mixed seq + sequenceIndex in one stream', async () => {
2366
+ const events: AgentWidgetEvent[] = [];
2367
+
2368
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2369
+
2370
+ global.fetch = vi.fn().mockImplementation(async () => {
2371
+ const encoder = new TextEncoder();
2372
+ const stream = new ReadableStream({
2373
+ start(controller) {
2374
+ const e = (data: any) => {
2375
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2376
+ };
2377
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2378
+ e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2379
+ // reason_delta uses sequenceIndex, step_delta uses seq — same counter
2380
+ e({ type: 'reason_start', reasoningId: 'r1', hidden: false, done: false });
2381
+ e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'thinking', hidden: false, done: false, sequenceIndex: 1 });
2382
+ e({ type: 'reason_complete', reasoningId: 'r1', hidden: false, done: true });
2383
+ // step_delta seq=2 continues from the same counter
2384
+ e({ type: 'step_delta', id: 's1', text: 'Result', partId: 'text_0', seq: 2 });
2385
+ e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2386
+ e({ type: 'flow_complete', success: true });
2387
+ controller.close();
2388
+ },
2389
+ });
2390
+ return { ok: true, body: stream };
2391
+ });
2392
+
2393
+ await client.dispatch({ messages: [] }, (event) => events.push(event));
2394
+
2395
+ const messageEvents = events.filter(
2396
+ (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2397
+ );
2398
+ // Should have both reasoning and text messages, properly ordered
2399
+ const reasoningMsgs = messageEvents.filter(e => e.message.reasoning?.chunks?.length);
2400
+ expect(reasoningMsgs.length).toBeGreaterThan(0);
2401
+ expect(reasoningMsgs[reasoningMsgs.length - 1].message.reasoning!.chunks.join('')).toBe('thinking');
2402
+
2403
+ const textMsgs = messageEvents.filter(e => e.message.role === 'assistant' && !e.message.variant && e.message.content);
2404
+ expect(textMsgs.length).toBeGreaterThan(0);
2405
+ expect(textMsgs[textMsgs.length - 1].message.content).toContain('Result');
2406
+ });
2407
+
2408
+ it('should handle cross-event buffering around tool events', async () => {
2409
+ const events: AgentWidgetEvent[] = [];
2410
+
2411
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2412
+
2413
+ global.fetch = vi.fn().mockImplementation(async () => {
2414
+ const encoder = new TextEncoder();
2415
+ const stream = new ReadableStream({
2416
+ start(controller) {
2417
+ const e = (data: any) => {
2418
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2419
+ };
2420
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2421
+ e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2422
+ // text_start and step_delta with seq, then tool events (no seq), then more text
2423
+ e({ type: 'text_start', partId: 'text_0', messageId: 'msg_1', seq: 1 });
2424
+ e({ type: 'step_delta', id: 's1', text: 'Before tool ', partId: 'text_0', seq: 2 });
2425
+ e({ type: 'text_end', partId: 'text_0', messageId: 'msg_1', seq: 3 });
2426
+ // Tool events don't carry top-level seq in non-agent flows
2427
+ e({ type: 'tool_start', toolCallId: 'tc1', name: 'fetch', parameters: {} });
2428
+ e({ type: 'tool_complete', toolCallId: 'tc1', name: 'fetch', result: { data: 'ok' }, executionTime: 100 });
2429
+ e({ type: 'text_start', partId: 'text_1', messageId: 'msg_1', seq: 4 });
2430
+ e({ type: 'step_delta', id: 's1', text: 'after tool', partId: 'text_1', seq: 5 });
2431
+ e({ type: 'text_end', partId: 'text_1', messageId: 'msg_1', seq: 6 });
2432
+ e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2433
+ e({ type: 'flow_complete', success: true });
2434
+ controller.close();
2435
+ },
2436
+ });
2437
+ return { ok: true, body: stream };
2438
+ });
2439
+
2440
+ await client.dispatch({ messages: [] }, (event) => events.push(event));
2441
+
2442
+ const messageEvents = events.filter(
2443
+ (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2444
+ );
2445
+ // Should have text content from both segments
2446
+ const allContent = messageEvents
2447
+ .filter(e => e.message.role === 'assistant' && !e.message.variant)
2448
+ .map(e => e.message.content);
2449
+ const combinedContent = allContent.join('');
2450
+ expect(combinedContent).toContain('Before tool ');
2451
+ expect(combinedContent).toContain('after tool');
2452
+ });
2453
+
2454
+ it('delivers sequenced events still buffered when the stream closes', async () => {
2455
+ // Regression: if the SSE stream ends while the reorder buffer is still
2456
+ // waiting for a missing seq number, previously those events were silently
2457
+ // dropped (destroy() cancelled the gap timer without flushing). The fix
2458
+ // is an end-of-stream flush + drain; this test guards against regression.
2459
+ const events: AgentWidgetEvent[] = [];
2460
+
2461
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2462
+
2463
+ global.fetch = vi.fn().mockImplementation(async () => {
2464
+ const encoder = new TextEncoder();
2465
+ const stream = new ReadableStream({
2466
+ start(controller) {
2467
+ const e = (data: any) => {
2468
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2469
+ };
2470
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2471
+ e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2472
+ // Only a seq=3 event arrives — seq=1 and seq=2 are never delivered.
2473
+ // Without the end-of-stream flush, this event would be stranded in
2474
+ // the reorder buffer and never emitted.
2475
+ e({ type: 'step_delta', id: 's1', text: 'tail', partId: 'text_0', seq: 3 });
2476
+ // Stream closes immediately, well inside the 50ms gap timer window.
2477
+ controller.close();
2478
+ },
2479
+ });
2480
+ return { ok: true, body: stream };
2481
+ });
2482
+
2483
+ await client.dispatch({ messages: [] }, (event) => events.push(event));
2484
+
2485
+ const messageEvents = events.filter(
2486
+ (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2487
+ );
2488
+ const assistantContent = messageEvents
2489
+ .filter((e) => e.message.role === 'assistant' && !e.message.variant)
2490
+ .map((e) => e.message.content)
2491
+ .join('');
2492
+
2493
+ expect(assistantContent).toContain('tail');
2494
+ });
2495
+
2496
+ it('drains timer-flushed sequenced events before the next SSE chunk arrives', async () => {
2497
+ vi.useFakeTimers();
2498
+ const events: AgentWidgetEvent[] = [];
2499
+
2500
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2501
+
2502
+ global.fetch = vi.fn().mockImplementation(async () => {
2503
+ const encoder = new TextEncoder();
2504
+ const stream = new ReadableStream({
2505
+ start(controller) {
2506
+ const e = (data: any) => {
2507
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2508
+ };
2509
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2510
+ e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2511
+ e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', seq: 1 });
2512
+ // seq=3 buffers while seq=2 is missing.
2513
+ e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', seq: 3 });
2514
+
2515
+ // Keep the stream open without delivering another SSE event until after
2516
+ // the gap timeout has fired. The buffered seq=3 event should still render.
2517
+ setTimeout(() => {
2518
+ e({ type: 'flow_complete', success: true });
2519
+ controller.close();
2520
+ }, 120);
2521
+ },
2522
+ });
2523
+ return { ok: true, body: stream };
2524
+ });
2525
+
2526
+ const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
2527
+
2528
+ await vi.advanceTimersByTimeAsync(60);
2529
+
2530
+ const messageEventsDuringPause = events.filter(
2531
+ (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2532
+ );
2533
+ const assistantContentDuringPause = messageEventsDuringPause
2534
+ .filter((e) => e.message.role === 'assistant' && !e.message.variant)
2535
+ .map((e) => e.message.content)
2536
+ .join('');
2537
+
2538
+ expect(assistantContentDuringPause).toContain('ac');
2539
+
2540
+ await vi.advanceTimersByTimeAsync(70);
2541
+ await dispatchPromise;
2542
+ vi.useRealTimers();
2543
+ });
2544
+
2545
+ it('delivers a buffered error event when the stream closes mid-gap', async () => {
2546
+ // Regression: an error event with seq > 1 arriving right before the
2547
+ // stream closes was being swallowed by the reorder buffer, leaving the
2548
+ // widget stuck in a streaming state with no error surfaced.
2549
+ const events: AgentWidgetEvent[] = [];
2550
+
2551
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2552
+
2553
+ global.fetch = vi.fn().mockImplementation(async () => {
2554
+ const encoder = new TextEncoder();
2555
+ const stream = new ReadableStream({
2556
+ start(controller) {
2557
+ const e = (data: any) => {
2558
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2559
+ };
2560
+ e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2561
+ // Only sequenced event — but seq > 1 so it would be buffered.
2562
+ e({ type: 'error', error: 'boom', seq: 2 });
2563
+ controller.close();
2564
+ },
2565
+ });
2566
+ return { ok: true, body: stream };
2567
+ });
2568
+
2569
+ await client.dispatch({ messages: [] }, (event) => events.push(event));
2570
+
2571
+ const errorEvents = events.filter((e) => e.type === 'error');
2572
+ expect(errorEvents.length).toBe(1);
2573
+ if (errorEvents[0].type === 'error') {
2574
+ expect(errorEvents[0].error.message).toBe('boom');
2575
+ }
2576
+ });
2216
2577
  });
2217
2578