@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/dist/animations/glyph-cycle.cjs +2 -262
- package/dist/animations/glyph-cycle.js +2 -235
- package/dist/animations/wipe.cjs +2 -72
- package/dist/animations/wipe.js +2 -45
- package/dist/index.cjs +49 -49
- 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 +84 -84
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -48
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +22 -1874
- package/dist/smart-dom-reader.js +22 -1847
- package/dist/testing.cjs +3 -84
- package/dist/testing.js +3 -55
- package/dist/theme-editor.cjs +54 -24695
- package/dist/theme-editor.js +54 -24682
- package/package.json +9 -6
- package/src/client.test.ts +261 -0
- package/src/client.ts +103 -25
- package/src/components/event-stream-view.ts +122 -1
- package/src/session.ts +4 -0
- package/src/types.ts +17 -0
- package/src/ui.ts +24 -3
- package/src/utils/throughput-tracker.test.ts +366 -0
- package/src/utils/throughput-tracker.ts +427 -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",
|
|
@@ -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
|
}
|
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)
|