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

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 (92) hide show
  1. package/composer/ComposerToolbar.d.ts +9 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +3 -3
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +12 -0
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +6 -3
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/composer/icons.d.ts +2 -0
  10. package/composer/icons.d.ts.map +1 -1
  11. package/composer/icons.js +4 -0
  12. package/composer/icons.js.map +1 -1
  13. package/execution/ExecutionProgress.d.ts.map +1 -1
  14. package/execution/ExecutionProgress.js +5 -1
  15. package/execution/ExecutionProgress.js.map +1 -1
  16. package/execution/MessageEntry.d.ts +7 -0
  17. package/execution/MessageEntry.d.ts.map +1 -1
  18. package/execution/MessageEntry.js +7 -4
  19. package/execution/MessageEntry.js.map +1 -1
  20. package/execution/MessageThread.d.ts +45 -3
  21. package/execution/MessageThread.d.ts.map +1 -1
  22. package/execution/MessageThread.js +66 -11
  23. package/execution/MessageThread.js.map +1 -1
  24. package/execution/index.d.ts +2 -0
  25. package/execution/index.d.ts.map +1 -1
  26. package/execution/index.js +1 -0
  27. package/execution/index.js.map +1 -1
  28. package/execution/useAgentExecutionActions.d.ts +67 -0
  29. package/execution/useAgentExecutionActions.d.ts.map +1 -0
  30. package/execution/useAgentExecutionActions.js +105 -0
  31. package/execution/useAgentExecutionActions.js.map +1 -0
  32. package/execution/useExecutionStream.d.ts +27 -0
  33. package/execution/useExecutionStream.d.ts.map +1 -1
  34. package/execution/useExecutionStream.js +48 -5
  35. package/execution/useExecutionStream.js.map +1 -1
  36. package/index.d.ts +2 -2
  37. package/index.d.ts.map +1 -1
  38. package/index.js +1 -1
  39. package/index.js.map +1 -1
  40. package/internal/VirtualizedThread.d.ts +4 -1
  41. package/internal/VirtualizedThread.d.ts.map +1 -1
  42. package/internal/VirtualizedThread.js +5 -2
  43. package/internal/VirtualizedThread.js.map +1 -1
  44. package/internal/store/conversation-store.d.ts +22 -0
  45. package/internal/store/conversation-store.d.ts.map +1 -1
  46. package/internal/store/conversation-store.js +43 -2
  47. package/internal/store/conversation-store.js.map +1 -1
  48. package/internal/stream-controller.d.ts +46 -2
  49. package/internal/stream-controller.d.ts.map +1 -1
  50. package/internal/stream-controller.js +95 -4
  51. package/internal/stream-controller.js.map +1 -1
  52. package/internal/useFetch.d.ts +7 -0
  53. package/internal/useFetch.d.ts.map +1 -1
  54. package/internal/useFetch.js +21 -0
  55. package/internal/useFetch.js.map +1 -1
  56. package/package.json +4 -4
  57. package/session/SessionViewer.js +39 -2
  58. package/session/SessionViewer.js.map +1 -1
  59. package/session/useSessionConversation.d.ts +55 -3
  60. package/session/useSessionConversation.d.ts.map +1 -1
  61. package/session/useSessionConversation.js +95 -10
  62. package/session/useSessionConversation.js.map +1 -1
  63. package/session/useSessionExecutions.d.ts +17 -1
  64. package/session/useSessionExecutions.d.ts.map +1 -1
  65. package/session/useSessionExecutions.js +6 -2
  66. package/session/useSessionExecutions.js.map +1 -1
  67. package/src/composer/ComposerToolbar.tsx +32 -9
  68. package/src/composer/SessionComposer.tsx +22 -1
  69. package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
  70. package/src/composer/icons.tsx +15 -0
  71. package/src/execution/ExecutionProgress.tsx +12 -0
  72. package/src/execution/MessageEntry.tsx +57 -2
  73. package/src/execution/MessageThread.tsx +203 -5
  74. package/src/execution/__tests__/MessageThread.test.tsx +130 -0
  75. package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
  76. package/src/execution/__tests__/useExecutionStream.test.tsx +95 -0
  77. package/src/execution/index.ts +6 -0
  78. package/src/execution/useAgentExecutionActions.ts +205 -0
  79. package/src/execution/useExecutionStream.ts +80 -4
  80. package/src/index.ts +3 -0
  81. package/src/internal/VirtualizedThread.tsx +10 -1
  82. package/src/internal/__tests__/stream-controller.test.ts +165 -10
  83. package/src/internal/__tests__/useFetch.test.tsx +59 -0
  84. package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
  85. package/src/internal/store/conversation-store.ts +46 -3
  86. package/src/internal/stream-controller.ts +123 -3
  87. package/src/internal/useFetch.ts +26 -0
  88. package/src/session/SessionViewer.tsx +87 -1
  89. package/src/session/__tests__/useSessionConversation.test.tsx +145 -0
  90. package/src/session/useSessionConversation.ts +163 -14
  91. package/src/session/useSessionExecutions.ts +23 -1
  92. package/styles.css +1 -1
@@ -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
  }
package/src/index.ts CHANGED
@@ -180,6 +180,7 @@ export {
180
180
  isTerminalPhase,
181
181
  useCreateAgentExecution,
182
182
  useExecutionStream,
183
+ useAgentExecutionActions,
183
184
  useSubmitApproval,
184
185
  ExecutionPhaseBadge,
185
186
  InteractionModeBadge,
@@ -251,6 +252,8 @@ export type {
251
252
  CreateAgentExecutionResult,
252
253
  UseCreateAgentExecutionReturn,
253
254
  UseExecutionStreamReturn,
255
+ UseAgentExecutionActionsOptions,
256
+ UseAgentExecutionActionsReturn,
254
257
  UseSubmitApprovalReturn,
255
258
  ExecutionPhaseBadgeProps,
256
259
  InteractionModeBadgeProps,
@@ -41,6 +41,9 @@ 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;
46
+ readonly onEditMessage?: (text: string) => void;
44
47
  }
45
48
 
46
49
  // ---------------------------------------------------------------------------
@@ -101,6 +104,9 @@ export function VirtualizedThread({
101
104
  org,
102
105
  planActionsDisabled,
103
106
  centerContent,
107
+ onRetrySend,
108
+ onRetryExecution,
109
+ onEditMessage,
104
110
  }: VirtualizedThreadProps) {
105
111
  const virtuosoRef = useRef<VirtuosoHandle>(null);
106
112
  const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -132,8 +138,11 @@ export function VirtualizedThread({
132
138
  onBuildFromPlan,
133
139
  org,
134
140
  planActionsDisabled,
141
+ onRetrySend,
142
+ onRetryExecution,
143
+ onEditMessage,
135
144
  }),
136
- [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled],
145
+ [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled, onRetrySend, onRetryExecution, onEditMessage],
137
146
  );
138
147
 
139
148
  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
+ });
@@ -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();
@@ -51,6 +51,8 @@ type Listener = () => void;
51
51
  export class ConversationStore {
52
52
  private _execution: AgentExecution | null = null;
53
53
  private _streamState: StreamState = IDLE_STATE;
54
+ private _connectTimedOut = false;
55
+ private _isSlow = false;
54
56
  private _listeners = new Set<Listener>();
55
57
 
56
58
  // -- Ingestion -----------------------------------------------------------
@@ -77,16 +79,47 @@ export class ConversationStore {
77
79
  this._notify();
78
80
  }
79
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
+
80
108
  /**
81
109
  * Reset to initial state. Used when the session identity changes
82
110
  * or the hook unmounts.
83
111
  */
84
112
  reset(): void {
85
- const wasIdle =
86
- 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;
87
118
  this._execution = null;
88
119
  this._streamState = IDLE_STATE;
89
- if (!wasIdle) this._notify();
120
+ this._connectTimedOut = false;
121
+ this._isSlow = false;
122
+ if (!wasClean) this._notify();
90
123
  }
91
124
 
92
125
  // -- useSyncExternalStore contract ---------------------------------------
@@ -112,6 +145,16 @@ export class ConversationStore {
112
145
  return this._streamState;
113
146
  };
114
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
+
115
158
  // -- Internal ------------------------------------------------------------
116
159
 
117
160
  private _notify(): void {