@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.
Files changed (52) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-CG-bLFmk.d.cts → types-B_JdVK9o.d.cts} +2 -0
  4. package/dist/animations/{types-CG-bLFmk.d.ts → types-B_JdVK9o.d.ts} +2 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui.d.cts +22 -0
  10. package/dist/approval-ui.d.ts +22 -0
  11. package/dist/artifacts-ui.d.cts +22 -0
  12. package/dist/artifacts-ui.d.ts +22 -0
  13. package/dist/codegen.cjs +1 -1
  14. package/dist/codegen.js +1 -1
  15. package/dist/context-mentions.d.cts +22 -0
  16. package/dist/context-mentions.d.ts +22 -0
  17. package/dist/event-stream-view.d.cts +22 -0
  18. package/dist/event-stream-view.d.ts +22 -0
  19. package/dist/forms-ui.d.cts +46 -4
  20. package/dist/forms-ui.d.ts +46 -4
  21. package/dist/history-view.d.cts +2 -0
  22. package/dist/history-view.d.ts +2 -0
  23. package/dist/index.cjs +63 -63
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +50 -4
  26. package/dist/index.d.ts +50 -4
  27. package/dist/index.global.js +48 -48
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +63 -63
  30. package/dist/index.js.map +1 -1
  31. package/dist/session-reconnect.d.cts +22 -0
  32. package/dist/session-reconnect.d.ts +22 -0
  33. package/dist/smart-dom-reader.d.cts +22 -0
  34. package/dist/smart-dom-reader.d.ts +22 -0
  35. package/dist/theme-editor-preview.cjs +65 -65
  36. package/dist/theme-editor-preview.d.cts +22 -0
  37. package/dist/theme-editor-preview.d.ts +22 -0
  38. package/dist/theme-editor-preview.js +43 -43
  39. package/dist/theme-editor.d.cts +22 -0
  40. package/dist/theme-editor.d.ts +22 -0
  41. package/dist/webmcp-runtime.d.cts +16 -0
  42. package/dist/webmcp-runtime.d.ts +16 -0
  43. package/package.json +1 -1
  44. package/src/client-visitor-history.test.ts +473 -3
  45. package/src/client.test.ts +141 -3
  46. package/src/client.ts +157 -39
  47. package/src/generated/runtype-openapi-contract.ts +2 -0
  48. package/src/session.ts +139 -103
  49. package/src/session.webmcp.test.ts +48 -1
  50. package/src/types.ts +30 -0
  51. package/src/ui.history-lifecycle.test.ts +2 -2
  52. package/src/ui.ts +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(executionId, {
3345
- [toolName]: answer,
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. Each call is executed
3570
- * against the page registry concurrently: every gated call renders its own
3571
- * native approval bubble, and a sibling's confirm Promise never blocks
3572
- * another's execution. Outputs are keyed by per-call `webMcpToolCallId`
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 concurrently. A null result means
3603
- // the call was deduped, aborted, or threw; it's omitted from the resume and
3604
- // (per the tolerant server) re-pauses for retry.
3605
- const executed = await Promise.all(
3606
- snapshots.map(async (toolMessage) => {
3607
- const wireToolName = toolMessage.toolCall?.name;
3608
- const callId = toolMessage.toolCall?.id;
3609
- if (!wireToolName || !callId) return null;
3610
-
3611
- const dedupeKey = `${executionId}:${callId}`;
3612
- if (
3613
- this.webMcpInflightKeys.has(dedupeKey) ||
3614
- this.webMcpResolvedKeys.has(dedupeKey) ||
3615
- this.isSuggestRepliesAlreadyResolved(toolMessage)
3616
- ) {
3617
- return null;
3618
- }
3619
- this.webMcpInflightKeys.add(dedupeKey);
3620
- claimedKeys.push(dedupeKey);
3621
-
3622
- // Clear the awaiting flag and keep the tool bubble running while the
3623
- // browser-side WebMCP promise is in flight. The initial `step_await`
3624
- // only means the server paused for a local tool; it is not completion.
3625
- const startedAt = this.markWebMcpToolRunning(toolMessage);
3626
-
3627
- // Per-call id wins for resume keying; fall back to the wire tool name
3628
- // for legacy servers that don't emit `webMcpToolCallId`.
3629
- const resumeKey =
3630
- toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
3631
-
3632
- // Built-in fire-and-forget tool: no bridge, no confirm gate, no
3633
- // browser-side execution: the chips render from the message list and
3634
- // the canned output joins the batch's single /resume.
3635
- if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
3636
- return {
3637
- dedupeKey,
3638
- resumeKey,
3639
- output: suggestRepliesToolResult(),
3640
- toolMessage,
3641
- startedAt,
3642
- completedAt: Date.now(),
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
- const execPromise = this.client.executeWebMcpToolCall(
3647
- wireToolName,
3648
- toolMessage.toolCall?.args,
3649
- batchController.signal,
3650
- );
3657
+ const execPromise = this.client.executeWebMcpToolCall(
3658
+ wireToolName,
3659
+ toolMessage.toolCall?.args,
3660
+ batchController.signal,
3661
+ );
3651
3662
 
3652
- let output: unknown;
3653
- if (!execPromise) {
3654
- output = {
3655
- isError: true,
3656
- content: [
3657
- { type: "text", text: "WebMCP not enabled on this widget." },
3658
- ],
3659
- };
3660
- } else {
3661
- try {
3662
- output = await execPromise;
3663
- } catch (error) {
3664
- const isAbortError =
3665
- error instanceof Error &&
3666
- (error.name === "AbortError" ||
3667
- error.message.includes("aborted") ||
3668
- error.message.includes("abort"));
3669
- if (!isAbortError) {
3670
- this.callbacks.onError?.(
3671
- error instanceof Error ? error : new Error(String(error)),
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("Aborted by cancel()"),
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
- return {
3698
- dedupeKey,
3699
- resumeKey,
3700
- output,
3698
+ }
3699
+ if (batchController.signal.aborted) {
3700
+ this.markWebMcpToolComplete(
3701
3701
  toolMessage,
3702
+ buildWebMcpErrorResult("Aborted by cancel()"),
3702
3703
  startedAt,
3703
- completedAt: Date.now(),
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
- { signal: options?.signal },
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
- // Graceful terminal or terminal error: the durable turn is over, so
4327
- // drop the resume handle (no reconnect should arm after this).
4328
- this.clearResumable();
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("drops the store entirely when history is disabled", async () => {
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).toHaveBeenCalledTimes(1);
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
- // Visitor-history credential store, keyed on (clientToken, keyPrefix,
8398
- // persistence-disabled). Owned here and injected into every client the
8399
- // session builds; `update()` rebuilds it when that tuple changes.
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.features?.history?.enabled === true ? (config.clientToken ?? null) : null;
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
- persistState(messages);
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
- if (config.resume && typeof config.reconnectStream === "function") {
11301
- session.resumeFromHandle(config.resume);
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