@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.
- package/execution/ExecutionProgress.d.ts.map +1 -1
- package/execution/ExecutionProgress.js +5 -1
- package/execution/ExecutionProgress.js.map +1 -1
- package/execution/MessageThread.d.ts +32 -3
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +59 -10
- package/execution/MessageThread.js.map +1 -1
- package/execution/useExecutionStream.d.ts +76 -5
- package/execution/useExecutionStream.d.ts.map +1 -1
- package/execution/useExecutionStream.js +166 -23
- package/execution/useExecutionStream.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +4 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/backoff.d.ts +61 -0
- package/internal/backoff.d.ts.map +1 -0
- package/internal/backoff.js +79 -0
- package/internal/backoff.js.map +1 -0
- package/internal/store/conversation-store.d.ts +34 -0
- package/internal/store/conversation-store.d.ts.map +1 -1
- package/internal/store/conversation-store.js +50 -2
- package/internal/store/conversation-store.js.map +1 -1
- package/internal/store/workflow-execution-event-store.d.ts +12 -0
- package/internal/store/workflow-execution-event-store.d.ts.map +1 -1
- package/internal/store/workflow-execution-event-store.js +7 -0
- package/internal/store/workflow-execution-event-store.js.map +1 -1
- package/internal/stream-controller.d.ts +57 -21
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +117 -3
- package/internal/stream-controller.js.map +1 -1
- package/internal/useFetch.d.ts +7 -0
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +21 -0
- package/internal/useFetch.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +26 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +41 -4
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +74 -10
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionExecutions.d.ts +17 -1
- package/session/useSessionExecutions.d.ts.map +1 -1
- package/session/useSessionExecutions.js +6 -2
- package/session/useSessionExecutions.js.map +1 -1
- package/src/execution/ExecutionProgress.tsx +12 -0
- package/src/execution/MessageThread.tsx +174 -5
- package/src/execution/__tests__/MessageThread.test.tsx +64 -0
- package/src/execution/__tests__/useExecutionStream.test.tsx +279 -0
- package/src/execution/useExecutionStream.ts +254 -34
- package/src/internal/VirtualizedThread.tsx +7 -1
- package/src/internal/__tests__/backoff.test.ts +99 -0
- package/src/internal/__tests__/stream-controller.test.ts +165 -10
- package/src/internal/__tests__/useFetch.test.tsx +59 -0
- package/src/internal/backoff.ts +100 -0
- package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
- package/src/internal/store/conversation-store.ts +68 -3
- package/src/internal/store/workflow-execution-event-store.ts +22 -0
- package/src/internal/stream-controller.ts +151 -26
- package/src/internal/useFetch.ts +26 -0
- package/src/session/SessionViewer.tsx +89 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
- package/src/session/useSessionConversation.ts +121 -15
- package/src/session/useSessionExecutions.ts +23 -1
- package/src/workflow/WorkflowExecutionHeader.tsx +4 -1
- package/src/workflow/WorkflowExecutionTimeline.tsx +2 -1
- package/src/workflow/__tests__/useWorkflowExecutionEventStream.test.tsx +117 -1
- package/src/workflow/execution/useWaterfallEntries.ts +2 -1
- package/src/workflow/useWorkflowExecutionEventStream.ts +122 -41
- package/src/workflow/waterfall/WaterfallTimeline.tsx +2 -1
- package/styles.css +1 -1
- package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
- package/workflow/WorkflowExecutionHeader.js +3 -1
- package/workflow/WorkflowExecutionHeader.js.map +1 -1
- package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
- package/workflow/WorkflowExecutionTimeline.js +1 -1
- package/workflow/WorkflowExecutionTimeline.js.map +1 -1
- package/workflow/execution/useWaterfallEntries.d.ts.map +1 -1
- package/workflow/execution/useWaterfallEntries.js +1 -1
- package/workflow/execution/useWaterfallEntries.js.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.d.ts +32 -4
- package/workflow/useWorkflowExecutionEventStream.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.js +75 -32
- package/workflow/useWorkflowExecutionEventStream.js.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.d.ts.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.js +1 -1
- 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
|
-
|
|
42
|
+
const slow: boolean[] = [];
|
|
43
|
+
const sink = {
|
|
40
44
|
snapshots,
|
|
41
45
|
states,
|
|
42
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
73
|
-
this._execution === null &&
|
|
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
|
-
|
|
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;
|