@agent-native/core 0.84.49 → 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 +20 -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 +59 -9
  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 +106 -29
  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 +50 -9
  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 +29 -3
  35. package/dist/client/sse-event-processor.d.ts.map +1 -1
  36. package/dist/client/sse-event-processor.js +72 -18
  37. package/dist/client/sse-event-processor.js.map +1 -1
  38. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  39. package/dist/notifications/routes.d.ts +1 -1
  40. package/dist/observability/routes.d.ts +7 -7
  41. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  42. package/package.json +1 -1
@@ -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 }
@@ -151,11 +151,18 @@ export const SSE_ACTION_PREPARATION_STALL_TIMEOUT_MS = 90_000;
151
151
  export interface SSEStreamOptions {
152
152
  /**
153
153
  * Durable background runs have their own server-side liveness budget and
154
- * heartbeat. While one is active, keepalive-only periods and zero-byte
155
- * action-preparation activity should keep the client attached instead of
156
- * aborting and starting duplicate continuations.
154
+ * heartbeat. While one is active, generic keepalive-only periods keep the
155
+ * client attached. Tool-input preparation is stricter: real byte progress
156
+ * keeps long payloads alive, but zero-byte/silent preparation still recovers
157
+ * so one stuck action cannot pin the chat forever.
157
158
  */
158
159
  durableBackgroundRun?: boolean;
160
+ /**
161
+ * Optional caller-owned preparation watchdog state. Passing the same object
162
+ * across reconnect reads keeps a stuck action preparation from getting a
163
+ * fresh stall budget every time the browser reattaches to the same run.
164
+ */
165
+ preparingActionState?: PreparingActionState;
159
166
  }
160
167
 
161
168
  type ActivityTrailEntry = AgentActivityTrailEntry;
@@ -176,8 +183,9 @@ type PreparingActionEntry = {
176
183
  lastProgressAt?: number;
177
184
  };
178
185
 
179
- type PreparingActionState = {
186
+ export type PreparingActionState = {
180
187
  entries?: Map<string, PreparingActionEntry>;
188
+ toolEntries?: Map<string, PreparingActionEntry>;
181
189
  };
182
190
 
183
191
  function formatProgressBytes(bytes: number): string {
@@ -278,6 +286,24 @@ function findPendingToolCallIndex(
278
286
  return -1;
279
287
  }
280
288
 
289
+ function findCompletedToolCallIndex(
290
+ content: ContentPart[],
291
+ toolCallId?: string,
292
+ ): number {
293
+ if (!toolCallId) return -1;
294
+ for (let i = content.length - 1; i >= 0; i--) {
295
+ const part = content[i];
296
+ if (
297
+ part.type === "tool-call" &&
298
+ part.toolCallId === toolCallId &&
299
+ part.result !== undefined
300
+ ) {
301
+ return i;
302
+ }
303
+ }
304
+ return -1;
305
+ }
306
+
281
307
  function appendActivityTrail(
282
308
  trail: ActivityTrailEntry[],
283
309
  next: ActivityTrailEntry,
@@ -293,6 +319,37 @@ function appendActivityTrail(
293
319
  }
294
320
  }
295
321
 
322
+ function refreshPreparingToolEntry(state: PreparingActionState, tool: string) {
323
+ const remainingEntries = [...(state.entries?.values() ?? [])].filter(
324
+ (entry) => entry.tool === tool,
325
+ );
326
+ if (remainingEntries.length === 0) {
327
+ state.toolEntries?.delete(tool);
328
+ return;
329
+ }
330
+ const deadlineBasis = (entry: PreparingActionEntry) =>
331
+ entry.lastProgressAt ?? entry.startedAt ?? Number.POSITIVE_INFINITY;
332
+ const oldestEntry = remainingEntries.reduce((oldest, entry) =>
333
+ deadlineBasis(entry) < deadlineBasis(oldest) ? entry : oldest,
334
+ );
335
+ const lastProgressBytes = remainingEntries.reduce<number | undefined>(
336
+ (max, entry) =>
337
+ entry.lastProgressBytes === undefined
338
+ ? max
339
+ : Math.max(max ?? 0, entry.lastProgressBytes),
340
+ undefined,
341
+ );
342
+ const toolEntries =
343
+ state.toolEntries ?? new Map<string, PreparingActionEntry>();
344
+ state.toolEntries = toolEntries;
345
+ toolEntries.set(tool, {
346
+ tool,
347
+ startedAt: oldestEntry.startedAt,
348
+ lastProgressAt: oldestEntry.lastProgressAt,
349
+ lastProgressBytes,
350
+ });
351
+ }
352
+
296
353
  function updatePreparingActionState(
297
354
  state: PreparingActionState,
298
355
  ev: SSEEvent,
@@ -301,7 +358,8 @@ function updatePreparingActionState(
301
358
  if (ev.type === "activity" && isPreparingActionActivity(ev)) {
302
359
  const tool = ev.tool?.trim() || undefined;
303
360
  if (!tool) return false;
304
- const key = ev.id?.trim() || tool;
361
+ const id = ev.id?.trim();
362
+ const key = id || tool;
305
363
  const entries = state.entries ?? new Map<string, PreparingActionEntry>();
306
364
  state.entries = entries;
307
365
  let entry = entries.get(key);
@@ -314,22 +372,35 @@ function updatePreparingActionState(
314
372
  };
315
373
  entries.set(key, entry);
316
374
  }
375
+ const toolEntries =
376
+ state.toolEntries ?? new Map<string, PreparingActionEntry>();
377
+ state.toolEntries = toolEntries;
378
+ let toolEntry = toolEntries.get(tool);
379
+ if (!toolEntry) {
380
+ toolEntry = {
381
+ tool,
382
+ startedAt: now,
383
+ lastProgressAt: undefined,
384
+ lastProgressBytes: undefined,
385
+ };
386
+ toolEntries.set(tool, toolEntry);
387
+ }
317
388
  const progressBytes = activityProgressBytes(ev);
318
389
  const previousBytes = entry.lastProgressBytes ?? 0;
390
+ let madeProgress = false;
319
391
  if (progressBytes !== undefined) {
320
392
  entry.lastProgressBytes = Math.max(previousBytes, progressBytes);
393
+ toolEntry.lastProgressBytes = Math.max(
394
+ toolEntry.lastProgressBytes ?? 0,
395
+ progressBytes,
396
+ );
397
+ madeProgress = id ? progressBytes > previousBytes : progressBytes > 0;
321
398
  }
322
- if (!ev.id?.trim()) {
323
- if (progressBytes !== undefined && progressBytes > 0) {
324
- entry.lastProgressAt = now;
325
- return true;
326
- }
327
- return false;
328
- }
329
- if (progressBytes !== undefined && progressBytes > previousBytes) {
399
+ if (madeProgress) {
330
400
  // A byte increase is proof the model is still streaming this action's
331
401
  // argument. Repeated zero-byte prep activity is only a heartbeat.
332
402
  entry.lastProgressAt = now;
403
+ toolEntry.lastProgressAt = now;
333
404
  return true;
334
405
  }
335
406
  return false;
@@ -348,29 +419,31 @@ function updatePreparingActionState(
348
419
  const tool = ev.tool?.trim();
349
420
  const id = ev.id?.trim();
350
421
  for (const [key, entry] of state.entries ?? []) {
351
- if ((id && key === id) || (!id && entry.tool === tool)) {
422
+ if ((id && key === id) || (!id && tool && entry.tool === tool)) {
352
423
  state.entries?.delete(key);
353
424
  }
354
425
  }
426
+ if (tool) {
427
+ refreshPreparingToolEntry(state, tool);
428
+ }
355
429
  } else {
356
430
  state.entries?.clear();
431
+ state.toolEntries?.clear();
357
432
  }
358
433
  }
359
434
  return undefined;
360
435
  }
361
436
 
362
- function hasStalledPreparingAction(
363
- state: PreparingActionState,
364
- now: number,
365
- options?: SSEStreamOptions,
366
- ) {
367
- if (options?.durableBackgroundRun === true) return false;
437
+ function hasStalledPreparingAction(state: PreparingActionState, now: number) {
368
438
  // Fire only when a tool input has gone SILENT — no further streaming deltas
369
439
  // for the whole window — never merely because a large input has been
370
440
  // streaming for a long time. `lastProgressAt` advances on every delta
371
441
  // heartbeat, so an actively-streaming large output keeps resetting this and
372
442
  // survives; a genuinely stuck prep (keepalive-only, no deltas) trips it.
373
- for (const entry of state.entries?.values() ?? []) {
443
+ for (const entry of [
444
+ ...(state.toolEntries?.values() ?? []),
445
+ ...(state.entries?.values() ?? []),
446
+ ]) {
374
447
  if (
375
448
  entry.startedAt !== undefined &&
376
449
  now - (entry.lastProgressAt ?? entry.startedAt) >=
@@ -787,6 +860,9 @@ export function processEvent(
787
860
  if (ev.type === "tool_start") {
788
861
  const args = (ev.input ?? {}) as Record<string, string>;
789
862
  const tool = ev.tool ?? "unknown";
863
+ if (findCompletedToolCallIndex(content, ev.id) >= 0) {
864
+ return { action: "continue" };
865
+ }
790
866
  if (typeof window !== "undefined") {
791
867
  window.dispatchEvent(
792
868
  new CustomEvent("agent-native:tool-start", {
@@ -868,6 +944,9 @@ export function processEvent(
868
944
  // so a tool_done frame with an undefined tool name still matches its
869
945
  // pending tool-call entry instead of leaving it forever unresolved.
870
946
  const doneTool = ev.tool ?? "unknown";
947
+ if (findCompletedToolCallIndex(content, ev.id) >= 0) {
948
+ return { action: "continue" };
949
+ }
871
950
  if (typeof window !== "undefined") {
872
951
  window.dispatchEvent(
873
952
  new CustomEvent("agent-native:tool-done", {
@@ -1219,7 +1298,8 @@ export async function* readSSEStream(
1219
1298
  let buf = "";
1220
1299
  let lastMeaningfulEventAt = Date.now();
1221
1300
  const activityTrail: ActivityTrailEntry[] = [];
1222
- const preparingActionState: PreparingActionState = {};
1301
+ const preparingActionState: PreparingActionState =
1302
+ options?.preparingActionState ?? {};
1223
1303
  const processEventState: ProcessEventState = {
1224
1304
  completedToolsAfterLastAssistantText: new Set(),
1225
1305
  };
@@ -1340,9 +1420,7 @@ export async function* readSSEStream(
1340
1420
  );
1341
1421
 
1342
1422
  if (result) yield withStreamMetadata(result);
1343
- if (
1344
- hasStalledPreparingAction(preparingActionState, Date.now(), options)
1345
- ) {
1423
+ if (hasStalledPreparingAction(preparingActionState, Date.now())) {
1346
1424
  throw new AgentAutoContinueSignal({
1347
1425
  reason: "no_progress",
1348
1426
  activityTrail: [...activityTrail],
@@ -1413,7 +1491,8 @@ export async function readSSEStreamRaw(
1413
1491
  let buf = "";
1414
1492
  let lastMeaningfulEventAt = Date.now();
1415
1493
  const activityTrail: ActivityTrailEntry[] = [];
1416
- const preparingActionState: PreparingActionState = {};
1494
+ const preparingActionState: PreparingActionState =
1495
+ options?.preparingActionState ?? {};
1417
1496
  const processEventState: ProcessEventState = {
1418
1497
  completedToolsAfterLastAssistantText: new Set(),
1419
1498
  };
@@ -1525,9 +1604,7 @@ export async function readSSEStreamRaw(
1525
1604
  : { reason: "stream_ended", activityTrail: [...activityTrail] },
1526
1605
  );
1527
1606
  }
1528
- if (
1529
- hasStalledPreparingAction(preparingActionState, Date.now(), options)
1530
- ) {
1607
+ if (hasStalledPreparingAction(preparingActionState, Date.now())) {
1531
1608
  onUpdate(contentSnapshot(content));
1532
1609
  throw new AgentAutoContinueSignal({
1533
1610
  reason: "no_progress",
@@ -429,6 +429,11 @@ function ReplayPlayer({
429
429
  if (events.length < 2) {
430
430
  throw new Error(t("sessions.noReplayEvents"));
431
431
  }
432
+ if (
433
+ !events.some((event) => event.type === RRWEB_EVENT_TYPE.FullSnapshot)
434
+ ) {
435
+ throw new Error(t("sessions.noReplayEvents"));
436
+ }
432
437
  setStatus("loading");
433
438
  setError(null);
434
439
  await import("@rrweb/replay/dist/style.css");
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-02
4
+ ---
5
+
6
+ Agent chat can keep working through longer data queries instead of stopping mid-action.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-02
4
+ ---
5
+
6
+ Session replay playback now preserves the initial page snapshot when an upload needs to retry.
@@ -9,11 +9,14 @@ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
9
9
  NITRO_PRESET = "netlify"
10
10
  NPM_CONFIG_PRODUCTION = "false"
11
11
  AGENT_PROD_CODE_EXECUTION = "sandboxed"
12
+ AGENT_CHAT_DURABLE_BACKGROUND = "true"
12
13
 
13
14
  # A2A delegation calls run a full agent loop (LLM + tool calls + DB queries).
14
15
  # Hosted agent chat uses a soft timeout before this function timeout so the
15
16
  # hidden auto-continuation path can hand off before Netlify kills the function.
16
17
  # If AGENT_RUN_SOFT_TIMEOUT_MS is customized, keep it below this timeout.
18
+ # Agent chat runs are dispatched to the emitted Netlify background function;
19
+ # the normal server timeout still protects non-background HTTP handlers.
17
20
  [functions."*"]
18
21
  timeout = 75
19
22
 
@@ -152,7 +152,8 @@ const MAX_REPLAY_BLOB_REF_LENGTH = 16 * 1024;
152
152
  const MAX_REPLAY_METADATA_BYTES = 16 * 1024;
153
153
  const MAX_REPLAY_EVENTS_PER_CHUNK = 1_000;
154
154
  const MAX_REPLAY_EVENTS_READ = 10_000;
155
- const MAX_REPLAY_EVENTS_RESPONSE_BYTES = 2 * 1024 * 1024;
155
+ const MAX_REPLAY_EVENTS_RESPONSE_BYTES =
156
+ MAX_BLOB_REPLAY_CHUNK_BYTES + 512 * 1024;
156
157
  const DEFAULT_SESSION_RECORDINGS_LIMIT = 50;
157
158
  const MAX_SESSION_RECORDINGS_LIMIT = 100;
158
159
  const DEFAULT_REPLAY_RETENTION_DAYS = 30;
@@ -550,6 +551,12 @@ async function storeReplayChunkBlob(
550
551
  503,
551
552
  );
552
553
  }
554
+ if (chunk.byteLength > MAX_INLINE_REPLAY_CHUNK_BYTES) {
555
+ throw replayError(
556
+ "Session replay chunk is too large for inline SQL fallback. Configure private blob storage for full snapshot playback.",
557
+ 503,
558
+ );
559
+ }
553
560
  warnInlineReplayFallback();
554
561
  return chunk;
555
562
  }
@@ -590,10 +597,7 @@ function normalizeReplayChunk(rawValue: unknown): NormalizedSessionReplayChunk {
590
597
  storageKind === "inline"
591
598
  ? Buffer.byteLength(inlineData ?? "", "utf8")
592
599
  : (replayInteger(raw.byteLength ?? raw.bytes) ?? 0);
593
- const maxBytes =
594
- storageKind === "inline"
595
- ? MAX_INLINE_REPLAY_CHUNK_BYTES
596
- : MAX_BLOB_REPLAY_CHUNK_BYTES;
600
+ const maxBytes = MAX_BLOB_REPLAY_CHUNK_BYTES;
597
601
  if (byteLength <= 0 || byteLength > maxBytes) {
598
602
  throw replayError(
599
603
  `Replay ${storageKind} chunks must be between 1 and ${maxBytes} bytes`,
@@ -28,6 +28,7 @@ import {
28
28
  } from "../lib/scoped-settings";
29
29
 
30
30
  const DATA_DICT_PREFIX = "data-dict-";
31
+ const ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS = 13 * 60_000;
31
32
 
32
33
  const INITIAL_TOOL_NAMES = [
33
34
  "view-screen",
@@ -170,6 +171,8 @@ export default createAgentChatPlugin({
170
171
  // Operators deploying to trusted internal environments can set
171
172
  // AGENT_PROD_CODE_EXECUTION=trusted to also enable bash/read/edit/write.
172
173
  codeExecution: { production: "sandboxed" },
174
+ durableBackgroundRuns: true,
175
+ runSoftTimeoutMs: ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS,
173
176
  resolveOrgId: async (event) => {
174
177
  const ctx = await getOrgContext(event);
175
178
  return ctx.orgId;
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-02
4
+ ---
5
+
6
+ Agent chat can keep working through longer visual plan updates instead of stopping mid-action.
@@ -8,8 +8,11 @@ functions = "templates/plan/.netlify/functions-internal"
8
8
  GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
9
9
  NITRO_PRESET = "netlify"
10
10
  NPM_CONFIG_PRODUCTION = "false"
11
+ AGENT_CHAT_DURABLE_BACKGROUND = "true"
11
12
 
12
13
  # A2A/MCP calls may include plan generation, artifact serialization, and
13
14
  # feedback polling. Keep this aligned with other agent-heavy hosted templates.
15
+ # Agent chat runs are dispatched to the emitted Netlify background function;
16
+ # the normal server timeout still protects non-background HTTP handlers.
14
17
  [functions."*"]
15
18
  timeout = 75