@runtypelabs/persona 4.22.1 → 4.23.1
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/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +24 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.global.js +22 -22
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +24 -24
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/theme-editor-preview.cjs +30 -30
- package/dist/theme-editor-preview.d.cts +6 -0
- package/dist/theme-editor-preview.d.ts +6 -0
- package/dist/theme-editor-preview.js +20 -20
- package/package.json +1 -1
- package/src/client-session-renewal.test.ts +268 -0
- package/src/client-visitor-history.test.ts +86 -0
- package/src/client.interrupt.test.ts +1 -1
- package/src/client.ts +61 -18
- package/src/launcher-global.ts +1 -1
- package/src/ui.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.23.1",
|
|
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",
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "./client";
|
|
3
|
+
import type { AgentWidgetEvent, ClientSession } from "./types";
|
|
4
|
+
import { createVisitorStore } from "./utils/visitor-store";
|
|
5
|
+
|
|
6
|
+
const message = {
|
|
7
|
+
id: "user-1",
|
|
8
|
+
role: "user" as const,
|
|
9
|
+
content: "continue",
|
|
10
|
+
createdAt: "2026-09-22T00:00:00Z",
|
|
11
|
+
};
|
|
12
|
+
const options = { messages: [message], assistantMessageId: "assistant-1" };
|
|
13
|
+
const session = (milliseconds = 600_000): ClientSession => ({
|
|
14
|
+
sessionId: "session-old",
|
|
15
|
+
conversationId: "conversation-owned",
|
|
16
|
+
expiresAt: new Date(Date.now() + milliseconds),
|
|
17
|
+
durableRecovery: { enabled: true },
|
|
18
|
+
flow: { id: "agent-1", name: "Agent", description: null },
|
|
19
|
+
config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
|
|
20
|
+
});
|
|
21
|
+
const expired = () =>
|
|
22
|
+
Response.json({ error: "Session not found or expired" }, { status: 401 });
|
|
23
|
+
const success = () =>
|
|
24
|
+
new Response('data: {"type":"done"}\n\n', {
|
|
25
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function setup(milliseconds = 600_000) {
|
|
29
|
+
const onSessionInit = vi.fn();
|
|
30
|
+
const onSessionExpired = vi.fn();
|
|
31
|
+
const setStoredSessionId = vi.fn();
|
|
32
|
+
const store = createVisitorStore("ct_test_renewal", "renewal-", true);
|
|
33
|
+
await store.set("cvt_owned");
|
|
34
|
+
const client = new AgentWidgetClient({
|
|
35
|
+
clientToken: "ct_test_renewal",
|
|
36
|
+
apiUrl: "https://example.com",
|
|
37
|
+
onSessionInit,
|
|
38
|
+
onSessionExpired,
|
|
39
|
+
setStoredSessionId,
|
|
40
|
+
});
|
|
41
|
+
client.setHistoryInternals({ visitorStore: store });
|
|
42
|
+
(client as unknown as { clientSession: ClientSession }).clientSession =
|
|
43
|
+
session(milliseconds);
|
|
44
|
+
const requests: Array<{
|
|
45
|
+
url: string;
|
|
46
|
+
body: Record<string, unknown>;
|
|
47
|
+
headers: Headers;
|
|
48
|
+
}> = [];
|
|
49
|
+
const chat = vi
|
|
50
|
+
.fn<() => Response | Promise<Response>>()
|
|
51
|
+
.mockImplementation(success);
|
|
52
|
+
const initialize = vi
|
|
53
|
+
.fn<() => Response | Promise<Response>>()
|
|
54
|
+
.mockImplementation(() =>
|
|
55
|
+
Response.json({ ...session(), sessionId: "session-new" }),
|
|
56
|
+
);
|
|
57
|
+
vi.stubGlobal(
|
|
58
|
+
"fetch",
|
|
59
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
60
|
+
requests.push({
|
|
61
|
+
url,
|
|
62
|
+
body: JSON.parse(String(init.body)),
|
|
63
|
+
headers: new Headers(init.headers),
|
|
64
|
+
});
|
|
65
|
+
return url.endsWith("/init") ? initialize() : chat();
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const events: AgentWidgetEvent[] = [];
|
|
69
|
+
const run = (signal?: AbortSignal) =>
|
|
70
|
+
client.dispatch({ ...options, signal }, (event) => events.push(event));
|
|
71
|
+
return {
|
|
72
|
+
client,
|
|
73
|
+
store,
|
|
74
|
+
requests,
|
|
75
|
+
chat,
|
|
76
|
+
initialize,
|
|
77
|
+
onSessionInit,
|
|
78
|
+
onSessionExpired,
|
|
79
|
+
setStoredSessionId,
|
|
80
|
+
events,
|
|
81
|
+
run,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.restoreAllMocks();
|
|
87
|
+
vi.unstubAllGlobals();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("client token session renewal", () => {
|
|
91
|
+
it("renews a legacy session through its session ID without enabling shared history", async () => {
|
|
92
|
+
const h = await setup(30_000);
|
|
93
|
+
(h.client as unknown as { clientSession: ClientSession }).clientSession = {
|
|
94
|
+
...session(30_000),
|
|
95
|
+
durableRecovery: { enabled: false },
|
|
96
|
+
};
|
|
97
|
+
h.initialize.mockImplementation(() =>
|
|
98
|
+
Response.json({
|
|
99
|
+
...session(),
|
|
100
|
+
sessionId: "session-new",
|
|
101
|
+
durableRecovery: { enabled: false },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
await h.run();
|
|
105
|
+
expect(h.requests[0].body).toMatchObject({ sessionId: "session-old" });
|
|
106
|
+
expect(h.requests[0].body).not.toHaveProperty("conversationId");
|
|
107
|
+
expect(h.requests[0].body).not.toHaveProperty("visitorHistory");
|
|
108
|
+
expect(h.requests[1].body.sessionId).toBe("session-new");
|
|
109
|
+
expect(h.onSessionExpired).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("resends the full tool registry after renewal rather than reusing the old fingerprint alone", async () => {
|
|
113
|
+
const h = await setup();
|
|
114
|
+
const tools = [{ name: "search", description: "Search", origin: "webmcp" }];
|
|
115
|
+
(h.client as unknown as { webMcpBridge: unknown }).webMcpBridge = {
|
|
116
|
+
snapshotForDispatch: () => tools,
|
|
117
|
+
};
|
|
118
|
+
await h.run();
|
|
119
|
+
h.chat.mockImplementationOnce(expired);
|
|
120
|
+
await h.run();
|
|
121
|
+
const bodies = h.requests
|
|
122
|
+
.filter((r) => r.url.endsWith("/chat"))
|
|
123
|
+
.map((r) => r.body);
|
|
124
|
+
expect(bodies[0].clientTools).toEqual(tools);
|
|
125
|
+
expect(bodies[1].clientTools).toBeUndefined();
|
|
126
|
+
expect(bodies[2].clientTools).toEqual(tools);
|
|
127
|
+
expect(bodies[2].clientToolsFingerprint).toBe(
|
|
128
|
+
bodies[1].clientToolsFingerprint,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it.each([30_000, -1])(
|
|
133
|
+
"renews a session with %i ms left and preserves visitor-owned conversation without history UI",
|
|
134
|
+
async (milliseconds) => {
|
|
135
|
+
const h = await setup(milliseconds);
|
|
136
|
+
await h.run();
|
|
137
|
+
expect(h.requests.map((r) => r.url)).toEqual([
|
|
138
|
+
"https://example.com/v1/client/init",
|
|
139
|
+
"https://example.com/v1/client/chat",
|
|
140
|
+
]);
|
|
141
|
+
expect(h.requests[0].body).toMatchObject({
|
|
142
|
+
conversationId: "conversation-owned",
|
|
143
|
+
visitorToken: "cvt_owned",
|
|
144
|
+
durableRecovery: true,
|
|
145
|
+
});
|
|
146
|
+
expect(h.requests[0].body).not.toHaveProperty("sessionId");
|
|
147
|
+
expect(h.requests[1].body).toMatchObject({
|
|
148
|
+
sessionId: "session-new",
|
|
149
|
+
assistantMessageId: "assistant-1",
|
|
150
|
+
messages: [{ id: "user-1", content: "continue" }],
|
|
151
|
+
});
|
|
152
|
+
expect(h.requests[1].headers.get("X-Visitor-Token")).toBe("cvt_owned");
|
|
153
|
+
expect(h.onSessionInit).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(h.onSessionExpired).not.toHaveBeenCalled();
|
|
155
|
+
expect(h.setStoredSessionId).toHaveBeenCalledWith("session-new");
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
it("retries only an explicit expired-session rejection with the same payload and turn identity", async () => {
|
|
160
|
+
const h = await setup();
|
|
161
|
+
h.chat.mockImplementationOnce(expired);
|
|
162
|
+
await h.run();
|
|
163
|
+
const bodies = h.requests
|
|
164
|
+
.filter((r) => r.url.endsWith("/chat"))
|
|
165
|
+
.map((r) => r.body);
|
|
166
|
+
expect(bodies).toHaveLength(2);
|
|
167
|
+
expect(bodies[1]).toEqual({ ...bodies[0], sessionId: "session-new" });
|
|
168
|
+
expect(h.onSessionExpired).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("stops after a second expired-session rejection", async () => {
|
|
172
|
+
const h = await setup();
|
|
173
|
+
h.chat.mockImplementation(expired);
|
|
174
|
+
await expect(h.run()).rejects.toThrow("Session expired");
|
|
175
|
+
expect(h.chat).toHaveBeenCalledTimes(2);
|
|
176
|
+
expect(h.initialize).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(h.onSessionExpired).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not retry an unrelated unauthorized response", async () => {
|
|
181
|
+
const h = await setup();
|
|
182
|
+
h.chat.mockImplementation(() =>
|
|
183
|
+
Response.json({ error: "Unauthorized" }, { status: 401 }),
|
|
184
|
+
);
|
|
185
|
+
await expect(h.run()).rejects.toThrow("Session expired");
|
|
186
|
+
expect(h.chat).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("does not renew or replay after cancellation during the rejected request", async () => {
|
|
191
|
+
const h = await setup();
|
|
192
|
+
const abort = new AbortController();
|
|
193
|
+
h.chat.mockImplementation(() => {
|
|
194
|
+
abort.abort();
|
|
195
|
+
return expired();
|
|
196
|
+
});
|
|
197
|
+
await expect(h.run(abort.signal)).rejects.toMatchObject({
|
|
198
|
+
name: "AbortError",
|
|
199
|
+
});
|
|
200
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
201
|
+
expect(h.chat).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not install a renewal or post a message after cancellation during init", async () => {
|
|
205
|
+
const h = await setup(30_000);
|
|
206
|
+
const abort = new AbortController();
|
|
207
|
+
h.initialize.mockImplementation(() => {
|
|
208
|
+
abort.abort();
|
|
209
|
+
return Response.json({ ...session(), sessionId: "session-new" });
|
|
210
|
+
});
|
|
211
|
+
await expect(h.run(abort.signal)).rejects.toMatchObject({
|
|
212
|
+
name: "AbortError",
|
|
213
|
+
});
|
|
214
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
215
|
+
expect(h.onSessionInit).not.toHaveBeenCalled();
|
|
216
|
+
expect(h.setStoredSessionId).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does not fork a conversation when renewal cannot reopen it", async () => {
|
|
220
|
+
const h = await setup(30_000);
|
|
221
|
+
h.initialize.mockImplementation(() =>
|
|
222
|
+
Response.json({ error: "not_found" }, { status: 404 }),
|
|
223
|
+
);
|
|
224
|
+
await expect(h.run()).rejects.toThrow("Conversation not found");
|
|
225
|
+
expect(h.initialize).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects a server response that switches conversations", async () => {
|
|
230
|
+
const h = await setup(30_000);
|
|
231
|
+
h.initialize.mockImplementation(() =>
|
|
232
|
+
Response.json({ ...session(), conversationId: "different" }),
|
|
233
|
+
);
|
|
234
|
+
await expect(h.run()).rejects.toThrow("did not preserve");
|
|
235
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
236
|
+
expect(h.onSessionInit).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("does not renew a durable conversation without visitor proof", async () => {
|
|
240
|
+
const h = await setup(30_000);
|
|
241
|
+
await h.store.clear();
|
|
242
|
+
await expect(h.run()).rejects.toThrow("visitor credential");
|
|
243
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
244
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("suppresses a superseded turn while its expired-session response is pending", async () => {
|
|
248
|
+
const h = await setup();
|
|
249
|
+
let rejectOld!: (response: Response) => void;
|
|
250
|
+
let started!: () => void;
|
|
251
|
+
const firstStarted = new Promise<void>((resolve) => {
|
|
252
|
+
started = resolve;
|
|
253
|
+
});
|
|
254
|
+
h.chat.mockImplementationOnce(() => {
|
|
255
|
+
started();
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
rejectOld = resolve;
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
const first = h.run();
|
|
261
|
+
await firstStarted;
|
|
262
|
+
await h.run();
|
|
263
|
+
rejectOld(expired());
|
|
264
|
+
await expect(first).rejects.toMatchObject({ name: "AbortError" });
|
|
265
|
+
expect(h.initialize).not.toHaveBeenCalled();
|
|
266
|
+
expect(h.chat).toHaveBeenCalledTimes(2);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -1080,3 +1080,89 @@ describe('client visitor history - dedupe', () => {
|
|
|
1080
1080
|
expect(h.sessionInits).toHaveLength(1);
|
|
1081
1081
|
});
|
|
1082
1082
|
});
|
|
1083
|
+
|
|
1084
|
+
describe('voice integration visitor credential access', () => {
|
|
1085
|
+
const controllers: ReturnType<typeof createAgentExperience>[] = [];
|
|
1086
|
+
const mountWidget = (config: Partial<AgentWidgetConfig> = {}) => {
|
|
1087
|
+
const mount = document.createElement('div');
|
|
1088
|
+
document.body.appendChild(mount);
|
|
1089
|
+
const controller = createAgentExperience(mount, {
|
|
1090
|
+
apiUrl: API_URL,
|
|
1091
|
+
clientToken: CLIENT_TOKEN,
|
|
1092
|
+
launcher: { enabled: false },
|
|
1093
|
+
...config,
|
|
1094
|
+
});
|
|
1095
|
+
controllers.push(controller);
|
|
1096
|
+
return controller;
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
afterEach(() => {
|
|
1100
|
+
controllers.forEach((controller) => controller.destroy());
|
|
1101
|
+
controllers.length = 0;
|
|
1102
|
+
document.body.innerHTML = '';
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it('reads minted proof after the claim response omits it without enriching the callback', async () => {
|
|
1106
|
+
const minted = { id: 'vis_1', token: 'cvt_minted', expiresAt: futureIso(), endUserId: null };
|
|
1107
|
+
installFetch([
|
|
1108
|
+
ok({ sessionId: 'sess_first', visitor: minted }),
|
|
1109
|
+
ok({ sessionId: 'sess_claimed', visitor: { ...minted, token: undefined } }),
|
|
1110
|
+
]);
|
|
1111
|
+
const onSessionInit = vi.fn();
|
|
1112
|
+
const controller = mountWidget({ persistState: false, onSessionInit });
|
|
1113
|
+
await vi.waitFor(() => expect(onSessionInit).toHaveBeenCalledOnce());
|
|
1114
|
+
expect(onSessionInit.mock.calls[0][0].visitor.token).toBeUndefined();
|
|
1115
|
+
await expect(controller.getVisitorToken()).resolves.toBe('cvt_minted');
|
|
1116
|
+
expect(JSON.stringify(controller.getState())).not.toContain('cvt_minted');
|
|
1117
|
+
expect(window.localStorage.getItem(await storageKeyFor())).toBeNull();
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('reads the current persisted credential after reload and after rotation or removal', async () => {
|
|
1121
|
+
await seedToken('cvt_stored');
|
|
1122
|
+
installFetch([ok()]);
|
|
1123
|
+
const controller = mountWidget();
|
|
1124
|
+
await expect(controller.getVisitorToken()).resolves.toBe('cvt_stored');
|
|
1125
|
+
await seedToken('cvt_rotated');
|
|
1126
|
+
await expect(controller.getVisitorToken()).resolves.toBe('cvt_rotated');
|
|
1127
|
+
window.localStorage.removeItem(await storageKeyFor());
|
|
1128
|
+
await expect(controller.getVisitorToken()).resolves.toBeNull();
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('does not return credentials from a replaced client-token scope', async () => {
|
|
1132
|
+
await seedToken('cvt_stored');
|
|
1133
|
+
installFetch([ok(), ok()]);
|
|
1134
|
+
const controller = mountWidget();
|
|
1135
|
+
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
|
1136
|
+
const pending = controller.getVisitorToken();
|
|
1137
|
+
controller.update({ clientToken: 'ct_other' });
|
|
1138
|
+
await expect(pending).resolves.toBeNull();
|
|
1139
|
+
await expect(controller.getVisitorToken()).resolves.toBeNull();
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it('returns null after teardown, including a read already in flight', async () => {
|
|
1143
|
+
await seedToken('cvt_stored');
|
|
1144
|
+
installFetch([ok()]);
|
|
1145
|
+
const controller = mountWidget();
|
|
1146
|
+
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
|
1147
|
+
const pending = controller.getVisitorToken();
|
|
1148
|
+
controller.destroy();
|
|
1149
|
+
controllers.length = 0;
|
|
1150
|
+
await expect(pending).resolves.toBeNull();
|
|
1151
|
+
await expect(controller.getVisitorToken()).resolves.toBeNull();
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it('returns null when no credential has been minted', async () => {
|
|
1155
|
+
installFetch([ok()]);
|
|
1156
|
+
const controller = mountWidget({ persistState: false });
|
|
1157
|
+
await expect(controller.getVisitorToken()).resolves.toBeNull();
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('does not fetch or mint a visitor outside client-token mode', async () => {
|
|
1161
|
+
const fetch = vi.fn();
|
|
1162
|
+
vi.stubGlobal('fetch', fetch);
|
|
1163
|
+
const controller = mountWidget({ clientToken: undefined });
|
|
1164
|
+
await expect(controller.getVisitorToken()).resolves.toBeNull();
|
|
1165
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
1166
|
+
vi.unstubAllGlobals();
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
@@ -111,7 +111,7 @@ describe("client-token turn identity", () => {
|
|
|
111
111
|
);
|
|
112
112
|
// Let the first dispatch claim the turn and start streaming.
|
|
113
113
|
await vi.waitFor(() =>
|
|
114
|
-
expect(firstEvents.some((e) => e.type === "status")).toBe(true)
|
|
114
|
+
expect(firstEvents.some((e) => e.type === "status" && e.status === "connected")).toBe(true)
|
|
115
115
|
);
|
|
116
116
|
|
|
117
117
|
const secondDispatch = client.dispatch({ messages, interrupt: true }, (event) =>
|
package/src/client.ts
CHANGED
|
@@ -755,6 +755,7 @@ export class AgentWidgetClient {
|
|
|
755
755
|
identityProof?: string | null;
|
|
756
756
|
storedSessionId?: string | null;
|
|
757
757
|
omitVisitorFields?: boolean;
|
|
758
|
+
signal?: AbortSignal;
|
|
758
759
|
}): Promise<ClientSession> {
|
|
759
760
|
const historyCapable = this.isHistoryCapable() && !opts.omitVisitorFields;
|
|
760
761
|
// Recovery is negotiated independently from the history UI. New servers
|
|
@@ -792,6 +793,7 @@ export class AgentWidgetClient {
|
|
|
792
793
|
: {}),
|
|
793
794
|
};
|
|
794
795
|
|
|
796
|
+
opts.signal?.throwIfAborted();
|
|
795
797
|
const response = await fetch(this.getClientApiUrl('init'), {
|
|
796
798
|
method: 'POST',
|
|
797
799
|
headers: {
|
|
@@ -799,6 +801,7 @@ export class AgentWidgetClient {
|
|
|
799
801
|
'X-Persona-Version': VERSION,
|
|
800
802
|
},
|
|
801
803
|
body: JSON.stringify(requestBody),
|
|
804
|
+
signal: opts.signal,
|
|
802
805
|
});
|
|
803
806
|
|
|
804
807
|
if (!response.ok) {
|
|
@@ -1805,25 +1808,44 @@ export class AgentWidgetClient {
|
|
|
1805
1808
|
onEvent({ type: "status", status: "connecting" });
|
|
1806
1809
|
|
|
1807
1810
|
try {
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
|
|
1811
|
+
const assertCurrentTurn = () => {
|
|
1812
|
+
options.signal?.throwIfAborted();
|
|
1813
|
+
if (!isCurrentTurn()) throw new DOMException('Turn superseded', 'AbortError');
|
|
1814
|
+
};
|
|
1815
|
+
let session = this.clientSession ?? (await this.initSession());
|
|
1816
|
+
assertCurrentTurn();
|
|
1817
|
+
const renewSession = async () => {
|
|
1818
|
+
assertCurrentTurn();
|
|
1819
|
+
const previous = session;
|
|
1820
|
+
const durable = previous.durableRecovery?.enabled === true;
|
|
1821
|
+
if (durable && (!previous.conversationId || !(await this.readVisitorToken()))) {
|
|
1822
|
+
throw new Error('Renewing this conversation requires its visitor credential.');
|
|
1823
|
+
}
|
|
1824
|
+
assertCurrentTurn();
|
|
1825
|
+
const renewed = await this.createClientSession({
|
|
1826
|
+
...(durable || this.isHistoryCapable()
|
|
1827
|
+
? { conversationId: previous.conversationId, durableResume: durable }
|
|
1828
|
+
: { storedSessionId: previous.sessionId }),
|
|
1829
|
+
signal: options.signal,
|
|
1830
|
+
});
|
|
1831
|
+
assertCurrentTurn();
|
|
1832
|
+
if (
|
|
1833
|
+
(previous.conversationId && renewed.conversationId !== previous.conversationId) ||
|
|
1834
|
+
(durable && renewed.durableRecovery?.enabled !== true)
|
|
1835
|
+
) {
|
|
1836
|
+
throw new Error('Session renewal did not preserve this conversation.');
|
|
1837
|
+
}
|
|
1838
|
+
session = this.finishInit(renewed, previous.conversationId ?? null, false);
|
|
1839
|
+
this.clientSession = session;
|
|
1840
|
+
this.resetClientToolsFingerprint();
|
|
1841
|
+
this.config.onSessionInit?.(session);
|
|
1842
|
+
};
|
|
1843
|
+
if (Date.now() >= session.expiresAt.getTime() - 60000) {
|
|
1844
|
+
await renewSession();
|
|
1819
1845
|
}
|
|
1820
1846
|
|
|
1821
1847
|
// Build the standard payload to get context/metadata from middleware
|
|
1822
1848
|
const basePayload = await this.buildPayload(options.messages);
|
|
1823
|
-
const recoveryVisitorToken =
|
|
1824
|
-
session.durableRecovery?.enabled === true
|
|
1825
|
-
? await this.readVisitorToken()
|
|
1826
|
-
: null;
|
|
1827
1849
|
|
|
1828
1850
|
// Build the chat request payload with message IDs for feedback tracking
|
|
1829
1851
|
// Filter out sessionId from metadata if present (it's only for local storage)
|
|
@@ -1869,9 +1891,17 @@ export class AgentWidgetClient {
|
|
|
1869
1891
|
// ship it again or just its fingerprint (retrying once on a 409 registry
|
|
1870
1892
|
// miss). The cache is committed only after a successful stream start
|
|
1871
1893
|
// (below), so a 409/failure leaves it untouched.
|
|
1872
|
-
const
|
|
1873
|
-
|
|
1874
|
-
|
|
1894
|
+
const send = async () => {
|
|
1895
|
+
assertCurrentTurn();
|
|
1896
|
+
const recoveryVisitorToken =
|
|
1897
|
+
session.durableRecovery?.enabled === true ? await this.readVisitorToken() : null;
|
|
1898
|
+
assertCurrentTurn();
|
|
1899
|
+
return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
|
|
1900
|
+
const chatRequest: ClientChatRequest = {
|
|
1901
|
+
...baseChatRequest,
|
|
1902
|
+
sessionId: session.sessionId,
|
|
1903
|
+
...toolFields,
|
|
1904
|
+
};
|
|
1875
1905
|
|
|
1876
1906
|
if (this.debug) {
|
|
1877
1907
|
// eslint-disable-next-line no-console
|
|
@@ -1891,6 +1921,19 @@ export class AgentWidgetClient {
|
|
|
1891
1921
|
signal: options.signal,
|
|
1892
1922
|
});
|
|
1893
1923
|
});
|
|
1924
|
+
};
|
|
1925
|
+
let result = await send();
|
|
1926
|
+
assertCurrentTurn();
|
|
1927
|
+
if (result.response.status === 401) {
|
|
1928
|
+
const rejection = await result.response.clone().json().catch(() => null);
|
|
1929
|
+
assertCurrentTurn();
|
|
1930
|
+
if (rejection?.error === 'Session not found or expired') {
|
|
1931
|
+
await renewSession();
|
|
1932
|
+
result = await send();
|
|
1933
|
+
assertCurrentTurn();
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
const { response, commit: commitClientToolsFingerprint } = result;
|
|
1894
1937
|
|
|
1895
1938
|
if (!response.ok) {
|
|
1896
1939
|
const data = await response.json().catch(() => ({ error: 'Chat request failed' }));
|
package/src/launcher-global.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* EXTRA-tier icon (`launcher.agentIconName: "shopping-cart"`) paints a
|
|
14
14
|
* correctly-sized empty placeholder and fills within one round-trip of the
|
|
15
15
|
* `icons-extra.js` sibling chunk (loader registered below). See the
|
|
16
|
-
* "Deferred Launcher Loading" pattern in
|
|
16
|
+
* "Deferred Launcher Loading" pattern in AGENTS.md.
|
|
17
17
|
*
|
|
18
18
|
* Public global (via tsup `--global-name AgentWidgetLauncher`):
|
|
19
19
|
*
|
package/src/ui.ts
CHANGED
|
@@ -731,6 +731,12 @@ type Controller = {
|
|
|
731
731
|
* Required host logout wiring.
|
|
732
732
|
*/
|
|
733
733
|
resetHistoryIdentity: () => Promise<{ remoteRevocationConfirmed: boolean }>;
|
|
734
|
+
/**
|
|
735
|
+
* Read the current browser visitor credential for a trusted voice integration.
|
|
736
|
+
* Returns null before minting, outside client-token mode, or after teardown.
|
|
737
|
+
* Never put this bearer credential in logs, URLs, or model context.
|
|
738
|
+
*/
|
|
739
|
+
getVisitorToken: () => Promise<string | null>;
|
|
734
740
|
/** Latest sanitized identity state; carries no token, proof, or identity id. */
|
|
735
741
|
getHistoryIdentityStatus: () => HistoryIdentityStatus;
|
|
736
742
|
/** `focus: false` keeps focus where it is (programmatic/preview opens). */
|
|
@@ -8982,6 +8988,7 @@ export const createAgentExperience = (
|
|
|
8982
8988
|
destroyCallbacks.push(() => {
|
|
8983
8989
|
session.destroy();
|
|
8984
8990
|
visitorStore?.destroy();
|
|
8991
|
+
visitorStore = null;
|
|
8985
8992
|
});
|
|
8986
8993
|
|
|
8987
8994
|
// Mirror read-aloud playback state into the action buttons, and surface it as
|
|
@@ -15215,6 +15222,12 @@ export const createAgentExperience = (
|
|
|
15215
15222
|
resetHistoryIdentity(): Promise<{ remoteRevocationConfirmed: boolean }> {
|
|
15216
15223
|
return resetHistoryIdentity();
|
|
15217
15224
|
},
|
|
15225
|
+
async getVisitorToken(): Promise<string | null> {
|
|
15226
|
+
const store = visitorStore;
|
|
15227
|
+
if (!store) return null;
|
|
15228
|
+
const token = await store.get();
|
|
15229
|
+
return store === visitorStore ? token : null;
|
|
15230
|
+
},
|
|
15218
15231
|
getHistoryIdentityStatus(): HistoryIdentityStatus {
|
|
15219
15232
|
if (!historyProvider) {
|
|
15220
15233
|
return historyFeatureEnabled()
|