@runtypelabs/persona 3.21.3 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.21.3",
3
+ "version": "3.23.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -27,6 +27,11 @@
27
27
  "import": "./dist/testing.js",
28
28
  "require": "./dist/testing.cjs"
29
29
  },
30
+ "./smart-dom-reader": {
31
+ "types": "./dist/smart-dom-reader.d.ts",
32
+ "import": "./dist/smart-dom-reader.js",
33
+ "require": "./dist/smart-dom-reader.cjs"
34
+ },
30
35
  "./animations/glyph-cycle": {
31
36
  "types": "./dist/animations/glyph-cycle.d.ts",
32
37
  "import": "./dist/animations/glyph-cycle.js",
@@ -44,6 +49,7 @@
44
49
  "src"
45
50
  ],
46
51
  "dependencies": {
52
+ "@mcp-b/webmcp-polyfill": "^3.0.0",
47
53
  "dompurify": "^3.3.3",
48
54
  "idiomorph": "^0.7.4",
49
55
  "lucide": "^0.552.0",
@@ -52,6 +58,7 @@
52
58
  "zod": "^3.22.4"
53
59
  },
54
60
  "devDependencies": {
61
+ "@size-limit/file": "^12.1.0",
55
62
  "@types/node": "^20.12.7",
56
63
  "@typescript-eslint/eslint-plugin": "^7.0.0",
57
64
  "@typescript-eslint/parser": "^7.0.0",
@@ -60,6 +67,7 @@
60
67
  "eslint-config-prettier": "^9.1.0",
61
68
  "fake-indexeddb": "^6.2.5",
62
69
  "rimraf": "^5.0.5",
70
+ "size-limit": "^12.1.0",
63
71
  "tsup": "^8.0.1",
64
72
  "typescript": "^5.4.5",
65
73
  "vitest": "^4.0.9"
@@ -91,10 +99,11 @@
91
99
  "access": "public"
92
100
  },
93
101
  "scripts": {
94
- "build": "rimraf dist && pnpm run build:styles && pnpm run build:client && pnpm run build:installer && pnpm run build:theme-ref && pnpm run build:theme-editor && pnpm run build:testing && pnpm run build:animations",
95
- "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --dts --out-dir dist --no-splitting",
96
- "build:testing": "tsup src/testing.ts --format esm,cjs --dts --out-dir dist --no-splitting",
97
- "build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --dts --out-dir dist/animations --no-splitting",
102
+ "build": "rimraf dist && pnpm run build:styles && pnpm run build:client && pnpm run build:installer && pnpm run build:theme-ref && pnpm run build:theme-editor && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:animations",
103
+ "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
104
+ "build:testing": "tsup src/testing.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
105
+ "build:smart-dom-reader": "tsup src/smart-dom-reader.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
106
+ "build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --minify --dts --out-dir dist/animations --no-splitting",
98
107
  "build:theme-ref": "tsup src/theme-reference.ts --format esm,cjs --minify --dts",
99
108
  "build:styles": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/styles/widget.css','dist/widget.css');\"",
100
109
  "build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" && tsup src/index-global.ts --format iife --global-name AgentWidget --minify --sourcemap --splitting false --out-dir dist --loader \".css=text\" && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\"",
@@ -103,6 +112,7 @@
103
112
  "typecheck": "tsc --noEmit",
104
113
  "test": "vitest",
105
114
  "test:ui": "vitest --ui",
106
- "test:run": "vitest run"
115
+ "test:run": "vitest run",
116
+ "size": "size-limit"
107
117
  }
108
118
  }
@@ -3083,6 +3083,92 @@ describe('AgentWidgetClient.resumeFlow', () => {
3083
3083
 
3084
3084
  expect(capturedUrl).toBe('http://localhost:43111/api/chat/dispatch/resume');
3085
3085
  });
3086
+
3087
+ it('routes to /v1/client/resume with sessionId in client-token mode (core#3889)', async () => {
3088
+ let capturedUrl: string | undefined;
3089
+ let capturedBody: Record<string, unknown> | undefined;
3090
+ let capturedHeaders: Record<string, string> | undefined;
3091
+ global.fetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
3092
+ capturedUrl = url;
3093
+ capturedBody = JSON.parse(init.body as string);
3094
+ capturedHeaders = init.headers as Record<string, string>;
3095
+ return { ok: true, body: null };
3096
+ });
3097
+
3098
+ const client = new AgentWidgetClient({
3099
+ clientToken: 'ct_live_demo',
3100
+ apiUrl: 'https://api.runtype-staging.com',
3101
+ });
3102
+ // Simulate an initialized client session (resumeFlow reads sessionId off it).
3103
+ (client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
3104
+ sessionId: 'cs_123',
3105
+ expiresAt: new Date(Date.now() + 60_000),
3106
+ };
3107
+
3108
+ await client.resumeFlow('exec_xyz', { toolu_A: { ok: true } });
3109
+
3110
+ // Session-authed sibling of /v1/client/chat — no Bearer key, sessionId in body.
3111
+ expect(capturedUrl).toBe('https://api.runtype-staging.com/v1/client/resume');
3112
+ expect(capturedBody).toEqual({
3113
+ executionId: 'exec_xyz',
3114
+ toolOutputs: { toolu_A: { ok: true } },
3115
+ streamResponse: true,
3116
+ sessionId: 'cs_123',
3117
+ });
3118
+ expect(capturedHeaders!['Authorization']).toBeUndefined();
3119
+ });
3120
+
3121
+ it('strips a trailing /v1/dispatch from apiUrl when building the client resume URL', async () => {
3122
+ let capturedUrl: string | undefined;
3123
+ global.fetch = vi.fn().mockImplementation(async (url: string) => {
3124
+ capturedUrl = url;
3125
+ return { ok: true, body: null };
3126
+ });
3127
+
3128
+ const client = new AgentWidgetClient({
3129
+ clientToken: 'ct_live_demo',
3130
+ apiUrl: 'https://api.runtype-staging.com/v1/dispatch',
3131
+ });
3132
+ // A live session so initSession() short-circuits instead of fetching /init.
3133
+ (client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
3134
+ sessionId: 'cs_123',
3135
+ expiresAt: new Date(Date.now() + 60_000),
3136
+ };
3137
+ await client.resumeFlow('exec_abc', {});
3138
+
3139
+ expect(capturedUrl).toBe('https://api.runtype-staging.com/v1/client/resume');
3140
+ });
3141
+
3142
+ it('refreshes the session via initSession() before resuming when stale (BugBot r3367875360)', async () => {
3143
+ let capturedBody: Record<string, unknown> | undefined;
3144
+ global.fetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
3145
+ capturedBody = JSON.parse(init.body as string);
3146
+ return { ok: true, body: null };
3147
+ });
3148
+
3149
+ const client = new AgentWidgetClient({
3150
+ clientToken: 'ct_live_demo',
3151
+ apiUrl: 'https://api.runtype-staging.com',
3152
+ });
3153
+ // A stale session that has already expired — a long WebMCP approval wait can
3154
+ // outlive it, so resumeFlow must not trust this.clientSession directly.
3155
+ (client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
3156
+ sessionId: 'cs_stale',
3157
+ expiresAt: new Date(Date.now() - 60_000),
3158
+ };
3159
+ // initSession() is the single source of truth for a live session (it returns
3160
+ // the existing one while unexpired, else re-inits). Assert resumeFlow awaits
3161
+ // it and sends the refreshed sessionId, not the stale one.
3162
+ const initSpy = vi
3163
+ .spyOn(client, 'initSession')
3164
+ .mockResolvedValue({ sessionId: 'cs_fresh', expiresAt: new Date(Date.now() + 60_000) } as never);
3165
+
3166
+ await client.resumeFlow('exec_xyz', { toolu_A: { ok: true } });
3167
+
3168
+ expect(initSpy).toHaveBeenCalledTimes(1);
3169
+ expect(capturedBody!.sessionId).toBe('cs_fresh');
3170
+ expect(capturedBody!.sessionId).not.toBe('cs_stale');
3171
+ });
3086
3172
  });
3087
3173
 
3088
3174
  // ============================================================================
@@ -3606,3 +3692,82 @@ describe('AgentWidgetClient - agent_media events', () => {
3606
3692
  });
3607
3693
  });
3608
3694
 
3695
+ describe('AgentWidgetClient - requestMiddleware preserves clientTools', () => {
3696
+ it('preserves clientTools when middleware returns a payload that omits it', async () => {
3697
+ // Iter-10 MED: naive middleware that rebuilds the payload by listing
3698
+ // only the fields it cares about used to drop the WebMCP clientTools
3699
+ // snapshot. Preserve them as a fallback when the middleware-returned
3700
+ // object doesn't mention clientTools at all.
3701
+ let capturedBody: string | null = null;
3702
+ global.fetch = vi.fn().mockImplementation(async (_url: string, options: { body: string }) => {
3703
+ capturedBody = options.body;
3704
+ const encoder = new TextEncoder();
3705
+ const stream = new ReadableStream({
3706
+ start(controller) {
3707
+ controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
3708
+ controller.close();
3709
+ },
3710
+ });
3711
+ return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } });
3712
+ });
3713
+ const client = new AgentWidgetClient({
3714
+ apiUrl: 'http://localhost:8000',
3715
+ requestMiddleware: ({ payload }) => ({
3716
+ // Naive middleware: rebuild without acknowledging clientTools.
3717
+ messages: payload.messages,
3718
+ }),
3719
+ });
3720
+ // Force a populated clientTools snapshot by stubbing the bridge spot.
3721
+ (client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null })
3722
+ .webMcpBridge = {
3723
+ snapshotForDispatch: () => [
3724
+ { name: 'search', description: 's', origin: 'webmcp' },
3725
+ ],
3726
+ };
3727
+ await client.dispatch(
3728
+ { messages: [{ id: 'u1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
3729
+ () => undefined,
3730
+ );
3731
+ expect(capturedBody).not.toBeNull();
3732
+ const parsed = JSON.parse(capturedBody!);
3733
+ expect(parsed.clientTools).toEqual([
3734
+ { name: 'search', description: 's', origin: 'webmcp' },
3735
+ ]);
3736
+ });
3737
+
3738
+ it("respects middleware that explicitly sets clientTools (even to undefined)", async () => {
3739
+ // The fallback only triggers when `clientTools` is entirely absent from
3740
+ // the middleware result. An integrator who sets `clientTools: undefined`
3741
+ // explicitly is opting out and must be respected.
3742
+ let capturedBody: string | null = null;
3743
+ global.fetch = vi.fn().mockImplementation(async (_url: string, options: { body: string }) => {
3744
+ capturedBody = options.body;
3745
+ const encoder = new TextEncoder();
3746
+ const stream = new ReadableStream({
3747
+ start(controller) {
3748
+ controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
3749
+ controller.close();
3750
+ },
3751
+ });
3752
+ return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } });
3753
+ });
3754
+ const client = new AgentWidgetClient({
3755
+ apiUrl: 'http://localhost:8000',
3756
+ requestMiddleware: ({ payload }) => ({
3757
+ messages: payload.messages,
3758
+ clientTools: undefined,
3759
+ }),
3760
+ });
3761
+ (client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null })
3762
+ .webMcpBridge = {
3763
+ snapshotForDispatch: () => [{ name: 'search', description: 's', origin: 'webmcp' }],
3764
+ };
3765
+ await client.dispatch(
3766
+ { messages: [{ id: 'u1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
3767
+ () => undefined,
3768
+ );
3769
+ const parsed = JSON.parse(capturedBody!);
3770
+ expect(parsed.clientTools).toBeUndefined();
3771
+ });
3772
+ });
3773
+
package/src/client.ts CHANGED
@@ -20,8 +20,10 @@ import {
20
20
  ClientFeedbackRequest,
21
21
  ClientFeedbackType,
22
22
  PersonaArtifactKind,
23
- ContentPart
23
+ ContentPart,
24
+ WebMcpConfirmHandler
24
25
  } from "./types";
26
+ import { WebMcpBridge } from "./webmcp-bridge";
25
27
  import {
26
28
  extractTextFromJson,
27
29
  createPlainTextParser,
@@ -169,6 +171,10 @@ export class AgentWidgetClient {
169
171
  private clientSession: ClientSession | null = null;
170
172
  private sessionInitPromise: Promise<ClientSession> | null = null;
171
173
 
174
+ // WebMCP — page-discovered tool consumption (see ./webmcp-bridge).
175
+ // Constructed lazily: null when `config.webmcp?.enabled !== true`.
176
+ private readonly webMcpBridge: WebMcpBridge | null;
177
+
172
178
  constructor(private config: AgentWidgetConfig = {}) {
173
179
  this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
174
180
  this.headers = {
@@ -183,6 +189,8 @@ export class AgentWidgetClient {
183
189
  this.customFetch = config.customFetch;
184
190
  this.parseSSEEvent = config.parseSSEEvent;
185
191
  this.getHeaders = config.getHeaders;
192
+ this.webMcpBridge =
193
+ config.webmcp?.enabled === true ? new WebMcpBridge(config.webmcp) : null;
186
194
  }
187
195
 
188
196
  /**
@@ -192,6 +200,44 @@ export class AgentWidgetClient {
192
200
  this.onSSEEvent = callback;
193
201
  }
194
202
 
203
+ /**
204
+ * WebMCP: wire (or replace) the confirm-bubble handler. Called from
205
+ * `ui.ts` once the widget panel is built and the approval-bubble
206
+ * chrome is ready to render.
207
+ */
208
+ public setWebMcpConfirmHandler(handler: WebMcpConfirmHandler | null): void {
209
+ this.webMcpBridge?.setConfirmHandler(handler);
210
+ }
211
+
212
+ /**
213
+ * WebMCP: `true` when the bridge installed the polyfill and can both
214
+ * snapshot the page registry and execute returned `webmcp:*` tool calls.
215
+ * `false` for any guard miss (no `document.modelContext`, polyfill not yet
216
+ * installed, or `config.webmcp.enabled` not set).
217
+ */
218
+ public isWebMcpOperational(): boolean {
219
+ return this.webMcpBridge?.isOperational() === true;
220
+ }
221
+
222
+ /**
223
+ * WebMCP: execute a returned `webmcp:<name>` tool call against the page's
224
+ * registry and return the normalized MCP-shaped result for `/resume`. The
225
+ * bridge handles confirm-bubble gating, the 30s timeout, error
226
+ * normalization, and `signal`-driven abort — callers never see throws.
227
+ *
228
+ * Returns `null` when WebMCP is not enabled on this client (signal to the
229
+ * session that it should fall back to the legacy local-tool resume path,
230
+ * if any).
231
+ */
232
+ public executeWebMcpToolCall(
233
+ wireToolName: string,
234
+ args: unknown,
235
+ signal?: AbortSignal,
236
+ ): Promise<import("./types").WebMcpToolResult> | null {
237
+ if (!this.webMcpBridge) return null;
238
+ return this.webMcpBridge.executeToolCall(wireToolName, args, signal);
239
+ }
240
+
195
241
  /**
196
242
  * Get the current SSE event callback (used to preserve across client recreation)
197
243
  */
@@ -216,11 +262,9 @@ export class AgentWidgetClient {
216
262
  /**
217
263
  * Get the appropriate API URL based on mode
218
264
  */
219
- private getClientApiUrl(endpoint: 'init' | 'chat'): string {
265
+ private getClientApiUrl(endpoint: 'init' | 'chat' | 'resume'): string {
220
266
  const baseUrl = this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
221
- return endpoint === 'init'
222
- ? `${baseUrl}/v1/client/init`
223
- : `${baseUrl}/v1/client/chat`;
267
+ return `${baseUrl}/v1/client/${endpoint}`;
224
268
  }
225
269
 
226
270
  /**
@@ -540,6 +584,9 @@ export class AgentWidgetClient {
540
584
  ...(sanitizedMetadata && Object.keys(sanitizedMetadata).length > 0 && { metadata: sanitizedMetadata }),
541
585
  ...(basePayload.inputs && Object.keys(basePayload.inputs).length > 0 && { inputs: basePayload.inputs }),
542
586
  ...(basePayload.context && { context: basePayload.context }),
587
+ // Forward per-turn WebMCP tools snapshotted by buildPayload(). The
588
+ // client-token chat endpoint accepts the same shape as /v1/dispatch.
589
+ ...(basePayload.clientTools && basePayload.clientTools.length > 0 && { clientTools: basePayload.clientTools }),
543
590
  };
544
591
 
545
592
  if (this.debug) {
@@ -811,24 +858,44 @@ export class AgentWidgetClient {
811
858
  * (client-executed) tools. Used by the built-in `ask_user_question`
812
859
  * answer-pill sheet, but generic enough for any LOCAL tool.
813
860
  *
814
- * Posts to the upstream `/resume` endpoint (the dispatch URL with
815
- * `/dispatch` replaced by `/resume` — works for both direct-to-Runtype
816
- * and the persona proxy) and returns the raw Response so the caller can
817
- * pipe its SSE body through `connectStream()`.
861
+ * Routes by mode:
862
+ * - **client-token mode**: POST `${apiBase}/v1/client/resume` (the
863
+ * session-authenticated sibling of `/v1/client/chat`; runtypelabs/core#3889),
864
+ * with the active `sessionId` in the body and no Bearer key — a browser
865
+ * client-token page holds no secret. `clientTools` are already persisted
866
+ * server-side from the dispatch turn, so only `toolOutputs` is re-sent.
867
+ * - **dispatch / proxy mode**: POST `${apiUrl}/resume` — Runtype mounts
868
+ * resume as a child of `/v1/dispatch`, so the URL is `${apiUrl}/resume`,
869
+ * and proxies follow the same shape (`/api/chat/dispatch/resume`).
870
+ *
871
+ * Returns the raw Response so the caller can pipe its SSE body through
872
+ * `connectStream()`.
818
873
  *
819
874
  * @param executionId - The paused execution id carried on `step_await`.
820
- * @param toolOutputs - Map keyed by tool name → the tool's result value.
875
+ * @param toolOutputs - Map keyed by per-call `toolCallId` (core#3878),
876
+ * falling back to tool name for legacy servers → the tool's result value.
821
877
  */
822
878
  public async resumeFlow(
823
879
  executionId: string,
824
880
  toolOutputs: Record<string, unknown>,
825
- options?: { streamResponse?: boolean }
881
+ options?: { streamResponse?: boolean; signal?: AbortSignal }
826
882
  ): Promise<Response> {
827
- const trimmed = this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE;
828
- // Runtype mounts POST /resume as a child route of /v1/dispatch, so the
829
- // final URL is always `${apiUrl}/resume`. Proxies should follow the
830
- // same shape (`/api/chat/dispatch/resume`) to keep the widget agnostic.
831
- const url = `${trimmed}/resume`;
883
+ const isClientToken = this.isClientTokenMode();
884
+ const url = isClientToken
885
+ ? this.getClientApiUrl('resume')
886
+ : `${this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE}/resume`;
887
+
888
+ // The client-token resume route authenticates the session, not a Bearer
889
+ // key. A WebMCP approval can sit awaiting user input for a long time, so by
890
+ // the time we resume the original session may have expired. Re-validate (and
891
+ // silently re-init if needed) via initSession() — which returns the live
892
+ // session when `new Date() < expiresAt`, else mints a fresh one — instead of
893
+ // trusting the possibly-stale `this.clientSession`. (core#3889; BugBot
894
+ // PR #214 r3367875360.)
895
+ let resumeSessionId: string | undefined;
896
+ if (isClientToken) {
897
+ resumeSessionId = (await this.initSession()).sessionId;
898
+ }
832
899
 
833
900
  let headers: Record<string, string> = {
834
901
  'Content-Type': 'application/json',
@@ -838,14 +905,21 @@ export class AgentWidgetClient {
838
905
  Object.assign(headers, await this.getHeaders());
839
906
  }
840
907
 
908
+ const body: Record<string, unknown> = {
909
+ executionId,
910
+ toolOutputs,
911
+ streamResponse: options?.streamResponse ?? true,
912
+ };
913
+ // Thread the (refreshed) sessionId through like `/v1/client/chat` does.
914
+ if (resumeSessionId) {
915
+ body.sessionId = resumeSessionId;
916
+ }
917
+
841
918
  return fetch(url, {
842
919
  method: 'POST',
843
920
  headers,
844
- body: JSON.stringify({
845
- executionId,
846
- toolOutputs,
847
- streamResponse: options?.streamResponse ?? true,
848
- }),
921
+ body: JSON.stringify(body),
922
+ signal: options?.signal,
849
923
  });
850
924
  }
851
925
 
@@ -883,6 +957,13 @@ export class AgentWidgetClient {
883
957
  }
884
958
  };
885
959
 
960
+ // WebMCP: snapshot the page tool registry per turn and ship as
961
+ // `clientTools[]`. The server merges these under the `webmcp:` namespace.
962
+ const webMcpSnapshot = await this.webMcpBridge?.snapshotForDispatch();
963
+ if (webMcpSnapshot && webMcpSnapshot.length > 0) {
964
+ payload.clientTools = webMcpSnapshot;
965
+ }
966
+
886
967
  // Add context from providers
887
968
  if (this.contextProviders.length) {
888
969
  const contextAggregate: Record<string, unknown> = {};
@@ -937,6 +1018,12 @@ export class AgentWidgetClient {
937
1018
  ...(this.config.flowId && { flowId: this.config.flowId })
938
1019
  };
939
1020
 
1021
+ // WebMCP: same per-turn snapshot as buildAgentPayload (flow-dispatch path).
1022
+ const webMcpSnapshot = await this.webMcpBridge?.snapshotForDispatch();
1023
+ if (webMcpSnapshot && webMcpSnapshot.length > 0) {
1024
+ payload.clientTools = webMcpSnapshot;
1025
+ }
1026
+
940
1027
  if (this.contextProviders.length) {
941
1028
  const contextAggregate: Record<string, unknown> = {};
942
1029
  await Promise.all(
@@ -970,7 +1057,22 @@ export class AgentWidgetClient {
970
1057
  config: this.config
971
1058
  });
972
1059
  if (result && typeof result === "object") {
973
- return result as AgentWidgetRequestPayload;
1060
+ const next = result as AgentWidgetRequestPayload;
1061
+ // Preserve `clientTools` if the middleware returned a fresh
1062
+ // payload object without it. Naive middlewares often rebuild
1063
+ // the payload by listing the fields they care about and
1064
+ // dropping `clientTools` accidentally; the WebMCP wire surface
1065
+ // is invisible to them. The integrator can still set
1066
+ // `clientTools: []` or `clientTools: undefined` explicitly to
1067
+ // strip them on purpose — we only fall back when the field is
1068
+ // entirely absent from the returned object.
1069
+ if (
1070
+ payload.clientTools !== undefined &&
1071
+ !("clientTools" in next)
1072
+ ) {
1073
+ next.clientTools = payload.clientTools;
1074
+ }
1075
+ return next;
974
1076
  }
975
1077
  } catch (error) {
976
1078
  if (typeof console !== "undefined") {
@@ -1811,7 +1913,22 @@ export class AgentWidgetClient {
1811
1913
  // ask_user_question bubble + sheet paths fire. Mark the message with
1812
1914
  // `awaitingLocalTool: true` so the UI knows to resolve via
1813
1915
  // resumeFlow rather than the legacy sendMessage fallback.
1814
- const toolId = (payload.toolId as string) ?? `local-${nextSequence()}`;
1916
+ //
1917
+ // Key the message by the per-call `toolCallId` (provider `toolu_…`;
1918
+ // core#3878) when present. Two PARALLEL calls to the SAME tool in one
1919
+ // turn collapse to an identical `toolId` (`runtime_webmcp:<name>_<ms>`)
1920
+ // and `index: 0` — only `toolCallId` distinguishes them. Keying on it
1921
+ // (a) keeps the two awaits as DISTINCT messages with their own args
1922
+ // instead of the second clobbering the first, and (b) merges each
1923
+ // await into the matching `tool_start` bubble (also keyed by
1924
+ // `toolCallId`). Fall back to the collapsed `toolId` for legacy
1925
+ // servers that don't emit `toolCallId`.
1926
+ const toolCallId: string | undefined =
1927
+ typeof payload.toolCallId === "string" && payload.toolCallId.length > 0
1928
+ ? (payload.toolCallId as string)
1929
+ : undefined;
1930
+ const toolId =
1931
+ toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
1815
1932
  const toolMessage = ensureToolMessage(toolId);
1816
1933
  const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
1817
1934
  tool.name = payload.toolName as string;
@@ -1827,6 +1944,10 @@ export class AgentWidgetClient {
1827
1944
  ...toolMessage.agentMetadata,
1828
1945
  executionId: (payload.executionId as string) ?? toolMessage.agentMetadata?.executionId,
1829
1946
  awaitingLocalTool: true,
1947
+ // Only set when the server emitted a real per-call id; its presence
1948
+ // is what tells session.ts to batch + key `/resume` by id rather
1949
+ // than by tool name (which can't represent two same-tool calls).
1950
+ ...(toolCallId ? { webMcpToolCallId: toolCallId } : {}),
1830
1951
  };
1831
1952
  emitMessage(toolMessage);
1832
1953
  } else if (payloadType === "text_start") {