@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.
- package/README.md +67 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +50 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +98 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -41
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +1875 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +1848 -0
- package/dist/theme-editor.cjs +2282 -90
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +2267 -90
- package/package.json +9 -2
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/composer-parts.test.ts +34 -0
- package/src/components/composer-parts.ts +9 -6
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +127 -5
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- 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
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
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 (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
1476
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1484
|
-
|
|
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);
|