@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,1034 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4
+
5
+ import { AgentWidgetClient } from './client';
6
+ import { createVisitorStore, visitorStoreKeys, type VisitorStore } from './utils/visitor-store';
7
+ import type {
8
+ AgentWidgetConfig,
9
+ AgentWidgetEvent,
10
+ ClientInitResponse,
11
+ ClientVisitorGrant,
12
+ HistoryIdentityStatus,
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
+ const CONVERSATIONS_URL = `${API_URL}/v1/client/conversations`;
21
+
22
+ type RecordedRequest = {
23
+ url: string;
24
+ method: string;
25
+ headers: Record<string, string>;
26
+ body?: Record<string, unknown>;
27
+ keepalive?: boolean;
28
+ };
29
+
30
+ type FakeResponse = {
31
+ ok: boolean;
32
+ status: number;
33
+ headers: { get(name: string): string | null };
34
+ json: () => Promise<unknown>;
35
+ };
36
+
37
+ const futureIso = () => new Date(Date.now() + 5 * 60_000).toISOString();
38
+
39
+ const respond = (
40
+ status: number,
41
+ body: unknown,
42
+ headers: Record<string, string> = {}
43
+ ): FakeResponse => {
44
+ const lowered: Record<string, string> = {};
45
+ for (const [key, value] of Object.entries(headers)) lowered[key.toLowerCase()] = value;
46
+ return {
47
+ ok: status >= 200 && status < 300,
48
+ status,
49
+ headers: { get: (name: string) => lowered[name.toLowerCase()] ?? null },
50
+ json: async () => body,
51
+ };
52
+ };
53
+
54
+ const initBody = (overrides: Partial<ClientInitResponse> = {}): ClientInitResponse => ({
55
+ sessionId: 'sess_1',
56
+ expiresAt: futureIso(),
57
+ flow: { id: 'flow_1', name: 'Flow', description: null },
58
+ conversationId: 'conv_1',
59
+ targetId: 'flow_1',
60
+ conversationRevision: 'rev_1',
61
+ config: { welcomeMessage: null, placeholder: 'Ask...', theme: null },
62
+ ...overrides,
63
+ });
64
+
65
+ const initOk = (overrides: Partial<ClientInitResponse> = {}) => () =>
66
+ respond(200, initBody(overrides));
67
+
68
+ /** Bound visitor: verified operations skip the pre-flight bind init. */
69
+ const boundVisitor: ClientVisitorGrant = {
70
+ id: 'vis_1',
71
+ expiresAt: futureIso(),
72
+ endUserId: 'user_1',
73
+ identityStatus: 'admitted',
74
+ };
75
+
76
+ const listOk = (
77
+ identityStatus: string | null = 'not_provided',
78
+ data: unknown[] = [],
79
+ nextCursor: string | null = null
80
+ ) => () =>
81
+ respond(
82
+ 200,
83
+ { data, nextCursor },
84
+ identityStatus ? { 'X-History-Identity-Status': identityStatus } : {}
85
+ );
86
+
87
+ let requests: RecordedRequest[] = [];
88
+
89
+ /** Serves `queue` in order; an extra request is a test failure, not a hang. */
90
+ const installFetch = (queue: Array<() => FakeResponse | Promise<FakeResponse>>) => {
91
+ global.fetch = vi.fn(
92
+ async (
93
+ url: unknown,
94
+ options: {
95
+ method?: string;
96
+ body?: string;
97
+ headers: Record<string, string>;
98
+ keepalive?: boolean;
99
+ }
100
+ ) => {
101
+ requests.push({
102
+ url: String(url),
103
+ method: options.method ?? 'GET',
104
+ headers: options.headers,
105
+ ...(options.body
106
+ ? { body: JSON.parse(options.body) as Record<string, unknown> }
107
+ : {}),
108
+ ...(options.keepalive !== undefined ? { keepalive: options.keepalive } : {}),
109
+ });
110
+ const next = queue.shift();
111
+ if (!next) throw new Error(`Unexpected fetch: ${String(url)}`);
112
+ return next();
113
+ }
114
+ ) as unknown as typeof fetch;
115
+ };
116
+
117
+ const storageKeyFor = async () => (await visitorStoreKeys(CLIENT_TOKEN, KEY_PREFIX)).storageKey;
118
+
119
+ const seedToken = async (token: string) => {
120
+ window.localStorage.setItem(await storageKeyFor(), token);
121
+ };
122
+
123
+ const readStoredToken = async () => window.localStorage.getItem(await storageKeyFor());
124
+
125
+ /** Cross-tab write: jsdom does not emit `storage` for same-window writes. */
126
+ const externalTokenChange = async (token: string | null) => {
127
+ const key = await storageKeyFor();
128
+ if (token === null) window.localStorage.removeItem(key);
129
+ else window.localStorage.setItem(key, token);
130
+ window.dispatchEvent(new StorageEvent('storage', { key, newValue: token }));
131
+ };
132
+
133
+ type Harness = {
134
+ client: AgentWidgetClient;
135
+ store: VisitorStore;
136
+ storedSessionId: string | null;
137
+ storedConversationId: string | null;
138
+ storedRevision: string | null;
139
+ statuses: HistoryIdentityStatus[];
140
+ };
141
+
142
+ const makeClient = (options: {
143
+ config?: Partial<AgentWidgetConfig>;
144
+ storedSessionId?: string | null;
145
+ storedConversationId?: string | null;
146
+ store?: VisitorStore;
147
+ } = {}): Harness => {
148
+ const store = options.store ?? createVisitorStore(CLIENT_TOKEN, KEY_PREFIX, false);
149
+ const harness: Partial<Harness> = {
150
+ store,
151
+ storedSessionId: options.storedSessionId ?? null,
152
+ storedConversationId: options.storedConversationId ?? null,
153
+ storedRevision: null,
154
+ statuses: [],
155
+ };
156
+ const internals: WidgetHistoryInternals = {
157
+ visitorStore: store,
158
+ setStoredConversationRevision: (revision) => {
159
+ harness.storedRevision = revision;
160
+ },
161
+ onHistoryIdentityStatusChanged: (status) => harness.statuses!.push(status),
162
+ };
163
+ const config: AgentWidgetConfig = {
164
+ apiUrl: API_URL,
165
+ clientToken: CLIENT_TOKEN,
166
+ features: { history: { enabled: true } },
167
+ getStoredSessionId: () => harness.storedSessionId ?? null,
168
+ setStoredSessionId: (sessionId) => {
169
+ harness.storedSessionId = sessionId;
170
+ },
171
+ getStoredConversationId: () => harness.storedConversationId ?? null,
172
+ setStoredConversationId: (conversationId) => {
173
+ harness.storedConversationId = conversationId;
174
+ },
175
+ ...options.config,
176
+ };
177
+ harness.client = new AgentWidgetClient(config, internals);
178
+ return harness as Harness;
179
+ };
180
+
181
+ beforeEach(() => {
182
+ requests = [];
183
+ window.localStorage.clear();
184
+ });
185
+
186
+ afterEach(() => {
187
+ vi.restoreAllMocks();
188
+ });
189
+
190
+ describe('history REST - request shape', () => {
191
+ it('lists conversations with the session id, cursor, limit, and target filter', async () => {
192
+ await seedToken('cvt_stored');
193
+ installFetch([
194
+ initOk({ sessionId: 'sess_live' }),
195
+ listOk('not_provided', [
196
+ {
197
+ id: 'conv_1',
198
+ title: 'Placeholder title',
199
+ flowId: 'flow_1',
200
+ messageCount: 4,
201
+ createdAt: '2026-08-01T00:00:00.000Z',
202
+ updatedAt: '2026-08-02T00:00:00.000Z',
203
+ },
204
+ ], 'cur_next'),
205
+ ]);
206
+ const h = makeClient();
207
+
208
+ const page = await h.client.listConversations({
209
+ cursor: 'cur_1',
210
+ limit: 25,
211
+ targetId: 'flow_1',
212
+ });
213
+
214
+ const url = new URL(requests[1].url);
215
+ expect(url.origin + url.pathname).toBe(CONVERSATIONS_URL);
216
+ expect(Object.fromEntries(url.searchParams)).toEqual({
217
+ cursor: 'cur_1',
218
+ limit: '25',
219
+ targetId: 'flow_1',
220
+ sessionId: 'sess_live',
221
+ });
222
+ expect(requests[1].method).toBe('GET');
223
+ expect(requests[1].headers['X-Visitor-Token']).toBe('cvt_stored');
224
+ expect(requests[1].headers['X-Persona-Version']).toBeTruthy();
225
+ // Deprecated flowId is consumed at this boundary and never re-exposed.
226
+ expect(page.data[0]).toEqual({
227
+ id: 'conv_1',
228
+ title: 'Placeholder title',
229
+ targetId: 'flow_1',
230
+ preview: null,
231
+ messageCount: 4,
232
+ createdAt: '2026-08-01T00:00:00.000Z',
233
+ updatedAt: '2026-08-02T00:00:00.000Z',
234
+ });
235
+ expect(page.nextCursor).toBe('cur_next');
236
+ });
237
+
238
+ it('returns wire messages, the cursor, and the revision from detail', async () => {
239
+ await seedToken('cvt_stored');
240
+ const wire = [
241
+ { id: 'm1', role: 'user', content: 'hi', displayAvailable: true },
242
+ { id: 'm2', role: 'assistant', displayContent: 'hello', displayAvailable: true },
243
+ ];
244
+ installFetch([
245
+ initOk({ sessionId: 'sess_live' }),
246
+ () =>
247
+ respond(
248
+ 200,
249
+ {
250
+ id: 'conv_1',
251
+ title: 'T',
252
+ targetId: 'flow_1',
253
+ preview: 'hello',
254
+ messageCount: 2,
255
+ createdAt: 'a',
256
+ updatedAt: 'b',
257
+ messages: wire,
258
+ nextMessageCursor: 'cur_older',
259
+ conversationRevision: 'rev_9',
260
+ },
261
+ { 'X-History-Identity-Status': 'not_provided' }
262
+ ),
263
+ ]);
264
+ const h = makeClient();
265
+
266
+ const detail = await h.client.getConversation('conv_1', { messageCursor: 'cur_1' });
267
+
268
+ const url = new URL(requests[1].url);
269
+ expect(url.pathname).toBe('/v1/client/conversations/conv_1');
270
+ expect(url.searchParams.get('messageCursor')).toBe('cur_1');
271
+ expect(detail.messages).toEqual(wire);
272
+ expect(detail.nextMessageCursor).toBe('cur_older');
273
+ expect(detail.conversationRevision).toBe('rev_9');
274
+ expect(detail.summary.targetId).toBe('flow_1');
275
+ expect(detail.summary.preview).toBe('hello');
276
+ });
277
+
278
+ it('deletes one conversation and reports the deleted count', async () => {
279
+ await seedToken('cvt_stored');
280
+ installFetch([
281
+ initOk({ sessionId: 'sess_live' }),
282
+ () => respond(200, { deleted: 1 }, { 'X-History-Identity-Status': 'not_provided' }),
283
+ ]);
284
+ const h = makeClient();
285
+
286
+ await expect(h.client.deleteConversation('conv_1')).resolves.toEqual({ deleted: 1 });
287
+ expect(requests[1].method).toBe('DELETE');
288
+ expect(new URL(requests[1].url).pathname).toBe('/v1/client/conversations/conv_1');
289
+ });
290
+
291
+ it('passes targetId through verbatim on delete-all', async () => {
292
+ await seedToken('cvt_stored');
293
+ installFetch([
294
+ initOk({ sessionId: 'sess_live' }),
295
+ () => respond(200, { deleted: 3 }, { 'X-History-Identity-Status': 'not_provided' }),
296
+ ]);
297
+ const h = makeClient();
298
+
299
+ await expect(h.client.deleteAllConversations({ targetId: 'agent_7' })).resolves.toEqual({
300
+ deleted: 3,
301
+ });
302
+ const url = new URL(requests[1].url);
303
+ expect(url.searchParams.get('targetId')).toBe('agent_7');
304
+ expect(requests[1].method).toBe('DELETE');
305
+ });
306
+
307
+ it('allows an omitted targetId on delete-all (headless client-token-wide scope)', async () => {
308
+ await seedToken('cvt_stored');
309
+ installFetch([
310
+ initOk({ sessionId: 'sess_live' }),
311
+ () => respond(200, { deleted: 0 }, { 'X-History-Identity-Status': 'not_provided' }),
312
+ ]);
313
+ const h = makeClient();
314
+
315
+ await expect(h.client.deleteAllConversations()).resolves.toEqual({ deleted: 0 });
316
+ const url = new URL(requests[1].url);
317
+ expect(url.searchParams.has('targetId')).toBe(false);
318
+ expect(url.searchParams.get('sessionId')).toBe('sess_live');
319
+ });
320
+ });
321
+
322
+ describe('history REST - header and scope discipline', () => {
323
+ it('sends the visitor token as a header on every call and never in a URL', async () => {
324
+ await seedToken('cvt_stored');
325
+ installFetch([
326
+ initOk({ sessionId: 'sess_live' }),
327
+ listOk(),
328
+ () => respond(200, { deleted: 1 }, { 'X-History-Identity-Status': 'not_provided' }),
329
+ () => respond(200, { deleted: 2 }, { 'X-History-Identity-Status': 'not_provided' }),
330
+ ]);
331
+ const h = makeClient();
332
+
333
+ await h.client.listConversations();
334
+ await h.client.deleteConversation('conv_1');
335
+ await h.client.deleteAllConversations({ targetId: 'flow_1' });
336
+
337
+ for (const request of requests.slice(1)) {
338
+ expect(request.headers['X-Visitor-Token']).toBe('cvt_stored');
339
+ expect(request.url).not.toContain('cvt_');
340
+ }
341
+ });
342
+
343
+ it('fails locally without a request when no visitor token is stored', async () => {
344
+ installFetch([initOk({ sessionId: 'sess_live' })]);
345
+ const h = makeClient();
346
+
347
+ await expect(h.client.listConversations()).rejects.toMatchObject({
348
+ code: 'visitor_token_missing',
349
+ });
350
+ // Only the session init ran.
351
+ expect(requests).toHaveLength(1);
352
+ });
353
+
354
+ it('never calls the identity provider for browser scope', async () => {
355
+ await seedToken('cvt_stored');
356
+ const getIdentityProof = vi.fn(() => 'rt_eu_abc');
357
+ installFetch([initOk({ sessionId: 'sess_live' }), listOk()]);
358
+ const h = makeClient({ config: { getIdentityProof } });
359
+
360
+ await h.client.listConversations({ scope: 'browser' });
361
+
362
+ expect(getIdentityProof).not.toHaveBeenCalled();
363
+ expect(requests[1].headers['X-Identity-Proof']).toBeUndefined();
364
+ });
365
+
366
+ it('resolves the proof once per logical request and reuses it across recovery', async () => {
367
+ await seedToken('cvt_stored');
368
+ const getIdentityProof = vi.fn(() => 'rt_eu_abc');
369
+ installFetch([
370
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
371
+ () => respond(401, { error: 'Session not found or expired' }),
372
+ initOk({ sessionId: 'sess_fresh', visitor: boundVisitor }),
373
+ listOk('admitted'),
374
+ ]);
375
+ const h = makeClient({ config: { getIdentityProof } });
376
+
377
+ await h.client.listConversations({ scope: 'verified-user' });
378
+
379
+ expect(getIdentityProof).toHaveBeenCalledTimes(1);
380
+ expect(requests[1].headers['X-Identity-Proof']).toBe('rt_eu_abc');
381
+ expect(requests[3].headers['X-Identity-Proof']).toBe('rt_eu_abc');
382
+ expect(requests[3].url).not.toContain('rt_eu_');
383
+ expect(new URL(requests[3].url).searchParams.get('sessionId')).toBe('sess_fresh');
384
+ });
385
+
386
+ it('falls back to browser scope when the provider returns null before binding', async () => {
387
+ await seedToken('cvt_stored');
388
+ const getIdentityProof = vi.fn(() => null);
389
+ installFetch([
390
+ initOk({
391
+ sessionId: 'sess_live',
392
+ visitor: { id: 'vis_1', expiresAt: futureIso(), endUserId: null },
393
+ }),
394
+ listOk(),
395
+ ]);
396
+ const h = makeClient({ config: { getIdentityProof } });
397
+
398
+ await h.client.listConversations({ scope: 'verified-user' });
399
+
400
+ expect(requests).toHaveLength(2);
401
+ expect(requests[1].headers['X-Identity-Proof']).toBeUndefined();
402
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
403
+ state: 'browser_only',
404
+ reason: 'proof_unavailable_before_binding',
405
+ });
406
+ });
407
+
408
+ it('fails closed when the provider returns null for a bound visitor', async () => {
409
+ await seedToken('cvt_stored');
410
+ installFetch([initOk({ sessionId: 'sess_live', visitor: boundVisitor })]);
411
+ const h = makeClient({ config: { getIdentityProof: () => null } });
412
+
413
+ await expect(h.client.listConversations({ scope: 'verified-user' })).rejects.toMatchObject({
414
+ code: 'authentication_required',
415
+ });
416
+ expect(requests).toHaveLength(1);
417
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
418
+ state: 'authentication_required',
419
+ reason: 'proof_unavailable_after_binding',
420
+ });
421
+ });
422
+
423
+ it('surfaces a thrown provider as identity_provider_failed with no history fetch', async () => {
424
+ await seedToken('cvt_stored');
425
+ installFetch([initOk({ sessionId: 'sess_live', visitor: boundVisitor })]);
426
+ const h = makeClient({
427
+ config: {
428
+ getIdentityProof: () => {
429
+ throw new Error('sso down');
430
+ },
431
+ },
432
+ });
433
+
434
+ await expect(h.client.listConversations({ scope: 'verified-user' })).rejects.toMatchObject({
435
+ code: 'identity_provider_failed',
436
+ });
437
+ expect(requests).toHaveLength(1);
438
+ expect(h.client.getHistoryIdentityStatus()).toEqual({ state: 'identity_provider_failed' });
439
+ });
440
+ });
441
+
442
+ describe('history REST - identity binding and acknowledgement', () => {
443
+ it('binds the visitor once with the proof before the first verified request', async () => {
444
+ await seedToken('cvt_stored');
445
+ installFetch([
446
+ initOk({
447
+ sessionId: 'sess_live',
448
+ visitor: { id: 'vis_1', expiresAt: futureIso(), endUserId: null },
449
+ }),
450
+ initOk({ sessionId: 'sess_bound', visitor: boundVisitor }),
451
+ listOk('admitted'),
452
+ ]);
453
+ const h = makeClient({
454
+ storedSessionId: 'sess_stored',
455
+ config: { getIdentityProof: () => 'rt_eu_abc' },
456
+ });
457
+
458
+ await h.client.listConversations({ scope: 'verified-user' });
459
+
460
+ expect(requests[1].url).toBe(INIT_URL);
461
+ // Ordinary init body plus the proof: the bind replays the current session id.
462
+ expect(requests[1].body).toMatchObject({
463
+ visitorHistory: true,
464
+ identityProof: 'rt_eu_abc',
465
+ sessionId: 'sess_live',
466
+ });
467
+ expect(new URL(requests[2].url).searchParams.get('sessionId')).toBe('sess_bound');
468
+ expect(h.client.getHistoryIdentityStatus()).toEqual({ state: 'verified' });
469
+ });
470
+
471
+ it('refuses verified history when the bind response reports an ignored proof', async () => {
472
+ await seedToken('cvt_stored');
473
+ installFetch([
474
+ initOk({
475
+ sessionId: 'sess_live',
476
+ visitor: { id: 'vis_1', expiresAt: futureIso(), endUserId: null },
477
+ }),
478
+ initOk({
479
+ sessionId: 'sess_bound',
480
+ visitor: {
481
+ id: 'vis_1',
482
+ expiresAt: futureIso(),
483
+ endUserId: null,
484
+ identityStatus: 'ignored',
485
+ },
486
+ }),
487
+ ]);
488
+ const h = makeClient({ config: { getIdentityProof: () => 'rt_eu_abc' } });
489
+
490
+ await expect(h.client.listConversations({ scope: 'verified-user' })).rejects.toMatchObject({
491
+ code: 'proof_not_admitted',
492
+ });
493
+ expect(requests).toHaveLength(2);
494
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
495
+ state: 'configuration_error',
496
+ reason: 'proof_not_admitted',
497
+ });
498
+ });
499
+
500
+ it('discards a verified 200 that acknowledges not_provided', async () => {
501
+ await seedToken('cvt_stored');
502
+ installFetch([
503
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
504
+ listOk('not_provided', [{ id: 'conv_1' }]),
505
+ ]);
506
+ const h = makeClient({ config: { getIdentityProof: () => 'rt_eu_abc' } });
507
+
508
+ await expect(h.client.listConversations({ scope: 'verified-user' })).rejects.toMatchObject({
509
+ code: 'proof_not_admitted',
510
+ });
511
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
512
+ state: 'configuration_error',
513
+ reason: 'proof_not_admitted',
514
+ });
515
+ });
516
+
517
+ it('discards a verified 200 with no acknowledgement header', async () => {
518
+ await seedToken('cvt_stored');
519
+ installFetch([
520
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
521
+ listOk(null, [{ id: 'conv_1' }]),
522
+ ]);
523
+ const h = makeClient({ config: { getIdentityProof: () => 'rt_eu_abc' } });
524
+
525
+ await expect(h.client.listConversations({ scope: 'verified-user' })).rejects.toMatchObject({
526
+ code: 'proof_not_admitted',
527
+ });
528
+ });
529
+
530
+ it('treats a 2xx "ignored" acknowledgement as a contract violation', async () => {
531
+ await seedToken('cvt_stored');
532
+ installFetch([
533
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
534
+ listOk('ignored', [{ id: 'conv_1' }]),
535
+ ]);
536
+ const h = makeClient({ config: { getIdentityProof: () => 'rt_eu_abc' } });
537
+
538
+ await expect(h.client.listConversations({ scope: 'verified-user' })).rejects.toMatchObject({
539
+ code: 'identity_contract_violation',
540
+ });
541
+ });
542
+
543
+ it('tolerates a missing header for browser scope and reports browser_only', async () => {
544
+ await seedToken('cvt_stored');
545
+ installFetch([initOk({ sessionId: 'sess_live' }), listOk(null, [{ id: 'conv_1' }])]);
546
+ const h = makeClient();
547
+
548
+ const page = await h.client.listConversations();
549
+
550
+ expect(page.data).toHaveLength(1);
551
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
552
+ state: 'browser_only',
553
+ reason: 'no_identity_provider',
554
+ });
555
+ });
556
+
557
+ it('treats "admitted" on a proof-less request as a contract violation', async () => {
558
+ await seedToken('cvt_stored');
559
+ installFetch([initOk({ sessionId: 'sess_live' }), listOk('admitted', [{ id: 'conv_1' }])]);
560
+ const h = makeClient();
561
+
562
+ await expect(h.client.listConversations()).rejects.toMatchObject({
563
+ code: 'identity_contract_violation',
564
+ });
565
+ });
566
+
567
+ it('maps a 503 identity_proof_not_admitted to the configuration state', async () => {
568
+ await seedToken('cvt_stored');
569
+ installFetch([
570
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
571
+ () =>
572
+ respond(
573
+ 503,
574
+ { error: 'identity_proof_not_admitted' },
575
+ { 'X-History-Identity-Status': 'ignored' }
576
+ ),
577
+ ]);
578
+ const h = makeClient({ config: { getIdentityProof: () => 'rt_eu_abc' } });
579
+
580
+ await expect(h.client.deleteAllConversations({ scope: 'verified-user' })).rejects.toMatchObject(
581
+ { code: 'proof_not_admitted' }
582
+ );
583
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
584
+ state: 'configuration_error',
585
+ reason: 'proof_not_admitted',
586
+ });
587
+ });
588
+ });
589
+
590
+ describe('history REST - identity status transitions', () => {
591
+ it('walks browser_only to verifying to verified, dedupes, and carries no secrets', async () => {
592
+ await seedToken('cvt_stored');
593
+ installFetch([
594
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
595
+ listOk('admitted'),
596
+ listOk('admitted'),
597
+ ]);
598
+ const h = makeClient({ config: { getIdentityProof: () => 'rt_eu_abc' } });
599
+
600
+ await h.client.listConversations({ scope: 'verified-user' });
601
+ await h.client.listConversations({ scope: 'verified-user' });
602
+
603
+ expect(h.statuses).toEqual([
604
+ { state: 'verifying' },
605
+ { state: 'verified' },
606
+ { state: 'verifying' },
607
+ { state: 'verified' },
608
+ ]);
609
+ for (const status of h.statuses) {
610
+ expect(Object.keys(status).every((key) => key === 'state' || key === 'reason')).toBe(true);
611
+ expect(JSON.stringify(status)).not.toContain('rt_eu_');
612
+ expect(JSON.stringify(status)).not.toContain('cvt_');
613
+ expect(JSON.stringify(status)).not.toContain('user_1');
614
+ }
615
+ });
616
+
617
+ it('does not re-announce an unchanged browser_only state', async () => {
618
+ await seedToken('cvt_stored');
619
+ installFetch([initOk({ sessionId: 'sess_live' }), listOk(), listOk()]);
620
+ const h = makeClient();
621
+
622
+ await h.client.listConversations();
623
+ await h.client.listConversations();
624
+
625
+ expect(h.statuses).toEqual([]);
626
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
627
+ state: 'browser_only',
628
+ reason: 'no_identity_provider',
629
+ });
630
+ });
631
+
632
+ it('reports unavailable after the 403 history degrade', async () => {
633
+ await seedToken('cvt_stored');
634
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
635
+ installFetch([
636
+ () => respond(403, { error: 'visitor_history_disabled' }),
637
+ initOk({ sessionId: 'sess_plain' }),
638
+ ]);
639
+ const h = makeClient();
640
+
641
+ await h.client.initSession();
642
+
643
+ expect(h.statuses).toEqual([{ state: 'unavailable', reason: 'history_disabled' }]);
644
+ await expect(h.client.listConversations()).rejects.toMatchObject({
645
+ code: 'history_disabled',
646
+ });
647
+ });
648
+ });
649
+
650
+ describe('history REST - selective one-shot 401 recovery', () => {
651
+ it('re-inits and retries once on an expired session', async () => {
652
+ await seedToken('cvt_stored');
653
+ installFetch([
654
+ initOk({ sessionId: 'sess_live' }),
655
+ () => respond(401, { error: 'Session not found or expired' }),
656
+ () =>
657
+ respond(200, {
658
+ ...initBody({ sessionId: 'sess_fresh' }),
659
+ visitor: { id: 'vis_2', token: 'cvt_replacement', expiresAt: futureIso(), endUserId: null },
660
+ }),
661
+ listOk(),
662
+ ]);
663
+ const h = makeClient();
664
+
665
+ await h.client.listConversations();
666
+
667
+ expect(requests).toHaveLength(4);
668
+ // The retry uses the replacement credential and the new session.
669
+ expect(requests[3].headers['X-Visitor-Token']).toBe('cvt_replacement');
670
+ expect(new URL(requests[3].url).searchParams.get('sessionId')).toBe('sess_fresh');
671
+ expect(await readStoredToken()).toBe('cvt_replacement');
672
+ });
673
+
674
+ it('rebinds with the same proof on visitor_identity_mismatch and retries once', async () => {
675
+ await seedToken('cvt_stored');
676
+ const getIdentityProof = vi.fn(() => 'rt_eu_abc');
677
+ installFetch([
678
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
679
+ () => respond(401, { error: 'visitor_identity_mismatch' }),
680
+ initOk({ sessionId: 'sess_rebound', visitor: boundVisitor }),
681
+ listOk('admitted'),
682
+ ]);
683
+ const h = makeClient({ config: { getIdentityProof } });
684
+
685
+ await h.client.listConversations({ scope: 'verified-user' });
686
+
687
+ expect(requests).toHaveLength(4);
688
+ expect(requests[2].url).toBe(INIT_URL);
689
+ expect(requests[2].body).toMatchObject({ identityProof: 'rt_eu_abc' });
690
+ expect(getIdentityProof).toHaveBeenCalledTimes(1);
691
+ });
692
+
693
+ it('never re-inits on invalid_identity_proof', async () => {
694
+ await seedToken('cvt_stored');
695
+ installFetch([
696
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
697
+ () => respond(401, { error: 'invalid_identity_proof' }),
698
+ ]);
699
+ const h = makeClient({ config: { getIdentityProof: () => 'rt_eu_abc' } });
700
+
701
+ await expect(h.client.listConversations({ scope: 'verified-user' })).rejects.toMatchObject({
702
+ code: 'invalid_identity_proof',
703
+ });
704
+ expect(requests).toHaveLength(2);
705
+ expect(h.client.getHistoryIdentityStatus()).toEqual({
706
+ state: 'authentication_required',
707
+ reason: 'invalid_identity_proof',
708
+ });
709
+ });
710
+
711
+ it('never retries a missing visitor token reported by the server', async () => {
712
+ await seedToken('cvt_stored');
713
+ installFetch([
714
+ initOk({ sessionId: 'sess_live' }),
715
+ () => respond(401, { error: 'Visitor token required' }),
716
+ ]);
717
+ const h = makeClient();
718
+
719
+ await expect(h.client.listConversations()).rejects.toMatchObject({
720
+ code: 'visitor_token_missing',
721
+ });
722
+ expect(requests).toHaveLength(2);
723
+ });
724
+
725
+ it('propagates a second 401 instead of recovering twice', async () => {
726
+ await seedToken('cvt_stored');
727
+ installFetch([
728
+ initOk({ sessionId: 'sess_live' }),
729
+ () => respond(401, { error: 'Session not found or expired' }),
730
+ initOk({ sessionId: 'sess_fresh' }),
731
+ () => respond(401, { error: 'Session not found or expired' }),
732
+ ]);
733
+ const h = makeClient();
734
+
735
+ await expect(h.client.listConversations()).rejects.toMatchObject({ code: 'unauthorized' });
736
+ expect(requests).toHaveLength(4);
737
+ });
738
+ });
739
+
740
+ describe('history REST - typed transport errors', () => {
741
+ it('surfaces 429 with retryAfterSeconds and no automatic retry', async () => {
742
+ await seedToken('cvt_stored');
743
+ installFetch([
744
+ initOk({ sessionId: 'sess_live' }),
745
+ () => respond(429, { error: 'rate_limited' }, { 'Retry-After': '10' }),
746
+ ]);
747
+ const h = makeClient();
748
+
749
+ await expect(h.client.listConversations()).rejects.toMatchObject({
750
+ code: 'rate_limited',
751
+ retryAfterSeconds: 10,
752
+ });
753
+ expect(requests).toHaveLength(2);
754
+ });
755
+
756
+ it('surfaces 404 as not_found', async () => {
757
+ await seedToken('cvt_stored');
758
+ installFetch([initOk({ sessionId: 'sess_live' }), () => respond(404, { error: 'not_found' })]);
759
+ const h = makeClient();
760
+
761
+ await expect(h.client.getConversation('conv_gone')).rejects.toMatchObject({
762
+ code: 'not_found',
763
+ });
764
+ });
765
+
766
+ it('discards a 200 whose credential changed mid-flight', async () => {
767
+ await seedToken('cvt_stored');
768
+ installFetch([
769
+ initOk({ sessionId: 'sess_live' }),
770
+ async () => {
771
+ // Another tab resets the visitor while this response is in flight.
772
+ await externalTokenChange('cvt_other');
773
+ return respond(200, { data: [{ id: 'conv_1' }], nextCursor: null }, {
774
+ 'X-History-Identity-Status': 'not_provided',
775
+ });
776
+ },
777
+ ]);
778
+ const h = makeClient();
779
+
780
+ await expect(h.client.listConversations()).rejects.toMatchObject({
781
+ code: 'credential_changed',
782
+ });
783
+ });
784
+ });
785
+
786
+ describe('history REST - display projection finalization', () => {
787
+ const projectionOk = (revision = 'rev_2') => () =>
788
+ respond(
789
+ 200,
790
+ { conversationRevision: revision },
791
+ { 'X-History-Identity-Status': 'not_provided' }
792
+ );
793
+
794
+ it('patches projections with keepalive, browser scope, and no identity proof', async () => {
795
+ await seedToken('cvt_stored');
796
+ const getIdentityProof = vi.fn(() => 'rt_eu_abc');
797
+ installFetch([initOk({ sessionId: 'sess_live' }), projectionOk()]);
798
+ const h = makeClient({ config: { getIdentityProof } });
799
+
800
+ const result = await h.client.finalizeDisplayProjections('conv_1', [
801
+ { id: 'm2', displayContent: 'final answer' },
802
+ ]);
803
+
804
+ expect(requests[1].method).toBe('PATCH');
805
+ expect(new URL(requests[1].url).pathname).toBe(
806
+ '/v1/client/conversations/conv_1/display-projections'
807
+ );
808
+ expect(requests[1].body).toEqual({ messages: [{ id: 'm2', displayContent: 'final answer' }] });
809
+ expect(requests[1].keepalive).toBe(true);
810
+ expect(requests[1].headers['X-Visitor-Token']).toBe('cvt_stored');
811
+ expect(requests[1].headers['X-Identity-Proof']).toBeUndefined();
812
+ expect(getIdentityProof).not.toHaveBeenCalled();
813
+ expect(result.conversationRevision).toBe('rev_2');
814
+ expect(h.storedRevision).toBe('rev_2');
815
+ // A transport op publishes no identity status of its own.
816
+ expect(h.statuses).toEqual([]);
817
+ });
818
+
819
+ it('enforces the per-message and batch caps locally with no request', async () => {
820
+ await seedToken('cvt_stored');
821
+ installFetch([]);
822
+ const h = makeClient();
823
+
824
+ await expect(
825
+ h.client.finalizeDisplayProjections('conv_1', [
826
+ { id: 'm1', displayContent: 'x'.repeat(32769) },
827
+ ])
828
+ ).rejects.toMatchObject({ code: 'payload_too_large' });
829
+
830
+ await expect(
831
+ h.client.finalizeDisplayProjections('conv_1', [
832
+ { id: 'm1', displayContent: 'x'.repeat(32768) },
833
+ { id: 'm2', displayContent: 'y'.repeat(20000) },
834
+ ])
835
+ ).rejects.toMatchObject({ code: 'payload_too_large' });
836
+
837
+ expect(requests).toHaveLength(0);
838
+ });
839
+
840
+ it('does not install the revision for a conversation that is no longer active', async () => {
841
+ await seedToken('cvt_stored');
842
+ installFetch([initOk({ sessionId: 'sess_live', conversationId: 'conv_other' }), projectionOk()]);
843
+ const h = makeClient();
844
+
845
+ const result = await h.client.finalizeDisplayProjections('conv_1', [
846
+ { id: 'm2', displayContent: 'final answer' },
847
+ ]);
848
+
849
+ expect(result.conversationRevision).toBe('rev_2');
850
+ // The init's revision stands: a different record cannot install this one.
851
+ expect(h.storedRevision).toBe('rev_1');
852
+ });
853
+
854
+ it('does not install the revision after the credential changed', async () => {
855
+ await seedToken('cvt_stored');
856
+ installFetch([
857
+ initOk({ sessionId: 'sess_live' }),
858
+ async () => {
859
+ await externalTokenChange('cvt_other');
860
+ return respond(
861
+ 200,
862
+ { conversationRevision: 'rev_2' },
863
+ { 'X-History-Identity-Status': 'not_provided' }
864
+ );
865
+ },
866
+ ]);
867
+ const h = makeClient();
868
+
869
+ await expect(
870
+ h.client.finalizeDisplayProjections('conv_1', [{ id: 'm2', displayContent: 'x' }])
871
+ ).rejects.toMatchObject({ code: 'credential_changed' });
872
+ expect(h.storedRevision).toBe('rev_1');
873
+ });
874
+
875
+ it('drops a pending projection for a deleted conversation', async () => {
876
+ await seedToken('cvt_stored');
877
+ installFetch([initOk({ sessionId: 'sess_live' }), () => respond(404, { error: 'not_found' })]);
878
+ const h = makeClient();
879
+
880
+ await expect(
881
+ h.client.finalizeDisplayProjections('conv_1', [{ id: 'm2', displayContent: 'x' }])
882
+ ).rejects.toMatchObject({ code: 'not_found' });
883
+ expect(h.storedRevision).toBe('rev_1');
884
+ });
885
+ });
886
+
887
+ describe('history REST - visitor reset', () => {
888
+ it('sends the token when present, clears the store, and never sends a proof', async () => {
889
+ await seedToken('cvt_stored');
890
+ const getIdentityProof = vi.fn(() => 'rt_eu_abc');
891
+ installFetch([
892
+ initOk({ sessionId: 'sess_live', visitor: boundVisitor }),
893
+ () => respond(200, { reset: true }, { 'X-History-Identity-Status': 'not_provided' }),
894
+ ]);
895
+ const h = makeClient({ config: { getIdentityProof } });
896
+
897
+ await expect(h.client.resetVisitor()).resolves.toEqual({ reset: true });
898
+
899
+ expect(requests[1].method).toBe('POST');
900
+ expect(new URL(requests[1].url).pathname).toBe('/v1/client/visitor/reset');
901
+ expect(new URL(requests[1].url).searchParams.get('sessionId')).toBe('sess_live');
902
+ expect(requests[1].headers['X-Visitor-Token']).toBe('cvt_stored');
903
+ expect(requests[1].headers['X-Identity-Proof']).toBeUndefined();
904
+ expect(getIdentityProof).not.toHaveBeenCalled();
905
+ expect(await readStoredToken()).toBeNull();
906
+ expect(h.statuses[0]).toEqual({ state: 'resetting' });
907
+ expect(h.client.getHistoryIdentityStatus()).toMatchObject({ state: 'browser_only' });
908
+ });
909
+
910
+ it('resets without a stored visitor token', async () => {
911
+ installFetch([
912
+ initOk({ sessionId: 'sess_live' }),
913
+ () => respond(200, { reset: true }, { 'X-History-Identity-Status': 'not_provided' }),
914
+ ]);
915
+ const h = makeClient();
916
+
917
+ await expect(h.client.resetVisitor()).resolves.toEqual({ reset: true });
918
+ expect(requests[1].headers['X-Visitor-Token']).toBeUndefined();
919
+ });
920
+
921
+ it('still clears the store when the remote reset fails', async () => {
922
+ await seedToken('cvt_stored');
923
+ installFetch([initOk({ sessionId: 'sess_live' }), () => respond(500, { error: 'boom' })]);
924
+ const h = makeClient();
925
+
926
+ await expect(h.client.resetVisitor()).rejects.toMatchObject({ code: 'request_failed' });
927
+ expect(await readStoredToken()).toBeNull();
928
+ });
929
+ });
930
+
931
+ describe('history - chat 410', () => {
932
+ it('surfaces a typed conversation_deleted error and never retries', async () => {
933
+ await seedToken('cvt_stored');
934
+ installFetch([
935
+ initOk({ sessionId: 'sess_live' }),
936
+ () => respond(410, { error: 'conversation_deleted' }),
937
+ ]);
938
+ const h = makeClient();
939
+ const events: AgentWidgetEvent[] = [];
940
+
941
+ await expect(
942
+ h.client.dispatch(
943
+ {
944
+ messages: [
945
+ {
946
+ id: 'usr_1',
947
+ role: 'user',
948
+ content: 'hello',
949
+ createdAt: '2026-08-01T00:00:00.000Z',
950
+ },
951
+ ],
952
+ },
953
+ (event) => events.push(event)
954
+ )
955
+ ).rejects.toMatchObject({ code: 'conversation_deleted' });
956
+
957
+ // Init + the one chat attempt: the stale payload is never resent.
958
+ expect(requests).toHaveLength(2);
959
+ expect(events.filter((event) => event.type === 'error')).toHaveLength(1);
960
+ });
961
+ });
962
+
963
+ describe('history - first-init lock', () => {
964
+ const installFakeLocks = () => {
965
+ const chains = new Map<string, Promise<unknown>>();
966
+ Object.defineProperty(globalThis.navigator, 'locks', {
967
+ configurable: true,
968
+ value: {
969
+ request: async <T>(name: string, callback: () => Promise<T>): Promise<T> => {
970
+ const previous = chains.get(name) ?? Promise.resolve();
971
+ let release!: () => void;
972
+ const held = new Promise<void>((resolve) => {
973
+ release = resolve;
974
+ });
975
+ chains.set(name, previous.then(() => held));
976
+ await previous;
977
+ try {
978
+ return await callback();
979
+ } finally {
980
+ release();
981
+ }
982
+ },
983
+ },
984
+ });
985
+ return () => {
986
+ Reflect.deleteProperty(globalThis.navigator as unknown as object, 'locks');
987
+ };
988
+ };
989
+
990
+ it('holds the lock across mint, write, and claim while a waiter joins the visitor', async () => {
991
+ const removeLocks = installFakeLocks();
992
+ let releaseMint!: () => void;
993
+ const mintGate = new Promise<void>((resolve) => {
994
+ releaseMint = resolve;
995
+ });
996
+ installFetch([
997
+ async () => {
998
+ await mintGate;
999
+ return respond(
1000
+ 200,
1001
+ initBody({
1002
+ sessionId: 'sess_first',
1003
+ visitor: { id: 'vis_1', token: 'cvt_new', expiresAt: futureIso(), endUserId: null },
1004
+ })
1005
+ );
1006
+ },
1007
+ initOk({ sessionId: 'sess_claimed' }),
1008
+ initOk({ sessionId: 'sess_second' }),
1009
+ ]);
1010
+ const winner = makeClient();
1011
+ const waiter = makeClient({ store: createVisitorStore(CLIENT_TOKEN, KEY_PREFIX, false) });
1012
+
1013
+ const first = winner.client.initSession();
1014
+ const second = waiter.client.initSession();
1015
+ await vi.waitFor(() => expect(requests).toHaveLength(1));
1016
+ for (let tick = 0; tick < 5; tick += 1) await Promise.resolve();
1017
+
1018
+ // The waiter cannot mint a second visitor while the winner holds the lock.
1019
+ expect(requests).toHaveLength(1);
1020
+
1021
+ releaseMint();
1022
+ await Promise.all([first, second]);
1023
+
1024
+ expect(requests.map((request) => request.body?.sessionId)).toEqual([
1025
+ undefined,
1026
+ 'sess_first',
1027
+ undefined,
1028
+ ]);
1029
+ // The waiter re-read the winner's token inside the lock instead of minting.
1030
+ expect(requests[2].body).toMatchObject({ visitorToken: 'cvt_new' });
1031
+ expect(requests[1].body).toMatchObject({ visitorToken: 'cvt_new' });
1032
+ removeLocks();
1033
+ });
1034
+ });