@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
|
@@ -11,14 +11,23 @@ import {
|
|
|
11
11
|
} from "react";
|
|
12
12
|
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
13
13
|
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
14
|
+
import { isTransientStreamError } from "@stigmer/sdk";
|
|
14
15
|
import { useStigmer } from "../hooks";
|
|
15
16
|
import { toError } from "../internal/toError";
|
|
16
17
|
import { useStreamRate } from "../internal/dev";
|
|
17
18
|
import {
|
|
18
19
|
StreamController,
|
|
20
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
21
|
+
DEFAULT_SLOW_THRESHOLD_MS,
|
|
19
22
|
type StreamControllerSink,
|
|
20
23
|
} from "../internal/stream-controller";
|
|
21
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
computeBackoffDelay,
|
|
26
|
+
sleep,
|
|
27
|
+
DEFAULT_RECONNECT_MAX_ATTEMPTS,
|
|
28
|
+
type BackoffOptions,
|
|
29
|
+
} from "../internal/backoff";
|
|
30
|
+
import { ConversationStore } from "../internal/store";
|
|
22
31
|
import { isTerminalPhase } from "./execution-phases";
|
|
23
32
|
|
|
24
33
|
/** Return value of {@link useExecutionStream}. */
|
|
@@ -37,14 +46,46 @@ export interface UseExecutionStreamReturn {
|
|
|
37
46
|
readonly isStreaming: boolean;
|
|
38
47
|
/** `true` after subscription starts but before the first snapshot arrives. */
|
|
39
48
|
readonly isConnecting: boolean;
|
|
40
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* `true` while a transient drop is being retried automatically in the
|
|
51
|
+
* background. The last snapshot stays visible and `error` remains `null` —
|
|
52
|
+
* surface a subtle "Reconnecting…" affordance, not an error. Becomes
|
|
53
|
+
* `false` once a snapshot is received (back to `isStreaming`) or retries
|
|
54
|
+
* are exhausted (then `error` is set).
|
|
55
|
+
*/
|
|
56
|
+
readonly isReconnecting: boolean;
|
|
57
|
+
/** 1-based count of the in-flight reconnect attempt; `0` when not reconnecting. */
|
|
58
|
+
readonly reconnectAttempt: number;
|
|
59
|
+
/**
|
|
60
|
+
* Error from the last failed stream attempt, or `null` when healthy.
|
|
61
|
+
*
|
|
62
|
+
* Only set once auto-reconnect has exhausted its attempts (or for a
|
|
63
|
+
* non-transient failure that is not retried). It stays `null` throughout
|
|
64
|
+
* background reconnection so a recoverable hiccup never shows as an error.
|
|
65
|
+
*/
|
|
41
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;
|
|
42
82
|
/**
|
|
43
83
|
* Reset error state and re-establish the stream subscription.
|
|
44
84
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
85
|
+
* The fallback after auto-reconnect exhausts, and a manual escape hatch in
|
|
86
|
+
* any lifecycle state — error, complete, or mid-stream. Resets the retry
|
|
87
|
+
* counter and preserves the last snapshot (no flash to empty). Uses the
|
|
88
|
+
* `connectKey` counter pattern consistent with `refetch()` in other SDK hooks.
|
|
48
89
|
*/
|
|
49
90
|
readonly reconnect: () => void;
|
|
50
91
|
}
|
|
@@ -65,6 +106,33 @@ export interface UseExecutionStreamOptions {
|
|
|
65
106
|
* preserving backward compatibility for standalone usage.
|
|
66
107
|
*/
|
|
67
108
|
readonly store?: ConversationStore;
|
|
109
|
+
/**
|
|
110
|
+
* Automatically re-establish the subscription with exponential backoff
|
|
111
|
+
* when a non-terminal stream drops (transport error, idle timeout, laptop
|
|
112
|
+
* sleep). Defaults to `true`. Set `false` to opt out and surface every
|
|
113
|
+
* drop as an immediate `error` for manual `reconnect()`.
|
|
114
|
+
*/
|
|
115
|
+
readonly autoReconnect?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Tune the auto-reconnect backoff schedule and attempt cap. Omitted fields
|
|
118
|
+
* fall back to SDK defaults (base 1s, ×2, max 30s, 10 attempts).
|
|
119
|
+
*/
|
|
120
|
+
readonly reconnectOptions?: BackoffOptions & {
|
|
121
|
+
/** Max attempts before surfacing a terminal `error`. */
|
|
122
|
+
readonly maxAttempts?: number;
|
|
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;
|
|
68
136
|
}
|
|
69
137
|
|
|
70
138
|
/**
|
|
@@ -73,7 +141,18 @@ export interface UseExecutionStreamOptions {
|
|
|
73
141
|
*
|
|
74
142
|
* Manages the full subscription lifecycle through a finite state
|
|
75
143
|
* machine: connection establishment, rAF-coalesced snapshot streaming,
|
|
76
|
-
* terminal-phase detection,
|
|
144
|
+
* terminal-phase detection, automatic reconnection with exponential
|
|
145
|
+
* backoff on transient drops, and manual reconnection as the fallback.
|
|
146
|
+
*
|
|
147
|
+
* **Resilience:** a non-terminal stream drop — whether a thrown transport
|
|
148
|
+
* error (WebKit "Load failed", `fetch failed`, `Unavailable`) or a graceful
|
|
149
|
+
* server close mid-run (idle timeout, load-balancer recycle) — is retried
|
|
150
|
+
* automatically with backoff. The last snapshot stays visible
|
|
151
|
+
* (`isReconnecting`), the access token is re-read on each attempt via the
|
|
152
|
+
* per-request interceptor, and `error` is surfaced only once attempts are
|
|
153
|
+
* exhausted. Completion is decided by the terminal phase, never by the
|
|
154
|
+
* stream merely ending (a graceful close of a running execution reconnects
|
|
155
|
+
* rather than falsely reporting "complete"). Opt out via `autoReconnect: false`.
|
|
77
156
|
*
|
|
78
157
|
* **Performance characteristics:**
|
|
79
158
|
* - Non-terminal snapshots are coalesced via `requestAnimationFrame`
|
|
@@ -122,6 +201,17 @@ export function useExecutionStream(
|
|
|
122
201
|
}
|
|
123
202
|
const store = options?.store ?? internalStoreRef.current!;
|
|
124
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
|
+
|
|
125
215
|
// -- Controller setup -----------------------------------------------------
|
|
126
216
|
const controllerRef = useRef<StreamController | null>(null);
|
|
127
217
|
if (!controllerRef.current) {
|
|
@@ -136,22 +226,53 @@ export function useExecutionStream(
|
|
|
136
226
|
store.setStreamState(state);
|
|
137
227
|
});
|
|
138
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
|
+
},
|
|
139
244
|
};
|
|
140
|
-
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
|
+
});
|
|
141
249
|
}
|
|
142
250
|
const controller = controllerRef.current;
|
|
143
251
|
|
|
144
|
-
// -- Reconnect ------------------------------------------------------------
|
|
145
|
-
const [connectKey, setConnectKey] = useState(0);
|
|
146
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);
|
|
147
257
|
setConnectKey((k) => k + 1);
|
|
148
|
-
}, []);
|
|
258
|
+
}, [store]);
|
|
149
259
|
|
|
150
260
|
// -- Stream rate instrumentation ------------------------------------------
|
|
151
261
|
const streamRate = useStreamRate();
|
|
152
262
|
const streamRateRef = useRef(streamRate);
|
|
153
263
|
streamRateRef.current = streamRate;
|
|
154
264
|
|
|
265
|
+
// -- Reconnect config (ref-backed so option identity churn never resubscribes)
|
|
266
|
+
const autoReconnect = options?.autoReconnect ?? true;
|
|
267
|
+
const reconnectOptions = options?.reconnectOptions;
|
|
268
|
+
const configRef = useRef({ autoReconnect, reconnectOptions });
|
|
269
|
+
configRef.current = { autoReconnect, reconnectOptions };
|
|
270
|
+
|
|
271
|
+
// Tracks the execution the store currently holds, so we reset the store on
|
|
272
|
+
// a genuine identity change (A → B) but preserve it across reconnects of the
|
|
273
|
+
// SAME execution. Mirrors useWorkflowExecutionEventStream / useFetch.
|
|
274
|
+
const prevExecutionIdRef = useRef<string | null>(null);
|
|
275
|
+
|
|
155
276
|
// -- Subscription effect --------------------------------------------------
|
|
156
277
|
// Note: controller, store, and streamRate are ref-backed stable objects —
|
|
157
278
|
// they MUST NOT appear in the deps array. Including them would cause
|
|
@@ -160,45 +281,126 @@ export function useExecutionStream(
|
|
|
160
281
|
if (!executionId) {
|
|
161
282
|
controller.reset();
|
|
162
283
|
store.reset();
|
|
284
|
+
prevExecutionIdRef.current = null;
|
|
285
|
+
connectAutoRetriedRef.current = false;
|
|
163
286
|
return;
|
|
164
287
|
}
|
|
165
288
|
|
|
289
|
+
// Reset only when switching to a different execution. Crucially we do NOT
|
|
290
|
+
// reset the store on reconnect (connectKey bump) or on cleanup — that
|
|
291
|
+
// would wipe the conversation to an empty "Connecting…" on every retry.
|
|
292
|
+
// The full-snapshot subscribe re-delivers the entire state on reconnect,
|
|
293
|
+
// so keeping the last-known-good snapshot is both correct and seamless.
|
|
294
|
+
if (
|
|
295
|
+
prevExecutionIdRef.current !== null &&
|
|
296
|
+
prevExecutionIdRef.current !== executionId
|
|
297
|
+
) {
|
|
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;
|
|
302
|
+
}
|
|
303
|
+
prevExecutionIdRef.current = executionId;
|
|
304
|
+
|
|
166
305
|
const abortController = new AbortController();
|
|
306
|
+
const signal = abortController.signal;
|
|
167
307
|
controller.start(executionId);
|
|
168
308
|
|
|
169
309
|
(async () => {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
310
|
+
const { autoReconnect: auto, reconnectOptions: backoff } =
|
|
311
|
+
configRef.current;
|
|
312
|
+
const maxAttempts = backoff?.maxAttempts ?? DEFAULT_RECONNECT_MAX_ATTEMPTS;
|
|
313
|
+
|
|
314
|
+
// 1-based count of consecutive failed attempts. Reset to 0 by any
|
|
315
|
+
// successful snapshot, so each healthy stretch gets a fresh backoff
|
|
316
|
+
// budget rather than inheriting the previous outage's attempt count.
|
|
317
|
+
let attempt = 0;
|
|
318
|
+
|
|
319
|
+
// Schedule the next retry after `error`, or stop. Returns `true` when
|
|
320
|
+
// the loop should continue (a retry was scheduled), `false` when it
|
|
321
|
+
// should exit (opted out, exhausted, or aborted). Shared by the
|
|
322
|
+
// thrown-error and premature-end paths so both converge on one policy.
|
|
323
|
+
const scheduleRetry = async (error: Error): Promise<boolean> => {
|
|
324
|
+
if (!auto || attempt >= maxAttempts) {
|
|
325
|
+
controller.handleError(error);
|
|
326
|
+
return false;
|
|
186
327
|
}
|
|
328
|
+
attempt += 1;
|
|
329
|
+
controller.handleReconnecting(attempt, error);
|
|
330
|
+
try {
|
|
331
|
+
await sleep(computeBackoffDelay(attempt, backoff), signal);
|
|
332
|
+
} catch {
|
|
333
|
+
return false; // aborted mid-backoff
|
|
334
|
+
}
|
|
335
|
+
return !signal.aborted;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
while (!signal.aborted) {
|
|
339
|
+
let sawTerminal = false;
|
|
340
|
+
try {
|
|
341
|
+
for await (const snapshot of stigmer.agentExecution.subscribe(
|
|
342
|
+
executionId,
|
|
343
|
+
signal,
|
|
344
|
+
)) {
|
|
345
|
+
if (signal.aborted) return;
|
|
346
|
+
|
|
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);
|
|
353
|
+
controller.handleSnapshot(snapshot);
|
|
354
|
+
streamRateRef.current.tick(snapshot.status?.messages?.length ?? 0);
|
|
355
|
+
|
|
356
|
+
const phase =
|
|
357
|
+
snapshot.status?.phase ??
|
|
358
|
+
ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
|
|
359
|
+
if (isTerminalPhase(phase)) {
|
|
360
|
+
sawTerminal = true;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
if (signal.aborted) return;
|
|
366
|
+
const error = toError(err);
|
|
367
|
+
// Only known-transient transport noise is retried. A non-transient
|
|
368
|
+
// error (not-found, invalid-argument, …) is deterministic — the
|
|
369
|
+
// same request would fail identically, so surface it immediately.
|
|
370
|
+
if (!auto || !isTransientStreamError(error)) {
|
|
371
|
+
controller.handleError(error);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (await scheduleRetry(error)) continue;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (signal.aborted) return;
|
|
187
379
|
|
|
188
|
-
if (
|
|
380
|
+
if (sawTerminal) {
|
|
381
|
+
// handleSnapshot already transitioned to `complete`; flush any
|
|
382
|
+
// buffered frame and finish. Completion is decided by the terminal
|
|
383
|
+
// phase, never by the stream merely ending.
|
|
189
384
|
controller.handleStreamEnd();
|
|
190
385
|
streamRateRef.current.summary();
|
|
386
|
+
return;
|
|
191
387
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
388
|
+
|
|
389
|
+
// The iterator finished without a terminal phase: the server closed a
|
|
390
|
+
// still-running stream (idle timeout, load-balancer recycle, pod
|
|
391
|
+
// restart). This is transient by definition — reconnect and the next
|
|
392
|
+
// full snapshot reconciles whatever changed (including, if it ended
|
|
393
|
+
// meanwhile, the terminal state we missed).
|
|
394
|
+
if (await scheduleRetry(new Error("The connection was interrupted."))) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
195
398
|
}
|
|
196
399
|
})();
|
|
197
400
|
|
|
198
401
|
return () => {
|
|
199
402
|
abortController.abort();
|
|
200
403
|
controller.reset();
|
|
201
|
-
store.reset();
|
|
202
404
|
};
|
|
203
405
|
}, [executionId, stigmer, connectKey]);
|
|
204
406
|
|
|
@@ -208,6 +410,11 @@ export function useExecutionStream(
|
|
|
208
410
|
store.subscribe,
|
|
209
411
|
store.getStreamState,
|
|
210
412
|
);
|
|
413
|
+
const connectTimedOut = useSyncExternalStore(
|
|
414
|
+
store.subscribe,
|
|
415
|
+
store.getConnectTimedOut,
|
|
416
|
+
);
|
|
417
|
+
const isSlow = useSyncExternalStore(store.subscribe, store.getSlow);
|
|
211
418
|
|
|
212
419
|
// -- Derive public return values ------------------------------------------
|
|
213
420
|
const phase = useMemo(
|
|
@@ -218,8 +425,21 @@ export function useExecutionStream(
|
|
|
218
425
|
|
|
219
426
|
const isStreaming = streamState.stage === "streaming";
|
|
220
427
|
const isConnecting = streamState.stage === "connecting";
|
|
221
|
-
const
|
|
222
|
-
|
|
428
|
+
const isReconnecting = streamState.stage === "reconnecting";
|
|
429
|
+
const reconnectAttempt =
|
|
430
|
+
streamState.stage === "reconnecting" ? streamState.attempt : 0;
|
|
431
|
+
const error = streamState.stage === "error" ? streamState.error : null;
|
|
223
432
|
|
|
224
|
-
return {
|
|
433
|
+
return {
|
|
434
|
+
execution,
|
|
435
|
+
phase,
|
|
436
|
+
isStreaming,
|
|
437
|
+
isConnecting,
|
|
438
|
+
isReconnecting,
|
|
439
|
+
reconnectAttempt,
|
|
440
|
+
error,
|
|
441
|
+
connectTimedOut,
|
|
442
|
+
isSlow,
|
|
443
|
+
reconnect,
|
|
444
|
+
};
|
|
225
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 (
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
computeBackoffDelay,
|
|
4
|
+
sleep,
|
|
5
|
+
AbortError,
|
|
6
|
+
DEFAULT_RECONNECT_BASE_DELAY_MS,
|
|
7
|
+
DEFAULT_RECONNECT_MAX_DELAY_MS,
|
|
8
|
+
} from "../backoff";
|
|
9
|
+
|
|
10
|
+
describe("computeBackoffDelay", () => {
|
|
11
|
+
// random=()=>1 collapses full jitter to its upper bound, exposing the raw
|
|
12
|
+
// exponential schedule for exact assertions.
|
|
13
|
+
const noJitter = () => 1;
|
|
14
|
+
|
|
15
|
+
it("grows exponentially from the base delay", () => {
|
|
16
|
+
expect(computeBackoffDelay(1, undefined, noJitter)).toBe(
|
|
17
|
+
DEFAULT_RECONNECT_BASE_DELAY_MS,
|
|
18
|
+
);
|
|
19
|
+
expect(computeBackoffDelay(2, undefined, noJitter)).toBe(2_000);
|
|
20
|
+
expect(computeBackoffDelay(3, undefined, noJitter)).toBe(4_000);
|
|
21
|
+
expect(computeBackoffDelay(5, undefined, noJitter)).toBe(16_000);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("caps at maxDelayMs", () => {
|
|
25
|
+
// attempt 6 → 32_000 raw, clamped to the 30_000 ceiling.
|
|
26
|
+
expect(computeBackoffDelay(6, undefined, noJitter)).toBe(
|
|
27
|
+
DEFAULT_RECONNECT_MAX_DELAY_MS,
|
|
28
|
+
);
|
|
29
|
+
expect(computeBackoffDelay(50, undefined, noJitter)).toBe(
|
|
30
|
+
DEFAULT_RECONNECT_MAX_DELAY_MS,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("applies full jitter within [0, capped]", () => {
|
|
35
|
+
expect(computeBackoffDelay(3, undefined, () => 0)).toBe(0);
|
|
36
|
+
expect(computeBackoffDelay(3, undefined, () => 0.5)).toBe(2_000);
|
|
37
|
+
for (let i = 0; i < 200; i++) {
|
|
38
|
+
const d = computeBackoffDelay(4); // real Math.random
|
|
39
|
+
expect(d).toBeGreaterThanOrEqual(0);
|
|
40
|
+
expect(d).toBeLessThanOrEqual(8_000);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("honors custom options", () => {
|
|
45
|
+
const opts = { baseDelayMs: 100, factor: 3, maxDelayMs: 1_000 };
|
|
46
|
+
expect(computeBackoffDelay(1, opts, noJitter)).toBe(100);
|
|
47
|
+
expect(computeBackoffDelay(2, opts, noJitter)).toBe(300);
|
|
48
|
+
expect(computeBackoffDelay(3, opts, noJitter)).toBe(900);
|
|
49
|
+
expect(computeBackoffDelay(4, opts, noJitter)).toBe(1_000); // 2700 capped
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("treats attempt < 1 as the first attempt", () => {
|
|
53
|
+
expect(computeBackoffDelay(0, undefined, noJitter)).toBe(
|
|
54
|
+
DEFAULT_RECONNECT_BASE_DELAY_MS,
|
|
55
|
+
);
|
|
56
|
+
expect(computeBackoffDelay(-5, undefined, noJitter)).toBe(
|
|
57
|
+
DEFAULT_RECONNECT_BASE_DELAY_MS,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("sleep", () => {
|
|
63
|
+
beforeEach(() => vi.useFakeTimers());
|
|
64
|
+
afterEach(() => vi.useRealTimers());
|
|
65
|
+
|
|
66
|
+
it("resolves after the delay", async () => {
|
|
67
|
+
const settled = vi.fn();
|
|
68
|
+
const p = sleep(1_000).then(settled);
|
|
69
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
70
|
+
expect(settled).not.toHaveBeenCalled();
|
|
71
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
72
|
+
await p;
|
|
73
|
+
expect(settled).toHaveBeenCalledOnce();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects immediately with AbortError when the signal is already aborted", async () => {
|
|
77
|
+
const ac = new AbortController();
|
|
78
|
+
ac.abort();
|
|
79
|
+
await expect(sleep(1_000, ac.signal)).rejects.toBeInstanceOf(AbortError);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rejects when aborted mid-wait and leaves no pending timer", async () => {
|
|
83
|
+
const ac = new AbortController();
|
|
84
|
+
const p = sleep(10_000, ac.signal);
|
|
85
|
+
ac.abort();
|
|
86
|
+
await expect(p).rejects.toBeInstanceOf(AbortError);
|
|
87
|
+
// No timer should survive the abort — advancing time settles nothing.
|
|
88
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does not reject after resolving (listener removed on success)", async () => {
|
|
92
|
+
const ac = new AbortController();
|
|
93
|
+
const p = sleep(500, ac.signal);
|
|
94
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
95
|
+
await expect(p).resolves.toBeUndefined();
|
|
96
|
+
// Aborting after the fact must not produce an unhandled rejection.
|
|
97
|
+
expect(() => ac.abort()).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
});
|