@runtypelabs/persona 3.14.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/src/client.ts CHANGED
@@ -20,13 +20,14 @@ import {
20
20
  ClientFeedbackType,
21
21
  PersonaArtifactKind
22
22
  } from "./types";
23
- import {
24
- extractTextFromJson,
23
+ import {
24
+ extractTextFromJson,
25
25
  createPlainTextParser,
26
26
  createJsonStreamParser,
27
27
  createRegexJsonParser,
28
28
  createXmlParser
29
29
  } from "./utils/formatting";
30
+ import { SequenceReorderBuffer } from "./utils/sequence-buffer";
30
31
  // artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
31
32
  // artifact events are always processed here regardless of config.
32
33
 
@@ -1295,44 +1296,45 @@ export class AgentWidgetClient {
1295
1296
  const streamParsers = new Map<string, AgentWidgetStreamParser>();
1296
1297
  // Track accumulated raw content for structured formats (JSON, XML, etc.)
1297
1298
  const rawContentBuffers = new Map<string, string>();
1298
- // Reorder buffer for out-of-order step_delta chunks (keyed by assistant message id)
1299
- const seqChunkBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1300
- // Reorder buffer for out-of-order reason_delta chunks (keyed by reasoning id)
1301
- const reasonSeqBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1299
+ // Rebuild incremental text by sequence so late arrivals can repair already-emitted
1300
+ // content after the reorder buffer's gap-timeout flush.
1301
+ const orderedChunkBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1302
+ const assistantMessagesByPartId = new Map<string, AgentWidgetMessage>();
1303
+ // Only the most-recently sealed segment is reconciled with step_complete's
1304
+ // final response. Earlier segments rely on their own async parser microtasks
1305
+ // resolving via the closure-captured `assistant` variable.
1306
+ let lastSealedTextSegment: AgentWidgetMessage | null = null;
1302
1307
 
1303
- /**
1304
- * Insert a chunk into a sequence-ordered buffer and return
1305
- * the full accumulated text in correct sequence order.
1306
- */
1307
- function insertSeqChunk(
1308
- bufferMap: Map<string, Array<{ seq: number; text: string }>>,
1309
- key: string,
1310
- seq: number,
1311
- text: string
1312
- ): string {
1313
- let buf = bufferMap.get(key);
1314
- if (!buf) {
1315
- buf = [];
1316
- bufferMap.set(key, buf);
1308
+ const insertOrderedChunk = (key: string, seq: number, text: string): string => {
1309
+ let chunks = orderedChunkBuffers.get(key);
1310
+ if (!chunks) {
1311
+ chunks = [];
1312
+ orderedChunkBuffers.set(key, chunks);
1317
1313
  }
1318
- // Binary-search insert to keep sorted by seq
1319
- let lo = 0, hi = buf.length;
1314
+
1315
+ let lo = 0;
1316
+ let hi = chunks.length;
1320
1317
  while (lo < hi) {
1321
1318
  const mid = (lo + hi) >>> 1;
1322
- if (buf[mid].seq < seq) lo = mid + 1;
1323
- else hi = mid;
1319
+ if (chunks[mid].seq < seq) {
1320
+ lo = mid + 1;
1321
+ } else {
1322
+ hi = mid;
1323
+ }
1324
1324
  }
1325
- buf.splice(lo, 0, { seq, text });
1326
- // Join all chunks in seq order
1327
- let result = "";
1328
- for (let i = 0; i < buf.length; i++) result += buf[i].text;
1329
- return result;
1330
- }
1331
1325
 
1332
- // Only the most-recently sealed segment is reconciled with step_complete's
1333
- // final response. Earlier segments rely on their own async parser microtasks
1334
- // resolving via the closure-captured `assistant` variable.
1335
- let lastSealedTextSegment: AgentWidgetMessage | null = null;
1326
+ if (chunks[lo]?.seq === seq) {
1327
+ chunks[lo] = { seq, text };
1328
+ } else {
1329
+ chunks.splice(lo, 0, { seq, text });
1330
+ }
1331
+
1332
+ let accumulated = "";
1333
+ for (let index = 0; index < chunks.length; index++) {
1334
+ accumulated += chunks[index].text;
1335
+ }
1336
+ return accumulated;
1337
+ };
1336
1338
 
1337
1339
  /**
1338
1340
  * After text_end + didSplitByPartId, merge the authoritative final response into the
@@ -1426,73 +1428,45 @@ export class AgentWidgetClient {
1426
1428
  finalizeCleanup();
1427
1429
  };
1428
1430
 
1431
+ // Sequence reorder buffer: SSE events carrying a `seq` (or `sequenceIndex`)
1432
+ // field are held and re-emitted in sequence order so that transport-level
1433
+ // reordering doesn't produce garbled output.
1434
+ const seqReadyQueue: Array<{ payloadType: string; payload: any }> = [];
1435
+ let isDrainScheduled = false;
1436
+ // Declared here so scheduleReadyQueueDrain can reference it; assigned
1437
+ // after all handler-scoped variables are initialised (before the SSE loop).
1438
+ let drainReadyQueue: () => void;
1439
+ // Two drain paths — both are intentional, do not remove either:
1440
+ // 1. Microtask drain (scheduleReadyQueueDrain): required when the
1441
+ // buffer's emitter fires from the gap-timeout setTimeout callback,
1442
+ // because there is no surrounding synchronous drain site there.
1443
+ // 2. Synchronous drain (drainReadyQueue() after each seqBuffer.push):
1444
+ // skips an extra microtask hop on the hot in-order push path.
1445
+ const scheduleReadyQueueDrain = () => {
1446
+ if (isDrainScheduled) return;
1447
+ isDrainScheduled = true;
1448
+ queueMicrotask(() => {
1449
+ isDrainScheduled = false;
1450
+ drainReadyQueue();
1451
+ });
1452
+ };
1453
+ const seqBuffer = new SequenceReorderBuffer((payloadType: string, payload: any) => {
1454
+ seqReadyQueue.push({ payloadType, payload });
1455
+ scheduleReadyQueueDrain();
1456
+ });
1429
1457
  // Agent execution state tracking
1430
1458
  let agentExecution: AgentExecutionState | null = null;
1431
1459
  // Track assistant messages per agent iteration for 'separate' mode
1432
1460
  const agentIterationMessages = new Map<number, AgentWidgetMessage>();
1433
1461
  const iterationDisplay = this.config.iterationDisplay ?? 'separate';
1434
1462
 
1435
- // eslint-disable-next-line no-constant-condition
1436
- while (true) {
1437
- const { done, value } = await reader.read();
1438
- if (done) break;
1439
-
1440
- buffer += decoder.decode(value, { stream: true });
1441
- const events = buffer.split("\n\n");
1442
- buffer = events.pop() ?? "";
1443
-
1444
- for (const event of events) {
1445
- const lines = event.split("\n");
1446
- let eventType = "message";
1447
- let data = "";
1448
-
1449
- for (const line of lines) {
1450
- if (line.startsWith("event:")) {
1451
- eventType = line.replace("event:", "").trim();
1452
- } else if (line.startsWith("data:")) {
1453
- data += line.replace("data:", "").trim();
1454
- }
1455
- }
1456
-
1457
- if (!data) continue;
1458
- let payload: any;
1459
- try {
1460
- payload = JSON.parse(data);
1461
- } catch (error) {
1462
- onEvent({
1463
- type: "error",
1464
- error:
1465
- error instanceof Error
1466
- ? error
1467
- : new Error("Failed to parse chat stream payload")
1468
- });
1469
- continue;
1470
- }
1471
-
1472
- const payloadType =
1473
- eventType !== "message" ? eventType : payload.type ?? "message";
1474
-
1475
- // Tap: capture raw SSE event for event stream inspector
1476
- this.onSSEEvent?.(payloadType, payload);
1477
-
1478
- // If custom SSE event parser is provided, try it first
1479
- if (this.parseSSEEvent) {
1480
- // Keep assistant message ref in sync
1481
- assistantMessageRef.current = assistantMessage;
1482
- const handled = await this.handleCustomSSEEvent(
1483
- payload,
1484
- onEvent,
1485
- assistantMessageRef,
1486
- emitMessage,
1487
- nextSequence,
1488
- partIdState
1489
- );
1490
- // Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
1491
- if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
1492
- assistantMessage = assistantMessageRef.current;
1493
- }
1494
- if (handled) continue; // Skip default handling if custom handler processed it
1495
- }
1463
+ // Drains reorder-buffered events through the main event handler.
1464
+ // Also invoked after the SSE loop exits so any events buffered at
1465
+ // end-of-stream are processed.
1466
+ drainReadyQueue = () => {
1467
+ for (let i = 0; i < seqReadyQueue.length; i++) {
1468
+ const payloadType = seqReadyQueue[i].payloadType;
1469
+ const payload = seqReadyQueue[i].payload;
1496
1470
 
1497
1471
  if (payloadType === "reason_start") {
1498
1472
  const reasoningId =
@@ -1531,12 +1505,11 @@ export class AgentWidgetClient {
1531
1505
  payload.delta ??
1532
1506
  "";
1533
1507
  if (chunk && payload.hidden !== true) {
1534
- const reasonSeq = typeof payload.sequenceIndex === 'number' ? payload.sequenceIndex : undefined;
1508
+ const reasonSeq = typeof payload.sequenceIndex === "number" ? payload.sequenceIndex : undefined;
1535
1509
  if (reasonSeq !== undefined) {
1536
- // Use reorder buffer so out-of-order chunks are assembled correctly
1537
- const ordered = insertSeqChunk(reasonSeqBuffers, reasoningId, reasonSeq, String(chunk));
1538
- // Rebuild the chunks array from the ordered concatenation.
1539
- // We store a single joined string to avoid duplicated / mis-ordered entries.
1510
+ // Rebuild chunks by seq so late arrivals after a gap-timeout flush
1511
+ // are inserted at the correct position rather than appended.
1512
+ const ordered = insertOrderedChunk(reasoningId, reasonSeq, String(chunk));
1540
1513
  reasoningMessage.reasoning.chunks = [ordered];
1541
1514
  } else {
1542
1515
  reasoningMessage.reasoning.chunks.push(String(chunk));
@@ -1552,7 +1525,7 @@ export class AgentWidgetClient {
1552
1525
  0,
1553
1526
  (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1554
1527
  );
1555
- reasonSeqBuffers.delete(reasoningId);
1528
+
1556
1529
  }
1557
1530
  reasoningMessage.streaming = reasoningMessage.reasoning.status !== "complete";
1558
1531
  emitMessage(reasoningMessage);
@@ -1573,7 +1546,7 @@ export class AgentWidgetClient {
1573
1546
  (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1574
1547
  );
1575
1548
  reasoningMessage.streaming = false;
1576
- reasonSeqBuffers.delete(reasoningId);
1549
+
1577
1550
  emitMessage(reasoningMessage);
1578
1551
  }
1579
1552
  const stepKey = getStepKey(payload);
@@ -1748,26 +1721,30 @@ export class AgentWidgetClient {
1748
1721
  partIdState.current = incomingPartId;
1749
1722
  }
1750
1723
 
1751
- const assistant = ensureAssistantMessage();
1752
- if (incomingPartId !== undefined && !assistant.partId) {
1753
- assistant.partId = incomingPartId;
1724
+ const assistant =
1725
+ incomingPartId !== undefined
1726
+ ? (assistantMessagesByPartId.get(incomingPartId) ?? ensureAssistantMessage())
1727
+ : ensureAssistantMessage();
1728
+ if (incomingPartId !== undefined) {
1729
+ if (!assistant.partId) {
1730
+ assistant.partId = incomingPartId;
1731
+ }
1732
+ assistantMessagesByPartId.set(incomingPartId, assistant);
1754
1733
  }
1755
1734
  // Support various field names: text, delta, content, chunk (Runtype uses 'chunk')
1756
1735
  const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
1757
1736
  if (chunk) {
1758
- // Check if the event carries a sequence number for reordering
1759
- const chunkSeq = typeof payload.seq === 'number' ? payload.seq : undefined;
1760
-
1761
1737
  // Accumulate raw content for structured format parsing.
1762
- // When seq is present, use the reorder buffer so out-of-order
1763
- // chunks are assembled in the correct server-intended order.
1764
- let accumulatedRaw: string;
1765
- if (chunkSeq !== undefined) {
1766
- accumulatedRaw = insertSeqChunk(seqChunkBuffers, assistant.id, chunkSeq, chunk);
1767
- } else {
1768
- const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
1769
- accumulatedRaw = rawBuffer + chunk;
1770
- }
1738
+ // Most out-of-order events are fixed at the dispatch layer, but once the
1739
+ // gap timeout flushes later seqs we can still see genuine late arrivals.
1740
+ // Rebuild chunked content by seq so those events repair prior output
1741
+ // instead of appending in the wrong position.
1742
+ const chunkSeq = typeof payload.seq === "number" ? payload.seq : undefined;
1743
+ const chunkBufferKey = incomingPartId ?? assistant.id;
1744
+ const accumulatedRaw =
1745
+ chunkSeq !== undefined
1746
+ ? insertOrderedChunk(chunkBufferKey, chunkSeq, String(chunk))
1747
+ : (rawContentBuffers.get(assistant.id) ?? "") + chunk;
1771
1748
  // Store raw content for action parsing, but NEVER set assistant.content to raw JSON
1772
1749
  assistant.rawContent = accumulatedRaw;
1773
1750
 
@@ -1790,13 +1767,7 @@ export class AgentWidgetClient {
1790
1767
 
1791
1768
  // If plain text parser, just append the chunk directly
1792
1769
  if (isPlainTextParser) {
1793
- // When seq-ordered, rebuild from the reorder buffer;
1794
- // otherwise fall back to simple append.
1795
- if (chunkSeq !== undefined) {
1796
- assistant.content = accumulatedRaw;
1797
- } else {
1798
- assistant.content += chunk;
1799
- }
1770
+ assistant.content = chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1800
1771
  // Clear any raw buffer/parser since we're in plain text mode
1801
1772
  rawContentBuffers.delete(assistant.id);
1802
1773
  streamParsers.delete(assistant.id);
@@ -1822,27 +1793,25 @@ export class AgentWidgetClient {
1822
1793
  } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1823
1794
  // Not a structured format - show as plain text
1824
1795
  const currentAssistant = assistantMessage;
1825
- if (currentAssistant && currentAssistant.id === assistant.id) {
1826
- if (chunkSeq !== undefined) {
1827
- currentAssistant.content = accumulatedRaw;
1828
- } else {
1829
- currentAssistant.content += chunk;
1830
- }
1831
- rawContentBuffers.delete(currentAssistant.id);
1832
- streamParsers.delete(currentAssistant.id);
1833
- currentAssistant.rawContent = undefined;
1834
- emitMessage(currentAssistant);
1796
+ const targetAssistant =
1797
+ currentAssistant && currentAssistant.id === assistant.id
1798
+ ? currentAssistant
1799
+ : assistant;
1800
+ if (targetAssistant.id === assistant.id) {
1801
+ targetAssistant.content =
1802
+ chunkSeq !== undefined ? accumulatedRaw : targetAssistant.content + chunk;
1803
+ rawContentBuffers.delete(targetAssistant.id);
1804
+ streamParsers.delete(targetAssistant.id);
1805
+ targetAssistant.rawContent = undefined;
1806
+ emitMessage(targetAssistant);
1835
1807
  }
1836
1808
  }
1837
1809
  // Otherwise wait for more chunks (incomplete structured format)
1838
1810
  // Don't emit message if parser hasn't extracted text yet
1839
1811
  }).catch(() => {
1840
1812
  // On error, treat as plain text
1841
- if (chunkSeq !== undefined) {
1842
- assistant.content = accumulatedRaw;
1843
- } else {
1844
- assistant.content += chunk;
1845
- }
1813
+ assistant.content =
1814
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1846
1815
  rawContentBuffers.delete(assistant.id);
1847
1816
  streamParsers.delete(assistant.id);
1848
1817
  assistant.rawContent = undefined;
@@ -1860,11 +1829,8 @@ export class AgentWidgetClient {
1860
1829
  emitMessage(assistant);
1861
1830
  } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1862
1831
  // Not a structured format - show as plain text
1863
- if (chunkSeq !== undefined) {
1864
- assistant.content = accumulatedRaw;
1865
- } else {
1866
- assistant.content += chunk;
1867
- }
1832
+ assistant.content =
1833
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1868
1834
  // Clear any raw buffer/parser if we were in structured format mode
1869
1835
  rawContentBuffers.delete(assistant.id);
1870
1836
  streamParsers.delete(assistant.id);
@@ -1917,7 +1883,6 @@ export class AgentWidgetClient {
1917
1883
  // Clean up
1918
1884
  streamParsers.delete(currentAssistant.id);
1919
1885
  rawContentBuffers.delete(currentAssistant.id);
1920
- seqChunkBuffers.delete(currentAssistant.id);
1921
1886
  emitMessage(currentAssistant);
1922
1887
  }
1923
1888
  }
@@ -1949,7 +1914,6 @@ export class AgentWidgetClient {
1949
1914
  streamParsers.delete(assistant.id);
1950
1915
  }
1951
1916
  rawContentBuffers.delete(assistant.id);
1952
- seqChunkBuffers.delete(assistant.id);
1953
1917
  assistant.streaming = false;
1954
1918
  emitMessage(assistant);
1955
1919
  }
@@ -1970,7 +1934,6 @@ export class AgentWidgetClient {
1970
1934
  const msg: AgentWidgetMessage = assistantMessage;
1971
1935
  streamParsers.delete(msg.id);
1972
1936
  rawContentBuffers.delete(msg.id);
1973
- seqChunkBuffers.delete(msg.id);
1974
1937
  if (msg.streaming !== false) {
1975
1938
  msg.streaming = false;
1976
1939
  emitMessage(msg);
@@ -2035,7 +1998,6 @@ export class AgentWidgetClient {
2035
1998
  // Clean up
2036
1999
  streamParsers.delete(currentAssistant.id);
2037
2000
  rawContentBuffers.delete(currentAssistant.id);
2038
- seqChunkBuffers.delete(currentAssistant.id);
2039
2001
  emitMessage(currentAssistant);
2040
2002
  }
2041
2003
  } else {
@@ -2053,7 +2015,6 @@ export class AgentWidgetClient {
2053
2015
  // Clean up
2054
2016
  streamParsers.delete(currentAssistant.id);
2055
2017
  rawContentBuffers.delete(currentAssistant.id);
2056
- seqChunkBuffers.delete(currentAssistant.id);
2057
2018
  emitMessage(currentAssistant);
2058
2019
  }
2059
2020
  }
@@ -2101,7 +2062,6 @@ export class AgentWidgetClient {
2101
2062
  }
2102
2063
  streamParsers.delete(assistant.id);
2103
2064
  rawContentBuffers.delete(assistant.id);
2104
- seqChunkBuffers.delete(assistant.id);
2105
2065
  assistant.streaming = false;
2106
2066
  emitMessage(assistant);
2107
2067
  }
@@ -2109,7 +2069,6 @@ export class AgentWidgetClient {
2109
2069
  // No final content, just mark as complete and clean up
2110
2070
  streamParsers.delete(assistant.id);
2111
2071
  rawContentBuffers.delete(assistant.id);
2112
- seqChunkBuffers.delete(assistant.id);
2113
2072
  assistant.streaming = false;
2114
2073
  emitMessage(assistant);
2115
2074
  }
@@ -2122,7 +2081,6 @@ export class AgentWidgetClient {
2122
2081
  const msg: AgentWidgetMessage = assistantMessage;
2123
2082
  streamParsers.delete(msg.id);
2124
2083
  rawContentBuffers.delete(msg.id);
2125
- seqChunkBuffers.delete(msg.id);
2126
2084
  if (msg.streaming !== false) {
2127
2085
  msg.streaming = false;
2128
2086
  emitMessage(msg);
@@ -2164,7 +2122,6 @@ export class AgentWidgetClient {
2164
2122
  // Clean up parser and buffer
2165
2123
  streamParsers.delete(assistant.id);
2166
2124
  rawContentBuffers.delete(assistant.id);
2167
- seqChunkBuffers.delete(assistant.id);
2168
2125
 
2169
2126
  // Only emit if something actually changed to avoid flicker
2170
2127
  const contentChanged = displayContent !== assistant.content;
@@ -2187,8 +2144,6 @@ export class AgentWidgetClient {
2187
2144
  const msg: AgentWidgetMessage = assistantMessage;
2188
2145
  streamParsers.delete(msg.id);
2189
2146
  rawContentBuffers.delete(msg.id);
2190
- seqChunkBuffers.delete(msg.id);
2191
-
2192
2147
  // Only emit if streaming state changed
2193
2148
  if (msg.streaming !== false) {
2194
2149
  msg.streaming = false;
@@ -2591,7 +2546,6 @@ export class AgentWidgetClient {
2591
2546
  assistantMessageRef.current = null;
2592
2547
  streamParsers.delete(id);
2593
2548
  rawContentBuffers.delete(id);
2594
- seqChunkBuffers.delete(id);
2595
2549
  } else if (
2596
2550
  payloadType === "error" ||
2597
2551
  payloadType === "step_error" ||
@@ -2631,6 +2585,79 @@ export class AgentWidgetClient {
2631
2585
  }
2632
2586
  }
2633
2587
  }
2588
+ seqReadyQueue.length = 0;
2589
+ };
2590
+
2591
+ // eslint-disable-next-line no-constant-condition
2592
+ while (true) {
2593
+ const { done, value } = await reader.read();
2594
+ if (done) break;
2595
+
2596
+ buffer += decoder.decode(value, { stream: true });
2597
+ const events = buffer.split("\n\n");
2598
+ buffer = events.pop() ?? "";
2599
+
2600
+ for (const event of events) {
2601
+ const lines = event.split("\n");
2602
+ let eventType = "message";
2603
+ let data = "";
2604
+
2605
+ for (const line of lines) {
2606
+ if (line.startsWith("event:")) {
2607
+ eventType = line.replace("event:", "").trim();
2608
+ } else if (line.startsWith("data:")) {
2609
+ data += line.replace("data:", "").trim();
2610
+ }
2611
+ }
2612
+
2613
+ if (!data) continue;
2614
+ let payload: any;
2615
+ try {
2616
+ payload = JSON.parse(data);
2617
+ } catch (error) {
2618
+ onEvent({
2619
+ type: "error",
2620
+ error:
2621
+ error instanceof Error
2622
+ ? error
2623
+ : new Error("Failed to parse chat stream payload")
2624
+ });
2625
+ continue;
2626
+ }
2627
+
2628
+ const payloadType =
2629
+ eventType !== "message" ? eventType : payload.type ?? "message";
2630
+
2631
+ // Tap: capture raw SSE event for event stream inspector
2632
+ this.onSSEEvent?.(payloadType, payload);
2633
+
2634
+ // If custom SSE event parser is provided, try it first
2635
+ if (this.parseSSEEvent) {
2636
+ // Keep assistant message ref in sync
2637
+ assistantMessageRef.current = assistantMessage;
2638
+ const handled = await this.handleCustomSSEEvent(
2639
+ payload,
2640
+ onEvent,
2641
+ assistantMessageRef,
2642
+ emitMessage,
2643
+ nextSequence,
2644
+ partIdState
2645
+ );
2646
+ // Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
2647
+ if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
2648
+ assistantMessage = assistantMessageRef.current;
2649
+ }
2650
+ if (handled) continue; // Skip default handling if custom handler processed it
2651
+ }
2652
+
2653
+ // Push through the sequence reorder buffer
2654
+ seqBuffer.push(payloadType, payload);
2655
+ drainReadyQueue();
2656
+ }
2634
2657
  }
2658
+
2659
+ seqBuffer.flushPending();
2660
+ drainReadyQueue();
2661
+ seqBuffer.destroy();
2635
2662
  }
2636
2663
  }
@@ -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/src/types.ts CHANGED
@@ -2901,6 +2901,15 @@ export type AgentWidgetConfig = {
2901
2901
  * ```
2902
2902
  */
2903
2903
  parseSSEEvent?: AgentWidgetSSEEventParser;
2904
+ /**
2905
+ * Called for every parsed SSE frame (after JSON parse), before native handling.
2906
+ * Use for lightweight side effects (e.g. telemetry). Does not replace native
2907
+ * streaming; pair with {@link parseSSEEvent} only when you need to override text mapping.
2908
+ *
2909
+ * When the event stream inspector is enabled, this runs in the same order as
2910
+ * events are appended to the inspector buffer.
2911
+ */
2912
+ onSSEEvent?: (eventType: string, payload: unknown) => void;
2904
2913
  /**
2905
2914
  * Layout configuration for customizing widget appearance and structure.
2906
2915
  * Provides control over header, messages, and content slots.
package/src/ui.ts CHANGED
@@ -1229,22 +1229,47 @@ export const createAgentExperience = (
1229
1229
  } else if (action === 'upvote' || action === 'downvote') {
1230
1230
  const currentVote = messageVoteState.get(messageId) ?? null;
1231
1231
  const wasActive = currentVote === action;
1232
+ const iconName = action === 'upvote' ? 'thumbs-up' : 'thumbs-down';
1232
1233
 
1233
1234
  if (wasActive) {
1234
- // Toggle off
1235
+ // Toggle off — revert to outline icon
1235
1236
  messageVoteState.delete(messageId);
1236
1237
  actionBtn.classList.remove("persona-message-action-active");
1238
+ const outlineIcon = renderLucideIcon(iconName, 14, "currentColor", 2);
1239
+ if (outlineIcon) {
1240
+ actionBtn.innerHTML = "";
1241
+ actionBtn.appendChild(outlineIcon);
1242
+ }
1237
1243
  } else {
1238
- // Clear opposite vote button
1244
+ // Clear opposite vote button and revert its icon
1239
1245
  const oppositeAction = action === 'upvote' ? 'downvote' : 'upvote';
1240
1246
  const oppositeBtn = actionsContainer.querySelector(`[data-action="${oppositeAction}"]`);
1241
1247
  if (oppositeBtn) {
1242
1248
  oppositeBtn.classList.remove("persona-message-action-active");
1249
+ const oppositeIconName = oppositeAction === 'upvote' ? 'thumbs-up' : 'thumbs-down';
1250
+ const outlineIcon = renderLucideIcon(oppositeIconName, 14, "currentColor", 2);
1251
+ if (outlineIcon) {
1252
+ oppositeBtn.innerHTML = "";
1253
+ oppositeBtn.appendChild(outlineIcon);
1254
+ }
1243
1255
  }
1244
1256
 
1245
1257
  messageVoteState.set(messageId, action);
1246
1258
  actionBtn.classList.add("persona-message-action-active");
1247
1259
 
1260
+ // Swap to filled icon
1261
+ const filledIcon = renderLucideIcon(iconName, 14, "currentColor", 2);
1262
+ if (filledIcon) {
1263
+ filledIcon.setAttribute("fill", "currentColor");
1264
+ actionBtn.innerHTML = "";
1265
+ actionBtn.appendChild(filledIcon);
1266
+ }
1267
+
1268
+ // Pop animation
1269
+ actionBtn.classList.remove("persona-message-action-pop");
1270
+ void actionBtn.offsetWidth; // force reflow to restart animation
1271
+ actionBtn.classList.add("persona-message-action-pop");
1272
+
1248
1273
  // Trigger feedback
1249
1274
  const messages = session.getMessages();
1250
1275
  const message = messages.find(m => m.id === messageId);
@@ -3118,9 +3143,10 @@ export const createAgentExperience = (
3118
3143
  });
3119
3144
  }
3120
3145
 
3121
- // Wire up event stream buffer to capture SSE events
3122
- if (eventStreamBuffer) {
3146
+ // Wire up optional SSE tap (host) + event stream buffer to capture SSE events
3147
+ if (eventStreamBuffer || config.onSSEEvent) {
3123
3148
  session.setSSEEventCallback((type: string, payload: unknown) => {
3149
+ config.onSSEEvent?.(type, payload);
3124
3150
  eventStreamBuffer?.push({
3125
3151
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3126
3152
  type,
@@ -4187,8 +4213,9 @@ export const createAgentExperience = (
4187
4213
  eventStreamStore = new EventStreamStore(eventStreamDbName);
4188
4214
  eventStreamBuffer = new EventStreamBuffer(eventStreamMaxEvents, eventStreamStore);
4189
4215
  eventStreamStore.open().then(() => eventStreamBuffer?.restore()).catch(() => {});
4190
- // Register the SSE event callback
4216
+ // Register the SSE event callback (host tap + buffer)
4191
4217
  session.setSSEEventCallback((type: string, payload: unknown) => {
4218
+ config.onSSEEvent?.(type, payload);
4192
4219
  eventStreamBuffer!.push({
4193
4220
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
4194
4221
  type,