@runtypelabs/persona 4.28.2 → 4.29.1

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 (145) 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-DrYAfpvk.js → chunk-BcLIgi1h.js} +1 -1
  13. package/dist/chunk-BkW8s3fj.js +2 -0
  14. package/dist/chunk-Bm2-bemv.js +1 -0
  15. package/dist/chunk-BvhTpG6k.js +1 -0
  16. package/dist/chunk-Csks3Qw1.js +1 -0
  17. package/dist/chunk-D5B629Mj.js +1 -0
  18. package/dist/chunk-DGlFKA0r.js +9 -0
  19. package/dist/chunk-DO9_WSpy.js +1 -0
  20. package/dist/chunk-DTDHeUsG.js +1 -0
  21. package/dist/chunk-DjFpGFGA.js +1 -0
  22. package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
  23. package/dist/chunk-lA5CJCgK.js +1 -0
  24. package/dist/client-history.js +1 -1
  25. package/dist/client-stream.js +1 -1
  26. package/dist/codegen.cjs +1 -1
  27. package/dist/codegen.js +1 -1
  28. package/dist/{composer-input-C39npJaz.d.cts → composer-input-B0P4OtNi.d.cts} +1 -1
  29. package/dist/{composer-input-C39npJaz.d.ts → composer-input-B0P4OtNi.d.ts} +1 -1
  30. package/dist/{context-mention-manager-DLCB1l9E.d.cts → context-mention-manager-DmLI_RM6.d.cts} +1 -1
  31. package/dist/{context-mention-manager-DLCB1l9E.d.ts → context-mention-manager-DmLI_RM6.d.ts} +1 -1
  32. package/dist/context-mentions-inline.d.cts +2 -2
  33. package/dist/context-mentions-inline.d.ts +2 -2
  34. package/dist/context-mentions.d.cts +3 -3
  35. package/dist/context-mentions.d.ts +3 -3
  36. package/dist/{defaults-DiAgWHXy.d.cts → defaults-DVo1wbZs.d.cts} +1 -1
  37. package/dist/{defaults-DiAgWHXy.d.ts → defaults-DVo1wbZs.d.ts} +1 -1
  38. package/dist/event-stream-view.d.cts +1 -1
  39. package/dist/event-stream-view.d.ts +1 -1
  40. package/dist/{fallback-speech-engine-C5_PUZjH.d.cts → fallback-speech-engine-BMnlmKnY.d.cts} +1 -1
  41. package/dist/{fallback-speech-engine-C5_PUZjH.d.ts → fallback-speech-engine-BMnlmKnY.d.ts} +1 -1
  42. package/dist/forms-ui.d.cts +2 -2
  43. package/dist/forms-ui.d.ts +2 -2
  44. package/dist/history-view.d.cts +1 -1
  45. package/dist/history-view.d.ts +1 -1
  46. package/dist/index.cjs +50 -49
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.cts +7 -7
  49. package/dist/index.d.ts +7 -7
  50. package/dist/index.global.js +2 -3
  51. package/dist/index.global.js.map +1 -1
  52. package/dist/index.js +38 -37
  53. package/dist/index.js.map +1 -1
  54. package/dist/{markdown-parsers-loader-BBdyIEU7.d.cts → markdown-parsers-loader-CeovcyYf.d.cts} +1 -1
  55. package/dist/{markdown-parsers-loader-BBdyIEU7.d.ts → markdown-parsers-loader-CeovcyYf.d.ts} +1 -1
  56. package/dist/{preview-Chjb0bmI.d.cts → preview-Bkb4UrQC.d.cts} +2 -2
  57. package/dist/{preview-Chjb0bmI.d.ts → preview-Bkb4UrQC.d.ts} +2 -2
  58. package/dist/session-actions.js +1 -1
  59. package/dist/{session-a_qIp4xp.d.ts → session-iLyKipoM.d.cts} +28 -10
  60. package/dist/{session-a_qIp4xp.d.cts → session-iLyKipoM.d.ts} +28 -10
  61. package/dist/session-reconnect.cjs +1 -1
  62. package/dist/session-reconnect.d.cts +8 -2
  63. package/dist/session-reconnect.d.ts +8 -2
  64. package/dist/session-reconnect.js +1 -1
  65. package/dist/smart-dom-reader.d.cts +1 -1
  66. package/dist/smart-dom-reader.d.ts +1 -1
  67. package/dist/theme-editor-preview.cjs +61 -60
  68. package/dist/theme-editor-preview.d.cts +1 -1
  69. package/dist/theme-editor-preview.d.ts +1 -1
  70. package/dist/theme-editor-preview.js +40 -40
  71. package/dist/theme-editor.d.cts +2 -2
  72. package/dist/theme-editor.d.ts +2 -2
  73. package/dist/theme-reference.d.cts +2 -2
  74. package/dist/theme-reference.d.ts +2 -2
  75. package/dist/{types-BdO1jNRm.d.cts → types-BuDirJkQ.d.cts} +200 -23
  76. package/dist/{types-BdO1jNRm.d.ts → types-BuDirJkQ.d.ts} +200 -23
  77. package/dist/voice-client-tools.cjs +1 -0
  78. package/dist/voice-client-tools.d.cts +13 -0
  79. package/dist/voice-client-tools.d.ts +13 -0
  80. package/dist/voice-client-tools.js +1 -0
  81. package/dist/voice-runtime.cjs +3 -3
  82. package/dist/voice-runtime.d.cts +15 -5
  83. package/dist/voice-runtime.d.ts +15 -5
  84. package/dist/voice-runtime.js +5 -5
  85. package/dist/voice-worklet-player.d.cts +2 -2
  86. package/dist/voice-worklet-player.d.ts +2 -2
  87. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.ts → webmcp-runtime-entry-CXLjRt1k.d.cts} +26 -5
  88. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.cts → webmcp-runtime-entry-CXLjRt1k.d.ts} +26 -5
  89. package/dist/webmcp-runtime.cjs +2 -1
  90. package/dist/webmcp-runtime.d.cts +1 -1
  91. package/dist/webmcp-runtime.d.ts +1 -1
  92. package/dist/webmcp-runtime.js +2 -1
  93. package/dist/widget.css +1 -1
  94. package/package.json +6 -1
  95. package/src/client-approve.test.ts +17 -1
  96. package/src/client-resume.ts +15 -3
  97. package/src/client.interrupt.test.ts +21 -0
  98. package/src/client.ts +61 -4
  99. package/src/client.voice-tools.test.ts +65 -0
  100. package/src/components/activity-row.ts +3 -2
  101. package/src/components/approval-actions.test.ts +8 -0
  102. package/src/components/approval-actions.ts +6 -2
  103. package/src/components/approval-bubble.test.ts +14 -0
  104. package/src/components/approval-bubble.ts +8 -1
  105. package/src/components/tool-bubble.ts +8 -5
  106. package/src/generated/runtype-openapi-contract.ts +39 -1
  107. package/src/index-core.ts +11 -0
  108. package/src/index-global.ts +6 -0
  109. package/src/resume-dedupe-local-only.test.ts +273 -0
  110. package/src/runtime/persist-state.test.ts +86 -0
  111. package/src/session-actions.ts +20 -6
  112. package/src/session-reconnect.ts +18 -1
  113. package/src/session.restored-browser-tools.test.ts +272 -0
  114. package/src/session.test.ts +26 -0
  115. package/src/session.ts +185 -31
  116. package/src/session.voice.test.ts +212 -0
  117. package/src/styles/widget.css +4 -2
  118. package/src/types.ts +178 -22
  119. package/src/ui.restored-browser-tools.test.ts +128 -0
  120. package/src/ui.ts +32 -18
  121. package/src/utils/formatting.test.ts +9 -1
  122. package/src/utils/formatting.ts +4 -0
  123. package/src/utils/local-only-message.ts +15 -0
  124. package/src/utils/restored-browser-state.ts +50 -0
  125. package/src/utils/storage.ts +12 -1
  126. package/src/utils/voice-prior-tool-results.ts +37 -0
  127. package/src/voice/runtype-voice-provider.ts +94 -7
  128. package/src/voice/session-voice-wiring.ts +27 -3
  129. package/src/voice/voice-client-tools-session.ts +116 -0
  130. package/src/voice/voice-delegation.ts +19 -3
  131. package/src/voice/voice.test.ts +396 -0
  132. package/src/voice-client-tools-bundle.test.ts +37 -0
  133. package/src/voice-client-tools-loader.ts +24 -0
  134. package/src/voice-client-tools.ts +9 -0
  135. package/src/webmcp-bridge.test.ts +152 -1
  136. package/src/webmcp-bridge.ts +5 -6
  137. package/src/webmcp-polyfill.integration.test.ts +5 -1
  138. package/src/webmcp-runtime-entry.ts +151 -26
  139. package/dist/chunk-B6zZsT6F.js +0 -1
  140. package/dist/chunk-CztSjKHK.js +0 -9
  141. package/dist/chunk-DbhxjyOB.js +0 -4
  142. package/dist/chunk-DsdqLStH.js +0 -1
  143. package/dist/chunk-GIqbL2Eg.js +0 -1
  144. package/dist/chunk-HUOUif-P.js +0 -1
  145. package/dist/chunk-UlcxdHef.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
 
@@ -3076,9 +3129,16 @@ export class AgentWidgetSession {
3076
3129
  } catch {
3077
3130
  // fall through to explicit approval
3078
3131
  }
3079
-
3132
+ if (info.signal?.aborted) return Promise.resolve(false);
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
+ ));
3080
3140
  const approval: AgentWidgetApproval = {
3081
- id: `webmcp-${++this.#webMcpApprovalSeq}`,
3141
+ id: approvalId,
3082
3142
  status: "pending",
3083
3143
  agentId: "",
3084
3144
  executionId: "",
@@ -3102,6 +3162,12 @@ export class AgentWidgetSession {
3102
3162
 
3103
3163
  return new Promise<boolean>((resolve) => {
3104
3164
  this.webMcpApprovalResolvers.set(approvalMessageId, resolve);
3165
+ // The call gave up (deadline or cancel): close the card so a late click cannot look like it ran the tool.
3166
+ info.signal?.addEventListener(
3167
+ "abort",
3168
+ () => this.resolveWebMcpApproval(approvalMessageId, "timeout"),
3169
+ { once: true }
3170
+ );
3105
3171
  });
3106
3172
  }
3107
3173
 
@@ -3113,7 +3179,7 @@ export class AgentWidgetSession {
3113
3179
  */
3114
3180
  public resolveWebMcpApproval(
3115
3181
  approvalMessageId: string,
3116
- decision: "approved" | "denied"
3182
+ decision: "approved" | "denied" | "timeout"
3117
3183
  ): void {
3118
3184
  const resolve = this.webMcpApprovalResolvers.get(approvalMessageId);
3119
3185
  if (!resolve) return;
@@ -3659,7 +3725,7 @@ export class AgentWidgetSession {
3659
3725
  this.webMcpResolvedKeys.clear();
3660
3726
  this.messages = this.#sortMessages(
3661
3727
  messages.map((message) => ({
3662
- ...message,
3728
+ ...restoreBrowserToolState(message),
3663
3729
  streaming: false,
3664
3730
  sequence: message.sequence ?? this.#nextSequence()
3665
3731
  }))
@@ -3766,7 +3832,9 @@ export class AgentWidgetSession {
3766
3832
  resolveFollowUpsFeature(this.config).enabled));
3767
3833
  if (
3768
3834
  event.message.agentMetadata?.awaitingLocalTool === true &&
3769
- autoResolvable
3835
+ autoResolvable &&
3836
+ this.messages.find(message => message.id === event.message.id)
3837
+ ?.agentMetadata?.awaitingLocalTool === true
3770
3838
  ) {
3771
3839
  // Collect the await into its executionId's batch instead of resolving
3772
3840
  // it on the spot. Parallel same-tool calls (core#3878) arrive as
@@ -4022,6 +4090,7 @@ export class AgentWidgetSession {
4022
4090
  seedContent,
4023
4091
  }),
4024
4092
  appendMessage: (message) => session.#appendMessage(message),
4093
+ settleParked: (executionId) => session.#settleParkedReconnect(executionId),
4025
4094
  nextSequence: () => session.#nextSequence(),
4026
4095
  emitReconnect: (event) => session.callbacks.onReconnect?.(event),
4027
4096
  buildErrorContent: (message) =>
@@ -4053,7 +4122,28 @@ export class AgentWidgetSession {
4053
4122
  if (typeof this.config.reconnectStream !== "function") return;
4054
4123
  if (this.#reconnecting) return;
4055
4124
 
4056
- 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();
4057
4147
  if (!assistantId) {
4058
4148
  assistantId = generateAssistantMessageId();
4059
4149
  this.#appendMessage({
@@ -4066,16 +4156,7 @@ export class AgentWidgetSession {
4066
4156
  });
4067
4157
  }
4068
4158
  this.#activeAssistantMessageId = assistantId;
4069
- if (!this.agentExecution) {
4070
- this.agentExecution = {
4071
- executionId: resume.executionId,
4072
- agentId: "",
4073
- agentName: "",
4074
- status: "running",
4075
- currentIteration: 0,
4076
- maxTurns: 0,
4077
- };
4078
- }
4159
+ this.#adoptBootExecution(resume.executionId);
4079
4160
  this.#resumable = {
4080
4161
  executionId: resume.executionId,
4081
4162
  lastEventId: resume.after,
@@ -4085,6 +4166,63 @@ export class AgentWidgetSession {
4085
4166
  this.#beginReconnect();
4086
4167
  }
4087
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
+
4088
4226
  /**
4089
4227
  * Reopen the trailing assistant bubble (the persisted partial) so replayed
4090
4228
  * deltas append to it. Returns its id, or null if the last turn doesn't end
@@ -4328,6 +4466,21 @@ export class AgentWidgetSession {
4328
4466
 
4329
4467
  this.messages = this.messages.map((existing, idx) => {
4330
4468
  if (idx !== index) return existing;
4469
+ // An unnamed skeleton precedes tool_start/await on a fresh parser. Keep
4470
+ // history until the populated event identifies the continuation. Also
4471
+ // retain actual terminal browser outcomes across replayed starts/deltas:
4472
+ // those events must never turn a completed mutation back into an await.
4473
+ if (
4474
+ existing.toolCall?.name && isWebMcpToolName(existing.toolCall.name) &&
4475
+ existing.toolCall.status === "complete" &&
4476
+ withSequence.toolCall?.id === existing.toolCall.id &&
4477
+ withSequence.toolCall.status !== "complete" &&
4478
+ ((withSequence.toolCall.status === "pending" && !withSequence.toolCall.name) ||
4479
+ (!existing.toolCall.interrupted &&
4480
+ (existing.toolCall.result !== undefined || existing.toolCall.success !== undefined) &&
4481
+ (!withSequence.agentMetadata?.executionId ||
4482
+ withSequence.agentMetadata.executionId === existing.agentMetadata?.executionId)))
4483
+ ) return existing;
4331
4484
  const merged = { ...existing, ...withSequence };
4332
4485
  // Preserve `ask_user_question` answered state across re-emissions.
4333
4486
  // Top-level merge would otherwise replace `agentMetadata` wholesale: // post-resume events (e.g. `tool_complete` re-emitted from a stale
@@ -4434,7 +4587,8 @@ export class AgentWidgetSession {
4434
4587
  existing.toolCall?.id === reTcId &&
4435
4588
  existingToolName !== undefined &&
4436
4589
  isAutoResolvedLocalToolName(existingToolName) &&
4437
- existing.toolCall?.status === "complete";
4590
+ existing.toolCall?.status === "complete" &&
4591
+ !existing.toolCall.interrupted;
4438
4592
  if (isInflight || isResolved || hasCompletedTool) {
4439
4593
  merged.agentMetadata = {
4440
4594
  ...(merged.agentMetadata ?? {}),
@@ -623,6 +623,218 @@ describe('AgentWidgetSession - voice client delegation bridge', () => {
623
623
  ]);
624
624
  });
625
625
 
626
+ it('offers no client tools when WebMCP is off', () => {
627
+ expect(h.state.bridge!.clientTools!()).toBeNull();
628
+ });
629
+
630
+ it('records a server-answered (direct) turn: the caption becomes the user turn, the readout an answer', async () => {
631
+ const caption = (role: 'user' | 'assistant', text: string, turnId: string) =>
632
+ h.state.transcriptCb!(role, text, true, { turnId, caption: true });
633
+ caption('user', 'Next slide', 'u1');
634
+ h.state.bridge!.recordDirectTurn!({
635
+ delegationId: 'd1',
636
+ userText: 'Next slide',
637
+ userUtteranceIds: ['u1'],
638
+ assistantText: 'Slide 2: Agenda.',
639
+ });
640
+ expect(view()).toEqual([
641
+ ['assistant', 'Welcome! How can I help?'],
642
+ ['user', 'Next slide'],
643
+ ['assistant', 'Slide 2: Agenda.'],
644
+ ]);
645
+ expect(messages.find((m) => m.content === 'Next slide')!.voiceCaption).toBeUndefined();
646
+ expect(spoken('voice-direct-d1')).toBe(true);
647
+ expect(dispatch).not.toHaveBeenCalled();
648
+
649
+ // The next typed turn carries both as conversation.
650
+ dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'Sure.', 'assistant-r2'));
651
+ await session.sendMessage('and the one after?');
652
+ const sent = dispatch.mock.calls[0][0].messages.filter((m) => !m.voiceCaption);
653
+ expect(sent.map((m) => m.content)).toEqual([
654
+ 'Welcome! How can I help?',
655
+ 'Next slide',
656
+ 'Slide 2: Agenda.',
657
+ 'and the one after?',
658
+ ]);
659
+ });
660
+
661
+ it('records a direct turn with no caption and no readout as just the request', () => {
662
+ h.state.bridge!.recordDirectTurn!({ delegationId: 'd2', userText: 'Next slide', userUtteranceIds: [], assistantText: '' });
663
+ expect(view()).toEqual([
664
+ ['assistant', 'Welcome! How can I help?'],
665
+ ['user', 'Next slide'],
666
+ ]);
667
+ });
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
+
626
838
  it('trims GPT-Live\'s leading space from bubbles and the submitted request', async () => {
627
839
  h.state.transcriptCb!('user', ' What are your opening hours', true, { turnId: 'u1', caption: true });
628
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 {