@runtypelabs/persona 3.36.0 → 4.0.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 (48) hide show
  1. package/README.md +0 -1
  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 +47 -47
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +328 -821
  13. package/dist/index.d.ts +328 -821
  14. package/dist/index.global.js +39 -39
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +47 -47
  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 +268 -181
  21. package/dist/smart-dom-reader.d.ts +268 -181
  22. package/dist/theme-editor-preview.cjs +41 -41
  23. package/dist/theme-editor-preview.d.cts +268 -181
  24. package/dist/theme-editor-preview.d.ts +268 -181
  25. package/dist/theme-editor-preview.js +41 -41
  26. package/dist/theme-editor.d.cts +268 -181
  27. package/dist/theme-editor.d.ts +268 -181
  28. package/package.json +1 -1
  29. package/src/client.test.ts +506 -1003
  30. package/src/client.ts +676 -909
  31. package/src/components/tool-bubble.ts +46 -33
  32. package/src/generated/runtype-openapi-contract.ts +211 -714
  33. package/src/index-core.ts +2 -3
  34. package/src/install.test.ts +1 -34
  35. package/src/install.ts +1 -22
  36. package/src/runtime/init.test.ts +8 -67
  37. package/src/runtime/init.ts +2 -17
  38. package/src/session.test.ts +8 -8
  39. package/src/session.ts +1 -1
  40. package/src/types.ts +11 -2
  41. package/src/ui.postprocess.test.ts +107 -0
  42. package/src/ui.ts +40 -5
  43. package/src/utils/__fixtures__/unified-translator.oracle.ts +717 -0
  44. package/src/utils/copy-selection.test.ts +37 -0
  45. package/src/utils/copy-selection.ts +19 -0
  46. package/src/utils/event-stream-capture.test.ts +9 -64
  47. package/src/utils/sequence-buffer.test.ts +0 -256
  48. package/src/utils/sequence-buffer.ts +0 -130
package/src/client.ts CHANGED
@@ -32,7 +32,6 @@ import {
32
32
  createRegexJsonParser,
33
33
  createXmlParser
34
34
  } from "./utils/formatting";
35
- import { SequenceReorderBuffer } from "./utils/sequence-buffer";
36
35
  import { VERSION } from "./version";
37
36
  // artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
38
37
  // artifact events are always processed here regardless of config.
@@ -1378,21 +1377,26 @@ export class AgentWidgetClient {
1378
1377
  let lastAssistantInTurn: AgentWidgetMessage | null = null;
1379
1378
  // Reference to track assistant message for custom event handler
1380
1379
  const assistantMessageRef = { current: null as AgentWidgetMessage | null };
1381
- // Track current partId for message segmentation at tool boundaries
1382
- const partIdState = { current: null as string | null };
1383
- 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>();
1384
1398
  const reasoningMessages = new Map<string, AgentWidgetMessage>();
1385
1399
  const toolMessages = new Map<string, AgentWidgetMessage>();
1386
- // Messages produced by steps inside a nested flow executed as a tool.
1387
- // Keyed by `${parentToolId}::${nestedStepId}::${partId}` so each nested
1388
- // step (send-stream, prompt) gets its own assistant message, and prompts
1389
- // with inner tool calls split into one message per text segment: still
1390
- // attributable to the parent tool call.
1391
- const nestedStepMessages = new Map<string, AgentWidgetMessage>();
1392
- // Most-recent partId seen for a given `${toolId}::${stepId}` scope, used
1393
- // to seal the previous segment when a new partId arrives within the
1394
- // same nested prompt step.
1395
- const nestedPartIdByStep = new Map<string, string>();
1396
1400
  const reasoningContext = {
1397
1401
  lastId: null as string | null,
1398
1402
  byStep: new Map<string, string>()
@@ -1402,49 +1406,6 @@ export class AgentWidgetClient {
1402
1406
  byCall: new Map<string, string>()
1403
1407
  };
1404
1408
 
1405
- // Nested message key. partId defaults to "" so steps without segmentation
1406
- // (e.g. send-stream) still have a deterministic single key.
1407
- const getNestedStepKey = (
1408
- toolId: string,
1409
- stepId: string,
1410
- partId: string = ""
1411
- ) => `${toolId}::${stepId}::${partId}`;
1412
-
1413
- // Prefix used to sweep every nested message belonging to a single
1414
- // (toolId, stepId) scope: needed on step_complete to seal any segments
1415
- // that are still streaming.
1416
- const getNestedStepPrefix = (toolId: string, stepId: string) =>
1417
- `${toolId}::${stepId}::`;
1418
-
1419
- const ensureNestedStepMessage = (
1420
- toolId: string,
1421
- stepId: string,
1422
- partId: string,
1423
- executionId?: string
1424
- ): AgentWidgetMessage => {
1425
- const key = getNestedStepKey(toolId, stepId, partId);
1426
- const existing = nestedStepMessages.get(key);
1427
- if (existing) return existing;
1428
- const idSuffix = partId ? `-${partId}` : "";
1429
- const message: AgentWidgetMessage = {
1430
- id: `nested-${toolId}-${stepId}${idSuffix}`,
1431
- role: "assistant",
1432
- content: "",
1433
- createdAt: new Date().toISOString(),
1434
- streaming: true,
1435
- sequence: nextSequence(),
1436
- ...(partId ? { partId } : {}),
1437
- agentMetadata: {
1438
- executionId,
1439
- parentToolId: toolId,
1440
- parentStepId: stepId,
1441
- },
1442
- };
1443
- nestedStepMessages.set(key, message);
1444
- emitMessage(message);
1445
- return message;
1446
- };
1447
-
1448
1409
  const normalizeKey = (value: unknown): string | null => {
1449
1410
  if (value === null || value === undefined) return null;
1450
1411
  try {
@@ -1482,11 +1443,12 @@ export class AgentWidgetClient {
1482
1443
  const ensureAssistantMessage = () => {
1483
1444
  if (assistantMessage) return assistantMessage;
1484
1445
  let id: string;
1446
+ const segment = currentTextBlockId;
1485
1447
  if (!assistantIdConsumed && baseAssistantId) {
1486
1448
  id = baseAssistantId;
1487
1449
  assistantIdConsumed = true;
1488
- } else if (baseAssistantId && partIdState.current) {
1489
- id = `${baseAssistantId}_${partIdState.current}`;
1450
+ } else if (baseAssistantId && segment) {
1451
+ id = `${baseAssistantId}_${segment}`;
1490
1452
  } else {
1491
1453
  id = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
1492
1454
  }
@@ -1678,11 +1640,6 @@ export class AgentWidgetClient {
1678
1640
  // Rebuild incremental text by sequence so late arrivals can repair already-emitted
1679
1641
  // content after the reorder buffer's gap-timeout flush.
1680
1642
  const orderedChunkBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1681
- const assistantMessagesByPartId = new Map<string, AgentWidgetMessage>();
1682
- // Only the most-recently sealed segment is reconciled with step_complete's
1683
- // final response. Earlier segments rely on their own async parser microtasks
1684
- // resolving via the closure-captured `assistant` variable.
1685
- let lastSealedTextSegment: AgentWidgetMessage | null = null;
1686
1643
 
1687
1644
  const insertOrderedChunk = (key: string, seq: number, text: string): string => {
1688
1645
  let chunks = orderedChunkBuffers.get(key);
@@ -1807,47 +1764,241 @@ export class AgentWidgetClient {
1807
1764
  finalizeCleanup();
1808
1765
  };
1809
1766
 
1810
- // Sequence reorder buffer: SSE events carrying a `seq` (or `sequenceIndex`)
1811
- // field are held and re-emitted in sequence order so that transport-level
1812
- // 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.
1813
1955
  const seqReadyQueue: Array<{ payloadType: string; payload: any }> = [];
1814
- let isDrainScheduled = false;
1815
- // Declared here so scheduleReadyQueueDrain can reference it; assigned
1816
- // 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).
1817
1958
  let drainReadyQueue: () => void;
1818
- // Two drain paths: both are intentional, do not remove either:
1819
- // 1. Microtask drain (scheduleReadyQueueDrain): required when the
1820
- // buffer's emitter fires from the gap-timeout setTimeout callback,
1821
- // because there is no surrounding synchronous drain site there.
1822
- // 2. Synchronous drain (drainReadyQueue() after each seqBuffer.push):
1823
- // skips an extra microtask hop on the hot in-order push path.
1824
- const scheduleReadyQueueDrain = () => {
1825
- if (isDrainScheduled) return;
1826
- isDrainScheduled = true;
1827
- queueMicrotask(() => {
1828
- isDrainScheduled = false;
1829
- drainReadyQueue();
1830
- });
1831
- };
1832
- const seqBuffer = new SequenceReorderBuffer((payloadType: string, payload: any) => {
1833
- seqReadyQueue.push({ payloadType, payload });
1834
- scheduleReadyQueueDrain();
1835
- });
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;
1836
1976
  // Agent execution state tracking
1837
1977
  let agentExecution: AgentExecutionState | null = null;
1838
1978
  // Track assistant messages per agent iteration for 'separate' mode
1839
1979
  const agentIterationMessages = new Map<number, AgentWidgetMessage>();
1840
1980
  const iterationDisplay = this.config.iterationDisplay ?? 'separate';
1841
1981
 
1842
- // Drains reorder-buffered events through the main event handler.
1843
- // 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
1844
1984
  // end-of-stream are processed.
1845
1985
  drainReadyQueue = () => {
1846
1986
  for (let i = 0; i < seqReadyQueue.length; i++) {
1847
1987
  const payloadType = seqReadyQueue[i].payloadType;
1848
1988
  const payload = seqReadyQueue[i].payload;
1849
1989
 
1850
- 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
+ }
1851
2002
  const reasoningId =
1852
2003
  resolveReasoningId(payload, true) ?? `reason-${nextSequence()}`;
1853
2004
  const reasoningMessage = ensureReasoningMessage(reasoningId);
@@ -1861,10 +2012,29 @@ export class AgentWidgetClient {
1861
2012
  resolveTimestamp(payload.startedAt ?? payload.timestamp);
1862
2013
  reasoningMessage.reasoning.completedAt = undefined;
1863
2014
  reasoningMessage.reasoning.durationMs = undefined;
2015
+ if (payload.scope === "loop" || payload.scope === "turn") {
2016
+ reasoningMessage.reasoning.scope = payload.scope;
2017
+ }
1864
2018
  reasoningMessage.streaming = true;
1865
2019
  reasoningMessage.reasoning.status = "streaming";
1866
2020
  emitMessage(reasoningMessage);
1867
- } 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
+ }
1868
2038
  const reasoningId =
1869
2039
  resolveReasoningId(payload, false) ??
1870
2040
  resolveReasoningId(payload, true) ??
@@ -1908,13 +2078,51 @@ export class AgentWidgetClient {
1908
2078
  }
1909
2079
  reasoningMessage.streaming = reasoningMessage.reasoning.status !== "complete";
1910
2080
  emitMessage(reasoningMessage);
1911
- } 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
+ }
1912
2104
  const reasoningId =
1913
2105
  resolveReasoningId(payload, false) ??
1914
2106
  resolveReasoningId(payload, true) ??
1915
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
+ }
1916
2118
  const reasoningMessage = reasoningMessages.get(reasoningId);
1917
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
+ }
1918
2126
  reasoningMessage.reasoning.status = "complete";
1919
2127
  reasoningMessage.reasoning.completedAt = resolveTimestamp(
1920
2128
  payload.completedAt ?? payload.timestamp
@@ -1933,14 +2141,30 @@ export class AgentWidgetClient {
1933
2141
  reasoningContext.byStep.delete(stepKey);
1934
2142
  }
1935
2143
  } else if (payloadType === "tool_start") {
1936
- const toolId =
1937
- 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()}`;
1938
2161
  const toolName = payload.toolName ?? payload.name;
1939
2162
  // Suppress tool UI for artifact emit tools: artifacts are handled via artifact_* events
1940
2163
  if (isArtifactEmitToolName(toolName)) {
1941
2164
  artifactToolCallIds.add(toolId);
1942
2165
  continue;
1943
2166
  }
2167
+ trackToolId(getToolCallKey(payload), toolId);
1944
2168
  const toolMessage = ensureToolMessage(toolId);
1945
2169
  const tool = toolMessage.toolCall ?? {
1946
2170
  id: toolId,
@@ -1948,10 +2172,10 @@ export class AgentWidgetClient {
1948
2172
  };
1949
2173
  tool.name = toolName ?? tool.name;
1950
2174
  tool.status = "running";
1951
- if (payload.args !== undefined) {
1952
- tool.args = payload.args;
1953
- } else if (payload.parameters !== undefined) {
2175
+ if (payload.parameters !== undefined) {
1954
2176
  tool.args = payload.parameters;
2177
+ } else if (payload.args !== undefined) {
2178
+ tool.args = payload.args;
1955
2179
  }
1956
2180
  tool.startedAt =
1957
2181
  tool.startedAt ??
@@ -1960,15 +2184,14 @@ export class AgentWidgetClient {
1960
2184
  tool.durationMs = undefined;
1961
2185
  toolMessage.toolCall = tool;
1962
2186
  toolMessage.streaming = true;
1963
- const agentCtx = payload.agentContext;
1964
- if (agentCtx || payload.executionId) {
2187
+ if (payload.executionId) {
1965
2188
  toolMessage.agentMetadata = {
1966
- executionId: agentCtx?.executionId ?? payload.executionId,
1967
- iteration: agentCtx?.iteration ?? payload.iteration,
2189
+ executionId: payload.executionId,
2190
+ iteration: payload.iteration,
1968
2191
  };
1969
2192
  }
1970
2193
  emitMessage(toolMessage);
1971
- } else if (payloadType === "tool_chunk" || payloadType === "tool_delta") {
2194
+ } else if (payloadType === "tool_output_delta") {
1972
2195
  const toolId =
1973
2196
  resolveToolId(payload, false) ??
1974
2197
  resolveToolId(payload, true) ??
@@ -2047,11 +2270,17 @@ export class AgentWidgetClient {
2047
2270
  if (callKey) {
2048
2271
  toolContext.byCall.delete(callKey);
2049
2272
  }
2050
- } else if (payloadType === "step_await" && payload.awaitReason === "local_tool_required" && payload.toolName) {
2051
- // LOCAL tool pause. Runtype's prompt step throws LocalToolRequiredError
2052
- // when the model calls a tool with `toolType: "local"`. The server
2053
- // emits step_await with the tool name, params, and execution id; the
2054
- // 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.
2055
2284
  //
2056
2285
  // Upsert a fully-populated tool-variant message so the existing
2057
2286
  // ask_user_question bubble + sheet paths fire. Mark the message with
@@ -2074,7 +2303,15 @@ export class AgentWidgetClient {
2074
2303
  const toolId =
2075
2304
  toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
2076
2305
  const toolMessage = ensureToolMessage(toolId);
2077
- 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;
2078
2315
  const webMcpTool = isWebMcpToolName(toolName);
2079
2316
  const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
2080
2317
  tool.name = toolName;
@@ -2088,7 +2325,8 @@ export class AgentWidgetClient {
2088
2325
  tool.status = webMcpTool ? "running" : "complete";
2089
2326
  tool.chunks = tool.chunks ?? [];
2090
2327
  tool.startedAt =
2091
- tool.startedAt ?? resolveTimestamp(payload.startedAt ?? payload.timestamp);
2328
+ tool.startedAt ??
2329
+ resolveTimestamp(payload.startedAt ?? payload.timestamp ?? payload.awaitedAt);
2092
2330
  if (webMcpTool) {
2093
2331
  tool.completedAt = undefined;
2094
2332
  tool.duration = undefined;
@@ -2109,339 +2347,123 @@ export class AgentWidgetClient {
2109
2347
  };
2110
2348
  emitMessage(toolMessage);
2111
2349
  } else if (payloadType === "text_start") {
2112
- // Lifecycle event: a new text segment is beginning (emitted at tool boundaries).
2113
- // When toolContext is present this fired inside a nested flow: it must not
2114
- // seal or rotate the outer assistant message. Nested prompt segmentation is
2115
- // handled via nestedStepMessages keyed by (toolId, stepId).
2116
- 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);
2117
2361
  continue;
2118
2362
  }
2119
- const incomingPartId = payload.partId;
2120
- if (incomingPartId !== undefined && partIdState.current !== null && incomingPartId !== partIdState.current) {
2121
- const prev = assistantMessage as AgentWidgetMessage | null;
2122
- 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 {
2123
2375
  prev.streaming = false;
2124
2376
  emitMessage(prev);
2125
- lastSealedTextSegment = prev;
2126
- assistantMessage = null;
2127
- didSplitByPartId = true;
2128
2377
  }
2129
- }
2130
- if (incomingPartId !== undefined) {
2131
- partIdState.current = incomingPartId;
2132
- }
2133
- } else if (payloadType === "text_end") {
2134
- // Lifecycle event: current text segment ended (tool call about to start).
2135
- // When toolContext is present the boundary belongs to a nested flow: leave
2136
- // outer assistant state alone so the outer stream is never interrupted by
2137
- // nested activity.
2138
- if ((payload as any).toolContext?.toolId) {
2139
- continue;
2140
- }
2141
- // Seal the current assistant message so the next segment gets a new one
2142
- const prev = assistantMessage as AgentWidgetMessage | null;
2143
- if (prev) {
2144
- prev.streaming = false;
2145
- emitMessage(prev);
2146
- lastSealedTextSegment = prev;
2147
2378
  assistantMessage = null;
2148
- didSplitByPartId = true;
2149
2379
  }
2150
- } else if (payloadType === "step_chunk" || payloadType === "step_delta") {
2151
- // Only process chunks for prompt steps, not tool/context steps
2152
- const stepType = (payload as any).stepType;
2153
- const executionType = (payload as any).executionType;
2154
- if (stepType === "tool" || executionType === "context") {
2155
- // 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);
2156
2403
  continue;
2157
2404
  }
2158
-
2159
- // Nested flow routing: when toolContext is present, this step_delta
2160
- // originated inside a nested flow executed as a tool. Surface it as
2161
- // its own assistant message keyed by the nested step id, so authors
2162
- // who add send-stream / prompt steps inside their flow see them as
2163
- // real messages in the timeline, in order: rather than merging
2164
- // into the outer assistant bubble or getting buried in the tool
2165
- // card. Each nested step id gets its own message; the parent tool
2166
- // bubble continues to represent the invocation via tool_* events.
2167
- const nestedToolCtx = (payload as any).toolContext as
2168
- | { toolId?: string; stepId?: string; executionId?: string }
2169
- | undefined;
2170
- if (nestedToolCtx?.toolId) {
2171
- const nestedStepId = String(
2172
- payload.id ?? nestedToolCtx.stepId ?? `step-${nextSequence()}`
2173
- );
2174
- const incomingPartId =
2175
- payload.partId !== undefined && payload.partId !== null
2176
- ? String(payload.partId)
2177
- : "";
2178
- const stepScopeKey = `${nestedToolCtx.toolId}::${nestedStepId}`;
2179
- const prevPartId = nestedPartIdByStep.get(stepScopeKey);
2180
-
2181
- // If partId changed within this nested step (prompt with inner
2182
- // tool call emitting a new text segment), seal the previous
2183
- // segment's message so each segment renders as its own bubble.
2184
- if (
2185
- incomingPartId !== "" &&
2186
- prevPartId !== undefined &&
2187
- prevPartId !== "" &&
2188
- prevPartId !== incomingPartId
2189
- ) {
2190
- const prev = nestedStepMessages.get(
2191
- getNestedStepKey(
2192
- nestedToolCtx.toolId,
2193
- nestedStepId,
2194
- prevPartId
2195
- )
2196
- );
2197
- if (prev && prev.streaming !== false) {
2198
- prev.streaming = false;
2199
- emitMessage(prev);
2200
- }
2201
- }
2202
- if (incomingPartId !== "") {
2203
- nestedPartIdByStep.set(stepScopeKey, incomingPartId);
2204
- }
2205
-
2206
- const nestedMsg = ensureNestedStepMessage(
2207
- nestedToolCtx.toolId,
2208
- nestedStepId,
2209
- incomingPartId,
2210
- nestedToolCtx.executionId
2211
- );
2212
- const nestedChunk =
2213
- payload.text ??
2214
- payload.delta ??
2215
- payload.content ??
2216
- payload.chunk ??
2217
- "";
2218
- if (nestedChunk) {
2219
- nestedMsg.content += String(nestedChunk);
2220
- nestedMsg.streaming = true;
2221
- emitMessage(nestedMsg);
2222
- }
2223
- if (payload.isComplete) {
2224
- nestedMsg.streaming = false;
2225
- emitMessage(nestedMsg);
2226
- }
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;
2227
2422
  continue;
2228
2423
  }
2229
-
2230
- // partId-based segmentation: when partId changes, seal current message
2231
- // and start a new one so text and tools render in chronological order
2232
- const incomingPartId = payload.partId;
2233
- if (incomingPartId !== undefined && partIdState.current !== null && incomingPartId !== partIdState.current) {
2234
- const prev = assistantMessage as AgentWidgetMessage | null;
2235
- if (prev) {
2236
- prev.streaming = false;
2237
- emitMessage(prev);
2238
- lastSealedTextSegment = prev;
2239
- assistantMessage = null;
2240
- didSplitByPartId = true;
2241
- }
2242
- }
2243
- if (incomingPartId !== undefined) {
2244
- partIdState.current = incomingPartId;
2245
- }
2246
-
2247
- const assistant =
2248
- incomingPartId !== undefined
2249
- ? (assistantMessagesByPartId.get(incomingPartId) ?? ensureAssistantMessage())
2250
- : ensureAssistantMessage();
2251
- if (incomingPartId !== undefined) {
2252
- if (!assistant.partId) {
2253
- assistant.partId = incomingPartId;
2254
- }
2255
- 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;
2256
2444
  }
2257
- // Support various field names: text, delta, content, chunk (Runtype uses 'chunk')
2258
- const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
2259
- if (chunk) {
2260
- // Accumulate raw content for structured format parsing.
2261
- // Most out-of-order events are fixed at the dispatch layer, but once the
2262
- // gap timeout flushes later seqs we can still see genuine late arrivals.
2263
- // Rebuild chunked content by seq so those events repair prior output
2264
- // instead of appending in the wrong position.
2265
- const chunkSeq = typeof payload.seq === "number" ? payload.seq : undefined;
2266
- const chunkBufferKey = incomingPartId ?? assistant.id;
2267
- const accumulatedRaw =
2268
- chunkSeq !== undefined
2269
- ? insertOrderedChunk(chunkBufferKey, chunkSeq, String(chunk))
2270
- : (rawContentBuffers.get(assistant.id) ?? "") + chunk;
2271
- // Store raw content for action parsing, but NEVER set assistant.content to raw JSON
2272
- assistant.rawContent = accumulatedRaw;
2273
-
2274
- // Use stream parser to parse
2275
- if (!streamParsers.has(assistant.id)) {
2276
- streamParsers.set(assistant.id, this.createStreamParser());
2277
- }
2278
- const parser = streamParsers.get(assistant.id)!;
2279
-
2280
- // Check if content looks like JSON
2281
- const looksLikeJson = accumulatedRaw.trim().startsWith('{') || accumulatedRaw.trim().startsWith('[');
2282
-
2283
- // Store raw buffer before processing (needed for step_complete handler)
2284
- if (looksLikeJson) {
2285
- rawContentBuffers.set(assistant.id, accumulatedRaw);
2286
- }
2287
-
2288
- // Check if this is a plain text parser (marked with __isPlainTextParser)
2289
- const isPlainTextParser = (parser as any).__isPlainTextParser === true;
2290
-
2291
- // If plain text parser, just append the chunk directly
2292
- if (isPlainTextParser) {
2293
- assistant.content = chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
2294
- // Clear any raw buffer/parser since we're in plain text mode
2295
- rawContentBuffers.delete(assistant.id);
2296
- streamParsers.delete(assistant.id);
2297
- assistant.rawContent = undefined;
2298
- emitMessage(assistant);
2299
- continue;
2300
- }
2301
-
2302
- // Try to parse with the parser (for structured parsers)
2303
- const parsedResult = parser.processChunk(accumulatedRaw);
2304
-
2305
- // Handle async parser result
2306
- if (parsedResult instanceof Promise) {
2307
- parsedResult.then((result) => {
2308
- // Extract text from result (could be string or object)
2309
- const text = typeof result === 'string' ? result : result?.text ?? null;
2310
-
2311
- if (text !== null && text.trim() !== "") {
2312
- // Parser successfully extracted text: update the chunk's assistant
2313
- // (not assistantMessage; text_end may have cleared that ref before microtasks run)
2314
- assistant.content = text;
2315
- emitMessage(assistant);
2316
- } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
2317
- // Not a structured format - show as plain text
2318
- const currentAssistant = assistantMessage;
2319
- const targetAssistant =
2320
- currentAssistant && currentAssistant.id === assistant.id
2321
- ? currentAssistant
2322
- : assistant;
2323
- if (targetAssistant.id === assistant.id) {
2324
- targetAssistant.content =
2325
- chunkSeq !== undefined ? accumulatedRaw : targetAssistant.content + chunk;
2326
- rawContentBuffers.delete(targetAssistant.id);
2327
- streamParsers.delete(targetAssistant.id);
2328
- targetAssistant.rawContent = undefined;
2329
- emitMessage(targetAssistant);
2330
- }
2331
- }
2332
- // Otherwise wait for more chunks (incomplete structured format)
2333
- // Don't emit message if parser hasn't extracted text yet
2334
- }).catch(() => {
2335
- // On error, treat as plain text
2336
- assistant.content =
2337
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
2338
- rawContentBuffers.delete(assistant.id);
2339
- streamParsers.delete(assistant.id);
2340
- assistant.rawContent = undefined;
2341
- emitMessage(assistant);
2342
- });
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;
2343
2453
  } else {
2344
- // Synchronous parser result
2345
- // Extract text from result (could be string, null, or object)
2346
- const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
2347
-
2348
- if (text !== null && text.trim() !== "") {
2349
- // Parser successfully extracted text
2350
- // Buffer is already set above
2351
- assistant.content = text;
2352
- emitMessage(assistant);
2353
- } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
2354
- // Not a structured format - show as plain text
2355
- assistant.content =
2356
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
2357
- // Clear any raw buffer/parser if we were in structured format mode
2358
- rawContentBuffers.delete(assistant.id);
2359
- streamParsers.delete(assistant.id);
2360
- assistant.rawContent = undefined;
2361
- emitMessage(assistant);
2362
- }
2363
- // Otherwise wait for more chunks (incomplete structured format)
2364
- // Don't emit message if parser hasn't extracted text yet
2365
- }
2366
-
2367
- // IMPORTANT: Don't call getExtractedText() and emit messages here
2368
- // This was causing raw JSON to be displayed because getExtractedText()
2369
- // wasn't extracting the "text" field correctly during streaming
2370
- }
2371
- if (payload.isComplete) {
2372
- const finalContent = payload.result?.response ?? assistant.content;
2373
- if (finalContent) {
2374
- // Check if we have raw content buffer that needs final processing
2375
- const rawBuffer = rawContentBuffers.get(assistant.id);
2376
- const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
2377
- assistant.rawContent = contentToProcess;
2378
-
2379
- // Try to extract text from final structured content
2380
- const parser = streamParsers.get(assistant.id);
2381
- let extractedText: string | null = null;
2382
- let asyncPending = false;
2383
-
2384
- if (parser) {
2385
- // First check if parser already has extracted text
2386
- extractedText = parser.getExtractedText();
2387
-
2388
- if (extractedText === null) {
2389
- // Try extracting with regex
2390
- extractedText = extractTextFromJson(contentToProcess);
2391
- }
2392
-
2393
- if (extractedText === null) {
2394
- // Try parser.processChunk as last resort
2395
- const parsedResult = parser.processChunk(contentToProcess);
2396
- if (parsedResult instanceof Promise) {
2397
- asyncPending = true;
2398
- parsedResult.then((result) => {
2399
- // Extract text from result (could be string or object)
2400
- const text = typeof result === 'string' ? result : result?.text ?? null;
2401
- if (text !== null) {
2402
- const currentAssistant = assistantMessage;
2403
- if (currentAssistant && currentAssistant.id === assistant.id) {
2404
- currentAssistant.content = text;
2405
- currentAssistant.streaming = false;
2406
- // Clean up
2407
- streamParsers.delete(currentAssistant.id);
2408
- rawContentBuffers.delete(currentAssistant.id);
2409
- emitMessage(currentAssistant);
2410
- }
2411
- }
2412
- });
2413
- } else {
2414
- // Extract text from synchronous result
2415
- extractedText = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
2416
- }
2417
- }
2418
- }
2419
-
2420
- // Skip sync emit if we're waiting on async parser
2421
- if (!asyncPending) {
2422
- // Set content: use extracted text if available, otherwise use raw content
2423
- if (extractedText !== null && extractedText.trim() !== "") {
2424
- assistant.content = extractedText;
2425
- } else if (!rawContentBuffers.has(assistant.id)) {
2426
- // Only use raw final content if we didn't accumulate chunks
2427
- assistant.content = ensureStringContent(finalContent);
2428
- }
2429
-
2430
- // Clean up parser and buffer
2431
- const parserToClose = streamParsers.get(assistant.id);
2432
- if (parserToClose) {
2433
- const closeResult = parserToClose.close?.();
2434
- if (closeResult instanceof Promise) {
2435
- closeResult.catch(() => {});
2436
- }
2437
- streamParsers.delete(assistant.id);
2438
- }
2439
- rawContentBuffers.delete(assistant.id);
2440
- assistant.streaming = false;
2441
- 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;
2442
2459
  }
2460
+ prev.streaming = false;
2461
+ emitMessage(prev);
2443
2462
  }
2463
+ assistantMessage = null;
2444
2464
  }
2465
+ currentTextBlockId = null;
2466
+ pendingFlowRaw = "";
2445
2467
  } else if (payloadType === "step_complete") {
2446
2468
  // Only process completions for prompt steps, not tool/context steps
2447
2469
  const stepType = (payload as any).stepType;
@@ -2451,378 +2473,139 @@ export class AgentWidgetClient {
2451
2473
  continue;
2452
2474
  }
2453
2475
 
2454
- // Nested flow: seal every segment message produced by this nested
2455
- // step (a single nested prompt step may have produced multiple
2456
- // messages, one per partId, when inner tool calls split it). The
2457
- // outer assistantMessage state is untouched so reconciliation for
2458
- // the outer flow still works.
2459
- const nestedCompleteCtx = (payload as any).toolContext as
2460
- | { toolId?: string; stepId?: string; executionId?: string }
2461
- | undefined;
2462
- if (nestedCompleteCtx?.toolId) {
2463
- const nestedStepId = String(
2464
- payload.id ?? nestedCompleteCtx.stepId ?? ""
2465
- );
2466
- if (nestedStepId) {
2467
- const prefix = getNestedStepPrefix(
2468
- nestedCompleteCtx.toolId,
2469
- nestedStepId
2470
- );
2471
- for (const [key, msg] of nestedStepMessages) {
2472
- if (key.startsWith(prefix) && msg.streaming !== false) {
2473
- msg.streaming = false;
2474
- emitMessage(msg);
2475
- }
2476
- }
2477
- nestedPartIdByStep.delete(
2478
- `${nestedCompleteCtx.toolId}::${nestedStepId}`
2479
- );
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);
2480
2492
  }
2493
+ onEvent({ type: "status", status: "idle" });
2481
2494
  continue;
2482
2495
  }
2483
2496
 
2484
- // Capture optional per-step stopReason emitted by the runtime
2485
- // (e.g. `'max_tool_calls'`, `'length'`). This is the dispatch-mode
2486
- // fallback: `agent_turn_complete` will overwrite it later in
2487
- // agent-loop streams.
2488
- const stepStopReason = (payload as any).stopReason as
2489
- | StopReasonKind
2490
- | undefined;
2491
-
2492
- if (didSplitByPartId) {
2493
- // Sealed segment(s): do not create a second bubble from step_complete.
2494
- // Merge authoritative final response into the last sealed segment (fixes async lag).
2495
- if (assistantMessage !== null) {
2496
- const msg: AgentWidgetMessage = assistantMessage;
2497
- if (stepStopReason) msg.stopReason = stepStopReason;
2498
- streamParsers.delete(msg.id);
2499
- rawContentBuffers.delete(msg.id);
2500
- if (msg.streaming !== false) {
2501
- msg.streaming = false;
2502
- emitMessage(msg);
2503
- }
2504
- }
2505
- const splitFinalContent = payload.result?.response;
2506
- const sealedForReconcile = lastSealedTextSegment;
2507
- if (sealedForReconcile) {
2508
- if (stepStopReason) sealedForReconcile.stopReason = stepStopReason;
2509
- if (splitFinalContent !== undefined && splitFinalContent !== null) {
2510
- reconcileSealedAssistantWithFinalResponse(sealedForReconcile, splitFinalContent);
2511
- } else {
2512
- streamParsers.delete(sealedForReconcile.id);
2513
- 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);
2514
2518
  }
2515
- }
2516
- lastSealedTextSegment = null;
2517
- continue;
2518
- }
2519
- const finalContent = payload.result?.response;
2520
- const assistant = ensureAssistantMessage();
2521
- if (stepStopReason) assistant.stopReason = stepStopReason;
2522
- if (finalContent !== undefined && finalContent !== null) {
2523
- // Check if we already have extracted text from streaming
2524
- const parser = streamParsers.get(assistant.id);
2525
- let hasExtractedText = false;
2526
- let asyncPending = false;
2527
-
2528
- if (parser) {
2529
- // First check if parser already extracted text during streaming
2530
- const currentExtractedText = parser.getExtractedText();
2531
- const rawBuffer = rawContentBuffers.get(assistant.id);
2532
- const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
2533
-
2534
- // Always set rawContent so action parsers can access the raw JSON
2535
- assistant.rawContent = contentToProcess;
2536
-
2537
- if (currentExtractedText !== null && currentExtractedText.trim() !== "") {
2538
- // We already have extracted text from streaming - use it
2539
- assistant.content = currentExtractedText;
2540
- hasExtractedText = true;
2541
- } else {
2542
- // No extracted text yet - try to extract from final content
2543
-
2544
- // Try fast path first
2545
- const extractedText = extractTextFromJson(contentToProcess);
2546
- if (extractedText !== null) {
2547
- assistant.content = extractedText;
2548
- 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);
2549
2533
  } else {
2550
- // Try parser
2551
- const parsedResult = parser.processChunk(contentToProcess);
2552
- if (parsedResult instanceof Promise) {
2553
- asyncPending = true;
2554
- parsedResult.then((result) => {
2555
- // Extract text from result (could be string or object)
2556
- const text = typeof result === 'string' ? result : result?.text ?? null;
2557
-
2558
- if (text !== null && text.trim() !== "") {
2559
- const currentAssistant = assistantMessage;
2560
- if (currentAssistant && currentAssistant.id === assistant.id) {
2561
- currentAssistant.content = text;
2562
- currentAssistant.streaming = false;
2563
- // Clean up
2564
- streamParsers.delete(currentAssistant.id);
2565
- rawContentBuffers.delete(currentAssistant.id);
2566
- emitMessage(currentAssistant);
2567
- }
2568
- } else {
2569
- // No extracted text - check if we should show raw content
2570
- const finalExtractedText = parser.getExtractedText();
2571
- const currentAssistant = assistantMessage;
2572
- if (currentAssistant && currentAssistant.id === assistant.id) {
2573
- if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
2574
- currentAssistant.content = finalExtractedText;
2575
- } else if (!rawContentBuffers.has(currentAssistant.id)) {
2576
- // Only show raw content if we never had any extracted text
2577
- currentAssistant.content = ensureStringContent(finalContent);
2578
- }
2579
- currentAssistant.streaming = false;
2580
- // Clean up
2581
- streamParsers.delete(currentAssistant.id);
2582
- rawContentBuffers.delete(currentAssistant.id);
2583
- emitMessage(currentAssistant);
2584
- }
2585
- }
2586
- });
2587
- } else {
2588
- // Extract text from synchronous result
2589
- const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
2590
-
2591
- if (text !== null && text.trim() !== "") {
2592
- assistant.content = text;
2593
- hasExtractedText = true;
2594
- } else {
2595
- // Check stub one more time
2596
- const finalExtractedText = parser.getExtractedText();
2597
- if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
2598
- assistant.content = finalExtractedText;
2599
- hasExtractedText = true;
2600
- }
2601
- }
2602
- }
2603
- }
2604
- }
2605
- }
2606
-
2607
- // Skip sync emit if we're waiting on async parser
2608
- if (!asyncPending) {
2609
- // Ensure rawContent is set even if there's no parser (for action parsing)
2610
- if (!assistant.rawContent) {
2611
- const rawBuffer = rawContentBuffers.get(assistant.id);
2612
- assistant.rawContent = rawBuffer ?? ensureStringContent(finalContent);
2613
- }
2614
-
2615
- // Only show raw content if we never extracted any text and no buffer was used
2616
- if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
2617
- // No extracted text and no streaming happened - show raw content
2618
- assistant.content = ensureStringContent(finalContent);
2619
- }
2620
-
2621
- // Clean up parser and buffer
2622
- if (parser) {
2623
- const closeResult = parser.close?.();
2624
- if (closeResult instanceof Promise) {
2625
- closeResult.catch(() => {});
2626
- }
2627
- }
2628
- streamParsers.delete(assistant.id);
2629
- rawContentBuffers.delete(assistant.id);
2630
- assistant.streaming = false;
2631
- emitMessage(assistant);
2632
- }
2633
- } else {
2634
- // No final content, just mark as complete and clean up
2635
- streamParsers.delete(assistant.id);
2636
- rawContentBuffers.delete(assistant.id);
2637
- assistant.streaming = false;
2638
- emitMessage(assistant);
2639
- }
2640
- } else if (payloadType === "flow_complete") {
2641
- const finalContent = payload.result?.response;
2642
- if (didSplitByPartId) {
2643
- // Content was split into multiple assistant messages: the full response
2644
- // in flow_complete would overwrite the last segment. Just finalize streaming.
2645
- if (assistantMessage !== null) {
2646
- const msg: AgentWidgetMessage = assistantMessage;
2647
- streamParsers.delete(msg.id);
2648
- rawContentBuffers.delete(msg.id);
2649
- if (msg.streaming !== false) {
2650
- msg.streaming = false;
2651
- emitMessage(msg);
2652
- }
2653
- }
2654
- } else if (finalContent !== undefined && finalContent !== null) {
2655
- const assistant = ensureAssistantMessage();
2656
- // Check if we have raw content buffer that needs final processing
2657
- const rawBuffer = rawContentBuffers.get(assistant.id);
2658
- const stringContent = rawBuffer ?? ensureStringContent(finalContent);
2659
- assistant.rawContent = stringContent;
2660
- // Try to extract text from structured content
2661
- let displayContent = ensureStringContent(finalContent);
2662
- const parser = streamParsers.get(assistant.id);
2663
- if (parser) {
2664
- const extractedText = extractTextFromJson(stringContent);
2665
- if (extractedText !== null) {
2666
- displayContent = extractedText;
2667
- } else {
2668
- // Try parser if it exists
2669
- const parsedResult = parser.processChunk(stringContent);
2670
- if (parsedResult instanceof Promise) {
2671
- parsedResult.then((result) => {
2672
- // Extract text from result (could be string or object)
2673
- const text = typeof result === 'string' ? result : result?.text ?? null;
2674
- if (text !== null) {
2675
- assistant.content = text;
2676
- assistant.streaming = false;
2677
- emitMessage(assistant);
2678
- }
2679
- });
2680
- }
2681
- const currentText = parser.getExtractedText();
2682
- if (currentText !== null) {
2683
- displayContent = currentText;
2534
+ assistant.streaming = false;
2535
+ emitMessage(assistant);
2684
2536
  }
2685
2537
  }
2686
2538
  }
2687
- // Clean up parser and buffer
2688
- streamParsers.delete(assistant.id);
2689
- rawContentBuffers.delete(assistant.id);
2690
-
2691
- // Only emit if something actually changed to avoid flicker
2692
- const contentChanged = displayContent !== assistant.content;
2693
- const streamingChanged = assistant.streaming !== false;
2694
-
2695
- if (contentChanged) {
2696
- assistant.content = displayContent;
2697
- }
2698
- assistant.streaming = false;
2699
-
2700
- // Only emit if content or streaming state changed
2701
- if (contentChanged || streamingChanged) {
2702
- emitMessage(assistant);
2703
- }
2704
- } else {
2705
- // No final content, just mark as complete and clean up
2706
- if (assistantMessage !== null) {
2707
- // Clean up any remaining parsers/buffers
2708
- // TypeScript narrowing issue - assistantMessage is checked for null above
2709
- const msg: AgentWidgetMessage = assistantMessage;
2710
- streamParsers.delete(msg.id);
2711
- rawContentBuffers.delete(msg.id);
2712
- // Only emit if streaming state changed
2713
- if (msg.streaming !== false) {
2714
- msg.streaming = false;
2715
- emitMessage(msg);
2716
- }
2717
- }
2539
+ continue;
2718
2540
  }
2719
- onEvent({ type: "status", status: "idle" });
2720
2541
  // ================================================================
2721
2542
  // Agent Loop Execution Events
2722
2543
  // ================================================================
2723
- } else if (payloadType === "agent_start") {
2724
- agentExecution = {
2725
- executionId: payload.executionId,
2726
- agentId: payload.agentId ?? 'virtual',
2727
- agentName: payload.agentName ?? '',
2728
- status: 'running',
2729
- currentIteration: 0,
2730
- maxTurns: payload.maxTurns ?? 1,
2731
- startedAt: resolveTimestamp(payload.startedAt)
2732
- };
2733
- } else if (payloadType === "agent_iteration_start") {
2734
- if (agentExecution) {
2735
- agentExecution.currentIteration = payload.iteration;
2736
- }
2737
-
2738
- // In 'separate' mode, finalize previous iteration's message and create a new one
2739
- if (iterationDisplay === 'separate' && payload.iteration > 1) {
2740
- const prevMsg = assistantMessage as AgentWidgetMessage | null;
2741
- if (prevMsg) {
2742
- prevMsg.streaming = false;
2743
- emitMessage(prevMsg);
2744
- // Store the completed message for this iteration
2745
- agentIterationMessages.set(payload.iteration - 1, prevMsg);
2746
- // Reset assistant message so ensureAssistantMessage creates a new one
2747
- assistantMessage = null;
2748
- }
2749
- }
2750
- } else if (payloadType === "agent_turn_start") {
2751
- // Reset the per-turn assistant tracker. lastAssistantInTurn is
2752
- // used by agent_turn_complete to attach stopReason to the final
2753
- // text segment of the turn even if that segment was sealed by an
2754
- // intervening tool-call boundary.
2755
- lastAssistantInTurn = null;
2756
- } else if (payloadType === "agent_turn_delta") {
2757
- if (payload.contentType === 'text') {
2758
- // Stream text to assistant message
2759
- const assistant = ensureAssistantMessage();
2760
- assistant.content += payload.delta ?? '';
2761
- assistant.agentMetadata = {
2544
+ } else if (payloadType === "execution_start") {
2545
+ executionKind = payload.kind === "flow" ? "flow" : "agent";
2546
+ if (executionKind === "agent") {
2547
+ agentExecution = {
2762
2548
  executionId: payload.executionId,
2763
- iteration: payload.iteration,
2764
- turnId: payload.turnId,
2765
- agentName: agentExecution?.agentName
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)
2766
2555
  };
2767
- lastAssistantInTurn = assistant;
2768
- emitMessage(assistant);
2769
- } else if (payload.contentType === 'thinking') {
2770
- // Stream thinking content to a reasoning message
2771
- const reasoningId = payload.turnId ?? `agent-think-${payload.iteration}`;
2772
- const reasoningMessage = ensureReasoningMessage(reasoningId);
2773
- reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
2774
- id: reasoningId,
2775
- status: "streaming",
2776
- chunks: []
2777
- };
2778
- reasoningMessage.reasoning.chunks.push(payload.delta ?? '');
2779
- reasoningMessage.agentMetadata = {
2780
- executionId: payload.executionId,
2781
- iteration: payload.iteration,
2782
- turnId: payload.turnId
2783
- };
2784
- emitMessage(reasoningMessage);
2785
- } else if (payload.contentType === 'tool_input') {
2786
- // Stream tool input to current tool message
2787
- const toolId = payload.toolCallId ?? toolContext.lastId;
2788
- if (toolId) {
2789
- const toolMessage = toolMessages.get(toolId);
2790
- if (toolMessage?.toolCall) {
2791
- toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
2792
- toolMessage.toolCall.chunks.push(payload.delta ?? '');
2793
- 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;
2794
2573
  }
2795
2574
  }
2575
+ lastIterationSeen = iteration;
2796
2576
  }
2797
- } else if (payloadType === "agent_turn_complete") {
2798
- // Mark any active reasoning for this turn as complete
2799
- const reasoningId = payload.turnId;
2800
- if (reasoningId) {
2801
- const reasoningMessage = reasoningMessages.get(reasoningId);
2802
- if (reasoningMessage?.reasoning) {
2803
- reasoningMessage.reasoning.status = "complete";
2804
- reasoningMessage.reasoning.completedAt = resolveTimestamp(payload.completedAt);
2805
- const start = reasoningMessage.reasoning.startedAt ?? Date.now();
2806
- reasoningMessage.reasoning.durationMs = Math.max(
2807
- 0,
2808
- (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
2809
- );
2810
- reasoningMessage.streaming = false;
2811
- 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);
2812
2592
  }
2813
2593
  }
2814
-
2815
- // Attach the turn-level stopReason to the assistant message
2816
- // produced by this turn. Only overwrite the current message: // prior turns already sealed their own stopReason via step_complete.
2817
- // Falls back to lastAssistantInTurn when the current bubble was
2818
- // sealed at a tool-call boundary mid-turn, so the notice still
2819
- // 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.
2820
2603
  const turnStopReason = (payload as any).stopReason as
2821
2604
  | StopReasonKind
2822
2605
  | undefined;
2823
2606
  const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
2824
2607
  if (turnStopReason && stopReasonTarget !== null) {
2825
- const turnId = payload.turnId;
2608
+ const turnId = payload.id;
2826
2609
  const matchesTurn =
2827
2610
  !turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
2828
2611
  if (matchesTurn) {
@@ -2830,83 +2613,58 @@ export class AgentWidgetClient {
2830
2613
  emitMessage(stopReasonTarget);
2831
2614
  }
2832
2615
  }
2833
- } else if (payloadType === "agent_tool_start") {
2834
- // Finalize any in-flight assistant text bubble so subsequent text
2835
- // deltas in this turn create a NEW bubble. Without this, text
2836
- // emitted before AND after a tool call accumulates into one
2837
- // message that renders below all the tool bubbles, losing the
2838
- // chronological text→tool→text→tool interleaving.
2839
- if (assistantMessage) {
2840
- assistantMessage.streaming = false;
2841
- emitMessage(assistantMessage);
2842
- assistantMessage = null;
2843
- }
2844
- const toolId = payload.toolCallId ?? `agent-tool-${nextSequence()}`;
2845
- trackToolId(getToolCallKey(payload), toolId);
2846
- const toolMessage = ensureToolMessage(toolId);
2847
- const tool = toolMessage.toolCall ?? {
2848
- id: toolId, status: "pending" as const,
2849
- name: undefined, args: undefined, chunks: undefined,
2850
- result: undefined, duration: undefined, startedAt: undefined,
2851
- completedAt: undefined, durationMs: undefined
2852
- };
2853
- tool.name = payload.toolName ?? payload.name ?? tool.name;
2854
- tool.status = "running";
2855
- if (payload.parameters !== undefined) {
2856
- tool.args = payload.parameters;
2857
- }
2858
- tool.startedAt = resolveTimestamp(payload.startedAt ?? payload.timestamp);
2859
- toolMessage.toolCall = tool;
2860
- toolMessage.streaming = true;
2861
- toolMessage.agentMetadata = {
2862
- executionId: payload.executionId,
2863
- iteration: payload.iteration
2864
- };
2865
- emitMessage(toolMessage);
2866
- } else if (payloadType === "agent_tool_delta") {
2867
- const toolId = payload.toolCallId ?? toolContext.lastId;
2868
- if (toolId) {
2869
- const toolMessage = toolMessages.get(toolId) ?? ensureToolMessage(toolId);
2870
- if (toolMessage.toolCall) {
2871
- toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
2872
- toolMessage.toolCall.chunks.push(payload.delta ?? '');
2873
- toolMessage.toolCall.status = "running";
2874
- toolMessage.streaming = true;
2875
- emitMessage(toolMessage);
2876
- }
2877
- }
2878
- } else if (payloadType === "agent_tool_complete") {
2879
- const toolId = payload.toolCallId ?? toolContext.lastId;
2880
- if (toolId) {
2881
- const toolMessage = toolMessages.get(toolId) ?? ensureToolMessage(toolId);
2882
- if (toolMessage.toolCall) {
2883
- toolMessage.toolCall.status = "complete";
2884
- if (payload.result !== undefined) {
2885
- toolMessage.toolCall.result = payload.result;
2886
- }
2887
- if (typeof payload.executionTime === "number") {
2888
- toolMessage.toolCall.durationMs = payload.executionTime;
2889
- }
2890
- toolMessage.toolCall.completedAt = resolveTimestamp(payload.completedAt ?? payload.timestamp);
2891
- toolMessage.streaming = false;
2892
- emitMessage(toolMessage);
2893
- const callKey = getToolCallKey(payload);
2894
- if (callKey) {
2895
- toolContext.byCall.delete(callKey);
2896
- }
2897
- }
2898
- }
2899
- } else if (payloadType === "agent_media") {
2900
- // A tool produced media (image / audio / video / file). Render it
2901
- // as a synthetic assistant message inserted at the point the tool
2902
- // completed: between the tool bubble and the next text turn.
2903
- //
2904
- // Wire format is the AI SDK–aligned `MediaContentPart` from
2905
- // @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:
2906
2633
  // { type: 'media', data, mediaType } // AI SDK v6: base64
2907
2634
  // { type: 'image-url', url, mediaType? } // AI SDK v3/v4
2908
2635
  // { type: 'file-url', url, mediaType } // AI SDK v3/v4
2909
- 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] : [];
2910
2668
  const mediaContentParts: ContentPart[] = [];
2911
2669
  for (const part of rawMedia) {
2912
2670
  if (!part || typeof part !== "object") continue;
@@ -2950,7 +2708,9 @@ export class AgentWidgetClient {
2950
2708
  mediaContentParts.push({
2951
2709
  type: "image",
2952
2710
  image: src,
2953
- ...(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 } : {}),
2954
2714
  });
2955
2715
  } else if (mediaType.startsWith("audio/")) {
2956
2716
  mediaContentParts.push({
@@ -2981,7 +2741,7 @@ export class AgentWidgetClient {
2981
2741
  // sharing an id would let `emitMessage` merge them by id and
2982
2742
  // overwrite the prior `contentParts`.
2983
2743
  const seq = nextSequence();
2984
- const toolCallIdRaw = payload.toolCallId;
2744
+ const toolCallIdRaw = mediaToolCallId;
2985
2745
  const mediaIdSuffix =
2986
2746
  typeof toolCallIdRaw === "string" && toolCallIdRaw.length > 0
2987
2747
  ? `${toolCallIdRaw}-${seq}`
@@ -2996,7 +2756,12 @@ export class AgentWidgetClient {
2996
2756
  sequence: seq,
2997
2757
  agentMetadata: {
2998
2758
  executionId: payload.executionId,
2999
- 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,
3000
2765
  },
3001
2766
  };
3002
2767
  emitMessage(mediaMessage);
@@ -3014,67 +2779,49 @@ export class AgentWidgetClient {
3014
2779
  assistantMessage = null;
3015
2780
  assistantMessageRef.current = null;
3016
2781
  }
3017
- } else if (payloadType === "agent_iteration_complete") {
3018
- // Iteration complete - no special handling needed
3019
- // In 'separate' mode, message finalization happens at next iteration_start
3020
- } else if (payloadType === "agent_reflection" || payloadType === "agent_reflect") {
3021
- // Create a reasoning message for reflection content
3022
- const reflectionId = `agent-reflection-${payload.executionId}-${payload.iteration}`;
3023
- const reflectionMessage: AgentWidgetMessage = {
3024
- id: reflectionId,
3025
- role: "assistant",
3026
- content: payload.reflection ?? '',
3027
- createdAt: new Date().toISOString(),
3028
- streaming: false,
3029
- variant: "reasoning",
3030
- sequence: nextSequence(),
3031
- reasoning: {
3032
- id: reflectionId,
3033
- status: "complete",
3034
- chunks: [payload.reflection ?? '']
3035
- },
3036
- agentMetadata: {
3037
- executionId: payload.executionId,
3038
- iteration: payload.iteration
3039
- }
3040
- };
3041
- emitMessage(reflectionMessage);
3042
- } else if (payloadType === "agent_complete") {
3043
- if (agentExecution) {
2782
+ } else if (payloadType === "execution_complete") {
2783
+ const kind = payload.kind ?? executionKind;
2784
+ if (kind === "agent" && agentExecution) {
3044
2785
  agentExecution.status = payload.success ? 'complete' : 'error';
3045
2786
  agentExecution.completedAt = resolveTimestamp(payload.completedAt);
3046
2787
  agentExecution.stopReason = payload.stopReason;
3047
2788
  }
3048
2789
 
3049
- // 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.
3050
2794
  const finalMsg = assistantMessage as AgentWidgetMessage | null;
3051
2795
  if (finalMsg) {
3052
- finalMsg.streaming = false;
3053
- 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;
3054
2803
  }
2804
+ currentTextBlockId = null;
2805
+ pendingFlowRaw = "";
2806
+ lastSealedFlowBubble = null;
3055
2807
 
3056
2808
  onEvent({ type: "status", status: "idle" });
3057
- } 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).
3058
2812
  const errorMessage = typeof payload.error === 'string'
3059
2813
  ? payload.error
3060
- : payload.error?.message ?? 'Agent execution error';
3061
- if (payload.recoverable) {
3062
- if (typeof console !== "undefined") {
3063
- // eslint-disable-next-line no-console
3064
- console.warn("[AgentWidget] Recoverable agent error:", errorMessage);
3065
- }
3066
- } else {
3067
- onEvent({
3068
- type: "error",
3069
- error: new Error(errorMessage)
3070
- });
3071
- }
3072
- } 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") {
3073
2820
  // Keep-alive heartbeat - no action needed
3074
2821
  // ================================================================
3075
2822
  // Tool Approval Events
3076
2823
  // ================================================================
3077
- } else if (payloadType === "agent_approval_start") {
2824
+ } else if (payloadType === "approval_start") {
3078
2825
  const approvalId = payload.approvalId ?? `approval-${nextSequence()}`;
3079
2826
  const approvalMessage: AgentWidgetMessage = {
3080
2827
  id: `approval-${approvalId}`,
@@ -3124,7 +2871,7 @@ export class AgentWidgetClient {
3124
2871
  },
3125
2872
  };
3126
2873
  emitMessage(approvalMessage);
3127
- } else if (payloadType === "agent_approval_complete") {
2874
+ } else if (payloadType === "approval_complete") {
3128
2875
  const approvalId = payload.approvalId;
3129
2876
  if (approvalId) {
3130
2877
  // Find and update the existing approval message
@@ -3269,8 +3016,33 @@ export class AgentWidgetClient {
3269
3016
  assistantMessageRef.current = null;
3270
3017
  streamParsers.delete(id);
3271
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
+ }
3272
3045
  } else if (
3273
- payloadType === "error" ||
3274
3046
  payloadType === "step_error" ||
3275
3047
  payloadType === "dispatch_error" ||
3276
3048
  payloadType === "flow_error"
@@ -3283,18 +3055,13 @@ export class AgentWidgetClient {
3283
3055
  if (msg != null && msg !== "") {
3284
3056
  resolvedError = new Error(String(msg));
3285
3057
  }
3286
- } else if (
3287
- payloadType === "step_error" ||
3288
- payloadType === "flow_error"
3289
- ) {
3058
+ } else {
3290
3059
  const e = payload.error;
3291
3060
  if (typeof e === "string" && e !== "") {
3292
3061
  resolvedError = new Error(e);
3293
3062
  } else if (e != null && typeof e === "object" && "message" in e) {
3294
3063
  resolvedError = new Error(String((e as { message?: unknown }).message ?? e));
3295
3064
  }
3296
- } else if (payloadType === "error" && payload.error != null && payload.error !== "") {
3297
- resolvedError = new Error(String(payload.error));
3298
3065
  }
3299
3066
 
3300
3067
  if (resolvedError) {
@@ -3364,7 +3131,7 @@ export class AgentWidgetClient {
3364
3131
  assistantMessageRef,
3365
3132
  emitMessage,
3366
3133
  nextSequence,
3367
- partIdState
3134
+ customParsePartId
3368
3135
  );
3369
3136
  // Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
3370
3137
  if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
@@ -3373,14 +3140,14 @@ export class AgentWidgetClient {
3373
3140
  if (handled) continue; // Skip default handling if custom handler processed it
3374
3141
  }
3375
3142
 
3376
- // Push through the sequence reorder buffer
3377
- 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 });
3378
3147
  drainReadyQueue();
3379
3148
  }
3380
3149
  }
3381
3150
 
3382
- seqBuffer.flushPending();
3383
3151
  drainReadyQueue();
3384
- seqBuffer.destroy();
3385
3152
  }
3386
3153
  }