@runtypelabs/persona 4.17.1 → 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 (158) hide show
  1. package/dist/chunk-3I2S3I5P.js +1 -0
  2. package/dist/chunk-76IELU7F.js +1 -0
  3. package/dist/chunk-EFJBUH5F.js +1 -0
  4. package/dist/chunk-PDPVORUF.js +1 -0
  5. package/dist/chunk-Q735PSZL.js +1 -0
  6. package/dist/chunk-VK6JOIYJ.js +1 -0
  7. package/dist/codegen.cjs +4 -4
  8. package/dist/codegen.js +4 -4
  9. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  10. package/dist/context-mentions.cjs +9 -8
  11. package/dist/context-mentions.d.cts +1212 -14
  12. package/dist/context-mentions.d.ts +1212 -14
  13. package/dist/context-mentions.js +9 -8
  14. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  15. package/dist/event-stream-view.cjs +129 -0
  16. package/dist/event-stream-view.d.cts +8384 -0
  17. package/dist/event-stream-view.d.ts +8384 -0
  18. package/dist/event-stream-view.js +129 -0
  19. package/dist/history-view-BD6OXLNA.js +898 -0
  20. package/dist/history-view.cjs +898 -0
  21. package/dist/history-view.d.cts +1461 -0
  22. package/dist/history-view.d.ts +1461 -0
  23. package/dist/history-view.js +898 -0
  24. package/dist/index.cjs +77 -77
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +1987 -17
  27. package/dist/index.d.ts +1987 -17
  28. package/dist/index.global.js +67 -66
  29. package/dist/index.global.js.map +1 -1
  30. package/dist/index.js +77 -77
  31. package/dist/index.js.map +1 -1
  32. package/dist/launcher.global.js +5 -4
  33. package/dist/launcher.global.js.map +1 -1
  34. package/dist/plugin-kit.cjs +1 -1
  35. package/dist/plugin-kit.d.cts +0 -8
  36. package/dist/plugin-kit.d.ts +0 -8
  37. package/dist/plugin-kit.js +1 -1
  38. package/dist/smart-dom-reader.d.cts +1212 -14
  39. package/dist/smart-dom-reader.d.ts +1212 -14
  40. package/dist/theme-editor-preview.cjs +970 -73
  41. package/dist/theme-editor-preview.d.cts +1331 -15
  42. package/dist/theme-editor-preview.d.ts +1331 -15
  43. package/dist/theme-editor-preview.js +81 -81
  44. package/dist/theme-editor.cjs +7 -7
  45. package/dist/theme-editor.d.cts +1215 -15
  46. package/dist/theme-editor.d.ts +1215 -15
  47. package/dist/theme-editor.js +9 -9
  48. package/dist/theme-reference.cjs +1 -1
  49. package/dist/theme-reference.d.cts +226 -7
  50. package/dist/theme-reference.d.ts +226 -7
  51. package/dist/theme-reference.js +1 -1
  52. package/dist/widget.css +1 -1
  53. package/package.json +16 -4
  54. package/src/client-history-rest.test.ts +1034 -0
  55. package/src/client-visitor-history.test.ts +596 -0
  56. package/src/client.ts +1040 -24
  57. package/src/components/artifact-inline.ts +8 -1
  58. package/src/components/artifact-pane.test.ts +278 -1
  59. package/src/components/artifact-pane.ts +135 -67
  60. package/src/components/artifact-preview.test.ts +14 -1
  61. package/src/components/artifact-preview.ts +9 -0
  62. package/src/components/header-builder.ts +3 -0
  63. package/src/components/header-layouts.test.ts +171 -7
  64. package/src/components/header-layouts.ts +83 -53
  65. package/src/components/header-parts.test.ts +154 -1
  66. package/src/components/header-parts.ts +266 -138
  67. package/src/components/history-confirm-dialog.ts +177 -0
  68. package/src/components/history-shell-copy.ts +99 -0
  69. package/src/components/history-view/announcer.ts +40 -0
  70. package/src/components/history-view/copy.ts +105 -0
  71. package/src/components/history-view/css.ts +1078 -0
  72. package/src/components/history-view/grouping.test.ts +142 -0
  73. package/src/components/history-view/grouping.ts +142 -0
  74. package/src/components/history-view/icons.ts +73 -0
  75. package/src/components/history-view/rows.ts +341 -0
  76. package/src/components/history-view/state.ts +309 -0
  77. package/src/components/history-view.test.ts +2660 -0
  78. package/src/components/history-view.ts +1891 -0
  79. package/src/components/launcher-surface.test.ts +133 -0
  80. package/src/components/launcher.test.ts +18 -0
  81. package/src/components/launcher.ts +7 -7
  82. package/src/components/panel.ts +17 -7
  83. package/src/components/welcome.ts +72 -2
  84. package/src/defaults.ts +36 -3
  85. package/src/event-stream-view-bundle.test.ts +69 -0
  86. package/src/event-stream-view-entry.ts +19 -0
  87. package/src/event-stream-view-loader.ts +31 -0
  88. package/src/event-stream-view.ts +19 -0
  89. package/src/generated/runtype-openapi-contract.ts +1 -0
  90. package/src/history-render.ts +325 -0
  91. package/src/history-view-bundle.test.ts +78 -0
  92. package/src/history-view-entry.ts +41 -0
  93. package/src/history-view-loader.test.ts +30 -0
  94. package/src/history-view-loader.ts +32 -0
  95. package/src/history-view.ts +28 -0
  96. package/src/index-core.ts +50 -2
  97. package/src/index-global.ts +55 -0
  98. package/src/internal/demo-history-provider.test.ts +402 -0
  99. package/src/internal/demo-history-provider.ts +689 -0
  100. package/src/internal/history-provider-registry.ts +25 -0
  101. package/src/internal/history-provider.ts +150 -0
  102. package/src/internal/runtype-history-provider.test.ts +313 -0
  103. package/src/internal/runtype-history-provider.ts +240 -0
  104. package/src/plugin-kit.test.ts +16 -0
  105. package/src/plugin-kit.ts +13 -5
  106. package/src/plugins/types.ts +177 -1
  107. package/src/session-history.test.ts +1397 -0
  108. package/src/session.ts +1200 -26
  109. package/src/styles/widget-styles.test.ts +133 -0
  110. package/src/styles/widget.css +227 -8
  111. package/src/theme-editor/preview-utils.test.ts +19 -0
  112. package/src/theme-editor/preview-utils.ts +15 -1
  113. package/src/theme-editor/preview.ts +7 -1
  114. package/src/theme-editor/role-mappings.ts +4 -1
  115. package/src/theme-editor/sections.test.ts +168 -1
  116. package/src/theme-editor/sections.ts +136 -7
  117. package/src/theme-editor/webmcp/summary.ts +16 -2
  118. package/src/theme-reference.ts +20 -6
  119. package/src/types/theme.ts +213 -7
  120. package/src/types.ts +927 -7
  121. package/src/ui.artifact-rail-resize.test.ts +235 -0
  122. package/src/ui.attachments-drop.test.ts +1 -0
  123. package/src/ui.header-update-stability.test.ts +2 -2
  124. package/src/ui.history-chunk.test.ts +72 -0
  125. package/src/ui.history-config-provider.test.ts +318 -0
  126. package/src/ui.history-controller.test.ts +711 -0
  127. package/src/ui.history-lifecycle.test.ts +456 -0
  128. package/src/ui.history-render-hooks.test.ts +904 -0
  129. package/src/ui.history-shell.test.ts +1887 -0
  130. package/src/ui.rail-overlay.test.ts +573 -0
  131. package/src/ui.rail-resize.test.ts +284 -0
  132. package/src/ui.rebuild-composer.test.ts +21 -0
  133. package/src/ui.render-welcome.test.ts +42 -0
  134. package/src/ui.shortcuts.test.ts +166 -0
  135. package/src/ui.ts +3071 -240
  136. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  137. package/src/utils/artifact-file.ts +15 -0
  138. package/src/utils/artifact-gate.test.ts +4 -0
  139. package/src/utils/artifact-gate.ts +13 -17
  140. package/src/utils/artifact-resize.test.ts +28 -0
  141. package/src/utils/artifact-resize.ts +9 -2
  142. package/src/utils/buttons.ts +188 -1
  143. package/src/utils/config-merge.test.ts +91 -0
  144. package/src/utils/dropdown.ts +10 -1
  145. package/src/utils/history-messages.test.ts +284 -0
  146. package/src/utils/history-messages.ts +284 -0
  147. package/src/utils/icons.ts +3 -0
  148. package/src/utils/shortcuts.test.ts +288 -0
  149. package/src/utils/shortcuts.ts +221 -0
  150. package/src/utils/theme.test.ts +501 -4
  151. package/src/utils/theme.ts +42 -3
  152. package/src/utils/tokens.ts +153 -15
  153. package/src/utils/tooltip-timing.ts +4 -0
  154. package/src/utils/tooltip.test.ts +249 -7
  155. package/src/utils/tooltip.ts +146 -4
  156. package/src/utils/visitor-store.test.ts +425 -0
  157. package/src/utils/visitor-store.ts +337 -0
  158. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,1397 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Session-layer conversation history (D5). The core scenarios run against BOTH
5
+ * the real Runtype provider (over a fake `/v1/client/*` backend) and the real
6
+ * in-memory demo provider: anything that needs a Runtype special case here is a
7
+ * seam defect, not a test problem.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+
12
+ import {
13
+ AgentWidgetSession,
14
+ SessionHistoryError,
15
+ type SessionHistoryNotice,
16
+ type SessionHistoryState,
17
+ } from './session';
18
+ import { createDemoHistoryProvider } from './internal/demo-history-provider';
19
+ import { createRuntypeHistoryProvider } from './internal/runtype-history-provider';
20
+ import type { HistoryProvider } from './internal/history-provider';
21
+ import {
22
+ createVisitorStore,
23
+ visitorStoreKeys,
24
+ type VisitorStore,
25
+ } from './utils/visitor-store';
26
+ import type {
27
+ AgentWidgetMessage,
28
+ ClientChatRequest,
29
+ PendingDisplayProjections,
30
+ WidgetHistoryInternals,
31
+ } from './types';
32
+
33
+ const CLIENT_TOKEN = 'ct_session_history';
34
+ const API_URL = 'https://api.runtype.com';
35
+ const WELCOME = 'Welcome! How can I help?';
36
+ const PAGE_SIZE = 2;
37
+
38
+ const T = (minute: number) =>
39
+ new Date(Date.UTC(2026, 0, 1, 0, minute)).toISOString();
40
+
41
+ type SeedMessage = {
42
+ id: string;
43
+ role: 'user' | 'assistant';
44
+ /** Visitor-visible projection. */
45
+ display: string;
46
+ /** Model channel as the API stored it; diverges from `display` when set. */
47
+ model?: string;
48
+ createdAt: string;
49
+ };
50
+
51
+ type SeedConversation = {
52
+ id: string;
53
+ title: string;
54
+ targetId: string;
55
+ messages: SeedMessage[];
56
+ };
57
+
58
+ const SEEDS: SeedConversation[] = [
59
+ {
60
+ id: 'conv-a',
61
+ title: 'Conversation A',
62
+ targetId: 'flow_1',
63
+ messages: [
64
+ {
65
+ id: 'a1',
66
+ role: 'user',
67
+ display: 'Where is my order?',
68
+ model: '{"intent":"order_status","orderId":41822}',
69
+ createdAt: T(0),
70
+ },
71
+ {
72
+ id: 'a2',
73
+ role: 'assistant',
74
+ display: 'It ships Thursday.',
75
+ createdAt: T(1),
76
+ },
77
+ ],
78
+ },
79
+ {
80
+ id: 'conv-b',
81
+ title: 'Conversation B',
82
+ targetId: 'flow_1',
83
+ messages: [
84
+ { id: 'b1', role: 'user', display: 'turn one', createdAt: T(10) },
85
+ { id: 'b2', role: 'assistant', display: 'turn two', createdAt: T(11) },
86
+ { id: 'b3', role: 'user', display: 'turn three', createdAt: T(12) },
87
+ { id: 'b4', role: 'assistant', display: 'turn four', createdAt: T(13) },
88
+ { id: 'b5', role: 'user', display: 'turn five', createdAt: T(14) },
89
+ ],
90
+ },
91
+ ];
92
+
93
+ // ── Fake Runtype backend ──────────────────────────────────────────────────
94
+
95
+ type FailableOperation =
96
+ | 'list'
97
+ | 'getPage'
98
+ | 'prepareOpen'
99
+ | 'prepareStartNew'
100
+ | 'delete'
101
+ | 'deleteAll';
102
+
103
+ type BackendConversation = {
104
+ id: string;
105
+ title: string;
106
+ targetId: string;
107
+ createdAt: string;
108
+ updatedAt: string;
109
+ revision: string;
110
+ messages: Array<{
111
+ id: string;
112
+ role: string;
113
+ content?: string;
114
+ displayContent?: string;
115
+ displayAvailable: boolean;
116
+ timestamp: string;
117
+ }>;
118
+ };
119
+
120
+ type RecordedRequest = {
121
+ url: string;
122
+ method: string;
123
+ body?: Record<string, unknown>;
124
+ };
125
+
126
+ const futureIso = () => new Date(Date.now() + 10 * 60_000).toISOString();
127
+
128
+ const respond = (status: number, body: unknown) => ({
129
+ ok: status >= 200 && status < 300,
130
+ status,
131
+ headers: {
132
+ get: (name: string) =>
133
+ name.toLowerCase() === 'x-history-identity-status' ? 'not_provided' : null,
134
+ },
135
+ json: async () => body,
136
+ });
137
+
138
+ const sseOk = (text: string) => {
139
+ const encoder = new TextEncoder();
140
+ return {
141
+ ok: true,
142
+ status: 200,
143
+ headers: { get: () => null },
144
+ body: new ReadableStream<Uint8Array>({
145
+ start(controller) {
146
+ controller.enqueue(encoder.encode(text));
147
+ controller.close();
148
+ },
149
+ }),
150
+ json: async () => ({}),
151
+ };
152
+ };
153
+
154
+ function createRuntypeBackend() {
155
+ const conversations = new Map<string, BackendConversation>();
156
+ const sessions = new Map<string, string>();
157
+ const requests: RecordedRequest[] = [];
158
+ const failures = new Map<FailableOperation, number>();
159
+ const chatQueue: Array<() => unknown> = [];
160
+ let sessionSeq = 0;
161
+ let convSeq = 0;
162
+ let revisionSeq = 0;
163
+
164
+ const nextRevision = () => `rev-${(revisionSeq += 1)}`;
165
+
166
+ for (const seed of SEEDS) {
167
+ conversations.set(seed.id, {
168
+ id: seed.id,
169
+ title: seed.title,
170
+ targetId: seed.targetId,
171
+ createdAt: seed.messages[0].createdAt,
172
+ updatedAt: seed.messages[seed.messages.length - 1].createdAt,
173
+ revision: nextRevision(),
174
+ messages: seed.messages.map((message) => ({
175
+ id: message.id,
176
+ role: message.role,
177
+ content: message.model ?? message.display,
178
+ ...(message.model ? { displayContent: message.display } : {}),
179
+ displayAvailable: true,
180
+ timestamp: message.createdAt,
181
+ })),
182
+ });
183
+ }
184
+
185
+ const shouldFail = (operation: FailableOperation): boolean => {
186
+ const remaining = failures.get(operation) ?? 0;
187
+ if (remaining <= 0) return false;
188
+ failures.set(operation, remaining - 1);
189
+ return true;
190
+ };
191
+
192
+ const summarize = (conversation: BackendConversation) => ({
193
+ id: conversation.id,
194
+ title: conversation.title,
195
+ targetId: conversation.targetId,
196
+ flowId: conversation.targetId,
197
+ preview: null,
198
+ messageCount: conversation.messages.length,
199
+ createdAt: conversation.createdAt,
200
+ updatedAt: conversation.updatedAt,
201
+ });
202
+
203
+ const fetchImpl = async (input: unknown, init?: RequestInit) => {
204
+ const url = String(input);
205
+ const method = init?.method ?? 'GET';
206
+ const body = init?.body
207
+ ? (JSON.parse(String(init.body)) as Record<string, unknown>)
208
+ : undefined;
209
+ requests.push({ url, method, ...(body ? { body } : {}) });
210
+ const path = new URL(url).pathname;
211
+ const query = new URL(url).searchParams;
212
+
213
+ if (path === '/v1/client/init') {
214
+ const resumeId = body?.conversationId as string | undefined;
215
+ if (resumeId && shouldFail('prepareOpen')) {
216
+ return respond(500, { error: 'init failed' });
217
+ }
218
+ if (!resumeId && !body?.sessionId && shouldFail('prepareStartNew')) {
219
+ return respond(500, { error: 'init failed' });
220
+ }
221
+ let conversationId: string;
222
+ if (resumeId) {
223
+ if (!conversations.has(resumeId)) {
224
+ return respond(404, { error: 'not_found' });
225
+ }
226
+ conversationId = resumeId;
227
+ } else if (body?.sessionId && sessions.has(String(body.sessionId))) {
228
+ conversationId = sessions.get(String(body.sessionId))!;
229
+ } else {
230
+ convSeq += 1;
231
+ conversationId = `conv-new-${convSeq}`;
232
+ const created = new Date().toISOString();
233
+ conversations.set(conversationId, {
234
+ id: conversationId,
235
+ title: 'New conversation',
236
+ targetId: 'flow_1',
237
+ createdAt: created,
238
+ updatedAt: created,
239
+ revision: nextRevision(),
240
+ messages: [],
241
+ });
242
+ }
243
+ sessionSeq += 1;
244
+ const sessionId = `sess-${sessionSeq}`;
245
+ sessions.set(sessionId, conversationId);
246
+ return respond(200, {
247
+ sessionId,
248
+ expiresAt: futureIso(),
249
+ flow: { id: 'flow_1', name: 'Flow', description: null },
250
+ conversationId,
251
+ targetId: 'flow_1',
252
+ conversationRevision: conversations.get(conversationId)!.revision,
253
+ config: { welcomeMessage: WELCOME, placeholder: 'Ask…', theme: null },
254
+ });
255
+ }
256
+
257
+ if (path === '/v1/client/chat') {
258
+ const next = chatQueue.shift();
259
+ if (next) return next();
260
+ return sseOk('data: {"type":"status","status":"idle","terminal":true}\n\n');
261
+ }
262
+
263
+ if (path === '/v1/client/visitor/reset') {
264
+ return respond(200, { reset: true });
265
+ }
266
+
267
+ if (path === '/v1/client/conversations' && method === 'GET') {
268
+ if (shouldFail('list')) return respond(500, { error: 'list failed' });
269
+ const targetId = query.get('targetId');
270
+ const rows = [...conversations.values()]
271
+ .filter((conversation) => conversation.messages.length > 0)
272
+ .filter(
273
+ (conversation) => !targetId || conversation.targetId === targetId
274
+ );
275
+ return respond(200, { data: rows.map(summarize), nextCursor: null });
276
+ }
277
+
278
+ if (path === '/v1/client/conversations' && method === 'DELETE') {
279
+ if (shouldFail('deleteAll')) return respond(500, { error: 'delete failed' });
280
+ const targetId = query.get('targetId');
281
+ let deleted = 0;
282
+ for (const conversation of [...conversations.values()]) {
283
+ if (targetId && conversation.targetId !== targetId) continue;
284
+ conversations.delete(conversation.id);
285
+ deleted += 1;
286
+ }
287
+ return respond(200, { deleted });
288
+ }
289
+
290
+ const detailMatch = /^\/v1\/client\/conversations\/([^/]+)$/.exec(path);
291
+ if (detailMatch) {
292
+ const conversation = conversations.get(decodeURIComponent(detailMatch[1]));
293
+ if (method === 'DELETE') {
294
+ if (shouldFail('delete')) return respond(500, { error: 'delete failed' });
295
+ if (!conversation) return respond(404, { error: 'not_found' });
296
+ conversations.delete(conversation.id);
297
+ return respond(200, { deleted: 1 });
298
+ }
299
+ if (shouldFail('getPage')) return respond(500, { error: 'detail failed' });
300
+ if (!conversation) return respond(404, { error: 'not_found' });
301
+ const cursor = query.get('messageCursor');
302
+ const end = cursor ? Number(cursor) : conversation.messages.length;
303
+ const start = Math.max(0, end - PAGE_SIZE);
304
+ return respond(200, {
305
+ conversation: summarize(conversation),
306
+ messages: conversation.messages.slice(start, end),
307
+ nextMessageCursor: start > 0 ? String(start) : null,
308
+ conversationRevision: conversation.revision,
309
+ });
310
+ }
311
+
312
+ const projectionMatch =
313
+ /^\/v1\/client\/conversations\/([^/]+)\/display-projections$/.exec(path);
314
+ if (projectionMatch) {
315
+ const conversation = conversations.get(
316
+ decodeURIComponent(projectionMatch[1])
317
+ );
318
+ if (!conversation) return respond(404, { error: 'not_found' });
319
+ const batch = (body?.messages ?? []) as Array<{
320
+ id: string;
321
+ displayContent: string;
322
+ }>;
323
+ for (const item of batch) {
324
+ const stored = conversation.messages.find((row) => row.id === item.id);
325
+ if (stored) stored.displayContent = item.displayContent;
326
+ }
327
+ conversation.revision = nextRevision();
328
+ return respond(200, { conversationRevision: conversation.revision });
329
+ }
330
+
331
+ return respond(404, { error: 'unhandled' });
332
+ };
333
+
334
+ return {
335
+ conversations,
336
+ requests,
337
+ fetchImpl,
338
+ failNext(operation: FailableOperation, times = 1) {
339
+ failures.set(operation, (failures.get(operation) ?? 0) + times);
340
+ },
341
+ queueChat(responder: () => unknown) {
342
+ chatQueue.push(responder);
343
+ },
344
+ appendMessage(conversationId: string, message: BackendConversation['messages'][number]) {
345
+ const conversation = conversations.get(conversationId);
346
+ if (!conversation) throw new Error(`unknown conversation ${conversationId}`);
347
+ conversation.messages.push(message);
348
+ conversation.revision = nextRevision();
349
+ conversation.updatedAt = message.timestamp;
350
+ },
351
+ revisionOf(conversationId: string) {
352
+ return conversations.get(conversationId)?.revision ?? null;
353
+ },
354
+ chatRequests(): ClientChatRequest[] {
355
+ return requests
356
+ .filter((request) => request.url.includes('/v1/client/chat'))
357
+ .map((request) => request.body as unknown as ClientChatRequest);
358
+ },
359
+ };
360
+ }
361
+
362
+ type Backend = ReturnType<typeof createRuntypeBackend>;
363
+
364
+ // ── Harness ───────────────────────────────────────────────────────────────
365
+
366
+ type Harness = {
367
+ session: AgentWidgetSession;
368
+ provider: HistoryProvider;
369
+ messages: () => AgentWidgetMessage[];
370
+ renderCount: () => number;
371
+ notices: SessionHistoryNotice[];
372
+ states: SessionHistoryState[];
373
+ meta: Record<string, unknown>;
374
+ failNext: (operation: FailableOperation) => void;
375
+ backend: Backend | null;
376
+ store: VisitorStore | null;
377
+ /** The RAW internals bag; re-composing an already-composed one double-wraps. */
378
+ rawInternals: WidgetHistoryInternals;
379
+ destroy: () => void;
380
+ };
381
+
382
+ function createMetadataSeam() {
383
+ const meta: Record<string, unknown> = {};
384
+ const read = (key: string): string | null =>
385
+ typeof meta[key] === 'string' ? (meta[key] as string) : null;
386
+ const write = (key: string, value: string | null) => {
387
+ if (value === null) delete meta[key];
388
+ else meta[key] = value;
389
+ };
390
+ return {
391
+ meta,
392
+ config: {
393
+ getStoredSessionId: () => read('sessionId'),
394
+ setStoredSessionId: (value: string) => write('sessionId', value),
395
+ clearStoredSessionId: () => write('sessionId', null),
396
+ getStoredConversationId: () => read('conversationId'),
397
+ setStoredConversationId: (value: string) => write('conversationId', value),
398
+ clearStoredConversationId: () => write('conversationId', null),
399
+ },
400
+ internals: {
401
+ getStoredConversationRevision: () => read('conversationRevision'),
402
+ setStoredConversationRevision: (value: string | null) =>
403
+ write('conversationRevision', value),
404
+ getStoredMessageCursor: () => read('messageCursor'),
405
+ setStoredMessageCursor: (value: string | null) =>
406
+ write('messageCursor', value),
407
+ getPendingDisplayProjections: () =>
408
+ (meta.pendingProjections as PendingDisplayProjections | undefined) ?? null,
409
+ setPendingDisplayProjections: (pending: PendingDisplayProjections | null) => {
410
+ if (pending === null) delete meta.pendingProjections;
411
+ else meta.pendingProjections = pending;
412
+ },
413
+ } satisfies WidgetHistoryInternals,
414
+ };
415
+ }
416
+
417
+ function createSessionShell(
418
+ config: Record<string, unknown>,
419
+ internals: WidgetHistoryInternals
420
+ ) {
421
+ let messages: AgentWidgetMessage[] = [];
422
+ let renders = 0;
423
+ const notices: SessionHistoryNotice[] = [];
424
+ const states: SessionHistoryState[] = [];
425
+ const session = new AgentWidgetSession(
426
+ config,
427
+ {
428
+ onMessagesChanged: (next) => {
429
+ messages = next;
430
+ renders += 1;
431
+ },
432
+ onStatusChanged: () => {},
433
+ onStreamingChanged: () => {},
434
+ onHistoryStateChanged: (state) => states.push(state),
435
+ onHistoryNotice: (notice) => notices.push(notice),
436
+ },
437
+ internals
438
+ );
439
+ return {
440
+ session,
441
+ messages: () => messages,
442
+ renderCount: () => renders,
443
+ notices,
444
+ states,
445
+ };
446
+ }
447
+
448
+ async function createRuntypeHarness(): Promise<Harness> {
449
+ const backend = createRuntypeBackend();
450
+ vi.stubGlobal('fetch', vi.fn(backend.fetchImpl));
451
+ const store: VisitorStore = createVisitorStore(CLIENT_TOKEN, 'persona-', false);
452
+ await store.set('cvt_session_history');
453
+
454
+ const seam = createMetadataSeam();
455
+ const internals: WidgetHistoryInternals = {
456
+ ...seam.internals,
457
+ visitorStore: store,
458
+ };
459
+ const shell = createSessionShell(
460
+ {
461
+ clientToken: CLIENT_TOKEN,
462
+ apiUrl: API_URL,
463
+ features: { history: { enabled: true, scope: 'browser' } },
464
+ ...seam.config,
465
+ },
466
+ internals
467
+ );
468
+
469
+ const provider = createRuntypeHistoryProvider({
470
+ client: shell.session.getClient(),
471
+ getIdentityProofConfigured: () => false,
472
+ onActivationCommitted: (clientSession) =>
473
+ shell.session.bindActivatedSession(clientSession),
474
+ getClient: () => shell.session.getClient(),
475
+ });
476
+ const rawInternals: WidgetHistoryInternals = {
477
+ ...internals,
478
+ historyProvider: provider,
479
+ };
480
+ shell.session.setHistoryInternals(rawInternals);
481
+
482
+ return {
483
+ ...shell,
484
+ provider,
485
+ meta: seam.meta,
486
+ backend,
487
+ store,
488
+ rawInternals,
489
+ failNext: (operation) => backend.failNext(operation),
490
+ destroy: () => {
491
+ store.destroy();
492
+ vi.unstubAllGlobals();
493
+ },
494
+ };
495
+ }
496
+
497
+ async function createDemoHarness(): Promise<Harness> {
498
+ const provider = createDemoHistoryProvider({
499
+ pageSize: PAGE_SIZE,
500
+ conversations: SEEDS.map((seed) => ({
501
+ id: seed.id,
502
+ title: seed.title,
503
+ targetId: seed.targetId,
504
+ createdAt: seed.messages[0].createdAt,
505
+ updatedAt: seed.messages[seed.messages.length - 1].createdAt,
506
+ messages: seed.messages.map((message) => ({
507
+ id: message.id,
508
+ role: message.role,
509
+ // The demo provider has no model channel: content IS the projection.
510
+ content: message.display,
511
+ createdAt: message.createdAt,
512
+ })),
513
+ })),
514
+ });
515
+
516
+ const seam = createMetadataSeam();
517
+ const internals: WidgetHistoryInternals = {
518
+ ...seam.internals,
519
+ historyProvider: provider,
520
+ };
521
+ const shell = createSessionShell({ apiUrl: API_URL, ...seam.config }, internals);
522
+
523
+ return {
524
+ ...shell,
525
+ provider,
526
+ meta: seam.meta,
527
+ backend: null,
528
+ store: null,
529
+ rawInternals: internals,
530
+ failNext: (operation) =>
531
+ provider.failNext(operation, { code: 'unavailable' }),
532
+ destroy: () => {},
533
+ };
534
+ }
535
+
536
+ const VARIANTS: Array<[string, () => Promise<Harness>]> = [
537
+ ['runtype provider', createRuntypeHarness],
538
+ ['demo provider', createDemoHarness],
539
+ ];
540
+
541
+ describe.each(VARIANTS)('session history core scenarios (%s)', (_name, build) => {
542
+ let harness: Harness;
543
+
544
+ beforeEach(async () => {
545
+ window.localStorage.clear();
546
+ harness = await build();
547
+ });
548
+
549
+ afterEach(() => {
550
+ harness.destroy();
551
+ vi.restoreAllMocks();
552
+ });
553
+
554
+ it('hydrates the newest page oldest-first and records the active record', async () => {
555
+ const result = await harness.session.openConversation('conv-b');
556
+
557
+ expect(harness.messages().map((message) => message.id)).toEqual(['b4', 'b5']);
558
+ expect(result.summary.id).toBe('conv-b');
559
+ expect(result.nextMessageCursor).toBeTruthy();
560
+ expect(harness.session.getActiveConversationId()).toBe('conv-b');
561
+ expect(harness.meta.conversationId).toBe('conv-b');
562
+ expect(harness.meta.conversationRevision).toBeTruthy();
563
+ expect(harness.session.getHistoryState().nextMessageCursor).toBe(
564
+ result.nextMessageCursor
565
+ );
566
+ });
567
+
568
+ it('keeps the display projection separate from the model channel on reopen', async () => {
569
+ await harness.session.openConversation('conv-a');
570
+
571
+ const [first] = harness.messages();
572
+ expect(first.content).toBe('Where is my order?');
573
+ expect(first.content).not.toContain('intent');
574
+ expect(first.llmContent).toBeUndefined();
575
+ expect(first.rawContent).toBeUndefined();
576
+ });
577
+
578
+ it('never injects the welcome message when reopening a conversation', async () => {
579
+ await harness.session.openConversation('conv-a');
580
+
581
+ expect(
582
+ harness.messages().some((message) => message.content === WELCOME)
583
+ ).toBe(false);
584
+ expect(harness.messages()).toHaveLength(2);
585
+ });
586
+
587
+ it('refuses to switch conversations while a turn is streaming', async () => {
588
+ harness.session.injectTestEvent({ type: 'status', status: 'connecting' });
589
+ expect(harness.session.isStreaming()).toBe(true);
590
+
591
+ const error = await harness.session
592
+ .openConversation('conv-a')
593
+ .catch((caught: unknown) => caught);
594
+ expect(error).toBeInstanceOf(SessionHistoryError);
595
+ expect((error as SessionHistoryError).code).toBe('conversation_busy');
596
+ expect(harness.messages()).toHaveLength(0);
597
+
598
+ harness.session.cancel();
599
+ });
600
+
601
+ it('lets the later selection win and discards the superseded one', async () => {
602
+ const first = harness.session
603
+ .openConversation('conv-a')
604
+ .catch((error: unknown) => error);
605
+ const second = await harness.session.openConversation('conv-b');
606
+ const firstResult = await first;
607
+
608
+ expect(second.summary.id).toBe('conv-b');
609
+ expect(firstResult).toBeInstanceOf(SessionHistoryError);
610
+ expect((firstResult as SessionHistoryError).code).toBe('superseded');
611
+ expect(harness.session.getActiveConversationId()).toBe('conv-b');
612
+ expect(harness.messages().map((message) => message.id)).toEqual(['b4', 'b5']);
613
+ });
614
+
615
+ it('prepends one older page with a single repaint and dedupes concurrent clicks', async () => {
616
+ const opened = await harness.session.openConversation('conv-b');
617
+ const before = harness.renderCount();
618
+
619
+ const cursor = opened.nextMessageCursor as string;
620
+ const [a, b] = await Promise.all([
621
+ harness.session.loadOlderMessages('conv-b', cursor),
622
+ harness.session.loadOlderMessages('conv-b', cursor),
623
+ ]);
624
+
625
+ expect(a).toBe(b);
626
+ expect(harness.renderCount()).toBe(before + 1);
627
+ expect(harness.messages().map((message) => message.id)).toEqual([
628
+ 'b2',
629
+ 'b3',
630
+ 'b4',
631
+ 'b5',
632
+ ]);
633
+ expect(harness.session.getHistoryState().nextMessageCursor).toBeTruthy();
634
+ });
635
+
636
+ it('leaves the current conversation intact when starting a new one fails', async () => {
637
+ await harness.session.openConversation('conv-a');
638
+ const before = harness.messages().map((message) => message.id);
639
+ harness.failNext('prepareStartNew');
640
+
641
+ await expect(harness.session.startNewConversation()).rejects.toBeTruthy();
642
+
643
+ expect(harness.messages().map((message) => message.id)).toEqual(before);
644
+ expect(harness.session.getActiveConversationId()).toBe('conv-a');
645
+ expect(harness.meta.conversationId).toBe('conv-a');
646
+ });
647
+
648
+ it('clears local state and commits the replacement on a successful new conversation', async () => {
649
+ await harness.session.openConversation('conv-a');
650
+
651
+ await harness.session.startNewConversation();
652
+
653
+ expect(
654
+ harness.messages().filter((message) => message.content !== WELCOME)
655
+ ).toHaveLength(0);
656
+ expect(harness.session.getActiveConversationId()).not.toBe('conv-a');
657
+ expect(harness.session.getHistoryState().nextMessageCursor).toBeNull();
658
+ expect(harness.meta.messageCursor).toBeUndefined();
659
+ });
660
+
661
+ it('is non-disruptive when deleting an inactive conversation', async () => {
662
+ await harness.session.openConversation('conv-a');
663
+ const activeBefore = harness.session.getActiveConversationId();
664
+
665
+ await harness.session.deleteConversation('conv-b');
666
+
667
+ expect(harness.session.getActiveConversationId()).toBe(activeBefore);
668
+ expect(harness.messages().map((message) => message.id)).toEqual(['a1', 'a2']);
669
+ expect(harness.session.getHistoryState().sendBlocked).toBe(false);
670
+ });
671
+
672
+ it('destroys the transcript and prepares a replacement after deleting the active record', async () => {
673
+ await harness.session.openConversation('conv-a');
674
+
675
+ await harness.session.deleteConversation('conv-a');
676
+
677
+ expect(
678
+ harness.messages().filter((message) => message.content !== WELCOME)
679
+ ).toHaveLength(0);
680
+ expect(harness.session.getActiveConversationId()).not.toBe('conv-a');
681
+ expect(harness.session.getHistoryState().recovery).toBeNull();
682
+ expect(harness.session.getHistoryState().sendBlocked).toBe(false);
683
+ // Never resurrected, even as a fallback.
684
+ await expect(harness.session.openConversation('conv-a')).rejects.toBeTruthy();
685
+ });
686
+
687
+ it('enters new_conversation_required when the replacement cannot be prepared', async () => {
688
+ await harness.session.openConversation('conv-a');
689
+ harness.failNext('prepareStartNew');
690
+
691
+ await harness.session.deleteConversation('conv-a');
692
+
693
+ const state = harness.session.getHistoryState();
694
+ expect(state.recovery).toBe('new_conversation_required');
695
+ expect(state.sendBlocked).toBe(true);
696
+ expect(harness.messages()).toHaveLength(0);
697
+ expect(
698
+ harness.notices.some(
699
+ (notice) => notice.code === 'new_conversation_required'
700
+ )
701
+ ).toBe(true);
702
+
703
+ // The next explicit action retries fresh initialization.
704
+ await harness.session.startNewConversation();
705
+ expect(harness.session.getHistoryState().recovery).toBeNull();
706
+ expect(harness.session.getHistoryState().sendBlocked).toBe(false);
707
+ });
708
+
709
+ it('destroys the active transcript after delete-all', async () => {
710
+ await harness.session.openConversation('conv-a');
711
+
712
+ const result = await harness.session.clearConversationHistory({
713
+ targetId: 'flow_1',
714
+ });
715
+
716
+ expect(result.deleted).toBeGreaterThan(0);
717
+ expect(
718
+ harness.messages().filter((message) => message.content !== WELCOME)
719
+ ).toHaveLength(0);
720
+ expect(harness.session.getActiveConversationId()).not.toBe('conv-a');
721
+ });
722
+
723
+ it('lists conversations through the seam with the active target filter', async () => {
724
+ const page = await harness.session.listConversations({ targetId: 'flow_1' });
725
+ expect(page.items.map((item) => item.id)).toEqual(
726
+ expect.arrayContaining(['conv-a', 'conv-b'])
727
+ );
728
+ });
729
+ });
730
+
731
+ // ── Runtype-specific behavior ─────────────────────────────────────────────
732
+
733
+ describe('session history (Runtype transport specifics)', () => {
734
+ let harness: Harness;
735
+ let backend: Backend;
736
+
737
+ beforeEach(async () => {
738
+ window.localStorage.clear();
739
+ harness = await createRuntypeHarness();
740
+ backend = harness.backend as Backend;
741
+ });
742
+
743
+ afterEach(() => {
744
+ harness.destroy();
745
+ vi.restoreAllMocks();
746
+ });
747
+
748
+ it('allows the welcome message after startNewConversation clears the transcript', async () => {
749
+ await harness.session.openConversation('conv-a');
750
+ expect(harness.messages().some((m) => m.content === WELCOME)).toBe(false);
751
+
752
+ await harness.session.startNewConversation();
753
+
754
+ expect(harness.messages().map((m) => m.content)).toEqual([WELCOME]);
755
+ });
756
+
757
+ it('bypasses the client session cache when preparing an open', async () => {
758
+ // Prime a live ordinary session first.
759
+ await harness.session.listConversations();
760
+ const before = backend.requests.filter((r) =>
761
+ r.url.endsWith('/v1/client/init')
762
+ ).length;
763
+
764
+ await harness.session.openConversation('conv-a');
765
+
766
+ const initCalls = backend.requests.filter((r) =>
767
+ r.url.endsWith('/v1/client/init')
768
+ );
769
+ expect(initCalls.length).toBe(before + 1);
770
+ const forced = initCalls[initCalls.length - 1];
771
+ expect(forced.body?.conversationId).toBe('conv-a');
772
+ // Strict union: a resume never carries the stored session id.
773
+ expect(forced.body?.sessionId).toBeUndefined();
774
+ });
775
+
776
+ it('sends the user turn display projection inline where it diverges', async () => {
777
+ await harness.session.startNewConversation();
778
+ harness.session.injectMessage({
779
+ role: 'user',
780
+ content: 'Show me the receipt',
781
+ llmContent: '{"tool":"receipt"}',
782
+ id: 'usr_projection',
783
+ });
784
+
785
+ await harness.session.continueConversation();
786
+
787
+ const chat = backend.chatRequests().at(-1);
788
+ const sent = chat?.messages.find((message) => message.id === 'usr_projection');
789
+ expect(sent?.content).toBe('{"tool":"receipt"}');
790
+ expect(sent?.displayContent).toBe('Show me the receipt');
791
+ });
792
+
793
+ describe('safe 410 recovery', () => {
794
+ it('retries once with only the just-submitted user turn', async () => {
795
+ await harness.session.openConversation('conv-a');
796
+ backend.queueChat(() => respond(410, { error: 'conversation_deleted' }));
797
+
798
+ await harness.session.sendMessage('please try again');
799
+
800
+ const chats = backend.chatRequests();
801
+ expect(chats).toHaveLength(2);
802
+ const [first, replacement] = chats;
803
+ expect(first.messages.length).toBeGreaterThan(1);
804
+ expect(replacement.messages).toHaveLength(1);
805
+ expect(replacement.messages[0].content).toBe('please try again');
806
+ // No prior message id or content is re-sent into the fresh record. Ids
807
+ // compare as whole values: a fresh random id can contain "a1"/"a2" as a
808
+ // substring (usr_..._iva26z7k), which made a serialized check flaky.
809
+ const sentIds = replacement.messages.map((message) => message.id);
810
+ for (const id of ['a1', 'a2']) expect(sentIds).not.toContain(id);
811
+ const serialized = JSON.stringify(replacement);
812
+ expect(serialized).not.toContain('Where is my order?');
813
+ expect(serialized).not.toContain('It ships Thursday.');
814
+ expect(replacement.sessionId).not.toBe(first.sessionId);
815
+
816
+ expect(
817
+ harness.notices.some(
818
+ (notice) => notice.code === 'conversation_deleted_recovered'
819
+ )
820
+ ).toBe(true);
821
+ expect(harness.messages().map((m) => m.content)).toContain(
822
+ 'please try again'
823
+ );
824
+ });
825
+
826
+ it('surfaces a second 410 instead of looping', async () => {
827
+ await harness.session.openConversation('conv-a');
828
+ const errors: Error[] = [];
829
+ backend.queueChat(() => respond(410, { error: 'conversation_deleted' }));
830
+ backend.queueChat(() => respond(410, { error: 'conversation_deleted' }));
831
+
832
+ const shell = harness.session as unknown as {
833
+ callbacks: { onError?: (error: Error) => void };
834
+ };
835
+ shell.callbacks.onError = (error) => errors.push(error);
836
+
837
+ await harness.session.sendMessage('still deleted');
838
+
839
+ expect(backend.chatRequests()).toHaveLength(2);
840
+ expect(errors.some((error) => /deleted/i.test(error.message))).toBe(true);
841
+ });
842
+ });
843
+
844
+ describe('authoritative boot reconciliation', () => {
845
+ /** Restored transcript + persisted metadata, then the real boot init. */
846
+ const bootWith = async (
847
+ conversationId: string,
848
+ persistedRevision?: string | null
849
+ ) => {
850
+ const conversation = backend.conversations.get(conversationId)!;
851
+ harness.session.hydrateMessages(
852
+ conversation.messages.map((message) => ({
853
+ id: message.id,
854
+ role: message.role as AgentWidgetMessage['role'],
855
+ content: message.displayContent ?? message.content ?? '',
856
+ createdAt: message.timestamp,
857
+ }))
858
+ );
859
+ harness.meta.conversationId = conversationId;
860
+ if (persistedRevision === null) delete harness.meta.conversationRevision;
861
+ else if (persistedRevision !== undefined) {
862
+ harness.meta.conversationRevision = persistedRevision;
863
+ }
864
+ return harness.session.initClientSession();
865
+ };
866
+
867
+ it('skips the detail fetch when the init revision matches the persisted one', async () => {
868
+ await bootWith('conv-a', backend.revisionOf('conv-a'));
869
+ const before = backend.requests.length;
870
+
871
+ await harness.session.reconcileBootConversation();
872
+
873
+ const detailCalls = backend.requests
874
+ .slice(before)
875
+ .filter((request) => /conversations\/conv-a(\?|$)/.test(request.url));
876
+ expect(detailCalls).toHaveLength(0);
877
+ });
878
+
879
+ it('merges the newest page with the server projection winning on overlap', async () => {
880
+ // Stale local transcript from the last page load.
881
+ harness.session.hydrateMessages([
882
+ { id: 'a1', role: 'user', content: 'Where is my order?', createdAt: T(0) },
883
+ {
884
+ id: 'a2',
885
+ role: 'assistant',
886
+ content: 'It ships Thursday.',
887
+ createdAt: T(1),
888
+ },
889
+ ]);
890
+ // The server refreshed a2's projection and appended a remote turn.
891
+ const conversation = backend.conversations.get('conv-a')!;
892
+ conversation.messages[1].displayContent = 'It ships Friday.';
893
+ backend.appendMessage('conv-a', {
894
+ id: 'a3',
895
+ role: 'assistant',
896
+ content: 'Added from another device.',
897
+ displayAvailable: true,
898
+ timestamp: T(2),
899
+ });
900
+ harness.meta.conversationId = 'conv-a';
901
+ harness.meta.conversationRevision = 'rev-stale';
902
+ await harness.session.initClientSession();
903
+
904
+ await harness.session.reconcileBootConversation();
905
+
906
+ const messages = harness.messages();
907
+ expect(messages.map((message) => message.id)).toEqual(['a1', 'a2', 'a3']);
908
+ // Older than the returned page: the local row survives untouched.
909
+ expect(messages[0].content).toBe('Where is my order?');
910
+ expect(messages[1].content).toBe('It ships Friday.');
911
+ expect(messages[2].content).toBe('Added from another device.');
912
+ expect(harness.meta.conversationRevision).toBe(backend.revisionOf('conv-a'));
913
+ });
914
+
915
+ it('replaces the transcript and keeps the cursor when nothing overlaps', async () => {
916
+ harness.session.hydrateMessages([
917
+ {
918
+ id: 'local-only',
919
+ role: 'user',
920
+ content: 'from an unrelated record',
921
+ createdAt: T(30),
922
+ },
923
+ ]);
924
+ harness.meta.conversationId = 'conv-b';
925
+ await harness.session.initClientSession();
926
+
927
+ await harness.session.reconcileBootConversation();
928
+
929
+ expect(harness.messages().map((message) => message.id)).toEqual([
930
+ 'b4',
931
+ 'b5',
932
+ ]);
933
+ expect(harness.session.getHistoryState().nextMessageCursor).toBeTruthy();
934
+ expect(harness.meta.messageCursor).toBeTruthy();
935
+ });
936
+
937
+ it('blocks sending until reconciliation completes', async () => {
938
+ await bootWith('conv-b', 'rev-stale');
939
+
940
+ const reconcile = harness.session.reconcileBootConversation();
941
+ expect(harness.session.getHistoryState().sendBlocked).toBe(true);
942
+ await reconcile;
943
+ expect(harness.session.getHistoryState().sendBlocked).toBe(false);
944
+ });
945
+
946
+ it('clears the persisted revision after a local mutation that returns none', async () => {
947
+ await harness.session.openConversation('conv-a');
948
+ expect(harness.meta.conversationRevision).toBeTruthy();
949
+
950
+ await harness.session.sendMessage('a local mutation');
951
+
952
+ expect(harness.meta.conversationRevision).toBeUndefined();
953
+ });
954
+ });
955
+
956
+ describe('display-projection finalization', () => {
957
+ const projectionCalls = () =>
958
+ backend.requests.filter((request) =>
959
+ request.url.includes('/display-projections')
960
+ );
961
+
962
+ /** A postprocessed assistant turn: display diverges from stored model text. */
963
+ const streamDivergentAssistant = (id = 'ast_final') => {
964
+ harness.session.injectTestEvent({
965
+ type: 'message',
966
+ message: {
967
+ id,
968
+ role: 'assistant',
969
+ content: 'Rendered summary for the visitor',
970
+ llmContent: '{"total":42}',
971
+ createdAt: T(20),
972
+ },
973
+ });
974
+ harness.session.injectTestEvent({
975
+ type: 'status',
976
+ status: 'idle',
977
+ terminal: true,
978
+ });
979
+ };
980
+
981
+ it('enqueues only the messages whose browser-final projection diverges', async () => {
982
+ await harness.session.openConversation('conv-a');
983
+ streamDivergentAssistant();
984
+ await harness.session.awaitHistorySettled();
985
+
986
+ const calls = projectionCalls();
987
+ expect(calls).toHaveLength(1);
988
+ expect(calls[0].body?.messages).toEqual([
989
+ { id: 'ast_final', displayContent: 'Rendered summary for the visitor' },
990
+ ]);
991
+
992
+ // Replaying the same terminal state is a no-op once acknowledged.
993
+ harness.session.injectTestEvent({
994
+ type: 'status',
995
+ status: 'idle',
996
+ terminal: true,
997
+ });
998
+ await harness.session.awaitHistorySettled();
999
+ expect(projectionCalls()).toHaveLength(1);
1000
+ });
1001
+
1002
+ it('collapses duplicate terminal scans queued before the first PATCH resolves', async () => {
1003
+ await harness.session.openConversation('conv-a');
1004
+ streamDivergentAssistant();
1005
+ // Second terminal before the first PATCH acknowledges: both scans see the
1006
+ // projection unacknowledged and chain identical batches.
1007
+ harness.session.injectTestEvent({
1008
+ type: 'status',
1009
+ status: 'idle',
1010
+ terminal: true,
1011
+ });
1012
+ await harness.session.awaitHistorySettled();
1013
+
1014
+ expect(projectionCalls()).toHaveLength(1);
1015
+ expect(harness.meta.pendingProjections).toBeUndefined();
1016
+ });
1017
+
1018
+ it('serializes finalization against the next conversation switch', async () => {
1019
+ await harness.session.openConversation('conv-a');
1020
+ streamDivergentAssistant();
1021
+
1022
+ await harness.session.openConversation('conv-b');
1023
+
1024
+ // The switch waited for the in-flight finalization before committing.
1025
+ expect(projectionCalls()).toHaveLength(1);
1026
+ expect(harness.meta.pendingProjections).toBeUndefined();
1027
+ expect(harness.session.getActiveConversationId()).toBe('conv-b');
1028
+ });
1029
+
1030
+ it('persists a pending marker and replays it on boot', async () => {
1031
+ await harness.session.openConversation('conv-a');
1032
+ // Fail both the initial attempt and its one retry.
1033
+ harness.session.injectTestEvent({
1034
+ type: 'message',
1035
+ message: {
1036
+ id: 'ast_pending',
1037
+ role: 'assistant',
1038
+ content: 'Visible copy',
1039
+ llmContent: '{"raw":true}',
1040
+ createdAt: T(21),
1041
+ },
1042
+ });
1043
+ const failing = vi
1044
+ .spyOn(harness.session.getClient(), 'finalizeDisplayProjections')
1045
+ .mockRejectedValue(new Error('network down'));
1046
+ harness.session.injectTestEvent({
1047
+ type: 'status',
1048
+ status: 'idle',
1049
+ terminal: true,
1050
+ });
1051
+ await harness.session.awaitHistorySettled();
1052
+
1053
+ expect(failing).toHaveBeenCalledTimes(2); // one transient retry, then stop
1054
+ expect(harness.meta.pendingProjections).toEqual({
1055
+ conversationId: 'conv-a',
1056
+ messageIds: ['ast_pending'],
1057
+ });
1058
+ expect(
1059
+ harness.notices.some(
1060
+ (notice) => notice.code === 'projection_finalization_failed'
1061
+ )
1062
+ ).toBe(true);
1063
+
1064
+ failing.mockRestore();
1065
+ await harness.session.replayPendingProjections();
1066
+
1067
+ expect(projectionCalls().at(-1)?.body?.messages).toEqual([
1068
+ { id: 'ast_pending', displayContent: 'Visible copy' },
1069
+ ]);
1070
+ expect(harness.meta.pendingProjections).toBeUndefined();
1071
+ });
1072
+
1073
+ it('serializes finalization ahead of the next dispatch', async () => {
1074
+ await harness.session.openConversation('conv-a');
1075
+ streamDivergentAssistant();
1076
+
1077
+ await harness.session.sendMessage('next turn');
1078
+
1079
+ const order = backend.requests.map((request) => request.url);
1080
+ const projectionIndex = order.findIndex((url) =>
1081
+ url.includes('/display-projections')
1082
+ );
1083
+ const chatIndex = order.lastIndexOf(`${API_URL}/v1/client/chat`);
1084
+ expect(projectionIndex).toBeGreaterThan(-1);
1085
+ expect(projectionIndex).toBeLessThan(chatIndex);
1086
+ });
1087
+
1088
+ it('invalidates a pending marker when the active record is deleted', async () => {
1089
+ await harness.session.openConversation('conv-a');
1090
+ harness.meta.pendingProjections = {
1091
+ conversationId: 'conv-a',
1092
+ messageIds: ['a2'],
1093
+ };
1094
+
1095
+ await harness.session.deleteConversation('conv-a');
1096
+
1097
+ expect(harness.meta.pendingProjections).toBeUndefined();
1098
+ });
1099
+
1100
+ it('drops a stale marker when the active conversation changed', async () => {
1101
+ await harness.session.openConversation('conv-a');
1102
+ harness.meta.pendingProjections = {
1103
+ conversationId: 'conv-zombie',
1104
+ messageIds: ['ghost'],
1105
+ };
1106
+
1107
+ await harness.session.replayPendingProjections();
1108
+
1109
+ expect(harness.meta.pendingProjections).toBeUndefined();
1110
+ expect(projectionCalls()).toHaveLength(0);
1111
+ });
1112
+
1113
+ it('invalidates pending projections on device reset', async () => {
1114
+ await harness.session.openConversation('conv-a');
1115
+ harness.meta.pendingProjections = {
1116
+ conversationId: 'conv-a',
1117
+ messageIds: ['a2'],
1118
+ };
1119
+
1120
+ const result = await harness.session.resetHistoryDevice();
1121
+
1122
+ expect(result.remoteRevocationConfirmed).toBe(true);
1123
+ expect(harness.meta.pendingProjections).toBeUndefined();
1124
+ expect(harness.session.getActiveConversationId()).toBeNull();
1125
+ expect(harness.messages()).toHaveLength(0);
1126
+ });
1127
+ });
1128
+
1129
+ describe('continuity wipe', () => {
1130
+ it('destroys local state and blocks dispatch until cleanup completes', async () => {
1131
+ await harness.session.openConversation('conv-a');
1132
+ harness.meta.pendingProjections = {
1133
+ conversationId: 'conv-a',
1134
+ messageIds: ['a2'],
1135
+ };
1136
+
1137
+ // The client announces the transition through the internals bag.
1138
+ const internals = (
1139
+ harness.session as unknown as {
1140
+ historyInternals: WidgetHistoryInternals;
1141
+ }
1142
+ ).historyInternals;
1143
+ internals.onHistoryContinuityChanged?.({
1144
+ previousConversationId: 'conv-a',
1145
+ conversationId: 'conv-replacement',
1146
+ });
1147
+
1148
+ expect(harness.messages()).toHaveLength(0);
1149
+ expect(harness.session.getHistoryState().sendBlocked).toBe(true);
1150
+ expect(harness.session.getActiveConversationId()).toBe('conv-replacement');
1151
+ expect(harness.meta.conversationId).toBeUndefined();
1152
+ expect(harness.meta.conversationRevision).toBeUndefined();
1153
+ expect(harness.meta.pendingProjections).toBeUndefined();
1154
+ expect(
1155
+ harness.notices.some(
1156
+ (notice) => notice.code === 'history_continuity_reset'
1157
+ )
1158
+ ).toBe(true);
1159
+
1160
+ await harness.session.awaitHistorySettled();
1161
+ expect(harness.session.getHistoryState().sendBlocked).toBe(false);
1162
+ });
1163
+
1164
+ it('holds a dispatch until the wipe finishes, then sends only the new turn', async () => {
1165
+ await harness.session.openConversation('conv-a');
1166
+ const before = backend.chatRequests().length;
1167
+ const internals = (
1168
+ harness.session as unknown as {
1169
+ historyInternals: WidgetHistoryInternals;
1170
+ }
1171
+ ).historyInternals;
1172
+ internals.onHistoryContinuityChanged?.({
1173
+ previousConversationId: 'conv-a',
1174
+ conversationId: 'conv-replacement',
1175
+ });
1176
+
1177
+ const send = harness.session.sendMessage('after the wipe');
1178
+ expect(backend.chatRequests()).toHaveLength(before);
1179
+ await send;
1180
+
1181
+ const chats = backend.chatRequests();
1182
+ expect(chats).toHaveLength(before + 1);
1183
+ expect(chats[chats.length - 1].messages).toHaveLength(1);
1184
+ expect(chats[chats.length - 1].messages[0].content).toBe('after the wipe');
1185
+ expect(harness.session.getHistoryState().sendBlocked).toBe(false);
1186
+ });
1187
+ });
1188
+ });
1189
+
1190
+ // ── External visitor-credential change (D3 privacy boundary) ──────────────
1191
+
1192
+ const OTHER_CLIENT_TOKEN = 'ct_session_history_sibling';
1193
+
1194
+ /** Store wrapper that counts how often the session binds/releases it. */
1195
+ function countingStore(base: VisitorStore) {
1196
+ const counts = { subscribes: 0, unsubscribes: 0 };
1197
+ const store: VisitorStore = {
1198
+ ...base,
1199
+ subscribe(callback) {
1200
+ counts.subscribes += 1;
1201
+ const off = base.subscribe(callback);
1202
+ return () => {
1203
+ counts.unsubscribes += 1;
1204
+ off();
1205
+ };
1206
+ },
1207
+ };
1208
+ return { store, counts };
1209
+ }
1210
+
1211
+ describe('external visitor-credential change', () => {
1212
+ let harness: Harness;
1213
+ let backend: Backend;
1214
+ let store: VisitorStore;
1215
+
1216
+ /** What a sibling tab's write looks like here: jsdom fires no storage event. */
1217
+ const externalWrite = async (
1218
+ value: string | null,
1219
+ clientToken = CLIENT_TOKEN
1220
+ ) => {
1221
+ const { storageKey } = await visitorStoreKeys(clientToken, 'persona-');
1222
+ if (value === null) window.localStorage.removeItem(storageKey);
1223
+ else window.localStorage.setItem(storageKey, value);
1224
+ window.dispatchEvent(
1225
+ new StorageEvent('storage', { key: storageKey, newValue: value })
1226
+ );
1227
+ await Promise.resolve();
1228
+ };
1229
+
1230
+ const initBodies = () =>
1231
+ backend.requests
1232
+ .filter((request) => request.url.endsWith('/v1/client/init'))
1233
+ .map((request) => request.body ?? {});
1234
+
1235
+ beforeEach(async () => {
1236
+ window.localStorage.clear();
1237
+ harness = await createRuntypeHarness();
1238
+ backend = harness.backend as Backend;
1239
+ store = harness.store as VisitorStore;
1240
+ await store.ready;
1241
+ });
1242
+
1243
+ afterEach(() => {
1244
+ harness.destroy();
1245
+ vi.restoreAllMocks();
1246
+ });
1247
+
1248
+ it('wipes local state, drops the cached session, and blocks the next dispatch on a re-init', async () => {
1249
+ await harness.session.openConversation('conv-a');
1250
+ harness.meta.pendingProjections = {
1251
+ conversationId: 'conv-a',
1252
+ messageIds: ['a2'],
1253
+ };
1254
+ const staleSessionId = harness.session.getClientSession()?.sessionId;
1255
+ expect(staleSessionId).toBeTruthy();
1256
+ expect(harness.messages()).toHaveLength(2);
1257
+
1258
+ await externalWrite(null);
1259
+
1260
+ expect(harness.messages()).toHaveLength(0);
1261
+ expect(harness.session.getActiveConversationId()).toBeNull();
1262
+ expect(harness.meta.conversationId).toBeUndefined();
1263
+ expect(harness.meta.sessionId).toBeUndefined();
1264
+ expect(harness.meta.conversationRevision).toBeUndefined();
1265
+ expect(harness.meta.pendingProjections).toBeUndefined();
1266
+ // No dispatch can reuse the session minted under the revoked credential.
1267
+ expect(harness.session.getClient().getClientSession()).toBeNull();
1268
+ expect(
1269
+ harness.notices.some((notice) => notice.code === 'history_continuity_reset')
1270
+ ).toBe(true);
1271
+
1272
+ const initsBefore = initBodies().length;
1273
+ await harness.session.sendMessage('after the reset');
1274
+
1275
+ expect(initBodies().length).toBeGreaterThan(initsBefore);
1276
+ const chat = backend.chatRequests().at(-1);
1277
+ expect(chat?.sessionId).not.toBe(staleSessionId);
1278
+ expect(
1279
+ chat?.messages.some((message) => message.content === 'after the reset')
1280
+ ).toBe(true);
1281
+ // The wiped transcript never leaks into the replacement conversation.
1282
+ expect(JSON.stringify(chat?.messages)).not.toContain('Where is my order?');
1283
+ });
1284
+
1285
+ it('re-inits before the chat request rather than after it', async () => {
1286
+ await harness.session.openConversation('conv-a');
1287
+ await externalWrite(null);
1288
+
1289
+ await harness.session.sendMessage('after the reset');
1290
+
1291
+ const order = backend.requests.map((request) => request.url);
1292
+ const chatIndex = order.lastIndexOf(`${API_URL}/v1/client/chat`);
1293
+ const initIndex = order.lastIndexOf(`${API_URL}/v1/client/init`);
1294
+ expect(initIndex).toBeGreaterThan(-1);
1295
+ expect(initIndex).toBeLessThan(chatIndex);
1296
+ });
1297
+
1298
+ it('adopts the sibling token when the credential is replaced', async () => {
1299
+ await harness.session.listConversations();
1300
+
1301
+ await externalWrite('cvt_sibling');
1302
+ await harness.session.sendMessage('hello again');
1303
+
1304
+ expect(initBodies().at(-1)?.visitorToken).toBe('cvt_sibling');
1305
+ });
1306
+
1307
+ it('ignores this tab own mint or write', async () => {
1308
+ await harness.session.openConversation('conv-a');
1309
+ const live = harness.session.getClientSession();
1310
+ const renders = harness.renderCount();
1311
+
1312
+ await store.set('cvt_local_mint');
1313
+
1314
+ expect(harness.messages().map((message) => message.id)).toEqual(['a1', 'a2']);
1315
+ expect(harness.renderCount()).toBe(renders);
1316
+ expect(harness.session.getClientSession()).toBe(live);
1317
+ expect(harness.session.getActiveConversationId()).toBe('conv-a');
1318
+ expect(harness.session.getClient().getClientSession()).not.toBeNull();
1319
+ });
1320
+
1321
+ it('ignores adoption out of an empty store (first-init convergence)', async () => {
1322
+ // No credential yet: the sibling's mint is convergence, not a break.
1323
+ await store.clear();
1324
+ await harness.session.sendMessage('first turn');
1325
+ const live = harness.session.getClient().getClientSession();
1326
+ const before = harness.messages().length;
1327
+ expect(before).toBeGreaterThan(0);
1328
+
1329
+ await externalWrite('cvt_winner');
1330
+
1331
+ expect(harness.messages()).toHaveLength(before);
1332
+ expect(harness.session.getClient().getClientSession()).toBe(live);
1333
+ expect(harness.meta.sessionId).toBeTruthy();
1334
+ });
1335
+
1336
+ it('reconciles a tab with no conversation without a visible wipe', async () => {
1337
+ await harness.session.listConversations();
1338
+ expect(harness.session.getClient().getClientSession()).not.toBeNull();
1339
+
1340
+ await externalWrite(null);
1341
+
1342
+ expect(harness.messages()).toHaveLength(0);
1343
+ expect(
1344
+ harness.notices.some((notice) => notice.code === 'history_continuity_reset')
1345
+ ).toBe(false);
1346
+ // The cache and the revoked visitor's ids still go.
1347
+ expect(harness.session.getClient().getClientSession()).toBeNull();
1348
+ expect(harness.meta.sessionId).toBeUndefined();
1349
+ expect(harness.meta.conversationId).toBeUndefined();
1350
+ });
1351
+
1352
+ it('keeps exactly one subscription across a re-key and releases it on destroy', async () => {
1353
+ const first = countingStore(store);
1354
+ harness.session.setHistoryInternals({
1355
+ ...harness.rawInternals,
1356
+ visitorStore: first.store,
1357
+ });
1358
+ // Re-threading the same store must not bind a second subscription.
1359
+ harness.session.setHistoryInternals({
1360
+ ...harness.rawInternals,
1361
+ visitorStore: first.store,
1362
+ });
1363
+ expect(first.counts.subscribes).toBe(1);
1364
+ expect(first.counts.unsubscribes).toBe(0);
1365
+
1366
+ const replacement = countingStore(
1367
+ createVisitorStore(OTHER_CLIENT_TOKEN, 'persona-', false)
1368
+ );
1369
+ await replacement.store.set('cvt_other');
1370
+ harness.session.setHistoryInternals({
1371
+ ...harness.rawInternals,
1372
+ visitorStore: replacement.store,
1373
+ });
1374
+ expect(first.counts.unsubscribes).toBe(1);
1375
+ expect(replacement.counts.subscribes).toBe(1);
1376
+
1377
+ await harness.session.openConversation('conv-a');
1378
+
1379
+ // The detached store no longer reaches the session.
1380
+ await externalWrite(null);
1381
+ expect(harness.messages()).toHaveLength(2);
1382
+ expect(harness.notices).toHaveLength(0);
1383
+
1384
+ // The current one does, exactly once.
1385
+ await externalWrite(null, OTHER_CLIENT_TOKEN);
1386
+ expect(harness.messages()).toHaveLength(0);
1387
+ expect(
1388
+ harness.notices.filter(
1389
+ (notice) => notice.code === 'history_continuity_reset'
1390
+ )
1391
+ ).toHaveLength(1);
1392
+
1393
+ harness.session.destroy();
1394
+ expect(replacement.counts.unsubscribes).toBe(1);
1395
+ replacement.store.destroy();
1396
+ });
1397
+ });