@stigmer/react 3.0.8-dev.20260613041848 → 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 (55) 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 +27 -0
  9. package/execution/useExecutionStream.d.ts.map +1 -1
  10. package/execution/useExecutionStream.js +48 -5
  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/store/conversation-store.d.ts +22 -0
  17. package/internal/store/conversation-store.d.ts.map +1 -1
  18. package/internal/store/conversation-store.js +43 -2
  19. package/internal/store/conversation-store.js.map +1 -1
  20. package/internal/stream-controller.d.ts +46 -2
  21. package/internal/stream-controller.d.ts.map +1 -1
  22. package/internal/stream-controller.js +95 -4
  23. package/internal/stream-controller.js.map +1 -1
  24. package/internal/useFetch.d.ts +7 -0
  25. package/internal/useFetch.d.ts.map +1 -1
  26. package/internal/useFetch.js +21 -0
  27. package/internal/useFetch.js.map +1 -1
  28. package/package.json +4 -4
  29. package/session/SessionViewer.js +23 -1
  30. package/session/SessionViewer.js.map +1 -1
  31. package/session/useSessionConversation.d.ts +34 -3
  32. package/session/useSessionConversation.d.ts.map +1 -1
  33. package/session/useSessionConversation.js +73 -10
  34. package/session/useSessionConversation.js.map +1 -1
  35. package/session/useSessionExecutions.d.ts +17 -1
  36. package/session/useSessionExecutions.d.ts.map +1 -1
  37. package/session/useSessionExecutions.js +6 -2
  38. package/session/useSessionExecutions.js.map +1 -1
  39. package/src/execution/ExecutionProgress.tsx +12 -0
  40. package/src/execution/MessageThread.tsx +174 -5
  41. package/src/execution/__tests__/MessageThread.test.tsx +64 -0
  42. package/src/execution/__tests__/useExecutionStream.test.tsx +95 -0
  43. package/src/execution/useExecutionStream.ts +80 -4
  44. package/src/internal/VirtualizedThread.tsx +7 -1
  45. package/src/internal/__tests__/stream-controller.test.ts +165 -10
  46. package/src/internal/__tests__/useFetch.test.tsx +59 -0
  47. package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
  48. package/src/internal/store/conversation-store.ts +46 -3
  49. package/src/internal/stream-controller.ts +123 -3
  50. package/src/internal/useFetch.ts +26 -0
  51. package/src/session/SessionViewer.tsx +62 -0
  52. package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
  53. package/src/session/useSessionConversation.ts +113 -14
  54. package/src/session/useSessionExecutions.ts +23 -1
  55. package/styles.css +1 -1
@@ -73,6 +73,7 @@ function makeExecution(opts: {
73
73
  phase?: ExecutionPhase;
74
74
  interactionMode?: InteractionMode;
75
75
  aiContent?: string;
76
+ error?: string;
76
77
  }): AgentExecution {
77
78
  const exec = create(AgentExecutionSchema);
78
79
 
@@ -91,6 +92,9 @@ function makeExecution(opts: {
91
92
 
92
93
  const status = create(AgentExecutionStatusSchema);
93
94
  status.phase = opts.phase ?? ExecutionPhase.EXECUTION_COMPLETED;
95
+ if (opts.error !== undefined) {
96
+ status.error = opts.error;
97
+ }
94
98
 
95
99
  const humanMsg = create(AgentMessageSchema);
96
100
  humanMsg.type = MessageType.MESSAGE_HUMAN;
@@ -246,6 +250,66 @@ describe("MessageThread", () => {
246
250
  expect(screen.getByText(/failed/i)).toBeTruthy();
247
251
  });
248
252
 
253
+ it("surfaces the server failure reason for a FAILED execution", () => {
254
+ const exec = makeExecution({
255
+ id: "exec-fail-reason",
256
+ phase: ExecutionPhase.EXECUTION_FAILED,
257
+ error: "Activity task timed out (RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED)",
258
+ });
259
+
260
+ render(<MessageThread executions={[exec]} />);
261
+
262
+ expect(
263
+ screen.getByText(/Activity task timed out/i),
264
+ ).toBeTruthy();
265
+ });
266
+
267
+ it("does NOT surface a failure reason for a COMPLETED execution", () => {
268
+ const exec = makeExecution({
269
+ id: "exec-ok",
270
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
271
+ error: "stale error that should be ignored",
272
+ });
273
+
274
+ render(<MessageThread executions={[exec]} />);
275
+
276
+ expect(screen.queryByText(/stale error/i)).toBeNull();
277
+ });
278
+
279
+ it("offers a Retry that resends the originating message on failure", () => {
280
+ const exec = makeExecution({
281
+ id: "exec-retry",
282
+ specMessage: "do the thing",
283
+ phase: ExecutionPhase.EXECUTION_FAILED,
284
+ error: "boom",
285
+ });
286
+ const onRetryExecution = vi.fn();
287
+
288
+ render(
289
+ <MessageThread executions={[exec]} onRetryExecution={onRetryExecution} />,
290
+ );
291
+
292
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
293
+ expect(onRetryExecution).toHaveBeenCalledWith("do the thing");
294
+ });
295
+
296
+ it("renders a failed pending message with an inline Retry", () => {
297
+ const onRetrySend = vi.fn();
298
+
299
+ render(
300
+ <MessageThread
301
+ executions={[]}
302
+ pendingUserMessage="unsent message"
303
+ pendingMessageFailed
304
+ onRetrySend={onRetrySend}
305
+ />,
306
+ );
307
+
308
+ expect(screen.getByText("unsent message")).toBeTruthy();
309
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
310
+ expect(onRetrySend).toHaveBeenCalledOnce();
311
+ });
312
+
249
313
  it("renders plan-completion card when last execution is completed Plan mode", () => {
250
314
  const exec = makeExecution({
251
315
  id: "exec-plan",
@@ -521,3 +521,98 @@ describe("useExecutionStream — auto-reconnect", () => {
521
521
  expect(subscribeFn).toHaveBeenCalledTimes(1);
522
522
  });
523
523
  });
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Watchdog (#175) — silent connect timeout + slow-stall hint
527
+ // ---------------------------------------------------------------------------
528
+
529
+ describe("useExecutionStream — watchdog", () => {
530
+ const IN_PROGRESS = ExecutionPhase.EXECUTION_IN_PROGRESS;
531
+
532
+ it("self-heals once then surfaces connectTimedOut on a silent connect", async () => {
533
+ // Every subscribe returns a stream that never delivers a snapshot.
534
+ const subscribeFn = vi.fn(
535
+ () => createControllableStream<AgentExecution>().generator,
536
+ );
537
+ const stigmer = createMockStigmer(
538
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
539
+ );
540
+
541
+ const { result } = renderHook(
542
+ () =>
543
+ useExecutionStream("exec-1", {
544
+ connectTimeoutMs: 20,
545
+ slowThresholdMs: 10_000,
546
+ }),
547
+ { wrapper: createWrapper(stigmer) },
548
+ );
549
+
550
+ // First timeout self-heals silently (no surfaced signal, a 2nd subscribe).
551
+ await waitFor(() => expect(subscribeFn).toHaveBeenCalledTimes(2));
552
+ // Second timeout surfaces the actionable signal — but never an error.
553
+ await waitFor(() => expect(result.current.connectTimedOut).toBe(true), {
554
+ timeout: 2000,
555
+ });
556
+ expect(result.current.error).toBeNull();
557
+ });
558
+
559
+ it("reconnect() clears connectTimedOut and re-subscribes", async () => {
560
+ const subscribeFn = vi.fn(
561
+ () => createControllableStream<AgentExecution>().generator,
562
+ );
563
+ const stigmer = createMockStigmer(
564
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
565
+ );
566
+
567
+ const { result } = renderHook(
568
+ () =>
569
+ useExecutionStream("exec-1", {
570
+ connectTimeoutMs: 20,
571
+ slowThresholdMs: 10_000,
572
+ }),
573
+ { wrapper: createWrapper(stigmer) },
574
+ );
575
+
576
+ await waitFor(() => expect(result.current.connectTimedOut).toBe(true), {
577
+ timeout: 2000,
578
+ });
579
+
580
+ const before = subscribeFn.mock.calls.length;
581
+ act(() => result.current.reconnect());
582
+
583
+ expect(result.current.connectTimedOut).toBe(false);
584
+ await waitFor(() =>
585
+ expect(subscribeFn.mock.calls.length).toBeGreaterThan(before),
586
+ );
587
+ });
588
+
589
+ it("flips isSlow on a silent live stream and clears it on the next snapshot", async () => {
590
+ const s = createControllableStream<AgentExecution>();
591
+ const subscribeFn = vi.fn(() => s.generator);
592
+ const stigmer = createMockStigmer(
593
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
594
+ );
595
+
596
+ const { result } = renderHook(
597
+ () =>
598
+ useExecutionStream("exec-1", {
599
+ connectTimeoutMs: 10_000,
600
+ slowThresholdMs: 20,
601
+ }),
602
+ { wrapper: createWrapper(stigmer) },
603
+ );
604
+
605
+ act(() => s.push(makeSnapshot(IN_PROGRESS)));
606
+ await waitFor(() => expect(result.current.isStreaming).toBe(true));
607
+
608
+ await waitFor(() => expect(result.current.isSlow).toBe(true), {
609
+ timeout: 2000,
610
+ });
611
+
612
+ // A terminal snapshot resolves the stall deterministically (no re-arm).
613
+ // The non-terminal "next snapshot clears + re-arms" path is covered without
614
+ // timing races in the StreamController unit test.
615
+ act(() => s.push(makeSnapshot(ExecutionPhase.EXECUTION_COMPLETED)));
616
+ await waitFor(() => expect(result.current.isSlow).toBe(false));
617
+ });
618
+ });
@@ -17,6 +17,8 @@ import { toError } from "../internal/toError";
17
17
  import { useStreamRate } from "../internal/dev";
18
18
  import {
19
19
  StreamController,
20
+ DEFAULT_CONNECT_TIMEOUT_MS,
21
+ DEFAULT_SLOW_THRESHOLD_MS,
20
22
  type StreamControllerSink,
21
23
  } from "../internal/stream-controller";
22
24
  import {
@@ -62,6 +64,21 @@ export interface UseExecutionStreamReturn {
62
64
  * background reconnection so a recoverable hiccup never shows as an error.
63
65
  */
64
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;
65
82
  /**
66
83
  * Reset error state and re-establish the stream subscription.
67
84
  *
@@ -104,6 +121,18 @@ export interface UseExecutionStreamOptions {
104
121
  /** Max attempts before surfacing a terminal `error`. */
105
122
  readonly maxAttempts?: number;
106
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;
107
136
  }
108
137
 
109
138
  /**
@@ -172,6 +201,17 @@ export function useExecutionStream(
172
201
  }
173
202
  const store = options?.store ?? internalStoreRef.current!;
174
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
+
175
215
  // -- Controller setup -----------------------------------------------------
176
216
  const controllerRef = useRef<StreamController | null>(null);
177
217
  if (!controllerRef.current) {
@@ -186,16 +226,36 @@ export function useExecutionStream(
186
226
  store.setStreamState(state);
187
227
  });
188
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
+ },
189
244
  };
190
- 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
+ });
191
249
  }
192
250
  const controller = controllerRef.current;
193
251
 
194
- // -- Reconnect ------------------------------------------------------------
195
- const [connectKey, setConnectKey] = useState(0);
196
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);
197
257
  setConnectKey((k) => k + 1);
198
- }, []);
258
+ }, [store]);
199
259
 
200
260
  // -- Stream rate instrumentation ------------------------------------------
201
261
  const streamRate = useStreamRate();
@@ -222,6 +282,7 @@ export function useExecutionStream(
222
282
  controller.reset();
223
283
  store.reset();
224
284
  prevExecutionIdRef.current = null;
285
+ connectAutoRetriedRef.current = false;
225
286
  return;
226
287
  }
227
288
 
@@ -235,6 +296,9 @@ export function useExecutionStream(
235
296
  prevExecutionIdRef.current !== executionId
236
297
  ) {
237
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;
238
302
  }
239
303
  prevExecutionIdRef.current = executionId;
240
304
 
@@ -281,6 +345,11 @@ export function useExecutionStream(
281
345
  if (signal.aborted) return;
282
346
 
283
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);
284
353
  controller.handleSnapshot(snapshot);
285
354
  streamRateRef.current.tick(snapshot.status?.messages?.length ?? 0);
286
355
 
@@ -341,6 +410,11 @@ export function useExecutionStream(
341
410
  store.subscribe,
342
411
  store.getStreamState,
343
412
  );
413
+ const connectTimedOut = useSyncExternalStore(
414
+ store.subscribe,
415
+ store.getConnectTimedOut,
416
+ );
417
+ const isSlow = useSyncExternalStore(store.subscribe, store.getSlow);
344
418
 
345
419
  // -- Derive public return values ------------------------------------------
346
420
  const phase = useMemo(
@@ -364,6 +438,8 @@ export function useExecutionStream(
364
438
  isReconnecting,
365
439
  reconnectAttempt,
366
440
  error,
441
+ connectTimedOut,
442
+ isSlow,
367
443
  reconnect,
368
444
  };
369
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 (
@@ -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
+ });