@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.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",
|
|
@@ -91,9 +97,10 @@
|
|
|
91
97
|
"access": "public"
|
|
92
98
|
},
|
|
93
99
|
"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",
|
|
100
|
+
"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",
|
|
95
101
|
"build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --dts --out-dir dist --no-splitting",
|
|
96
102
|
"build:testing": "tsup src/testing.ts --format esm,cjs --dts --out-dir dist --no-splitting",
|
|
103
|
+
"build:smart-dom-reader": "tsup src/smart-dom-reader.ts --format esm,cjs --dts --out-dir dist --no-splitting",
|
|
97
104
|
"build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --dts --out-dir dist/animations --no-splitting",
|
|
98
105
|
"build:theme-ref": "tsup src/theme-reference.ts --format esm,cjs --minify --dts",
|
|
99
106
|
"build:styles": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/styles/widget.css','dist/widget.css');\"",
|
package/src/client.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
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
|
|
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
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|
|
@@ -69,6 +69,40 @@ describe("createSendButton", () => {
|
|
|
69
69
|
send.setMode("send");
|
|
70
70
|
expect(send.button.textContent).toBe("Send");
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
describe("icon mode", () => {
|
|
74
|
+
const iconConfig: AgentWidgetConfig = {
|
|
75
|
+
...baseConfig,
|
|
76
|
+
sendButton: { useIcon: true, iconName: "send", stopIconName: "square" },
|
|
77
|
+
};
|
|
78
|
+
const iconCount = (btn: HTMLElement) => btn.querySelectorAll("svg").length;
|
|
79
|
+
|
|
80
|
+
it("keeps exactly one icon across a send→stop→send cycle", () => {
|
|
81
|
+
const send = createSendButton(iconConfig);
|
|
82
|
+
expect(iconCount(send.button)).toBe(1);
|
|
83
|
+
send.setMode("stop");
|
|
84
|
+
expect(iconCount(send.button)).toBe(1);
|
|
85
|
+
send.setMode("send");
|
|
86
|
+
expect(iconCount(send.button)).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not stack a stale icon when an external re-render swapped the live icon node", () => {
|
|
90
|
+
const send = createSendButton(iconConfig);
|
|
91
|
+
// Simulate a DOM morph/re-render (e.g. a host calling controller.update())
|
|
92
|
+
// that replaces the live icon child with a clone. This detaches the
|
|
93
|
+
// captured `sendIcon` reference, so `sendIcon.parentNode !== button`.
|
|
94
|
+
// The old replaceChild/appendChild fallback then left BOTH icons mounted,
|
|
95
|
+
// producing the doubled send-arrow after the first send→stop→send cycle.
|
|
96
|
+
const live = send.button.firstElementChild as SVGElement;
|
|
97
|
+
send.button.replaceChildren(live.cloneNode(true));
|
|
98
|
+
expect(iconCount(send.button)).toBe(1);
|
|
99
|
+
|
|
100
|
+
send.setMode("stop");
|
|
101
|
+
expect(iconCount(send.button)).toBe(1);
|
|
102
|
+
send.setMode("send");
|
|
103
|
+
expect(iconCount(send.button)).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
72
106
|
});
|
|
73
107
|
|
|
74
108
|
describe("createMicButton", () => {
|
|
@@ -218,12 +218,15 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
|
|
|
218
218
|
if (useIcon) {
|
|
219
219
|
if (sendIcon && stopIcon) {
|
|
220
220
|
const next = mode === "stop" ? stopIcon : sendIcon;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
221
|
+
// Replace whatever icon is currently mounted — the button only ever
|
|
222
|
+
// holds the single active icon. We use replaceChildren(next) rather
|
|
223
|
+
// than replaceChild(next, prev) against a captured `prev` reference:
|
|
224
|
+
// an external re-render/morph can swap the live icon child out from
|
|
225
|
+
// under us, detaching our captured node so `prev.parentNode !== button`.
|
|
226
|
+
// The old appendChild fallback then left BOTH icons mounted, which is
|
|
227
|
+
// how the send button ended up showing two stacked arrows after the
|
|
228
|
+
// first send→stop→send cycle.
|
|
229
|
+
button.replaceChildren(next);
|
|
227
230
|
}
|
|
228
231
|
} else {
|
|
229
232
|
button.textContent = mode === "stop" ? stopLabel : sendLabel;
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,9 @@ export type {
|
|
|
20
20
|
AgentWidgetStreamParser,
|
|
21
21
|
AgentWidgetStreamParserResult,
|
|
22
22
|
AgentWidgetRequestPayload,
|
|
23
|
+
// Context provider types (e.g. for config.contextProviders)
|
|
24
|
+
AgentWidgetContextProvider,
|
|
25
|
+
AgentWidgetContextProviderContext,
|
|
23
26
|
AgentWidgetCustomFetch,
|
|
24
27
|
AgentWidgetSSEEventParser,
|
|
25
28
|
AgentWidgetSSEEventResult,
|
|
@@ -78,6 +81,12 @@ export type {
|
|
|
78
81
|
// Approval types
|
|
79
82
|
AgentWidgetApproval,
|
|
80
83
|
AgentWidgetApprovalConfig,
|
|
84
|
+
// WebMCP — page-discovered tool consumption
|
|
85
|
+
AgentWidgetWebMcpConfig,
|
|
86
|
+
ClientToolDefinition,
|
|
87
|
+
WebMcpConfirmHandler,
|
|
88
|
+
WebMcpConfirmInfo,
|
|
89
|
+
WebMcpToolResult,
|
|
81
90
|
// Event stream types
|
|
82
91
|
SSEEventRecord,
|
|
83
92
|
EventStreamConfig,
|
|
@@ -121,6 +130,12 @@ export {
|
|
|
121
130
|
} from "./session";
|
|
122
131
|
export { AgentWidgetClient } from "./client";
|
|
123
132
|
export type { SSEEventCallback } from "./client";
|
|
133
|
+
export {
|
|
134
|
+
WebMcpBridge,
|
|
135
|
+
WEBMCP_TOOL_PREFIX,
|
|
136
|
+
isWebMcpToolName,
|
|
137
|
+
stripWebMcpPrefix
|
|
138
|
+
} from "./webmcp-bridge";
|
|
124
139
|
export { createLocalStorageAdapter } from "./utils/storage";
|
|
125
140
|
export {
|
|
126
141
|
createActionManager,
|
|
@@ -212,6 +227,17 @@ export type {
|
|
|
212
227
|
AgentWidgetStreamAnimationPlaceholder,
|
|
213
228
|
} from "./types";
|
|
214
229
|
|
|
230
|
+
// Action system types — needed to type the `actionHandlers` / `actionParsers`
|
|
231
|
+
// config options and to author custom handlers/parsers.
|
|
232
|
+
export type {
|
|
233
|
+
AgentWidgetActionHandler,
|
|
234
|
+
AgentWidgetActionHandlerResult,
|
|
235
|
+
AgentWidgetActionParser,
|
|
236
|
+
AgentWidgetParsedAction,
|
|
237
|
+
AgentWidgetActionContext,
|
|
238
|
+
AgentWidgetActionEventPayload,
|
|
239
|
+
} from "./types";
|
|
240
|
+
|
|
215
241
|
// Dropdown utility exports
|
|
216
242
|
export { createDropdownMenu } from "./utils/dropdown";
|
|
217
243
|
export type { DropdownMenuItem, CreateDropdownOptions, DropdownMenuHandle } from "./utils/dropdown";
|