@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
@@ -1053,12 +1053,15 @@ type RuntypeExecutionStreamEvent = ({
1053
1053
  seq: number;
1054
1054
  type: "approval_complete";
1055
1055
  }) | ({
1056
+ awaitReason?: string;
1056
1057
  awaitedAt?: string;
1058
+ crawlId?: string;
1057
1059
  executionId: string;
1058
1060
  origin?: "webmcp" | "sdk";
1059
1061
  pageOrigin?: string;
1060
1062
  parameters?: Record<string, unknown>;
1061
1063
  seq: number;
1064
+ stepId?: string;
1062
1065
  toolCallId?: string;
1063
1066
  toolId?: string;
1064
1067
  toolName?: string;
@@ -1147,13 +1150,16 @@ type RuntypeFlowSSEEvent = {
1147
1150
  type: "flow_error";
1148
1151
  upgradeUrl?: string;
1149
1152
  }) | ({
1153
+ awaitReason?: string;
1150
1154
  awaitedAt: string;
1155
+ crawlId?: string;
1151
1156
  executionId?: string;
1152
1157
  flowId: string;
1153
1158
  origin?: "webmcp" | "sdk";
1154
1159
  pageOrigin?: string;
1155
1160
  parameters?: Record<string, unknown>;
1156
1161
  seq?: number;
1162
+ stepId?: string;
1157
1163
  toolCallId?: string;
1158
1164
  toolId?: string;
1159
1165
  toolName?: string;
@@ -1693,6 +1699,27 @@ type AgentWidgetWebMcpConfig = {
1693
1699
  */
1694
1700
  onConfirm?: WebMcpConfirmHandler;
1695
1701
  };
1702
+ /**
1703
+ * The coordinates needed to resume a durable agent turn after the SSE
1704
+ * connection drops (any resumable, server-persisted execution, e.g. Claude
1705
+ * Managed agents or async/background runs). Held on the session while a
1706
+ * resumable stream is in flight
1707
+ * and surfaced to the host via {@link AgentWidgetConfig.onExecutionState} so it
1708
+ * can persist `{ executionId, lastEventId }` next to its own `conversationId`
1709
+ * for the tab-reload path.
1710
+ *
1711
+ * `conversationId` is intentionally absent: it is host-owned (it lives in the
1712
+ * host's `reconnectStream`/`customFetch` closure; the widget never sees it).
1713
+ */
1714
+ type ResumableHandle = {
1715
+ /** The durable turn to reconnect to (from `agentMetadata.executionId`). */
1716
+ executionId: string;
1717
+ /** Highest SSE `id:` seq applied: the `?after=` reconnect cursor. */
1718
+ lastEventId: string;
1719
+ /** The open assistant message being streamed into, kept open on resume. */
1720
+ assistantMessageId: string;
1721
+ status: 'running';
1722
+ };
1696
1723
  /**
1697
1724
  * Metadata attached to messages created during agent execution.
1698
1725
  */
@@ -3009,6 +3036,10 @@ type AgentWidgetStatusIndicatorConfig = {
3009
3036
  connectingText?: string;
3010
3037
  connectedText?: string;
3011
3038
  errorText?: string;
3039
+ /** Status text while a dropped durable stream is awaiting reconnect. */
3040
+ pausedText?: string;
3041
+ /** Status text while a reconnect attempt is in flight. */
3042
+ resumingText?: string;
3012
3043
  };
3013
3044
  type AgentWidgetVoiceRecognitionConfig = {
3014
3045
  enabled?: boolean;
@@ -5090,6 +5121,65 @@ type AgentWidgetConfig = {
5090
5121
  * ```
5091
5122
  */
5092
5123
  customFetch?: AgentWidgetCustomFetch;
5124
+ /**
5125
+ * Durable-session reconnect transport (host-owned, symmetric to
5126
+ * {@link customFetch}). When a durable agent stream drops mid-turn (any
5127
+ * resumable, server-persisted execution, e.g. Claude Managed agents or
5128
+ * async/background runs), the widget calls this to fetch the read-only
5129
+ * reconnect stream and pipes its body through the normal event pipeline to
5130
+ * resume from where it left off.
5131
+ *
5132
+ * The host closure owns `agentId` / `conversationId` / base URL / auth and
5133
+ * builds the events request, e.g.:
5134
+ *
5135
+ * ```typescript
5136
+ * reconnectStream: ({ executionId, after, signal }) =>
5137
+ * fetch(
5138
+ * `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` +
5139
+ * `/events?conversationId=${conversationId}&after=${after}`,
5140
+ * { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": ver }, signal }
5141
+ * )
5142
+ * ```
5143
+ *
5144
+ * Resolve with a `text/event-stream` `Response`. Throw or resolve non-ok to
5145
+ * signal "this attempt failed" (the widget backs off and retries, then gives
5146
+ * up after the bounded attempts). Reconnect only ever arms on the durable
5147
+ * lane (streams carrying SSE `id:` lines); without this hook a drop finalizes
5148
+ * exactly as before.
5149
+ */
5150
+ reconnectStream?: (ctx: {
5151
+ executionId: string;
5152
+ after: string;
5153
+ signal: AbortSignal;
5154
+ }) => Promise<Response>;
5155
+ /**
5156
+ * Tuning for the auto-reconnect backoff. Defaults to ~5 attempts over ~30s
5157
+ * (`backoffMs: [1000, 2000, 4000, 8000, 8000]`).
5158
+ */
5159
+ reconnect?: {
5160
+ /** Max reconnect attempts before giving up and finalizing. @default backoffMs.length */
5161
+ maxAttempts?: number;
5162
+ /** Per-attempt delay (ms) before each retry. @default [1000, 2000, 4000, 8000, 8000] */
5163
+ backoffMs?: number[];
5164
+ };
5165
+ /**
5166
+ * Called whenever the durable resume handle changes: created when a durable
5167
+ * turn starts streaming, updated as the cursor advances (throttled), and
5168
+ * `null` when the turn finishes, errors, or is torn down. Persist
5169
+ * `{ executionId, lastEventId }` next to your `conversationId` and pass it
5170
+ * back via {@link resume} on the next mount to survive a tab reload.
5171
+ */
5172
+ onExecutionState?: (handle: ResumableHandle | null) => void;
5173
+ /**
5174
+ * Resume a durable turn on boot (tab-reload path). When present alongside
5175
+ * {@link reconnectStream}, the widget enters `resuming` immediately on mount
5176
+ * and reconnects from `after`, replaying everything past the cursor into the
5177
+ * restored conversation.
5178
+ */
5179
+ resume?: {
5180
+ executionId: string;
5181
+ after: string;
5182
+ };
5093
5183
  /**
5094
5184
  * Custom SSE event parser for non-standard streaming response formats.
5095
5185
  *
@@ -1053,12 +1053,15 @@ type RuntypeExecutionStreamEvent = ({
1053
1053
  seq: number;
1054
1054
  type: "approval_complete";
1055
1055
  }) | ({
1056
+ awaitReason?: string;
1056
1057
  awaitedAt?: string;
1058
+ crawlId?: string;
1057
1059
  executionId: string;
1058
1060
  origin?: "webmcp" | "sdk";
1059
1061
  pageOrigin?: string;
1060
1062
  parameters?: Record<string, unknown>;
1061
1063
  seq: number;
1064
+ stepId?: string;
1062
1065
  toolCallId?: string;
1063
1066
  toolId?: string;
1064
1067
  toolName?: string;
@@ -1147,13 +1150,16 @@ type RuntypeFlowSSEEvent = {
1147
1150
  type: "flow_error";
1148
1151
  upgradeUrl?: string;
1149
1152
  }) | ({
1153
+ awaitReason?: string;
1150
1154
  awaitedAt: string;
1155
+ crawlId?: string;
1151
1156
  executionId?: string;
1152
1157
  flowId: string;
1153
1158
  origin?: "webmcp" | "sdk";
1154
1159
  pageOrigin?: string;
1155
1160
  parameters?: Record<string, unknown>;
1156
1161
  seq?: number;
1162
+ stepId?: string;
1157
1163
  toolCallId?: string;
1158
1164
  toolId?: string;
1159
1165
  toolName?: string;
@@ -1693,6 +1699,27 @@ type AgentWidgetWebMcpConfig = {
1693
1699
  */
1694
1700
  onConfirm?: WebMcpConfirmHandler;
1695
1701
  };
1702
+ /**
1703
+ * The coordinates needed to resume a durable agent turn after the SSE
1704
+ * connection drops (any resumable, server-persisted execution, e.g. Claude
1705
+ * Managed agents or async/background runs). Held on the session while a
1706
+ * resumable stream is in flight
1707
+ * and surfaced to the host via {@link AgentWidgetConfig.onExecutionState} so it
1708
+ * can persist `{ executionId, lastEventId }` next to its own `conversationId`
1709
+ * for the tab-reload path.
1710
+ *
1711
+ * `conversationId` is intentionally absent: it is host-owned (it lives in the
1712
+ * host's `reconnectStream`/`customFetch` closure; the widget never sees it).
1713
+ */
1714
+ type ResumableHandle = {
1715
+ /** The durable turn to reconnect to (from `agentMetadata.executionId`). */
1716
+ executionId: string;
1717
+ /** Highest SSE `id:` seq applied: the `?after=` reconnect cursor. */
1718
+ lastEventId: string;
1719
+ /** The open assistant message being streamed into, kept open on resume. */
1720
+ assistantMessageId: string;
1721
+ status: 'running';
1722
+ };
1696
1723
  /**
1697
1724
  * Metadata attached to messages created during agent execution.
1698
1725
  */
@@ -3009,6 +3036,10 @@ type AgentWidgetStatusIndicatorConfig = {
3009
3036
  connectingText?: string;
3010
3037
  connectedText?: string;
3011
3038
  errorText?: string;
3039
+ /** Status text while a dropped durable stream is awaiting reconnect. */
3040
+ pausedText?: string;
3041
+ /** Status text while a reconnect attempt is in flight. */
3042
+ resumingText?: string;
3012
3043
  };
3013
3044
  type AgentWidgetVoiceRecognitionConfig = {
3014
3045
  enabled?: boolean;
@@ -5090,6 +5121,65 @@ type AgentWidgetConfig = {
5090
5121
  * ```
5091
5122
  */
5092
5123
  customFetch?: AgentWidgetCustomFetch;
5124
+ /**
5125
+ * Durable-session reconnect transport (host-owned, symmetric to
5126
+ * {@link customFetch}). When a durable agent stream drops mid-turn (any
5127
+ * resumable, server-persisted execution, e.g. Claude Managed agents or
5128
+ * async/background runs), the widget calls this to fetch the read-only
5129
+ * reconnect stream and pipes its body through the normal event pipeline to
5130
+ * resume from where it left off.
5131
+ *
5132
+ * The host closure owns `agentId` / `conversationId` / base URL / auth and
5133
+ * builds the events request, e.g.:
5134
+ *
5135
+ * ```typescript
5136
+ * reconnectStream: ({ executionId, after, signal }) =>
5137
+ * fetch(
5138
+ * `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` +
5139
+ * `/events?conversationId=${conversationId}&after=${after}`,
5140
+ * { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": ver }, signal }
5141
+ * )
5142
+ * ```
5143
+ *
5144
+ * Resolve with a `text/event-stream` `Response`. Throw or resolve non-ok to
5145
+ * signal "this attempt failed" (the widget backs off and retries, then gives
5146
+ * up after the bounded attempts). Reconnect only ever arms on the durable
5147
+ * lane (streams carrying SSE `id:` lines); without this hook a drop finalizes
5148
+ * exactly as before.
5149
+ */
5150
+ reconnectStream?: (ctx: {
5151
+ executionId: string;
5152
+ after: string;
5153
+ signal: AbortSignal;
5154
+ }) => Promise<Response>;
5155
+ /**
5156
+ * Tuning for the auto-reconnect backoff. Defaults to ~5 attempts over ~30s
5157
+ * (`backoffMs: [1000, 2000, 4000, 8000, 8000]`).
5158
+ */
5159
+ reconnect?: {
5160
+ /** Max reconnect attempts before giving up and finalizing. @default backoffMs.length */
5161
+ maxAttempts?: number;
5162
+ /** Per-attempt delay (ms) before each retry. @default [1000, 2000, 4000, 8000, 8000] */
5163
+ backoffMs?: number[];
5164
+ };
5165
+ /**
5166
+ * Called whenever the durable resume handle changes: created when a durable
5167
+ * turn starts streaming, updated as the cursor advances (throttled), and
5168
+ * `null` when the turn finishes, errors, or is torn down. Persist
5169
+ * `{ executionId, lastEventId }` next to your `conversationId` and pass it
5170
+ * back via {@link resume} on the next mount to survive a tab reload.
5171
+ */
5172
+ onExecutionState?: (handle: ResumableHandle | null) => void;
5173
+ /**
5174
+ * Resume a durable turn on boot (tab-reload path). When present alongside
5175
+ * {@link reconnectStream}, the widget enters `resuming` immediately on mount
5176
+ * and reconnects from `after`, replaying everything past the cursor into the
5177
+ * restored conversation.
5178
+ */
5179
+ resume?: {
5180
+ executionId: string;
5181
+ after: string;
5182
+ };
5093
5183
  /**
5094
5184
  * Custom SSE event parser for non-standard streaming response formats.
5095
5185
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.5.0",
3
+ "version": "4.6.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",
@@ -124,7 +124,7 @@
124
124
  "build:markdown-parsers": "tsup --config tsup.markdown-parsers.config.ts",
125
125
  "build:plugin-kit": "tsup src/plugin-kit.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
126
126
  "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
127
- "build:theme-editor-preview": "tsup src/theme-editor-preview.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
127
+ "build:theme-editor-preview": "tsup src/theme-editor-preview.ts --format esm,cjs --minify --dts --out-dir dist",
128
128
  "build:testing": "tsup src/testing.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
129
129
  "build:smart-dom-reader": "tsup src/smart-dom-reader.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
130
130
  "build:voice-worklet-player": "tsup src/voice-worklet-player.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
package/src/client.ts CHANGED
@@ -954,11 +954,12 @@ export class AgentWidgetClient {
954
954
  public async processStream(
955
955
  body: ReadableStream<Uint8Array>,
956
956
  onEvent: SSEHandler,
957
- assistantMessageId?: string
957
+ assistantMessageId?: string,
958
+ seedContent?: string
958
959
  ): Promise<void> {
959
960
  onEvent({ type: "status", status: "connected" });
960
961
  try {
961
- await this.streamResponse(body, onEvent, assistantMessageId);
962
+ await this.streamResponse(body, onEvent, assistantMessageId, seedContent);
962
963
  } finally {
963
964
  onEvent({ type: "status", status: "idle" });
964
965
  }
@@ -1353,7 +1354,12 @@ export class AgentWidgetClient {
1353
1354
  private async streamResponse(
1354
1355
  body: ReadableStream<Uint8Array>,
1355
1356
  onEvent: SSEHandler,
1356
- assistantMessageId?: string
1357
+ assistantMessageId?: string,
1358
+ // Durable reconnect: seed the assistant accumulator with the text already
1359
+ // shown before the drop, so replayed post-cursor deltas APPEND to it
1360
+ // instead of a fresh stream clobbering it (the replay carries only
1361
+ // `seq > after`, i.e. the new deltas, not the full text).
1362
+ seedContent?: string
1357
1363
  ) {
1358
1364
  const reader = body.getReader();
1359
1365
  const decoder = new TextDecoder();
@@ -1490,10 +1496,14 @@ export class AgentWidgetClient {
1490
1496
  const ensureAssistantMessage = () => {
1491
1497
  if (assistantMessage) return assistantMessage;
1492
1498
  let id: string;
1499
+ let initialContent = "";
1493
1500
  const segment = currentTextBlockId;
1494
1501
  if (!assistantIdConsumed && baseAssistantId) {
1495
1502
  id = baseAssistantId;
1496
1503
  assistantIdConsumed = true;
1504
+ // First (and only) time we reuse the caller-supplied id: this is the
1505
+ // bubble a durable reconnect resumes into, so continue its text.
1506
+ initialContent = seedContent ?? "";
1497
1507
  } else if (baseAssistantId && segment) {
1498
1508
  id = `${baseAssistantId}_${segment}`;
1499
1509
  } else {
@@ -1502,7 +1512,7 @@ export class AgentWidgetClient {
1502
1512
  assistantMessage = {
1503
1513
  id,
1504
1514
  role: "assistant",
1505
- content: "",
1515
+ content: initialContent,
1506
1516
  createdAt: new Date().toISOString(),
1507
1517
  streaming: true,
1508
1518
  sequence: nextSequence()
@@ -2877,7 +2887,11 @@ export class AgentWidgetClient {
2877
2887
  pendingFlowRaw = "";
2878
2888
  lastSealedFlowBubble = null;
2879
2889
 
2880
- onEvent({ type: "status", status: "idle" });
2890
+ // `terminal: true` marks this as a graceful finish (not a drop). The
2891
+ // session uses it to distinguish the real end-of-turn from the plain
2892
+ // `idle` the dispatch wrappers emit in their `finally` when a durable
2893
+ // connection drops mid-stream (durable-reconnect drop detection).
2894
+ onEvent({ type: "status", status: "idle", terminal: true });
2881
2895
  } else if (payloadType === "execution_error") {
2882
2896
  // Terminal failure. The non-terminal `error` is handled
2883
2897
  // separately (recoverable → warn).
@@ -3163,20 +3177,43 @@ export class AgentWidgetClient {
3163
3177
  const lines = event.split("\n");
3164
3178
  let eventType = "message";
3165
3179
  let data = "";
3180
+ // Durable-reconnect cursor: the SSE `id:` line (the durable row seq).
3181
+ // Only durable, resumable agent executions stamp these (e.g. Claude
3182
+ // Managed agents, or any async/background run the backend persists and
3183
+ // can replay); other streams carry no cursor. We emit a `cursor`
3184
+ // event AFTER the frame is fully parsed and dispatched, so the session's
3185
+ // `lastEventId` only advances past frames it has actually applied, so the
3186
+ // happy path has no dupes against the server's `seq > after` replay.
3187
+ let frameId: string | null = null;
3166
3188
 
3167
3189
  for (const line of lines) {
3168
3190
  if (line.startsWith("event:")) {
3169
3191
  eventType = line.replace("event:", "").trim();
3170
3192
  } else if (line.startsWith("data:")) {
3171
3193
  data += line.replace("data:", "").trim();
3194
+ } else if (line.startsWith("id:")) {
3195
+ frameId = line.slice(3).trim();
3172
3196
  }
3173
3197
  }
3174
3198
 
3175
- if (!data) continue;
3199
+ const advanceCursor = () => {
3200
+ if (frameId !== null && frameId !== "") {
3201
+ onEvent({ type: "cursor", id: frameId });
3202
+ }
3203
+ };
3204
+
3205
+ // A frame with an `id:` but no `data:` (e.g. a bare keepalive line) is
3206
+ // still a received durable row, so advance the cursor past it.
3207
+ if (!data) {
3208
+ advanceCursor();
3209
+ continue;
3210
+ }
3176
3211
  let payload: any;
3177
3212
  try {
3178
3213
  payload = JSON.parse(data);
3179
3214
  } catch (error) {
3215
+ // Parse failure: the frame was NOT applied. Do NOT advance the cursor
3216
+ // so a reconnect re-fetches this row.
3180
3217
  onEvent({
3181
3218
  type: "error",
3182
3219
  error:
@@ -3209,7 +3246,10 @@ export class AgentWidgetClient {
3209
3246
  if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
3210
3247
  assistantMessage = assistantMessageRef.current;
3211
3248
  }
3212
- if (handled) continue; // Skip default handling if custom handler processed it
3249
+ if (handled) {
3250
+ advanceCursor();
3251
+ continue; // Skip default handling if custom handler processed it
3252
+ }
3213
3253
  }
3214
3254
 
3215
3255
  // The wire is the wire vocabulary; the handler consumes it
@@ -3217,6 +3257,7 @@ export class AgentWidgetClient {
3217
3257
  // drains straight through.
3218
3258
  seqReadyQueue.push({ payloadType, payload });
3219
3259
  drainReadyQueue();
3260
+ advanceCursor();
3220
3261
  }
3221
3262
  }
3222
3263
 
@@ -311,12 +311,15 @@ export type RuntypeExecutionStreamEvent = ({
311
311
  seq: number;
312
312
  type: "approval_complete";
313
313
  }) | ({
314
+ awaitReason?: string;
314
315
  awaitedAt?: string;
316
+ crawlId?: string;
315
317
  executionId: string;
316
318
  origin?: "webmcp" | "sdk";
317
319
  pageOrigin?: string;
318
320
  parameters?: Record<string, unknown>;
319
321
  seq: number;
322
+ stepId?: string;
320
323
  toolCallId?: string;
321
324
  toolId?: string;
322
325
  toolName?: string;
@@ -406,13 +409,16 @@ export type RuntypeFlowSSEEvent = {
406
409
  type: "flow_error";
407
410
  upgradeUrl?: string;
408
411
  }) | ({
412
+ awaitReason?: string;
409
413
  awaitedAt: string;
414
+ crawlId?: string;
410
415
  executionId?: string;
411
416
  flowId: string;
412
417
  origin?: "webmcp" | "sdk";
413
418
  pageOrigin?: string;
414
419
  parameters?: Record<string, unknown>;
415
420
  seq?: number;
421
+ stepId?: string;
416
422
  toolCallId?: string;
417
423
  toolId?: string;
418
424
  toolName?: string;
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Wake-listener behavior for durable reconnect (`session-reconnect.ts`). These
4
+ // need a DOM so the `visibilitychange` / `online` listeners actually attach, so
5
+ // this file runs in jsdom (the sibling `reconnect.test.ts` is node-env, where
6
+ // the `typeof document/window` guards skip the listeners entirely).
7
+ //
8
+ // The crux: `online` must short-circuit the backoff sleep even when the tab is
9
+ // backgrounded, while `visibilitychange` must only wake on the show transition.
10
+
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
12
+ import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
13
+ import { AgentWidgetMessage } from "./types";
14
+
15
+ const enc = new TextEncoder();
16
+
17
+ function sseStream(frames: string[]): ReadableStream<Uint8Array> {
18
+ return new ReadableStream({
19
+ start(controller) {
20
+ for (const f of frames) controller.enqueue(enc.encode(f));
21
+ controller.close();
22
+ },
23
+ });
24
+ }
25
+
26
+ function frame(
27
+ id: number | null,
28
+ type: string,
29
+ data: Record<string, unknown> = {}
30
+ ): string {
31
+ const head = id !== null ? `id: ${id}\n` : "";
32
+ return `${head}event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
33
+ }
34
+
35
+ async function waitFor(pred: () => boolean, timeoutMs = 1500): Promise<void> {
36
+ const start = Date.now();
37
+ // eslint-disable-next-line no-constant-condition
38
+ while (true) {
39
+ if (pred()) return;
40
+ if (Date.now() - start > timeoutMs) throw new Error("waitFor: timed out");
41
+ await new Promise((r) => setTimeout(r, 5));
42
+ }
43
+ }
44
+
45
+ function setVisibility(state: "visible" | "hidden"): void {
46
+ Object.defineProperty(document, "visibilityState", {
47
+ configurable: true,
48
+ get: () => state,
49
+ });
50
+ }
51
+
52
+ describe("AgentWidgetSession - reconnect wake listeners", () => {
53
+ let messages: AgentWidgetMessage[];
54
+ let status: AgentWidgetSessionStatus;
55
+ let reconnectPhases: string[];
56
+
57
+ const baseCallbacks = () => ({
58
+ onMessagesChanged: (m: AgentWidgetMessage[]) => {
59
+ messages = m;
60
+ },
61
+ onStatusChanged: (s: AgentWidgetSessionStatus) => {
62
+ status = s;
63
+ },
64
+ onStreamingChanged: () => {},
65
+ onError: () => {},
66
+ onReconnect: (ev: { phase: string }) => {
67
+ reconnectPhases.push(ev.phase);
68
+ },
69
+ });
70
+
71
+ const assistantText = () =>
72
+ messages.find((m) => m.role === "assistant" && !m.variant)?.content ?? "";
73
+
74
+ // A session that drops mid-stream, fails its first reconnect attempt (forcing
75
+ // a deliberately long backoff sleep), then succeeds on the second. A prompt
76
+ // 2nd attempt can therefore only mean a wake fired — never the 10s timer.
77
+ function dropThenResumeSession() {
78
+ const initial = sseStream([
79
+ frame(1, "text_delta", {
80
+ id: "text_0",
81
+ delta: "Hello",
82
+ executionId: "exec_1",
83
+ }),
84
+ ]);
85
+ const resume = sseStream([
86
+ frame(2, "text_delta", {
87
+ id: "text_0",
88
+ delta: " world",
89
+ executionId: "exec_1",
90
+ }),
91
+ frame(3, "execution_complete", { success: true, kind: "agent" }),
92
+ ]);
93
+ let calls = 0;
94
+ const session = new AgentWidgetSession(
95
+ {
96
+ apiUrl: "http://x",
97
+ customFetch: async () => ({ ok: true, body: initial }) as any,
98
+ reconnectStream: async () => {
99
+ calls += 1;
100
+ if (calls === 1) return { ok: false } as any;
101
+ return { ok: true, body: resume } as any;
102
+ },
103
+ reconnect: { backoffMs: [10000], maxAttempts: 5 },
104
+ },
105
+ baseCallbacks()
106
+ );
107
+ return {
108
+ session,
109
+ getCalls: () => calls,
110
+ };
111
+ }
112
+
113
+ beforeEach(() => {
114
+ messages = [];
115
+ status = "idle";
116
+ reconnectPhases = [];
117
+ setVisibility("visible");
118
+ });
119
+
120
+ afterEach(() => {
121
+ setVisibility("visible");
122
+ vi.restoreAllMocks();
123
+ });
124
+
125
+ it("online wakes the backoff immediately even when the tab is backgrounded", async () => {
126
+ const { session, getCalls } = dropThenResumeSession();
127
+
128
+ await session.sendMessage("hi");
129
+ // Wait until the first reconnect attempt has failed and we're sleeping in
130
+ // the (10s) backoff before the second attempt.
131
+ await waitFor(() => status === "paused");
132
+ expect(getCalls()).toBe(1);
133
+
134
+ // Background the tab, then regain connectivity. The visibility guard must
135
+ // NOT suppress the `online` wake.
136
+ setVisibility("hidden");
137
+ window.dispatchEvent(new Event("online"));
138
+
139
+ // Without the fix this times out (the loop would wait the full 10s).
140
+ await waitFor(() => reconnectPhases.includes("resumed"));
141
+ expect(getCalls()).toBe(2);
142
+ expect(assistantText()).toBe("Hello world");
143
+ expect(status).toBe("idle");
144
+ });
145
+
146
+ it("visibilitychange wakes the backoff when the tab becomes visible", async () => {
147
+ const { session, getCalls } = dropThenResumeSession();
148
+
149
+ await session.sendMessage("hi");
150
+ await waitFor(() => status === "paused");
151
+ expect(getCalls()).toBe(1);
152
+
153
+ // Tab brought back to the foreground: the show transition should wake.
154
+ setVisibility("visible");
155
+ document.dispatchEvent(new Event("visibilitychange"));
156
+
157
+ await waitFor(() => reconnectPhases.includes("resumed"));
158
+ expect(getCalls()).toBe(2);
159
+ expect(assistantText()).toBe("Hello world");
160
+ expect(status).toBe("idle");
161
+ });
162
+ });