@runtypelabs/persona 3.15.0 → 3.16.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/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
 
@@ -1056,6 +1057,16 @@ export class AgentWidgetClient {
1056
1057
  let didSplitByPartId = false;
1057
1058
  const reasoningMessages = new Map<string, AgentWidgetMessage>();
1058
1059
  const toolMessages = new Map<string, AgentWidgetMessage>();
1060
+ // Messages produced by steps inside a nested flow executed as a tool.
1061
+ // Keyed by `${parentToolId}::${nestedStepId}::${partId}` so each nested
1062
+ // step (send-stream, prompt) gets its own assistant message, and prompts
1063
+ // with inner tool calls split into one message per text segment — still
1064
+ // attributable to the parent tool call.
1065
+ const nestedStepMessages = new Map<string, AgentWidgetMessage>();
1066
+ // Most-recent partId seen for a given `${toolId}::${stepId}` scope, used
1067
+ // to seal the previous segment when a new partId arrives within the
1068
+ // same nested prompt step.
1069
+ const nestedPartIdByStep = new Map<string, string>();
1059
1070
  const reasoningContext = {
1060
1071
  lastId: null as string | null,
1061
1072
  byStep: new Map<string, string>()
@@ -1065,6 +1076,49 @@ export class AgentWidgetClient {
1065
1076
  byCall: new Map<string, string>()
1066
1077
  };
1067
1078
 
1079
+ // Nested message key. partId defaults to "" so steps without segmentation
1080
+ // (e.g. send-stream) still have a deterministic single key.
1081
+ const getNestedStepKey = (
1082
+ toolId: string,
1083
+ stepId: string,
1084
+ partId: string = ""
1085
+ ) => `${toolId}::${stepId}::${partId}`;
1086
+
1087
+ // Prefix used to sweep every nested message belonging to a single
1088
+ // (toolId, stepId) scope — needed on step_complete to seal any segments
1089
+ // that are still streaming.
1090
+ const getNestedStepPrefix = (toolId: string, stepId: string) =>
1091
+ `${toolId}::${stepId}::`;
1092
+
1093
+ const ensureNestedStepMessage = (
1094
+ toolId: string,
1095
+ stepId: string,
1096
+ partId: string,
1097
+ executionId?: string
1098
+ ): AgentWidgetMessage => {
1099
+ const key = getNestedStepKey(toolId, stepId, partId);
1100
+ const existing = nestedStepMessages.get(key);
1101
+ if (existing) return existing;
1102
+ const idSuffix = partId ? `-${partId}` : "";
1103
+ const message: AgentWidgetMessage = {
1104
+ id: `nested-${toolId}-${stepId}${idSuffix}`,
1105
+ role: "assistant",
1106
+ content: "",
1107
+ createdAt: new Date().toISOString(),
1108
+ streaming: true,
1109
+ sequence: nextSequence(),
1110
+ ...(partId ? { partId } : {}),
1111
+ agentMetadata: {
1112
+ executionId,
1113
+ parentToolId: toolId,
1114
+ parentStepId: stepId,
1115
+ },
1116
+ };
1117
+ nestedStepMessages.set(key, message);
1118
+ emitMessage(message);
1119
+ return message;
1120
+ };
1121
+
1068
1122
  const normalizeKey = (value: unknown): string | null => {
1069
1123
  if (value === null || value === undefined) return null;
1070
1124
  try {
@@ -1295,44 +1349,45 @@ export class AgentWidgetClient {
1295
1349
  const streamParsers = new Map<string, AgentWidgetStreamParser>();
1296
1350
  // Track accumulated raw content for structured formats (JSON, XML, etc.)
1297
1351
  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 }>>();
1352
+ // Rebuild incremental text by sequence so late arrivals can repair already-emitted
1353
+ // content after the reorder buffer's gap-timeout flush.
1354
+ const orderedChunkBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1355
+ const assistantMessagesByPartId = new Map<string, AgentWidgetMessage>();
1356
+ // Only the most-recently sealed segment is reconciled with step_complete's
1357
+ // final response. Earlier segments rely on their own async parser microtasks
1358
+ // resolving via the closure-captured `assistant` variable.
1359
+ let lastSealedTextSegment: AgentWidgetMessage | null = null;
1302
1360
 
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);
1361
+ const insertOrderedChunk = (key: string, seq: number, text: string): string => {
1362
+ let chunks = orderedChunkBuffers.get(key);
1363
+ if (!chunks) {
1364
+ chunks = [];
1365
+ orderedChunkBuffers.set(key, chunks);
1317
1366
  }
1318
- // Binary-search insert to keep sorted by seq
1319
- let lo = 0, hi = buf.length;
1367
+
1368
+ let lo = 0;
1369
+ let hi = chunks.length;
1320
1370
  while (lo < hi) {
1321
1371
  const mid = (lo + hi) >>> 1;
1322
- if (buf[mid].seq < seq) lo = mid + 1;
1323
- else hi = mid;
1372
+ if (chunks[mid].seq < seq) {
1373
+ lo = mid + 1;
1374
+ } else {
1375
+ hi = mid;
1376
+ }
1324
1377
  }
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
1378
 
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;
1379
+ if (chunks[lo]?.seq === seq) {
1380
+ chunks[lo] = { seq, text };
1381
+ } else {
1382
+ chunks.splice(lo, 0, { seq, text });
1383
+ }
1384
+
1385
+ let accumulated = "";
1386
+ for (let index = 0; index < chunks.length; index++) {
1387
+ accumulated += chunks[index].text;
1388
+ }
1389
+ return accumulated;
1390
+ };
1336
1391
 
1337
1392
  /**
1338
1393
  * After text_end + didSplitByPartId, merge the authoritative final response into the
@@ -1426,73 +1481,45 @@ export class AgentWidgetClient {
1426
1481
  finalizeCleanup();
1427
1482
  };
1428
1483
 
1484
+ // Sequence reorder buffer: SSE events carrying a `seq` (or `sequenceIndex`)
1485
+ // field are held and re-emitted in sequence order so that transport-level
1486
+ // reordering doesn't produce garbled output.
1487
+ const seqReadyQueue: Array<{ payloadType: string; payload: any }> = [];
1488
+ let isDrainScheduled = false;
1489
+ // Declared here so scheduleReadyQueueDrain can reference it; assigned
1490
+ // after all handler-scoped variables are initialised (before the SSE loop).
1491
+ let drainReadyQueue: () => void;
1492
+ // Two drain paths — both are intentional, do not remove either:
1493
+ // 1. Microtask drain (scheduleReadyQueueDrain): required when the
1494
+ // buffer's emitter fires from the gap-timeout setTimeout callback,
1495
+ // because there is no surrounding synchronous drain site there.
1496
+ // 2. Synchronous drain (drainReadyQueue() after each seqBuffer.push):
1497
+ // skips an extra microtask hop on the hot in-order push path.
1498
+ const scheduleReadyQueueDrain = () => {
1499
+ if (isDrainScheduled) return;
1500
+ isDrainScheduled = true;
1501
+ queueMicrotask(() => {
1502
+ isDrainScheduled = false;
1503
+ drainReadyQueue();
1504
+ });
1505
+ };
1506
+ const seqBuffer = new SequenceReorderBuffer((payloadType: string, payload: any) => {
1507
+ seqReadyQueue.push({ payloadType, payload });
1508
+ scheduleReadyQueueDrain();
1509
+ });
1429
1510
  // Agent execution state tracking
1430
1511
  let agentExecution: AgentExecutionState | null = null;
1431
1512
  // Track assistant messages per agent iteration for 'separate' mode
1432
1513
  const agentIterationMessages = new Map<number, AgentWidgetMessage>();
1433
1514
  const iterationDisplay = this.config.iterationDisplay ?? 'separate';
1434
1515
 
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
- }
1516
+ // Drains reorder-buffered events through the main event handler.
1517
+ // Also invoked after the SSE loop exits so any events buffered at
1518
+ // end-of-stream are processed.
1519
+ drainReadyQueue = () => {
1520
+ for (let i = 0; i < seqReadyQueue.length; i++) {
1521
+ const payloadType = seqReadyQueue[i].payloadType;
1522
+ const payload = seqReadyQueue[i].payload;
1496
1523
 
1497
1524
  if (payloadType === "reason_start") {
1498
1525
  const reasoningId =
@@ -1531,12 +1558,11 @@ export class AgentWidgetClient {
1531
1558
  payload.delta ??
1532
1559
  "";
1533
1560
  if (chunk && payload.hidden !== true) {
1534
- const reasonSeq = typeof payload.sequenceIndex === 'number' ? payload.sequenceIndex : undefined;
1561
+ const reasonSeq = typeof payload.sequenceIndex === "number" ? payload.sequenceIndex : undefined;
1535
1562
  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.
1563
+ // Rebuild chunks by seq so late arrivals after a gap-timeout flush
1564
+ // are inserted at the correct position rather than appended.
1565
+ const ordered = insertOrderedChunk(reasoningId, reasonSeq, String(chunk));
1540
1566
  reasoningMessage.reasoning.chunks = [ordered];
1541
1567
  } else {
1542
1568
  reasoningMessage.reasoning.chunks.push(String(chunk));
@@ -1552,7 +1578,7 @@ export class AgentWidgetClient {
1552
1578
  0,
1553
1579
  (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1554
1580
  );
1555
- reasonSeqBuffers.delete(reasoningId);
1581
+
1556
1582
  }
1557
1583
  reasoningMessage.streaming = reasoningMessage.reasoning.status !== "complete";
1558
1584
  emitMessage(reasoningMessage);
@@ -1573,7 +1599,7 @@ export class AgentWidgetClient {
1573
1599
  (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1574
1600
  );
1575
1601
  reasoningMessage.streaming = false;
1576
- reasonSeqBuffers.delete(reasoningId);
1602
+
1577
1603
  emitMessage(reasoningMessage);
1578
1604
  }
1579
1605
  const stepKey = getStepKey(payload);
@@ -1696,7 +1722,13 @@ export class AgentWidgetClient {
1696
1722
  toolContext.byCall.delete(callKey);
1697
1723
  }
1698
1724
  } else if (payloadType === "text_start") {
1699
- // Lifecycle event: a new text segment is beginning (emitted at tool boundaries)
1725
+ // Lifecycle event: a new text segment is beginning (emitted at tool boundaries).
1726
+ // When toolContext is present this fired inside a nested flow — it must not
1727
+ // seal or rotate the outer assistant message. Nested prompt segmentation is
1728
+ // handled via nestedStepMessages keyed by (toolId, stepId).
1729
+ if ((payload as any).toolContext?.toolId) {
1730
+ continue;
1731
+ }
1700
1732
  const incomingPartId = payload.partId;
1701
1733
  if (incomingPartId !== undefined && partIdState.current !== null && incomingPartId !== partIdState.current) {
1702
1734
  const prev = assistantMessage as AgentWidgetMessage | null;
@@ -1712,7 +1744,13 @@ export class AgentWidgetClient {
1712
1744
  partIdState.current = incomingPartId;
1713
1745
  }
1714
1746
  } else if (payloadType === "text_end") {
1715
- // Lifecycle event: current text segment ended (tool call about to start)
1747
+ // Lifecycle event: current text segment ended (tool call about to start).
1748
+ // When toolContext is present the boundary belongs to a nested flow — leave
1749
+ // outer assistant state alone so the outer stream is never interrupted by
1750
+ // nested activity.
1751
+ if ((payload as any).toolContext?.toolId) {
1752
+ continue;
1753
+ }
1716
1754
  // Seal the current assistant message so the next segment gets a new one
1717
1755
  const prev = assistantMessage as AgentWidgetMessage | null;
1718
1756
  if (prev) {
@@ -1731,6 +1769,77 @@ export class AgentWidgetClient {
1731
1769
  continue;
1732
1770
  }
1733
1771
 
1772
+ // Nested flow routing: when toolContext is present, this step_delta
1773
+ // originated inside a nested flow executed as a tool. Surface it as
1774
+ // its own assistant message keyed by the nested step id, so authors
1775
+ // who add send-stream / prompt steps inside their flow see them as
1776
+ // real messages in the timeline, in order — rather than merging
1777
+ // into the outer assistant bubble or getting buried in the tool
1778
+ // card. Each nested step id gets its own message; the parent tool
1779
+ // bubble continues to represent the invocation via tool_* events.
1780
+ const nestedToolCtx = (payload as any).toolContext as
1781
+ | { toolId?: string; stepId?: string; executionId?: string }
1782
+ | undefined;
1783
+ if (nestedToolCtx?.toolId) {
1784
+ const nestedStepId = String(
1785
+ payload.id ?? nestedToolCtx.stepId ?? `step-${nextSequence()}`
1786
+ );
1787
+ const incomingPartId =
1788
+ payload.partId !== undefined && payload.partId !== null
1789
+ ? String(payload.partId)
1790
+ : "";
1791
+ const stepScopeKey = `${nestedToolCtx.toolId}::${nestedStepId}`;
1792
+ const prevPartId = nestedPartIdByStep.get(stepScopeKey);
1793
+
1794
+ // If partId changed within this nested step (prompt with inner
1795
+ // tool call emitting a new text segment), seal the previous
1796
+ // segment's message so each segment renders as its own bubble.
1797
+ if (
1798
+ incomingPartId !== "" &&
1799
+ prevPartId !== undefined &&
1800
+ prevPartId !== "" &&
1801
+ prevPartId !== incomingPartId
1802
+ ) {
1803
+ const prev = nestedStepMessages.get(
1804
+ getNestedStepKey(
1805
+ nestedToolCtx.toolId,
1806
+ nestedStepId,
1807
+ prevPartId
1808
+ )
1809
+ );
1810
+ if (prev && prev.streaming !== false) {
1811
+ prev.streaming = false;
1812
+ emitMessage(prev);
1813
+ }
1814
+ }
1815
+ if (incomingPartId !== "") {
1816
+ nestedPartIdByStep.set(stepScopeKey, incomingPartId);
1817
+ }
1818
+
1819
+ const nestedMsg = ensureNestedStepMessage(
1820
+ nestedToolCtx.toolId,
1821
+ nestedStepId,
1822
+ incomingPartId,
1823
+ nestedToolCtx.executionId
1824
+ );
1825
+ const nestedChunk =
1826
+ payload.text ??
1827
+ payload.delta ??
1828
+ payload.content ??
1829
+ payload.chunk ??
1830
+ "";
1831
+ if (nestedChunk) {
1832
+ nestedMsg.content += String(nestedChunk);
1833
+ nestedMsg.streaming = true;
1834
+ emitMessage(nestedMsg);
1835
+ }
1836
+ if (payload.isComplete) {
1837
+ nestedMsg.streaming = false;
1838
+ emitMessage(nestedMsg);
1839
+ }
1840
+ continue;
1841
+ }
1842
+
1734
1843
  // partId-based segmentation: when partId changes, seal current message
1735
1844
  // and start a new one so text and tools render in chronological order
1736
1845
  const incomingPartId = payload.partId;
@@ -1748,26 +1857,30 @@ export class AgentWidgetClient {
1748
1857
  partIdState.current = incomingPartId;
1749
1858
  }
1750
1859
 
1751
- const assistant = ensureAssistantMessage();
1752
- if (incomingPartId !== undefined && !assistant.partId) {
1753
- assistant.partId = incomingPartId;
1860
+ const assistant =
1861
+ incomingPartId !== undefined
1862
+ ? (assistantMessagesByPartId.get(incomingPartId) ?? ensureAssistantMessage())
1863
+ : ensureAssistantMessage();
1864
+ if (incomingPartId !== undefined) {
1865
+ if (!assistant.partId) {
1866
+ assistant.partId = incomingPartId;
1867
+ }
1868
+ assistantMessagesByPartId.set(incomingPartId, assistant);
1754
1869
  }
1755
1870
  // Support various field names: text, delta, content, chunk (Runtype uses 'chunk')
1756
1871
  const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
1757
1872
  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
1873
  // 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
- }
1874
+ // Most out-of-order events are fixed at the dispatch layer, but once the
1875
+ // gap timeout flushes later seqs we can still see genuine late arrivals.
1876
+ // Rebuild chunked content by seq so those events repair prior output
1877
+ // instead of appending in the wrong position.
1878
+ const chunkSeq = typeof payload.seq === "number" ? payload.seq : undefined;
1879
+ const chunkBufferKey = incomingPartId ?? assistant.id;
1880
+ const accumulatedRaw =
1881
+ chunkSeq !== undefined
1882
+ ? insertOrderedChunk(chunkBufferKey, chunkSeq, String(chunk))
1883
+ : (rawContentBuffers.get(assistant.id) ?? "") + chunk;
1771
1884
  // Store raw content for action parsing, but NEVER set assistant.content to raw JSON
1772
1885
  assistant.rawContent = accumulatedRaw;
1773
1886
 
@@ -1790,13 +1903,7 @@ export class AgentWidgetClient {
1790
1903
 
1791
1904
  // If plain text parser, just append the chunk directly
1792
1905
  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
- }
1906
+ assistant.content = chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1800
1907
  // Clear any raw buffer/parser since we're in plain text mode
1801
1908
  rawContentBuffers.delete(assistant.id);
1802
1909
  streamParsers.delete(assistant.id);
@@ -1822,27 +1929,25 @@ export class AgentWidgetClient {
1822
1929
  } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1823
1930
  // Not a structured format - show as plain text
1824
1931
  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);
1932
+ const targetAssistant =
1933
+ currentAssistant && currentAssistant.id === assistant.id
1934
+ ? currentAssistant
1935
+ : assistant;
1936
+ if (targetAssistant.id === assistant.id) {
1937
+ targetAssistant.content =
1938
+ chunkSeq !== undefined ? accumulatedRaw : targetAssistant.content + chunk;
1939
+ rawContentBuffers.delete(targetAssistant.id);
1940
+ streamParsers.delete(targetAssistant.id);
1941
+ targetAssistant.rawContent = undefined;
1942
+ emitMessage(targetAssistant);
1835
1943
  }
1836
1944
  }
1837
1945
  // Otherwise wait for more chunks (incomplete structured format)
1838
1946
  // Don't emit message if parser hasn't extracted text yet
1839
1947
  }).catch(() => {
1840
1948
  // On error, treat as plain text
1841
- if (chunkSeq !== undefined) {
1842
- assistant.content = accumulatedRaw;
1843
- } else {
1844
- assistant.content += chunk;
1845
- }
1949
+ assistant.content =
1950
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1846
1951
  rawContentBuffers.delete(assistant.id);
1847
1952
  streamParsers.delete(assistant.id);
1848
1953
  assistant.rawContent = undefined;
@@ -1860,11 +1965,8 @@ export class AgentWidgetClient {
1860
1965
  emitMessage(assistant);
1861
1966
  } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1862
1967
  // Not a structured format - show as plain text
1863
- if (chunkSeq !== undefined) {
1864
- assistant.content = accumulatedRaw;
1865
- } else {
1866
- assistant.content += chunk;
1867
- }
1968
+ assistant.content =
1969
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1868
1970
  // Clear any raw buffer/parser if we were in structured format mode
1869
1971
  rawContentBuffers.delete(assistant.id);
1870
1972
  streamParsers.delete(assistant.id);
@@ -1917,7 +2019,6 @@ export class AgentWidgetClient {
1917
2019
  // Clean up
1918
2020
  streamParsers.delete(currentAssistant.id);
1919
2021
  rawContentBuffers.delete(currentAssistant.id);
1920
- seqChunkBuffers.delete(currentAssistant.id);
1921
2022
  emitMessage(currentAssistant);
1922
2023
  }
1923
2024
  }
@@ -1949,7 +2050,6 @@ export class AgentWidgetClient {
1949
2050
  streamParsers.delete(assistant.id);
1950
2051
  }
1951
2052
  rawContentBuffers.delete(assistant.id);
1952
- seqChunkBuffers.delete(assistant.id);
1953
2053
  assistant.streaming = false;
1954
2054
  emitMessage(assistant);
1955
2055
  }
@@ -1963,6 +2063,37 @@ export class AgentWidgetClient {
1963
2063
  // Skip tool-related completions - they're handled by tool_complete
1964
2064
  continue;
1965
2065
  }
2066
+
2067
+ // Nested flow: seal every segment message produced by this nested
2068
+ // step (a single nested prompt step may have produced multiple
2069
+ // messages, one per partId, when inner tool calls split it). The
2070
+ // outer assistantMessage state is untouched so reconciliation for
2071
+ // the outer flow still works.
2072
+ const nestedCompleteCtx = (payload as any).toolContext as
2073
+ | { toolId?: string; stepId?: string; executionId?: string }
2074
+ | undefined;
2075
+ if (nestedCompleteCtx?.toolId) {
2076
+ const nestedStepId = String(
2077
+ payload.id ?? nestedCompleteCtx.stepId ?? ""
2078
+ );
2079
+ if (nestedStepId) {
2080
+ const prefix = getNestedStepPrefix(
2081
+ nestedCompleteCtx.toolId,
2082
+ nestedStepId
2083
+ );
2084
+ for (const [key, msg] of nestedStepMessages) {
2085
+ if (key.startsWith(prefix) && msg.streaming !== false) {
2086
+ msg.streaming = false;
2087
+ emitMessage(msg);
2088
+ }
2089
+ }
2090
+ nestedPartIdByStep.delete(
2091
+ `${nestedCompleteCtx.toolId}::${nestedStepId}`
2092
+ );
2093
+ }
2094
+ continue;
2095
+ }
2096
+
1966
2097
  if (didSplitByPartId) {
1967
2098
  // Sealed segment(s) — do not create a second bubble from step_complete.
1968
2099
  // Merge authoritative final response into the last sealed segment (fixes async lag).
@@ -1970,7 +2101,6 @@ export class AgentWidgetClient {
1970
2101
  const msg: AgentWidgetMessage = assistantMessage;
1971
2102
  streamParsers.delete(msg.id);
1972
2103
  rawContentBuffers.delete(msg.id);
1973
- seqChunkBuffers.delete(msg.id);
1974
2104
  if (msg.streaming !== false) {
1975
2105
  msg.streaming = false;
1976
2106
  emitMessage(msg);
@@ -2035,7 +2165,6 @@ export class AgentWidgetClient {
2035
2165
  // Clean up
2036
2166
  streamParsers.delete(currentAssistant.id);
2037
2167
  rawContentBuffers.delete(currentAssistant.id);
2038
- seqChunkBuffers.delete(currentAssistant.id);
2039
2168
  emitMessage(currentAssistant);
2040
2169
  }
2041
2170
  } else {
@@ -2053,7 +2182,6 @@ export class AgentWidgetClient {
2053
2182
  // Clean up
2054
2183
  streamParsers.delete(currentAssistant.id);
2055
2184
  rawContentBuffers.delete(currentAssistant.id);
2056
- seqChunkBuffers.delete(currentAssistant.id);
2057
2185
  emitMessage(currentAssistant);
2058
2186
  }
2059
2187
  }
@@ -2101,7 +2229,6 @@ export class AgentWidgetClient {
2101
2229
  }
2102
2230
  streamParsers.delete(assistant.id);
2103
2231
  rawContentBuffers.delete(assistant.id);
2104
- seqChunkBuffers.delete(assistant.id);
2105
2232
  assistant.streaming = false;
2106
2233
  emitMessage(assistant);
2107
2234
  }
@@ -2109,7 +2236,6 @@ export class AgentWidgetClient {
2109
2236
  // No final content, just mark as complete and clean up
2110
2237
  streamParsers.delete(assistant.id);
2111
2238
  rawContentBuffers.delete(assistant.id);
2112
- seqChunkBuffers.delete(assistant.id);
2113
2239
  assistant.streaming = false;
2114
2240
  emitMessage(assistant);
2115
2241
  }
@@ -2122,7 +2248,6 @@ export class AgentWidgetClient {
2122
2248
  const msg: AgentWidgetMessage = assistantMessage;
2123
2249
  streamParsers.delete(msg.id);
2124
2250
  rawContentBuffers.delete(msg.id);
2125
- seqChunkBuffers.delete(msg.id);
2126
2251
  if (msg.streaming !== false) {
2127
2252
  msg.streaming = false;
2128
2253
  emitMessage(msg);
@@ -2164,7 +2289,6 @@ export class AgentWidgetClient {
2164
2289
  // Clean up parser and buffer
2165
2290
  streamParsers.delete(assistant.id);
2166
2291
  rawContentBuffers.delete(assistant.id);
2167
- seqChunkBuffers.delete(assistant.id);
2168
2292
 
2169
2293
  // Only emit if something actually changed to avoid flicker
2170
2294
  const contentChanged = displayContent !== assistant.content;
@@ -2187,8 +2311,6 @@ export class AgentWidgetClient {
2187
2311
  const msg: AgentWidgetMessage = assistantMessage;
2188
2312
  streamParsers.delete(msg.id);
2189
2313
  rawContentBuffers.delete(msg.id);
2190
- seqChunkBuffers.delete(msg.id);
2191
-
2192
2314
  // Only emit if streaming state changed
2193
2315
  if (msg.streaming !== false) {
2194
2316
  msg.streaming = false;
@@ -2591,7 +2713,6 @@ export class AgentWidgetClient {
2591
2713
  assistantMessageRef.current = null;
2592
2714
  streamParsers.delete(id);
2593
2715
  rawContentBuffers.delete(id);
2594
- seqChunkBuffers.delete(id);
2595
2716
  } else if (
2596
2717
  payloadType === "error" ||
2597
2718
  payloadType === "step_error" ||
@@ -2631,6 +2752,79 @@ export class AgentWidgetClient {
2631
2752
  }
2632
2753
  }
2633
2754
  }
2755
+ seqReadyQueue.length = 0;
2756
+ };
2757
+
2758
+ // eslint-disable-next-line no-constant-condition
2759
+ while (true) {
2760
+ const { done, value } = await reader.read();
2761
+ if (done) break;
2762
+
2763
+ buffer += decoder.decode(value, { stream: true });
2764
+ const events = buffer.split("\n\n");
2765
+ buffer = events.pop() ?? "";
2766
+
2767
+ for (const event of events) {
2768
+ const lines = event.split("\n");
2769
+ let eventType = "message";
2770
+ let data = "";
2771
+
2772
+ for (const line of lines) {
2773
+ if (line.startsWith("event:")) {
2774
+ eventType = line.replace("event:", "").trim();
2775
+ } else if (line.startsWith("data:")) {
2776
+ data += line.replace("data:", "").trim();
2777
+ }
2778
+ }
2779
+
2780
+ if (!data) continue;
2781
+ let payload: any;
2782
+ try {
2783
+ payload = JSON.parse(data);
2784
+ } catch (error) {
2785
+ onEvent({
2786
+ type: "error",
2787
+ error:
2788
+ error instanceof Error
2789
+ ? error
2790
+ : new Error("Failed to parse chat stream payload")
2791
+ });
2792
+ continue;
2793
+ }
2794
+
2795
+ const payloadType =
2796
+ eventType !== "message" ? eventType : payload.type ?? "message";
2797
+
2798
+ // Tap: capture raw SSE event for event stream inspector
2799
+ this.onSSEEvent?.(payloadType, payload);
2800
+
2801
+ // If custom SSE event parser is provided, try it first
2802
+ if (this.parseSSEEvent) {
2803
+ // Keep assistant message ref in sync
2804
+ assistantMessageRef.current = assistantMessage;
2805
+ const handled = await this.handleCustomSSEEvent(
2806
+ payload,
2807
+ onEvent,
2808
+ assistantMessageRef,
2809
+ emitMessage,
2810
+ nextSequence,
2811
+ partIdState
2812
+ );
2813
+ // Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
2814
+ if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
2815
+ assistantMessage = assistantMessageRef.current;
2816
+ }
2817
+ if (handled) continue; // Skip default handling if custom handler processed it
2818
+ }
2819
+
2820
+ // Push through the sequence reorder buffer
2821
+ seqBuffer.push(payloadType, payload);
2822
+ drainReadyQueue();
2823
+ }
2634
2824
  }
2825
+
2826
+ seqBuffer.flushPending();
2827
+ drainReadyQueue();
2828
+ seqBuffer.destroy();
2635
2829
  }
2636
2830
  }