@stigmer/react 3.0.8-dev.20260612100207 → 3.0.8-dev.20260613041848

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 (59) hide show
  1. package/execution/useExecutionStream.d.ts +49 -5
  2. package/execution/useExecutionStream.d.ts.map +1 -1
  3. package/execution/useExecutionStream.js +118 -18
  4. package/execution/useExecutionStream.js.map +1 -1
  5. package/internal/backoff.d.ts +61 -0
  6. package/internal/backoff.d.ts.map +1 -0
  7. package/internal/backoff.js +79 -0
  8. package/internal/backoff.js.map +1 -0
  9. package/internal/store/conversation-store.d.ts +12 -0
  10. package/internal/store/conversation-store.d.ts.map +1 -1
  11. package/internal/store/conversation-store.js +7 -0
  12. package/internal/store/conversation-store.js.map +1 -1
  13. package/internal/store/workflow-execution-event-store.d.ts +12 -0
  14. package/internal/store/workflow-execution-event-store.d.ts.map +1 -1
  15. package/internal/store/workflow-execution-event-store.js +7 -0
  16. package/internal/store/workflow-execution-event-store.js.map +1 -1
  17. package/internal/stream-controller.d.ts +11 -19
  18. package/internal/stream-controller.d.ts.map +1 -1
  19. package/internal/stream-controller.js +24 -1
  20. package/internal/stream-controller.js.map +1 -1
  21. package/package.json +4 -4
  22. package/session/SessionViewer.js +4 -1
  23. package/session/SessionViewer.js.map +1 -1
  24. package/session/useSessionConversation.d.ts +7 -1
  25. package/session/useSessionConversation.d.ts.map +1 -1
  26. package/session/useSessionConversation.js +1 -0
  27. package/session/useSessionConversation.js.map +1 -1
  28. package/src/execution/__tests__/useExecutionStream.test.tsx +184 -0
  29. package/src/execution/useExecutionStream.ts +174 -30
  30. package/src/internal/__tests__/backoff.test.ts +99 -0
  31. package/src/internal/backoff.ts +100 -0
  32. package/src/internal/store/conversation-store.ts +22 -0
  33. package/src/internal/store/workflow-execution-event-store.ts +22 -0
  34. package/src/internal/stream-controller.ts +30 -25
  35. package/src/session/SessionViewer.tsx +27 -0
  36. package/src/session/useSessionConversation.ts +8 -1
  37. package/src/workflow/WorkflowExecutionHeader.tsx +4 -1
  38. package/src/workflow/WorkflowExecutionTimeline.tsx +2 -1
  39. package/src/workflow/__tests__/useWorkflowExecutionEventStream.test.tsx +117 -1
  40. package/src/workflow/execution/useWaterfallEntries.ts +2 -1
  41. package/src/workflow/useWorkflowExecutionEventStream.ts +122 -41
  42. package/src/workflow/waterfall/WaterfallTimeline.tsx +2 -1
  43. package/styles.css +1 -1
  44. package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
  45. package/workflow/WorkflowExecutionHeader.js +3 -1
  46. package/workflow/WorkflowExecutionHeader.js.map +1 -1
  47. package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
  48. package/workflow/WorkflowExecutionTimeline.js +1 -1
  49. package/workflow/WorkflowExecutionTimeline.js.map +1 -1
  50. package/workflow/execution/useWaterfallEntries.d.ts.map +1 -1
  51. package/workflow/execution/useWaterfallEntries.js +1 -1
  52. package/workflow/execution/useWaterfallEntries.js.map +1 -1
  53. package/workflow/useWorkflowExecutionEventStream.d.ts +32 -4
  54. package/workflow/useWorkflowExecutionEventStream.d.ts.map +1 -1
  55. package/workflow/useWorkflowExecutionEventStream.js +75 -32
  56. package/workflow/useWorkflowExecutionEventStream.js.map +1 -1
  57. package/workflow/waterfall/WaterfallTimeline.d.ts.map +1 -1
  58. package/workflow/waterfall/WaterfallTimeline.js +1 -1
  59. package/workflow/waterfall/WaterfallTimeline.js.map +1 -1
@@ -307,6 +307,7 @@ function ConversationColumn({
307
307
  className="flex-1"
308
308
  />
309
309
  <div className="mx-auto w-full max-w-3xl">
310
+ {conv.isReconnecting && <ReconnectingIndicator />}
310
311
  {conv.streamError && (
311
312
  <StreamErrorBanner
312
313
  error={conv.streamError}
@@ -546,6 +547,32 @@ function SendErrorBanner({ error }: { error: Error }) {
546
547
  );
547
548
  }
548
549
 
550
+ function ReconnectingIndicator() {
551
+ return (
552
+ <div
553
+ role="status"
554
+ aria-live="polite"
555
+ className="flex items-center gap-2 border-t border-border bg-muted px-4 py-2 text-sm text-muted-foreground"
556
+ >
557
+ <svg
558
+ width="14"
559
+ height="14"
560
+ viewBox="0 0 24 24"
561
+ fill="none"
562
+ stroke="currentColor"
563
+ strokeWidth="2"
564
+ strokeLinecap="round"
565
+ strokeLinejoin="round"
566
+ className="shrink-0 animate-spin motion-reduce:animate-none"
567
+ aria-hidden="true"
568
+ >
569
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
570
+ </svg>
571
+ <span className="truncate">Reconnecting…</span>
572
+ </div>
573
+ );
574
+ }
575
+
549
576
  function StreamErrorBanner({
550
577
  error,
551
578
  onReconnect,
@@ -179,7 +179,13 @@ export interface UseSessionConversationReturn {
179
179
  /** Error from session or execution list loading, or `null` when healthy. */
180
180
  readonly loadError: Error | null;
181
181
 
182
- /** Error from the execution stream, or `null` when healthy. */
182
+ /**
183
+ * `true` while the execution stream is auto-reconnecting after a transient
184
+ * drop. The conversation stays visible and `streamError` remains `null` —
185
+ * surface a subtle "Reconnecting…" hint rather than an error banner.
186
+ */
187
+ readonly isReconnecting: boolean;
188
+ /** Error from the execution stream, or `null` when healthy or reconnecting. */
183
189
  readonly streamError: Error | null;
184
190
  /** Reset the stream error and re-establish the execution stream subscription. */
185
191
  readonly reconnectStream: () => void;
@@ -463,6 +469,7 @@ export function useSessionConversation(
463
469
  isLoading,
464
470
  loadError,
465
471
 
472
+ isReconnecting: stream.isReconnecting,
466
473
  streamError: stream.error,
467
474
  reconnectStream: stream.reconnect,
468
475
  };
@@ -82,7 +82,10 @@ export const WorkflowExecutionHeader = memo(function WorkflowExecutionHeader({
82
82
  const isRunning = RUNNING_PHASES.has(phase);
83
83
  const isPaused = phase === ExecutionPhase.EXECUTION_PAUSED;
84
84
  const isFailed = phase === ExecutionPhase.EXECUTION_FAILED;
85
- const isLive = streamState.stage === "streaming" || streamState.stage === "connecting";
85
+ const isLive =
86
+ streamState.stage === "streaming" ||
87
+ streamState.stage === "connecting" ||
88
+ streamState.stage === "reconnecting";
86
89
 
87
90
  return (
88
91
  <header className={cn("flex items-center gap-3 border-b border-border px-4 py-3", className)}>
@@ -77,7 +77,8 @@ export const WorkflowExecutionTimeline = memo(function WorkflowExecutionTimeline
77
77
  }
78
78
  }, [events.length]);
79
79
 
80
- const isLive = streamState.stage === "streaming";
80
+ const isLive =
81
+ streamState.stage === "streaming" || streamState.stage === "reconnecting";
81
82
  const isConnecting = streamState.stage === "connecting";
82
83
  const isComplete = streamState.stage === "complete";
83
84
  const isError = streamState.stage === "error";
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { renderHook, act } from "@testing-library/react";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
4
  import { createElement } from "react";
5
5
  import { create } from "@bufbuild/protobuf";
@@ -417,3 +417,119 @@ describe("useWorkflowExecutionEventStream", () => {
417
417
  expect(result.current.events[0]?.taskName).toBe("beta");
418
418
  });
419
419
  });
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // Auto-reconnect (#174)
423
+ // ---------------------------------------------------------------------------
424
+
425
+ describe("useWorkflowExecutionEventStream — auto-reconnect", () => {
426
+ it("auto-reconnects on a transient drop and resumes from the latest sequence", async () => {
427
+ let call = 0;
428
+ const subscribeEvents = vi.fn((_req: any) => {
429
+ call += 1;
430
+ if (call === 1) {
431
+ return (async function* () {
432
+ yield makeTaskStartedEvent(5, "t5");
433
+ throw new TypeError("Load failed");
434
+ })();
435
+ }
436
+ return (async function* () {
437
+ yield makeTaskStartedEvent(6, "t6");
438
+ })();
439
+ });
440
+ const client = makeMockClient({ subscribeEvents });
441
+
442
+ const { result } = renderHook(
443
+ () =>
444
+ useWorkflowExecutionEventStream("wex-001", {
445
+ executionPhase: PHASE.IN_PROGRESS,
446
+ reconnectOptions: { baseDelayMs: 5, maxDelayMs: 5 },
447
+ }),
448
+ { wrapper: createWrapper(client) },
449
+ );
450
+
451
+ await waitFor(() => expect(subscribeEvents).toHaveBeenCalledTimes(2));
452
+ await waitFor(() => expect(result.current.events).toHaveLength(2));
453
+
454
+ // The resumed subscription continues after the last received sequence (5),
455
+ // so no events are lost or duplicated.
456
+ const secondReq = (subscribeEvents.mock.calls as unknown[][])[1]?.[0] as {
457
+ afterSequence: bigint;
458
+ };
459
+ expect(secondReq.afterSequence).toBe(BigInt(5));
460
+ expect(result.current.error).toBeNull();
461
+ });
462
+
463
+ it("surfaces an error after exhausting reconnect attempts", async () => {
464
+ const subscribeEvents = vi.fn(
465
+ () =>
466
+ (async function* () {
467
+ throw new TypeError("Load failed");
468
+ })(),
469
+ );
470
+ const client = makeMockClient({ subscribeEvents });
471
+
472
+ const { result } = renderHook(
473
+ () =>
474
+ useWorkflowExecutionEventStream("wex-001", {
475
+ executionPhase: PHASE.IN_PROGRESS,
476
+ reconnectOptions: { baseDelayMs: 1, maxDelayMs: 1, maxAttempts: 2 },
477
+ }),
478
+ { wrapper: createWrapper(client) },
479
+ );
480
+
481
+ await waitFor(() => expect(result.current.error).not.toBeNull(), {
482
+ timeout: 2000,
483
+ });
484
+ expect(result.current.streamState.stage).toBe("error");
485
+ // 1 initial attempt + 2 retries.
486
+ expect(subscribeEvents).toHaveBeenCalledTimes(3);
487
+ });
488
+
489
+ it("marks unsupported on UNIMPLEMENTED without retrying", async () => {
490
+ const subscribeEvents = vi.fn(
491
+ () =>
492
+ (async function* () {
493
+ throw new Error("UNIMPLEMENTED: event streaming not supported");
494
+ })(),
495
+ );
496
+ const client = makeMockClient({ subscribeEvents });
497
+
498
+ const { result } = renderHook(
499
+ () =>
500
+ useWorkflowExecutionEventStream("wex-001", {
501
+ executionPhase: PHASE.IN_PROGRESS,
502
+ reconnectOptions: { baseDelayMs: 1, maxDelayMs: 1 },
503
+ }),
504
+ { wrapper: createWrapper(client) },
505
+ );
506
+
507
+ await waitFor(() =>
508
+ expect(result.current.streamState.stage).toBe("unsupported"),
509
+ );
510
+ await new Promise((r) => setTimeout(r, 20));
511
+ expect(subscribeEvents).toHaveBeenCalledTimes(1);
512
+ });
513
+
514
+ it("treats a clean stream end as completion (never a reconnect loop)", async () => {
515
+ const subscribeEvents = vi.fn(async function* () {
516
+ /* no events, then clean end */
517
+ });
518
+ const client = makeMockClient({ subscribeEvents });
519
+
520
+ const { result } = renderHook(
521
+ () =>
522
+ useWorkflowExecutionEventStream("wex-001", {
523
+ executionPhase: PHASE.IN_PROGRESS,
524
+ reconnectOptions: { baseDelayMs: 1, maxDelayMs: 1 },
525
+ }),
526
+ { wrapper: createWrapper(client) },
527
+ );
528
+
529
+ await waitFor(() =>
530
+ expect(result.current.streamState.stage).toBe("complete"),
531
+ );
532
+ await new Promise((r) => setTimeout(r, 20));
533
+ expect(subscribeEvents).toHaveBeenCalledTimes(1);
534
+ });
535
+ });
@@ -61,7 +61,8 @@ export function useWaterfallEntries({
61
61
  executionStartIso,
62
62
  executionDurationMs,
63
63
  }: UseWaterfallEntriesOptions): UseWaterfallEntriesReturn {
64
- const isLive = streamState.stage === "streaming";
64
+ const isLive =
65
+ streamState.stage === "streaming" || streamState.stage === "reconnecting";
65
66
  const execStartEpoch = useMemo(
66
67
  () => (executionStartIso ? new Date(executionStartIso).getTime() : 0),
67
68
  [executionStartIso],
@@ -16,8 +16,15 @@ import {
16
16
  SubscribeEventsRequestSchema,
17
17
  } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
18
18
  import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/enum_pb";
19
+ import { isTransientStreamError } from "@stigmer/sdk";
19
20
  import { useStigmer } from "../hooks";
20
21
  import { toError } from "../internal/toError";
22
+ import {
23
+ computeBackoffDelay,
24
+ sleep,
25
+ DEFAULT_RECONNECT_MAX_ATTEMPTS,
26
+ type BackoffOptions,
27
+ } from "../internal/backoff";
21
28
  import {
22
29
  WorkflowExecutionEventStore,
23
30
  type WorkflowEventStreamState,
@@ -41,6 +48,20 @@ export interface UseWorkflowExecutionEventStreamOptions {
41
48
  * (terminal). When omitted, defaults to live streaming.
42
49
  */
43
50
  readonly executionPhase?: ExecutionPhase;
51
+ /**
52
+ * Automatically re-establish the live subscription with exponential
53
+ * backoff when it drops with a transient transport error, resuming from
54
+ * the last received `sequence_number` (no events lost). Defaults to `true`.
55
+ */
56
+ readonly autoReconnect?: boolean;
57
+ /**
58
+ * Tune the auto-reconnect backoff schedule and attempt cap. Omitted fields
59
+ * fall back to SDK defaults (base 1s, ×2, max 30s, 10 attempts).
60
+ */
61
+ readonly reconnectOptions?: BackoffOptions & {
62
+ /** Max attempts before surfacing a terminal `error`. */
63
+ readonly maxAttempts?: number;
64
+ };
44
65
  }
45
66
 
46
67
  /** Return value of {@link useWorkflowExecutionEventStream}. */
@@ -59,9 +80,20 @@ export interface UseWorkflowExecutionEventStreamReturn {
59
80
  readonly isStreaming: boolean;
60
81
  /** `true` while connecting to the event stream. */
61
82
  readonly isConnecting: boolean;
62
- /** Error from the last failed stream attempt, or `null`. */
83
+ /**
84
+ * `true` while a transient drop is being retried automatically. Accumulated
85
+ * events stay visible and `error` remains `null`; on success the
86
+ * subscription resumes from the last sequence number with no events lost.
87
+ */
88
+ readonly isReconnecting: boolean;
89
+ /** 1-based count of the in-flight reconnect attempt; `0` when not reconnecting. */
90
+ readonly reconnectAttempt: number;
91
+ /**
92
+ * Error from the last failed stream attempt, or `null`. Set only once
93
+ * auto-reconnect exhausts its attempts (or for a non-transient failure).
94
+ */
63
95
  readonly error: Error | null;
64
- /** Re-establish the stream subscription. */
96
+ /** Re-establish the stream subscription (manual fallback). */
65
97
  readonly reconnect: () => void;
66
98
  }
67
99
 
@@ -99,8 +131,10 @@ export function isRecoveryTransition(
99
131
  * integration.
100
132
  *
101
133
  * For running executions: subscribes via `subscribeEvents` with
102
- * replay+live-tail. On disconnect, reconnects from the last received
103
- * sequence number.
134
+ * replay+live-tail. A transient drop auto-reconnects with exponential
135
+ * backoff, resuming from the last received sequence number so no events are
136
+ * lost; `error` is surfaced only once retries are exhausted. A clean stream
137
+ * end is the server's completion signal and is never retried.
104
138
  *
105
139
  * For terminal executions: loads the full event log via paginated
106
140
  * `getEventLog` calls.
@@ -136,6 +170,8 @@ export function useWorkflowExecutionEventStream(
136
170
 
137
171
  const eventTypes = options?.eventTypes;
138
172
  const executionPhase = options?.executionPhase;
173
+ const autoReconnect = options?.autoReconnect ?? true;
174
+ const reconnectOptions = options?.reconnectOptions;
139
175
 
140
176
  // Stable ref for values that should not trigger re-subscription
141
177
  const storeRef = useRef(store);
@@ -239,50 +275,90 @@ export function useWorkflowExecutionEventStream(
239
275
  }
240
276
  })();
241
277
  } else {
242
- // Live-stream events for running executions
278
+ // Live-stream events for running executions, with auto-reconnect.
243
279
  currentStore.setStreamState({ stage: "connecting", executionId });
244
280
 
245
281
  (async () => {
246
- try {
247
- const afterSequence = currentStore.getLatestSequence();
248
-
249
- for await (const event of stigmer.workflowExecution.subscribeEvents(
250
- create(SubscribeEventsRequestSchema, {
251
- executionId,
252
- afterSequence,
253
- eventTypes: eventTypes ? [...eventTypes] : [],
254
- }),
255
- abortController.signal,
256
- )) {
257
- if (abortController.signal.aborted) return;
258
-
259
- startTransition(() => {
260
- currentStore.appendEvents([event]);
261
- if (currentStore.getStreamState().stage === "connecting") {
262
- currentStore.setStreamState({ stage: "streaming", executionId });
263
- }
264
- });
265
- }
266
-
267
- if (!abortController.signal.aborted) {
268
- currentStore.setStreamState({ stage: "complete", executionId });
269
- }
270
- } catch (err) {
271
- if (abortController.signal.aborted) return;
272
-
273
- const error = toError(err);
274
- const isUnimplemented =
275
- error.message.includes("UNIMPLEMENTED") ||
276
- error.message.includes("unimplemented");
277
-
278
- if (isUnimplemented) {
279
- currentStore.setStreamState({ stage: "unsupported", executionId });
280
- } else {
282
+ const signal = abortController.signal;
283
+ const maxAttempts =
284
+ reconnectOptions?.maxAttempts ?? DEFAULT_RECONNECT_MAX_ATTEMPTS;
285
+
286
+ // 1-based count of consecutive failed attempts, reset by any event.
287
+ let attempt = 0;
288
+
289
+ while (!signal.aborted) {
290
+ try {
291
+ // Re-read each attempt: after a drop we resume from the last
292
+ // sequence number, so the server replays only what we missed and
293
+ // no events are lost or duplicated.
294
+ const afterSequence = currentStore.getLatestSequence();
295
+
296
+ for await (const event of stigmer.workflowExecution.subscribeEvents(
297
+ create(SubscribeEventsRequestSchema, {
298
+ executionId,
299
+ afterSequence,
300
+ eventTypes: eventTypes ? [...eventTypes] : [],
301
+ }),
302
+ signal,
303
+ )) {
304
+ if (signal.aborted) return;
305
+
306
+ attempt = 0; // an event proves the connection is healthy
307
+ startTransition(() => {
308
+ currentStore.appendEvents([event]);
309
+ const stage = currentStore.getStreamState().stage;
310
+ if (stage === "connecting" || stage === "reconnecting") {
311
+ currentStore.setStreamState({ stage: "streaming", executionId });
312
+ }
313
+ });
314
+ }
315
+
316
+ // A clean end of the event stream is the server's completion
317
+ // signal (the execution finished). Unlike the agent snapshot
318
+ // stream, there is no separate terminal marker to re-check, so we
319
+ // must NOT treat this as a premature drop — doing so would loop
320
+ // forever re-subscribing past the final sequence. Transient drops
321
+ // surface as thrown errors (handled below), not a clean end.
322
+ if (!signal.aborted) {
323
+ currentStore.setStreamState({ stage: "complete", executionId });
324
+ }
325
+ return;
326
+ } catch (err) {
327
+ if (signal.aborted) return;
328
+
329
+ const error = toError(err);
330
+ const isUnimplemented =
331
+ error.message.includes("UNIMPLEMENTED") ||
332
+ error.message.includes("unimplemented");
333
+
334
+ // A server without event-stream support will never recover —
335
+ // surface the unsupported state immediately, never retry.
336
+ if (isUnimplemented) {
337
+ currentStore.setStreamState({ stage: "unsupported", executionId });
338
+ return;
339
+ }
340
+
341
+ if (
342
+ !autoReconnect ||
343
+ !isTransientStreamError(error) ||
344
+ attempt >= maxAttempts
345
+ ) {
346
+ currentStore.setStreamState({ stage: "error", executionId, error });
347
+ return;
348
+ }
349
+
350
+ attempt += 1;
281
351
  currentStore.setStreamState({
282
- stage: "error",
352
+ stage: "reconnecting",
283
353
  executionId,
354
+ attempt,
284
355
  error,
285
356
  });
357
+ try {
358
+ await sleep(computeBackoffDelay(attempt, reconnectOptions), signal);
359
+ } catch {
360
+ return; // aborted mid-backoff
361
+ }
286
362
  }
287
363
  }
288
364
  })();
@@ -302,6 +378,9 @@ export function useWorkflowExecutionEventStream(
302
378
 
303
379
  const isStreaming = streamState.stage === "streaming";
304
380
  const isConnecting = streamState.stage === "connecting";
381
+ const isReconnecting = streamState.stage === "reconnecting";
382
+ const reconnectAttempt =
383
+ streamState.stage === "reconnecting" ? streamState.attempt : 0;
305
384
  const error = streamState.stage === "error" ? streamState.error : null;
306
385
 
307
386
  return {
@@ -312,6 +391,8 @@ export function useWorkflowExecutionEventStream(
312
391
  totalTasks,
313
392
  isStreaming,
314
393
  isConnecting,
394
+ isReconnecting,
395
+ reconnectAttempt,
315
396
  error,
316
397
  reconnect,
317
398
  };
@@ -89,7 +89,8 @@ export const WaterfallTimeline = memo(function WaterfallTimeline({
89
89
 
90
90
  // Empty state
91
91
  if (entries.length === 0) {
92
- const isConnecting = streamState.stage === "connecting";
92
+ const isConnecting =
93
+ streamState.stage === "connecting" || streamState.stage === "reconnecting";
93
94
  return (
94
95
  <div className={cn("flex items-center justify-center text-xs text-[var(--stgm-muted-foreground,#737373)]", className)}>
95
96
  {isConnecting ? "Loading timeline…" : "No task data available"}