@runtypelabs/persona 4.19.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 (77) 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 +99 -5
  10. package/dist/approval-ui.d.ts +99 -5
  11. package/dist/artifacts-ui.d.cts +99 -5
  12. package/dist/artifacts-ui.d.ts +99 -5
  13. package/dist/codegen.cjs +1 -1
  14. package/dist/codegen.js +1 -1
  15. package/dist/context-mentions.d.cts +99 -5
  16. package/dist/context-mentions.d.ts +99 -5
  17. package/dist/event-stream-view.d.cts +99 -5
  18. package/dist/event-stream-view.d.ts +99 -5
  19. package/dist/forms-ui.d.cts +123 -9
  20. package/dist/forms-ui.d.ts +123 -9
  21. package/dist/history-view.d.cts +2 -0
  22. package/dist/history-view.d.ts +2 -0
  23. package/dist/index.cjs +51 -51
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +128 -10
  26. package/dist/index.d.ts +128 -10
  27. package/dist/index.global.js +34 -34
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +51 -51
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js.map +1 -1
  32. package/dist/session-reconnect.d.cts +99 -5
  33. package/dist/session-reconnect.d.ts +99 -5
  34. package/dist/smart-dom-reader.d.cts +99 -5
  35. package/dist/smart-dom-reader.d.ts +99 -5
  36. package/dist/theme-editor-preview.cjs +53 -53
  37. package/dist/theme-editor-preview.d.cts +99 -5
  38. package/dist/theme-editor-preview.d.ts +99 -5
  39. package/dist/theme-editor-preview.js +31 -31
  40. package/dist/theme-editor.d.cts +99 -5
  41. package/dist/theme-editor.d.ts +99 -5
  42. package/dist/theme-reference.cjs +1 -1
  43. package/dist/theme-reference.d.cts +46 -5
  44. package/dist/theme-reference.d.ts +46 -5
  45. package/dist/theme-reference.js +1 -1
  46. package/dist/webmcp-runtime.d.cts +16 -0
  47. package/dist/webmcp-runtime.d.ts +16 -0
  48. package/dist/widget.css +1 -1
  49. package/package.json +1 -1
  50. package/src/client-visitor-history.test.ts +342 -3
  51. package/src/client.test.ts +141 -3
  52. package/src/client.ts +157 -39
  53. package/src/components/composer-actions.ts +27 -1
  54. package/src/components/composer-model-picker.ts +4 -14
  55. package/src/components/composer-overflow-menu.ts +14 -0
  56. package/src/components/composer-parts.ts +20 -0
  57. package/src/generated/runtype-openapi-contract.ts +2 -0
  58. package/src/index-core.ts +2 -0
  59. package/src/session.ts +139 -103
  60. package/src/session.webmcp.test.ts +48 -1
  61. package/src/styles/widget-styles.test.ts +83 -1
  62. package/src/styles/widget.css +50 -14
  63. package/src/theme-reference.ts +1 -1
  64. package/src/types/theme.ts +47 -5
  65. package/src/types.ts +63 -0
  66. package/src/ui.composer-action-visibility.test.ts +202 -0
  67. package/src/ui.composer-models.test.ts +37 -0
  68. package/src/ui.composer-modes.test.ts +82 -0
  69. package/src/ui.composer-overflow.test.ts +78 -0
  70. package/src/ui.history-lifecycle.test.ts +2 -2
  71. package/src/ui.ts +77 -6
  72. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1633 -686
  73. package/src/utils/composer-modes.test.ts +33 -0
  74. package/src/utils/composer-modes.ts +19 -2
  75. package/src/utils/tokens-parity.test.ts +20 -0
  76. package/src/utils/tokens.test.ts +76 -0
  77. package/src/utils/tokens.ts +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.19.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', () => {
@@ -911,6 +911,60 @@ describe('AgentWidgetClient - Agent Payload Building', () => {
911
911
  });
912
912
  });
913
913
 
914
+ it('proves the exact recovery visitor on durable client-token chat', async () => {
915
+ let capturedHeaders: Record<string, string> | undefined;
916
+ global.fetch = vi.fn().mockImplementation(async (_url: string, options: RequestInit) => {
917
+ capturedHeaders = options.headers as Record<string, string>;
918
+ const encoder = new TextEncoder();
919
+ return {
920
+ ok: true,
921
+ body: new ReadableStream({
922
+ start(controller) {
923
+ controller.enqueue(encoder.encode(sseEvent('agent_complete', {
924
+ executionId: 'exec_1',
925
+ agentId: 'agent_123',
926
+ success: true,
927
+ iterations: 1,
928
+ completedAt: new Date().toISOString(),
929
+ seq: 1,
930
+ })));
931
+ controller.close();
932
+ },
933
+ }),
934
+ };
935
+ });
936
+
937
+ const client = new AgentWidgetClient({
938
+ apiUrl: 'https://api.runtype.com',
939
+ clientToken: 'ct_live_demo',
940
+ agentId: 'agent_123',
941
+ });
942
+ (client as unknown as {
943
+ clientSession: {
944
+ sessionId: string;
945
+ expiresAt: Date;
946
+ durableRecovery: { enabled: boolean };
947
+ };
948
+ }).clientSession = {
949
+ sessionId: 'sess_durable',
950
+ expiresAt: new Date(Date.now() + 10 * 60_000),
951
+ durableRecovery: { enabled: true },
952
+ };
953
+ (client as unknown as { readVisitorToken: () => Promise<string> }).readVisitorToken =
954
+ async () => 'cvt_owner';
955
+
956
+ await client.dispatch({
957
+ messages: [{
958
+ id: 'usr_1',
959
+ role: 'user',
960
+ content: 'Hello durable agent',
961
+ createdAt: '2025-01-01T00:00:00.000Z',
962
+ }],
963
+ }, () => {});
964
+
965
+ expect(capturedHeaders?.['X-Visitor-Token']).toBe('cvt_owner');
966
+ });
967
+
914
968
  it('should build agent payload with agent config', async () => {
915
969
  let capturedPayload: any = null;
916
970
  global.fetch = vi.fn().mockImplementation(async (_url: string, options: any) => {
@@ -3011,7 +3065,7 @@ describe('AgentWidgetClient.resumeFlow', () => {
3011
3065
  expect(capturedUrl).toBe('http://localhost:43111/api/chat/dispatch/resume');
3012
3066
  });
3013
3067
 
3014
- it('routes to /v1/client/resume with sessionId in client-token mode (core#3889)', async () => {
3068
+ it('routes durable client-token resume with the live session, visitor proof, and cursor', async () => {
3015
3069
  let capturedUrl: string | undefined;
3016
3070
  let capturedBody: Record<string, unknown> | undefined;
3017
3071
  let capturedHeaders: Record<string, string> | undefined;
@@ -3027,12 +3081,21 @@ describe('AgentWidgetClient.resumeFlow', () => {
3027
3081
  apiUrl: 'https://api.runtype.com',
3028
3082
  });
3029
3083
  // Simulate an initialized client session (resumeFlow reads sessionId off it).
3030
- (client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
3084
+ (client as unknown as {
3085
+ clientSession: {
3086
+ sessionId: string;
3087
+ expiresAt: Date;
3088
+ durableRecovery: { enabled: boolean };
3089
+ };
3090
+ }).clientSession = {
3031
3091
  sessionId: 'cs_123',
3032
3092
  expiresAt: new Date(Date.now() + 60_000),
3093
+ durableRecovery: { enabled: true },
3033
3094
  };
3095
+ (client as unknown as { readVisitorToken: () => Promise<string> }).readVisitorToken =
3096
+ async () => 'cvt_resume';
3034
3097
 
3035
- await client.resumeFlow('exec_xyz', { toolu_A: { ok: true } });
3098
+ await client.resumeFlow('exec_xyz', { toolu_A: { ok: true } }, { after: '18' });
3036
3099
 
3037
3100
  // Session-authed sibling of /v1/client/chat: no Bearer key, sessionId in body.
3038
3101
  expect(capturedUrl).toBe('https://api.runtype.com/v1/client/resume');
@@ -3041,8 +3104,10 @@ describe('AgentWidgetClient.resumeFlow', () => {
3041
3104
  toolOutputs: { toolu_A: { ok: true } },
3042
3105
  streamResponse: true,
3043
3106
  sessionId: 'cs_123',
3107
+ after: '18',
3044
3108
  });
3045
3109
  expect(capturedHeaders!['Authorization']).toBeUndefined();
3110
+ expect(capturedHeaders!['X-Visitor-Token']).toBe('cvt_resume');
3046
3111
  });
3047
3112
 
3048
3113
  it('strips a trailing /v1/dispatch from apiUrl when building the client resume URL', async () => {
@@ -4611,3 +4676,76 @@ describe('AgentWidgetClient - mention context', () => {
4611
4676
  expect(get().context).toBeUndefined();
4612
4677
  });
4613
4678
  });
4679
+
4680
+ describe('AgentWidgetClient - agent text runs through the configured stream parser', () => {
4681
+ // Regression: agent-mode text_delta used to append the raw delta straight onto
4682
+ // `content`, bypassing `streamParser` entirely. Demos that pin a server-side
4683
+ // agent AND reply with a JSON envelope (the page-context / smart-dom-reader
4684
+ // demo) therefore rendered the raw `{"text": ...}` in the bubble, and never
4685
+ // populated `rawContent`, so the action manager saw nothing to dispatch.
4686
+ const envelopeStream = (execId: string, chunks: string[]) => [
4687
+ sseEvent('execution_start', { kind: 'agent', executionId: execId, agentId: 'virtual', agentName: 'Test', maxTurns: 1, startedAt: new Date().toISOString(), seq: 1 }),
4688
+ sseEvent('turn_start', { executionId: execId, id: 'turn_1', iteration: 1, role: 'assistant', seq: 2 }),
4689
+ sseEvent('text_start', { executionId: execId, id: 'text_1', role: 'assistant', seq: 3 }),
4690
+ ...chunks.map((delta, i) =>
4691
+ sseEvent('text_delta', { executionId: execId, id: 'text_1', delta, seq: 4 + i })
4692
+ ),
4693
+ sseEvent('text_complete', { executionId: execId, id: 'text_1', seq: 90 }),
4694
+ sseEvent('turn_complete', { executionId: execId, id: 'turn_1', iteration: 1, role: 'assistant', completedAt: new Date().toISOString(), seq: 91 }),
4695
+ sseEvent('execution_complete', { kind: 'agent', executionId: execId, success: true, completedAt: new Date().toISOString(), seq: 92 }),
4696
+ ];
4697
+
4698
+ const lastAssistant = (events: AgentWidgetEvent[]): AgentWidgetMessage | null => {
4699
+ for (let i = events.length - 1; i >= 0; i--) {
4700
+ const e = events[i];
4701
+ if (e.type === 'message' && e.message.role === 'assistant') return e.message;
4702
+ }
4703
+ return null;
4704
+ };
4705
+
4706
+ const runAgent = async (
4707
+ chunks: string[],
4708
+ config: Record<string, unknown> = {}
4709
+ ): Promise<AgentWidgetMessage | null> => {
4710
+ global.fetch = createRawStreamFetch(envelopeStream('exec_env', chunks));
4711
+ const events: AgentWidgetEvent[] = [];
4712
+ const client = new AgentWidgetClient({
4713
+ apiUrl: 'http://localhost:8000',
4714
+ agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
4715
+ ...config,
4716
+ } as any);
4717
+ await client.dispatch(
4718
+ { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4719
+ (e) => events.push(e)
4720
+ );
4721
+ return lastAssistant(events);
4722
+ };
4723
+
4724
+ it('extracts `text` from a JSON envelope streamed across agent text_delta chunks', async () => {
4725
+ const msg = await runAgent(
4726
+ ['{"text": "In the ', '**Featured drop**', ', we have 2 items."}'],
4727
+ { streamParser: () => createJsonStreamParser() }
4728
+ );
4729
+
4730
+ expect(msg?.content).toBe('In the **Featured drop**, we have 2 items.');
4731
+ // The bubble must not show the envelope itself.
4732
+ expect(msg?.content).not.toContain('{"text"');
4733
+ });
4734
+
4735
+ it('keeps the raw envelope on rawContent so the action manager can dispatch it', async () => {
4736
+ const envelope = '{"action": "add_to_cart", "product": "headphones", "text": "Added it!"}';
4737
+ const msg = await runAgent([envelope], {
4738
+ streamParser: () => createJsonStreamParser(),
4739
+ });
4740
+
4741
+ expect(msg?.content).toBe('Added it!');
4742
+ expect(msg?.rawContent).toBe(envelope);
4743
+ });
4744
+
4745
+ it('still plain-appends agent prose when no custom parser is configured', async () => {
4746
+ const msg = await runAgent(['Hello', ' World']);
4747
+
4748
+ expect(msg?.content).toBe('Hello World');
4749
+ expect(msg?.rawContent).toBeUndefined();
4750
+ });
4751
+ });