@stigmer/react 3.0.8-dev.20260612122433 → 3.0.8-dev.20260613051837
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/execution/ExecutionProgress.d.ts.map +1 -1
- package/execution/ExecutionProgress.js +5 -1
- package/execution/ExecutionProgress.js.map +1 -1
- package/execution/MessageThread.d.ts +32 -3
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +59 -10
- package/execution/MessageThread.js.map +1 -1
- package/execution/useExecutionStream.d.ts +76 -5
- package/execution/useExecutionStream.d.ts.map +1 -1
- package/execution/useExecutionStream.js +166 -23
- package/execution/useExecutionStream.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +4 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/backoff.d.ts +61 -0
- package/internal/backoff.d.ts.map +1 -0
- package/internal/backoff.js +79 -0
- package/internal/backoff.js.map +1 -0
- package/internal/store/conversation-store.d.ts +34 -0
- package/internal/store/conversation-store.d.ts.map +1 -1
- package/internal/store/conversation-store.js +50 -2
- package/internal/store/conversation-store.js.map +1 -1
- package/internal/store/workflow-execution-event-store.d.ts +12 -0
- package/internal/store/workflow-execution-event-store.d.ts.map +1 -1
- package/internal/store/workflow-execution-event-store.js +7 -0
- package/internal/store/workflow-execution-event-store.js.map +1 -1
- package/internal/stream-controller.d.ts +57 -21
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +117 -3
- package/internal/stream-controller.js.map +1 -1
- package/internal/useFetch.d.ts +7 -0
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +21 -0
- package/internal/useFetch.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +26 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +41 -4
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +74 -10
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionExecutions.d.ts +17 -1
- package/session/useSessionExecutions.d.ts.map +1 -1
- package/session/useSessionExecutions.js +6 -2
- package/session/useSessionExecutions.js.map +1 -1
- package/src/execution/ExecutionProgress.tsx +12 -0
- package/src/execution/MessageThread.tsx +174 -5
- package/src/execution/__tests__/MessageThread.test.tsx +64 -0
- package/src/execution/__tests__/useExecutionStream.test.tsx +279 -0
- package/src/execution/useExecutionStream.ts +254 -34
- package/src/internal/VirtualizedThread.tsx +7 -1
- package/src/internal/__tests__/backoff.test.ts +99 -0
- package/src/internal/__tests__/stream-controller.test.ts +165 -10
- package/src/internal/__tests__/useFetch.test.tsx +59 -0
- package/src/internal/backoff.ts +100 -0
- package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
- package/src/internal/store/conversation-store.ts +68 -3
- package/src/internal/store/workflow-execution-event-store.ts +22 -0
- package/src/internal/stream-controller.ts +151 -26
- package/src/internal/useFetch.ts +26 -0
- package/src/session/SessionViewer.tsx +89 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
- package/src/session/useSessionConversation.ts +121 -15
- package/src/session/useSessionExecutions.ts +23 -1
- package/src/workflow/WorkflowExecutionHeader.tsx +4 -1
- package/src/workflow/WorkflowExecutionTimeline.tsx +2 -1
- package/src/workflow/__tests__/useWorkflowExecutionEventStream.test.tsx +117 -1
- package/src/workflow/execution/useWaterfallEntries.ts +2 -1
- package/src/workflow/useWorkflowExecutionEventStream.ts +122 -41
- package/src/workflow/waterfall/WaterfallTimeline.tsx +2 -1
- package/styles.css +1 -1
- package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
- package/workflow/WorkflowExecutionHeader.js +3 -1
- package/workflow/WorkflowExecutionHeader.js.map +1 -1
- package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
- package/workflow/WorkflowExecutionTimeline.js +1 -1
- package/workflow/WorkflowExecutionTimeline.js.map +1 -1
- package/workflow/execution/useWaterfallEntries.d.ts.map +1 -1
- package/workflow/execution/useWaterfallEntries.js +1 -1
- package/workflow/execution/useWaterfallEntries.js.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.d.ts +32 -4
- package/workflow/useWorkflowExecutionEventStream.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.js +75 -32
- package/workflow/useWorkflowExecutionEventStream.js.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.d.ts.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.js +1 -1
- package/workflow/waterfall/WaterfallTimeline.js.map +1 -1
|
@@ -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
|
-
|
|
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’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
|
+
});
|