@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.
- 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 +27 -0
- package/execution/useExecutionStream.d.ts.map +1 -1
- package/execution/useExecutionStream.js +48 -5
- 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/store/conversation-store.d.ts +22 -0
- package/internal/store/conversation-store.d.ts.map +1 -1
- package/internal/store/conversation-store.js +43 -2
- package/internal/store/conversation-store.js.map +1 -1
- package/internal/stream-controller.d.ts +46 -2
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +95 -4
- 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 +23 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +34 -3
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +73 -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 +95 -0
- package/src/execution/useExecutionStream.ts +80 -4
- package/src/internal/VirtualizedThread.tsx +7 -1
- package/src/internal/__tests__/stream-controller.test.ts +165 -10
- package/src/internal/__tests__/useFetch.test.tsx +59 -0
- package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
- package/src/internal/store/conversation-store.ts +46 -3
- package/src/internal/stream-controller.ts +123 -3
- package/src/internal/useFetch.ts +26 -0
- package/src/session/SessionViewer.tsx +62 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
- package/src/session/useSessionConversation.ts +113 -14
- package/src/session/useSessionExecutions.ts +23 -1
- 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
|
-
|
|
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
|
+
});
|