@runtypelabs/persona 4.20.0 → 4.21.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 (52) 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-CG-bLFmk.d.cts → types-B_JdVK9o.d.cts} +2 -0
  4. package/dist/animations/{types-CG-bLFmk.d.ts → types-B_JdVK9o.d.ts} +2 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui.d.cts +22 -0
  10. package/dist/approval-ui.d.ts +22 -0
  11. package/dist/artifacts-ui.d.cts +22 -0
  12. package/dist/artifacts-ui.d.ts +22 -0
  13. package/dist/codegen.cjs +1 -1
  14. package/dist/codegen.js +1 -1
  15. package/dist/context-mentions.d.cts +22 -0
  16. package/dist/context-mentions.d.ts +22 -0
  17. package/dist/event-stream-view.d.cts +22 -0
  18. package/dist/event-stream-view.d.ts +22 -0
  19. package/dist/forms-ui.d.cts +46 -4
  20. package/dist/forms-ui.d.ts +46 -4
  21. package/dist/history-view.d.cts +2 -0
  22. package/dist/history-view.d.ts +2 -0
  23. package/dist/index.cjs +43 -43
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +50 -4
  26. package/dist/index.d.ts +50 -4
  27. package/dist/index.global.js +30 -30
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +43 -43
  30. package/dist/index.js.map +1 -1
  31. package/dist/session-reconnect.d.cts +22 -0
  32. package/dist/session-reconnect.d.ts +22 -0
  33. package/dist/smart-dom-reader.d.cts +22 -0
  34. package/dist/smart-dom-reader.d.ts +22 -0
  35. package/dist/theme-editor-preview.cjs +53 -53
  36. package/dist/theme-editor-preview.d.cts +22 -0
  37. package/dist/theme-editor-preview.d.ts +22 -0
  38. package/dist/theme-editor-preview.js +31 -31
  39. package/dist/theme-editor.d.cts +22 -0
  40. package/dist/theme-editor.d.ts +22 -0
  41. package/dist/webmcp-runtime.d.cts +16 -0
  42. package/dist/webmcp-runtime.d.ts +16 -0
  43. package/package.json +1 -1
  44. package/src/client-visitor-history.test.ts +342 -3
  45. package/src/client.test.ts +141 -3
  46. package/src/client.ts +157 -39
  47. package/src/generated/runtype-openapi-contract.ts +2 -0
  48. package/src/session.ts +139 -103
  49. package/src/session.webmcp.test.ts +48 -1
  50. package/src/types.ts +30 -0
  51. package/src/ui.history-lifecycle.test.ts +2 -2
  52. package/src/ui.ts +77 -6
@@ -1644,8 +1644,10 @@ type RuntypeExecutionStreamEvent = ({
1644
1644
  message: string;
1645
1645
  };
1646
1646
  executionId: string;
1647
+ finalOutput?: string;
1647
1648
  kind: "agent" | "flow";
1648
1649
  seq: number;
1650
+ stopReason?: string;
1649
1651
  type: "execution_error";
1650
1652
  upgradeUrl?: string;
1651
1653
  }) | ({
@@ -2979,6 +2981,22 @@ type AgentWidgetWebMcpConfig = {
2979
2981
  * `window.confirm` fallback only applies when no widget UI is attached.
2980
2982
  */
2981
2983
  onConfirm?: WebMcpConfirmHandler;
2984
+ /**
2985
+ * How the widget runs the `webmcp:*` calls of ONE turn when the model emits
2986
+ * several at once.
2987
+ *
2988
+ * - `'parallel'` (default): every call executes concurrently and a gated
2989
+ * sibling's approval never blocks another's execution. Right for
2990
+ * independent, order-insensitive tools (catalog lookups, add-to-cart).
2991
+ * - `'sequential'`: calls execute one at a time, in the order the model
2992
+ * emitted them; the next starts only after the previous one settles
2993
+ * (including its approval bubble). Use this when the page tools share
2994
+ * mutable state that concurrent execution would interleave, e.g. a
2995
+ * canvas where every stroke selects a color/tool before drawing.
2996
+ *
2997
+ * Either way, all outputs still return to the server in a single `/resume`.
2998
+ */
2999
+ execution?: 'parallel' | 'sequential';
2982
3000
  };
2983
3001
  /**
2984
3002
  * The coordinates needed to resume a durable agent turn after the SSE
@@ -7273,6 +7291,10 @@ type ClientSession = {
7273
7291
  targetId?: string;
7274
7292
  /** Opaque change token; differs whenever the conversation transcript mutated. */
7275
7293
  conversationRevision?: string;
7294
+ /** Server-negotiated durable reconnect support for this client-token session. */
7295
+ durableRecovery?: {
7296
+ enabled: boolean;
7297
+ };
7276
7298
  /** Visitor grant backing history. `token` appears only at mint. */
7277
7299
  visitor?: ClientVisitorGrant;
7278
7300
  /** Configuration from the server */
@@ -1644,8 +1644,10 @@ type RuntypeExecutionStreamEvent = ({
1644
1644
  message: string;
1645
1645
  };
1646
1646
  executionId: string;
1647
+ finalOutput?: string;
1647
1648
  kind: "agent" | "flow";
1648
1649
  seq: number;
1650
+ stopReason?: string;
1649
1651
  type: "execution_error";
1650
1652
  upgradeUrl?: string;
1651
1653
  }) | ({
@@ -2979,6 +2981,22 @@ type AgentWidgetWebMcpConfig = {
2979
2981
  * `window.confirm` fallback only applies when no widget UI is attached.
2980
2982
  */
2981
2983
  onConfirm?: WebMcpConfirmHandler;
2984
+ /**
2985
+ * How the widget runs the `webmcp:*` calls of ONE turn when the model emits
2986
+ * several at once.
2987
+ *
2988
+ * - `'parallel'` (default): every call executes concurrently and a gated
2989
+ * sibling's approval never blocks another's execution. Right for
2990
+ * independent, order-insensitive tools (catalog lookups, add-to-cart).
2991
+ * - `'sequential'`: calls execute one at a time, in the order the model
2992
+ * emitted them; the next starts only after the previous one settles
2993
+ * (including its approval bubble). Use this when the page tools share
2994
+ * mutable state that concurrent execution would interleave, e.g. a
2995
+ * canvas where every stroke selects a color/tool before drawing.
2996
+ *
2997
+ * Either way, all outputs still return to the server in a single `/resume`.
2998
+ */
2999
+ execution?: 'parallel' | 'sequential';
2982
3000
  };
2983
3001
  /**
2984
3002
  * The coordinates needed to resume a durable agent turn after the SSE
@@ -7273,6 +7291,10 @@ type ClientSession = {
7273
7291
  targetId?: string;
7274
7292
  /** Opaque change token; differs whenever the conversation transcript mutated. */
7275
7293
  conversationRevision?: string;
7294
+ /** Server-negotiated durable reconnect support for this client-token session. */
7295
+ durableRecovery?: {
7296
+ enabled: boolean;
7297
+ };
7276
7298
  /** Visitor grant backing history. `token` appears only at mint. */
7277
7299
  visitor?: ClientVisitorGrant;
7278
7300
  /** Configuration from the server */
@@ -111,6 +111,22 @@ type AgentWidgetWebMcpConfig = {
111
111
  * `window.confirm` fallback only applies when no widget UI is attached.
112
112
  */
113
113
  onConfirm?: WebMcpConfirmHandler;
114
+ /**
115
+ * How the widget runs the `webmcp:*` calls of ONE turn when the model emits
116
+ * several at once.
117
+ *
118
+ * - `'parallel'` (default): every call executes concurrently and a gated
119
+ * sibling's approval never blocks another's execution. Right for
120
+ * independent, order-insensitive tools (catalog lookups, add-to-cart).
121
+ * - `'sequential'`: calls execute one at a time, in the order the model
122
+ * emitted them; the next starts only after the previous one settles
123
+ * (including its approval bubble). Use this when the page tools share
124
+ * mutable state that concurrent execution would interleave, e.g. a
125
+ * canvas where every stroke selects a color/tool before drawing.
126
+ *
127
+ * Either way, all outputs still return to the server in a single `/resume`.
128
+ */
129
+ execution?: 'parallel' | 'sequential';
114
130
  };
115
131
 
116
132
  /**
@@ -111,6 +111,22 @@ type AgentWidgetWebMcpConfig = {
111
111
  * `window.confirm` fallback only applies when no widget UI is attached.
112
112
  */
113
113
  onConfirm?: WebMcpConfirmHandler;
114
+ /**
115
+ * How the widget runs the `webmcp:*` calls of ONE turn when the model emits
116
+ * several at once.
117
+ *
118
+ * - `'parallel'` (default): every call executes concurrently and a gated
119
+ * sibling's approval never blocks another's execution. Right for
120
+ * independent, order-insensitive tools (catalog lookups, add-to-cart).
121
+ * - `'sequential'`: calls execute one at a time, in the order the model
122
+ * emitted them; the next starts only after the previous one settles
123
+ * (including its approval bubble). Use this when the page tools share
124
+ * mutable state that concurrent execution would interleave, e.g. a
125
+ * canvas where every stroke selects a color/tool before drawing.
126
+ *
127
+ * Either way, all outputs still return to the server in a single `/resume`.
128
+ */
129
+ execution?: 'parallel' | 'sequential';
114
130
  };
115
131
 
116
132
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.20.0",
3
+ "version": "4.21.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -35,6 +35,7 @@ const initBody = (overrides: Partial<ClientInitResponse> = {}): ClientInitRespon
35
35
  conversationId: 'conv_1',
36
36
  targetId: 'flow_1',
37
37
  conversationRevision: 'rev_1',
38
+ durableRecovery: { enabled: true },
38
39
  config: { welcomeMessage: null, placeholder: 'Ask...', theme: null },
39
40
  ...overrides,
40
41
  });
@@ -85,6 +86,8 @@ type Harness = {
85
86
  sessionInits: ClientSession[];
86
87
  continuity: Array<{ previousConversationId: string | null; conversationId: string }>;
87
88
  availability: boolean[];
89
+ durableResumePending: boolean;
90
+ resumableWrites: Array<{ executionId: string; after: string } | null>;
88
91
  writes: string[];
89
92
  };
90
93
 
@@ -92,6 +95,7 @@ const makeClient = (options: {
92
95
  history?: boolean;
93
96
  storedSessionId?: string | null;
94
97
  storedConversationId?: string | null;
98
+ durableResumePending?: boolean;
95
99
  historyBootstrapReady?: Promise<void>;
96
100
  config?: Partial<AgentWidgetConfig>;
97
101
  }): Harness => {
@@ -104,6 +108,8 @@ const makeClient = (options: {
104
108
  sessionInits: [],
105
109
  continuity: [],
106
110
  availability: [],
111
+ durableResumePending: options.durableResumePending ?? false,
112
+ resumableWrites: [],
107
113
  writes: [],
108
114
  };
109
115
  const internals: WidgetHistoryInternals = {
@@ -120,6 +126,11 @@ const makeClient = (options: {
120
126
  harness.continuity!.push(info);
121
127
  harness.writes!.push(`continuity:${info.previousConversationId}->${info.conversationId}`);
122
128
  },
129
+ shouldResumeDurableConversation: () => harness.durableResumePending === true,
130
+ setStoredResumableHandle: (handle) => {
131
+ harness.durableResumePending = handle !== null;
132
+ harness.resumableWrites!.push(handle);
133
+ },
123
134
  };
124
135
  const config: AgentWidgetConfig = {
125
136
  apiUrl: API_URL,
@@ -168,6 +179,7 @@ describe('client visitor history - init capability shape', () => {
168
179
  body: {
169
180
  token: CLIENT_TOKEN,
170
181
  visitorHistory: true,
182
+ durableRecovery: true,
171
183
  visitorToken: 'cvt_stored',
172
184
  sessionId: 'sess_old',
173
185
  },
@@ -175,7 +187,7 @@ describe('client visitor history - init capability shape', () => {
175
187
  expect(requests[0].body).not.toHaveProperty('conversationId');
176
188
  });
177
189
 
178
- it('omits every visitor field when history is disabled', async () => {
190
+ it('negotiates recovery without enabling the history UI', async () => {
179
191
  await seedToken('cvt_stored');
180
192
  installFetch([ok()]);
181
193
  const h = makeClient({ history: false, storedSessionId: 'sess_old' });
@@ -184,8 +196,11 @@ describe('client visitor history - init capability shape', () => {
184
196
 
185
197
  expect(requests[0].body).toEqual({
186
198
  token: CLIENT_TOKEN,
199
+ durableRecovery: true,
200
+ visitorToken: 'cvt_stored',
187
201
  sessionId: 'sess_old',
188
202
  });
203
+ expect(requests[0].body).not.toHaveProperty('conversationId');
189
204
  });
190
205
 
191
206
  it('normalizes targetId, preferring the top-level field over flow.id', async () => {
@@ -203,6 +218,41 @@ describe('client visitor history - init capability shape', () => {
203
218
  expect(a.flow.id).toBe('flow_1');
204
219
  expect(b.targetId).toBe('flow_7');
205
220
  });
221
+
222
+ it('uses the exact visitor credential and durable cursor for reconnect', async () => {
223
+ await seedToken('cvt_stored');
224
+ const events = new Response('event: ping\ndata: {}\n\n', {
225
+ headers: { 'Content-Type': 'text/event-stream' },
226
+ });
227
+ const fetchMock = vi
228
+ .fn()
229
+ .mockResolvedValueOnce(ok()())
230
+ .mockResolvedValueOnce(events);
231
+ global.fetch = fetchMock as unknown as typeof fetch;
232
+ const h = makeClient({});
233
+ await h.client.initSession();
234
+
235
+ const controller = new AbortController();
236
+ const response = await h.client.reconnectClientTokenStream({
237
+ executionId: 'exec_7',
238
+ after: '12',
239
+ signal: controller.signal,
240
+ });
241
+
242
+ expect(response).toBe(events);
243
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
244
+ `${API_URL}/v1/client/conversations/conv_1/executions/exec_7/events` +
245
+ '?sessionId=sess_1&after=12'
246
+ );
247
+ expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
248
+ method: 'GET',
249
+ headers: {
250
+ 'X-Persona-Version': expect.any(String),
251
+ 'X-Visitor-Token': 'cvt_stored',
252
+ },
253
+ signal: controller.signal,
254
+ });
255
+ });
206
256
  });
207
257
 
208
258
  describe('client visitor history - mint persistence and immediate claim', () => {
@@ -313,6 +363,7 @@ describe('client visitor history - boot resume', () => {
313
363
  expect(requests[0].body).toEqual({
314
364
  token: CLIENT_TOKEN,
315
365
  visitorHistory: true,
366
+ durableRecovery: true,
316
367
  visitorToken: 'cvt_stored',
317
368
  conversationId: 'conv_1',
318
369
  });
@@ -331,6 +382,103 @@ describe('client visitor history - boot resume', () => {
331
382
  expect(requests[0].body).not.toHaveProperty('conversationId');
332
383
  });
333
384
 
385
+ it('reopens a durable conversation without enabling the history UI', async () => {
386
+ await seedToken('cvt_stored');
387
+ installFetch([ok({ sessionId: 'sess_resumed' })]);
388
+ const h = makeClient({
389
+ history: false,
390
+ storedSessionId: 'sess_old',
391
+ storedConversationId: 'conv_1',
392
+ durableResumePending: true,
393
+ });
394
+
395
+ await h.client.initSession();
396
+
397
+ expect(requests[0].body).toEqual({
398
+ token: CLIENT_TOKEN,
399
+ durableRecovery: true,
400
+ visitorToken: 'cvt_stored',
401
+ conversationId: 'conv_1',
402
+ });
403
+ expect(h.resumableWrites).toHaveLength(0);
404
+ });
405
+
406
+ it('preserves the stored session without history or a durable handle', async () => {
407
+ await seedToken('cvt_stored');
408
+ installFetch([ok({ sessionId: 'sess_ordinary', conversationId: 'conv_2' })]);
409
+ const h = makeClient({
410
+ history: false,
411
+ storedSessionId: 'sess_old',
412
+ storedConversationId: 'conv_1',
413
+ });
414
+
415
+ await h.client.initSession();
416
+
417
+ expect(requests[0].body).toEqual({
418
+ token: CLIENT_TOKEN,
419
+ durableRecovery: true,
420
+ visitorToken: 'cvt_stored',
421
+ sessionId: 'sess_old',
422
+ });
423
+ expect(requests[0].body).not.toHaveProperty('conversationId');
424
+ });
425
+
426
+ it('drops a pending handle when the server does not enable recovery', async () => {
427
+ await seedToken('cvt_stored');
428
+ installFetch([
429
+ ok({ sessionId: 'sess_resumed', durableRecovery: { enabled: false } }),
430
+ ]);
431
+ const h = makeClient({
432
+ history: false,
433
+ storedConversationId: 'conv_1',
434
+ durableResumePending: true,
435
+ });
436
+
437
+ const session = await h.client.initSession();
438
+
439
+ expect(requests).toHaveLength(1);
440
+ expect(requests[0].body).toMatchObject({ conversationId: 'conv_1' });
441
+ expect(session.durableRecovery).toEqual({ enabled: false });
442
+ expect(h.resumableWrites).toEqual([null]);
443
+ });
444
+
445
+ it('drops a pending handle when an old server omits the recovery capability', async () => {
446
+ await seedToken('cvt_stored');
447
+ installFetch([ok({ sessionId: 'sess_legacy', durableRecovery: undefined })]);
448
+ const h = makeClient({
449
+ history: false,
450
+ storedConversationId: 'conv_1',
451
+ durableResumePending: true,
452
+ });
453
+
454
+ const session = await h.client.initSession();
455
+
456
+ expect(session.durableRecovery).toBeUndefined();
457
+ expect(h.resumableWrites).toEqual([null]);
458
+ });
459
+
460
+ it('clears a stale pending handle before its single ordinary fallback', async () => {
461
+ await seedToken('cvt_stored');
462
+ installFetch([
463
+ fail(404, { error: 'Conversation not found' }),
464
+ ok({ sessionId: 'sess_new', conversationId: 'conv_2' }),
465
+ ]);
466
+ const h = makeClient({
467
+ history: false,
468
+ storedSessionId: 'sess_old',
469
+ storedConversationId: 'conv_1',
470
+ durableResumePending: true,
471
+ });
472
+
473
+ await h.client.initSession();
474
+
475
+ expect(requests).toHaveLength(2);
476
+ expect(requests[0].body).toMatchObject({ conversationId: 'conv_1' });
477
+ expect(requests[1].body).toMatchObject({ sessionId: 'sess_old' });
478
+ expect(requests[1].body).not.toHaveProperty('conversationId');
479
+ expect(h.resumableWrites).toEqual([null]);
480
+ });
481
+
334
482
  it('falls back exactly once on a resume 404 and reports the continuity break', async () => {
335
483
  await seedToken('cvt_stored');
336
484
  installFetch([
@@ -417,6 +565,7 @@ describe('client visitor history - prepared sessions', () => {
417
565
  expect(requests[1].body).toEqual({
418
566
  token: CLIENT_TOKEN,
419
567
  visitorHistory: true,
568
+ durableRecovery: true,
420
569
  visitorToken: 'cvt_stored',
421
570
  conversationId: 'conv_other',
422
571
  });
@@ -478,6 +627,7 @@ describe('client visitor history - prepared sessions', () => {
478
627
  expect(requests[0].body).toEqual({
479
628
  token: CLIENT_TOKEN,
480
629
  visitorHistory: true,
630
+ durableRecovery: true,
481
631
  visitorToken: 'cvt_stored',
482
632
  });
483
633
  expect(prepared.session.sessionId).toBe('sess_new');
@@ -504,10 +654,16 @@ describe('client visitor history - 403 degrade', () => {
504
654
  expect(h.availability).toEqual([false]);
505
655
  expect(warn).toHaveBeenCalledTimes(1);
506
656
 
507
- // Latched for the client's lifetime: later inits skip the visitor fields.
657
+ // History remains latched off, while recovery negotiates independently
658
+ // without breaking ordinary session continuity.
508
659
  h.client.clearClientSession();
509
660
  await h.client.initSession();
510
- expect(requests[2].body).toEqual({ token: CLIENT_TOKEN, sessionId: 'sess_plain' });
661
+ expect(requests[2].body).toEqual({
662
+ token: CLIENT_TOKEN,
663
+ durableRecovery: true,
664
+ visitorToken: 'cvt_stored',
665
+ sessionId: 'sess_plain',
666
+ });
511
667
  });
512
668
 
513
669
  it('rejects rather than degrading a conversation resume into another record', async () => {
@@ -566,6 +722,7 @@ describe('controller wiring', () => {
566
722
  expect(requests[0].body).toEqual({
567
723
  token: CLIENT_TOKEN,
568
724
  visitorHistory: true,
725
+ durableRecovery: true,
569
726
  visitorToken: 'cvt_stored',
570
727
  conversationId: 'conv_stored',
571
728
  });
@@ -573,6 +730,188 @@ describe('controller wiring', () => {
573
730
  controller.destroy();
574
731
  mount.remove();
575
732
  });
733
+
734
+ it('persists the built-in durable handle without history UI config and clears it at terminal', async () => {
735
+ await seedToken('cvt_stored');
736
+ installFetch([ok({ sessionId: 'sess_ui' })]);
737
+ const mount = document.createElement('div');
738
+ document.body.appendChild(mount);
739
+ const controller = createAgentExperience(mount, {
740
+ apiUrl: API_URL,
741
+ clientToken: CLIENT_TOKEN,
742
+ launcher: { enabled: false },
743
+ });
744
+ await vi.waitFor(() => expect(requests).toHaveLength(1));
745
+
746
+ let streamController!: ReadableStreamDefaultController<Uint8Array>;
747
+ const stream = new ReadableStream<Uint8Array>({
748
+ start(value) {
749
+ streamController = value;
750
+ },
751
+ });
752
+ const connected = controller.connectStream(stream);
753
+ const encoder = new TextEncoder();
754
+ streamController.enqueue(
755
+ encoder.encode(
756
+ 'id: 7\nevent: text_delta\ndata: {"type":"text_delta","executionId":"exec_ui","id":"text_ui","delta":"Hi"}\n\n'
757
+ )
758
+ );
759
+ await vi.waitFor(() =>
760
+ expect(controller.getPersistentMetadata().durableResume).toEqual({
761
+ executionId: 'exec_ui',
762
+ after: '7',
763
+ })
764
+ );
765
+
766
+ streamController.enqueue(
767
+ encoder.encode(
768
+ 'id: 8\nevent: execution_complete\ndata: {"type":"execution_complete","executionId":"exec_ui","kind":"agent","success":true}\n\n'
769
+ )
770
+ );
771
+ streamController.close();
772
+ await connected;
773
+ expect(controller.getPersistentMetadata()).not.toHaveProperty('durableResume');
774
+
775
+ controller.destroy();
776
+ mount.remove();
777
+ });
778
+
779
+ it('retains the built-in durable handle during page exit', async () => {
780
+ await seedToken('cvt_stored');
781
+ installFetch([ok({ sessionId: 'sess_ui' })]);
782
+ const mount = document.createElement('div');
783
+ document.body.appendChild(mount);
784
+ const controller = createAgentExperience(mount, {
785
+ apiUrl: API_URL,
786
+ clientToken: CLIENT_TOKEN,
787
+ launcher: { enabled: false },
788
+ });
789
+ await vi.waitFor(() => expect(requests).toHaveLength(1));
790
+
791
+ let firstStreamController!: ReadableStreamDefaultController<Uint8Array>;
792
+ const firstStream = new ReadableStream<Uint8Array>({
793
+ start(value) {
794
+ firstStreamController = value;
795
+ },
796
+ });
797
+ const firstConnected = controller.connectStream(firstStream);
798
+ const encoder = new TextEncoder();
799
+ firstStreamController.enqueue(
800
+ encoder.encode(
801
+ 'id: 7\nevent: text_delta\ndata: {"type":"text_delta","executionId":"exec_exit","id":"text_exit","delta":"Hi"}\n\n'
802
+ )
803
+ );
804
+ await vi.waitFor(() =>
805
+ expect(controller.getPersistentMetadata().durableResume).toEqual({
806
+ executionId: 'exec_exit',
807
+ after: '7',
808
+ })
809
+ );
810
+
811
+ window.dispatchEvent(new Event('pagehide'));
812
+ firstStreamController.enqueue(
813
+ encoder.encode(
814
+ 'id: 8\nevent: execution_complete\ndata: {"type":"execution_complete","executionId":"exec_exit","kind":"agent","success":true}\n\n'
815
+ )
816
+ );
817
+ firstStreamController.close();
818
+ await firstConnected;
819
+ expect(controller.getPersistentMetadata().durableResume).toEqual({
820
+ executionId: 'exec_exit',
821
+ after: '7',
822
+ });
823
+
824
+ controller.destroy();
825
+ mount.remove();
826
+ });
827
+
828
+ it('uses Persona-owned stored recovery state to reopen the durable conversation', async () => {
829
+ await seedToken('cvt_stored');
830
+ global.fetch = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
831
+ if (String(url) === INIT_URL) {
832
+ requests.push({
833
+ url: String(url),
834
+ body: JSON.parse(String(options?.body)) as Record<string, unknown>,
835
+ headers: (options?.headers ?? {}) as Record<string, string>,
836
+ });
837
+ return new Response(
838
+ JSON.stringify(
839
+ initBody({
840
+ sessionId: 'sess_resumed',
841
+ conversationId: 'conv_durable',
842
+ })
843
+ ),
844
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
845
+ );
846
+ }
847
+ return new Response(
848
+ 'id: 20\nevent: execution_complete\ndata: {"type":"execution_complete","executionId":"exec_durable","kind":"agent","success":true}\n\n',
849
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
850
+ );
851
+ }) as unknown as typeof fetch;
852
+ const mount = document.createElement('div');
853
+ document.body.appendChild(mount);
854
+ const controller = createAgentExperience(mount, {
855
+ apiUrl: API_URL,
856
+ clientToken: CLIENT_TOKEN,
857
+ launcher: { enabled: false },
858
+ storageAdapter: {
859
+ load: async () => ({
860
+ messages: [],
861
+ metadata: {
862
+ conversationId: 'conv_durable',
863
+ durableResume: { executionId: 'exec_durable', after: '19' },
864
+ },
865
+ }),
866
+ save: () => undefined,
867
+ },
868
+ });
869
+
870
+ await vi.waitFor(() => expect(requests).toHaveLength(1));
871
+ expect(requests[0].body).toMatchObject({
872
+ durableRecovery: true,
873
+ visitorToken: 'cvt_stored',
874
+ conversationId: 'conv_durable',
875
+ });
876
+ expect(requests[0].body).not.toHaveProperty('visitorHistory');
877
+
878
+ controller.destroy();
879
+ mount.remove();
880
+ });
881
+
882
+ it('does not pass Persona-stored recovery state to a custom reconnect transport', async () => {
883
+ await seedToken('cvt_stored');
884
+ installFetch([ok({ sessionId: 'sess_ui' })]);
885
+ const reconnectStream = vi.fn(
886
+ () => new Promise<Response>(() => undefined)
887
+ );
888
+ const mount = document.createElement('div');
889
+ document.body.appendChild(mount);
890
+ const controller = createAgentExperience(mount, {
891
+ apiUrl: API_URL,
892
+ clientToken: CLIENT_TOKEN,
893
+ launcher: { enabled: false },
894
+ reconnectStream,
895
+ storageAdapter: {
896
+ load: async () => ({
897
+ messages: [],
898
+ metadata: {
899
+ conversationId: 'conv_host',
900
+ durableResume: { executionId: 'exec_host', after: '19' },
901
+ },
902
+ }),
903
+ save: () => undefined,
904
+ },
905
+ });
906
+
907
+ await vi.waitFor(() => expect(requests).toHaveLength(1));
908
+ expect(requests[0].body).not.toHaveProperty('conversationId');
909
+ expect(controller.getStatus()).toBe('idle');
910
+ expect(reconnectStream).not.toHaveBeenCalled();
911
+
912
+ controller.destroy();
913
+ mount.remove();
914
+ });
576
915
  });
577
916
 
578
917
  describe('client visitor history - dedupe', () => {