@stigmer/react 3.0.8-dev.20260612122433 → 3.0.8-dev.20260613051837

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 (88) hide show
  1. package/execution/ExecutionProgress.d.ts.map +1 -1
  2. package/execution/ExecutionProgress.js +5 -1
  3. package/execution/ExecutionProgress.js.map +1 -1
  4. package/execution/MessageThread.d.ts +32 -3
  5. package/execution/MessageThread.d.ts.map +1 -1
  6. package/execution/MessageThread.js +59 -10
  7. package/execution/MessageThread.js.map +1 -1
  8. package/execution/useExecutionStream.d.ts +76 -5
  9. package/execution/useExecutionStream.d.ts.map +1 -1
  10. package/execution/useExecutionStream.js +166 -23
  11. package/execution/useExecutionStream.js.map +1 -1
  12. package/internal/VirtualizedThread.d.ts +3 -1
  13. package/internal/VirtualizedThread.d.ts.map +1 -1
  14. package/internal/VirtualizedThread.js +4 -2
  15. package/internal/VirtualizedThread.js.map +1 -1
  16. package/internal/backoff.d.ts +61 -0
  17. package/internal/backoff.d.ts.map +1 -0
  18. package/internal/backoff.js +79 -0
  19. package/internal/backoff.js.map +1 -0
  20. package/internal/store/conversation-store.d.ts +34 -0
  21. package/internal/store/conversation-store.d.ts.map +1 -1
  22. package/internal/store/conversation-store.js +50 -2
  23. package/internal/store/conversation-store.js.map +1 -1
  24. package/internal/store/workflow-execution-event-store.d.ts +12 -0
  25. package/internal/store/workflow-execution-event-store.d.ts.map +1 -1
  26. package/internal/store/workflow-execution-event-store.js +7 -0
  27. package/internal/store/workflow-execution-event-store.js.map +1 -1
  28. package/internal/stream-controller.d.ts +57 -21
  29. package/internal/stream-controller.d.ts.map +1 -1
  30. package/internal/stream-controller.js +117 -3
  31. package/internal/stream-controller.js.map +1 -1
  32. package/internal/useFetch.d.ts +7 -0
  33. package/internal/useFetch.d.ts.map +1 -1
  34. package/internal/useFetch.js +21 -0
  35. package/internal/useFetch.js.map +1 -1
  36. package/package.json +4 -4
  37. package/session/SessionViewer.js +26 -1
  38. package/session/SessionViewer.js.map +1 -1
  39. package/session/useSessionConversation.d.ts +41 -4
  40. package/session/useSessionConversation.d.ts.map +1 -1
  41. package/session/useSessionConversation.js +74 -10
  42. package/session/useSessionConversation.js.map +1 -1
  43. package/session/useSessionExecutions.d.ts +17 -1
  44. package/session/useSessionExecutions.d.ts.map +1 -1
  45. package/session/useSessionExecutions.js +6 -2
  46. package/session/useSessionExecutions.js.map +1 -1
  47. package/src/execution/ExecutionProgress.tsx +12 -0
  48. package/src/execution/MessageThread.tsx +174 -5
  49. package/src/execution/__tests__/MessageThread.test.tsx +64 -0
  50. package/src/execution/__tests__/useExecutionStream.test.tsx +279 -0
  51. package/src/execution/useExecutionStream.ts +254 -34
  52. package/src/internal/VirtualizedThread.tsx +7 -1
  53. package/src/internal/__tests__/backoff.test.ts +99 -0
  54. package/src/internal/__tests__/stream-controller.test.ts +165 -10
  55. package/src/internal/__tests__/useFetch.test.tsx +59 -0
  56. package/src/internal/backoff.ts +100 -0
  57. package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
  58. package/src/internal/store/conversation-store.ts +68 -3
  59. package/src/internal/store/workflow-execution-event-store.ts +22 -0
  60. package/src/internal/stream-controller.ts +151 -26
  61. package/src/internal/useFetch.ts +26 -0
  62. package/src/session/SessionViewer.tsx +89 -0
  63. package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
  64. package/src/session/useSessionConversation.ts +121 -15
  65. package/src/session/useSessionExecutions.ts +23 -1
  66. package/src/workflow/WorkflowExecutionHeader.tsx +4 -1
  67. package/src/workflow/WorkflowExecutionTimeline.tsx +2 -1
  68. package/src/workflow/__tests__/useWorkflowExecutionEventStream.test.tsx +117 -1
  69. package/src/workflow/execution/useWaterfallEntries.ts +2 -1
  70. package/src/workflow/useWorkflowExecutionEventStream.ts +122 -41
  71. package/src/workflow/waterfall/WaterfallTimeline.tsx +2 -1
  72. package/styles.css +1 -1
  73. package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
  74. package/workflow/WorkflowExecutionHeader.js +3 -1
  75. package/workflow/WorkflowExecutionHeader.js.map +1 -1
  76. package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
  77. package/workflow/WorkflowExecutionTimeline.js +1 -1
  78. package/workflow/WorkflowExecutionTimeline.js.map +1 -1
  79. package/workflow/execution/useWaterfallEntries.d.ts.map +1 -1
  80. package/workflow/execution/useWaterfallEntries.js +1 -1
  81. package/workflow/execution/useWaterfallEntries.js.map +1 -1
  82. package/workflow/useWorkflowExecutionEventStream.d.ts +32 -4
  83. package/workflow/useWorkflowExecutionEventStream.d.ts.map +1 -1
  84. package/workflow/useWorkflowExecutionEventStream.js +75 -32
  85. package/workflow/useWorkflowExecutionEventStream.js.map +1 -1
  86. package/workflow/waterfall/WaterfallTimeline.d.ts.map +1 -1
  87. package/workflow/waterfall/WaterfallTimeline.js +1 -1
  88. package/workflow/waterfall/WaterfallTimeline.js.map +1 -1
@@ -11,14 +11,23 @@ import {
11
11
  } from "react";
12
12
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
13
13
  import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
14
+ import { isTransientStreamError } from "@stigmer/sdk";
14
15
  import { useStigmer } from "../hooks";
15
16
  import { toError } from "../internal/toError";
16
17
  import { useStreamRate } from "../internal/dev";
17
18
  import {
18
19
  StreamController,
20
+ DEFAULT_CONNECT_TIMEOUT_MS,
21
+ DEFAULT_SLOW_THRESHOLD_MS,
19
22
  type StreamControllerSink,
20
23
  } from "../internal/stream-controller";
21
- import { ConversationStore, type StreamState } from "../internal/store";
24
+ import {
25
+ computeBackoffDelay,
26
+ sleep,
27
+ DEFAULT_RECONNECT_MAX_ATTEMPTS,
28
+ type BackoffOptions,
29
+ } from "../internal/backoff";
30
+ import { ConversationStore } from "../internal/store";
22
31
  import { isTerminalPhase } from "./execution-phases";
23
32
 
24
33
  /** Return value of {@link useExecutionStream}. */
@@ -37,14 +46,46 @@ export interface UseExecutionStreamReturn {
37
46
  readonly isStreaming: boolean;
38
47
  /** `true` after subscription starts but before the first snapshot arrives. */
39
48
  readonly isConnecting: boolean;
40
- /** Error from the last failed stream attempt, or `null` when healthy. */
49
+ /**
50
+ * `true` while a transient drop is being retried automatically in the
51
+ * background. The last snapshot stays visible and `error` remains `null` —
52
+ * surface a subtle "Reconnecting…" affordance, not an error. Becomes
53
+ * `false` once a snapshot is received (back to `isStreaming`) or retries
54
+ * are exhausted (then `error` is set).
55
+ */
56
+ readonly isReconnecting: boolean;
57
+ /** 1-based count of the in-flight reconnect attempt; `0` when not reconnecting. */
58
+ readonly reconnectAttempt: number;
59
+ /**
60
+ * Error from the last failed stream attempt, or `null` when healthy.
61
+ *
62
+ * Only set once auto-reconnect has exhausted its attempts (or for a
63
+ * non-transient failure that is not retried). It stays `null` throughout
64
+ * background reconnection so a recoverable hiccup never shows as an error.
65
+ */
41
66
  readonly error: Error | null;
67
+ /**
68
+ * `true` when the stream opened but no first snapshot arrived within the
69
+ * connect-timeout window, even after one silent self-heal. Distinct from
70
+ * `error` (a thrown/closed stream that auto-reconnect retries): this is the
71
+ * *silent* hang the retry loop can never observe. Surface an actionable
72
+ * "the agent hasn't started — Retry" affordance wired to `reconnect()`.
73
+ * Cleared by the next snapshot or a manual `reconnect()`. Never auto-aborts.
74
+ */
75
+ readonly connectTimedOut: boolean;
76
+ /**
77
+ * `true` when a non-terminal stream has produced no new snapshot for the
78
+ * slow-stall window. Purely informational ("still working — taking longer
79
+ * than usual"); cleared by the next snapshot. Never an error, never aborts.
80
+ */
81
+ readonly isSlow: boolean;
42
82
  /**
43
83
  * Reset error state and re-establish the stream subscription.
44
84
  *
45
- * Works in any lifecycle state error, complete, or mid-stream.
46
- * Uses the `connectKey` counter pattern consistent with `refetch()`
47
- * in other SDK hooks.
85
+ * The fallback after auto-reconnect exhausts, and a manual escape hatch in
86
+ * any lifecycle state error, complete, or mid-stream. Resets the retry
87
+ * counter and preserves the last snapshot (no flash to empty). Uses the
88
+ * `connectKey` counter pattern consistent with `refetch()` in other SDK hooks.
48
89
  */
49
90
  readonly reconnect: () => void;
50
91
  }
@@ -65,6 +106,33 @@ export interface UseExecutionStreamOptions {
65
106
  * preserving backward compatibility for standalone usage.
66
107
  */
67
108
  readonly store?: ConversationStore;
109
+ /**
110
+ * Automatically re-establish the subscription with exponential backoff
111
+ * when a non-terminal stream drops (transport error, idle timeout, laptop
112
+ * sleep). Defaults to `true`. Set `false` to opt out and surface every
113
+ * drop as an immediate `error` for manual `reconnect()`.
114
+ */
115
+ readonly autoReconnect?: boolean;
116
+ /**
117
+ * Tune the auto-reconnect backoff schedule and attempt cap. Omitted fields
118
+ * fall back to SDK defaults (base 1s, ×2, max 30s, 10 attempts).
119
+ */
120
+ readonly reconnectOptions?: BackoffOptions & {
121
+ /** Max attempts before surfacing a terminal `error`. */
122
+ readonly maxAttempts?: number;
123
+ };
124
+ /**
125
+ * Hard connect-timeout in ms: how long the stream may stay `connecting`
126
+ * (no first snapshot) before the watchdog self-heals once and then surfaces
127
+ * `connectTimedOut`. Defaults to {@link DEFAULT_CONNECT_TIMEOUT_MS} (10s).
128
+ */
129
+ readonly connectTimeoutMs?: number;
130
+ /**
131
+ * Soft slow-stall threshold in ms: how long a non-terminal stream may go
132
+ * without a new snapshot before `isSlow` flips on. Defaults to
133
+ * {@link DEFAULT_SLOW_THRESHOLD_MS} (60s).
134
+ */
135
+ readonly slowThresholdMs?: number;
68
136
  }
69
137
 
70
138
  /**
@@ -73,7 +141,18 @@ export interface UseExecutionStreamOptions {
73
141
  *
74
142
  * Manages the full subscription lifecycle through a finite state
75
143
  * machine: connection establishment, rAF-coalesced snapshot streaming,
76
- * terminal-phase detection, error handling, and manual reconnection.
144
+ * terminal-phase detection, automatic reconnection with exponential
145
+ * backoff on transient drops, and manual reconnection as the fallback.
146
+ *
147
+ * **Resilience:** a non-terminal stream drop — whether a thrown transport
148
+ * error (WebKit "Load failed", `fetch failed`, `Unavailable`) or a graceful
149
+ * server close mid-run (idle timeout, load-balancer recycle) — is retried
150
+ * automatically with backoff. The last snapshot stays visible
151
+ * (`isReconnecting`), the access token is re-read on each attempt via the
152
+ * per-request interceptor, and `error` is surfaced only once attempts are
153
+ * exhausted. Completion is decided by the terminal phase, never by the
154
+ * stream merely ending (a graceful close of a running execution reconnects
155
+ * rather than falsely reporting "complete"). Opt out via `autoReconnect: false`.
77
156
  *
78
157
  * **Performance characteristics:**
79
158
  * - Non-terminal snapshots are coalesced via `requestAnimationFrame`
@@ -122,6 +201,17 @@ export function useExecutionStream(
122
201
  }
123
202
  const store = options?.store ?? internalStoreRef.current!;
124
203
 
204
+ // -- Reconnect ------------------------------------------------------------
205
+ const [connectKey, setConnectKey] = useState(0);
206
+
207
+ // Self-heal-once guard for the connect-timeout watchdog. A ref so it survives
208
+ // the reconnect that the watchdog itself triggers (the controller is reset on
209
+ // every teardown and therefore cannot hold it). Reset only on a genuinely
210
+ // fresh start — a new execution, a healthy snapshot, or a manual reconnect —
211
+ // never on the watchdog's own self-heal hop, so the policy is exactly:
212
+ // first silent timeout → reconnect once; second → surface.
213
+ const connectAutoRetriedRef = useRef(false);
214
+
125
215
  // -- Controller setup -----------------------------------------------------
126
216
  const controllerRef = useRef<StreamController | null>(null);
127
217
  if (!controllerRef.current) {
@@ -136,22 +226,53 @@ export function useExecutionStream(
136
226
  store.setStreamState(state);
137
227
  });
138
228
  },
229
+ onConnectTimeout() {
230
+ if (connectAutoRetriedRef.current) {
231
+ // Already self-healed once and still silent — surface an actionable
232
+ // signal. The store dedupes, so this never causes a render storm.
233
+ store.setConnectTimedOut(true);
234
+ } else {
235
+ // First silent timeout: re-establish the subscription once. Most
236
+ // transient hangs (buffering proxy, cold start) clear on the retry.
237
+ connectAutoRetriedRef.current = true;
238
+ setConnectKey((k) => k + 1);
239
+ }
240
+ },
241
+ setSlow(value) {
242
+ store.setSlow(value);
243
+ },
139
244
  };
140
- controllerRef.current = new StreamController(sink);
245
+ controllerRef.current = new StreamController(sink, undefined, undefined, {
246
+ connectTimeoutMs: options?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
247
+ slowThresholdMs: options?.slowThresholdMs ?? DEFAULT_SLOW_THRESHOLD_MS,
248
+ });
141
249
  }
142
250
  const controller = controllerRef.current;
143
251
 
144
- // -- Reconnect ------------------------------------------------------------
145
- const [connectKey, setConnectKey] = useState(0);
146
252
  const reconnect = useCallback(() => {
253
+ // A manual retry gives a fresh self-heal budget and clears the surfaced
254
+ // timeout so the affordance disappears the instant the user acts.
255
+ connectAutoRetriedRef.current = false;
256
+ store.setConnectTimedOut(false);
147
257
  setConnectKey((k) => k + 1);
148
- }, []);
258
+ }, [store]);
149
259
 
150
260
  // -- Stream rate instrumentation ------------------------------------------
151
261
  const streamRate = useStreamRate();
152
262
  const streamRateRef = useRef(streamRate);
153
263
  streamRateRef.current = streamRate;
154
264
 
265
+ // -- Reconnect config (ref-backed so option identity churn never resubscribes)
266
+ const autoReconnect = options?.autoReconnect ?? true;
267
+ const reconnectOptions = options?.reconnectOptions;
268
+ const configRef = useRef({ autoReconnect, reconnectOptions });
269
+ configRef.current = { autoReconnect, reconnectOptions };
270
+
271
+ // Tracks the execution the store currently holds, so we reset the store on
272
+ // a genuine identity change (A → B) but preserve it across reconnects of the
273
+ // SAME execution. Mirrors useWorkflowExecutionEventStream / useFetch.
274
+ const prevExecutionIdRef = useRef<string | null>(null);
275
+
155
276
  // -- Subscription effect --------------------------------------------------
156
277
  // Note: controller, store, and streamRate are ref-backed stable objects —
157
278
  // they MUST NOT appear in the deps array. Including them would cause
@@ -160,45 +281,126 @@ export function useExecutionStream(
160
281
  if (!executionId) {
161
282
  controller.reset();
162
283
  store.reset();
284
+ prevExecutionIdRef.current = null;
285
+ connectAutoRetriedRef.current = false;
163
286
  return;
164
287
  }
165
288
 
289
+ // Reset only when switching to a different execution. Crucially we do NOT
290
+ // reset the store on reconnect (connectKey bump) or on cleanup — that
291
+ // would wipe the conversation to an empty "Connecting…" on every retry.
292
+ // The full-snapshot subscribe re-delivers the entire state on reconnect,
293
+ // so keeping the last-known-good snapshot is both correct and seamless.
294
+ if (
295
+ prevExecutionIdRef.current !== null &&
296
+ prevExecutionIdRef.current !== executionId
297
+ ) {
298
+ store.reset();
299
+ // A genuinely different execution earns a fresh self-heal budget; the
300
+ // bump that re-runs this effect on reconnect must NOT reset the guard.
301
+ connectAutoRetriedRef.current = false;
302
+ }
303
+ prevExecutionIdRef.current = executionId;
304
+
166
305
  const abortController = new AbortController();
306
+ const signal = abortController.signal;
167
307
  controller.start(executionId);
168
308
 
169
309
  (async () => {
170
- try {
171
- for await (const snapshot of stigmer.agentExecution.subscribe(
172
- executionId,
173
- abortController.signal,
174
- )) {
175
- if (abortController.signal.aborted) return;
176
-
177
- controller.handleSnapshot(snapshot);
178
- streamRateRef.current.tick(
179
- snapshot.status?.messages?.length ?? 0,
180
- );
181
-
182
- const phase =
183
- snapshot.status?.phase ??
184
- ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
185
- if (isTerminalPhase(phase)) break;
310
+ const { autoReconnect: auto, reconnectOptions: backoff } =
311
+ configRef.current;
312
+ const maxAttempts = backoff?.maxAttempts ?? DEFAULT_RECONNECT_MAX_ATTEMPTS;
313
+
314
+ // 1-based count of consecutive failed attempts. Reset to 0 by any
315
+ // successful snapshot, so each healthy stretch gets a fresh backoff
316
+ // budget rather than inheriting the previous outage's attempt count.
317
+ let attempt = 0;
318
+
319
+ // Schedule the next retry after `error`, or stop. Returns `true` when
320
+ // the loop should continue (a retry was scheduled), `false` when it
321
+ // should exit (opted out, exhausted, or aborted). Shared by the
322
+ // thrown-error and premature-end paths so both converge on one policy.
323
+ const scheduleRetry = async (error: Error): Promise<boolean> => {
324
+ if (!auto || attempt >= maxAttempts) {
325
+ controller.handleError(error);
326
+ return false;
186
327
  }
328
+ attempt += 1;
329
+ controller.handleReconnecting(attempt, error);
330
+ try {
331
+ await sleep(computeBackoffDelay(attempt, backoff), signal);
332
+ } catch {
333
+ return false; // aborted mid-backoff
334
+ }
335
+ return !signal.aborted;
336
+ };
337
+
338
+ while (!signal.aborted) {
339
+ let sawTerminal = false;
340
+ try {
341
+ for await (const snapshot of stigmer.agentExecution.subscribe(
342
+ executionId,
343
+ signal,
344
+ )) {
345
+ if (signal.aborted) return;
346
+
347
+ attempt = 0; // a snapshot proves the connection is healthy
348
+ // The first snapshot also resolves the connect watchdog: clear any
349
+ // surfaced timeout and refresh the self-heal budget for any future
350
+ // silent stretch on this same execution.
351
+ connectAutoRetriedRef.current = false;
352
+ store.setConnectTimedOut(false);
353
+ controller.handleSnapshot(snapshot);
354
+ streamRateRef.current.tick(snapshot.status?.messages?.length ?? 0);
355
+
356
+ const phase =
357
+ snapshot.status?.phase ??
358
+ ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
359
+ if (isTerminalPhase(phase)) {
360
+ sawTerminal = true;
361
+ break;
362
+ }
363
+ }
364
+ } catch (err) {
365
+ if (signal.aborted) return;
366
+ const error = toError(err);
367
+ // Only known-transient transport noise is retried. A non-transient
368
+ // error (not-found, invalid-argument, …) is deterministic — the
369
+ // same request would fail identically, so surface it immediately.
370
+ if (!auto || !isTransientStreamError(error)) {
371
+ controller.handleError(error);
372
+ return;
373
+ }
374
+ if (await scheduleRetry(error)) continue;
375
+ return;
376
+ }
377
+
378
+ if (signal.aborted) return;
187
379
 
188
- if (!abortController.signal.aborted) {
380
+ if (sawTerminal) {
381
+ // handleSnapshot already transitioned to `complete`; flush any
382
+ // buffered frame and finish. Completion is decided by the terminal
383
+ // phase, never by the stream merely ending.
189
384
  controller.handleStreamEnd();
190
385
  streamRateRef.current.summary();
386
+ return;
191
387
  }
192
- } catch (err) {
193
- if (abortController.signal.aborted) return;
194
- controller.handleError(toError(err));
388
+
389
+ // The iterator finished without a terminal phase: the server closed a
390
+ // still-running stream (idle timeout, load-balancer recycle, pod
391
+ // restart). This is transient by definition — reconnect and the next
392
+ // full snapshot reconciles whatever changed (including, if it ended
393
+ // meanwhile, the terminal state we missed).
394
+ if (await scheduleRetry(new Error("The connection was interrupted."))) {
395
+ continue;
396
+ }
397
+ return;
195
398
  }
196
399
  })();
197
400
 
198
401
  return () => {
199
402
  abortController.abort();
200
403
  controller.reset();
201
- store.reset();
202
404
  };
203
405
  }, [executionId, stigmer, connectKey]);
204
406
 
@@ -208,6 +410,11 @@ export function useExecutionStream(
208
410
  store.subscribe,
209
411
  store.getStreamState,
210
412
  );
413
+ const connectTimedOut = useSyncExternalStore(
414
+ store.subscribe,
415
+ store.getConnectTimedOut,
416
+ );
417
+ const isSlow = useSyncExternalStore(store.subscribe, store.getSlow);
211
418
 
212
419
  // -- Derive public return values ------------------------------------------
213
420
  const phase = useMemo(
@@ -218,8 +425,21 @@ export function useExecutionStream(
218
425
 
219
426
  const isStreaming = streamState.stage === "streaming";
220
427
  const isConnecting = streamState.stage === "connecting";
221
- const error =
222
- streamState.stage === "error" ? streamState.error : null;
428
+ const isReconnecting = streamState.stage === "reconnecting";
429
+ const reconnectAttempt =
430
+ streamState.stage === "reconnecting" ? streamState.attempt : 0;
431
+ const error = streamState.stage === "error" ? streamState.error : null;
223
432
 
224
- return { execution, phase, isStreaming, isConnecting, error, reconnect };
433
+ return {
434
+ execution,
435
+ phase,
436
+ isStreaming,
437
+ isConnecting,
438
+ isReconnecting,
439
+ reconnectAttempt,
440
+ error,
441
+ connectTimedOut,
442
+ isSlow,
443
+ reconnect,
444
+ };
225
445
  }
@@ -41,6 +41,8 @@ export interface VirtualizedThreadProps {
41
41
  readonly org?: string;
42
42
  readonly planActionsDisabled?: boolean;
43
43
  readonly centerContent?: boolean;
44
+ readonly onRetrySend?: () => void;
45
+ readonly onRetryExecution?: (message: string) => void;
44
46
  }
45
47
 
46
48
  // ---------------------------------------------------------------------------
@@ -101,6 +103,8 @@ export function VirtualizedThread({
101
103
  org,
102
104
  planActionsDisabled,
103
105
  centerContent,
106
+ onRetrySend,
107
+ onRetryExecution,
104
108
  }: VirtualizedThreadProps) {
105
109
  const virtuosoRef = useRef<VirtuosoHandle>(null);
106
110
  const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -132,8 +136,10 @@ export function VirtualizedThread({
132
136
  onBuildFromPlan,
133
137
  org,
134
138
  planActionsDisabled,
139
+ onRetrySend,
140
+ onRetryExecution,
135
141
  }),
136
- [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled],
142
+ [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled, onRetrySend, onRetryExecution],
137
143
  );
138
144
 
139
145
  return (
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ computeBackoffDelay,
4
+ sleep,
5
+ AbortError,
6
+ DEFAULT_RECONNECT_BASE_DELAY_MS,
7
+ DEFAULT_RECONNECT_MAX_DELAY_MS,
8
+ } from "../backoff";
9
+
10
+ describe("computeBackoffDelay", () => {
11
+ // random=()=>1 collapses full jitter to its upper bound, exposing the raw
12
+ // exponential schedule for exact assertions.
13
+ const noJitter = () => 1;
14
+
15
+ it("grows exponentially from the base delay", () => {
16
+ expect(computeBackoffDelay(1, undefined, noJitter)).toBe(
17
+ DEFAULT_RECONNECT_BASE_DELAY_MS,
18
+ );
19
+ expect(computeBackoffDelay(2, undefined, noJitter)).toBe(2_000);
20
+ expect(computeBackoffDelay(3, undefined, noJitter)).toBe(4_000);
21
+ expect(computeBackoffDelay(5, undefined, noJitter)).toBe(16_000);
22
+ });
23
+
24
+ it("caps at maxDelayMs", () => {
25
+ // attempt 6 → 32_000 raw, clamped to the 30_000 ceiling.
26
+ expect(computeBackoffDelay(6, undefined, noJitter)).toBe(
27
+ DEFAULT_RECONNECT_MAX_DELAY_MS,
28
+ );
29
+ expect(computeBackoffDelay(50, undefined, noJitter)).toBe(
30
+ DEFAULT_RECONNECT_MAX_DELAY_MS,
31
+ );
32
+ });
33
+
34
+ it("applies full jitter within [0, capped]", () => {
35
+ expect(computeBackoffDelay(3, undefined, () => 0)).toBe(0);
36
+ expect(computeBackoffDelay(3, undefined, () => 0.5)).toBe(2_000);
37
+ for (let i = 0; i < 200; i++) {
38
+ const d = computeBackoffDelay(4); // real Math.random
39
+ expect(d).toBeGreaterThanOrEqual(0);
40
+ expect(d).toBeLessThanOrEqual(8_000);
41
+ }
42
+ });
43
+
44
+ it("honors custom options", () => {
45
+ const opts = { baseDelayMs: 100, factor: 3, maxDelayMs: 1_000 };
46
+ expect(computeBackoffDelay(1, opts, noJitter)).toBe(100);
47
+ expect(computeBackoffDelay(2, opts, noJitter)).toBe(300);
48
+ expect(computeBackoffDelay(3, opts, noJitter)).toBe(900);
49
+ expect(computeBackoffDelay(4, opts, noJitter)).toBe(1_000); // 2700 capped
50
+ });
51
+
52
+ it("treats attempt < 1 as the first attempt", () => {
53
+ expect(computeBackoffDelay(0, undefined, noJitter)).toBe(
54
+ DEFAULT_RECONNECT_BASE_DELAY_MS,
55
+ );
56
+ expect(computeBackoffDelay(-5, undefined, noJitter)).toBe(
57
+ DEFAULT_RECONNECT_BASE_DELAY_MS,
58
+ );
59
+ });
60
+ });
61
+
62
+ describe("sleep", () => {
63
+ beforeEach(() => vi.useFakeTimers());
64
+ afterEach(() => vi.useRealTimers());
65
+
66
+ it("resolves after the delay", async () => {
67
+ const settled = vi.fn();
68
+ const p = sleep(1_000).then(settled);
69
+ await vi.advanceTimersByTimeAsync(999);
70
+ expect(settled).not.toHaveBeenCalled();
71
+ await vi.advanceTimersByTimeAsync(1);
72
+ await p;
73
+ expect(settled).toHaveBeenCalledOnce();
74
+ });
75
+
76
+ it("rejects immediately with AbortError when the signal is already aborted", async () => {
77
+ const ac = new AbortController();
78
+ ac.abort();
79
+ await expect(sleep(1_000, ac.signal)).rejects.toBeInstanceOf(AbortError);
80
+ });
81
+
82
+ it("rejects when aborted mid-wait and leaves no pending timer", async () => {
83
+ const ac = new AbortController();
84
+ const p = sleep(10_000, ac.signal);
85
+ ac.abort();
86
+ await expect(p).rejects.toBeInstanceOf(AbortError);
87
+ // No timer should survive the abort — advancing time settles nothing.
88
+ expect(vi.getTimerCount()).toBe(0);
89
+ });
90
+
91
+ it("does not reject after resolving (listener removed on success)", async () => {
92
+ const ac = new AbortController();
93
+ const p = sleep(500, ac.signal);
94
+ await vi.advanceTimersByTimeAsync(500);
95
+ await expect(p).resolves.toBeUndefined();
96
+ // Aborting after the fact must not produce an unhandled rejection.
97
+ expect(() => ac.abort()).not.toThrow();
98
+ });
99
+ });