@agent-native/core 0.84.51 → 0.84.52

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 (42) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/run-store.ts +33 -6
  5. package/corpus/core/src/client/AssistantChat.tsx +84 -23
  6. package/corpus/core/src/client/agent-chat-adapter.ts +19 -0
  7. package/corpus/core/src/client/blocks/library/DiffBlock.tsx +7 -5
  8. package/corpus/core/src/client/session-replay.ts +170 -47
  9. package/corpus/core/src/client/sse-event-processor.ts +75 -14
  10. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +5 -0
  11. package/corpus/templates/analytics/changelog/2026-07-02-agent-chat-can-keep-working-through-longer-data-queries-inst.md +6 -0
  12. package/corpus/templates/analytics/changelog/2026-07-02-session-replay-playback-recovers-from-failed-snapshot-uploads.md +6 -0
  13. package/corpus/templates/analytics/netlify.toml +3 -0
  14. package/corpus/templates/analytics/server/lib/session-replay.ts +9 -5
  15. package/corpus/templates/analytics/server/plugins/agent-chat.ts +3 -0
  16. package/corpus/templates/plan/changelog/2026-07-02-agent-chat-can-keep-working-through-longer-visual-plan-updat.md +6 -0
  17. package/corpus/templates/plan/netlify.toml +3 -0
  18. package/corpus/templates/plan/server/plugins/agent-chat.ts +4 -0
  19. package/dist/agent/run-store.d.ts.map +1 -1
  20. package/dist/agent/run-store.js +30 -6
  21. package/dist/agent/run-store.js.map +1 -1
  22. package/dist/client/AssistantChat.d.ts.map +1 -1
  23. package/dist/client/AssistantChat.js +64 -13
  24. package/dist/client/AssistantChat.js.map +1 -1
  25. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  26. package/dist/client/agent-chat-adapter.js +14 -0
  27. package/dist/client/agent-chat-adapter.js.map +1 -1
  28. package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
  29. package/dist/client/blocks/library/DiffBlock.js +6 -5
  30. package/dist/client/blocks/library/DiffBlock.js.map +1 -1
  31. package/dist/client/session-replay.d.ts.map +1 -1
  32. package/dist/client/session-replay.js +134 -39
  33. package/dist/client/session-replay.js.map +1 -1
  34. package/dist/client/sse-event-processor.d.ts +25 -0
  35. package/dist/client/sse-event-processor.d.ts.map +1 -1
  36. package/dist/client/sse-event-processor.js +50 -13
  37. package/dist/client/sse-event-processor.js.map +1 -1
  38. package/dist/collab/routes.d.ts +1 -1
  39. package/dist/notifications/routes.d.ts +1 -1
  40. package/dist/observability/routes.d.ts +5 -5
  41. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  42. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2044
31
- - template files: 4998
31
+ - template files: 5001
@@ -1,5 +1,13 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.84.52
4
+
5
+ ### Patch Changes
6
+
7
+ - ca38cc7: Carry action-preparation stall detection across reconnect reads for the same run so zero-byte tool input retries cannot keep background chats alive indefinitely.
8
+ - 899ebf1: Hide the diff "Show all lines" footer when annotation anchoring already renders every line.
9
+ - c1e18fb: Make session replay uploads retry failed batches without advancing sequence ids.
10
+
3
11
  ## 0.84.51
4
12
 
5
13
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.84.51",
3
+ "version": "0.84.52",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -763,9 +763,9 @@ export async function reconcileTerminalRunFromEvents(
763
763
  sql: `UPDATE agent_runs
764
764
  SET status = ?,
765
765
  completed_at = COALESCE(completed_at, ?, ${livenessBasisSql()}),
766
- error_code = CASE WHEN ? IS NOT NULL THEN ? ELSE error_code END,
767
- error_detail = CASE WHEN ? IS NOT NULL THEN ? ELSE error_detail END,
768
- terminal_reason = COALESCE(terminal_reason, ?)
766
+ error_code = ?,
767
+ error_detail = ?,
768
+ terminal_reason = ?
769
769
  WHERE id = ?
770
770
  AND (
771
771
  status = 'running'
@@ -775,8 +775,6 @@ export async function reconcileTerminalRunFromEvents(
775
775
  status,
776
776
  latest.eventAt,
777
777
  errorCode,
778
- errorCode,
779
- errorDetail,
780
778
  errorDetail,
781
779
  terminalReason,
782
780
  runId,
@@ -1248,7 +1246,7 @@ export async function listRunsForThread(
1248
1246
  await ensureRunTables();
1249
1247
  const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
1250
1248
  const client = getDbExec();
1251
- const { rows } = await client.execute({
1249
+ let { rows } = await client.execute({
1252
1250
  sql: `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, error_code, abort_reason, dispatch_mode, terminal_reason, diag_stage
1253
1251
  FROM agent_runs
1254
1252
  WHERE thread_id = ?
@@ -1256,6 +1254,35 @@ export async function listRunsForThread(
1256
1254
  LIMIT ?`,
1257
1255
  args: [threadId, limit],
1258
1256
  });
1257
+ let repairedTerminalRow = false;
1258
+ for (const r of rows) {
1259
+ const row = r as {
1260
+ id?: string;
1261
+ status?: string;
1262
+ error_code?: string | null;
1263
+ };
1264
+ const runId = row.id;
1265
+ if (!runId) continue;
1266
+ const canReconcileFromEvents =
1267
+ row.status === "running" ||
1268
+ (row.status === "errored" &&
1269
+ row.error_code === STALE_RUN_ERROR_EVENT.errorCode);
1270
+ if (!canReconcileFromEvents) continue;
1271
+ repairedTerminalRow =
1272
+ (await reconcileTerminalRunFromEvents(runId).catch(() => false)) ||
1273
+ repairedTerminalRow;
1274
+ }
1275
+ if (repairedTerminalRow) {
1276
+ const refreshed = await client.execute({
1277
+ sql: `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, error_code, abort_reason, dispatch_mode, terminal_reason, diag_stage
1278
+ FROM agent_runs
1279
+ WHERE thread_id = ?
1280
+ ORDER BY started_at DESC
1281
+ LIMIT ?`,
1282
+ args: [threadId, limit],
1283
+ });
1284
+ rows = refreshed.rows;
1285
+ }
1259
1286
  return rows.map((r) => {
1260
1287
  const row = r as {
1261
1288
  id: string;
@@ -153,6 +153,7 @@ import {
153
153
  import {
154
154
  AgentAutoContinueSignal,
155
155
  type ContentPart,
156
+ type PreparingActionState,
156
157
  readSSEStreamRaw,
157
158
  settleInterruptedToolCalls,
158
159
  } from "./sse-event-processor.js";
@@ -675,7 +676,7 @@ export function resolveAssistantChatRunningStatusLabel({
675
676
  }): string {
676
677
  if (runningActivityLabel) return runningActivityLabel;
677
678
  if (isAutoResuming) return "Resuming";
678
- if (isReconnecting && hasReconnectContent) return "Continuing";
679
+ if (isReconnecting && hasReconnectContent) return "Still working";
679
680
  return "Thinking";
680
681
  }
681
682
 
@@ -1516,7 +1517,7 @@ const AssistantChatInner = forwardRef<
1516
1517
  const textStreaming = showRunningInUI || externalStreaming;
1517
1518
  // A revealed activity label wins; otherwise keep recovery states calm and
1518
1519
  // product-facing. Reconnect is transport machinery, so normal replay reads as
1519
- // "Continuing" instead of exposing "Reconnecting" mid-chat.
1520
+ // ongoing work instead of exposing "Reconnecting" mid-chat.
1520
1521
  const runningStatusLabel = resolveAssistantChatRunningStatusLabel({
1521
1522
  runningActivityLabel,
1522
1523
  isAutoResuming,
@@ -1863,6 +1864,27 @@ const AssistantChatInner = forwardRef<
1863
1864
  const streamReconnect = async () => {
1864
1865
  let noProgressDuringReconnect = false;
1865
1866
  let latestContent: ContentPart[] = [];
1867
+ const preparingActionState: PreparingActionState = {};
1868
+ const sameRunStillActive = async (): Promise<
1869
+ "active" | "inactive" | "unknown"
1870
+ > => {
1871
+ try {
1872
+ const res = await fetch(
1873
+ `${apiUrl}/runs/active?threadId=${encodeURIComponent(threadId)}`,
1874
+ { signal: abortCtrl.signal },
1875
+ );
1876
+ if (!res.ok) return "unknown";
1877
+ const info = (await res.json()) as ActiveRunLookup;
1878
+ return info.active === true &&
1879
+ String(info.runId ?? "") === runId &&
1880
+ info.status === "running" &&
1881
+ !activeRunLooksStale(info)
1882
+ ? "active"
1883
+ : "inactive";
1884
+ } catch {
1885
+ return "unknown";
1886
+ }
1887
+ };
1866
1888
  const threadPollInterval =
1867
1889
  afterSeq > 0
1868
1890
  ? window.setInterval(() => {
@@ -1871,15 +1893,28 @@ const AssistantChatInner = forwardRef<
1871
1893
  }, 2000)
1872
1894
  : undefined;
1873
1895
  try {
1874
- const sseRes = await fetch(
1875
- `${apiUrl}/runs/${encodeURIComponent(runId)}/events?after=${afterSeq}`,
1876
- { signal: abortCtrl.signal },
1877
- );
1878
- if (sseRes.ok && sseRes.body) {
1879
- const content: ContentPart[] = [];
1880
- latestContent = content;
1881
- const toolCallCounter = { value: 0 };
1896
+ const content: ContentPart[] = [];
1897
+ latestContent = content;
1898
+ const toolCallCounter = { value: 0 };
1882
1899
 
1900
+ while (
1901
+ reconnectRunIdRef.current === runId &&
1902
+ !abortCtrl.signal.aborted
1903
+ ) {
1904
+ const reconnectAfterSeq = resolveReconnectAfterSeq(threadId, runId);
1905
+ reconnectTailOnlyRef.current = reconnectAfterSeq > 0;
1906
+ const sseRes = await fetch(
1907
+ `${apiUrl}/runs/${encodeURIComponent(runId)}/events?after=${reconnectAfterSeq}`,
1908
+ { signal: abortCtrl.signal },
1909
+ );
1910
+ if (!sseRes.ok || !sseRes.body) {
1911
+ const activeState = await sameRunStillActive();
1912
+ if (activeState !== "inactive") {
1913
+ await new Promise((resolve) => window.setTimeout(resolve, 250));
1914
+ continue;
1915
+ }
1916
+ break;
1917
+ }
1883
1918
  let rafPending = false;
1884
1919
  let latestSnapshot: ContentPart[] = [];
1885
1920
  const scheduleUpdate = (snapshot: ContentPart[]) => {
@@ -1892,21 +1927,47 @@ const AssistantChatInner = forwardRef<
1892
1927
  });
1893
1928
  };
1894
1929
 
1895
- await readSSEStreamRaw(
1896
- sseRes.body,
1897
- content,
1898
- toolCallCounter,
1899
- tabId,
1900
- scheduleUpdate,
1901
- (seq) => {
1902
- markReconnectProgress();
1903
- updateActiveRunSeq(seq);
1904
- },
1905
- );
1906
- if (afterSeq === 0) {
1907
- setReconnectContent([...content]);
1930
+ try {
1931
+ await readSSEStreamRaw(
1932
+ sseRes.body,
1933
+ content,
1934
+ toolCallCounter,
1935
+ tabId,
1936
+ scheduleUpdate,
1937
+ (seq) => {
1938
+ markReconnectProgress();
1939
+ updateActiveRunSeq(seq);
1940
+ },
1941
+ { preparingActionState },
1942
+ );
1943
+ if (reconnectAfterSeq === 0) {
1944
+ setReconnectContent([...content]);
1945
+ }
1946
+ break;
1947
+ } catch (err) {
1948
+ if (
1949
+ err instanceof AgentAutoContinueSignal &&
1950
+ err.reason === "stream_ended"
1951
+ ) {
1952
+ if (reconnectAfterSeq === 0) {
1953
+ setReconnectContent([...content]);
1954
+ }
1955
+ const activeState = await sameRunStillActive();
1956
+ if (activeState !== "inactive") {
1957
+ await new Promise((resolve) =>
1958
+ window.setTimeout(resolve, 250),
1959
+ );
1960
+ continue;
1961
+ }
1962
+ }
1963
+ throw err;
1908
1964
  }
1909
1965
  }
1966
+ if (reconnectTimedOut && abortCtrl.signal.aborted) {
1967
+ const timeoutError = new Error("Reconnect timed out");
1968
+ timeoutError.name = "AbortError";
1969
+ throw timeoutError;
1970
+ }
1910
1971
  } catch (err) {
1911
1972
  if (
1912
1973
  err instanceof AgentAutoContinueSignal &&
@@ -20,6 +20,7 @@ import {
20
20
  type AgentActivityTrailEntry,
21
21
  type AgentAutoContinueErrorInfo,
22
22
  type ContentPart,
23
+ type PreparingActionState,
23
24
  readSSEStream,
24
25
  settleInterruptedToolCalls,
25
26
  } from "./sse-event-processor.js";
@@ -1381,6 +1382,10 @@ export function createAgentChatAdapter(
1381
1382
  let runId: string | null = null;
1382
1383
  let lastSeq = -1;
1383
1384
  const seenRunSeqs = new Map<string, number>();
1385
+ const preparingActionStatesByRun = new Map<
1386
+ string,
1387
+ PreparingActionState
1388
+ >();
1384
1389
  let currentRunDispatchMode: string | null = null;
1385
1390
  let currentMessageText = normalizeMentions(
1386
1391
  recoveryMessageText.trim() || userMessageText,
@@ -1570,9 +1575,23 @@ export function createAgentChatAdapter(
1570
1575
  }
1571
1576
  };
1572
1577
 
1578
+ const preparingActionStateForRun = (
1579
+ id: string | null,
1580
+ ): PreparingActionState | undefined => {
1581
+ if (!id) return undefined;
1582
+ const existing = preparingActionStatesByRun.get(id);
1583
+ if (existing) return existing;
1584
+ const state: PreparingActionState = {};
1585
+ preparingActionStatesByRun.set(id, state);
1586
+ return state;
1587
+ };
1588
+
1573
1589
  const currentSSEOptions = () => ({
1574
1590
  durableBackgroundRun:
1575
1591
  currentRunDispatchMode?.startsWith("background") === true,
1592
+ ...(runId
1593
+ ? { preparingActionState: preparingActionStateForRun(runId) }
1594
+ : {}),
1576
1595
  });
1577
1596
 
1578
1597
  const captureChatClientError = (
@@ -838,8 +838,8 @@ function DiffRead({
838
838
  effectiveMode === "split" ? splitLineCount : rows.length;
839
839
  const shouldLimitRows = totalVisibleLineCount > DEFAULT_VISIBLE_DIFF_LINES;
840
840
  // Never truncate away an annotated row: extend the window past the last one.
841
- const effectiveRowLimit = useMemo(() => {
842
- if (showAllRows || !shouldLimitRows) return undefined;
841
+ const collapsedRowLimit = useMemo(() => {
842
+ if (!shouldLimitRows) return undefined;
843
843
  let limit = DEFAULT_VISIBLE_DIFF_LINES;
844
844
  if (hasAnnotations) {
845
845
  for (let idx = rows.length - 1; idx >= limit; idx -= 1) {
@@ -850,8 +850,10 @@ function DiffRead({
850
850
  }
851
851
  }
852
852
  return limit;
853
- }, [showAllRows, shouldLimitRows, hasAnnotations, rows, markersForRow]);
854
- const rowLimit = effectiveRowLimit;
853
+ }, [shouldLimitRows, hasAnnotations, rows, markersForRow]);
854
+ const hasHiddenRows =
855
+ collapsedRowLimit != null && collapsedRowLimit < totalVisibleLineCount;
856
+ const rowLimit = showAllRows ? undefined : collapsedRowLimit;
855
857
  const displayedRows =
856
858
  effectiveMode === "unified" && rowLimit ? rows.slice(0, rowLimit) : rows;
857
859
 
@@ -965,7 +967,7 @@ function DiffRead({
965
967
  ctx={ctx}
966
968
  />
967
969
  )}
968
- {!unchanged && shouldLimitRows && (
970
+ {!unchanged && hasHiddenRows && (
969
971
  <button
970
972
  type="button"
971
973
  data-plan-interactive
@@ -8,6 +8,7 @@ type ReplayEvent = Record<string, unknown>;
8
8
  type QueuedReplayEvent = {
9
9
  json: string;
10
10
  timestampMs: number;
11
+ type: number | null;
11
12
  };
12
13
  type ReplayStopFn = () => void;
13
14
  export type SessionReplayUrlMatcher =
@@ -48,6 +49,7 @@ interface SessionReplayState {
48
49
  /** Pre-serialized + scrubbed event JSON strings, ready to splice at flush. */
49
50
  queue: QueuedReplayEvent[];
50
51
  queuedBytes: number;
52
+ retryBatches: QueuedReplayEvent[][];
51
53
  flushTimer: number | null;
52
54
  maxDurationTimer: number | null;
53
55
  flushing: boolean;
@@ -195,6 +197,7 @@ const DEFAULT_MAX_DURATION_MS = 30 * 60 * 1000;
195
197
  const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
196
198
  const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
197
199
  const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
200
+ const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
198
201
  const URL_LIKE_KEYS = new Set([
199
202
  "url",
200
203
  "uri",
@@ -219,6 +222,7 @@ function getState(): SessionReplayState {
219
222
  sequence: 0,
220
223
  queue: [],
221
224
  queuedBytes: 0,
225
+ retryBatches: [],
222
226
  flushTimer: null,
223
227
  maxDurationTimer: null,
224
228
  flushing: false,
@@ -613,11 +617,10 @@ function enqueueReplayEvent(
613
617
  state.queue.push({
614
618
  json: serialized,
615
619
  timestampMs: replayEventTimestampMs(event),
620
+ type: typeof event.type === "number" ? event.type : null,
616
621
  });
617
622
  state.queuedBytes += estimatedBytes;
618
- if (state.queue.length >= state.options.maxEventsPerBatch) {
619
- void flushSessionReplay("max-events");
620
- }
623
+ flushQueuedReplayIfNeeded(state);
621
624
  }
622
625
 
623
626
  function replayExtraProperties(
@@ -668,11 +671,18 @@ function replayPropertiesForUpload(
668
671
  return properties;
669
672
  }
670
673
 
674
+ interface ReplayUploadPayload {
675
+ body: string;
676
+ replayId: string;
677
+ sessionId: string;
678
+ sequence: number;
679
+ }
680
+
671
681
  function buildReplayBody(
672
682
  state: SessionReplayState,
673
683
  reason: string,
674
684
  events: QueuedReplayEvent[],
675
- ): string | null {
685
+ ): ReplayUploadPayload | null {
676
686
  const options = state.options;
677
687
  if (!options || !state.replayId) return null;
678
688
  const sessionId = getAnalyticsSessionId();
@@ -713,19 +723,17 @@ function buildReplayBody(
713
723
  timestamp: new Date().toISOString(),
714
724
  properties,
715
725
  };
716
- state.sequence += 1;
717
- persistReplaySequence(
718
- sessionId,
719
- state.replayId,
720
- state.startedAtMs,
721
- state.sequence,
722
- );
723
726
  // Events are already serialized+scrubbed JSON strings; splice them into the
724
727
  // envelope without re-serializing the (potentially large) events array.
725
728
  const envelopeJson = JSON.stringify(envelope);
726
- return `${envelopeJson.slice(0, -1)},"events":[${events
727
- .map((event) => event.json)
728
- .join(",")}]}`;
729
+ return {
730
+ body: `${envelopeJson.slice(0, -1)},"events":[${events
731
+ .map((event) => event.json)
732
+ .join(",")}]}`,
733
+ replayId: state.replayId,
734
+ sessionId,
735
+ sequence: state.sequence,
736
+ };
729
737
  }
730
738
 
731
739
  interface ReplayUploadBody {
@@ -745,13 +753,6 @@ function isCrossOriginReplayEndpoint(endpoint: string): boolean {
745
753
  }
746
754
  }
747
755
 
748
- function replayUploadByteLength(body: string): number {
749
- if (typeof TextEncoder !== "undefined") {
750
- return new TextEncoder().encode(body).byteLength;
751
- }
752
- return body.length;
753
- }
754
-
755
756
  async function gzipReplayBody(body: string): Promise<Blob | null> {
756
757
  if (
757
758
  typeof CompressionStream === "undefined" ||
@@ -792,39 +793,68 @@ async function buildReplayUploadBody(body: string): Promise<ReplayUploadBody> {
792
793
  };
793
794
  }
794
795
 
796
+ function replayUploadBodyBytes(body: BodyInit): number {
797
+ if (typeof body === "string") {
798
+ if (typeof TextEncoder !== "undefined") {
799
+ return new TextEncoder().encode(body).byteLength;
800
+ }
801
+ return body.length;
802
+ }
803
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
804
+ return body.size;
805
+ }
806
+ if (body instanceof ArrayBuffer) {
807
+ return body.byteLength;
808
+ }
809
+ if (ArrayBuffer.isView(body)) {
810
+ return body.byteLength;
811
+ }
812
+ return MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES + 1;
813
+ }
814
+
815
+ function canUseReplayKeepalive(body: BodyInit): boolean {
816
+ return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
817
+ }
818
+
795
819
  async function sendReplayUpload(
796
820
  options: NormalizedSessionReplayOptions,
797
821
  body: string,
822
+ callbacks: { beforeKeepaliveUpload?: () => void } = {},
798
823
  ): Promise<void> {
799
824
  if (isCrossOriginReplayEndpoint(options.endpoint)) {
800
- if (navigator.sendBeacon) {
801
- const sent = navigator.sendBeacon(options.endpoint, body);
802
- if (sent) return;
803
- }
804
- await fetch(options.endpoint, {
825
+ const canUseKeepalive = canUseReplayKeepalive(body);
826
+ if (canUseKeepalive) callbacks.beforeKeepaliveUpload?.();
827
+ const response = await fetch(options.endpoint, {
805
828
  method: "POST",
806
829
  body,
807
- keepalive:
808
- replayUploadByteLength(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES,
830
+ keepalive: canUseKeepalive,
809
831
  headers: { "Content-Type": "text/plain;charset=UTF-8" },
810
- }).catch(() => {});
832
+ });
833
+ if (!response.ok) {
834
+ throw new Error(
835
+ `Session replay upload failed with HTTP ${response.status}`,
836
+ );
837
+ }
811
838
  return;
812
839
  }
813
840
 
814
841
  const upload = await buildReplayUploadBody(body);
815
- if (!upload.compressed && navigator.sendBeacon) {
816
- const sent = navigator.sendBeacon(options.endpoint, body);
817
- if (sent) return;
818
- }
819
- await fetch(options.endpoint, {
842
+ const canUseKeepalive = canUseReplayKeepalive(upload.body);
843
+ if (canUseKeepalive) callbacks.beforeKeepaliveUpload?.();
844
+ const response = await fetch(options.endpoint, {
820
845
  method: "POST",
821
846
  body: upload.body,
822
- keepalive: true,
847
+ keepalive: canUseKeepalive,
823
848
  headers: {
824
849
  ...upload.headers,
825
850
  "X-Agent-Native-Analytics-Key": options.publicKey,
826
851
  },
827
- }).catch(() => {});
852
+ });
853
+ if (!response.ok) {
854
+ throw new Error(
855
+ `Session replay upload failed with HTTP ${response.status}`,
856
+ );
857
+ }
828
858
  }
829
859
 
830
860
  function isFinalFlushReason(reason: string): boolean {
@@ -838,26 +868,118 @@ function isFinalFlushReason(reason: string): boolean {
838
868
  ].includes(reason);
839
869
  }
840
870
 
871
+ function shouldReserveSequenceBeforeKeepalive(reason: string): boolean {
872
+ return (
873
+ reason === "pagehide" ||
874
+ reason === "beforeunload" ||
875
+ reason === "visibility-hidden"
876
+ );
877
+ }
878
+
879
+ function hasFullSnapshot(events: QueuedReplayEvent[]): boolean {
880
+ return events.some((event) => event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE);
881
+ }
882
+
883
+ function hasPendingReplayBatch(state: SessionReplayState): boolean {
884
+ return state.retryBatches.length > 0 || state.queue.length > 0;
885
+ }
886
+
887
+ function shouldFlushQueuedReplay(state: SessionReplayState): boolean {
888
+ if (!state.options || state.queue.length === 0) return false;
889
+ return (
890
+ hasFullSnapshot(state.queue) ||
891
+ state.queue.length >= state.options.maxEventsPerBatch ||
892
+ state.queuedBytes >= state.options.maxBatchBytes
893
+ );
894
+ }
895
+
896
+ function flushQueuedReplayIfNeeded(state: SessionReplayState): void {
897
+ const options = state.options;
898
+ if (!options) return;
899
+ if (state.retryBatches.length > 0) return;
900
+ if (!shouldFlushQueuedReplay(state)) return;
901
+ const reason = hasFullSnapshot(state.queue)
902
+ ? "full-snapshot"
903
+ : state.queue.length >= options.maxEventsPerBatch
904
+ ? "max-events"
905
+ : "max-bytes";
906
+ void flushSessionReplay(reason);
907
+ }
908
+
909
+ function queuedReplayBytes(events: QueuedReplayEvent[]): number {
910
+ return events.reduce((total, event) => total + event.json.length, 0);
911
+ }
912
+
913
+ function restoreReplayEvents(
914
+ state: SessionReplayState,
915
+ events: QueuedReplayEvent[],
916
+ ): void {
917
+ state.retryBatches.unshift(events);
918
+ }
919
+
920
+ function advanceReplaySequence(
921
+ state: SessionReplayState,
922
+ payload: ReplayUploadPayload,
923
+ ): void {
924
+ if (state.replayId !== payload.replayId) return;
925
+ state.sequence = Math.max(state.sequence, payload.sequence + 1);
926
+ persistReplaySequence(
927
+ payload.sessionId,
928
+ payload.replayId,
929
+ state.startedAtMs,
930
+ state.sequence,
931
+ );
932
+ }
933
+
934
+ function rollbackReplaySequenceReservation(
935
+ state: SessionReplayState,
936
+ payload: ReplayUploadPayload,
937
+ ): void {
938
+ if (state.replayId !== payload.replayId) return;
939
+ if (state.sequence !== payload.sequence + 1) return;
940
+ state.sequence = payload.sequence;
941
+ persistReplaySequence(
942
+ payload.sessionId,
943
+ payload.replayId,
944
+ state.startedAtMs,
945
+ state.sequence,
946
+ );
947
+ }
948
+
841
949
  export async function flushSessionReplay(reason = "manual"): Promise<void> {
842
950
  const state = getState();
843
- if (!state.options || state.queue.length === 0 || state.flushing) return;
844
- const events = state.queue.splice(0, state.queue.length);
845
- state.queuedBytes = 0;
846
- const body = buildReplayBody(state, reason, events);
847
- if (!body || !state.options) {
848
- state.queue = events.concat(state.queue);
849
- state.queuedBytes += events.reduce(
850
- (total, event) => total + event.json.length,
851
- 0,
852
- );
951
+ if (!state.options || !hasPendingReplayBatch(state) || state.flushing) return;
952
+ const events = state.retryBatches.shift() ?? state.queue.splice(0);
953
+ state.queuedBytes = queuedReplayBytes(state.queue);
954
+ const payload = buildReplayBody(state, reason, events);
955
+ if (!payload || !state.options) {
956
+ restoreReplayEvents(state, events);
853
957
  return;
854
958
  }
855
959
  state.flushing = true;
960
+ let uploaded = false;
961
+ let reservedSequence = false;
856
962
  try {
857
- await sendReplayUpload(state.options, body);
963
+ await sendReplayUpload(state.options, payload.body, {
964
+ beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
965
+ ? () => {
966
+ advanceReplaySequence(state, payload);
967
+ reservedSequence = true;
968
+ }
969
+ : undefined,
970
+ });
971
+ if (!reservedSequence) advanceReplaySequence(state, payload);
972
+ uploaded = true;
973
+ } catch (error) {
974
+ if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
975
+ restoreReplayEvents(state, events);
976
+ console.warn("[session-replay] upload failed", error);
858
977
  } finally {
859
978
  state.flushing = false;
860
979
  }
980
+ if (uploaded && hasPendingReplayBatch(state)) {
981
+ flushQueuedReplayIfNeeded(state);
982
+ }
861
983
  }
862
984
 
863
985
  function installUrlMonitor(state: SessionReplayState): void {
@@ -1002,6 +1124,7 @@ async function startSessionReplayRecorder(
1002
1124
  state.sequence = replaySession.sequence;
1003
1125
  state.queue = [];
1004
1126
  state.queuedBytes = 0;
1127
+ state.retryBatches = [];
1005
1128
  state.stopRecorder = null;
1006
1129
  state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
1007
1130
  ? { ...initialProperties }