@runtypelabs/persona 4.29.0 → 4.29.2

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 (134) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/wipe.d.cts +1 -1
  4. package/dist/animations/wipe.d.ts +1 -1
  5. package/dist/approval-ui.cjs +3 -3
  6. package/dist/approval-ui.d.cts +1 -1
  7. package/dist/approval-ui.d.ts +1 -1
  8. package/dist/approval-ui.js +3 -3
  9. package/dist/artifacts-ui.d.cts +2 -2
  10. package/dist/artifacts-ui.d.ts +2 -2
  11. package/dist/chunk-B1-_pp-Y.js +4 -0
  12. package/dist/chunk-B2HCRERn.js +9 -0
  13. package/dist/chunk-Bm2-bemv.js +1 -0
  14. package/dist/chunk-BvhTpG6k.js +1 -0
  15. package/dist/{chunk-DjOvgFsn.js → chunk-Car1mORT.js} +1 -1
  16. package/dist/chunk-D5B629Mj.js +1 -0
  17. package/dist/chunk-DO9_WSpy.js +1 -0
  18. package/dist/chunk-DTDHeUsG.js +1 -0
  19. package/dist/chunk-DjFpGFGA.js +1 -0
  20. package/dist/chunk-DrpuvY5W.js +1 -0
  21. package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
  22. package/dist/client-history.js +1 -1
  23. package/dist/client-stream.js +1 -1
  24. package/dist/codegen.cjs +1 -1
  25. package/dist/codegen.js +1 -1
  26. package/dist/{composer-input-D3sB8_P_.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
  27. package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
  28. package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
  29. package/dist/{context-mention-manager-D2qxE5op.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
  30. package/dist/context-mentions-inline.d.cts +2 -2
  31. package/dist/context-mentions-inline.d.ts +2 -2
  32. package/dist/context-mentions.d.cts +3 -3
  33. package/dist/context-mentions.d.ts +3 -3
  34. package/dist/{defaults-Dsw66XqE.d.cts → defaults-DN-L67ak.d.cts} +1 -1
  35. package/dist/{defaults-Dsw66XqE.d.ts → defaults-DN-L67ak.d.ts} +1 -1
  36. package/dist/event-stream-view.d.cts +1 -1
  37. package/dist/event-stream-view.d.ts +1 -1
  38. package/dist/{fallback-speech-engine-DJpJcWAk.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
  39. package/dist/{fallback-speech-engine-DJpJcWAk.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
  40. package/dist/forms-ui.d.cts +2 -2
  41. package/dist/forms-ui.d.ts +2 -2
  42. package/dist/history-view.d.cts +1 -1
  43. package/dist/history-view.d.ts +1 -1
  44. package/dist/index.cjs +56 -56
  45. package/dist/index.cjs.map +1 -1
  46. package/dist/index.d.cts +6 -6
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.global.js +2 -3
  49. package/dist/index.global.js.map +1 -1
  50. package/dist/index.js +42 -42
  51. package/dist/index.js.map +1 -1
  52. package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
  53. package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
  54. package/dist/{preview-DKKpfTo7.d.cts → preview-m9QlqsZi.d.cts} +2 -2
  55. package/dist/{preview-DKKpfTo7.d.ts → preview-m9QlqsZi.d.ts} +2 -2
  56. package/dist/{session-DleMr9dZ.d.ts → session-BtvvmWnw.d.cts} +21 -9
  57. package/dist/{session-DleMr9dZ.d.cts → session-BtvvmWnw.d.ts} +21 -9
  58. package/dist/session-actions.js +1 -1
  59. package/dist/session-reconnect.cjs +1 -1
  60. package/dist/session-reconnect.d.cts +8 -2
  61. package/dist/session-reconnect.d.ts +8 -2
  62. package/dist/session-reconnect.js +1 -1
  63. package/dist/smart-dom-reader.d.cts +1 -1
  64. package/dist/smart-dom-reader.d.ts +1 -1
  65. package/dist/theme-editor-preview.cjs +62 -62
  66. package/dist/theme-editor-preview.d.cts +1 -1
  67. package/dist/theme-editor-preview.d.ts +1 -1
  68. package/dist/theme-editor-preview.js +41 -41
  69. package/dist/theme-editor.d.cts +2 -2
  70. package/dist/theme-editor.d.ts +2 -2
  71. package/dist/theme-reference.d.cts +2 -2
  72. package/dist/theme-reference.d.ts +2 -2
  73. package/dist/{types-oAZ1VReS.d.cts → types-DL-oBHE1.d.cts} +41 -3
  74. package/dist/{types-oAZ1VReS.d.ts → types-DL-oBHE1.d.ts} +41 -3
  75. package/dist/voice-client-tools.d.cts +1 -1
  76. package/dist/voice-client-tools.d.ts +1 -1
  77. package/dist/voice-runtime.cjs +3 -3
  78. package/dist/voice-runtime.d.cts +10 -4
  79. package/dist/voice-runtime.d.ts +10 -4
  80. package/dist/voice-runtime.js +5 -5
  81. package/dist/voice-worklet-player.d.cts +2 -2
  82. package/dist/voice-worklet-player.d.ts +2 -2
  83. package/dist/{webmcp-runtime-entry-Deg0NfmB.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
  84. package/dist/{webmcp-runtime-entry-Deg0NfmB.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
  85. package/dist/webmcp-runtime.d.cts +1 -1
  86. package/dist/webmcp-runtime.d.ts +1 -1
  87. package/dist/widget.css +1 -1
  88. package/package.json +1 -1
  89. package/src/client-approve.test.ts +17 -1
  90. package/src/client-resume.ts +15 -3
  91. package/src/client.interrupt.test.ts +21 -0
  92. package/src/client.ts +24 -4
  93. package/src/components/activity-row.ts +3 -2
  94. package/src/components/approval-actions.test.ts +8 -0
  95. package/src/components/approval-actions.ts +6 -2
  96. package/src/components/approval-bubble.test.ts +14 -0
  97. package/src/components/approval-bubble.ts +8 -1
  98. package/src/components/tool-bubble.ts +8 -5
  99. package/src/generated/runtype-openapi-contract.ts +7 -1
  100. package/src/resume-dedupe-local-only.test.ts +273 -0
  101. package/src/runtime/persist-state.test.ts +86 -0
  102. package/src/session-actions.ts +20 -6
  103. package/src/session-reconnect.ts +18 -1
  104. package/src/session.restored-browser-tools.test.ts +272 -0
  105. package/src/session.test.ts +23 -0
  106. package/src/session.ts +211 -46
  107. package/src/session.voice.test.ts +169 -0
  108. package/src/styles/widget.css +4 -2
  109. package/src/types.ts +37 -2
  110. package/src/ui.restored-browser-tools.test.ts +128 -0
  111. package/src/ui.scroll.test.ts +7 -2
  112. package/src/ui.stream-replay.test.ts +86 -0
  113. package/src/ui.streaming-coalescing.test.ts +183 -0
  114. package/src/ui.ts +152 -124
  115. package/src/ui.typing-indicator.test.ts +21 -0
  116. package/src/utils/formatting.test.ts +9 -1
  117. package/src/utils/formatting.ts +4 -0
  118. package/src/utils/local-only-message.ts +15 -0
  119. package/src/utils/message-fingerprint.test.ts +9 -0
  120. package/src/utils/message-fingerprint.ts +18 -5
  121. package/src/utils/morph.ts +10 -0
  122. package/src/utils/restored-browser-state.ts +50 -0
  123. package/src/utils/storage.ts +12 -1
  124. package/src/utils/voice-prior-tool-results.ts +37 -0
  125. package/src/voice/runtype-voice-provider.ts +13 -8
  126. package/src/voice/session-voice-wiring.ts +1 -2
  127. package/src/voice/voice-delegation.ts +10 -3
  128. package/src/voice/voice.test.ts +38 -2
  129. package/dist/chunk-B6zZsT6F.js +0 -1
  130. package/dist/chunk-CIVsNcrk.js +0 -1
  131. package/dist/chunk-CKdlNyzM.js +0 -9
  132. package/dist/chunk-DbhxjyOB.js +0 -4
  133. package/dist/chunk-DsdqLStH.js +0 -1
  134. package/dist/chunk-GIqbL2Eg.js +0 -1
package/src/ui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isBrowserLocalApproval } from "./utils/restored-browser-state";
1
2
  import { activityVariant, activityDuration, createActivityLifecycle, createActivityGroup } from "./components/activity-row";
2
3
  import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
3
4
  import { usesSessionVoice } from "./utils/voice-support";
@@ -331,7 +332,8 @@ import {
331
332
  defaultActionHandlers,
332
333
  defaultJsonActionParser
333
334
  } from "./utils/actions";
334
- import { createLocalStorageAdapter } from "./utils/storage";
335
+ import { createLocalStorageAdapter, transcriptStorageKey } from "./utils/storage";
336
+ import { isLocalOnlyMessage } from "./utils/local-only-message";
335
337
  import { createVisitorStore, type VisitorStore } from "./utils/visitor-store";
336
338
  import { loadHistoryView } from "./history-view-loader";
337
339
  import { getHistoryShellSync, loadHistoryShell, type HistoryShellModule } from "./history-shell-loader";
@@ -501,11 +503,13 @@ const ensureRecord = (value: unknown): Record<string, unknown> => {
501
503
  return { ...(value as Record<string, unknown>) };
502
504
  };
503
505
 
504
- const stripStreamingFromMessages = (messages: AgentWidgetMessage[]) =>
505
- messages.map((message) => ({
506
- ...message,
507
- streaming: false
508
- }));
506
+ const messagesForStorage = (messages: AgentWidgetMessage[]) =>
507
+ messages
508
+ .filter((message) => !isLocalOnlyMessage(message))
509
+ .map((message) => ({
510
+ ...message,
511
+ streaming: false
512
+ }));
509
513
 
510
514
  type Controller = {
511
515
  update: (config: AgentWidgetConfigPatch) => void;
@@ -1064,7 +1068,12 @@ export const createAgentExperience = (
1064
1068
  const storageAdapter: AgentWidgetStorageAdapter | null =
1065
1069
  messagePersistenceDisabled
1066
1070
  ? null
1067
- : (config.storageAdapter ?? createLocalStorageAdapter());
1071
+ : (config.storageAdapter ??
1072
+ createLocalStorageAdapter(
1073
+ transcriptStorageKey(
1074
+ typeof config.persistState === "object" ? config.persistState.keyPrefix : undefined
1075
+ )
1076
+ ));
1068
1077
  const personaOwnsClientTokenRecovery =
1069
1078
  Boolean(config.clientToken) && typeof config.reconnectStream !== "function";
1070
1079
  let persistentMetadata: Record<string, unknown> = {};
@@ -3055,8 +3064,8 @@ export const createAgentExperience = (
3055
3064
 
3056
3065
  // WebMCP gate approvals resolve a local Promise the bridge is parked on
3057
3066
  // (no server round-trip); server-driven approvals call the API. The
3058
- // `toolType` marker set in `requestWebMcpApproval` discriminates the two.
3059
- if (approvalMessage.approval.toolType === "webmcp") {
3067
+ // `toolType` marker and absent execution id identify browser-local gates.
3068
+ if (isBrowserLocalApproval(approvalMessage.approval)) {
3060
3069
  session.resolveWebMcpApproval(messageId, decision);
3061
3070
  } else {
3062
3071
  session.resolveApproval(approvalMessage.approval, decision);
@@ -4926,6 +4935,13 @@ export const createAgentExperience = (
4926
4935
 
4927
4936
  let isStreaming = false;
4928
4937
  const messageCache = createMessageCache();
4938
+ // Assistant message ids seen streaming in a coalesced (never-applied)
4939
+ // transcript snapshot; see `coalesceStreamingChange`.
4940
+ const coalescedStreamingIds = new Set<string>();
4941
+ // Fingerprint each live transcript row was last morphed to, by message id.
4942
+ // A row whose message still has that fingerprint is left in place by the
4943
+ // next render pass instead of being cloned from the cache and re-morphed.
4944
+ let liveRowFingerprints = new Map<string, string>();
4929
4945
  // Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
4930
4946
  // bubble for, per message id. Lets us skip unnecessary rebuilds across
4931
4947
  // re-renders so user state inside the plugin (typed text, focus) survives.
@@ -5159,7 +5175,7 @@ export const createAgentExperience = (
5159
5175
 
5160
5176
  const getMessagesForPersistence = () =>
5161
5177
  session
5162
- ? stripStreamingFromMessages(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
5178
+ ? messagesForStorage(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
5163
5179
  : [];
5164
5180
 
5165
5181
  let storageMutationTail: Promise<void> | null = null;
@@ -5209,7 +5225,7 @@ export const createAgentExperience = (
5209
5225
 
5210
5226
  // Allow saving even if session doesn't exist yet (for metadata during init)
5211
5227
  const messages = messagesOverride
5212
- ? stripStreamingFromMessages(messagesOverride)
5228
+ ? messagesForStorage(messagesOverride)
5213
5229
  : session
5214
5230
  ? getMessagesForPersistence()
5215
5231
  : [];
@@ -6020,7 +6036,7 @@ export const createAgentExperience = (
6020
6036
 
6021
6037
  if (
6022
6038
  message.role === "assistant" &&
6023
- previous?.streaming &&
6039
+ (previous?.streaming || coalescedStreamingIds.has(message.id)) &&
6024
6040
  message.streaming === false
6025
6041
  ) {
6026
6042
  eventBus.emit("assistant:complete", message);
@@ -6040,6 +6056,7 @@ export const createAgentExperience = (
6040
6056
  nextState.forEach((value, key) => {
6041
6057
  messageState.set(key, value);
6042
6058
  });
6059
+ coalescedStreamingIds.clear();
6043
6060
  };
6044
6061
 
6045
6062
 
@@ -6106,6 +6123,21 @@ export const createAgentExperience = (
6106
6123
  // Build new content in a temporary container for morphing
6107
6124
  const tempContainer = document.createElement("div");
6108
6125
 
6126
+ // Unchanged messages keep their live row: the pass appends an empty
6127
+ // `data-persona-reuse` stub that `morphMessages` matches by id and skips,
6128
+ // so a render costs O(changed rows) rather than a deep clone, serialize,
6129
+ // and morph of the whole transcript. Tool grouping restyles and re-parents
6130
+ // rows after this loop, so grouped transcripts always rebuild.
6131
+ const reuseLiveRows = !config.features?.toolCallDisplay?.grouped;
6132
+ const liveRows = new Set<string>();
6133
+ if (reuseLiveRows) {
6134
+ for (const child of Array.from(container.children)) {
6135
+ const id = child.getAttribute("data-wrapper-id");
6136
+ if (id) liveRows.add(id);
6137
+ }
6138
+ }
6139
+ const nextLiveRowFingerprints = new Map<string, string>();
6140
+
6109
6141
  // Create inline loading indicator renderer using priority chain: plugin -> config -> default
6110
6142
  const getInlineLoadingIndicatorRenderer = (): LoadingIndicatorRenderer | undefined => {
6111
6143
  // Check if any plugin has renderLoadingIndicator
@@ -6239,7 +6271,8 @@ export const createAgentExperience = (
6239
6271
  return (
6240
6272
  dropCompletedToolCalls &&
6241
6273
  message.variant === "tool" &&
6242
- message.toolCall?.status === "complete"
6274
+ message.toolCall?.status === "complete" &&
6275
+ !message.toolCall.interrupted
6243
6276
  );
6244
6277
  };
6245
6278
 
@@ -6318,7 +6351,19 @@ export const createAgentExperience = (
6318
6351
  ? null
6319
6352
  : getCachedWrapper(messageCache, message.id, fingerprint);
6320
6353
  if (cachedWrapper) {
6321
- tempContainer.appendChild(cachedWrapper.cloneNode(true));
6354
+ if (
6355
+ liveRows.has(message.id) &&
6356
+ liveRowFingerprints.get(message.id) === fingerprint
6357
+ ) {
6358
+ const stub = document.createElement("div");
6359
+ stub.id = `wrapper-${message.id}`;
6360
+ stub.setAttribute("data-wrapper-id", message.id);
6361
+ stub.setAttribute("data-persona-reuse", "true");
6362
+ tempContainer.appendChild(stub);
6363
+ } else {
6364
+ tempContainer.appendChild(cachedWrapper.cloneNode(true));
6365
+ }
6366
+ if (reuseLiveRows) nextLiveRowFingerprints.set(message.id, fingerprint);
6322
6367
  // Keep the overlay sheet alive only while the server is actively
6323
6368
  // waiting on the user (awaitingLocalTool === true). Before await
6324
6369
  // fires, or after the answer resumes the flow, omit from
@@ -6507,7 +6552,7 @@ export const createAgentExperience = (
6507
6552
  ?.getMessages()
6508
6553
  .find((m) => m.id === approvalMessageId);
6509
6554
  if (!live?.approval) return;
6510
- if (live.approval.toolType === "webmcp") {
6555
+ if (isBrowserLocalApproval(live.approval)) {
6511
6556
  sessionRef.current?.resolveWebMcpApproval(live.id, decision);
6512
6557
  } else {
6513
6558
  sessionRef.current?.resolveApproval(live.approval, decision, options);
@@ -6774,6 +6819,7 @@ export const createAgentExperience = (
6774
6819
  wrapper.appendChild(bubble);
6775
6820
  setCachedWrapper(messageCache, message.id, fingerprint, wrapper);
6776
6821
  tempContainer.appendChild(wrapper);
6822
+ if (reuseLiveRows) nextLiveRowFingerprints.set(message.id, fingerprint);
6777
6823
  });
6778
6824
 
6779
6825
  // Prune any ask_user_question sheets whose source message is no longer in
@@ -6801,7 +6847,7 @@ export const createAgentExperience = (
6801
6847
  // A tool call hidden by `completedVisibility: "removed"` does not
6802
6848
  // create a visible break in the transcript, so it should not split
6803
6849
  // an otherwise contiguous group.
6804
- if (dropCompletedToolCalls && message.toolCall.status === "complete") {
6850
+ if (dropCompletedToolCalls && message.toolCall.status === "complete" && !message.toolCall.interrupted) {
6805
6851
  return;
6806
6852
  }
6807
6853
  currentGroup.push(message);
@@ -6859,10 +6905,12 @@ export const createAgentExperience = (
6859
6905
  id, name: `${group.length} tools`, status: active ? "running" : "complete",
6860
6906
  chunks: group.flatMap(item => item.toolCall?.chunks ?? []),
6861
6907
  success: !group.some(item => item.toolCall?.success === false),
6908
+ interrupted: !active && group.some(item => item.toolCall?.interrupted),
6862
6909
  } };
6863
6910
  activityIds.add(id);
6864
6911
  activityLifecycle.observe(synthetic, "tool", toolExpansionState, config.features.toolCallDisplay);
6865
- const label = `${active ? "Using" : "Used"} ${group.length} tools`;
6912
+ const label = synthetic.toolCall?.interrupted ? `${group.length} tools · Interrupted`
6913
+ : `${active ? "Using" : "Used"} ${group.length} tools`;
6866
6914
  const custom = config.toolCall?.renderGroupedSummary?.({ messages: group, toolCalls: group.map(item => item.toolCall!), defaultSummary: label, config });
6867
6915
  const row = createActivityGroup(synthetic, config, toolExpansionState.has(id), custom ?? label);
6868
6916
  row.bubble.classList.add("persona-tool-group");
@@ -6888,7 +6936,9 @@ export const createAgentExperience = (
6888
6936
  "persona-tool-group-summary persona-text-xs persona-text-persona-muted";
6889
6937
  summary.id = `tool-group-summary-${group[0].id}`;
6890
6938
 
6891
- const defaultSummary = `Called ${group.length} ${group.length === 1 ? "tool" : "tools"}`;
6939
+ const toolCount = `${group.length} ${group.length === 1 ? "tool" : "tools"}`;
6940
+ const defaultSummary = group.some(item => item.toolCall?.interrupted)
6941
+ ? `${toolCount} · Interrupted` : `Called ${toolCount}`;
6892
6942
  const renderedSummary = config.toolCall?.renderGroupedSummary?.({
6893
6943
  messages: group,
6894
6944
  toolCalls: group
@@ -7106,6 +7156,16 @@ export const createAgentExperience = (
7106
7156
 
7107
7157
  // Use idiomorph to morph the container contents
7108
7158
  morphMessages(container, tempContainer);
7159
+ liveRowFingerprints = nextLiveRowFingerprints;
7160
+ // A stub idiomorph inserted instead of matching to its live row would
7161
+ // leave an empty row; restore it from the cache.
7162
+ for (const stub of Array.from(
7163
+ container.querySelectorAll<HTMLElement>(":scope > [data-persona-reuse]")
7164
+ )) {
7165
+ const cached = messageCache.get(stub.getAttribute("data-wrapper-id") ?? "");
7166
+ if (cached) stub.replaceWith(cached.wrapper.cloneNode(true));
7167
+ else stub.remove();
7168
+ }
7109
7169
 
7110
7170
  // Set initial edge-fade state on the live table wrappers and attach the
7111
7171
  // delegated scroll listener (once) that keeps the fades in sync.
@@ -8582,74 +8642,48 @@ export const createAgentExperience = (
8582
8642
  }, 100);
8583
8643
  };
8584
8644
 
8585
- const isDeepEqual = (left: unknown, right: unknown): boolean => {
8586
- if (Object.is(left, right)) return true;
8587
- if (left === null || right === null) return false;
8588
- if (typeof left !== "object" || typeof right !== "object") return false;
8589
- if (Array.isArray(left) || Array.isArray(right)) {
8590
- if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
8591
- return false;
8592
- }
8593
- return left.every((value, index) => isDeepEqual(value, right[index]));
8594
- }
8595
- const leftRecord = left as Record<string, unknown>;
8596
- const rightRecord = right as Record<string, unknown>;
8597
- const leftKeys = Object.keys(leftRecord);
8598
- const rightKeys = Object.keys(rightRecord);
8599
- return (
8600
- leftKeys.length === rightKeys.length &&
8601
- leftKeys.every(
8602
- (key) =>
8603
- Object.prototype.hasOwnProperty.call(rightRecord, key) &&
8604
- isDeepEqual(leftRecord[key], rightRecord[key])
8605
- )
8606
- );
8607
- };
8608
-
8609
- const withoutStreamingText = (message: AgentWidgetMessage) => {
8610
- const { content: _content, rawContent: _rawContent, llmContent: _llmContent, ...rest } = message;
8611
- return rest;
8612
- };
8613
-
8614
- const isPlainStreamingAssistant = (message: AgentWidgetMessage) =>
8615
- message.role === "assistant" &&
8616
- message.streaming === true &&
8617
- !message.variant &&
8618
- !message.toolCall &&
8619
- !message.tools &&
8620
- !message.approval &&
8621
- !message.reasoning &&
8622
- !message.contentParts &&
8623
- !message.stopReason &&
8624
- !hasComponentDirective(message);
8645
+ /**
8646
+ * Changes that must reach the DOM (and storage) in the same task rather than
8647
+ * on the next animation frame: a new or edited user message drives send
8648
+ * anchoring and `user:message`, and an approval or a pending local tool
8649
+ * (ask_user_question, WebMCP) is waiting on the user and must be durable
8650
+ * before the page can unload. Everything else in a streaming burst is
8651
+ * coalesced.
8652
+ */
8653
+ const needsImmediateApply = (message: AgentWidgetMessage) =>
8654
+ message.role === "user" ||
8655
+ message.variant === "approval" ||
8656
+ message.agentMetadata?.awaitingLocalTool === true ||
8657
+ isAskUserQuestionMessage(message);
8625
8658
 
8626
- const isPureStreamingTextProgression = (
8659
+ /**
8660
+ * One pass over a streaming update: returns false when a changed entry needs
8661
+ * an immediate apply, and otherwise remembers assistant messages that
8662
+ * streamed inside the coalesced burst, so `trackMessages` still emits
8663
+ * `assistant:complete` when a message starts and finishes between two
8664
+ * applied snapshots. The session copies only the messages it changes, so
8665
+ * identity marks the changed entries; this pointer scan costs no more than
8666
+ * the array copy the session makes for every update.
8667
+ */
8668
+ const coalesceStreamingChange = (
8627
8669
  previous: AgentWidgetMessage[],
8628
- next: AgentWidgetMessage[],
8629
- candidate: { index: number; id: string }
8670
+ next: AgentWidgetMessage[]
8630
8671
  ) => {
8631
- if (previous.length !== next.length) return false;
8632
- const before = previous[candidate.index];
8633
- const after = next[candidate.index];
8634
- if (!before || !after || before.id !== candidate.id || after.id !== candidate.id) {
8635
- return false;
8672
+ if (next.length < previous.length) return false;
8673
+ for (let index = 0; index < next.length; index += 1) {
8674
+ const message = next[index];
8675
+ if (message === previous[index]) continue;
8676
+ if (needsImmediateApply(message)) return false;
8677
+ if (message.role === "assistant" && message.streaming) {
8678
+ coalescedStreamingIds.add(message.id);
8679
+ }
8636
8680
  }
8637
- const textChanged =
8638
- !Object.is(before.content, after.content) ||
8639
- !Object.is(before.rawContent, after.rawContent) ||
8640
- !Object.is(before.llmContent, after.llmContent);
8641
- return (
8642
- textChanged &&
8643
- isPlainStreamingAssistant(before) &&
8644
- isPlainStreamingAssistant(after) &&
8645
- isDeepEqual(withoutStreamingText(before), withoutStreamingText(after))
8646
- );
8681
+ return true;
8647
8682
  };
8648
8683
 
8649
8684
  let lastAppliedMessages: AgentWidgetMessage[] | null = null;
8650
- let activeStreamingTextCandidate: { index: number; id: string } | null = null;
8651
- let pendingStreamingTextMessages: AgentWidgetMessage[] | null = null;
8652
- let streamingTextRAF: number | null = null;
8685
+ let pendingMessages: AgentWidgetMessage[] | null = null;
8686
+ let pendingMessagesRAF: number | null = null;
8653
8687
  let pendingDurablePersistenceMessages: AgentWidgetMessage[] | null = null;
8654
8688
 
8655
8689
  const persistMessages = (messages: AgentWidgetMessage[]) => {
@@ -8658,7 +8692,9 @@ export const createAgentExperience = (
8658
8692
  queueMicrotask(() => {
8659
8693
  if (pendingDurablePersistenceMessages === messages) {
8660
8694
  pendingDurablePersistenceMessages = null;
8661
- persistState(messages);
8695
+ // The resume cursor is read now, so save the newest snapshot: updates
8696
+ // coalesced after this one (still waiting on a frame) already advanced it.
8697
+ persistState(pendingMessages ?? messages);
8662
8698
  }
8663
8699
  });
8664
8700
  };
@@ -8667,17 +8703,13 @@ export const createAgentExperience = (
8667
8703
  lastAppliedMessages = messages;
8668
8704
  let lastUserMessage: AgentWidgetMessage | undefined;
8669
8705
  let lastAssistantMessage: AgentWidgetMessage | undefined;
8670
- activeStreamingTextCandidate = null;
8671
8706
  for (let index = messages.length - 1; index >= 0; index -= 1) {
8672
8707
  const message = messages[index];
8673
8708
  if (!lastUserMessage && message.role === "user") lastUserMessage = message;
8674
8709
  if (!lastAssistantMessage && message.role === "assistant") {
8675
8710
  lastAssistantMessage = message;
8676
8711
  }
8677
- if (!activeStreamingTextCandidate && isPlainStreamingAssistant(message)) {
8678
- activeStreamingTextCandidate = { index, id: message.id };
8679
- }
8680
- if (lastUserMessage && lastAssistantMessage && activeStreamingTextCandidate) break;
8712
+ if (lastUserMessage && lastAssistantMessage) break;
8681
8713
  }
8682
8714
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
8683
8715
  // Freshly (re)built inline artifact blocks render from their persisted
@@ -8726,52 +8758,48 @@ export const createAgentExperience = (
8726
8758
  syncComposerBarPeek();
8727
8759
  };
8728
8760
 
8729
- const flushPendingStreamingText = () => {
8730
- if (streamingTextRAF !== null) {
8731
- cancelAnimationFrame(streamingTextRAF);
8732
- streamingTextRAF = null;
8761
+ const flushPendingMessages = () => {
8762
+ if (pendingMessagesRAF !== null) {
8763
+ cancelAnimationFrame(pendingMessagesRAF);
8764
+ pendingMessagesRAF = null;
8733
8765
  }
8734
- const pending = pendingStreamingTextMessages;
8735
- pendingStreamingTextMessages = null;
8766
+ const pending = pendingMessages;
8767
+ pendingMessages = null;
8736
8768
  if (pending) applyMessagesChanged(pending);
8737
8769
  };
8738
8770
 
8739
- const discardPendingStreamingText = () => {
8740
- if (streamingTextRAF !== null) cancelAnimationFrame(streamingTextRAF);
8741
- streamingTextRAF = null;
8742
- pendingStreamingTextMessages = null;
8771
+ const discardPendingMessages = () => {
8772
+ if (pendingMessagesRAF !== null) cancelAnimationFrame(pendingMessagesRAF);
8773
+ pendingMessagesRAF = null;
8774
+ pendingMessages = null;
8743
8775
  };
8744
8776
 
8777
+ // While a turn streams, every SSE frame changes the transcript, and a render
8778
+ // pass costs O(messages). Applying each frame synchronously makes a burst of
8779
+ // N frames (a long tool run, or a finished transcript replayed through
8780
+ // `connectStream`) cost O(N²) and block the main thread. Coalesce streaming
8781
+ // updates to one render per animation frame; the stream ending, a clear, or
8782
+ // a change in `needsImmediateApply` flushes synchronously.
8745
8783
  const handleMessagesChanged = (messages: AgentWidgetMessage[]) => {
8746
- const comparison = pendingStreamingTextMessages ?? lastAppliedMessages;
8747
- if (
8748
- isStreaming &&
8749
- comparison &&
8750
- activeStreamingTextCandidate &&
8751
- isPureStreamingTextProgression(
8752
- comparison,
8753
- messages,
8754
- activeStreamingTextCandidate
8755
- )
8756
- ) {
8757
- pendingStreamingTextMessages = messages;
8758
- if (streamingTextRAF === null) {
8759
- streamingTextRAF = requestAnimationFrame(() => {
8760
- streamingTextRAF = null;
8761
- const pending = pendingStreamingTextMessages;
8762
- pendingStreamingTextMessages = null;
8784
+ if (messages.length === 0) {
8785
+ discardPendingMessages();
8786
+ applyMessagesChanged(messages);
8787
+ return;
8788
+ }
8789
+ const comparison = pendingMessages ?? lastAppliedMessages;
8790
+ if (isStreaming && comparison && coalesceStreamingChange(comparison, messages)) {
8791
+ pendingMessages = messages;
8792
+ if (pendingMessagesRAF === null) {
8793
+ pendingMessagesRAF = requestAnimationFrame(() => {
8794
+ pendingMessagesRAF = null;
8795
+ const pending = pendingMessages;
8796
+ pendingMessages = null;
8763
8797
  if (pending) applyMessagesChanged(pending);
8764
8798
  });
8765
8799
  }
8766
8800
  return;
8767
8801
  }
8768
-
8769
- if (messages.length === 0) {
8770
- discardPendingStreamingText();
8771
- applyMessagesChanged(messages);
8772
- return;
8773
- }
8774
- flushPendingStreamingText();
8802
+ flushPendingMessages();
8775
8803
  applyMessagesChanged(messages);
8776
8804
  };
8777
8805
 
@@ -8797,9 +8825,9 @@ export const createAgentExperience = (
8797
8825
  onStreamingChanged(streaming) {
8798
8826
  if (!streaming) {
8799
8827
  if (session?.getMessages().length === 0) {
8800
- discardPendingStreamingText();
8828
+ discardPendingMessages();
8801
8829
  } else {
8802
- flushPendingStreamingText();
8830
+ flushPendingMessages();
8803
8831
  }
8804
8832
  }
8805
8833
  isStreaming = streaming;
@@ -13277,8 +13305,8 @@ export const createAgentExperience = (
13277
13305
  // Mirror the in-panel click handler: WebMCP gate bubbles resolve a local
13278
13306
  // Promise the bridge is parked on (no server round-trip and they carry an
13279
13307
  // empty executionId/agentId), so they must NOT hit the server approval
13280
- // API. Route by the `toolType` marker set in `requestWebMcpApproval`.
13281
- if (approvalMessage.approval.toolType === "webmcp") {
13308
+ // API. Server-backed approvals retain their execution id and use the API.
13309
+ if (isBrowserLocalApproval(approvalMessage.approval)) {
13282
13310
  session.resolveWebMcpApproval(approvalMessage.id, decision);
13283
13311
  return;
13284
13312
  }
@@ -13421,7 +13449,7 @@ export const createAgentExperience = (
13421
13449
  // Commit the latest coalesced transcript while the live DOM and storage
13422
13450
  // pipeline still exist, then let the normal teardown cancel any work
13423
13451
  // scheduled by that final apply.
13424
- flushPendingStreamingText();
13452
+ flushPendingMessages();
13425
13453
  if (toolElapsedTimerId != null) {
13426
13454
  clearInterval(toolElapsedTimerId);
13427
13455
  toolElapsedTimerId = null;
@@ -145,9 +145,27 @@ describe("standalone typing indicator with hidden activity rows", () => {
145
145
  document.body.innerHTML = "";
146
146
  if (typeof localStorage !== "undefined") localStorage.clear();
147
147
  vi.restoreAllMocks();
148
+ vi.unstubAllGlobals();
148
149
  });
149
150
 
151
+ // Streaming transcript updates render once per animation frame; queue the
152
+ // frames so each injection can be rendered synchronously.
153
+ let frames = new Map<number, (time: number) => void>();
154
+ let nextFrameId = 1;
155
+ const renderFrame = () => {
156
+ const pending = [...frames.values()];
157
+ frames.clear();
158
+ pending.forEach((callback) => callback(0));
159
+ };
160
+
150
161
  const mountStreaming = async (config: Record<string, unknown>) => {
162
+ frames = new Map();
163
+ vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
164
+ const id = nextFrameId++;
165
+ frames.set(id, callback);
166
+ return id;
167
+ });
168
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
151
169
  global.fetch = vi.fn().mockImplementation(
152
170
  () => new Promise(() => {})
153
171
  ) as unknown as typeof fetch;
@@ -190,6 +208,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
190
208
  toolCall: { id, name: "lookup", status },
191
209
  },
192
210
  });
211
+ renderFrame();
193
212
  };
194
213
 
195
214
  const injectReasoning = (
@@ -211,6 +230,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
211
230
  reasoning: { id, status, chunks: ["thinking"] },
212
231
  },
213
232
  });
233
+ renderFrame();
214
234
  };
215
235
 
216
236
  const typingIndicator = (mount: HTMLElement) =>
@@ -344,6 +364,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
344
364
  streaming: true,
345
365
  },
346
366
  });
367
+ renderFrame();
347
368
  expect(typingIndicator(mount)).toBeNull();
348
369
 
349
370
  controller.destroy();
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed } from "./formatting";
2
+ import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed, computeToolElapsed } from "./formatting";
3
3
 
4
4
  describe("JSON Stream Parser", () => {
5
5
  it("should extract text field incrementally as JSON streams in", () => {
@@ -268,3 +268,11 @@ describe("computeReasoningElapsed", () => {
268
268
  expect(result).toBe("<0.1s");
269
269
  });
270
270
  });
271
+
272
+ it("never invents an interrupted tool's duration, including elapsed render hooks", () => {
273
+ const tool = { id: "browser", status: "complete" as const, interrupted: true };
274
+ expect(computeToolElapsed(tool)).toBe("");
275
+ expect(computeToolElapsed({ ...tool, startedAt: 1 })).toBe("");
276
+ expect(computeToolElapsed({ ...tool, durationMs: 2000 })).toBe("2s");
277
+ expect(computeToolElapsed({ ...tool, startedAt: 1000, completedAt: 4000 })).toBe("3s");
278
+ });
@@ -124,6 +124,10 @@ export const formatElapsedMs = (ms: number): string => {
124
124
  * Computes the current elapsed time string for a tool call.
125
125
  */
126
126
  export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
127
+ if (
128
+ tool.interrupted && tool.duration === undefined && tool.durationMs === undefined &&
129
+ (tool.startedAt === undefined || tool.completedAt === undefined)
130
+ ) return "";
127
131
  const durationMs =
128
132
  typeof tool.duration === "number"
129
133
  ? tool.duration
@@ -0,0 +1,15 @@
1
+ import type { AgentWidgetMessage } from "../types";
2
+
3
+ /** Id prefix of the bubble the durable reconnect loop posts when it gives up. */
4
+ export const RECONNECT_FAILED_ID_PREFIX = "reconnect-failed-";
5
+
6
+ /**
7
+ * A widget-generated notice that is shown in this browser only: never sent as
8
+ * conversation history and never written to the storage adapter. The id check
9
+ * covers reconnect-failure bubbles persisted before `localOnly` existed.
10
+ */
11
+ export const isLocalOnlyMessage = (message: AgentWidgetMessage): boolean =>
12
+ message.localOnly === true ||
13
+ (message.role === "assistant" &&
14
+ typeof message.id === "string" &&
15
+ message.id.startsWith(RECONNECT_FAILED_ID_PREFIX));
@@ -35,6 +35,15 @@ describe("computeMessageFingerprint", () => {
35
35
  expect(fp1).toBe(fp2);
36
36
  });
37
37
 
38
+ it("changes when tool args are replaced with a longer object", () => {
39
+ const toolMessage = (args: unknown) =>
40
+ makeMessage({ variant: "tool", toolCall: { status: "running", args } });
41
+ const fp1 = computeMessageFingerprint(toolMessage({ q: "a" }), 0);
42
+ const fp2 = computeMessageFingerprint(toolMessage({ q: "a", limit: 10 }), 0);
43
+ expect(fp1).not.toBe(fp2);
44
+ expect(computeMessageFingerprint(toolMessage({ q: "a" }), 0)).toBe(fp1);
45
+ });
46
+
38
47
  it("changes when content changes", () => {
39
48
  const fp1 = computeMessageFingerprint(makeMessage({ content: "Hello" }), 0);
40
49
  const fp2 = computeMessageFingerprint(makeMessage({ content: "Hello world" }), 0);
@@ -35,6 +35,23 @@ export type MessageCacheEntry = {
35
35
 
36
36
  export type MessageCache = Map<string, MessageCacheEntry>;
37
37
 
38
+ // Every render pass fingerprints every message, so re-stringifying tool args
39
+ // each time would cost O(total args bytes) per pass. Streaming replaces the
40
+ // args object rather than mutating it, so the length is memoized per object.
41
+ const argsLengths = new WeakMap<object, number>();
42
+
43
+ function argsLength(args: unknown): number {
44
+ if (typeof args === "string") return args.length;
45
+ if (!args) return 0;
46
+ if (typeof args !== "object") return JSON.stringify(args)?.length ?? 0;
47
+ let length = argsLengths.get(args);
48
+ if (length === undefined) {
49
+ length = JSON.stringify(args)?.length ?? 0;
50
+ argsLengths.set(args, length);
51
+ }
52
+ return length;
53
+ }
54
+
38
55
  /**
39
56
  * Compute a fast fingerprint for a message to detect changes.
40
57
  * Uses string concatenation with a delimiter rather than JSON.stringify for performance.
@@ -62,11 +79,7 @@ export function computeMessageFingerprint(
62
79
  message.toolCall?.name ?? "",
63
80
  message.toolCall?.chunks?.length ?? 0,
64
81
  message.toolCall?.chunks?.[message.toolCall.chunks.length - 1]?.slice(-32) ?? "",
65
- typeof message.toolCall?.args === "string"
66
- ? message.toolCall.args.length
67
- : message.toolCall?.args
68
- ? JSON.stringify(message.toolCall.args).length
69
- : 0,
82
+ argsLength(message.toolCall?.args),
70
83
  message.reasoning?.chunks?.length ?? 0,
71
84
  message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.length ?? 0,
72
85
  message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.slice(-32) ?? "",
@@ -24,6 +24,16 @@ export const morphMessages = (
24
24
  beforeNodeMorphed(oldNode: Node, newNode: Node): boolean | void {
25
25
  if (!(oldNode instanceof HTMLElement)) return;
26
26
 
27
+ // Render passes stand in an unchanged transcript row with an empty
28
+ // `data-persona-reuse` stub: keep the live row as is.
29
+ if (
30
+ newNode instanceof HTMLElement &&
31
+ newNode.hasAttribute("data-persona-reuse") &&
32
+ oldNode.id === newNode.id
33
+ ) {
34
+ return false;
35
+ }
36
+
27
37
  // Preserve typing indicator dots to maintain animation continuity
28
38
  // Also preserve elements with data-preserve-animation attribute for custom loading indicators
29
39
  if (preserveTypingAnimation) {