@runtypelabs/persona 4.17.0 → 4.18.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.
Files changed (165) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,596 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4
+
5
+ import { AgentWidgetClient, HistoryClientError } from './client';
6
+ import { createAgentExperience } from './ui';
7
+ import { visitorStoreKeys, createVisitorStore, type VisitorStore } from './utils/visitor-store';
8
+ import type {
9
+ AgentWidgetConfig,
10
+ AgentWidgetStoredState,
11
+ ClientInitResponse,
12
+ ClientSession,
13
+ WidgetHistoryInternals,
14
+ } from './types';
15
+
16
+ const CLIENT_TOKEN = 'ct_live_history';
17
+ const KEY_PREFIX = 'persona-';
18
+ const API_URL = 'https://api.runtype.com';
19
+ const INIT_URL = `${API_URL}/v1/client/init`;
20
+
21
+ type RecordedRequest = {
22
+ url: string;
23
+ body: Record<string, unknown>;
24
+ headers: Record<string, string>;
25
+ };
26
+
27
+ type FetchResult = { ok: boolean; status: number; json: () => Promise<unknown> };
28
+
29
+ const futureIso = () => new Date(Date.now() + 5 * 60_000).toISOString();
30
+
31
+ const initBody = (overrides: Partial<ClientInitResponse> = {}): ClientInitResponse => ({
32
+ sessionId: 'sess_1',
33
+ expiresAt: futureIso(),
34
+ flow: { id: 'flow_1', name: 'Flow', description: null },
35
+ conversationId: 'conv_1',
36
+ targetId: 'flow_1',
37
+ conversationRevision: 'rev_1',
38
+ config: { welcomeMessage: null, placeholder: 'Ask...', theme: null },
39
+ ...overrides,
40
+ });
41
+
42
+ const ok = (overrides: Partial<ClientInitResponse> = {}): (() => FetchResult) => () => ({
43
+ ok: true,
44
+ status: 200,
45
+ json: async () => initBody(overrides),
46
+ });
47
+
48
+ const fail = (status: number, payload: Record<string, unknown>): (() => FetchResult) => () => ({
49
+ ok: false,
50
+ status,
51
+ json: async () => payload,
52
+ });
53
+
54
+ let requests: RecordedRequest[] = [];
55
+
56
+ /** Serves `queue` in order; an extra request is a test failure, not a hang. */
57
+ const installFetch = (queue: Array<() => FetchResult | Promise<FetchResult>>) => {
58
+ global.fetch = vi.fn(async (url: unknown, options: { body: string; headers: Record<string, string> }) => {
59
+ requests.push({
60
+ url: String(url),
61
+ body: JSON.parse(options.body) as Record<string, unknown>,
62
+ headers: options.headers,
63
+ });
64
+ const next = queue.shift();
65
+ if (!next) throw new Error(`Unexpected fetch: ${String(url)}`);
66
+ return next();
67
+ }) as unknown as typeof fetch;
68
+ };
69
+
70
+ const storageKeyFor = async (clientToken = CLIENT_TOKEN, prefix = KEY_PREFIX) =>
71
+ (await visitorStoreKeys(clientToken, prefix)).storageKey;
72
+
73
+ const seedToken = async (token: string) => {
74
+ window.localStorage.setItem(await storageKeyFor(), token);
75
+ };
76
+
77
+ const readStoredToken = async () => window.localStorage.getItem(await storageKeyFor());
78
+
79
+ type Harness = {
80
+ client: AgentWidgetClient;
81
+ store: VisitorStore;
82
+ storedSessionId: string | null;
83
+ storedConversationId: string | null;
84
+ storedRevision: string | null;
85
+ sessionInits: ClientSession[];
86
+ continuity: Array<{ previousConversationId: string | null; conversationId: string }>;
87
+ availability: boolean[];
88
+ writes: string[];
89
+ };
90
+
91
+ const makeClient = (options: {
92
+ history?: boolean;
93
+ storedSessionId?: string | null;
94
+ storedConversationId?: string | null;
95
+ historyBootstrapReady?: Promise<void>;
96
+ config?: Partial<AgentWidgetConfig>;
97
+ }): Harness => {
98
+ const store = createVisitorStore(CLIENT_TOKEN, KEY_PREFIX, false);
99
+ const harness: Partial<Harness> = {
100
+ store,
101
+ storedSessionId: options.storedSessionId ?? null,
102
+ storedConversationId: options.storedConversationId ?? null,
103
+ storedRevision: null,
104
+ sessionInits: [],
105
+ continuity: [],
106
+ availability: [],
107
+ writes: [],
108
+ };
109
+ const internals: WidgetHistoryInternals = {
110
+ visitorStore: store,
111
+ ...(options.historyBootstrapReady
112
+ ? { historyBootstrapReady: options.historyBootstrapReady }
113
+ : {}),
114
+ setStoredConversationRevision: (revision) => {
115
+ harness.storedRevision = revision;
116
+ harness.writes!.push(`revision:${revision}`);
117
+ },
118
+ onHistoryAvailabilityChanged: (available) => harness.availability!.push(available),
119
+ onHistoryContinuityChanged: (info) => {
120
+ harness.continuity!.push(info);
121
+ harness.writes!.push(`continuity:${info.previousConversationId}->${info.conversationId}`);
122
+ },
123
+ };
124
+ const config: AgentWidgetConfig = {
125
+ apiUrl: API_URL,
126
+ clientToken: CLIENT_TOKEN,
127
+ ...(options.history === false ? {} : { features: { history: { enabled: true } } }),
128
+ getStoredSessionId: () => harness.storedSessionId ?? null,
129
+ setStoredSessionId: (sessionId) => {
130
+ harness.storedSessionId = sessionId;
131
+ harness.writes!.push(`session:${sessionId}`);
132
+ },
133
+ getStoredConversationId: () => harness.storedConversationId ?? null,
134
+ setStoredConversationId: (conversationId) => {
135
+ harness.storedConversationId = conversationId;
136
+ harness.writes!.push(`conversation:${conversationId}`);
137
+ },
138
+ clearStoredConversationId: () => {
139
+ harness.storedConversationId = null;
140
+ },
141
+ onSessionInit: (session) => harness.sessionInits!.push(session),
142
+ ...options.config,
143
+ };
144
+ harness.client = new AgentWidgetClient(config, internals);
145
+ return harness as Harness;
146
+ };
147
+
148
+ beforeEach(() => {
149
+ requests = [];
150
+ window.localStorage.clear();
151
+ });
152
+
153
+ afterEach(() => {
154
+ vi.restoreAllMocks();
155
+ });
156
+
157
+ describe('client visitor history - init capability shape', () => {
158
+ it('sends visitorHistory, the awaited stored token, and the stored session id', async () => {
159
+ await seedToken('cvt_stored');
160
+ installFetch([ok()]);
161
+ const h = makeClient({ storedSessionId: 'sess_old' });
162
+
163
+ await h.client.initSession();
164
+
165
+ expect(requests).toHaveLength(1);
166
+ expect(requests[0]).toMatchObject({
167
+ url: INIT_URL,
168
+ body: {
169
+ token: CLIENT_TOKEN,
170
+ visitorHistory: true,
171
+ visitorToken: 'cvt_stored',
172
+ sessionId: 'sess_old',
173
+ },
174
+ });
175
+ expect(requests[0].body).not.toHaveProperty('conversationId');
176
+ });
177
+
178
+ it('omits every visitor field when history is disabled', async () => {
179
+ await seedToken('cvt_stored');
180
+ installFetch([ok()]);
181
+ const h = makeClient({ history: false, storedSessionId: 'sess_old' });
182
+
183
+ await h.client.initSession();
184
+
185
+ expect(requests[0].body).toEqual({
186
+ token: CLIENT_TOKEN,
187
+ sessionId: 'sess_old',
188
+ });
189
+ });
190
+
191
+ it('normalizes targetId, preferring the top-level field over flow.id', async () => {
192
+ installFetch([
193
+ ok({ targetId: 'agent_9' }),
194
+ ok({ sessionId: 'sess_2', targetId: undefined, flow: { id: 'flow_7', name: 'F', description: null } }),
195
+ ]);
196
+ const first = makeClient({});
197
+ const second = makeClient({});
198
+
199
+ const a = await first.client.initSession();
200
+ const b = await second.client.initSession();
201
+
202
+ expect(a.targetId).toBe('agent_9');
203
+ expect(a.flow.id).toBe('flow_1');
204
+ expect(b.targetId).toBe('flow_7');
205
+ });
206
+ });
207
+
208
+ describe('client visitor history - mint persistence and immediate claim', () => {
209
+ const mint = { id: 'vis_1', token: 'cvt_new', expiresAt: futureIso(), endUserId: null };
210
+
211
+ it('persists a minted token then performs exactly one claim init', async () => {
212
+ installFetch([
213
+ ok({ sessionId: 'sess_first', visitor: mint }),
214
+ ok({ sessionId: 'sess_claimed', conversationId: 'conv_1', conversationRevision: 'rev_2' }),
215
+ ]);
216
+ const h = makeClient({ storedSessionId: null });
217
+
218
+ const session = await h.client.initSession();
219
+
220
+ expect(await readStoredToken()).toBe('cvt_new');
221
+ expect(requests).toHaveLength(2);
222
+ expect(requests[0].body).not.toHaveProperty('visitorToken');
223
+ expect(requests[1].body).toMatchObject({
224
+ visitorHistory: true,
225
+ visitorToken: 'cvt_new',
226
+ sessionId: 'sess_first',
227
+ });
228
+ expect(session.sessionId).toBe('sess_claimed');
229
+ expect(h.client.getClientSession()?.sessionId).toBe('sess_claimed');
230
+ expect(h.sessionInits).toHaveLength(1);
231
+ expect(h.sessionInits[0].sessionId).toBe('sess_claimed');
232
+ expect(h.storedSessionId).toBe('sess_claimed');
233
+ expect(h.storedRevision).toBe('rev_2');
234
+ });
235
+
236
+ it('leaves storage untouched when the response carries no token key', async () => {
237
+ installFetch([ok({ visitor: { id: 'vis_1', expiresAt: futureIso(), endUserId: null } })]);
238
+ const h = makeClient({});
239
+
240
+ await h.client.initSession();
241
+
242
+ expect(await readStoredToken()).toBeNull();
243
+ expect(requests).toHaveLength(1);
244
+ });
245
+
246
+ it('falls back to the first session when the claim init fails, without a retry loop', async () => {
247
+ installFetch([
248
+ ok({ sessionId: 'sess_first', visitor: mint }),
249
+ fail(500, { error: 'boom' }),
250
+ ]);
251
+ const h = makeClient({});
252
+
253
+ const session = await h.client.initSession();
254
+
255
+ expect(requests).toHaveLength(2);
256
+ expect(session.sessionId).toBe('sess_first');
257
+ expect(h.client.getClientSession()?.sessionId).toBe('sess_first');
258
+ expect(h.sessionInits).toHaveLength(1);
259
+ expect(await readStoredToken()).toBe('cvt_new');
260
+ });
261
+ });
262
+
263
+ describe('client visitor history - bootstrap gate', () => {
264
+ it('performs no fetch until historyBootstrapReady resolves', async () => {
265
+ let release!: () => void;
266
+ const gate = new Promise<void>((resolve) => {
267
+ release = resolve;
268
+ });
269
+ installFetch([ok()]);
270
+ const h = makeClient({ historyBootstrapReady: gate, storedConversationId: 'conv_1' });
271
+
272
+ const eager = h.client.initSession();
273
+ const first = h.client.initSession();
274
+ await Promise.resolve();
275
+ await Promise.resolve();
276
+
277
+ expect(requests).toHaveLength(0);
278
+
279
+ release();
280
+ await Promise.all([eager, first]);
281
+
282
+ expect(requests).toHaveLength(1);
283
+ });
284
+
285
+ it('reads the persisted conversation id only after the gate resolves', async () => {
286
+ await seedToken('cvt_stored');
287
+ let release!: () => void;
288
+ const gate = new Promise<void>((resolve) => {
289
+ release = resolve;
290
+ });
291
+ installFetch([ok()]);
292
+ const h = makeClient({ historyBootstrapReady: gate });
293
+
294
+ const pending = h.client.initSession();
295
+ await Promise.resolve();
296
+ // Late-arriving stored state, exactly what an async storage adapter does.
297
+ h.storedConversationId = 'conv_late';
298
+ release();
299
+ await pending;
300
+
301
+ expect(requests[0].body).toMatchObject({ conversationId: 'conv_late' });
302
+ });
303
+ });
304
+
305
+ describe('client visitor history - boot resume', () => {
306
+ it('resumes by conversation id and never sends the stored session id', async () => {
307
+ await seedToken('cvt_stored');
308
+ installFetch([ok({ sessionId: 'sess_resumed' })]);
309
+ const h = makeClient({ storedSessionId: 'sess_old', storedConversationId: 'conv_1' });
310
+
311
+ await h.client.initSession();
312
+
313
+ expect(requests[0].body).toEqual({
314
+ token: CLIENT_TOKEN,
315
+ visitorHistory: true,
316
+ visitorToken: 'cvt_stored',
317
+ conversationId: 'conv_1',
318
+ });
319
+ expect(h.continuity).toHaveLength(0);
320
+ expect(h.storedRevision).toBe('rev_1');
321
+ });
322
+
323
+ it('replays the stored session id when no conversation id is persisted', async () => {
324
+ await seedToken('cvt_stored');
325
+ installFetch([ok()]);
326
+ const h = makeClient({ storedSessionId: 'sess_old' });
327
+
328
+ await h.client.initSession();
329
+
330
+ expect(requests[0].body).toMatchObject({ sessionId: 'sess_old' });
331
+ expect(requests[0].body).not.toHaveProperty('conversationId');
332
+ });
333
+
334
+ it('falls back exactly once on a resume 404 and reports the continuity break', async () => {
335
+ await seedToken('cvt_stored');
336
+ installFetch([
337
+ fail(404, { error: 'Conversation not found' }),
338
+ ok({ sessionId: 'sess_new', conversationId: 'conv_2' }),
339
+ ]);
340
+ const h = makeClient({ storedSessionId: 'sess_old', storedConversationId: 'conv_1' });
341
+
342
+ const session = await h.client.initSession();
343
+
344
+ expect(requests).toHaveLength(2);
345
+ expect(requests[1].body).toMatchObject({ sessionId: 'sess_old' });
346
+ expect(requests[1].body).not.toHaveProperty('conversationId');
347
+ expect(session.conversationId).toBe('conv_2');
348
+ expect(h.continuity).toEqual([
349
+ { previousConversationId: 'conv_1', conversationId: 'conv_2' },
350
+ ]);
351
+ });
352
+
353
+ it('falls back exactly once on a resume 401 visitor_required', async () => {
354
+ await seedToken('cvt_stale');
355
+ installFetch([
356
+ fail(401, { error: 'visitor_required' }),
357
+ ok({ sessionId: 'sess_new', conversationId: 'conv_2' }),
358
+ ]);
359
+ const h = makeClient({ storedConversationId: 'conv_1' });
360
+
361
+ await h.client.initSession();
362
+
363
+ expect(requests).toHaveLength(2);
364
+ expect(h.continuity).toHaveLength(1);
365
+ });
366
+
367
+ it('does not resume without a stored visitor token', async () => {
368
+ installFetch([ok()]);
369
+ const h = makeClient({ storedSessionId: 'sess_old', storedConversationId: 'conv_1' });
370
+
371
+ await h.client.initSession();
372
+
373
+ expect(requests[0].body).not.toHaveProperty('conversationId');
374
+ expect(requests[0].body).toMatchObject({ sessionId: 'sess_old' });
375
+ });
376
+ });
377
+
378
+ describe('client visitor history - continuity guard', () => {
379
+ it('announces a differing conversation id before persisting it', async () => {
380
+ installFetch([ok({ conversationId: 'conv_b', conversationRevision: 'rev_b' })]);
381
+ const h = makeClient({ storedConversationId: 'conv_a' });
382
+
383
+ await h.client.initSession();
384
+
385
+ expect(h.continuity).toEqual([
386
+ { previousConversationId: 'conv_a', conversationId: 'conv_b' },
387
+ ]);
388
+ expect(h.writes.indexOf('continuity:conv_a->conv_b')).toBeLessThan(
389
+ h.writes.indexOf('conversation:conv_b')
390
+ );
391
+ expect(h.storedConversationId).toBe('conv_b');
392
+ expect(h.storedRevision).toBe('rev_b');
393
+ });
394
+
395
+ it('stays quiet and just refreshes the revision when the same id comes back', async () => {
396
+ await seedToken('cvt_stored');
397
+ installFetch([ok({ conversationId: 'conv_a', conversationRevision: 'rev_9' })]);
398
+ const h = makeClient({ storedConversationId: 'conv_a' });
399
+
400
+ await h.client.initSession();
401
+
402
+ expect(h.continuity).toHaveLength(0);
403
+ expect(h.storedRevision).toBe('rev_9');
404
+ });
405
+ });
406
+
407
+ describe('client visitor history - prepared sessions', () => {
408
+ it('bypasses a live cached session and sends no session id', async () => {
409
+ await seedToken('cvt_stored');
410
+ installFetch([ok({ sessionId: 'sess_active' }), ok({ sessionId: 'sess_prepared' })]);
411
+ const h = makeClient({ storedSessionId: 'sess_old' });
412
+
413
+ await h.client.initSession();
414
+ const prepared = await h.client.prepareConversationSession('conv_other');
415
+
416
+ expect(requests).toHaveLength(2);
417
+ expect(requests[1].body).toEqual({
418
+ token: CLIENT_TOKEN,
419
+ visitorHistory: true,
420
+ visitorToken: 'cvt_stored',
421
+ conversationId: 'conv_other',
422
+ });
423
+ expect(prepared.session.sessionId).toBe('sess_prepared');
424
+ // Nothing installed and no id persisted until commit.
425
+ expect(h.client.getClientSession()?.sessionId).toBe('sess_active');
426
+ expect(h.storedConversationId).toBe('conv_1');
427
+ expect(h.storedSessionId).toBe('sess_active');
428
+
429
+ prepared.commit();
430
+ expect(h.client.getClientSession()?.sessionId).toBe('sess_prepared');
431
+ expect(h.storedConversationId).toBe('conv_1');
432
+ });
433
+
434
+ it('discard is inert and idempotent', async () => {
435
+ await seedToken('cvt_stored');
436
+ installFetch([ok({ sessionId: 'sess_active' }), ok({ sessionId: 'sess_prepared' })]);
437
+ const h = makeClient({});
438
+
439
+ await h.client.initSession();
440
+ const prepared = await h.client.prepareConversationSession('conv_other');
441
+ prepared.discard();
442
+ prepared.discard();
443
+ prepared.commit();
444
+
445
+ expect(h.client.getClientSession()?.sessionId).toBe('sess_active');
446
+ });
447
+
448
+ it('sends the identity proof when one is supplied instead of a token', async () => {
449
+ installFetch([ok({ sessionId: 'sess_prepared' })]);
450
+ const h = makeClient({});
451
+
452
+ await h.client.prepareConversationSession('conv_other', { proof: 'rt_eu_abc' });
453
+
454
+ expect(requests[0].body).toMatchObject({
455
+ conversationId: 'conv_other',
456
+ identityProof: 'rt_eu_abc',
457
+ });
458
+ expect(requests[0].body).not.toHaveProperty('sessionId');
459
+ });
460
+
461
+ it('fails locally with a typed error and no fetch when no credential exists', async () => {
462
+ installFetch([]);
463
+ const h = makeClient({});
464
+
465
+ await expect(h.client.prepareConversationSession('conv_other')).rejects.toMatchObject({
466
+ code: 'conversation_credential_missing',
467
+ });
468
+ expect(requests).toHaveLength(0);
469
+ });
470
+
471
+ it('prepares a new conversation without session or conversation ids', async () => {
472
+ await seedToken('cvt_stored');
473
+ installFetch([ok({ sessionId: 'sess_new' })]);
474
+ const h = makeClient({ storedSessionId: 'sess_old', storedConversationId: 'conv_1' });
475
+
476
+ const prepared = await h.client.prepareNewConversationSession();
477
+
478
+ expect(requests[0].body).toEqual({
479
+ token: CLIENT_TOKEN,
480
+ visitorHistory: true,
481
+ visitorToken: 'cvt_stored',
482
+ });
483
+ expect(prepared.session.sessionId).toBe('sess_new');
484
+ expect(h.storedSessionId).toBe('sess_old');
485
+ });
486
+ });
487
+
488
+ describe('client visitor history - 403 degrade', () => {
489
+ it('re-inits once without visitor fields and reports unavailability', async () => {
490
+ await seedToken('cvt_stored');
491
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
492
+ installFetch([
493
+ fail(403, { error: 'visitor_history_disabled' }),
494
+ ok({ sessionId: 'sess_plain' }),
495
+ ok({ sessionId: 'sess_plain_2' }),
496
+ ]);
497
+ const h = makeClient({ storedSessionId: 'sess_old' });
498
+
499
+ const session = await h.client.initSession();
500
+
501
+ expect(requests).toHaveLength(2);
502
+ expect(requests[1].body).toEqual({ token: CLIENT_TOKEN, sessionId: 'sess_old' });
503
+ expect(session.sessionId).toBe('sess_plain');
504
+ expect(h.availability).toEqual([false]);
505
+ expect(warn).toHaveBeenCalledTimes(1);
506
+
507
+ // Latched for the client's lifetime: later inits skip the visitor fields.
508
+ h.client.clearClientSession();
509
+ await h.client.initSession();
510
+ expect(requests[2].body).toEqual({ token: CLIENT_TOKEN, sessionId: 'sess_plain' });
511
+ });
512
+
513
+ it('rejects rather than degrading a conversation resume into another record', async () => {
514
+ await seedToken('cvt_stored');
515
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
516
+ installFetch([fail(403, { error: 'visitor_history_disabled' })]);
517
+ const h = makeClient({});
518
+
519
+ await expect(h.client.prepareConversationSession('conv_other')).rejects.toBeInstanceOf(
520
+ HistoryClientError
521
+ );
522
+ expect(requests).toHaveLength(1);
523
+ expect(h.availability).toEqual([false]);
524
+ });
525
+
526
+ it('keeps the original error text for a non-history 403', async () => {
527
+ installFetch([fail(403, { error: 'origin_not_allowed' })]);
528
+ const h = makeClient({ history: false });
529
+
530
+ await expect(h.client.initSession()).rejects.toThrow(/Origin not allowed/);
531
+ });
532
+ });
533
+
534
+ describe('controller wiring', () => {
535
+ it('gates the eager init on the async storage adapter and threads the visitor store', async () => {
536
+ await seedToken('cvt_stored');
537
+ installFetch([ok({ sessionId: 'sess_ui' })]);
538
+ let releaseLoad!: (state: AgentWidgetStoredState) => void;
539
+ const mount = document.createElement('div');
540
+ document.body.appendChild(mount);
541
+
542
+ const controller = createAgentExperience(mount, {
543
+ apiUrl: API_URL,
544
+ clientToken: CLIENT_TOKEN,
545
+ launcher: { enabled: false },
546
+ features: { history: { enabled: true } },
547
+ storageAdapter: {
548
+ load: () =>
549
+ new Promise<AgentWidgetStoredState>((resolve) => {
550
+ releaseLoad = resolve;
551
+ }),
552
+ save: () => undefined,
553
+ },
554
+ });
555
+
556
+ await Promise.resolve();
557
+ await Promise.resolve();
558
+ expect(requests).toHaveLength(0);
559
+
560
+ releaseLoad({
561
+ messages: [],
562
+ metadata: { sessionId: 'sess_stored', conversationId: 'conv_stored' },
563
+ });
564
+ await vi.waitFor(() => expect(requests).toHaveLength(1));
565
+
566
+ expect(requests[0].body).toEqual({
567
+ token: CLIENT_TOKEN,
568
+ visitorHistory: true,
569
+ visitorToken: 'cvt_stored',
570
+ conversationId: 'conv_stored',
571
+ });
572
+
573
+ controller.destroy();
574
+ mount.remove();
575
+ });
576
+ });
577
+
578
+ describe('client visitor history - dedupe', () => {
579
+ it('deduplicates concurrent initSession calls with the new body fields', async () => {
580
+ await seedToken('cvt_stored');
581
+ installFetch([ok()]);
582
+ const h = makeClient({ storedSessionId: 'sess_old' });
583
+
584
+ const [a, b, c] = await Promise.all([
585
+ h.client.initSession(),
586
+ h.client.initSession(),
587
+ h.client.initSession(),
588
+ ]);
589
+
590
+ expect(requests).toHaveLength(1);
591
+ expect(requests[0].body).toMatchObject({ visitorHistory: true, visitorToken: 'cvt_stored' });
592
+ expect(a).toBe(b);
593
+ expect(b).toBe(c);
594
+ expect(h.sessionInits).toHaveLength(1);
595
+ });
596
+ });