@runtypelabs/persona 4.20.0 → 4.21.1
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 +63 -63
- 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 +48 -48
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +63 -63
- 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 +65 -65
- package/dist/theme-editor-preview.d.cts +22 -0
- package/dist/theme-editor-preview.d.ts +22 -0
- package/dist/theme-editor-preview.js +43 -43
- 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 +473 -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 +95 -7
package/src/session.ts
CHANGED
|
@@ -1553,6 +1553,7 @@ export class AgentWidgetSession {
|
|
|
1553
1553
|
|
|
1554
1554
|
/** Conversation-scoped caches that must not leak across records. */
|
|
1555
1555
|
private resetConversationScopedState(): void {
|
|
1556
|
+
this.historyInternals.setStoredResumableHandle?.(null);
|
|
1556
1557
|
this.agentExecution = null;
|
|
1557
1558
|
this.clearArtifactState();
|
|
1558
1559
|
this.webMcpInflightKeys.clear();
|
|
@@ -3341,9 +3342,16 @@ export class AgentWidgetSession {
|
|
|
3341
3342
|
}
|
|
3342
3343
|
|
|
3343
3344
|
try {
|
|
3344
|
-
const response = await this.client.resumeFlow(
|
|
3345
|
-
|
|
3346
|
-
|
|
3345
|
+
const response = await this.client.resumeFlow(
|
|
3346
|
+
executionId,
|
|
3347
|
+
{ [toolName]: answer },
|
|
3348
|
+
{
|
|
3349
|
+
after:
|
|
3350
|
+
this.resumable?.executionId === executionId
|
|
3351
|
+
? this.resumable.lastEventId
|
|
3352
|
+
: undefined,
|
|
3353
|
+
},
|
|
3354
|
+
);
|
|
3347
3355
|
|
|
3348
3356
|
if (!response.ok) {
|
|
3349
3357
|
const errorData = await response.json().catch(() => null);
|
|
@@ -3566,10 +3574,12 @@ export class AgentWidgetSession {
|
|
|
3566
3574
|
/**
|
|
3567
3575
|
* Resolve one or more parallel local-tool awaits sharing one paused
|
|
3568
3576
|
* executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
|
|
3569
|
-
* delegates size-1 resolves here after its guards.
|
|
3570
|
-
* against the page registry concurrently: every gated call renders
|
|
3571
|
-
* native approval bubble, and a sibling's confirm Promise never
|
|
3572
|
-
* another's execution.
|
|
3577
|
+
* delegates size-1 resolves here after its guards. By default each call is
|
|
3578
|
+
* executed against the page registry concurrently: every gated call renders
|
|
3579
|
+
* its own native approval bubble, and a sibling's confirm Promise never
|
|
3580
|
+
* blocks another's execution. With `webmcp.execution: "sequential"` the
|
|
3581
|
+
* calls run one at a time in emission order instead (see the config docs
|
|
3582
|
+
* for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
|
|
3573
3583
|
* (server prefers it over tool name; name-keying remains the fallback for
|
|
3574
3584
|
* legacy single/distinct-tool turns), so two calls to the SAME tool no longer
|
|
3575
3585
|
* collide. The server is tolerant: any call we omit (declined-after-abort,
|
|
@@ -3599,111 +3609,125 @@ export class AgentWidgetSession {
|
|
|
3599
3609
|
this.webMcpResolveControllers.add(batchController);
|
|
3600
3610
|
this.setStreaming(true);
|
|
3601
3611
|
|
|
3602
|
-
// Phase 1: execute every pending call
|
|
3603
|
-
//
|
|
3604
|
-
//
|
|
3605
|
-
const
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
)
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
}
|
|
3612
|
+
// Phase 1: execute every pending call. A null result means the call was
|
|
3613
|
+
// deduped, aborted, or threw; it's omitted from the resume and (per the
|
|
3614
|
+
// tolerant server) re-pauses for retry.
|
|
3615
|
+
const executeOne = async (
|
|
3616
|
+
toolMessage: AgentWidgetMessage,
|
|
3617
|
+
): Promise<ExecutedWebMcpTool | null> => {
|
|
3618
|
+
const wireToolName = toolMessage.toolCall?.name;
|
|
3619
|
+
const callId = toolMessage.toolCall?.id;
|
|
3620
|
+
if (!wireToolName || !callId) return null;
|
|
3621
|
+
|
|
3622
|
+
const dedupeKey = `${executionId}:${callId}`;
|
|
3623
|
+
if (
|
|
3624
|
+
this.webMcpInflightKeys.has(dedupeKey) ||
|
|
3625
|
+
this.webMcpResolvedKeys.has(dedupeKey) ||
|
|
3626
|
+
this.isSuggestRepliesAlreadyResolved(toolMessage)
|
|
3627
|
+
) {
|
|
3628
|
+
return null;
|
|
3629
|
+
}
|
|
3630
|
+
this.webMcpInflightKeys.add(dedupeKey);
|
|
3631
|
+
claimedKeys.push(dedupeKey);
|
|
3632
|
+
|
|
3633
|
+
// Clear the awaiting flag and keep the tool bubble running while the
|
|
3634
|
+
// browser-side WebMCP promise is in flight. The initial `step_await`
|
|
3635
|
+
// only means the server paused for a local tool; it is not completion.
|
|
3636
|
+
const startedAt = this.markWebMcpToolRunning(toolMessage);
|
|
3637
|
+
|
|
3638
|
+
// Per-call id wins for resume keying; fall back to the wire tool name
|
|
3639
|
+
// for legacy servers that don't emit `webMcpToolCallId`.
|
|
3640
|
+
const resumeKey =
|
|
3641
|
+
toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
|
|
3642
|
+
|
|
3643
|
+
// Built-in fire-and-forget tool: no bridge, no confirm gate, no
|
|
3644
|
+
// browser-side execution: the chips render from the message list and
|
|
3645
|
+
// the canned output joins the batch's single /resume.
|
|
3646
|
+
if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
|
|
3647
|
+
return {
|
|
3648
|
+
dedupeKey,
|
|
3649
|
+
resumeKey,
|
|
3650
|
+
output: suggestRepliesToolResult(),
|
|
3651
|
+
toolMessage,
|
|
3652
|
+
startedAt,
|
|
3653
|
+
completedAt: Date.now(),
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3645
3656
|
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3657
|
+
const execPromise = this.client.executeWebMcpToolCall(
|
|
3658
|
+
wireToolName,
|
|
3659
|
+
toolMessage.toolCall?.args,
|
|
3660
|
+
batchController.signal,
|
|
3661
|
+
);
|
|
3651
3662
|
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
);
|
|
3673
|
-
}
|
|
3674
|
-
this.markWebMcpToolComplete(
|
|
3675
|
-
toolMessage,
|
|
3676
|
-
buildWebMcpErrorResult(
|
|
3677
|
-
isAbortError
|
|
3678
|
-
? "Aborted by cancel()"
|
|
3679
|
-
: getWebMcpErrorMessage(error),
|
|
3680
|
-
),
|
|
3681
|
-
startedAt,
|
|
3663
|
+
let output: unknown;
|
|
3664
|
+
if (!execPromise) {
|
|
3665
|
+
output = {
|
|
3666
|
+
isError: true,
|
|
3667
|
+
content: [
|
|
3668
|
+
{ type: "text", text: "WebMCP not enabled on this widget." },
|
|
3669
|
+
],
|
|
3670
|
+
};
|
|
3671
|
+
} else {
|
|
3672
|
+
try {
|
|
3673
|
+
output = await execPromise;
|
|
3674
|
+
} catch (error) {
|
|
3675
|
+
const isAbortError =
|
|
3676
|
+
error instanceof Error &&
|
|
3677
|
+
(error.name === "AbortError" ||
|
|
3678
|
+
error.message.includes("aborted") ||
|
|
3679
|
+
error.message.includes("abort"));
|
|
3680
|
+
if (!isAbortError) {
|
|
3681
|
+
this.callbacks.onError?.(
|
|
3682
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
3682
3683
|
);
|
|
3683
|
-
// Release the dedupe claim so a re-emit can retry this call.
|
|
3684
|
-
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3685
|
-
return null;
|
|
3686
3684
|
}
|
|
3687
|
-
}
|
|
3688
|
-
if (batchController.signal.aborted) {
|
|
3689
3685
|
this.markWebMcpToolComplete(
|
|
3690
3686
|
toolMessage,
|
|
3691
|
-
buildWebMcpErrorResult(
|
|
3687
|
+
buildWebMcpErrorResult(
|
|
3688
|
+
isAbortError
|
|
3689
|
+
? "Aborted by cancel()"
|
|
3690
|
+
: getWebMcpErrorMessage(error),
|
|
3691
|
+
),
|
|
3692
3692
|
startedAt,
|
|
3693
3693
|
);
|
|
3694
|
+
// Release the dedupe claim so a re-emit can retry this call.
|
|
3694
3695
|
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3695
3696
|
return null;
|
|
3696
3697
|
}
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
output,
|
|
3698
|
+
}
|
|
3699
|
+
if (batchController.signal.aborted) {
|
|
3700
|
+
this.markWebMcpToolComplete(
|
|
3701
3701
|
toolMessage,
|
|
3702
|
+
buildWebMcpErrorResult("Aborted by cancel()"),
|
|
3702
3703
|
startedAt,
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3704
|
+
);
|
|
3705
|
+
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3706
|
+
return null;
|
|
3707
|
+
}
|
|
3708
|
+
return {
|
|
3709
|
+
dedupeKey,
|
|
3710
|
+
resumeKey,
|
|
3711
|
+
output,
|
|
3712
|
+
toolMessage,
|
|
3713
|
+
startedAt,
|
|
3714
|
+
completedAt: Date.now(),
|
|
3715
|
+
};
|
|
3716
|
+
};
|
|
3717
|
+
|
|
3718
|
+
// Parallel (default): a gated sibling's approval never blocks another's
|
|
3719
|
+
// execution. Sequential: one at a time in emission order, so tools that
|
|
3720
|
+
// share page state (a canvas, a form) don't interleave; the next call
|
|
3721
|
+
// starts only once the previous one has settled, approval included.
|
|
3722
|
+
let executed: Array<ExecutedWebMcpTool | null>;
|
|
3723
|
+
if (this.config.webmcp?.execution === "sequential") {
|
|
3724
|
+
executed = [];
|
|
3725
|
+
for (const toolMessage of snapshots) {
|
|
3726
|
+
executed.push(await executeOne(toolMessage));
|
|
3727
|
+
}
|
|
3728
|
+
} else {
|
|
3729
|
+
executed = await Promise.all(snapshots.map(executeOne));
|
|
3730
|
+
}
|
|
3707
3731
|
|
|
3708
3732
|
let ready: ExecutedWebMcpTool[] = [];
|
|
3709
3733
|
try {
|
|
@@ -3720,6 +3744,10 @@ export class AgentWidgetSession {
|
|
|
3720
3744
|
|
|
3721
3745
|
const response = await this.client.resumeFlow(executionId, toolOutputs, {
|
|
3722
3746
|
signal: batchController.signal,
|
|
3747
|
+
after:
|
|
3748
|
+
this.resumable?.executionId === executionId
|
|
3749
|
+
? this.resumable.lastEventId
|
|
3750
|
+
: undefined,
|
|
3723
3751
|
});
|
|
3724
3752
|
if (!response.ok) {
|
|
3725
3753
|
const errorData = await response.json().catch(() => null);
|
|
@@ -3895,7 +3923,13 @@ export class AgentWidgetSession {
|
|
|
3895
3923
|
const response = await this.client.resumeFlow(
|
|
3896
3924
|
executionId,
|
|
3897
3925
|
{ [resumeKey]: output },
|
|
3898
|
-
{
|
|
3926
|
+
{
|
|
3927
|
+
signal: options?.signal,
|
|
3928
|
+
after:
|
|
3929
|
+
this.resumable?.executionId === executionId
|
|
3930
|
+
? this.resumable.lastEventId
|
|
3931
|
+
: undefined,
|
|
3932
|
+
},
|
|
3899
3933
|
);
|
|
3900
3934
|
if (!response.ok) {
|
|
3901
3935
|
const errorData = await response.json().catch(() => null);
|
|
@@ -4323,9 +4357,11 @@ export class AgentWidgetSession {
|
|
|
4323
4357
|
if (event.status === "connecting") {
|
|
4324
4358
|
this.setStreaming(true);
|
|
4325
4359
|
} else if (event.status === "idle" || event.status === "error") {
|
|
4326
|
-
//
|
|
4327
|
-
//
|
|
4328
|
-
|
|
4360
|
+
// A client-tool pause is an intentional stream end but NOT a terminal:
|
|
4361
|
+
// retain its cursor so `/client/resume` can attach after the await
|
|
4362
|
+
// frame instead of replaying the whole turn. Real terminals and
|
|
4363
|
+
// ordinary non-durable idles still clear the handle.
|
|
4364
|
+
if (!this.isAwaitPending()) this.clearResumable();
|
|
4329
4365
|
// Keep the typing indicator up while a WebMCP resolve is still in
|
|
4330
4366
|
// flight: in a chained turn the intermediate resume stream ends with an
|
|
4331
4367
|
// idle status, but the successor tool is still executing. The resolve's
|
|
@@ -13,9 +13,10 @@ const makeSession = (overrides?: {
|
|
|
13
13
|
resumeOk?: boolean;
|
|
14
14
|
isOperational?: boolean;
|
|
15
15
|
executeImpl?: () => Promise<WebMcpToolResult>;
|
|
16
|
+
webmcp?: Record<string, unknown>;
|
|
16
17
|
}) => {
|
|
17
18
|
const session = new AgentWidgetSession(
|
|
18
|
-
{ apiUrl: "http://test", webmcp: { enabled: true } },
|
|
19
|
+
{ apiUrl: "http://test", webmcp: { enabled: true, ...overrides?.webmcp } },
|
|
19
20
|
{
|
|
20
21
|
onMessagesChanged: () => undefined,
|
|
21
22
|
onStatusChanged: () => undefined,
|
|
@@ -949,6 +950,52 @@ describe("AgentWidgetSession: WebMCP parallel batched resume (core#3878)", () =>
|
|
|
949
950
|
expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_A", "toolu_B"]);
|
|
950
951
|
});
|
|
951
952
|
|
|
953
|
+
it("webmcp.execution: 'sequential' runs siblings one at a time, in emission order", async () => {
|
|
954
|
+
// Paint-style tools share page state (select tool + color, then draw), so
|
|
955
|
+
// a multi-call turn must not interleave. The second execute must not even
|
|
956
|
+
// START until the first has resolved; the batch still posts ONE /resume.
|
|
957
|
+
let releaseA: (r: WebMcpToolResult) => void = () => undefined;
|
|
958
|
+
let releaseB: (r: WebMcpToolResult) => void = () => undefined;
|
|
959
|
+
const pA = new Promise<WebMcpToolResult>((r) => (releaseA = r));
|
|
960
|
+
const pB = new Promise<WebMcpToolResult>((r) => (releaseB = r));
|
|
961
|
+
const started: string[] = [];
|
|
962
|
+
|
|
963
|
+
const { session, resumeSpy } = makeSession({
|
|
964
|
+
webmcp: { execution: "sequential" },
|
|
965
|
+
});
|
|
966
|
+
const client = (session as unknown as { client: Record<string, unknown> })
|
|
967
|
+
.client;
|
|
968
|
+
(client.executeWebMcpToolCall as ReturnType<typeof vi.fn>).mockImplementation(
|
|
969
|
+
(_name: string, args: { sku: string }) => {
|
|
970
|
+
started.push(args.sku);
|
|
971
|
+
return args.sku === "SHOE-001" ? pA : pB;
|
|
972
|
+
},
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
feed(session, parallelAwait("toolu_A", "SHOE-001"));
|
|
976
|
+
feed(session, parallelAwait("toolu_B", "SHOE-007"));
|
|
977
|
+
endStream(session);
|
|
978
|
+
await flushMicrotasks();
|
|
979
|
+
|
|
980
|
+
// Only the first call is in flight; the second waits on it.
|
|
981
|
+
expect(started).toEqual(["SHOE-001"]);
|
|
982
|
+
expect(resumeSpy).not.toHaveBeenCalled();
|
|
983
|
+
|
|
984
|
+
releaseA({ content: [{ type: "text", text: "a" }] });
|
|
985
|
+
await flushMicrotasks();
|
|
986
|
+
expect(started).toEqual(["SHOE-001", "SHOE-007"]);
|
|
987
|
+
expect(resumeSpy).not.toHaveBeenCalled();
|
|
988
|
+
|
|
989
|
+
releaseB({ content: [{ type: "text", text: "b" }] });
|
|
990
|
+
await flushMicrotasks();
|
|
991
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
992
|
+
const toolOutputs = (resumeSpy.mock.calls[0]! as unknown[])[1] as Record<
|
|
993
|
+
string,
|
|
994
|
+
unknown
|
|
995
|
+
>;
|
|
996
|
+
expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_A", "toolu_B"]);
|
|
997
|
+
});
|
|
998
|
+
|
|
952
999
|
it("records elapsed time for each resolved tool in a batched WebMCP resume", async () => {
|
|
953
1000
|
vi.useFakeTimers();
|
|
954
1001
|
vi.setSystemTime(1_000);
|
package/src/types.ts
CHANGED
|
@@ -908,6 +908,22 @@ export type AgentWidgetWebMcpConfig = {
|
|
|
908
908
|
* `window.confirm` fallback only applies when no widget UI is attached.
|
|
909
909
|
*/
|
|
910
910
|
onConfirm?: WebMcpConfirmHandler;
|
|
911
|
+
/**
|
|
912
|
+
* How the widget runs the `webmcp:*` calls of ONE turn when the model emits
|
|
913
|
+
* several at once.
|
|
914
|
+
*
|
|
915
|
+
* - `'parallel'` (default): every call executes concurrently and a gated
|
|
916
|
+
* sibling's approval never blocks another's execution. Right for
|
|
917
|
+
* independent, order-insensitive tools (catalog lookups, add-to-cart).
|
|
918
|
+
* - `'sequential'`: calls execute one at a time, in the order the model
|
|
919
|
+
* emitted them; the next starts only after the previous one settles
|
|
920
|
+
* (including its approval bubble). Use this when the page tools share
|
|
921
|
+
* mutable state that concurrent execution would interleave, e.g. a
|
|
922
|
+
* canvas where every stroke selects a color/tool before drawing.
|
|
923
|
+
*
|
|
924
|
+
* Either way, all outputs still return to the server in a single `/resume`.
|
|
925
|
+
*/
|
|
926
|
+
execution?: 'parallel' | 'sequential';
|
|
911
927
|
};
|
|
912
928
|
|
|
913
929
|
/**
|
|
@@ -5674,6 +5690,8 @@ export type ClientSession = {
|
|
|
5674
5690
|
targetId?: string;
|
|
5675
5691
|
/** Opaque change token; differs whenever the conversation transcript mutated. */
|
|
5676
5692
|
conversationRevision?: string;
|
|
5693
|
+
/** Server-negotiated durable reconnect support for this client-token session. */
|
|
5694
|
+
durableRecovery?: { enabled: boolean };
|
|
5677
5695
|
/** Visitor grant backing history. `token` appears only at mint. */
|
|
5678
5696
|
visitor?: ClientVisitorGrant;
|
|
5679
5697
|
/** Configuration from the server */
|
|
@@ -5714,6 +5732,8 @@ export type ClientInitResponse = {
|
|
|
5714
5732
|
conversationId?: string;
|
|
5715
5733
|
targetId?: string;
|
|
5716
5734
|
conversationRevision?: string;
|
|
5735
|
+
/** Present on servers that understand the durable-recovery negotiation. */
|
|
5736
|
+
durableRecovery?: { enabled: boolean };
|
|
5717
5737
|
visitor?: ClientVisitorGrant;
|
|
5718
5738
|
config: {
|
|
5719
5739
|
welcomeMessage: string | null;
|
|
@@ -5805,6 +5825,16 @@ export interface WidgetHistoryInternals {
|
|
|
5805
5825
|
/** Persist the revision beside the active conversation id (internal only). */
|
|
5806
5826
|
setStoredConversationRevision?: (revision: string | null) => void;
|
|
5807
5827
|
getStoredConversationRevision?: () => string | null;
|
|
5828
|
+
/** Internal client-token durable resume handle, persisted with widget state. */
|
|
5829
|
+
setStoredResumableHandle?: (
|
|
5830
|
+
handle: { executionId: string; after: string } | null
|
|
5831
|
+
) => void;
|
|
5832
|
+
getStoredResumableHandle?: () => {
|
|
5833
|
+
executionId: string;
|
|
5834
|
+
after: string;
|
|
5835
|
+
} | null;
|
|
5836
|
+
/** True only while Persona owns a persisted client-token recovery attempt. */
|
|
5837
|
+
shouldResumeDurableConversation?: () => boolean;
|
|
5808
5838
|
/** "Show earlier messages" cursor for the persisted transcript. */
|
|
5809
5839
|
setStoredMessageCursor?: (cursor: string | null) => void;
|
|
5810
5840
|
getStoredMessageCursor?: () => string | null;
|
|
@@ -212,7 +212,7 @@ describe("history controller lifecycle", () => {
|
|
|
212
212
|
expect(window.localStorage.getItem(keysA.storageKey)).toBe("cvt_alpha");
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
it("
|
|
215
|
+
it("keeps the recovery credential store when history UI is disabled", async () => {
|
|
216
216
|
await seed();
|
|
217
217
|
const { controller } = mount({
|
|
218
218
|
clientToken: TOKEN_A,
|
|
@@ -224,7 +224,7 @@ describe("history controller lifecycle", () => {
|
|
|
224
224
|
await flush(20);
|
|
225
225
|
|
|
226
226
|
expect(recorder.instances.length).toBe(1);
|
|
227
|
-
expect(recorder.instances[0]!.destroy).
|
|
227
|
+
expect(recorder.instances[0]!.destroy).not.toHaveBeenCalled();
|
|
228
228
|
expect(recorder.instances[0]!.clear).not.toHaveBeenCalled();
|
|
229
229
|
});
|
|
230
230
|
});
|
package/src/ui.ts
CHANGED
|
@@ -1054,6 +1054,8 @@ export const createAgentExperience = (
|
|
|
1054
1054
|
messagePersistenceDisabled
|
|
1055
1055
|
? null
|
|
1056
1056
|
: (config.storageAdapter ?? createLocalStorageAdapter());
|
|
1057
|
+
const personaOwnsClientTokenRecovery =
|
|
1058
|
+
Boolean(config.clientToken) && typeof config.reconnectStream !== "function";
|
|
1057
1059
|
let persistentMetadata: Record<string, unknown> = {};
|
|
1058
1060
|
let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
|
|
1059
1061
|
// Resolves once stored state has been applied (or its load failed). Every
|
|
@@ -5313,6 +5315,17 @@ export const createAgentExperience = (
|
|
|
5313
5315
|
? getMessagesForPersistence()
|
|
5314
5316
|
: [];
|
|
5315
5317
|
|
|
5318
|
+
const durableHandle =
|
|
5319
|
+
personaOwnsClientTokenRecovery && session?.getResumableHandle();
|
|
5320
|
+
if (durableHandle) {
|
|
5321
|
+
persistentMetadata = {
|
|
5322
|
+
...persistentMetadata,
|
|
5323
|
+
durableResume: {
|
|
5324
|
+
executionId: durableHandle.executionId,
|
|
5325
|
+
after: durableHandle.lastEventId,
|
|
5326
|
+
},
|
|
5327
|
+
};
|
|
5328
|
+
}
|
|
5316
5329
|
const payload: AgentWidgetStoredState = {
|
|
5317
5330
|
messages,
|
|
5318
5331
|
metadata: persistentMetadata,
|
|
@@ -8370,6 +8383,20 @@ export const createAgentExperience = (
|
|
|
8370
8383
|
const stored = persistentMetadata[key];
|
|
8371
8384
|
return typeof stored === 'string' ? stored : null;
|
|
8372
8385
|
};
|
|
8386
|
+
const readStoredResumableHandle = (): {
|
|
8387
|
+
executionId: string;
|
|
8388
|
+
after: string;
|
|
8389
|
+
} | null => {
|
|
8390
|
+
const stored = persistentMetadata.durableResume;
|
|
8391
|
+
if (!stored || typeof stored !== 'object') return null;
|
|
8392
|
+
const candidate = stored as Record<string, unknown>;
|
|
8393
|
+
return typeof candidate.executionId === 'string' &&
|
|
8394
|
+
candidate.executionId.length > 0 &&
|
|
8395
|
+
typeof candidate.after === 'string' &&
|
|
8396
|
+
candidate.after.length > 0
|
|
8397
|
+
? { executionId: candidate.executionId, after: candidate.after }
|
|
8398
|
+
: null;
|
|
8399
|
+
};
|
|
8373
8400
|
if (config.clientToken) {
|
|
8374
8401
|
config = {
|
|
8375
8402
|
...config,
|
|
@@ -8394,11 +8421,11 @@ export const createAgentExperience = (
|
|
|
8394
8421
|
};
|
|
8395
8422
|
}
|
|
8396
8423
|
|
|
8397
|
-
//
|
|
8398
|
-
// persistence-disabled).
|
|
8399
|
-
//
|
|
8424
|
+
// Exact-browser visitor credential store, keyed on (clientToken, keyPrefix,
|
|
8425
|
+
// persistence-disabled). Durable reconnect and optional visitor history use
|
|
8426
|
+
// the same credential but negotiate independent server capabilities.
|
|
8400
8427
|
const historyStoreToken = (): string | null =>
|
|
8401
|
-
config.
|
|
8428
|
+
config.clientToken ?? null;
|
|
8402
8429
|
const buildVisitorStore = (token: string) =>
|
|
8403
8430
|
createVisitorStore(token, currentKeyPrefix(), config.persistState === false);
|
|
8404
8431
|
const initialStoreToken = historyStoreToken();
|
|
@@ -8446,6 +8473,16 @@ export const createAgentExperience = (
|
|
|
8446
8473
|
pendingDisplayProjections: pending,
|
|
8447
8474
|
}));
|
|
8448
8475
|
},
|
|
8476
|
+
getStoredResumableHandle: readStoredResumableHandle,
|
|
8477
|
+
setStoredResumableHandle: (handle) => {
|
|
8478
|
+
if (!handle) {
|
|
8479
|
+
dropMetadataKey('durableResume');
|
|
8480
|
+
return;
|
|
8481
|
+
}
|
|
8482
|
+
updateSessionMetadata((prev) => ({ ...prev, durableResume: handle }));
|
|
8483
|
+
},
|
|
8484
|
+
shouldResumeDurableConversation: () =>
|
|
8485
|
+
personaOwnsClientTokenRecovery && readStoredResumableHandle() !== null,
|
|
8449
8486
|
...(hostOwnsConversationId
|
|
8450
8487
|
? {}
|
|
8451
8488
|
: {
|
|
@@ -8463,6 +8500,40 @@ export const createAgentExperience = (
|
|
|
8463
8500
|
},
|
|
8464
8501
|
}),
|
|
8465
8502
|
};
|
|
8503
|
+
|
|
8504
|
+
// Runtype client-token widgets already possess the session, conversation,
|
|
8505
|
+
// and exact-browser visitor credential needed by the public reconnect route.
|
|
8506
|
+
// Install Persona's existing reconnect hooks by default so this mode follows
|
|
8507
|
+
// the same path as host-configured durable dashboard chats.
|
|
8508
|
+
if (personaOwnsClientTokenRecovery) {
|
|
8509
|
+
const hostExecutionState = config.onExecutionState;
|
|
8510
|
+
const recoveryWindow = mount.ownerDocument.defaultView;
|
|
8511
|
+
let pageExitInProgress = false;
|
|
8512
|
+
const markPageExit = () => (pageExitInProgress = true);
|
|
8513
|
+
const markPageActive = () => (pageExitInProgress = false);
|
|
8514
|
+
recoveryWindow?.addEventListener("pagehide", markPageExit);
|
|
8515
|
+
recoveryWindow?.addEventListener("pageshow", markPageActive);
|
|
8516
|
+
destroyCallbacks.push(() => {
|
|
8517
|
+
recoveryWindow?.removeEventListener("pagehide", markPageExit);
|
|
8518
|
+
recoveryWindow?.removeEventListener("pageshow", markPageActive);
|
|
8519
|
+
});
|
|
8520
|
+
config = {
|
|
8521
|
+
...config,
|
|
8522
|
+
reconnectStream:
|
|
8523
|
+
config.reconnectStream ??
|
|
8524
|
+
((ctx) => session.getClient().reconnectClientTokenStream(ctx)),
|
|
8525
|
+
onExecutionState: (handle) => {
|
|
8526
|
+
if (handle || !pageExitInProgress) {
|
|
8527
|
+
historyInternals.setStoredResumableHandle?.(
|
|
8528
|
+
handle
|
|
8529
|
+
? { executionId: handle.executionId, after: handle.lastEventId }
|
|
8530
|
+
: null
|
|
8531
|
+
);
|
|
8532
|
+
}
|
|
8533
|
+
hostExecutionState?.(handle);
|
|
8534
|
+
},
|
|
8535
|
+
};
|
|
8536
|
+
}
|
|
8466
8537
|
const syncVisitorStore = () => {
|
|
8467
8538
|
const token = historyStoreToken();
|
|
8468
8539
|
if (
|
|
@@ -8572,6 +8643,18 @@ export const createAgentExperience = (
|
|
|
8572
8643
|
let activeStreamingTextCandidate: { index: number; id: string } | null = null;
|
|
8573
8644
|
let pendingStreamingTextMessages: AgentWidgetMessage[] | null = null;
|
|
8574
8645
|
let streamingTextRAF: number | null = null;
|
|
8646
|
+
let pendingDurablePersistenceMessages: AgentWidgetMessage[] | null = null;
|
|
8647
|
+
|
|
8648
|
+
const persistMessages = (messages: AgentWidgetMessage[]) => {
|
|
8649
|
+
if (!personaOwnsClientTokenRecovery) return persistState(messages);
|
|
8650
|
+
pendingDurablePersistenceMessages = messages;
|
|
8651
|
+
queueMicrotask(() => {
|
|
8652
|
+
if (pendingDurablePersistenceMessages === messages) {
|
|
8653
|
+
pendingDurablePersistenceMessages = null;
|
|
8654
|
+
persistState(messages);
|
|
8655
|
+
}
|
|
8656
|
+
});
|
|
8657
|
+
};
|
|
8575
8658
|
|
|
8576
8659
|
const applyMessagesChanged = (messages: AgentWidgetMessage[]) => {
|
|
8577
8660
|
lastAppliedMessages = messages;
|
|
@@ -8632,7 +8715,7 @@ export const createAgentExperience = (
|
|
|
8632
8715
|
}
|
|
8633
8716
|
|
|
8634
8717
|
voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
|
|
8635
|
-
|
|
8718
|
+
persistMessages(messages);
|
|
8636
8719
|
syncComposerBarPeek();
|
|
8637
8720
|
};
|
|
8638
8721
|
|
|
@@ -11297,8 +11380,13 @@ export const createAgentExperience = (
|
|
|
11297
11380
|
// the restored conversation. Fires AFTER hydration so the trailing partial
|
|
11298
11381
|
// assistant bubble exists for the replay to append to.
|
|
11299
11382
|
const maybeBootResume = () => {
|
|
11300
|
-
|
|
11301
|
-
|
|
11383
|
+
const resume =
|
|
11384
|
+
config.resume ??
|
|
11385
|
+
(personaOwnsClientTokenRecovery
|
|
11386
|
+
? historyInternals.getStoredResumableHandle?.()
|
|
11387
|
+
: null);
|
|
11388
|
+
if (resume && typeof config.reconnectStream === "function") {
|
|
11389
|
+
session.resumeFromHandle(resume);
|
|
11302
11390
|
}
|
|
11303
11391
|
};
|
|
11304
11392
|
|