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