@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.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CG-bLFmk.d.cts → types-B_JdVK9o.d.cts} +2 -0
- package/dist/animations/{types-CG-bLFmk.d.ts → types-B_JdVK9o.d.ts} +2 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +2 -0
- package/dist/animations-extra.d.ts +2 -0
- package/dist/approval-ui.d.cts +99 -5
- package/dist/approval-ui.d.ts +99 -5
- package/dist/artifacts-ui.d.cts +99 -5
- package/dist/artifacts-ui.d.ts +99 -5
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +99 -5
- package/dist/context-mentions.d.ts +99 -5
- package/dist/event-stream-view.d.cts +99 -5
- package/dist/event-stream-view.d.ts +99 -5
- package/dist/forms-ui.d.cts +123 -9
- package/dist/forms-ui.d.ts +123 -9
- package/dist/history-view.d.cts +2 -0
- package/dist/history-view.d.ts +2 -0
- package/dist/index.cjs +51 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -10
- package/dist/index.d.ts +128 -10
- package/dist/index.global.js +34 -34
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +51 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/session-reconnect.d.cts +99 -5
- package/dist/session-reconnect.d.ts +99 -5
- package/dist/smart-dom-reader.d.cts +99 -5
- package/dist/smart-dom-reader.d.ts +99 -5
- package/dist/theme-editor-preview.cjs +53 -53
- package/dist/theme-editor-preview.d.cts +99 -5
- package/dist/theme-editor-preview.d.ts +99 -5
- package/dist/theme-editor-preview.js +31 -31
- package/dist/theme-editor.d.cts +99 -5
- package/dist/theme-editor.d.ts +99 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +46 -5
- package/dist/theme-reference.d.ts +46 -5
- package/dist/theme-reference.js +1 -1
- package/dist/webmcp-runtime.d.cts +16 -0
- package/dist/webmcp-runtime.d.ts +16 -0
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/client-visitor-history.test.ts +342 -3
- package/src/client.test.ts +141 -3
- package/src/client.ts +157 -39
- package/src/components/composer-actions.ts +27 -1
- package/src/components/composer-model-picker.ts +4 -14
- package/src/components/composer-overflow-menu.ts +14 -0
- package/src/components/composer-parts.ts +20 -0
- package/src/generated/runtype-openapi-contract.ts +2 -0
- package/src/index-core.ts +2 -0
- package/src/session.ts +139 -103
- package/src/session.webmcp.test.ts +48 -1
- package/src/styles/widget-styles.test.ts +83 -1
- package/src/styles/widget.css +50 -14
- package/src/theme-reference.ts +1 -1
- package/src/types/theme.ts +47 -5
- package/src/types.ts +63 -0
- package/src/ui.composer-action-visibility.test.ts +202 -0
- package/src/ui.composer-models.test.ts +37 -0
- package/src/ui.composer-modes.test.ts +82 -0
- package/src/ui.composer-overflow.test.ts +78 -0
- package/src/ui.history-lifecycle.test.ts +2 -2
- package/src/ui.ts +77 -6
- package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1633 -686
- package/src/utils/composer-modes.test.ts +33 -0
- package/src/utils/composer-modes.ts +19 -2
- package/src/utils/tokens-parity.test.ts +20 -0
- package/src/utils/tokens.test.ts +76 -0
- 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.
|
|
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('
|
|
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
|
-
//
|
|
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({
|
|
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', () => {
|
package/src/client.test.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
+
});
|