@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.23.0",
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",
@@ -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.clientSession = null;
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
- const chatRequest: ClientChatRequest = {
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
- if (this.debug) {
593
- // eslint-disable-next-line no-console
594
- console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
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
- const response = await fetch(this.getClientApiUrl('chat'), {
598
- method: 'POST',
599
- headers: {
600
- 'Content-Type': 'application/json',
601
- },
602
- body: JSON.stringify(chatRequest),
603
- signal: controller.signal,
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 errorData = await response.json().catch(() => ({ error: 'Chat request failed' }));
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.clientSession = null;
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(errorData.hint || 'Message limit reached for this session.');
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(errorData.error || 'Failed to send message');
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
+ });
@@ -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;