@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.
Files changed (88) 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 +76 -5
  9. package/execution/useExecutionStream.d.ts.map +1 -1
  10. package/execution/useExecutionStream.js +166 -23
  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/backoff.d.ts +61 -0
  17. package/internal/backoff.d.ts.map +1 -0
  18. package/internal/backoff.js +79 -0
  19. package/internal/backoff.js.map +1 -0
  20. package/internal/store/conversation-store.d.ts +34 -0
  21. package/internal/store/conversation-store.d.ts.map +1 -1
  22. package/internal/store/conversation-store.js +50 -2
  23. package/internal/store/conversation-store.js.map +1 -1
  24. package/internal/store/workflow-execution-event-store.d.ts +12 -0
  25. package/internal/store/workflow-execution-event-store.d.ts.map +1 -1
  26. package/internal/store/workflow-execution-event-store.js +7 -0
  27. package/internal/store/workflow-execution-event-store.js.map +1 -1
  28. package/internal/stream-controller.d.ts +57 -21
  29. package/internal/stream-controller.d.ts.map +1 -1
  30. package/internal/stream-controller.js +117 -3
  31. package/internal/stream-controller.js.map +1 -1
  32. package/internal/useFetch.d.ts +7 -0
  33. package/internal/useFetch.d.ts.map +1 -1
  34. package/internal/useFetch.js +21 -0
  35. package/internal/useFetch.js.map +1 -1
  36. package/package.json +4 -4
  37. package/session/SessionViewer.js +26 -1
  38. package/session/SessionViewer.js.map +1 -1
  39. package/session/useSessionConversation.d.ts +41 -4
  40. package/session/useSessionConversation.d.ts.map +1 -1
  41. package/session/useSessionConversation.js +74 -10
  42. package/session/useSessionConversation.js.map +1 -1
  43. package/session/useSessionExecutions.d.ts +17 -1
  44. package/session/useSessionExecutions.d.ts.map +1 -1
  45. package/session/useSessionExecutions.js +6 -2
  46. package/session/useSessionExecutions.js.map +1 -1
  47. package/src/execution/ExecutionProgress.tsx +12 -0
  48. package/src/execution/MessageThread.tsx +174 -5
  49. package/src/execution/__tests__/MessageThread.test.tsx +64 -0
  50. package/src/execution/__tests__/useExecutionStream.test.tsx +279 -0
  51. package/src/execution/useExecutionStream.ts +254 -34
  52. package/src/internal/VirtualizedThread.tsx +7 -1
  53. package/src/internal/__tests__/backoff.test.ts +99 -0
  54. package/src/internal/__tests__/stream-controller.test.ts +165 -10
  55. package/src/internal/__tests__/useFetch.test.tsx +59 -0
  56. package/src/internal/backoff.ts +100 -0
  57. package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
  58. package/src/internal/store/conversation-store.ts +68 -3
  59. package/src/internal/store/workflow-execution-event-store.ts +22 -0
  60. package/src/internal/stream-controller.ts +151 -26
  61. package/src/internal/useFetch.ts +26 -0
  62. package/src/session/SessionViewer.tsx +89 -0
  63. package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
  64. package/src/session/useSessionConversation.ts +121 -15
  65. package/src/session/useSessionExecutions.ts +23 -1
  66. package/src/workflow/WorkflowExecutionHeader.tsx +4 -1
  67. package/src/workflow/WorkflowExecutionTimeline.tsx +2 -1
  68. package/src/workflow/__tests__/useWorkflowExecutionEventStream.test.tsx +117 -1
  69. package/src/workflow/execution/useWaterfallEntries.ts +2 -1
  70. package/src/workflow/useWorkflowExecutionEventStream.ts +122 -41
  71. package/src/workflow/waterfall/WaterfallTimeline.tsx +2 -1
  72. package/styles.css +1 -1
  73. package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
  74. package/workflow/WorkflowExecutionHeader.js +3 -1
  75. package/workflow/WorkflowExecutionHeader.js.map +1 -1
  76. package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
  77. package/workflow/WorkflowExecutionTimeline.js +1 -1
  78. package/workflow/WorkflowExecutionTimeline.js.map +1 -1
  79. package/workflow/execution/useWaterfallEntries.d.ts.map +1 -1
  80. package/workflow/execution/useWaterfallEntries.js +1 -1
  81. package/workflow/execution/useWaterfallEntries.js.map +1 -1
  82. package/workflow/useWorkflowExecutionEventStream.d.ts +32 -4
  83. package/workflow/useWorkflowExecutionEventStream.d.ts.map +1 -1
  84. package/workflow/useWorkflowExecutionEventStream.js +75 -32
  85. package/workflow/useWorkflowExecutionEventStream.js.map +1 -1
  86. package/workflow/waterfall/WaterfallTimeline.d.ts.map +1 -1
  87. package/workflow/waterfall/WaterfallTimeline.js +1 -1
  88. package/workflow/waterfall/WaterfallTimeline.js.map +1 -1
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { lazy, memo, Suspense, useCallback, useMemo } from "react";
3
+ import { lazy, memo, Suspense, useCallback, useMemo, useState } from "react";
4
4
  import { create } from "@bufbuild/protobuf";
5
5
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
6
6
  import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
@@ -60,6 +60,27 @@ export interface MessageThreadProps {
60
60
  * first snapshot.
61
61
  */
62
62
  readonly pendingUserMessage?: string | null;
63
+ /**
64
+ * Marks the pending user message as failed-to-send. The optimistic
65
+ * bubble renders an inline "Couldn't send — Retry" affordance instead
66
+ * of the sending indicator, so a failed follow-up never silently
67
+ * vanishes. Pair with {@link onRetrySend}.
68
+ *
69
+ * @default false
70
+ */
71
+ readonly pendingMessageFailed?: boolean;
72
+ /**
73
+ * Retry handler for a failed pending message. Wired to the inline
74
+ * "Retry" control when {@link pendingMessageFailed} is `true`.
75
+ */
76
+ readonly onRetrySend?: () => void;
77
+ /**
78
+ * Retry handler for a terminal-failed execution that exposed a server
79
+ * error reason. Receives the originating message; the consumer typically
80
+ * resends it as a new execution. When omitted, no Retry control is shown
81
+ * beside the surfaced failure reason.
82
+ */
83
+ readonly onRetryExecution?: (message: string) => void;
63
84
  /** Additional CSS class names for the root container. */
64
85
  readonly className?: string;
65
86
  /**
@@ -178,10 +199,11 @@ export interface MessageThreadProps {
178
199
  * part of the public API.
179
200
  */
180
201
  export type ThreadItem =
181
- | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean }
202
+ | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean }
182
203
  | { readonly kind: "tool-group"; readonly toolCalls: readonly ToolCall[]; readonly subAgentExecutions: readonly SubAgentExecution[]; readonly key: string }
183
204
  | { readonly kind: "sub-agent"; readonly subAgentExecution: SubAgentExecution; readonly key: string }
184
205
  | { readonly kind: "phase-badge"; readonly phase: ExecutionPhase; readonly key: string }
206
+ | { readonly kind: "execution-error"; readonly error: string; readonly retryMessage?: string; readonly key: string }
185
207
  | { readonly kind: "approval-request"; readonly pendingApproval: PendingApproval; readonly key: string }
186
208
  | { readonly kind: "setup-progress"; readonly workspaceEntries: readonly WorkspaceEntry[]; readonly serverPhase?: string; readonly isAwaitingResponse?: boolean; readonly key: string }
187
209
  | { readonly kind: "context-compacted"; readonly event: SummarizationEventView; readonly key: string }
@@ -216,6 +238,7 @@ export function buildThreadItems(
216
238
  includeApprovals: boolean,
217
239
  workspaceEntries: readonly WorkspaceEntry[] | undefined,
218
240
  summarizationEvents?: readonly SummarizationEventView[],
241
+ pendingMessageFailed = false,
219
242
  ): ThreadItem[] {
220
243
  const items: ThreadItem[] = [];
221
244
  const allExecutions = activeStreamExecution
@@ -393,6 +416,22 @@ export function buildThreadItems(
393
416
  phase: lastPhase,
394
417
  key: `phase-${lastPhase}`,
395
418
  });
419
+
420
+ // The server populates `status.error` only on EXECUTION_FAILED. Surface it
421
+ // beside the badge so a failure that produced no messages still explains
422
+ // itself (the CLI shows this reason; the chat previously showed nothing).
423
+ // Kept as its own item so the badge component stays presentational.
424
+ const reason = lastExec?.status?.error;
425
+ if (reason) {
426
+ const specMessage = lastExec?.spec?.message;
427
+ items.push({
428
+ kind: "execution-error",
429
+ error: reason,
430
+ retryMessage:
431
+ specMessage && specMessage !== "execute" ? specMessage : undefined,
432
+ key: `execution-error-${lastExec?.metadata?.id ?? lastPhase}`,
433
+ });
434
+ }
396
435
  }
397
436
 
398
437
  if (
@@ -430,7 +469,10 @@ export function buildThreadItems(
430
469
  kind: "message",
431
470
  message: syntheticPending,
432
471
  key: "pending-user-turn",
433
- isPending: true,
472
+ // A failed turn shows the inline error instead of the dimmed
473
+ // sending state — the two are mutually exclusive.
474
+ isPending: !pendingMessageFailed,
475
+ isFailed: pendingMessageFailed,
434
476
  });
435
477
  }
436
478
  }
@@ -465,6 +507,9 @@ export function MessageThread({
465
507
  executions,
466
508
  activeStreamExecution,
467
509
  pendingUserMessage,
510
+ pendingMessageFailed = false,
511
+ onRetrySend,
512
+ onRetryExecution,
468
513
  className,
469
514
  formatToolCallSummary,
470
515
  onApprovalSubmit,
@@ -483,8 +528,8 @@ export function MessageThread({
483
528
 
484
529
  const includeApprovals = onApprovalSubmit != null;
485
530
  const items = useMemo(
486
- () => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents),
487
- [executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents],
531
+ () => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed),
532
+ [executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed],
488
533
  );
489
534
 
490
535
  useKeyStability(items);
@@ -517,6 +562,8 @@ export function MessageThread({
517
562
  org={org}
518
563
  planActionsDisabled={planActionsDisabled}
519
564
  centerContent={centerContent}
565
+ onRetrySend={onRetrySend}
566
+ onRetryExecution={onRetryExecution}
520
567
  />
521
568
  </Suspense>
522
569
  </div>
@@ -536,6 +583,8 @@ export function MessageThread({
536
583
  onBuildFromPlan={onBuildFromPlan}
537
584
  org={org}
538
585
  planActionsDisabled={planActionsDisabled}
586
+ onRetrySend={onRetrySend}
587
+ onRetryExecution={onRetryExecution}
539
588
  />
540
589
  );
541
590
  }
@@ -560,6 +609,8 @@ interface NonVirtualizedThreadProps {
560
609
  readonly onBuildFromPlan?: () => void;
561
610
  readonly org?: string;
562
611
  readonly planActionsDisabled?: boolean;
612
+ readonly onRetrySend?: () => void;
613
+ readonly onRetryExecution?: (message: string) => void;
563
614
  }
564
615
 
565
616
  function NonVirtualizedThread({
@@ -574,6 +625,8 @@ function NonVirtualizedThread({
574
625
  onBuildFromPlan,
575
626
  org,
576
627
  planActionsDisabled,
628
+ onRetrySend,
629
+ onRetryExecution,
577
630
  }: NonVirtualizedThreadProps) {
578
631
  const { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest } =
579
632
  useAutoScroll();
@@ -609,6 +662,8 @@ function NonVirtualizedThread({
609
662
  onBuildFromPlan={onBuildFromPlan}
610
663
  org={org}
611
664
  planActionsDisabled={planActionsDisabled}
665
+ onRetrySend={onRetrySend}
666
+ onRetryExecution={onRetryExecution}
612
667
  />
613
668
  </ThreadItemWrapper>
614
669
  ))}
@@ -645,6 +700,8 @@ export interface ThreadItemRendererProps {
645
700
  readonly onBuildFromPlan?: () => void;
646
701
  readonly org?: string;
647
702
  readonly planActionsDisabled?: boolean;
703
+ readonly onRetrySend?: () => void;
704
+ readonly onRetryExecution?: (message: string) => void;
648
705
  }
649
706
 
650
707
  /**
@@ -666,9 +723,16 @@ export function ThreadItemRenderer({
666
723
  onBuildFromPlan,
667
724
  org,
668
725
  planActionsDisabled,
726
+ onRetrySend,
727
+ onRetryExecution,
669
728
  }: ThreadItemRendererProps) {
670
729
  switch (item.kind) {
671
730
  case "message":
731
+ if (item.isFailed) {
732
+ return (
733
+ <FailedUserMessage message={item.message} onRetry={onRetrySend} />
734
+ );
735
+ }
672
736
  return (
673
737
  <MessageEntry
674
738
  message={item.message}
@@ -697,6 +761,14 @@ export function ThreadItemRenderer({
697
761
  <ExecutionPhaseBadge phase={item.phase} />
698
762
  </div>
699
763
  );
764
+ case "execution-error":
765
+ return (
766
+ <ExecutionErrorNotice
767
+ error={item.error}
768
+ retryMessage={item.retryMessage}
769
+ onRetry={onRetryExecution}
770
+ />
771
+ );
700
772
  case "approval-request":
701
773
  return (
702
774
  <ApprovalCardRow
@@ -736,6 +808,103 @@ export function ThreadItemRenderer({
736
808
  }
737
809
  }
738
810
 
811
+ // ---------------------------------------------------------------------------
812
+ // FailedUserMessage — optimistic turn whose send failed
813
+ // ---------------------------------------------------------------------------
814
+
815
+ /**
816
+ * Renders a user message whose send failed: the message itself stays visible
817
+ * (so the typed text is never lost) with an inline, actionable error beneath
818
+ * it. The error copy is intentionally short — the full reason is surfaced by
819
+ * the consumer's send-error banner; this is the in-thread "Retry" affordance.
820
+ */
821
+ function FailedUserMessage({
822
+ message,
823
+ onRetry,
824
+ }: {
825
+ message: AgentMessage;
826
+ onRetry?: () => void;
827
+ }) {
828
+ return (
829
+ <div className="flex flex-col gap-1">
830
+ <MessageEntry message={message} />
831
+ <div
832
+ role="alert"
833
+ className="mx-4 flex items-center gap-2 text-xs text-destructive"
834
+ >
835
+ <span className="min-w-0 flex-1 truncate">Couldn&rsquo;t send.</span>
836
+ {onRetry && (
837
+ <button
838
+ type="button"
839
+ onClick={onRetry}
840
+ className="shrink-0 rounded font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
841
+ >
842
+ Retry
843
+ </button>
844
+ )}
845
+ </div>
846
+ </div>
847
+ );
848
+ }
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // ExecutionErrorNotice — server failure reason for a terminal-failed execution
852
+ // ---------------------------------------------------------------------------
853
+
854
+ /**
855
+ * Renders the server-reported failure reason (`AgentExecutionStatus.error`)
856
+ * for an execution that died — typically before producing any messages. The
857
+ * reason can be a long Temporal error, so it is clamped by default with a
858
+ * Show more / Show less toggle. An optional Retry resends the originating
859
+ * message as a fresh execution.
860
+ */
861
+ function ExecutionErrorNotice({
862
+ error,
863
+ retryMessage,
864
+ onRetry,
865
+ }: {
866
+ error: string;
867
+ retryMessage?: string;
868
+ onRetry?: (message: string) => void;
869
+ }) {
870
+ const [expanded, setExpanded] = useState(false);
871
+ const canRetry = !!onRetry && !!retryMessage;
872
+
873
+ return (
874
+ <div
875
+ role="alert"
876
+ className="mx-4 flex flex-col gap-1.5 rounded-md bg-destructive-subtle px-3 py-2"
877
+ >
878
+ <p
879
+ className={cn(
880
+ "text-xs whitespace-pre-wrap break-words text-destructive",
881
+ !expanded && "line-clamp-3",
882
+ )}
883
+ >
884
+ {error}
885
+ </p>
886
+ <div className="flex items-center gap-3 text-xs">
887
+ <button
888
+ type="button"
889
+ onClick={() => setExpanded((v) => !v)}
890
+ className="font-medium text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
891
+ >
892
+ {expanded ? "Show less" : "Show more"}
893
+ </button>
894
+ {canRetry && (
895
+ <button
896
+ type="button"
897
+ onClick={() => onRetry!(retryMessage!)}
898
+ className="font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
899
+ >
900
+ Retry
901
+ </button>
902
+ )}
903
+ </div>
904
+ </div>
905
+ );
906
+ }
907
+
739
908
  // ---------------------------------------------------------------------------
740
909
  // ApprovalCardRow — stabilizes the onSubmit callback for React.memo
741
910
  // ---------------------------------------------------------------------------
@@ -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",
@@ -309,6 +309,21 @@ describe("useExecutionStream", () => {
309
309
  });
310
310
  });
311
311
 
312
+ it("does not auto-retry a non-transient error (goes straight to error)", async () => {
313
+ const { result } = renderHook(() => useExecutionStream("exec-1"), {
314
+ wrapper: createWrapper(mockStigmer),
315
+ });
316
+
317
+ // A deterministic error (plain Error, not transport noise) must not retry.
318
+ act(() => stream.fail(new Error("invalid argument")));
319
+
320
+ await waitFor(() => {
321
+ expect(result.current.error?.message).toBe("invalid argument");
322
+ });
323
+ expect(result.current.isReconnecting).toBe(false);
324
+ expect(subscribeFn).toHaveBeenCalledTimes(1);
325
+ });
326
+
312
327
  it("handles all terminal phases correctly", async () => {
313
328
  for (const terminalPhase of [
314
329
  ExecutionPhase.EXECUTION_COMPLETED,
@@ -337,3 +352,267 @@ describe("useExecutionStream", () => {
337
352
  }
338
353
  });
339
354
  });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Auto-reconnect (#174)
358
+ // ---------------------------------------------------------------------------
359
+
360
+ describe("useExecutionStream — auto-reconnect", () => {
361
+ const IN_PROGRESS = ExecutionPhase.EXECUTION_IN_PROGRESS;
362
+ // Tiny backoff so retries fire near-instantly under real timers; avoids the
363
+ // fake-timer / `waitFor` interaction that makes such tests brittle.
364
+ const fastReconnect = { baseDelayMs: 5, maxDelayMs: 5 } as const;
365
+
366
+ it("auto-reconnects after a transient drop and keeps the last snapshot", async () => {
367
+ const s1 = createControllableStream<AgentExecution>();
368
+ const s2 = createControllableStream<AgentExecution>();
369
+ let call = 0;
370
+ const subscribeFn = vi.fn(() => (call++ === 0 ? s1.generator : s2.generator));
371
+ const stigmer = createMockStigmer(
372
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
373
+ );
374
+
375
+ const { result } = renderHook(
376
+ () => useExecutionStream("exec-1", { reconnectOptions: fastReconnect }),
377
+ { wrapper: createWrapper(stigmer) },
378
+ );
379
+
380
+ act(() => s1.push(makeSnapshot(IN_PROGRESS)));
381
+ await waitFor(() => expect(result.current.isStreaming).toBe(true));
382
+
383
+ // WebKit's bare `TypeError: Load failed` — the #174 symptom.
384
+ act(() => s1.fail(new TypeError("Load failed")));
385
+
386
+ await waitFor(() => expect(result.current.isReconnecting).toBe(true));
387
+ // No error surfaced and the conversation stays visible while retrying.
388
+ expect(result.current.error).toBeNull();
389
+ expect(result.current.execution).not.toBeNull();
390
+
391
+ // Backoff elapses → the stream is re-subscribed (full snapshot resume).
392
+ await waitFor(() => expect(subscribeFn).toHaveBeenCalledTimes(2));
393
+
394
+ act(() => s2.push(makeSnapshot(IN_PROGRESS)));
395
+ await waitFor(() => expect(result.current.isStreaming).toBe(true));
396
+ expect(result.current.isReconnecting).toBe(false);
397
+ });
398
+
399
+ it("auto-reconnects when the stream ends before a terminal phase", async () => {
400
+ const s1 = createControllableStream<AgentExecution>();
401
+ const s2 = createControllableStream<AgentExecution>();
402
+ let call = 0;
403
+ const subscribeFn = vi.fn(() => (call++ === 0 ? s1.generator : s2.generator));
404
+ const stigmer = createMockStigmer(
405
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
406
+ );
407
+
408
+ const { result } = renderHook(
409
+ () => useExecutionStream("exec-1", { reconnectOptions: fastReconnect }),
410
+ { wrapper: createWrapper(stigmer) },
411
+ );
412
+
413
+ act(() => s1.push(makeSnapshot(IN_PROGRESS)));
414
+ await waitFor(() => expect(result.current.isStreaming).toBe(true));
415
+
416
+ // A graceful close of a still-running execution (idle timeout, LB recycle)
417
+ // must reconnect, never be reported as "complete".
418
+ act(() => s1.finish());
419
+
420
+ await waitFor(() => expect(subscribeFn).toHaveBeenCalledTimes(2));
421
+ expect(result.current.isReconnecting).toBe(true);
422
+ expect(result.current.error).toBeNull();
423
+ });
424
+
425
+ it("surfaces an error only after retries are exhausted", async () => {
426
+ const subscribeFn = vi.fn(
427
+ () =>
428
+ (async function* (): AsyncGenerator<AgentExecution> {
429
+ throw new TypeError("Load failed");
430
+ })(),
431
+ );
432
+ const stigmer = createMockStigmer(
433
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
434
+ );
435
+
436
+ const { result } = renderHook(
437
+ () =>
438
+ useExecutionStream("exec-1", {
439
+ reconnectOptions: { baseDelayMs: 1, maxDelayMs: 1, maxAttempts: 2 },
440
+ }),
441
+ { wrapper: createWrapper(stigmer) },
442
+ );
443
+
444
+ await waitFor(() => expect(result.current.error).not.toBeNull(), {
445
+ timeout: 2000,
446
+ });
447
+ expect(result.current.error?.message).toBe("Load failed");
448
+ expect(result.current.isReconnecting).toBe(false);
449
+ // 1 initial attempt + 2 retries.
450
+ expect(subscribeFn).toHaveBeenCalledTimes(3);
451
+ });
452
+
453
+ it("does not reconnect after a terminal snapshot", async () => {
454
+ const s1 = createControllableStream<AgentExecution>();
455
+ const subscribeFn = vi.fn(() => s1.generator);
456
+ const stigmer = createMockStigmer(
457
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
458
+ );
459
+
460
+ const { result } = renderHook(
461
+ () => useExecutionStream("exec-1", { reconnectOptions: fastReconnect }),
462
+ { wrapper: createWrapper(stigmer) },
463
+ );
464
+
465
+ act(() => s1.push(makeSnapshot(ExecutionPhase.EXECUTION_COMPLETED)));
466
+ await waitFor(() => expect(result.current.isStreaming).toBe(false));
467
+
468
+ await new Promise((r) => setTimeout(r, 30));
469
+ expect(subscribeFn).toHaveBeenCalledTimes(1);
470
+ expect(result.current.isReconnecting).toBe(false);
471
+ });
472
+
473
+ it("does not re-subscribe when unmounted during backoff", async () => {
474
+ const randomSpy = vi.spyOn(Math, "random").mockReturnValue(1); // full delay
475
+ const subscribeFn = vi.fn(
476
+ () =>
477
+ (async function* (): AsyncGenerator<AgentExecution> {
478
+ throw new TypeError("Load failed");
479
+ })(),
480
+ );
481
+ const stigmer = createMockStigmer(
482
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
483
+ );
484
+
485
+ const { result, unmount } = renderHook(
486
+ () =>
487
+ useExecutionStream("exec-1", {
488
+ reconnectOptions: { baseDelayMs: 1_000, maxDelayMs: 1_000 },
489
+ }),
490
+ { wrapper: createWrapper(stigmer) },
491
+ );
492
+
493
+ await waitFor(() => expect(result.current.isReconnecting).toBe(true));
494
+ expect(subscribeFn).toHaveBeenCalledTimes(1);
495
+
496
+ unmount();
497
+ await new Promise((r) => setTimeout(r, 50));
498
+ expect(subscribeFn).toHaveBeenCalledTimes(1);
499
+
500
+ randomSpy.mockRestore();
501
+ });
502
+
503
+ it("respects autoReconnect: false (immediate error, no retry)", async () => {
504
+ const s1 = createControllableStream<AgentExecution>();
505
+ const subscribeFn = vi.fn(() => s1.generator);
506
+ const stigmer = createMockStigmer(
507
+ subscribeFn as unknown as Stigmer["agentExecution"]["subscribe"],
508
+ );
509
+
510
+ const { result } = renderHook(
511
+ () => useExecutionStream("exec-1", { autoReconnect: false }),
512
+ { wrapper: createWrapper(stigmer) },
513
+ );
514
+
515
+ act(() => s1.push(makeSnapshot(IN_PROGRESS)));
516
+ await waitFor(() => expect(result.current.isStreaming).toBe(true));
517
+
518
+ act(() => s1.fail(new TypeError("Load failed")));
519
+ await waitFor(() => expect(result.current.error).not.toBeNull());
520
+ expect(result.current.isReconnecting).toBe(false);
521
+ expect(subscribeFn).toHaveBeenCalledTimes(1);
522
+ });
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
+ });