@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.
Files changed (55) hide show
  1. package/execution/ExecutionProgress.d.ts.map +1 -1
  2. package/execution/ExecutionProgress.js +5 -1
  3. package/execution/ExecutionProgress.js.map +1 -1
  4. package/execution/MessageThread.d.ts +32 -3
  5. package/execution/MessageThread.d.ts.map +1 -1
  6. package/execution/MessageThread.js +59 -10
  7. package/execution/MessageThread.js.map +1 -1
  8. package/execution/useExecutionStream.d.ts +27 -0
  9. package/execution/useExecutionStream.d.ts.map +1 -1
  10. package/execution/useExecutionStream.js +48 -5
  11. package/execution/useExecutionStream.js.map +1 -1
  12. package/internal/VirtualizedThread.d.ts +3 -1
  13. package/internal/VirtualizedThread.d.ts.map +1 -1
  14. package/internal/VirtualizedThread.js +4 -2
  15. package/internal/VirtualizedThread.js.map +1 -1
  16. package/internal/store/conversation-store.d.ts +22 -0
  17. package/internal/store/conversation-store.d.ts.map +1 -1
  18. package/internal/store/conversation-store.js +43 -2
  19. package/internal/store/conversation-store.js.map +1 -1
  20. package/internal/stream-controller.d.ts +46 -2
  21. package/internal/stream-controller.d.ts.map +1 -1
  22. package/internal/stream-controller.js +95 -4
  23. package/internal/stream-controller.js.map +1 -1
  24. package/internal/useFetch.d.ts +7 -0
  25. package/internal/useFetch.d.ts.map +1 -1
  26. package/internal/useFetch.js +21 -0
  27. package/internal/useFetch.js.map +1 -1
  28. package/package.json +4 -4
  29. package/session/SessionViewer.js +23 -1
  30. package/session/SessionViewer.js.map +1 -1
  31. package/session/useSessionConversation.d.ts +34 -3
  32. package/session/useSessionConversation.d.ts.map +1 -1
  33. package/session/useSessionConversation.js +73 -10
  34. package/session/useSessionConversation.js.map +1 -1
  35. package/session/useSessionExecutions.d.ts +17 -1
  36. package/session/useSessionExecutions.d.ts.map +1 -1
  37. package/session/useSessionExecutions.js +6 -2
  38. package/session/useSessionExecutions.js.map +1 -1
  39. package/src/execution/ExecutionProgress.tsx +12 -0
  40. package/src/execution/MessageThread.tsx +174 -5
  41. package/src/execution/__tests__/MessageThread.test.tsx +64 -0
  42. package/src/execution/__tests__/useExecutionStream.test.tsx +95 -0
  43. package/src/execution/useExecutionStream.ts +80 -4
  44. package/src/internal/VirtualizedThread.tsx +7 -1
  45. package/src/internal/__tests__/stream-controller.test.ts +165 -10
  46. package/src/internal/__tests__/useFetch.test.tsx +59 -0
  47. package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
  48. package/src/internal/store/conversation-store.ts +46 -3
  49. package/src/internal/stream-controller.ts +123 -3
  50. package/src/internal/useFetch.ts +26 -0
  51. package/src/session/SessionViewer.tsx +62 -0
  52. package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
  53. package/src/session/useSessionConversation.ts +113 -14
  54. package/src/session/useSessionExecutions.ts +23 -1
  55. package/styles.css +1 -1
@@ -190,6 +190,67 @@ describe("ConversationStore", () => {
190
190
  });
191
191
  });
192
192
 
193
+ describe("watchdog signals", () => {
194
+ it("connectTimedOut: defaults false, notifies only on change", () => {
195
+ const store = new ConversationStore();
196
+ expect(store.getConnectTimedOut()).toBe(false);
197
+
198
+ const listener = vi.fn();
199
+ store.subscribe(listener);
200
+
201
+ store.setConnectTimedOut(true);
202
+ expect(store.getConnectTimedOut()).toBe(true);
203
+ expect(listener).toHaveBeenCalledTimes(1);
204
+
205
+ store.setConnectTimedOut(true);
206
+ expect(listener).toHaveBeenCalledTimes(1); // no-op on same value
207
+
208
+ store.setConnectTimedOut(false);
209
+ expect(store.getConnectTimedOut()).toBe(false);
210
+ expect(listener).toHaveBeenCalledTimes(2);
211
+ });
212
+
213
+ it("isSlow: defaults false, notifies only on change", () => {
214
+ const store = new ConversationStore();
215
+ expect(store.getSlow()).toBe(false);
216
+
217
+ const listener = vi.fn();
218
+ store.subscribe(listener);
219
+
220
+ store.setSlow(true);
221
+ expect(store.getSlow()).toBe(true);
222
+ expect(listener).toHaveBeenCalledTimes(1);
223
+
224
+ store.setSlow(true);
225
+ expect(listener).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ it("reset clears both watchdog signals", () => {
229
+ const store = new ConversationStore();
230
+ store.setConnectTimedOut(true);
231
+ store.setSlow(true);
232
+
233
+ const listener = vi.fn();
234
+ store.subscribe(listener);
235
+
236
+ store.reset();
237
+ expect(store.getConnectTimedOut()).toBe(false);
238
+ expect(store.getSlow()).toBe(false);
239
+ expect(listener).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it("reset notifies when only a watchdog signal was set", () => {
243
+ const store = new ConversationStore();
244
+ store.setSlow(true);
245
+
246
+ const listener = vi.fn();
247
+ store.subscribe(listener);
248
+
249
+ store.reset();
250
+ expect(listener).toHaveBeenCalledTimes(1);
251
+ });
252
+ });
253
+
193
254
  describe("reset", () => {
194
255
  it("clears execution and stream state", () => {
195
256
  const store = new ConversationStore();
@@ -51,6 +51,8 @@ type Listener = () => void;
51
51
  export class ConversationStore {
52
52
  private _execution: AgentExecution | null = null;
53
53
  private _streamState: StreamState = IDLE_STATE;
54
+ private _connectTimedOut = false;
55
+ private _isSlow = false;
54
56
  private _listeners = new Set<Listener>();
55
57
 
56
58
  // -- Ingestion -----------------------------------------------------------
@@ -77,16 +79,47 @@ export class ConversationStore {
77
79
  this._notify();
78
80
  }
79
81
 
82
+ /**
83
+ * Set the hard connect-timeout signal — the stream opened but no first
84
+ * snapshot arrived within the watchdog window even after a silent retry.
85
+ *
86
+ * Orthogonal to {@link setStreamState}: the stream may still be live, so
87
+ * this is **not** a lifecycle stage and deliberately does not touch the
88
+ * `error` stage (that is auto-reconnect's domain). Booleans are stable by
89
+ * value, so no snapshot caching is needed; listeners fire only on change.
90
+ */
91
+ setConnectTimedOut(value: boolean): void {
92
+ if (this._connectTimedOut === value) return;
93
+ this._connectTimedOut = value;
94
+ this._notify();
95
+ }
96
+
97
+ /**
98
+ * Set the soft slow-stall hint — the stream is non-terminal but has gone
99
+ * silent past the watchdog window. Purely informational ("still working,
100
+ * taking longer than usual"); cleared by the next snapshot. Never aborts.
101
+ */
102
+ setSlow(value: boolean): void {
103
+ if (this._isSlow === value) return;
104
+ this._isSlow = value;
105
+ this._notify();
106
+ }
107
+
80
108
  /**
81
109
  * Reset to initial state. Used when the session identity changes
82
110
  * or the hook unmounts.
83
111
  */
84
112
  reset(): void {
85
- const wasIdle =
86
- this._execution === null && this._streamState.stage === "idle";
113
+ const wasClean =
114
+ this._execution === null &&
115
+ this._streamState.stage === "idle" &&
116
+ !this._connectTimedOut &&
117
+ !this._isSlow;
87
118
  this._execution = null;
88
119
  this._streamState = IDLE_STATE;
89
- if (!wasIdle) this._notify();
120
+ this._connectTimedOut = false;
121
+ this._isSlow = false;
122
+ if (!wasClean) this._notify();
90
123
  }
91
124
 
92
125
  // -- useSyncExternalStore contract ---------------------------------------
@@ -112,6 +145,16 @@ export class ConversationStore {
112
145
  return this._streamState;
113
146
  };
114
147
 
148
+ /** Stable snapshot selector for the hard connect-timeout signal. */
149
+ getConnectTimedOut = (): boolean => {
150
+ return this._connectTimedOut;
151
+ };
152
+
153
+ /** Stable snapshot selector for the soft slow-stall hint. */
154
+ getSlow = (): boolean => {
155
+ return this._isSlow;
156
+ };
157
+
115
158
  // -- Internal ------------------------------------------------------------
116
159
 
117
160
  private _notify(): void {
@@ -14,6 +14,20 @@ import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecutio
14
14
 
15
15
  const IDLE: StreamState = { stage: "idle" };
16
16
 
17
+ /**
18
+ * Hard connect-timeout: time the stream may stay `connecting` (no first
19
+ * snapshot) before the watchdog acts. Sized well above a healthy connect
20
+ * (the server sends the initial snapshot in ~1 RTT) yet short enough that a
21
+ * silent hang surfaces an affordance within a few seconds, not minutes.
22
+ */
23
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
24
+ /**
25
+ * Soft slow-stall threshold: a non-terminal stream that produces no new
26
+ * snapshot for this long flips an informational "still working" hint. Long
27
+ * enough that ordinary model thinking-time never trips it.
28
+ */
29
+ export const DEFAULT_SLOW_THRESHOLD_MS = 60_000;
30
+
17
31
  /**
18
32
  * Callback interface for the stream controller to communicate with
19
33
  * its host (the React hook). All mutations to external state go
@@ -24,6 +38,27 @@ export interface StreamControllerSink {
24
38
  ingestSnapshot(snapshot: AgentExecution): void;
25
39
  /** Transition the store's stream lifecycle state. */
26
40
  setStreamState(state: StreamState): void;
41
+ /**
42
+ * The hard connect-timeout elapsed while still `connecting`. The host owns
43
+ * the self-heal-once-then-surface policy (it holds the `connectKey` reconnect
44
+ * counter and the per-subscription guard that survives a reconnect, which
45
+ * the controller — reset on every teardown — cannot).
46
+ */
47
+ onConnectTimeout(): void;
48
+ /** Set or clear the soft slow-stall hint signal. */
49
+ setSlow(value: boolean): void;
50
+ }
51
+
52
+ /**
53
+ * Injectable watchdog configuration. Timers default to `setTimeout`/
54
+ * `clearTimeout`; tests pass fakes so the watchdog is exercised without real
55
+ * time. Mirrors the existing `scheduleFlush`/`cancelFlush` injection idiom.
56
+ */
57
+ export interface StreamControllerWatchdog {
58
+ readonly setTimer?: (cb: () => void, ms: number) => number;
59
+ readonly clearTimer?: (id: number) => void;
60
+ readonly connectTimeoutMs?: number;
61
+ readonly slowThresholdMs?: number;
27
62
  }
28
63
 
29
64
  // ---------------------------------------------------------------------------
@@ -52,6 +87,14 @@ export class StreamController {
52
87
  private _scheduleFlush: (cb: () => void) => number;
53
88
  private _cancelFlush: (id: number) => void;
54
89
 
90
+ // -- Watchdog ------------------------------------------------------------
91
+ private _setTimer: (cb: () => void, ms: number) => number;
92
+ private _clearTimer: (id: number) => void;
93
+ private _connectTimeoutMs: number;
94
+ private _slowThresholdMs: number;
95
+ private _connectTimerId: number | null = null;
96
+ private _slowTimerId: number | null = null;
97
+
55
98
  constructor(
56
99
  sink: StreamControllerSink,
57
100
  scheduleFlush: (cb: () => void) => number = typeof requestAnimationFrame !== "undefined"
@@ -60,10 +103,19 @@ export class StreamController {
60
103
  cancelFlush: (id: number) => void = typeof cancelAnimationFrame !== "undefined"
61
104
  ? (id: number) => cancelAnimationFrame(id)
62
105
  : (id: number) => clearTimeout(id),
106
+ watchdog?: StreamControllerWatchdog,
63
107
  ) {
64
108
  this._sink = sink;
65
109
  this._scheduleFlush = scheduleFlush;
66
110
  this._cancelFlush = cancelFlush;
111
+ this._setTimer =
112
+ watchdog?.setTimer ??
113
+ ((cb, ms) => setTimeout(cb, ms) as unknown as number);
114
+ this._clearTimer = watchdog?.clearTimer ?? ((id) => clearTimeout(id));
115
+ this._connectTimeoutMs =
116
+ watchdog?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
117
+ this._slowThresholdMs =
118
+ watchdog?.slowThresholdMs ?? DEFAULT_SLOW_THRESHOLD_MS;
67
119
  }
68
120
 
69
121
  /** Current FSM state (read-only). */
@@ -78,6 +130,11 @@ export class StreamController {
78
130
  start(executionId: string): void {
79
131
  this._cancelPendingFlush();
80
132
  this._bufferedSnapshot = null;
133
+ // Arm both watchdogs the moment we begin connecting. The connect-timeout
134
+ // guards the first snapshot; the slow-stall hint guards every quiet stretch
135
+ // thereafter. Both are reset by the next snapshot and cleared on any exit.
136
+ this._armConnectTimer();
137
+ this._armSlowTimer();
81
138
  this._transition({ stage: "connecting", executionId });
82
139
  }
83
140
 
@@ -95,13 +152,19 @@ export class StreamController {
95
152
  const terminal = isTerminalPhase(phase);
96
153
 
97
154
  if (terminal) {
155
+ this._cancelWatchdogs();
156
+ this._sink.setSlow(false);
98
157
  this._cancelPendingFlush();
99
158
  this._bufferedSnapshot = null;
100
159
  this._sink.ingestSnapshot(snapshot);
101
160
  this._transition({ stage: "complete", executionId });
102
161
  } else {
103
- // A snapshot proves the (re)connection is healthy: advance from either
104
- // the initial `connecting` or a `reconnecting` retry into `streaming`.
162
+ // A snapshot proves the (re)connection is healthy: the connect-timeout no
163
+ // longer applies, and the slow-stall window restarts from now.
164
+ this._cancelConnectTimer();
165
+ this._armSlowTimer();
166
+ // Advance from either the initial `connecting` or a `reconnecting` retry
167
+ // into `streaming`.
105
168
  if (
106
169
  this._state.stage === "connecting" ||
107
170
  this._state.stage === "reconnecting"
@@ -123,6 +186,12 @@ export class StreamController {
123
186
  handleReconnecting(attempt: number, error: Error): void {
124
187
  const executionId = this._activeExecutionId();
125
188
  if (!executionId) return;
189
+ // Auto-reconnect (#174) has taken over with its own visible affordance and
190
+ // bounded attempt budget, so the silence watchdogs stand down — they exist
191
+ // for the case where the stream stalls *without* a drop. The slow hint is
192
+ // cleared so "reconnecting" and "slow" never show at once.
193
+ this._cancelWatchdogs();
194
+ this._sink.setSlow(false);
126
195
  this._transition({ stage: "reconnecting", executionId, attempt, error });
127
196
  }
128
197
 
@@ -134,6 +203,8 @@ export class StreamController {
134
203
  const executionId = this._activeExecutionId();
135
204
  if (!executionId) return;
136
205
 
206
+ this._cancelWatchdogs();
207
+ this._sink.setSlow(false);
137
208
  this._flushBuffer();
138
209
  if (this._state.stage !== "complete") {
139
210
  this._transition({ stage: "complete", executionId });
@@ -148,13 +219,17 @@ export class StreamController {
148
219
  const executionId = this._activeExecutionId();
149
220
  if (!executionId) return;
150
221
 
222
+ this._cancelWatchdogs();
223
+ this._sink.setSlow(false);
151
224
  this._cancelPendingFlush();
152
225
  this._flushBuffer();
153
226
  this._transition({ stage: "error", executionId, error });
154
227
  }
155
228
 
156
- /** Reset to idle. Cancels any pending flush. */
229
+ /** Reset to idle. Cancels any pending flush and watchdog timers. */
157
230
  reset(): void {
231
+ this._cancelWatchdogs();
232
+ this._sink.setSlow(false);
158
233
  this._cancelPendingFlush();
159
234
  this._bufferedSnapshot = null;
160
235
  if (this._state.stage !== "idle") {
@@ -203,4 +278,49 @@ export class StreamController {
203
278
  this._rafId = null;
204
279
  }
205
280
  }
281
+
282
+ // -- Watchdog timers ------------------------------------------------------
283
+
284
+ private _armConnectTimer(): void {
285
+ this._cancelConnectTimer();
286
+ this._connectTimerId = this._setTimer(() => {
287
+ this._connectTimerId = null;
288
+ // Only meaningful while still awaiting the first snapshot; any later
289
+ // stage has its own handling and has already cleared this timer.
290
+ if (this._state.stage !== "connecting") return;
291
+ this._sink.onConnectTimeout();
292
+ }, this._connectTimeoutMs);
293
+ }
294
+
295
+ private _armSlowTimer(): void {
296
+ this._cancelSlowTimer();
297
+ // Re-arming means activity just resumed (or is starting), so a prior slow
298
+ // hint no longer holds — clear it before counting the next quiet stretch.
299
+ this._sink.setSlow(false);
300
+ this._slowTimerId = this._setTimer(() => {
301
+ this._slowTimerId = null;
302
+ if (this._state.stage !== "connecting" && this._state.stage !== "streaming")
303
+ return;
304
+ this._sink.setSlow(true);
305
+ }, this._slowThresholdMs);
306
+ }
307
+
308
+ private _cancelConnectTimer(): void {
309
+ if (this._connectTimerId !== null) {
310
+ this._clearTimer(this._connectTimerId);
311
+ this._connectTimerId = null;
312
+ }
313
+ }
314
+
315
+ private _cancelSlowTimer(): void {
316
+ if (this._slowTimerId !== null) {
317
+ this._clearTimer(this._slowTimerId);
318
+ this._slowTimerId = null;
319
+ }
320
+ }
321
+
322
+ private _cancelWatchdogs(): void {
323
+ this._cancelConnectTimer();
324
+ this._cancelSlowTimer();
325
+ }
206
326
  }
@@ -15,6 +15,14 @@ export interface UseFetchOptions {
15
15
  */
16
16
  readonly refetchInterval?: number | false;
17
17
 
18
+ /**
19
+ * Re-fetch when the window regains focus or the tab becomes visible
20
+ * again. Covers the app-relaunch / tab-switch case where data may have
21
+ * gone stale while the app was backgrounded. A focus refetch is skipped
22
+ * while a fetch is already in flight. Defaults to `false`.
23
+ */
24
+ readonly refetchOnWindowFocus?: boolean;
25
+
18
26
  /**
19
27
  * Stable string key for cross-mount caching.
20
28
  *
@@ -183,6 +191,24 @@ export function useFetch<T>(
183
191
  return () => clearInterval(id);
184
192
  }, [refetchInterval, fetchFn, refetch]);
185
193
 
194
+ const refetchOnWindowFocus = options?.refetchOnWindowFocus;
195
+ useEffect(() => {
196
+ if (!refetchOnWindowFocus || !fetchFn) return;
197
+ if (typeof window === "undefined") return;
198
+ const onActive = () => {
199
+ if (!isFetchingRef.current) refetch();
200
+ };
201
+ const onVisible = () => {
202
+ if (document.visibilityState === "visible") onActive();
203
+ };
204
+ window.addEventListener("focus", onActive);
205
+ document.addEventListener("visibilitychange", onVisible);
206
+ return () => {
207
+ window.removeEventListener("focus", onActive);
208
+ document.removeEventListener("visibilitychange", onVisible);
209
+ };
210
+ }, [refetchOnWindowFocus, fetchFn, refetch]);
211
+
186
212
  const isLoading = isFetching && !hasDataRef.current;
187
213
  const isRefetching = isFetching && hasDataRef.current;
188
214
 
@@ -290,12 +290,24 @@ function ConversationColumn({
290
290
  const { conv } = flow;
291
291
  const sendError = flow.submitError ?? conv.sendError ?? conv.approvalError;
292
292
 
293
+ // Retry a terminal-failed execution by resending its originating message
294
+ // through the full submit pipeline (agent override, runtime-env, workspace).
295
+ const onRetryExecution = useCallback(
296
+ (message: string) => {
297
+ void flow.handleSubmit(message);
298
+ },
299
+ [flow.handleSubmit],
300
+ );
301
+
293
302
  return (
294
303
  <div className="flex h-full min-w-0 flex-col">
295
304
  <MessageThread
296
305
  executions={conv.completedExecutions}
297
306
  activeStreamExecution={conv.activeStreamExecution}
298
307
  pendingUserMessage={conv.pendingUserMessage}
308
+ pendingMessageFailed={!!conv.sendError && !!conv.pendingUserMessage}
309
+ onRetrySend={conv.retryLastSend}
310
+ onRetryExecution={onRetryExecution}
299
311
  onApprovalSubmit={flow.submitApproval}
300
312
  submittingApprovalIds={conv.submittingApprovalIds}
301
313
  workspaceEntries={conv.workspaceEntries}
@@ -308,6 +320,10 @@ function ConversationColumn({
308
320
  />
309
321
  <div className="mx-auto w-full max-w-3xl">
310
322
  {conv.isReconnecting && <ReconnectingIndicator />}
323
+ {conv.connectTimedOut && (
324
+ <ConnectTimedOutBanner onRetry={conv.reconnectStream} />
325
+ )}
326
+ {conv.isSlow && !conv.connectTimedOut && <SlowIndicator />}
311
327
  {conv.streamError && (
312
328
  <StreamErrorBanner
313
329
  error={conv.streamError}
@@ -573,6 +589,52 @@ function ReconnectingIndicator() {
573
589
  );
574
590
  }
575
591
 
592
+ /**
593
+ * Actionable banner for the connect-timeout watchdog: the stream opened but
594
+ * never delivered a first snapshot — the agent hasn't started. Mirrors
595
+ * {@link StreamErrorBanner}'s shape (message + Retry) since both resolve via
596
+ * the same `reconnect()` path, but its copy names the specific failure.
597
+ */
598
+ function ConnectTimedOutBanner({ onRetry }: { onRetry: () => void }) {
599
+ return (
600
+ <div role="alert" className="flex items-center gap-3 border-t border-border bg-muted px-4 py-2.5">
601
+ <p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
602
+ The agent hasn&rsquo;t started yet.
603
+ </p>
604
+ <button
605
+ type="button"
606
+ onClick={onRetry}
607
+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-card"
608
+ >
609
+ Retry
610
+ </button>
611
+ </div>
612
+ );
613
+ }
614
+
615
+ /**
616
+ * Low-weight informational hint for the slow-stall watchdog: a live stream has
617
+ * gone quiet longer than usual. Never an error and offers no action — the
618
+ * stream is still expected to resume on its own.
619
+ */
620
+ function SlowIndicator() {
621
+ return (
622
+ <div
623
+ role="status"
624
+ aria-live="polite"
625
+ className="flex items-center gap-2 border-t border-border-muted px-4 py-1.5 text-xs text-muted-foreground"
626
+ >
627
+ <span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
628
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-muted-foreground opacity-75 motion-reduce:animate-none" />
629
+ <span className="relative inline-flex h-2 w-2 rounded-full bg-muted-foreground" />
630
+ </span>
631
+ <span className="min-w-0 flex-1 truncate">
632
+ Still working — this is taking longer than usual.
633
+ </span>
634
+ </div>
635
+ );
636
+ }
637
+
576
638
  function StreamErrorBanner({
577
639
  error,
578
640
  onReconnect,
@@ -306,6 +306,59 @@ describe("useSessionConversation", () => {
306
306
  });
307
307
  });
308
308
 
309
+ it("preserves the message and surfaces sendError when the send fails", async () => {
310
+ methods.listBySession.mockResolvedValue({ entries: [] });
311
+ methods.executionCreate.mockRejectedValue(new Error("create boom"));
312
+
313
+ const { result } = renderHook(
314
+ () => useSessionConversation("session-1", "org"),
315
+ { wrapper: createWrapper(mockStigmer) },
316
+ );
317
+
318
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
319
+
320
+ await act(async () => {
321
+ await result.current.sendFollowUp("keep me");
322
+ });
323
+
324
+ // The failure is surfaced and the user's text is NOT lost.
325
+ expect(result.current.sendError?.message).toBe("create boom");
326
+ expect(result.current.pendingUserMessage).toBe("keep me");
327
+ });
328
+
329
+ it("retryLastSend resubmits the same message after a failure", async () => {
330
+ methods.listBySession.mockResolvedValue({ entries: [] });
331
+ methods.executionCreate.mockRejectedValueOnce(new Error("boom"));
332
+ const retried = makeExecution("e-retry", ExecutionPhase.EXECUTION_PENDING);
333
+ retried.metadata!.id = "e-retry";
334
+ methods.executionCreate.mockResolvedValue(retried);
335
+
336
+ const { result } = renderHook(
337
+ () => useSessionConversation("session-1", "org"),
338
+ { wrapper: createWrapper(mockStigmer) },
339
+ );
340
+
341
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
342
+
343
+ await act(async () => {
344
+ await result.current.sendFollowUp("retry me");
345
+ });
346
+ expect(result.current.sendError).not.toBeNull();
347
+
348
+ await act(async () => {
349
+ result.current.retryLastSend();
350
+ });
351
+
352
+ await waitFor(() =>
353
+ expect(methods.executionCreate).toHaveBeenCalledTimes(2),
354
+ );
355
+ expect(methods.executionCreate).toHaveBeenLastCalledWith(
356
+ expect.objectContaining({ message: "retry me" }),
357
+ );
358
+ // The retry cleared the prior failure for the new attempt.
359
+ expect(result.current.sendError).toBeNull();
360
+ });
361
+
309
362
  it("full follow-up lifecycle: active execution completes → canSendFollowUp → sendFollowUp succeeds", async () => {
310
363
  // Start with one IN_PROGRESS execution (simulates a Cursor execution running)
311
364
  const activeExec = makeExecution("exec-1", ExecutionPhase.EXECUTION_IN_PROGRESS);