@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.
- package/composer/ComposerToolbar.d.ts +9 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +3 -3
- package/composer/ComposerToolbar.js.map +1 -1
- package/composer/SessionComposer.d.ts +12 -0
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +6 -3
- package/composer/SessionComposer.js.map +1 -1
- package/composer/icons.d.ts +2 -0
- package/composer/icons.d.ts.map +1 -1
- package/composer/icons.js +4 -0
- package/composer/icons.js.map +1 -1
- package/execution/ExecutionProgress.d.ts.map +1 -1
- package/execution/ExecutionProgress.js +5 -1
- package/execution/ExecutionProgress.js.map +1 -1
- package/execution/MessageEntry.d.ts +7 -0
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +7 -4
- package/execution/MessageEntry.js.map +1 -1
- package/execution/MessageThread.d.ts +45 -3
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +66 -11
- package/execution/MessageThread.js.map +1 -1
- package/execution/index.d.ts +2 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +1 -0
- package/execution/index.js.map +1 -1
- package/execution/useAgentExecutionActions.d.ts +67 -0
- package/execution/useAgentExecutionActions.d.ts.map +1 -0
- package/execution/useAgentExecutionActions.js +105 -0
- package/execution/useAgentExecutionActions.js.map +1 -0
- 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/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +4 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +5 -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 +39 -2
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +55 -3
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +95 -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/composer/ComposerToolbar.tsx +32 -9
- package/src/composer/SessionComposer.tsx +22 -1
- package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
- package/src/composer/icons.tsx +15 -0
- package/src/execution/ExecutionProgress.tsx +12 -0
- package/src/execution/MessageEntry.tsx +57 -2
- package/src/execution/MessageThread.tsx +203 -5
- package/src/execution/__tests__/MessageThread.test.tsx +130 -0
- package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
- package/src/execution/__tests__/useExecutionStream.test.tsx +95 -0
- package/src/execution/index.ts +6 -0
- package/src/execution/useAgentExecutionActions.ts +205 -0
- package/src/execution/useExecutionStream.ts +80 -4
- package/src/index.ts +3 -0
- package/src/internal/VirtualizedThread.tsx +10 -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 +87 -1
- package/src/session/__tests__/useSessionConversation.test.tsx +145 -0
- package/src/session/useSessionConversation.ts +163 -14
- package/src/session/useSessionExecutions.ts +23 -1
- 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
|
-
|
|
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
|
+
});
|
|
@@ -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
|
|
86
|
-
this._execution === null &&
|
|
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
|
-
|
|
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 {
|