@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
@@ -10,6 +10,7 @@ import {
10
10
  StreamController,
11
11
  type StreamControllerSink,
12
12
  } from "../stream-controller";
13
+ import type { StreamState } from "../store/conversation-store";
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // Helpers
@@ -32,19 +33,64 @@ function makeSnapshot(
32
33
  function createTestSink(): StreamControllerSink & {
33
34
  snapshots: AgentExecution[];
34
35
  states: Array<{ stage: string; executionId?: string; error?: Error }>;
36
+ connectTimeouts: number;
37
+ slow: boolean[];
35
38
  } {
36
39
  const snapshots: AgentExecution[] = [];
37
40
  const states: Array<{ stage: string; executionId?: string; error?: Error }> =
38
41
  [];
39
- return {
42
+ const slow: boolean[] = [];
43
+ const sink = {
40
44
  snapshots,
41
45
  states,
42
- ingestSnapshot(snapshot) {
46
+ connectTimeouts: 0,
47
+ slow,
48
+ ingestSnapshot(snapshot: AgentExecution) {
43
49
  snapshots.push(snapshot);
44
50
  },
45
- setStreamState(state) {
51
+ setStreamState(state: StreamState) {
46
52
  states.push(state as never);
47
53
  },
54
+ onConnectTimeout() {
55
+ sink.connectTimeouts += 1;
56
+ },
57
+ setSlow(value: boolean) {
58
+ slow.push(value);
59
+ },
60
+ };
61
+ return sink;
62
+ }
63
+
64
+ /**
65
+ * Manual watchdog clock: records armed timers without firing them. Tests fire
66
+ * a specific timer explicitly by its configured duration, so no real time is
67
+ * consumed and existing (non-watchdog) tests are unaffected — their timers are
68
+ * simply recorded and discarded.
69
+ */
70
+ function createManualTimers() {
71
+ const timers = new Map<number, { cb: () => void; ms: number }>();
72
+ let nextId = 1;
73
+ return {
74
+ setTimer(cb: () => void, ms: number): number {
75
+ const id = nextId++;
76
+ timers.set(id, { cb, ms });
77
+ return id;
78
+ },
79
+ clearTimer(id: number): void {
80
+ timers.delete(id);
81
+ },
82
+ /** Fire every currently-armed timer whose duration equals `ms`. */
83
+ fire(ms: number): void {
84
+ for (const [id, t] of [...timers]) {
85
+ if (t.ms === ms) {
86
+ timers.delete(id);
87
+ t.cb();
88
+ }
89
+ }
90
+ },
91
+ get pending(): number {
92
+ return timers.size;
93
+ },
48
94
  };
49
95
  }
50
96
 
@@ -80,19 +126,27 @@ function createSynchronousScheduler() {
80
126
  // Tests
81
127
  // ---------------------------------------------------------------------------
82
128
 
129
+ // Distinct, small watchdog durations so a test can fire one timer without the
130
+ // other (the manual clock fires by exact duration).
131
+ const CONNECT_MS = 100;
132
+ const SLOW_MS = 500;
133
+
83
134
  describe("StreamController", () => {
84
135
  let sink: ReturnType<typeof createTestSink>;
85
136
  let scheduler: ReturnType<typeof createSynchronousScheduler>;
137
+ let timers: ReturnType<typeof createManualTimers>;
86
138
  let controller: StreamController;
87
139
 
88
140
  beforeEach(() => {
89
141
  sink = createTestSink();
90
142
  scheduler = createSynchronousScheduler();
91
- controller = new StreamController(
92
- sink,
93
- scheduler.schedule,
94
- scheduler.cancel,
95
- );
143
+ timers = createManualTimers();
144
+ controller = new StreamController(sink, scheduler.schedule, scheduler.cancel, {
145
+ setTimer: timers.setTimer,
146
+ clearTimer: timers.clearTimer,
147
+ connectTimeoutMs: CONNECT_MS,
148
+ slowThresholdMs: SLOW_MS,
149
+ });
96
150
  });
97
151
 
98
152
  describe("initial state", () => {
@@ -393,6 +447,101 @@ describe("StreamController", () => {
393
447
  });
394
448
  });
395
449
 
450
+ describe("watchdog — connect timeout", () => {
451
+ it("fires onConnectTimeout while still connecting", () => {
452
+ controller.start("exec-1");
453
+ timers.fire(CONNECT_MS);
454
+ expect(sink.connectTimeouts).toBe(1);
455
+ });
456
+
457
+ it("does not fire once a first snapshot arrives (connect timer cleared)", () => {
458
+ controller.start("exec-1");
459
+ controller.handleSnapshot(
460
+ makeSnapshot(ExecutionPhase.EXECUTION_IN_PROGRESS),
461
+ );
462
+ timers.fire(CONNECT_MS);
463
+ expect(sink.connectTimeouts).toBe(0);
464
+ });
465
+
466
+ it("does not fire after a terminal snapshot", () => {
467
+ controller.start("exec-1");
468
+ controller.handleSnapshot(
469
+ makeSnapshot(ExecutionPhase.EXECUTION_FAILED),
470
+ );
471
+ timers.fire(CONNECT_MS);
472
+ expect(sink.connectTimeouts).toBe(0);
473
+ });
474
+
475
+ it("does not fire after reset", () => {
476
+ controller.start("exec-1");
477
+ controller.reset();
478
+ timers.fire(CONNECT_MS);
479
+ expect(sink.connectTimeouts).toBe(0);
480
+ });
481
+
482
+ it("stands down while auto-reconnect is in flight", () => {
483
+ controller.start("exec-1");
484
+ controller.handleReconnecting(1, new Error("drop"));
485
+ timers.fire(CONNECT_MS);
486
+ expect(sink.connectTimeouts).toBe(0);
487
+ });
488
+ });
489
+
490
+ describe("watchdog — slow stall", () => {
491
+ it("sets the slow hint after the threshold while streaming", () => {
492
+ controller.start("exec-1");
493
+ controller.handleSnapshot(
494
+ makeSnapshot(ExecutionPhase.EXECUTION_IN_PROGRESS),
495
+ );
496
+ timers.fire(SLOW_MS);
497
+ expect(sink.slow.at(-1)).toBe(true);
498
+ });
499
+
500
+ it("clears the slow hint on the next snapshot", () => {
501
+ controller.start("exec-1");
502
+ timers.fire(SLOW_MS);
503
+ expect(sink.slow.at(-1)).toBe(true);
504
+
505
+ controller.handleSnapshot(
506
+ makeSnapshot(ExecutionPhase.EXECUTION_IN_PROGRESS),
507
+ );
508
+ // Re-arming on a fresh snapshot clears the prior hint.
509
+ expect(sink.slow.at(-1)).toBe(false);
510
+ });
511
+
512
+ it("clears the slow hint on a terminal snapshot", () => {
513
+ controller.start("exec-1");
514
+ timers.fire(SLOW_MS);
515
+ expect(sink.slow.at(-1)).toBe(true);
516
+
517
+ controller.handleSnapshot(
518
+ makeSnapshot(ExecutionPhase.EXECUTION_COMPLETED),
519
+ );
520
+ expect(sink.slow.at(-1)).toBe(false);
521
+ });
522
+
523
+ it("clears the slow hint and timer on reset", () => {
524
+ controller.start("exec-1");
525
+ timers.fire(SLOW_MS);
526
+ controller.reset();
527
+ expect(sink.slow.at(-1)).toBe(false);
528
+ // The (already fired) timer leaves nothing armed; a stray fire is a no-op.
529
+ const before = sink.slow.length;
530
+ timers.fire(SLOW_MS);
531
+ expect(sink.slow.length).toBe(before);
532
+ });
533
+
534
+ it("does not fire after reaching a terminal phase", () => {
535
+ controller.start("exec-1");
536
+ controller.handleSnapshot(
537
+ makeSnapshot(ExecutionPhase.EXECUTION_COMPLETED),
538
+ );
539
+ const before = sink.slow.length;
540
+ timers.fire(SLOW_MS);
541
+ expect(sink.slow.length).toBe(before);
542
+ });
543
+ });
544
+
396
545
  describe("default constructor (browser rAF binding)", () => {
397
546
  let mockRaf: ReturnType<typeof vi.fn>;
398
547
  let mockCaf: ReturnType<typeof vi.fn>;
@@ -415,7 +564,10 @@ describe("StreamController", () => {
415
564
  });
416
565
 
417
566
  it("does not throw when using default scheduleFlush/cancelFlush", () => {
418
- const defaultController = new StreamController(sink);
567
+ const defaultController = new StreamController(sink, undefined, undefined, {
568
+ setTimer: timers.setTimer,
569
+ clearTimer: timers.clearTimer,
570
+ });
419
571
  defaultController.start("exec-1");
420
572
 
421
573
  expect(() => {
@@ -428,7 +580,10 @@ describe("StreamController", () => {
428
580
  });
429
581
 
430
582
  it("delegates cancelFlush to cancelAnimationFrame", () => {
431
- const defaultController = new StreamController(sink);
583
+ const defaultController = new StreamController(sink, undefined, undefined, {
584
+ setTimer: timers.setTimer,
585
+ clearTimer: timers.clearTimer,
586
+ });
432
587
  defaultController.start("exec-1");
433
588
  defaultController.handleSnapshot(
434
589
  makeSnapshot(ExecutionPhase.EXECUTION_IN_PROGRESS),
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
+ import { useFetch } from "../useFetch";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // useFetch — refetchOnWindowFocus (#175 re-discovery)
7
+ // ---------------------------------------------------------------------------
8
+ //
9
+ // No FetchCacheProvider is mounted; useFetch tolerates a null cache, so these
10
+ // tests exercise the focus/visibility refetch path in isolation.
11
+
12
+ describe("useFetch — refetchOnWindowFocus", () => {
13
+ it("refetches when the window regains focus", async () => {
14
+ const fetchFn = vi.fn().mockResolvedValue("data");
15
+
16
+ renderHook(() =>
17
+ useFetch(fetchFn, ["k"], "initial", { refetchOnWindowFocus: true }),
18
+ );
19
+
20
+ await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
21
+
22
+ act(() => {
23
+ window.dispatchEvent(new Event("focus"));
24
+ });
25
+
26
+ await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2));
27
+ });
28
+
29
+ it("does not refetch on focus when the option is off (default)", async () => {
30
+ const fetchFn = vi.fn().mockResolvedValue("data");
31
+
32
+ renderHook(() => useFetch(fetchFn, ["k"], "initial"));
33
+
34
+ await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
35
+
36
+ act(() => {
37
+ window.dispatchEvent(new Event("focus"));
38
+ });
39
+
40
+ // Give any (incorrectly registered) listener a chance to fire.
41
+ await new Promise((r) => setTimeout(r, 30));
42
+ expect(fetchFn).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("does not register focus refetch when fetching is disabled", async () => {
46
+ renderHook(() =>
47
+ useFetch<string>(null, ["k"], "initial", { refetchOnWindowFocus: true }),
48
+ );
49
+
50
+ act(() => {
51
+ window.dispatchEvent(new Event("focus"));
52
+ });
53
+
54
+ // Nothing to assert beyond "does not throw" — a null fetchFn must not
55
+ // attach a listener that would call into a non-existent fetch.
56
+ await new Promise((r) => setTimeout(r, 10));
57
+ expect(true).toBe(true);
58
+ });
59
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Exponential-backoff scheduling for resilient stream reconnection.
3
+ *
4
+ * Pure and framework-agnostic — the timing math is a plain function and the
5
+ * wait is a cancelable promise, so both are exhaustively unit-testable
6
+ * without React or fake DOM (mirrors the codebase's extract-the-pure-core
7
+ * convention, e.g. `computeFollowCenter` / `isRecoveryTransition`).
8
+ *
9
+ * @internal Not part of the public `@stigmer/react` API.
10
+ */
11
+
12
+ /** Tunable backoff schedule. All fields optional — sensible defaults apply. */
13
+ export interface BackoffOptions {
14
+ /** Delay before the first retry, in milliseconds. */
15
+ readonly baseDelayMs?: number;
16
+ /** Upper bound on any single delay, in milliseconds. */
17
+ readonly maxDelayMs?: number;
18
+ /** Multiplier applied per attempt (`base * factor^(attempt-1)`). */
19
+ readonly factor?: number;
20
+ }
21
+
22
+ /** Delay before the first reconnect attempt. */
23
+ export const DEFAULT_RECONNECT_BASE_DELAY_MS = 1_000;
24
+ /** Ceiling for any single reconnect delay. */
25
+ export const DEFAULT_RECONNECT_MAX_DELAY_MS = 30_000;
26
+ /** Per-attempt growth multiplier. */
27
+ export const DEFAULT_RECONNECT_FACTOR = 2;
28
+ /**
29
+ * Attempts before giving up and surfacing a terminal error. With the
30
+ * defaults above this is ≈ several minutes of outage before the user sees
31
+ * an error banner — long enough to ride out sleep/wake and network blips,
32
+ * bounded enough to avoid an unbounded background loop against a stream
33
+ * that will never recover (e.g. a deleted execution).
34
+ */
35
+ export const DEFAULT_RECONNECT_MAX_ATTEMPTS = 10;
36
+
37
+ /**
38
+ * Compute the backoff delay (ms) for a 1-based reconnect attempt.
39
+ *
40
+ * Exponential growth (`base * factor^(attempt-1)`) capped at `maxDelayMs`,
41
+ * then **full jitter** — a uniform random point in `[0, capped]`. Full
42
+ * jitter (AWS, "Exponential Backoff And Jitter") de-synchronizes a fleet of
43
+ * clients that all dropped at the same instant, preventing a reconnect
44
+ * thundering herd against a recovering server.
45
+ *
46
+ * `random` is injectable purely so tests can assert exact values; callers
47
+ * should omit it.
48
+ */
49
+ export function computeBackoffDelay(
50
+ attempt: number,
51
+ opts?: BackoffOptions,
52
+ random: () => number = Math.random,
53
+ ): number {
54
+ const base = opts?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
55
+ const max = opts?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
56
+ const factor = opts?.factor ?? DEFAULT_RECONNECT_FACTOR;
57
+
58
+ const safeAttempt = Math.max(1, Math.floor(attempt));
59
+ const exponential = base * factor ** (safeAttempt - 1);
60
+ const capped = Math.min(exponential, max);
61
+ return Math.round(random() * capped);
62
+ }
63
+
64
+ /** Rejection reason for an aborted {@link sleep}, distinguishable by name. */
65
+ export class AbortError extends Error {
66
+ constructor() {
67
+ super("The operation was aborted.");
68
+ this.name = "AbortError";
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Promise-based delay that settles after `ms`, or rejects immediately with
74
+ * {@link AbortError} if `signal` is (or becomes) aborted.
75
+ *
76
+ * The timer is cleared and the abort listener removed on every exit path, so
77
+ * a reconnect wait leaves nothing pending when a component unmounts or the
78
+ * subscription is torn down mid-backoff — no leaked timer, no resubscribe
79
+ * after teardown.
80
+ */
81
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
82
+ return new Promise<void>((resolve, reject) => {
83
+ if (signal?.aborted) {
84
+ reject(new AbortError());
85
+ return;
86
+ }
87
+
88
+ const onAbort = () => {
89
+ clearTimeout(timer);
90
+ reject(new AbortError());
91
+ };
92
+
93
+ const timer = setTimeout(() => {
94
+ signal?.removeEventListener("abort", onAbort);
95
+ resolve();
96
+ }, ms);
97
+
98
+ signal?.addEventListener("abort", onAbort, { once: true });
99
+ });
100
+ }
@@ -190,6 +190,67 @@ describe("ConversationStore", () => {
190
190
  });
191
191
  });
192
192
 
193
+ describe("watchdog signals", () => {
194
+ it("connectTimedOut: defaults false, notifies only on change", () => {
195
+ const store = new ConversationStore();
196
+ expect(store.getConnectTimedOut()).toBe(false);
197
+
198
+ const listener = vi.fn();
199
+ store.subscribe(listener);
200
+
201
+ store.setConnectTimedOut(true);
202
+ expect(store.getConnectTimedOut()).toBe(true);
203
+ expect(listener).toHaveBeenCalledTimes(1);
204
+
205
+ store.setConnectTimedOut(true);
206
+ expect(listener).toHaveBeenCalledTimes(1); // no-op on same value
207
+
208
+ store.setConnectTimedOut(false);
209
+ expect(store.getConnectTimedOut()).toBe(false);
210
+ expect(listener).toHaveBeenCalledTimes(2);
211
+ });
212
+
213
+ it("isSlow: defaults false, notifies only on change", () => {
214
+ const store = new ConversationStore();
215
+ expect(store.getSlow()).toBe(false);
216
+
217
+ const listener = vi.fn();
218
+ store.subscribe(listener);
219
+
220
+ store.setSlow(true);
221
+ expect(store.getSlow()).toBe(true);
222
+ expect(listener).toHaveBeenCalledTimes(1);
223
+
224
+ store.setSlow(true);
225
+ expect(listener).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ it("reset clears both watchdog signals", () => {
229
+ const store = new ConversationStore();
230
+ store.setConnectTimedOut(true);
231
+ store.setSlow(true);
232
+
233
+ const listener = vi.fn();
234
+ store.subscribe(listener);
235
+
236
+ store.reset();
237
+ expect(store.getConnectTimedOut()).toBe(false);
238
+ expect(store.getSlow()).toBe(false);
239
+ expect(listener).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it("reset notifies when only a watchdog signal was set", () => {
243
+ const store = new ConversationStore();
244
+ store.setSlow(true);
245
+
246
+ const listener = vi.fn();
247
+ store.subscribe(listener);
248
+
249
+ store.reset();
250
+ expect(listener).toHaveBeenCalledTimes(1);
251
+ });
252
+ });
253
+
193
254
  describe("reset", () => {
194
255
  it("clears execution and stream state", () => {
195
256
  const store = new ConversationStore();
@@ -9,6 +9,19 @@ export type StreamState =
9
9
  | { readonly stage: "idle" }
10
10
  | { readonly stage: "connecting"; readonly executionId: string }
11
11
  | { readonly stage: "streaming"; readonly executionId: string }
12
+ | {
13
+ /**
14
+ * A non-terminal stream drop is being retried in the background. The
15
+ * last-known-good snapshot stays visible and no error is surfaced —
16
+ * the public `error` only appears once retries are exhausted. `attempt`
17
+ * is the 1-based retry count; `error` is the transient cause, retained
18
+ * for diagnostics (it is not shown to the user while reconnecting).
19
+ */
20
+ readonly stage: "reconnecting";
21
+ readonly executionId: string;
22
+ readonly attempt: number;
23
+ readonly error: Error;
24
+ }
12
25
  | { readonly stage: "complete"; readonly executionId: string }
13
26
  | {
14
27
  readonly stage: "error";
@@ -38,6 +51,8 @@ type Listener = () => void;
38
51
  export class ConversationStore {
39
52
  private _execution: AgentExecution | null = null;
40
53
  private _streamState: StreamState = IDLE_STATE;
54
+ private _connectTimedOut = false;
55
+ private _isSlow = false;
41
56
  private _listeners = new Set<Listener>();
42
57
 
43
58
  // -- Ingestion -----------------------------------------------------------
@@ -64,16 +79,47 @@ export class ConversationStore {
64
79
  this._notify();
65
80
  }
66
81
 
82
+ /**
83
+ * Set the hard connect-timeout signal — the stream opened but no first
84
+ * snapshot arrived within the watchdog window even after a silent retry.
85
+ *
86
+ * Orthogonal to {@link setStreamState}: the stream may still be live, so
87
+ * this is **not** a lifecycle stage and deliberately does not touch the
88
+ * `error` stage (that is auto-reconnect's domain). Booleans are stable by
89
+ * value, so no snapshot caching is needed; listeners fire only on change.
90
+ */
91
+ setConnectTimedOut(value: boolean): void {
92
+ if (this._connectTimedOut === value) return;
93
+ this._connectTimedOut = value;
94
+ this._notify();
95
+ }
96
+
97
+ /**
98
+ * Set the soft slow-stall hint — the stream is non-terminal but has gone
99
+ * silent past the watchdog window. Purely informational ("still working,
100
+ * taking longer than usual"); cleared by the next snapshot. Never aborts.
101
+ */
102
+ setSlow(value: boolean): void {
103
+ if (this._isSlow === value) return;
104
+ this._isSlow = value;
105
+ this._notify();
106
+ }
107
+
67
108
  /**
68
109
  * Reset to initial state. Used when the session identity changes
69
110
  * or the hook unmounts.
70
111
  */
71
112
  reset(): void {
72
- const wasIdle =
73
- this._execution === null && this._streamState.stage === "idle";
113
+ const wasClean =
114
+ this._execution === null &&
115
+ this._streamState.stage === "idle" &&
116
+ !this._connectTimedOut &&
117
+ !this._isSlow;
74
118
  this._execution = null;
75
119
  this._streamState = IDLE_STATE;
76
- if (!wasIdle) this._notify();
120
+ this._connectTimedOut = false;
121
+ this._isSlow = false;
122
+ if (!wasClean) this._notify();
77
123
  }
78
124
 
79
125
  // -- useSyncExternalStore contract ---------------------------------------
@@ -99,6 +145,16 @@ export class ConversationStore {
99
145
  return this._streamState;
100
146
  };
101
147
 
148
+ /** Stable snapshot selector for the hard connect-timeout signal. */
149
+ getConnectTimedOut = (): boolean => {
150
+ return this._connectTimedOut;
151
+ };
152
+
153
+ /** Stable snapshot selector for the soft slow-stall hint. */
154
+ getSlow = (): boolean => {
155
+ return this._isSlow;
156
+ };
157
+
102
158
  // -- Internal ------------------------------------------------------------
103
159
 
104
160
  private _notify(): void {
@@ -122,6 +178,15 @@ function streamStateEqual(a: StreamState, b: StreamState): boolean {
122
178
  a.error === b.error
123
179
  )
124
180
  return true;
181
+ // Each retry bumps `attempt`, so two reconnecting states are only equal
182
+ // when the attempt matches — every attempt must re-notify subscribers.
183
+ if (
184
+ a.stage === "reconnecting" &&
185
+ b.stage === "reconnecting" &&
186
+ a.executionId === b.executionId &&
187
+ a.attempt === b.attempt
188
+ )
189
+ return true;
125
190
  if ("executionId" in a && "executionId" in b)
126
191
  return a.executionId === b.executionId;
127
192
  return false;
@@ -10,6 +10,19 @@ export type WorkflowEventStreamState =
10
10
  | { readonly stage: "idle" }
11
11
  | { readonly stage: "connecting"; readonly executionId: string }
12
12
  | { readonly stage: "streaming"; readonly executionId: string }
13
+ | {
14
+ /**
15
+ * A non-terminal event stream drop is being retried in the background.
16
+ * Accumulated events stay visible and no error is surfaced until retries
17
+ * are exhausted. On reconnect the subscription resumes from the last
18
+ * received `sequence_number`, so no events are lost. `attempt` is the
19
+ * 1-based retry count; `error` is the transient cause (diagnostic only).
20
+ */
21
+ readonly stage: "reconnecting";
22
+ readonly executionId: string;
23
+ readonly attempt: number;
24
+ readonly error: Error;
25
+ }
13
26
  | { readonly stage: "complete"; readonly executionId: string }
14
27
  | {
15
28
  readonly stage: "error";
@@ -422,6 +435,15 @@ function streamStateEqual(
422
435
  a.error === b.error
423
436
  )
424
437
  return true;
438
+ // Each retry bumps `attempt`, so two reconnecting states are only equal
439
+ // when the attempt matches — every attempt must re-notify subscribers.
440
+ if (
441
+ a.stage === "reconnecting" &&
442
+ b.stage === "reconnecting" &&
443
+ a.executionId === b.executionId &&
444
+ a.attempt === b.attempt
445
+ )
446
+ return true;
425
447
  if ("executionId" in a && "executionId" in b)
426
448
  return a.executionId === b.executionId;
427
449
  return false;