@runtypelabs/persona 3.22.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.22.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",
@@ -58,6 +58,7 @@
58
58
  "zod": "^3.22.4"
59
59
  },
60
60
  "devDependencies": {
61
+ "@size-limit/file": "^12.1.0",
61
62
  "@types/node": "^20.12.7",
62
63
  "@typescript-eslint/eslint-plugin": "^7.0.0",
63
64
  "@typescript-eslint/parser": "^7.0.0",
@@ -66,6 +67,7 @@
66
67
  "eslint-config-prettier": "^9.1.0",
67
68
  "fake-indexeddb": "^6.2.5",
68
69
  "rimraf": "^5.0.5",
70
+ "size-limit": "^12.1.0",
69
71
  "tsup": "^8.0.1",
70
72
  "typescript": "^5.4.5",
71
73
  "vitest": "^4.0.9"
@@ -98,10 +100,10 @@
98
100
  },
99
101
  "scripts": {
100
102
  "build": "rimraf dist && pnpm run build:styles && pnpm run build:client && pnpm run build:installer && pnpm run build:theme-ref && pnpm run build:theme-editor && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:animations",
101
- "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --dts --out-dir dist --no-splitting",
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",
104
- "build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --dts --out-dir dist/animations --no-splitting",
103
+ "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
104
+ "build:testing": "tsup src/testing.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
105
+ "build:smart-dom-reader": "tsup src/smart-dom-reader.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
106
+ "build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --minify --dts --out-dir dist/animations --no-splitting",
105
107
  "build:theme-ref": "tsup src/theme-reference.ts --format esm,cjs --minify --dts",
106
108
  "build:styles": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/styles/widget.css','dist/widget.css');\"",
107
109
  "build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" && tsup src/index-global.ts --format iife --global-name AgentWidget --minify --sourcemap --splitting false --out-dir dist --loader \".css=text\" && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\"",
@@ -110,6 +112,7 @@
110
112
  "typecheck": "tsc --noEmit",
111
113
  "test": "vitest",
112
114
  "test:ui": "vitest --ui",
113
- "test:run": "vitest run"
115
+ "test:run": "vitest run",
116
+ "size": "size-limit"
114
117
  }
115
118
  }
@@ -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)