@runtypelabs/persona 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) 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-C6tFDxKy.d.cts → types-8RICZWQe.d.cts} +6 -0
  4. package/dist/animations/{types-C6tFDxKy.d.ts → types-8RICZWQe.d.ts} +6 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-DFBSCFYN.js +1 -0
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +51 -51
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +228 -2
  13. package/dist/index.d.ts +228 -2
  14. package/dist/index.global.js +37 -37
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +51 -51
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
  20. package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
  21. package/dist/session-reconnect-U77QFUR7.js +1 -0
  22. package/dist/smart-dom-reader.d.cts +90 -0
  23. package/dist/smart-dom-reader.d.ts +90 -0
  24. package/dist/theme-editor-preview.cjs +48 -48
  25. package/dist/theme-editor-preview.d.cts +130 -1
  26. package/dist/theme-editor-preview.d.ts +130 -1
  27. package/dist/theme-editor-preview.js +48 -48
  28. package/dist/theme-editor.d.cts +90 -0
  29. package/dist/theme-editor.d.ts +90 -0
  30. package/package.json +2 -2
  31. package/src/client.ts +48 -7
  32. package/src/generated/runtype-openapi-contract.ts +6 -0
  33. package/src/reconnect-wake.test.ts +162 -0
  34. package/src/reconnect.test.ts +430 -0
  35. package/src/session-reconnect.ts +282 -0
  36. package/src/session.ts +408 -5
  37. package/src/types.ts +107 -1
  38. package/src/ui.stream-animation-update.test.ts +99 -0
  39. package/src/ui.ts +71 -1
  40. package/src/utils/constants.ts +3 -1
package/src/session.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  AgentWidgetApprovalDecisionOptions,
13
13
  WebMcpConfirmInfo,
14
14
  AgentExecutionState,
15
+ ResumableHandle,
15
16
  ClientSession,
16
17
  ContentPart,
17
18
  InjectMessageOptions,
@@ -44,12 +45,25 @@ import {
44
45
  } from "./voice";
45
46
  import { resolveSpeakableText } from "./utils/speech-text";
46
47
  import { loadRuntypeTts } from "./voice/runtype-tts-loader";
48
+ // Type-only (erased at build): the runtime reconnect machinery is reached via a
49
+ // dynamic import in `beginReconnect`, so it never lands in bundles that don't
50
+ // opt into durable reconnect (see ./session-reconnect.ts).
51
+ import type {
52
+ ReconnectController,
53
+ ReconnectHost,
54
+ } from "./session-reconnect";
47
55
 
48
56
  export type AgentWidgetSessionStatus =
49
57
  | "idle"
50
58
  | "connecting"
51
59
  | "connected"
52
- | "error";
60
+ | "error"
61
+ // Durable-session reconnect states. `paused`: a durable stream dropped and a
62
+ // reconnect is pending. `resuming`: a reconnect attempt is in flight. While
63
+ // in either, the in-progress assistant bubble stays open (not finalized) and
64
+ // `streaming` stays true so the typing indicator persists.
65
+ | "paused"
66
+ | "resuming";
53
67
 
54
68
  /**
55
69
  * Config fields the `AgentWidgetClient` reads to shape the connection and each
@@ -107,6 +121,17 @@ type SessionCallbacks = {
107
121
  artifacts: PersonaArtifactRecord[];
108
122
  selectedId: string | null;
109
123
  }) => void;
124
+ /**
125
+ * Durable-session reconnect lifecycle, surfaced so the UI can map it to the
126
+ * public `stream:paused` / `stream:resuming` / `stream:resumed` controller
127
+ * events. `paused` fires once on the drop, `resuming` once per attempt,
128
+ * `resumed` when a reconnect reaches its terminal.
129
+ */
130
+ onReconnect?: (event: {
131
+ phase: "paused" | "resuming" | "resumed";
132
+ handle: ResumableHandle;
133
+ attempt?: number;
134
+ }) => void;
110
135
  };
111
136
 
112
137
  /**
@@ -171,6 +196,29 @@ export class AgentWidgetSession {
171
196
  // Agent execution state
172
197
  private agentExecution: AgentExecutionState | null = null;
173
198
 
199
+ // ── Durable-session reconnect ──────────────────────────────────
200
+ // The in-flight resume handle for the durable lane (any resumable agent
201
+ // execution the backend persists and can replay, e.g. Claude Managed agents
202
+ // or async/background runs). Created when a stream that carries SSE `id:`
203
+ // lines first yields a cursor (and an executionId is known); advanced as the
204
+ // cursor climbs; cleared on a graceful terminal, a terminal error, or any
205
+ // teardown. Drives both in-session reconnect and host persistence
206
+ // (`onExecutionState`).
207
+ private resumable: ResumableHandle | null = null;
208
+ // The assistant message id of the current turn, so a reconnect keeps filling
209
+ // the SAME bubble instead of opening a new one.
210
+ private activeAssistantMessageId: string | null = null;
211
+ // True while a reconnect run is active (guards against re-entry from a second
212
+ // drop and tells the dispatch/connectStream catches to stay quiet).
213
+ private reconnecting = false;
214
+ // The reconnect orchestration (backoff loop, wake listeners, give-up
215
+ // finalizer) lives in a lazily-imported module so it stays out of bundles
216
+ // that never opt into durable reconnect. Created on the first reconnect.
217
+ private reconnectController: ReconnectController | null = null;
218
+ private reconnectControllerPromise: Promise<ReconnectController> | null = null;
219
+ // Trailing-edge throttle for `onExecutionState` so it isn't fired per delta.
220
+ private executionStateTimer: ReturnType<typeof setTimeout> | null = null;
221
+
174
222
  private artifacts = new Map<string, PersonaArtifactRecord>();
175
223
  private selectedArtifactId: string | null = null;
176
224
 
@@ -1096,10 +1144,17 @@ export class AgentWidgetSession {
1096
1144
  // one) so a lingering resolve can't race the new dispatch or post a stale
1097
1145
  // /resume against a superseded execution.
1098
1146
  this.abortWebMcpResolves();
1147
+ // A new turn also supersedes any pending durable reconnect from the prior
1148
+ // turn (cancels backoff/listeners, clears the old resume handle).
1149
+ this.teardownReconnect();
1099
1150
 
1100
1151
  // Generate IDs for both user message and expected assistant response
1101
1152
  const userMessageId = generateUserMessageId();
1102
1153
  const assistantMessageId = generateAssistantMessageId();
1154
+ // The active assistant bubble for a durable reconnect is captured from the
1155
+ // real streamed message events (see handleEvent), not pre-assigned here:
1156
+ // the proxy path auto-generates a different id than `assistantMessageId`.
1157
+ this.activeAssistantMessageId = null;
1103
1158
 
1104
1159
  const userMessage: AgentWidgetMessage = {
1105
1160
  id: userMessageId,
@@ -1132,6 +1187,11 @@ export class AgentWidgetSession {
1132
1187
  this.handleEvent
1133
1188
  );
1134
1189
  } catch (error) {
1190
+ // A durable drop fired the dispatch wrapper's `finally` (plain `idle`)
1191
+ // first, which already flipped us into `resuming` and armed reconnect.
1192
+ // The subsequent dispatch rejection must NOT paint a dispatch-error
1193
+ // bubble: the turn is being resumed, not failed.
1194
+ if (this.status === "resuming" || this.reconnecting) return;
1135
1195
  // Check if this is an abort error (user canceled, navigated away, etc.)
1136
1196
  // In these cases, don't show fallback - the request was intentionally interrupted
1137
1197
  const isAbortError =
@@ -1191,8 +1251,10 @@ export class AgentWidgetSession {
1191
1251
  if (this.streaming) return;
1192
1252
 
1193
1253
  this.abortController?.abort();
1254
+ this.teardownReconnect();
1194
1255
 
1195
1256
  const assistantMessageId = generateAssistantMessageId();
1257
+ this.activeAssistantMessageId = null;
1196
1258
 
1197
1259
  this.setStreaming(true);
1198
1260
 
@@ -1211,6 +1273,7 @@ export class AgentWidgetSession {
1211
1273
  this.handleEvent
1212
1274
  );
1213
1275
  } catch (error) {
1276
+ if (this.status === "resuming" || this.reconnecting) return;
1214
1277
  // Check if this is an abort error (a prior in-flight stream was canceled,
1215
1278
  // the user navigated away, etc.). In these cases, don't show fallback or
1216
1279
  // fire onError - the request was intentionally interrupted.
@@ -1258,18 +1321,44 @@ export class AgentWidgetSession {
1258
1321
  */
1259
1322
  public async connectStream(
1260
1323
  stream: ReadableStream<Uint8Array>,
1261
- options?: { assistantMessageId?: string; allowReentry?: boolean }
1324
+ options?: {
1325
+ assistantMessageId?: string;
1326
+ allowReentry?: boolean;
1327
+ /**
1328
+ * Durable reconnect: keep the assistant message identified by
1329
+ * `assistantMessageId` OPEN so the replayed deltas keep filling it. The
1330
+ * default stale-finalize pass would otherwise seal the very bubble we're
1331
+ * resuming into.
1332
+ */
1333
+ preserveAssistantId?: boolean;
1334
+ /**
1335
+ * Durable reconnect: text already shown in the resumed bubble, so the
1336
+ * fresh stream's accumulator continues from it (the replay carries only
1337
+ * post-cursor deltas, not the full text).
1338
+ */
1339
+ seedContent?: string;
1340
+ }
1262
1341
  ): Promise<void> {
1263
1342
  if (this.streaming && !options?.allowReentry) return;
1264
1343
  if (!options?.allowReentry) {
1265
1344
  this.abortController?.abort();
1266
1345
  }
1267
1346
 
1347
+ // Durable reconnect keeps filling the same bubble: track its id so the
1348
+ // cursor handler attaches to it.
1349
+ if (options?.preserveAssistantId && options.assistantMessageId) {
1350
+ this.activeAssistantMessageId = options.assistantMessageId;
1351
+ }
1352
+
1268
1353
  // Finalize any stale streaming messages from the previous stream
1269
- // (e.g., tool messages interrupted by approval pause)
1354
+ // (e.g., tool messages interrupted by approval pause), except the bubble
1355
+ // an active reconnect is resuming into.
1356
+ const preserveId = options?.preserveAssistantId
1357
+ ? options.assistantMessageId
1358
+ : undefined;
1270
1359
  let hasStale = false;
1271
1360
  for (const msg of this.messages) {
1272
- if (msg.streaming) {
1361
+ if (msg.streaming && msg.id !== preserveId) {
1273
1362
  msg.streaming = false;
1274
1363
  hasStale = true;
1275
1364
  }
@@ -1284,9 +1373,14 @@ export class AgentWidgetSession {
1284
1373
  await this.client.processStream(
1285
1374
  stream,
1286
1375
  this.handleEvent,
1287
- options?.assistantMessageId
1376
+ options?.assistantMessageId,
1377
+ options?.seedContent
1288
1378
  );
1289
1379
  } catch (error) {
1380
+ // During a durable reconnect a thrown resume stream is just another drop:
1381
+ // the reconnect loop owns the retry/backoff. Don't paint an error or tear
1382
+ // down, stay in `resuming`.
1383
+ if (this.status === "resuming" || this.reconnecting) return;
1290
1384
  this.setStatus("error");
1291
1385
  // Mirror the idle/error handlers: a failed resume stream must not tear
1292
1386
  // down streaming/abortController while another WebMCP resolve is still
@@ -2446,6 +2540,9 @@ export class AgentWidgetSession {
2446
2540
  public cancel() {
2447
2541
  this.abortController?.abort();
2448
2542
  this.abortController = null;
2543
+ // A user stop also cancels any pending/in-flight durable reconnect and
2544
+ // clears the resume handle (the abort above already killed its fetch).
2545
+ this.teardownReconnect();
2449
2546
  // Tear down every in-flight WebMCP resolve (each owns its own controller,
2450
2547
  // independent of the shared one above). Clear the inflight set so retries
2451
2548
  // are possible if the user re-issues the same step_await context.
@@ -2464,6 +2561,7 @@ export class AgentWidgetSession {
2464
2561
  this.stopSpeaking();
2465
2562
  this.abortController?.abort();
2466
2563
  this.abortController = null;
2564
+ this.teardownReconnect();
2467
2565
  // Tear down every in-flight WebMCP resolve too: their messages are about
2468
2566
  // to be wiped, and a microtask-deferred resolve must not survive the clear.
2469
2567
  this.abortWebMcpResolves();
@@ -2603,6 +2701,9 @@ export class AgentWidgetSession {
2603
2701
  public hydrateMessages(messages: AgentWidgetMessage[]) {
2604
2702
  this.abortController?.abort();
2605
2703
  this.abortController = null;
2704
+ // Hydration replaces the conversation: also cancel any pending reconnect and
2705
+ // clear the resume handle (a boot resume re-arms via resumeFromHandle after).
2706
+ this.teardownReconnect();
2606
2707
  // Hydration replaces the conversation: abort and forget every in-flight
2607
2708
  // WebMCP resolve; their messages are about to be replaced.
2608
2709
  this.abortWebMcpResolves();
@@ -2638,6 +2739,18 @@ export class AgentWidgetSession {
2638
2739
  if (event.type === "message") {
2639
2740
  this.upsertMessage(event.message);
2640
2741
 
2742
+ // Track the open assistant text bubble's REAL streamed id so a durable
2743
+ // reconnect keeps filling the same message. The proxy path auto-generates
2744
+ // this id (it isn't the session's pre-generated one), so we must read it
2745
+ // off the actual message events, not assume it.
2746
+ if (
2747
+ event.message.role === "assistant" &&
2748
+ !event.message.variant &&
2749
+ event.message.streaming
2750
+ ) {
2751
+ this.activeAssistantMessageId = event.message.id;
2752
+ }
2753
+
2641
2754
  // Local-tool auto-resolve: when a step_await emits a tool-variant
2642
2755
  // message for a `webmcp:*` tool, or the built-in fire-and-forget
2643
2756
  // `suggest_replies`: resolve it and post the result to /resume.
@@ -2694,11 +2807,27 @@ export class AgentWidgetSession {
2694
2807
  this.agentExecution.currentIteration = event.message.agentMetadata.iteration;
2695
2808
  }
2696
2809
  }
2810
+ } else if (event.type === "cursor") {
2811
+ // Durable-reconnect cursor. Track the highest SSE `id:` seq as the resume
2812
+ // cursor and (lazily) form the resume handle once the executionId is
2813
+ // known. Only durable, resumable executions emit these, so this is the
2814
+ // natural gate: no `id:` lines → no handle → reconnect never arms.
2815
+ this.trackCursor(event.id);
2697
2816
  } else if (event.type === "status") {
2817
+ // A plain `idle` (no `terminal`) on the durable lane, while the run is
2818
+ // genuinely mid-turn, is a dropped connection, not a finish and not an
2819
+ // intentional `step_await` pause. Reconnect instead of finalizing.
2820
+ if (event.status === "idle" && !event.terminal && this.isDurableDrop()) {
2821
+ this.beginReconnect();
2822
+ return;
2823
+ }
2698
2824
  this.setStatus(event.status);
2699
2825
  if (event.status === "connecting") {
2700
2826
  this.setStreaming(true);
2701
2827
  } else if (event.status === "idle" || event.status === "error") {
2828
+ // Graceful terminal or terminal error: the durable turn is over, so
2829
+ // drop the resume handle (no reconnect should arm after this).
2830
+ this.clearResumable();
2702
2831
  // Keep the typing indicator up while a WebMCP resolve is still in
2703
2832
  // flight: in a chained turn the intermediate resume stream ends with an
2704
2833
  // idle status, but the successor tool is still executing. The resolve's
@@ -2733,6 +2862,8 @@ export class AgentWidgetSession {
2733
2862
  }
2734
2863
  } else if (event.type === "error") {
2735
2864
  this.setStatus("error");
2865
+ // Terminal error: drop the resume handle so no reconnect arms.
2866
+ this.clearResumable();
2736
2867
  // Mirror the idle/status handler: don't tear down streaming while a
2737
2868
  // WebMCP resolve is still confirming/executing on another stream: an
2738
2869
  // error on one chained resume stream must not hide the typing indicator
@@ -2756,6 +2887,278 @@ export class AgentWidgetSession {
2756
2887
  }
2757
2888
  };
2758
2889
 
2890
+ // ── Durable-session reconnect ──────────────────────────────────
2891
+
2892
+ /**
2893
+ * Record the latest SSE `id:` cursor and (lazily) form/advance the resume
2894
+ * handle. Needs an executionId (lifted into `agentExecution` from the same
2895
+ * frame's metadata, handled before this trailing cursor event) and the active
2896
+ * assistant message id.
2897
+ */
2898
+ private trackCursor(id: string): void {
2899
+ const executionId = this.agentExecution?.executionId;
2900
+ if (!executionId || !this.activeAssistantMessageId) return;
2901
+ // Only track while the run is live. A graceful terminal sets the execution
2902
+ // to 'complete'/'error' and clears the handle BEFORE its own frame's
2903
+ // trailing cursor fires; without this guard that cursor would re-arm the
2904
+ // handle and the next plain `idle` would look like a spurious drop.
2905
+ if (this.agentExecution?.status !== "running") return;
2906
+ const isNew = this.resumable === null;
2907
+ this.resumable = {
2908
+ executionId,
2909
+ lastEventId: id,
2910
+ assistantMessageId: this.activeAssistantMessageId,
2911
+ status: "running",
2912
+ };
2913
+ // Fire immediately when the handle first appears (so the host can persist
2914
+ // the executionId promptly); throttle subsequent cursor advances.
2915
+ this.notifyExecutionState(isNew);
2916
+ }
2917
+
2918
+ /**
2919
+ * The drop gate: is this stream-end a dropped connection we should reconnect,
2920
+ * rather than a graceful finish or an intentional `step_await` pause? ALL must
2921
+ * hold (see plan §Design overview keystone 3).
2922
+ */
2923
+ private isDurableDrop(): boolean {
2924
+ return (
2925
+ this.resumable !== null &&
2926
+ typeof this.config.reconnectStream === "function" &&
2927
+ this.abortController?.signal.aborted !== true &&
2928
+ this.webMcpResolveControllers.size === 0 &&
2929
+ this.webMcpAwaitBatches.size === 0 &&
2930
+ !this.isAwaitPending()
2931
+ );
2932
+ }
2933
+
2934
+ /**
2935
+ * True when the run is parked awaiting user input (an unanswered
2936
+ * `ask_user_question` / local-tool await, or a pending approval). Those end
2937
+ * the stream with `idle` and no terminal too, but are NOT drops.
2938
+ */
2939
+ private isAwaitPending(): boolean {
2940
+ return this.messages.some((m) => {
2941
+ if (
2942
+ m.agentMetadata?.awaitingLocalTool === true &&
2943
+ m.agentMetadata?.askUserQuestionAnswered !== true
2944
+ ) {
2945
+ return true;
2946
+ }
2947
+ if (m.variant === "approval" && m.approval?.status === "pending") {
2948
+ return true;
2949
+ }
2950
+ return false;
2951
+ });
2952
+ }
2953
+
2954
+ /**
2955
+ * Enter the reconnect flow. Keeps the assistant bubble open and `streaming`
2956
+ * true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
2957
+ * calls (a second drop while reconnecting) are no-ops.
2958
+ */
2959
+ private beginReconnect(): void {
2960
+ if (this.reconnecting) return;
2961
+ if (!this.resumable || typeof this.config.reconnectStream !== "function") {
2962
+ return;
2963
+ }
2964
+ // Flip the visible state synchronously — the bubble stays open and a
2965
+ // `resuming` status is observable immediately (e.g. right after
2966
+ // `resumeFromHandle`). The heavy backoff loop is loaded lazily below.
2967
+ this.reconnecting = true;
2968
+ this.callbacks.onReconnect?.({ phase: "paused", handle: this.resumable });
2969
+ this.setStreaming(true);
2970
+ this.setStatus("resuming");
2971
+ void this.loadReconnectController().then((controller) => {
2972
+ // The run may have been torn down (new turn / cancel / hydrate) while the
2973
+ // module was loading; only start the loop if we're still reconnecting.
2974
+ if (this.reconnecting && this.resumable) controller.begin();
2975
+ });
2976
+ }
2977
+
2978
+ /**
2979
+ * Lazily import and instantiate the reconnect controller. Cached so repeated
2980
+ * reconnects reuse one controller (and one module load). Deliberately kept off
2981
+ * every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
2982
+ * that never reconnects never pulls the chunk.
2983
+ */
2984
+ private loadReconnectController(): Promise<ReconnectController> {
2985
+ if (this.reconnectController) {
2986
+ return Promise.resolve(this.reconnectController);
2987
+ }
2988
+ if (!this.reconnectControllerPromise) {
2989
+ this.reconnectControllerPromise = import("./session-reconnect").then(
2990
+ ({ createReconnectController }) => {
2991
+ const controller = createReconnectController(
2992
+ this.buildReconnectHost()
2993
+ );
2994
+ this.reconnectController = controller;
2995
+ return controller;
2996
+ }
2997
+ );
2998
+ }
2999
+ return this.reconnectControllerPromise;
3000
+ }
3001
+
3002
+ /** The narrow surface the lazily-loaded reconnect loop drives. */
3003
+ private buildReconnectHost(): ReconnectHost {
3004
+ const session = this;
3005
+ return {
3006
+ get config() {
3007
+ return session.config;
3008
+ },
3009
+ getResumable: () => session.resumable,
3010
+ clearResumable: () => session.clearResumable(),
3011
+ getStatus: () => session.status,
3012
+ setStatus: (status) => session.setStatus(status),
3013
+ setStreaming: (streaming) => session.setStreaming(streaming),
3014
+ setReconnecting: (value) => {
3015
+ session.reconnecting = value;
3016
+ },
3017
+ setAbortController: (controller) => {
3018
+ session.abortController = controller;
3019
+ },
3020
+ getMessages: () => session.messages,
3021
+ notifyMessagesChanged: () =>
3022
+ session.callbacks.onMessagesChanged([...session.messages]),
3023
+ resumeConnect: (body, assistantMessageId, seedContent) =>
3024
+ session.connectStream(body, {
3025
+ assistantMessageId,
3026
+ allowReentry: true,
3027
+ preserveAssistantId: true,
3028
+ seedContent,
3029
+ }),
3030
+ appendMessage: (message) => session.appendMessage(message),
3031
+ nextSequence: () => session.nextSequence(),
3032
+ emitReconnect: (event) => session.callbacks.onReconnect?.(event),
3033
+ buildErrorContent: (message) =>
3034
+ buildDispatchErrorContent(
3035
+ new Error(message),
3036
+ session.config.errorMessage
3037
+ ),
3038
+ onError: (error) => session.callbacks.onError?.(error),
3039
+ };
3040
+ }
3041
+
3042
+ /** Public manual retry (e.g. a "Reconnect" button). */
3043
+ public reconnectNow(): void {
3044
+ if (this.reconnecting) {
3045
+ // Already trying, so just short-circuit the current backoff. (No-op if the
3046
+ // controller is still loading; the first attempt hasn't slept yet.)
3047
+ this.reconnectController?.wake();
3048
+ return;
3049
+ }
3050
+ this.beginReconnect();
3051
+ }
3052
+
3053
+ /**
3054
+ * Resume a durable turn on boot (tab-reload path). Re-opens the trailing
3055
+ * assistant bubble (or mints one), restores the execution/handle, and
3056
+ * reconnects from the persisted cursor. No-op without `reconnectStream`.
3057
+ */
3058
+ public resumeFromHandle(resume: { executionId: string; after: string }): void {
3059
+ if (typeof this.config.reconnectStream !== "function") return;
3060
+ if (this.reconnecting) return;
3061
+
3062
+ let assistantId = this.reopenTrailingAssistant();
3063
+ if (!assistantId) {
3064
+ assistantId = generateAssistantMessageId();
3065
+ this.appendMessage({
3066
+ id: assistantId,
3067
+ role: "assistant",
3068
+ content: "",
3069
+ createdAt: new Date().toISOString(),
3070
+ streaming: true,
3071
+ sequence: this.nextSequence(),
3072
+ });
3073
+ }
3074
+ this.activeAssistantMessageId = assistantId;
3075
+ if (!this.agentExecution) {
3076
+ this.agentExecution = {
3077
+ executionId: resume.executionId,
3078
+ agentId: "",
3079
+ agentName: "",
3080
+ status: "running",
3081
+ currentIteration: 0,
3082
+ maxTurns: 0,
3083
+ };
3084
+ }
3085
+ this.resumable = {
3086
+ executionId: resume.executionId,
3087
+ lastEventId: resume.after,
3088
+ assistantMessageId: assistantId,
3089
+ status: "running",
3090
+ };
3091
+ this.beginReconnect();
3092
+ }
3093
+
3094
+ /**
3095
+ * Reopen the trailing assistant bubble (the persisted partial) so replayed
3096
+ * deltas append to it. Returns its id, or null if the last turn doesn't end
3097
+ * in a plain assistant message.
3098
+ */
3099
+ private reopenTrailingAssistant(): string | null {
3100
+ for (let i = this.messages.length - 1; i >= 0; i--) {
3101
+ const m = this.messages[i];
3102
+ if (m.role === "assistant" && !m.variant) {
3103
+ m.streaming = true;
3104
+ this.callbacks.onMessagesChanged([...this.messages]);
3105
+ return m.id;
3106
+ }
3107
+ if (m.role === "user") break;
3108
+ }
3109
+ return null;
3110
+ }
3111
+
3112
+ /**
3113
+ * Tear down any in-flight/pending reconnect and clear the resume handle. Runs
3114
+ * on every new turn / cancel / hydrate, so it must NOT pull the reconnect
3115
+ * chunk: it only delegates to the controller if one was already created.
3116
+ */
3117
+ private teardownReconnect(): void {
3118
+ this.reconnecting = false;
3119
+ this.reconnectController?.teardown();
3120
+ this.clearResumable();
3121
+ }
3122
+
3123
+ /** Drop the resume handle and notify the host (`onExecutionState(null)`). */
3124
+ private clearResumable(): void {
3125
+ if (this.executionStateTimer) {
3126
+ clearTimeout(this.executionStateTimer);
3127
+ this.executionStateTimer = null;
3128
+ }
3129
+ const had = this.resumable !== null;
3130
+ this.resumable = null;
3131
+ if (had) this.config.onExecutionState?.(null);
3132
+ }
3133
+
3134
+ /**
3135
+ * Surface the resume handle to the host for persistence. Immediate on
3136
+ * create/clear; trailing-edge throttled on cursor advances so it isn't called
3137
+ * per delta.
3138
+ */
3139
+ private notifyExecutionState(immediate: boolean): void {
3140
+ const cb = this.config.onExecutionState;
3141
+ if (!cb) return;
3142
+ if (immediate) {
3143
+ if (this.executionStateTimer) {
3144
+ clearTimeout(this.executionStateTimer);
3145
+ this.executionStateTimer = null;
3146
+ }
3147
+ cb(this.resumable);
3148
+ return;
3149
+ }
3150
+ if (this.executionStateTimer) return; // a trailing call is already queued
3151
+ this.executionStateTimer = setTimeout(() => {
3152
+ this.executionStateTimer = null;
3153
+ this.config.onExecutionState?.(this.resumable);
3154
+ }, 500);
3155
+ }
3156
+
3157
+ /** The current durable resume handle, if any (read-only). */
3158
+ public getResumableHandle(): ResumableHandle | null {
3159
+ return this.resumable;
3160
+ }
3161
+
2759
3162
  private setStatus(status: AgentWidgetSessionStatus) {
2760
3163
  if (this.status === status) return;
2761
3164
  this.status = status;
package/src/types.ts CHANGED
@@ -386,6 +386,28 @@ export type AgentExecutionState = {
386
386
  stopReason?: 'complete' | 'end_turn' | 'max_turns' | 'max_cost' | 'timeout' | 'error';
387
387
  };
388
388
 
389
+ /**
390
+ * The coordinates needed to resume a durable agent turn after the SSE
391
+ * connection drops (any resumable, server-persisted execution, e.g. Claude
392
+ * Managed agents or async/background runs). Held on the session while a
393
+ * resumable stream is in flight
394
+ * and surfaced to the host via {@link AgentWidgetConfig.onExecutionState} so it
395
+ * can persist `{ executionId, lastEventId }` next to its own `conversationId`
396
+ * for the tab-reload path.
397
+ *
398
+ * `conversationId` is intentionally absent: it is host-owned (it lives in the
399
+ * host's `reconnectStream`/`customFetch` closure; the widget never sees it).
400
+ */
401
+ export type ResumableHandle = {
402
+ /** The durable turn to reconnect to (from `agentMetadata.executionId`). */
403
+ executionId: string;
404
+ /** Highest SSE `id:` seq applied: the `?after=` reconnect cursor. */
405
+ lastEventId: string;
406
+ /** The open assistant message being streamed into, kept open on resume. */
407
+ assistantMessageId: string;
408
+ status: 'running';
409
+ };
410
+
389
411
  /**
390
412
  * Metadata attached to messages created during agent execution.
391
413
  */
@@ -689,6 +711,12 @@ export type AgentWidgetControllerEventMap = {
689
711
  "eventStream:closed": { timestamp: number };
690
712
  "approval:requested": { approval: AgentWidgetApproval; message: AgentWidgetMessage };
691
713
  "approval:resolved": { approval: AgentWidgetApproval; decision: string };
714
+ /** A durable stream dropped; the widget is about to reconnect. */
715
+ "stream:paused": { executionId: string; after: string };
716
+ /** A reconnect attempt is in flight (`attempt` is 1-based). */
717
+ "stream:resuming": { executionId: string; after: string; attempt: number };
718
+ /** The durable turn resumed and reached its terminal after a reconnect. */
719
+ "stream:resumed": { executionId: string; after: string };
692
720
  };
693
721
 
694
722
  /**
@@ -1851,6 +1879,10 @@ export type AgentWidgetStatusIndicatorConfig = {
1851
1879
  connectingText?: string;
1852
1880
  connectedText?: string;
1853
1881
  errorText?: string;
1882
+ /** Status text while a dropped durable stream is awaiting reconnect. */
1883
+ pausedText?: string;
1884
+ /** Status text while a reconnect attempt is in flight. */
1885
+ resumingText?: string;
1854
1886
  };
1855
1887
 
1856
1888
  export type AgentWidgetVoiceRecognitionConfig = {
@@ -4160,6 +4192,62 @@ export type AgentWidgetConfig = {
4160
4192
  * ```
4161
4193
  */
4162
4194
  customFetch?: AgentWidgetCustomFetch;
4195
+ /**
4196
+ * Durable-session reconnect transport (host-owned, symmetric to
4197
+ * {@link customFetch}). When a durable agent stream drops mid-turn (any
4198
+ * resumable, server-persisted execution, e.g. Claude Managed agents or
4199
+ * async/background runs), the widget calls this to fetch the read-only
4200
+ * reconnect stream and pipes its body through the normal event pipeline to
4201
+ * resume from where it left off.
4202
+ *
4203
+ * The host closure owns `agentId` / `conversationId` / base URL / auth and
4204
+ * builds the events request, e.g.:
4205
+ *
4206
+ * ```typescript
4207
+ * reconnectStream: ({ executionId, after, signal }) =>
4208
+ * fetch(
4209
+ * `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` +
4210
+ * `/events?conversationId=${conversationId}&after=${after}`,
4211
+ * { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": ver }, signal }
4212
+ * )
4213
+ * ```
4214
+ *
4215
+ * Resolve with a `text/event-stream` `Response`. Throw or resolve non-ok to
4216
+ * signal "this attempt failed" (the widget backs off and retries, then gives
4217
+ * up after the bounded attempts). Reconnect only ever arms on the durable
4218
+ * lane (streams carrying SSE `id:` lines); without this hook a drop finalizes
4219
+ * exactly as before.
4220
+ */
4221
+ reconnectStream?: (ctx: {
4222
+ executionId: string;
4223
+ after: string;
4224
+ signal: AbortSignal;
4225
+ }) => Promise<Response>;
4226
+ /**
4227
+ * Tuning for the auto-reconnect backoff. Defaults to ~5 attempts over ~30s
4228
+ * (`backoffMs: [1000, 2000, 4000, 8000, 8000]`).
4229
+ */
4230
+ reconnect?: {
4231
+ /** Max reconnect attempts before giving up and finalizing. @default backoffMs.length */
4232
+ maxAttempts?: number;
4233
+ /** Per-attempt delay (ms) before each retry. @default [1000, 2000, 4000, 8000, 8000] */
4234
+ backoffMs?: number[];
4235
+ };
4236
+ /**
4237
+ * Called whenever the durable resume handle changes: created when a durable
4238
+ * turn starts streaming, updated as the cursor advances (throttled), and
4239
+ * `null` when the turn finishes, errors, or is torn down. Persist
4240
+ * `{ executionId, lastEventId }` next to your `conversationId` and pass it
4241
+ * back via {@link resume} on the next mount to survive a tab reload.
4242
+ */
4243
+ onExecutionState?: (handle: ResumableHandle | null) => void;
4244
+ /**
4245
+ * Resume a durable turn on boot (tab-reload path). When present alongside
4246
+ * {@link reconnectStream}, the widget enters `resuming` immediately on mount
4247
+ * and reconnects from `after`, replaying everything past the cursor into the
4248
+ * restored conversation.
4249
+ */
4250
+ resume?: { executionId: string; after: string };
4163
4251
  /**
4164
4252
  * Custom SSE event parser for non-standard streaming response formats.
4165
4253
  *
@@ -4736,8 +4824,26 @@ export type PersonaArtifactManualUpsert =
4736
4824
 
4737
4825
  export type AgentWidgetEvent =
4738
4826
  | { type: "message"; message: AgentWidgetMessage }
4739
- | { type: "status"; status: "connecting" | "connected" | "error" | "idle" }
4827
+ | {
4828
+ type: "status";
4829
+ status: "connecting" | "connected" | "error" | "idle";
4830
+ /**
4831
+ * Set on the `idle` emitted from a graceful execution terminal
4832
+ * (`execution_complete`). Distinguishes a real finish from the plain
4833
+ * `idle` the dispatch wrappers emit in their `finally` on a dropped
4834
+ * connection. The durable-reconnect drop detection keys off this.
4835
+ */
4836
+ terminal?: boolean;
4837
+ }
4740
4838
  | { type: "error"; error: Error }
4839
+ /**
4840
+ * Durable-session reconnect cursor. Carries the SSE `id:` line (the durable
4841
+ * row seq) of a fully-parsed frame so the session can track `lastEventId`
4842
+ * and resume from `?after=<id>` after a drop. Only emitted on the durable
4843
+ * lane (any resumable execution that stamps `id:` lines, e.g. Claude Managed
4844
+ * agents or async/background runs).
4845
+ */
4846
+ | { type: "cursor"; id: string }
4741
4847
  | {
4742
4848
  type: "artifact_start";
4743
4849
  id: string;