@runtypelabs/persona 3.21.2 → 3.22.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 (59) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  5. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +50 -43
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +474 -6
  11. package/dist/index.d.ts +474 -6
  12. package/dist/index.global.js +98 -88
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +48 -41
  15. package/dist/index.js.map +1 -1
  16. package/dist/smart-dom-reader.cjs +1875 -0
  17. package/dist/smart-dom-reader.d.cts +4521 -0
  18. package/dist/smart-dom-reader.d.ts +4521 -0
  19. package/dist/smart-dom-reader.js +1848 -0
  20. package/dist/theme-editor.cjs +2282 -90
  21. package/dist/theme-editor.d.cts +348 -1
  22. package/dist/theme-editor.d.ts +348 -1
  23. package/dist/theme-editor.js +2267 -90
  24. package/package.json +9 -2
  25. package/src/client.test.ts +165 -0
  26. package/src/client.ts +144 -23
  27. package/src/components/composer-parts.test.ts +34 -0
  28. package/src/components/composer-parts.ts +9 -6
  29. package/src/index.ts +26 -0
  30. package/src/session.test.ts +258 -0
  31. package/src/session.ts +886 -30
  32. package/src/session.webmcp.test.ts +815 -0
  33. package/src/smart-dom-reader.test.ts +135 -0
  34. package/src/smart-dom-reader.ts +135 -0
  35. package/src/theme-editor/color-utils.test.ts +59 -0
  36. package/src/theme-editor/color-utils.ts +38 -2
  37. package/src/theme-editor/index.ts +35 -0
  38. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  39. package/src/theme-editor/webmcp/coerce.ts +286 -0
  40. package/src/theme-editor/webmcp/index.ts +45 -0
  41. package/src/theme-editor/webmcp/summary.ts +324 -0
  42. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  43. package/src/theme-editor/webmcp/tools.ts +795 -0
  44. package/src/theme-editor/webmcp/types.ts +87 -0
  45. package/src/types.ts +186 -0
  46. package/src/ui.composer-keyboard.test.ts +229 -0
  47. package/src/ui.ts +127 -5
  48. package/src/utils/composer-history.test.ts +128 -0
  49. package/src/utils/composer-history.ts +113 -0
  50. package/src/utils/message-fingerprint.test.ts +20 -0
  51. package/src/utils/message-fingerprint.ts +2 -0
  52. package/src/utils/smart-dom-adapter.test.ts +257 -0
  53. package/src/utils/smart-dom-adapter.ts +217 -0
  54. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  55. package/src/vendor/smart-dom-reader/README.md +61 -0
  56. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  57. package/src/vendor/smart-dom-reader/index.js +1618 -0
  58. package/src/webmcp-bridge.test.ts +429 -0
  59. package/src/webmcp-bridge.ts +547 -0
package/src/session.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { AgentWidgetClient, type SSEEventCallback } from "./client";
2
+ import { isWebMcpToolName } from "./webmcp-bridge";
2
3
  import {
3
4
  AgentWidgetConfig,
4
5
  AgentWidgetEvent,
5
6
  AgentWidgetMessage,
6
7
  AgentWidgetApproval,
8
+ WebMcpConfirmInfo,
7
9
  AgentExecutionState,
8
10
  ClientSession,
9
11
  ContentPart,
@@ -49,6 +51,31 @@ type SessionCallbacks = {
49
51
  }) => void;
50
52
  };
51
53
 
54
+ /**
55
+ * Build the user-facing content shown when a dispatch fails before any
56
+ * assistant content streamed back. This fires on real network/server errors
57
+ * (connection refused, CORS, 4xx/5xx, malformed stream) — not just an
58
+ * un-wired proxy — so the copy stays honest about the failure and surfaces the
59
+ * underlying reason to help with debugging.
60
+ *
61
+ * Callers can override the copy via `config.errorMessage` (a static string or
62
+ * a function of the error). An override that returns an empty string yields ""
63
+ * here, which the caller treats as "suppress the fallback bubble".
64
+ */
65
+ function buildDispatchErrorContent(
66
+ error: unknown,
67
+ override?: AgentWidgetConfig["errorMessage"]
68
+ ): string {
69
+ const err = error instanceof Error ? error : new Error(String(error));
70
+
71
+ if (typeof override === "string") return override;
72
+ if (typeof override === "function") return override(err);
73
+
74
+ const base =
75
+ "Sorry — I couldn't reach the assistant. The chat service didn't respond. Please check that your proxy or backend is running and reachable, then try again.";
76
+ return err.message ? `${base}\n\n_Details: ${err.message}_` : base;
77
+ }
78
+
52
79
  export class AgentWidgetSession {
53
80
  private client: AgentWidgetClient;
54
81
  private messages: AgentWidgetMessage[];
@@ -66,6 +93,67 @@ export class AgentWidgetSession {
66
93
  private artifacts = new Map<string, PersonaArtifactRecord>();
67
94
  private selectedArtifactId: string | null = null;
68
95
 
96
+ // WebMCP dedupe — keys are `${executionId}:${toolCallId}` so they're
97
+ // naturally scoped to a single dispatch. A later dispatch (new executionId)
98
+ // that happens to recycle a `toolCall.id` never collides with prior entries,
99
+ // and a stale re-emit from an in-flight prior dispatch stays blocked because
100
+ // its executionId is still in the set.
101
+ //
102
+ // webMcpInflightKeys — currently executing; cleared on completion of
103
+ // EITHER /resume success OR /resume throw. Blocks
104
+ // concurrent re-fire during the resolve round-trip.
105
+ // webMcpResolvedKeys — /resume HTTP returned 2xx; not cleared on a new
106
+ // dispatch (executionId scoping makes that
107
+ // unnecessary). Blocks stale step_await re-emits
108
+ // for the same execution.
109
+ //
110
+ // If `/resume` throws (network error, server 5xx), we DO want a retry path:
111
+ // the dispatch is recoverable. Such a tool stays in neither set, so a
112
+ // subsequent re-emit will re-trigger.
113
+ private webMcpInflightKeys: Set<string> = new Set();
114
+ private webMcpResolvedKeys: Set<string> = new Set();
115
+ // Per-resolve AbortControllers, kept in a set so multiple `webmcp:*`
116
+ // step_await resolves in one turn never abort one another. The shared
117
+ // `this.abortController` is intentionally NOT used by resolveWebMcpToolCall:
118
+ // in a CHAINED turn (tool A → /resume → tool B, where the server emits B's
119
+ // step_await inside A's resume SSE stream) the shared controller is still
120
+ // piping A's resume stream — the very stream that just delivered B. Aborting
121
+ // it mid-chain (the prior shared-controller pre-abort) tore that stream down,
122
+ // so B never reached execute() and its /resume was never POSTed, pausing the
123
+ // dispatch forever. cancel(), clearMessages(), hydrateMessages(), and
124
+ // sendMessage() iterate this set to tear every in-flight resolve down on a
125
+ // real stop / new turn.
126
+ private webMcpResolveControllers: Set<AbortController> = new Set();
127
+ // Bumped on every teardown / new-turn boundary (cancel, clearMessages,
128
+ // hydrateMessages, sendMessage). A resolveWebMcpToolCall deferred via
129
+ // queueMicrotask captures the epoch at queue time and bails if it changed,
130
+ // so a resolve queued just before a teardown can't escape it by installing a
131
+ // fresh controller after the set was already cleared.
132
+ private webMcpEpoch = 0;
133
+ // WebMCP native approval-bubble gate. When no custom `webmcp.onConfirm` is
134
+ // supplied, the bridge's confirm handler routes here: we inject an
135
+ // approval-variant message and park the bridge on a Promise that resolves
136
+ // when the user clicks Approve/Deny (see requestWebMcpApproval /
137
+ // resolveWebMcpApproval). Resolvers are keyed by the approval message id.
138
+ private webMcpApprovalResolvers: Map<string, (approved: boolean) => void> =
139
+ new Map();
140
+ private webMcpApprovalSeq = 0;
141
+ // Parallel local-tool batching (core#3878). A single model turn can emit
142
+ // multiple `step_await(local_tool_required)` events for ONE paused
143
+ // executionId — including two PARALLEL calls to the SAME tool ("add SHOE-001
144
+ // and SHOE-007"). Those collapse to an identical `toolId`/`index` and differ
145
+ // only by the per-call `webMcpToolCallId`. We collect all awaits for an
146
+ // executionId that arrive in the same stream tick, then post ONE `/resume`
147
+ // keyed by `webMcpToolCallId` — NOT one `/resume` per tool keyed by name
148
+ // (which collides for same-tool calls, and whose concurrent posts on one
149
+ // execution raced → the second 404'd → the turn hung). Keyed by executionId;
150
+ // `seen` dedupes duplicate step_await re-emits within a batch. Cleared on
151
+ // every teardown via `abortWebMcpResolves`.
152
+ private webMcpAwaitBatches: Map<
153
+ string,
154
+ { snapshots: AgentWidgetMessage[]; seen: Set<string> }
155
+ > = new Map();
156
+
69
157
  // Voice support
70
158
  private voiceProvider: VoiceProvider | null = null;
71
159
  private voiceActive = false;
@@ -81,6 +169,7 @@ export class AgentWidgetSession {
81
169
  }));
82
170
  this.messages = this.sortMessages(this.messages);
83
171
  this.client = new AgentWidgetClient(config);
172
+ this.wireDefaultWebMcpConfirm();
84
173
 
85
174
  // Hydrate artifacts from config (mirrors `initialMessages`). Restored
86
175
  // records are forced to `status: "complete"` — a mid-stream artifact should
@@ -511,9 +600,20 @@ export class AgentWidgetSession {
511
600
  }
512
601
 
513
602
  public updateConfig(next: AgentWidgetConfig) {
603
+ // Replacing the client invalidates every in-flight WebMCP resolve, buffered
604
+ // parallel-await batch, and pending approval bubble tied to the OLD client/
605
+ // session. Tear them down BEFORE the swap (the new client has no session
606
+ // yet) so a deferred batch flush or a parked confirm can't fire against the
607
+ // fresh client — in client-token mode that would POST /resume without a
608
+ // valid sessionId and strand the paused turn. Mirrors clearMessages' WebMCP
609
+ // reset; the client swap already abandons any in-flight stream regardless.
610
+ this.abortWebMcpResolves();
611
+ this.webMcpInflightKeys.clear();
612
+ this.webMcpResolvedKeys.clear();
514
613
  const prevSSECallback = this.client.getSSEEventCallback();
515
614
  this.config = { ...this.config, ...next };
516
615
  this.client = new AgentWidgetClient(this.config);
616
+ this.wireDefaultWebMcpConfirm();
517
617
  if (prevSSECallback) {
518
618
  this.client.setSSEEventCallback(prevSSECallback);
519
619
  }
@@ -773,6 +873,11 @@ export class AgentWidgetSession {
773
873
 
774
874
  this.stopSpeaking();
775
875
  this.abortController?.abort();
876
+ // A new user turn supersedes any in-flight WebMCP resolve from the prior
877
+ // turn. Tear them down here (they own controllers separate from the shared
878
+ // one) so a lingering resolve can't race the new dispatch or post a stale
879
+ // /resume against a superseded execution.
880
+ this.abortWebMcpResolves();
776
881
 
777
882
  // Generate IDs for both user message and expected assistant response
778
883
  const userMessageId = generateUserMessageId();
@@ -818,16 +923,23 @@ export class AgentWidgetSession {
818
923
  error.message.includes('abort'));
819
924
 
820
925
  if (!isAbortError) {
821
- const fallback: AgentWidgetMessage = {
822
- id: assistantMessageId, // Use the pre-generated ID for fallback too
823
- role: "assistant",
824
- createdAt: new Date().toISOString(),
825
- content:
826
- "It looks like the proxy isn't returning a real response yet. Here's a sample message so you can continue testing locally.",
827
- sequence: this.nextSequence()
828
- };
926
+ const content = buildDispatchErrorContent(
927
+ error,
928
+ this.config.errorMessage
929
+ );
930
+ // An override that returns "" suppresses the fallback bubble entirely
931
+ // (onError still fires below).
932
+ if (content) {
933
+ const fallback: AgentWidgetMessage = {
934
+ id: assistantMessageId, // Use the pre-generated ID for fallback too
935
+ role: "assistant",
936
+ createdAt: new Date().toISOString(),
937
+ content,
938
+ sequence: this.nextSequence()
939
+ };
829
940
 
830
- this.appendMessage(fallback);
941
+ this.appendMessage(fallback);
942
+ }
831
943
  }
832
944
 
833
945
  this.setStatus("idle");
@@ -881,23 +993,43 @@ export class AgentWidgetSession {
881
993
  this.handleEvent
882
994
  );
883
995
  } catch (error) {
884
- const fallback: AgentWidgetMessage = {
885
- id: assistantMessageId,
886
- role: "assistant",
887
- createdAt: new Date().toISOString(),
888
- content:
889
- "It looks like the proxy isn't returning a real response yet. Here's a sample message so you can continue testing locally.",
890
- sequence: this.nextSequence()
891
- };
996
+ // Check if this is an abort error (a prior in-flight stream was canceled,
997
+ // the user navigated away, etc.). In these cases, don't show fallback or
998
+ // fire onError - the request was intentionally interrupted.
999
+ const isAbortError =
1000
+ error instanceof Error &&
1001
+ (error.name === 'AbortError' ||
1002
+ error.message.includes('aborted') ||
1003
+ error.message.includes('abort'));
892
1004
 
893
- this.appendMessage(fallback);
1005
+ if (!isAbortError) {
1006
+ const content = buildDispatchErrorContent(
1007
+ error,
1008
+ this.config.errorMessage
1009
+ );
1010
+ // An override that returns "" suppresses the fallback bubble entirely
1011
+ // (onError still fires below).
1012
+ if (content) {
1013
+ const fallback: AgentWidgetMessage = {
1014
+ id: assistantMessageId,
1015
+ role: "assistant",
1016
+ createdAt: new Date().toISOString(),
1017
+ content,
1018
+ sequence: this.nextSequence()
1019
+ };
1020
+
1021
+ this.appendMessage(fallback);
1022
+ }
1023
+ }
894
1024
  this.setStatus("idle");
895
1025
  this.setStreaming(false);
896
1026
  this.abortController = null;
897
- if (error instanceof Error) {
898
- this.callbacks.onError?.(error);
899
- } else {
900
- this.callbacks.onError?.(new Error(String(error)));
1027
+ if (!isAbortError) {
1028
+ if (error instanceof Error) {
1029
+ this.callbacks.onError?.(error);
1030
+ } else {
1031
+ this.callbacks.onError?.(new Error(String(error)));
1032
+ }
901
1033
  }
902
1034
  }
903
1035
  }
@@ -938,14 +1070,111 @@ export class AgentWidgetSession {
938
1070
  );
939
1071
  } catch (error) {
940
1072
  this.setStatus("error");
941
- this.setStreaming(false);
942
- this.abortController = null;
1073
+ // Mirror the idle/error handlers: a failed resume stream must not tear
1074
+ // down streaming/abortController while another WebMCP resolve is still
1075
+ // confirming or executing. The in-flight resolve's `finally` owns the
1076
+ // teardown once `webMcpResolveControllers` drains.
1077
+ if (this.webMcpResolveControllers.size === 0) {
1078
+ this.setStreaming(false);
1079
+ this.abortController = null;
1080
+ }
943
1081
  this.callbacks.onError?.(
944
1082
  error instanceof Error ? error : new Error(String(error))
945
1083
  );
946
1084
  }
947
1085
  }
948
1086
 
1087
+ /**
1088
+ * Install the native approval-bubble confirm handler on the WebMCP bridge
1089
+ * when the integrator hasn't supplied a custom `webmcp.onConfirm`. Without
1090
+ * this, the bridge falls back to a blunt `window.confirm`. Safe to call
1091
+ * repeatedly (e.g. after the client is re-created in `updateConfig`).
1092
+ */
1093
+ private wireDefaultWebMcpConfirm(): void {
1094
+ const webmcp = this.config.webmcp;
1095
+ if (webmcp?.enabled === true && !webmcp.onConfirm) {
1096
+ this.client.setWebMcpConfirmHandler((info) =>
1097
+ this.requestWebMcpApproval(info)
1098
+ );
1099
+ }
1100
+ }
1101
+
1102
+ /**
1103
+ * Default WebMCP confirm gate: render Persona's native in-panel approval
1104
+ * bubble and resolve when the user clicks Approve/Deny. Returns immediately
1105
+ * with `true` when `webmcp.autoApprove(info)` opts the tool out of the gate
1106
+ * (e.g. a read-only catalog search), so no bubble is shown. The bridge
1107
+ * awaits this Promise before executing the page tool.
1108
+ */
1109
+ public requestWebMcpApproval(info: WebMcpConfirmInfo): Promise<boolean> {
1110
+ // Per-tool policy hook — auto-allow opted-out tools without any UI. A
1111
+ // throwing predicate must not block the call, so fall through to an
1112
+ // explicit gate on error.
1113
+ try {
1114
+ if (this.config.webmcp?.autoApprove?.(info) === true) {
1115
+ return Promise.resolve(true);
1116
+ }
1117
+ } catch {
1118
+ // fall through to explicit approval
1119
+ }
1120
+
1121
+ const approval: AgentWidgetApproval = {
1122
+ id: `webmcp-${++this.webMcpApprovalSeq}`,
1123
+ status: "pending",
1124
+ agentId: "",
1125
+ executionId: "",
1126
+ toolName: info.toolName,
1127
+ toolType: "webmcp",
1128
+ description:
1129
+ info.description ?? `Allow the assistant to run ${info.toolName}?`,
1130
+ parameters: info.args,
1131
+ };
1132
+ const approvalMessageId = `approval-${approval.id}`;
1133
+
1134
+ this.upsertMessage({
1135
+ id: approvalMessageId,
1136
+ role: "assistant",
1137
+ content: "",
1138
+ createdAt: new Date().toISOString(),
1139
+ streaming: false,
1140
+ variant: "approval",
1141
+ approval,
1142
+ });
1143
+
1144
+ return new Promise<boolean>((resolve) => {
1145
+ this.webMcpApprovalResolvers.set(approvalMessageId, resolve);
1146
+ });
1147
+ }
1148
+
1149
+ /**
1150
+ * Resolve a pending WebMCP approval bubble (from the Approve/Deny click in
1151
+ * `ui.ts`). Updates the bubble to its resolved state and unblocks the
1152
+ * bridge Promise parked in `requestWebMcpApproval`. No-op if already
1153
+ * resolved (double-click, re-render).
1154
+ */
1155
+ public resolveWebMcpApproval(
1156
+ approvalMessageId: string,
1157
+ decision: "approved" | "denied"
1158
+ ): void {
1159
+ const resolve = this.webMcpApprovalResolvers.get(approvalMessageId);
1160
+ if (!resolve) return;
1161
+ this.webMcpApprovalResolvers.delete(approvalMessageId);
1162
+
1163
+ const existing = this.messages.find((m) => m.id === approvalMessageId);
1164
+ if (existing?.approval) {
1165
+ this.upsertMessage({
1166
+ ...existing,
1167
+ approval: {
1168
+ ...existing.approval,
1169
+ status: decision,
1170
+ resolvedAt: Date.now(),
1171
+ },
1172
+ });
1173
+ }
1174
+
1175
+ resolve(decision === "approved");
1176
+ }
1177
+
949
1178
  /**
950
1179
  * Resolve a tool approval request (approve or deny).
951
1180
  * Updates the approval message status, calls the API (or custom onDecision),
@@ -1280,9 +1509,532 @@ export class AgentWidgetSession {
1280
1509
  }
1281
1510
  }
1282
1511
 
1512
+ /**
1513
+ * Collect a `webmcp:*` LOCAL-tool `step_await` into a per-executionId batch
1514
+ * and schedule a single deferred flush. Parallel calls (core#3878) emit
1515
+ * several `step_await`s for ONE paused execution within the same stream tick;
1516
+ * buffering them and flushing once lets us post ONE `/resume` keyed by the
1517
+ * per-call `webMcpToolCallId` rather than racing N name-keyed resumes on the
1518
+ * same execution (which 404'd on the second and hung the turn).
1519
+ *
1520
+ * Deferred via `queueMicrotask` (epoch-guarded) for the same reason the old
1521
+ * direct resolve was: handleEvent must return first so the dispatch's
1522
+ * `connectStream` sees end-of-stream and releases the shared abortController
1523
+ * before a resolve grabs it.
1524
+ *
1525
+ * Awaits without an `executionId` or `toolCall.id` can't be batched (no key)
1526
+ * — route them straight to the single-call path, which surfaces the malformed
1527
+ * wire shape via `onError` / an `isError` resume.
1528
+ */
1529
+ private enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
1530
+ const executionId = toolMessage.agentMetadata?.executionId;
1531
+ const callId = toolMessage.toolCall?.id;
1532
+ if (!executionId || !callId) {
1533
+ const queuedEpoch = this.webMcpEpoch;
1534
+ queueMicrotask(() => {
1535
+ if (queuedEpoch !== this.webMcpEpoch) return;
1536
+ void this.resolveWebMcpToolCall(toolMessage);
1537
+ });
1538
+ return;
1539
+ }
1540
+
1541
+ let batch = this.webMcpAwaitBatches.get(executionId);
1542
+ if (!batch) {
1543
+ batch = { snapshots: [], seen: new Set() };
1544
+ this.webMcpAwaitBatches.set(executionId, batch);
1545
+ }
1546
+ // Duplicate step_await re-emit for a call already in this batch — ignore.
1547
+ if (batch.seen.has(callId)) return;
1548
+ batch.seen.add(callId);
1549
+ batch.snapshots.push(toolMessage);
1550
+ // NB: no flush is scheduled here. Flushing happens once the stream that is
1551
+ // delivering these awaits ENDS (handleEvent's `status: idle` →
1552
+ // scheduleWebMcpBatchFlush). Flushing per-await on the next microtask would
1553
+ // race SSE chunk boundaries: two PARALLEL step_awaits split across separate
1554
+ // `read()` chunks would flush the first alone and post a partial resume.
1555
+ // Waiting for stream end guarantees every parallel await is collected first.
1556
+ }
1557
+
1558
+ /**
1559
+ * Flush every buffered local-tool await batch, one `/resume` per executionId.
1560
+ * Called once a stream ends (`status: idle` / `error`) — by then all parallel
1561
+ * `step_await`s the stream carried have been collected, even if split across
1562
+ * SSE chunks. Deferred via `queueMicrotask` (epoch-guarded) so the idle
1563
+ * handler returns first and the stream's end-of-stream teardown (streaming /
1564
+ * abortController) settles before a resolve grabs them — the same ordering the
1565
+ * single-call resolve always relied on.
1566
+ */
1567
+ private scheduleWebMcpBatchFlush(): void {
1568
+ if (this.webMcpAwaitBatches.size === 0) return;
1569
+ const queuedEpoch = this.webMcpEpoch;
1570
+ queueMicrotask(() => {
1571
+ if (queuedEpoch !== this.webMcpEpoch) return;
1572
+ for (const executionId of [...this.webMcpAwaitBatches.keys()]) {
1573
+ this.flushWebMcpAwaitBatch(executionId);
1574
+ }
1575
+ });
1576
+ }
1577
+
1578
+ /**
1579
+ * Run a buffered batch of local-tool awaits for one executionId. Size 1
1580
+ * (single call, or distinct-tool turns that happened to arrive alone) takes
1581
+ * the original single-call path; size >1 (parallel calls) takes the batched
1582
+ * path that posts ONE `/resume`. The batch is removed from the map up front
1583
+ * so any later sibling re-emit (e.g. from a re-pause) forms a fresh batch
1584
+ * rather than mutating one already in flight.
1585
+ */
1586
+ private flushWebMcpAwaitBatch(executionId: string): void {
1587
+ const batch = this.webMcpAwaitBatches.get(executionId);
1588
+ if (!batch) return;
1589
+ this.webMcpAwaitBatches.delete(executionId);
1590
+ const { snapshots } = batch;
1591
+ if (snapshots.length === 1) {
1592
+ void this.resolveWebMcpToolCall(snapshots[0]);
1593
+ } else if (snapshots.length > 1) {
1594
+ void this.resolveWebMcpToolCallBatch(executionId, snapshots);
1595
+ }
1596
+ }
1597
+
1598
+ /**
1599
+ * Resolve TWO OR MORE parallel local-tool awaits sharing one paused
1600
+ * executionId with a SINGLE `/resume` (core#3878). Each call is executed
1601
+ * against the page registry concurrently — every gated call renders its own
1602
+ * native approval bubble, and a sibling's confirm Promise never blocks
1603
+ * another's execution. Outputs are keyed by per-call `webMcpToolCallId`
1604
+ * (server prefers it over tool name; name-keying remains the fallback for
1605
+ * legacy single/distinct-tool turns), so two calls to the SAME tool no longer
1606
+ * collide. The server is tolerant: any call we omit (declined-after-abort,
1607
+ * dedupe, exec failure) simply re-pauses and is retried on its re-emit.
1608
+ *
1609
+ * Mirrors `resolveWebMcpToolCall`'s dedupe / abort / streaming machinery, but
1610
+ * shares one resume POST and marks every resolved key on that POST's HTTP OK.
1611
+ */
1612
+ private async resolveWebMcpToolCallBatch(
1613
+ executionId: string,
1614
+ snapshots: AgentWidgetMessage[],
1615
+ ): Promise<void> {
1616
+ const claimedKeys: string[] = [];
1617
+ const controllers: AbortController[] = [];
1618
+ // Dedicated controller for the shared resume fetch so cancel() can abort it
1619
+ // alongside the per-call ones (all live in webMcpResolveControllers).
1620
+ const resumeController = new AbortController();
1621
+ this.webMcpResolveControllers.add(resumeController);
1622
+ this.setStreaming(true);
1623
+
1624
+ // Phase 1 — execute every pending call concurrently. A null result means
1625
+ // the call was deduped, aborted, or threw; it's omitted from the resume and
1626
+ // (per the tolerant server) re-pauses for retry.
1627
+ const executed = await Promise.all(
1628
+ snapshots.map(async (toolMessage) => {
1629
+ const wireToolName = toolMessage.toolCall?.name;
1630
+ const callId = toolMessage.toolCall?.id;
1631
+ if (!wireToolName || !callId) return null;
1632
+
1633
+ const dedupeKey = `${executionId}:${callId}`;
1634
+ if (
1635
+ this.webMcpInflightKeys.has(dedupeKey) ||
1636
+ this.webMcpResolvedKeys.has(dedupeKey)
1637
+ ) {
1638
+ return null;
1639
+ }
1640
+ this.webMcpInflightKeys.add(dedupeKey);
1641
+ claimedKeys.push(dedupeKey);
1642
+
1643
+ // Clear the awaiting flag so the local-tool UI doesn't linger.
1644
+ this.upsertMessage({
1645
+ ...toolMessage,
1646
+ agentMetadata: {
1647
+ ...toolMessage.agentMetadata,
1648
+ awaitingLocalTool: false,
1649
+ },
1650
+ });
1651
+
1652
+ const controller = new AbortController();
1653
+ this.webMcpResolveControllers.add(controller);
1654
+ controllers.push(controller);
1655
+
1656
+ // Per-call id wins for resume keying; fall back to the wire tool name
1657
+ // for legacy servers that don't emit `webMcpToolCallId`.
1658
+ const resumeKey =
1659
+ toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
1660
+
1661
+ const execPromise = this.client.executeWebMcpToolCall(
1662
+ wireToolName,
1663
+ toolMessage.toolCall?.args,
1664
+ controller.signal,
1665
+ );
1666
+
1667
+ let output: unknown;
1668
+ if (!execPromise) {
1669
+ output = {
1670
+ isError: true,
1671
+ content: [
1672
+ { type: "text", text: "WebMCP not enabled on this widget." },
1673
+ ],
1674
+ };
1675
+ } else {
1676
+ try {
1677
+ output = await execPromise;
1678
+ } catch (error) {
1679
+ const isAbortError =
1680
+ error instanceof Error &&
1681
+ (error.name === "AbortError" ||
1682
+ error.message.includes("aborted") ||
1683
+ error.message.includes("abort"));
1684
+ if (!isAbortError) {
1685
+ this.callbacks.onError?.(
1686
+ error instanceof Error ? error : new Error(String(error)),
1687
+ );
1688
+ }
1689
+ // Release the dedupe claim so a re-emit can retry this call.
1690
+ this.webMcpInflightKeys.delete(dedupeKey);
1691
+ return null;
1692
+ }
1693
+ }
1694
+ if (controller.signal.aborted) {
1695
+ this.webMcpInflightKeys.delete(dedupeKey);
1696
+ return null;
1697
+ }
1698
+ return { dedupeKey, resumeKey, output };
1699
+ }),
1700
+ );
1701
+
1702
+ try {
1703
+ const ready = executed.filter(
1704
+ (r): r is { dedupeKey: string; resumeKey: string; output: unknown } =>
1705
+ r !== null,
1706
+ );
1707
+ // Everything deduped/aborted/failed — nothing to post.
1708
+ if (ready.length === 0) return;
1709
+
1710
+ const toolOutputs: Record<string, unknown> = {};
1711
+ for (const r of ready) {
1712
+ // Two omitted-on-collision safety: if two calls somehow resolve to the
1713
+ // same key (only possible on a legacy name fallback), last write wins —
1714
+ // the server re-pauses the unrepresented call for retry.
1715
+ toolOutputs[r.resumeKey] = r.output;
1716
+ }
1717
+
1718
+ const response = await this.client.resumeFlow(executionId, toolOutputs, {
1719
+ signal: resumeController.signal,
1720
+ });
1721
+ if (!response.ok) {
1722
+ const errorData = await response.json().catch(() => null);
1723
+ throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
1724
+ }
1725
+ // Server accepted the batch — mark every included call resolved so stale
1726
+ // re-emits don't re-execute the page tool.
1727
+ for (const r of ready) {
1728
+ this.webMcpResolvedKeys.add(r.dedupeKey);
1729
+ }
1730
+ if (response.body) {
1731
+ await this.connectStream(response.body, { allowReentry: true });
1732
+ }
1733
+ } catch (error) {
1734
+ const isAbortError =
1735
+ error instanceof Error &&
1736
+ (error.name === "AbortError" ||
1737
+ error.message.includes("aborted") ||
1738
+ error.message.includes("abort"));
1739
+ if (!isAbortError) {
1740
+ this.callbacks.onError?.(
1741
+ error instanceof Error ? error : new Error(String(error)),
1742
+ );
1743
+ }
1744
+ } finally {
1745
+ for (const key of claimedKeys) {
1746
+ this.webMcpInflightKeys.delete(key);
1747
+ }
1748
+ for (const controller of controllers) {
1749
+ this.webMcpResolveControllers.delete(controller);
1750
+ }
1751
+ this.webMcpResolveControllers.delete(resumeController);
1752
+ if (this.webMcpResolveControllers.size === 0 && !this.abortController) {
1753
+ this.setStreaming(false);
1754
+ }
1755
+ }
1756
+ }
1757
+
1758
+ /**
1759
+ * Resolve a paused `webmcp:*` LOCAL tool call by executing it against the
1760
+ * host page's tool registry and posting the result to `/resume`.
1761
+ *
1762
+ * Triggered automatically from `handleEvent` when a `step_await`-derived
1763
+ * message arrives with a `webmcp:` prefix — the user does not click a
1764
+ * pill; the bridge's confirm-bubble gate is the only interactive surface.
1765
+ *
1766
+ * Idempotent on the message's `toolCall.id`: re-emits of the same step_await
1767
+ * (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
1768
+ * modes — declined, timed out, throw, unknown tool — all resolve into a
1769
+ * `{ isError: true, content: [...] }` payload that resumes the dispatch
1770
+ * cleanly so the agent can recover.
1771
+ */
1772
+ public async resolveWebMcpToolCall(
1773
+ toolMessage: AgentWidgetMessage,
1774
+ ): Promise<void> {
1775
+ const executionId = toolMessage.agentMetadata?.executionId;
1776
+ const wireToolName = toolMessage.toolCall?.name;
1777
+ const toolCallId = toolMessage.toolCall?.id;
1778
+
1779
+ // Malformed step_await wire shapes shouldn't silently strand the
1780
+ // server-side dispatch. Three failure modes:
1781
+ // - no executionId: no /resume target exists; surface to the host
1782
+ // via onError so an operator can react. This is a server-side
1783
+ // wire-shape bug — Persona can't recover it from the client.
1784
+ // - no wireToolName: defensive guard — handleEvent only calls us
1785
+ // when tc.name is a `webmcp:` prefix, so this path indicates a
1786
+ // direct caller misuse. Silent return.
1787
+ // - no toolCallId: dedupe key falls apart, but the server can still
1788
+ // advance if we post an isError for the wireToolName. Do that
1789
+ // and bail before the dedupe path.
1790
+ if (!executionId) {
1791
+ this.callbacks.onError?.(
1792
+ new Error(
1793
+ "WebMCP step_await missing executionId — dispatch left paused.",
1794
+ ),
1795
+ );
1796
+ return;
1797
+ }
1798
+ if (!wireToolName) return;
1799
+ if (!toolCallId) {
1800
+ // No toolCall.id → no per-call dedupe key. Fall back to a synthetic
1801
+ // `(executionId):(wireToolName)` so identical malformed re-emits don't
1802
+ // re-POST /resume. Idempotent on duplicate bad payloads.
1803
+ const malformedKey = `${executionId}:__no_tool_id__:${wireToolName}`;
1804
+ if (
1805
+ this.webMcpInflightKeys.has(malformedKey) ||
1806
+ this.webMcpResolvedKeys.has(malformedKey)
1807
+ ) {
1808
+ return;
1809
+ }
1810
+ this.webMcpInflightKeys.add(malformedKey);
1811
+ try {
1812
+ await this.resumeWithToolOutput(executionId, wireToolName, {
1813
+ isError: true,
1814
+ content: [
1815
+ {
1816
+ type: "text",
1817
+ text: "WebMCP step_await missing toolCall.id — cannot execute the page tool.",
1818
+ },
1819
+ ],
1820
+ });
1821
+ this.webMcpResolvedKeys.add(malformedKey);
1822
+ } catch (error) {
1823
+ this.callbacks.onError?.(
1824
+ error instanceof Error ? error : new Error(String(error)),
1825
+ );
1826
+ } finally {
1827
+ this.webMcpInflightKeys.delete(malformedKey);
1828
+ }
1829
+ return;
1830
+ }
1831
+
1832
+ // Dedupe key scoped by executionId — see `webMcpInflightKeys` doc comment
1833
+ // for the failure-recovery + cross-dispatch rationale.
1834
+ const dedupeKey = `${executionId}:${toolCallId}`;
1835
+ if (
1836
+ this.webMcpInflightKeys.has(dedupeKey) ||
1837
+ this.webMcpResolvedKeys.has(dedupeKey)
1838
+ ) {
1839
+ return;
1840
+ }
1841
+ this.webMcpInflightKeys.add(dedupeKey);
1842
+
1843
+ // Mark resolved on the message so the UI's local-tool sheet (if any
1844
+ // generic one ever lands) does not show — this is a fully-automatic
1845
+ // tool from the user's perspective, modulo the confirm bubble.
1846
+ this.upsertMessage({
1847
+ ...toolMessage,
1848
+ agentMetadata: {
1849
+ ...toolMessage.agentMetadata,
1850
+ awaitingLocalTool: false,
1851
+ },
1852
+ });
1853
+
1854
+ // Per-resolve AbortController, NOT the shared `this.abortController`.
1855
+ // A single turn can produce multiple `webmcp:*` step_await messages —
1856
+ // both PARALLEL (two awaits in one stream) and, more commonly, CHAINED
1857
+ // (tool A → /resume → tool B, where B's step_await arrives inside A's
1858
+ // resume SSE stream). The old code pre-aborted `this.abortController`
1859
+ // here to mirror the sibling resolve paths; in the chained case that
1860
+ // aborted the stream still delivering B, so B never executed and its
1861
+ // /resume was never POSTed — the dispatch hung forever. Using a dedicated
1862
+ // per-resolve controller leaves the in-flight resume stream untouched.
1863
+ // cancel()/clearMessages()/hydrateMessages()/sendMessage() iterate
1864
+ // `webMcpResolveControllers` to tear these down on a real stop / new turn.
1865
+ const resolveController = new AbortController();
1866
+ this.webMcpResolveControllers.add(resolveController);
1867
+ const { signal } = resolveController;
1868
+ this.setStreaming(true);
1869
+
1870
+ const args = toolMessage.toolCall?.args;
1871
+ // Thread the signal INTO the bridge — short-circuits the confirm bubble
1872
+ // and the execute() race on cancel(), so a late confirm-approval after
1873
+ // cancel() cannot fire a host-page side effect with no matching /resume.
1874
+ const execPromise = this.client.executeWebMcpToolCall(
1875
+ wireToolName,
1876
+ args,
1877
+ signal,
1878
+ );
1879
+
1880
+ try {
1881
+ let resumeOutput: unknown;
1882
+ if (!execPromise) {
1883
+ // Client has no bridge (config.webmcp.enabled !== true). Resume with
1884
+ // an error so the dispatch can advance instead of hanging.
1885
+ resumeOutput = {
1886
+ isError: true,
1887
+ content: [
1888
+ { type: "text", text: "WebMCP not enabled on this widget." },
1889
+ ],
1890
+ };
1891
+ } else {
1892
+ resumeOutput = await execPromise;
1893
+ }
1894
+ // If cancel() fired during execute, the bridge returned an aborted
1895
+ // result — don't post it. The server's SSE has been torn down; a
1896
+ // /resume now would just produce an orphan dispatch on the server.
1897
+ // Streaming/teardown is handled by the shared `finally` below (gated on
1898
+ // the resolve set) so we don't clobber a sibling resolve or a live
1899
+ // dispatch's controller here.
1900
+ if (signal.aborted) {
1901
+ return;
1902
+ }
1903
+ // Mark resolved as soon as the HTTP /resume returns OK — not after the
1904
+ // SSE stream finishes. `connectStream` swallows downstream SSE errors
1905
+ // (they surface via onError, not by rethrowing), so awaiting it doesn't
1906
+ // tell us whether the server actually processed the resume. Marking
1907
+ // here pairs with the dedupe semantics: a successful POST means the
1908
+ // server got the answer; later step_await re-emits for the same
1909
+ // toolCall.id are stale and must not re-execute the page tool.
1910
+ // Key the resume by the per-call id (core#3878) when present; the server
1911
+ // prefers it over tool name. Falls back to the wire tool name for legacy
1912
+ // servers — the original name-keyed contract, still correct for a single
1913
+ // call (only same-tool PARALLEL calls could collide on the name).
1914
+ const resumeKey =
1915
+ toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
1916
+ await this.resumeWithToolOutput(executionId, resumeKey, resumeOutput, {
1917
+ onHttpOk: () => {
1918
+ this.webMcpResolvedKeys.add(dedupeKey);
1919
+ },
1920
+ signal,
1921
+ });
1922
+ } catch (error) {
1923
+ const isAbortError =
1924
+ error instanceof Error &&
1925
+ (error.name === "AbortError" ||
1926
+ error.message.includes("aborted") ||
1927
+ error.message.includes("abort"));
1928
+ // Streaming/teardown handled by the shared `finally` (gated on the
1929
+ // resolve set) — do NOT null the shared `this.abortController` here; it
1930
+ // may belong to a live dispatch or sibling resolve, not to us.
1931
+ if (!isAbortError) {
1932
+ // The bridge normalizes tool errors into result objects, so reaching
1933
+ // here means a network failure during `/resume` itself, OR a stream
1934
+ // hookup error. Surface to onError, but DO NOT mark resolved — a
1935
+ // later step_await re-emit should be allowed to retry the resume.
1936
+ this.callbacks.onError?.(
1937
+ error instanceof Error ? error : new Error(String(error)),
1938
+ );
1939
+ }
1940
+ } finally {
1941
+ this.webMcpInflightKeys.delete(dedupeKey);
1942
+ this.webMcpResolveControllers.delete(resolveController);
1943
+ // Only flip streaming off when this was the last in-flight resolve AND
1944
+ // no shared dispatch is live. Otherwise a finishing resolve would hide
1945
+ // the typing indicator while a sibling (parallel) or successor (chained)
1946
+ // resolve — or a live dispatch — is still running.
1947
+ if (this.webMcpResolveControllers.size === 0 && !this.abortController) {
1948
+ this.setStreaming(false);
1949
+ }
1950
+ }
1951
+ }
1952
+
1953
+ /**
1954
+ * POST `/resume` with a SINGLE tool's output and pipe the resulting SSE
1955
+ * stream back through `connectStream`. Shared by every single-call local-tool
1956
+ * resolve path (ask_user_question and single WebMCP calls). Parallel WebMCP
1957
+ * calls use `resolveWebMcpToolCallBatch`, which posts one resume for many.
1958
+ *
1959
+ * `resumeKey` is the `toolOutputs` map key: the per-call `webMcpToolCallId`
1960
+ * for WebMCP (core#3878), or the tool name for ask_user_question / legacy
1961
+ * servers. `onHttpOk` runs synchronously between the HTTP-status check and the
1962
+ * stream pipe; it lets the WebMCP resolve path commit the dedupe flag at
1963
+ * "server accepted the answer" rather than "stream finished cleanly".
1964
+ */
1965
+ private async resumeWithToolOutput(
1966
+ executionId: string,
1967
+ resumeKey: string,
1968
+ output: unknown,
1969
+ options?: { onHttpOk?: () => void; signal?: AbortSignal },
1970
+ ): Promise<void> {
1971
+ const response = await this.client.resumeFlow(
1972
+ executionId,
1973
+ { [resumeKey]: output },
1974
+ { signal: options?.signal },
1975
+ );
1976
+ if (!response.ok) {
1977
+ const errorData = await response.json().catch(() => null);
1978
+ throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
1979
+ }
1980
+ options?.onHttpOk?.();
1981
+ if (response.body) {
1982
+ await this.connectStream(response.body, { allowReentry: true });
1983
+ } else if (this.webMcpResolveControllers.size === 0) {
1984
+ // No stream to pipe. Clear streaming only when no WebMCP resolve is in
1985
+ // flight — for a WebMCP caller the current resolve's controller is still
1986
+ // in the set, so its own `finally` (gated on the set draining) owns the
1987
+ // teardown. Non-WebMCP callers (ask_user_question) keep the old behavior.
1988
+ this.setStreaming(false);
1989
+ this.abortController = null;
1990
+ }
1991
+ }
1992
+
1993
+ /**
1994
+ * Tear down every in-flight WebMCP resolve and advance the epoch. Each
1995
+ * resolve owns a dedicated AbortController (chained/parallel resolves don't
1996
+ * share one), so we abort them individually; the aborts propagate into the
1997
+ * bridge's execute race and into each `/resume` fetch signal. Bumping
1998
+ * `webMcpEpoch` strands any resolve still deferred in a queued microtask —
1999
+ * it captured the prior epoch and bails before installing a fresh
2000
+ * controller, so it can't escape this teardown. Called from every stop /
2001
+ * new-turn boundary (cancel, clearMessages, hydrateMessages, sendMessage).
2002
+ */
2003
+ private abortWebMcpResolves(): void {
2004
+ for (const controller of this.webMcpResolveControllers) {
2005
+ controller.abort();
2006
+ }
2007
+ this.webMcpResolveControllers.clear();
2008
+ // Settle every approval bubble still awaiting a click. The bridge parks a
2009
+ // resolve on `await requestConfirm(...)` (→ requestWebMcpApproval) and only
2010
+ // re-checks `signal.aborted` AFTER that await returns — aborting the
2011
+ // controller above does NOT unblock it. Left unsettled, the bridge's
2012
+ // execute(), its `/resume`, and the resolve's `finally` would all hang
2013
+ // forever (and the resolver map would leak across teardowns). Route through
2014
+ // `resolveWebMcpApproval(…, "denied")` so each parked Promise resolves
2015
+ // `false` AND its bubble message flips out of `pending` (no stale "Approve/
2016
+ // Deny" left clickable). The bridge then returns cleanly and its
2017
+ // post-confirm `signal.aborted` guard bails before any host-page side effect
2018
+ // or stale `/resume`. Snapshot the keys first — resolveWebMcpApproval
2019
+ // mutates the map as it deletes each resolver.
2020
+ for (const approvalMessageId of [...this.webMcpApprovalResolvers.keys()]) {
2021
+ this.resolveWebMcpApproval(approvalMessageId, "denied");
2022
+ }
2023
+ // Drop any awaits buffered for a not-yet-flushed batch — their messages are
2024
+ // being torn down, and a microtask-deferred flush must not survive. The
2025
+ // epoch bump below also strands an already-scheduled flush.
2026
+ this.webMcpAwaitBatches.clear();
2027
+ this.webMcpEpoch++;
2028
+ }
2029
+
1283
2030
  public cancel() {
1284
2031
  this.abortController?.abort();
1285
2032
  this.abortController = null;
2033
+ // Tear down every in-flight WebMCP resolve (each owns its own controller,
2034
+ // independent of the shared one above). Clear the inflight set so retries
2035
+ // are possible if the user re-issues the same step_await context.
2036
+ this.abortWebMcpResolves();
2037
+ this.webMcpInflightKeys.clear();
1286
2038
  // Stop any in-progress audio too — when the user hits "stop", they want
1287
2039
  // the assistant to actually stop talking, not just stop generating tokens.
1288
2040
  // Both helpers are safe no-ops when audio isn't configured.
@@ -1296,9 +2048,17 @@ export class AgentWidgetSession {
1296
2048
  this.stopSpeaking();
1297
2049
  this.abortController?.abort();
1298
2050
  this.abortController = null;
2051
+ // Tear down every in-flight WebMCP resolve too — their messages are about
2052
+ // to be wiped, and a microtask-deferred resolve must not survive the clear.
2053
+ this.abortWebMcpResolves();
1299
2054
  this.messages = [];
1300
2055
  this.agentExecution = null;
1301
2056
  this.clearArtifactState();
2057
+ // Clearing messages also wipes the WebMCP dedupe state — a fresh
2058
+ // conversation should not refuse to call a webmcp:* tool just because
2059
+ // a tool with the same key resolved in the prior conversation.
2060
+ this.webMcpInflightKeys.clear();
2061
+ this.webMcpResolvedKeys.clear();
1302
2062
  this.setStreaming(false);
1303
2063
  this.setStatus("idle");
1304
2064
  this.callbacks.onMessagesChanged([...this.messages]);
@@ -1423,6 +2183,13 @@ export class AgentWidgetSession {
1423
2183
  public hydrateMessages(messages: AgentWidgetMessage[]) {
1424
2184
  this.abortController?.abort();
1425
2185
  this.abortController = null;
2186
+ // Hydration replaces the conversation — abort and forget every in-flight
2187
+ // WebMCP resolve; their messages are about to be replaced.
2188
+ this.abortWebMcpResolves();
2189
+ // Wipe the WebMCP dedupe state alongside the message restore — the
2190
+ // incoming snapshot is treated as a fresh conversation context.
2191
+ this.webMcpInflightKeys.clear();
2192
+ this.webMcpResolvedKeys.clear();
1426
2193
  this.messages = this.sortMessages(
1427
2194
  messages.map((message) => ({
1428
2195
  ...message,
@@ -1451,6 +2218,36 @@ export class AgentWidgetSession {
1451
2218
  if (event.type === "message") {
1452
2219
  this.upsertMessage(event.message);
1453
2220
 
2221
+ // WebMCP auto-resolve: when a step_await emits a tool-variant message
2222
+ // for a `webmcp:*` tool, drive the bridge to execute it and post the
2223
+ // result to /resume. Unlike ask_user_question, no user pill click is
2224
+ // required — the bridge's confirm bubble is the only interactive surface.
2225
+ //
2226
+ // Defer via `queueMicrotask` so handleEvent returns FIRST. The current
2227
+ // SSE consumer is still mid-loop; once we return, the dispatch's
2228
+ // `connectStream` sees end-of-stream (server closes the SSE at
2229
+ // step_await), flips status to "idle", and clears `abortController`
2230
+ // before our resolve grabs them. Without this, the original dispatch's
2231
+ // finalizer would clobber the new abort controller and `streaming=true`
2232
+ // set inside `resolveWebMcpToolCall`.
2233
+ //
2234
+ // ALWAYS resolve when the wire name carries the `webmcp:` prefix, even
2235
+ // if the bridge is non-operational. Otherwise the dispatch stays paused
2236
+ // indefinitely — `resolveWebMcpToolCall` translates the missing-bridge
2237
+ // case into an isError result that resumes the flow cleanly.
2238
+ const tc = event.message.toolCall;
2239
+ if (
2240
+ event.message.agentMetadata?.awaitingLocalTool === true &&
2241
+ tc?.name &&
2242
+ isWebMcpToolName(tc.name)
2243
+ ) {
2244
+ // Collect the await into its executionId's batch instead of resolving
2245
+ // it on the spot. Parallel same-tool calls (core#3878) arrive as
2246
+ // separate `step_await`s in the same stream; batching lets us post ONE
2247
+ // `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
2248
+ this.enqueueWebMcpAwait(event.message);
2249
+ }
2250
+
1454
2251
  // Track agent execution state from message metadata
1455
2252
  if (event.message.agentMetadata?.executionId) {
1456
2253
  if (!this.agentExecution) {
@@ -1471,17 +2268,49 @@ export class AgentWidgetSession {
1471
2268
  if (event.status === "connecting") {
1472
2269
  this.setStreaming(true);
1473
2270
  } else if (event.status === "idle" || event.status === "error") {
1474
- this.setStreaming(false);
1475
- this.abortController = null;
1476
- // Mark agent execution as complete when streaming ends
2271
+ // Keep the typing indicator up while a WebMCP resolve is still in
2272
+ // flight: in a chained turn the intermediate resume stream ends with an
2273
+ // idle status, but the successor tool is still executing. The resolve's
2274
+ // own `finally` flips streaming off once the resolve set drains.
2275
+ if (this.webMcpResolveControllers.size === 0) {
2276
+ this.setStreaming(false);
2277
+ this.abortController = null;
2278
+ }
2279
+ // Mark agent execution as complete when streaming ends — UNLESS local
2280
+ // tools are still outstanding. A batched WebMCP resume is deferred to
2281
+ // the microtask below (so `webMcpResolveControllers` is still empty
2282
+ // here) and a chained resolve may be mid-flight; marking the run
2283
+ // 'complete' now would make isAgentRunning() report a finished run while
2284
+ // page tools are still executing. Stay 'running' — the resume stream's
2285
+ // own idle (with batches drained and resolves settled) marks it done.
2286
+ const webMcpPending =
2287
+ this.webMcpAwaitBatches.size > 0 ||
2288
+ this.webMcpResolveControllers.size > 0;
1477
2289
  if (this.agentExecution?.status === 'running') {
1478
- this.agentExecution.status = event.status === "error" ? 'error' : 'complete';
2290
+ if (event.status === "error") {
2291
+ this.agentExecution.status = 'error';
2292
+ } else if (!webMcpPending) {
2293
+ this.agentExecution.status = 'complete';
2294
+ }
1479
2295
  }
2296
+ // The stream that delivered any local-tool `step_await`s has now ended,
2297
+ // so every parallel await it carried is collected. Flush them as ONE
2298
+ // batched `/resume` per executionId (deferred — see
2299
+ // scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
2300
+ // doesn't fight the end-of-stream streaming/abortController reset.
2301
+ this.scheduleWebMcpBatchFlush();
1480
2302
  }
1481
2303
  } else if (event.type === "error") {
1482
2304
  this.setStatus("error");
1483
- this.setStreaming(false);
1484
- this.abortController = null;
2305
+ // Mirror the idle/status handler: don't tear down streaming while a
2306
+ // WebMCP resolve is still confirming/executing on another stream — an
2307
+ // error on one chained resume stream must not hide the typing indicator
2308
+ // (or null a controller) for a sibling/successor resolve still in flight.
2309
+ // The resolve's own `finally` flips streaming off once the set drains.
2310
+ if (this.webMcpResolveControllers.size === 0) {
2311
+ this.setStreaming(false);
2312
+ this.abortController = null;
2313
+ }
1485
2314
  if (this.agentExecution?.status === 'running') {
1486
2315
  this.agentExecution.status = 'error';
1487
2316
  }
@@ -1668,6 +2497,33 @@ export class AgentWidgetSession {
1668
2497
  awaitingLocalTool: false,
1669
2498
  };
1670
2499
  }
2500
+ // WebMCP equivalent: once a `webmcp:*` tool has started resolving
2501
+ // (inflight) or resolved, a duplicate `step_await` re-emit must not
2502
+ // flip `awaitingLocalTool` back to true and resurrect the "waiting on
2503
+ // local tool" UI. resolveWebMcpToolCall's dedupe path returns without
2504
+ // re-touching the message, so correct the merge here (also avoids a
2505
+ // one-frame flash before that microtask runs).
2506
+ const reTcName = withSequence.toolCall?.name;
2507
+ const reExecId = withSequence.agentMetadata?.executionId;
2508
+ const reTcId = withSequence.toolCall?.id;
2509
+ if (
2510
+ reTcName &&
2511
+ isWebMcpToolName(reTcName) &&
2512
+ reExecId &&
2513
+ reTcId &&
2514
+ withSequence.agentMetadata?.awaitingLocalTool === true
2515
+ ) {
2516
+ const reKey = `${reExecId}:${reTcId}`;
2517
+ if (
2518
+ this.webMcpInflightKeys.has(reKey) ||
2519
+ this.webMcpResolvedKeys.has(reKey)
2520
+ ) {
2521
+ merged.agentMetadata = {
2522
+ ...(merged.agentMetadata ?? {}),
2523
+ awaitingLocalTool: false,
2524
+ };
2525
+ }
2526
+ }
1671
2527
  return merged;
1672
2528
  });
1673
2529
  this.messages = this.sortMessages(this.messages);