@runtypelabs/persona 3.23.0 → 3.24.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/dist/index.cjs +34 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.global.js +48 -48
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +35 -35
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +17 -17
- package/dist/theme-editor.js +21 -21
- package/package.json +1 -1
- package/src/client.test.ts +261 -0
- package/src/client.ts +103 -25
- package/src/session.ts +4 -0
- package/src/types.ts +17 -0
- package/src/webmcp-bridge.test.ts +80 -0
- package/src/webmcp-bridge.ts +68 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.24.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",
|
package/src/client.test.ts
CHANGED
|
@@ -3771,3 +3771,264 @@ describe('AgentWidgetClient - requestMiddleware preserves clientTools', () => {
|
|
|
3771
3771
|
});
|
|
3772
3772
|
});
|
|
3773
3773
|
|
|
3774
|
+
|
|
3775
|
+
// ============================================================================
|
|
3776
|
+
// Diff-only / send-once WebMCP clientTools (client-token mode)
|
|
3777
|
+
// ============================================================================
|
|
3778
|
+
|
|
3779
|
+
describe('AgentWidgetClient - diff-only clientTools (client-token)', () => {
|
|
3780
|
+
const TOOLS = [
|
|
3781
|
+
{ name: 'add_to_cart', description: 'Add to cart', origin: 'webmcp' as const },
|
|
3782
|
+
];
|
|
3783
|
+
|
|
3784
|
+
function sse(): Response {
|
|
3785
|
+
const encoder = new TextEncoder();
|
|
3786
|
+
const stream = new ReadableStream({
|
|
3787
|
+
start(c) {
|
|
3788
|
+
c.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
3789
|
+
c.close();
|
|
3790
|
+
},
|
|
3791
|
+
});
|
|
3792
|
+
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } });
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
// A client-token client with a live session (so initSession short-circuits)
|
|
3796
|
+
// and a stubbed bridge returning `tools`.
|
|
3797
|
+
function makeClient(tools: unknown[]) {
|
|
3798
|
+
const client = new AgentWidgetClient({
|
|
3799
|
+
clientToken: 'ct_live_demo',
|
|
3800
|
+
apiUrl: 'https://api.runtype-staging.com',
|
|
3801
|
+
});
|
|
3802
|
+
(client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
|
|
3803
|
+
sessionId: 'cs_diff_1',
|
|
3804
|
+
expiresAt: new Date(Date.now() + 600_000),
|
|
3805
|
+
};
|
|
3806
|
+
(client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null }).webMcpBridge = {
|
|
3807
|
+
snapshotForDispatch: () => tools,
|
|
3808
|
+
};
|
|
3809
|
+
return client;
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
const userMsg = (content = 'hi') => ({
|
|
3813
|
+
messages: [{ id: 'u1', role: 'user' as const, content, createdAt: new Date().toISOString() }],
|
|
3814
|
+
assistantMessageId: 'a1',
|
|
3815
|
+
});
|
|
3816
|
+
|
|
3817
|
+
let chatBodies: Array<Record<string, unknown>>;
|
|
3818
|
+
beforeEach(() => {
|
|
3819
|
+
chatBodies = [];
|
|
3820
|
+
});
|
|
3821
|
+
|
|
3822
|
+
function captureSseFetch() {
|
|
3823
|
+
return vi.fn().mockImplementation(async (url: string, init: { body: string }) => {
|
|
3824
|
+
if (url.includes('/client/chat')) chatBodies.push(JSON.parse(init.body));
|
|
3825
|
+
return sse();
|
|
3826
|
+
});
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
it('first turn sends the full tool list AND a fingerprint', async () => {
|
|
3830
|
+
global.fetch = captureSseFetch();
|
|
3831
|
+
const client = makeClient(TOOLS);
|
|
3832
|
+
|
|
3833
|
+
await client.dispatch(userMsg(), () => undefined);
|
|
3834
|
+
|
|
3835
|
+
expect(chatBodies).toHaveLength(1);
|
|
3836
|
+
expect(chatBodies[0]!.clientTools).toEqual(TOOLS);
|
|
3837
|
+
expect(typeof chatBodies[0]!.clientToolsFingerprint).toBe('string');
|
|
3838
|
+
});
|
|
3839
|
+
|
|
3840
|
+
it('an unchanged second turn sends fingerprint-only (no clientTools array)', async () => {
|
|
3841
|
+
global.fetch = captureSseFetch();
|
|
3842
|
+
const client = makeClient(TOOLS);
|
|
3843
|
+
|
|
3844
|
+
await client.dispatch(userMsg('one'), () => undefined);
|
|
3845
|
+
await client.dispatch(userMsg('two'), () => undefined);
|
|
3846
|
+
|
|
3847
|
+
expect(chatBodies).toHaveLength(2);
|
|
3848
|
+
expect(chatBodies[1]!.clientTools).toBeUndefined();
|
|
3849
|
+
expect(chatBodies[1]!.clientToolsFingerprint).toBe(chatBodies[0]!.clientToolsFingerprint);
|
|
3850
|
+
});
|
|
3851
|
+
|
|
3852
|
+
it('a changed tool set resends the full list with a new fingerprint', async () => {
|
|
3853
|
+
global.fetch = captureSseFetch();
|
|
3854
|
+
// Mutable stub so the second turn snapshots a different set.
|
|
3855
|
+
const live = [...TOOLS];
|
|
3856
|
+
const client = new AgentWidgetClient({
|
|
3857
|
+
clientToken: 'ct_live_demo',
|
|
3858
|
+
apiUrl: 'https://api.runtype-staging.com',
|
|
3859
|
+
});
|
|
3860
|
+
(client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
|
|
3861
|
+
sessionId: 'cs_diff_1',
|
|
3862
|
+
expiresAt: new Date(Date.now() + 600_000),
|
|
3863
|
+
};
|
|
3864
|
+
(client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null }).webMcpBridge = {
|
|
3865
|
+
snapshotForDispatch: () => live,
|
|
3866
|
+
};
|
|
3867
|
+
|
|
3868
|
+
await client.dispatch(userMsg('one'), () => undefined);
|
|
3869
|
+
live.push({ name: 'checkout', description: 'Checkout', origin: 'webmcp' });
|
|
3870
|
+
await client.dispatch(userMsg('two'), () => undefined);
|
|
3871
|
+
|
|
3872
|
+
expect(chatBodies).toHaveLength(2);
|
|
3873
|
+
expect(chatBodies[1]!.clientTools).toEqual(live);
|
|
3874
|
+
expect(chatBodies[1]!.clientToolsFingerprint).not.toBe(chatBodies[0]!.clientToolsFingerprint);
|
|
3875
|
+
});
|
|
3876
|
+
|
|
3877
|
+
it('order-independent: reordering the same tools stays fingerprint-only', async () => {
|
|
3878
|
+
global.fetch = captureSseFetch();
|
|
3879
|
+
const live = [
|
|
3880
|
+
{ name: 'a', description: 'A', origin: 'webmcp' as const },
|
|
3881
|
+
{ name: 'b', description: 'B', origin: 'webmcp' as const },
|
|
3882
|
+
];
|
|
3883
|
+
const client = new AgentWidgetClient({
|
|
3884
|
+
clientToken: 'ct_live_demo',
|
|
3885
|
+
apiUrl: 'https://api.runtype-staging.com',
|
|
3886
|
+
});
|
|
3887
|
+
(client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
|
|
3888
|
+
sessionId: 'cs_diff_1',
|
|
3889
|
+
expiresAt: new Date(Date.now() + 600_000),
|
|
3890
|
+
};
|
|
3891
|
+
let current = live;
|
|
3892
|
+
(client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null }).webMcpBridge = {
|
|
3893
|
+
snapshotForDispatch: () => current,
|
|
3894
|
+
};
|
|
3895
|
+
|
|
3896
|
+
await client.dispatch(userMsg('one'), () => undefined);
|
|
3897
|
+
current = [live[1]!, live[0]!]; // same set, reordered
|
|
3898
|
+
await client.dispatch(userMsg('two'), () => undefined);
|
|
3899
|
+
|
|
3900
|
+
expect(chatBodies[1]!.clientTools).toBeUndefined();
|
|
3901
|
+
});
|
|
3902
|
+
|
|
3903
|
+
it('a 409 client_tools_resend_required triggers exactly one retry with the full list', async () => {
|
|
3904
|
+
let call = 0;
|
|
3905
|
+
global.fetch = vi.fn().mockImplementation(async (url: string, init: { body: string }) => {
|
|
3906
|
+
if (!url.includes('/client/chat')) return sse();
|
|
3907
|
+
call += 1;
|
|
3908
|
+
chatBodies.push(JSON.parse(init.body));
|
|
3909
|
+
if (call === 1) {
|
|
3910
|
+
return new Response(JSON.stringify({ error: 'client_tools_resend_required' }), {
|
|
3911
|
+
status: 409,
|
|
3912
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
return sse();
|
|
3916
|
+
});
|
|
3917
|
+
const client = makeClient(TOOLS);
|
|
3918
|
+
// Pre-seed the cache so the first attempt would be fingerprint-only — the
|
|
3919
|
+
// server then forces a resend.
|
|
3920
|
+
(client as unknown as { lastSentClientToolsFingerprint: string | null; clientToolsFingerprintSessionId: string | null }).lastSentClientToolsFingerprint =
|
|
3921
|
+
'stale';
|
|
3922
|
+
(client as unknown as { clientToolsFingerprintSessionId: string | null }).clientToolsFingerprintSessionId =
|
|
3923
|
+
'cs_diff_1';
|
|
3924
|
+
|
|
3925
|
+
await client.dispatch(userMsg(), () => undefined);
|
|
3926
|
+
|
|
3927
|
+
expect(chatBodies).toHaveLength(2);
|
|
3928
|
+
// Retry carried the full list...
|
|
3929
|
+
expect(chatBodies[1]!.clientTools).toEqual(TOOLS);
|
|
3930
|
+
// ...with the SAME messages + assistantMessageId (no double user message).
|
|
3931
|
+
expect(chatBodies[1]!.messages).toEqual(chatBodies[0]!.messages);
|
|
3932
|
+
expect(chatBodies[1]!.assistantMessageId).toBe(chatBodies[0]!.assistantMessageId);
|
|
3933
|
+
});
|
|
3934
|
+
|
|
3935
|
+
it('clearMessages-style reset (resetClientToolsFingerprint) forces a full resend next turn', async () => {
|
|
3936
|
+
global.fetch = captureSseFetch();
|
|
3937
|
+
const client = makeClient(TOOLS);
|
|
3938
|
+
|
|
3939
|
+
await client.dispatch(userMsg('one'), () => undefined);
|
|
3940
|
+
client.resetClientToolsFingerprint();
|
|
3941
|
+
await client.dispatch(userMsg('two'), () => undefined);
|
|
3942
|
+
|
|
3943
|
+
expect(chatBodies[1]!.clientTools).toEqual(TOOLS); // not fingerprint-only
|
|
3944
|
+
});
|
|
3945
|
+
|
|
3946
|
+
it('does not commit the cache on a network failure (next turn resends full)', async () => {
|
|
3947
|
+
let call = 0;
|
|
3948
|
+
global.fetch = vi.fn().mockImplementation(async (url: string, init: { body: string }) => {
|
|
3949
|
+
if (!url.includes('/client/chat')) return sse();
|
|
3950
|
+
call += 1;
|
|
3951
|
+
chatBodies.push(JSON.parse(init.body));
|
|
3952
|
+
if (call === 1) throw new Error('network down');
|
|
3953
|
+
return sse();
|
|
3954
|
+
});
|
|
3955
|
+
const client = makeClient(TOOLS);
|
|
3956
|
+
|
|
3957
|
+
await client.dispatch(userMsg('one'), () => undefined).catch(() => undefined);
|
|
3958
|
+
await client.dispatch(userMsg('two'), () => undefined);
|
|
3959
|
+
|
|
3960
|
+
// Second turn still sends the full list because the first never committed.
|
|
3961
|
+
expect(chatBodies[1]!.clientTools).toEqual(TOOLS);
|
|
3962
|
+
});
|
|
3963
|
+
|
|
3964
|
+
it('minting a fresh session via initSession() resets the fingerprint cache', async () => {
|
|
3965
|
+
global.fetch = vi.fn().mockImplementation(async (url: string) => {
|
|
3966
|
+
if (url.includes('/client/init')) {
|
|
3967
|
+
return new Response(
|
|
3968
|
+
JSON.stringify({
|
|
3969
|
+
sessionId: 'cs_freshly_minted',
|
|
3970
|
+
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
3971
|
+
flow: { id: 'flow_x', name: 'F', description: null },
|
|
3972
|
+
config: { welcomeMessage: null, placeholder: 'p', theme: null },
|
|
3973
|
+
}),
|
|
3974
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
3975
|
+
);
|
|
3976
|
+
}
|
|
3977
|
+
return sse();
|
|
3978
|
+
});
|
|
3979
|
+
const client = new AgentWidgetClient({
|
|
3980
|
+
clientToken: 'ct_live_demo',
|
|
3981
|
+
apiUrl: 'https://api.runtype-staging.com',
|
|
3982
|
+
});
|
|
3983
|
+
// Pre-seed a stale fingerprint bound to a prior session (no live session, so
|
|
3984
|
+
// initSession() takes the mint path and fetches /client/init).
|
|
3985
|
+
const internals = client as unknown as {
|
|
3986
|
+
lastSentClientToolsFingerprint: string | null;
|
|
3987
|
+
clientToolsFingerprintSessionId: string | null;
|
|
3988
|
+
};
|
|
3989
|
+
internals.lastSentClientToolsFingerprint = 'stale-fp';
|
|
3990
|
+
internals.clientToolsFingerprintSessionId = 'cs_old';
|
|
3991
|
+
|
|
3992
|
+
await client.initSession();
|
|
3993
|
+
|
|
3994
|
+
expect(internals.lastSentClientToolsFingerprint).toBeNull();
|
|
3995
|
+
expect(internals.clientToolsFingerprintSessionId).toBeNull();
|
|
3996
|
+
});
|
|
3997
|
+
});
|
|
3998
|
+
|
|
3999
|
+
describe('AgentWidgetClient - non-client-token paths always send full clientTools', () => {
|
|
4000
|
+
function sse(): Response {
|
|
4001
|
+
const encoder = new TextEncoder();
|
|
4002
|
+
const stream = new ReadableStream({
|
|
4003
|
+
start(c) {
|
|
4004
|
+
c.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
4005
|
+
c.close();
|
|
4006
|
+
},
|
|
4007
|
+
});
|
|
4008
|
+
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } });
|
|
4009
|
+
}
|
|
4010
|
+
|
|
4011
|
+
it('proxy mode sends full clientTools and no fingerprint on every turn', async () => {
|
|
4012
|
+
const bodies: Array<Record<string, unknown>> = [];
|
|
4013
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, init: { body: string }) => {
|
|
4014
|
+
bodies.push(JSON.parse(init.body));
|
|
4015
|
+
return sse();
|
|
4016
|
+
});
|
|
4017
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
4018
|
+
(client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } | null }).webMcpBridge = {
|
|
4019
|
+
snapshotForDispatch: () => [{ name: 'search', description: 's', origin: 'webmcp' }],
|
|
4020
|
+
};
|
|
4021
|
+
|
|
4022
|
+
const msg = () => ({
|
|
4023
|
+
messages: [{ id: 'u1', role: 'user' as const, content: 'hi', createdAt: new Date().toISOString() }],
|
|
4024
|
+
});
|
|
4025
|
+
await client.dispatch(msg(), () => undefined);
|
|
4026
|
+
await client.dispatch(msg(), () => undefined);
|
|
4027
|
+
|
|
4028
|
+
expect(bodies).toHaveLength(2);
|
|
4029
|
+
for (const b of bodies) {
|
|
4030
|
+
expect(b.clientTools).toEqual([{ name: 'search', description: 's', origin: 'webmcp' }]);
|
|
4031
|
+
expect(b.clientToolsFingerprint).toBeUndefined();
|
|
4032
|
+
}
|
|
4033
|
+
});
|
|
4034
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
ContentPart,
|
|
24
24
|
WebMcpConfirmHandler
|
|
25
25
|
} from "./types";
|
|
26
|
-
import { WebMcpBridge } from "./webmcp-bridge";
|
|
26
|
+
import { WebMcpBridge, computeClientToolsFingerprint } from "./webmcp-bridge";
|
|
27
27
|
import {
|
|
28
28
|
extractTextFromJson,
|
|
29
29
|
createPlainTextParser,
|
|
@@ -171,6 +171,14 @@ export class AgentWidgetClient {
|
|
|
171
171
|
private clientSession: ClientSession | null = null;
|
|
172
172
|
private sessionInitPromise: Promise<ClientSession> | null = null;
|
|
173
173
|
|
|
174
|
+
// Diff-only / send-once WebMCP tool dispatch (client-token mode ONLY).
|
|
175
|
+
// Fingerprint of the clientTools[] last *sent in full* and confirmed by a
|
|
176
|
+
// successful stream start; null => the next client-token turn sends the full
|
|
177
|
+
// array. Paired with the sessionId it was sent under so a session change
|
|
178
|
+
// (silent re-init / expiry) forces a fresh full send.
|
|
179
|
+
private lastSentClientToolsFingerprint: string | null = null;
|
|
180
|
+
private clientToolsFingerprintSessionId: string | null = null;
|
|
181
|
+
|
|
174
182
|
// WebMCP — page-discovered tool consumption (see ./webmcp-bridge).
|
|
175
183
|
// Constructed lazily: null when `config.webmcp?.enabled !== true`.
|
|
176
184
|
private readonly webMcpBridge: WebMcpBridge | null;
|
|
@@ -297,6 +305,12 @@ export class AgentWidgetClient {
|
|
|
297
305
|
try {
|
|
298
306
|
const session = await this.sessionInitPromise;
|
|
299
307
|
this.clientSession = session;
|
|
308
|
+
// A freshly-minted session must resend the full WebMCP tool list on its
|
|
309
|
+
// next turn: drop any diff-only fingerprint cached under a prior session,
|
|
310
|
+
// so we never claim "unchanged" against a session the server didn't store
|
|
311
|
+
// the set under. (Belt-and-suspenders with the sessionId comparison in the
|
|
312
|
+
// send decision and the server's 409 resend signal.)
|
|
313
|
+
this.resetClientToolsFingerprint();
|
|
300
314
|
this.config.onSessionInit?.(session);
|
|
301
315
|
return session;
|
|
302
316
|
} finally {
|
|
@@ -358,6 +372,17 @@ export class AgentWidgetClient {
|
|
|
358
372
|
public clearClientSession(): void {
|
|
359
373
|
this.clientSession = null;
|
|
360
374
|
this.sessionInitPromise = null;
|
|
375
|
+
this.resetClientToolsFingerprint();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Forget the diff-only WebMCP tool fingerprint so the next client-token turn
|
|
380
|
+
* resends the full `clientTools[]`. Called when the session is cleared and
|
|
381
|
+
* when the conversation is reset (`WidgetSession.clearMessages`).
|
|
382
|
+
*/
|
|
383
|
+
public resetClientToolsFingerprint(): void {
|
|
384
|
+
this.lastSentClientToolsFingerprint = null;
|
|
385
|
+
this.clientToolsFingerprintSessionId = null;
|
|
361
386
|
}
|
|
362
387
|
|
|
363
388
|
/**
|
|
@@ -551,7 +576,7 @@ export class AgentWidgetClient {
|
|
|
551
576
|
// Check if session is about to expire (within 1 minute)
|
|
552
577
|
if (new Date() >= new Date(session.expiresAt.getTime() - 60000)) {
|
|
553
578
|
// Session expired or expiring soon
|
|
554
|
-
this.
|
|
579
|
+
this.clearClientSession();
|
|
555
580
|
this.config.onSessionExpired?.();
|
|
556
581
|
const error = new Error('Session expired. Please refresh to continue.');
|
|
557
582
|
onEvent({ type: "error", error });
|
|
@@ -569,7 +594,8 @@ export class AgentWidgetClient {
|
|
|
569
594
|
)
|
|
570
595
|
: undefined;
|
|
571
596
|
|
|
572
|
-
|
|
597
|
+
// Common (tools-independent) fields for the chat request.
|
|
598
|
+
const baseChatRequest: Omit<ClientChatRequest, 'clientTools' | 'clientToolsFingerprint'> = {
|
|
573
599
|
sessionId: session.sessionId,
|
|
574
600
|
// Filter out messages with empty content to prevent validation errors
|
|
575
601
|
messages: options.messages.filter(hasValidContent).map(m => ({
|
|
@@ -584,44 +610,90 @@ export class AgentWidgetClient {
|
|
|
584
610
|
...(sanitizedMetadata && Object.keys(sanitizedMetadata).length > 0 && { metadata: sanitizedMetadata }),
|
|
585
611
|
...(basePayload.inputs && Object.keys(basePayload.inputs).length > 0 && { inputs: basePayload.inputs }),
|
|
586
612
|
...(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 }),
|
|
590
613
|
};
|
|
591
614
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
615
|
+
// Diff-only / send-once WebMCP tool dispatch. `buildPayload()` already
|
|
616
|
+
// snapshotted the full set; decide whether to ship it again or just its
|
|
617
|
+
// fingerprint. First turn of a session, or a changed set, sends the full
|
|
618
|
+
// array; an unchanged set sends only the fingerprint and the server
|
|
619
|
+
// reuses its stored copy. The cache is committed only after a successful
|
|
620
|
+
// stream start (below), so a 409/failure leaves it untouched.
|
|
621
|
+
const fullClientTools = basePayload.clientTools;
|
|
622
|
+
const hasClientTools = !!(fullClientTools && fullClientTools.length > 0);
|
|
623
|
+
const clientToolsFingerprint = hasClientTools
|
|
624
|
+
? computeClientToolsFingerprint(fullClientTools!)
|
|
625
|
+
: undefined;
|
|
626
|
+
const sameSession = this.clientToolsFingerprintSessionId === session.sessionId;
|
|
627
|
+
const unchanged =
|
|
628
|
+
hasClientTools && sameSession && this.lastSentClientToolsFingerprint === clientToolsFingerprint;
|
|
629
|
+
|
|
630
|
+
// `forceFull` flips to true after a 409 cache-miss so the single retry
|
|
631
|
+
// resends the full list. Capture any error body read inside the loop so
|
|
632
|
+
// the `!response.ok` handler below doesn't re-consume the stream.
|
|
633
|
+
let forceFull = false;
|
|
634
|
+
let errorData: { error?: string; hint?: string } | null = null;
|
|
635
|
+
let response: Response;
|
|
636
|
+
for (let attempt = 0; ; attempt++) {
|
|
637
|
+
const sendFull = hasClientTools && (forceFull || !unchanged);
|
|
638
|
+
const chatRequest: ClientChatRequest = {
|
|
639
|
+
...baseChatRequest,
|
|
640
|
+
...(sendFull && fullClientTools ? { clientTools: fullClientTools } : {}),
|
|
641
|
+
...(clientToolsFingerprint ? { clientToolsFingerprint } : {}),
|
|
642
|
+
};
|
|
596
643
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
644
|
+
if (this.debug) {
|
|
645
|
+
// eslint-disable-next-line no-console
|
|
646
|
+
console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
response = await fetch(this.getClientApiUrl('chat'), {
|
|
650
|
+
method: 'POST',
|
|
651
|
+
headers: {
|
|
652
|
+
'Content-Type': 'application/json',
|
|
653
|
+
},
|
|
654
|
+
body: JSON.stringify(chatRequest),
|
|
655
|
+
signal: controller.signal,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// Diff-only cache miss: the server has no stored tool set matching our
|
|
659
|
+
// fingerprint. Retry exactly once with the full list. A second miss
|
|
660
|
+
// falls through to the normal error handling below (no infinite loop).
|
|
661
|
+
if (response.status === 409 && attempt === 0 && hasClientTools) {
|
|
662
|
+
const body = (await response.json().catch(() => null)) as
|
|
663
|
+
| { error?: string; hint?: string }
|
|
664
|
+
| null;
|
|
665
|
+
if (body?.error === 'client_tools_resend_required') {
|
|
666
|
+
forceFull = true;
|
|
667
|
+
// Invalidate so future turns also resend until a clean success
|
|
668
|
+
// commits a fresh fingerprint.
|
|
669
|
+
this.lastSentClientToolsFingerprint = null;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
// Some other 409 — keep the parsed body for the handler below.
|
|
673
|
+
errorData = body ?? { error: 'Chat request failed' };
|
|
674
|
+
}
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
605
677
|
|
|
606
678
|
if (!response.ok) {
|
|
607
|
-
const
|
|
608
|
-
|
|
679
|
+
const data = errorData ?? (await response.json().catch(() => ({ error: 'Chat request failed' })));
|
|
680
|
+
|
|
609
681
|
if (response.status === 401) {
|
|
610
682
|
// Session expired
|
|
611
|
-
this.
|
|
683
|
+
this.clearClientSession();
|
|
612
684
|
this.config.onSessionExpired?.();
|
|
613
685
|
const error = new Error('Session expired. Please refresh to continue.');
|
|
614
686
|
onEvent({ type: "error", error });
|
|
615
687
|
throw error;
|
|
616
688
|
}
|
|
617
|
-
|
|
689
|
+
|
|
618
690
|
if (response.status === 429) {
|
|
619
|
-
const error = new Error(
|
|
691
|
+
const error = new Error(data.hint || 'Message limit reached for this session.');
|
|
620
692
|
onEvent({ type: "error", error });
|
|
621
693
|
throw error;
|
|
622
694
|
}
|
|
623
|
-
|
|
624
|
-
const error = new Error(
|
|
695
|
+
|
|
696
|
+
const error = new Error(data.error || 'Failed to send message');
|
|
625
697
|
onEvent({ type: "error", error });
|
|
626
698
|
throw error;
|
|
627
699
|
}
|
|
@@ -632,6 +704,12 @@ export class AgentWidgetClient {
|
|
|
632
704
|
throw error;
|
|
633
705
|
}
|
|
634
706
|
|
|
707
|
+
// Stream is good: the server now holds this tool set under this
|
|
708
|
+
// fingerprint for the session. Commit the cache so unchanged follow-up
|
|
709
|
+
// turns can send fingerprint-only.
|
|
710
|
+
this.lastSentClientToolsFingerprint = clientToolsFingerprint ?? null;
|
|
711
|
+
this.clientToolsFingerprintSessionId = session.sessionId;
|
|
712
|
+
|
|
635
713
|
onEvent({ type: "status", status: "connected" });
|
|
636
714
|
|
|
637
715
|
// Stream the response (same SSE handling as proxy mode)
|
package/src/session.ts
CHANGED
|
@@ -2059,6 +2059,10 @@ export class AgentWidgetSession {
|
|
|
2059
2059
|
// a tool with the same key resolved in the prior conversation.
|
|
2060
2060
|
this.webMcpInflightKeys.clear();
|
|
2061
2061
|
this.webMcpResolvedKeys.clear();
|
|
2062
|
+
// A fresh conversation must resend the full WebMCP tool list on its next
|
|
2063
|
+
// turn — drop the diff-only fingerprint cache (server keys by recordId, so
|
|
2064
|
+
// a new conversation has no stored set to match).
|
|
2065
|
+
this.client.resetClientToolsFingerprint();
|
|
2062
2066
|
this.setStreaming(false);
|
|
2063
2067
|
this.setStatus("idle");
|
|
2064
2068
|
this.callbacks.onMessagesChanged([...this.messages]);
|
package/src/types.ts
CHANGED
|
@@ -2224,6 +2224,23 @@ export type ClientChatRequest = {
|
|
|
2224
2224
|
context?: Record<string, unknown>;
|
|
2225
2225
|
/** WebMCP page-discovered tools — same shape as `dispatch.clientTools[]`. */
|
|
2226
2226
|
clientTools?: ClientToolDefinition[];
|
|
2227
|
+
/**
|
|
2228
|
+
* Diff-only / send-once: order-independent fingerprint of the client tool set.
|
|
2229
|
+
* When the set is unchanged from the previous turn the widget sends this
|
|
2230
|
+
* WITHOUT `clientTools` and the server reuses its stored set. On a cache miss
|
|
2231
|
+
* the server replies `409 { error: 'client_tools_resend_required' }` and the
|
|
2232
|
+
* widget retries once with the full `clientTools[]`.
|
|
2233
|
+
*/
|
|
2234
|
+
clientToolsFingerprint?: string;
|
|
2235
|
+
};
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* Body the server returns (HTTP 409) when it holds no stored tool set matching
|
|
2239
|
+
* a fingerprint-only `/client/chat` turn. The widget retries once with the full
|
|
2240
|
+
* `clientTools[]` (and the fingerprint).
|
|
2241
|
+
*/
|
|
2242
|
+
export type ClientToolsResendRequiredResponse = {
|
|
2243
|
+
error: 'client_tools_resend_required';
|
|
2227
2244
|
};
|
|
2228
2245
|
|
|
2229
2246
|
/**
|
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
WebMcpBridge,
|
|
30
30
|
isWebMcpToolName,
|
|
31
31
|
stripWebMcpPrefix,
|
|
32
|
+
computeClientToolsFingerprint,
|
|
32
33
|
} from "./webmcp-bridge";
|
|
34
|
+
import type { ClientToolDefinition } from "./types";
|
|
33
35
|
|
|
34
36
|
type MockClient = { requestUserInteraction: (cb: () => unknown) => Promise<unknown> };
|
|
35
37
|
|
|
@@ -427,3 +429,81 @@ describe("WebMcpBridge.executeToolCall", () => {
|
|
|
427
429
|
expect(executeSpy).not.toHaveBeenCalled();
|
|
428
430
|
});
|
|
429
431
|
});
|
|
432
|
+
|
|
433
|
+
describe("computeClientToolsFingerprint — diff-only / send-once", () => {
|
|
434
|
+
const tool = (over: Partial<ClientToolDefinition> = {}): ClientToolDefinition => ({
|
|
435
|
+
name: "search",
|
|
436
|
+
description: "Search the catalog",
|
|
437
|
+
parametersSchema: { type: "object", properties: { q: { type: "string" } } },
|
|
438
|
+
origin: "webmcp",
|
|
439
|
+
...over,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("returns a stable sentinel for an empty set", () => {
|
|
443
|
+
expect(computeClientToolsFingerprint([])).toBe(computeClientToolsFingerprint([]));
|
|
444
|
+
expect(computeClientToolsFingerprint([])).toBe("0:empty");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("is deterministic for the same set", () => {
|
|
448
|
+
const a = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
|
|
449
|
+
const b = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
|
|
450
|
+
expect(a).toBe(b);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("is order-independent (tool order does not matter)", () => {
|
|
454
|
+
const ab = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
|
|
455
|
+
const ba = computeClientToolsFingerprint([tool({ name: "b" }), tool({ name: "a" })]);
|
|
456
|
+
expect(ab).toBe(ba);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("changes when a description changes", () => {
|
|
460
|
+
expect(computeClientToolsFingerprint([tool({ description: "x" })])).not.toBe(
|
|
461
|
+
computeClientToolsFingerprint([tool({ description: "y" })]),
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("changes when the schema changes", () => {
|
|
466
|
+
const base = computeClientToolsFingerprint([tool()]);
|
|
467
|
+
const changed = computeClientToolsFingerprint([
|
|
468
|
+
tool({ parametersSchema: { type: "object", properties: { q: { type: "number" } } } }),
|
|
469
|
+
]);
|
|
470
|
+
expect(changed).not.toBe(base);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("changes when a tool is added", () => {
|
|
474
|
+
const one = computeClientToolsFingerprint([tool({ name: "a" })]);
|
|
475
|
+
const two = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
|
|
476
|
+
expect(two).not.toBe(one);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("ignores pageOrigin (audit metadata, not part of the contract)", () => {
|
|
480
|
+
const withOrigin = computeClientToolsFingerprint([tool({ pageOrigin: "https://a.example" })]);
|
|
481
|
+
const without = computeClientToolsFingerprint([tool({ pageOrigin: undefined })]);
|
|
482
|
+
expect(withOrigin).toBe(without);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("reflects annotations (they ride along to the server)", () => {
|
|
486
|
+
const plain = computeClientToolsFingerprint([tool()]);
|
|
487
|
+
const annotated = computeClientToolsFingerprint([
|
|
488
|
+
tool({ annotations: { readOnlyHint: true } }),
|
|
489
|
+
]);
|
|
490
|
+
expect(annotated).not.toBe(plain);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("stays within the server's 128-char wire bound for large tool sets", () => {
|
|
494
|
+
// The server validates `clientToolsFingerprint` as `z.string().max(128)`.
|
|
495
|
+
// A fingerprint that grew with the tool content would 400 the first turn.
|
|
496
|
+
const many = Array.from({ length: 50 }, (_, i) =>
|
|
497
|
+
tool({
|
|
498
|
+
name: `tool_${i}`,
|
|
499
|
+
description: `A fairly long description for tool number ${i} `.repeat(8),
|
|
500
|
+
parametersSchema: {
|
|
501
|
+
type: "object",
|
|
502
|
+
properties: { a: { type: "string" }, b: { type: "number" }, c: { type: "boolean" } },
|
|
503
|
+
},
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
const fp = computeClientToolsFingerprint(many);
|
|
507
|
+
expect(fp.length).toBeLessThanOrEqual(128);
|
|
508
|
+
});
|
|
509
|
+
});
|
package/src/webmcp-bridge.ts
CHANGED
|
@@ -86,6 +86,74 @@ const log = {
|
|
|
86
86
|
},
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Compute a stable, order-independent fingerprint of a `ClientToolDefinition[]`
|
|
91
|
+
* snapshot, for the diff-only / send-once dispatch path (client-token mode).
|
|
92
|
+
*
|
|
93
|
+
* The widget caches "the fingerprint of the tool set last sent in full" for the
|
|
94
|
+
* current session; an unchanged set on a follow-up turn lets it ship only the
|
|
95
|
+
* fingerprint instead of the whole array. Per-tool strings are sorted so tool
|
|
96
|
+
* ordering does not affect the result. `pageOrigin` is deliberately excluded —
|
|
97
|
+
* it is audit metadata, not part of the tool contract.
|
|
98
|
+
*
|
|
99
|
+
* This is a fast, non-cryptographic content key. The canonical per-tool content
|
|
100
|
+
* is hashed down to a short, fixed-length digest so the result fits the server's
|
|
101
|
+
* `clientToolsFingerprint` wire field (`z.string().max(128)`) regardless of how
|
|
102
|
+
* many tools the page registers — sending the raw concatenated content would
|
|
103
|
+
* overflow that bound and be rejected with a 400. The server stores and compares
|
|
104
|
+
* the widget's fingerprint verbatim, so cross-implementation byte-equality is NOT
|
|
105
|
+
* required — only self-consistency across this widget's turns.
|
|
106
|
+
*/
|
|
107
|
+
export function computeClientToolsFingerprint(
|
|
108
|
+
tools: ClientToolDefinition[],
|
|
109
|
+
): string {
|
|
110
|
+
if (tools.length === 0) return "0:empty";
|
|
111
|
+
const parts = tools
|
|
112
|
+
.map((t) =>
|
|
113
|
+
[
|
|
114
|
+
t.name,
|
|
115
|
+
t.description ?? "",
|
|
116
|
+
t.parametersSchema ? JSON.stringify(t.parametersSchema) : "",
|
|
117
|
+
t.origin ?? "",
|
|
118
|
+
t.annotations ? JSON.stringify(t.annotations) : "",
|
|
119
|
+
].join("\x1f"),
|
|
120
|
+
)
|
|
121
|
+
.sort();
|
|
122
|
+
return `${tools.length}:${hashFingerprintContent(parts.join("\x1e"))}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* cyrb53 — a fast, well-distributed non-cryptographic string hash. Returns a
|
|
127
|
+
* 53-bit value (safe-integer range). Two independent seeds are combined by the
|
|
128
|
+
* caller for a ~106-bit digest, which makes accidental collisions across a
|
|
129
|
+
* single conversation's handful of tool-set variants infeasible.
|
|
130
|
+
*/
|
|
131
|
+
function cyrb53(str: string, seed: number): number {
|
|
132
|
+
let h1 = 0xdeadbeef ^ seed;
|
|
133
|
+
let h2 = 0x41c6ce57 ^ seed;
|
|
134
|
+
for (let i = 0; i < str.length; i++) {
|
|
135
|
+
const ch = str.charCodeAt(i);
|
|
136
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
137
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
138
|
+
}
|
|
139
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
|
140
|
+
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
141
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
|
142
|
+
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
143
|
+
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compress the canonical tool-set content string into a short, fixed-length
|
|
148
|
+
* fingerprint (≤ ~24 chars) that fits the server's 128-char wire bound. Uses two
|
|
149
|
+
* seeded cyrb53 passes, base-36 encoded.
|
|
150
|
+
*/
|
|
151
|
+
function hashFingerprintContent(content: string): string {
|
|
152
|
+
const a = cyrb53(content, 0).toString(36);
|
|
153
|
+
const b = cyrb53(content, 0x9e3779b1).toString(36);
|
|
154
|
+
return `${a}.${b}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
89
157
|
export class WebMcpBridge {
|
|
90
158
|
private confirmHandler: WebMcpConfirmHandler | null;
|
|
91
159
|
private readonly timeoutMs: number;
|