@runtypelabs/persona 4.24.0 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) 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/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +243 -8
  12. package/dist/approval-ui.d.ts +243 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +243 -8
  17. package/dist/artifacts-ui.d.ts +243 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +243 -8
  46. package/dist/context-mentions.d.ts +243 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +246 -31
  51. package/dist/event-stream-view.d.ts +246 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +295 -667
  54. package/dist/forms-ui.d.ts +295 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +301 -738
  65. package/dist/index.d.ts +301 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +243 -8
  80. package/dist/session-reconnect.d.ts +243 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +243 -8
  83. package/dist/smart-dom-reader.d.ts +243 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +245 -8
  87. package/dist/theme-editor-preview.d.ts +245 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +244 -23
  91. package/dist/theme-editor.d.ts +244 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11643 -192
  100. package/dist/voice-runtime.d.ts +11643 -192
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +8 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +875 -1530
  151. package/src/session.voice.test.ts +1241 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +269 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +399 -2770
  172. package/src/ui.voice-prewarm.test.ts +222 -0
  173. package/src/utils/attachment-manager.ts +67 -67
  174. package/src/utils/constants.ts +3 -0
  175. package/src/utils/context-mention-controller.ts +323 -323
  176. package/src/utils/context-mention-manager.ts +40 -40
  177. package/src/utils/context-mention-orchestrator.ts +28 -0
  178. package/src/utils/event-stream-buffer.ts +50 -50
  179. package/src/utils/event-stream-store.ts +43 -43
  180. package/src/utils/throughput-tracker.ts +34 -34
  181. package/src/utils/virtual-scroller.ts +67 -67
  182. package/src/utils/visitor-store.test.ts +50 -0
  183. package/src/utils/visitor-store.ts +23 -5
  184. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  185. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  186. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  189. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  190. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  191. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  192. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  193. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  195. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  198. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  199. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  200. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  201. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  202. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  206. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  207. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  208. package/src/voice/audio-playback-manager.test.ts +57 -0
  209. package/src/voice/audio-playback-manager.ts +120 -98
  210. package/src/voice/browser-voice-provider.ts +37 -37
  211. package/src/voice/fallback-speech-engine.ts +22 -16
  212. package/src/voice/keyed-voice-transcript.ts +314 -0
  213. package/src/voice/mic-state-styles.ts +182 -0
  214. package/src/voice/read-aloud-controller.ts +46 -44
  215. package/src/voice/runtype-speech-engine.ts +35 -33
  216. package/src/voice/runtype-voice-provider.ts +856 -164
  217. package/src/voice/session-voice-wiring.ts +266 -0
  218. package/src/voice/voice-delegation.test.ts +71 -0
  219. package/src/voice/voice-delegation.ts +364 -0
  220. package/src/voice/voice-turn-tracker.ts +6 -6
  221. package/src/voice/voice.test.ts +1250 -7
  222. package/src/voice/worklet-playback-engine.test.ts +81 -0
  223. package/src/voice/worklet-playback-engine.ts +21 -3
  224. package/src/voice-runtime-loader.ts +4 -1
  225. package/src/voice-runtime.ts +7 -0
  226. package/src/webmcp-runtime-entry.ts +41 -39
  227. package/dist/approval-ui-3NMYNXBD.js +0 -1
  228. package/dist/chunk-5EIIHQLQ.js +0 -1
  229. package/dist/chunk-7CO4T5KB.js +0 -1
  230. package/dist/chunk-EFJBUH5F.js +0 -1
  231. package/dist/chunk-R2TMIMDI.js +0 -3
  232. package/dist/chunk-UTVLOTWB.js +0 -9
  233. package/dist/context-mentions-45MODWOY.js +0 -161
  234. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  235. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  236. package/dist/voice-runtime-EFR3R5ES.js +0 -1
  237. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -0,0 +1,909 @@
1
+ /**
2
+ * Approval, ask-user-question and WebMCP/suggest-replies resolve paths for
3
+ * `AgentWidgetSession`.
4
+ *
5
+ * Split out of `session.ts` so the IIFE/CDN bundle can ship them as the lazy
6
+ * `session-actions.js` sibling chunk (see `session-actions-loader.ts`).
7
+ * They only run once the agent pauses for an approval or a local tool, and
8
+ * the session prefetches the chunk as soon as such a pause arrives. The
9
+ * session's public methods keep their signatures and delegate here through
10
+ * a host object (`SessionActionsHost`) that exposes the internals they need.
11
+ */
12
+ import type { AgentWidgetClient } from "./client";
13
+ import {
14
+ SUGGEST_REPLIES_TOOL_NAME,
15
+ suggestRepliesToolResult,
16
+ } from "./suggest-replies-tool";
17
+ import type {
18
+ AgentWidgetConfig,
19
+ AgentWidgetMessage,
20
+ AgentWidgetApproval,
21
+ AgentWidgetApprovalDecisionOptions,
22
+ } from "./types";
23
+ import type { SessionCallbacks } from "./session";
24
+
25
+ /** Session internals the moved paths touch (TS-private fields on the class). */
26
+ export type SessionActionsInternals = {
27
+ client: AgentWidgetClient;
28
+ config: AgentWidgetConfig;
29
+ callbacks: SessionCallbacks;
30
+ messages: AgentWidgetMessage[];
31
+ abortController: AbortController | null;
32
+ approvalTokens: Map<string, object>;
33
+ webMcpInflightKeys: Set<string>;
34
+ webMcpResolvedKeys: Set<string>;
35
+ webMcpResolveControllers: Set<AbortController>;
36
+ upsertMessage(message: AgentWidgetMessage): void;
37
+ markAskUserQuestionResolved(
38
+ toolMessage: AgentWidgetMessage,
39
+ answers?: Record<string, string | string[]>
40
+ ): void;
41
+ connectStream(
42
+ stream: ReadableStream<Uint8Array>,
43
+ options?: { allowReentry?: boolean }
44
+ ): Promise<void>;
45
+ };
46
+
47
+ /** Built once per session; wraps the `#private` members the paths need. */
48
+ export type SessionActionsHost = {
49
+ s: SessionActionsInternals;
50
+ setStreaming(streaming: boolean): void;
51
+ appendMessage(message: AgentWidgetMessage): void;
52
+ nextSequence(): number;
53
+ settleApprovalPausedToolCall(approvalMessageId: string): void;
54
+ /** `after` cursor for a /resume on this execution (durable streams). */
55
+ resumeAfter(executionId: string): string | undefined;
56
+ };
57
+
58
+ const buildWebMcpErrorResult = (message: string) => ({
59
+ isError: true,
60
+ content: [{ type: "text" as const, text: message }],
61
+ });
62
+
63
+ const getWebMcpErrorMessage = (
64
+ error: unknown,
65
+ fallback = "WebMCP tool execution failed.",
66
+ ): string => {
67
+ if (error instanceof Error && error.message) return error.message;
68
+ if (typeof error === "string" && error) return error;
69
+ return fallback;
70
+ };
71
+
72
+ /**
73
+ * Resolve a tool approval request (approve or deny).
74
+ * Updates the approval message status, calls the API (or custom onDecision),
75
+ * and pipes the response stream through connectStream().
76
+ */
77
+ export async function resolveApproval(
78
+ h: SessionActionsHost,
79
+ approval: AgentWidgetApproval,
80
+ decision: 'approved' | 'denied',
81
+ options?: AgentWidgetApprovalDecisionOptions
82
+ ): Promise<void> {
83
+ // 1. Update approval message status immediately for responsive UI
84
+ const approvalMessageId = `approval-${approval.id}`;
85
+ const errorMessageId = `approval-error-${approval.id}`;
86
+ const requestToken = {};
87
+ h.s.approvalTokens.set(approvalMessageId, requestToken);
88
+ const updatedApproval: AgentWidgetApproval = {
89
+ ...approval,
90
+ status: decision,
91
+ resolvedAt: Date.now(),
92
+ };
93
+ // Anchor the bubble where the agent paused for permission. An approval is a
94
+ // timeline checkpoint, not a "now" event, so resolving it must preserve the
95
+ // original message's createdAt/sequence: otherwise sortMessages (which
96
+ // orders by createdAt first) would re-stamp it to now and float it past any
97
+ // message created later (e.g. a long-pending approval resolved after more
98
+ // conversation, or restored/replayed transcripts).
99
+ const existing = h.s.messages.find((m) => m.id === approvalMessageId);
100
+ const updatedMessage: AgentWidgetMessage = {
101
+ id: approvalMessageId,
102
+ role: "assistant",
103
+ content: "",
104
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
105
+ ...(existing?.sequence !== undefined ? { sequence: existing.sequence } : {}),
106
+ streaming: false,
107
+ variant: "approval",
108
+ approval: updatedApproval,
109
+ };
110
+ h.s.upsertMessage(updatedMessage);
111
+
112
+ // Show the standalone typing indicator immediately while we wait for the
113
+ // approval round-trip. Install an abortController so cancel() works during
114
+ // the silent gap. See `resolveAskUserQuestion` for the same pattern.
115
+ h.s.abortController?.abort();
116
+ h.s.abortController = new AbortController();
117
+ h.setStreaming(true);
118
+
119
+ // 2. Call onDecision callback if provided, otherwise use client.resolveApproval()
120
+ const approvalConfig = h.s.config.approval;
121
+ const onDecision = approvalConfig && typeof approvalConfig === 'object' ? approvalConfig.onDecision : undefined;
122
+
123
+ try {
124
+ let response: Response | ReadableStream<Uint8Array> | void;
125
+
126
+ if (onDecision) {
127
+ response = await onDecision(
128
+ {
129
+ approvalId: approval.id,
130
+ executionId: approval.executionId,
131
+ agentId: approval.agentId,
132
+ toolName: approval.toolName,
133
+ },
134
+ decision,
135
+ options
136
+ );
137
+ } else {
138
+ response = await h.s.client.resolveApproval(
139
+ {
140
+ agentId: approval.agentId,
141
+ executionId: approval.executionId,
142
+ approvalId: approval.id,
143
+ },
144
+ decision
145
+ );
146
+ }
147
+
148
+ // 3. Pipe through connectStream if we got a response with a body
149
+ if (response) {
150
+ let stream: ReadableStream<Uint8Array> | null = null;
151
+ if (response instanceof Response) {
152
+ if (!response.ok) {
153
+ const errorData = await response.json().catch(() => null);
154
+ // `message` carries the visitor-facing text when present (e.g.
155
+ // 409 APPROVAL_ALREADY_RESOLVED); 403 APPROVAL_APPROVER_NOT_END_USER
156
+ // puts it in `error`.
157
+ const errorText: string =
158
+ errorData?.message ?? errorData?.error ?? `Approval request failed: ${response.status}`;
159
+ // The decision did not apply, so the card must not claim it did.
160
+ // A pause the server reports as gone (already resolved, or expired
161
+ // / unknown) settles as timed out; anything else returns to
162
+ // pending so the visitor can retry. The notice says why.
163
+ const pauseGone =
164
+ errorData?.code === "APPROVAL_ALREADY_RESOLVED" ||
165
+ (response.status === 404 && /no paused execution/i.test(String(errorData?.error)));
166
+ // Only while the card still holds this request's decision: a
167
+ // newer request (or its approval_complete) may have settled it.
168
+ // The token tells requests apart even within one millisecond.
169
+ const current = h.s.messages.find((m) => m.id === approvalMessageId);
170
+ if (
171
+ h.s.approvalTokens.get(approvalMessageId) === requestToken &&
172
+ current?.approval?.resolvedAt === updatedApproval.resolvedAt
173
+ ) {
174
+ h.s.upsertMessage({
175
+ ...updatedMessage,
176
+ approval: pauseGone ? { ...updatedApproval, status: "timeout" } : approval,
177
+ });
178
+ // A gone pause never resumes, so its tool bubble must not spin.
179
+ if (pauseGone) h.settleApprovalPausedToolCall(approvalMessageId);
180
+ h.s.upsertMessage({
181
+ id: errorMessageId,
182
+ role: "assistant",
183
+ content: errorText,
184
+ createdAt: new Date().toISOString(),
185
+ streaming: false,
186
+ sequence: h.nextSequence(),
187
+ });
188
+ }
189
+ throw new Error(errorText);
190
+ }
191
+ stream = response.body;
192
+ } else if (response instanceof ReadableStream) {
193
+ stream = response;
194
+ }
195
+
196
+ // Accepted: a retry that succeeds supersedes the earlier failure notice.
197
+ // Only while this is still the latest request: an older request that
198
+ // succeeds late must not erase a newer request's failure notice.
199
+ if (
200
+ h.s.approvalTokens.get(approvalMessageId) === requestToken &&
201
+ h.s.messages.some((m) => m.id === errorMessageId)
202
+ ) {
203
+ h.s.messages = h.s.messages.filter((m) => m.id !== errorMessageId);
204
+ h.s.callbacks.onMessagesChanged([...h.s.messages]);
205
+ }
206
+
207
+ if (stream) {
208
+ await h.s.connectStream(stream, { allowReentry: true });
209
+ } else {
210
+ if (decision === 'denied') {
211
+ // No stream body for denied: inject a denial message, and settle
212
+ // the paused tool bubble since no approval_complete will arrive.
213
+ h.settleApprovalPausedToolCall(approvalMessageId);
214
+ h.appendMessage({
215
+ id: `denial-${approval.id}`,
216
+ role: "assistant",
217
+ content: "Tool execution was denied by user.",
218
+ createdAt: new Date().toISOString(),
219
+ streaming: false,
220
+ sequence: h.nextSequence(),
221
+ });
222
+ }
223
+ // No body to pipe: drop the pre-set streaming flag so the indicator
224
+ // doesn't linger forever.
225
+ h.setStreaming(false);
226
+ h.s.abortController = null;
227
+ }
228
+ } else {
229
+ // onDecision returned void / no response: drop the pre-set flag.
230
+ h.setStreaming(false);
231
+ h.s.abortController = null;
232
+ }
233
+ } catch (error) {
234
+ const isAbortError =
235
+ error instanceof Error &&
236
+ (error.name === 'AbortError' ||
237
+ error.message.includes('aborted') ||
238
+ error.message.includes('abort'));
239
+
240
+ h.setStreaming(false);
241
+ h.s.abortController = null;
242
+
243
+ if (!isAbortError) {
244
+ h.s.callbacks.onError?.(
245
+ error instanceof Error ? error : new Error(String(error))
246
+ );
247
+ }
248
+ } finally {
249
+ // Settled: only in-flight requests keep a token.
250
+ if (h.s.approvalTokens.get(approvalMessageId) === requestToken) {
251
+ h.s.approvalTokens.delete(approvalMessageId);
252
+ }
253
+ }
254
+ }
255
+
256
+ export async function resolveAskUserQuestion(
257
+ h: SessionActionsHost,
258
+ toolMessage: AgentWidgetMessage,
259
+ answer: string | Record<string, string | string[]>
260
+ ): Promise<void> {
261
+ // Idempotent: guards against rapid double-clicks on answer pills before
262
+ // the re-render swaps the card to its collapsed/answered state.
263
+ const live = h.s.messages.find((m) => m.id === toolMessage.id);
264
+ if (live?.agentMetadata?.askUserQuestionAnswered === true) return;
265
+
266
+ const executionId = toolMessage.agentMetadata?.executionId;
267
+ const toolName = toolMessage.toolCall?.name;
268
+ if (!executionId || !toolName) {
269
+ h.s.callbacks.onError?.(
270
+ new Error(
271
+ "resolveAskUserQuestion: message is missing executionId or toolCall.name"
272
+ )
273
+ );
274
+ return;
275
+ }
276
+
277
+ // Flip answered flag first so the next render skips the sheet re-mount,
278
+ // avoiding the race between removeAskUserQuestionSheet's 180ms slide-out
279
+ // timer and the renders that fire as the resume stream lands. Pass the
280
+ // structured answer Record (when present) so it's atomically persisted
281
+ // alongside the flag: the answered-state review card depends on
282
+ // `agentMetadata.askUserQuestionAnswers` being populated at render time.
283
+ //
284
+ // For single-question payloads, callers (built-in pick handler, plugins)
285
+ // resolve with a plain string. Derive a `{ [questionText]: answer }` Record
286
+ // from the toolCall args so the answered-card render path is consistent
287
+ // with grouped flows.
288
+ let structuredAnswers: Record<string, string | string[]> | undefined =
289
+ typeof answer === "string" ? undefined : answer;
290
+ if (structuredAnswers === undefined && typeof answer === "string") {
291
+ const args = toolMessage.toolCall?.args as
292
+ | { questions?: Array<{ question?: unknown }> }
293
+ | undefined;
294
+ const questions = Array.isArray(args?.questions) ? args!.questions : [];
295
+ if (questions.length === 1) {
296
+ const qText = typeof questions[0]?.question === "string"
297
+ ? (questions[0].question as string)
298
+ : "";
299
+ if (qText) structuredAnswers = { [qText]: answer };
300
+ }
301
+ }
302
+ h.s.markAskUserQuestionResolved(toolMessage, structuredAnswers);
303
+
304
+ // Show the standalone typing indicator immediately: the network round-trip
305
+ // to /resume is otherwise silent, which reads as broken. The render
306
+ // condition in ui.ts already shows the indicator once streaming flips true
307
+ // and the last message is a user bubble (the answer we inject below).
308
+ // Install an abortController so cancel() works during this silent gap.
309
+ h.s.abortController?.abort();
310
+ h.s.abortController = new AbortController();
311
+ h.setStreaming(true);
312
+
313
+ // Inject Q→A pair messages: one assistant bubble per question, one user
314
+ // bubble per answer, so the transcript reads like a normal conversation.
315
+ // The original ask_user_question tool message is suppressed by the
316
+ // renderer once `askUserQuestionAnswered` is true. Skipped questions get
317
+ // a muted italic `*Skipped*` user bubble (rendered through the standard
318
+ // markdown pipeline).
319
+ const toolCallId = toolMessage.toolCall!.id;
320
+ const args = toolMessage.toolCall?.args as
321
+ | { questions?: Array<{ question?: unknown; header?: unknown }> }
322
+ | undefined;
323
+ const questions = Array.isArray(args?.questions) ? args!.questions : [];
324
+ if (questions.length === 0) {
325
+ const fallback =
326
+ typeof answer === "string"
327
+ ? answer
328
+ : Object.entries(answer)
329
+ .map(
330
+ ([q, v]) => `${q}: ${Array.isArray(v) ? v.join(", ") : v}`
331
+ )
332
+ .join(" | ");
333
+ h.appendMessage({
334
+ id: `ask-user-answer-${toolCallId}`,
335
+ role: "user",
336
+ content: fallback,
337
+ createdAt: new Date().toISOString(),
338
+ streaming: false,
339
+ sequence: h.nextSequence(),
340
+ });
341
+ } else {
342
+ const stored = structuredAnswers ?? {};
343
+ questions.forEach((p, i) => {
344
+ const qText = typeof p?.question === "string" ? p.question : "";
345
+ if (!qText) return;
346
+ const ans = stored[qText];
347
+ const answerStr = Array.isArray(ans)
348
+ ? ans.join(", ")
349
+ : typeof ans === "string"
350
+ ? ans
351
+ : "";
352
+ h.appendMessage({
353
+ id: `ask-user-q-${toolCallId}-${i}`,
354
+ role: "assistant",
355
+ content: qText,
356
+ createdAt: new Date().toISOString(),
357
+ streaming: false,
358
+ sequence: h.nextSequence(),
359
+ });
360
+ h.appendMessage({
361
+ id: `ask-user-a-${toolCallId}-${i}`,
362
+ role: "user",
363
+ content: answerStr || "*Skipped*",
364
+ createdAt: new Date().toISOString(),
365
+ streaming: false,
366
+ sequence: h.nextSequence(),
367
+ });
368
+ });
369
+ }
370
+
371
+ try {
372
+ const response = await h.s.client.resumeFlow(
373
+ executionId,
374
+ { [toolName]: answer },
375
+ {
376
+ after:
377
+ h.resumeAfter(executionId),
378
+ },
379
+ );
380
+
381
+ if (!response.ok) {
382
+ const errorData = await response.json().catch(() => null);
383
+ throw new Error(
384
+ errorData?.error ?? `Resume failed: ${response.status}`
385
+ );
386
+ }
387
+
388
+ if (response.body) {
389
+ await h.s.connectStream(response.body, { allowReentry: true });
390
+ } else {
391
+ // No body to pipe: drop the pre-set streaming flag so the indicator
392
+ // doesn't linger forever.
393
+ h.setStreaming(false);
394
+ h.s.abortController = null;
395
+ }
396
+ } catch (error) {
397
+ // Mirror sendMessage: a cancel() during the await aborts the controller
398
+ // and surfaces an AbortError: don't treat that as a real failure.
399
+ const isAbortError =
400
+ error instanceof Error &&
401
+ (error.name === 'AbortError' ||
402
+ error.message.includes('aborted') ||
403
+ error.message.includes('abort'));
404
+
405
+ h.setStreaming(false);
406
+ h.s.abortController = null;
407
+
408
+ if (!isAbortError) {
409
+ h.s.callbacks.onError?.(
410
+ error instanceof Error ? error : new Error(String(error))
411
+ );
412
+ }
413
+ }
414
+ }
415
+
416
+ function resolveWebMcpToolStartedAt(
417
+ h: SessionActionsHost,
418
+ toolMessage: AgentWidgetMessage,
419
+ ): number {
420
+ const stored = h.s.messages.find((m) => m.id === toolMessage.id);
421
+ const candidates = [
422
+ stored?.toolCall?.startedAt,
423
+ toolMessage.toolCall?.startedAt,
424
+ ];
425
+ for (const candidate of candidates) {
426
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
427
+ return candidate;
428
+ }
429
+ }
430
+ return Date.now();
431
+ }
432
+
433
+ /**
434
+ * Persisted-resolution guard for `suggest_replies`. The in-memory dedupe
435
+ * sets (`webMcpInflightKeys` / `webMcpResolvedKeys`) are cleared by
436
+ * hydrateMessages/clearMessages/cancel, but `suggestRepliesResolved`
437
+ * survives on the stored message, so a stale `await` re-emit after a
438
+ * hydration must not re-POST `/resume` for an already-resolved call (the
439
+ * historical double-resume failure mode the batching work exists to avoid).
440
+ * Checks the LIVE message first; the handleEvent snapshot is a fresh wire
441
+ * skeleton whose metadata never carries the flag.
442
+ */
443
+ function isSuggestRepliesAlreadyResolved(
444
+ h: SessionActionsHost,
445
+ toolMessage: AgentWidgetMessage,
446
+ ): boolean {
447
+ if (toolMessage.toolCall?.name !== SUGGEST_REPLIES_TOOL_NAME) return false;
448
+ const stored = h.s.messages.find((m) => m.id === toolMessage.id);
449
+ return (
450
+ (stored ?? toolMessage).agentMetadata?.suggestRepliesResolved === true
451
+ );
452
+ }
453
+
454
+ function markWebMcpToolRunning(
455
+ h: SessionActionsHost,
456
+ toolMessage: AgentWidgetMessage,
457
+ ): number {
458
+ const startedAt = resolveWebMcpToolStartedAt(h, toolMessage);
459
+ h.s.upsertMessage({
460
+ ...toolMessage,
461
+ streaming: true,
462
+ agentMetadata: {
463
+ ...toolMessage.agentMetadata,
464
+ awaitingLocalTool: false,
465
+ },
466
+ toolCall: toolMessage.toolCall
467
+ ? {
468
+ ...toolMessage.toolCall,
469
+ status: "running",
470
+ startedAt,
471
+ completedAt: undefined,
472
+ duration: undefined,
473
+ durationMs: undefined,
474
+ }
475
+ : toolMessage.toolCall,
476
+ });
477
+ return startedAt;
478
+ }
479
+
480
+ function markWebMcpToolComplete(
481
+ h: SessionActionsHost,
482
+ toolMessage: AgentWidgetMessage,
483
+ result: unknown,
484
+ startedAt: number,
485
+ completedAt = Date.now(),
486
+ extraMetadata?: Partial<
487
+ NonNullable<AgentWidgetMessage["agentMetadata"]>
488
+ >,
489
+ ): void {
490
+ // A teardown such as clearMessages()/hydrateMessages()/new send can remove
491
+ // the bubble while an aborted WebMCP promise is settling. Never resurrect a
492
+ // cleared message just to mark the old resolve complete.
493
+ if (!h.s.messages.some((message) => message.id === toolMessage.id)) return;
494
+ h.s.upsertMessage({
495
+ ...toolMessage,
496
+ streaming: false,
497
+ agentMetadata: {
498
+ ...toolMessage.agentMetadata,
499
+ awaitingLocalTool: false,
500
+ ...extraMetadata,
501
+ },
502
+ toolCall: toolMessage.toolCall
503
+ ? {
504
+ ...toolMessage.toolCall,
505
+ status: "complete",
506
+ result,
507
+ startedAt,
508
+ completedAt,
509
+ duration: undefined,
510
+ durationMs: Math.max(0, completedAt - startedAt),
511
+ }
512
+ : toolMessage.toolCall,
513
+ });
514
+ }
515
+
516
+ /**
517
+ * Resolve one or more parallel local-tool awaits sharing one paused
518
+ * executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
519
+ * delegates size-1 resolves here after its guards. By default each call is
520
+ * executed against the page registry concurrently: every gated call renders
521
+ * its own native approval bubble, and a sibling's confirm Promise never
522
+ * blocks another's execution. With `webmcp.execution: "sequential"` the
523
+ * calls run one at a time in emission order instead (see the config docs
524
+ * for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
525
+ * (server prefers it over tool name; name-keying remains the fallback for
526
+ * legacy single/distinct-tool turns), so two calls to the SAME tool no longer
527
+ * collide. The server is tolerant: any call we omit (declined-after-abort,
528
+ * dedupe, exec failure) simply re-pauses and is retried on its re-emit.
529
+ *
530
+ * Owns the dedupe / abort / streaming machinery for both routes; resolved
531
+ * keys are marked on the shared resume POST's HTTP OK.
532
+ */
533
+ export async function resolveWebMcpToolCallBatch(
534
+ h: SessionActionsHost,
535
+ executionId: string,
536
+ snapshots: AgentWidgetMessage[],
537
+ ): Promise<void> {
538
+ type ExecutedWebMcpTool = {
539
+ dedupeKey: string;
540
+ resumeKey: string;
541
+ output: unknown;
542
+ toolMessage: AgentWidgetMessage;
543
+ startedAt: number;
544
+ completedAt: number;
545
+ };
546
+ const claimedKeys: string[] = [];
547
+ // One controller per batch, shared by every execute and the resume fetch.
548
+ // Teardown (`abortWebMcpResolves`) only ever aborts the whole set, so
549
+ // finer granularity buys nothing; one registration per resolve op also
550
+ // keeps `webMcpResolveControllers.size` === in-flight resolve count.
551
+ const batchController = new AbortController();
552
+ h.s.webMcpResolveControllers.add(batchController);
553
+ h.setStreaming(true);
554
+
555
+ // Phase 1: execute every pending call. A null result means the call was
556
+ // deduped, aborted, or threw; it's omitted from the resume and (per the
557
+ // tolerant server) re-pauses for retry.
558
+ const executeOne = async (
559
+ toolMessage: AgentWidgetMessage,
560
+ ): Promise<ExecutedWebMcpTool | null> => {
561
+ const wireToolName = toolMessage.toolCall?.name;
562
+ const callId = toolMessage.toolCall?.id;
563
+ if (!wireToolName || !callId) return null;
564
+
565
+ const dedupeKey = `${executionId}:${callId}`;
566
+ if (
567
+ h.s.webMcpInflightKeys.has(dedupeKey) ||
568
+ h.s.webMcpResolvedKeys.has(dedupeKey) ||
569
+ isSuggestRepliesAlreadyResolved(h, toolMessage)
570
+ ) {
571
+ return null;
572
+ }
573
+ h.s.webMcpInflightKeys.add(dedupeKey);
574
+ claimedKeys.push(dedupeKey);
575
+
576
+ // Clear the awaiting flag and keep the tool bubble running while the
577
+ // browser-side WebMCP promise is in flight. The initial `await`
578
+ // only means the server paused for a local tool; it is not completion.
579
+ const startedAt = markWebMcpToolRunning(h, toolMessage);
580
+
581
+ // Per-call id wins for resume keying; fall back to the wire tool name
582
+ // for legacy servers that don't emit `webMcpToolCallId`.
583
+ const resumeKey =
584
+ toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
585
+
586
+ // Built-in fire-and-forget tool: no bridge, no confirm gate, no
587
+ // browser-side execution: the chips render from the message list and
588
+ // the canned output joins the batch's single /resume.
589
+ if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
590
+ return {
591
+ dedupeKey,
592
+ resumeKey,
593
+ output: suggestRepliesToolResult(),
594
+ toolMessage,
595
+ startedAt,
596
+ completedAt: Date.now(),
597
+ };
598
+ }
599
+
600
+ const execPromise = h.s.client.executeWebMcpToolCall(
601
+ wireToolName,
602
+ toolMessage.toolCall?.args,
603
+ batchController.signal,
604
+ );
605
+
606
+ let output: unknown;
607
+ if (!execPromise) {
608
+ output = {
609
+ isError: true,
610
+ content: [
611
+ { type: "text", text: "WebMCP not enabled on this widget." },
612
+ ],
613
+ };
614
+ } else {
615
+ try {
616
+ output = await execPromise;
617
+ } catch (error) {
618
+ const isAbortError =
619
+ error instanceof Error &&
620
+ (error.name === "AbortError" ||
621
+ error.message.includes("aborted") ||
622
+ error.message.includes("abort"));
623
+ if (!isAbortError) {
624
+ h.s.callbacks.onError?.(
625
+ error instanceof Error ? error : new Error(String(error)),
626
+ );
627
+ }
628
+ markWebMcpToolComplete(h,
629
+ toolMessage,
630
+ buildWebMcpErrorResult(
631
+ isAbortError
632
+ ? "Aborted by cancel()"
633
+ : getWebMcpErrorMessage(error),
634
+ ),
635
+ startedAt,
636
+ );
637
+ // Release the dedupe claim so a re-emit can retry this call.
638
+ h.s.webMcpInflightKeys.delete(dedupeKey);
639
+ return null;
640
+ }
641
+ }
642
+ if (batchController.signal.aborted) {
643
+ markWebMcpToolComplete(h,
644
+ toolMessage,
645
+ buildWebMcpErrorResult("Aborted by cancel()"),
646
+ startedAt,
647
+ );
648
+ h.s.webMcpInflightKeys.delete(dedupeKey);
649
+ return null;
650
+ }
651
+ return {
652
+ dedupeKey,
653
+ resumeKey,
654
+ output,
655
+ toolMessage,
656
+ startedAt,
657
+ completedAt: Date.now(),
658
+ };
659
+ };
660
+
661
+ // Parallel (default): a gated sibling's approval never blocks another's
662
+ // execution. Sequential: one at a time in emission order, so tools that
663
+ // share page state (a canvas, a form) don't interleave; the next call
664
+ // starts only once the previous one has settled, approval included.
665
+ let executed: Array<ExecutedWebMcpTool | null>;
666
+ if (h.s.config.webmcp?.execution === "sequential") {
667
+ executed = [];
668
+ for (const toolMessage of snapshots) {
669
+ executed.push(await executeOne(toolMessage));
670
+ }
671
+ } else {
672
+ executed = await Promise.all(snapshots.map(executeOne));
673
+ }
674
+
675
+ let ready: ExecutedWebMcpTool[] = [];
676
+ try {
677
+ ready = executed.filter((r): r is ExecutedWebMcpTool => r !== null);
678
+ // Everything deduped/aborted/failed: nothing to post.
679
+ if (ready.length === 0) return;
680
+
681
+ const toolOutputs: Record<string, unknown> = {};
682
+ for (const r of ready) {
683
+ // Two omitted-on-collision safety: if two calls somehow resolve to the
684
+ // same key (only possible on a legacy name fallback), last write wins: // the server re-pauses the unrepresented call for retry.
685
+ toolOutputs[r.resumeKey] = r.output;
686
+ }
687
+
688
+ const response = await h.s.client.resumeFlow(executionId, toolOutputs, {
689
+ signal: batchController.signal,
690
+ after:
691
+ h.resumeAfter(executionId),
692
+ });
693
+ if (!response.ok) {
694
+ const errorData = await response.json().catch(() => null);
695
+ throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
696
+ }
697
+ // Server accepted the batch: mark every included call resolved so stale
698
+ // re-emits don't re-execute the page tool, then complete each bubble.
699
+ // Do this only after /resume HTTP success; if /resume fails, the server
700
+ // may still be paused and the retry path must not show a final result.
701
+ const batch = ready[0]!.dedupeKey;
702
+ for (const r of ready) {
703
+ h.s.webMcpResolvedKeys.add(r.dedupeKey);
704
+ const toolName = r.toolMessage.toolCall?.name;
705
+ const toolCallId = r.toolMessage.agentMetadata?.webMcpToolCallId;
706
+ markWebMcpToolComplete(h,
707
+ r.toolMessage,
708
+ r.output,
709
+ r.startedAt,
710
+ r.completedAt,
711
+ {
712
+ ...(toolName === SUGGEST_REPLIES_TOOL_NAME
713
+ ? { suggestRepliesResolved: true }
714
+ : {}),
715
+ // Only a provider call id can be replayed: a legacy name-keyed
716
+ // resume has nothing the model's transcript can reference.
717
+ ...(toolName && toolCallId
718
+ ? {
719
+ clientToolAnswer: {
720
+ toolCallId,
721
+ toolName,
722
+ args: r.toolMessage.toolCall?.args,
723
+ result: r.output,
724
+ batch,
725
+ },
726
+ }
727
+ : {}),
728
+ },
729
+ );
730
+ }
731
+ if (response.body) {
732
+ await h.s.connectStream(response.body, { allowReentry: true });
733
+ }
734
+ } catch (error) {
735
+ const isAbortError =
736
+ error instanceof Error &&
737
+ (error.name === "AbortError" ||
738
+ error.message.includes("aborted") ||
739
+ error.message.includes("abort"));
740
+ if (!isAbortError) {
741
+ h.s.callbacks.onError?.(
742
+ error instanceof Error ? error : new Error(String(error)),
743
+ );
744
+ } else {
745
+ for (const r of ready) {
746
+ markWebMcpToolComplete(h,
747
+ r.toolMessage,
748
+ buildWebMcpErrorResult("Aborted by cancel()"),
749
+ r.startedAt,
750
+ );
751
+ }
752
+ }
753
+ } finally {
754
+ for (const key of claimedKeys) {
755
+ h.s.webMcpInflightKeys.delete(key);
756
+ }
757
+ h.s.webMcpResolveControllers.delete(batchController);
758
+ if (h.s.webMcpResolveControllers.size === 0 && !h.s.abortController) {
759
+ h.setStreaming(false);
760
+ }
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Resolve a paused auto-resolving LOCAL tool call and post the result to
766
+ * `/resume`: `webmcp:*` calls execute against the host page's tool
767
+ * registry; the built-in `suggest_replies` skips execution entirely and
768
+ * resumes with a canned "shown" result (the chips render from the message
769
+ * list, not from this resolve).
770
+ *
771
+ * Triggered automatically from `handleEvent` when an `await`-derived
772
+ * message arrives for such a tool: the user does not click a pill; the
773
+ * bridge's confirm-bubble gate (WebMCP only) is the only interactive
774
+ * surface.
775
+ *
776
+ * Idempotent on the message's `toolCall.id`: re-emits of the same await
777
+ * (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
778
+ * modes, declined, timed out, throw, unknown tool, all resolve into a
779
+ * `{ isError: true, content: [...] }` payload that resumes the dispatch
780
+ * cleanly so the agent can recover.
781
+ *
782
+ * After the malformed-wire guards this is a thin gate over
783
+ * `resolveWebMcpToolCallBatch` with a size-1 batch: the dedupe check runs
784
+ * synchronously here so a stale re-emit never toggles streaming.
785
+ */
786
+ export async function resolveWebMcpToolCall(
787
+ h: SessionActionsHost,
788
+ toolMessage: AgentWidgetMessage,
789
+ ): Promise<void> {
790
+ const executionId = toolMessage.agentMetadata?.executionId;
791
+ const wireToolName = toolMessage.toolCall?.name;
792
+ const toolCallId = toolMessage.toolCall?.id;
793
+
794
+ // Malformed await wire shapes shouldn't silently strand the
795
+ // server-side dispatch. Three failure modes:
796
+ // - no executionId: no /resume target exists; surface to the host
797
+ // via onError so an operator can react. This is a server-side
798
+ // wire-shape bug: Persona can't recover it from the client.
799
+ // - no wireToolName: defensive guard: handleEvent only calls us
800
+ // for an auto-resolving local tool name (`webmcp:*` or
801
+ // `suggest_replies`), so this path indicates a direct caller
802
+ // misuse. Silent return.
803
+ // - no toolCallId: dedupe key falls apart, but the server can still
804
+ // advance if we post an isError for the wireToolName. Do that
805
+ // and bail before the dedupe path.
806
+ if (!executionId) {
807
+ h.s.callbacks.onError?.(
808
+ new Error(
809
+ "WebMCP await missing executionId: dispatch left paused.",
810
+ ),
811
+ );
812
+ return;
813
+ }
814
+ if (!wireToolName) return;
815
+ if (!toolCallId) {
816
+ // No toolCall.id → no per-call dedupe key. Fall back to a synthetic
817
+ // `(executionId):(wireToolName)` so identical malformed re-emits don't
818
+ // re-POST /resume. Idempotent on duplicate bad payloads.
819
+ const malformedKey = `${executionId}:__no_tool_id__:${wireToolName}`;
820
+ if (
821
+ h.s.webMcpInflightKeys.has(malformedKey) ||
822
+ h.s.webMcpResolvedKeys.has(malformedKey)
823
+ ) {
824
+ return;
825
+ }
826
+ h.s.webMcpInflightKeys.add(malformedKey);
827
+ try {
828
+ await resumeWithToolOutput(h, executionId, wireToolName, {
829
+ isError: true,
830
+ content: [
831
+ {
832
+ type: "text",
833
+ text: "WebMCP await missing toolCall.id: cannot execute the page tool.",
834
+ },
835
+ ],
836
+ });
837
+ h.s.webMcpResolvedKeys.add(malformedKey);
838
+ } catch (error) {
839
+ h.s.callbacks.onError?.(
840
+ error instanceof Error ? error : new Error(String(error)),
841
+ );
842
+ } finally {
843
+ h.s.webMcpInflightKeys.delete(malformedKey);
844
+ }
845
+ return;
846
+ }
847
+
848
+ // Dedupe key scoped by executionId: see `webMcpInflightKeys` doc comment
849
+ // for the failure-recovery + cross-dispatch rationale. The persisted
850
+ // `suggestRepliesResolved` guard backs the in-memory sets across
851
+ // hydrations. Checked synchronously HERE so a stale re-emit stays a pure
852
+ // no-op (no streaming toggle, no controller registration); the batch path
853
+ // re-checks under its own inflight claim.
854
+ const dedupeKey = `${executionId}:${toolCallId}`;
855
+ if (
856
+ h.s.webMcpInflightKeys.has(dedupeKey) ||
857
+ h.s.webMcpResolvedKeys.has(dedupeKey) ||
858
+ isSuggestRepliesAlreadyResolved(h, toolMessage)
859
+ ) {
860
+ return;
861
+ }
862
+ return resolveWebMcpToolCallBatch(h, executionId, [toolMessage]);
863
+ }
864
+
865
+ /**
866
+ * POST `/resume` with a SINGLE tool's output and pipe the resulting SSE
867
+ * stream back through `connectStream`. Shared by every single-call local-tool
868
+ * resolve path (ask_user_question and single WebMCP calls). Parallel WebMCP
869
+ * calls use `resolveWebMcpToolCallBatch`, which posts one resume for many.
870
+ *
871
+ * `resumeKey` is the `toolOutputs` map key: the per-call `webMcpToolCallId`
872
+ * for WebMCP (core#3878), or the tool name for ask_user_question / legacy
873
+ * servers. `onHttpOk` runs synchronously between the HTTP-status check and the
874
+ * stream pipe; it lets the WebMCP resolve path commit the dedupe flag at
875
+ * "server accepted the answer" rather than "stream finished cleanly".
876
+ */
877
+ async function resumeWithToolOutput(
878
+ h: SessionActionsHost,
879
+ executionId: string,
880
+ resumeKey: string,
881
+ output: unknown,
882
+ options?: { onHttpOk?: () => void; signal?: AbortSignal },
883
+ ): Promise<void> {
884
+ const response = await h.s.client.resumeFlow(
885
+ executionId,
886
+ { [resumeKey]: output },
887
+ {
888
+ signal: options?.signal,
889
+ after:
890
+ h.resumeAfter(executionId),
891
+ },
892
+ );
893
+ if (!response.ok) {
894
+ const errorData = await response.json().catch(() => null);
895
+ throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
896
+ }
897
+ options?.onHttpOk?.();
898
+ if (response.body) {
899
+ await h.s.connectStream(response.body, { allowReentry: true });
900
+ } else if (h.s.webMcpResolveControllers.size === 0) {
901
+ // No stream to pipe. Clear streaming only when no WebMCP resolve is in
902
+ // flight: for a WebMCP caller the current resolve's controller is still
903
+ // in the set, so its own `finally` (gated on the set draining) owns the
904
+ // teardown. Non-WebMCP callers (ask_user_question) keep the old behavior.
905
+ h.setStreaming(false);
906
+ h.s.abortController = null;
907
+ }
908
+ }
909
+