@runtypelabs/persona 3.37.0 → 4.1.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.
Files changed (55) hide show
  1. package/README.md +4 -5
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
  5. package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +54 -52
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +328 -839
  13. package/dist/index.d.ts +328 -839
  14. package/dist/index.global.js +41 -39
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +54 -52
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/smart-dom-reader.d.cts +269 -192
  21. package/dist/smart-dom-reader.d.ts +269 -192
  22. package/dist/theme-editor-preview.cjs +55 -53
  23. package/dist/theme-editor-preview.d.cts +269 -192
  24. package/dist/theme-editor-preview.d.ts +269 -192
  25. package/dist/theme-editor-preview.js +57 -55
  26. package/dist/theme-editor.d.cts +269 -192
  27. package/dist/theme-editor.d.ts +269 -192
  28. package/dist/widget.css +14 -0
  29. package/package.json +1 -1
  30. package/src/client.test.ts +446 -1041
  31. package/src/client.ts +684 -967
  32. package/src/components/message-bubble.ts +7 -0
  33. package/src/components/tool-bubble.ts +46 -33
  34. package/src/generated/runtype-openapi-contract.ts +210 -714
  35. package/src/index-core.ts +2 -3
  36. package/src/install.test.ts +1 -34
  37. package/src/install.ts +1 -26
  38. package/src/runtime/init.test.ts +8 -67
  39. package/src/runtime/init.ts +2 -17
  40. package/src/session.test.ts +8 -8
  41. package/src/session.ts +1 -1
  42. package/src/styles/widget.css +14 -0
  43. package/src/types.ts +12 -13
  44. package/src/ui.postprocess.test.ts +107 -0
  45. package/src/ui.ts +45 -5
  46. package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
  47. package/src/utils/copy-selection.test.ts +37 -0
  48. package/src/utils/copy-selection.ts +19 -0
  49. package/src/utils/event-stream-capture.test.ts +9 -64
  50. package/src/utils/streaming-table.test.ts +100 -0
  51. package/src/utils/streaming-table.ts +103 -0
  52. package/src/utils/sequence-buffer.test.ts +0 -256
  53. package/src/utils/sequence-buffer.ts +0 -130
  54. package/src/utils/unified-event-bridge.test.ts +0 -263
  55. package/src/utils/unified-event-bridge.ts +0 -431
package/src/client.ts CHANGED
@@ -32,8 +32,6 @@ import {
32
32
  createRegexJsonParser,
33
33
  createXmlParser
34
34
  } from "./utils/formatting";
35
- import { SequenceReorderBuffer } from "./utils/sequence-buffer";
36
- import { UnifiedToLegacyBridge, isUnifiedLifecycleStart } from "./utils/unified-event-bridge";
37
35
  import { VERSION } from "./version";
38
36
  // artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
39
37
  // artifact events are always processed here regardless of config.
@@ -293,17 +291,6 @@ export class AgentWidgetClient {
293
291
  return !!this.config.agent;
294
292
  }
295
293
 
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
-
307
294
  /**
308
295
  * Get the appropriate API URL based on mode
309
296
  */
@@ -694,7 +681,7 @@ export class AgentWidgetClient {
694
681
  console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
695
682
  }
696
683
 
697
- response = await fetch(this.withEventsParam(this.getClientApiUrl('chat')), {
684
+ response = await fetch(this.getClientApiUrl('chat'), {
698
685
  method: 'POST',
699
686
  headers: {
700
687
  'Content-Type': 'application/json',
@@ -814,7 +801,7 @@ export class AgentWidgetClient {
814
801
  if (this.customFetch) {
815
802
  try {
816
803
  response = await this.customFetch(
817
- this.withEventsParam(this.apiUrl),
804
+ this.apiUrl,
818
805
  {
819
806
  method: "POST",
820
807
  headers,
@@ -829,7 +816,7 @@ export class AgentWidgetClient {
829
816
  throw err;
830
817
  }
831
818
  } else {
832
- response = await fetch(this.withEventsParam(this.apiUrl), {
819
+ response = await fetch(this.apiUrl, {
833
820
  method: "POST",
834
821
  headers,
835
822
  body: JSON.stringify(payload),
@@ -890,7 +877,7 @@ export class AgentWidgetClient {
890
877
  if (this.customFetch) {
891
878
  try {
892
879
  response = await this.customFetch(
893
- this.withEventsParam(this.apiUrl),
880
+ this.apiUrl,
894
881
  {
895
882
  method: "POST",
896
883
  headers,
@@ -905,7 +892,7 @@ export class AgentWidgetClient {
905
892
  throw err;
906
893
  }
907
894
  } else {
908
- response = await fetch(this.withEventsParam(this.apiUrl), {
895
+ response = await fetch(this.apiUrl, {
909
896
  method: "POST",
910
897
  headers,
911
898
  body: JSON.stringify(payload),
@@ -1008,11 +995,9 @@ export class AgentWidgetClient {
1008
995
  options?: { streamResponse?: boolean; signal?: AbortSignal }
1009
996
  ): Promise<Response> {
1010
997
  const isClientToken = this.isClientTokenMode();
1011
- const url = this.withEventsParam(
1012
- isClientToken
1013
- ? this.getClientApiUrl('resume')
1014
- : `${this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE}/resume`
1015
- );
998
+ const url = isClientToken
999
+ ? this.getClientApiUrl('resume')
1000
+ : `${this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE}/resume`;
1016
1001
 
1017
1002
  // The client-token resume route authenticates the session, not a Bearer
1018
1003
  // key. A WebMCP approval can sit awaiting user input for a long time, so by
@@ -1392,21 +1377,26 @@ export class AgentWidgetClient {
1392
1377
  let lastAssistantInTurn: AgentWidgetMessage | null = null;
1393
1378
  // Reference to track assistant message for custom event handler
1394
1379
  const assistantMessageRef = { current: null as AgentWidgetMessage | null };
1395
- // Track current partId for message segmentation at tool boundaries
1396
- const partIdState = { current: null as string | null };
1397
- let didSplitByPartId = false;
1380
+ // Segmentation state for the `parseSSEEvent` extensibility callback (the
1381
+ // consumer's own `partId` field) independent of the unified wire.
1382
+ const customParsePartId = { current: null as string | null };
1383
+ // Unified text-channel block id (from `text_start`/`text_delta` `id`). Drives
1384
+ // bubble-id segmentation on the unified wire in place of the legacy `partId`:
1385
+ // a new block id means a new bubble, sealed at `text_complete`/tool boundaries.
1386
+ let currentTextBlockId: string | null = null;
1387
+ // Raw text accumulated for the open flow block before its bubble is
1388
+ // materialized — lets a whitespace-only block resolve without a stray bubble.
1389
+ let pendingFlowRaw = "";
1390
+ // Nested flow-as-tool attribution (PR #4602): a text/reasoning block whose
1391
+ // `parentToolCallId` matches a `tool_start.toolCallId` belongs to a flow
1392
+ // running as that tool. Keyed by the unified block id, these route the block's
1393
+ // deltas into a message tagged `agentMetadata.parentToolId` (the parent tool's
1394
+ // row) instead of the top-level assistant/reasoning channel.
1395
+ const nestedBlockParent = new Map<string, string>();
1396
+ const nestedBlockMessages = new Map<string, AgentWidgetMessage>();
1397
+ const nestedBlockRaw = new Map<string, string>();
1398
1398
  const reasoningMessages = new Map<string, AgentWidgetMessage>();
1399
1399
  const toolMessages = new Map<string, AgentWidgetMessage>();
1400
- // Messages produced by steps inside a nested flow executed as a tool.
1401
- // Keyed by `${parentToolId}::${nestedStepId}::${partId}` so each nested
1402
- // step (send-stream, prompt) gets its own assistant message, and prompts
1403
- // with inner tool calls split into one message per text segment: still
1404
- // attributable to the parent tool call.
1405
- const nestedStepMessages = new Map<string, AgentWidgetMessage>();
1406
- // Most-recent partId seen for a given `${toolId}::${stepId}` scope, used
1407
- // to seal the previous segment when a new partId arrives within the
1408
- // same nested prompt step.
1409
- const nestedPartIdByStep = new Map<string, string>();
1410
1400
  const reasoningContext = {
1411
1401
  lastId: null as string | null,
1412
1402
  byStep: new Map<string, string>()
@@ -1416,49 +1406,6 @@ export class AgentWidgetClient {
1416
1406
  byCall: new Map<string, string>()
1417
1407
  };
1418
1408
 
1419
- // Nested message key. partId defaults to "" so steps without segmentation
1420
- // (e.g. send-stream) still have a deterministic single key.
1421
- const getNestedStepKey = (
1422
- toolId: string,
1423
- stepId: string,
1424
- partId: string = ""
1425
- ) => `${toolId}::${stepId}::${partId}`;
1426
-
1427
- // Prefix used to sweep every nested message belonging to a single
1428
- // (toolId, stepId) scope: needed on step_complete to seal any segments
1429
- // that are still streaming.
1430
- const getNestedStepPrefix = (toolId: string, stepId: string) =>
1431
- `${toolId}::${stepId}::`;
1432
-
1433
- const ensureNestedStepMessage = (
1434
- toolId: string,
1435
- stepId: string,
1436
- partId: string,
1437
- executionId?: string
1438
- ): AgentWidgetMessage => {
1439
- const key = getNestedStepKey(toolId, stepId, partId);
1440
- const existing = nestedStepMessages.get(key);
1441
- if (existing) return existing;
1442
- const idSuffix = partId ? `-${partId}` : "";
1443
- const message: AgentWidgetMessage = {
1444
- id: `nested-${toolId}-${stepId}${idSuffix}`,
1445
- role: "assistant",
1446
- content: "",
1447
- createdAt: new Date().toISOString(),
1448
- streaming: true,
1449
- sequence: nextSequence(),
1450
- ...(partId ? { partId } : {}),
1451
- agentMetadata: {
1452
- executionId,
1453
- parentToolId: toolId,
1454
- parentStepId: stepId,
1455
- },
1456
- };
1457
- nestedStepMessages.set(key, message);
1458
- emitMessage(message);
1459
- return message;
1460
- };
1461
-
1462
1409
  const normalizeKey = (value: unknown): string | null => {
1463
1410
  if (value === null || value === undefined) return null;
1464
1411
  try {
@@ -1496,11 +1443,12 @@ export class AgentWidgetClient {
1496
1443
  const ensureAssistantMessage = () => {
1497
1444
  if (assistantMessage) return assistantMessage;
1498
1445
  let id: string;
1446
+ const segment = currentTextBlockId;
1499
1447
  if (!assistantIdConsumed && baseAssistantId) {
1500
1448
  id = baseAssistantId;
1501
1449
  assistantIdConsumed = true;
1502
- } else if (baseAssistantId && partIdState.current) {
1503
- id = `${baseAssistantId}_${partIdState.current}`;
1450
+ } else if (baseAssistantId && segment) {
1451
+ id = `${baseAssistantId}_${segment}`;
1504
1452
  } else {
1505
1453
  id = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
1506
1454
  }
@@ -1692,11 +1640,6 @@ export class AgentWidgetClient {
1692
1640
  // Rebuild incremental text by sequence so late arrivals can repair already-emitted
1693
1641
  // content after the reorder buffer's gap-timeout flush.
1694
1642
  const orderedChunkBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1695
- const assistantMessagesByPartId = new Map<string, AgentWidgetMessage>();
1696
- // Only the most-recently sealed segment is reconciled with step_complete's
1697
- // final response. Earlier segments rely on their own async parser microtasks
1698
- // resolving via the closure-captured `assistant` variable.
1699
- let lastSealedTextSegment: AgentWidgetMessage | null = null;
1700
1643
 
1701
1644
  const insertOrderedChunk = (key: string, seq: number, text: string): string => {
1702
1645
  let chunks = orderedChunkBuffers.get(key);
@@ -1821,58 +1764,241 @@ export class AgentWidgetClient {
1821
1764
  finalizeCleanup();
1822
1765
  };
1823
1766
 
1824
- // Sequence reorder buffer: SSE events carrying a `seq` (or `sequenceIndex`)
1825
- // field are held and re-emitted in sequence order so that transport-level
1826
- // reordering doesn't produce garbled output.
1767
+ // === Unified flow text channel ===
1768
+ // Flow prompt-step text streams as `text_delta` blocks (segmented by
1769
+ // `text_start`/`text_complete`) and can be structured JSON, so each block
1770
+ // runs through the per-bubble structured-content parser — agent text stays
1771
+ // plain. This is the legacy step_delta parser core, re-keyed from `partId`
1772
+ // to the unified block-id bubble. The caller materializes the bubble lazily
1773
+ // (whitespace-only blocks around tool boundaries never leave a stray bubble)
1774
+ // and `step_complete.result.response` reconciles the authoritative final.
1775
+ let lastSealedFlowBubble: AgentWidgetMessage | null = null;
1776
+
1777
+ // Stream one accumulated chunk of flow block text through the parser, setting
1778
+ // display `content` (extracted) + `rawContent` (raw) and emitting. Mirrors the
1779
+ // legacy step_delta chunk path; plain text bypasses the structured parser.
1780
+ const applyFlowTextChunk = (
1781
+ assistant: AgentWidgetMessage,
1782
+ accumulatedRaw: string,
1783
+ chunk: string,
1784
+ chunkSeq: number | undefined
1785
+ ) => {
1786
+ assistant.rawContent = accumulatedRaw;
1787
+ if (!streamParsers.has(assistant.id)) {
1788
+ streamParsers.set(assistant.id, this.createStreamParser());
1789
+ }
1790
+ const parser = streamParsers.get(assistant.id)!;
1791
+ const looksLikeJson =
1792
+ accumulatedRaw.trim().startsWith("{") || accumulatedRaw.trim().startsWith("[");
1793
+ if (looksLikeJson) {
1794
+ rawContentBuffers.set(assistant.id, accumulatedRaw);
1795
+ }
1796
+ const isPlainTextParser = (parser as any).__isPlainTextParser === true;
1797
+ if (isPlainTextParser) {
1798
+ assistant.content =
1799
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1800
+ rawContentBuffers.delete(assistant.id);
1801
+ streamParsers.delete(assistant.id);
1802
+ assistant.rawContent = undefined;
1803
+ emitMessage(assistant);
1804
+ return;
1805
+ }
1806
+ const parsedResult = parser.processChunk(accumulatedRaw);
1807
+ if (parsedResult instanceof Promise) {
1808
+ parsedResult
1809
+ .then((result) => {
1810
+ const text = typeof result === "string" ? result : result?.text ?? null;
1811
+ if (text !== null && text.trim() !== "") {
1812
+ assistant.content = text;
1813
+ emitMessage(assistant);
1814
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
1815
+ assistant.content =
1816
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1817
+ rawContentBuffers.delete(assistant.id);
1818
+ streamParsers.delete(assistant.id);
1819
+ assistant.rawContent = undefined;
1820
+ emitMessage(assistant);
1821
+ }
1822
+ })
1823
+ .catch(() => {
1824
+ assistant.content =
1825
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1826
+ rawContentBuffers.delete(assistant.id);
1827
+ streamParsers.delete(assistant.id);
1828
+ assistant.rawContent = undefined;
1829
+ emitMessage(assistant);
1830
+ });
1831
+ } else {
1832
+ const text =
1833
+ typeof parsedResult === "string" ? parsedResult : parsedResult?.text ?? null;
1834
+ if (text !== null && text.trim() !== "") {
1835
+ assistant.content = text;
1836
+ emitMessage(assistant);
1837
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
1838
+ assistant.content =
1839
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
1840
+ rawContentBuffers.delete(assistant.id);
1841
+ streamParsers.delete(assistant.id);
1842
+ assistant.rawContent = undefined;
1843
+ emitMessage(assistant);
1844
+ }
1845
+ }
1846
+ };
1847
+
1848
+ // Seal a flow text block at `text_complete`: run final structured extraction
1849
+ // off the accumulated raw buffer (U2: `text_complete.text` mirrors that raw
1850
+ // buffer, so we never double-count), then finalize the bubble. The structured
1851
+ // `step_complete.result.response` reconciles afterward.
1852
+ const finalizeFlowTextBlock = (
1853
+ assistant: AgentWidgetMessage,
1854
+ finalContent?: unknown
1855
+ ) => {
1856
+ const effectiveFinal =
1857
+ finalContent !== undefined && finalContent !== null
1858
+ ? finalContent
1859
+ : assistant.content;
1860
+ if (
1861
+ effectiveFinal === undefined ||
1862
+ effectiveFinal === null ||
1863
+ effectiveFinal === ""
1864
+ ) {
1865
+ assistant.streaming = false;
1866
+ emitMessage(assistant);
1867
+ return;
1868
+ }
1869
+ const rawBuffer = rawContentBuffers.get(assistant.id);
1870
+ const contentToProcess = rawBuffer ?? ensureStringContent(effectiveFinal);
1871
+ assistant.rawContent = contentToProcess;
1872
+ const parser = streamParsers.get(assistant.id);
1873
+ let extractedText: string | null = null;
1874
+ let asyncPending = false;
1875
+ if (parser) {
1876
+ extractedText = parser.getExtractedText();
1877
+ if (extractedText === null) {
1878
+ extractedText = extractTextFromJson(contentToProcess);
1879
+ }
1880
+ if (extractedText === null) {
1881
+ const parsedResult = parser.processChunk(contentToProcess);
1882
+ if (parsedResult instanceof Promise) {
1883
+ asyncPending = true;
1884
+ parsedResult
1885
+ .then((result) => {
1886
+ const text =
1887
+ typeof result === "string" ? result : result?.text ?? null;
1888
+ if (text !== null) {
1889
+ assistant.content = text;
1890
+ assistant.streaming = false;
1891
+ streamParsers.delete(assistant.id);
1892
+ rawContentBuffers.delete(assistant.id);
1893
+ emitMessage(assistant);
1894
+ }
1895
+ })
1896
+ .catch(() => {});
1897
+ } else {
1898
+ extractedText =
1899
+ typeof parsedResult === "string"
1900
+ ? parsedResult
1901
+ : parsedResult?.text ?? null;
1902
+ }
1903
+ }
1904
+ }
1905
+ if (!asyncPending) {
1906
+ if (extractedText !== null && extractedText.trim() !== "") {
1907
+ assistant.content = extractedText;
1908
+ } else if (!rawContentBuffers.has(assistant.id)) {
1909
+ assistant.content = ensureStringContent(effectiveFinal);
1910
+ }
1911
+ const parserToClose = streamParsers.get(assistant.id);
1912
+ if (parserToClose) {
1913
+ const closeResult = parserToClose.close?.();
1914
+ if (closeResult instanceof Promise) closeResult.catch(() => {});
1915
+ streamParsers.delete(assistant.id);
1916
+ }
1917
+ rawContentBuffers.delete(assistant.id);
1918
+ assistant.streaming = false;
1919
+ emitMessage(assistant);
1920
+ }
1921
+ };
1922
+
1923
+ // Materialize (lazily) the message for a nested flow-as-tool block, tagged
1924
+ // with the parent tool-call id so the UI renders it in the parent tool's row.
1925
+ const ensureNestedBlockMessage = (
1926
+ blockId: string,
1927
+ parentToolCallId: string,
1928
+ variant?: "reasoning"
1929
+ ): AgentWidgetMessage => {
1930
+ const existing = nestedBlockMessages.get(blockId);
1931
+ if (existing) return existing;
1932
+ const message: AgentWidgetMessage = {
1933
+ id: `nested-${parentToolCallId}-${blockId}`,
1934
+ role: "assistant",
1935
+ content: "",
1936
+ createdAt: new Date().toISOString(),
1937
+ streaming: true,
1938
+ sequence: nextSequence(),
1939
+ ...(variant ? { variant } : {}),
1940
+ ...(variant === "reasoning"
1941
+ ? { reasoning: { id: blockId, status: "streaming", chunks: [] } }
1942
+ : {}),
1943
+ agentMetadata: { parentToolId: parentToolCallId },
1944
+ };
1945
+ nestedBlockMessages.set(blockId, message);
1946
+ emitMessage(message);
1947
+ return message;
1948
+ };
1949
+
1950
+ // Ready queue of parsed unified frames awaiting a drain. The API streams the
1951
+ // neutral 33-event unified vocabulary; each frame is parsed in the SSE loop
1952
+ // below and rendered directly by the handler (no translation bridge), then
1953
+ // pushed here. The unified stream is a single, in-order SSE connection, so
1954
+ // frames drain straight through with no reordering.
1827
1955
  const seqReadyQueue: Array<{ payloadType: string; payload: any }> = [];
1828
- let isDrainScheduled = false;
1829
- // Declared here so scheduleReadyQueueDrain can reference it; assigned
1830
- // after all handler-scoped variables are initialised (before the SSE loop).
1956
+ // Declared here so later closures can reference it; assigned after all
1957
+ // handler-scoped variables are initialised (before the SSE loop).
1831
1958
  let drainReadyQueue: () => void;
1832
- // Two drain paths: both are intentional, do not remove either:
1833
- // 1. Microtask drain (scheduleReadyQueueDrain): required when the
1834
- // buffer's emitter fires from the gap-timeout setTimeout callback,
1835
- // because there is no surrounding synchronous drain site there.
1836
- // 2. Synchronous drain (drainReadyQueue() after each seqBuffer.push):
1837
- // skips an extra microtask hop on the hot in-order push path.
1838
- const scheduleReadyQueueDrain = () => {
1839
- if (isDrainScheduled) return;
1840
- isDrainScheduled = true;
1841
- queueMicrotask(() => {
1842
- isDrainScheduled = false;
1843
- drainReadyQueue();
1844
- });
1845
- };
1846
- const seqBuffer = new SequenceReorderBuffer((payloadType: string, payload: any) => {
1847
- seqReadyQueue.push({ payloadType, payload });
1848
- scheduleReadyQueueDrain();
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;
1959
+ // Per-stream media-block buffer: the unified media triad
1960
+ // (media_start/media_delta/media_complete) is reassembled here into a single
1961
+ // synthetic message at media_complete, keyed by the block id.
1962
+ const mediaBuffers = new Map<
1963
+ string,
1964
+ { mediaType?: string; role?: string; toolCallId?: unknown; parts: string[] }
1965
+ >();
1966
+ // Tracks the last iteration surfaced as a per-iteration message boundary, so
1967
+ // `turn_start` advancing the iteration rotates the bubble in 'separate' mode.
1968
+ let lastIterationSeen = 0;
1969
+ // Execution kind, resolved from the leading `execution_start` frame. Drives
1970
+ // the agent-vs-flow branches that the single unified vocabulary collapses.
1971
+ let executionKind: "agent" | "flow" = "agent";
1972
+ // Open turn id (from `turn_start`). Unified text/reasoning deltas carry their
1973
+ // own block id, not the turn id, so the turn id is threaded onto agentMetadata
1974
+ // from here.
1975
+ let openTurnId: string | null = null;
1861
1976
  // Agent execution state tracking
1862
1977
  let agentExecution: AgentExecutionState | null = null;
1863
1978
  // Track assistant messages per agent iteration for 'separate' mode
1864
1979
  const agentIterationMessages = new Map<number, AgentWidgetMessage>();
1865
1980
  const iterationDisplay = this.config.iterationDisplay ?? 'separate';
1866
1981
 
1867
- // Drains reorder-buffered events through the main event handler.
1868
- // Also invoked after the SSE loop exits so any events buffered at
1982
+ // Drains the queued transduced events through the main event handler.
1983
+ // Also invoked after the SSE loop exits so any events queued at
1869
1984
  // end-of-stream are processed.
1870
1985
  drainReadyQueue = () => {
1871
1986
  for (let i = 0; i < seqReadyQueue.length; i++) {
1872
1987
  const payloadType = seqReadyQueue[i].payloadType;
1873
1988
  const payload = seqReadyQueue[i].payload;
1874
1989
 
1875
- if (payloadType === "reason_start") {
1990
+ if (payloadType === "reasoning_start") {
1991
+ // Nested flow-as-tool thinking (PR #4602): route to the parent tool's row.
1992
+ const rStartBlockId = typeof payload.id === "string" ? payload.id : null;
1993
+ const rStartParent =
1994
+ typeof payload.parentToolCallId === "string" && payload.parentToolCallId
1995
+ ? payload.parentToolCallId
1996
+ : null;
1997
+ if (rStartBlockId && rStartParent) {
1998
+ nestedBlockParent.set(rStartBlockId, rStartParent);
1999
+ ensureNestedBlockMessage(rStartBlockId, rStartParent, "reasoning");
2000
+ continue;
2001
+ }
1876
2002
  const reasoningId =
1877
2003
  resolveReasoningId(payload, true) ?? `reason-${nextSequence()}`;
1878
2004
  const reasoningMessage = ensureReasoningMessage(reasoningId);
@@ -1886,10 +2012,29 @@ export class AgentWidgetClient {
1886
2012
  resolveTimestamp(payload.startedAt ?? payload.timestamp);
1887
2013
  reasoningMessage.reasoning.completedAt = undefined;
1888
2014
  reasoningMessage.reasoning.durationMs = undefined;
2015
+ if (payload.scope === "loop" || payload.scope === "turn") {
2016
+ reasoningMessage.reasoning.scope = payload.scope;
2017
+ }
1889
2018
  reasoningMessage.streaming = true;
1890
2019
  reasoningMessage.reasoning.status = "streaming";
1891
2020
  emitMessage(reasoningMessage);
1892
- } else if (payloadType === "reason_delta" || payloadType === "reason_chunk") {
2021
+ } else if (payloadType === "reasoning_delta") {
2022
+ // Nested flow-as-tool thinking: append to the parent-tool-row message.
2023
+ const rDeltaBlockId = typeof payload.id === "string" ? payload.id : null;
2024
+ if (
2025
+ rDeltaBlockId &&
2026
+ nestedBlockParent.has(rDeltaBlockId) &&
2027
+ nestedBlockMessages.has(rDeltaBlockId)
2028
+ ) {
2029
+ const nested = nestedBlockMessages.get(rDeltaBlockId)!;
2030
+ const nestedChunk =
2031
+ payload.reasoningText ?? payload.text ?? payload.delta ?? "";
2032
+ if (nestedChunk && payload.hidden !== true && nested.reasoning) {
2033
+ nested.reasoning.chunks.push(String(nestedChunk));
2034
+ emitMessage(nested);
2035
+ }
2036
+ continue;
2037
+ }
1893
2038
  const reasoningId =
1894
2039
  resolveReasoningId(payload, false) ??
1895
2040
  resolveReasoningId(payload, true) ??
@@ -1933,13 +2078,51 @@ export class AgentWidgetClient {
1933
2078
  }
1934
2079
  reasoningMessage.streaming = reasoningMessage.reasoning.status !== "complete";
1935
2080
  emitMessage(reasoningMessage);
1936
- } else if (payloadType === "reason_complete") {
2081
+ } else if (payloadType === "reasoning_complete") {
2082
+ // Nested flow-as-tool thinking close: seal the parent-tool-row message.
2083
+ const rCompleteBlockId = typeof payload.id === "string" ? payload.id : null;
2084
+ if (
2085
+ rCompleteBlockId &&
2086
+ nestedBlockParent.has(rCompleteBlockId) &&
2087
+ nestedBlockMessages.has(rCompleteBlockId)
2088
+ ) {
2089
+ const nested = nestedBlockMessages.get(rCompleteBlockId)!;
2090
+ if (nested.reasoning) {
2091
+ const nestedReflection =
2092
+ typeof payload.text === "string" ? payload.text : "";
2093
+ if (nestedReflection && nested.reasoning.chunks.length === 0) {
2094
+ nested.reasoning.chunks.push(nestedReflection);
2095
+ }
2096
+ nested.reasoning.status = "complete";
2097
+ nested.streaming = false;
2098
+ emitMessage(nested);
2099
+ }
2100
+ nestedBlockParent.delete(rCompleteBlockId);
2101
+ nestedBlockMessages.delete(rCompleteBlockId);
2102
+ continue;
2103
+ }
1937
2104
  const reasoningId =
1938
2105
  resolveReasoningId(payload, false) ??
1939
2106
  resolveReasoningId(payload, true) ??
1940
2107
  `reason-${nextSequence()}`;
2108
+ // A close carrying text (or scope:"loop") is a cross-iteration
2109
+ // reflection fold (merged spec §4 E3): the API streams nothing for the
2110
+ // block, then delivers the whole reflection as `text` on the close.
2111
+ // Materialize a reasoning bubble even if no reasoning_start/delta opened
2112
+ // one, and adopt the close text when the block streamed no chunks (the
2113
+ // common reflection case, where reasoning_start opened an empty bubble).
2114
+ const reflectionText = typeof payload.text === "string" ? payload.text : "";
2115
+ if (!reasoningMessages.get(reasoningId) && (reflectionText || payload.scope === "loop")) {
2116
+ ensureReasoningMessage(reasoningId);
2117
+ }
1941
2118
  const reasoningMessage = reasoningMessages.get(reasoningId);
1942
2119
  if (reasoningMessage?.reasoning) {
2120
+ if (payload.scope === "loop" || payload.scope === "turn") {
2121
+ reasoningMessage.reasoning.scope = payload.scope;
2122
+ }
2123
+ if (reflectionText && reasoningMessage.reasoning.chunks.length === 0) {
2124
+ reasoningMessage.reasoning.chunks.push(reflectionText);
2125
+ }
1943
2126
  reasoningMessage.reasoning.status = "complete";
1944
2127
  reasoningMessage.reasoning.completedAt = resolveTimestamp(
1945
2128
  payload.completedAt ?? payload.timestamp
@@ -1958,14 +2141,30 @@ export class AgentWidgetClient {
1958
2141
  reasoningContext.byStep.delete(stepKey);
1959
2142
  }
1960
2143
  } else if (payloadType === "tool_start") {
1961
- const toolId =
1962
- resolveToolId(payload, true) ?? `tool-${nextSequence()}`;
2144
+ // Unified tool family (agent + flow). Seal any open assistant bubble so
2145
+ // text→tool→text interleaves chronologically (the API also emits a
2146
+ // text_complete here, so this is usually a no-op — kept for safety).
2147
+ if (assistantMessage) {
2148
+ (assistantMessage as AgentWidgetMessage).streaming = false;
2149
+ emitMessage(assistantMessage as AgentWidgetMessage);
2150
+ assistantMessage = null;
2151
+ }
2152
+ // Unified denormalizes `iteration` onto tool frames too (merged spec §2).
2153
+ // Track it so media/reflection blocks — which carry no iteration of their
2154
+ // own — can be stamped with the enclosing iteration even on tool-only
2155
+ // turns that never emit a `turn_start`.
2156
+ if (typeof payload.iteration === "number") lastIterationSeen = payload.iteration;
2157
+ const toolId: string =
2158
+ (typeof payload.toolCallId === "string" ? payload.toolCallId : undefined) ??
2159
+ resolveToolId(payload, true) ??
2160
+ `tool-${nextSequence()}`;
1963
2161
  const toolName = payload.toolName ?? payload.name;
1964
2162
  // Suppress tool UI for artifact emit tools: artifacts are handled via artifact_* events
1965
2163
  if (isArtifactEmitToolName(toolName)) {
1966
2164
  artifactToolCallIds.add(toolId);
1967
2165
  continue;
1968
2166
  }
2167
+ trackToolId(getToolCallKey(payload), toolId);
1969
2168
  const toolMessage = ensureToolMessage(toolId);
1970
2169
  const tool = toolMessage.toolCall ?? {
1971
2170
  id: toolId,
@@ -1973,10 +2172,10 @@ export class AgentWidgetClient {
1973
2172
  };
1974
2173
  tool.name = toolName ?? tool.name;
1975
2174
  tool.status = "running";
1976
- if (payload.args !== undefined) {
1977
- tool.args = payload.args;
1978
- } else if (payload.parameters !== undefined) {
2175
+ if (payload.parameters !== undefined) {
1979
2176
  tool.args = payload.parameters;
2177
+ } else if (payload.args !== undefined) {
2178
+ tool.args = payload.args;
1980
2179
  }
1981
2180
  tool.startedAt =
1982
2181
  tool.startedAt ??
@@ -1985,15 +2184,14 @@ export class AgentWidgetClient {
1985
2184
  tool.durationMs = undefined;
1986
2185
  toolMessage.toolCall = tool;
1987
2186
  toolMessage.streaming = true;
1988
- const agentCtx = payload.agentContext;
1989
- if (agentCtx || payload.executionId) {
2187
+ if (payload.executionId) {
1990
2188
  toolMessage.agentMetadata = {
1991
- executionId: agentCtx?.executionId ?? payload.executionId,
1992
- iteration: agentCtx?.iteration ?? payload.iteration,
2189
+ executionId: payload.executionId,
2190
+ iteration: payload.iteration,
1993
2191
  };
1994
2192
  }
1995
2193
  emitMessage(toolMessage);
1996
- } else if (payloadType === "tool_chunk" || payloadType === "tool_delta") {
2194
+ } else if (payloadType === "tool_output_delta") {
1997
2195
  const toolId =
1998
2196
  resolveToolId(payload, false) ??
1999
2197
  resolveToolId(payload, true) ??
@@ -2072,11 +2270,17 @@ export class AgentWidgetClient {
2072
2270
  if (callKey) {
2073
2271
  toolContext.byCall.delete(callKey);
2074
2272
  }
2075
- } else if (payloadType === "step_await" && payload.awaitReason === "local_tool_required" && payload.toolName) {
2076
- // LOCAL tool pause. Runtype's prompt step throws LocalToolRequiredError
2077
- // when the model calls a tool with `toolType: "local"`. The server
2078
- // emits step_await with the tool name, params, and execution id; the
2079
- // execution pauses until the client POSTs /resume with toolOutputs.
2273
+ } else if (payloadType === "await" && payload.toolName) {
2274
+ // LOCAL tool pause. Two wire shapes resolve here, by dispatch target:
2275
+ // - FLOW dispatch `step_await` + `awaitReason: "local_tool_required"`
2276
+ // (Runtype's prompt step throws LocalToolRequiredError when the model
2277
+ // calls a `toolType: "local"` tool).
2278
+ // - AGENT dispatch → `agent_await` (the agent runtime's native pause).
2279
+ // Either way the server emits the tool name, params, and execution id;
2280
+ // the execution pauses until the client POSTs /resume with toolOutputs.
2281
+ // `agent_await` carries a BARE tool name plus an `origin`; page tools
2282
+ // (origin "webmcp") are normalized to the `webmcp:`-prefixed form below
2283
+ // so the bridge + session.ts `/resume` keying are identical for both.
2080
2284
  //
2081
2285
  // Upsert a fully-populated tool-variant message so the existing
2082
2286
  // ask_user_question bubble + sheet paths fire. Mark the message with
@@ -2099,7 +2303,15 @@ export class AgentWidgetClient {
2099
2303
  const toolId =
2100
2304
  toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
2101
2305
  const toolMessage = ensureToolMessage(toolId);
2102
- const toolName = payload.toolName as string;
2306
+ const rawToolName = payload.toolName as string;
2307
+ // `agent_await` page tools arrive with a bare name; synthesize the
2308
+ // `webmcp:` prefix so isWebMcpToolName (and the bridge's prefix-strip on
2309
+ // resume) treat them identically to a flow `step_await`.
2310
+ const toolName =
2311
+ payload.origin === "webmcp" &&
2312
+ !isWebMcpToolName(rawToolName)
2313
+ ? `webmcp:${rawToolName}`
2314
+ : rawToolName;
2103
2315
  const webMcpTool = isWebMcpToolName(toolName);
2104
2316
  const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
2105
2317
  tool.name = toolName;
@@ -2113,7 +2325,8 @@ export class AgentWidgetClient {
2113
2325
  tool.status = webMcpTool ? "running" : "complete";
2114
2326
  tool.chunks = tool.chunks ?? [];
2115
2327
  tool.startedAt =
2116
- tool.startedAt ?? resolveTimestamp(payload.startedAt ?? payload.timestamp);
2328
+ tool.startedAt ??
2329
+ resolveTimestamp(payload.startedAt ?? payload.timestamp ?? payload.awaitedAt);
2117
2330
  if (webMcpTool) {
2118
2331
  tool.completedAt = undefined;
2119
2332
  tool.duration = undefined;
@@ -2134,339 +2347,123 @@ export class AgentWidgetClient {
2134
2347
  };
2135
2348
  emitMessage(toolMessage);
2136
2349
  } else if (payloadType === "text_start") {
2137
- // Lifecycle event: a new text segment is beginning (emitted at tool boundaries).
2138
- // When toolContext is present this fired inside a nested flow: it must not
2139
- // seal or rotate the outer assistant message. Nested prompt segmentation is
2140
- // handled via nestedStepMessages keyed by (toolId, stepId).
2141
- if ((payload as any).toolContext?.toolId) {
2350
+ // Nested flow-as-tool text (PR #4602): a `parentToolCallId` means this
2351
+ // block belongs to a flow running as that tool record the mapping and
2352
+ // leave the top-level assistant bubble untouched (the nested deltas route
2353
+ // into the parent tool's row).
2354
+ const startBlockId = typeof payload.id === "string" ? payload.id : null;
2355
+ const startParent =
2356
+ typeof payload.parentToolCallId === "string" && payload.parentToolCallId
2357
+ ? payload.parentToolCallId
2358
+ : null;
2359
+ if (startBlockId && startParent) {
2360
+ nestedBlockParent.set(startBlockId, startParent);
2142
2361
  continue;
2143
2362
  }
2144
- const incomingPartId = payload.partId;
2145
- if (incomingPartId !== undefined && partIdState.current !== null && incomingPartId !== partIdState.current) {
2146
- const prev = assistantMessage as AgentWidgetMessage | null;
2147
- if (prev) {
2363
+ // Unified text-channel block open. A new block id means a new bubble, so
2364
+ // seal any open assistant bubble; the next text_delta creates a fresh one
2365
+ // (lazily). The API emits a fresh block at every tool/media/approval/await
2366
+ // boundary, so block-id keying drives segmentation — no partId.
2367
+ const prev = assistantMessage as AgentWidgetMessage | null;
2368
+ if (prev) {
2369
+ // Normally text_complete already sealed the prior block; this is the
2370
+ // defensive path if a producer opens a new block without closing.
2371
+ if (executionKind === "flow") {
2372
+ finalizeFlowTextBlock(prev);
2373
+ lastSealedFlowBubble = prev;
2374
+ } else {
2148
2375
  prev.streaming = false;
2149
2376
  emitMessage(prev);
2150
- lastSealedTextSegment = prev;
2151
- assistantMessage = null;
2152
- didSplitByPartId = true;
2153
2377
  }
2154
- }
2155
- if (incomingPartId !== undefined) {
2156
- partIdState.current = incomingPartId;
2157
- }
2158
- } else if (payloadType === "text_end") {
2159
- // Lifecycle event: current text segment ended (tool call about to start).
2160
- // When toolContext is present the boundary belongs to a nested flow: leave
2161
- // outer assistant state alone so the outer stream is never interrupted by
2162
- // nested activity.
2163
- if ((payload as any).toolContext?.toolId) {
2164
- continue;
2165
- }
2166
- // Seal the current assistant message so the next segment gets a new one
2167
- const prev = assistantMessage as AgentWidgetMessage | null;
2168
- if (prev) {
2169
- prev.streaming = false;
2170
- emitMessage(prev);
2171
- lastSealedTextSegment = prev;
2172
2378
  assistantMessage = null;
2173
- didSplitByPartId = true;
2174
2379
  }
2175
- } else if (payloadType === "step_chunk" || payloadType === "step_delta") {
2176
- // Only process chunks for prompt steps, not tool/context steps
2177
- const stepType = (payload as any).stepType;
2178
- const executionType = (payload as any).executionType;
2179
- if (stepType === "tool" || executionType === "context") {
2180
- // Skip tool-related chunks - they're handled by tool_start/tool_complete
2380
+ currentTextBlockId =
2381
+ typeof payload.id === "string" ? payload.id : currentTextBlockId;
2382
+ pendingFlowRaw = "";
2383
+ } else if (payloadType === "text_delta") {
2384
+ // Nested flow-as-tool text: route to the parent tool's row, through the
2385
+ // same structured-content parser, never the top-level assistant channel.
2386
+ const deltaBlockId = typeof payload.id === "string" ? payload.id : null;
2387
+ const nestedParent = deltaBlockId
2388
+ ? nestedBlockParent.get(deltaBlockId)
2389
+ : undefined;
2390
+ if (deltaBlockId && nestedParent) {
2391
+ const nestedDelta =
2392
+ typeof payload.delta === "string" ? payload.delta : "";
2393
+ const nestedRaw = (nestedBlockRaw.get(deltaBlockId) ?? "") + nestedDelta;
2394
+ nestedBlockRaw.set(deltaBlockId, nestedRaw);
2395
+ if (nestedRaw.trim() === "") continue;
2396
+ const nested = ensureNestedBlockMessage(deltaBlockId, nestedParent);
2397
+ nested.agentMetadata = {
2398
+ ...nested.agentMetadata,
2399
+ executionId: payload.executionId,
2400
+ parentToolId: nestedParent,
2401
+ };
2402
+ applyFlowTextChunk(nested, nestedRaw, nestedDelta, undefined);
2181
2403
  continue;
2182
2404
  }
2183
-
2184
- // Nested flow routing: when toolContext is present, this step_delta
2185
- // originated inside a nested flow executed as a tool. Surface it as
2186
- // its own assistant message keyed by the nested step id, so authors
2187
- // who add send-stream / prompt steps inside their flow see them as
2188
- // real messages in the timeline, in order: rather than merging
2189
- // into the outer assistant bubble or getting buried in the tool
2190
- // card. Each nested step id gets its own message; the parent tool
2191
- // bubble continues to represent the invocation via tool_* events.
2192
- const nestedToolCtx = (payload as any).toolContext as
2193
- | { toolId?: string; stepId?: string; executionId?: string }
2194
- | undefined;
2195
- if (nestedToolCtx?.toolId) {
2196
- const nestedStepId = String(
2197
- payload.id ?? nestedToolCtx.stepId ?? `step-${nextSequence()}`
2198
- );
2199
- const incomingPartId =
2200
- payload.partId !== undefined && payload.partId !== null
2201
- ? String(payload.partId)
2202
- : "";
2203
- const stepScopeKey = `${nestedToolCtx.toolId}::${nestedStepId}`;
2204
- const prevPartId = nestedPartIdByStep.get(stepScopeKey);
2205
-
2206
- // If partId changed within this nested step (prompt with inner
2207
- // tool call emitting a new text segment), seal the previous
2208
- // segment's message so each segment renders as its own bubble.
2209
- if (
2210
- incomingPartId !== "" &&
2211
- prevPartId !== undefined &&
2212
- prevPartId !== "" &&
2213
- prevPartId !== incomingPartId
2214
- ) {
2215
- const prev = nestedStepMessages.get(
2216
- getNestedStepKey(
2217
- nestedToolCtx.toolId,
2218
- nestedStepId,
2219
- prevPartId
2220
- )
2221
- );
2222
- if (prev && prev.streaming !== false) {
2223
- prev.streaming = false;
2224
- emitMessage(prev);
2225
- }
2226
- }
2227
- if (incomingPartId !== "") {
2228
- nestedPartIdByStep.set(stepScopeKey, incomingPartId);
2229
- }
2230
-
2231
- const nestedMsg = ensureNestedStepMessage(
2232
- nestedToolCtx.toolId,
2233
- nestedStepId,
2234
- incomingPartId,
2235
- nestedToolCtx.executionId
2236
- );
2237
- const nestedChunk =
2238
- payload.text ??
2239
- payload.delta ??
2240
- payload.content ??
2241
- payload.chunk ??
2242
- "";
2243
- if (nestedChunk) {
2244
- nestedMsg.content += String(nestedChunk);
2245
- nestedMsg.streaming = true;
2246
- emitMessage(nestedMsg);
2247
- }
2248
- if (payload.isComplete) {
2249
- nestedMsg.streaming = false;
2250
- emitMessage(nestedMsg);
2251
- }
2405
+ currentTextBlockId =
2406
+ typeof payload.id === "string" ? payload.id : currentTextBlockId;
2407
+ if (executionKind === "flow") {
2408
+ // Flow prompt-step text can be structured JSON: accumulate the raw
2409
+ // block and run it through the structured-content parser, keyed by the
2410
+ // block-id bubble. Materialize lazily so a whitespace-only block
2411
+ // (newlines around a tool boundary) never leaves a stray bubble.
2412
+ const delta = typeof payload.delta === "string" ? payload.delta : "";
2413
+ pendingFlowRaw += delta;
2414
+ if (pendingFlowRaw.trim() === "") continue;
2415
+ const assistant = ensureAssistantMessage();
2416
+ assistant.agentMetadata = {
2417
+ executionId: payload.executionId,
2418
+ iteration: payload.iteration,
2419
+ };
2420
+ applyFlowTextChunk(assistant, pendingFlowRaw, delta, undefined);
2421
+ lastAssistantInTurn = assistant;
2252
2422
  continue;
2253
2423
  }
2254
-
2255
- // partId-based segmentation: when partId changes, seal current message
2256
- // and start a new one so text and tools render in chronological order
2257
- const incomingPartId = payload.partId;
2258
- if (incomingPartId !== undefined && partIdState.current !== null && incomingPartId !== partIdState.current) {
2259
- const prev = assistantMessage as AgentWidgetMessage | null;
2260
- if (prev) {
2261
- prev.streaming = false;
2262
- emitMessage(prev);
2263
- lastSealedTextSegment = prev;
2264
- assistantMessage = null;
2265
- didSplitByPartId = true;
2266
- }
2267
- }
2268
- if (incomingPartId !== undefined) {
2269
- partIdState.current = incomingPartId;
2270
- }
2271
-
2272
- const assistant =
2273
- incomingPartId !== undefined
2274
- ? (assistantMessagesByPartId.get(incomingPartId) ?? ensureAssistantMessage())
2275
- : ensureAssistantMessage();
2276
- if (incomingPartId !== undefined) {
2277
- if (!assistant.partId) {
2278
- assistant.partId = incomingPartId;
2279
- }
2280
- assistantMessagesByPartId.set(incomingPartId, assistant);
2424
+ const assistant = ensureAssistantMessage();
2425
+ assistant.content += payload.delta ?? '';
2426
+ assistant.agentMetadata = {
2427
+ executionId: payload.executionId,
2428
+ iteration: payload.iteration,
2429
+ turnId: openTurnId ?? undefined,
2430
+ agentName: agentExecution?.agentName
2431
+ };
2432
+ lastAssistantInTurn = assistant;
2433
+ emitMessage(assistant);
2434
+ } else if (payloadType === "text_complete") {
2435
+ // Nested flow-as-tool text block close: seal its parent-tool-row message.
2436
+ const completeBlockId = typeof payload.id === "string" ? payload.id : null;
2437
+ if (completeBlockId && nestedBlockParent.has(completeBlockId)) {
2438
+ const nested = nestedBlockMessages.get(completeBlockId);
2439
+ if (nested) finalizeFlowTextBlock(nested);
2440
+ nestedBlockParent.delete(completeBlockId);
2441
+ nestedBlockRaw.delete(completeBlockId);
2442
+ nestedBlockMessages.delete(completeBlockId);
2443
+ continue;
2281
2444
  }
2282
- // Support various field names: text, delta, content, chunk (Runtype uses 'chunk')
2283
- const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
2284
- if (chunk) {
2285
- // Accumulate raw content for structured format parsing.
2286
- // Most out-of-order events are fixed at the dispatch layer, but once the
2287
- // gap timeout flushes later seqs we can still see genuine late arrivals.
2288
- // Rebuild chunked content by seq so those events repair prior output
2289
- // instead of appending in the wrong position.
2290
- const chunkSeq = typeof payload.seq === "number" ? payload.seq : undefined;
2291
- const chunkBufferKey = incomingPartId ?? assistant.id;
2292
- const accumulatedRaw =
2293
- chunkSeq !== undefined
2294
- ? insertOrderedChunk(chunkBufferKey, chunkSeq, String(chunk))
2295
- : (rawContentBuffers.get(assistant.id) ?? "") + chunk;
2296
- // Store raw content for action parsing, but NEVER set assistant.content to raw JSON
2297
- assistant.rawContent = accumulatedRaw;
2298
-
2299
- // Use stream parser to parse
2300
- if (!streamParsers.has(assistant.id)) {
2301
- streamParsers.set(assistant.id, this.createStreamParser());
2302
- }
2303
- const parser = streamParsers.get(assistant.id)!;
2304
-
2305
- // Check if content looks like JSON
2306
- const looksLikeJson = accumulatedRaw.trim().startsWith('{') || accumulatedRaw.trim().startsWith('[');
2307
-
2308
- // Store raw buffer before processing (needed for step_complete handler)
2309
- if (looksLikeJson) {
2310
- rawContentBuffers.set(assistant.id, accumulatedRaw);
2311
- }
2312
-
2313
- // Check if this is a plain text parser (marked with __isPlainTextParser)
2314
- const isPlainTextParser = (parser as any).__isPlainTextParser === true;
2315
-
2316
- // If plain text parser, just append the chunk directly
2317
- if (isPlainTextParser) {
2318
- assistant.content = chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
2319
- // Clear any raw buffer/parser since we're in plain text mode
2320
- rawContentBuffers.delete(assistant.id);
2321
- streamParsers.delete(assistant.id);
2322
- assistant.rawContent = undefined;
2323
- emitMessage(assistant);
2324
- continue;
2325
- }
2326
-
2327
- // Try to parse with the parser (for structured parsers)
2328
- const parsedResult = parser.processChunk(accumulatedRaw);
2329
-
2330
- // Handle async parser result
2331
- if (parsedResult instanceof Promise) {
2332
- parsedResult.then((result) => {
2333
- // Extract text from result (could be string or object)
2334
- const text = typeof result === 'string' ? result : result?.text ?? null;
2335
-
2336
- if (text !== null && text.trim() !== "") {
2337
- // Parser successfully extracted text: update the chunk's assistant
2338
- // (not assistantMessage; text_end may have cleared that ref before microtasks run)
2339
- assistant.content = text;
2340
- emitMessage(assistant);
2341
- } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
2342
- // Not a structured format - show as plain text
2343
- const currentAssistant = assistantMessage;
2344
- const targetAssistant =
2345
- currentAssistant && currentAssistant.id === assistant.id
2346
- ? currentAssistant
2347
- : assistant;
2348
- if (targetAssistant.id === assistant.id) {
2349
- targetAssistant.content =
2350
- chunkSeq !== undefined ? accumulatedRaw : targetAssistant.content + chunk;
2351
- rawContentBuffers.delete(targetAssistant.id);
2352
- streamParsers.delete(targetAssistant.id);
2353
- targetAssistant.rawContent = undefined;
2354
- emitMessage(targetAssistant);
2355
- }
2356
- }
2357
- // Otherwise wait for more chunks (incomplete structured format)
2358
- // Don't emit message if parser hasn't extracted text yet
2359
- }).catch(() => {
2360
- // On error, treat as plain text
2361
- assistant.content =
2362
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
2363
- rawContentBuffers.delete(assistant.id);
2364
- streamParsers.delete(assistant.id);
2365
- assistant.rawContent = undefined;
2366
- emitMessage(assistant);
2367
- });
2445
+ // Seal the current text block's bubble.
2446
+ const prev = assistantMessage as AgentWidgetMessage | null;
2447
+ if (prev) {
2448
+ if (executionKind === "flow") {
2449
+ // Final structured extraction off the accumulated raw buffer; the
2450
+ // authoritative step_complete.result.response reconciles next.
2451
+ finalizeFlowTextBlock(prev);
2452
+ lastSealedFlowBubble = prev;
2368
2453
  } else {
2369
- // Synchronous parser result
2370
- // Extract text from result (could be string, null, or object)
2371
- const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
2372
-
2373
- if (text !== null && text.trim() !== "") {
2374
- // Parser successfully extracted text
2375
- // Buffer is already set above
2376
- assistant.content = text;
2377
- emitMessage(assistant);
2378
- } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
2379
- // Not a structured format - show as plain text
2380
- assistant.content =
2381
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
2382
- // Clear any raw buffer/parser if we were in structured format mode
2383
- rawContentBuffers.delete(assistant.id);
2384
- streamParsers.delete(assistant.id);
2385
- assistant.rawContent = undefined;
2386
- emitMessage(assistant);
2387
- }
2388
- // Otherwise wait for more chunks (incomplete structured format)
2389
- // Don't emit message if parser hasn't extracted text yet
2390
- }
2391
-
2392
- // IMPORTANT: Don't call getExtractedText() and emit messages here
2393
- // This was causing raw JSON to be displayed because getExtractedText()
2394
- // wasn't extracting the "text" field correctly during streaming
2395
- }
2396
- if (payload.isComplete) {
2397
- const finalContent = payload.result?.response ?? assistant.content;
2398
- if (finalContent) {
2399
- // Check if we have raw content buffer that needs final processing
2400
- const rawBuffer = rawContentBuffers.get(assistant.id);
2401
- const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
2402
- assistant.rawContent = contentToProcess;
2403
-
2404
- // Try to extract text from final structured content
2405
- const parser = streamParsers.get(assistant.id);
2406
- let extractedText: string | null = null;
2407
- let asyncPending = false;
2408
-
2409
- if (parser) {
2410
- // First check if parser already has extracted text
2411
- extractedText = parser.getExtractedText();
2412
-
2413
- if (extractedText === null) {
2414
- // Try extracting with regex
2415
- extractedText = extractTextFromJson(contentToProcess);
2416
- }
2417
-
2418
- if (extractedText === null) {
2419
- // Try parser.processChunk as last resort
2420
- const parsedResult = parser.processChunk(contentToProcess);
2421
- if (parsedResult instanceof Promise) {
2422
- asyncPending = true;
2423
- parsedResult.then((result) => {
2424
- // Extract text from result (could be string or object)
2425
- const text = typeof result === 'string' ? result : result?.text ?? null;
2426
- if (text !== null) {
2427
- const currentAssistant = assistantMessage;
2428
- if (currentAssistant && currentAssistant.id === assistant.id) {
2429
- currentAssistant.content = text;
2430
- currentAssistant.streaming = false;
2431
- // Clean up
2432
- streamParsers.delete(currentAssistant.id);
2433
- rawContentBuffers.delete(currentAssistant.id);
2434
- emitMessage(currentAssistant);
2435
- }
2436
- }
2437
- });
2438
- } else {
2439
- // Extract text from synchronous result
2440
- extractedText = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
2441
- }
2442
- }
2443
- }
2444
-
2445
- // Skip sync emit if we're waiting on async parser
2446
- if (!asyncPending) {
2447
- // Set content: use extracted text if available, otherwise use raw content
2448
- if (extractedText !== null && extractedText.trim() !== "") {
2449
- assistant.content = extractedText;
2450
- } else if (!rawContentBuffers.has(assistant.id)) {
2451
- // Only use raw final content if we didn't accumulate chunks
2452
- assistant.content = ensureStringContent(finalContent);
2453
- }
2454
-
2455
- // Clean up parser and buffer
2456
- const parserToClose = streamParsers.get(assistant.id);
2457
- if (parserToClose) {
2458
- const closeResult = parserToClose.close?.();
2459
- if (closeResult instanceof Promise) {
2460
- closeResult.catch(() => {});
2461
- }
2462
- streamParsers.delete(assistant.id);
2463
- }
2464
- rawContentBuffers.delete(assistant.id);
2465
- assistant.streaming = false;
2466
- emitMessage(assistant);
2454
+ // U2: text_complete carries the assembled text, but the bubble already
2455
+ // holds it from the deltas only fall back to payload.text when no
2456
+ // delta content was seen, never double-count.
2457
+ if ((prev.content ?? "") === "" && typeof payload.text === "string") {
2458
+ prev.content = payload.text;
2467
2459
  }
2460
+ prev.streaming = false;
2461
+ emitMessage(prev);
2468
2462
  }
2463
+ assistantMessage = null;
2469
2464
  }
2465
+ currentTextBlockId = null;
2466
+ pendingFlowRaw = "";
2470
2467
  } else if (payloadType === "step_complete") {
2471
2468
  // Only process completions for prompt steps, not tool/context steps
2472
2469
  const stepType = (payload as any).stepType;
@@ -2476,378 +2473,139 @@ export class AgentWidgetClient {
2476
2473
  continue;
2477
2474
  }
2478
2475
 
2479
- // Nested flow: seal every segment message produced by this nested
2480
- // step (a single nested prompt step may have produced multiple
2481
- // messages, one per partId, when inner tool calls split it). The
2482
- // outer assistantMessage state is untouched so reconciliation for
2483
- // the outer flow still works.
2484
- const nestedCompleteCtx = (payload as any).toolContext as
2485
- | { toolId?: string; stepId?: string; executionId?: string }
2486
- | undefined;
2487
- if (nestedCompleteCtx?.toolId) {
2488
- const nestedStepId = String(
2489
- payload.id ?? nestedCompleteCtx.stepId ?? ""
2490
- );
2491
- if (nestedStepId) {
2492
- const prefix = getNestedStepPrefix(
2493
- nestedCompleteCtx.toolId,
2494
- nestedStepId
2495
- );
2496
- for (const [key, msg] of nestedStepMessages) {
2497
- if (key.startsWith(prefix) && msg.streaming !== false) {
2498
- msg.streaming = false;
2499
- emitMessage(msg);
2500
- }
2501
- }
2502
- nestedPartIdByStep.delete(
2503
- `${nestedCompleteCtx.toolId}::${nestedStepId}`
2504
- );
2476
+ // A failed step (`success:false`) including the legacy `step_error`
2477
+ // event, which the unified encoder folds into a failed `step_complete`
2478
+ // surfaces as a terminal error and finalizes the stream.
2479
+ if (payload.success === false) {
2480
+ const e = payload.error;
2481
+ const message =
2482
+ typeof e === "string" && e !== ""
2483
+ ? e
2484
+ : e != null && typeof e === "object" && "message" in e
2485
+ ? String((e as { message?: unknown }).message ?? "Step failed")
2486
+ : "Step failed";
2487
+ onEvent({ type: "error", error: new Error(message) });
2488
+ const finalMsg = assistantMessage as AgentWidgetMessage | null;
2489
+ if (finalMsg && finalMsg.streaming) {
2490
+ finalMsg.streaming = false;
2491
+ emitMessage(finalMsg);
2505
2492
  }
2493
+ onEvent({ type: "status", status: "idle" });
2506
2494
  continue;
2507
2495
  }
2508
2496
 
2509
- // Capture optional per-step stopReason emitted by the runtime
2510
- // (e.g. `'max_tool_calls'`, `'length'`). This is the dispatch-mode
2511
- // fallback: `agent_turn_complete` will overwrite it later in
2512
- // agent-loop streams.
2513
- const stepStopReason = (payload as any).stopReason as
2514
- | StopReasonKind
2515
- | undefined;
2516
-
2517
- if (didSplitByPartId) {
2518
- // Sealed segment(s): do not create a second bubble from step_complete.
2519
- // Merge authoritative final response into the last sealed segment (fixes async lag).
2520
- if (assistantMessage !== null) {
2521
- const msg: AgentWidgetMessage = assistantMessage;
2522
- if (stepStopReason) msg.stopReason = stepStopReason;
2523
- streamParsers.delete(msg.id);
2524
- rawContentBuffers.delete(msg.id);
2525
- if (msg.streaming !== false) {
2526
- msg.streaming = false;
2527
- emitMessage(msg);
2528
- }
2529
- }
2530
- const splitFinalContent = payload.result?.response;
2531
- const sealedForReconcile = lastSealedTextSegment;
2532
- if (sealedForReconcile) {
2533
- if (stepStopReason) sealedForReconcile.stopReason = stepStopReason;
2534
- if (splitFinalContent !== undefined && splitFinalContent !== null) {
2535
- reconcileSealedAssistantWithFinalResponse(sealedForReconcile, splitFinalContent);
2536
- } else {
2537
- streamParsers.delete(sealedForReconcile.id);
2538
- rawContentBuffers.delete(sealedForReconcile.id);
2497
+ // Unified flow: reconcile the just-sealed text block with the
2498
+ // authoritative structured final (`result.response`). Displayed content
2499
+ // stays as streamed a multi-segment step keeps each bubble's own text;
2500
+ // reconcile only fills/repairs the last sealed block and sets rawContent.
2501
+ // A pure-tool / text-less step (no sealed flow bubble) completes silently.
2502
+ {
2503
+ const sealed = lastSealedFlowBubble;
2504
+ lastSealedFlowBubble = null;
2505
+ const flowStopReason = (payload as any).stopReason as
2506
+ | StopReasonKind
2507
+ | undefined;
2508
+ const finalResponse = payload.result?.response;
2509
+ if (sealed) {
2510
+ if (flowStopReason) sealed.stopReason = flowStopReason;
2511
+ if (finalResponse !== undefined && finalResponse !== null) {
2512
+ reconcileSealedAssistantWithFinalResponse(sealed, finalResponse);
2513
+ } else if (sealed.streaming !== false) {
2514
+ streamParsers.delete(sealed.id);
2515
+ rawContentBuffers.delete(sealed.id);
2516
+ sealed.streaming = false;
2517
+ emitMessage(sealed);
2539
2518
  }
2540
- }
2541
- lastSealedTextSegment = null;
2542
- continue;
2543
- }
2544
- const finalContent = payload.result?.response;
2545
- const assistant = ensureAssistantMessage();
2546
- if (stepStopReason) assistant.stopReason = stepStopReason;
2547
- if (finalContent !== undefined && finalContent !== null) {
2548
- // Check if we already have extracted text from streaming
2549
- const parser = streamParsers.get(assistant.id);
2550
- let hasExtractedText = false;
2551
- let asyncPending = false;
2552
-
2553
- if (parser) {
2554
- // First check if parser already extracted text during streaming
2555
- const currentExtractedText = parser.getExtractedText();
2556
- const rawBuffer = rawContentBuffers.get(assistant.id);
2557
- const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
2558
-
2559
- // Always set rawContent so action parsers can access the raw JSON
2560
- assistant.rawContent = contentToProcess;
2561
-
2562
- if (currentExtractedText !== null && currentExtractedText.trim() !== "") {
2563
- // We already have extracted text from streaming - use it
2564
- assistant.content = currentExtractedText;
2565
- hasExtractedText = true;
2566
- } else {
2567
- // No extracted text yet - try to extract from final content
2568
-
2569
- // Try fast path first
2570
- const extractedText = extractTextFromJson(contentToProcess);
2571
- if (extractedText !== null) {
2572
- assistant.content = extractedText;
2573
- hasExtractedText = true;
2519
+ } else {
2520
+ // Buffered / dispatch-mode step: no streamed text block, but the step
2521
+ // carries the final response (and/or a stopReason) — render it as the
2522
+ // assistant message. An empty response + stopReason still surfaces a
2523
+ // sealed bubble so the UI can show an affordance.
2524
+ const hasResponse =
2525
+ finalResponse !== undefined &&
2526
+ finalResponse !== null &&
2527
+ finalResponse !== "";
2528
+ if (hasResponse || flowStopReason) {
2529
+ const assistant = ensureAssistantMessage();
2530
+ if (flowStopReason) assistant.stopReason = flowStopReason;
2531
+ if (hasResponse) {
2532
+ finalizeFlowTextBlock(assistant, finalResponse);
2574
2533
  } else {
2575
- // Try parser
2576
- const parsedResult = parser.processChunk(contentToProcess);
2577
- if (parsedResult instanceof Promise) {
2578
- asyncPending = true;
2579
- parsedResult.then((result) => {
2580
- // Extract text from result (could be string or object)
2581
- const text = typeof result === 'string' ? result : result?.text ?? null;
2582
-
2583
- if (text !== null && text.trim() !== "") {
2584
- const currentAssistant = assistantMessage;
2585
- if (currentAssistant && currentAssistant.id === assistant.id) {
2586
- currentAssistant.content = text;
2587
- currentAssistant.streaming = false;
2588
- // Clean up
2589
- streamParsers.delete(currentAssistant.id);
2590
- rawContentBuffers.delete(currentAssistant.id);
2591
- emitMessage(currentAssistant);
2592
- }
2593
- } else {
2594
- // No extracted text - check if we should show raw content
2595
- const finalExtractedText = parser.getExtractedText();
2596
- const currentAssistant = assistantMessage;
2597
- if (currentAssistant && currentAssistant.id === assistant.id) {
2598
- if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
2599
- currentAssistant.content = finalExtractedText;
2600
- } else if (!rawContentBuffers.has(currentAssistant.id)) {
2601
- // Only show raw content if we never had any extracted text
2602
- currentAssistant.content = ensureStringContent(finalContent);
2603
- }
2604
- currentAssistant.streaming = false;
2605
- // Clean up
2606
- streamParsers.delete(currentAssistant.id);
2607
- rawContentBuffers.delete(currentAssistant.id);
2608
- emitMessage(currentAssistant);
2609
- }
2610
- }
2611
- });
2612
- } else {
2613
- // Extract text from synchronous result
2614
- const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
2615
-
2616
- if (text !== null && text.trim() !== "") {
2617
- assistant.content = text;
2618
- hasExtractedText = true;
2619
- } else {
2620
- // Check stub one more time
2621
- const finalExtractedText = parser.getExtractedText();
2622
- if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
2623
- assistant.content = finalExtractedText;
2624
- hasExtractedText = true;
2625
- }
2626
- }
2627
- }
2628
- }
2629
- }
2630
- }
2631
-
2632
- // Skip sync emit if we're waiting on async parser
2633
- if (!asyncPending) {
2634
- // Ensure rawContent is set even if there's no parser (for action parsing)
2635
- if (!assistant.rawContent) {
2636
- const rawBuffer = rawContentBuffers.get(assistant.id);
2637
- assistant.rawContent = rawBuffer ?? ensureStringContent(finalContent);
2638
- }
2639
-
2640
- // Only show raw content if we never extracted any text and no buffer was used
2641
- if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
2642
- // No extracted text and no streaming happened - show raw content
2643
- assistant.content = ensureStringContent(finalContent);
2644
- }
2645
-
2646
- // Clean up parser and buffer
2647
- if (parser) {
2648
- const closeResult = parser.close?.();
2649
- if (closeResult instanceof Promise) {
2650
- closeResult.catch(() => {});
2651
- }
2652
- }
2653
- streamParsers.delete(assistant.id);
2654
- rawContentBuffers.delete(assistant.id);
2655
- assistant.streaming = false;
2656
- emitMessage(assistant);
2657
- }
2658
- } else {
2659
- // No final content, just mark as complete and clean up
2660
- streamParsers.delete(assistant.id);
2661
- rawContentBuffers.delete(assistant.id);
2662
- assistant.streaming = false;
2663
- emitMessage(assistant);
2664
- }
2665
- } else if (payloadType === "flow_complete") {
2666
- const finalContent = payload.result?.response;
2667
- if (didSplitByPartId) {
2668
- // Content was split into multiple assistant messages: the full response
2669
- // in flow_complete would overwrite the last segment. Just finalize streaming.
2670
- if (assistantMessage !== null) {
2671
- const msg: AgentWidgetMessage = assistantMessage;
2672
- streamParsers.delete(msg.id);
2673
- rawContentBuffers.delete(msg.id);
2674
- if (msg.streaming !== false) {
2675
- msg.streaming = false;
2676
- emitMessage(msg);
2677
- }
2678
- }
2679
- } else if (finalContent !== undefined && finalContent !== null) {
2680
- const assistant = ensureAssistantMessage();
2681
- // Check if we have raw content buffer that needs final processing
2682
- const rawBuffer = rawContentBuffers.get(assistant.id);
2683
- const stringContent = rawBuffer ?? ensureStringContent(finalContent);
2684
- assistant.rawContent = stringContent;
2685
- // Try to extract text from structured content
2686
- let displayContent = ensureStringContent(finalContent);
2687
- const parser = streamParsers.get(assistant.id);
2688
- if (parser) {
2689
- const extractedText = extractTextFromJson(stringContent);
2690
- if (extractedText !== null) {
2691
- displayContent = extractedText;
2692
- } else {
2693
- // Try parser if it exists
2694
- const parsedResult = parser.processChunk(stringContent);
2695
- if (parsedResult instanceof Promise) {
2696
- parsedResult.then((result) => {
2697
- // Extract text from result (could be string or object)
2698
- const text = typeof result === 'string' ? result : result?.text ?? null;
2699
- if (text !== null) {
2700
- assistant.content = text;
2701
- assistant.streaming = false;
2702
- emitMessage(assistant);
2703
- }
2704
- });
2705
- }
2706
- const currentText = parser.getExtractedText();
2707
- if (currentText !== null) {
2708
- displayContent = currentText;
2534
+ assistant.streaming = false;
2535
+ emitMessage(assistant);
2709
2536
  }
2710
2537
  }
2711
2538
  }
2712
- // Clean up parser and buffer
2713
- streamParsers.delete(assistant.id);
2714
- rawContentBuffers.delete(assistant.id);
2715
-
2716
- // Only emit if something actually changed to avoid flicker
2717
- const contentChanged = displayContent !== assistant.content;
2718
- const streamingChanged = assistant.streaming !== false;
2719
-
2720
- if (contentChanged) {
2721
- assistant.content = displayContent;
2722
- }
2723
- assistant.streaming = false;
2724
-
2725
- // Only emit if content or streaming state changed
2726
- if (contentChanged || streamingChanged) {
2727
- emitMessage(assistant);
2728
- }
2729
- } else {
2730
- // No final content, just mark as complete and clean up
2731
- if (assistantMessage !== null) {
2732
- // Clean up any remaining parsers/buffers
2733
- // TypeScript narrowing issue - assistantMessage is checked for null above
2734
- const msg: AgentWidgetMessage = assistantMessage;
2735
- streamParsers.delete(msg.id);
2736
- rawContentBuffers.delete(msg.id);
2737
- // Only emit if streaming state changed
2738
- if (msg.streaming !== false) {
2739
- msg.streaming = false;
2740
- emitMessage(msg);
2741
- }
2742
- }
2539
+ continue;
2743
2540
  }
2744
- onEvent({ type: "status", status: "idle" });
2745
2541
  // ================================================================
2746
2542
  // Agent Loop Execution Events
2747
2543
  // ================================================================
2748
- } else if (payloadType === "agent_start") {
2749
- agentExecution = {
2750
- executionId: payload.executionId,
2751
- agentId: payload.agentId ?? 'virtual',
2752
- agentName: payload.agentName ?? '',
2753
- status: 'running',
2754
- currentIteration: 0,
2755
- maxTurns: payload.maxTurns ?? 1,
2756
- startedAt: resolveTimestamp(payload.startedAt)
2757
- };
2758
- } else if (payloadType === "agent_iteration_start") {
2759
- if (agentExecution) {
2760
- agentExecution.currentIteration = payload.iteration;
2761
- }
2762
-
2763
- // In 'separate' mode, finalize previous iteration's message and create a new one
2764
- if (iterationDisplay === 'separate' && payload.iteration > 1) {
2765
- const prevMsg = assistantMessage as AgentWidgetMessage | null;
2766
- if (prevMsg) {
2767
- prevMsg.streaming = false;
2768
- emitMessage(prevMsg);
2769
- // Store the completed message for this iteration
2770
- agentIterationMessages.set(payload.iteration - 1, prevMsg);
2771
- // Reset assistant message so ensureAssistantMessage creates a new one
2772
- assistantMessage = null;
2773
- }
2774
- }
2775
- } else if (payloadType === "agent_turn_start") {
2776
- // Reset the per-turn assistant tracker. lastAssistantInTurn is
2777
- // used by agent_turn_complete to attach stopReason to the final
2778
- // text segment of the turn even if that segment was sealed by an
2779
- // intervening tool-call boundary.
2780
- lastAssistantInTurn = null;
2781
- } else if (payloadType === "agent_turn_delta") {
2782
- if (payload.contentType === 'text') {
2783
- // Stream text to assistant message
2784
- const assistant = ensureAssistantMessage();
2785
- assistant.content += payload.delta ?? '';
2786
- assistant.agentMetadata = {
2787
- executionId: payload.executionId,
2788
- iteration: payload.iteration,
2789
- turnId: payload.turnId,
2790
- agentName: agentExecution?.agentName
2791
- };
2792
- lastAssistantInTurn = assistant;
2793
- emitMessage(assistant);
2794
- } else if (payload.contentType === 'thinking') {
2795
- // Stream thinking content to a reasoning message
2796
- const reasoningId = payload.turnId ?? `agent-think-${payload.iteration}`;
2797
- const reasoningMessage = ensureReasoningMessage(reasoningId);
2798
- reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
2799
- id: reasoningId,
2800
- status: "streaming",
2801
- chunks: []
2802
- };
2803
- reasoningMessage.reasoning.chunks.push(payload.delta ?? '');
2804
- reasoningMessage.agentMetadata = {
2544
+ } else if (payloadType === "execution_start") {
2545
+ executionKind = payload.kind === "flow" ? "flow" : "agent";
2546
+ if (executionKind === "agent") {
2547
+ agentExecution = {
2805
2548
  executionId: payload.executionId,
2806
- iteration: payload.iteration,
2807
- turnId: payload.turnId
2549
+ agentId: payload.agentId ?? 'virtual',
2550
+ agentName: payload.agentName ?? '',
2551
+ status: 'running',
2552
+ currentIteration: 0,
2553
+ maxTurns: payload.maxTurns ?? 1,
2554
+ startedAt: resolveTimestamp(payload.startedAt)
2808
2555
  };
2809
- emitMessage(reasoningMessage);
2810
- } else if (payload.contentType === 'tool_input') {
2811
- // Stream tool input to current tool message
2812
- const toolId = payload.toolCallId ?? toolContext.lastId;
2813
- if (toolId) {
2814
- const toolMessage = toolMessages.get(toolId);
2815
- if (toolMessage?.toolCall) {
2816
- toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
2817
- toolMessage.toolCall.chunks.push(payload.delta ?? '');
2818
- emitMessage(toolMessage);
2556
+ }
2557
+ } else if (payloadType === "turn_start") {
2558
+ // Unified collapsed `agent_iteration_*` into a denormalized `iteration`
2559
+ // field on the turn (merged spec §2). Reconstruct the per-iteration
2560
+ // message boundary the 'separate' renderer keys off: when the iteration
2561
+ // advances, seal the previous iteration's bubble and rotate to a new one.
2562
+ const iteration =
2563
+ typeof payload.iteration === "number" ? payload.iteration : lastIterationSeen;
2564
+ if (iteration !== lastIterationSeen) {
2565
+ if (agentExecution) agentExecution.currentIteration = iteration;
2566
+ if (iterationDisplay === 'separate' && iteration > 1) {
2567
+ const prevMsg = assistantMessage as AgentWidgetMessage | null;
2568
+ if (prevMsg) {
2569
+ prevMsg.streaming = false;
2570
+ emitMessage(prevMsg);
2571
+ agentIterationMessages.set(iteration - 1, prevMsg);
2572
+ assistantMessage = null;
2819
2573
  }
2820
2574
  }
2575
+ lastIterationSeen = iteration;
2821
2576
  }
2822
- } else if (payloadType === "agent_turn_complete") {
2823
- // Mark any active reasoning for this turn as complete
2824
- const reasoningId = payload.turnId;
2825
- if (reasoningId) {
2826
- const reasoningMessage = reasoningMessages.get(reasoningId);
2827
- if (reasoningMessage?.reasoning) {
2828
- reasoningMessage.reasoning.status = "complete";
2829
- reasoningMessage.reasoning.completedAt = resolveTimestamp(payload.completedAt);
2830
- const start = reasoningMessage.reasoning.startedAt ?? Date.now();
2831
- reasoningMessage.reasoning.durationMs = Math.max(
2832
- 0,
2833
- (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
2834
- );
2835
- reasoningMessage.streaming = false;
2836
- emitMessage(reasoningMessage);
2577
+ openTurnId = typeof payload.id === "string" ? payload.id : null;
2578
+ // Reset the per-turn assistant tracker. lastAssistantInTurn is used by
2579
+ // turn_complete to attach stopReason to the final text segment of the
2580
+ // turn even if that segment was sealed by an intervening tool boundary.
2581
+ lastAssistantInTurn = null;
2582
+ } else if (payloadType === "tool_input_delta") {
2583
+ // Streamed tool arguments (display-only; authoritative args ride
2584
+ // tool_input_complete / tool_start).
2585
+ const toolId = payload.toolCallId ?? toolContext.lastId;
2586
+ if (toolId) {
2587
+ const toolMessage = toolMessages.get(toolId);
2588
+ if (toolMessage?.toolCall) {
2589
+ toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
2590
+ toolMessage.toolCall.chunks.push(payload.delta ?? '');
2591
+ emitMessage(toolMessage);
2837
2592
  }
2838
2593
  }
2839
-
2840
- // Attach the turn-level stopReason to the assistant message
2841
- // produced by this turn. Only overwrite the current message: // prior turns already sealed their own stopReason via step_complete.
2842
- // Falls back to lastAssistantInTurn when the current bubble was
2843
- // sealed at a tool-call boundary mid-turn, so the notice still
2844
- // attaches to the final visible text segment.
2594
+ } else if (payloadType === "tool_input_complete") {
2595
+ // Authoritative args are set at tool_start; nothing to render here.
2596
+ continue;
2597
+ } else if (payloadType === "turn_complete") {
2598
+ // Reasoning is sealed by its own reasoning_complete in the unified
2599
+ // vocabulary; this only attaches the turn-level stopReason to the
2600
+ // assistant message produced by this turn. Falls back to
2601
+ // lastAssistantInTurn when the bubble was sealed at a tool boundary
2602
+ // mid-turn, so the notice still attaches to the final visible segment.
2845
2603
  const turnStopReason = (payload as any).stopReason as
2846
2604
  | StopReasonKind
2847
2605
  | undefined;
2848
2606
  const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
2849
2607
  if (turnStopReason && stopReasonTarget !== null) {
2850
- const turnId = payload.turnId;
2608
+ const turnId = payload.id;
2851
2609
  const matchesTurn =
2852
2610
  !turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
2853
2611
  if (matchesTurn) {
@@ -2855,83 +2613,58 @@ export class AgentWidgetClient {
2855
2613
  emitMessage(stopReasonTarget);
2856
2614
  }
2857
2615
  }
2858
- } else if (payloadType === "agent_tool_start") {
2859
- // Finalize any in-flight assistant text bubble so subsequent text
2860
- // deltas in this turn create a NEW bubble. Without this, text
2861
- // emitted before AND after a tool call accumulates into one
2862
- // message that renders below all the tool bubbles, losing the
2863
- // chronological text→tool→text→tool interleaving.
2864
- if (assistantMessage) {
2865
- assistantMessage.streaming = false;
2866
- emitMessage(assistantMessage);
2867
- assistantMessage = null;
2868
- }
2869
- const toolId = payload.toolCallId ?? `agent-tool-${nextSequence()}`;
2870
- trackToolId(getToolCallKey(payload), toolId);
2871
- const toolMessage = ensureToolMessage(toolId);
2872
- const tool = toolMessage.toolCall ?? {
2873
- id: toolId, status: "pending" as const,
2874
- name: undefined, args: undefined, chunks: undefined,
2875
- result: undefined, duration: undefined, startedAt: undefined,
2876
- completedAt: undefined, durationMs: undefined
2877
- };
2878
- tool.name = payload.toolName ?? payload.name ?? tool.name;
2879
- tool.status = "running";
2880
- if (payload.parameters !== undefined) {
2881
- tool.args = payload.parameters;
2882
- }
2883
- tool.startedAt = resolveTimestamp(payload.startedAt ?? payload.timestamp);
2884
- toolMessage.toolCall = tool;
2885
- toolMessage.streaming = true;
2886
- toolMessage.agentMetadata = {
2887
- executionId: payload.executionId,
2888
- iteration: payload.iteration
2889
- };
2890
- emitMessage(toolMessage);
2891
- } else if (payloadType === "agent_tool_delta") {
2892
- const toolId = payload.toolCallId ?? toolContext.lastId;
2893
- if (toolId) {
2894
- const toolMessage = toolMessages.get(toolId) ?? ensureToolMessage(toolId);
2895
- if (toolMessage.toolCall) {
2896
- toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
2897
- toolMessage.toolCall.chunks.push(payload.delta ?? '');
2898
- toolMessage.toolCall.status = "running";
2899
- toolMessage.streaming = true;
2900
- emitMessage(toolMessage);
2901
- }
2902
- }
2903
- } else if (payloadType === "agent_tool_complete") {
2904
- const toolId = payload.toolCallId ?? toolContext.lastId;
2905
- if (toolId) {
2906
- const toolMessage = toolMessages.get(toolId) ?? ensureToolMessage(toolId);
2907
- if (toolMessage.toolCall) {
2908
- toolMessage.toolCall.status = "complete";
2909
- if (payload.result !== undefined) {
2910
- toolMessage.toolCall.result = payload.result;
2911
- }
2912
- if (typeof payload.executionTime === "number") {
2913
- toolMessage.toolCall.durationMs = payload.executionTime;
2914
- }
2915
- toolMessage.toolCall.completedAt = resolveTimestamp(payload.completedAt ?? payload.timestamp);
2916
- toolMessage.streaming = false;
2917
- emitMessage(toolMessage);
2918
- const callKey = getToolCallKey(payload);
2919
- if (callKey) {
2920
- toolContext.byCall.delete(callKey);
2921
- }
2922
- }
2923
- }
2924
- } else if (payloadType === "agent_media") {
2925
- // A tool produced media (image / audio / video / file). Render it
2926
- // as a synthetic assistant message inserted at the point the tool
2927
- // completed: between the tool bubble and the next text turn.
2928
- //
2929
- // Wire format is the AI SDK–aligned `MediaContentPart` from
2930
- // @runtypelabs/shared:
2616
+ if (openTurnId === payload.id) openTurnId = null;
2617
+ } else if (payloadType === "media_start") {
2618
+ // Open a unified media block; buffer fragments until media_complete.
2619
+ const id = String(payload.id);
2620
+ mediaBuffers.set(id, {
2621
+ mediaType: typeof payload.mediaType === "string" ? payload.mediaType : undefined,
2622
+ role: typeof payload.role === "string" ? payload.role : undefined,
2623
+ toolCallId: payload.toolCallId,
2624
+ parts: [],
2625
+ });
2626
+ } else if (payloadType === "media_delta") {
2627
+ const buf = mediaBuffers.get(String(payload.id));
2628
+ if (buf && typeof payload.delta === "string") buf.parts.push(payload.delta);
2629
+ } else if (payloadType === "media_complete") {
2630
+ // Reassemble the buffered media triad into a single AI SDK–aligned
2631
+ // `MediaContentPart`, then render it as a synthetic assistant message
2632
+ // inserted between the tool bubble and the next text turn:
2931
2633
  // { type: 'media', data, mediaType } // AI SDK v6: base64
2932
2634
  // { type: 'image-url', url, mediaType? } // AI SDK v3/v4
2933
2635
  // { type: 'file-url', url, mediaType } // AI SDK v3/v4
2934
- const rawMedia = Array.isArray(payload.media) ? payload.media : [];
2636
+ const mediaBlockId = String(payload.id);
2637
+ const buf = mediaBuffers.get(mediaBlockId);
2638
+ mediaBuffers.delete(mediaBlockId);
2639
+ const completeMediaType =
2640
+ (typeof payload.mediaType === "string" ? payload.mediaType : undefined) ??
2641
+ buf?.mediaType ??
2642
+ "application/octet-stream";
2643
+ const completeData = typeof payload.data === "string" ? payload.data : undefined;
2644
+ const completeUrl =
2645
+ typeof payload.url === "string"
2646
+ ? payload.url
2647
+ : buf && buf.parts.length > 0
2648
+ ? buf.parts.join("")
2649
+ : undefined;
2650
+ let reconstructed: Record<string, unknown> | null = null;
2651
+ if (completeData) {
2652
+ reconstructed = { type: "media", data: completeData, mediaType: completeMediaType };
2653
+ } else if (completeUrl) {
2654
+ // The unified wire is mediaType-only; a URL part with no declared MIME
2655
+ // arrives as the bare bucket hint "image" (per the API encoder). Treat
2656
+ // that — and any real `image/*` — as a hosted image so we don't misroute
2657
+ // generated images into the file bucket.
2658
+ const lower = completeMediaType.toLowerCase();
2659
+ const isImage = lower === "image" || lower.startsWith("image/");
2660
+ reconstructed = {
2661
+ type: isImage ? "image-url" : "file-url",
2662
+ url: completeUrl,
2663
+ mediaType: completeMediaType,
2664
+ };
2665
+ }
2666
+ const mediaToolCallId = payload.toolCallId ?? buf?.toolCallId;
2667
+ const rawMedia = reconstructed ? [reconstructed] : [];
2935
2668
  const mediaContentParts: ContentPart[] = [];
2936
2669
  for (const part of rawMedia) {
2937
2670
  if (!part || typeof part !== "object") continue;
@@ -2975,7 +2708,9 @@ export class AgentWidgetClient {
2975
2708
  mediaContentParts.push({
2976
2709
  type: "image",
2977
2710
  image: src,
2978
- ...(mediaType ? { mimeType: mediaType } : {}),
2711
+ // Only a real MIME (`image/png`) is a usable mimeType; the bare
2712
+ // bucket hint "image" (a hosted URL with no declared type) is not.
2713
+ ...(mediaType.includes("/") ? { mimeType: mediaType } : {}),
2979
2714
  });
2980
2715
  } else if (mediaType.startsWith("audio/")) {
2981
2716
  mediaContentParts.push({
@@ -3006,7 +2741,7 @@ export class AgentWidgetClient {
3006
2741
  // sharing an id would let `emitMessage` merge them by id and
3007
2742
  // overwrite the prior `contentParts`.
3008
2743
  const seq = nextSequence();
3009
- const toolCallIdRaw = payload.toolCallId;
2744
+ const toolCallIdRaw = mediaToolCallId;
3010
2745
  const mediaIdSuffix =
3011
2746
  typeof toolCallIdRaw === "string" && toolCallIdRaw.length > 0
3012
2747
  ? `${toolCallIdRaw}-${seq}`
@@ -3021,7 +2756,12 @@ export class AgentWidgetClient {
3021
2756
  sequence: seq,
3022
2757
  agentMetadata: {
3023
2758
  executionId: payload.executionId,
3024
- iteration: payload.iteration,
2759
+ // Media blocks carry no iteration of their own; stamp the
2760
+ // enclosing iteration tracked from turn/tool frames.
2761
+ iteration:
2762
+ typeof payload.iteration === "number"
2763
+ ? payload.iteration
2764
+ : lastIterationSeen,
3025
2765
  },
3026
2766
  };
3027
2767
  emitMessage(mediaMessage);
@@ -3039,67 +2779,49 @@ export class AgentWidgetClient {
3039
2779
  assistantMessage = null;
3040
2780
  assistantMessageRef.current = null;
3041
2781
  }
3042
- } else if (payloadType === "agent_iteration_complete") {
3043
- // Iteration complete - no special handling needed
3044
- // In 'separate' mode, message finalization happens at next iteration_start
3045
- } else if (payloadType === "agent_reflection" || payloadType === "agent_reflect") {
3046
- // Create a reasoning message for reflection content
3047
- const reflectionId = `agent-reflection-${payload.executionId}-${payload.iteration}`;
3048
- const reflectionMessage: AgentWidgetMessage = {
3049
- id: reflectionId,
3050
- role: "assistant",
3051
- content: payload.reflection ?? '',
3052
- createdAt: new Date().toISOString(),
3053
- streaming: false,
3054
- variant: "reasoning",
3055
- sequence: nextSequence(),
3056
- reasoning: {
3057
- id: reflectionId,
3058
- status: "complete",
3059
- chunks: [payload.reflection ?? '']
3060
- },
3061
- agentMetadata: {
3062
- executionId: payload.executionId,
3063
- iteration: payload.iteration
3064
- }
3065
- };
3066
- emitMessage(reflectionMessage);
3067
- } else if (payloadType === "agent_complete") {
3068
- if (agentExecution) {
2782
+ } else if (payloadType === "execution_complete") {
2783
+ const kind = payload.kind ?? executionKind;
2784
+ if (kind === "agent" && agentExecution) {
3069
2785
  agentExecution.status = payload.success ? 'complete' : 'error';
3070
2786
  agentExecution.completedAt = resolveTimestamp(payload.completedAt);
3071
2787
  agentExecution.stopReason = payload.stopReason;
3072
2788
  }
3073
2789
 
3074
- // Finalize the current assistant message
2790
+ // Finalize any still-open assistant message. Per-step reconciliation
2791
+ // (step_complete.result.response) normally sealed the flow blocks
2792
+ // already; this is the defensive close for an unterminated block, and
2793
+ // for flow it runs the final structured extraction off the raw buffer.
3075
2794
  const finalMsg = assistantMessage as AgentWidgetMessage | null;
3076
2795
  if (finalMsg) {
3077
- finalMsg.streaming = false;
3078
- emitMessage(finalMsg);
2796
+ if (kind === "flow" && finalMsg.streaming !== false) {
2797
+ finalizeFlowTextBlock(finalMsg);
2798
+ } else {
2799
+ finalMsg.streaming = false;
2800
+ emitMessage(finalMsg);
2801
+ }
2802
+ assistantMessage = null;
3079
2803
  }
2804
+ currentTextBlockId = null;
2805
+ pendingFlowRaw = "";
2806
+ lastSealedFlowBubble = null;
3080
2807
 
3081
2808
  onEvent({ type: "status", status: "idle" });
3082
- } else if (payloadType === "agent_error") {
2809
+ } else if (payloadType === "execution_error") {
2810
+ // Terminal failure. The unified non-terminal `error` is handled
2811
+ // separately (recoverable → warn).
3083
2812
  const errorMessage = typeof payload.error === 'string'
3084
2813
  ? payload.error
3085
- : payload.error?.message ?? 'Agent execution error';
3086
- if (payload.recoverable) {
3087
- if (typeof console !== "undefined") {
3088
- // eslint-disable-next-line no-console
3089
- console.warn("[AgentWidget] Recoverable agent error:", errorMessage);
3090
- }
3091
- } else {
3092
- onEvent({
3093
- type: "error",
3094
- error: new Error(errorMessage)
3095
- });
3096
- }
3097
- } else if (payloadType === "agent_ping") {
2814
+ : payload.error?.message ?? 'Execution error';
2815
+ onEvent({
2816
+ type: "error",
2817
+ error: new Error(errorMessage)
2818
+ });
2819
+ } else if (payloadType === "ping") {
3098
2820
  // Keep-alive heartbeat - no action needed
3099
2821
  // ================================================================
3100
2822
  // Tool Approval Events
3101
2823
  // ================================================================
3102
- } else if (payloadType === "agent_approval_start") {
2824
+ } else if (payloadType === "approval_start") {
3103
2825
  const approvalId = payload.approvalId ?? `approval-${nextSequence()}`;
3104
2826
  const approvalMessage: AgentWidgetMessage = {
3105
2827
  id: `approval-${approvalId}`,
@@ -3149,7 +2871,7 @@ export class AgentWidgetClient {
3149
2871
  },
3150
2872
  };
3151
2873
  emitMessage(approvalMessage);
3152
- } else if (payloadType === "agent_approval_complete") {
2874
+ } else if (payloadType === "approval_complete") {
3153
2875
  const approvalId = payload.approvalId;
3154
2876
  if (approvalId) {
3155
2877
  // Find and update the existing approval message
@@ -3294,8 +3016,33 @@ export class AgentWidgetClient {
3294
3016
  assistantMessageRef.current = null;
3295
3017
  streamParsers.delete(id);
3296
3018
  rawContentBuffers.delete(id);
3019
+ } else if (payloadType === "error") {
3020
+ // Unified non-terminal error (merged spec). A bare `error` is
3021
+ // recoverable by default — a transient notice such as "rate limited,
3022
+ // retrying" — and the execution continues, so it must NOT surface as a
3023
+ // fatal error or finalize the stream. The API routes terminal failures
3024
+ // through `execution_error`. Only an explicit `recoverable: false`
3025
+ // promotes a unified `error` to terminal.
3026
+ if (
3027
+ payload.recoverable === false &&
3028
+ payload.error != null &&
3029
+ payload.error !== ""
3030
+ ) {
3031
+ const errorMessage =
3032
+ typeof payload.error === "string"
3033
+ ? payload.error
3034
+ : (payload.error as { message?: unknown })?.message != null
3035
+ ? String((payload.error as { message?: unknown }).message)
3036
+ : "Execution error";
3037
+ onEvent({ type: "error", error: new Error(errorMessage) });
3038
+ const finalMsg = assistantMessage as AgentWidgetMessage | null;
3039
+ if (finalMsg && finalMsg.streaming) {
3040
+ finalMsg.streaming = false;
3041
+ emitMessage(finalMsg);
3042
+ }
3043
+ onEvent({ type: "status", status: "idle" });
3044
+ }
3297
3045
  } else if (
3298
- payloadType === "error" ||
3299
3046
  payloadType === "step_error" ||
3300
3047
  payloadType === "dispatch_error" ||
3301
3048
  payloadType === "flow_error"
@@ -3308,18 +3055,13 @@ export class AgentWidgetClient {
3308
3055
  if (msg != null && msg !== "") {
3309
3056
  resolvedError = new Error(String(msg));
3310
3057
  }
3311
- } else if (
3312
- payloadType === "step_error" ||
3313
- payloadType === "flow_error"
3314
- ) {
3058
+ } else {
3315
3059
  const e = payload.error;
3316
3060
  if (typeof e === "string" && e !== "") {
3317
3061
  resolvedError = new Error(e);
3318
3062
  } else if (e != null && typeof e === "object" && "message" in e) {
3319
3063
  resolvedError = new Error(String((e as { message?: unknown }).message ?? e));
3320
3064
  }
3321
- } else if (payloadType === "error" && payload.error != null && payload.error !== "") {
3322
- resolvedError = new Error(String(payload.error));
3323
3065
  }
3324
3066
 
3325
3067
  if (resolvedError) {
@@ -3389,7 +3131,7 @@ export class AgentWidgetClient {
3389
3131
  assistantMessageRef,
3390
3132
  emitMessage,
3391
3133
  nextSequence,
3392
- partIdState
3134
+ customParsePartId
3393
3135
  );
3394
3136
  // Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
3395
3137
  if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
@@ -3398,39 +3140,14 @@ export class AgentWidgetClient {
3398
3140
  if (handled) continue; // Skip default handling if custom handler processed it
3399
3141
  }
3400
3142
 
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
-
3426
- // Push through the sequence reorder buffer
3427
- seqBuffer.push(payloadType, payload);
3143
+ // The wire is the neutral unified vocabulary; the handler consumes it
3144
+ // natively. The stream is single-connection and in order, so each frame
3145
+ // drains straight through.
3146
+ seqReadyQueue.push({ payloadType, payload });
3428
3147
  drainReadyQueue();
3429
3148
  }
3430
3149
  }
3431
3150
 
3432
- seqBuffer.flushPending();
3433
3151
  drainReadyQueue();
3434
- seqBuffer.destroy();
3435
3152
  }
3436
3153
  }