@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.
- 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 +22 -0
- package/dist/approval-ui.d.ts +22 -0
- package/dist/artifacts-ui.d.cts +22 -0
- package/dist/artifacts-ui.d.ts +22 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +22 -0
- package/dist/context-mentions.d.ts +22 -0
- package/dist/event-stream-view.d.cts +22 -0
- package/dist/event-stream-view.d.ts +22 -0
- package/dist/forms-ui.d.cts +46 -4
- package/dist/forms-ui.d.ts +46 -4
- package/dist/history-view.d.cts +2 -0
- package/dist/history-view.d.ts +2 -0
- package/dist/index.cjs +43 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -4
- package/dist/index.d.ts +50 -4
- package/dist/index.global.js +30 -30
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +43 -43
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +22 -0
- package/dist/session-reconnect.d.ts +22 -0
- package/dist/smart-dom-reader.d.cts +22 -0
- package/dist/smart-dom-reader.d.ts +22 -0
- package/dist/theme-editor-preview.cjs +53 -53
- package/dist/theme-editor-preview.d.cts +22 -0
- package/dist/theme-editor-preview.d.ts +22 -0
- package/dist/theme-editor-preview.js +31 -31
- package/dist/theme-editor.d.cts +22 -0
- package/dist/theme-editor.d.ts +22 -0
- package/dist/webmcp-runtime.d.cts +16 -0
- package/dist/webmcp-runtime.d.ts +16 -0
- 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/generated/runtype-openapi-contract.ts +2 -0
- package/src/session.ts +139 -103
- package/src/session.webmcp.test.ts +48 -1
- package/src/types.ts +30 -0
- package/src/ui.history-lifecycle.test.ts +2 -2
- package/src/ui.ts +77 -6
package/dist/theme-editor.d.cts
CHANGED
|
@@ -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 */
|
package/dist/theme-editor.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/webmcp-runtime.d.ts
CHANGED
|
@@ -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.
|
|
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', () => {
|