@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/session.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type SSEEventCallback,
5
5
  } from "./client";
6
6
  import { isWebMcpToolName } from "./webmcp-bridge";
7
+ import { isBrowserLocalApproval, restoreBrowserToolState } from "./utils/restored-browser-state";
7
8
  import type { SessionActionsHost, SessionActionsInternals } from "./session-actions";
8
9
  import {
9
10
  getSessionActionsSync,
@@ -42,12 +43,14 @@ import {
42
43
  generateAssistantMessageId
43
44
  } from "./utils/message-id";
44
45
  import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT, createTextPart, hasImages } from "./utils/content";
46
+ import { voicePriorToolRows } from "./utils/voice-prior-tool-results";
45
47
  import {
46
48
  buildArtifactRefRawContent,
47
49
  resolveArtifactDisplayMode
48
50
  } from "./utils/artifact-display";
49
51
  import type {
50
52
  VoiceProvider,
53
+ VoicePriorToolResult,
51
54
  VoiceStatus,
52
55
  VoiceConfig,
53
56
  ReadAloudState,
@@ -437,7 +440,7 @@ export class AgentWidgetSession {
437
440
  ) {
438
441
  this.historyInternals = this.#composeHistoryInternals(historyInternals);
439
442
  this.messages = [...(config.initialMessages ?? [])].map((message) => ({
440
- ...message,
443
+ ...restoreBrowserToolState(message),
441
444
  sequence: message.sequence ?? this.#nextSequence()
442
445
  }));
443
446
  this.messages = this.#sortMessages(this.messages);
@@ -1343,7 +1346,8 @@ export class AgentWidgetSession {
1343
1346
  if (this.#activeConversationId && this.#activeConversationId !== id) {
1344
1347
  return { messages: [], nextMessageCursor: page.nextCursor };
1345
1348
  }
1346
- const merged = mergeWireMessagesById(this.messages, page.messages);
1349
+ const restored = this.#restoreHistoryPage(page.messages);
1350
+ const merged = mergeWireMessagesById(this.messages, restored);
1347
1351
  this.messages = this.#sortMessages(
1348
1352
  merged.map((message) => this.#ensureSequence(message))
1349
1353
  );
@@ -1354,7 +1358,30 @@ export class AgentWidgetSession {
1354
1358
  this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
1355
1359
  this.callbacks.onMessagesChanged([...this.messages]);
1356
1360
  this.#emitHistoryState();
1357
- return { messages: page.messages, nextMessageCursor: page.nextCursor };
1361
+ return { messages: restored, nextMessageCursor: page.nextCursor };
1362
+ }
1363
+
1364
+ /** Normalize incoming history without retiring callbacks owned by this chat. */
1365
+ #restoreHistoryPage(messages: AgentWidgetMessage[]): AgentWidgetMessage[] {
1366
+ const current = new Map(this.messages.map(message => [message.id, message]));
1367
+ return messages.map(message => {
1368
+ const live = current.get(message.id);
1369
+ if (live) {
1370
+ if (this.webMcpApprovalResolvers.has(live.id)) return live;
1371
+ // A decision made while the history fetch was in flight must not be
1372
+ // replaced by the page's stale pending copy of the same browser card.
1373
+ if (live.approval && isBrowserLocalApproval(live.approval) &&
1374
+ live.approval.id === message.approval?.id &&
1375
+ live.approval.status !== "pending" && message.approval?.status === "pending") return live;
1376
+ const executionId = live.agentMetadata?.executionId;
1377
+ const tools = [...(live.toolCall ? [live.toolCall] : []), ...(live.tools ?? [])];
1378
+ if (executionId && tools.some(tool =>
1379
+ this.webMcpInflightKeys.has(`${executionId}:${tool.id}`) ||
1380
+ this.webMcpResolvedKeys.has(`${executionId}:${tool.id}`)
1381
+ )) return live;
1382
+ }
1383
+ return restoreBrowserToolState(message);
1384
+ });
1358
1385
  }
1359
1386
 
1360
1387
  /**
@@ -1800,6 +1827,7 @@ export class AgentWidgetSession {
1800
1827
  }
1801
1828
 
1802
1829
  const page = await provider.getPage(conversationId, { context });
1830
+ const restored = this.#restoreHistoryPage(page.messages);
1803
1831
  const serverIds = new Set(page.messages.map((message) => message.id));
1804
1832
  const overlaps = this.messages.some((message) => serverIds.has(message.id));
1805
1833
  const pageOldest = page.messages[0]?.createdAt;
@@ -1807,7 +1835,7 @@ export class AgentWidgetSession {
1807
1835
  let reconciled: AgentWidgetMessage[];
1808
1836
  if (!overlaps) {
1809
1837
  // No shared id: the local transcript is not this record's newest window.
1810
- reconciled = page.messages.map((message) => this.#ensureSequence(message));
1838
+ reconciled = restored.map((message) => this.#ensureSequence(message));
1811
1839
  } else {
1812
1840
  const durableId = this.#resumable ? this.#activeAssistantMessageId : null;
1813
1841
  const survivors = this.messages.filter((message) => {
@@ -1816,7 +1844,7 @@ export class AgentWidgetSession {
1816
1844
  // Older than the server window is legitimately absent from this page.
1817
1845
  return pageOldest !== undefined && message.createdAt < pageOldest;
1818
1846
  });
1819
- reconciled = mergeWireMessagesById(survivors, page.messages).map(
1847
+ reconciled = mergeWireMessagesById(survivors, restored).map(
1820
1848
  (message) => this.#ensureSequence(message)
1821
1849
  );
1822
1850
  }
@@ -2042,7 +2070,12 @@ export class AgentWidgetSession {
2042
2070
  controller: AbortController,
2043
2071
  assistantMessageId: string,
2044
2072
  userMessageId: string,
2045
- turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean; voiceSpoken?: boolean }
2073
+ turnOptions?: {
2074
+ composerOptions?: ComposerOptionsPayload;
2075
+ interrupt?: boolean;
2076
+ voiceSpoken?: boolean;
2077
+ voicePriorToolResults?: VoicePriorToolResult[];
2078
+ }
2046
2079
  ): Promise<void> {
2047
2080
  this.#recordDispatchedProjections(snapshot);
2048
2081
  try {
@@ -2053,7 +2086,8 @@ export class AgentWidgetSession {
2053
2086
  assistantMessageId,
2054
2087
  composerOptions: turnOptions?.composerOptions,
2055
2088
  interrupt: turnOptions?.interrupt,
2056
- voiceSpoken: turnOptions?.voiceSpoken
2089
+ voiceSpoken: turnOptions?.voiceSpoken,
2090
+ voicePriorToolResults: turnOptions?.voicePriorToolResults
2057
2091
  },
2058
2092
  this.handleEvent
2059
2093
  );
@@ -2072,6 +2106,7 @@ export class AgentWidgetSession {
2072
2106
  assistantMessageId,
2073
2107
  composerOptions: turnOptions?.composerOptions,
2074
2108
  voiceSpoken: turnOptions?.voiceSpoken,
2109
+ voicePriorToolResults: turnOptions?.voicePriorToolResults,
2075
2110
  },
2076
2111
  this.handleEvent
2077
2112
  );
@@ -2535,9 +2570,11 @@ export class AgentWidgetSession {
2535
2570
  * Internal (voice client delegation): this is the delegated voice turn.
2536
2571
  * `userMessageId` names an existing transcript bubble to submit as the
2537
2572
  * turn's user message instead of appending a new one, so the spoken
2538
- * request appears (and is sent) exactly once.
2573
+ * request appears (and is sent) exactly once. `priorToolResults`: page
2574
+ * tools the voice server already ran for this turn, recorded once as
2575
+ * completed tool rows after the request (see voicePriorToolRows).
2539
2576
  */
2540
- voiceTurn?: { userMessageId?: string };
2577
+ voiceTurn?: { userMessageId?: string | null; priorToolResults?: unknown };
2541
2578
  }
2542
2579
  ) {
2543
2580
  const input = rawInput.trim();
@@ -2692,12 +2729,25 @@ export class AgentWidgetSession {
2692
2729
 
2693
2730
  // A submitted voice bubble may sit above captions or a turn that finished
2694
2731
  // while it waited: the request still ends on it.
2732
+ const sentAt = new Date().toISOString();
2695
2733
  const snapshot = voiceBubble
2696
- ? [
2697
- ...this.messages.filter((m) => m !== voiceBubble),
2698
- { ...voiceBubble, createdAt: new Date().toISOString() }
2699
- ]
2734
+ ? [...this.messages.filter((m) => m !== voiceBubble), { ...voiceBubble, createdAt: sentAt }]
2700
2735
  : [...this.messages];
2736
+ // Tools the voice server already ran answer this request and are never run
2737
+ // again. Stored at the request's own time, so every later replay keeps them
2738
+ // right after it; this request (moved to `sentAt`) sends them at `sentAt`.
2739
+ const requestAt = (voiceBubble ?? this.messages.find((m) => m.id === userMessageId))?.createdAt ?? sentAt;
2740
+ const priorRows = voicePriorToolRows(options?.voiceTurn?.priorToolResults, requestAt);
2741
+ for (const row of priorRows) {
2742
+ if (this.messages.some((m) => m.id === row.id)) continue;
2743
+ this.#appendMessage(row);
2744
+ snapshot.push({ ...row, createdAt: sentAt });
2745
+ }
2746
+ // Client-token chat replays from the server's transcript: it gets them as a field.
2747
+ const voicePriorToolResults = priorRows.map(({ agentMetadata }) => {
2748
+ const { toolCallId, toolName, args, result } = agentMetadata!.clientToolAnswer!;
2749
+ return { toolCallId, name: toolName, arguments: args as Record<string, unknown>, result };
2750
+ });
2701
2751
 
2702
2752
  try {
2703
2753
  await this.#dispatchWithDeletedRecovery(
@@ -2709,7 +2759,8 @@ export class AgentWidgetSession {
2709
2759
  composerOptions: options?.composerOptions,
2710
2760
  interrupt: options?.interrupt,
2711
2761
  // The delegated voice turn's reply is read aloud (spoken-reply hint).
2712
- voiceSpoken: !!options?.voiceTurn
2762
+ voiceSpoken: !!options?.voiceTurn,
2763
+ ...(voicePriorToolResults.length > 0 && { voicePriorToolResults })
2713
2764
  }
2714
2765
  );
2715
2766
  } catch (error) {
@@ -2740,6 +2791,7 @@ export class AgentWidgetSession {
2740
2791
  role: "assistant",
2741
2792
  createdAt: new Date().toISOString(),
2742
2793
  content,
2794
+ localOnly: true,
2743
2795
  sequence: this.#nextSequence()
2744
2796
  };
2745
2797
 
@@ -2928,6 +2980,7 @@ export class AgentWidgetSession {
2928
2980
  role: "assistant",
2929
2981
  createdAt: new Date().toISOString(),
2930
2982
  content,
2983
+ localOnly: true,
2931
2984
  sequence: this.#nextSequence()
2932
2985
  };
2933
2986
 
@@ -3078,8 +3131,14 @@ export class AgentWidgetSession {
3078
3131
  }
3079
3132
  if (info.signal?.aborted) return Promise.resolve(false);
3080
3133
 
3134
+ let approvalId: string;
3135
+ do {
3136
+ approvalId = `webmcp-${++this.#webMcpApprovalSeq}`;
3137
+ } while (this.messages.some(message =>
3138
+ message.id === `approval-${approvalId}` || message.approval?.id === approvalId
3139
+ ));
3081
3140
  const approval: AgentWidgetApproval = {
3082
- id: `webmcp-${++this.#webMcpApprovalSeq}`,
3141
+ id: approvalId,
3083
3142
  status: "pending",
3084
3143
  agentId: "",
3085
3144
  executionId: "",
@@ -3666,7 +3725,7 @@ export class AgentWidgetSession {
3666
3725
  this.webMcpResolvedKeys.clear();
3667
3726
  this.messages = this.#sortMessages(
3668
3727
  messages.map((message) => ({
3669
- ...message,
3728
+ ...restoreBrowserToolState(message),
3670
3729
  streaming: false,
3671
3730
  sequence: message.sequence ?? this.#nextSequence()
3672
3731
  }))
@@ -3773,7 +3832,9 @@ export class AgentWidgetSession {
3773
3832
  resolveFollowUpsFeature(this.config).enabled));
3774
3833
  if (
3775
3834
  event.message.agentMetadata?.awaitingLocalTool === true &&
3776
- autoResolvable
3835
+ autoResolvable &&
3836
+ this.messages.find(message => message.id === event.message.id)
3837
+ ?.agentMetadata?.awaitingLocalTool === true
3777
3838
  ) {
3778
3839
  // Collect the await into its executionId's batch instead of resolving
3779
3840
  // it on the spot. Parallel same-tool calls (core#3878) arrive as
@@ -4029,6 +4090,7 @@ export class AgentWidgetSession {
4029
4090
  seedContent,
4030
4091
  }),
4031
4092
  appendMessage: (message) => session.#appendMessage(message),
4093
+ settleParked: (executionId) => session.#settleParkedReconnect(executionId),
4032
4094
  nextSequence: () => session.#nextSequence(),
4033
4095
  emitReconnect: (event) => session.callbacks.onReconnect?.(event),
4034
4096
  buildErrorContent: (message) =>
@@ -4060,7 +4122,28 @@ export class AgentWidgetSession {
4060
4122
  if (typeof this.config.reconnectStream !== "function") return;
4061
4123
  if (this.#reconnecting) return;
4062
4124
 
4063
- let assistantId = this.#reopenTrailingAssistant();
4125
+ // A run parked on an await the restored transcript can still answer (an
4126
+ // ask_user_question card, a server approval) has no stream to replay, and
4127
+ // reconnecting would spin into a "Connection lost" bubble. Keep the cursor
4128
+ // so the answer's `/resume` attaches after the await frame, as in-session.
4129
+ if (this.#isAwaitPending()) {
4130
+ this.#adoptBootExecution(resume.executionId);
4131
+ this.#resumable = {
4132
+ executionId: resume.executionId,
4133
+ lastEventId: resume.after,
4134
+ assistantMessageId: this.#activeAssistantMessageId ?? "",
4135
+ status: "running",
4136
+ };
4137
+ return;
4138
+ }
4139
+
4140
+ // Parked on a WebMCP call the reload interrupted: still reconnect once, so
4141
+ // a replayed `await` re-dispatches the call on this page, but don't reopen
4142
+ // the pre-tool text bubble or paint an empty one. The reconnect loop ends
4143
+ // the turn quietly when nothing replays (`#settleParkedReconnect`).
4144
+ let assistantId = this.#hasInterruptedBrowserTool(resume.executionId)
4145
+ ? generateAssistantMessageId()
4146
+ : this.#reopenTrailingAssistant();
4064
4147
  if (!assistantId) {
4065
4148
  assistantId = generateAssistantMessageId();
4066
4149
  this.#appendMessage({
@@ -4073,16 +4156,7 @@ export class AgentWidgetSession {
4073
4156
  });
4074
4157
  }
4075
4158
  this.#activeAssistantMessageId = assistantId;
4076
- if (!this.agentExecution) {
4077
- this.agentExecution = {
4078
- executionId: resume.executionId,
4079
- agentId: "",
4080
- agentName: "",
4081
- status: "running",
4082
- currentIteration: 0,
4083
- maxTurns: 0,
4084
- };
4085
- }
4159
+ this.#adoptBootExecution(resume.executionId);
4086
4160
  this.#resumable = {
4087
4161
  executionId: resume.executionId,
4088
4162
  lastEventId: resume.after,
@@ -4092,6 +4166,63 @@ export class AgentWidgetSession {
4092
4166
  this.#beginReconnect();
4093
4167
  }
4094
4168
 
4169
+ /**
4170
+ * A reconnect attempt left the run parked rather than dropped: the turn is
4171
+ * waiting on the visitor or a page tool (keep the cursor, as an in-session
4172
+ * await pause does), or on a WebMCP call the reload interrupted (nothing can
4173
+ * answer it, so drop the handle). Either way stop reconnecting without a
4174
+ * failure bubble. Returns false for an ordinary drop, which keeps retrying.
4175
+ */
4176
+ #settleParkedReconnect(executionId: string): boolean {
4177
+ const pageToolBusy =
4178
+ this.webMcpResolveControllers.size > 0 || this.webMcpAwaitBatches.size > 0;
4179
+ if (!pageToolBusy && !this.#isAwaitPending()) {
4180
+ if (!this.#hasInterruptedBrowserTool(executionId)) return false;
4181
+ this.#clearResumable();
4182
+ }
4183
+ this.#reconnecting = false;
4184
+ if (!pageToolBusy) {
4185
+ let changed = false;
4186
+ for (const msg of this.messages) {
4187
+ if (msg.streaming) {
4188
+ msg.streaming = false;
4189
+ changed = true;
4190
+ }
4191
+ }
4192
+ if (changed) this.callbacks.onMessagesChanged([...this.messages]);
4193
+ this.#setStreaming(false);
4194
+ this.abortController = null;
4195
+ }
4196
+ this.#setStatus("idle");
4197
+ return true;
4198
+ }
4199
+
4200
+ /** Restore the running execution a boot resume refers to, if none is live. */
4201
+ #adoptBootExecution(executionId: string): void {
4202
+ if (this.agentExecution) return;
4203
+ this.agentExecution = {
4204
+ executionId,
4205
+ agentId: "",
4206
+ agentName: "",
4207
+ status: "running",
4208
+ currentIteration: 0,
4209
+ maxTurns: 0,
4210
+ };
4211
+ }
4212
+
4213
+ /**
4214
+ * True when restore settled one of this execution's WebMCP calls as
4215
+ * interrupted (see `restoreBrowserToolState`): the run is parked on a
4216
+ * browser callback that no longer exists.
4217
+ */
4218
+ #hasInterruptedBrowserTool(executionId: string): boolean {
4219
+ return this.messages.some(
4220
+ (m) =>
4221
+ m.agentMetadata?.executionId === executionId &&
4222
+ [m.toolCall, ...(m.tools ?? [])].some((tool) => tool?.interrupted === true)
4223
+ );
4224
+ }
4225
+
4095
4226
  /**
4096
4227
  * Reopen the trailing assistant bubble (the persisted partial) so replayed
4097
4228
  * deltas append to it. Returns its id, or null if the last turn doesn't end
@@ -4321,7 +4452,13 @@ export class AgentWidgetSession {
4321
4452
 
4322
4453
  #appendMessage(message: AgentWidgetMessage) {
4323
4454
  const withSequence = this.#ensureSequence(message);
4324
- this.messages = this.#sortMessages([...this.messages, withSequence]);
4455
+ const last = this.messages[this.messages.length - 1];
4456
+ // Streamed messages almost always arrive in order. Re-sorting the whole
4457
+ // transcript on every append makes a long tool run O(N²) in date parses.
4458
+ this.messages =
4459
+ !last || this.#compareMessages(last, withSequence) <= 0
4460
+ ? [...this.messages, withSequence]
4461
+ : this.#sortMessages([...this.messages, withSequence]);
4325
4462
  this.callbacks.onMessagesChanged([...this.messages]);
4326
4463
  }
4327
4464
 
@@ -4335,6 +4472,21 @@ export class AgentWidgetSession {
4335
4472
 
4336
4473
  this.messages = this.messages.map((existing, idx) => {
4337
4474
  if (idx !== index) return existing;
4475
+ // An unnamed skeleton precedes tool_start/await on a fresh parser. Keep
4476
+ // history until the populated event identifies the continuation. Also
4477
+ // retain actual terminal browser outcomes across replayed starts/deltas:
4478
+ // those events must never turn a completed mutation back into an await.
4479
+ if (
4480
+ existing.toolCall?.name && isWebMcpToolName(existing.toolCall.name) &&
4481
+ existing.toolCall.status === "complete" &&
4482
+ withSequence.toolCall?.id === existing.toolCall.id &&
4483
+ withSequence.toolCall.status !== "complete" &&
4484
+ ((withSequence.toolCall.status === "pending" && !withSequence.toolCall.name) ||
4485
+ (!existing.toolCall.interrupted &&
4486
+ (existing.toolCall.result !== undefined || existing.toolCall.success !== undefined) &&
4487
+ (!withSequence.agentMetadata?.executionId ||
4488
+ withSequence.agentMetadata.executionId === existing.agentMetadata?.executionId)))
4489
+ ) return existing;
4338
4490
  const merged = { ...existing, ...withSequence };
4339
4491
  // Preserve `ask_user_question` answered state across re-emissions.
4340
4492
  // Top-level merge would otherwise replace `agentMetadata` wholesale: // post-resume events (e.g. `tool_complete` re-emitted from a stale
@@ -4441,7 +4593,8 @@ export class AgentWidgetSession {
4441
4593
  existing.toolCall?.id === reTcId &&
4442
4594
  existingToolName !== undefined &&
4443
4595
  isAutoResolvedLocalToolName(existingToolName) &&
4444
- existing.toolCall?.status === "complete";
4596
+ existing.toolCall?.status === "complete" &&
4597
+ !existing.toolCall.interrupted;
4445
4598
  if (isInflight || isResolved || hasCompletedTool) {
4446
4599
  merged.agentMetadata = {
4447
4600
  ...(merged.agentMetadata ?? {}),
@@ -4457,7 +4610,17 @@ export class AgentWidgetSession {
4457
4610
  }
4458
4611
  return merged;
4459
4612
  });
4460
- this.messages = this.#sortMessages(this.messages);
4613
+ // The transcript is kept sorted, so only the merged entry can be out of
4614
+ // place; skip the full re-sort when it still sits between its neighbors.
4615
+ const updated = this.messages[index];
4616
+ const before = this.messages[index - 1];
4617
+ const after = this.messages[index + 1];
4618
+ if (
4619
+ (before && this.#compareMessages(before, updated) > 0) ||
4620
+ (after && this.#compareMessages(updated, after) > 0)
4621
+ ) {
4622
+ this.messages = this.#sortMessages(this.messages);
4623
+ }
4461
4624
  this.callbacks.onMessagesChanged([...this.messages]);
4462
4625
  }
4463
4626
 
@@ -4475,22 +4638,24 @@ export class AgentWidgetSession {
4475
4638
  return this.#sequenceCounter++;
4476
4639
  }
4477
4640
 
4478
- #sortMessages(messages: AgentWidgetMessage[]) {
4479
- return [...messages].sort((a, b) => {
4480
- // Sort by createdAt timestamp first (chronological order)
4481
- const timeA = new Date(a.createdAt).getTime();
4482
- const timeB = new Date(b.createdAt).getTime();
4483
- if (!Number.isNaN(timeA) && !Number.isNaN(timeB) && timeA !== timeB) {
4484
- return timeA - timeB;
4485
- }
4641
+ #compareMessages(a: AgentWidgetMessage, b: AgentWidgetMessage) {
4642
+ // Sort by createdAt timestamp first (chronological order)
4643
+ const timeA = new Date(a.createdAt).getTime();
4644
+ const timeB = new Date(b.createdAt).getTime();
4645
+ if (!Number.isNaN(timeA) && !Number.isNaN(timeB) && timeA !== timeB) {
4646
+ return timeA - timeB;
4647
+ }
4486
4648
 
4487
- // Fall back to sequence if timestamps are equal or invalid
4488
- const seqA = a.sequence ?? 0;
4489
- const seqB = b.sequence ?? 0;
4490
- if (seqA !== seqB) return seqA - seqB;
4649
+ // Fall back to sequence if timestamps are equal or invalid
4650
+ const seqA = a.sequence ?? 0;
4651
+ const seqB = b.sequence ?? 0;
4652
+ if (seqA !== seqB) return seqA - seqB;
4491
4653
 
4492
- // Final fallback to ID
4493
- return a.id.localeCompare(b.id);
4494
- });
4654
+ // Final fallback to ID
4655
+ return a.id.localeCompare(b.id);
4656
+ }
4657
+
4658
+ #sortMessages(messages: AgentWidgetMessage[]) {
4659
+ return [...messages].sort((a, b) => this.#compareMessages(a, b));
4495
4660
  }
4496
4661
  }
@@ -666,6 +666,175 @@ describe('AgentWidgetSession - voice client delegation bridge', () => {
666
666
  ]);
667
667
  });
668
668
 
669
+ describe('prior tool results on a hand-back', () => {
670
+ const jump = {
671
+ toolCallId: 'vtc_1',
672
+ name: 'webmcp:jump_to_slide',
673
+ arguments: { position: 5 },
674
+ result: { slidePosition: 5, slideTitle: 'Pricing' },
675
+ };
676
+ const toolRows = () => messages.filter((m) => m.variant === 'tool');
677
+
678
+ it('records the executed call once, under the request and before the reply, as a completed call', async () => {
679
+ h.state.transcriptCb!('user', 'Jump to the pricing slide', true, { turnId: 'u1', caption: true });
680
+ dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'You are on Pricing.'));
681
+ await h.state.bridge!.runDelegatedTurn(
682
+ req({ delegationId: 'd1', userText: 'Jump to the pricing slide', userUtteranceIds: ['u1'], priorToolResults: [jump] }),
683
+ );
684
+
685
+ expect(messages.map((m) => [m.role, m.variant ?? '', m.content])).toEqual([
686
+ ['assistant', '', 'Welcome! How can I help?'],
687
+ ['user', '', 'Jump to the pricing slide'],
688
+ ['assistant', 'tool', ''],
689
+ ['assistant', '', 'You are on Pricing.'],
690
+ ]);
691
+ const [row] = toolRows();
692
+ expect(row.toolCall).toEqual({
693
+ id: 'vtc_1',
694
+ name: 'webmcp:jump_to_slide',
695
+ status: 'complete',
696
+ args: { position: 5 },
697
+ result: { slidePosition: 5, slideTitle: 'Pricing' },
698
+ });
699
+ expect(row.agentMetadata).toEqual({
700
+ clientToolAnswer: {
701
+ toolCallId: 'vtc_1',
702
+ toolName: 'webmcp:jump_to_slide',
703
+ args: { position: 5 },
704
+ result: { slidePosition: 5, slideTitle: 'Pricing' },
705
+ },
706
+ });
707
+ // Nothing marks it awaiting, so the WebMCP resolve path never runs it.
708
+ expect(row.agentMetadata?.awaitingLocalTool).toBeUndefined();
709
+
710
+ // The request ends on the user turn followed by the executed call.
711
+ const sent = dispatch.mock.calls[0][0].messages.filter((m) => !m.voiceCaption);
712
+ expect(sent.slice(-2).map((m) => [m.role, m.variant ?? '', m.content])).toEqual([
713
+ ['user', '', 'Jump to the pricing slide'],
714
+ ['assistant', 'tool', ''],
715
+ ]);
716
+ });
717
+
718
+ it('records nothing when the field is absent or holds no well-formed entry', async () => {
719
+ dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'Done.'));
720
+ await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'next slide' }));
721
+ await h.state.bridge!.runDelegatedTurn(
722
+ req({
723
+ delegationId: 'd2',
724
+ userText: 'next slide',
725
+ priorToolResults: [{ name: 'webmcp:next_slide' }, { toolCallId: 'vtc_2' }, 'junk', null],
726
+ }),
727
+ );
728
+ await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd3', userText: 'next slide', priorToolResults: 'nope' }));
729
+ expect(toolRows()).toEqual([]);
730
+ expect(dispatch).toHaveBeenCalledTimes(3);
731
+ });
732
+
733
+ it('defaults missing arguments and result, and never duplicates a call already recorded', async () => {
734
+ dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'Done.'));
735
+ const bare = { toolCallId: 'vtc_3', name: 'webmcp:next_slide', arguments: 'bad' };
736
+ await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'next slide', priorToolResults: [bare] }));
737
+ await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'next slide', priorToolResults: [bare] }));
738
+ await session.sendMessage('thanks');
739
+ expect(toolRows()).toHaveLength(1);
740
+ expect(toolRows()[0].toolCall).toMatchObject({ args: {}, result: null });
741
+ });
742
+
743
+ it('hands the client-token request the executed calls only when the hand-back carried some', async () => {
744
+ dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'Done.'));
745
+ await h.state.bridge!.runDelegatedTurn(
746
+ req({ delegationId: 'd1', userText: 'jump', priorToolResults: [jump, 'junk', { ...jump, result: 'repeat' }] }),
747
+ );
748
+ await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd2', userText: 'next slide' }));
749
+ await session.sendMessage('typed');
750
+ const options = dispatch.mock.calls.map(([o]) => o as { voicePriorToolResults?: unknown });
751
+ expect(options[0].voicePriorToolResults).toEqual([jump]);
752
+ expect(toolRows()).toHaveLength(1);
753
+ expect(options[1].voicePriorToolResults).toBeUndefined();
754
+ expect(options[2].voicePriorToolResults).toBeUndefined();
755
+ });
756
+
757
+ it('keeps the executed call right after its request on later replays, even past a reply that finished first', async () => {
758
+ const bodies: Array<{ messages: Array<Record<string, unknown>> }> = [];
759
+ vi.stubGlobal(
760
+ 'fetch',
761
+ vi.fn(async (_url: string, init?: RequestInit) => {
762
+ bodies.push(JSON.parse(String(init?.body)));
763
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
764
+ }),
765
+ );
766
+ vi.useFakeTimers({ toFake: ['Date'] });
767
+ try {
768
+ delete (session as unknown as { client: { dispatch?: Dispatch } }).client.dispatch;
769
+ vi.setSystemTime(new Date('2026-10-10T10:00:00.000Z'));
770
+ h.state.transcriptCb!('user', 'Jump to the pricing slide', true, { turnId: 'u1', caption: true });
771
+ vi.setSystemTime(new Date('2026-10-10T10:00:01.000Z'));
772
+ session.injectMessage({ role: 'assistant', content: 'An earlier answer.' });
773
+ vi.setSystemTime(new Date('2026-10-10T10:00:02.000Z'));
774
+ await h.state.bridge!.runDelegatedTurn(
775
+ req({ delegationId: 'd1', userText: 'Jump to the pricing slide', userUtteranceIds: ['u1'], priorToolResults: [jump] }),
776
+ );
777
+ vi.setSystemTime(new Date('2026-10-10T10:00:03.000Z'));
778
+ await session.sendMessage('and the next one?');
779
+ } finally {
780
+ vi.useRealTimers();
781
+ vi.unstubAllGlobals();
782
+ }
783
+ const roles = (body: { messages: Array<Record<string, unknown>> }) =>
784
+ body.messages.map((m) => (m.toolCalls ? 'calls' : m.toolResults ? 'results' : m.content));
785
+ expect(roles(bodies[0])).toEqual([
786
+ 'Welcome! How can I help?',
787
+ 'An earlier answer.',
788
+ 'Jump to the pricing slide',
789
+ 'calls',
790
+ 'results',
791
+ ]);
792
+ expect(roles(bodies[1])).toEqual([
793
+ 'Welcome! How can I help?',
794
+ 'Jump to the pricing slide',
795
+ 'calls',
796
+ 'results',
797
+ 'An earlier answer.',
798
+ 'and the next one?',
799
+ ]);
800
+ });
801
+
802
+ it('replays the executed call to the model as a toolCalls/toolResults pair after the request (proxy mode)', async () => {
803
+ const bodies: Array<{ messages: Array<Record<string, unknown>> }> = [];
804
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
805
+ bodies.push(JSON.parse(String(init?.body)));
806
+ return new Response('', { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
807
+ });
808
+ vi.stubGlobal('fetch', fetchMock);
809
+ try {
810
+ delete (session as unknown as { client: { dispatch?: Dispatch } }).client.dispatch;
811
+ h.state.transcriptCb!('user', 'Jump to the pricing slide', true, { turnId: 'u1', caption: true });
812
+ await h.state.bridge!.runDelegatedTurn(
813
+ req({ delegationId: 'd1', userText: 'Jump to the pricing slide', userUtteranceIds: ['u1'], priorToolResults: [jump] }),
814
+ );
815
+ } finally {
816
+ vi.unstubAllGlobals();
817
+ }
818
+ expect(bodies).toHaveLength(1);
819
+ expect(bodies[0].messages.map(({ createdAt: _at, ...m }) => m)).toEqual([
820
+ { role: 'assistant', content: 'Welcome! How can I help?' },
821
+ { role: 'user', content: 'Jump to the pricing slide' },
822
+ {
823
+ role: 'assistant',
824
+ content: '',
825
+ toolCalls: [{ toolCallId: 'vtc_1', toolName: 'webmcp_jump_to_slide', args: { position: 5 } }],
826
+ },
827
+ {
828
+ role: 'tool',
829
+ content: '',
830
+ toolResults: [
831
+ { toolCallId: 'vtc_1', toolName: 'webmcp_jump_to_slide', result: { slidePosition: 5, slideTitle: 'Pricing' } },
832
+ ],
833
+ },
834
+ ]);
835
+ });
836
+ });
837
+
669
838
  it('trims GPT-Live\'s leading space from bubbles and the submitted request', async () => {
670
839
  h.state.transcriptCb!('user', ' What are your opening hours', true, { turnId: 'u1', caption: true });
671
840
  dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'Nine to five.'));
@@ -4750,14 +4750,16 @@
4750
4750
 
4751
4751
  .persona-approval-resolved {
4752
4752
  display: flex;
4753
- align-items: center;
4753
+ align-items: flex-start;
4754
4754
  gap: 0.45rem;
4755
4755
  margin: 0.5rem 0;
4756
4756
  padding: 0.3rem 0.15rem;
4757
4757
  font-size: 0.8rem;
4758
+ line-height: 1.4;
4758
4759
  color: var(--persona-muted, #6b7280);
4759
4760
  }
4760
- .persona-approval-resolved svg { width: 0.95rem; height: 0.95rem; display: block; flex-shrink: 0; }
4761
+ .persona-approval-resolved svg { width: 0.95rem; height: 0.95rem; display: block; flex-shrink: 0; margin-top: 0.085rem; }
4762
+ .persona-approval-resolved-text { min-width: 0; overflow-wrap: anywhere; }
4761
4763
  .persona-approval-resolved-name { font-weight: 600; }
4762
4764
 
4763
4765
  .persona-approval-actions {